Files
pigway-device-agent/web/index.html
T

105 lines
73 KiB
HTML
Raw 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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>PIGWay Pi Control</title>
<style>
:root{color-scheme:dark;--bg:#0b1118;--panel:#121b25;--panel2:#0f1822;--line:#263546;--text:#e7edf5;--muted:#91a4b8;--good:#50d890;--bad:#ff6b6b;--blue:#58a6ff}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top right,#14263b 0,#0b1118 42%);color:var(--text);font:14px/1.45 system-ui,-apple-system,sans-serif}main{max-width:1180px;margin:auto;padding:28px 20px 70px}header{display:flex;justify-content:space-between;align-items:end;gap:18px;margin-bottom:22px}h1{font-size:27px;margin:0}h2{font-size:18px;margin:0 0 8px}h3{font-size:15px;margin:0}.sub,.muted,.hint{color:var(--muted)}.hint{font-size:12px;margin-top:4px}.live{display:flex;align-items:center;gap:8px}.dot{width:9px;height:9px;border-radius:50%;background:var(--bad)}.dot.ok{background:var(--good);box-shadow:0 0 12px var(--good)}nav{display:flex;gap:8px;margin:18px 0;position:sticky;top:0;z-index:5;padding:8px 0;background:#0b1118e8;backdrop-filter:blur(8px)}button{border:1px solid var(--line);background:#162230;color:var(--text);border-radius:8px;padding:9px 13px;cursor:pointer}button:hover,button.active{border-color:var(--blue);background:#1b3047}button:disabled{opacity:.45;cursor:not-allowed}.danger{color:#ff8e8e}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.card,.panel,.group{background:color-mix(in srgb,var(--panel) 94%,transparent);border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:none}.group{background:var(--panel2);margin-top:12px}.group-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.value{font-size:23px;font-weight:700;margin-top:4px}.wide{grid-column:span 2}.panel{margin-top:12px}.rows{display:grid;gap:8px}.row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:8px 0;border-bottom:1px solid #203040}.row:last-child{border:0}.row-actions{display:flex;gap:6px;align-items:center;flex-wrap:wrap;justify-content:flex-end}.badge{border-radius:999px;padding:3px 8px;background:#243447;white-space:nowrap}.badge.good{color:var(--good)}.badge.bad{color:var(--bad)}.hidden{display:none!important}.log{font:12px/1.5 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;padding:8px 0;border-bottom:1px solid #203040}.log-label{font:12px system-ui;color:var(--blue);margin-right:8px}.config-shell{display:grid;grid-template-columns:210px minmax(0,1fr);gap:14px;margin-top:14px}.config-menu{display:flex;flex-direction:column;gap:7px;align-self:start;position:sticky;top:66px}.config-menu button{text-align:left}.config-page{display:none}.config-page.active{display:block}.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.field label{display:block;color:var(--muted);font-size:12px}.field input,.field select,.service-form input,.service-search{width:100%;margin-top:5px;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:9px}.field input:focus,.field select:focus,.service-form input:focus,.service-search:focus{outline:1px solid var(--blue);border-color:var(--blue)}.input-unit{position:relative}.input-unit input{padding-right:62px}.unit{position:absolute;right:10px;top:50%;transform:translateY(-50%);color:var(--muted);font-size:12px;pointer-events:none}.span2{grid-column:span 2}.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}.toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:12px 0}.toolbar input{min-width:280px;flex:1;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:9px}.savebar{position:sticky;bottom:10px;z-index:4;background:#101b27ef;border:1px solid var(--line);border-radius:10px;padding:10px;margin-top:18px;box-shadow:0 8px 30px #0008}.service-list{max-height:390px;overflow:auto;border-top:1px solid var(--line);margin-top:10px}.service-form{display:grid;grid-template-columns:1fr 1.4fr;gap:10px}.service-form .full{grid-column:span 2}.service-form label{color:var(--muted);font-size:12px}.error{color:var(--bad)}.success{color:var(--good)}@media(max-width:800px){.grid{grid-template-columns:repeat(2,1fr)}.fields,.service-form{grid-template-columns:1fr}.span2,.service-form .full{grid-column:span 1}.wide{grid-column:span 2}.config-shell{grid-template-columns:1fr}.config-menu{position:static;display:grid;grid-template-columns:repeat(2,1fr)}.rgb-row{grid-template-columns:68px 1fr}.rgb-row .field{grid-column:2}.color-field{grid-row:1/span 3}}@media(max-width:470px){.grid{grid-template-columns:1fr}.wide{grid-column:span 1}header{align-items:start;flex-direction:column}.config-menu{grid-template-columns:1fr}.row{align-items:flex-start;flex-direction:column}.row-actions{justify-content:flex-start}}
</style>
<style>
.log-filter-top{display:grid;grid-template-columns:180px 180px auto minmax(190px,1fr);gap:10px;align-items:end;margin:12px 0 10px}.log-filter-bottom{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;gap:10px;align-items:end;margin-bottom:12px}.log-filter-top label,.log-filter-bottom label{color:var(--muted);font-size:12px}.log-filter-top input,.log-filter-bottom select{width:100%;margin-top:5px;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:9px}.quick-range{display:flex;gap:7px;flex-wrap:nowrap;align-items:center}.log-actions{display:flex;gap:8px;align-items:center;justify-content:flex-end;white-space:nowrap}.log-actions #logSummary{margin-right:4px}.log-table-wrap{overflow:auto;border:1px solid var(--line);border-radius:9px}.log-table{width:100%;border-collapse:collapse;min-width:760px}.log-table th,.log-table td{text-align:left;padding:10px;border-bottom:1px solid #203040;vertical-align:top}.log-table th{position:sticky;top:0;background:#152130;z-index:1}.sort-button{border:0;background:transparent;padding:0;font-weight:700}.sort-button:hover{background:transparent}.log-message{font:12px/1.5 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;word-break:break-word}.log-pager{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:12px}@media(max-width:980px){.log-filter-top{grid-template-columns:1fr 1fr}.quick-range{grid-column:1/span 2;grid-row:2}.log-filter-top>label:last-child{grid-column:1/span 2}.log-filter-bottom{grid-template-columns:repeat(3,1fr)}.log-actions{grid-column:1/span 3}}@media(max-width:600px){.log-filter-top,.log-filter-bottom{grid-template-columns:1fr}.quick-range,.log-filter-top>label:last-child,.log-actions{grid-column:1}.quick-range{overflow-x:auto;justify-content:flex-start}.log-actions{flex-wrap:wrap}.log-actions #logSummary{width:100%;margin:0}}
.header-actions{display:flex;align-items:center;gap:14px;flex-wrap:wrap;justify-content:flex-end}.preference{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px}.preference select{height:35px;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:7px 30px 7px 10px}.theme-preference select{min-width:138px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.header-actions .live{height:35px}.service-list{scrollbar-width:none}.service-list:hover{scrollbar-width:thin;scrollbar-color:var(--line) transparent}.service-list::-webkit-scrollbar{width:0}.service-list:hover::-webkit-scrollbar{width:7px}.service-list::-webkit-scrollbar-track{background:transparent}.service-list::-webkit-scrollbar-thumb{background:var(--line);border-radius:999px}[data-theme="light"]{color-scheme:light;--bg:#eef3f8;--panel:#fff;--panel2:#f6f9fc;--line:#c8d4e0;--text:#172333;--muted:#5d7085;--good:#168653;--bad:#c93d3d;--blue:#176fc1}[data-theme="light"] body{background:radial-gradient(circle at top right,#d9e9f8 0,#eef3f8 45%)}[data-theme="light"] nav{background:#eef3f8e8}[data-theme="light"] button{background:#e7eef6}[data-theme="light"] button:hover,[data-theme="light"] button.active{background:#d9e9f8}[data-theme="light"] .field input,[data-theme="light"] .field select,[data-theme="light"] .service-form input,[data-theme="light"] .service-search,[data-theme="light"] .toolbar input,[data-theme="light"] .log-filter-top input,[data-theme="light"] .log-filter-bottom select,[data-theme="light"] .preference select{background:#fff}[data-theme="light"] .badge{background:#e5edf5;color:#31465c}[data-theme="light"] .badge.good{background:#d9f3e5;color:#087444}[data-theme="light"] .badge.bad{background:#fde5e5;color:#b72f2f}[data-theme="light"] .savebar{background:#f7fafcef;box-shadow:0 8px 25px #3453}[data-theme="light"] .log-table th{background:#e7eef6}[data-theme="light"] .row,[data-theme="light"] .log-table th,[data-theme="light"] .log-table td,[data-theme="light"] .log,[data-theme="light"] .service-list{border-color:#d6e0ea}@media(max-width:470px){.header-actions{justify-content:flex-start}}.host-panel{margin-top:14px;transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease}.host-head{display:flex;justify-content:space-between;gap:16px;align-items:start;margin-bottom:12px}.device-details{display:flex;gap:8px 16px;flex-wrap:wrap;font-size:12px;color:var(--muted);margin-top:5px}.host-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.metric{background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:12px}.metric .value{font-size:20px}.progress{height:7px;background:color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;overflow:hidden;margin:8px 0}.progress span{display:block;height:100%;background:var(--blue);border-radius:inherit}.host-select{display:grid;gap:4px;color:var(--muted);font-size:12px}.host-select select{min-width:210px;border:1px solid var(--line);border-radius:7px;background:var(--panel2);color:var(--text);padding:9px}.config-target-toolbar{display:grid;grid-template-columns:minmax(220px,.65fr) minmax(0,1.35fr);align-items:end}.token-control{display:flex;gap:8px;align-items:center}.token-control input{min-width:0;margin:0}.token-control button{white-space:nowrap}.bulk-tools{display:flex;gap:8px;flex-wrap:wrap;margin:12px 0}.bulk-section{padding:12px}.bulk-section-title{display:flex;align-items:center;gap:8px;font-weight:700}.bulk-items{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 18px;margin:10px 0 0 24px}.bulk-item{display:flex;align-items:flex-start;gap:7px}.bulk-item span{min-width:0}.bulk-value{display:block;color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bulk-target{display:grid;grid-template-columns:auto minmax(120px,.5fr) minmax(220px,1fr);gap:10px;align-items:center}.bulk-target input[type="password"],.bulk-target input[type="text"]{width:100%;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:8px}.host-checks{display:flex;gap:12px;flex-wrap:wrap}.host-checks label{display:flex;gap:6px;align-items:center}.offline-card{border-color:var(--bad)}@media(hover:hover) and (pointer:fine){.host-panel:hover{transform:translateY(-3px);border-color:color-mix(in srgb,var(--blue) 45%,var(--line));box-shadow:0 16px 34px #0006}[data-theme="light"] .host-panel:hover{box-shadow:0 14px 30px #3454}}@media(max-width:900px){.host-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:650px){.config-target-toolbar{grid-template-columns:1fr}.bulk-items{grid-template-columns:1fr}.bulk-target{grid-template-columns:auto 1fr}.bulk-target input{grid-column:1/span 2}}@media(max-width:550px){.host-grid{grid-template-columns:1fr}.host-head{flex-direction:column}}
</style>
</head>
<body><main>
<header><div><h1>PIGWay Pi Control</h1><div class="sub" id="identity">正在连接树莓派…</div></div><div class="header-actions"><label class="preference"><span>语言</span><select id="language"><option value="zh">中文</option><option value="en">English</option></select></label><label class="preference theme-preference"><span class="sr-only">颜色模式</span><select id="theme" aria-label="颜色模式"><option value="auto">◐ 跟随系统</option><option value="light">☀ 浅色模式</option><option value="dark">☾ 深色模式</option></select></label><div class="live"><span class="dot" id="dot"></span><span id="liveText">离线</span></div></div></header>
<nav><button class="active" data-tab="overview">运行状态</button><button data-tab="logs">事件日志</button><button data-tab="config">系统配置</button></nav>
<section id="overview"><div id="hostCards"><div class="panel muted">正在读取主机状态…</div></div></section>
<section id="logs" class="hidden"><div class="panel"><h2>事件日志</h2><p class="muted">查询 systemd journal 中仍在保留的全部硬件 Agent 事件。</p><div class="log-filter-top"><label>开始日期<input id="logStart" type="date"></label><label>结束日期<input id="logEnd" type="date"></label><div class="quick-range"><button data-log-range="today">今天</button><button data-log-range="yesterday">昨天</button><button data-log-range="week">本周</button><button data-log-range="month">本月</button><button data-log-range="all">全部</button></div><label>关键词<input id="logSearch" placeholder="搜索日志内容"></label></div><div class="log-filter-bottom"><label>严重程度<select id="logSeverity"><option value="">全部程度</option><option value="DEBUG">调试</option><option value="INFO">信息</option><option value="NOTICE">注意</option><option value="WARN">警告</option><option value="ERROR">错误</option><option value="CRITICAL">严重</option><option value="ALERT,EMERGENCY">紧急</option></select></label><label>机器标识<select id="logMachine"><option value="">全部机器</option></select></label><label>事件类型<select id="logEvent"><option value="">全部事件</option></select></label><div class="log-actions"><span class="muted" id="logSummary"></span><button id="refreshLogs">查询</button><button id="resetLogs">重置</button></div></div><div class="log-table-wrap"><table class="log-table"><thead><tr><th><button class="sort-button" data-log-sort="timestamp">时间</button></th><th><button class="sort-button" data-log-sort="machine">机器</button></th><th><button class="sort-button" data-log-sort="severity">程度</button></th><th><button class="sort-button" data-log-sort="event">事件类型</button></th><th><button class="sort-button" data-log-sort="message">内容</button></th></tr></thead><tbody id="logRows"></tbody></table></div><div class="log-pager"><button id="logPrev">上一页</button><span class="muted" id="logPage"></span><button id="logNext">下一页</button></div></div></section>
<section id="config" class="hidden"><div class="panel"><h2>系统配置</h2><p class="muted">配置按用途分组,只显示当前模式需要的选项。保存时会备份配置并重启硬件 Agent。</p><div class="toolbar config-target-toolbar"><label class="host-select">配置机器<select id="configHost"></select></label><label class="host-select">配置写入令牌(Bearer Token)<span class="token-control"><input id="token" type="password" placeholder="当前机器配置令牌(Bearer Token)"><button id="toggleToken" type="button">显示令牌</button></span></label></div><div class="config-shell"><aside class="config-menu" id="configMenu"></aside><div id="configRows"></div></div><div class="savebar" id="mainSavebar"><button id="saveConfig">保存到当前机器</button> <span id="saveState" class="muted">尚未修改</span></div></div></section>
</main><script>
let LANG=localStorage.getItem('pigwayLanguage')||'zh';
const EN={
'语言':'Language','颜色模式':'Appearance','中文':'中文','自动':'Auto','深色':'Dark','浅色':'Light','◐ 跟随系统':'◐ Follow system','☀ 浅色模式':'☀ Light mode','☾ 深色模式':'☾ Dark mode','正在连接树莓派…':'Connecting to Raspberry Pi…','在线':'Online','离线':'Offline',
'运行状态':'Status','事件日志':'Event log','系统配置':'Settings','处理器负载':'CPU load','处理器温度':'CPU temperature','内存占用':'Memory usage','磁盘占用':'Disk usage','主网络':'Primary network','显示状态':'Display status','当前告警':'Active alerts','无告警':'No alerts','服务与进程监控':'Service and process monitors',
'查询 systemd journal 中仍在保留的全部硬件 Agent 事件。':'Query all retained hardware Agent events in the systemd journal.','今天':'Today','昨天':'Yesterday','本周':'This week','本月':'This month','全部':'All','开始日期':'Start date','结束日期':'End date','严重程度':'Severity','全部程度':'All severities','调试':'Debug','信息':'Info','注意':'Notice','警告':'Warning','错误':'Error','严重':'Critical','紧急':'Emergency','事件类型':'Event type','全部事件':'All events','风扇档位变化':'Fan level changed','关键词':'Keyword','搜索日志内容':'Search log messages','查询':'Search','重置':'Reset','时间':'Time','程度':'Severity','内容':'Message','上一页':'Previous','下一页':'Next',
'配置按用途分组,只显示当前模式需要的选项。保存时会备份配置并重启硬件 Agent。':'Settings are grouped by purpose and only relevant options are shown. Saving creates a backup and restarts the hardware Agent.','配置写入令牌(Bearer Token)':'Configuration Bearer Token','显示令牌':'Show token','隐藏令牌':'Hide token','保存并应用':'Save and apply','尚未修改':'No changes',
'灯光设置':'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','Web 与 API':'Web and API','监听地址、端口和日志数量':'Bind address, port and log count',
'正常运行灯效':'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','颜色':'Color','红色通道':'Red channel','绿色通道':'Green channel','蓝色通道':'Blue channel','范围 0–255。':'Range: 0–255.','亮灯时间':'On time','灭灯时间':'Off time','毫秒':'ms','秒':'s','像素':'px','条':'entries',
'当前系统服务':'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','编辑':'Edit','删除监控':'Delete monitor','正在运行':'Running','未运行':'Stopped','监控已暂停':'Monitor paused','运行中':'Running','服务已停止':'Service stopped','备注:':'Note: ','未填写':'Not provided','暂无服务说明':'No service description','没有匹配的服务':'No matching services','尚未监控任何服务':'No monitored services','正在保存…':'Saving…','保存成功':'Saved','暂无配置项':'No settings','暂无数据':'No data',
'程序启动':'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','正常':'Normal','停止':'Stopped','系统服务':'System service','普通进程':'Process','当前没有告警':'No active alerts','没有符合条件的日志':'No matching events','数据来源:systemd journal':'Source: systemd journal','项尚未保存':'unsaved changes','没有需要保存的修改':'No changes to save','正在保存并应用…':'Saving and applying…'
};
Object.assign(EN,{
'正常状态灯效':'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.'
});
Object.assign(EN,{'机器标识':'Host ID','机器':'Host','全部机器':'All hosts','配置机器':'Configure host','多机配置同步':'Multi-host configuration sync','勾选目标机器后,将当前页面中已修改的配置项批量同步到这些机器。':'Select target hosts to copy the changed settings on this page.','当前机器配置令牌(Bearer Token)':'Current host Bearer Token','保存到当前机器':'Save to current host','批量同步已修改项':'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','添加或更新机器':'Add or update host','节点地址':'Node URL','保存机器':'Save host','正在读取主机状态…':'Loading host status…','可用':'Available','剩余':'Free','本机':'Local','连接失败':'Connection failed'});
Object.assign(EN,{'端口':'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'});
Object.assign(EN,{'批量同步':'Batch sync','从当前配置机器复制所选配置项到目标机器':'Copy selected settings from the current configuration host to target hosts','来源机器':'Source host','目标机器':'Target hosts','配置项':'Settings','全部选择':'Select all','全部取消':'Clear all','反选':'Invert selection','开始批量同步':'Start batch sync','机器标识和机器列表不会参与同步。':'Host identity and host registry are never synchronized.','请选择至少一个配置项':'Select at least one setting','请选择至少一台目标机器':'Select at least one target host','不能选择来源机器作为目标':'The source host cannot also be a target','目标机器令牌':'Target host token','项已选择':'settings selected'});
const ZH=Object.fromEntries(Object.entries(EN).map(([zh,en])=>[en,zh])),tr=s=>LANG==='en'?(EN[s]||s):(ZH[s]||s);
function translateNode(root=document){const map=LANG==='en'?EN:ZH,translate=value=>{const trim=value.trim();return trim&&map[trim]?value.replace(trim,map[trim]):value};const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);let n;while(n=walker.nextNode())n.nodeValue=translate(n.nodeValue);root.querySelectorAll?.('[placeholder],[title],[aria-label]').forEach(e=>['placeholder','title','aria-label'].forEach(a=>{const v=e.getAttribute(a);if(v)e.setAttribute(a,translate(v))}))}
function applyTheme(value){const resolved=value==='auto'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):value;document.documentElement.dataset.theme=resolved}
const $=id=>document.getElementById(id),tabs=[...document.querySelectorAll('nav button')];let original={},fleetHosts=[],currentConfigHost='';const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const sectionMeta={device:['设备标识','自定义本机显示名称和稳定标识'],hosts:['机器列表','登记需要聚合监控的其他节点'],led:['灯光设置','正常灯效与告警颜色'],alerts:['告警阈值','处理器、内存、温度、磁盘和 Wi-Fi'],fan:['风扇温控','温度档位与降档回差'],timing:['刷新周期','传感器、屏幕、网络和健康检查'],display:['屏幕布局','OLED 四行文字位置'],dark_mode:['板载指示灯','树莓派电源灯与活动灯'],services:['服务监控','监控 systemd 服务'],processes:['进程监控','监控普通进程关键字'],api:['Web 与 API','监听地址、端口和日志数量'],bulk_sync:['批量同步','从当前配置机器复制所选配置项到目标机器']};
const labels={'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':'弱信号持续时间','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]}色通道`));
const hints={'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,应高于弱信号阈值。','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。'));
const options={'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','不干预']]};
const numberRules={'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,.1],'alerts.temp_critical':[0,120,.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],'fan.start':[0,100,.1],'fan.level2':[0,100,.1],'fan.level3':[0,100,.1],'fan.level4':[0,100,.1],'fan.max':[0,100,.1],'fan.hysteresis':[0,20,.1],'timing.sensor_interval':[.2,3600,.1],'timing.oled_refresh_interval':[.2,3600,.1],'timing.page_interval':[1,3600,.1],'timing.network_interval':[1,3600,.1],'timing.service_interval':[1,3600,.1],'timing.health_interval':[.2,3600,.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]));
const units={'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':'秒','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':'条'};
const sectionOrder=['device','hosts','led','alerts','fan','timing','display','dark_mode','services','processes','api'],bulkSections=sectionOrder.filter(s=>!['device','hosts'].includes(s)),alertColorGroups=[['cpu','处理器、温度与散热'],['power','供电与欠压'],['memory','内存'],['storage','存储'],['network','网络与 Wi-Fi'],['service','服务与进程']];
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 信号弱'},rgbNames={OFF:'全灭',NORMAL_SOLID:'正常 · 自定义常亮',NORMAL_FLASH:'正常 · 自定义闪烁',NORMAL_FLOW:'正常 · 流水灯',NORMAL_BREATHE:'正常 · 呼吸灯',NORMAL_MARQUEE:'正常 · 跑马灯',NORMAL_RAINBOW:'正常 · 彩虹灯',NORMAL_COLORFUL:'正常 · 炫彩灯'},eventNames={FAN_LEVEL:'风扇档位变化',START:'程序启动',STOP:'程序停止',ALERT_ACTIVE:'告警出现',ALERT_RECOVERED:'告警恢复',DISPLAY_OWNER:'显示对象切换',INTERNET_DOWN:'互联网断开',INTERNET_RECOVERED:'互联网恢复',NET_SWITCH:'网络接口切换',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:'监控对象恢复'};
function ownerName(id){const [type,suffix]=String(id||'').split('|',2),name=ownerNames[type]||type;return tr(name)+(suffix?` · ${suffix}`:'')}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])}${LANG==='zh'?'闪烁':' flash'}`}function eventName(id){return tr(eventNames[id]||(LANG==='zh'?`未知事件(${id})`:String(id).toLowerCase().replaceAll('_',' ')))}
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>`}
function rgbGroup(values,prefix,title){const r=Number(values[`${prefix}_r`]||0),g=Number(values[`${prefix}_g`]||0),b=Number(values[`${prefix}_b`]||0),hex='#'+[r,g,b].map(x=>Math.max(0,Math.min(255,x)).toString(16).padStart(2,'0')).join('');return`<div class="group"><div class="group-head"><h3>${tr(title)}</h3><span class="muted">${tr('点击色块打开色盘')}</span></div><div class="rgb-row"><div class="color-field"><label>${tr('颜色')}</label><div class="swatch-wrap"><span class="swatch" data-swatch="${prefix}" style="background:${hex}"></span><input class="color-picker" type="color" data-color-prefix="${prefix}" value="${hex}" title="${LANG==='zh'?'选择':'Choose ' }${esc(tr(title))}"></div></div>${['r','g','b'].map(c=>field('led',`${prefix}_${c}`,values[`${prefix}_${c}`])).join('')}</div></div>`}
function normalGroup(v){return`<div class="group"><div class="group-head"><div><h3>正常运行灯效</h3><div class="hint">没有告警时显示;告警出现后自动让位。</div></div></div><div class="fields">${field('led','normal_mode',v.normal_mode,'span2')}<div class="normal-field span2" data-modes="solid flash">${rgbGroup(v,'normal','自定义颜色')}</div><div class="normal-field" data-modes="flash">${field('led','normal_flash_on_ms',v.normal_flash_on_ms)}</div><div class="normal-field" data-modes="flash">${field('led','normal_flash_off_ms',v.normal_flash_off_ms)}</div><div class="normal-field" data-modes="flow breathe marquee rainbow colorful">${field('led','normal_effect_speed',v.normal_effect_speed)}</div><div class="normal-field" data-modes="flow breathe">${field('led','normal_effect_color',v.normal_effect_color)}</div></div></div>`}
function renderLed(v){const flash=['warning','critical','emergency'].map((x,i)=>`<div class="group"><h3>${tr(['普通告警闪烁节奏','严重告警闪烁节奏','紧急告警闪烁节奏'][i])}</h3><div class="fields">${field('led',`${x}_flash_on_ms`,v[`${x}_flash_on_ms`])}${field('led',`${x}_flash_off_ms`,v[`${x}_flash_off_ms`])}</div></div>`).join(''),advanced=v.write_delay_ms===undefined?'':`<div class="group"><h3>硬件高级参数</h3><div class="fields">${field('led','write_delay_ms',v.write_delay_ms)}</div></div>`;return normalGroup(v)+`<div class="group"><div class="group-head"><div><h3>告警对象颜色</h3><div class="hint">告警对象决定颜色,严重程度决定闪烁速度。</div></div></div>${alertColorGroups.map(([p,t])=>rgbGroup(v,p,tr(t))).join('')}</div>`+flash+advanced}
function renderGeneral(s,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>`}
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"><button id="saveMonitor">保存监控</button> <button id="resetMonitor">清空</button> <span id="serviceMessage" class="muted"></span></div></div></div>`}
function renderHosts(v){const entries=Object.entries(v||{});return`<div class="group"><h3>已登记机器</h3><div class="rows">${entries.map(([id,url])=>`<div class="row"><span><b>${esc(id)}</b><br><span class="muted">${esc(url)}</span></span><button class="danger" data-delete-host="${esc(id)}">删除</button></div>`).join('')||'<div class="muted">尚未登记其他机器</div>'}</div></div><div class="group"><h3>添加或更新机器</h3><div class="service-form"><label>机器标识<input id="hostId" maxlength="64" placeholder="例如:living-room"></label><label>节点地址<input id="hostUrl" maxlength="200" placeholder="例如:http://192.168.1.20:6001"></label><div class="full"><button id="saveHost">保存机器</button> <span id="hostMessage" class="muted"></span></div></div></div>`}
function renderBulkTargets(){const targets=fleetHosts.filter(h=>h.id!==currentConfigHost);return`<div class="group"><h3>${tr('目标机器')}</h3><div class="hint">${tr('机器标识和机器列表不会参与同步。')}</div><div class="rows">${targets.map(h=>`<label class="bulk-target"><input type="checkbox" data-bulk-target="${esc(h.id)}"><b>${esc(h.name||h.id)}</b><input type="password" data-bulk-token="${esc(h.id)}" value="${esc(hostToken(h.id))}" placeholder="${tr('目标机器令牌')}"></label>`).join('')||`<div class="muted">${tr('尚未登记其他机器')}</div>`}</div></div>`}
function bulkValue(section,key,value){const item=options[`${section}.${key}`]?.find(([v])=>v===String(value));return item?tr(item[1]):value}
function renderBulkSync(){const groups=bulkSections.filter(s=>original[s]&&Object.keys(original[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(original[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(fleetHosts.find(h=>h.id===currentConfigHost)?.name||currentConfigHost)} · ${esc(currentConfigHost)}</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>`}
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=LANG==='zh'?`已选择 ${n} 项`:`${n} settings selected`}
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()};document.querySelectorAll('[data-bulk-token]').forEach(x=>x.onchange=()=>saveHostToken(x.dataset.bulkToken,x.value.trim()));$('bulkApply').onclick=applyBulkConfig;updateBulkSelection()}
function updateConditional(){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()}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})}
function renderConfig(){const ss=[...sectionOrder.filter(s=>original[s]),'bulk_sync'];$('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==='led'?renderLed(original[s]):s==='services'?renderServices():s==='hosts'?renderHosts(original[s]):renderGeneral(s,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',b.dataset.configSection==='bulk_sync');if(b.dataset.configSection==='services')loadServices()};$('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]'))markChanged()};$('refreshServices')?.addEventListener('click',loadServices);$('serviceSearch')?.addEventListener('input',renderServiceLists);$('saveMonitor')?.addEventListener('click',saveMonitor);$('resetMonitor')?.addEventListener('click',resetMonitorForm);$('saveHost')?.addEventListener('click',saveHost);document.querySelectorAll('[data-delete-host]').forEach(b=>b.onclick=()=>deleteHost(b.dataset.deleteHost));bindBulkSync();updateConditional()}
function markChanged(){let n=0;document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(i.value!==original[i.dataset.section][i.dataset.key])n++});$('saveState').textContent=n?(LANG==='zh'?`有 ${n} 项尚未保存`:`${n} unsaved changes`):tr('尚未修改');$('saveState').className=n?'':'muted'}
tabs.forEach(b=>b.onclick=()=>{tabs.forEach(x=>x.classList.toggle('active',x===b));['overview','logs','config'].forEach(id=>$(id).classList.toggle('hidden',id!==b.dataset.tab));if(b.dataset.tab==='logs')loadLogs();if(b.dataset.tab==='config')loadConfig()});async function get(url,opt){const r=await fetch(url,opt),text=await r.text();let j;try{j=JSON.parse(text)}catch{throw Error(LANG==='zh'?`服务器返回异常 (${r.status})`:`Invalid server response (${r.status})`)}if(!r.ok)throw Error(j.error||(LANG==='zh'?`请求失败 (${r.status})`:`Request failed (${r.status})`));return j}function rows(target,items,empty='暂无数据'){$(target).innerHTML=items.length?items.join(''):`<div class="muted">${empty}</div>`}
let serviceCache=[],editingServiceName='',serviceReadOnly=false;function tokenHeaders(){const token=$('token').value.trim();localStorage.setItem('pigwayToken',token);return{'Content-Type':'application/json','Authorization':`Bearer ${token}`}}
async function loadServices(){if(!$('systemServices'))return;try{serviceReadOnly=!fleetHosts.find(x=>x.id===currentConfigHost)?.local;const d=await get(`/api/v1/services?host=${encodeURIComponent(currentConfigHost)}`);serviceCache=d.services;renderServiceLists()}catch(e){$('systemServices').innerHTML=`<div class="error">${esc(e.message)}</div>`}}
function renderServiceLists(){if(!$('systemServices'))return;const q=$('serviceSearch').value.trim().toLowerCase(),filtered=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=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(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))}
function selectService(unit){const s=serviceCache.find(x=>x.unit===unit);if(!s)return;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'})}
function resetMonitorForm(){editingServiceName='';$('monitorName').value='';$('monitorTarget').value='';$('monitorNote').value='';$('serviceFormTitle').textContent='新建服务监控';$('serviceMessage').textContent='';$('serviceMessage').className='muted'}
async function saveMonitor(){const body={operation:'save',name:$('monitorName').value.trim(),target:$('monitorTarget').value.trim(),note:$('monitorNote').value.trim(),previous_name: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'}}
async function deleteMonitor(unit){const s=serviceCache.find(x=>x.unit===unit);if(!s||!confirm(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({operation:'delete',name:s.monitor_name,target:s.unit,note:''})});await loadConfig();document.querySelector('[data-config-section="services"]')?.click()}catch(e){alert(e.message)}}
async function monitorAction(unit,action){const s=serviceCache.find(x=>x.unit===unit);if(!s)return;try{await get('/api/v1/services',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify({operation:action,name:s.monitor_name,target:s.unit,note:s.note||''})});await loadServices()}catch(e){alert(e.message)}}
async function serviceAction(unit,action){if(!confirm(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({unit,action})});setTimeout(loadServices,700)}catch(e){alert(e.message)}}
function bytes(value){const n=Number(value||0);if(!n)return'--';const units=['B','KB','MB','GB','TB'];let x=n,i=0;while(x>=1024&&i<units.length-1){x/=1024;i++}return`${x>=100||i===0?x.toFixed(0):x.toFixed(1)} ${units[i]}`}
function meter(percent){const p=Math.max(0,Math.min(100,Number(percent||0)));return`<div class="progress"><span style="width:${p}%"></span></div>`}
function hostCard(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><h2>${esc(m.name||h.name||m.id)}</h2><div class="device-details"><span>ID ${esc(m.id||h.id)}</span><span>${LANG==='zh'?'主机名':'Hostname'} ${esc(m.hostname||v.hostname||'--')}</span><span>${esc(v.model||'--')}</span><span>${LANG==='zh'?'序列号':'Serial'} ${esc(v.serial||'--')}</span><span>${LANG==='zh'?'硬件版本':'Revision'} ${esc(v.revision||'--')}</span><span>${esc(v.os||'--')}</span><span>${LANG==='zh'?'内核':'Kernel'} ${esc(v.kernel||'--')}</span><span>${esc(v.architecture||'--')} · CPU ${esc(v.cpu_count||'--')}</span><span>${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><div class="muted">${LANG==='zh'?'频率':'Frequency'} ${esc(y.cpu_freq_mhz)} MHz · ${esc(y.temperature_c)}°C · ${LANG==='zh'?'风扇':'Fan'} ${esc(y.fan_name)}</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)} · ${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)} · ${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 · `:''}${esc(n.ip_label)} ${esc(n.ip)} · ${n.internet?(LANG==='zh'?'互联网正常':'Internet online'):(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">OLED: ${esc(ownerName(d.oled_page))} · RGB: ${esc(rgbName(d.rgb_mode))}</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><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>`}
function renderHostCards(entries){const root=$('hostCards'),seen=new Set;entries.forEach((entry,index)=>{const id=String(entry.host.id),template=document.createElement('template');template.innerHTML=hostCard(entry);const next=template.content.firstElementChild;next.dataset.hostId=id;let current=[...root.children].find(x=>x.dataset.hostId===id);if(current){current.className=next.className;current.innerHTML=next.innerHTML;if(root.children[index]!==current)root.insertBefore(current,root.children[index]||null)}else root.insertBefore(next,root.children[index]||null);seen.add(id)});[...root.children].forEach(x=>{if(!seen.has(x.dataset.hostId))x.remove()})}
async function loadStatus(){try{const fleet=await get('/api/v1/fleet/status'),online=fleet.hosts.filter(x=>x.online);$('dot').classList.toggle('ok',online.length>0);$('liveText').textContent=LANG==='zh'?`${online.length}/${fleet.hosts.length} 在线`:`${online.length}/${fleet.hosts.length} online`;$('identity').textContent=LANG==='zh'?`多机监控 · ${fleet.hosts.length} 台设备 · ${new Date(fleet.timestamp*1000).toLocaleTimeString()}`:`Fleet monitoring · ${fleet.hosts.length} hosts · ${new Date(fleet.timestamp*1000).toLocaleTimeString()}`;renderHostCards(fleet.hosts);fleetHosts=fleet.hosts.map(x=>x.host);refreshHostSelectors()}catch(e){$('dot').classList.remove('ok');$('liveText').textContent=tr('离线');$('identity').textContent=e.message}}
const severityNames={DEBUG:'调试',INFO:'信息',NOTICE:'注意',WARN:'警告',ERROR:'错误',CRITICAL:'严重',ALERT:'紧急',EMERGENCY:'紧急'};let logState={sort:'timestamp',order:'desc',offset:0,limit:100,total:0};
function localDateValue(d){return`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`}
function setLogRange(range){const now=new Date(),today=new Date(now.getFullYear(),now.getMonth(),now.getDate()),start=new Date(today),end=new Date(today);if(range==='all'){$('logStart').value='';$('logEnd').value='';return}if(range==='yesterday'){start.setDate(start.getDate()-1);end.setDate(end.getDate()-1)}else if(range==='week'){start.setDate(start.getDate()-((start.getDay()+6)%7))}else if(range==='month'){start.setDate(1)}$('logStart').value=localDateValue(start);$('logEnd').value=localDateValue(end)}
function logTimestamp(value,end=false){if(!value)return'';const d=new Date(`${value}T00:00:00`);if(end)d.setDate(d.getDate()+1);return Math.floor(d.getTime()/1000)-(end?1:0)}
async function loadLogs(reset=true){if(reset)logState.offset=0;try{const p=new URLSearchParams({limit:logState.limit,offset:logState.offset,sort:logState.sort,order:logState.order}),since=logTimestamp($('logStart').value),until=logTimestamp($('logEnd').value,true),severity=$('logSeverity').value,event=$('logEvent').value,machine=$('logMachine').value,search=$('logSearch').value.trim();if(since)p.set('since',since);if(until)p.set('until',until);if(severity)p.set('severity',severity);if(event)p.set('event',event);if(machine)p.set('machine',machine);if(search)p.set('search',search);const d=await get(`/api/v1/fleet/logs?${p}`);logState.total=d.total;const selectedEvent=$('logEvent').value,selectedMachine=$('logMachine').value;$('logEvent').innerHTML=`<option value="">${tr('全部事件')}</option>`+d.event_types.map(x=>`<option value="${esc(x)}" ${x===selectedEvent?'selected':''}>${esc(eventName(x))}</option>`).join('');$('logMachine').innerHTML=`<option value="">${LANG==='zh'?'全部机器':'All hosts'}</option>`+d.machines.map(x=>`<option value="${esc(x.id)}" ${x.id===selectedMachine?'selected':''}>${esc(x.name)} · ${esc(x.id)}</option>`).join('');$('logRows').innerHTML=d.logs.length?d.logs.map(x=>`<tr><td>${esc(new Date(x.timestamp/1000).toLocaleString())}</td><td>${esc(x.machine_name||x.machine_id)}</td><td><span class="badge ${['ERROR','CRITICAL','ALERT','EMERGENCY'].includes(x.severity)?'bad':''}">${esc(tr(severityNames[x.severity]||x.severity))}</span></td><td>${esc(eventName(x.event))}</td><td class="log-message">${esc(x.message)}</td></tr>`).join(''):`<tr><td colspan="5" class="muted">${tr('没有符合条件的日志')}</td></tr>`;$('logSummary').textContent=LANG==='zh'?`共 ${d.total} 条 · 数据来源:各机器 systemd journal`:`${d.total} events · Source: each host's systemd journal`;$('logPage').textContent=d.total?`${d.offset+1}–${Math.min(d.offset+d.limit,d.total)} / ${d.total}`:(LANG==='zh'?'0 条':'0 events');$('logPrev').disabled=d.offset===0;$('logNext').disabled=d.offset+d.limit>=d.total;document.querySelectorAll('[data-log-sort]').forEach(b=>{const names={timestamp:LANG==='zh'?'时间':'Time',machine:LANG==='zh'?'机器':'Host',severity:LANG==='zh'?'程度':'Severity',event:LANG==='zh'?'事件类型':'Event type',message:LANG==='zh'?'内容':'Message'},name=names[b.dataset.logSort];b.textContent=name+(b.dataset.logSort===logState.sort?(logState.order==='asc'?' ↑':' ↓'):'')})}catch(e){$('logRows').innerHTML=`<tr><td colspan="5" class="error">${esc(e.message)}</td></tr>`}}
function hostToken(id){return localStorage.getItem(`pigwayToken:${id}`)||localStorage.getItem('pigwayToken')||''}
function saveHostToken(id,value){localStorage.setItem(`pigwayToken:${id}`,value);if(fleetHosts.find(x=>x.id===id)?.local)localStorage.setItem('pigwayToken',value)}
function refreshHostSelectors(){if(!fleetHosts.length)return;const current=$('configHost').value||currentConfigHost||fleetHosts[0].id;$('configHost').innerHTML=fleetHosts.map(h=>`<option value="${esc(h.id)}" ${h.id===current?'selected':''}>${esc(h.name||h.id)} · ${esc(h.id)}</option>`).join('');currentConfigHost=$('configHost').value}
function changedUpdates(){const updates={};document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(original[i.dataset.section]&&i.value!==original[i.dataset.section][i.dataset.key]){(updates[i.dataset.section]??={})[i.dataset.key]=i.value}});return updates}
async function ensureHosts(){if(fleetHosts.length)return;const d=await get('/api/v1/hosts');fleetHosts=d.hosts;refreshHostSelectors()}
async function loadConfig(){try{await ensureHosts();currentConfigHost=$('configHost').value||currentConfigHost||fleetHosts[0].id;$('token').value=hostToken(currentConfigHost);const d=await get(`/api/v1/config?host=${encodeURIComponent(currentConfigHost)}`);original=d.config;renderConfig();markChanged()}catch(e){$('configRows').innerHTML=`<div class="error">${esc(e.message)}</div>`}}
async function saveHost(){const id=$('hostId').value.trim(),url=$('hostUrl').value.trim(),message=$('hostMessage');try{message.textContent='正在保存…';const local=fleetHosts.find(x=>x.local),token=hostToken(local?.id||'');const d=await get('/api/v1/hosts',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({operation:'save',id,url})});fleetHosts=d.hosts;refreshHostSelectors();await loadConfig();message.textContent='保存成功'}catch(e){message.textContent=e.message;message.className='error'}}
async function deleteHost(id){if(!confirm(LANG==='zh'?`确定删除机器 ${id} 吗?`:`Delete host ${id}?`))return;try{const local=fleetHosts.find(x=>x.local),token=hostToken(local?.id||'');const d=await get('/api/v1/hosts',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({operation:'delete',id})});fleetHosts=d.hosts;currentConfigHost=local?.id||'';refreshHostSelectors();await loadConfig()}catch(e){alert(e.message)}}
async function applyConfig(){const updates=changedUpdates();if(!Object.keys(updates).length){$('saveState').textContent=tr('没有需要保存的修改');return}const local=fleetHosts.find(x=>x.local),target=currentConfigHost;try{$('saveConfig').disabled=true;$('saveState').textContent=tr('正在保存并应用…');saveHostToken(target,$('token').value.trim());const d=await get('/api/v1/fleet/config',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${hostToken(local?.id||'')}`},body:JSON.stringify({targets:[target],updates,tokens:{[target]:hostToken(target)}})}),failed=d.results.filter(x=>!x.ok);$('saveState').textContent=failed.length?failed[0].error:(LANG==='zh'?'保存成功':'Saved');$('saveState').className=failed.length?'error':'success';if(!failed.length)setTimeout(loadConfig,1200)}catch(e){$('saveState').textContent=e.message;$('saveState').className='error'}finally{$('saveConfig').disabled=false}}
async function applyBulkConfig(){const state=$('bulkState'),button=$('bulkApply'),targets=[...document.querySelectorAll('[data-bulk-target]:checked')].map(x=>x.dataset.bulkTarget),selected=[...document.querySelectorAll('[data-bulk-item]:checked')].map(x=>x.dataset.bulkItem);if(!targets.length){state.textContent=tr('请选择至少一台目标机器');state.className='error';return}if(!selected.length){state.textContent=tr('请选择至少一个配置项');state.className='error';return}const updates={};selected.forEach(path=>{const [section,key]=path.split('.',2);(updates[section]??={})[key]=original[section][key]});const local=fleetHosts.find(x=>x.local),tokens={};document.querySelectorAll('[data-bulk-token]').forEach(x=>{saveHostToken(x.dataset.bulkToken,x.value.trim());tokens[x.dataset.bulkToken]=x.value.trim()});try{button.disabled=true;state.textContent=tr('正在保存并应用…');const d=await get('/api/v1/fleet/config',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${hostToken(local?.id||'')}`},body:JSON.stringify({targets,updates,tokens})}),failed=d.results.filter(x=>!x.ok);state.textContent=failed.length?(LANG==='zh'?`部分失败:${failed.map(x=>`${x.id}: ${x.error}`).join(';')}`:`Partial failure: ${failed.map(x=>`${x.id}: ${x.error}`).join('; ')}`):(LANG==='zh'?`已同步 ${selected.length} 项配置到 ${targets.length} 台机器`:`Synced ${selected.length} settings to ${targets.length} hosts`);state.className=failed.length?'error':'success'}catch(e){state.textContent=e.message;state.className='error'}finally{button.disabled=false}}
document.querySelectorAll('[data-log-range]').forEach(b=>b.onclick=()=>{setLogRange(b.dataset.logRange);loadLogs()});document.querySelectorAll('[data-log-sort]').forEach(b=>b.onclick=()=>{const value=b.dataset.logSort;if(logState.sort===value)logState.order=logState.order==='asc'?'desc':'asc';else{logState.sort=value;logState.order=value==='timestamp'?'desc':'asc'}loadLogs()});$('refreshLogs').onclick=()=>loadLogs();$('resetLogs').onclick=()=>{setLogRange('today');$('logSeverity').value='';$('logMachine').value='';$('logEvent').value='';$('logSearch').value='';logState.sort='timestamp';logState.order='desc';loadLogs()};$('logPrev').onclick=()=>{logState.offset=Math.max(0,logState.offset-logState.limit);loadLogs(false)};$('logNext').onclick=()=>{if(logState.offset+logState.limit<logState.total){logState.offset+=logState.limit;loadLogs(false)}};
const language=$('language'),theme=$('theme'),themeMedia=matchMedia('(prefers-color-scheme: dark)');language.value=LANG;theme.value=localStorage.getItem('pigwayTheme')||'auto';applyTheme(theme.value);language.onchange=()=>{LANG=language.value;localStorage.setItem('pigwayLanguage',LANG);location.reload()};theme.onchange=()=>{localStorage.setItem('pigwayTheme',theme.value);applyTheme(theme.value)};themeMedia.addEventListener?.('change',()=>{if(theme.value==='auto')applyTheme('auto')});translateNode();new MutationObserver(ms=>ms.forEach(m=>m.addedNodes.forEach(n=>{if(n.nodeType===Node.ELEMENT_NODE)translateNode(n);else if(n.nodeType===Node.TEXT_NODE){const map=LANG==='en'?EN:ZH,trim=n.nodeValue.trim();if(map[trim])n.nodeValue=n.nodeValue.replace(trim,map[trim])}}))).observe(document.body,{childList:true,subtree:true});document.documentElement.lang=LANG==='zh'?'zh-CN':'en';setLogRange('today');$('toggleToken').onclick=()=>{const i=$('token'),show=i.type==='password';i.type=show?'text':'password';$('toggleToken').textContent=tr(show?'隐藏令牌':'显示令牌')};$('token').onchange=()=>saveHostToken(currentConfigHost,$('token').value.trim());$('configHost').onchange=()=>{currentConfigHost=$('configHost').value;$('token').value=hostToken(currentConfigHost);loadConfig()};$('saveConfig').onclick=applyConfig;loadStatus();setInterval(loadStatus,3000);
</script></body></html>