Files
T

300 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});