300 lines
14 KiB
JavaScript
300 lines
14 KiB
JavaScript
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();
|
|||
|
|
});
|