From ac6db9c2da29d6c1d2b5a8e5c72723f8ee58ff95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 11 Oct 2026 14:52:40 +0800 Subject: [PATCH] feat: publish standalone PIGWay Agent Console plugin --- .gitignore | 4 + README.md | 18 ++ SHA256SUMS | 14 ++ package.py | 23 +++ plugin/manifest.json | 42 +++++ plugin/ui/config.js | 364 +++++++++++++++++++++++++++++++++++ plugin/ui/device.js | 30 +++ plugin/ui/hardware.js | 299 +++++++++++++++++++++++++++++ plugin/ui/index.js | 30 +++ plugin/ui/overview.js | 24 +++ plugin/ui/schema.js | 385 ++++++++++++++++++++++++++++++++++++++ plugin/ui/services.js | 193 +++++++++++++++++++ plugin/ui/styles.css | 54 ++++++ plugin/ui/translations.js | 288 ++++++++++++++++++++++++++++ plugin/worker.py | 130 +++++++++++++ 15 files changed, 1898 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SHA256SUMS create mode 100644 package.py create mode 100644 plugin/manifest.json create mode 100644 plugin/ui/config.js create mode 100644 plugin/ui/device.js create mode 100644 plugin/ui/hardware.js create mode 100644 plugin/ui/index.js create mode 100644 plugin/ui/overview.js create mode 100644 plugin/ui/schema.js create mode 100644 plugin/ui/services.js create mode 100644 plugin/ui/styles.css create mode 100644 plugin/ui/translations.js create mode 100644 plugin/worker.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3f3dfe --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +dist/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..7feb755 --- /dev/null +++ b/README.md @@ -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,不操作真实设备。 diff --git a/SHA256SUMS b/SHA256SUMS new file mode 100644 index 0000000..f5ad61a --- /dev/null +++ b/SHA256SUMS @@ -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 diff --git a/package.py b/package.py new file mode 100644 index 0000000..5615e88 --- /dev/null +++ b/package.py @@ -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) diff --git a/plugin/manifest.json b/plugin/manifest.json new file mode 100644 index 0000000..fb1a478 --- /dev/null +++ b/plugin/manifest.json @@ -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 +} diff --git a/plugin/ui/config.js b/plugin/ui/config.js new file mode 100644 index 0000000..c7db661 --- /dev/null +++ b/plugin/ui/config.js @@ -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 `

${tr(t)}

${tr(d)}
${ + Object.entries(v) + .map(([k, x]) => field(s, k, x)) + .join("") || '
暂无配置项
' + }
`; +} +export function renderServices() { + return `

当前系统服务

点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。
正在读取 systemd 服务…

已监控服务

“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。

新建服务监控

`; +} +export function renderProcesses() { + return `

${pluginText("已监控进程", "Monitored processes")}

${pluginText("用于没有 systemd 服务的程序,通过 pgrep -f 匹配完整命令行。", "For programs without a systemd service. Matching uses pgrep -f against the full command line.")}
${pluginText("正在读取进程监控…", "Loading process monitors…")}

${pluginText("新建进程监控", "Add process monitor")}

`; +} + +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 = `
${esc(e.message)}
`; + } +} +export function renderProcessList() { + rows( + "processList", + appState.processCache.map( + (p) => + `
${esc(p.name)} ${tr(p.up ? "正常" : "停止")}
${esc(p.target)}
`, + ), + 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 `

${tr("目标机器")}

${tr("机器标识和机器列表不会参与同步。")}
${ + configurableHosts() + .filter( + (h) => + h.id !== appState.bulkSource && + h.config_contract === + appState.fleetHosts.find( + (source) => source.id === appState.bulkSource, + )?.config_contract, + ) + .map( + (h) => + ``, + ) + .join("") || pluginText("没有其他设备", "No other devices") + }
`; +} +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) => + `
${Object.entries( + appState.bulkOriginal[s], + ) + .map( + ([k, v]) => + ``, + ) + .join("")}
`, + ) + .join(""); + return `

${tr("来源机器")}

${esc(hostLabel(appState.fleetHosts.find((h) => h.id === appState.bulkSource) || { id: appState.bulkSource }))}
${renderBulkTargets()}

${tr("配置项")}

${groups}
`; +} +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) => + ``, + ) + .join(""); + $("configRows").innerHTML = ss + .map( + (s, i) => + `

${tr(sectionMeta[s]?.[0] || s)}

${tr(sectionMeta[s]?.[1] || "")}

${s === "bulk_sync" ? renderBulkSync() : s === "plugins" ? renderPlugins() : s === "services" ? renderServices() : s === "processes" ? renderProcesses() : s === "hosts" ? renderHosts(appState.original[s]) : renderGeneral(s, appState.original[s])}
`, + ) + .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"; +} diff --git a/plugin/ui/device.js b/plugin/ui/device.js new file mode 100644 index 0000000..ef4c627 --- /dev/null +++ b/plugin/ui/device.js @@ -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 `
${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( + "", + )}

