Files

367 lines
15 KiB
JavaScript

import { state as appState } from "/assets/js/state.js";
import { addStorageSettings } from "./storage.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();
addStorageSettings();
}
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";
}