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

84 lines
54 KiB
HTML
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.
<!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:0 12px 28px #0004}.group{background:var(--panel2);box-shadow:none;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{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;align-items:end}.log-filter label{color:var(--muted);font-size:12px}.log-filter input,.log-filter 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:wrap;margin:12px 0}.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:800px){.log-filter{grid-template-columns:1fr 1fr}}@media(max-width:470px){.log-filter{grid-template-columns:1fr}}
.header-actions{display:flex;align-items:end;gap:12px;flex-wrap:wrap;justify-content:flex-end}.preference{display:grid;gap:4px;color:var(--muted);font-size:12px}.preference select{border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:7px 28px 7px 9px}[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"] .card,[data-theme="light"] .panel,[data-theme="light"] .group{box-shadow:0 10px 24px #3452}[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 input,[data-theme="light"] .log-filter select,[data-theme="light"] .preference select{background:#fff}[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}}
</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"><span>颜色模式</span><select id="theme"><option value="auto">自动</option><option value="dark">深色</option><option value="light">浅色</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 class="grid">
<div class="card"><div class="muted">处理器负载</div><div class="value" id="cpu">--</div><div class="muted" id="freq">--</div></div><div class="card"><div class="muted">处理器温度</div><div class="value" id="temp">--</div><div class="muted" id="fan">--</div></div><div class="card"><div class="muted">内存占用</div><div class="value" id="memory">--</div></div><div class="card"><div class="muted">磁盘占用</div><div class="value" id="disk">--</div></div><div class="card wide"><div class="muted">主网络</div><div class="value" id="network">--</div><div class="muted" id="address">--</div></div><div class="card wide"><div class="muted">显示状态</div><div class="value" id="owner">--</div><div class="muted" id="rgb">--</div></div>
</div><div class="panel"><h2>当前告警</h2><div class="rows" id="alerts"><div class="muted">无告警</div></div></div><div class="panel"><h2>服务与进程监控</h2><div class="rows" id="watches"></div></div></section>
<section id="logs" class="hidden"><div class="panel"><h2>事件日志</h2><p class="muted">查询 systemd journal 中仍在保留的全部硬件 Agent 事件。</p><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><div class="log-filter"><label>开始日期<input id="logStart" type="date"></label><label>结束日期<input id="logEnd" type="date"></label><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="logEvent"><option value="">全部事件</option></select></label><label>关键词<input id="logSearch" placeholder="搜索日志内容"></label></div><div class="toolbar"><button id="refreshLogs">查询</button><button id="resetLogs">重置</button><span class="muted" id="logSummary"></span></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="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"><input id="token" type="password" placeholder="配置写入令牌(Bearer Token)"><button id="toggleToken">显示令牌</button></div><div class="config-shell"><aside class="config-menu" id="configMenu"></aside><div id="configRows"></div></div><div class="savebar"><button id="saveConfig">保存并应用</button> <span id="saveState" class="muted">尚未修改</span></div></div></section>
</main><script>
const LANG=localStorage.getItem('pigwayLanguage')||'zh';
const EN={
'语言':'Language','颜色模式':'Appearance','中文':'中文','自动':'Auto','深色':'Dark','浅色':'Light','正在连接树莓派…':'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','关键词':'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.','硬件校准项,通常保持 10 毫秒。':'Hardware calibration setting; 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.'
});
const tr=s=>LANG==='en'?(EN[s]||s):s;
function translateNode(root=document){if(LANG!=='en')return;const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);let n;while(n=walker.nextNode()){const raw=n.nodeValue,trim=raw.trim();if(trim&&EN[trim])n.nodeValue=raw.replace(trim,EN[trim])}root.querySelectorAll?.('[placeholder],[title]').forEach(e=>['placeholder','title'].forEach(a=>{const v=e.getAttribute(a);if(v&&EN[v])e.setAttribute(a,EN[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={};const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const sectionMeta={led:['灯光设置','正常灯效与告警颜色'],alerts:['告警阈值','处理器、内存、温度、磁盘和 Wi-Fi'],fan:['风扇温控','温度档位与降档回差'],timing:['刷新周期','传感器、屏幕、网络和健康检查'],display:['屏幕布局','OLED 四行文字位置'],dark_mode:['板载指示灯','树莓派电源灯与活动灯'],services:['服务监控','监控 systemd 服务'],processes:['进程监控','监控普通进程关键字'],api:['Web 与 API','监听地址、端口和日志数量']};
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':'默认日志条数'};
['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':'硬件校准项,通常保持 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=['led','alerts','fan','timing','display','dark_mode','services','processes','api'],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={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 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]);$('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==='led'?renderLed(original[s]):s==='services'?renderServices():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));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);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='';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{const d=await get('/api/v1/services');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>`),'尚未监控任何服务');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)}}
async function loadStatus(){try{const s=await get('/api/v1/status'),y=s.system,n=s.network,d=s.display;$('dot').classList.add('ok');$('liveText').textContent=tr('在线');$('identity').textContent=LANG==='zh'?`版本 ${s.version} · 已运行 ${Math.floor(s.uptime_seconds/3600)} 小时 · ${new Date(s.timestamp*1000).toLocaleTimeString()}`:`Version ${s.version} · Up ${Math.floor(s.uptime_seconds/3600)} hours · ${new Date(s.timestamp*1000).toLocaleTimeString()}`;$('cpu').textContent=`${y.cpu_percent}%`;$('freq').textContent=LANG==='zh'?`频率 ${y.cpu_freq_mhz} MHz`:`Frequency ${y.cpu_freq_mhz} MHz`;$('temp').textContent=`${y.temperature_c}°C`;const fanZh=({OFF:'关闭',L1:'一档',L2:'二档',L3:'三档',L4:'四档',MAX:'全速'})[y.fan_name]||y.fan_name,fanEn=({OFF:'Off',L1:'Level 1',L2:'Level 2',L3:'Level 3',L4:'Level 4',MAX:'Full speed'})[y.fan_name]||y.fan_name;$('fan').textContent=LANG==='zh'?`风扇 ${fanZh}`:`Fan ${fanEn}`;$('memory').textContent=`${y.memory_percent}%`;$('disk').textContent=`${y.disk_percent}%`;$('network').textContent=`${tr(n.kind==='WIF'?'无线网络':n.kind==='ETH'?'有线网络':n.kind)} ${n.metric}`;$('address').textContent=LANG==='zh'?`${n.interface} · ${n.ip_label==='IP4'?'IPv4':'IPv6'} ${n.ip} · 互联网${n.internet?'正常':'断开'}`:`${n.interface} · ${n.ip_label==='IP4'?'IPv4':'IPv6'} ${n.ip} · Internet ${n.internet?'online':'down'}`;$('owner').textContent=ownerName(d.owner);$('rgb').textContent=`OLED: ${ownerName(d.oled_page)} · RGB: ${rgbName(d.rgb_mode)}`;rows('alerts',d.alerts.map(a=>`<div class="row"><span><b>${esc(ownerName(a.id))}</b><br><span class="muted">${esc(a.l2)} ${esc(a.l3)}</span></span><span class="badge bad">${LANG==='zh'?`等级 ${a.severity} · 优先级 ${a.priority}`:`Severity ${a.severity} · Priority ${a.priority}`}</span></div>`),tr('当前没有告警'));rows('watches',s.watches.map(w=>`<div class="row"><span><b>${esc(w.name)}</b><br><span class="muted">${tr(w.type==='service'?'系统服务':'普通进程')} · ${esc(w.target)}</span></span><span class="badge ${w.up?'good':'bad'}">${tr(w.up?'正常':'停止')}</span></div>`))}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,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(search)p.set('search',search);const d=await get(`/api/v1/logs?${p}`);logState.total=d.total;const selected=$('logEvent').value;$('logEvent').innerHTML=`<option value="">${tr('全部事件')}</option>`+d.event_types.map(x=>`<option value="${esc(x)}" ${x===selected?'selected':''}>${esc(eventName(x))}</option>`).join('');$('logRows').innerHTML=d.logs.length?d.logs.map(x=>`<tr><td>${esc(new Date(x.timestamp/1000).toLocaleString())}</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="4" class="muted">${tr('没有符合条件的日志')}</td></tr>`;$('logSummary').textContent=LANG==='zh'?`共 ${d.total} 条 · 数据来源:systemd journal`:`${d.total} events · Source: 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 name=b.textContent.replace(/[ ↑↓]$/,'');b.textContent=name+(b.dataset.logSort===logState.sort?(logState.order==='asc'?' ↑':' ↓'):'')})}catch(e){$('logRows').innerHTML=`<tr><td colspan="4" class="error">${esc(e.message)}</td></tr>`}}
async function loadConfig(){try{const d=await get('/api/v1/config');original=d.config;renderConfig();markChanged()}catch(e){$('configRows').innerHTML=`<div class="error">${esc(e.message)}</div>`}}
document.querySelectorAll('[data-log-range]').forEach(b=>b.onclick=()=>{setLogRange(b.dataset.logRange);loadLogs()});document.querySelectorAll('[data-log-sort]').forEach(b=>b.onclick=()=>{const s=b.dataset.logSort;if(logState.sort===s)logState.order=logState.order==='asc'?'desc':'asc';else{logState.sort=s;logState.order=s==='timestamp'?'desc':'asc'}loadLogs()});$('refreshLogs').onclick=()=>loadLogs();$('resetLogs').onclick=()=>{setLogRange('today');$('logSeverity').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=()=>{localStorage.setItem('pigwayLanguage',language.value);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&&EN[n.nodeValue.trim()])n.nodeValue=n.nodeValue.replace(n.nodeValue.trim(),EN[n.nodeValue.trim()])}))).observe(document.body,{childList:true,subtree:true});document.documentElement.lang=LANG==='zh'?'zh-CN':'en';setLogRange('today');$('token').value=localStorage.getItem('pigwayToken')||'';$('toggleToken').onclick=()=>{const i=$('token'),show=i.type==='password';i.type=show?'text':'password';$('toggleToken').textContent=tr(show?'隐藏令牌':'显示令牌')};$('saveConfig').onclick=async()=>{const token=$('token').value.trim();localStorage.setItem('pigwayToken',token);const updates={};document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(i.value!==original[i.dataset.section][i.dataset.key]){(updates[i.dataset.section]??={})[i.dataset.key]=i.value}});if(!Object.keys(updates).length){$('saveState').textContent=tr('没有需要保存的修改');return}try{$('saveConfig').disabled=true;$('saveState').textContent=tr('正在保存并应用…');const d=await get('/api/v1/config',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({updates})});$('saveState').textContent=LANG==='zh'?`保存成功,已创建备份 ${d.backup}`:`Saved; backup created at ${d.backup}`;$('saveState').className='success';setTimeout(loadConfig,1500)}catch(e){$('saveState').textContent=e.message;$('saveState').className='error'}finally{$('saveConfig').disabled=false}};loadStatus();setInterval(loadStatus,3000);
</script></body></html>