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 13:24:06 +08:00
: 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 : 14 px / 1.45 system-ui , - apple-system , sans-serif } main { max-width : 1180 px ; margin : auto ; padding : 28 px 20 px 70 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 : 18 px ; margin : 0 0 8 px } h3 { font-size : 15 px ; margin : 0 }. sub , . muted , . hint { color : var ( -- muted )}. hint { font-size : 12 px ; margin-top : 4 px }. 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 ; position : sticky ; top : 0 ; z-index : 5 ; padding : 8 px 0 ; background : #0b1118 e8 ; backdrop-filter : blur ( 8 px )} 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 } button : disabled { opacity : .45 ; cursor : not-allowed }. danger { color : #ff8e8e }. grid { display : grid ; grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr )); gap : 12 px }. card , . panel , . group { 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 }. group { background : var ( -- panel2 ); box-shadow : none ; margin-top : 12 px }. group-head { display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; margin-bottom : 12 px }. 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 ; align-items : center ; gap : 16 px ; padding : 8 px 0 ; border-bottom : 1 px solid #203040 }. row : last-child { border : 0 }. row-actions { display : flex ; gap : 6 px ; align-items : center ; flex-wrap : wrap ; justify-content : flex-end }. badge { border-radius : 999 px ; padding : 3 px 8 px ; background : #243447 ; white-space : nowrap }. badge . good { color : var ( -- good )}. badge . bad { color : var ( -- bad )}. hidden { display : none !important }. 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 }. log-label { font : 12 px system-ui ; color : var ( -- blue ); margin-right : 8 px }. config-shell { display : grid ; grid-template-columns : 210 px minmax ( 0 , 1 fr ); gap : 14 px ; margin-top : 14 px }. config-menu { display : flex ; flex-direction : column ; gap : 7 px ; align-self : start ; position : sticky ; top : 66 px }. 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 , 1 fr )); gap : 12 px }. field label { display : block ; color : var ( -- muted ); font-size : 12 px }. field input , . field select , . service-form input , . service-search { width : 100 % ; margin-top : 5 px ; border : 1 px solid var ( -- line ); border-radius : 7 px ; background : #0d151e ; color : var ( -- text ); padding : 9 px }. field input : focus , . field select : focus , . service-form input : focus , . service-search : focus { outline : 1 px solid var ( -- blue ); border-color : var ( -- blue )}. input-unit { position : relative }. input-unit input { padding-right : 62 px }. unit { position : absolute ; right : 10 px ; top : 50 % ; transform : translateY ( -50 % ); color : var ( -- muted ); font-size : 12 px ; pointer-events : none }. span2 { grid-column : span 2 }. rgb-row { display : grid ; grid-template-columns : 78 px repeat ( 3 , minmax ( 0 , 1 fr )); gap : 9 px ; 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 : 12 px ; text-align : center }. swatch-wrap { position : relative ; width : 42 px ; height : 42 px ; margin : 5 px auto 0 }. swatch { display : block ; width : 42 px ; height : 42 px ; border-radius : 50 % ; border : 2 px solid #fff5 ; box-shadow : 0 0 12 px #0008 ; cursor : pointer }. color-picker { position : absolute ; inset : 0 ; width : 42 px !important ; height : 42 px !important ; padding : 0 !important ; margin : 0 !important ; opacity : 0 ; cursor : pointer }. 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 ;
2026-09-27 10:21:12 +08:00
</ style >
</ head >
< body >< main >
2026-09-27 11:00:41 +08:00
< 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 熄灭板载灯。' };
2026-09-27 13:24:06 +08:00
[ 'normal' , 'cpu' , 'power' , 'memory' , 'storage' , 'network' , 'service' ]. forEach ( g =>[ 'r' , 'g' , 'b' ]. forEach ( c => hints [ `led. ${ g } _ ${ c } ` ] = '范围 0– 255。' ));
2026-09-27 11:00:41 +08:00
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 ]));
2026-09-27 11:20:45 +08:00
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' : '条' };
2026-09-27 11:00:41 +08:00
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 ] } 闪烁` }
2026-09-27 11:20:45 +08:00
function field ( section , key , value , extra = '' ){ const id = ` ${ section } . ${ key } ` , opts = options [ id ], rule = numberRules [ id ], label = labels [ id ] || key , help = hints [ id ] || '' , unit = 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' : '' } > ${ 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>` }
2026-09-27 13:24:06 +08:00
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> ${ title } </h3><span class="muted">点击色块打开色盘</span></div><div class="rgb-row"><div class="color-field"><label>颜色</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="选择 ${ esc ( title ) } "></div></div> ${ [ 'r' , 'g' , 'b' ]. map ( c => field ( 'led' , ` ${ prefix } _ ${ c } ` , values [ ` ${ prefix } _ ${ c } ` ])). join ( '' ) } </div></div>` }
2026-09-27 11:00:41 +08:00
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>` }
2026-09-27 11:20:45 +08:00
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 } "> ${ sectionMeta [ s ] ? .[ 0 ] || s } </button>` ). join ( '' ); $ ( 'configRows' ). 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 ]) : 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 ()}
2026-09-27 11:00:41 +08:00
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 ( target , items , empty = '暂无数据' ){ $ ( target ). innerHTML = items . length ? items . join ( '' ) : `<div class="muted"> ${ empty } </div>` }
2026-09-27 11:20:45 +08:00
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 ? 'good' : '' } "> ${ s . monitored ? '已监控' : '未监控' } </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 . active === 'active' ? 'good' : 'bad' } "> ${ s . active === 'active' ? '运行中' : '已停止' } </span><br><span class="muted"> ${ esc ( s . unit ) }${ s . note ? ` · ${ esc ( s . note ) } ` : '' } </span></span><span class="row-actions"><button data-service-action=" ${ s . active === 'active' ? 'stop' : 'start' } " data-unit=" ${ esc ( s . unit ) } " ${ s . protected && s . active === 'active' ? 'disabled title="远程管理和 PIGWay 核心服务受保护"' : '' } > ${ 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-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 ( `确定删除 ${ s . monitor_name } 的监控吗?\n不会删除或停止 ${ 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 serviceAction ( unit , action ){ if ( ! confirm ( `确定 ${ action === 'start' ? '启动' : '停止' } ${ unit } 吗?` )) 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 )}}
2026-09-27 11:00:41 +08:00
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 () } ` ; $ ( 'cpu' ). 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 );
2026-09-27 10:21:12 +08:00
</ script ></ body ></ html >