365 lines
15 KiB
JavaScript
365 lines
15 KiB
JavaScript
import { state as appState } from "/assets/js/state.js";
|
|
import { renderDevice } from "./device.js";
|
|
import { renderHosts } from "/assets/js/devices.js";
|
|
import {
|
|
bulkSections,
|
|
field,
|
|
labels,
|
|
options,
|
|
sectionMeta,
|
|
sectionOrder,
|
|
} from "./schema.js";
|
|
import { tr } from "/assets/js/i18n.js";
|
|
import { loadPlugins, pluginText, renderPlugins } from "./hardware.js";
|
|
import { $, configurableHosts, esc, hostLabel } from "/assets/js/shared.js";
|
|
import { get, rows } from "/assets/js/navigation.js";
|
|
import {
|
|
loadServices,
|
|
renderServiceLists,
|
|
resetMonitorForm,
|
|
saveMonitor,
|
|
tokenHeaders,
|
|
} from "./services.js";
|
|
import {
|
|
applyBulkConfig,
|
|
deleteHost,
|
|
saveHost,
|
|
} from "/assets/js/device-actions.js";
|
|
|
|
export function renderGeneral(s, v) {
|
|
if (s === "device") return renderDevice(v);
|
|
const [t, d] = sectionMeta[s] || [s, ""];
|
|
return `<div class="group"><div class="group-head"><div><h3>${tr(t)}</h3><div class="hint">${tr(d)}</div></div></div><div class="fields">${
|
|
Object.entries(v)
|
|
.map(([k, x]) => field(s, k, x))
|
|
.join("") || '<div class="muted">暂无配置项</div>'
|
|
}</div></div>`;
|
|
}
|
|
export function renderServices() {
|
|
return `<div class="group"><div class="group-head"><div><h3>当前系统服务</h3><div class="hint">点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。</div></div><button id="refreshServices">刷新列表</button></div><input class="service-search" id="serviceSearch" placeholder="搜索服务名称或说明"><div class="service-list rows" id="systemServices"><div class="muted">正在读取 systemd 服务…</div></div></div><div class="group"><h3>已监控服务</h3><div class="hint">“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。</div><div class="rows" id="monitoredServices"></div></div><div class="group"><h3 id="serviceFormTitle">新建服务监控</h3><div class="service-form"><label>OLED 显示名称<input id="monitorName" maxlength="64" placeholder="例如:DOCKER"></label><label>systemd 服务名称<input id="monitorTarget" maxlength="128" placeholder="例如:docker.service"></label><label class="full">备注说明<input id="monitorNote" maxlength="120" placeholder="例如:家庭相册容器服务"></label><div class="full action-row"><button id="saveMonitor">保存监控</button> <button id="resetMonitor">清空</button> <span id="serviceMessage" class="muted"></span></div></div></div>`;
|
|
}
|
|
export function renderProcesses() {
|
|
return `<div class="group"><h3>${pluginText("已监控进程", "Monitored processes")}</h3><div class="hint">${pluginText("用于没有 systemd 服务的程序,通过 pgrep -f 匹配完整命令行。", "For programs without a systemd service. Matching uses pgrep -f against the full command line.")}</div><div class="rows" id="processList"><div class="muted">${pluginText("正在读取进程监控…", "Loading process monitors…")}</div></div></div><div class="group"><h3 id="processFormTitle">${pluginText("新建进程监控", "Add process monitor")}</h3><div class="service-form"><label>${pluginText("显示名称", "Display name")}<input id="processName" maxlength="64" placeholder="例如:MYAPP"></label><label>${pluginText("进程匹配关键字", "Process match expression")}<input id="processTarget" maxlength="256" placeholder="例如:/opt/myapp/server"></label><div class="full action-row"><button id="saveProcess">${pluginText("保存监控", "Save monitor")}</button><button id="resetProcess">${pluginText("清空", "Clear")}</button><span id="processMessage" class="muted"></span></div></div></div>`;
|
|
}
|
|
|
|
export async function loadProcesses() {
|
|
if (!$("processList")) return;
|
|
try {
|
|
const d = await get(
|
|
`/api/v1/processes?host=${encodeURIComponent(appState.currentConfigHost)}`,
|
|
);
|
|
appState.processCache = d.processes;
|
|
renderProcessList();
|
|
} catch (e) {
|
|
$("processList").innerHTML = `<div class="error">${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
export function renderProcessList() {
|
|
rows(
|
|
"processList",
|
|
appState.processCache.map(
|
|
(p) =>
|
|
`<div class="row"><span><b>${esc(p.name)}</b> <span class="badge ${p.up ? "good" : "bad"}">${tr(p.up ? "正常" : "停止")}</span><br><span class="muted">${esc(p.target)}</span></span><span class="row-actions"><button data-edit-process="${esc(p.name)}">${tr("编辑")}</button><button class="danger" data-delete-process="${esc(p.name)}">${tr("删除")}</button></span></div>`,
|
|
),
|
|
pluginText("尚未监控任何普通进程", "No process monitors configured"),
|
|
);
|
|
document
|
|
.querySelectorAll("[data-edit-process]")
|
|
.forEach((b) => (b.onclick = () => selectProcess(b.dataset.editProcess)));
|
|
document
|
|
.querySelectorAll("[data-delete-process]")
|
|
.forEach((b) => (b.onclick = () => deleteProcess(b.dataset.deleteProcess)));
|
|
}
|
|
export function selectProcess(name) {
|
|
const p = appState.processCache.find((x) => x.name === name);
|
|
if (!p) return;
|
|
appState.editingProcessName = p.name;
|
|
$("processName").value = p.name;
|
|
$("processTarget").value = p.target;
|
|
$("processFormTitle").textContent = pluginText(
|
|
"编辑进程监控",
|
|
"Edit process monitor",
|
|
);
|
|
}
|
|
export function resetProcessForm() {
|
|
appState.editingProcessName = "";
|
|
$("processName").value = "";
|
|
$("processTarget").value = "";
|
|
$("processFormTitle").textContent = pluginText(
|
|
"新建进程监控",
|
|
"Add process monitor",
|
|
);
|
|
$("processMessage").textContent = "";
|
|
}
|
|
export async function saveProcess() {
|
|
const state = $("processMessage");
|
|
try {
|
|
state.textContent = pluginText("正在保存…", "Saving…");
|
|
await get("/api/v1/processes", {
|
|
method: "PUT",
|
|
headers: tokenHeaders(),
|
|
body: JSON.stringify({
|
|
host: appState.currentConfigHost,
|
|
operation: "save",
|
|
name: $("processName").value.trim(),
|
|
target: $("processTarget").value.trim(),
|
|
previous_name: appState.editingProcessName,
|
|
}),
|
|
});
|
|
resetProcessForm();
|
|
await loadProcesses();
|
|
state.textContent = pluginText("保存成功", "Saved");
|
|
state.className = "success";
|
|
} catch (e) {
|
|
state.textContent = e.message;
|
|
state.className = "error";
|
|
}
|
|
}
|
|
export async function deleteProcess(name) {
|
|
if (
|
|
!confirm(
|
|
pluginText(
|
|
`确定删除 ${name} 的进程监控吗?`,
|
|
`Remove process monitor ${name}?`,
|
|
),
|
|
)
|
|
)
|
|
return;
|
|
try {
|
|
await get("/api/v1/processes", {
|
|
method: "PUT",
|
|
headers: tokenHeaders(),
|
|
body: JSON.stringify({
|
|
host: appState.currentConfigHost,
|
|
operation: "delete",
|
|
name,
|
|
}),
|
|
});
|
|
await loadProcesses();
|
|
} catch (e) {
|
|
alert(e.message);
|
|
}
|
|
}
|
|
export function renderBulkTargets() {
|
|
return `<div class="group"><h3>${tr("目标机器")}</h3><div class="hint">${tr("机器标识和机器列表不会参与同步。")}</div>${
|
|
configurableHosts()
|
|
.filter(
|
|
(h) =>
|
|
h.id !== appState.bulkSource &&
|
|
h.config_contract ===
|
|
appState.fleetHosts.find(
|
|
(source) => source.id === appState.bulkSource,
|
|
)?.config_contract,
|
|
)
|
|
.map(
|
|
(h) =>
|
|
`<label class="action-row"><input type="checkbox" data-bulk-target="${esc(h.id)}">${esc(hostLabel(h))}</label>`,
|
|
)
|
|
.join("") || pluginText("没有其他设备", "No other devices")
|
|
}</div>`;
|
|
}
|
|
export function bulkValue(section, key, value) {
|
|
const item = options[`${section}.${key}`]?.find(([v]) => v === String(value));
|
|
return item ? tr(item[1]) : value;
|
|
}
|
|
export function renderBulkSync() {
|
|
const groups = bulkSections
|
|
.filter(
|
|
(s) =>
|
|
appState.bulkOriginal[s] &&
|
|
Object.keys(appState.bulkOriginal[s]).length,
|
|
)
|
|
.map(
|
|
(s) =>
|
|
`<div class="group bulk-section"><label class="bulk-section-title"><input type="checkbox" data-bulk-section="${esc(s)}">${tr(sectionMeta[s]?.[0] || s)}</label><div class="bulk-items">${Object.entries(
|
|
appState.bulkOriginal[s],
|
|
)
|
|
.map(
|
|
([k, v]) =>
|
|
`<label class="bulk-item"><input type="checkbox" data-bulk-item="${esc(s)}.${esc(k)}"><span>${esc(tr(labels[`${s}.${k}`] || k))}<small class="bulk-value">${esc(bulkValue(s, k, v))}</small></span></label>`,
|
|
)
|
|
.join("")}</div></div>`,
|
|
)
|
|
.join("");
|
|
return `<div class="group"><h3>${tr("来源机器")}</h3><div>${esc(hostLabel(appState.fleetHosts.find((h) => h.id === appState.bulkSource) || { id: appState.bulkSource }))}</div></div>${renderBulkTargets()}<div class="bulk-tools"><button id="bulkAll">${tr("全部选择")}</button><button id="bulkNone">${tr("全部取消")}</button><button id="bulkInvert">${tr("反选")}</button><span class="muted" id="bulkCount"></span></div><div><h3>${tr("配置项")}</h3>${groups}</div><div class="savebar"><button id="bulkApply">${tr("开始批量同步")}</button> <span id="bulkState" class="muted"></span></div>`;
|
|
}
|
|
export function updateBulkSelection() {
|
|
document.querySelectorAll("[data-bulk-section]").forEach((parent) => {
|
|
const items = [
|
|
...document.querySelectorAll(
|
|
`[data-bulk-item^="${parent.dataset.bulkSection}."]`,
|
|
),
|
|
];
|
|
parent.checked = items.length > 0 && items.every((x) => x.checked);
|
|
parent.indeterminate = items.some((x) => x.checked) && !parent.checked;
|
|
});
|
|
const n = document.querySelectorAll("[data-bulk-item]:checked").length;
|
|
if ($("bulkCount"))
|
|
$("bulkCount").textContent =
|
|
appState.LANG === "zh" ? `已选择 ${n} 项` : `${n} settings selected`;
|
|
}
|
|
export function bindBulkSync() {
|
|
if (!$("bulkApply")) return;
|
|
document.querySelectorAll("[data-bulk-section]").forEach(
|
|
(x) =>
|
|
(x.onchange = () => {
|
|
document
|
|
.querySelectorAll(`[data-bulk-item^="${x.dataset.bulkSection}."]`)
|
|
.forEach((i) => (i.checked = x.checked));
|
|
updateBulkSelection();
|
|
}),
|
|
);
|
|
document
|
|
.querySelectorAll("[data-bulk-item]")
|
|
.forEach((x) => (x.onchange = updateBulkSelection));
|
|
$("bulkAll").onclick = () => {
|
|
document
|
|
.querySelectorAll("[data-bulk-item]")
|
|
.forEach((x) => (x.checked = true));
|
|
updateBulkSelection();
|
|
};
|
|
$("bulkNone").onclick = () => {
|
|
document
|
|
.querySelectorAll("[data-bulk-item]")
|
|
.forEach((x) => (x.checked = false));
|
|
updateBulkSelection();
|
|
};
|
|
$("bulkInvert").onclick = () => {
|
|
document
|
|
.querySelectorAll("[data-bulk-item]")
|
|
.forEach((x) => (x.checked = !x.checked));
|
|
updateBulkSelection();
|
|
};
|
|
$("bulkApply").onclick = applyBulkConfig;
|
|
updateBulkSelection();
|
|
}
|
|
export function updateConditional() {
|
|
const alertMode =
|
|
document.querySelector('[data-key="alert_mode"]')?.value || "breathe";
|
|
document
|
|
.querySelectorAll(".alert-flash")
|
|
.forEach((e) => e.classList.toggle("hidden", alertMode !== "flash"));
|
|
document
|
|
.querySelectorAll(".alert-breathe")
|
|
.forEach((e) => e.classList.toggle("hidden", alertMode !== "breathe"));
|
|
const mode =
|
|
document.querySelector('[data-section="led"][data-key="normal_mode"]')
|
|
?.value || "off";
|
|
document
|
|
.querySelectorAll(".normal-field")
|
|
.forEach((x) =>
|
|
x.classList.toggle("hidden", !x.dataset.modes.split(" ").includes(mode)),
|
|
);
|
|
updateSwatches();
|
|
}
|
|
export function updateSwatches() {
|
|
document.querySelectorAll("[data-swatch]").forEach((s) => {
|
|
const p = s.dataset.swatch,
|
|
read = (c) =>
|
|
Math.max(
|
|
0,
|
|
Math.min(
|
|
255,
|
|
Number(
|
|
document.querySelector(
|
|
`[data-section="led"][data-key="${p}_${c}"]`,
|
|
)?.value || 0,
|
|
),
|
|
),
|
|
),
|
|
hex =
|
|
"#" +
|
|
["r", "g", "b"]
|
|
.map((c) => Math.round(read(c)).toString(16).padStart(2, "0"))
|
|
.join("");
|
|
s.style.background = hex;
|
|
const picker = document.querySelector(`[data-color-prefix="${p}"]`);
|
|
if (picker) picker.value = hex;
|
|
});
|
|
}
|
|
export function renderConfig() {
|
|
const ss = sectionOrder.filter(
|
|
(s) => appState.original[s] || s === "plugins",
|
|
);
|
|
$("configMenu").innerHTML = ss
|
|
.map(
|
|
(s, i) =>
|
|
`<button class="${i === 0 ? "active" : ""}" data-config-section="${s}">${tr(sectionMeta[s]?.[0] || s)}</button>`,
|
|
)
|
|
.join("");
|
|
$("configRows").innerHTML = ss
|
|
.map(
|
|
(s, i) =>
|
|
`<section class="config-page ${i === 0 ? "active" : ""}" data-config-page="${s}"><h2>${tr(sectionMeta[s]?.[0] || s)}</h2><p class="muted">${tr(sectionMeta[s]?.[1] || "")}</p>${s === "bulk_sync" ? renderBulkSync() : s === "plugins" ? renderPlugins() : s === "services" ? renderServices() : s === "processes" ? renderProcesses() : s === "hosts" ? renderHosts(appState.original[s]) : renderGeneral(s, appState.original[s])}</section>`,
|
|
)
|
|
.join("");
|
|
$("configMenu").onclick = (e) => {
|
|
const b = e.target.closest("button");
|
|
if (!b) return;
|
|
document
|
|
.querySelectorAll("[data-config-section]")
|
|
.forEach((x) => x.classList.toggle("active", x === b));
|
|
document
|
|
.querySelectorAll("[data-config-page]")
|
|
.forEach((x) =>
|
|
x.classList.toggle(
|
|
"active",
|
|
x.dataset.configPage === b.dataset.configSection,
|
|
),
|
|
);
|
|
$("mainSavebar").classList.toggle(
|
|
"hidden",
|
|
["bulk_sync", "plugins"].includes(b.dataset.configSection),
|
|
);
|
|
if (b.dataset.configSection === "services") loadServices();
|
|
if (b.dataset.configSection === "processes") loadProcesses();
|
|
if (b.dataset.configSection === "plugins") loadPlugins();
|
|
};
|
|
$("configRows").oninput = (e) => {
|
|
if (e.target.matches("[data-color-prefix]")) {
|
|
const p = e.target.dataset.colorPrefix,
|
|
v = e.target.value;
|
|
["r", "g", "b"].forEach((c, i) => {
|
|
document.querySelector(
|
|
`[data-section="led"][data-key="${p}_${c}"]`,
|
|
).value = parseInt(v.slice(1 + i * 2, 3 + i * 2), 16);
|
|
});
|
|
}
|
|
if (e.target.matches('[data-section="led"],[data-color-prefix]'))
|
|
updateConditional();
|
|
if (e.target.matches("[data-section]")) {
|
|
if (e.target.dataset.section === "device")
|
|
e.target.dataset.deviceAuto = "false";
|
|
markChanged();
|
|
}
|
|
};
|
|
$("refreshServices")?.addEventListener("click", loadServices);
|
|
$("serviceSearch")?.addEventListener("input", renderServiceLists);
|
|
$("saveMonitor")?.addEventListener("click", saveMonitor);
|
|
$("resetMonitor")?.addEventListener("click", resetMonitorForm);
|
|
$("saveProcess")?.addEventListener("click", saveProcess);
|
|
$("resetProcess")?.addEventListener("click", resetProcessForm);
|
|
$("saveHost")?.addEventListener("click", saveHost);
|
|
document
|
|
.querySelectorAll("[data-delete-host]")
|
|
.forEach((b) => (b.onclick = () => deleteHost(b.dataset.deleteHost)));
|
|
bindBulkSync();
|
|
updateConditional();
|
|
}
|
|
export function markChanged() {
|
|
let n = 0;
|
|
document.querySelectorAll("#configRows [data-section]").forEach((i) => {
|
|
if (
|
|
(i.dataset.deviceAuto === "true" ? "auto" : i.value) !==
|
|
appState.original[i.dataset.section][i.dataset.key]
|
|
)
|
|
n++;
|
|
});
|
|
$("saveState").textContent = n
|
|
? appState.LANG === "zh"
|
|
? `有 ${n} 项尚未保存`
|
|
: `${n} unsaved changes`
|
|
: tr("尚未修改");
|
|
$("saveState").className = n ? "" : "muted";
|
|
}
|