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";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { state as appState } from "/assets/js/state.js";
|
||||
import { tr } from "/assets/js/i18n.js";
|
||||
import { pluginText } from "/assets/js/shared.js";
|
||||
import { field } from "./schema.js";
|
||||
import { markChanged } from "./config.js";
|
||||
export function renderDevice(values) {
|
||||
return `<div class="group"><div class="fields">${Object.entries(values)
|
||||
.map(([key, raw]) => {
|
||||
const automatic = !raw || raw.toLowerCase() === "auto",
|
||||
value = automatic
|
||||
? appState.deviceIdentity[key === "identifier" ? "id" : "name"] ||
|
||||
appState.deviceIdentity.hostname ||
|
||||
""
|
||||
: raw;
|
||||
return field("device", key, value).replace(
|
||||
'data-section="device"',
|
||||
`data-device-auto="${automatic}" data-section="device"`,
|
||||
);
|
||||
})
|
||||
.join(
|
||||
"",
|
||||
)}</div><div class="action-row device-default-actions"><button type="button" onclick="resetDeviceDefaults()">${tr("恢复设备默认值")}</button></div><p class="hint">${pluginText("默认值跟随本机主机名;修改后点击保存到当前机器。", "Defaults follow the device hostname. Save to the current host to apply changes.")}</p></div>`;
|
||||
}
|
||||
export function resetDeviceDefaults() {
|
||||
document.querySelectorAll('[data-section="device"]').forEach((el) => {
|
||||
el.value = appState.deviceIdentity.hostname || "";
|
||||
el.dataset.deviceAuto = "true";
|
||||
});
|
||||
markChanged();
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { state as appState } from "/assets/js/state.js";
|
||||
import { tr } from "/assets/js/i18n.js";
|
||||
import { $, esc } from "/assets/js/shared.js";
|
||||
import { ownerName, rgbName } from "./schema.js";
|
||||
import { get } from "/assets/js/navigation.js";
|
||||
import { tokenHeaders } from "./services.js";
|
||||
import { ensureHosts, loadConfig } from "/assets/js/settings.js";
|
||||
|
||||
export function pluginText(zh, en) {
|
||||
return appState.LANG === "zh" ? zh : en;
|
||||
}
|
||||
export function renderPlugins() {
|
||||
return `<div class="group"><p>${pluginText("仅发现插件不会接管插件的设备能力。接入后发送监控状态;暂停或断开联动不会停止插件的本地功能。", "Discovery does not take control of plugin device capabilities. Connecting sends monitoring state; pausing or disconnecting the link does not stop the plugin’s local functions.")}</p><div class="action-row"><button type="button" id="refreshPlugins" onclick="loadPlugins()">${tr("刷新列表")}</button> <span id="pluginRefreshState" class="hint" role="status" aria-live="polite"></span></div><div id="pluginList"></div></div><div id="pluginEditor"></div>`;
|
||||
}
|
||||
export function fanMetric(plugins) {
|
||||
const fans = Object.entries(plugins)
|
||||
.filter(
|
||||
([, p]) =>
|
||||
p.online && p.capabilities?.fan && p.status?.available !== false,
|
||||
)
|
||||
.map(([id, p]) => {
|
||||
const st = p.status;
|
||||
let value = "";
|
||||
if (
|
||||
p.capabilities.fan.rpm &&
|
||||
typeof st.fan_rpm === "number" &&
|
||||
Number.isFinite(st.fan_rpm) &&
|
||||
st.fan_rpm >= 0
|
||||
)
|
||||
value = st.fan_rpm + " RPM";
|
||||
else if (st.fan_name && st.fan_name !== "UNAVAILABLE")
|
||||
value =
|
||||
{
|
||||
OFF: pluginText("关闭", "Off"),
|
||||
L1: pluginText("低速", "Low"),
|
||||
L2: pluginText("中低速", "Medium-low"),
|
||||
L3: pluginText("中速", "Medium"),
|
||||
L4: pluginText("高速", "High"),
|
||||
MAX: pluginText("全速", "Full speed"),
|
||||
}[st.fan_name] || st.fan_name;
|
||||
return value ? { name: p.name || id, value } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
return fans.length
|
||||
? `<span> · ${fans.map((f) => esc(pluginText("风扇", "Fan") + (fans.length > 1 ? " " + f.name : "") + " " + f.value)).join(" · ")}</span>`
|
||||
: "";
|
||||
}
|
||||
export function pluginSummary(plugins, host) {
|
||||
if (!Object.keys(plugins).length)
|
||||
return `<div class="group"><strong>${pluginText("风扇与硬件插件", "Fan and hardware plugins")}</strong><p class="muted">${pluginText("尚未接入硬件插件,风扇状态不可用。独立安装并启动插件后,可在插件管理中接入。", "No hardware plugin connected; fan status is unavailable. Install and start a plugin independently, then connect it in plugin management.")}</p><button type="button" data-manage-plugin-host="${esc(host)}">${pluginText("管理插件", "Manage plugin")}</button></div>`;
|
||||
return Object.entries(plugins)
|
||||
.map(([id, p]) => {
|
||||
const st = p.status || {},
|
||||
cap = p.capabilities || {},
|
||||
details = [];
|
||||
if (p.online) {
|
||||
if (cap.display)
|
||||
details.push("OLED " + esc(ownerName(st.oled_page || "")));
|
||||
if (cap.rgb) details.push("RGB " + esc(rgbName(st.rgb_mode || "")));
|
||||
}
|
||||
return `<div class="group action-row"><span><strong>${esc(p.name || id)}</strong> · ${p.online ? tr("在线") : tr("离线")}${details.length ? " · " + details.join(" · ") : ""}</span><button type="button" data-manage-plugin-host="${esc(host)}">${pluginText("管理插件", "Manage plugin")}</button></div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function loadPlugins() {
|
||||
const list = $("pluginList");
|
||||
if (!list) return;
|
||||
const button = $("refreshPlugins"),
|
||||
feedback = $("pluginRefreshState");
|
||||
if (button?.disabled) return;
|
||||
if (button) button.disabled = true;
|
||||
if (feedback) feedback.textContent = pluginText("正在刷新…", "Refreshing…");
|
||||
list.textContent = pluginText(
|
||||
"正在发现本机插件…",
|
||||
"Discovering local plugins…",
|
||||
);
|
||||
try {
|
||||
const data = await get(
|
||||
"/api/v1/plugins?host=" + encodeURIComponent(appState.currentConfigHost),
|
||||
);
|
||||
appState.pluginRows = data.plugins;
|
||||
if (feedback)
|
||||
feedback.textContent =
|
||||
pluginText(
|
||||
"刷新完成,发现 " + appState.pluginRows.length + " 个插件 · ",
|
||||
"Refreshed, " + appState.pluginRows.length + " plugins found · ",
|
||||
) + new Date().toLocaleTimeString();
|
||||
list.innerHTML =
|
||||
appState.pluginRows
|
||||
.map(
|
||||
(p) =>
|
||||
`<div class="group"><h3>${esc(p.descriptor?.name || p.id)}</h3><div class="hint">${esc(p.socket)} · ${p.online ? tr("在线") : tr("离线")} · ${p.linked ? (p.paused ? pluginText("联动已暂停", "Link paused") : pluginText("已接入", "Connected")) : pluginText("尚未接入", "Not connected")}</div>${p.error ? `<p class="error">${esc(p.error)}</p>` : ""}${p.status?.errors && Object.keys(p.status.errors).length ? `<p class="error">${esc(JSON.stringify(p.status.errors))}</p>` : ""}<p>${Object.keys(
|
||||
p.descriptor?.capabilities || {},
|
||||
)
|
||||
.map((k) =>
|
||||
esc(
|
||||
{
|
||||
fan: pluginText("风扇", "Fan"),
|
||||
display: pluginText("屏幕", "Display"),
|
||||
rgb: pluginText("灯光", "Lighting"),
|
||||
}[k] || k,
|
||||
),
|
||||
)
|
||||
.join(
|
||||
" · ",
|
||||
)}</p><div class="toolbar">${p.linked ? `<button data-plugin-op="${p.paused ? "resume" : "pause"}" data-plugin-id="${esc(p.id)}">${p.paused ? pluginText("恢复告警联动", "Resume alert link") : pluginText("暂停告警联动", "Pause alert link")}</button><button data-plugin-op="unlink" data-plugin-id="${esc(p.id)}">${pluginText("断开接入", "Disconnect")}</button>` : `<button data-plugin-op="link" data-plugin-id="${esc(p.id)}" ${p.online ? "" : "disabled"}>${pluginText("接入插件", "Connect plugin")}</button>`}<button data-plugin-edit="${esc(p.id)}" ${p.online ? "" : "disabled"}>${pluginText("插件配置", "Plugin settings")}</button></div></div>`,
|
||||
)
|
||||
.join("") ||
|
||||
`<p class="muted">${pluginText("未发现正在运行的硬件插件。刷新只查询插件,不会安装或启动服务。若已安装,请先在目标机器启动插件,再刷新并接入;未接入不影响监控。", "No running hardware plugins found. Refresh only queries plugins; it does not install or start services. Start the installed plugin on the target host, then refresh and connect. Monitoring works independently.")}</p>`;
|
||||
list
|
||||
.querySelectorAll("[data-plugin-op]")
|
||||
.forEach(
|
||||
(b) =>
|
||||
(b.onclick = () =>
|
||||
pluginAction(b.dataset.pluginId, b.dataset.pluginOp)),
|
||||
);
|
||||
list
|
||||
.querySelectorAll("[data-plugin-edit]")
|
||||
.forEach((b) => (b.onclick = () => editPlugin(b.dataset.pluginEdit)));
|
||||
} catch (e) {
|
||||
list.textContent = e.message;
|
||||
if (feedback)
|
||||
feedback.textContent = pluginText("刷新失败", "Refresh failed");
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
export async function pluginPut(path, payload) {
|
||||
return get(path, {
|
||||
method: "PUT",
|
||||
headers: tokenHeaders(),
|
||||
body: JSON.stringify({ ...payload, host: appState.currentConfigHost }),
|
||||
});
|
||||
}
|
||||
export async function pluginAction(id, operation) {
|
||||
try {
|
||||
await pluginPut("/api/v1/plugins", { id, operation });
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
export function capabilityField(section, key, value, spec) {
|
||||
const unit =
|
||||
appState.LANG === "zh"
|
||||
? { s: "秒", ms: "毫秒" }[spec.unit] || spec.unit
|
||||
: spec.unit,
|
||||
label = spec.label?.[appState.LANG] || spec.label?.en || key,
|
||||
id = `${section}.${key}`,
|
||||
vis = spec.visible
|
||||
? ` data-cap-visible="${esc(JSON.stringify(spec.visible))}"`
|
||||
: "";
|
||||
return `<label class="field"${vis}>${esc(label)}${spec.type === "enum" ? `<select data-cap-section="${esc(section)}" data-cap-key="${esc(key)}">${spec.choices.map((x) => `<option value="${esc(x)}" ${String(value) === String(x) ? "selected" : ""}>${esc(pluginChoice(x))}</option>`).join("")}</select>` : `<div class="input-unit"><input type="number" step="${section === "rgb" ? 1 : "any"}" min="${esc(spec.min)}" max="${esc(spec.max)}" data-cap-section="${esc(section)}" data-cap-key="${esc(key)}" value="${esc(value)}"><span class="unit">${esc(unit)}</span></div><span class="hint">${pluginText("范围", "Range")} ${spec.min}–${spec.max} ${esc(unit)}</span>`}</label>`;
|
||||
}
|
||||
export function pluginChoice(x) {
|
||||
const zh = {
|
||||
off: "全灭",
|
||||
solid: "自定义常亮",
|
||||
flash: "自定义闪烁(实验性)",
|
||||
breathe: "内置呼吸",
|
||||
flow: "流水灯",
|
||||
marquee: "跑马灯",
|
||||
rainbow: "彩虹灯",
|
||||
colorful: "炫彩灯",
|
||||
red: "红色",
|
||||
green: "绿色",
|
||||
blue: "蓝色",
|
||||
yellow: "黄色",
|
||||
purple: "紫色",
|
||||
cyan: "青色",
|
||||
white: "白色",
|
||||
};
|
||||
return appState.LANG === "zh" ? zh[x] || x : x;
|
||||
}
|
||||
export function capabilityFields(section, fields, values) {
|
||||
const seen = new Set();
|
||||
return Object.entries(fields)
|
||||
.map(([key, spec]) => {
|
||||
if (!spec.color_group)
|
||||
return capabilityField(section, key, values[key], spec);
|
||||
if (seen.has(spec.color_group)) return "";
|
||||
seen.add(spec.color_group);
|
||||
const members = Object.entries(fields).filter(
|
||||
([, v]) => v.color_group === spec.color_group,
|
||||
),
|
||||
hex =
|
||||
"#" +
|
||||
["r", "g", "b"]
|
||||
.map((c) =>
|
||||
Number(values[members.find(([, v]) => v.channel === c)[0]])
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
)
|
||||
.join("");
|
||||
return `<div class="group span2" data-cap-visible="${esc(JSON.stringify(spec.visible || {}))}"><div style="display:grid;grid-template-columns:52px repeat(3,minmax(0,1fr));gap:10px;align-items:start"><label style="text-align:center">${pluginText("颜色", "Color")}<input type="color" data-cap-color="${esc(spec.color_group)}" value="${hex}" aria-label="${pluginText("选择颜色", "Choose color")}" style="width:38px;height:34px;padding:0;margin-top:5px"></label>${members.map(([k, v]) => capabilityField(section, k, values[k], { ...v, visible: null })).join("")}</div></div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
export async function editPlugin(id) {
|
||||
try {
|
||||
appState.pluginSelected = id;
|
||||
appState.pluginDraft = await get(
|
||||
"/api/v1/plugin/config?host=" +
|
||||
encodeURIComponent(appState.currentConfigHost) +
|
||||
"&id=" +
|
||||
encodeURIComponent(id),
|
||||
);
|
||||
const data = appState.pluginDraft,
|
||||
editor = $("pluginEditor");
|
||||
editor.innerHTML = `<div class="group"><h3>${esc(id)}</h3><p class="hint">${pluginText("配置由插件保存并立即应用,不重启监控。", "Settings are saved and applied by the plugin without restarting monitoring.")}</p>${(data.notes || []).map((n) => `<p class="hint">${esc(n[appState.LANG] || n.en || "")}</p>`).join("")}${Object.entries(
|
||||
data.schema,
|
||||
)
|
||||
.map(
|
||||
([section, fields]) =>
|
||||
`<div class="group"><h3>${{ fan: pluginText("风扇", "Fan"), oled: pluginText("屏幕", "Display"), rgb: pluginText("灯光", "Lighting") }[section] || esc(section)}</h3><div class="fields">${capabilityFields(section, fields, data.config[section])}</div></div>`,
|
||||
)
|
||||
.join(
|
||||
"",
|
||||
)}<div class="plugin-actions"><button id="savePlugin">${pluginText("保存插件配置", "Save plugin settings")}</button> <span id="pluginSaveState"></span></div></div>`;
|
||||
editor.oninput = (e) => {
|
||||
if (e.target.dataset.capColor) {
|
||||
const group = e.target.dataset.capColor,
|
||||
hex = e.target.value;
|
||||
["r", "g", "b"].forEach((c, i) => {
|
||||
editor.querySelector(`[data-cap-key="${group}_${c}"]`).value =
|
||||
parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16);
|
||||
});
|
||||
}
|
||||
for (const picker of editor.querySelectorAll("[data-cap-color]")) {
|
||||
picker.value =
|
||||
"#" +
|
||||
["r", "g", "b"]
|
||||
.map((c) =>
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
255,
|
||||
Number(
|
||||
editor.querySelector(
|
||||
`[data-cap-key="${picker.dataset.capColor}_${c}"]`,
|
||||
).value,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
updatePluginVisibility();
|
||||
};
|
||||
updatePluginVisibility();
|
||||
$("savePlugin").onclick = savePluginConfig;
|
||||
} catch (e) {
|
||||
$("pluginEditor").textContent = e.message;
|
||||
}
|
||||
}
|
||||
export function updatePluginVisibility() {
|
||||
document.querySelectorAll("[data-cap-visible]").forEach((el) => {
|
||||
const rules = JSON.parse(el.dataset.capVisible);
|
||||
el.classList.toggle(
|
||||
"hidden",
|
||||
!Object.entries(rules).every(([key, values]) =>
|
||||
values.includes(
|
||||
document.querySelector(`[data-cap-key="${key}"]`)?.value,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
export async function savePluginConfig() {
|
||||
const updates = {};
|
||||
for (const el of document.querySelectorAll("[data-cap-key]")) {
|
||||
if (!el.reportValidity()) return;
|
||||
const s = el.dataset.capSection,
|
||||
k = el.dataset.capKey;
|
||||
(updates[s] ??= {})[k] = el.value;
|
||||
}
|
||||
try {
|
||||
await pluginPut("/api/v1/plugin/config", {
|
||||
id: appState.pluginSelected,
|
||||
updates,
|
||||
});
|
||||
$("pluginSaveState").textContent = pluginText("保存成功", "Saved");
|
||||
} catch (e) {
|
||||
$("pluginSaveState").textContent = e.message;
|
||||
}
|
||||
}
|
||||
document.addEventListener("click", async (e) => {
|
||||
const b = e.target.closest("[data-manage-plugin-host]");
|
||||
if (!b) return;
|
||||
appState.currentConfigHost = b.dataset.managePluginHost;
|
||||
await ensureHosts();
|
||||
$("configHost").value = appState.currentConfigHost;
|
||||
document.querySelector('[data-tab="config"]').click();
|
||||
await loadConfig();
|
||||
document.querySelector('[data-config-section="plugins"]')?.click();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { state } from "/assets/js/state.js";
|
||||
import { registerTranslations } from "/assets/js/i18n.js";
|
||||
import translations from "./translations.js";
|
||||
registerTranslations(translations);
|
||||
for (const [key, value] of Object.entries({
|
||||
pluginRows: [],
|
||||
pluginDraft: null,
|
||||
pluginSelected: "",
|
||||
processCache: [],
|
||||
editingProcessName: "",
|
||||
serviceCache: [],
|
||||
editingServiceName: "",
|
||||
serviceReadOnly: false,
|
||||
}))
|
||||
state[key] ??= value;
|
||||
export { agentHostCard as hostCard } from "./overview.js";
|
||||
export {
|
||||
renderConfig,
|
||||
markChanged,
|
||||
renderBulkSync,
|
||||
bindBulkSync,
|
||||
} from "./config.js";
|
||||
export { loadPlugins } from "./hardware.js";
|
||||
export { resetDeviceDefaults } from "./device.js";
|
||||
export { eventName } from "./schema.js";
|
||||
|
||||
const style = document.createElement("link");
|
||||
style.rel = "stylesheet";
|
||||
style.href = new URL("./styles.css", import.meta.url).href;
|
||||
document.head.append(style);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { state as appState } from "/assets/js/state.js";
|
||||
import { esc } from "/assets/js/shared.js";
|
||||
import { tr } from "/assets/js/i18n.js";
|
||||
import { bytes, formatUptime, meter } from "/assets/js/overview.js";
|
||||
import { fanMetric, pluginSummary } from "./hardware.js";
|
||||
import { ownerName } from "./schema.js";
|
||||
|
||||
export function agentHostCard(entry) {
|
||||
const h = entry.host;
|
||||
if (!entry.online)
|
||||
return `<article class="panel host-panel offline-card"><div class="host-head"><div><h2>${esc(h.name || h.id)}</h2><div class="device-details"><span>ID ${esc(h.id)}</span><span>${esc(h.url || tr("本机"))}</span></div></div><span class="badge bad">${tr("离线")}</span></div><div class="error">${esc(entry.error || tr("连接失败"))}</div></article>`;
|
||||
const s = entry.status,
|
||||
y = s.system || {},
|
||||
n = s.network || {},
|
||||
d = s.display || {},
|
||||
v = s.device || {},
|
||||
m = s.machine || h,
|
||||
signal = n.signal_percent,
|
||||
memUsed = y.memory_used_bytes,
|
||||
memTotal = y.memory_total_bytes,
|
||||
diskUsed = y.disk_used_bytes,
|
||||
diskTotal = y.disk_total_bytes;
|
||||
return `<article class="panel host-panel"><div class="host-head"><div><div class="host-title"><h2>${esc(m.name || h.name || m.id)}</h2><span class="uptime-badge">${esc(formatUptime(s.uptime_seconds))}</span></div><div class="device-details"><span>ID ${esc(m.id || h.id)}</span><span>${appState.LANG === "zh" ? "主机名" : "Hostname"} ${esc(m.hostname || v.hostname || "--")}</span><span>${esc(v.manufacturer || "")} ${esc(v.model || "--")}</span><span>${appState.LANG === "zh" ? "序列号" : "Serial"} ${esc(v.serial || "--")}</span><span>${appState.LANG === "zh" ? "硬件版本" : "Revision"} ${esc(v.revision || "--")}</span><span>${esc(v.os || "--")}</span><span>${appState.LANG === "zh" ? "内核" : "Kernel"} ${esc(v.kernel || "--")}</span><span>${esc(v.architecture || "--")} · CPU ${esc(v.cpu_count || "--")}</span><span>${appState.LANG === "zh" ? "程序版本" : "Agent"} ${esc(s.version || "--")}</span></div></div><span class="badge good">${tr("在线")}</span></div><div class="host-grid"><div class="metric"><div class="muted">${tr("处理器负载")}</div><div class="value">${esc(y.cpu_percent)}%</div>${meter(y.cpu_percent)}<div class="muted">${appState.LANG === "zh" ? "频率" : "Frequency"} ${esc(y.cpu_freq_mhz)} MHz · ${esc(y.temperature_c)}°C${fanMetric(s.plugins || {})}</div></div><div class="metric"><div class="muted">${tr("内存占用")}</div><div class="value">${esc(y.memory_percent)}%</div>${meter(y.memory_percent)}<div class="muted">${bytes(memUsed)} / ${bytes(memTotal)} · ${appState.LANG === "zh" ? "可用" : "Available"} ${bytes(y.memory_available_bytes)}</div></div><div class="metric"><div class="muted">${tr("磁盘占用")}</div><div class="value">${esc(y.disk_percent)}%</div>${meter(y.disk_percent)}<div class="muted">${bytes(diskUsed)} / ${bytes(diskTotal)} · ${appState.LANG === "zh" ? "剩余" : "Free"} ${bytes(y.disk_free_bytes)}</div></div><div class="metric"><div class="muted">${tr("主网络")}</div><div class="value">${n.kind === "WIF" && signal !== null && signal !== undefined ? `${esc(signal)}%` : esc(n.kind || "--")}</div>${n.kind === "WIF" ? meter(signal) : ""}<div class="muted">${esc(n.interface || "--")} · ${n.kind === "WIF" ? `${esc(n.metric)} dBm · ` : n.kind === "ETH" && n.link_speed_mbps ? `${esc(n.link_speed_mbps)} Mbps · ` : ""}${esc(n.ip_label)} ${esc(n.ip)} · ${n.internet ? (appState.LANG === "zh" ? "互联网正常" : "Internet online") : appState.LANG === "zh" ? "互联网断开" : "Internet down"}</div></div><div class="metric"><div class="muted">${tr("显示状态")}</div><div class="value">${esc(ownerName(d.owner))}</div><div class="muted">${appState.LANG === "zh" ? "告警状态独立于硬件插件" : "Alert state is independent of hardware plugins"}</div></div><div class="metric"><div class="muted">${tr("当前告警")}</div><div class="value">${(d.alerts || []).length}</div><div class="muted">${(d.alerts || []).map((a) => esc(ownerName(a.id))).join(" · ") || tr("无告警")}</div></div></div>${pluginSummary(s.plugins || {}, h.id)}<div class="group"><h3>${tr("服务与进程监控")}</h3><div class="rows">${(s.watches || []).map((w) => `<div class="row"><span>${esc(w.name)} · ${esc(w.target)}</span><span class="badge ${w.up ? "good" : "bad"}">${tr(w.up ? "正常" : "停止")}</span></div>`).join("") || `<div class="muted">${tr("暂无数据")}</div>`}</div></div></article>`;
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { state as appState } from "/assets/js/state.js";
|
||||
import { tr } from "/assets/js/i18n.js";
|
||||
import { esc } from "/assets/js/shared.js";
|
||||
|
||||
export const sectionMeta = {
|
||||
plugins: ["硬件插件", "独立安装,按实际能力提供风扇、屏幕或灯光控制"],
|
||||
hardware: ["硬件插件", "可选 Yahboom 散热板;未安装时保持关闭"],
|
||||
device: ["设备标识", "自定义本机显示名称和稳定标识"],
|
||||
hosts: ["机器列表", "登记需要聚合监控的其他节点"],
|
||||
led: ["灯光设置", "正常灯效与告警颜色"],
|
||||
alerts: ["告警阈值", "处理器、内存、温度、磁盘和 Wi-Fi"],
|
||||
fan: ["风扇温控", "温度档位与降档回差"],
|
||||
timing: ["刷新周期", "传感器、屏幕、网络和健康检查"],
|
||||
display: ["屏幕布局", "OLED 四行文字位置"],
|
||||
dark_mode: ["板载指示灯", "树莓派电源灯与活动灯"],
|
||||
services: ["服务监控", "监控 systemd 服务"],
|
||||
processes: ["进程监控", "监控普通进程关键字"],
|
||||
api: ["API 设置", "API 开关、监听地址、端口和日志数量"],
|
||||
bulk_sync: ["批量同步", "从当前配置机器复制所选配置项到目标机器"],
|
||||
};
|
||||
export const labels = {
|
||||
"api.enabled": "启用远程管理 API",
|
||||
"hardware.cooling_hat_enabled": "启用散热板(OLED、风扇、RGB)",
|
||||
"led.alert_mode": "告警灯效模式",
|
||||
"led.custom_min_hold_ms": "自定义闪烁最小保持时间",
|
||||
"led.normal_mode": "正常状态灯效",
|
||||
"led.normal_r": "红色通道",
|
||||
"led.normal_g": "绿色通道",
|
||||
"led.normal_b": "蓝色通道",
|
||||
"led.normal_flash_on_ms": "亮灯时间",
|
||||
"led.normal_flash_off_ms": "灭灯时间",
|
||||
"led.normal_effect_speed": "内置效果速度",
|
||||
"led.normal_effect_color": "内置效果颜色",
|
||||
"led.warning_flash_on_ms": "普通告警亮灯",
|
||||
"led.warning_flash_off_ms": "普通告警灭灯",
|
||||
"led.critical_flash_on_ms": "严重告警亮灯",
|
||||
"led.critical_flash_off_ms": "严重告警灭灯",
|
||||
"led.emergency_flash_on_ms": "紧急告警亮灯",
|
||||
"led.emergency_flash_off_ms": "紧急告警灭灯",
|
||||
"led.write_delay_ms": "RGB 寄存器写入间隔",
|
||||
"alerts.cpu_high": "处理器高负载阈值",
|
||||
"alerts.cpu_recover": "处理器恢复阈值",
|
||||
"alerts.cpu_high_seconds": "处理器持续时间",
|
||||
"alerts.memory_high": "内存高占用阈值",
|
||||
"alerts.memory_recover": "内存恢复阈值",
|
||||
"alerts.memory_high_seconds": "内存持续时间",
|
||||
"alerts.temp_high": "高温告警阈值",
|
||||
"alerts.temp_critical": "严重高温阈值",
|
||||
"alerts.disk_high": "磁盘高占用阈值",
|
||||
"alerts.disk_critical": "磁盘严重阈值",
|
||||
"alerts.wifi_weak": "Wi-Fi 弱信号阈值",
|
||||
"alerts.wifi_recover": "Wi-Fi 恢复阈值",
|
||||
"alerts.wifi_weak_seconds": "弱信号持续时间",
|
||||
"alerts.ethernet_min_speed_mbps": "有线最低协商速率",
|
||||
"alerts.ethernet_speed_seconds": "有线降速持续时间",
|
||||
"fan.start": "风扇启动温度",
|
||||
"fan.level2": "二档起始温度",
|
||||
"fan.level3": "三档起始温度",
|
||||
"fan.level4": "四档起始温度",
|
||||
"fan.max": "全速起始温度",
|
||||
"fan.hysteresis": "降档温度回差",
|
||||
"timing.sensor_interval": "传感器采样周期",
|
||||
"timing.oled_refresh_interval": "OLED 刷新周期",
|
||||
"timing.page_interval": "OLED 页面轮播周期",
|
||||
"timing.network_interval": "网络检查周期",
|
||||
"timing.service_interval": "服务检查周期",
|
||||
"timing.health_interval": "健康检查周期",
|
||||
"display.row1_y": "第一行纵坐标",
|
||||
"display.row2_y": "第二行纵坐标",
|
||||
"display.row3_y": "第三行纵坐标",
|
||||
"display.row4_y": "第四行纵坐标",
|
||||
"dark_mode.pi_pwr_led": "树莓派电源灯",
|
||||
"dark_mode.pi_act_led": "树莓派活动灯",
|
||||
"api.bind": "监听地址",
|
||||
"api.port": "监听端口",
|
||||
"api.log_limit": "默认日志条数",
|
||||
"device.identifier": "机器标识",
|
||||
"device.name": "显示名称",
|
||||
};
|
||||
["cpu", "power", "memory", "storage", "network", "service"].forEach((g) =>
|
||||
["r", "g", "b"].forEach(
|
||||
(c) =>
|
||||
(labels[`led.${g}_${c}`] = `${{ r: "红", g: "绿", b: "蓝" }[c]}色通道`),
|
||||
),
|
||||
);
|
||||
export const hints = {
|
||||
"hardware.cooling_hat_enabled":
|
||||
"默认关闭;启用前运行安装器 --enable-cooling-hat 安装依赖。",
|
||||
"led.alert_mode": "推荐内置呼吸;自定义闪烁仍可能异色或延迟。",
|
||||
"led.custom_min_hold_ms": "范围 1500–60000 毫秒。实际亮灭时间不会低于此值。",
|
||||
"led.normal_mode": "仅在没有当前告警时生效;告警会立即抢占。",
|
||||
"led.normal_r": "范围 0–255。",
|
||||
"led.normal_g": "范围 0–255。",
|
||||
"led.normal_b": "范围 0–255。",
|
||||
"led.normal_flash_on_ms": "范围 50–60000 毫秒。",
|
||||
"led.normal_flash_off_ms": "范围 50–60000 毫秒。",
|
||||
"led.normal_effect_speed": "MCU 内置速度档位。",
|
||||
"led.normal_effect_color": "仅流水灯和呼吸灯使用。",
|
||||
"led.write_delay_ms": "MCU 内置灯效参数写入间隔,通常保持 10 毫秒。",
|
||||
"alerts.wifi_weak": "单位 dBm,数值越接近 0 信号越强。",
|
||||
"alerts.wifi_recover": "单位 dBm,应高于弱信号阈值。",
|
||||
"alerts.ethernet_min_speed_mbps":
|
||||
"低于此协商速率并持续设定时间后告警;设为 0 关闭。",
|
||||
"alerts.ethernet_speed_seconds": "过滤网线插拔或协商过程中的瞬时降速。",
|
||||
"fan.hysteresis": "避免临界温度附近频繁切换档位。",
|
||||
"api.bind": "0.0.0.0 允许局域网访问;127.0.0.1 仅本机。",
|
||||
"api.port": "范围 1–65535;保存后 API 自动重启。",
|
||||
"dark_mode.pi_pwr_led": "选择“关闭”时由程序熄灭板载灯。",
|
||||
"dark_mode.pi_act_led": "选择“关闭”时由程序熄灭板载灯。",
|
||||
};
|
||||
["normal", "cpu", "power", "memory", "storage", "network", "service"].forEach(
|
||||
(g) =>
|
||||
["r", "g", "b"].forEach((c) => (hints[`led.${g}_${c}`] = "范围 0–255。")),
|
||||
);
|
||||
export const options = {
|
||||
"api.enabled": [
|
||||
["false", "关闭"],
|
||||
["true", "开启"],
|
||||
],
|
||||
"hardware.cooling_hat_enabled": [
|
||||
["false", "关闭"],
|
||||
["true", "启用"],
|
||||
],
|
||||
"led.alert_mode": [
|
||||
["breathe", "内置呼吸(推荐)"],
|
||||
["flash", "自定义颜色闪烁(实验性)"],
|
||||
],
|
||||
"led.normal_mode": [
|
||||
["off", "全灭"],
|
||||
["solid", "自定义颜色常亮"],
|
||||
["flash", "自定义颜色闪烁"],
|
||||
["flow", "流水灯"],
|
||||
["breathe", "呼吸灯"],
|
||||
["marquee", "跑马灯"],
|
||||
["rainbow", "彩虹灯"],
|
||||
["colorful", "炫彩灯"],
|
||||
],
|
||||
"led.normal_effect_speed": [
|
||||
["1", "慢速"],
|
||||
["2", "中速"],
|
||||
["3", "快速"],
|
||||
],
|
||||
"led.normal_effect_color": [
|
||||
["red", "红色"],
|
||||
["green", "绿色"],
|
||||
["blue", "蓝色"],
|
||||
["yellow", "黄色"],
|
||||
["purple", "紫色"],
|
||||
["cyan", "青色"],
|
||||
["white", "白色"],
|
||||
],
|
||||
"dark_mode.pi_pwr_led": [
|
||||
["off", "关闭"],
|
||||
["on", "不干预"],
|
||||
],
|
||||
"dark_mode.pi_act_led": [
|
||||
["off", "关闭"],
|
||||
["on", "不干预"],
|
||||
],
|
||||
};
|
||||
export const numberRules = {
|
||||
"led.custom_min_hold_ms": [1500, 60000, 100],
|
||||
"led.normal_r": [0, 255, 1],
|
||||
"led.normal_g": [0, 255, 1],
|
||||
"led.normal_b": [0, 255, 1],
|
||||
"led.normal_flash_on_ms": [50, 60000, 10],
|
||||
"led.normal_flash_off_ms": [50, 60000, 10],
|
||||
"led.warning_flash_on_ms": [50, 60000, 10],
|
||||
"led.warning_flash_off_ms": [50, 60000, 10],
|
||||
"led.critical_flash_on_ms": [50, 60000, 10],
|
||||
"led.critical_flash_off_ms": [50, 60000, 10],
|
||||
"led.emergency_flash_on_ms": [50, 60000, 10],
|
||||
"led.emergency_flash_off_ms": [50, 60000, 10],
|
||||
"led.write_delay_ms": [1, 1000, 1],
|
||||
"alerts.cpu_high": [0, 100, 1],
|
||||
"alerts.cpu_recover": [0, 100, 1],
|
||||
"alerts.cpu_high_seconds": [1, 86400, 1],
|
||||
"alerts.memory_high": [0, 100, 1],
|
||||
"alerts.memory_recover": [0, 100, 1],
|
||||
"alerts.memory_high_seconds": [1, 86400, 1],
|
||||
"alerts.temp_high": [0, 120, 0.1],
|
||||
"alerts.temp_critical": [0, 120, 0.1],
|
||||
"alerts.disk_high": [0, 100, 1],
|
||||
"alerts.disk_critical": [0, 100, 1],
|
||||
"alerts.wifi_weak": [-120, 0, 1],
|
||||
"alerts.wifi_recover": [-120, 0, 1],
|
||||
"alerts.wifi_weak_seconds": [1, 86400, 1],
|
||||
"alerts.ethernet_min_speed_mbps": [0, 100000, 1],
|
||||
"alerts.ethernet_speed_seconds": [1, 86400, 1],
|
||||
"fan.start": [0, 100, 0.1],
|
||||
"fan.level2": [0, 100, 0.1],
|
||||
"fan.level3": [0, 100, 0.1],
|
||||
"fan.level4": [0, 100, 0.1],
|
||||
"fan.max": [0, 100, 0.1],
|
||||
"fan.hysteresis": [0, 20, 0.1],
|
||||
"timing.sensor_interval": [0.2, 3600, 0.1],
|
||||
"timing.oled_refresh_interval": [0.2, 3600, 0.1],
|
||||
"timing.page_interval": [1, 3600, 0.1],
|
||||
"timing.network_interval": [1, 3600, 0.1],
|
||||
"timing.service_interval": [1, 3600, 0.1],
|
||||
"timing.health_interval": [0.2, 3600, 0.1],
|
||||
"display.row1_y": [0, 31, 1],
|
||||
"display.row2_y": [0, 31, 1],
|
||||
"display.row3_y": [0, 31, 1],
|
||||
"display.row4_y": [0, 31, 1],
|
||||
"api.port": [1, 65535, 1],
|
||||
"api.log_limit": [1, 1000, 1],
|
||||
};
|
||||
["cpu", "power", "memory", "storage", "network", "service"].forEach((g) =>
|
||||
["r", "g", "b"].forEach((c) => (numberRules[`led.${g}_${c}`] = [0, 255, 1])),
|
||||
);
|
||||
export const units = {
|
||||
"led.custom_min_hold_ms": "毫秒",
|
||||
"led.normal_flash_on_ms": "毫秒",
|
||||
"led.normal_flash_off_ms": "毫秒",
|
||||
"led.warning_flash_on_ms": "毫秒",
|
||||
"led.warning_flash_off_ms": "毫秒",
|
||||
"led.critical_flash_on_ms": "毫秒",
|
||||
"led.critical_flash_off_ms": "毫秒",
|
||||
"led.emergency_flash_on_ms": "毫秒",
|
||||
"led.emergency_flash_off_ms": "毫秒",
|
||||
"led.write_delay_ms": "毫秒",
|
||||
"alerts.cpu_high": "%",
|
||||
"alerts.cpu_recover": "%",
|
||||
"alerts.cpu_high_seconds": "秒",
|
||||
"alerts.memory_high": "%",
|
||||
"alerts.memory_recover": "%",
|
||||
"alerts.memory_high_seconds": "秒",
|
||||
"alerts.temp_high": "°C",
|
||||
"alerts.temp_critical": "°C",
|
||||
"alerts.disk_high": "%",
|
||||
"alerts.disk_critical": "%",
|
||||
"alerts.wifi_weak": "dBm",
|
||||
"alerts.wifi_recover": "dBm",
|
||||
"alerts.wifi_weak_seconds": "秒",
|
||||
"alerts.ethernet_min_speed_mbps": "Mbps",
|
||||
"alerts.ethernet_speed_seconds": "秒",
|
||||
"fan.start": "°C",
|
||||
"fan.level2": "°C",
|
||||
"fan.level3": "°C",
|
||||
"fan.level4": "°C",
|
||||
"fan.max": "°C",
|
||||
"fan.hysteresis": "°C",
|
||||
"timing.sensor_interval": "秒",
|
||||
"timing.oled_refresh_interval": "秒",
|
||||
"timing.page_interval": "秒",
|
||||
"timing.network_interval": "秒",
|
||||
"timing.service_interval": "秒",
|
||||
"timing.health_interval": "秒",
|
||||
"display.row1_y": "像素",
|
||||
"display.row2_y": "像素",
|
||||
"display.row3_y": "像素",
|
||||
"display.row4_y": "像素",
|
||||
"api.port": "端口",
|
||||
"api.log_limit": "条",
|
||||
};
|
||||
export const sectionOrder = [
|
||||
"device",
|
||||
"plugins",
|
||||
"alerts",
|
||||
"timing",
|
||||
"dark_mode",
|
||||
"services",
|
||||
"processes",
|
||||
"api",
|
||||
],
|
||||
bulkSections = sectionOrder.filter(
|
||||
(s) => !["device", "hosts", "plugins"].includes(s),
|
||||
),
|
||||
alertColorGroups = [
|
||||
["cpu", "处理器、温度与散热"],
|
||||
["power", "供电与欠压"],
|
||||
["memory", "内存"],
|
||||
["storage", "存储"],
|
||||
["network", "网络与 Wi-Fi"],
|
||||
["service", "服务与进程"],
|
||||
];
|
||||
export const ownerNames = {
|
||||
NORMAL_HOME: "正常主页",
|
||||
ROOT_READONLY: "根文件系统只读",
|
||||
TEMP_CRITICAL: "处理器温度严重",
|
||||
POWER_UNDERVOLT: "供电欠压",
|
||||
THROTTLED: "处理器降频",
|
||||
FREQ_CAPPED: "频率受限",
|
||||
NETWORK_DOWN: "网络断开",
|
||||
INTERNET_DOWN: "互联网断开",
|
||||
SERVICE_DOWN: "服务停止",
|
||||
PROCESS_DOWN: "进程停止",
|
||||
DISK_CRITICAL: "磁盘严重不足",
|
||||
TEMP_HIGH: "处理器温度偏高",
|
||||
CPU_HIGH: "处理器负载过高",
|
||||
MEMORY_HIGH: "内存占用过高",
|
||||
DISK_HIGH: "磁盘空间不足",
|
||||
WIFI_WEAK: "Wi-Fi 信号弱",
|
||||
ETHERNET_SPEED_LOW: "有线连接速率降低",
|
||||
},
|
||||
rgbNames = {
|
||||
DISABLED: "未启用",
|
||||
OFF: "全灭",
|
||||
NORMAL_SOLID: "正常 · 自定义常亮",
|
||||
NORMAL_FLASH: "正常 · 自定义闪烁",
|
||||
NORMAL_FLOW: "正常 · 流水灯",
|
||||
NORMAL_BREATHE: "正常 · 呼吸灯",
|
||||
NORMAL_MARQUEE: "正常 · 跑马灯",
|
||||
NORMAL_RAINBOW: "正常 · 彩虹灯",
|
||||
NORMAL_COLORFUL: "正常 · 炫彩灯",
|
||||
},
|
||||
eventNames = {
|
||||
HARDWARE_ENABLED: "硬件插件已启用",
|
||||
HARDWARE_DISABLED: "硬件插件已关闭",
|
||||
HARDWARE_UNAVAILABLE: "硬件插件不可用",
|
||||
FAN_LEVEL: "风扇档位变化",
|
||||
START: "程序启动",
|
||||
STOP: "程序停止",
|
||||
ALERT_ACTIVE: "告警出现",
|
||||
ALERT_RECOVERED: "告警恢复",
|
||||
DISPLAY_OWNER: "显示对象切换",
|
||||
INTERNET_DOWN: "互联网断开",
|
||||
INTERNET_RECOVERED: "互联网恢复",
|
||||
NET_SWITCH: "网络接口切换",
|
||||
ETHERNET_SPEED_LOW: "有线连接速率降低",
|
||||
ETHERNET_SPEED_RECOVERED: "有线连接速率恢复",
|
||||
NETWORK_DOWN: "网络断开",
|
||||
NETWORK_RECOVERED: "网络恢复",
|
||||
POWER_LOW: "供电欠压",
|
||||
POWER_RECOVERED: "供电恢复",
|
||||
POWER_HISTORY: "历史电源状态",
|
||||
HISTORY_FLAG: "历史电源状态",
|
||||
ROOT_READONLY: "根文件系统只读",
|
||||
RGB_ERROR: "RGB 控制错误",
|
||||
RGB_MODE: "RGB 模式切换",
|
||||
RGB_RECOVERED: "RGB 控制恢复",
|
||||
SYSTEM: "系统事件",
|
||||
WATCH_DOWN: "监控对象停止",
|
||||
WATCH_RECOVERED: "监控对象恢复",
|
||||
};
|
||||
export function ownerName(id) {
|
||||
const [type, suffix] = String(id || "").split("|", 2),
|
||||
name = ownerNames[type] || type;
|
||||
return tr(name) + (suffix ? ` · ${suffix}` : "");
|
||||
}
|
||||
export function rgbName(mode) {
|
||||
if (rgbNames[mode]) return tr(rgbNames[mode]);
|
||||
const p = String(mode || "").split("_"),
|
||||
groups = {
|
||||
CPU: "处理器",
|
||||
POWER: "供电",
|
||||
MEMORY: "内存",
|
||||
STORAGE: "存储",
|
||||
NETWORK: "网络",
|
||||
SERVICE: "服务",
|
||||
},
|
||||
levels = {
|
||||
WARNING: "普通告警",
|
||||
CRITICAL: "严重告警",
|
||||
EMERGENCY: "紧急告警",
|
||||
};
|
||||
return `${tr(groups[p[0]] || p[0])} · ${tr(levels[p[1]] || p[1])}${p[2] === "BREATHE" ? (appState.LANG === "zh" ? "呼吸" : " breathing") : appState.LANG === "zh" ? "闪烁" : " flash"}`;
|
||||
}
|
||||
export function eventName(id) {
|
||||
return tr(
|
||||
eventNames[id] ||
|
||||
(appState.LANG === "zh"
|
||||
? `未知事件(${id})`
|
||||
: String(id).toLowerCase().replaceAll("_", " ")),
|
||||
);
|
||||
}
|
||||
export function field(section, key, value, extra = "") {
|
||||
const id = `${section}.${key}`,
|
||||
opts = options[id],
|
||||
rule = numberRules[id],
|
||||
label = tr(labels[id] || key),
|
||||
help = tr(hints[id] || ""),
|
||||
unit = tr(units[id] || "");
|
||||
let control;
|
||||
if (opts)
|
||||
control = `<select data-section="${esc(section)}" data-key="${esc(key)}">${opts.map(([v, l]) => `<option value="${v}" ${String(value) === v ? "selected" : ""}>${tr(l)}</option>`).join("")}</select>`;
|
||||
else {
|
||||
const input = `<input ${rule ? 'type="number"' : ""} ${rule ? `min="${rule[0]}" max="${rule[1]}" step="${rule[2]}"` : ""} data-section="${esc(section)}" data-key="${esc(key)}" value="${esc(value)}">`;
|
||||
control = unit
|
||||
? `<div class="input-unit">${input}<span class="unit">${unit}</span></div>`
|
||||
: input;
|
||||
}
|
||||
return `<div class="field ${extra}"><label>${esc(label)}${control}</label>${help ? `<div class="hint">${esc(help)}</div>` : ""}</div>`;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { state as appState } from "/assets/js/state.js";
|
||||
import { $, esc } from "/assets/js/shared.js";
|
||||
import { get, rows } from "/assets/js/navigation.js";
|
||||
import { loadConfig } from "/assets/js/settings.js";
|
||||
|
||||
export function tokenHeaders() {
|
||||
return { "Content-Type": "application/json" };
|
||||
}
|
||||
export async function loadServices() {
|
||||
if (!$("systemServices")) return;
|
||||
try {
|
||||
appState.serviceReadOnly = false;
|
||||
const d = await get(
|
||||
`/api/v1/services?host=${encodeURIComponent(appState.currentConfigHost)}`,
|
||||
);
|
||||
appState.serviceCache = d.services;
|
||||
renderServiceLists();
|
||||
} catch (e) {
|
||||
$("systemServices").innerHTML =
|
||||
`<div class="error">${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
export function renderServiceLists() {
|
||||
if (!$("systemServices")) return;
|
||||
const q = $("serviceSearch").value.trim().toLowerCase(),
|
||||
filtered = appState.serviceCache.filter(
|
||||
(s) =>
|
||||
!q || `${s.unit} ${s.description} ${s.note}`.toLowerCase().includes(q),
|
||||
);
|
||||
rows(
|
||||
"systemServices",
|
||||
filtered.map(
|
||||
(s) =>
|
||||
`<div class="row"><span><b>${esc(s.unit)}</b><br><span class="muted">${esc(s.description || "暂无服务说明")} · ${esc(s.active === "active" ? "正在运行" : "未运行")}</span></span><span class="row-actions"><span class="badge ${s.monitored && !s.paused ? "good" : ""}">${s.monitored ? (s.paused ? "已暂停" : "已监控") : "未监控"}</span><button data-select-service="${esc(s.unit)}">${s.monitored ? "编辑监控" : "加入监控"}</button></span></div>`,
|
||||
),
|
||||
"没有匹配的服务",
|
||||
);
|
||||
const monitored = appState.serviceCache.filter((s) => s.monitored);
|
||||
rows(
|
||||
"monitoredServices",
|
||||
monitored.map(
|
||||
(s) =>
|
||||
`<div class="row"><span><b>${esc(s.monitor_name)}</b> <span class="badge ${s.paused ? "" : s.active === "active" ? "good" : "bad"}">${s.paused ? "监控已暂停" : s.active === "active" ? "运行中" : "服务已停止"}</span><br><span class="muted">${esc(s.unit)}</span><br><span class="muted">备注:${esc(s.note || "未填写")}</span></span><span class="row-actions"><button data-monitor-action="${s.paused ? "resume" : "pause"}" data-unit="${esc(s.unit)}">${s.paused ? "恢复监控" : "暂停监控"}</button><button data-service-action="${s.active === "active" ? "stop" : "start"}" data-unit="${esc(s.unit)}" ${s.protected && s.active === "active" ? 'disabled title="API 服务不能停止自身"' : ""}>${s.active === "active" ? "停止服务" : "启动服务"}</button><button data-select-service="${esc(s.unit)}">编辑</button><button class="danger" data-delete-monitor="${esc(s.unit)}">删除监控</button></span></div>`,
|
||||
),
|
||||
"尚未监控任何服务",
|
||||
);
|
||||
if (appState.serviceReadOnly)
|
||||
document
|
||||
.querySelectorAll("#configRows button")
|
||||
.forEach((b) => (b.disabled = true));
|
||||
document
|
||||
.querySelectorAll("[data-select-service]")
|
||||
.forEach((b) => (b.onclick = () => selectService(b.dataset.selectService)));
|
||||
document
|
||||
.querySelectorAll("[data-monitor-action]")
|
||||
.forEach(
|
||||
(b) =>
|
||||
(b.onclick = () =>
|
||||
monitorAction(b.dataset.unit, b.dataset.monitorAction)),
|
||||
);
|
||||
document
|
||||
.querySelectorAll("[data-service-action]")
|
||||
.forEach(
|
||||
(b) =>
|
||||
(b.onclick = () =>
|
||||
serviceAction(b.dataset.unit, b.dataset.serviceAction)),
|
||||
);
|
||||
document
|
||||
.querySelectorAll("[data-delete-monitor]")
|
||||
.forEach((b) => (b.onclick = () => deleteMonitor(b.dataset.deleteMonitor)));
|
||||
}
|
||||
export function selectService(unit) {
|
||||
const s = appState.serviceCache.find((x) => x.unit === unit);
|
||||
if (!s) return;
|
||||
appState.editingServiceName = s.monitor_name || "";
|
||||
$("monitorTarget").value = s.unit;
|
||||
$("monitorName").value =
|
||||
s.monitor_name ||
|
||||
s.unit
|
||||
.replace(/\.service$/, "")
|
||||
.replace(/[^A-Za-z0-9_.-]/g, "_")
|
||||
.slice(0, 16)
|
||||
.toUpperCase();
|
||||
$("monitorNote").value = s.note || s.description || "";
|
||||
$("serviceFormTitle").textContent = s.monitored
|
||||
? "编辑服务监控"
|
||||
: "新建服务监控";
|
||||
$("serviceMessage").textContent = "已自动填写,可继续修改。";
|
||||
$("monitorName").scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
export function resetMonitorForm() {
|
||||
appState.editingServiceName = "";
|
||||
$("monitorName").value = "";
|
||||
$("monitorTarget").value = "";
|
||||
$("monitorNote").value = "";
|
||||
$("serviceFormTitle").textContent = "新建服务监控";
|
||||
$("serviceMessage").textContent = "";
|
||||
$("serviceMessage").className = "muted";
|
||||
}
|
||||
export async function saveMonitor() {
|
||||
const body = {
|
||||
host: appState.currentConfigHost,
|
||||
operation: "save",
|
||||
name: $("monitorName").value.trim(),
|
||||
target: $("monitorTarget").value.trim(),
|
||||
note: $("monitorNote").value.trim(),
|
||||
previous_name: appState.editingServiceName,
|
||||
};
|
||||
try {
|
||||
$("serviceMessage").textContent = "正在保存…";
|
||||
await get("/api/v1/services", {
|
||||
method: "PUT",
|
||||
headers: tokenHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
resetMonitorForm();
|
||||
await loadServices();
|
||||
$("serviceMessage").textContent = "保存成功";
|
||||
$("serviceMessage").className = "success";
|
||||
} catch (e) {
|
||||
$("serviceMessage").textContent = e.message;
|
||||
$("serviceMessage").className = "error";
|
||||
}
|
||||
}
|
||||
export async function deleteMonitor(unit) {
|
||||
const s = appState.serviceCache.find((x) => x.unit === unit);
|
||||
if (
|
||||
!s ||
|
||||
!confirm(
|
||||
appState.LANG === "zh"
|
||||
? `确定删除 ${s.monitor_name} 的监控吗?\n不会删除或停止 ${unit}。`
|
||||
: `Remove monitoring for ${s.monitor_name}?\nThis will not remove or stop ${unit}.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await get("/api/v1/services", {
|
||||
method: "PUT",
|
||||
headers: tokenHeaders(),
|
||||
body: JSON.stringify({
|
||||
host: appState.currentConfigHost,
|
||||
operation: "delete",
|
||||
name: s.monitor_name,
|
||||
target: s.unit,
|
||||
note: "",
|
||||
}),
|
||||
});
|
||||
await loadConfig();
|
||||
document.querySelector('[data-config-section="services"]')?.click();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
export async function monitorAction(unit, action) {
|
||||
const s = appState.serviceCache.find((x) => x.unit === unit);
|
||||
if (!s) return;
|
||||
try {
|
||||
await get("/api/v1/services", {
|
||||
method: "PUT",
|
||||
headers: tokenHeaders(),
|
||||
body: JSON.stringify({
|
||||
host: appState.currentConfigHost,
|
||||
operation: action,
|
||||
name: s.monitor_name,
|
||||
target: s.unit,
|
||||
note: s.note || "",
|
||||
}),
|
||||
});
|
||||
await loadServices();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
export async function serviceAction(unit, action) {
|
||||
if (
|
||||
!confirm(
|
||||
appState.LANG === "zh"
|
||||
? `确定${action === "start" ? "启动服务" : "停止服务"} ${unit} 吗?\n这会直接执行 systemctl ${action}。`
|
||||
: `${action === "start" ? "Start" : "Stop"} ${unit}?\nThis runs systemctl ${action} directly.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await get("/api/v1/services/control", {
|
||||
method: "PUT",
|
||||
headers: tokenHeaders(),
|
||||
body: JSON.stringify({ host: appState.currentConfigHost, unit, action }),
|
||||
});
|
||||
setTimeout(loadServices, 700);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
.rgb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 78px repeat(3, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
align-items: start;
|
||||
}
|
||||
.color-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.color-field label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.swatch-wrap {
|
||||
position: relative;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin: 5px auto 0;
|
||||
}
|
||||
.swatch {
|
||||
display: block;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff5;
|
||||
box-shadow: 0 0 12px #0008;
|
||||
cursor: pointer;
|
||||
}
|
||||
.color-picker {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 42px !important;
|
||||
height: 42px !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.rgb-row {
|
||||
grid-template-columns: 68px 1fr;
|
||||
}
|
||||
.rgb-row .field {
|
||||
grid-column: 2;
|
||||
}
|
||||
.color-field {
|
||||
grid-row: 1 / span 3;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
export default {
|
||||
"独立安装,按实际能力提供风扇、屏幕或灯光控制":
|
||||
"Install independently; fan, display and lighting controls follow advertised capabilities",
|
||||
未启用: "Disabled",
|
||||
硬件插件已启用: "Hardware plugin enabled",
|
||||
硬件插件已关闭: "Hardware plugin disabled",
|
||||
硬件插件不可用: "Hardware plugin unavailable",
|
||||
"可选 Yahboom 散热板;未安装时保持关闭":
|
||||
"Optional Yahboom cooling HAT; leave disabled when absent",
|
||||
"启用散热板(OLED、风扇、RGB)": "Enable cooling HAT (OLED, fan, RGB)",
|
||||
告警灯效模式: "Alert lighting mode",
|
||||
自定义闪烁最小保持时间: "Minimum custom flash hold",
|
||||
"默认关闭;启用前运行安装器 --enable-cooling-hat 安装依赖。":
|
||||
"Disabled by default; install dependencies with --enable-cooling-hat before enabling.",
|
||||
"推荐内置呼吸;自定义闪烁仍可能异色或延迟。":
|
||||
"Built-in breathing recommended; custom flashing may still show wrong colors or delays.",
|
||||
"范围 1500–60000 毫秒。实际亮灭时间不会低于此值。":
|
||||
"Range 1500–60000 ms. Each light/dark state lasts at least this long.",
|
||||
"内置呼吸(推荐)": "Built-in breathing (recommended)",
|
||||
"自定义颜色闪烁(实验性)": "Custom color flashing (experimental)",
|
||||
"散热板插件关闭时,灯光与风扇设置不控制硬件。":
|
||||
"Lighting and fan settings do not control hardware while the plugin is disabled.",
|
||||
"内置呼吸:CPU 红、电源黄、内存紫、存储白、网络蓝、服务青。":
|
||||
"Built-in breathing: CPU red, power yellow, memory purple, storage white, network blue, services cyan.",
|
||||
"普通、严重、紧急对应 MCU 速度档 1、2、3;不支持自定义呼吸颜色或精确周期。":
|
||||
"Warning, critical and emergency use MCU speed levels 1, 2 and 3; custom breathing colors and exact periods are unsupported.",
|
||||
自动: "Auto",
|
||||
"◐ 跟随系统": "◐ Follow system",
|
||||
"☀ 浅色模式": "☀ Light mode",
|
||||
"☾ 深色模式": "☾ Dark mode",
|
||||
处理器负载: "CPU load",
|
||||
处理器温度: "CPU temperature",
|
||||
内存占用: "Memory usage",
|
||||
磁盘占用: "Disk usage",
|
||||
主网络: "Primary network",
|
||||
显示状态: "Display status",
|
||||
服务与进程监控: "Service and process monitors",
|
||||
风扇档位变化: "Fan level changed",
|
||||
"配置写入令牌(Bearer Token)": "Configuration Bearer Token",
|
||||
显示令牌: "Show token",
|
||||
隐藏令牌: "Hide token",
|
||||
灯光设置: "Lighting",
|
||||
正常灯效与告警颜色: "Normal effects and alert colors",
|
||||
告警阈值: "Alert thresholds",
|
||||
"处理器、内存、温度、磁盘和 Wi-Fi":
|
||||
"CPU, memory, temperature, disk and Wi-Fi",
|
||||
风扇温控: "Fan control",
|
||||
温度档位与降档回差: "Temperature levels and hysteresis",
|
||||
刷新周期: "Refresh intervals",
|
||||
"传感器、屏幕、网络和健康检查": "Sensors, display, network and health checks",
|
||||
屏幕布局: "Display layout",
|
||||
"OLED 四行文字位置": "OLED four-line positions",
|
||||
板载指示灯: "Board LEDs",
|
||||
树莓派电源灯与活动灯: "Raspberry Pi power and activity LEDs",
|
||||
服务监控: "Service monitoring",
|
||||
"监控 systemd 服务": "Monitor systemd services",
|
||||
进程监控: "Process monitoring",
|
||||
监控普通进程关键字: "Monitor process keywords",
|
||||
"API 设置": "API settings",
|
||||
"API 开关、监听地址、端口和日志数量": "API listener, port and log settings",
|
||||
正常运行灯效: "Normal lighting",
|
||||
"没有告警时显示;告警出现后自动让位。":
|
||||
"Shown when no alert is active; alerts take priority.",
|
||||
告警对象颜色: "Alert category colors",
|
||||
"告警对象决定颜色,严重程度决定闪烁速度。":
|
||||
"The alert category determines color; severity determines flash timing.",
|
||||
普通告警闪烁节奏: "Warning flash timing",
|
||||
严重告警闪烁节奏: "Critical flash timing",
|
||||
紧急告警闪烁节奏: "Emergency flash timing",
|
||||
硬件高级参数: "Advanced hardware settings",
|
||||
自定义颜色: "Custom color",
|
||||
点击色块打开色盘: "Click the swatch to open the color picker",
|
||||
红色通道: "Red channel",
|
||||
绿色通道: "Green channel",
|
||||
蓝色通道: "Blue channel",
|
||||
"范围 0–255。": "Range: 0–255.",
|
||||
亮灯时间: "On time",
|
||||
灭灯时间: "Off time",
|
||||
毫秒: "ms",
|
||||
秒: "s",
|
||||
像素: "px",
|
||||
当前系统服务: "System services",
|
||||
"点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。":
|
||||
"Click “Add monitor” to fill the form below, or enter a future service manually.",
|
||||
刷新列表: "Refresh list",
|
||||
搜索服务名称或说明: "Search service name or description",
|
||||
"正在读取 systemd 服务…": "Loading systemd services…",
|
||||
已监控服务: "Monitored services",
|
||||
"“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。":
|
||||
"“Pause monitor” stops only status checks; “Stop service” operates systemd.",
|
||||
新建服务监控: "Add service monitor",
|
||||
编辑服务监控: "Edit service monitor",
|
||||
"OLED 显示名称": "OLED display name",
|
||||
"systemd 服务名称": "systemd service name",
|
||||
备注说明: "Description",
|
||||
保存监控: "Save monitor",
|
||||
清空: "Clear",
|
||||
未监控: "Not monitored",
|
||||
已监控: "Monitored",
|
||||
已暂停: "Paused",
|
||||
加入监控: "Add monitor",
|
||||
编辑监控: "Edit monitor",
|
||||
暂停监控: "Pause monitor",
|
||||
恢复监控: "Resume monitor",
|
||||
停止服务: "Stop service",
|
||||
启动服务: "Start service",
|
||||
删除监控: "Delete monitor",
|
||||
正在运行: "Running",
|
||||
未运行: "Stopped",
|
||||
监控已暂停: "Monitor paused",
|
||||
运行中: "Running",
|
||||
服务已停止: "Service stopped",
|
||||
"备注:": "Note: ",
|
||||
未填写: "Not provided",
|
||||
暂无服务说明: "No service description",
|
||||
没有匹配的服务: "No matching services",
|
||||
尚未监控任何服务: "No monitored services",
|
||||
"正在保存…": "Saving…",
|
||||
暂无配置项: "No settings",
|
||||
程序启动: "Agent started",
|
||||
程序停止: "Agent stopped",
|
||||
告警出现: "Alert active",
|
||||
告警恢复: "Alert recovered",
|
||||
显示对象切换: "Display owner changed",
|
||||
互联网断开: "Internet down",
|
||||
互联网恢复: "Internet recovered",
|
||||
网络接口切换: "Network interface changed",
|
||||
"RGB 控制错误": "RGB control error",
|
||||
"RGB 模式切换": "RGB mode changed",
|
||||
"RGB 控制恢复": "RGB control recovered",
|
||||
系统事件: "System event",
|
||||
监控对象停止: "Monitored target down",
|
||||
监控对象恢复: "Monitored target recovered",
|
||||
网络断开: "Network down",
|
||||
网络恢复: "Network recovered",
|
||||
供电欠压: "Power undervoltage",
|
||||
供电恢复: "Power recovered",
|
||||
历史电源状态: "Power history flag",
|
||||
根文件系统只读: "Root filesystem read-only",
|
||||
正常主页: "Normal home",
|
||||
处理器温度严重: "Critical CPU temperature",
|
||||
处理器降频: "CPU throttled",
|
||||
频率受限: "Frequency capped",
|
||||
服务停止: "Service down",
|
||||
进程停止: "Process down",
|
||||
磁盘严重不足: "Disk critically full",
|
||||
处理器温度偏高: "High CPU temperature",
|
||||
处理器负载过高: "High CPU load",
|
||||
内存占用过高: "High memory usage",
|
||||
磁盘空间不足: "Disk usage high",
|
||||
"Wi-Fi 信号弱": "Weak Wi-Fi signal",
|
||||
全灭: "Off",
|
||||
普通告警: "Warning",
|
||||
严重告警: "Critical",
|
||||
紧急告警: "Emergency",
|
||||
无线网络: "Wi-Fi",
|
||||
有线网络: "Ethernet",
|
||||
停止: "Stopped",
|
||||
系统服务: "System service",
|
||||
普通进程: "Process",
|
||||
当前没有告警: "No active alerts",
|
||||
"数据来源:systemd journal": "Source: systemd journal",
|
||||
项尚未保存: "unsaved changes",
|
||||
正常状态灯效: "Normal mode",
|
||||
自定义颜色常亮: "Custom solid color",
|
||||
自定义颜色闪烁: "Custom color flash",
|
||||
流水灯: "Flow",
|
||||
呼吸灯: "Breathe",
|
||||
跑马灯: "Marquee",
|
||||
彩虹灯: "Rainbow",
|
||||
炫彩灯: "Colorful",
|
||||
内置效果速度: "Built-in effect speed",
|
||||
内置效果颜色: "Built-in effect color",
|
||||
慢速: "Slow",
|
||||
中速: "Medium",
|
||||
快速: "Fast",
|
||||
红色: "Red",
|
||||
绿色: "Green",
|
||||
蓝色: "Blue",
|
||||
黄色: "Yellow",
|
||||
紫色: "Purple",
|
||||
青色: "Cyan",
|
||||
白色: "White",
|
||||
关闭: "Off",
|
||||
不干预: "Unmanaged",
|
||||
普通告警亮灯: "Warning on time",
|
||||
普通告警灭灯: "Warning off time",
|
||||
严重告警亮灯: "Critical on time",
|
||||
严重告警灭灯: "Critical off time",
|
||||
紧急告警亮灯: "Emergency on time",
|
||||
紧急告警灭灯: "Emergency off time",
|
||||
"RGB 寄存器写入间隔": "RGB register write interval",
|
||||
处理器高负载阈值: "High CPU threshold",
|
||||
处理器恢复阈值: "CPU recovery threshold",
|
||||
处理器持续时间: "CPU duration",
|
||||
内存高占用阈值: "High memory threshold",
|
||||
内存恢复阈值: "Memory recovery threshold",
|
||||
内存持续时间: "Memory duration",
|
||||
高温告警阈值: "High temperature threshold",
|
||||
严重高温阈值: "Critical temperature threshold",
|
||||
磁盘高占用阈值: "High disk threshold",
|
||||
磁盘严重阈值: "Critical disk threshold",
|
||||
"Wi-Fi 弱信号阈值": "Weak Wi-Fi threshold",
|
||||
"Wi-Fi 恢复阈值": "Wi-Fi recovery threshold",
|
||||
弱信号持续时间: "Weak signal duration",
|
||||
风扇启动温度: "Fan start temperature",
|
||||
二档起始温度: "Level 2 temperature",
|
||||
三档起始温度: "Level 3 temperature",
|
||||
四档起始温度: "Level 4 temperature",
|
||||
全速起始温度: "Full-speed temperature",
|
||||
降档温度回差: "Downshift hysteresis",
|
||||
传感器采样周期: "Sensor interval",
|
||||
"OLED 刷新周期": "OLED refresh interval",
|
||||
"OLED 页面轮播周期": "OLED page interval",
|
||||
网络检查周期: "Network check interval",
|
||||
服务检查周期: "Service check interval",
|
||||
健康检查周期: "Health check interval",
|
||||
第一行纵坐标: "Row 1 Y position",
|
||||
第二行纵坐标: "Row 2 Y position",
|
||||
第三行纵坐标: "Row 3 Y position",
|
||||
第四行纵坐标: "Row 4 Y position",
|
||||
树莓派电源灯: "Raspberry Pi power LED",
|
||||
树莓派活动灯: "Raspberry Pi activity LED",
|
||||
监听地址: "Bind address",
|
||||
监听端口: "Listen port",
|
||||
默认日志条数: "Default log entries",
|
||||
"处理器、温度与散热": "CPU, temperature and cooling",
|
||||
供电与欠压: "Power and undervoltage",
|
||||
内存: "Memory",
|
||||
存储: "Storage",
|
||||
"网络与 Wi-Fi": "Network and Wi-Fi",
|
||||
服务与进程: "Services and processes",
|
||||
"仅在没有当前告警时生效;告警会立即抢占。":
|
||||
"Only active when there are no alerts; alerts take priority immediately.",
|
||||
"MCU 内置速度档位。": "MCU built-in speed level.",
|
||||
"仅流水灯和呼吸灯使用。": "Used only by flow and breathe modes.",
|
||||
"MCU 内置灯效参数写入间隔,通常保持 10 毫秒。":
|
||||
"MCU built-in effect parameter write interval; normally keep 10 ms.",
|
||||
"单位 dBm,数值越接近 0 信号越强。":
|
||||
"Unit: dBm. Values closer to 0 mean a stronger signal.",
|
||||
"单位 dBm,应高于弱信号阈值。":
|
||||
"Unit: dBm. Must be above the weak-signal threshold.",
|
||||
"避免临界温度附近频繁切换档位。":
|
||||
"Prevents rapid level changes near a threshold.",
|
||||
"选择“关闭”时由程序熄灭板载灯。":
|
||||
"Choose “Off” to let the program turn off the board LED.",
|
||||
多机配置同步: "Multi-host configuration sync",
|
||||
"勾选目标机器后,将当前页面中已修改的配置项批量同步到这些机器。":
|
||||
"Select target hosts to copy the changed settings on this page.",
|
||||
"当前机器配置令牌(Bearer Token)": "Current host Bearer Token",
|
||||
批量同步已修改项: "Sync changed settings",
|
||||
设备标识: "Device identity",
|
||||
自定义本机显示名称和稳定标识:
|
||||
"Set the local display name and stable identifier",
|
||||
显示名称: "Display name",
|
||||
机器列表: "Host list",
|
||||
登记需要聚合监控的其他节点: "Register nodes for fleet monitoring",
|
||||
已登记机器: "Registered hosts",
|
||||
尚未登记其他机器: "No remote hosts registered",
|
||||
节点地址: "Node URL",
|
||||
剩余: "Free",
|
||||
本机: "Local host",
|
||||
连接失败: "Connection failed",
|
||||
端口: "port",
|
||||
"0.0.0.0 允许局域网访问;127.0.0.1 仅本机。":
|
||||
"0.0.0.0 allows LAN access; 127.0.0.1 is local only.",
|
||||
"范围 1–65535;保存后 API 自动重启。":
|
||||
"Range: 1–65535. The API restarts after saving.",
|
||||
"API 服务不能停止自身": "The API service cannot stop itself",
|
||||
"已自动填写,可继续修改。": "Filled automatically; you can continue editing.",
|
||||
请先选择同步目标机器: "Select target hosts first",
|
||||
从当前配置机器复制所选配置项到目标机器:
|
||||
"Copy selected settings from the current configuration host to target hosts",
|
||||
全部选择: "Select all",
|
||||
全部取消: "Clear all",
|
||||
反选: "Invert selection",
|
||||
开始批量同步: "Start batch sync",
|
||||
"机器标识和机器列表不会参与同步。":
|
||||
"Host identity and host registry are never synchronized.",
|
||||
不能选择来源机器作为目标: "The source host cannot also be a target",
|
||||
目标机器令牌: "Target host token",
|
||||
项已选择: "settings selected",
|
||||
恢复设备默认值: "Restore device defaults",
|
||||
"启用远程管理 API": "Enable remote management API",
|
||||
开启: "Enabled",
|
||||
"创建管理员账号后,此浏览器将保持登录 30 天。账号只在当前 Console 中使用。":
|
||||
"Create an administrator account. This browser will stay signed in for 30 days. The account is used only by this Console.",
|
||||
};
|
||||
Reference in New Issue
Block a user