From 0dac42cd64c10fe8775e3ecc40d0d9ab7200f74c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 27 Sep 2026 21:42:57 +0800 Subject: [PATCH] fix: show fan telemetry with CPU and space action rows --- SHA256SUMS | 2 +- web/index.html | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/SHA256SUMS b/SHA256SUMS index 94a93b7..cdf0a2b 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -7,5 +7,5 @@ e83c52906953a723fc79208aecf08372a875e0bc2f74b4283329a475744f8984 README.md 3da8b5d94e2206bffc19e1c4fa493b31880c9a90fd6a02546461fe54a7255dbd config/pigway-pi-control.conf 20c584762cae3a50a498df25e11272c3c191a62c9ce823e63f8224d14637b1d4 systemd/pigway-pi-control-api.service 35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service -6b6e22d8b24e706a3802872856c5c32c07b3bb740f831ec66da313b4b2b1eb68 web/index.html +3cbd0bf15b3de8c988167529c4490d3ff73e76d7517b2cf9457693f0e04958f3 web/index.html 73b7922be93bf57169589755137dc5ec7ec7c8059c85c2bf2ee53a344e534d7e docs/HARDWARE_PLUGIN_API.md diff --git a/web/index.html b/web/index.html index b9c8d05..853ed11 100644 --- a/web/index.html +++ b/web/index.html @@ -13,6 +13,7 @@ .header-actions{display:flex;align-items:center;gap:14px;flex-wrap:wrap;justify-content:flex-end}.preference{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px}.preference select{height:35px;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:7px 30px 7px 10px}.theme-preference select{min-width:138px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.header-actions .live{height:35px}.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:7px}.service-list::-webkit-scrollbar-track{background:transparent}.service-list::-webkit-scrollbar-thumb{background:var(--line);border-radius:999px}[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:#eef3f8e8}[data-theme="light"] button{background:#e7eef6}[data-theme="light"] button:hover,[data-theme="light"] button.active{background:#d9e9f8}[data-theme="light"] .field input,[data-theme="light"] .field select,[data-theme="light"] .service-form input,[data-theme="light"] .service-search,[data-theme="light"] .toolbar input,[data-theme="light"] .log-filter-top input,[data-theme="light"] .log-filter-bottom select,[data-theme="light"] .preference select{background:#fff}[data-theme="light"] .badge{background:#e5edf5;color:#31465c}[data-theme="light"] .badge.good{background:#d9f3e5;color:#087444}[data-theme="light"] .badge.bad{background:#fde5e5;color:#b72f2f}[data-theme="light"] .savebar{background:#f7fafcef;box-shadow:0 8px 25px #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:14px;transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease}.host-head{display:flex;justify-content:space-between;gap:16px;align-items:start;margin-bottom:12px}.device-details{display:flex;gap:8px 16px;flex-wrap:wrap;font-size:12px;color:var(--muted);margin-top:5px}.host-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.metric{background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:12px}.metric .value{font-size:20px}.progress{height:7px;background:color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;overflow:hidden;margin:8px 0}.progress span{display:block;height:100%;background:var(--blue);border-radius:inherit}.host-select{display:grid;gap:4px;color:var(--muted);font-size:12px}.host-select select{min-width:210px;border:1px solid var(--line);border-radius:7px;background:var(--panel2);color:var(--text);padding:9px}.config-target-toolbar{display:grid;grid-template-columns:minmax(220px,.65fr) minmax(0,1.35fr);align-items:end}.token-control{display:flex;gap:8px;align-items:center}.token-control input{min-width:0;margin:0}.token-control button{white-space:nowrap}.bulk-tools{display:flex;gap:8px;flex-wrap:wrap;margin:12px 0}.bulk-section{padding:12px}.bulk-section-title{display:flex;align-items:center;gap:8px;font-weight:700}.bulk-items{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 18px;margin:10px 0 0 24px}.bulk-item{display:flex;align-items:flex-start;gap:7px}.bulk-item span{min-width:0}.bulk-value{display:block;color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bulk-target{display:grid;grid-template-columns:auto minmax(120px,.5fr) minmax(220px,1fr);gap:10px;align-items:center}.bulk-target input[type="password"],.bulk-target input[type="text"]{width:100%;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:8px}.host-checks{display:flex;gap:12px;flex-wrap:wrap}.host-checks label{display:flex;gap:6px;align-items:center}.offline-card{border-color:var(--bad)}@media(hover:hover) and (pointer:fine){.host-panel:hover{transform:translateY(-3px);border-color:color-mix(in srgb,var(--blue) 45%,var(--line));box-shadow:0 16px 34px #0006}[data-theme="light"] .host-panel:hover{box-shadow:0 14px 30px #3454}}@media(max-width:900px){.host-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:650px){.config-target-toolbar{grid-template-columns:1fr}.bulk-items{grid-template-columns:1fr}.bulk-target{grid-template-columns:auto 1fr}.bulk-target input{grid-column:1/span 2}}@media(max-width:550px){.host-grid{grid-template-columns:1fr}.host-head{flex-direction:column}} .plugin-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:16px} #mainSavebar{display:flex;align-items:center;gap:16px;flex-wrap:wrap} +.action-row,.savebar,.plugin-actions{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.action-row>button{flex-shrink:0}.bulk-tools{column-gap:16px}.group-head{column-gap:16px}
@@ -63,8 +64,9 @@ const ownerNames={NORMAL_HOME:'正常主页',ROOT_READONLY:'根文件系统只 function ownerName(id){const [type,suffix]=String(id||'').split('|',2),name=ownerNames[type]||type;return tr(name)+(suffix?` · ${suffix}`:'')}function rgbName(mode){if(rgbNames[mode])return tr(rgbNames[mode]);const p=String(mode||'').split('_'),groups={CPU:'处理器',POWER:'供电',MEMORY:'内存',STORAGE:'存储',NETWORK:'网络',SERVICE:'服务'},levels={WARNING:'普通告警',CRITICAL:'严重告警',EMERGENCY:'紧急告警'};return`${tr(groups[p[0]]||p[0])} · ${tr(levels[p[1]]||p[1])}${p[2]==='BREATHE'?(LANG==='zh'?'呼吸':' breathing'):(LANG==='zh'?'闪烁':' flash')}`}function eventName(id){return tr(eventNames[id]||(LANG==='zh'?`未知事件(${id})`:String(id).toLowerCase().replaceAll('_',' ')))} 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=``;else{const input=``;control=unit?`
${input}${unit}
`:input}return`
${help?`
${esc(help)}
`:''}
`} function pluginText(zh,en){return LANG==='zh'?zh:en} -function renderPlugins(){return `

${pluginText('仅发现插件不会接管插件的设备能力。接入后发送监控状态;暂停或断开联动不会停止插件的本地功能。','Discovery does not take control of plugin device capabilities. Connecting sends monitoring state; pausing or disconnecting the link does not stop the plugin’s local functions.')}

`} -function pluginSummary(plugins,host){if(!Object.keys(plugins).length)return `
${pluginText('风扇与硬件插件','Fan and hardware plugins')}

${pluginText('尚未接入硬件插件,风扇状态不可用。独立安装并启动插件后,可在插件管理中接入。','No hardware plugin connected; fan status is unavailable. Install and start a plugin independently, then connect it in plugin management.')}

`;return Object.entries(plugins).map(([id,p])=>{const st=p.status||{},cap=p.capabilities||{},details=[];if(p.online){if(cap.fan)details.push(pluginText('风扇设置','Fan setting')+' '+esc(st.fan_name||'—'));if(cap.display)details.push('OLED '+esc(ownerName(st.oled_page||'')));if(cap.rgb)details.push('RGB '+esc(rgbName(st.rgb_mode||'')))}return `
${esc(p.name||id)} · ${p.online?tr('在线'):tr('离线')} ${details.join(' · ')}
`}).join('')} +function renderPlugins(){return `

${pluginText('仅发现插件不会接管插件的设备能力。接入后发送监控状态;暂停或断开联动不会停止插件的本地功能。','Discovery does not take control of plugin device capabilities. Connecting sends monitoring state; pausing or disconnecting the link does not stop the plugin’s local functions.')}

`} +function fanMetric(plugins){const fans=Object.entries(plugins).filter(([,p])=>p.online&&p.capabilities?.fan&&p.status?.available!==false).map(([id,p])=>{const st=p.status;let value='';if(p.capabilities.fan.rpm&&typeof st.fan_rpm==='number'&&Number.isFinite(st.fan_rpm)&&st.fan_rpm>=0)value=st.fan_rpm+' RPM';else if(st.fan_name&&st.fan_name!=='UNAVAILABLE')value=pluginText('档位 ','level ')+(st.fan_name==='OFF'?pluginText('关闭','Off'):st.fan_name);return value?{name:p.name||id,value}:null}).filter(Boolean);return fans.length?`
${fans.map(f=>esc(pluginText('风扇','Fan')+(fans.length>1?' '+f.name:'')+' · '+f.value)).join('
')}
`:''} +function pluginSummary(plugins,host){if(!Object.keys(plugins).length)return `
${pluginText('风扇与硬件插件','Fan and hardware plugins')}

${pluginText('尚未接入硬件插件,风扇状态不可用。独立安装并启动插件后,可在插件管理中接入。','No hardware plugin connected; fan status is unavailable. Install and start a plugin independently, then connect it in plugin management.')}

`;return Object.entries(plugins).map(([id,p])=>{const st=p.status||{},cap=p.capabilities||{},details=[];if(p.online){if(cap.display)details.push('OLED '+esc(ownerName(st.oled_page||'')));if(cap.rgb)details.push('RGB '+esc(rgbName(st.rgb_mode||'')))}return `
${esc(p.name||id)} · ${p.online?tr('在线'):tr('离线')} ${details.join(' · ')}
`}).join('')} let pluginRows=[],pluginDraft=null,pluginSelected=''; async function loadPlugins(){const list=$('pluginList');if(!list)return;const button=$('refreshPlugins'),feedback=$('pluginRefreshState');if(button?.disabled)return;if(button)button.disabled=true;if(feedback)feedback.textContent=pluginText('正在刷新…','Refreshing…');list.textContent=pluginText('正在发现本机插件…','Discovering local plugins…');try{const data=await get('/api/v1/plugins?host='+encodeURIComponent(currentConfigHost));pluginRows=data.plugins;if(feedback)feedback.textContent=pluginText('刷新完成,发现 '+pluginRows.length+' 个插件 · ','Refreshed, '+pluginRows.length+' plugins found · ')+new Date().toLocaleTimeString();list.innerHTML=pluginRows.map(p=>`

${esc(p.descriptor?.name||p.id)}

${esc(p.socket)} · ${p.online?tr('在线'):tr('离线')} · ${p.linked?(p.paused?pluginText('联动已暂停','Link paused'):pluginText('已接入','Connected')):pluginText('尚未接入','Not connected')}
${p.error?`

${esc(p.error)}

`:''}${p.status?.errors&&Object.keys(p.status.errors).length?`

${esc(JSON.stringify(p.status.errors))}

`:''}

${Object.keys(p.descriptor?.capabilities||{}).map(k=>esc(({fan:pluginText('风扇','Fan'),display:pluginText('屏幕','Display'),rgb:pluginText('灯光','Lighting')})[k]||k)).join(' · ')}

${p.linked?``:``}
`).join('')||`

${pluginText('未发现正在运行的硬件插件。刷新只查询插件,不会安装或启动服务。若已安装,请先在目标机器启动插件,再刷新并接入;未接入不影响监控。','No running hardware plugins found. Refresh only queries plugins; it does not install or start services. Start the installed plugin on the target host, then refresh and connect. Monitoring works independently.')}

`;list.querySelectorAll('[data-plugin-op]').forEach(b=>b.onclick=()=>pluginAction(b.dataset.pluginId,b.dataset.pluginOp));list.querySelectorAll('[data-plugin-edit]').forEach(b=>b.onclick=()=>editPlugin(b.dataset.pluginEdit))}catch(e){list.textContent=e.message;if(feedback)feedback.textContent=pluginText('刷新失败','Refresh failed')}finally{if(button)button.disabled=false}} @@ -85,8 +87,8 @@ async function loadMachines(){try{const registry=await get('/api/v1/hosts');flee async function loadMachineBulk(){const source=bulkSource;$('machineBulk').textContent=pluginText('正在读取配置…','Loading configuration…');try{const d=await get('/api/v1/config?host='+encodeURIComponent(source));if(source!==bulkSource)return;bulkOriginal=d.config;$('machineBulk').innerHTML=renderBulkSync();bindBulkSync()}catch(e){$('machineBulk').textContent=e.message}} function renderGeneral(s,v){if(s==='device')return renderDevice(v);const [t,d]=sectionMeta[s]||[s,''];return`

${tr(t)}

${tr(d)}
${Object.entries(v).map(([k,x])=>field(s,k,x)).join('')||'
暂无配置项
'}
`} -function renderServices(){return`

当前系统服务

点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。
正在读取 systemd 服务…

已监控服务

“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。

新建服务监控

`} -function renderHosts(v){const entries=Object.entries(v||{});return`

已登记机器

${entries.map(([id,url])=>`
${esc(id)}
${esc(url)}
`).join('')||'
尚未登记其他机器
'}

添加或更新机器

`} +function renderServices(){return`

当前系统服务

点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。
正在读取 systemd 服务…

已监控服务

“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。

新建服务监控

`} +function renderHosts(v){const entries=Object.entries(v||{});return`

已登记机器

${entries.map(([id,url])=>`
${esc(id)}
${esc(url)}
`).join('')||'
尚未登记其他机器
'}

添加或更新机器

`} function renderBulkTargets(){const targets=fleetHosts.filter(h=>h.id!==bulkSource);return`

${tr('目标机器')}

${tr('机器标识和机器列表不会参与同步。')}
${targets.map(h=>``).join('')||`
${tr('尚未登记其他机器')}
`}
`} function bulkValue(section,key,value){const item=options[`${section}.${key}`]?.find(([v])=>v===String(value));return item?tr(item[1]):value} function renderBulkSync(){const groups=bulkSections.filter(s=>bulkOriginal[s]&&Object.keys(bulkOriginal[s]).length).map(s=>`
${Object.entries(bulkOriginal[s]).map(([k,v])=>``).join('')}
`).join('');return`

${tr('来源机器')}

${esc(fleetHosts.find(h=>h.id===bulkSource)?.name||bulkSource)} · ${esc(bulkSource)}
${renderBulkTargets()}

${tr('配置项')}

${groups}
`} @@ -107,7 +109,7 @@ async function monitorAction(unit,action){const s=serviceCache.find(x=>x.unit=== async function serviceAction(unit,action){if(!confirm(LANG==='zh'?`确定${action==='start'?'启动服务':'停止服务'} ${unit} 吗?\n这会直接执行 systemctl ${action}。`:`${action==='start'?'Start':'Stop'} ${unit}?\nThis runs systemctl ${action} directly.`))return;try{await get('/api/v1/services/control',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify({unit,action})});setTimeout(loadServices,700)}catch(e){alert(e.message)}} 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=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`
`} -function hostCard(entry){const h=entry.host;if(!entry.online)return`

${esc(h.name||h.id)}

ID ${esc(h.id)}${esc(h.url||tr('本机'))}
${tr('离线')}
${esc(entry.error||tr('连接失败'))}
`;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`

${esc(m.name||h.name||m.id)}

ID ${esc(m.id||h.id)}${LANG==='zh'?'主机名':'Hostname'} ${esc(m.hostname||v.hostname||'--')}${esc(v.manufacturer||'')} ${esc(v.model||'--')}${LANG==='zh'?'序列号':'Serial'} ${esc(v.serial||'--')}${LANG==='zh'?'硬件版本':'Revision'} ${esc(v.revision||'--')}${esc(v.os||'--')}${LANG==='zh'?'内核':'Kernel'} ${esc(v.kernel||'--')}${esc(v.architecture||'--')} · CPU ${esc(v.cpu_count||'--')}${LANG==='zh'?'程序版本':'Agent'} ${esc(s.version||'--')}
${tr('在线')}
${tr('处理器负载')}
${esc(y.cpu_percent)}%
${meter(y.cpu_percent)}
${LANG==='zh'?'频率':'Frequency'} ${esc(y.cpu_freq_mhz)} MHz · ${esc(y.temperature_c)}°C
${tr('内存占用')}
${esc(y.memory_percent)}%
${meter(y.memory_percent)}
${bytes(memUsed)} / ${bytes(memTotal)} · ${LANG==='zh'?'可用':'Available'} ${bytes(y.memory_available_bytes)}
${tr('磁盘占用')}
${esc(y.disk_percent)}%
${meter(y.disk_percent)}
${bytes(diskUsed)} / ${bytes(diskTotal)} · ${LANG==='zh'?'剩余':'Free'} ${bytes(y.disk_free_bytes)}
${tr('主网络')}
${n.kind==='WIF'&&signal!==null&&signal!==undefined?`${esc(signal)}%`:esc(n.kind||'--')}
${n.kind==='WIF'?meter(signal):''}
${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')}
${tr('显示状态')}
${esc(ownerName(d.owner))}
${LANG==='zh'?'告警状态独立于硬件插件':'Alert state is independent of hardware plugins'}
${tr('当前告警')}
${(d.alerts||[]).length}
${(d.alerts||[]).map(a=>esc(ownerName(a.id))).join(' · ')||tr('无告警')}
${pluginSummary(s.plugins||{},m.id||h.id)}

${tr('服务与进程监控')}

${(s.watches||[]).map(w=>`
${esc(w.name)} · ${esc(w.target)}${tr(w.up?'正常':'停止')}
`).join('')||`
${tr('暂无数据')}
`}
`} +function hostCard(entry){const h=entry.host;if(!entry.online)return`

${esc(h.name||h.id)}

ID ${esc(h.id)}${esc(h.url||tr('本机'))}
${tr('离线')}
${esc(entry.error||tr('连接失败'))}
`;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`

${esc(m.name||h.name||m.id)}

ID ${esc(m.id||h.id)}${LANG==='zh'?'主机名':'Hostname'} ${esc(m.hostname||v.hostname||'--')}${esc(v.manufacturer||'')} ${esc(v.model||'--')}${LANG==='zh'?'序列号':'Serial'} ${esc(v.serial||'--')}${LANG==='zh'?'硬件版本':'Revision'} ${esc(v.revision||'--')}${esc(v.os||'--')}${LANG==='zh'?'内核':'Kernel'} ${esc(v.kernel||'--')}${esc(v.architecture||'--')} · CPU ${esc(v.cpu_count||'--')}${LANG==='zh'?'程序版本':'Agent'} ${esc(s.version||'--')}
${tr('在线')}
${tr('处理器负载')}
${esc(y.cpu_percent)}%
${meter(y.cpu_percent)}
${LANG==='zh'?'频率':'Frequency'} ${esc(y.cpu_freq_mhz)} MHz · ${esc(y.temperature_c)}°C
${fanMetric(s.plugins||{})}
${tr('内存占用')}
${esc(y.memory_percent)}%
${meter(y.memory_percent)}
${bytes(memUsed)} / ${bytes(memTotal)} · ${LANG==='zh'?'可用':'Available'} ${bytes(y.memory_available_bytes)}
${tr('磁盘占用')}
${esc(y.disk_percent)}%
${meter(y.disk_percent)}
${bytes(diskUsed)} / ${bytes(diskTotal)} · ${LANG==='zh'?'剩余':'Free'} ${bytes(y.disk_free_bytes)}
${tr('主网络')}
${n.kind==='WIF'&&signal!==null&&signal!==undefined?`${esc(signal)}%`:esc(n.kind||'--')}
${n.kind==='WIF'?meter(signal):''}
${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')}
${tr('显示状态')}
${esc(ownerName(d.owner))}
${LANG==='zh'?'告警状态独立于硬件插件':'Alert state is independent of hardware plugins'}
${tr('当前告警')}
${(d.alerts||[]).length}
${(d.alerts||[]).map(a=>esc(ownerName(a.id))).join(' · ')||tr('无告警')}
${pluginSummary(s.plugins||{},m.id||h.id)}

${tr('服务与进程监控')}

${(s.watches||[]).map(w=>`
${esc(w.name)} · ${esc(w.target)}${tr(w.up?'正常':'停止')}
`).join('')||`
${tr('暂无数据')}
`}
`} function renderHostCards(entries){const root=$('hostCards'),seen=new Set;entries.forEach((entry,index)=>{const id=String(entry.host.id),template=document.createElement('template');template.innerHTML=hostCard(entry);const next=template.content.firstElementChild;next.dataset.hostId=id;let current=[...root.children].find(x=>x.dataset.hostId===id);if(current){current.className=next.className;current.innerHTML=next.innerHTML;if(root.children[index]!==current)root.insertBefore(current,root.children[index]||null)}else root.insertBefore(next,root.children[index]||null);seen.add(id)});[...root.children].forEach(x=>{if(!seen.has(x.dataset.hostId))x.remove()})} async function loadStatus(){try{const fleet=await get('/api/v1/fleet/status'),online=fleet.hosts.filter(x=>x.online);$('dot').classList.toggle('ok',online.length>0);$('liveText').textContent=LANG==='zh'?`${online.length}/${fleet.hosts.length} 在线`:`${online.length}/${fleet.hosts.length} online`;$('identity').textContent=LANG==='zh'?`多机监控 · ${fleet.hosts.length} 台设备 · ${new Date(fleet.timestamp*1000).toLocaleTimeString()}`:`Fleet monitoring · ${fleet.hosts.length} hosts · ${new Date(fleet.timestamp*1000).toLocaleTimeString()}`;renderHostCards(fleet.hosts);fleetHosts=fleet.hosts.map(x=>x.host);refreshHostSelectors()}catch(e){$('dot').classList.remove('ok');$('liveText').textContent=tr('离线');$('identity').textContent=e.message}} const severityNames={DEBUG:'调试',INFO:'信息',NOTICE:'注意',WARN:'警告',ERROR:'错误',CRITICAL:'严重',ALERT:'紧急',EMERGENCY:'紧急'};let logState={sort:'timestamp',order:'desc',offset:0,limit:100,total:0};