2026-09-27 10:21:12 +08:00
<!doctype html>
< html lang = "zh-CN" >
< head >
< meta charset = "utf-8" >< meta name = "viewport" content = "width=device-width,initial-scale=1" >
2026-09-27 10:29:01 +08:00
< title > PIGWay Pi Control</ title >
2026-09-27 10:21:12 +08:00
< style >
2026-09-27 10:36:32 +08:00
: 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 : 14 px / 1.45 system-ui , - apple-system , sans-serif } main { max-width : 1180 px ; margin : auto ; padding : 28 px 20 px 60 px } header { display : flex ; justify-content : space-between ; align-items : end ; gap : 18 px ; margin-bottom : 22 px } h1 { font-size : 27 px ; margin : 0 } h2 { font-size : 17 px ; margin : 0 0 14 px }. sub , . muted { color : var ( -- muted )}. live { display : flex ; align-items : center ; gap : 8 px }. dot { width : 9 px ; height : 9 px ; border-radius : 50 % ; background : var ( -- bad )}. dot . ok { background : var ( -- good ); box-shadow : 0 0 12 px var ( -- good )} nav { display : flex ; gap : 8 px ; margin : 18 px 0 } button { border : 1 px solid var ( -- line ); background : #162230 ; color : var ( -- text ); border-radius : 8 px ; padding : 9 px 13 px ; cursor : pointer } button : hover , button . active { border-color : var ( -- blue ); background : #1b3047 }. grid { display : grid ; grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr )); gap : 12 px }. card , . panel { background : color-mix ( in srgb , var ( -- panel ) 94 % , transparent ); border : 1 px solid var ( -- line ); border-radius : 12 px ; padding : 16 px ; box-shadow : 0 12 px 28 px #0004 }. value { font-size : 23 px ; font-weight : 700 ; margin-top : 4 px }. wide { grid-column : span 2 }. panel { margin-top : 12 px }. rows { display : grid ; gap : 8 px }. row { display : flex ; justify-content : space-between ; gap : 16 px ; padding : 8 px 0 ; border-bottom : 1 px solid #203040 }. row : last-child { border : 0 }. badge { border-radius : 999 px ; padding : 3 px 8 px ; background : #243447 }. badge . good { color : var ( -- good )}. badge . bad { color : var ( -- bad )}. hidden { display : none }. log { font : 12 px / 1.5 ui-monospace , SFMono-Regular , monospace ; white-space : pre-wrap ; padding : 8 px 0 ; border-bottom : 1 px solid #203040 }. config-section { margin : 10 px 0 ; border : 1 px solid var ( -- line ); border-radius : 10 px ; padding : 12 px }. config-section summary { cursor : pointer ; font-weight : 700 }. fields { display : grid ; grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 10 px ; margin-top : 12 px }. field label { display : block ; color : var ( -- muted ); font-size : 12 px }. field input , . field select { width : 100 % ; margin-top : 4 px ; border : 1 px solid var ( -- line ); border-radius : 7 px ; background : #0d151e ; color : var ( -- text ); padding : 8 px }. hint { color : var ( -- muted ); font-size : 11 px ; margin-top : 4 px }. toolbar { display : flex ; gap : 10 px ; align-items : center ; flex-wrap : wrap ; margin : 12 px 0 }. toolbar input { min-width : 280 px ; flex : 1 ; border : 1 px solid var ( -- line ); border-radius : 7 px ; background : #0d151e ; color : var ( -- text ); padding : 9 px }. error { color : var ( -- bad )}@ media ( max-width : 800px ) {. grid { grid-template-columns : repeat ( 2 , 1 fr )}. fields { grid-template-columns : 1 fr }. wide { grid-column : span 2 }}@ media ( max-width : 470px ) {. grid { grid-template-columns : 1 fr }. wide { grid-column : span 1 } header { align-items : start ; flex-direction : column }}
2026-09-27 10:21:12 +08:00
</ style >
</ head >
< body >< main >
2026-09-27 10:29:01 +08:00
< 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 >
2026-09-27 10:21:12 +08:00
< 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 ]));
2026-09-27 10:36:32 +08:00
const configOptions = {
'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' , '白' ]]
};
const configHints = {
'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 ms。' , 'led.normal_flash_off_ms' : '闪烁灭灯时间,50..60000 ms。' ,
'led.normal_effect_speed' : 'MCU 内置效果速度。' , 'led.normal_effect_color' : '仅流水灯和呼吸灯使用;其他内置效果忽略。'
};
function configField ( section , key , value ){ const id = ` ${ section } . ${ key } ` , opts = configOptions [ id ], hint = configHints [ 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 } ( ${ v } )</option>` ). join ( '' ) } </select>` : `<input data-section=" ${ esc ( section ) } " data-key=" ${ esc ( key ) } " value=" ${ esc ( value ) } ">` ; return `<div class="field"><label> ${ esc ( key ) }${ control } </label> ${ hint ? `<div class="hint"> ${ esc ( hint ) } </div>` : '' } </div>` }
2026-09-27 10:21:12 +08:00
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 ){ $ ( 'logRows' ). innerHTML = `<div class="error"> ${ esc ( e . message ) } </div>` }}
2026-09-27 10:36:32 +08:00
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" ${ section === 'led' ? 'open' : '' } ><summary>[ ${ esc ( section ) } ]</summary><div class="fields"> ${ Object . entries ( values ). map (([ k , v ])=> configField ( section , k , v )). join ( '' ) } </div></details>` ). join ( '' )} catch ( e ){ $ ( 'configRows' ). innerHTML = `<div class="error"> ${ esc ( e . message ) } </div>` }}
2026-09-27 10:21:12 +08:00
$ ( 'refreshLogs' ). onclick = loadLogs ; $ ( 'token' ). value = localStorage . getItem ( 'pigwayToken' ) || '' ;
2026-09-27 10:36:32 +08:00
$ ( '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 { $ ( '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' }};
2026-09-27 10:21:12 +08:00
loadStatus (); setInterval ( loadStatus , 3000 );
</ script ></ body ></ html >