feat: publish standalone PIGWay Agent Console plugin

This commit is contained in:
way
2026-10-11 14:52:40 +08:00
commit ac6db9c2da
15 changed files with 1898 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
dist/
.DS_Store
+18
View File
@@ -0,0 +1,18 @@
# PIGWay Agent Console Plugin
独立维护的树莓派接入插件:连接现有 PIGWay Device Agent,不在树莓派安装新进程。设备 API、状态转换、配置字段、服务/进程监控、硬件界面及其翻译均由本仓库维护。
依赖 Python 标准库;接入 Console JSON-RPC v1 与可信前端扩展 `console-ui-v1`。插件 ID 保持 `pigway-agent`,已有设备可直接继续使用。
打包:
```sh
mkdir -p dist
python3 package.py plugin dist/pigway-agent-1.0.0.zip
```
在控制中心的机器管理页面安装 ZIP,再启用。开发时可用 `PIGWAY_ADAPTER_PATH=/absolute/path/pigway-console-adapter-agent/plugin` 加载源码;Docker 推荐使用持久化数据卷中的安装包。
包根目录只有 manifest、worker 与 ui;打包工具、文档和 Git 元数据不进入插件包。前端扩展通过 Console 的共享状态、请求、翻译与基础显示函数接入,不注入额外安装步骤。只应安装可信插件:前端模块与主页面共享 JavaScript 执行环境,加载失败、同步异常及 Promise 拒绝可降级,但不能隔离死循环或恶意代码;后端独立进程的隔离也不是安全沙箱。
正式仓库:https://tea.pigway.com/way/pigway-console-adapter-agent 。首次独立版本为 v1.0.0,需要 Console v2.0.0。与 Console 的集成回归使用后者 tests 下的模拟 Agent,不操作真实设备。
+14
View File
@@ -0,0 +1,14 @@
7d856a7ec552a841097cd14f2f3e357efcd05c13f70eb98a415789e615f6fbf2 .gitignore
f577e72ab33e2cc2dcb2fde5f598adab9dd0ae0602cef517c8281eb39bf00eb6 README.md
d081c599d821149853368b1a8637c57a16995344406ff5ec9d7619b66e0479b3 package.py
b7c560b9f529ef8dcdf74f1bc74a36745fc005c2432ff80325e1c9e87573e7b2 plugin/manifest.json
e5c04001dbd8ed8bba614f7b341237fc6487a9a8fe2f28ccb6f59278725b0a69 plugin/ui/config.js
c98f34119436e3020ef20b325f91617943aefe662119cf76738e0226c2e91cbb plugin/ui/device.js
89b9d7994e7477e18c3f43e6f0975e04d5106056ba581b2964db0f39183d6f8a plugin/ui/hardware.js
af631694fcd7da21b77b554e6ee1bf2eaa93bd46a0b8b543e367f032d3236ecb plugin/ui/index.js
fadb6bf24d6fcf569fc4157847a3585a1008aad99ce5a0e2b87c9c0658f6d225 plugin/ui/overview.js
422e1f8323d29a4be757f39e8b60addd7764c436a1302673809e0c547fce5ff4 plugin/ui/schema.js
5d967b5d4e62eed7022bc04fe29e4ed670f2e288cf20adae0c25233f94a29079 plugin/ui/services.js
56e67d2839cbb865133e3b5f796a07f36595c0f6c30480f8e2fac79e7d51e03f plugin/ui/styles.css
0b79b9c5b5f23727164e05b127caca72255416309166ba200ccbd15a9667473c plugin/ui/translations.js
c12c7b8a3c2fdb5eaef8ca535fcae04d960d4c89c86740529166f3e25cd8c7c9 plugin/worker.py
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""Package an independently maintained adapter without Console internals."""
import argparse
import json
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
parser = argparse.ArgumentParser()
parser.add_argument('directory', type=Path)
parser.add_argument('output', type=Path)
args = parser.parse_args()
root = args.directory.resolve()
manifest = json.loads((root / 'manifest.json').read_text())
entry = (root / manifest['entrypoint']).resolve()
if not entry.is_relative_to(root) or not entry.is_file():
parser.error('invalid adapter entrypoint')
with ZipFile(args.output, 'w', ZIP_DEFLATED) as archive:
for path in sorted(root.rglob('*')):
if path.is_symlink():
parser.error('symlinks are not supported')
if path.is_file() and path.resolve() != args.output.resolve() and not any(part in ('.git', '__pycache__') for part in path.parts) and path.suffix != '.pyc':
archive.write(path, path.relative_to(root))
print('Adapter package created:', args.output)
+42
View File
@@ -0,0 +1,42 @@
{
"id": "pigway-agent",
"name": "PIGWay Agent",
"version": "1.0.0",
"protocol_version": 1,
"entrypoint": "worker.py",
"runtime": "python3",
"capabilities": [
"status",
"logs",
"config",
"services",
"processes",
"hardware"
],
"config_contract": "pigway-agent-v1",
"connection_schema": {
"url": {
"label": {
"zh": "设备地址",
"en": "Device URL"
},
"type": "string",
"required": true,
"format": "http-origin"
},
"token": {
"label": {
"zh": "设备 API 令牌",
"en": "Device API token"
},
"type": "string",
"required": true,
"secret": true
}
},
"ui": {
"contract": "console-ui-v1",
"entrypoint": "ui/index.js"
},
"legacy_connection_fields": true
}
+364
View File
@@ -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";
}
+30
View File
@@ -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();
}
+299
View File
@@ -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();
});
+30
View File
@@ -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);
+24
View File
@@ -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>`;
}
+385
View File
@@ -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>`;
}
+193
View File
@@ -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);
}
}
+54
View File
@@ -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;
}
}
+288
View File
@@ -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.",
};
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Official Agent adapter. This process alone knows Agent endpoints/authentication."""
import concurrent.futures
import json
import sys
import threading
from pathlib import Path
from urllib.error import HTTPError
from urllib.parse import urlencode, urlparse
from urllib.request import HTTPRedirectHandler, Request, build_opener
MANIFEST = json.loads(Path(__file__).with_name('manifest.json').read_text())
OUTPUT_LOCK = threading.Lock()
DEVICE_LOCKS = {}
LOCK = threading.Lock()
MAX_MESSAGE = 16 * 1024 * 1024
READS = {'config': '/config', 'services': '/services', 'processes': '/processes',
'hardware': '/plugins', 'hardware_config': '/plugin/config'}
WRITES = {**READS, 'service_control': '/services/control'}
class AdapterError(Exception):
def __init__(self, code):
self.code = code
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def request(device, endpoint, method='GET', payload=None, query=None):
origin = device['connection']['url'].rstrip('/')
parsed = urlparse(origin)
if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment:
raise AdapterError('INVALID_CONFIG')
url = origin + '/api/v1' + endpoint
if query:
url += '?' + urlencode(query)
body = None if payload is None else json.dumps(payload).encode()
req = Request(url, data=body, method=method, headers={
'Authorization': 'Bearer ' + device['credentials']['token'], 'Content-Type': 'application/json'})
try:
with build_opener(NoRedirect).open(req, timeout=5) as response:
raw = response.read(MAX_MESSAGE + 1)
if len(raw) > MAX_MESSAGE:
raise AdapterError('INVALID_RESPONSE')
value = json.loads(raw)
if not isinstance(value, dict):
raise AdapterError('INVALID_RESPONSE')
return value
except HTTPError as exc:
raise AdapterError('AUTH_FAILED' if exc.code in (401, 403) else 'RESULT_UNKNOWN' if method == 'PUT' and exc.code >= 500 else 'DEVICE_API_ERROR') from None
except (OSError, ValueError):
raise AdapterError('RESULT_UNKNOWN' if method == 'PUT' else 'DEVICE_UNAVAILABLE') from None
def collect(device):
value = request(device, '/status')
if not isinstance(value.get('system'), dict) or not isinstance(value.get('machine'), dict):
raise AdapterError('INVALID_RESPONSE')
metrics = []
units = {'cpu_percent': 'percent', 'memory_percent': 'percent', 'disk_percent': 'percent',
'temperature_c': 'celsius', 'cpu_freq_mhz': 'megahertz'}
for key, unit in units.items():
number = value['system'].get(key)
if isinstance(number, (int, float)) and not isinstance(number, bool):
metrics.append({'resource_id': 'host', 'key': key, 'value': number, 'unit': unit})
return {'schema_version': 1, 'observed_at': value.get('timestamp'),
'identity': {'reported_name': value['machine'].get('name'), 'category': 'host',
'details': value.get('device', {})},
'capabilities': MANIFEST['capabilities'], 'metrics': metrics,
'resources': [{'id': 'host', 'type': 'host'}],
'alerts': value.get('display', {}).get('alerts', []),
'alerts_complete': isinstance(value.get('display', {}).get('alerts'), list),
'view': value}
def dispatch(method, params):
if method in ('adapter.initialize', 'adapter.describe', 'adapter.health'):
return MANIFEST
device = params['device']
with LOCK:
device_lock = DEVICE_LOCKS.setdefault(device['id'], threading.Lock())
with device_lock:
if method == 'device.validate':
return request(device, '/health')
if method == 'device.collect':
return collect(device)
if method == 'device.logs.query':
return request(device, '/logs', query=params.get('query'))
if method == 'device.read':
return request(device, READS[params['resource']], query=params.get('query'))
if method == 'device.write':
return request(device, WRITES[params['resource']], 'PUT', params.get('payload'))
raise AdapterError('UNSUPPORTED')
def handle(message):
try:
result = dispatch(message['method'], message.get('params', {}))
reply = {'jsonrpc': '2.0', 'id': message['id'], 'result': result}
except AdapterError as exc:
reply = {'jsonrpc': '2.0', 'id': message['id'], 'error': {'code': -32000, 'data': exc.code}}
except Exception:
# Do not copy vendor exception messages: they may contain credentials or URLs.
reply = {'jsonrpc': '2.0', 'id': message['id'], 'error': {'code': -32000, 'data': 'DEVICE_UNAVAILABLE'}}
raw = json.dumps(reply, allow_nan=False, separators=(',', ':'))
with OUTPUT_LOCK:
print(raw, flush=True)
def main():
# Bound both running and queued calls even if the parent violates its limits.
slots = threading.BoundedSemaphore(8)
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
while True:
line = sys.stdin.buffer.readline(MAX_MESSAGE + 1)
if not line:
break
if len(line) > MAX_MESSAGE or not line.endswith(b'\n'):
break
message = json.loads(line)
slots.acquire()
task = pool.submit(handle, message)
task.add_done_callback(lambda _: slots.release())
if __name__ == '__main__':
main()