feat: publish standalone PIGWay Agent Console plugin
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user