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:29:11 +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 >
2026-09-27 13:42:58 +08:00
< style >
. log-filter { display : grid ; grid-template-columns : repeat ( 5 , minmax ( 0 , 1 fr )); gap : 10 px ; align-items : end }. log-filter label { color : var ( -- muted ); font-size : 12 px }. log-filter input , . log-filter select { width : 100 % ; margin-top : 5 px ; border : 1 px solid var ( -- line ); border-radius : 7 px ; background : #0d151e ; color : var ( -- text ); padding : 9 px }. quick-range { display : flex ; gap : 7 px ; flex-wrap : wrap ; margin : 12 px 0 }. log-table-wrap { overflow : auto ; border : 1 px solid var ( -- line ); border-radius : 9 px }. log-table { width : 100 % ; border-collapse : collapse ; min-width : 760 px }. log-table th , . log-table td { text-align : left ; padding : 10 px ; border-bottom : 1 px 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 : 12 px / 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 : 10 px ; margin-top : 12 px }@ media ( max-width : 800px ) {. log-filter { grid-template-columns : 1 fr 1 fr }}@ media ( max-width : 470px ) {. log-filter { grid-template-columns : 1 fr }}
2026-09-27 13:59:11 +08:00
. header-actions { display : flex ; align-items : end ; gap : 12 px ; flex-wrap : wrap ; justify-content : flex-end }. preference { display : grid ; gap : 4 px ; color : var ( -- muted ); font-size : 12 px }. preference select { border : 1 px solid var ( -- line ); border-radius : 7 px ; background : #0d151e ; color : var ( -- text ); padding : 7 px 28 px 7 px 9 px } [ 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 : #eef3f8 e8 } [ 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 10 px 24 px #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 : #f7fafc ef ; box-shadow : 0 8 px 25 px #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 }}
2026-09-27 13:42:58 +08:00
</ style >
2026-09-27 10:21:12 +08:00
</ head >
< body >< main >
2026-09-27 13:59:11 +08:00
< 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 >
2026-09-27 11:00:41 +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" > 处理器负载</ 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 >
2026-09-27 13:59:11 +08:00
< 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 >
2026-09-27 11:00:41 +08:00
< 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 >
2026-09-27 13:59:11 +08:00
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 }
2026-09-27 11:00:41 +08:00
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]}色通道` ));
2026-09-27 13:59:11 +08:00
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' : '选择“关闭”时由程序熄灭板载灯。' };
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' , '服务与进程' ]];
2026-09-27 13:59:11 +08:00
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>` }
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>` }
2026-09-27 13:59:11 +08:00
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>` }
2026-09-27 11:20:45 +08:00
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 })}
2026-09-27 13:59:11 +08:00
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>` }
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>` }}
2026-09-27 13:49:09 +08:00
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 ))}
2026-09-27 11:20:45 +08:00
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' }}
2026-09-27 13:59:11 +08:00
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 )}}
2026-09-27 13:49:09 +08:00
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 )}}
2026-09-27 13:59:11 +08:00
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 }}
2026-09-27 13:42:58 +08:00
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 )}
2026-09-27 13:59:11 +08:00
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>` }}
2026-09-27 13:42:58 +08:00
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>` }}
2026-09-27 13:59:11 +08:00
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 );
2026-09-27 10:21:12 +08:00
</ script ></ body ></ html >