@@ -4,37 +4,38 @@
< 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;--line:#263546;--text:#e7edf5;--muted:#91a4b8;--good:#50d890;--warn:#ffbd4a;-- 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 6 0px}header{display:flex;justify-content:space-between;align-items:end;gap:18px;margin-bottom:22px}h1{font-size:27px;margin:0}h2{font-size:17 px;margin:0 0 14px }.sub,.muted{color:var(--muted)}.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}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}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.card,.panel{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}.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;gap:16px;padding:8px 0;border-bottom:1px solid #203040}.row:last-child{border:0}.badge{border-radius:999px;padding:3px 8px;background:#243447}.badge.good{color:var(--good)}.badge.bad{color:var(--bad)}.hidden{display:none}.log{font:12px/1.5 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;padding:8px 0;border-bottom:1px solid #203040}.config-section{margin :10 px 0;border:1px solid var(--line);border-radiu s:10px;padding :12 px}.config-section summary{cursor:pointer;font-weight:700 }.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top: 12px}.field label{display:block;color:var(--muted);font-size:12px}.field input{width:100%;margin-top:4 px;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:8px}.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}.error{color:var(--bad)}@media(max-width:800px){.grid{grid-template-columns:repeat(2,1fr)}.fields{grid-template-columns:1fr}.wide{grid-column:span 2 }}@media(max-width:470px){.grid{grid-template-columns:1fr}.wide{grid-column:span 1}header{align-items:start;flex-direction:column}}
: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 7 0px}header{display:flex;justify-content:space-between;align-items:end;gap:18px;margin-bottom:22px}h1{font-size:27px;margin:0}h2{font-size:18 px;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}.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;gap:16px;padding:8px 0;border-bottom:1px solid #203040}.row:last-child{border:0}.badge{border-radius:999px;padding:3px 8px;background:#243447}.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 :12 px system-ui;color:var(--blue);margin-right:8px}.config-shell{display:grid;grid-template-column s:2 10px minmax(0,1fr);gap:14px;margin-top :14 px}.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 {width:100%;margin-top:5 px;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:9px}.field input:focus,.field select:focus{outline:1px solid var(--blue);border-color:var(--blue)}.span2{grid-column:span 2}.rgb-row{display:grid;grid-template-columns:48px repeat(3,1fr);gap:9px;align-items:end}.swatch{width:38px;height:38px;border-radius:50%;border:2px solid #fff3;box-shadow:0 0 12px #0008;margin-bottom:2px}.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;box-shadow:0 8px 30px #0008}.error{color:var(--bad)}.success{color:var(--good)}@media(max-width:800px){.grid{grid-template-columns:repeat(2,1fr)}.fields{grid-template-columns:1fr}.span2{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:38px 1fr}.rgb-row .field{grid-column:2}.swatch{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} }
< / style >
< / head >
< body > < main >
< header > < div > < h1 > PIGWay Pi Control< / h1 > < div class = "sub" id = "identity" > 正在连接 Raspberry Pi …< / div > < / div > < div class = "live" > < span class = "dot" id = "dot" > < / span > < span id = "liveText" > 离线< / span > < / 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" > CPU < / 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" > < div class = "toolbar" > < button id = "refreshLogs" > 刷新日志< / button > < span class = "muted" > 最近200条 Agent journal< / span > < / div > < div id = "logRows" > < / div > < / div > < / section >
< section id = "config" class = "hidden" > < div class = "panel" > < h2 > 用户配置< / h2 > < p class = "muted" > 保存前请输入安装时生成的 API token。修改会备份配置并重启硬件 Agent;API 端口变化会自动重启本服务。< / p > < div class = "toolbar" > < input id = "token" type = "password" placeholder = "Bearer token" > < button id = "saveConfig" > 保存并应用< / button > < span id = "saveState" > < / span > < / div > < div id = "configRows" > < / div > < / div > < / section >
< / main >
< script >
const $= id=>document.getElementById(id), tabs=[...document.querySelectorAll('nav button')];let original={};
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'& ','< ':'< ','>':'> ','"':'" ',"'":'' '}[c]));
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);const j=await r.json();if(!r.ok)throw Error(j.error||r.statusText);return j }
function rows(target,items,empty='暂无数据'){$(target).innerHTML=items.length?items .join(''):` < div class = "muted" > ${empty} < / div > `}
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='在线';$('identity').textContent=`v${s.version} · 运行 ${Math.floor(s.uptime_seconds/3600)} 小时 · ${new Date(s.timestamp*1000).toLocaleTimeString()}`;$('cpu').textContent=`${y.cpu_percent}%`;$('freq').textContent=`${y.cpu_freq_mhz} MHz`;$('temp').textContent=`${y.temperature_c}°C`;$('fan').textContent=`风扇 ${y.fan_name}`;$('memory').textContent=`${y.memory_percent}%`;$('disk').textContent=`${y.disk_percent}%`;$('network').textContent=`${n.kind} ${n.metric}`;$('address').textContent=`${n.interface} · ${n.ip_label} ${n.ip} · Internet ${n.internet?'OK':'DOWN'}`;$('owner').textContent=d.owner;$('rgb').textContent=`OLED ${d.oled_page} · RGB ${d.rgb_mode}`;rows('alerts',d.alerts.map(a=>` < div class = "row" > < span > < b > ${esc(a.title)} < / b > < br > < span class = "muted" > ${esc(a.l2)} ${esc(a.l3)} < / span > < / span > < span class = "badge bad" > S${a.severity} · P${a.priority} < / span > < / div > `),'无告警');rows('watches',s.watches.map(w=>` < div class = "row" > < span > < b > ${esc(w.name)} < / b > < br > < span class = "muted" > ${esc(w.type)} · ${esc(w.target)} < / span > < / span > < span class = "badge ${w.up?'good':'bad'}" > ${w.up?'UP':'DOWN'} < / span > < / div > `))}catch(e){$('dot').classList.remove('ok');$('liveText').textContent='离线';$('identity').textContent=e.message} }
async function loadLogs(){try{const d=await get('/api/v1/logs?limit=200');rows('logRows',d.logs.reverse().map(x =>`< div class = "log" > ${esc(x.message) }< / div > `))}catch(e){$('lo gRows').innerHTML=`< div class = "error" > ${esc(e.message )}< / div > `} }
async function loadConfig(){try{const d=await get('/api/v1/config');original=d.config;$ ('configRows').innerHTML=Object.entries(original).map(([section,values])=>` < details class = "config-section" > < summary > [${esc(section)}] < / summary > < div class = "fields" > ${Object.entries(values).map(([k,v])=>` < div class = "field" > < label > ${esc(k)} < input data-section = "${esc(section)}" data-key = "${esc(k)}" value = "${esc(v)}" > < / label > < / div > `).join('')} < / div > < / details > `).join('')}catch(e){$('configRows').innerHTML=` < div class = "error" > ${esc(e.message)} < / div > `} }
$('refreshLogs').onclick=loadLogs;$('token').value=localStorage. getI tem('pigwayToken')||'';
$('saveConfig').onclick=async()=>{const token=$('token').value.trim();localStorage.setItem('pigwayToken',token);const updates={};document.querySelectorAll('#configRows in put ').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='没有变化';return}try{$('saveState').textContent='正在应用…';const d=await get('/api/v1/config',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({updates})});$('saveState').textContent=`已保存,备份 ${d.backup}`;setTimeout(loadConfig,1500)}catch(e){$('saveState').textContent=e.message;$('saveState').className='error' }};
loadStatus();setInterval(loadStatus,3000);
< header > < div > < h1 > PIGWay Pi Control< / h1 > < div class = "sub" id = "identity" > 正在连接树莓派 …< / div > < / div > < div class = "live" > < span class = "dot" id = "dot" > < / span > < span id = "liveText" > 离线< / span > < / 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 " > < div class = "toolbar" > < button id = "refreshLogs "> 刷新日志 < / button > < span class = "muted" > 最近 200 条硬件 Agent 事件 < / span > < / div > < div id = "logRows "> < / 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 $=id=>document.getElementById(id),tabs=[...document.querySelectorAll('nav button')];let original={};const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'& ','< ':'< ','>':'> ','"':'" ',"'":'' '}[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':'选择“关闭”时由 PIGWay 熄灭板载灯。','dark_mode.pi_act_led':'选择“关闭”时由 PIGWay 熄灭板载灯。'};
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 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:'显示对象切换',RGB_MODE:'RGB 模式切换',INTERNET_DOWN:'互联网断开',INTERNET_RECOVERED:'互联网恢复',NETWORK_DOWN:'网络断开',NETWORK_RECOVERED:'网络恢复',POWER_LOW:'供电欠压',POWER_RECOVERED:'供电恢复'};
function ownerName(id){const [type,suffix]=String(id||'').split('|',2);return(ownerNames[type]||type)+(suffix?` · ${suffix}`:'')}function rgbName(mode){if(rgbNames[mode])return rgbNames[mode];const p=String(mode||'').split('_'),groups={CPU:'处理器',POWER:'供电',MEMORY:'内存',STORAGE:'存储',NETWORK:'网络',SERVICE:'服务'},levels={WARNING:'普通告警',CRITICAL:'严重告警',EMERGENCY:'紧急告警'};return`${groups[p[0]]||p[0]} · ${levels[p[1]]||p[1]}闪烁`}
function field(section,key,value,extra=''){ const id=`${section}.${key}`,opts=options[id],rule=numberRules[id],label=labels[id]||key,help=hints[id]||'';const control=opts?` < select data-section = "${esc(section)}" data-key = "${esc(key)}" > ${opts.map(([v,l])=>` < option value = "${v}" $ { String ( value ) = = = v ? ' selected ' : ' ' } > ${l} < / option > `).join('')} < / select > `:` < 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)}" > `;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=values[`${prefix}_r`],g=values[`${prefix}_g`],b=values[`${prefix}_b`];return`< div class = "group" > < div class = "group-head" > < h3 > ${title}< / h3 > < span class = "muted" > RGB 自定义颜色< / span > < / div > < div class = "rgb-row" > < span class = "swatch" data-swatch = "${prefix}" style = "background:rgb(${r},${g},${b})" > < / span > ${['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 > ${['普通告警','严重告警','紧急告警'][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,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 > ${t} < / h3 > < div class = "hint" > ${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 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=>Number(document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`)?.value||0);s.style.background=`rgb(${read('r')},${read('g')},${read('b')})`}) }
function renderConfig(){const ss=sectionOrder.filter(s=>original[s]);$('configMenu').innerHTML=ss.map((s,i) =>`< button class = "${i===0?'active':''}" data-config-section = "${s}" > ${sectionMeta[s]?.[0]||s }< / button > `).join('');$('confi gRows').innerHTML=ss.map((s,i)=> `< section class = "config-page ${i===0?'active':''}" data-config-page = "${s}" > < h2 > ${sectionMeta[s]?.[0]||s} < / h2 > < p class = "muted" > ${sectionMeta[s]?.[1]||''} < / p > ${s==='led'?renderLed(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))};$('configRows').oninput=e=>{if(e.target.matches('[data-section="led"]'))updateConditional();markChanged()};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?`有 ${n} 项尚未保存`:'尚未修改';$('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(`服务器返回异常 (${r.status})`)}if(!r.ok)throw Error(j.error||`请求失败 (${r.status})`);return j}function rows(tar get,i tems,empty='暂无数据'){$(target).innerHTML=items.length?items.join(''):` < div class = "muted" > ${empty} < / div > `}
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='在线';$('identity').textContent=`版本 ${s.version} · 已运行 ${Math.floor(s.uptime_seconds/3600)} 小时 · ${new Date(s.timestamp*1000).toLocaleTimeString()}`;$('c pu').textContent=`${y.cpu_percent}%`;$('freq').textContent=`频率 ${y.cpu_freq_mhz} MHz`;$('temp').textContent=`${y.temperature_c}°C`;$('fan').textContent=`风扇 ${({OFF:'关闭',L1:'一档',L2:'二档',L3:'三档',L4:'四档',MAX:'全速'})[y.fan_name]||y.fan_name}`;$('memory').textContent=`${y.memory_percent}%`;$('disk').textContent=`${y.disk_percent}%`;$('network').textContent=`${n.kind==='WIF'?'无线网络':n.kind==='ETH'?'有线网络':n.kind} ${n.metric}`;$('address').textContent=`${n.interface} · ${n.ip_label==='IP4'?'IPv4':'IPv6'} ${n.ip} · 互联网${n.internet?'正常':'断开'}`;$('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" > 等级 ${a.severity} · 优先级 ${a.priority} < / span > < / div > `),'当前没有告警');rows('watches',s.watches.map(w=>` < div class = "row" > < span > < b > ${esc(w.name)} < / b > < br > < span class = "muted" > ${w.type==='service'?'系统服务':'普通进程'} · ${esc(w.target)} < / span > < / span > < span class = "badge ${w.up?'good':'bad'}" > ${w.up?'正常':'停止'} < / span > < / div > `))}catch(e){$('dot').classList.remove('ok');$('liveText').textContent='离线';$('identity').textContent=e.message }}
async function loadLogs(){try{const d=await get('/api/v1/logs?limit=200');rows('logRows',d.logs.reverse().map(x=>{const m=String(x.message).match(/event=([^ ]+)/),label=m?(eventNames[m[1]]||m[1]):'系统事件';return`< div class = "log" > < span class = "log-label" > ${esc(label)}< / span > ${esc(x.message)}< / div > `}))}catch(e){$('logRows').innerHTML=`< div class = "error" > ${esc(e.message)}< / div > `}}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 > `}}
$('refreshLogs').onclick=loadLogs;$('token').value=localStorage.getItem('pigwayToken')||'';$('toggleToken').onclick=()=>{const i=$('token'),show=i.type==='password';i.type=show?'text':'password';$('toggleToken').textContent=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='没有需要保存的修改';return}try{$('saveConfig').disabled=true;$('saveState').textContent='正在保存并应用…';const d=await get('/api/v1/config',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({updates})});$('saveState').textContent=`保存成功,已创建备份 ${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 >