@@ -36,19 +36,20 @@ function rgbGroup(values,prefix,title){const r=Number(values[`${prefix}_r`]||0),
function normalGroup ( v ) { return ` <div class="group"><div class="group-head"><div><h3>正常运行灯效</h3><div class="hint">没有告警时显示;告警出现后自动让位。</div></div></div><div class="fields"> ${ field ( 'led' , 'normal_mode' , v . normal _mode , 'span2' ) } <div class="normal-field span2" data-modes="solid flash"> ${ rgbGroup ( v , 'normal' , '自定义颜色' ) } </div><div class="normal-field" data-modes="flash"> ${ field ( 'led' , 'normal_flash_on_ms' , v . normal _flash _on _ms ) } </div><div class="normal-field" data-modes="flash"> ${ field ( 'led' , 'normal_flash_off_ms' , v . normal _flash _off _ms ) } </div><div class="normal-field" data-modes="flow breathe marquee rainbow colorful"> ${ field ( 'led' , 'normal_effect_speed' , v . normal _effect _speed ) } </div><div class="normal-field" data-modes="flow breathe"> ${ field ( 'led' , 'normal_effect_color' , v . normal _effect _color ) } </div></div></div> ` }
function renderLed ( v ) { const flash = [ 'warning' , 'critical' , 'emergency' ] . map ( ( x , i ) => ` <div class="group"><h3> ${ [ '普通告警' , '严重告警' , '紧急告警' ] [ i ] } 闪烁节奏</h3><div class="fields"> ${ field ( 'led' , ` ${ x } _flash_on_ms ` , v [ ` ${ x } _flash_on_ms ` ] ) } ${ field ( 'led' , ` ${ x } _flash_off_ms ` , v [ ` ${ x } _flash_off_ms ` ] ) } </div></div> ` ) . join ( '' ) , advanced = v . write _delay _ms === undefined ? '' : ` <div class="group"><h3>硬件高级参数</h3><div class="fields"> ${ field ( 'led' , 'write_delay_ms' , v . write _delay _ms ) } </div></div> ` ; return normalGroup ( v ) + ` <div class="group"><div class="group-head"><div><h3>告警对象颜色</h3><div class="hint">告警对象决定颜色,严重程度决定闪烁速度。</div></div></div> ${ alertColorGroups . map ( ( [ p , t ] ) => rgbGroup ( v , p , t ) ) . join ( '' ) } </div> ` + flash + advanced }
function renderGeneral ( s , v ) { const [ t , d ] = sectionMeta [ s ] || [ s , '' ] ; return ` <div class="group"><div class="group-head"><div><h3> ${ t } </h3><div class="hint"> ${ d } </div></div></div><div class="fields"> ${ Object . entries ( v ) . map ( ( [ k , x ] ) => field ( s , k , x ) ) . join ( '' ) || '<div class="muted">暂无配置项</div>' } </div></div> ` }
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">可启动、停止、编辑或删除监控;只有当前 API 服务不能在这里停止 。</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 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">“暂停监控”只停止 PIGWay 检查,不影响服务运行;“停止服务”才会操作 systemd 。</div><div class="rows" id="monitoredServices"></div></div><div class="group"><h3 id="serviceFormTitle">新建服务监控</h3><div class="service-form"><label>OLED 显示名称<input id="monitorName" maxlength="64" placeholder="例如:DOCKER"></label><label>systemd 服务名称<input id="monitorTarget" maxlength="128" placeholder="例如:docker.service"></label><label class="full">备注说明<input id="monitorNote" maxlength="120" placeholder="例如:家庭相册容器服务"></label><div class="full"><button id="saveMonitor">保存监控</button> <button id="resetMonitor">清空</button> <span id="serviceMessage" class="muted"></span></div></div></div> ` }
function updateConditional ( ) { const mode = document . querySelector ( '[data-section="led"][data-key="normal_mode"]' ) ? . value || 'off' ; document . querySelectorAll ( '.normal-field' ) . forEach ( x => x . classList . toggle ( 'hidden' , ! x . dataset . modes . split ( ' ' ) . includes ( mode ) ) ) ; updateSwatches ( ) } function updateSwatches ( ) { document . querySelectorAll ( '[data-swatch]' ) . forEach ( s => { const p = s . dataset . swatch , read = c => Math . max ( 0 , Math . min ( 255 , Number ( document . querySelector ( ` [data-section="led"][data-key=" ${ p } _ ${ c } "] ` ) ? . value || 0 ) ) ) , hex = '#' + [ 'r' , 'g' , 'b' ] . map ( c => Math . round ( read ( c ) ) . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' ) ; s . style . background = hex ; const picker = document . querySelector ( ` [data-color-prefix=" ${ p } "] ` ) ; if ( picker ) picker . value = hex } ) }
function renderConfig ( ) { const ss = sectionOrder . filter ( s => original [ s ] ) ; $ ( 'configMenu' ) . innerHTML = ss . map ( ( s , i ) => ` <button class=" ${ i === 0 ? 'active' : '' } " data-config-section=" ${ s } "> ${ sectionMeta [ s ] ? . [ 0 ] || s } </button> ` ) . join ( '' ) ; $ ( 'configRows' ) . innerHTML = ss . map ( ( s , i ) => ` <section class="config-page ${ i === 0 ? 'active' : '' } " data-config-page=" ${ s } "><h2> ${ sectionMeta [ s ] ? . [ 0 ] || s } </h2><p class="muted"> ${ sectionMeta [ s ] ? . [ 1 ] || '' } </p> ${ s === 'led' ? renderLed ( original [ s ] ) : s === 'services' ? renderServices ( ) : renderGeneral ( s , original [ s ] ) } </section> ` ) . join ( '' ) ; $ ( 'configMenu' ) . onclick = e => { const b = e . target . closest ( 'button' ) ; if ( ! b ) return ; document . querySelectorAll ( '[data-config-section]' ) . forEach ( x => x . classList . toggle ( 'active' , x === b ) ) ; document . querySelectorAll ( '[data-config-page]' ) . forEach ( x => x . classList . toggle ( 'active' , x . dataset . configPage === b . dataset . configSection ) ) ; if ( b . dataset . configSection === 'services' ) loadServices ( ) } ; $ ( 'configRows' ) . oninput = e => { if ( e . target . matches ( '[data-color-prefix]' ) ) { const p = e . target . dataset . colorPrefix , v = e . target . value ; [ 'r' , 'g' , 'b' ] . forEach ( ( c , i ) => { document . querySelector ( ` [data-section="led"][data-key=" ${ p } _ ${ c } "] ` ) . value = parseInt ( v . slice ( 1 + i * 2 , 3 + i * 2 ) , 16 ) } ) } if ( e . target . matches ( '[data-section="led"],[data-color-prefix]' ) ) updateConditional ( ) ; if ( e . target . matches ( '[data-section]' ) ) markChanged ( ) } ; $ ( 'refreshServices' ) ? . addEventListener ( 'click' , loadServices ) ; $ ( 'serviceSearch' ) ? . addEventListener ( 'input' , renderServiceLists ) ; $ ( 'saveMonitor' ) ? . addEventListener ( 'click' , saveMonitor ) ; $ ( 'resetMonitor' ) ? . addEventListener ( 'click' , resetMonitorForm ) ; updateConditional ( ) }
function markChanged ( ) { let n = 0 ; document . querySelectorAll ( '#configRows [data-section]' ) . forEach ( i => { if ( i . value !== original [ i . dataset . section ] [ i . dataset . key ] ) n ++ } ) ; $ ( 'saveState' ) . textContent = n ? ` 有 ${ n } 项尚未保存 ` : '尚未修改' ; $ ( 'saveState' ) . className = n ? '' : 'muted' }
tabs . forEach ( b => b . onclick = ( ) => { tabs . forEach ( x => x . classList . toggle ( 'active' , x === b ) ) ; [ 'overview' , 'logs' , 'config' ] . forEach ( id => $ ( id ) . classList . toggle ( 'hidden' , id !== b . dataset . tab ) ) ; if ( b . dataset . tab === 'logs' ) loadLogs ( ) ; if ( b . dataset . tab === 'config' ) loadConfig ( ) } ) ; async function get ( url , opt ) { const r = await fetch ( url , opt ) , text = await r . text ( ) ; let j ; try { j = JSON . parse ( text ) } catch { throw Error ( ` 服务器返回异常 ( ${ r . status } ) ` ) } if ( ! r . ok ) throw Error ( j . error || ` 请求失败 ( ${ r . status } ) ` ) ; return j } function rows ( target , items , empty = '暂无数据' ) { $ ( target ) . innerHTML = items . length ? items . join ( '' ) : ` <div class="muted"> ${ empty } </div> ` }
let serviceCache = [ ] , editingServiceName = '' ; function tokenHeaders ( ) { const token = $ ( 'token' ) . value . trim ( ) ; localStorage . setItem ( 'pigwayToken' , token ) ; return { 'Content-Type' : 'application/json' , 'Authorization' : ` Bearer ${ token } ` } }
async function loadServices ( ) { if ( ! $ ( 'systemServices' ) ) return ; try { const d = await get ( '/api/v1/services' ) ; serviceCache = d . services ; renderServiceLists ( ) } catch ( e ) { $ ( 'systemServices' ) . innerHTML = ` <div class="error"> ${ esc ( e . message ) } </div> ` } }
function renderServiceLists ( ) { if ( ! $ ( 'systemServices' ) ) return ; const q = $ ( 'serviceSearch' ) . value . trim ( ) . toLowerCase ( ) , filtered = serviceCache . filter ( s => ! q || ` ${ s . unit } ${ s . description } ${ s . note } ` . toLowerCase ( ) . includes ( q ) ) ; rows ( 'systemServices' , filtered . map ( s => ` <div class="row"><span><b> ${ esc ( s . unit ) } </b><br><span class="muted"> ${ esc ( s . description || '暂无服务说明' ) } · ${ esc ( s . active === 'active' ? '正在运行' : '未运行' ) } </span></span><span class="row-actions"><span class="badge ${ s . monitored ? 'good' : '' } "> ${ s . monitored ? '已监控' : '未监控' } </span><button data-select-service=" ${ esc ( s . unit ) } "> ${ s . monitored ? '编辑监控' : '加入监控' } </button></span></div> ` ) , '没有匹配的服务' ) ; const monitored = serviceCache . filter ( s => s . monitored ) ; rows ( 'monitoredServices' , monitored . map ( s => ` <div class="row"><span><b> ${ esc ( s . monitor _name ) } </b> <span class="badge ${ s . active === 'active' ? 'good' : 'bad' } "> ${ s . active === 'active' ? '运行中' : '已停止' } </span><br><span class="muted"> ${ esc ( s . unit ) } </span><br><span class="muted">备注: ${ esc ( s . note || '未填写' ) } </span></span><span class="row-actions"><button data-service-action=" ${ s . active === 'active' ? 'stop' : 'start' } " data-unit=" ${ esc ( s . unit ) } " ${ s . protected && s . active === 'active' ? 'disabled title="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-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 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 ) ) }
function selectService ( unit ) { const s = serviceCache . find ( x => x . unit === unit ) ; if ( ! s ) return ; editingServiceName = s . monitor _name || '' ; $ ( 'monitorTarget' ) . value = s . unit ; $ ( 'monitorName' ) . value = s . monitor _name || s . unit . replace ( /\.service$/ , '' ) . replace ( /[^A-Za-z0-9_.-]/g , '_' ) . slice ( 0 , 16 ) . toUpperCase ( ) ; $ ( 'monitorNote' ) . value = s . note || s . description || '' ; $ ( 'serviceFormTitle' ) . textContent = s . monitored ? '编辑服务监控' : '新建服务监控' ; $ ( 'serviceMessage' ) . textContent = '已自动填写,可继续修改。' ; $ ( 'monitorName' ) . scrollIntoView ( { behavior : 'smooth' , block : 'center' } ) }
function resetMonitorForm ( ) { editingServiceName = '' ; $ ( 'monitorName' ) . value = '' ; $ ( 'monitorTarget' ) . value = '' ; $ ( 'monitorNote' ) . value = '' ; $ ( 'serviceFormTitle' ) . textContent = '新建服务监控' ; $ ( 'serviceMessage' ) . textContent = '' ; $ ( 'serviceMessage' ) . className = 'muted' }
async function saveMonitor ( ) { const body = { operation : 'save' , name : $ ( 'monitorName' ) . value . trim ( ) , target : $ ( 'monitorTarget' ) . value . trim ( ) , note : $ ( 'monitorNote' ) . value . trim ( ) , previous _name : editingServiceName } ; try { $ ( 'serviceMessage' ) . textContent = '正在保存…' ; await get ( '/api/v1/services' , { method : 'PUT' , headers : tokenHeaders ( ) , body : JSON . stringify ( body ) } ) ; resetMonitorForm ( ) ; await loadServices ( ) ; $ ( 'serviceMessage' ) . textContent = '保存成功' ; $ ( 'serviceMessage' ) . className = 'success' } catch ( e ) { $ ( 'serviceMessage' ) . textContent = e . message ; $ ( 'serviceMessage' ) . className = 'error' } }
async function deleteMonitor ( unit ) { const s = serviceCache . find ( x => x . unit === unit ) ; if ( ! s || ! confirm ( ` 确定删除 ${ s . monitor _name } 的监控吗? \n 不会删除或停止 ${ unit } 。 ` ) ) return ; try { await get ( '/api/v1/services' , { method : 'PUT' , headers : tokenHeaders ( ) , body : JSON . stringify ( { operation : 'delete' , name : s . monitor _name , target : s . unit , note : '' } ) } ) ; await loadConfig ( ) ; document . querySelector ( '[data-config-section="services"]' ) ? . click ( ) } catch ( e ) { alert ( e . message ) } }
async function serviceAction ( unit , action ) { if ( ! confirm ( ` 确定 ${ action === 'start' ? '启动' : '停止' } ${ unit } 吗? ` ) ) return ; try { await get ( '/api/v1/services/control ' , { method : 'PUT' , headers : tokenHeaders ( ) , body : JSON . stringify ( { unit , action } ) } ) ; setTimeout ( loadServices , 700 ) } catch ( e ) { alert ( e . message ) } }
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 ( ` 确定 ${ action === 'start' ? '启动服务' : '停止服务' } ${ unit } 吗? \n 这会直接执行 systemctl ${ action } 。 ` ) ) 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 = '在线' ; $ ( 'identity' ) . textContent = ` 版本 ${ s . version } · 已运行 ${ Math . floor ( s . uptime _seconds / 3600 ) } 小时 · ${ new Date ( s . timestamp * 1000 ) . toLocaleTimeString ( ) } ` ; $ ( 'cpu' ) . textContent = ` ${ y . cpu _percent } % ` ; $ ( 'freq' ) . textContent = ` 频率 ${ y . cpu _freq _mhz } MHz ` ; $ ( 'temp' ) . textContent = ` ${ y . temperature _c } °C ` ; $ ( 'fan' ) . textContent = ` 风扇 ${ ( { OFF : '关闭' , L1 : '一档' , L2 : '二档' , L3 : '三档' , L4 : '四档' , MAX : '全速' } )[y.fan_name]||y.fan_name} ` ; $ ( 'memory' ) . textContent = ` ${ y . memory _percent } % ` ; $ ( 'disk' ) . textContent = ` ${ y . disk _percent } % ` ; $ ( 'network' ) . textContent = ` ${ n . kind === 'WIF' ? '无线网络' : n . kind === 'ETH' ? '有线网络' : n . kind } ${ n . metric } ` ; $ ( 'address' ) . textContent = ` ${ n . interface } · ${ n . ip _label === 'IP4' ? 'IPv4' : 'IPv6' } ${ n . ip } · 互联网 ${ n . internet ? '正常' : '断开' } ` ; $ ( 'owner' ) . textContent = ownerName ( d . owner ) ; $ ( 'rgb' ) . textContent = ` OLED: ${ ownerName ( d . oled _page ) } · RGB: ${ rgbName ( d . rgb _mode ) } ` ; rows ( 'alerts' , d . alerts . map ( a => ` <div class="row"><span><b> ${ esc ( ownerName ( a . id ) ) } </b><br><span class="muted"> ${ esc ( a . l2 ) } ${ esc ( a . l3 ) } </span></span><span class="badge bad">等级 ${ a . severity } · 优先级 ${ a . priority } </span></div> ` ) , '当前没有告警' ) ; rows ( 'watches' , s . watches . map ( w => ` <div class="row"><span><b> ${ esc ( w . name ) } </b><br><span class="muted"> ${ w . type === 'service' ? '系统服务' : '普通进程' } · ${ esc ( w . target ) } </span></span><span class="badge ${ w . up ? 'good' : 'bad' } "> ${ w . up ? '正常' : '停止' } </span></div> ` ) ) } catch ( e ) { $ ( 'dot' ) . classList . remove ( 'ok' ) ; $ ( 'liveText' ) . textContent = '离线' ; $ ( 'identity' ) . textContent = e . message } }
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' ) } ` }