@@ -8,19 +8,17 @@
< / style >
< style >
. log-filter { display : grid ; grid-template-columns : repeat ( 5 , minmax ( 0 , 1 fr ) ) ; gap : 10 px ; align-items : end } . log-filter label { color : var ( - - muted ) ; font-size : 12 px } . log-filter input , . log-filter select { width : 100 % ; margin-top : 5 px ; border : 1 px solid var ( - - line ) ; border-radius : 7 px ; background : #0d151e ; color : var ( - - text ) ; padding : 9 px } . quick-range { display : flex ; gap : 7 px ; flex-wrap : wrap ; margin : 12 px 0 } . log-table-wrap { overflow : auto ; border : 1 px solid var ( - - line ) ; border-radius : 9 px } . log-table { width : 100 % ; border-collapse : collapse ; min-width : 760 px } . log-table th , . log-table td { text-align : left ; padding : 10 px ; border-bottom : 1 px solid #203040 ; vertical-align : top } . log-table th { position : sticky ; top : 0 ; background : #152130 ; z-index : 1 } . sort-button { border : 0 ; background : transparent ; padding : 0 ; font-weight : 700 } . sort-button : hover { background : transparent } . log-message { font : 12 px / 1.5 ui-monospace , SFMono-Regular , monospace ; white-space : pre-wrap ; word-break : break-word } . log-pager { display : flex ; justify-content : space-between ; align-items : center ; gap : 10 px ; margin-top : 12 px } @ media ( max-width : 800px ) { . log-filter { grid-template-columns : 1 fr 1 fr } } @ media ( max-width : 470px ) { . log-filter { grid-template-columns : 1 fr } }
. header-actions { display : flex ; align-items : end ; gap : 12 px ; flex-wrap : wrap ; justify-content : flex-end } . preference { display : grid ; gap : 4 px ; color : var ( - - muted ) ; font-size : 12 px } . preference select { border : 1 px solid var ( - - line ) ; border-radius : 7 px ; background : #0d151e ; color : var ( - - text ) ; padding : 7 px 28 px 7 px 9 px } [ data-theme = "light" ] { color-scheme : light ; --bg : #eef3f8 ; --panel : #fff ; --panel2 : #f6f9fc ; --line : #c8d4e0 ; --text : #172333 ; --muted : #5d7085 ; --good : #168653 ; --bad : #c93d3d ; --blue : #176fc1 } [ data-theme = "light" ] body { background : radial-gradient ( circle at top right , #d9e9f8 0 , #eef3f8 45 % ) } [ data-theme = "light" ] nav { background : #eef3f8 e8 } [ data-theme = "light" ] button { background : #e7eef6 } [ data-theme = "light" ] button : hover , [ data-theme = "light" ] button . active { background : #d9e9f8 } [ data-theme = "light" ] . card , [ data-theme = "light" ] . panel , [ data-theme = "light" ] . group { box-shadow : 0 10 px 24 px #3452 } [ data-theme = "light" ] . field input , [ data-theme = "light" ] . field select , [ data-theme = "light" ] . service-form input , [ data-theme = "light" ] . service-search , [ data-theme = "light" ] . toolbar input , [ data-theme = "light" ] . log-filter input , [ data-theme = "light" ] . log-filter select , [ data-theme = "light" ] . preference select { background : #fff } [ data-theme = "light" ] . savebar { background : #f7fafc ef ; box-shadow : 0 8 px 25 px #3453 } [ data-theme = "light" ] . log-table th { background : #e7eef6 } [ data-theme = "light" ] . row , [ data-theme = "light" ] . log-table th , [ data-theme = "light" ] . log-table td , [ data-theme = "light" ] . log , [ data-theme = "light" ] . service-list { border-color : #d6e0ea } @ media ( max-width : 470px ) { . header-actions { justify-content : flex-start } }
. header-actions { display : flex ; align-items : center ; gap : 12 px ; flex-wrap : wrap ; justify-content : flex-end } . preference { display : grid ; gap : 4 px ; color : var ( - - muted ) ; font-size : 12 px } . preference select { height : 35 px ; border : 1 px solid var ( - - line ) ; border-radius : 7 px ; background : #0d151e ; color : var ( - - text ) ; padding : 7 px 28 px 7 px 9 px } . header-actions . live { align-self : flex-end ; 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" ] . card , [ data-theme = "light" ] . panel , [ data-theme = "light" ] . group { box-shadow : 0 10 px 24 px #3452 } [ data-theme = "light" ] . field input , [ data-theme = "light" ] . field select , [ data-theme = "light" ] . service-form input , [ data-theme = "light" ] . service-search , [ data-theme = "light" ] . toolbar input , [ data-theme = "light" ] . log-filter input , [ data-theme = "light" ] . log-filter select , [ data-theme = "light" ] . preference select { background : #fff } [ data-theme = "light" ] . 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 } . 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 } . host-checks { display : flex ; gap : 12 px ; flex-wrap : wrap } . host-checks label { display : flex ; gap : 6 px ; align-items : center } . offline-card { border-color : var ( - - bad ) } @ media ( max-width : 900px ) { . host-grid { grid-template-columns : repeat ( 2 , 1 fr ) } } @ media ( max-width : 550px ) { . host-grid { grid-template-columns : 1 fr } . host-head { flex-direction : column } }
< / style >
< / head >
< body > < main >
< header > < div > < h1 > PIGWay Pi Control< / h1 > < div class = "sub" id = "identity" > 正在连接树莓派…< / div > < / div > < div class = "header-actions" > < label class = "preference" > < span > 语言< / span > < select id = "language" > < option value = "zh" > 中文< / option > < option value = "en" > English< / option > < / select > < / label > < label class = "preference" > < span > 颜色模式< / span > < select id = "theme" > < option value = "auto" > 自动< / option > < option value = "dark" > 深色< / option > < option value = "light" > 浅色< / option > < / select > < / label > < div class = "live" > < span class = "dot" id = "dot" > < / span > < span id = "liveText" > 离线< / span > < / div > < / div > < / header >
< nav > < button class = "active" data-tab = "overview" > 运行状态< / button > < button data-tab = "logs" > 事件日志< / button > < button data-tab = "config" > 系统配置< / button > < / nav >
< section id = "overview" > < div class = "grid" >
< div class = "card " > < div class = "muted" > 处理器负载 < / div > < div class = "value" id = "cpu "> -- < / div > < div class = "muted" id = "freq "> -- < / div > < / div > < div class = "card" > < div class = "mu ted "> 处理器温度 < / div > < div class = "value" id = "temp " > -- < / div > < div class = "muted" id = "fan " > -- < / div > < / div > < div class = "card " > < div class = "muted" > 内存占用 < / div > < div class = "value" id = "memory "> -- < / div > < / div > < div class = "c ard " > < div class = "muted " > 磁盘占用 < / div > < div class = "value " id = "disk " > -- < / div > < / div > < div class = "card wide" > < div class = "muted" > 主网络 < / div > < div class = "value " id = "network " > -- < / div > < div class = "muted " id = "address " > -- < / div > < / div > < div class = "card wide" > < div class = "muted" > 显示状态 < / div > < div class = "value" id = "owner "> -- < / div > < div class = "muted" id = "rgb" > -- < / div > < / div >
< / div > < div class = "panel" > < h2 > 当前告警 < / h2 > < div class = "rows" id = "alerts" > < div class = "muted" > 无告警 < / div > < / div > < / div > < div class = "panel" > < h2 > 服务与进程监控 < / h2 > < div class = "rows" id = "watches" > < / div > < / div > < / section >
< section id = "logs" class = "hidden" > < div class = "panel" > < h2 > 事件日志< / h2 > < p class = "muted" > 查询 systemd journal 中仍在保留的全部硬件 Agent 事件。< / p > < div class = "quick-range" > < button data-log-range = "today" > 今天< / button > < button data-log-range = "yesterday" > 昨天< / button > < button data-log-range = "week" > 本周< / button > < button data-log-range = "month" > 本月< / button > < button data-log-range = "all" > 全部< / button > < / div > < div class = "log-filter" > < label > 开始日期< input id = "logStart" type = "date" > < / label > < label > 结束日期< input id = "logEnd" type = "date" > < / label > < label > 严重程度< select id = "logSeverity" > < option value = "" > 全部程度< / option > < option value = "DEBUG" > 调试< / option > < option value = "INFO" > 信息< / option > < option value = "NOTICE" > 注意< / option > < option value = "WARN" > 警告< / option > < option value = "ERROR" > 错误< / option > < option value = "CRITICAL" > 严重< / option > < option value = "ALERT,EMERGENCY" > 紧急< / option > < / select > < / label > < label > 事件类型< select id = "logEvent" > < option value = "" > 全部事件< / option > < / select > < / label > < label > 关键词< input id = "logSearch" placeholder = "搜索日志内容" > < / label > < / div > < div class = "toolbar" > < button id = "refreshLogs" > 查询< / button > < button id = "resetLogs" > 重置< / button > < span class = "muted" id = "logSummary" > < / span > < / div > < div class = "log-table-wrap" > < table class = "log-table" > < thead > < tr > < th > < button class = "sort-button" data-log-sort = "timestamp" > 时间< / button > < / th > < th > < button class = "sort-button" data-log-sort = "severity" > 程度< / button > < / th > < th > < button class = "sort-button" data-log-sort = "event" > 事件类型< / button > < / th > < th > < button class = "sort-button" data-log-sort = "message" > 内容< / button > < / th > < / tr > < / thead > < tbody id = "logRows" > < / tbody > < / table > < / div > < div class = "log-pager" > < button id = "logPrev" > 上一页< / button > < span class = "muted" id = "logPage" > < / span > < button id = "logNext" > 下一页< / button > < / div > < / div > < / section >
< section id = "config" class = "hidden" > < div class = "panel" > < h2 > 系统配置< / h2 > < p class = "muted" > 配置按用途分组,只显示当前模式需要的选项。保存时会备份配置并重启硬件 Agent。< / p > < div class = "toolbar" > < input id = "token" type = "password" placeholder = "配置写入令牌(Bearer Token) " > < button id = "toggleToken" > 显示令牌< / button > < / div > < div class = "config-shell" > < aside class = "config-menu" id = "configMenu" > < / aside > < div id = "configRows" > < / div > < / div > < div class = "savebar" > < button id = "saveConfig" > 保存并应用< / button > < span id = "saveState" class = "muted" > 尚未修改< / span > < / div > < / div > < / section >
< section id = "overview" > < div id = "hostCards" > < div class = "panel muted" > 正在读取主机状态… < / div > < / div > < / section >
< section id = "logs" class = "hidden" > < div class = "panel " > < h2 > 事件日志 < / h2 > < p class = "muted" > 查询 systemd journal 中仍在保留的全部硬件 Agent 事件。 < / p > < div class = "quick-range" > < button data-log-range = "today "> 今天 < / button > < button data-log-range = "yesterday" > 昨天 < / button > < button data-log-range = "week" > 本周 < / button > < button data-log-range = "month" > 本月 < / button > < button data-log-range = "all "> 全部 < / button > < / div > < div class = "log-filter" > < label > 开始日期 < input id = "logStart" type = "date" > < / label > < label > 结束日期 < input id = "logEnd" type = "da te"> < / label > < label > 严重程度 < select id = "logSeverity" > < option value = "" > 全部程度 < / option > < option value = "DEBUG" > 调试 < / option > < option value = "INFO" > 信息 < / option > < option value = "NOTICE" > 注意 < / option > < option value = "WARN " > 警告 < / option > < option value = "ERROR" > 错误 < / option > < option value = "CRITICAL " > 严重 < / option > < option value = "ALERT,EMERGENCY" > 紧急 < / option > < / select > < / label > < label > 机器标识 < select id = "logMachine" > < option value = "" > 全部机器 < / option > < / select > < / label > < label > 事件类型 < select id = "logEvent" > < option value = "" > 全部事件 < / option > < / select > < / label > < label > 关键词 < input id = "logSearch" placeholder = "搜索日志内容 "> < / label > < / div > < div class = "toolb ar" > < button id = "refreshLogs " > 查询 < / button > < button id = "resetLogs" > 重置 < / button > < span class = "muted " id = "logSummary " > < / span > < / div > < div class = "log-table-wrap" > < table class = "log-table" > < thead > < tr > < th > < button class = "sort-button" data-log-sort = "timestamp" > 时间 < / button > < / th > < th > < button class = "sort-button " data-log-sort = "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 >
< section id = "config" class = "hidden" > < div class = "panel" > < h2 > 系统配置 < / h2 > < p class = "muted" > 配置按用途分组,只显示当前模式需要的选项。保存时会备份配置并重启硬件 Agent。 < / p > < div class = "toolbar" > < label class = "host-select" > 配置机器 < select id = "configHost" > < / select > < / label > < input id = "token" type = "password" placeholder = "当前机器配置令牌(Bearer Token) " > < button id = "toggleToken" > 显示令牌 < / button > < / div > < div class = "group" > < div class = "group-head" > < div > < h3 > 多机配置同步 < / h3 > < div class = "hint" > 勾选目标机器后,将当前页面中已修改的配置项批量同步到这些机器。 < / div > < / div > < / div > < div id = "syncHosts" class = "host-checks" > < / div > < / div > < div class = "config-shell" > < aside class = "config-menu" id = "configMenu" > < / aside > < div id = "configRows" > < / div > < / div > < div class = "savebar" > < button id = "saveConfig" > 保存到当前机器 < / button > < button id = "syncConfig" > 批量同步已修改项 < / button > < span id = "saveState" class = "muted" > 尚未修改 < / span > < / div > < / div > < / section >
< / main > < script >
const LANG = localStorage . getItem ( 'pigwayLanguage' ) || 'zh' ;
let LANG = localStorage . getItem ( 'pigwayLanguage' ) || 'zh' ;
const EN = {
'语言' : 'Language' , '颜色模式' : 'Appearance' , '中文' : '中文' , '自动' : 'Auto' , '深色' : 'Dark' , '浅色' : 'Light' , '正在连接树莓派…' : 'Connecting to Raspberry Pi…' , '在线' : 'Online' , '离线' : 'Offline' ,
'运行状态' : 'Status' , '事件日志' : 'Event log' , '系统配置' : 'Settings' , '处理器负载' : 'CPU load' , '处理器温度' : 'CPU temperature' , '内存占用' : 'Memory usage' , '磁盘占用' : 'Disk usage' , '主网络' : 'Primary network' , '显示状态' : 'Display status' , '当前告警' : 'Active alerts' , '无告警' : 'No alerts' , '服务与进程监控' : 'Service and process monitors' ,
@@ -38,19 +36,21 @@ Object.assign(EN,{
'风扇启动温度' : 'Fan start temperature' , '二档起始温度' : 'Level 2 temperature' , '三档起始温度' : 'Level 3 temperature' , '四档起始温度' : 'Level 4 temperature' , '全速起始温度' : 'Full-speed temperature' , '降档温度回差' : 'Downshift hysteresis' , '传感器采样周期' : 'Sensor interval' , 'OLED 刷新周期' : 'OLED refresh interval' , 'OLED 页面轮播周期' : 'OLED page interval' , '网络检查周期' : 'Network check interval' , '服务检查周期' : 'Service check interval' , '健康检查周期' : 'Health check interval' , '第一行纵坐标' : 'Row 1 Y position' , '第二行纵坐标' : 'Row 2 Y position' , '第三行纵坐标' : 'Row 3 Y position' , '第四行纵坐标' : 'Row 4 Y position' , '树莓派电源灯' : 'Raspberry Pi power LED' , '树莓派活动灯' : 'Raspberry Pi activity LED' , '监听地址' : 'Bind address' , '监听端口' : 'Listen port' , '默认日志条数' : 'Default log entries' ,
'处理器、温度与散热' : 'CPU, temperature and cooling' , '供电与欠压' : 'Power and undervoltage' , '内存' : 'Memory' , '存储' : 'Storage' , '网络与 Wi-Fi' : 'Network and Wi-Fi' , '服务与进程' : 'Services and processes' , '仅在没有当前告警时生效;告警会立即抢占。' : 'Only active when there are no alerts; alerts take priority immediately.' , 'MCU 内置速度档位。' : 'MCU built-in speed level.' , '仅流水灯和呼吸灯使用。' : 'Used only by flow and breathe modes.' , '硬件校准项,通常保持 10 毫秒。' : 'Hardware calibration setting; normally keep 10 ms.' , '单位 dBm,数值越接近 0 信号越强。' : 'Unit: dBm. Values closer to 0 mean a stronger signal.' , '单位 dBm,应高于弱信号阈值。' : 'Unit: dBm. Must be above the weak-signal threshold.' , '避免临界温度附近频繁切换档位。' : 'Prevents rapid level changes near a threshold.' , '选择“关闭”时由程序熄灭板载灯。' : 'Choose “Off” to let the program turn off the board LED.'
} ) ;
const tr = s => LANG === 'en' ? ( EN [ s ] || s ) : s ;
function translateNode ( root = document ) { if ( LANG !== 'en' ) return ; const walker = document . createTreeWalker ( root , NodeFilter . SHOW _TEXT ) ; let n ; while ( n = walker . nextNode ( ) ) { const raw = n . nodeValue , trim = raw . trim ( ) ; if ( trim && EN [ trim ] ) n . nodeValue = raw . replace ( trim , EN [ trim ] ) } root . querySelectorAll ? . ( '[placeholder],[title]' ) . forEach ( e => [ 'placeholder' , 'title' ] . forEach ( a => { const v = e . getAttribute ( a ) ; if ( v && EN [ v ] ) e . setAttribute ( a , EN [ v ] ) } ) ) }
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' } ) ;
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' } ) ;
const ZH = Object . fromEntries ( Object . entries ( EN ) . map ( ( [ zh , en ] ) => [ en , zh ] ) ) , tr = s => LANG === 'en' ? ( EN [ s ] || s ) : ( ZH [ s ] || s ) ;
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]' ) . forEach ( e => [ 'placeholder' , 'title' ] . forEach ( a => { const v = e . getAttribute ( a ) ; if ( v ) e . setAttribute ( a , translate ( v ) ) } ) ) }
function applyTheme ( value ) { const resolved = value === 'auto' ? ( matchMedia ( '(prefers-color-scheme: dark)' ) . matches ? 'dark' : 'light' ) : value ; document . documentElement . dataset . theme = resolved }
const $ = id => document . getElementById ( id ) , tabs = [ ... document . querySelectorAll ( 'nav button' ) ] ; let original = { } ; const esc = s => String ( s ? ? '' ) . replace ( /[&<>"']/g , c => ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } [ c ] ) ) ;
const sectionMeta = { led : [ '灯光设置' , '正常灯效与告警颜色' ] , alerts : [ '告警阈值' , '处理器、内存、温度、磁盘和 Wi-Fi' ] , fan : [ '风扇温控' , '温度档位与降档回差' ] , timing : [ '刷新周期' , '传感器、屏幕、网络和健康检查' ] , display : [ '屏幕布局' , 'OLED 四行文字位置' ] , dark _mode : [ '板载指示灯' , '树莓派电源灯与活动灯' ] , services : [ '服务监控' , '监控 systemd 服务' ] , processes : [ '进程监控' , '监控普通进程关键字' ] , api : [ 'Web 与 API' , '监听地址、端口和日志数量' ] } ;
const labels = { 'led.normal_mode' : '正常状态灯效' , 'led.normal_r' : '红色通道' , 'led.normal_g' : '绿色通道' , 'led.normal_b' : '蓝色通道' , 'led.normal_flash_on_ms' : '亮灯时间' , 'led.normal_flash_off_ms' : '灭灯时间' , 'led.normal_effect_speed' : '内置效果速度' , 'led.normal_effect_color' : '内置效果颜色' , 'led.warning_flash_on_ms' : '普通告警亮灯' , 'led.warning_flash_off_ms' : '普通告警灭灯' , 'led.critical_flash_on_ms' : '严重告警亮灯' , 'led.critical_flash_off_ms' : '严重告警灭灯' , 'led.emergency_flash_on_ms' : '紧急告警亮灯' , 'led.emergency_flash_off_ms' : '紧急告警灭灯' , 'led.write_delay_ms' : 'RGB 寄存器写入间隔' , 'alerts.cpu_high' : '处理器高负载阈值' , 'alerts.cpu_recover' : '处理器恢复阈值' , 'alerts.cpu_high_seconds' : '处理器持续时间' , 'alerts.memory_high' : '内存高占用阈值' , 'alerts.memory_recover' : '内存恢复阈值' , 'alerts.memory_high_seconds' : '内存持续时间' , 'alerts.temp_high' : '高温告警阈值' , 'alerts.temp_critical' : '严重高温阈值' , 'alerts.disk_high' : '磁盘高占用阈值' , 'alerts.disk_critical' : '磁盘严重阈值' , 'alerts.wifi_weak' : 'Wi-Fi 弱信号阈值' , 'alerts.wifi_recover' : 'Wi-Fi 恢复阈值' , 'alerts.wifi_weak_seconds' : '弱信号持续时间' , 'fan.start' : '风扇启动温度' , 'fan.level2' : '二档起始温度' , 'fan.level3' : '三档起始温度' , 'fan.level4' : '四档起始温度' , 'fan.max' : '全速起始温度' , 'fan.hysteresis' : '降档温度回差' , 'timing.sensor_interval' : '传感器采样周期' , 'timing.oled_refresh_interval' : 'OLED 刷新周期' , 'timing.page_interval' : 'OLED 页面轮播周期' , 'timing.network_interval' : '网络检查周期' , 'timing.service_interval' : '服务检查周期' , 'timing.health_interval' : '健康检查周期' , 'display.row1_y' : '第一行纵坐标' , 'display.row2_y' : '第二行纵坐标' , 'display.row3_y' : '第三行纵坐标' , 'display.row4_y' : '第四行纵坐标' , 'dark_mode.pi_pwr_led' : '树莓派电源灯' , 'dark_mode.pi_act_led' : '树莓派活动灯' , 'api.bind' : '监听地址' , 'api.port' : '监听端口' , 'api.log_limit' : '默认日志条数' } ;
const $ = id => document . getElementById ( id ) , tabs = [ ... document . querySelectorAll ( 'nav button' ) ] ; let original = { } , fleetHosts = [ ] , currentConfigHost = '' ; const esc = s => String ( s ? ? '' ) . replace ( /[&<>"']/g , c => ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } [ c ] ) ) ;
const sectionMeta = { device : [ '设备标识' , '自定义本机显示名称和稳定标识' ] , hosts : [ '机器列表' , '登记需要聚合监控的其他节点' ] , led : [ '灯光设置' , '正常灯效与告警颜色' ] , alerts : [ '告警阈值' , '处理器、内存、温度、磁盘和 Wi-Fi' ] , fan : [ '风扇温控' , '温度档位与降档回差' ] , timing : [ '刷新周期' , '传感器、屏幕、网络和健康检查' ] , display : [ '屏幕布局' , 'OLED 四行文字位置' ] , dark _mode : [ '板载指示灯' , '树莓派电源灯与活动灯' ] , services : [ '服务监控' , '监控 systemd 服务' ] , processes : [ '进程监控' , '监控普通进程关键字' ] , api : [ 'Web 与 API' , '监听地址、端口和日志数量' ] } ;
const labels = { 'led.normal_mode' : '正常状态灯效' , 'led.normal_r' : '红色通道' , 'led.normal_g' : '绿色通道' , 'led.normal_b' : '蓝色通道' , 'led.normal_flash_on_ms' : '亮灯时间' , 'led.normal_flash_off_ms' : '灭灯时间' , 'led.normal_effect_speed' : '内置效果速度' , 'led.normal_effect_color' : '内置效果颜色' , 'led.warning_flash_on_ms' : '普通告警亮灯' , 'led.warning_flash_off_ms' : '普通告警灭灯' , 'led.critical_flash_on_ms' : '严重告警亮灯' , 'led.critical_flash_off_ms' : '严重告警灭灯' , 'led.emergency_flash_on_ms' : '紧急告警亮灯' , 'led.emergency_flash_off_ms' : '紧急告警灭灯' , 'led.write_delay_ms' : 'RGB 寄存器写入间隔' , 'alerts.cpu_high' : '处理器高负载阈值' , 'alerts.cpu_recover' : '处理器恢复阈值' , 'alerts.cpu_high_seconds' : '处理器持续时间' , 'alerts.memory_high' : '内存高占用阈值' , 'alerts.memory_recover' : '内存恢复阈值' , 'alerts.memory_high_seconds' : '内存持续时间' , 'alerts.temp_high' : '高温告警阈值' , 'alerts.temp_critical' : '严重高温阈值' , 'alerts.disk_high' : '磁盘高占用阈值' , 'alerts.disk_critical' : '磁盘严重阈值' , 'alerts.wifi_weak' : 'Wi-Fi 弱信号阈值' , 'alerts.wifi_recover' : 'Wi-Fi 恢复阈值' , 'alerts.wifi_weak_seconds' : '弱信号持续时间' , 'fan.start' : '风扇启动温度' , 'fan.level2' : '二档起始温度' , 'fan.level3' : '三档起始温度' , 'fan.level4' : '四档起始温度' , 'fan.max' : '全速起始温度' , 'fan.hysteresis' : '降档温度回差' , 'timing.sensor_interval' : '传感器采样周期' , 'timing.oled_refresh_interval' : 'OLED 刷新周期' , 'timing.page_interval' : 'OLED 页面轮播周期' , 'timing.network_interval' : '网络检查周期' , 'timing.service_interval' : '服务检查周期' , 'timing.health_interval' : '健康检查周期' , 'display.row1_y' : '第一行纵坐标' , 'display.row2_y' : '第二行纵坐标' , 'display.row3_y' : '第三行纵坐标' , 'display.row4_y' : '第四行纵坐标' , 'dark_mode.pi_pwr_led' : '树莓派电源灯' , 'dark_mode.pi_act_led' : '树莓派活动灯' , 'api.bind' : '监听地址' , 'api.port' : '监听端口' , 'api.log_limit' : '默认日志条数' , 'device.identifier' : '机器标识' , 'device.name' : '显示名称' } ;
[ 'cpu' , 'power' , 'memory' , 'storage' , 'network' , 'service' ] . forEach ( g => [ 'r' , 'g' , 'b' ] . forEach ( c => labels [ ` led. ${ g } _ ${ c } ` ] = ` ${ ( { r : '红' , g : '绿' , b : '蓝' } )[c]}色通道 ` ) ) ;
const hints = { 'led.normal_mode' : '仅在没有当前告警时生效;告警会立即抢占。' , 'led.normal_r' : '范围 0– 255。' , 'led.normal_g' : '范围 0– 255。' , 'led.normal_b' : '范围 0– 255。' , 'led.normal_flash_on_ms' : '范围 50– 60000 毫秒。' , 'led.normal_flash_off_ms' : '范围 50– 60000 毫秒。' , 'led.normal_effect_speed' : 'MCU 内置速度档位。' , 'led.normal_effect_color' : '仅流水灯和呼吸灯使用。' , 'led.write_delay_ms' : '硬件校准项,通常保持 10 毫秒。' , 'alerts.wifi_weak' : '单位 dBm,数值越接近 0 信号越强。' , 'alerts.wifi_recover' : '单位 dBm,应高于弱信号阈值。' , 'fan.hysteresis' : '避免临界温度附近频繁切换档位。' , 'api.bind' : '0.0.0.0 允许局域网访问;127.0.0.1 仅本机。' , 'api.port' : '范围 1– 65535;保存后 API 自动重启。' , 'dark_mode.pi_pwr_led' : '选择“关闭”时由程序熄灭板载灯。' , 'dark_mode.pi_act_led' : '选择“关闭”时由程序熄灭板载灯。' } ;
[ 'normal' , 'cpu' , 'power' , 'memory' , 'storage' , 'network' , 'service' ] . forEach ( g => [ 'r' , 'g' , 'b' ] . forEach ( c => hints [ ` led. ${ g } _ ${ c } ` ] = '范围 0– 255。' ) ) ;
const options = { 'led.normal_mode' : [ [ 'off' , '全灭' ] , [ 'solid' , '自定义颜色常亮' ] , [ 'flash' , '自定义颜色闪烁' ] , [ 'flow' , '流水灯' ] , [ 'breathe' , '呼吸灯' ] , [ 'marquee' , '跑马灯' ] , [ 'rainbow' , '彩虹灯' ] , [ 'colorful' , '炫彩灯' ] ] , 'led.normal_effect_speed' : [ [ '1' , '慢速' ] , [ '2' , '中速' ] , [ '3' , '快速' ] ] , 'led.normal_effect_color' : [ [ 'red' , '红色' ] , [ 'green' , '绿色' ] , [ 'blue' , '蓝色' ] , [ 'yellow' , '黄色' ] , [ 'purple' , '紫色' ] , [ 'cyan' , '青色' ] , [ 'white' , '白色' ] ] , 'dark_mode.pi_pwr_led' : [ [ 'off' , '关闭' ] , [ 'on' , '不干预' ] ] , 'dark_mode.pi_act_led' : [ [ 'off' , '关闭' ] , [ 'on' , '不干预' ] ] } ;
const numberRules = { 'led.normal_r' : [ 0 , 255 , 1 ] , 'led.normal_g' : [ 0 , 255 , 1 ] , 'led.normal_b' : [ 0 , 255 , 1 ] , 'led.normal_flash_on_ms' : [ 50 , 60000 , 10 ] , 'led.normal_flash_off_ms' : [ 50 , 60000 , 10 ] , 'led.warning_flash_on_ms' : [ 50 , 60000 , 10 ] , 'led.warning_flash_off_ms' : [ 50 , 60000 , 10 ] , 'led.critical_flash_on_ms' : [ 50 , 60000 , 10 ] , 'led.critical_flash_off_ms' : [ 50 , 60000 , 10 ] , 'led.emergency_flash_on_ms' : [ 50 , 60000 , 10 ] , 'led.emergency_flash_off_ms' : [ 50 , 60000 , 10 ] , 'led.write_delay_ms' : [ 1 , 1000 , 1 ] , 'alerts.cpu_high' : [ 0 , 100 , 1 ] , 'alerts.cpu_recover' : [ 0 , 100 , 1 ] , 'alerts.cpu_high_seconds' : [ 1 , 86400 , 1 ] , 'alerts.memory_high' : [ 0 , 100 , 1 ] , 'alerts.memory_recover' : [ 0 , 100 , 1 ] , 'alerts.memory_high_seconds' : [ 1 , 86400 , 1 ] , 'alerts.temp_high' : [ 0 , 120 , . 1 ] , 'alerts.temp_critical' : [ 0 , 120 , . 1 ] , 'alerts.disk_high' : [ 0 , 100 , 1 ] , 'alerts.disk_critical' : [ 0 , 100 , 1 ] , 'alerts.wifi_weak' : [ - 120 , 0 , 1 ] , 'alerts.wifi_recover' : [ - 120 , 0 , 1 ] , 'alerts.wifi_weak_seconds' : [ 1 , 86400 , 1 ] , 'fan.start' : [ 0 , 100 , . 1 ] , 'fan.level2' : [ 0 , 100 , . 1 ] , 'fan.level3' : [ 0 , 100 , . 1 ] , 'fan.level4' : [ 0 , 100 , . 1 ] , 'fan.max' : [ 0 , 100 , . 1 ] , 'fan.hysteresis' : [ 0 , 20 , . 1 ] , 'timing.sensor_interval' : [ . 2 , 3600 , . 1 ] , 'timing.oled_refresh_interval' : [ . 2 , 3600 , . 1 ] , 'timing.page_interval' : [ 1 , 3600 , . 1 ] , 'timing.network_interval' : [ 1 , 3600 , . 1 ] , 'timing.service_interval' : [ 1 , 3600 , . 1 ] , 'timing.health_interval' : [ . 2 , 3600 , . 1 ] , 'display.row1_y' : [ 0 , 31 , 1 ] , 'display.row2_y' : [ 0 , 31 , 1 ] , 'display.row3_y' : [ 0 , 31 , 1 ] , 'display.row4_y' : [ 0 , 31 , 1 ] , 'api.port' : [ 1 , 65535 , 1 ] , 'api.log_limit' : [ 1 , 1000 , 1 ] } ; [ 'cpu' , 'power' , 'memory' , 'storage' , 'network' , 'service' ] . forEach ( g => [ 'r' , 'g' , 'b' ] . forEach ( c => numberRules [ ` led. ${ g } _ ${ c } ` ] = [ 0 , 255 , 1 ] ) ) ;
const units = { 'led.normal_flash_on_ms' : '毫秒' , 'led.normal_flash_off_ms' : '毫秒' , 'led.warning_flash_on_ms' : '毫秒' , 'led.warning_flash_off_ms' : '毫秒' , 'led.critical_flash_on_ms' : '毫秒' , 'led.critical_flash_off_ms' : '毫秒' , 'led.emergency_flash_on_ms' : '毫秒' , 'led.emergency_flash_off_ms' : '毫秒' , 'led.write_delay_ms' : '毫秒' , 'alerts.cpu_high' : '%' , 'alerts.cpu_recover' : '%' , 'alerts.cpu_high_seconds' : '秒' , 'alerts.memory_high' : '%' , 'alerts.memory_recover' : '%' , 'alerts.memory_high_seconds' : '秒' , 'alerts.temp_high' : '°C' , 'alerts.temp_critical' : '°C' , 'alerts.disk_high' : '%' , 'alerts.disk_critical' : '%' , 'alerts.wifi_weak' : 'dBm' , 'alerts.wifi_recover' : 'dBm' , 'alerts.wifi_weak_seconds' : '秒' , 'fan.start' : '°C' , 'fan.level2' : '°C' , 'fan.level3' : '°C' , 'fan.level4' : '°C' , 'fan.max' : '°C' , 'fan.hysteresis' : '°C' , 'timing.sensor_interval' : '秒' , 'timing.oled_refresh_interval' : '秒' , 'timing.page_interval' : '秒' , 'timing.network_interval' : '秒' , 'timing.service_interval' : '秒' , 'timing.health_interval' : '秒' , 'display.row1_y' : '像素' , 'display.row2_y' : '像素' , 'display.row3_y' : '像素' , 'display.row4_y' : '像素' , 'api.port' : '端口' , 'api.log_limit' : '条' } ;
const sectionOrder = [ 'led' , 'alerts' , 'fan' , 'timing' , 'display' , 'dark_mode' , 'services' , 'processes' , 'api' ] , alertColorGroups = [ [ 'cpu' , '处理器、温度与散热' ] , [ 'power' , '供电与欠压' ] , [ 'memory' , '内存' ] , [ 'storage' , '存储' ] , [ 'network' , '网络与 Wi-Fi' ] , [ 'service' , '服务与进程' ] ] ;
const sectionOrder = [ 'device' , 'hosts' , ' led', 'alerts' , 'fan' , 'timing' , 'display' , 'dark_mode' , 'services' , 'processes' , 'api' ] , alertColorGroups = [ [ 'cpu' , '处理器、温度与散热' ] , [ 'power' , '供电与欠压' ] , [ 'memory' , '内存' ] , [ 'storage' , '存储' ] , [ 'network' , '网络与 Wi-Fi' ] , [ 'service' , '服务与进程' ] ] ;
const ownerNames = { NORMAL _HOME : '正常主页' , ROOT _READONLY : '根文件系统只读' , TEMP _CRITICAL : '处理器温度严重' , POWER _UNDERVOLT : '供电欠压' , THROTTLED : '处理器降频' , FREQ _CAPPED : '频率受限' , NETWORK _DOWN : '网络断开' , INTERNET _DOWN : '互联网断开' , SERVICE _DOWN : '服务停止' , PROCESS _DOWN : '进程停止' , DISK _CRITICAL : '磁盘严重不足' , TEMP _HIGH : '处理器温度偏高' , CPU _HIGH : '处理器负载过高' , MEMORY _HIGH : '内存占用过高' , DISK _HIGH : '磁盘空间不足' , WIFI _WEAK : 'Wi-Fi 信号弱' } , rgbNames = { OFF : '全灭' , NORMAL _SOLID : '正常 · 自定义常亮' , NORMAL _FLASH : '正常 · 自定义闪烁' , NORMAL _FLOW : '正常 · 流水灯' , NORMAL _BREATHE : '正常 · 呼吸灯' , NORMAL _MARQUEE : '正常 · 跑马灯' , NORMAL _RAINBOW : '正常 · 彩虹灯' , NORMAL _COLORFUL : '正常 · 炫彩灯' } , eventNames = { START : '程序启动' , STOP : '程序停止' , ALERT _ACTIVE : '告警出现' , ALERT _RECOVERED : '告警恢复' , DISPLAY _OWNER : '显示对象切换' , INTERNET _DOWN : '互联网断开' , INTERNET _RECOVERED : '互联网恢复' , NET _SWITCH : '网络接口切换' , NETWORK _DOWN : '网络断开' , NETWORK _RECOVERED : '网络恢复' , POWER _LOW : '供电欠压' , POWER _RECOVERED : '供电恢复' , POWER _HISTORY : '历史电源状态' , HISTORY _FLAG : '历史电源状态' , ROOT _READONLY : '根文件系统只读' , RGB _ERROR : 'RGB 控制错误' , RGB _MODE : 'RGB 模式切换' , RGB _RECOVERED : 'RGB 控制恢复' , SYSTEM : '系统事件' , WATCH _DOWN : '监控对象停止' , WATCH _RECOVERED : '监控对象恢复' } ;
function ownerName ( id ) { const [ type , suffix ] = String ( id || '' ) . split ( '|' , 2 ) , name = ownerNames [ type ] || type ; return tr ( name ) + ( suffix ? ` · ${ suffix } ` : '' ) } function rgbName ( mode ) { if ( rgbNames [ mode ] ) return tr ( rgbNames [ mode ] ) ; const p = String ( mode || '' ) . split ( '_' ) , groups = { CPU : '处理器' , POWER : '供电' , MEMORY : '内存' , STORAGE : '存储' , NETWORK : '网络' , SERVICE : '服务' } , levels = { WARNING : '普通告警' , CRITICAL : '严重告警' , EMERGENCY : '紧急告警' } ; return ` ${ tr ( groups [ p [ 0 ] ] || p [ 0 ] ) } · ${ tr ( levels [ p [ 1 ] ] || p [ 1 ] ) } ${ LANG === 'zh' ? '闪烁' : ' flash' } ` } function eventName ( id ) { return tr ( eventNames [ id ] || ( LANG === 'zh' ? ` 未知事件( ${ id } ) ` : String ( id ) . toLowerCase ( ) . replaceAll ( '_' , ' ' ) ) ) }
function field ( section , key , value , extra = '' ) { const id = ` ${ section } . ${ key } ` , opts = options [ id ] , rule = numberRules [ id ] , label = tr ( labels [ id ] || key ) , help = tr ( hints [ id ] || '' ) , unit = tr ( units [ id ] || '' ) ; let control ; if ( opts ) control = ` <select data-section=" ${ esc ( section ) } " data-key=" ${ esc ( key ) } "> ${ opts . map ( ( [ v , l ] ) => ` <option value=" ${ v } " ${ String ( value ) === v ? 'selected' : '' } > ${ tr ( l ) } </option> ` ) . join ( '' ) } </select> ` ; else { const input = ` <input ${ rule ? 'type="number"' : '' } ${ rule ? ` min=" ${ rule [ 0 ] } " max=" ${ rule [ 1 ] } " step=" ${ rule [ 2 ] } " ` : '' } data-section=" ${ esc ( section ) } " data-key=" ${ esc ( key ) } " value=" ${ esc ( value ) } "> ` ; control = unit ? ` <div class="input-unit"> ${ input } <span class="unit"> ${ unit } </span></div> ` : input } return ` <div class="field ${ extra } "><label> ${ esc ( label ) } ${ control } </label> ${ help ? ` <div class="hint"> ${ esc ( help ) } </div> ` : '' } </div> ` }
@@ -59,25 +59,38 @@ function normalGroup(v){return`<div class="group"><div class="group-head"><div><
function renderLed ( v ) { const flash = [ 'warning' , 'critical' , 'emergency' ] . map ( ( x , i ) => ` <div class="group"><h3> ${ tr ( [ '普通告警闪烁节奏' , '严重告警闪烁节奏' , '紧急告警闪烁节奏' ] [ i ] ) } </h3><div class="fields"> ${ field ( 'led' , ` ${ x } _flash_on_ms ` , v [ ` ${ x } _flash_on_ms ` ] ) } ${ field ( 'led' , ` ${ x } _flash_off_ms ` , v [ ` ${ x } _flash_off_ms ` ] ) } </div></div> ` ) . join ( '' ) , advanced = v . write _delay _ms === undefined ? '' : ` <div class="group"><h3>硬件高级参数</h3><div class="fields"> ${ field ( 'led' , 'write_delay_ms' , v . write _delay _ms ) } </div></div> ` ; return normalGroup ( v ) + ` <div class="group"><div class="group-head"><div><h3>告警对象颜色</h3><div class="hint">告警对象决定颜色,严重程度决定闪烁速度。</div></div></div> ${ alertColorGroups . map ( ( [ p , t ] ) => rgbGroup ( v , p , tr ( t ) ) ) . join ( '' ) } </div> ` + flash + advanced }
function renderGeneral ( s , v ) { const [ t , d ] = sectionMeta [ s ] || [ s , '' ] ; return ` <div class="group"><div class="group-head"><div><h3> ${ tr ( t ) } </h3><div class="hint"> ${ tr ( d ) } </div></div></div><div class="fields"> ${ Object . entries ( v ) . map ( ( [ k , x ] ) => field ( s , k , x ) ) . join ( '' ) || '<div class="muted">暂无配置项</div>' } </div></div> ` }
function renderServices ( ) { return ` <div class="group"><div class="group-head"><div><h3>当前系统服务</h3><div class="hint">点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。</div></div><button id="refreshServices">刷新列表</button></div><input class="service-search" id="serviceSearch" placeholder="搜索服务名称或说明"><div class="service-list rows" id="systemServices"><div class="muted">正在读取 systemd 服务…</div></div></div><div class="group"><h3>已监控服务</h3><div class="hint">“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。</div><div class="rows" id="monitoredServices"></div></div><div class="group"><h3 id="serviceFormTitle">新建服务监控</h3><div class="service-form"><label>OLED 显示名称<input id="monitorName" maxlength="64" placeholder="例如:DOCKER"></label><label>systemd 服务名称<input id="monitorTarget" maxlength="128" placeholder="例如:docker.service"></label><label class="full">备注说明<input id="monitorNote" maxlength="120" placeholder="例如:家庭相册容器服务"></label><div class="full"><button id="saveMonitor">保存监控</button> <button id="resetMonitor">清空</button> <span id="serviceMessage" class="muted"></span></div></div></div> ` }
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> ` }
function updateConditional ( ) { const mode = document . querySelector ( '[data-section="led"][data-key="normal_mode"]' ) ? . value || 'off' ; document . querySelectorAll ( '.normal-field' ) . forEach ( x => x . classList . toggle ( 'hidden' , ! x . dataset . modes . split ( ' ' ) . includes ( mode ) ) ) ; updateSwatches ( ) } function updateSwatches ( ) { document . querySelectorAll ( '[data-swatch]' ) . forEach ( s => { const p = s . dataset . swatch , read = c => Math . max ( 0 , Math . min ( 255 , Number ( document . querySelector ( ` [data-section="led"][data-key=" ${ p } _ ${ c } "] ` ) ? . value || 0 ) ) ) , hex = '#' + [ 'r' , 'g' , 'b' ] . map ( c => Math . round ( read ( c ) ) . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' ) ; s . style . background = hex ; const picker = document . querySelector ( ` [data-color-prefix=" ${ p } "] ` ) ; if ( picker ) picker . value = hex } ) }
function renderConfig ( ) { const ss = sectionOrder . filter ( s => original [ s ] ) ; $ ( 'configMenu' ) . innerHTML = ss . map ( ( s , i ) => ` <button class=" ${ i === 0 ? 'active' : '' } " data-config-section=" ${ s } "> ${ tr ( sectionMeta [ s ] ? . [ 0 ] || s ) } </button> ` ) . join ( '' ) ; $ ( 'configRows' ) . innerHTML = ss . map ( ( s , i ) => ` <section class="config-page ${ i === 0 ? 'active' : '' } " data-config-page=" ${ s } "><h2> ${ tr ( sectionMeta [ s ] ? . [ 0 ] || s ) } </h2><p class="muted"> ${ tr ( sectionMeta [ s ] ? . [ 1 ] || '' ) } </p> ${ s === 'led' ? renderLed ( original [ s ] ) : s === 'services' ? renderServices ( ) : renderGeneral ( s , original [ s ] ) } </section> ` ) . join ( '' ) ; $ ( 'configMenu' ) . onclick = e => { const b = e . target . closest ( 'button' ) ; if ( ! b ) return ; document . querySelectorAll ( '[data-config-section]' ) . forEach ( x => x . classList . toggle ( 'active' , x === b ) ) ; document . querySelectorAll ( '[data-config-page]' ) . forEach ( x => x . classList . toggle ( 'active' , x . dataset . configPage === b . dataset . configSection ) ) ; if ( b . dataset . configSection === 'services' ) loadServices ( ) } ; $ ( 'configRows' ) . oninput = e => { if ( e . target . matches ( '[data-color-prefix]' ) ) { const p = e . target . dataset . colorPrefix , v = e . target . value ; [ 'r' , 'g' , 'b' ] . forEach ( ( c , i ) => { document . querySelector ( ` [data-section="led"][data-key=" ${ p } _ ${ c } "] ` ) . value = parseInt ( v . slice ( 1 + i * 2 , 3 + i * 2 ) , 16 ) } ) } if ( e . target . matches ( '[data-section="led"],[data-color-prefix]' ) ) updateConditional ( ) ; if ( e . target . matches ( '[data-section]' ) ) markChanged ( ) } ; $ ( 'refreshServices' ) ? . addEventListener ( 'click' , loadServices ) ; $ ( 'serviceSearch' ) ? . addEventListener ( 'input' , renderServiceLists ) ; $ ( 'saveMonitor' ) ? . addEventListener ( 'click' , saveMonitor ) ; $ ( 'resetMonitor' ) ? . addEventListener ( 'click' , resetMonitorForm ) ; updateConditional ( ) }
function renderConfig ( ) { const ss = sectionOrder . filter ( s => original [ s ] ) ; $ ( 'configMenu' ) . innerHTML = ss . map ( ( s , i ) => ` <button class=" ${ i === 0 ? 'active' : '' } " data-config-section=" ${ s } "> ${ tr ( sectionMeta [ s ] ? . [ 0 ] || s ) } </button> ` ) . join ( '' ) ; $ ( 'configRows' ) . innerHTML = ss . map ( ( s , i ) => ` <section class="config-page ${ i === 0 ? 'active' : '' } " data-config-page=" ${ s } "><h2> ${ tr ( sectionMeta [ s ] ? . [ 0 ] || s ) } </h2><p class="muted"> ${ tr ( sectionMeta [ s ] ? . [ 1 ] || '' ) } </p> ${ s === 'led' ? renderLed ( original [ s ] ) : s === 'services' ? renderServices ( ) : 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 ) ) ; if ( b . dataset . configSection === 'services' ) loadServices ( ) } ; $ ( 'configRows' ) . oninput = e => { if ( e . target . matches ( '[data-color-prefix]' ) ) { const p = e . target . dataset . colorPrefix , v = e . target . value ; [ 'r' , 'g' , 'b' ] . forEach ( ( c , i ) => { document . querySelector ( ` [data-section="led"][data-key=" ${ p } _ ${ c } "] ` ) . value = parseInt ( v . slice ( 1 + i * 2 , 3 + i * 2 ) , 16 ) } ) } if ( e . target . matches ( '[data-section="led"],[data-color-prefix]' ) ) updateConditional ( ) ; if ( e . target . matches ( '[data-section]' ) ) markChanged ( ) } ; $ ( 'refreshServices' ) ? . addEventListener ( 'click' , loadServices ) ; $ ( 'serviceSearch' ) ? . addEventListener ( 'input' , renderServiceLists ) ; $ ( 'saveMonitor' ) ? . addEventListener ( 'click' , saveMonitor ) ; $ ( 'resetMonitor' ) ? . addEventListener ( 'click' , resetMonitorForm ) ; $ ( 'saveHost' ) ? . addEventListener ( 'click' , saveHost ) ; document . querySelectorAll ( '[data-delete-host]' ) . forEach ( b => b . onclick = ( ) => deleteHost ( b . dataset . deleteHost ) ) ; updateConditional ( ) }
function markChanged ( ) { let n = 0 ; document . querySelectorAll ( '#configRows [data-section]' ) . forEach ( i => { if ( i . value !== original [ i . dataset . section ] [ i . dataset . key ] ) n ++ } ) ; $ ( 'saveState' ) . textContent = n ? ( LANG === 'zh' ? ` 有 ${ n } 项尚未保存 ` : ` ${ n } unsaved changes ` ) : tr ( '尚未修改' ) ; $ ( 'saveState' ) . className = n ? '' : 'muted' }
tabs . forEach ( b => b . onclick = ( ) => { tabs . forEach ( x => x . classList . toggle ( 'active' , x === b ) ) ; [ 'overview' , 'logs' , 'config' ] . forEach ( id => $ ( id ) . classList . toggle ( 'hidden' , id !== b . dataset . tab ) ) ; if ( b . dataset . tab === 'logs' ) loadLogs ( ) ; if ( b . dataset . tab === 'config' ) loadConfig ( ) } ) ; async function get ( url , opt ) { const r = await fetch ( url , opt ) , text = await r . text ( ) ; let j ; try { j = JSON . parse ( text ) } catch { throw Error ( LANG === 'zh' ? ` 服务器返回异常 ( ${ r . status } ) ` : ` Invalid server response ( ${ r . status } ) ` ) } if ( ! r . ok ) throw Error ( j . error || ( LANG === 'zh' ? ` 请求失败 ( ${ r . status } ) ` : ` Request failed ( ${ r . status } ) ` ) ) ; return j } function rows ( target , items , empty = '暂无数据' ) { $ ( target ) . innerHTML = items . length ? items . join ( '' ) : ` <div class="muted"> ${ empty } </div> ` }
let serviceCache = [ ] , editingServiceName = '' ; function tokenHeaders ( ) { const token = $ ( 'token' ) . value . trim ( ) ; localStorage . setItem ( 'pigwayToken' , token ) ; return { 'Content-Type' : 'application/json' , 'Authorization' : ` Bearer ${ token } ` } }
async function loadServices ( ) { if ( ! $ ( 'systemServices' ) ) return ; try { const d = await get ( ' /api/v1/services' ) ; serviceCache = d . services ; renderServiceLists ( ) } catch ( e ) { $ ( 'systemServices' ) . innerHTML = ` <div class="error"> ${ esc ( e . message ) } </div> ` } }
function renderServiceLists ( ) { if ( ! $ ( 'systemServices' ) ) return ; const q = $ ( 'serviceSearch' ) . value . trim ( ) . toLowerCase ( ) , filtered = serviceCache . filter ( s => ! q || ` ${ s . unit } ${ s . description } ${ s . note } ` . toLowerCase ( ) . includes ( q ) ) ; rows ( 'systemServices' , filtered . map ( s => ` <div class="row"><span><b> ${ esc ( s . unit ) } </b><br><span class="muted"> ${ esc ( s . description || '暂无服务说明' ) } · ${ esc ( s . active === 'active' ? '正在运行' : '未运行' ) } </span></span><span class="row-actions"><span class="badge ${ s . monitored && ! s . paused ? 'good' : '' } "> ${ s . monitored ? ( s . paused ? '已暂停' : '已监控' ) : '未监控' } </span><button data-select-service=" ${ esc ( s . unit ) } "> ${ s . monitored ? '编辑监控' : '加入监控' } </button></span></div> ` ) , '没有匹配的服务' ) ; const monitored = serviceCache . filter ( s => s . monitored ) ; rows ( 'monitoredServices' , monitored . map ( s => ` <div class="row"><span><b> ${ esc ( s . monitor _name ) } </b> <span class="badge ${ s . paused ? '' : s . active === 'active' ? 'good' : 'bad' } "> ${ s . paused ? '监控已暂停' : s . active === 'active' ? '运行中' : '服务已停止' } </span><br><span class="muted"> ${ esc ( s . unit ) } </span><br><span class="muted">备注: ${ esc ( s . note || '未填写' ) } </span></span><span class="row-actions"><button data-monitor-action=" ${ s . paused ? 'resume' : 'pause' } " data-unit=" ${ esc ( s . unit ) } "> ${ s . paused ? '恢复监控' : '暂停监控' } </button><button data-service-action=" ${ s . active === 'active' ? 'stop' : 'start' } " data-unit=" ${ esc ( s . unit ) } " ${ s . protected && s . active === 'active' ? 'disabled title="API 服务不能停止自身"' : '' } > ${ s . active === 'active' ? '停止服务' : '启动服务' } </button><button data-select-service=" ${ esc ( s . unit ) } ">编辑</button><button class="danger" data-delete-monitor=" ${ esc ( s . unit ) } ">删除监控</button></span></div> ` ) , '尚未监控任何服务' ) ; document . querySelectorAll ( '[data-select-service]' ) . forEach ( b => b . onclick = ( ) => selectService ( b . dataset . selectService ) ) ; document . querySelectorAll ( '[data-monitor-action]' ) . forEach ( b => b . onclick = ( ) => monitorAction ( b . dataset . unit , b . dataset . monitorAction ) ) ; document . querySelectorAll ( '[data-service-action]' ) . forEach ( b => b . onclick = ( ) => serviceAction ( b . dataset . unit , b . dataset . serviceAction ) ) ; document . querySelectorAll ( '[data-delete-monitor]' ) . forEach ( b => b . onclick = ( ) => deleteMonitor ( b . dataset . deleteMonitor ) ) }
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 ) ) }
function selectService ( unit ) { const s = serviceCache . find ( x => x . unit === unit ) ; if ( ! s ) return ; editingServiceName = s . monitor _name || '' ; $ ( 'monitorTarget' ) . value = s . unit ; $ ( 'monitorName' ) . value = s . monitor _name || s . unit . replace ( /\.service$/ , '' ) . replace ( /[^A-Za-z0-9_.-]/g , '_' ) . slice ( 0 , 16 ) . toUpperCase ( ) ; $ ( 'monitorNote' ) . value = s . note || s . description || '' ; $ ( 'serviceFormTitle' ) . textContent = s . monitored ? '编辑服务监控' : '新建服务监控' ; $ ( 'serviceMessage' ) . textContent = '已自动填写,可继续修改。' ; $ ( 'monitorName' ) . scrollIntoView ( { behavior : 'smooth' , block : 'center' } ) }
function resetMonitorForm ( ) { editingServiceName = '' ; $ ( 'monitorName' ) . value = '' ; $ ( 'monitorTarget' ) . value = '' ; $ ( 'monitorNote' ) . value = '' ; $ ( 'serviceFormTitle' ) . textContent = '新建服务监控' ; $ ( 'serviceMessage' ) . textContent = '' ; $ ( 'serviceMessage' ) . className = 'muted' }
async function saveMonitor ( ) { const body = { operation : 'save' , name : $ ( 'monitorName' ) . value . trim ( ) , target : $ ( 'monitorTarget' ) . value . trim ( ) , note : $ ( 'monitorNote' ) . value . trim ( ) , previous _name : editingServiceName } ; try { $ ( 'serviceMessage' ) . textContent = '正在保存…' ; await get ( '/api/v1/services' , { method : 'PUT' , headers : tokenHeaders ( ) , body : JSON . stringify ( body ) } ) ; resetMonitorForm ( ) ; await loadServices ( ) ; $ ( 'serviceMessage' ) . textContent = '保存成功' ; $ ( 'serviceMessage' ) . className = 'success' } catch ( e ) { $ ( 'serviceMessage' ) . textContent = e . message ; $ ( 'serviceMessage' ) . className = 'error' } }
async function deleteMonitor ( unit ) { const s = serviceCache . find ( x => x . unit === unit ) ; if ( ! s || ! confirm ( LANG === 'zh' ? ` 确定删除 ${ s . monitor _name } 的监控吗? \n 不会删除或停止 ${ unit } 。 ` : ` Remove monitoring for ${ s . monitor _name } ? \n This 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 ) } }
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 ) } }
async function serviceAction ( unit , action ) { if ( ! confirm ( LANG === 'zh' ? ` 确定 ${ action === 'start' ? '启动服务' : '停止服务' } ${ unit } 吗? \n 这会直接执行 systemctl ${ action } 。 ` : ` ${ action === 'start' ? 'Start' : 'Stop' } ${ unit } ? \n This runs systemctl ${ action } directly. ` ) ) return ; try { await get ( '/api/v1/services/control' , { method : 'PUT' , headers : tokenHeaders ( ) , body : JSON . stringify ( { unit , action } ) } ) ; setTimeout ( loadServices , 700 ) } catch ( e ) { alert ( e . message ) } }
async function loadStatus ( ) { try { const s = await get ( '/api/v1/status' ) , y = s . system , n = s . network , d = s . display ; $ ( 'dot' ) . classList . add ( 'ok' ) ; $ ( 'liveText' ) . textContent = tr ( '在线' ) ; $ ( 'identity' ) . textContent = LANG === 'zh' ? ` 版本 ${ s . version } · 已运行 ${ Math . floor ( s . uptime _seconds / 3600 ) } 小时 · ${ new Date ( s . timestamp * 1000 ) . toLocaleTimeString ( ) } ` : ` Version ${ s . version } · Up ${ Math . floor ( s . uptime _seconds / 3600 ) } hours · ${ new Date ( s . timestamp * 1000 ) . toLocaleTimeString ( ) } ` ; $ ( 'cpu' ) . textContent = ` ${ y . cpu _percent } % ` ; $ ( 'freq' ) . textContent = LANG === 'zh' ? ` 频率 ${ y . cpu _freq _mhz } MHz ` : ` Frequency ${ y . cpu _freq _mhz } MHz ` ; $ ( 'temp' ) . textContent = ` ${ y . temperature _c } °C ` ; const fanZh = ( { OFF : '关闭' , L1 : '一档' , L2 : '二档' , L3 : '三档' , L4 : '四档' , MAX : '全速' } ) [ y . fan _name ] || y . fan _name , fanEn = ( { OFF : 'Off' , L1 : 'Level 1' , L2 : 'Level 2' , L3 : 'Level 3' , L4 : 'Level 4' , MAX : 'Full speed' } ) [ y . fan _name ] || y . fan _name ; $ ( 'fan' ) . textContent = LANG === 'zh' ? ` 风扇 ${ fanZh } ` : ` Fan ${ fanEn } ` ; $ ( 'memory' ) . textContent = ` ${ y . memory _percent } % ` ; $ ( 'disk' ) . textContent = ` ${ y . disk _percent } % ` ; $ ( 'network' ) . textContent = ` ${ tr ( n . kind === 'WIF' ? '无线网络' : n . kind === 'ETH' ? '有线网络' : n . kind ) } ${ n . metric } ` ; $ ( 'address' ) . textContent = LANG === 'zh' ? ` ${ n . interface } · ${ n . ip _label === 'IP4' ? 'IPv4' : 'IPv6' } ${ n . ip } · 互联网 ${ n . internet ? '正常' : '断开' } ` : ` ${ n . interface } · ${ n . ip _label === 'IP4' ? 'IPv4' : 'IPv6' } ${ n . ip } · Internet ${ n . internet ? 'online' : 'down' } ` ; $ ( 'owner' ) . textContent = ownerName ( d . owner ) ; $ ( 'rgb' ) . textContent = ` OLED: ${ ownerName ( d . oled _page ) } · RGB: ${ rgbName ( d . rgb _mode ) } ` ; rows ( 'alerts' , d . alerts . map ( a => ` <div class="row"><span><b> ${ esc ( ownerName ( a . id ) ) } </b><br><span class="muted"> ${ esc ( a . l2 ) } ${ esc ( a . l3 ) } </span></span><span class="badge bad"> ${ LANG === 'zh' ? ` 等级 ${ a . severity } · 优先级 ${ a . priority } ` : ` Severity ${ a . severity } · Priority ${ a . priority } ` } </span></div> ` ) , tr ( '当前没有告警' ) ) ; rows ( 'watches' , s . watches . map ( w => ` <div class="row"><span><b> ${ esc ( w . name ) } </b><br><span class="muted"> ${ tr ( w . type === 'service' ? '系统服务' : '普通进程' ) } · ${ esc ( w . target ) } </span></span><span class="badge ${ w . up ? 'good' : 'bad' } "> ${ tr ( w . up ? '正常' : '停止' ) } </span></div> ` ) ) } catch ( e ) { $ ( 'dot' ) . classList . remove ( 'ok' ) ; $ ( 'liveText' ) . textContent = tr ( '离线' ) ; $ ( 'identity' ) . textContent = e . message } }
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> ` }
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 . 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><div class="muted"> ${ LANG === 'zh' ? '频率' : 'Frequency' } ${ esc ( y . cpu _freq _mhz ) } MHz · ${ esc ( y . temperature _c ) } °C · ${ LANG === 'zh' ? '风扇' : 'Fan' } ${ esc ( y . fan _name ) } </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">OLED: ${ esc ( ownerName ( d . oled _page ) ) } · RGB: ${ esc ( rgbName ( d . rgb _mode ) ) } </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><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> ` }
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 ( ) } ` ; $ ( 'hostCards' ) . innerHTML = fleet . hosts . map ( hostCard ) . join ( '' ) ; fleetHosts = fleet . hosts . map ( x => x . host ) ; refreshHostSelectors ( ) } catch ( e ) { $ ( 'dot' ) . classList . remove ( 'ok' ) ; $ ( 'liveText' ) . textContent = tr ( '离线' ) ; $ ( 'identity' ) . textContent = e . message } }
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 ) }
async function loadLogs ( reset = true ) { if ( reset ) logState . offset = 0 ; try { const p = new URLSearchParams ( { limit : logState . limit , offset : logState . offset , sort : logState . sort , order : logState . order } ) , since = logTimestamp ( $ ( 'logStart' ) . value ) , until = logTimestamp ( $ ( 'logEnd' ) . value , true ) , severity = $ ( 'logSeverity' ) . value , event = $ ( 'logEvent' ) . value , search = $ ( 'logSearch' ) . value . trim ( ) ; if ( since ) p . set ( 'since' , since ) ; if ( until ) p . set ( 'until' , until ) ; if ( severity ) p . set ( 'severity' , severity ) ; if ( event ) p . set ( 'event' , event ) ; if ( search ) p . set ( 'search' , search ) ; const d = await get ( ` /api/v1/logs? ${ p } ` ) ; logState . total = d . total ; const selected = $ ( 'logEvent' ) . value ; $ ( 'logEvent' ) . innerHTML = ` <option value=""> ${ tr ( '全部事件' ) } </option> ` + d . event _types . map ( x => ` <option value=" ${ esc ( x ) } " ${ x === selected ? 'selected' : '' } > ${ esc ( eventName ( x ) ) } </option> ` ) . join ( '' ) ; $ ( 'logRows' ) . innerHTML = d . logs . length ? d . logs . map ( x => ` <tr><td> ${ esc ( new Date ( x . timestamp / 1000 ) . toLocaleString ( ) ) } </td><td><span class="badge ${ [ 'ERROR' , 'CRITICAL' , 'ALERT' , 'EMERGENCY' ] . includes ( x . severity ) ? 'bad' : '' } "> ${ esc ( tr ( severityNames [ x . severity ] || x . severity ) ) } </span></td><td> ${ esc ( eventName ( x . event ) ) } </td><td class="log-message"> ${ esc ( x . message ) } </td></tr> ` ) . join ( '' ) : ` <tr><td colspan="4 " class="muted"> ${ tr ( '没有符合条件的日志' ) } </td></tr> ` ; $ ( 'logSummary' ) . textContent = LANG === 'zh' ? ` 共 ${ d . total } 条 · 数据来源:systemd journal ` : ` ${ d . total } events · Source: systemd journal ` ; $ ( 'logPage' ) . textContent = d . total ? ` ${ d . offset + 1 } – ${ Math . min ( d . offset + d . limit , d . total ) } / ${ d . total } ` : ( LANG === 'zh' ? '0 条' : '0 events' ) ; $ ( 'logPrev' ) . disabled = d . offset === 0 ; $ ( 'logNext' ) . disabled = d . offset + d . limit >= d . total ; document . querySelectorAll ( '[data-log-sort]' ) . forEach ( b => { const name = b . textContent . replace ( /[ ↑↓]$/ , '' ) ; b . textContent = name + ( b . dataset . logSort === logState . sort ? ( logState . order === 'asc' ? ' ↑' : ' ↓' ) : '' ) } ) } catch ( e ) { $ ( 'logRows' ) . innerHTML = ` <tr><td colspan="4 " class="error"> ${ esc ( e . message ) } </td></tr> ` } }
async function loadConfig ( ) { try { const d = await get ( '/api/v1/config' ) ; original = d . config ; renderConfig ( ) ; markChanged ( ) } catch ( e ) { $ ( 'configRows' ) . innerHTML = ` <div class="error"> ${ esc ( e . message ) } </div> ` } }
document . querySelectorAll ( '[data-log-range]' ) . forEach ( b => b . onclick = ( ) => { setLogRange ( b . dataset . logRange ) ; loadLogs ( ) } ) ; document . querySelectorAll ( '[data-log-sort]' ) . forEach ( b => b . onclick = ( ) => { const s = b . dataset . logSort ; if ( logState . sort === s ) logState . order = logState . order === 'asc' ? 'desc' : 'asc' ; else { logState . sort = s ; logState . order = s === 'timestamp' ? 'desc' : 'asc' } loadLogs ( ) } ) ; $ ( 'refreshLogs' ) . onclick = ( ) => loadLogs ( ) ; $ ( 'resetLogs' ) . onclick = ( ) => { setLogRange ( 'today' ) ; $ ( 'logSeverity' ) . value = '' ; $ ( 'logEvent' ) . value = '' ; $ ( 'logSearch' ) . value = '' ; logState . sort = 'timestamp' ; logState . order = 'desc' ; loadLogs ( ) } ; $ ( 'logPrev' ) . onclick = ( ) => { logState . offset = Math . max ( 0 , logState . offset - logState . limit ) ; loadLogs ( false ) } ; $ ( 'logNext' ) . onclick = ( ) => { if ( logState . offset + logState . limit < logState . total ) { logState . offset += logState . limit ; loadLogs ( false ) } } ; const language = $ ( 'language' ) , theme = $ ( 'theme' ) , themeMedia = matchMedia ( '(prefers-color-scheme: dark)' ) ; language . value = LANG ; theme . value = localStorage . getItem ( 'pigwayTheme' ) || 'auto' ; applyTheme ( theme . value ) ; language . onchange = ( ) => { localStorage . setItem ( 'pigwayLanguage' , language . value ) ; location . reload ( ) } ; theme . onchange = ( ) => { localStorage . setItem ( 'pigwayTheme' , theme . value ) ; applyTheme ( theme . value ) } ; themeMedia . addEventListener ? . ( 'change' , ( ) => { if ( theme . value === 'auto' ) applyTheme ( 'auto' ) } ) ; translateNode ( ) ; new MutationObserver ( ms => ms . forEach ( m => m . addedNodes . forEach ( n => { if ( n . nodeType === Node . ELEMENT _NODE ) translateNode ( n ) ; else if ( n . nodeType === Node . TEXT _NODE && EN [ n . nodeValue . trim ( ) ] ) n . nodeValue = n . nodeValue . replace ( n . nodeValue . trim ( ) , EN [ n . nodeValue . trim ( ) ] ) } ) ) ) . observe ( document . body , { childList : true , subtree : true } ) ; document . documentElement . lang = LANG === 'zh' ? 'zh-CN' : 'en' ; setLogRange ( 'today' ) ; $ ( 'token' ) . value = localStorage . getItem ( 'pigwayToken' ) || '' ; $ ( 'toggleToken' ) . onclick = ( ) => { const i = $ ( 'token' ) , show = i . type === 'password' ; i . type = show ? 'text' : 'password' ; $ ( 'toggleToken' ) . textContent = tr ( show ? '隐藏令牌' : '显示令牌' ) } ; $ ( 'saveConfig' ) . onclick = async ( ) => { const token = $ ( 'token' ) . value . trim ( ) ; localStorage . setItem ( 'pigwayToken' , token ) ; const updates = { } ; document . querySelectorAll ( '#configRows [data-section]' ) . forEach ( i => { if ( i . value !== original [ i . dataset . section ] [ i . dataset . key ] ) { ( updates [ i . dataset . section ] ? ? = { } ) [ i . dataset . key ] = i . value } } ) ; if ( ! Object . keys ( updates ) . length ) { $ ( 'saveState' ) . textContent = tr ( '没有需要保存的修改' ) ; return } try { $ ( 'saveConfig' ) . disabled = true ; $ ( 'saveState' ) . textContent = tr ( '正在保存并应用…' ) ; const d = await get ( '/api/v1/config' , { method : 'PUT' , headers : { 'Content-Type' : 'application/json' , 'Authorization' : ` Bearer ${ token } ` } , body : JSON . stringify ( { updates } ) } ) ; $ ( 'saveState' ) . textContent = LANG === 'zh' ? ` 保存成功,已创建备份 ${ d . backup } ` : ` Saved; backup created at ${ d . backup } ` ; $ ( 'saveState' ) . className = 'success' ; setTimeout ( loadConfig , 1500 ) } catch ( e ) { $ ( 'saveState' ) . textContent = e . message ; $ ( 'saveState' ) . className = 'error' } finally { $ ( 'saveConfig' ) . disabled = false } } ; loadStatus ( ) ; setInterval ( loadStatus , 3000 ) ;
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> ` } }
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 ) }
function refreshHostSelectors ( ) { if ( ! fleetHosts . length ) return ; const current = $ ( 'configHost' ) . value || currentConfigHost || fleetHosts [ 0 ] . id , checked = new Set ( [ ... document . querySelectorAll ( '#syncHosts input:checked' ) ] . map ( x => x . value ) ) ; $ ( '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 ; $ ( 'syncHosts' ) . innerHTML = fleetHosts . map ( h => ` <label><input type="checkbox" value=" ${ esc ( h . id ) } " ${ checked . has ( h . id ) ? 'checked' : '' } > ${ esc ( h . name || h . id ) } </label> ` ) . join ( '' ) }
function changedUpdates ( ) { const updates = { } ; document . querySelectorAll ( '#configRows [data-section]' ) . forEach ( i => { if ( original [ i . dataset . section ] && i . value !== original [ i . dataset . section ] [ i . dataset . key ] ) { ( updates [ i . dataset . section ] ? ? = { } ) [ i . dataset . key ] = i . value } } ) ; return updates }
async function ensureHosts ( ) { if ( fleetHosts . length ) return ; const d = await get ( '/api/v1/hosts' ) ; fleetHosts = d . hosts ; refreshHostSelectors ( ) }
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 ; 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 loadConfig ( ) ; message . textContent = '保存成功' } 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 loadConfig ( ) } catch ( e ) { alert ( e . message ) } }
async function applyConfig ( targets ) { const updates = changedUpdates ( ) ; if ( targets . length > 1 ) { delete updates . device ; delete updates . hosts ; } if ( ! Object . keys ( updates ) . length ) { $ ( 'saveState' ) . textContent = tr ( '没有需要保存的修改' ) ; return } const local = fleetHosts . find ( x => x . local ) , tokens = Object . fromEntries ( targets . map ( id => [ id , hostToken ( id ) ] ) ) ; try { $ ( 'saveConfig' ) . disabled = true ; $ ( 'syncConfig' ) . disabled = true ; $ ( 'saveState' ) . textContent = tr ( '正在保存并应用…' ) ; saveHostToken ( currentConfigHost , $ ( 'token' ) . value . trim ( ) ) ; tokens [ currentConfigHost ] = $ ( '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 , updates , tokens } ) } ) ; const failed = d . results . filter ( x => ! x . ok ) ; $ ( 'saveState' ) . 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' ? ` 已同步到 ${ d . results . length } 台机器 ` : ` Synced to ${ d . results . length } hosts ` ) ; $ ( '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 ; $ ( 'syncConfig' ) . disabled = false } }
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 ) } } ;
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 ( [ currentConfigHost ] ) ; $ ( 'syncConfig' ) . onclick = ( ) => { const targets = [ ... document . querySelectorAll ( '#syncHosts input:checked' ) ] . map ( x => x . value ) ; if ( ! targets . length ) { $ ( 'saveState' ) . textContent = LANG === 'zh' ? '请先选择同步目标机器' : 'Select target hosts first' ; return } applyConfig ( targets ) } ; loadStatus ( ) ; setInterval ( loadStatus , 3000 ) ;
< / script > < / body > < / html >