${pluginText("默认值跟随本机主机名;修改后点击保存到当前机器。", "Defaults follow the device hostname. Save to the current host to apply changes.")}

`; +} +export function resetDeviceDefaults() { + document.querySelectorAll('[data-section="device"]').forEach((el) => { + el.value = appState.deviceIdentity.hostname || ""; + el.dataset.deviceAuto = "true"; + }); + markChanged(); +} diff --git a/plugin/ui/hardware.js b/plugin/ui/hardware.js new file mode 100644 index 0000000..f4c9b15 --- /dev/null +++ b/plugin/ui/hardware.js @@ -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 `

${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.")}

`; +} +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 + ? ` · ${fans.map((f) => esc(pluginText("风扇", "Fan") + (fans.length > 1 ? " " + f.name : "") + " " + f.value)).join(" · ")}` + : ""; +} +export function pluginSummary(plugins, host) { + if (!Object.keys(plugins).length) + return `
${pluginText("风扇与硬件插件", "Fan and hardware plugins")}

${pluginText("尚未接入硬件插件,风扇状态不可用。独立安装并启动插件后,可在插件管理中接入。", "No hardware plugin connected; fan status is unavailable. Install and start a plugin independently, then connect it in plugin management.")}

`; + 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 `
${esc(p.name || id)} · ${p.online ? tr("在线") : tr("离线")}${details.length ? " · " + details.join(" · ") : ""}
`; + }) + .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) => + `

${esc(p.descriptor?.name || p.id)}

${esc(p.socket)} · ${p.online ? tr("在线") : tr("离线")} · ${p.linked ? (p.paused ? pluginText("联动已暂停", "Link paused") : pluginText("已接入", "Connected")) : pluginText("尚未接入", "Not connected")}
${p.error ? `

${esc(p.error)}

` : ""}${p.status?.errors && Object.keys(p.status.errors).length ? `

${esc(JSON.stringify(p.status.errors))}

` : ""}

${Object.keys( + p.descriptor?.capabilities || {}, + ) + .map((k) => + esc( + { + fan: pluginText("风扇", "Fan"), + display: pluginText("屏幕", "Display"), + rgb: pluginText("灯光", "Lighting"), + }[k] || k, + ), + ) + .join( + " · ", + )}

${p.linked ? `` : ``}
`, + ) + .join("") || + `

${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.")}

`; + 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 ``; +} +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 `
${members.map(([k, v]) => capabilityField(section, k, values[k], { ...v, visible: null })).join("")}
`; + }) + .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 = `

${esc(id)}

${pluginText("配置由插件保存并立即应用,不重启监控。", "Settings are saved and applied by the plugin without restarting monitoring.")}

${(data.notes || []).map((n) => `

${esc(n[appState.LANG] || n.en || "")}

`).join("")}${Object.entries( + data.schema, + ) + .map( + ([section, fields]) => + `

${{ fan: pluginText("风扇", "Fan"), oled: pluginText("屏幕", "Display"), rgb: pluginText("灯光", "Lighting") }[section] || esc(section)}

${capabilityFields(section, fields, data.config[section])}
`, + ) + .join( + "", + )}
`; + 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(); +}); diff --git a/plugin/ui/index.js b/plugin/ui/index.js new file mode 100644 index 0000000..79bdd89 --- /dev/null +++ b/plugin/ui/index.js @@ -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); diff --git a/plugin/ui/overview.js b/plugin/ui/overview.js new file mode 100644 index 0000000..4314ee3 --- /dev/null +++ b/plugin/ui/overview.js @@ -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 `

${esc(h.name || h.id)}

ID ${esc(h.id)}${esc(h.url || tr("本机"))}
${tr("离线")}
${esc(entry.error || tr("连接失败"))}
`; + 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 `

${esc(m.name || h.name || m.id)}

${esc(formatUptime(s.uptime_seconds))}
ID ${esc(m.id || h.id)}${appState.LANG === "zh" ? "主机名" : "Hostname"} ${esc(m.hostname || v.hostname || "--")}${esc(v.manufacturer || "")} ${esc(v.model || "--")}${appState.LANG === "zh" ? "序列号" : "Serial"} ${esc(v.serial || "--")}${appState.LANG === "zh" ? "硬件版本" : "Revision"} ${esc(v.revision || "--")}${esc(v.os || "--")}${appState.LANG === "zh" ? "内核" : "Kernel"} ${esc(v.kernel || "--")}${esc(v.architecture || "--")} · CPU ${esc(v.cpu_count || "--")}${appState.LANG === "zh" ? "程序版本" : "Agent"} ${esc(s.version || "--")}
${tr("在线")}
${tr("处理器负载")}
${esc(y.cpu_percent)}%
${meter(y.cpu_percent)}
${appState.LANG === "zh" ? "频率" : "Frequency"} ${esc(y.cpu_freq_mhz)} MHz · ${esc(y.temperature_c)}°C${fanMetric(s.plugins || {})}
${tr("内存占用")}
${esc(y.memory_percent)}%
${meter(y.memory_percent)}
${bytes(memUsed)} / ${bytes(memTotal)} · ${appState.LANG === "zh" ? "可用" : "Available"} ${bytes(y.memory_available_bytes)}
${tr("磁盘占用")}
${esc(y.disk_percent)}%
${meter(y.disk_percent)}
${bytes(diskUsed)} / ${bytes(diskTotal)} · ${appState.LANG === "zh" ? "剩余" : "Free"} ${bytes(y.disk_free_bytes)}
${tr("主网络")}
${n.kind === "WIF" && signal !== null && signal !== undefined ? `${esc(signal)}%` : esc(n.kind || "--")}
${n.kind === "WIF" ? meter(signal) : ""}
${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"}
${tr("显示状态")}
${esc(ownerName(d.owner))}
${appState.LANG === "zh" ? "告警状态独立于硬件插件" : "Alert state is independent of hardware plugins"}
${tr("当前告警")}
${(d.alerts || []).length}
${(d.alerts || []).map((a) => esc(ownerName(a.id))).join(" · ") || tr("无告警")}
${pluginSummary(s.plugins || {}, h.id)}

${tr("服务与进程监控")}

${(s.watches || []).map((w) => `
${esc(w.name)} · ${esc(w.target)}${tr(w.up ? "正常" : "停止")}
`).join("") || `
${tr("暂无数据")}
`}
`; +} diff --git a/plugin/ui/schema.js b/plugin/ui/schema.js new file mode 100644 index 0000000..5c69435 --- /dev/null +++ b/plugin/ui/schema.js @@ -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 = ``; + else { + const input = ``; + control = unit + ? `
${input}${unit}
` + : input; + } + return `
${help ? `
${esc(help)}
` : ""}
`; +} diff --git a/plugin/ui/services.js b/plugin/ui/services.js new file mode 100644 index 0000000..61bbb37 --- /dev/null +++ b/plugin/ui/services.js @@ -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 = + `
${esc(e.message)}
`; + } +} +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) => + `
${esc(s.unit)}
${esc(s.description || "暂无服务说明")} · ${esc(s.active === "active" ? "正在运行" : "未运行")}
${s.monitored ? (s.paused ? "已暂停" : "已监控") : "未监控"}
`, + ), + "没有匹配的服务", + ); + const monitored = appState.serviceCache.filter((s) => s.monitored); + rows( + "monitoredServices", + monitored.map( + (s) => + `
${esc(s.monitor_name)} ${s.paused ? "监控已暂停" : s.active === "active" ? "运行中" : "服务已停止"}
${esc(s.unit)}
备注:${esc(s.note || "未填写")}
`, + ), + "尚未监控任何服务", + ); + 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); + } +} diff --git a/plugin/ui/styles.css b/plugin/ui/styles.css new file mode 100644 index 0000000..8b9a631 --- /dev/null +++ b/plugin/ui/styles.css @@ -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; + } +} diff --git a/plugin/ui/translations.js b/plugin/ui/translations.js new file mode 100644 index 0000000..c0952bf --- /dev/null +++ b/plugin/ui/translations.js @@ -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.", +}; diff --git a/plugin/worker.py b/plugin/worker.py new file mode 100644 index 0000000..03a0dd9 --- /dev/null +++ b/plugin/worker.py @@ -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()