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 14:37:36 +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 : none }. group { background : var ( -- panel2 ); 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 ; background : #0d151e ; color : var (
2026-09-27 21:21:36 +08:00
. plugin - actions { display : flex ; align - items : center ; gap : 10 px ; flex - wrap : wrap ; margin - top : 16 px }
2026-09-27 10:21:12 +08:00
</ style >
2026-09-27 13:42:58 +08:00
< style >
2026-09-27 14:58:21 +08:00
. log-filter-top { display : grid ; grid-template-columns : 180 px 180 px auto minmax ( 190 px , 1 fr ); gap : 10 px ; align-items : end ; margin : 12 px 0 10 px }. log-filter-bottom { display : grid ; grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )) auto ; gap : 10 px ; align-items : end ; margin-bottom : 12 px }. log-filter-top label , . log-filter-bottom label { color : var ( -- muted ); font-size : 12 px }. log-filter-top input , . log-filter-bottom 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 : nowrap ; align-items : center }. log-actions { display : flex ; gap : 8 px ; align-items : center ; justify-content : flex-end ; white-space : nowrap }. log-actions # logSummary { margin-right : 4 px }. 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 : 980px ) {. log-filter-top { grid-template-columns : 1 fr 1 fr }. quick-range { grid-column : 1 / span 2 ; grid-row : 2 }. log-filter-top > label : last-child { grid-column : 1 / span 2 }. log-filter-bottom { grid-template-columns : repeat ( 3 , 1 fr )}. log-actions { grid-column : 1 / span 3 }}@ media ( max-width : 600px ) {. log-filter-top , . log-filter-bottom { grid-template-columns : 1 fr }. quick-range , . log-filter-top > label : last-child , . log-actions { grid-column : 1 }. quick-range { overflow-x : auto ; justify-content : flex-start }. log-actions { flex-wrap : wrap }. log-actions # logSummary { width : 100 % ; margin : 0 }}
. header-actions { display : flex ; align-items : center ; gap : 14 px ; flex-wrap : wrap ; justify-content : flex-end }. preference { display : flex ; align-items : center ; gap : 8 px ; color : var ( -- muted ); font-size : 12 px }. preference select { height : 35 px ; border : 1 px solid var ( -- line ); border-radius : 7 px ; background : #0d151e ; color : var ( -- text ); padding : 7 px 30 px 7 px 10 px }. theme-preference select { min-width : 138 px }. sr-only { position : absolute ; width : 1 px ; height : 1 px ; padding : 0 ; margin : -1 px ; overflow : hidden ; clip : rect ( 0 , 0 , 0 , 0 ); white-space : nowrap ; border : 0 }. header-actions . live { height : 35 px }. service-list { scrollbar-width : none }. service-list : hover { scrollbar-width : thin ; scrollbar-color : var ( -- line ) transparent }. service-list :: -webkit-scrollbar { width : 0 }. service-list : hover :: -webkit-scrollbar { width : 7 px }. service-list :: -webkit-scrollbar-track { background : transparent }. service-list :: -webkit-scrollbar-thumb { background : var ( -- line ); border-radius : 999 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" ] . 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-top input ,[ data-theme = "light" ] . log-filter-bottom select ,[ data-theme = "light" ] . preference select { background : #fff } [ data-theme = "light" ] . badge { background : #e5edf5 ; color : #31465c } [ data-theme = "light" ] . badge . good { background : #d9f3e5 ; color : #087444 } [ data-theme = "light" ] . badge . bad { background : #fde5e5 ; color : #b72f2f } [ 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 }}. host-panel { margin-top : 14 px ; transition : transform .18 s ease , box-shadow .18 s ease , border-color .18 s ease }. host-head { display : flex ; justify-content : space-between ; gap : 16 px ; align-items : start ; margin-bottom : 12 px }. device-details { display : flex ; gap : 8 px 16 px ; flex-wrap : wrap ; font-size : 12 px ; color : var ( -- muted ); margin-top : 5 px }. host-grid { display : grid ; grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )); gap : 10 px }. metric { background : var ( -- panel2 ); border : 1 px solid var ( -- line ); border-radius : 9 px ; padding : 12 px }. metric . value { font-size : 20 px }. progress { height : 7 px ; background : color-mix ( in srgb , var ( -- line ) 70 % , transparent ); border-radius : 999 px ; overflow : hidden ; margin : 8 px 0 }. progress span { display : block ; height : 100 % ; background : var ( -- blue ); border-radius : inherit }. host-select { display : grid ; gap : 4 px ; color : var ( -- muted ); font-size : 12 px }. host-select select { min-width : 210 px ; border : 1 px solid var ( -- line ); border-radius : 7 px ; background : var ( -- panel2 ); color : var ( -- text ); padding : 9 px }. config-target-toolbar { display : grid ; grid-template-columns : minmax ( 220 px , .65 fr ) minmax ( 0 , 1.35 fr ); align-items : end }. token-control { display : flex ; gap : 8 px ; align-items : center }. token-control input { min-width : 0 ; margin : 0 }. token-control button { white-space : nowrap }. bulk-tools { display : flex ; gap : 8 px ; flex-wrap : wrap ; margin : 12 px 0 }. bulk-section { padding : 12 px }. bulk-section-title { display : flex ; align-items : center ; gap : 8 px ; font-weight : 700 }. bulk-items { display : grid ; grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 6 px 18 px ; margin : 10 px 0 0 24 px }. bulk-item { display : flex ; align-items : flex-start ; gap : 7 px }. bulk-item span { min-width : 0 }. bulk-value { display : block ; color : var ( -- muted ); font-size : 12 px ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap }. bulk-target { display : grid ; grid-template-columns : auto minmax ( 120 px , .5 fr ) minmax ( 220 px , 1 fr ); gap : 10 px ; align-items : center }. bulk-target input [ type = "password" ], . bulk-target input [ type = "text" ] { width : 100 % ; border : 1 px soli
2026-09-27 21:21:36 +08:00
. plugin-actions { display : flex ; align-items : center ; gap : 10 px ; flex-wrap : wrap ; margin-top : 16 px }
2026-09-27 13:42:58 +08:00
</ style >
2026-09-27 10:21:12 +08:00
</ head >
< body >< main >
2026-09-27 14:55:03 +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 theme-preference" >< span class = "sr-only" > 颜色模式</ span >< select id = "theme" aria-label = "颜色模式" >< option value = "auto" > ◐ 跟随系统</ option >< option value = "light" > ☀ 浅色模式</ option >< option value = "dark" > ☾ 深色模式</ option ></ select ></ label >< div class = "live" >< span class = "dot" id = "dot" ></ span >< span id = "liveText" > 离线</ span ></ div ></ div ></ header >
2026-09-27 21:20:44 +08:00
< nav >< button class = "active" data-tab = "overview" > 运行状态</ button >< button data-tab = "logs" > 事件日志</ button >< button data-tab = "machines" > 机器管理</ button >< button data-tab = "config" > 系统配置</ button ></ nav >
2026-09-27 14:20:38 +08:00
< section id = "overview" >< div id = "hostCards" >< div class = "panel muted" > 正在读取主机状态…</ div ></ div ></ section >
2026-09-27 14:58:21 +08:00
< section id = "logs" class = "hidden" >< div class = "panel" >< h2 > 事件日志</ h2 >< p class = "muted" > 查询 systemd journal 中仍在保留的全部硬件 Agent 事件。</ p >< div class = "log-filter-top" >< label > 开始日期< input id = "logStart" type = "date" ></ label >< label > 结束日期< input id = "logEnd" type = "date" ></ label >< 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 >< label > 关键词< input id = "logSearch" placeholder = "搜索日志内容" ></ label ></ div >< div class = "log-filter-bottom" >< 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 = "logMachine" >< option value = "" > 全部机器</ option ></ select ></ label >< label > 事件类型< select id = "logEvent" >< option value = "" > 全部事件</ option ></ select ></ label >< div class = "log-actions" >< span class = "muted" id = "logSummary" ></ span >< button id = "refreshLogs" > 查询</ button >< button id = "resetLogs" > 重置</ button ></ div ></ 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 = "machine" > 机器</ 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 21:20:44 +08:00
< section id = "machines" class = "hidden" >< div class = "panel" >< h2 > 机器管理</ h2 >< div id = "machineContent" ></ div ></ div ></ section >
2026-09-27 20:24:34 +08:00
< section id = "config" class = "hidden" >< div class = "panel" >< h2 > 系统配置</ h2 >< p class = "muted" > 配置按用途分组。监控设置与硬件插件配置独立保存。</ p >< div class = "toolbar config-target-toolbar" >< label class = "host-select" > 配置机器< select id = "configHost" ></ select ></ label >< label class = "host-select" > 配置写入令牌(Bearer Token) < span class = "token-control" >< input id = "token" type = "password" placeholder = "当前机器配置令牌(Bearer Token) " >< button id = "toggleToken" type = "button" > 显示令牌</ button ></ span ></ label ></ div >< div class = "config-shell" >< aside class = "config-menu" id = "configMenu" ></ aside >< div id = "configRows" ></ div ></ div >< div class = "savebar" id = "mainSavebar" >< button id = "saveConfig" > 保存到当前机器</ button > < span id = "saveState" class = "muted" > 尚未修改</ span ></ div ></ div ></ section >
2026-09-27 11:00:41 +08:00
</ main >< script >
2026-09-27 14:29:20 +08:00
let LANG = localStorage . getItem ( 'pigwayLanguage' ) || 'zh' ;
2026-09-27 20:24:34 +08:00
const EN = { "硬件插件" : "Hardware plugins" , "独立安装,按实际能力提供风扇、屏幕或灯光控制" : "Install independently; fan, display and lighting controls follow advertised capabilities" , "配置按用途分组。监控设置与硬件插件配置独立保存。" : "Settings are grouped by purpose. Monitoring and hardware plugin settings are saved independently." , "未启用" : "Disabled" , "硬件插件已启用" : "Hardware plugin enabled" , "硬件插件已关闭" : "Hardware plugin disabled" , "硬件插件不可用" : "Hardware plugin unavailable" , "硬件插件" : "Hardware plugin" , "可选 Yahboom 散热板;未安装时保持关闭" : "Optional Yahboom cooling HAT; leave disabled when absent" , "启用散热板(OLED、风扇、RGB) " : "Enable cooling HAT (OLED, fan, RGB)" , "告警灯效模式" : "Alert lighting mode" , "自定义闪烁最小保持时间" : "Minimum custom flash hold" , "默认关闭;启用前运行安装器 --enable-cooling-hat 安装依赖。" : "Disabled by default; install dependencies with --enable-cooling-hat before enabling." , "推荐内置呼吸;自定义闪烁仍可能异色或延迟。" : "Built-in breathing recommended; custom flashing may still show wrong colors or delays." , "范围 1500–60000 毫秒。实际亮灭时间不会低于此值。" : "Range 1500– 60000 ms. Each light/dark state lasts at least this long." , "内置呼吸(推荐)" : "Built-in breathing (recommended)" , "自定义颜色闪烁(实验性)" : "Custom color flashing (experimental)" , "散热板插件关闭时,灯光与风扇设置不控制硬件。" : "Lighting and fan settings do not control hardware while the plugin is disabled." , "内置呼吸:CPU 红、电源黄、内存紫、存储白、网络蓝、服务青。" : "Built-in breathing: CPU red, power yellow, memory purple, storage white, network blue, services cyan." , "普通、严重、紧急对应 MCU 速度档 1、2、3;不支持自定义呼吸颜色或精确周期。" : "Warning, critical and emergency use MCU speed levels 1, 2 and 3; custom breathing colors and exact periods are unsupported." , "启用" : "Enabled" ,
2026-09-27 14:55:03 +08:00
'语言' : 'Language' , '颜色模式' : 'Appearance' , '中文' : '中文' , '自动' : 'Auto' , '深色' : 'Dark' , '浅色' : 'Light' , '◐ 跟随系统' : '◐ Follow system' , '☀ 浅色模式' : '☀ Light mode' , '☾ 深色模式' : '☾ Dark mode' , '正在连接树莓派…' : 'Connecting to Raspberry Pi…' , '在线' : 'Online' , '离线' : 'Offline' ,
2026-09-27 13:59:11 +08:00
'运行状态' : 'Status' , '事件日志' : 'Event log' , '系统配置' : 'Settings' , '处理器负载' : 'CPU load' , '处理器温度' : 'CPU temperature' , '内存占用' : 'Memory usage' , '磁盘占用' : 'Disk usage' , '主网络' : 'Primary network' , '显示状态' : 'Display status' , '当前告警' : 'Active alerts' , '无告警' : 'No alerts' , '服务与进程监控' : 'Service and process monitors' ,
2026-09-27 15:16:10 +08:00
'查询 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' , '风扇档位变化' : 'Fan level changed' , '关键词' : 'Keyword' , '搜索日志内容' : 'Search log messages' , '查询' : 'Search' , '重置' : 'Reset' , '时间' : 'Time' , '程度' : 'Severity' , '内容' : 'Message' , '上一页' : 'Previous' , '下一页' : 'Next' ,
2026-09-27 20:24:34 +08:00
'配置按用途分组。监控设置与硬件插件配置独立保存。' : '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' ,
2026-09-27 13:59:11 +08:00
'灯光设置' : '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' ,
2026-09-27 15:03:15 +08:00
'处理器、温度与散热' : '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.' , 'MCU 内置灯效参数写入间隔,通常保持 10 毫秒。' : 'MCU built-in effect parameter write interval; 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.'
2026-09-27 13:59:11 +08:00
});
2026-09-27 14:20:38 +08:00
Object . assign ( EN ,{ '机器标识' : 'Host ID' , '机器' : 'Host' , '全部机器' : 'All hosts' , '配置机器' : 'Configure host' , '多机配置同步' : 'Multi-host configuration sync' , '勾选目标机器后,将当前页面中已修改的配置项批量同步到这些机器。' : 'Select target hosts to copy the changed settings on this page.' , '当前机器配置令牌(Bearer Token) ' : 'Current host Bearer Token' , '保存到当前机器' : 'Save to current host' , '批量同步已修改项' : 'Sync changed settings' , '设备标识' : 'Device identity' , '自定义本机显示名称和稳定标识' : 'Set the local display name and stable identifier' , '显示名称' : 'Display name' , '机器列表' : 'Host list' , '登记需要聚合监控的其他节点' : 'Register nodes for fleet monitoring' , '已登记机器' : 'Registered hosts' , '尚未登记其他机器' : 'No remote hosts registered' , '添加或更新机器' : 'Add or update host' , '节点地址' : 'Node URL' , '保存机器' : 'Save host' , '正在读取主机状态…' : 'Loading host status…' , '可用' : 'Available' , '剩余' : 'Free' , '本机' : 'Local' , '连接失败' : 'Connection failed' });
2026-09-27 14:29:20 +08:00
Object . assign ( EN ,{ '端口' : 'port' , '0.0.0.0 允许局域网访问;127.0.0.1 仅本机。' : '0.0.0.0 allows LAN access; 127.0.0.1 is local only.' , '范围 1– 65535;保存后 API 自动重启。' : 'Range: 1– 65535. The API restarts after saving.' , 'API 服务不能停止自身' : 'The API service cannot stop itself' , '已自动填写,可继续修改。' : 'Filled automatically; you can continue editing.' , '请先选择同步目标机器' : 'Select target hosts first' });
2026-09-27 14:48:03 +08:00
Object . assign ( EN ,{ '批量同步' : 'Batch sync' , '从当前配置机器复制所选配置项到目标机器' : 'Copy selected settings from the current configuration host to target hosts' , '来源机器' : 'Source host' , '目标机器' : 'Target hosts' , '配置项' : 'Settings' , '全部选择' : 'Select all' , '全部取消' : 'Clear all' , '反选' : 'Invert selection' , '开始批量同步' : 'Start batch sync' , '机器标识和机器列表不会参与同步。' : 'Host identity and host registry are never synchronized.' , '请选择至少一个配置项' : 'Select at least one setting' , '请选择至少一台目标机器' : 'Select at least one target host' , '不能选择来源机器作为目标' : 'The source host cannot also be a target' , '目标机器令牌' : 'Target host token' , '项已选择' : 'settings selected' });
2026-09-27 20:24:34 +08:00
Object . assign ( EN ,{ '硬件插件' : 'Hardware plugins' , '配置按用途分组。监控设置与硬件插件配置独立保存。' : 'Monitoring settings and hardware plugin settings are saved independently.' });
2026-09-27 21:20:44 +08:00
Object . assign ( EN ,{ '机器管理' : 'Machine management' , '恢复设备默认值' : 'Restore device defaults' , '本机' : 'Local host' });
2026-09-27 14:29:20 +08:00
const ZH = Object . fromEntries ( Object . entries ( EN ). map (([ zh , en ])=>[ en , zh ])), tr = s => LANG === 'en' ? ( EN [ s ] || s ) : ( ZH [ s ] || s );
2026-09-27 14:55:03 +08:00
function translateNode ( root = document ){ const map = LANG === 'en' ? EN : ZH , translate = value =>{ const trim = value . trim (); return trim && map [ trim ] ? value . replace ( trim , map [ trim ]) : value }; const walker = document . createTreeWalker ( root , NodeFilter . SHOW_TEXT ); let n ; while ( n = walker . nextNode ()) n . nodeValue = translate ( n . nodeValue ); root . querySelectorAll ? .( '[placeholder],[title],[aria-label]' ). forEach ( e =>[ 'placeholder' , 'title' , 'aria-label' ]. forEach ( a =>{ const v = e . getAttribute ( a ); if ( v ) e . setAttribute ( a , translate ( v ))}))}
2026-09-27 13:59:11 +08:00
function applyTheme ( value ){ const resolved = value === 'auto' ? ( matchMedia ( '(prefers-color-scheme: dark)' ). matches ? 'dark' : 'light' ) : value ; document . documentElement . dataset . theme = resolved }
2026-09-27 14:20:38 +08:00
const $ = id => document . getElementById ( id ), tabs = [... document . querySelectorAll ( 'nav button' )]; let original = {}, fleetHosts = [], currentConfigHost = '' ; const esc = s => String ( s ?? '' ). replace ( /[&<>"']/g , c =>({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' }[ c ]));
2026-09-27 20:24:34 +08:00
const sectionMeta = { plugins : [ '硬件插件' , '独立安装,按实际能力提供风扇、屏幕或灯光控制' ], hardware : [ '硬件插件' , '可选 Yahboom 散热板;未安装时保持关闭' ], device : [ '设备标识' , '自定义本机显示名称和稳定标识' ], hosts : [ '机器列表' , '登记需要聚合监控的其他节点' ], led : [ '灯光设置' , '正常灯效与告警颜色' ], alerts : [ '告警阈值' , '处理器、内存、温度、磁盘和 Wi-Fi' ], fan : [ '风扇温控' , '温度档位与降档回差' ], timing : [ '刷新周期' , '传感器、屏幕、网络和健康检查' ], display : [ '屏幕布局' , 'OLED 四行文字位置' ], dark_mode : [ '板载指示灯' , '树莓派电源灯与活动灯' ], services : [ '服务监控' , '监控 systemd 服务' ], processes : [ '进程监控' , '监控普通进程关键字' ], api : [ 'Web 与 API' , '监听地址、端口和日志数量' ], bulk_sync : [ '批量同步' , '从当前配置机器复制所选配置项到目标机器' ]};
const labels = { 'hardware.cooling_hat_enabled' : '启用散热板(OLED、风扇、RGB) ' , 'led.alert_mode' : '告警灯效模式' , 'led.custom_min_hold_ms' : '自定义闪烁最小保持时间' , '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' : '默认日志条数' , 'device.identifier' : '机器标识' , 'device.name' : '显示名称' };
2026-09-27 11:00:41 +08:00
[ 'cpu' , 'power' , 'memory' , 'storage' , 'network' , 'service' ]. forEach ( g =>[ 'r' , 'g' , 'b' ]. forEach ( c => labels [ `led. ${ g } _ ${ c } ` ] = ` ${ ({ r : '红' , g : '绿' , b : '蓝' } )[c]}色通道` ));
2026-09-27 20:24:34 +08:00
const hints = { 'hardware.cooling_hat_enabled' : '默认关闭;启用前运行安装器 --enable-cooling-hat 安装依赖。' , 'led.alert_mode' : '推荐内置呼吸;自定义闪烁仍可能异色或延迟。' , 'led.custom_min_hold_ms' : '范围 1500–60000 毫秒。实际亮灭时间不会低于此值。' , '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' : 'MCU 内置灯效参数写入间隔,通常保持 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 20:24:34 +08:00
const options = { 'hardware.cooling_hat_enabled' : [[ 'false' , '关闭' ],[ 'true' , '启用' ]], 'led.alert_mode' : [[ 'breathe' , '内置呼吸(推荐)' ],[ 'flash' , '自定义颜色闪烁(实验性)' ]], '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.custom_min_hold_ms' : [ 1500 , 60000 , 100 ], 'led.normal_r' : [ 0 , 255 , 1 ], 'led.normal_g' : [ 0 , 255 , 1 ], 'led.normal_b' : [ 0 , 255 , 1 ], 'led.normal_flash_on_ms' : [ 50 , 60000 , 10 ], 'led.normal_flash_off_ms' : [ 50 , 60000 , 10 ], 'led.warning_flash_on_ms' : [ 50 , 60000 , 10 ], 'led.warning_flash_off_ms' : [ 50 , 60000 , 10 ], 'led.critical_flash_on_ms' : [ 50 , 60000 , 10 ], 'led.critical_flash_off_ms' : [ 50 , 60000 , 10 ], 'led.emergency_flash_on_ms' : [ 50 , 60000 , 10 ], 'led.emergency_flash_off_ms' : [ 50 , 60000 , 10 ], 'led.write_delay_ms' : [ 1 , 1000 , 1 ], 'alerts.cpu_high' : [ 0 , 100 , 1 ], 'alerts.cpu_recover' : [ 0 , 100 , 1 ], 'alerts.cpu_high_seconds' : [ 1 , 86400 , 1 ], 'alerts.memory_high' : [ 0 , 100 , 1 ], 'alerts.memory_recover' : [ 0 , 100 , 1 ], 'alerts.memory_high_seconds' : [ 1 , 86400 , 1 ], 'alerts.temp_high' : [ 0 , 120 ,. 1 ], 'alerts.temp_critical' : [ 0 , 120 ,. 1 ], 'alerts.disk_high' : [ 0 , 100 , 1 ], 'alerts.disk_critical' : [ 0 , 100 , 1 ], 'alerts.wifi_weak' : [ - 120 , 0 , 1 ], 'alerts.wifi_recover' : [ - 120 , 0 , 1 ], 'alerts.wifi_weak_seconds' : [ 1 , 86400 , 1 ], 'fan.start' : [ 0 , 100 ,. 1 ], 'fan.level2' : [ 0 , 100 ,. 1 ], 'fan.level3' : [ 0 , 100 ,. 1 ], 'fan.level4' : [ 0 , 100 ,. 1 ], 'fan.max' : [ 0 , 100 ,. 1 ], 'fan.hysteresis' : [ 0 , 20 ,. 1 ], 'timing.sensor_interval' : [. 2 , 3600 ,. 1 ], 'timing.oled_refresh_interval' : [. 2 , 3600 ,. 1 ], 'timing.page_interval' : [ 1 , 3600 ,. 1 ], 'timing.network_interval' : [ 1 , 3600 ,. 1 ], 'timing.service_interval' : [ 1 , 3600 ,. 1 ], 'timing.health_interval' : [. 2 , 3600 ,. 1 ], 'display.row1_y' : [ 0 , 31 , 1 ], 'display.row2_y' : [ 0 , 31 , 1 ], 'display.row3_y' : [ 0 , 31 , 1 ], 'display.row4_y' : [ 0 , 31 , 1 ], 'api.port' : [ 1 , 65535 , 1 ], 'api.log_limit' : [ 1 , 1000 , 1 ]};[ 'cpu' , 'power' , 'memory' , 'storage' , 'network' , 'service' ]. forEach ( g =>[ 'r' , 'g' , 'b' ]. forEach ( c => numberRules [ `led. ${ g } _ ${ c } ` ] = [ 0 , 255 , 1 ]));
const units = { 'led.custom_min_hold_ms' : '毫秒' , '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 21:20:44 +08:00
const sectionOrder = [ 'device' , 'plugins' , 'alerts' , 'timing' , 'dark_mode' , 'services' , 'processes' , 'api' ], bulkSections = sectionOrder . filter ( s => ! [ 'device' , 'hosts' , 'plugins' ]. includes ( s )), alertColorGroups = [[ 'cpu' , '处理器、温度与散热' ],[ 'power' , '供电与欠压' ],[ 'memory' , '内存' ],[ 'storage' , '存储' ],[ 'network' , '网络与 Wi-Fi' ],[ 'service' , '服务与进程' ]];
2026-09-27 20:24:34 +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 = { DISABLED : '未启用' , OFF : '全灭' , NORMAL_SOLID : '正常 · 自定义常亮' , NORMAL_FLASH : '正常 · 自定义闪烁' , NORMAL_FLOW : '正常 · 流水灯' , NORMAL_BREATHE : '正常 · 呼吸灯' , NORMAL_MARQUEE : '正常 · 跑马灯' , NORMAL_RAINBOW : '正常 · 彩虹灯' , NORMAL_COLORFUL : '正常 · 炫彩灯' }, eventNames = { HARDWARE_ENABLED : '硬件插件已启用' , HARDWARE_DISABLED : '硬件插件已关闭' , HARDWARE_UNAVAILABLE : '硬件插件不可用' , FAN_LEVEL : '风扇档位变化' , 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 ]) }${ p [ 2 ] === 'BREATHE' ? ( LANG === 'zh' ? '呼吸' : ' breathing' ) : ( LANG === 'zh' ? '闪烁' : ' flash' ) } ` } function eventName ( id ){ return tr ( eventNames [ id ] || ( LANG === 'zh' ? `未知事件( ${ id } ) `: String ( id ). toLowerCase (). replaceAll ( '_' , ' ' )))}
2026-09-27 13:59:11 +08:00
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>` }
2026-09-27 20:24:34 +08:00
function pluginText ( zh , en ){ return LANG === 'zh' ? zh : en }
2026-09-27 21:02:52 +08:00
function renderPlugins (){ return `<div class="group"><p> ${ pluginText ( '仅发现插件不会接管插件的设备能力。接入后发送监控状态;暂停或断开联动不会停止插件的本地功能。' , 'Discovery does not take control of plugin device capabilities. Connecting sends monitoring state; pausing or disconnecting the link does not stop the plugin’ s local functions.' ) } </p><button type="button" id="refreshPlugins" onclick="loadPlugins()"> ${ tr ( '刷新列表' ) } </button> <span id="pluginRefreshState" class="hint" role="status" aria-live="polite"></span><div id="pluginList"></div></div><div id="pluginEditor"></div>` }
2026-09-27 20:31:29 +08:00
function pluginSummary ( plugins , host ){ if ( ! Object . keys ( plugins ). length ) return `<div class="group"><strong> ${ pluginText ( '风扇与硬件插件' , 'Fan and hardware plugins' ) } </strong><p class="muted"> ${ pluginText ( '尚未接入硬件插件,风扇状态不可用。独立安装并启动插件后,可在插件管理中接入。' , 'No hardware plugin connected; fan status is unavailable. Install and start a plugin independently, then connect it in plugin management.' ) } </p><button type="button" data-manage-plugin-host=" ${ esc ( host ) } "> ${ pluginText ( '管理插件' , 'Manage plugin' ) } </button></div>` ; return Object . entries ( plugins ). map (([ id , p ])=>{ const st = p . status || {}, cap = p . capabilities || {}, details = []; if ( p . online ){ if ( cap . fan ) details . push ( pluginText ( '风扇设置' , 'Fan setting' ) + ' ' + esc ( st . fan_name || '—' )); if ( cap . display ) details . push ( 'OLED ' + esc ( ownerName ( st . oled_page || '' ))); if ( cap . rgb ) details . push ( 'RGB ' + esc ( rgbName ( st . rgb_mode || '' )))} return `<div class="group"><strong> ${ esc ( p . name || id ) } </strong> · ${ p . online ? tr ( '在线' ) : tr ( '离线' ) } ${ details . join ( ' · ' ) } <button type="button" data-manage-plugin-host=" ${ esc ( host ) } "> ${ pluginText ( '管理插件' , 'Manage plugin' ) } </button></div>` }). join ( '' )}
2026-09-27 20:24:34 +08:00
let pluginRows = [], pluginDraft = null , pluginSelected = '' ;
2026-09-27 20:33:28 +08:00
async function loadPlugins (){ const list = $ ( 'pluginList' ); if ( ! list ) return ; const button = $ ( 'refreshPlugins' ), feedback = $ ( 'pluginRefreshState' ); if ( button ? . disabled ) return ; if ( button ) button . disabled = true ; if ( feedback ) feedback . textContent = pluginText ( '正在刷新…' , 'Refreshing…' ); list . textContent = pluginText ( '正在发现本机插件…' , 'Discovering local plugins…' ); try { const data = await get ( '/api/v1/plugins?host=' + encodeURIComponent ( currentConfigHost )); pluginRows = data . plugins ; if ( feedback ) feedback . textContent = pluginText ( '刷新完成,发现 ' + pluginRows . length + ' 个插件 · ' , 'Refreshed, ' + pluginRows . length + ' plugins found · ' ) + new Date (). toLocaleTimeString (); list . innerHTML = pluginRows . map ( p => `<div class="group"><h3> ${ esc ( p . descriptor ? . name || p . id ) } </h3><div class="hint"> ${ esc ( p . socket ) } · ${ p . online ? tr ( '在线' ) : tr ( '离线' ) } · ${ p . linked ? ( p . paused ? pluginText ( '联动已暂停' , 'Link paused' ) : pluginText ( '已接入' , 'Connected' )) : pluginText ( '尚未接入' , 'Not connected' ) } </div> ${ p . error ? `<p class="error"> ${ esc ( p . error ) } </p>` : '' }${ p . status ? . errors && Object . keys ( p . status . errors ). length ? `<p class="error"> ${ esc ( JSON . stringify ( p . status . errors )) } </p>` : '' } <p> ${ Object . keys ( p . descriptor ? . capabilities || { } ).map(k=>esc(({fan:pluginText('风扇','Fan'),display:pluginText('屏幕','Display'),rgb:pluginText('灯光','Lighting')})[k]||k)).join(' · ')}</p><div class="toolbar"> ${ p . linked ? `<button data-plugin-op=" ${ p . paused ? 'resume' : 'pause' } " data-plugin-id=" ${ esc ( p . id ) } "> ${ p . paused ? pluginText ( '恢复告警联动' , 'Resume alert link' ) : pluginText ( '暂停告警联动' , 'Pause alert link' ) } </button><button data-plugin-op="unlink" data-plugin-id=" ${ esc ( p . id ) } "> ${ pluginText ( '断开接入' , 'Disconnect' ) } </button>` : `<button data-plugin-op="link" data-plugin-id=" ${ esc ( p . id ) } " ${ p . online ? '' : 'disabled' } > ${ pluginText ( '接入插件' , 'Connect plugin' ) } </button>` } <button data-plugin-edit=" ${ esc ( p . id ) } " ${ p . online ? '' : 'disabled' } > ${ pluginText ( '插件配置' , 'Plugin settings' ) } </button></div></div>` ). join ( '' ) || `<p class="muted"> ${ pluginText ( '未发现正在运行的硬件插件。刷新只查询插件,不会安装或启动服务。若已安装,请先在目标机器启动插件,再刷新并接入;未接入不影响监控。' , 'No running hardware plugins found. Refresh only queries plugins; it does not install or start services. Start the installed plugin on the target host, then refresh and connect. Monitoring works independently.' ) } </p>` ; list . querySelectorAll ( '[data-plugin-op]' ). forEach ( b => b . onclick = ()=> pluginAction ( b . dataset . pluginId , b . dataset . pluginOp )); list . querySelectorAll ( '[data-plugin-edit]' ). forEach ( b => b . onclick = ()=> editPlugin ( b . dataset . pluginEdit ))} catch ( e ){ list . textContent = e . message ; if ( feedback ) feedback . textContent = pluginText ( '刷新失败' , 'Refresh failed' )} finally { if ( button ) button . disabled = false }}
2026-09-27 20:24:34 +08:00
async function pluginPut ( path , payload ){ const local = fleetHosts . find ( h => h . local ); saveHostToken ( currentConfigHost , $ ( 'token' ). value . trim ()); return get ( path ,{ method : 'PUT' , headers : { 'Content-Type' : 'application/json' , 'Authorization' : `Bearer ${ hostToken ( local ? . id || '' ) } ` }, body : JSON . stringify ({... payload , host : currentConfigHost , target_token : hostToken ( currentConfigHost )})})}
async function pluginAction ( id , operation ){ try { await pluginPut ( '/api/v1/plugins' ,{ id , operation }); await loadPlugins ()} catch ( e ){ alert ( e . message )}}
function capabilityField ( section , key , value , spec ){ const unit = LANG === 'zh' ? ({ s : '秒' , ms : '毫秒' }[ spec . unit ] || spec . unit ) : spec . unit , label = spec . label ? .[ LANG ] || spec . label ? . en || key , id = ` ${ section } . ${ key } ` , vis = spec . visible ? ` data-cap-visible=" ${ esc ( JSON . stringify ( spec . visible )) } "` : '' ; return `<label class="field" ${ vis } > ${ esc ( label ) }${ spec . type === 'enum' ? `<select data-cap-section=" ${ esc ( section ) } " data-cap-key=" ${ esc ( key ) } "> ${ spec . choices . map ( x => `<option value=" ${ esc ( x ) } " ${ String ( value ) === String ( x ) ? 'selected' : '' } > ${ esc ( pluginChoice ( x )) } </option>` ). join ( '' ) } </select>` : `<div class="input-unit"><input type="number" step=" ${ section === 'rgb' ? 1 : 'any' } " min=" ${ esc ( spec . min ) } " max=" ${ esc ( spec . max ) } " data-cap-section=" ${ esc ( section ) } " data-cap-key=" ${ esc ( key ) } " value=" ${ esc ( value ) } "><span class="unit"> ${ esc ( unit ) } </span></div><span class="hint"> ${ pluginText ( '范围' , 'Range' ) } ${ spec . min } – ${ spec . max } ${ esc ( unit ) } </span>` } </label>` }
function pluginChoice ( x ){ const zh = { off : '全灭' , solid : '自定义常亮' , flash : '自定义闪烁(实验性)' , breathe : '内置呼吸' , flow : '流水灯' , marquee : '跑马灯' , rainbow : '彩虹灯' , colorful : '炫彩灯' , red : '红色' , green : '绿色' , blue : '蓝色' , yellow : '黄色' , purple : '紫色' , cyan : '青色' , white : '白色' }; return LANG === 'zh' ? ( zh [ x ] || x ) : x }
function capabilityFields ( section , fields , values ){ const seen = new Set (); return Object . entries ( fields ). map (([ key , spec ])=>{ if ( ! spec . color_group ) return capabilityField ( section , key , values [ key ], spec ); if ( seen . has ( spec . color_group )) return '' ; seen . add ( spec . color_group ); const members = Object . entries ( fields ). filter (([, v ])=> v . color_group === spec . color_group ), hex = '#' + [ 'r' , 'g' , 'b' ]. map ( c => Number ( values [ members . find (([, v ])=> v . channel === c )[ 0 ]]). toString ( 16 ). padStart ( 2 , '0' )). join ( '' ); return `<div class="group span2" data-cap-visible=" ${ esc ( JSON . stringify ( spec . visible || { } ))}"><div style="display:grid;grid-template-columns:52px repeat(3,minmax(0,1fr));gap:10px;align-items:start"><label style="text-align:center"> ${ pluginText ( '颜色' , 'Color' ) } <input type="color" data-cap-color=" ${ esc ( spec . color_group ) } " value=" ${ hex } " aria-label=" ${ pluginText ( '选择颜色' , 'Choose color' ) } " style="width:38px;height:34px;padding:0;margin-top:5px"></label> ${ members . map (([ k , v ])=> capabilityField ( section , k , values [ k ],{... v , visible : null } )).join('')}</div></div>` }). join ( '' )}
2026-09-27 21:21:36 +08:00
async function editPlugin ( id ){ try { pluginSelected = id ; pluginDraft = await get ( '/api/v1/plugin/config?host=' + encodeURIComponent ( currentConfigHost ) + '&id=' + encodeURIComponent ( id )); const data = pluginDraft , editor = $ ( 'pluginEditor' ); editor . innerHTML = `<div class="group"><h3> ${ esc ( id ) } </h3><p class="hint"> ${ pluginText ( '配置由插件保存并立即应用,不重启监控。' , 'Settings are saved and applied by the plugin without restarting monitoring.' ) } </p> ${ ( data . notes || []). map ( n => `<p class="hint"> ${ esc ( n [ LANG ] || n . en || '' ) } </p>` ). join ( '' ) }${ Object . entries ( data . schema ). map (([ section , fields ])=> `<div class="group"><h3> ${ ({ fan : pluginText ( '风扇' , 'Fan' ), oled : pluginText ( '屏幕' , 'Display' ), rgb : pluginText ( '灯光' , 'Lighting' ) } )[section]||esc(section)}</h3><div class="fields"> ${ capabilityFields ( section , fields , data . config [ section ]) } </div></div>` ). join ( '' ) } <div class="plugin-actions"><button id="savePlugin"> ${ pluginText ( '保存插件配置' , 'Save plugin settings' ) } </button> <span id="pluginSaveState"></span></div></div>` ; editor . oninput = e =>{ if ( e . target . dataset . capColor ){ const group = e . target . dataset . capColor , hex = e . target . value ;[ 'r' , 'g' , 'b' ]. forEach (( c , i )=>{ editor . querySelector ( `[data-cap-key=" ${ group } _ ${ c } "]` ). value = parseInt ( hex . slice ( 1 + 2 * i , 3 + 2 * i ), 16 )})} for ( const picker of editor . querySelectorAll ( '[data-cap-color]' )){ picker . value = '#' + [ 'r' , 'g' , 'b' ]. map ( c => Math . max ( 0 , Math . min ( 255 , Number ( editor . querySelector ( `[data-cap-key=" ${ picker . dataset . capColor } _ ${ c } "]` ). value ))). toString ( 16 ). padStart ( 2 , '0' )). join ( '' )} updatePluginVisibility ()}; updatePluginVisibility (); $ ( 'savePlugin' ). onclick = savePluginConfig } catch ( e ){ $ ( 'pluginEditor' ). textContent = e . message }}
2026-09-27 20:24:34 +08:00
function updatePluginVisibility (){ document . querySelectorAll ( '[data-cap-visible]' ). forEach ( el =>{ const rules = JSON . parse ( el . dataset . capVisible ); el . classList . toggle ( 'hidden' , ! Object . entries ( rules ). every (([ key , values ])=> values . includes ( document . querySelector ( `[data-cap-key=" ${ key } "]` ) ? . value )))})}
async function savePluginConfig (){ const updates = {}; for ( const el of document . querySelectorAll ( '[data-cap-key]' )){ if ( ! el . reportValidity ()) return ; const s = el . dataset . capSection , k = el . dataset . capKey ;( updates [ s ] ??= {})[ k ] = el . value } try { await pluginPut ( '/api/v1/plugin/config' ,{ id : pluginSelected , updates }); $ ( 'pluginSaveState' ). textContent = pluginText ( '保存成功' , 'Saved' )} catch ( e ){ $ ( 'pluginSaveState' ). textContent = e . message }}
document . addEventListener ( 'click' , async e =>{ const b = e . target . closest ( '[data-manage-plugin-host]' ); if ( ! b ) return ; currentConfigHost = b . dataset . managePluginHost ; await ensureHosts (); $ ( 'configHost' ). value = currentConfigHost ; document . querySelector ( '[data-tab="config"]' ). click (); await loadConfig (); document . querySelector ( '[data-config-section="plugins"]' ) ? . click ()});
2026-09-27 21:20:44 +08:00
let deviceIdentity = {}, bulkOriginal = {}, bulkSource = '' ;
function renderDevice ( values ){ return `<div class="group"><div class="fields"> ${ Object . entries ( values ). map (([ key , raw ])=>{ const automatic =! raw || raw . toLowerCase () === 'auto' , value = automatic ? ( deviceIdentity [ key === 'identifier' ? 'id' : 'name' ] || deviceIdentity . hostname || '' ) : raw ; return field ( 'device' , key , value ). replace ( 'data-section="device"' , `data-device-auto=" ${ automatic } " data-section="device"` ) } ).join('')}</div><button type="button" onclick="resetDeviceDefaults()"> ${ tr ( '恢复设备默认值' ) } </button><p class="hint"> ${ pluginText ( '默认值跟随本机主机名;修改后点击保存到当前机器。' , 'Defaults follow the device hostname. Save to the current host to apply changes.' ) } </p></div>` }
function resetDeviceDefaults (){ document . querySelectorAll ( '[data-section="device"]' ). forEach ( el =>{ el . value = deviceIdentity . hostname || '' ; el . dataset . deviceAuto = 'true' }); markChanged ()}
async function loadMachines (){ try { const registry = await get ( '/api/v1/hosts' ); fleetHosts = registry . hosts ; bulkSource = fleetHosts . some ( h => h . id === bulkSource ) ? bulkSource : fleetHosts [ 0 ]. id ; const local = fleetHosts . find ( h => h . local ); $ ( 'machineContent' ). innerHTML = `<div class="group"><label class="field"> ${ pluginText ( '管理令牌(本机)' , 'Management token (local host)' ) } <input id="machineToken" type="password" value=" ${ esc ( hostToken ( local . id )) } "></label></div><div class="group"><h3> ${ tr ( '本机' ) } </h3> ${ esc ( local . name ) } · ${ esc ( local . id ) } </div> ${ renderHosts ( Object . fromEntries ( fleetHosts . filter ( h => ! h . local ). map ( h =>[ h . id , h . url ]))) } <div class="group"><h2> ${ tr ( '批量同步' ) } </h2><label class="host-select"> ${ tr ( '来源机器' ) } <select id="bulkSource"> ${ fleetHosts . map ( h => `<option value=" ${ esc ( h . id ) } " ${ h . id === bulkSource ? 'selected' : '' } > ${ esc ( h . name || h . id ) } </option>` ). join ( '' ) } </select></label><div id="machineBulk"></div></div>` ; $ ( 'machineToken' ). onchange = ()=> saveHostToken ( local . id , $ ( 'machineToken' ). value . trim ()); $ ( 'saveHost' ). onclick = saveHost ; document . querySelectorAll ( '[data-delete-host]' ). forEach ( b => b . onclick = ()=> deleteHost ( b . dataset . deleteHost )); $ ( 'bulkSource' ). onchange = ()=>{ bulkSource = $ ( 'bulkSource' ). value ; loadMachineBulk ()}; await loadMachineBulk ()} catch ( e ){ $ ( 'machineContent' ). textContent = e . message }}
async function loadMachineBulk (){ const source = bulkSource ; $ ( 'machineBulk' ). textContent = pluginText ( '正在读取配置…' , 'Loading configuration…' ); try { const d = await get ( '/api/v1/config?host=' + encodeURIComponent ( source )); if ( source !== bulkSource ) return ; bulkOriginal = d . config ; $ ( 'machineBulk' ). innerHTML = renderBulkSync (); bindBulkSync ()} catch ( e ){ $ ( 'machineBulk' ). textContent = e . message }}
function renderGeneral ( s , v ){ if ( s === 'device' ) return renderDevice ( 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>` }
2026-09-27 13:59:11 +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>` }
2026-09-27 14:20:38 +08:00
function renderHosts ( v ){ const entries = Object . entries ( v || {}); return `<div class="group"><h3>已登记机器</h3><div class="rows"> ${ entries . map (([ id , url ])=> `<div class="row"><span><b> ${ esc ( id ) } </b><br><span class="muted"> ${ esc ( url ) } </span></span><button class="danger" data-delete-host=" ${ esc ( id ) } ">删除</button></div>` ). join ( '' ) || '<div class="muted">尚未登记其他机器</div>' } </div></div><div class="group"><h3>添加或更新机器</h3><div class="service-form"><label>机器标识<input id="hostId" maxlength="64" placeholder="例如:living-room"></label><label>节点地址<input id="hostUrl" maxlength="200" placeholder="例如:http://192.168.1.20:6001"></label><div class="full"><button id="saveHost">保存机器</button> <span id="hostMessage" class="muted"></span></div></div></div>` }
2026-09-27 21:20:44 +08:00
function renderBulkTargets (){ const targets = fleetHosts . filter ( h => h . id !== bulkSource ); return `<div class="group"><h3> ${ tr ( '目标机器' ) } </h3><div class="hint"> ${ tr ( '机器标识和机器列表不会参与同步。' ) } </div><div class="rows"> ${ targets . map ( h => `<label class="bulk-target"><input type="checkbox" data-bulk-target=" ${ esc ( h . id ) } "><b> ${ esc ( h . name || h . id ) } </b><input type="password" data-bulk-token=" ${ esc ( h . id ) } " value=" ${ esc ( hostToken ( h . id )) } " placeholder=" ${ tr ( '目标机器令牌' ) } "></label>` ). join ( '' ) || `<div class="muted"> ${ tr ( '尚未登记其他机器' ) } </div>` } </div></div>` }
2026-09-27 14:48:03 +08:00
function bulkValue ( section , key , value ){ const item = options [ ` ${ section } . ${ key } ` ] ? . find (([ v ])=> v === String ( value )); return item ? tr ( item [ 1 ]) : value }
2026-09-27 21:20:44 +08:00
function renderBulkSync (){ const groups = bulkSections . filter ( s => bulkOriginal [ s ] && Object . keys ( bulkOriginal [ s ]). length ). map ( s => `<div class="group bulk-section"><label class="bulk-section-title"><input type="checkbox" data-bulk-section=" ${ esc ( s ) } "> ${ tr ( sectionMeta [ s ] ? .[ 0 ] || s ) } </label><div class="bulk-items"> ${ Object . entries ( bulkOriginal [ s ]). map (([ k , v ])=> `<label class="bulk-item"><input type="checkbox" data-bulk-item=" ${ esc ( s ) } . ${ esc ( k ) } "><span> ${ esc ( tr ( labels [ ` ${ s } . ${ k } ` ] || k )) } <small class="bulk-value"> ${ esc ( bulkValue ( s , k , v )) } </small></span></label>` ). join ( '' ) } </div></div>` ). join ( '' ); return `<div class="group"><h3> ${ tr ( '来源机器' ) } </h3><div> ${ esc ( fleetHosts . find ( h => h . id === bulkSource ) ? . name || bulkSource ) } · ${ esc ( bulkSource ) } </div></div> ${ renderBulkTargets () } <div class="bulk-tools"><button id="bulkAll"> ${ tr ( '全部选择' ) } </button><button id="bulkNone"> ${ tr ( '全部取消' ) } </button><button id="bulkInvert"> ${ tr ( '反选' ) } </button><span class="muted" id="bulkCount"></span></div><div><h3> ${ tr ( '配置项' ) } </h3> ${ groups } </div><div class="savebar"><button id="bulkApply"> ${ tr ( '开始批量同步' ) } </button> <span id="bulkState" class="muted"></span></div>` }
2026-09-27 14:48:03 +08:00
function updateBulkSelection (){ document . querySelectorAll ( '[data-bulk-section]' ). forEach ( parent =>{ const items = [... document . querySelectorAll ( `[data-bulk-item^=" ${ parent . dataset . bulkSection } ."]` )]; parent . checked = items . length > 0 && items . every ( x => x . checked ); parent . indeterminate = items . some ( x => x . checked ) &&! parent . checked }); const n = document . querySelectorAll ( '[data-bulk-item]:checked' ). length ; if ( $ ( 'bulkCount' )) $ ( 'bulkCount' ). textContent = LANG === 'zh' ? `已选择 ${ n } 项` : ` ${ n } settings selected` }
function bindBulkSync (){ if ( ! $ ( 'bulkApply' )) return ; document . querySelectorAll ( '[data-bulk-section]' ). forEach ( x => x . onchange = ()=>{ document . querySelectorAll ( `[data-bulk-item^=" ${ x . dataset . bulkSection } ."]` ). forEach ( i => i . checked = x . checked ); updateBulkSelection ()}); document . querySelectorAll ( '[data-bulk-item]' ). forEach ( x => x . onchange = updateBulkSelection ); $ ( 'bulkAll' ). onclick = ()=>{ document . querySelectorAll ( '[data-bulk-item]' ). forEach ( x => x . checked = true ); updateBulkSelection ()}; $ ( 'bulkNone' ). onclick = ()=>{ document . querySelectorAll ( '[data-bulk-item]' ). forEach ( x => x . checked = false ); updateBulkSelection ()}; $ ( 'bulkInvert' ). onclick = ()=>{ document . querySelectorAll ( '[data-bulk-item]' ). forEach ( x => x . checked =! x . checked ); updateBulkSelection ()}; document . querySelectorAll ( '[data-bulk-token]' ). forEach ( x => x . onchange = ()=> saveHostToken ( x . dataset . bulkToken , x . value . trim ())); $ ( 'bulkApply' ). onclick = applyBulkConfig ; updateBulkSelection ()}
2026-09-27 20:24:34 +08:00
function updateConditional (){ const alertMode = document . querySelector ( '[data-key="alert_mode"]' ) ? . value || 'breathe' ; document . querySelectorAll ( '.alert-flash' ). forEach ( e => e . classList . toggle ( 'hidden' , alertMode !== 'flash' )); document . querySelectorAll ( '.alert-breathe' ). forEach ( e => e . classList . toggle ( 'hidden' , alertMode !== 'breathe' )); 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 21:20:44 +08:00
function renderConfig (){ const ss = sectionOrder . filter ( s => original [ s ] || s === 'plugins' ); $ ( '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 === 'bulk_sync' ? renderBulkSync () : s === 'plugins' ? renderPlugins () : s === 'services' ? renderServices () : s === 'hosts' ? renderHosts ( original [ s ]) : renderGeneral ( s , original [ s ]) } </section>` ). join ( '' ); $ ( 'configMenu' ). onclick = e =>{ const b = e . target . closest ( 'button' ); if ( ! b ) return ; document . querySelectorAll ( '[data-config-section]' ). forEach ( x => x . classList . toggle ( 'active' , x === b )); document . querySelectorAll ( '[data-config-page]' ). forEach ( x => x . classList . toggle ( 'active' , x . dataset . configPage === b . dataset . configSection )); $ ( 'mainSavebar' ). classList . toggle ( 'hidden' ,[ 'bulk_sync' , 'plugins' ]. includes ( b . dataset . configSection )); if ( b . dataset . configSection === 'services' ) loadServices (); if ( b . dataset . configSection === 'plugins' ) loadPlugins ()}; $ ( '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]' )){ if ( e . target . dataset . section === 'device' ) e . target . dataset . deviceAuto = 'false' ; markChanged ()}}; $ ( 'refreshServices' ) ? . addEventListener ( 'click' , loadServices ); $ ( 'serviceSearch' ) ? . addEventListener ( 'input' , renderServiceLists ); $ ( 'saveMonitor' ) ? . addEventListener ( 'click' , saveMonitor ); $ ( 'resetMonitor' ) ? . addEventListener ( 'click' , resetMonitorForm ); $ ( 'saveHost' ) ? . addEventListener ( 'click' , saveHost ); document . querySelectorAll ( '[data-delete-host]' ). forEach ( b => b . onclick = ()=> deleteHost ( b . dataset . deleteHost )); bindBulkSync (); updateConditional ()}
function markChanged (){ let n = 0 ; document . querySelectorAll ( '#configRows [data-section]' ). forEach ( i =>{ if (( i . dataset . deviceAuto === 'true' ? 'auto' : 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' , 'machines' ]. forEach ( id => $ ( id ). classList . toggle ( 'hidden' , id !== b . dataset . tab )); if ( b . dataset . tab === 'machines' ) loadMachines (); 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 14:20:38 +08:00
let serviceCache = [], editingServiceName = '' , serviceReadOnly = false ; 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 { serviceReadOnly =! fleetHosts . find ( x => x . id === currentConfigHost ) ? . local ; const d = await get ( `/api/v1/services?host= ${ encodeURIComponent ( currentConfigHost ) } ` ); serviceCache = d . services ; renderServiceLists ()} catch ( e ){ $ ( 'systemServices' ). innerHTML = `<div class="error"> ${ esc ( e . message ) } </div>` }}
function renderServiceLists (){ if ( ! $ ( 'systemServices' )) return ; const q = $ ( 'serviceSearch' ). value . trim (). toLowerCase (), filtered = serviceCache . filter ( s => ! q || ` ${ s . unit } ${ s . description } ${ s . note } ` . toLowerCase (). includes ( q )); rows ( 'systemServices' , filtered . map ( s => `<div class="row"><span><b> ${ esc ( s . unit ) } </b><br><span class="muted"> ${ esc ( s . description || '暂无服务说明' ) } · ${ esc ( s . active === 'active' ? '正在运行' : '未运行' ) } </span></span><span class="row-actions"><span class="badge ${ s . monitored &&! s . paused ? 'good' : '' } "> ${ s . monitored ? ( s . paused ? '已暂停' : '已监控' ) : '未监控' } </span><button data-select-service=" ${ esc ( s . unit ) } "> ${ s . monitored ? '编辑监控' : '加入监控' } </button></span></div>` ), '没有匹配的服务' ); const monitored = serviceCache . filter ( s => s . monitored ); rows ( 'monitoredServices' , monitored . map ( s => `<div class="row"><span><b> ${ esc ( s . monitor_name ) } </b> <span class="badge ${ s . paused ? '' : s . active === 'active' ? 'good' : 'bad' } "> ${ s . paused ? '监控已暂停' : s . active === 'active' ? '运行中' : '服务已停止' } </span><br><span class="muted"> ${ esc ( s . unit ) } </span><br><span class="muted">备注: ${ esc ( s . note || '未填写' ) } </span></span><span class="row-actions"><button data-monitor-action=" ${ s . paused ? 'resume' : 'pause' } " data-unit=" ${ esc ( s . unit ) } "> ${ s . paused ? '恢复监控' : '暂停监控' } </button><button data-service-action=" ${ s . active === 'active' ? 'stop' : 'start' } " data-unit=" ${ esc ( s . unit ) } " ${ s . protected && s . active === 'active' ? 'disabled title="API 服务不能停止自身"' : '' } > ${ s . active === 'active' ? '停止服务' : '启动服务' } </button><button data-select-service=" ${ esc ( s . unit ) } ">编辑</button><button class="danger" data-delete-monitor=" ${ esc ( s . unit ) } ">删除监控</button></span></div>` ), '尚未监控任何服务' ); if ( serviceReadOnly ) document . querySelectorAll ( '#configRows button' ). forEach ( b => b . disabled = true ); 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 )}}
2026-09-27 14:20:38 +08:00
function bytes ( value ){ const n = Number ( value || 0 ); if ( ! n ) return '--' ; const units = [ 'B' , 'KB' , 'MB' , 'GB' , 'TB' ]; let x = n , i = 0 ; while ( x >= 1024 && i < units . length - 1 ){ x /= 1024 ; i ++ } return ` ${ x >= 100 || i === 0 ? x . toFixed ( 0 ) : x . toFixed ( 1 ) } ${ units [ i ] } ` }
function meter ( percent ){ const p = Math . max ( 0 , Math . min ( 100 , Number ( percent || 0 ))); return `<div class="progress"><span style="width: ${ p } %"></span></div>` }
2026-09-27 21:20:44 +08:00
function hostCard ( entry ){ const h = entry . host ; if ( ! entry . online ) return `<article class="panel host-panel offline-card"><div class="host-head"><div><h2> ${ esc ( h . name || h . id ) } </h2><div class="device-details"><span>ID ${ esc ( h . id ) } </span><span> ${ esc ( h . url || tr ( '本机' )) } </span></div></div><span class="badge bad"> ${ tr ( '离线' ) } </span></div><div class="error"> ${ esc ( entry . error || tr ( '连接失败' )) } </div></article>` ; const s = entry . status , y = s . system || {}, n = s . network || {}, d = s . display || {}, v = s . device || {}, m = s . machine || h , signal = n . signal_percent , memUsed = y . memory_used_bytes , memTotal = y . memory_total_bytes , diskUsed = y . disk_used_bytes , diskTotal = y . disk_total_bytes ; return `<article class="panel host-panel"><div class="host-head"><div><h2> ${ esc ( m . name || h . name || m . id ) } </h2><div class="device-details"><span>ID ${ esc ( m . id || h . id ) } </span><span> ${ LANG === 'zh' ? '主机名' : 'Hostname' } ${ esc ( m . hostname || v . hostname || '--' ) } </span><span> ${ esc ( v . manufacturer || '' ) } ${ esc ( v . model || '--' ) } </span><span> ${ LANG === 'zh' ? '序列号' : 'Serial' } ${ esc ( v . serial || '--' ) } </span><span> ${ LANG === 'zh' ? '硬件版本' : 'Revision' } ${ esc ( v . revision || '--' ) } </span><span> ${ esc ( v . os || '--' ) } </span><span> ${ LANG === 'zh' ? '内核' : 'Kernel' } ${ esc ( v . kernel || '--' ) } </span><span> ${ esc ( v . architecture || '--' ) } · CPU ${ esc ( v . cpu_count || '--' ) } </span><span> ${ LANG === 'zh' ? '程序版本' : 'Agent' } ${ esc ( s . version || '--' ) } </span></div></div><span class="badge good"> ${ tr ( '在线' ) } </span></div><div class="host-grid"><div class="metric"><div class="muted"> ${ tr ( '处理器负载' ) } </div><div class="value"> ${ esc ( y . cpu_percent ) } %</div> ${ meter ( y . cpu_percent ) } <div class="muted"> ${ LANG === 'zh' ? '频率' : 'Frequency' } ${ esc ( y . cpu_freq_mhz ) } MHz · ${ esc ( y . temperature_c ) } °C</div></div><div class="metric"><div class="muted"> ${ tr ( '内存占用' ) } </div><div class="value"> ${ esc ( y . memory_percent ) } %</div> ${ meter ( y . memory_percent ) } <div class="muted"> ${ bytes ( memUsed ) } / ${ bytes ( memTotal ) } · ${ LANG === 'zh' ? '可用' : 'Available' } ${ bytes ( y . memory_available_bytes ) } </div></div><div class="metric"><div class="muted"> ${ tr ( '磁盘占用' ) } </div><div class="value"> ${ esc ( y . disk_percent ) } %</div> ${ meter ( y . disk_percent ) } <div class="muted"> ${ bytes ( diskUsed ) } / ${ bytes ( diskTotal ) } · ${ LANG === 'zh' ? '剩余' : 'Free' } ${ bytes ( y . disk_free_bytes ) } </div></div><div class="metric"><div class="muted"> ${ tr ( '主网络' ) } </div><div class="value"> ${ n . kind === 'WIF' && signal !== null && signal !== undefined ? ` ${ esc ( signal ) } %` : esc ( n . kind || '--' ) } </div> ${ n . kind === 'WIF' ? meter ( signal ) : '' } <div class="muted"> ${ esc ( n . interface || '--' ) } · ${ n . kind === 'WIF' ? ` ${ esc ( n . metric ) } dBm · ` : '' }${ esc ( n . ip_label ) } ${ esc ( n . ip ) } · ${ n . internet ? ( LANG === 'zh' ? '互联网正常' : 'Internet online' ) : ( LANG === 'zh' ? '互联网断开' : 'Internet down' ) } </div></div><div class="metric"><div class="muted"> ${ tr ( '显示状态' ) } </div><div class="value"> ${ esc ( ownerName ( d . owner )) } </div><div class="muted"> ${ LANG === 'zh' ? '告警状态独立于硬件插件' : 'Alert state is independent of hardware plugins' } </div></div><div class="metric"><div class="muted"> ${ tr ( '当前告警' ) } </div><div class="value"> ${ ( d . alerts || []). length } </div><div class="muted"> ${ ( d . alerts || []). map ( a => esc ( ownerName ( a . id ))). join ( ' · ' ) || tr ( '无告警' ) } </div></div></div> ${ pluginSummary ( s . plugins || { } ,m.id||h.id)}<div class="group"><h3> ${ tr ( '服务与进程监控' ) } </h3><div class="rows"> ${ ( s . watches || []). map ( w => `<div class="row"><span> ${ esc ( w . name ) } · ${ esc ( w . target ) } </span><span class="badge ${ w . up ? 'good' : 'bad' } "> ${ tr ( w . up ? '正常' : '停止' ) } </span></div>` ). join ( '' ) || `<div class="muted"> ${ tr ( '暂无数据' ) } </div>` } </div></div></article>` }
2026-09-27 14:50:35 +08:00
function renderHostCards ( entries ){ const root = $ ( 'hostCards' ), seen = new Set ; entries . forEach (( entry , index )=>{ const id = String ( entry . host . id ), template = document . createElement ( 'template' ); template . innerHTML = hostCard ( entry ); const next = template . content . firstElementChild ; next . dataset . hostId = id ; let current = [... root . children ]. find ( x => x . dataset . hostId === id ); if ( current ){ current . className = next . className ; current . innerHTML = next . innerHTML ; if ( root . children [ index ] !== current ) root . insertBefore ( current , root . children [ index ] || null )} else root . insertBefore ( next , root . children [ index ] || null ); seen . add ( id )});[... root . children ]. forEach ( x =>{ if ( ! seen . has ( x . dataset . hostId )) x . remove ()})}
async function loadStatus (){ try { const fleet = await get ( '/api/v1/fleet/status' ), online = fleet . hosts . filter ( x => x . online ); $ ( 'dot' ). classList . toggle ( 'ok' , online . length > 0 ); $ ( 'liveText' ). textContent = LANG === 'zh' ? ` ${ online . length } / ${ fleet . hosts . length } 在线` : ` ${ online . length } / ${ fleet . hosts . length } online` ; $ ( 'identity' ). textContent = LANG === 'zh' ? `多机监控 · ${ fleet . hosts . length } 台设备 · ${ new Date ( fleet . timestamp * 1000 ). toLocaleTimeString () } ` : `Fleet monitoring · ${ fleet . hosts . length } hosts · ${ new Date ( fleet . timestamp * 1000 ). toLocaleTimeString () } ` ; renderHostCards ( fleet . hosts ); fleetHosts = fleet . hosts . map ( x => x . host ); refreshHostSelectors ()} 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 14:29:20 +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 , machine = $ ( 'logMachine' ). 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 ( machine ) p . set ( 'machine' , machine ); if ( search ) p . set ( 'search' , search ); const d = await get ( `/api/v1/fleet/logs? ${ p } ` ); logState . total = d . total ; const selectedEvent = $ ( 'logEvent' ). value , selectedMachine = $ ( 'logMachine' ). value ; $ ( 'logEvent' ). innerHTML = `<option value=""> ${ tr ( '全部事件' ) } </option>` + d . event_types . map ( x => `<option value=" ${ esc ( x ) } " ${ x === selectedEvent ? 'selected' : '' } > ${ esc ( eventName ( x )) } </option>` ). join ( '' ); $ ( 'logMachine' ). innerHTML = `<option value=""> ${ LANG === 'zh' ? '全部机器' : 'All hosts' } </option>` + d . machines . map ( x => `<option value=" ${ esc ( x . id ) } " ${ x . id === selectedMachine ? 'selected' : '' } > ${ esc ( x . name ) } · ${ esc ( x . id ) } </option>` ). join ( '' ); $ ( 'logRows' ). innerHTML = d . logs . length ? d . logs . map ( x => `<tr><td> ${ esc ( new Date ( x . timestamp / 1000 ). toLocaleString ()) } </td><td> ${ esc ( x . machine_name || x . machine_id ) } </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="5" class="muted"> ${ tr ( '没有符合条件的日志' ) } </td></tr>` ; $ ( 'logSummary' ). textContent = LANG === 'zh' ? `共 ${ d . total } 条 · 数据来源:各机器 systemd journal` : ` ${ d . total } events · Source: each host's 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 names = { timestamp : LANG === 'zh' ? '时间' : 'Time' , machine : LANG === 'zh' ? '机器' : 'Host' , severity : LANG === 'zh' ? '程度' : 'Severity' , event : LANG === 'zh' ? '事件类型' : 'Event type' , message : LANG === 'zh' ? '内容' : 'Message' }, name = names [ b . dataset . logSort ]; b . textContent = name + ( b . dataset . logSort === logState . sort ? ( logState . order === 'asc' ? ' ↑' : ' ↓' ) : '' )})} catch ( e ){ $ ( 'logRows' ). innerHTML = `<tr><td colspan="5" class="error"> ${ esc ( e . message ) } </td></tr>` }}
2026-09-27 14:20:38 +08:00
function hostToken ( id ){ return localStorage . getItem ( `pigwayToken: ${ id } ` ) || localStorage . getItem ( 'pigwayToken' ) || '' }
function saveHostToken ( id , value ){ localStorage . setItem ( `pigwayToken: ${ id } ` , value ); if ( fleetHosts . find ( x => x . id === id ) ? . local ) localStorage . setItem ( 'pigwayToken' , value )}
2026-09-27 14:48:03 +08:00
function refreshHostSelectors (){ if ( ! fleetHosts . length ) return ; const current = $ ( 'configHost' ). value || currentConfigHost || fleetHosts [ 0 ]. id ; $ ( 'configHost' ). innerHTML = fleetHosts . map ( h => `<option value=" ${ esc ( h . id ) } " ${ h . id === current ? 'selected' : '' } > ${ esc ( h . name || h . id ) } · ${ esc ( h . id ) } </option>` ). join ( '' ); currentConfigHost = $ ( 'configHost' ). value }
2026-09-27 21:20:44 +08:00
function changedUpdates (){ const updates = {}; document . querySelectorAll ( '#configRows [data-section]' ). forEach ( i =>{ if ( original [ i . dataset . section ] && ( i . dataset . deviceAuto === 'true' ? 'auto' : i . value ) !== original [ i . dataset . section ][ i . dataset . key ]){( updates [ i . dataset . section ] ??= {})[ i . dataset . key ] = i . dataset . deviceAuto === 'true' ? 'auto' : i . value }}); return updates }
2026-09-27 14:20:38 +08:00
async function ensureHosts (){ if ( fleetHosts . length ) return ; const d = await get ( '/api/v1/hosts' ); fleetHosts = d . hosts ; refreshHostSelectors ()}
2026-09-27 21:20:44 +08:00
async function loadConfig (){ try { await ensureHosts (); currentConfigHost = $ ( 'configHost' ). value || currentConfigHost || fleetHosts [ 0 ]. id ; $ ( 'token' ). value = hostToken ( currentConfigHost ); const d = await get ( `/api/v1/config?host= ${ encodeURIComponent ( currentConfigHost ) } ` ); original = d . config ; deviceIdentity = d . machine || {}; renderConfig (); markChanged ()} catch ( e ){ $ ( 'configRows' ). innerHTML = `<div class="error"> ${ esc ( e . message ) } </div>` }}
async function saveHost (){ const id = $ ( 'hostId' ). value . trim (), url = $ ( 'hostUrl' ). value . trim (), message = $ ( 'hostMessage' ); try { message . textContent = '正在保存…' ; const local = fleetHosts . find ( x => x . local ), token = hostToken ( local ? . id || '' ); const d = await get ( '/api/v1/hosts' ,{ method : 'PUT' , headers : { 'Content-Type' : 'application/json' , 'Authorization' : `Bearer ${ token } ` }, body : JSON . stringify ({ operation : 'save' , id , url })}); fleetHosts = d . hosts ; refreshHostSelectors (); await loadMachines ()} catch ( e ){ message . textContent = e . message ; message . className = 'error' }}
async function deleteHost ( id ){ if ( ! confirm ( LANG === 'zh' ? `确定删除机器 ${ id } 吗?` : `Delete host ${ id } ?` )) return ; try { const local = fleetHosts . find ( x => x . local ), token = hostToken ( local ? . id || '' ); const d = await get ( '/api/v1/hosts' ,{ method : 'PUT' , headers : { 'Content-Type' : 'application/json' , 'Authorization' : `Bearer ${ token } ` }, body : JSON . stringify ({ operation : 'delete' , id })}); fleetHosts = d . hosts ; currentConfigHost = local ? . id || '' ; refreshHostSelectors (); await loadMachines ()} catch ( e ){ alert ( e . message )}}
2026-09-27 14:48:03 +08:00
async function applyConfig (){ const updates = changedUpdates (); if ( ! Object . keys ( updates ). length ){ $ ( 'saveState' ). textContent = tr ( '没有需要保存的修改' ); return } const local = fleetHosts . find ( x => x . local ), target = currentConfigHost ; try { $ ( 'saveConfig' ). disabled = true ; $ ( 'saveState' ). textContent = tr ( '正在保存并应用…' ); saveHostToken ( target , $ ( 'token' ). value . trim ()); const d = await get ( '/api/v1/fleet/config' ,{ method : 'PUT' , headers : { 'Content-Type' : 'application/json' , 'Authorization' : `Bearer ${ hostToken ( local ? . id || '' ) } ` }, body : JSON . stringify ({ targets : [ target ], updates , tokens : {[ target ] : hostToken ( target )}})}), failed = d . results . filter ( x => ! x . ok ); $ ( 'saveState' ). textContent = failed . length ? failed [ 0 ]. error : ( LANG === 'zh' ? '保存成功' : 'Saved' ); $ ( 'saveState' ). className = failed . length ? 'error' : 'success' ; if ( ! failed . length ) setTimeout ( loadConfig , 1200 )} catch ( e ){ $ ( 'saveState' ). textContent = e . message ; $ ( 'saveState' ). className = 'error' } finally { $ ( 'saveConfig' ). disabled = false }}
2026-09-27 21:20:44 +08:00
async function applyBulkConfig (){ const state = $ ( 'bulkState' ), button = $ ( 'bulkApply' ), targets = [... document . querySelectorAll ( '[data-bulk-target]:checked' )]. map ( x => x . dataset . bulkTarget ), selected = [... document . querySelectorAll ( '[data-bulk-item]:checked' )]. map ( x => x . dataset . bulkItem ); if ( ! targets . length ){ state . textContent = tr ( '请选择至少一台目标机器' ); state . className = 'error' ; return } if ( ! selected . length ){ state . textContent = tr ( '请选择至少一个配置项' ); state . className = 'error' ; return } const updates = {}; selected . forEach ( path =>{ const [ section , key ] = path . split ( '.' , 2 );( updates [ section ] ??= {})[ key ] = bulkOriginal [ section ][ key ]}); const local = fleetHosts . find ( x => x . local ), tokens = {}; document . querySelectorAll ( '[data-bulk-token]' ). forEach ( x =>{ saveHostToken ( x . dataset . bulkToken , x . value . trim ()); tokens [ x . dataset . bulkToken ] = x . value . trim ()}); try { button . disabled = true ; state . textContent = tr ( '正在保存并应用…' ); const d = await get ( '/api/v1/fleet/config' ,{ method : 'PUT' , headers : { 'Content-Type' : 'application/json' , 'Authorization' : `Bearer ${ hostToken ( local ? . id || '' ) } ` }, body : JSON . stringify ({ targets , updates , tokens })}), failed = d . results . filter ( x => ! x . ok ); state . textContent = failed . length ? ( LANG === 'zh' ? `部分失败: ${ failed . map ( x => ` ${ x . id } : ${ x . error } ` ). join ( '; ' ) } ` : `Partial failure: ${ failed . map ( x => ` ${ x . id } : ${ x . error } ` ). join ( '; ' ) } ` ) : ( LANG === 'zh' ? `已同步 ${ selected . length } 项配置到 ${ targets . length } 台机器` : `Synced ${ selected . length } settings to ${ targets . length } hosts` ); state . className = failed . length ? 'error' : 'success' } catch ( e ){ state . textContent = e . message ; state . className = 'error' } finally { button . disabled = false }}
2026-09-27 14:20:38 +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 value = b . dataset . logSort ; if ( logState . sort === value ) logState . order = logState . order === 'asc' ? 'desc' : 'asc' ; else { logState . sort = value ; logState . order = value === 'timestamp' ? 'desc' : 'asc' } loadLogs ()}); $ ( 'refreshLogs' ). onclick = ()=> loadLogs (); $ ( 'resetLogs' ). onclick = ()=>{ setLogRange ( 'today' ); $ ( 'logSeverity' ). value = '' ; $ ( 'logMachine' ). 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 )}};
2026-09-27 14:48:03 +08:00
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 = ()=>{ LANG = language . value ; localStorage . setItem ( 'pigwayLanguage' , LANG ); 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 ){ const map = LANG === 'en' ? EN : ZH , trim = n . nodeValue . trim (); if ( map [ trim ]) n . nodeValue = n . nodeValue . replace ( trim , map [ trim ])}}))). observe ( document . body ,{ childList : true , subtree : true }); document . documentElement . lang = LANG === 'zh' ? 'zh-CN' : 'en' ; setLogRange ( 'today' ); $ ( 'toggleToken' ). onclick = ()=>{ const i = $ ( 'token' ), show = i . type === 'password' ; i . type = show ? 'text' : 'password' ; $ ( 'toggleToken' ). textContent = tr ( show ? '隐藏令牌' : '显示令牌' )}; $ ( 'token' ). onchange = ()=> saveHostToken ( currentConfigHost , $ ( 'token' ). value . trim ()); $ ( 'configHost' ). onchange = ()=>{ currentConfigHost = $ ( 'configHost' ). value ; $ ( 'token' ). value = hostToken ( currentConfigHost ); loadConfig ()}; $ ( 'saveConfig' ). onclick = applyConfig ; loadStatus (); setInterval ( loadStatus , 3000 );
2026-09-27 10:21:12 +08:00
</ script ></ body ></ html >