feat: add machine management and resolved device identity
This commit is contained in:
+2
-2
@@ -1,11 +1,11 @@
|
||||
e83c52906953a723fc79208aecf08372a875e0bc2f74b4283329a475744f8984 README.md
|
||||
8372d65296273c034cac7d3a6c702ceb1d8168dac2bf81586fecf2fe57219059 install.sh
|
||||
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||
8b9df5b92218ff1c809d570141b2568228a1a4bdcb041eca29fcc6b51c61889c app/pigway_pi_control.py
|
||||
0f1ebcb0e9fa63744fcf0c0045fe4db3fa6eedcf22c333b46d6079d9105fad5c app/pigway_pi_control.py
|
||||
339f28978641242436e141add0cc923ed5ebb855bea9f6c9fe949b8eb8579527 app/pigway_pi_control_api.py
|
||||
3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
|
||||
3da8b5d94e2206bffc19e1c4fa493b31880c9a90fd6a02546461fe54a7255dbd config/pigway-pi-control.conf
|
||||
20c584762cae3a50a498df25e11272c3c191a62c9ce823e63f8224d14637b1d4 systemd/pigway-pi-control-api.service
|
||||
35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service
|
||||
64c21a4e4d93a68fcca1d0b0126655a56f306a290cced6eed5350ab9d5b5004f web/index.html
|
||||
fe32725efb7edcd54a2e0d037e0abbe3cb8994ee836a3d4b8822171f43cf3f36 web/index.html
|
||||
73b7922be93bf57169589755137dc5ec7ec7c8059c85c2bf2ee53a344e534d7e docs/HARDWARE_PLUGIN_API.md
|
||||
|
||||
@@ -53,6 +53,22 @@ def device_details():
|
||||
except Exception: pass
|
||||
try: info["model"]=Path("/proc/device-tree/model").read_bytes().rstrip(b"\0").decode()
|
||||
except Exception: pass
|
||||
# Device tree identifies ARM boards; DMI covers PCs and many other vendors.
|
||||
for key,files in {
|
||||
"manufacturer":["/sys/class/dmi/id/sys_vendor"],
|
||||
"model":["/sys/firmware/devicetree/base/model","/sys/class/dmi/id/product_name"],
|
||||
"revision":["/sys/class/dmi/id/product_version","/sys/class/dmi/id/board_version"],
|
||||
"serial":["/sys/class/dmi/id/product_serial"],
|
||||
}.items():
|
||||
if info.get(key):continue
|
||||
for filename in files:
|
||||
try:value=Path(filename).read_text().strip().strip("\0")
|
||||
except (OSError,UnicodeError):continue
|
||||
if value and value.lower() not in ("none","unknown","default string","to be filled by o.e.m."):
|
||||
info[key]=value;break
|
||||
if not info.get("manufacturer"):
|
||||
try:info["manufacturer"]=Path("/sys/firmware/devicetree/base/compatible").read_bytes().split(b"\0")[0].decode().split(',')[0]
|
||||
except (OSError,UnicodeError):pass
|
||||
try:
|
||||
os_release={}
|
||||
for line in Path("/etc/os-release").read_text().splitlines():
|
||||
|
||||
+23
-14
@@ -13,9 +13,10 @@
|
||||
</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 theme-preference"><span class="sr-only">颜色模式</span><select id="theme" aria-label="颜色模式"><option value="auto">◐ 跟随系统</option><option value="light">☀ 浅色模式</option><option value="dark">☾ 深色模式</option></select></label><div class="live"><span class="dot" id="dot"></span><span id="liveText">离线</span></div></div></header>
|
||||
<nav><button class="active" data-tab="overview">运行状态</button><button data-tab="logs">事件日志</button><button data-tab="config">系统配置</button></nav>
|
||||
<nav><button class="active" data-tab="overview">运行状态</button><button data-tab="logs">事件日志</button><button data-tab="machines">机器管理</button><button data-tab="config">系统配置</button></nav>
|
||||
<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="log-filter-top"><label>开始日期<input id="logStart" type="date"></label><label>结束日期<input id="logEnd" type="date"></label><div class="quick-range"><button data-log-range="today">今天</button><button data-log-range="yesterday">昨天</button><button data-log-range="week">本周</button><button data-log-range="month">本月</button><button data-log-range="all">全部</button></div><label>关键词<input id="logSearch" placeholder="搜索日志内容"></label></div><div class="log-filter-bottom"><label>严重程度<select id="logSeverity"><option value="">全部程度</option><option value="DEBUG">调试</option><option value="INFO">信息</option><option value="NOTICE">注意</option><option value="WARN">警告</option><option value="ERROR">错误</option><option value="CRITICAL">严重</option><option value="ALERT,EMERGENCY">紧急</option></select></label><label>机器标识<select id="logMachine"><option value="">全部机器</option></select></label><label>事件类型<select id="logEvent"><option value="">全部事件</option></select></label><div class="log-actions"><span class="muted" id="logSummary"></span><button id="refreshLogs">查询</button><button id="resetLogs">重置</button></div></div><div class="log-table-wrap"><table class="log-table"><thead><tr><th><button class="sort-button" data-log-sort="timestamp">时间</button></th><th><button class="sort-button" data-log-sort="machine">机器</button></th><th><button class="sort-button" data-log-sort="severity">程度</button></th><th><button class="sort-button" data-log-sort="event">事件类型</button></th><th><button class="sort-button" data-log-sort="message">内容</button></th></tr></thead><tbody id="logRows"></tbody></table></div><div class="log-pager"><button id="logPrev">上一页</button><span class="muted" id="logPage"></span><button id="logNext">下一页</button></div></div></section>
|
||||
<section id="machines" class="hidden"><div class="panel"><h2>机器管理</h2><div id="machineContent"></div></div></section>
|
||||
<section id="config" class="hidden"><div class="panel"><h2>系统配置</h2><p class="muted">配置按用途分组。监控设置与硬件插件配置独立保存。</p><div class="toolbar config-target-toolbar"><label class="host-select">配置机器<select id="configHost"></select></label><label class="host-select">配置写入令牌(Bearer Token)<span class="token-control"><input id="token" type="password" placeholder="当前机器配置令牌(Bearer Token)"><button id="toggleToken" type="button">显示令牌</button></span></label></div><div class="config-shell"><aside class="config-menu" id="configMenu"></aside><div id="configRows"></div></div><div class="savebar" id="mainSavebar"><button id="saveConfig">保存到当前机器</button> <span id="saveState" class="muted">尚未修改</span></div></div></section>
|
||||
</main><script>
|
||||
let LANG=localStorage.getItem('pigwayLanguage')||'zh';
|
||||
@@ -40,6 +41,7 @@ Object.assign(EN,{'机器标识':'Host ID','机器':'Host','全部机器':'All h
|
||||
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'});
|
||||
Object.assign(EN,{'批量同步':'Batch sync','从当前配置机器复制所选配置项到目标机器':'Copy selected settings from the current configuration host to target hosts','来源机器':'Source host','目标机器':'Target hosts','配置项':'Settings','全部选择':'Select all','全部取消':'Clear all','反选':'Invert selection','开始批量同步':'Start batch sync','机器标识和机器列表不会参与同步。':'Host identity and host registry are never synchronized.','请选择至少一个配置项':'Select at least one setting','请选择至少一台目标机器':'Select at least one target host','不能选择来源机器作为目标':'The source host cannot also be a target','目标机器令牌':'Target host token','项已选择':'settings selected'});
|
||||
Object.assign(EN,{'硬件插件':'Hardware plugins','配置按用途分组。监控设置与硬件插件配置独立保存。':'Monitoring settings and hardware plugin settings are saved independently.'});
|
||||
Object.assign(EN,{'机器管理':'Machine management','恢复设备默认值':'Restore device defaults','本机':'Local host'});
|
||||
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],[aria-label]').forEach(e=>['placeholder','title','aria-label'].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}
|
||||
@@ -52,7 +54,7 @@ const hints={'hardware.cooling_hat_enabled':'默认关闭;启用前运行安
|
||||
const options={'hardware.cooling_hat_enabled':[['false','关闭'],['true','启用']],'led.alert_mode':[['breathe','内置呼吸(推荐)'],['flash','自定义颜色闪烁(实验性)']],'led.normal_mode':[['off','全灭'],['solid','自定义颜色常亮'],['flash','自定义颜色闪烁'],['flow','流水灯'],['breathe','呼吸灯'],['marquee','跑马灯'],['rainbow','彩虹灯'],['colorful','炫彩灯']],'led.normal_effect_speed':[['1','慢速'],['2','中速'],['3','快速']],'led.normal_effect_color':[['red','红色'],['green','绿色'],['blue','蓝色'],['yellow','黄色'],['purple','紫色'],['cyan','青色'],['white','白色']],'dark_mode.pi_pwr_led':[['off','关闭'],['on','不干预']],'dark_mode.pi_act_led':[['off','关闭'],['on','不干预']]};
|
||||
const numberRules={'led.custom_min_hold_ms':[1500,60000,100],'led.normal_r':[0,255,1],'led.normal_g':[0,255,1],'led.normal_b':[0,255,1],'led.normal_flash_on_ms':[50,60000,10],'led.normal_flash_off_ms':[50,60000,10],'led.warning_flash_on_ms':[50,60000,10],'led.warning_flash_off_ms':[50,60000,10],'led.critical_flash_on_ms':[50,60000,10],'led.critical_flash_off_ms':[50,60000,10],'led.emergency_flash_on_ms':[50,60000,10],'led.emergency_flash_off_ms':[50,60000,10],'led.write_delay_ms':[1,1000,1],'alerts.cpu_high':[0,100,1],'alerts.cpu_recover':[0,100,1],'alerts.cpu_high_seconds':[1,86400,1],'alerts.memory_high':[0,100,1],'alerts.memory_recover':[0,100,1],'alerts.memory_high_seconds':[1,86400,1],'alerts.temp_high':[0,120,.1],'alerts.temp_critical':[0,120,.1],'alerts.disk_high':[0,100,1],'alerts.disk_critical':[0,100,1],'alerts.wifi_weak':[-120,0,1],'alerts.wifi_recover':[-120,0,1],'alerts.wifi_weak_seconds':[1,86400,1],'fan.start':[0,100,.1],'fan.level2':[0,100,.1],'fan.level3':[0,100,.1],'fan.level4':[0,100,.1],'fan.max':[0,100,.1],'fan.hysteresis':[0,20,.1],'timing.sensor_interval':[.2,3600,.1],'timing.oled_refresh_interval':[.2,3600,.1],'timing.page_interval':[1,3600,.1],'timing.network_interval':[1,3600,.1],'timing.service_interval':[1,3600,.1],'timing.health_interval':[.2,3600,.1],'display.row1_y':[0,31,1],'display.row2_y':[0,31,1],'display.row3_y':[0,31,1],'display.row4_y':[0,31,1],'api.port':[1,65535,1],'api.log_limit':[1,1000,1]};['cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>numberRules[`led.${g}_${c}`]=[0,255,1]));
|
||||
const units={'led.custom_min_hold_ms':'毫秒','led.normal_flash_on_ms':'毫秒','led.normal_flash_off_ms':'毫秒','led.warning_flash_on_ms':'毫秒','led.warning_flash_off_ms':'毫秒','led.critical_flash_on_ms':'毫秒','led.critical_flash_off_ms':'毫秒','led.emergency_flash_on_ms':'毫秒','led.emergency_flash_off_ms':'毫秒','led.write_delay_ms':'毫秒','alerts.cpu_high':'%','alerts.cpu_recover':'%','alerts.cpu_high_seconds':'秒','alerts.memory_high':'%','alerts.memory_recover':'%','alerts.memory_high_seconds':'秒','alerts.temp_high':'°C','alerts.temp_critical':'°C','alerts.disk_high':'%','alerts.disk_critical':'%','alerts.wifi_weak':'dBm','alerts.wifi_recover':'dBm','alerts.wifi_weak_seconds':'秒','fan.start':'°C','fan.level2':'°C','fan.level3':'°C','fan.level4':'°C','fan.max':'°C','fan.hysteresis':'°C','timing.sensor_interval':'秒','timing.oled_refresh_interval':'秒','timing.page_interval':'秒','timing.network_interval':'秒','timing.service_interval':'秒','timing.health_interval':'秒','display.row1_y':'像素','display.row2_y':'像素','display.row3_y':'像素','display.row4_y':'像素','api.port':'端口','api.log_limit':'条'};
|
||||
const sectionOrder=['device','hosts','plugins','alerts','timing','dark_mode','services','processes','api'],bulkSections=sectionOrder.filter(s=>!['device','hosts','plugins'].includes(s)),alertColorGroups=[['cpu','处理器、温度与散热'],['power','供电与欠压'],['memory','内存'],['storage','存储'],['network','网络与 Wi-Fi'],['service','服务与进程']];
|
||||
const sectionOrder=['device','plugins','alerts','timing','dark_mode','services','processes','api'],bulkSections=sectionOrder.filter(s=>!['device','hosts','plugins'].includes(s)),alertColorGroups=[['cpu','处理器、温度与散热'],['power','供电与欠压'],['memory','内存'],['storage','存储'],['network','网络与 Wi-Fi'],['service','服务与进程']];
|
||||
const ownerNames={NORMAL_HOME:'正常主页',ROOT_READONLY:'根文件系统只读',TEMP_CRITICAL:'处理器温度严重',POWER_UNDERVOLT:'供电欠压',THROTTLED:'处理器降频',FREQ_CAPPED:'频率受限',NETWORK_DOWN:'网络断开',INTERNET_DOWN:'互联网断开',SERVICE_DOWN:'服务停止',PROCESS_DOWN:'进程停止',DISK_CRITICAL:'磁盘严重不足',TEMP_HIGH:'处理器温度偏高',CPU_HIGH:'处理器负载过高',MEMORY_HIGH:'内存占用过高',DISK_HIGH:'磁盘空间不足',WIFI_WEAK:'Wi-Fi 信号弱'},rgbNames={DISABLED:'未启用',OFF:'全灭',NORMAL_SOLID:'正常 · 自定义常亮',NORMAL_FLASH:'正常 · 自定义闪烁',NORMAL_FLOW:'正常 · 流水灯',NORMAL_BREATHE:'正常 · 呼吸灯',NORMAL_MARQUEE:'正常 · 跑马灯',NORMAL_RAINBOW:'正常 · 彩虹灯',NORMAL_COLORFUL:'正常 · 炫彩灯'},eventNames={HARDWARE_ENABLED:'硬件插件已启用',HARDWARE_DISABLED:'硬件插件已关闭',HARDWARE_UNAVAILABLE:'硬件插件不可用',FAN_LEVEL:'风扇档位变化',START:'程序启动',STOP:'程序停止',ALERT_ACTIVE:'告警出现',ALERT_RECOVERED:'告警恢复',DISPLAY_OWNER:'显示对象切换',INTERNET_DOWN:'互联网断开',INTERNET_RECOVERED:'互联网恢复',NET_SWITCH:'网络接口切换',NETWORK_DOWN:'网络断开',NETWORK_RECOVERED:'网络恢复',POWER_LOW:'供电欠压',POWER_RECOVERED:'供电恢复',POWER_HISTORY:'历史电源状态',HISTORY_FLAG:'历史电源状态',ROOT_READONLY:'根文件系统只读',RGB_ERROR:'RGB 控制错误',RGB_MODE:'RGB 模式切换',RGB_RECOVERED:'RGB 控制恢复',SYSTEM:'系统事件',WATCH_DOWN:'监控对象停止',WATCH_RECOVERED:'监控对象恢复'};
|
||||
function ownerName(id){const [type,suffix]=String(id||'').split('|',2),name=ownerNames[type]||type;return tr(name)+(suffix?` · ${suffix}`:'')}function rgbName(mode){if(rgbNames[mode])return tr(rgbNames[mode]);const p=String(mode||'').split('_'),groups={CPU:'处理器',POWER:'供电',MEMORY:'内存',STORAGE:'存储',NETWORK:'网络',SERVICE:'服务'},levels={WARNING:'普通告警',CRITICAL:'严重告警',EMERGENCY:'紧急告警'};return`${tr(groups[p[0]]||p[0])} · ${tr(levels[p[1]]||p[1])}${p[2]==='BREATHE'?(LANG==='zh'?'呼吸':' breathing'):(LANG==='zh'?'闪烁':' flash')}`}function eventName(id){return tr(eventNames[id]||(LANG==='zh'?`未知事件(${id})`:String(id).toLowerCase().replaceAll('_',' ')))}
|
||||
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>`}
|
||||
@@ -71,18 +73,25 @@ async function editPlugin(id){try{pluginSelected=id;pluginDraft=await get('/api/
|
||||
function updatePluginVisibility(){document.querySelectorAll('[data-cap-visible]').forEach(el=>{const rules=JSON.parse(el.dataset.capVisible);el.classList.toggle('hidden',!Object.entries(rules).every(([key,values])=>values.includes(document.querySelector(`[data-cap-key="${key}"]`)?.value)))})}
|
||||
async function savePluginConfig(){const updates={};for(const el of document.querySelectorAll('[data-cap-key]')){if(!el.reportValidity())return;const s=el.dataset.capSection,k=el.dataset.capKey;(updates[s]??={})[k]=el.value}try{await pluginPut('/api/v1/plugin/config',{id:pluginSelected,updates});$('pluginSaveState').textContent=pluginText('保存成功','Saved')}catch(e){$('pluginSaveState').textContent=e.message}}
|
||||
document.addEventListener('click',async e=>{const b=e.target.closest('[data-manage-plugin-host]');if(!b)return;currentConfigHost=b.dataset.managePluginHost;await ensureHosts();$('configHost').value=currentConfigHost;document.querySelector('[data-tab="config"]').click();await loadConfig();document.querySelector('[data-config-section="plugins"]')?.click()});
|
||||
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>`}
|
||||
|
||||
let deviceIdentity={},bulkOriginal={},bulkSource='';
|
||||
function renderDevice(values){return `<div class="group"><div class="fields">${Object.entries(values).map(([key,raw])=>{const automatic=!raw||raw.toLowerCase()==='auto',value=automatic?(deviceIdentity[key==='identifier'?'id':'name']||deviceIdentity.hostname||''):raw;return field('device',key,value).replace('data-section="device"',`data-device-auto="${automatic}" data-section="device"`)}).join('')}</div><button type="button" onclick="resetDeviceDefaults()">${tr('恢复设备默认值')}</button><p class="hint">${pluginText('默认值跟随本机主机名;修改后点击保存到当前机器。','Defaults follow the device hostname. Save to the current host to apply changes.')}</p></div>`}
|
||||
function resetDeviceDefaults(){document.querySelectorAll('[data-section="device"]').forEach(el=>{el.value=deviceIdentity.hostname||'';el.dataset.deviceAuto='true'});markChanged()}
|
||||
async function loadMachines(){try{const registry=await get('/api/v1/hosts');fleetHosts=registry.hosts;bulkSource=fleetHosts.some(h=>h.id===bulkSource)?bulkSource:fleetHosts[0].id;const local=fleetHosts.find(h=>h.local);$('machineContent').innerHTML=`<div class="group"><label class="field">${pluginText('管理令牌(本机)','Management token (local host)')}<input id="machineToken" type="password" value="${esc(hostToken(local.id))}"></label></div><div class="group"><h3>${tr('本机')}</h3>${esc(local.name)} · ${esc(local.id)}</div>${renderHosts(Object.fromEntries(fleetHosts.filter(h=>!h.local).map(h=>[h.id,h.url])))}<div class="group"><h2>${tr('批量同步')}</h2><label class="host-select">${tr('来源机器')}<select id="bulkSource">${fleetHosts.map(h=>`<option value="${esc(h.id)}" ${h.id===bulkSource?'selected':''}>${esc(h.name||h.id)}</option>`).join('')}</select></label><div id="machineBulk"></div></div>`;$('machineToken').onchange=()=>saveHostToken(local.id,$('machineToken').value.trim());$('saveHost').onclick=saveHost;document.querySelectorAll('[data-delete-host]').forEach(b=>b.onclick=()=>deleteHost(b.dataset.deleteHost));$('bulkSource').onchange=()=>{bulkSource=$('bulkSource').value;loadMachineBulk()};await loadMachineBulk()}catch(e){$('machineContent').textContent=e.message}}
|
||||
async function loadMachineBulk(){const source=bulkSource;$('machineBulk').textContent=pluginText('正在读取配置…','Loading configuration…');try{const d=await get('/api/v1/config?host='+encodeURIComponent(source));if(source!==bulkSource)return;bulkOriginal=d.config;$('machineBulk').innerHTML=renderBulkSync();bindBulkSync()}catch(e){$('machineBulk').textContent=e.message}}
|
||||
|
||||
function renderGeneral(s,v){if(s==='device')return renderDevice(v);const [t,d]=sectionMeta[s]||[s,''];return`<div class="group"><div class="group-head"><div><h3>${tr(t)}</h3><div class="hint">${tr(d)}</div></div></div><div class="fields">${Object.entries(v).map(([k,x])=>field(s,k,x)).join('')||'<div class="muted">暂无配置项</div>'}</div></div>`}
|
||||
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 renderBulkTargets(){const targets=fleetHosts.filter(h=>h.id!==currentConfigHost);return`<div class="group"><h3>${tr('目标机器')}</h3><div class="hint">${tr('机器标识和机器列表不会参与同步。')}</div><div class="rows">${targets.map(h=>`<label class="bulk-target"><input type="checkbox" data-bulk-target="${esc(h.id)}"><b>${esc(h.name||h.id)}</b><input type="password" data-bulk-token="${esc(h.id)}" value="${esc(hostToken(h.id))}" placeholder="${tr('目标机器令牌')}"></label>`).join('')||`<div class="muted">${tr('尚未登记其他机器')}</div>`}</div></div>`}
|
||||
function renderBulkTargets(){const targets=fleetHosts.filter(h=>h.id!==bulkSource);return`<div class="group"><h3>${tr('目标机器')}</h3><div class="hint">${tr('机器标识和机器列表不会参与同步。')}</div><div class="rows">${targets.map(h=>`<label class="bulk-target"><input type="checkbox" data-bulk-target="${esc(h.id)}"><b>${esc(h.name||h.id)}</b><input type="password" data-bulk-token="${esc(h.id)}" value="${esc(hostToken(h.id))}" placeholder="${tr('目标机器令牌')}"></label>`).join('')||`<div class="muted">${tr('尚未登记其他机器')}</div>`}</div></div>`}
|
||||
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=>original[s]&&Object.keys(original[s]).length).map(s=>`<div class="group bulk-section"><label class="bulk-section-title"><input type="checkbox" data-bulk-section="${esc(s)}">${tr(sectionMeta[s]?.[0]||s)}</label><div class="bulk-items">${Object.entries(original[s]).map(([k,v])=>`<label class="bulk-item"><input type="checkbox" data-bulk-item="${esc(s)}.${esc(k)}"><span>${esc(tr(labels[`${s}.${k}`]||k))}<small class="bulk-value">${esc(bulkValue(s,k,v))}</small></span></label>`).join('')}</div></div>`).join('');return`<div class="group"><h3>${tr('来源机器')}</h3><div>${esc(fleetHosts.find(h=>h.id===currentConfigHost)?.name||currentConfigHost)} · ${esc(currentConfigHost)}</div></div>${renderBulkTargets()}<div class="bulk-tools"><button id="bulkAll">${tr('全部选择')}</button><button id="bulkNone">${tr('全部取消')}</button><button id="bulkInvert">${tr('反选')}</button><span class="muted" id="bulkCount"></span></div><div><h3>${tr('配置项')}</h3>${groups}</div><div class="savebar"><button id="bulkApply">${tr('开始批量同步')}</button> <span id="bulkState" class="muted"></span></div>`}
|
||||
function renderBulkSync(){const groups=bulkSections.filter(s=>bulkOriginal[s]&&Object.keys(bulkOriginal[s]).length).map(s=>`<div class="group bulk-section"><label class="bulk-section-title"><input type="checkbox" data-bulk-section="${esc(s)}">${tr(sectionMeta[s]?.[0]||s)}</label><div class="bulk-items">${Object.entries(bulkOriginal[s]).map(([k,v])=>`<label class="bulk-item"><input type="checkbox" data-bulk-item="${esc(s)}.${esc(k)}"><span>${esc(tr(labels[`${s}.${k}`]||k))}<small class="bulk-value">${esc(bulkValue(s,k,v))}</small></span></label>`).join('')}</div></div>`).join('');return`<div class="group"><h3>${tr('来源机器')}</h3><div>${esc(fleetHosts.find(h=>h.id===bulkSource)?.name||bulkSource)} · ${esc(bulkSource)}</div></div>${renderBulkTargets()}<div class="bulk-tools"><button id="bulkAll">${tr('全部选择')}</button><button id="bulkNone">${tr('全部取消')}</button><button id="bulkInvert">${tr('反选')}</button><span class="muted" id="bulkCount"></span></div><div><h3>${tr('配置项')}</h3>${groups}</div><div class="savebar"><button id="bulkApply">${tr('开始批量同步')}</button> <span id="bulkState" class="muted"></span></div>`}
|
||||
function updateBulkSelection(){document.querySelectorAll('[data-bulk-section]').forEach(parent=>{const items=[...document.querySelectorAll(`[data-bulk-item^="${parent.dataset.bulkSection}."]`)];parent.checked=items.length>0&&items.every(x=>x.checked);parent.indeterminate=items.some(x=>x.checked)&&!parent.checked});const n=document.querySelectorAll('[data-bulk-item]:checked').length;if($('bulkCount'))$('bulkCount').textContent=LANG==='zh'?`已选择 ${n} 项`:`${n} settings selected`}
|
||||
function bindBulkSync(){if(!$('bulkApply'))return;document.querySelectorAll('[data-bulk-section]').forEach(x=>x.onchange=()=>{document.querySelectorAll(`[data-bulk-item^="${x.dataset.bulkSection}."]`).forEach(i=>i.checked=x.checked);updateBulkSelection()});document.querySelectorAll('[data-bulk-item]').forEach(x=>x.onchange=updateBulkSelection);$('bulkAll').onclick=()=>{document.querySelectorAll('[data-bulk-item]').forEach(x=>x.checked=true);updateBulkSelection()};$('bulkNone').onclick=()=>{document.querySelectorAll('[data-bulk-item]').forEach(x=>x.checked=false);updateBulkSelection()};$('bulkInvert').onclick=()=>{document.querySelectorAll('[data-bulk-item]').forEach(x=>x.checked=!x.checked);updateBulkSelection()};document.querySelectorAll('[data-bulk-token]').forEach(x=>x.onchange=()=>saveHostToken(x.dataset.bulkToken,x.value.trim()));$('bulkApply').onclick=applyBulkConfig;updateBulkSelection()}
|
||||
function updateConditional(){const alertMode=document.querySelector('[data-key="alert_mode"]')?.value||'breathe';document.querySelectorAll('.alert-flash').forEach(e=>e.classList.toggle('hidden',alertMode!=='flash'));document.querySelectorAll('.alert-breathe').forEach(e=>e.classList.toggle('hidden',alertMode!=='breathe'));const mode=document.querySelector('[data-section="led"][data-key="normal_mode"]')?.value||'off';document.querySelectorAll('.normal-field').forEach(x=>x.classList.toggle('hidden',!x.dataset.modes.split(' ').includes(mode)));updateSwatches()}function updateSwatches(){document.querySelectorAll('[data-swatch]').forEach(s=>{const p=s.dataset.swatch,read=c=>Math.max(0,Math.min(255,Number(document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`)?.value||0))),hex='#'+['r','g','b'].map(c=>Math.round(read(c)).toString(16).padStart(2,'0')).join('');s.style.background=hex;const picker=document.querySelector(`[data-color-prefix="${p}"]`);if(picker)picker.value=hex})}
|
||||
function renderConfig(){const ss=[...sectionOrder.filter(s=>original[s]||s==='plugins'),'bulk_sync'];$('configMenu').innerHTML=ss.map((s,i)=>`<button class="${i===0?'active':''}" data-config-section="${s}">${tr(sectionMeta[s]?.[0]||s)}</button>`).join('');$('configRows').innerHTML=ss.map((s,i)=>`<section class="config-page ${i===0?'active':''}" data-config-page="${s}"><h2>${tr(sectionMeta[s]?.[0]||s)}</h2><p class="muted">${tr(sectionMeta[s]?.[1]||'')}</p>${s==='bulk_sync'?renderBulkSync():s==='plugins'?renderPlugins():s==='services'?renderServices():s==='hosts'?renderHosts(original[s]):renderGeneral(s,original[s])}</section>`).join('');$('configMenu').onclick=e=>{const b=e.target.closest('button');if(!b)return;document.querySelectorAll('[data-config-section]').forEach(x=>x.classList.toggle('active',x===b));document.querySelectorAll('[data-config-page]').forEach(x=>x.classList.toggle('active',x.dataset.configPage===b.dataset.configSection));$('mainSavebar').classList.toggle('hidden',['bulk_sync','plugins'].includes(b.dataset.configSection));if(b.dataset.configSection==='services')loadServices();if(b.dataset.configSection==='plugins')loadPlugins()};$('configRows').oninput=e=>{if(e.target.matches('[data-color-prefix]')){const p=e.target.dataset.colorPrefix,v=e.target.value;['r','g','b'].forEach((c,i)=>{document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`).value=parseInt(v.slice(1+i*2,3+i*2),16)})}if(e.target.matches('[data-section="led"],[data-color-prefix]'))updateConditional();if(e.target.matches('[data-section]'))markChanged()};$('refreshServices')?.addEventListener('click',loadServices);$('serviceSearch')?.addEventListener('input',renderServiceLists);$('saveMonitor')?.addEventListener('click',saveMonitor);$('resetMonitor')?.addEventListener('click',resetMonitorForm);$('saveHost')?.addEventListener('click',saveHost);document.querySelectorAll('[data-delete-host]').forEach(b=>b.onclick=()=>deleteHost(b.dataset.deleteHost));bindBulkSync();updateConditional()}
|
||||
function markChanged(){let n=0;document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(i.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>`}
|
||||
function renderConfig(){const ss=sectionOrder.filter(s=>original[s]||s==='plugins');$('configMenu').innerHTML=ss.map((s,i)=>`<button class="${i===0?'active':''}" data-config-section="${s}">${tr(sectionMeta[s]?.[0]||s)}</button>`).join('');$('configRows').innerHTML=ss.map((s,i)=>`<section class="config-page ${i===0?'active':''}" data-config-page="${s}"><h2>${tr(sectionMeta[s]?.[0]||s)}</h2><p class="muted">${tr(sectionMeta[s]?.[1]||'')}</p>${s==='bulk_sync'?renderBulkSync():s==='plugins'?renderPlugins():s==='services'?renderServices():s==='hosts'?renderHosts(original[s]):renderGeneral(s,original[s])}</section>`).join('');$('configMenu').onclick=e=>{const b=e.target.closest('button');if(!b)return;document.querySelectorAll('[data-config-section]').forEach(x=>x.classList.toggle('active',x===b));document.querySelectorAll('[data-config-page]').forEach(x=>x.classList.toggle('active',x.dataset.configPage===b.dataset.configSection));$('mainSavebar').classList.toggle('hidden',['bulk_sync','plugins'].includes(b.dataset.configSection));if(b.dataset.configSection==='services')loadServices();if(b.dataset.configSection==='plugins')loadPlugins()};$('configRows').oninput=e=>{if(e.target.matches('[data-color-prefix]')){const p=e.target.dataset.colorPrefix,v=e.target.value;['r','g','b'].forEach((c,i)=>{document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`).value=parseInt(v.slice(1+i*2,3+i*2),16)})}if(e.target.matches('[data-section="led"],[data-color-prefix]'))updateConditional();if(e.target.matches('[data-section]')){if(e.target.dataset.section==='device')e.target.dataset.deviceAuto='false';markChanged()}};$('refreshServices')?.addEventListener('click',loadServices);$('serviceSearch')?.addEventListener('input',renderServiceLists);$('saveMonitor')?.addEventListener('click',saveMonitor);$('resetMonitor')?.addEventListener('click',resetMonitorForm);$('saveHost')?.addEventListener('click',saveHost);document.querySelectorAll('[data-delete-host]').forEach(b=>b.onclick=()=>deleteHost(b.dataset.deleteHost));bindBulkSync();updateConditional()}
|
||||
function markChanged(){let n=0;document.querySelectorAll('#configRows [data-section]').forEach(i=>{if((i.dataset.deviceAuto==='true'?'auto':i.value)!==original[i.dataset.section][i.dataset.key])n++});$('saveState').textContent=n?(LANG==='zh'?`有 ${n} 项尚未保存`:`${n} unsaved changes`):tr('尚未修改');$('saveState').className=n?'':'muted'}
|
||||
tabs.forEach(b=>b.onclick=()=>{tabs.forEach(x=>x.classList.toggle('active',x===b));['overview','logs','config','machines'].forEach(id=>$(id).classList.toggle('hidden',id!==b.dataset.tab));if(b.dataset.tab==='machines')loadMachines();if(b.dataset.tab==='logs')loadLogs();if(b.dataset.tab==='config')loadConfig()});async function get(url,opt){const r=await fetch(url,opt),text=await r.text();let j;try{j=JSON.parse(text)}catch{throw Error(LANG==='zh'?`服务器返回异常 (${r.status})`:`Invalid server response (${r.status})`)}if(!r.ok)throw Error(j.error||(LANG==='zh'?`请求失败 (${r.status})`:`Request failed (${r.status})`));return j}function rows(target,items,empty='暂无数据'){$(target).innerHTML=items.length?items.join(''):`<div class="muted">${empty}</div>`}
|
||||
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))}
|
||||
@@ -94,7 +103,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<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>${meter(y.cpu_percent)}<div class="muted">${LANG==='zh'?'频率':'Frequency'} ${esc(y.cpu_freq_mhz)} MHz · ${esc(y.temperature_c)}°C</div></div><div class="metric"><div class="muted">${tr('内存占用')}</div><div class="value">${esc(y.memory_percent)}%</div>${meter(y.memory_percent)}<div class="muted">${bytes(memUsed)} / ${bytes(memTotal)} · ${LANG==='zh'?'可用':'Available'} ${bytes(y.memory_available_bytes)}</div></div><div class="metric"><div class="muted">${tr('磁盘占用')}</div><div class="value">${esc(y.disk_percent)}%</div>${meter(y.disk_percent)}<div class="muted">${bytes(diskUsed)} / ${bytes(diskTotal)} · ${LANG==='zh'?'剩余':'Free'} ${bytes(y.disk_free_bytes)}</div></div><div class="metric"><div class="muted">${tr('主网络')}</div><div class="value">${n.kind==='WIF'&&signal!==null&&signal!==undefined?`${esc(signal)}%`:esc(n.kind||'--')}</div>${n.kind==='WIF'?meter(signal):''}<div class="muted">${esc(n.interface||'--')} · ${n.kind==='WIF'?`${esc(n.metric)} dBm · `:''}${esc(n.ip_label)} ${esc(n.ip)} · ${n.internet?(LANG==='zh'?'互联网正常':'Internet online'):(LANG==='zh'?'互联网断开':'Internet down')}</div></div><div class="metric"><div class="muted">${tr('显示状态')}</div><div class="value">${esc(ownerName(d.owner))}</div><div class="muted">${LANG==='zh'?'告警状态独立于硬件插件':'Alert state is independent of hardware plugins'}</div></div><div class="metric"><div class="muted">${tr('当前告警')}</div><div class="value">${(d.alerts||[]).length}</div><div class="muted">${(d.alerts||[]).map(a=>esc(ownerName(a.id))).join(' · ')||tr('无告警')}</div></div></div>${pluginSummary(s.plugins||{},m.id||h.id)}<div class="group"><h3>${tr('服务与进程监控')}</h3><div class="rows">${(s.watches||[]).map(w=>`<div class="row"><span>${esc(w.name)} · ${esc(w.target)}</span><span class="badge ${w.up?'good':'bad'}">${tr(w.up?'正常':'停止')}</span></div>`).join('')||`<div class="muted">${tr('暂无数据')}</div>`}</div></div></article>`}
|
||||
function hostCard(entry){const h=entry.host;if(!entry.online)return`<article class="panel host-panel offline-card"><div class="host-head"><div><h2>${esc(h.name||h.id)}</h2><div class="device-details"><span>ID ${esc(h.id)}</span><span>${esc(h.url||tr('本机'))}</span></div></div><span class="badge bad">${tr('离线')}</span></div><div class="error">${esc(entry.error||tr('连接失败'))}</div></article>`;const s=entry.status,y=s.system||{},n=s.network||{},d=s.display||{},v=s.device||{},m=s.machine||h,signal=n.signal_percent,memUsed=y.memory_used_bytes,memTotal=y.memory_total_bytes,diskUsed=y.disk_used_bytes,diskTotal=y.disk_total_bytes;return`<article class="panel host-panel"><div class="host-head"><div><h2>${esc(m.name||h.name||m.id)}</h2><div class="device-details"><span>ID ${esc(m.id||h.id)}</span><span>${LANG==='zh'?'主机名':'Hostname'} ${esc(m.hostname||v.hostname||'--')}</span><span>${esc(v.manufacturer||'')} ${esc(v.model||'--')}</span><span>${LANG==='zh'?'序列号':'Serial'} ${esc(v.serial||'--')}</span><span>${LANG==='zh'?'硬件版本':'Revision'} ${esc(v.revision||'--')}</span><span>${esc(v.os||'--')}</span><span>${LANG==='zh'?'内核':'Kernel'} ${esc(v.kernel||'--')}</span><span>${esc(v.architecture||'--')} · CPU ${esc(v.cpu_count||'--')}</span><span>${LANG==='zh'?'程序版本':'Agent'} ${esc(s.version||'--')}</span></div></div><span class="badge good">${tr('在线')}</span></div><div class="host-grid"><div class="metric"><div class="muted">${tr('处理器负载')}</div><div class="value">${esc(y.cpu_percent)}%</div>${meter(y.cpu_percent)}<div class="muted">${LANG==='zh'?'频率':'Frequency'} ${esc(y.cpu_freq_mhz)} MHz · ${esc(y.temperature_c)}°C</div></div><div class="metric"><div class="muted">${tr('内存占用')}</div><div class="value">${esc(y.memory_percent)}%</div>${meter(y.memory_percent)}<div class="muted">${bytes(memUsed)} / ${bytes(memTotal)} · ${LANG==='zh'?'可用':'Available'} ${bytes(y.memory_available_bytes)}</div></div><div class="metric"><div class="muted">${tr('磁盘占用')}</div><div class="value">${esc(y.disk_percent)}%</div>${meter(y.disk_percent)}<div class="muted">${bytes(diskUsed)} / ${bytes(diskTotal)} · ${LANG==='zh'?'剩余':'Free'} ${bytes(y.disk_free_bytes)}</div></div><div class="metric"><div class="muted">${tr('主网络')}</div><div class="value">${n.kind==='WIF'&&signal!==null&&signal!==undefined?`${esc(signal)}%`:esc(n.kind||'--')}</div>${n.kind==='WIF'?meter(signal):''}<div class="muted">${esc(n.interface||'--')} · ${n.kind==='WIF'?`${esc(n.metric)} dBm · `:''}${esc(n.ip_label)} ${esc(n.ip)} · ${n.internet?(LANG==='zh'?'互联网正常':'Internet online'):(LANG==='zh'?'互联网断开':'Internet down')}</div></div><div class="metric"><div class="muted">${tr('显示状态')}</div><div class="value">${esc(ownerName(d.owner))}</div><div class="muted">${LANG==='zh'?'告警状态独立于硬件插件':'Alert state is independent of hardware plugins'}</div></div><div class="metric"><div class="muted">${tr('当前告警')}</div><div class="value">${(d.alerts||[]).length}</div><div class="muted">${(d.alerts||[]).map(a=>esc(ownerName(a.id))).join(' · ')||tr('无告警')}</div></div></div>${pluginSummary(s.plugins||{},m.id||h.id)}<div class="group"><h3>${tr('服务与进程监控')}</h3><div class="rows">${(s.watches||[]).map(w=>`<div class="row"><span>${esc(w.name)} · ${esc(w.target)}</span><span class="badge ${w.up?'good':'bad'}">${tr(w.up?'正常':'停止')}</span></div>`).join('')||`<div class="muted">${tr('暂无数据')}</div>`}</div></div></article>`}
|
||||
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};
|
||||
@@ -105,13 +114,13 @@ async function loadLogs(reset=true){if(reset)logState.offset=0;try{const p=new U
|
||||
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;$('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}
|
||||
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}
|
||||
function changedUpdates(){const updates={};document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(original[i.dataset.section]&&(i.dataset.deviceAuto==='true'?'auto':i.value)!==original[i.dataset.section][i.dataset.key]){(updates[i.dataset.section]??={})[i.dataset.key]=i.dataset.deviceAuto==='true'?'auto':i.value}});return updates}
|
||||
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 loadConfig(){try{await ensureHosts();currentConfigHost=$('configHost').value||currentConfigHost||fleetHosts[0].id;$('token').value=hostToken(currentConfigHost);const d=await get(`/api/v1/config?host=${encodeURIComponent(currentConfigHost)}`);original=d.config;deviceIdentity=d.machine||{};renderConfig();markChanged()}catch(e){$('configRows').innerHTML=`<div class="error">${esc(e.message)}</div>`}}
|
||||
async function saveHost(){const id=$('hostId').value.trim(),url=$('hostUrl').value.trim(),message=$('hostMessage');try{message.textContent='正在保存…';const local=fleetHosts.find(x=>x.local),token=hostToken(local?.id||'');const d=await get('/api/v1/hosts',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({operation:'save',id,url})});fleetHosts=d.hosts;refreshHostSelectors();await loadMachines()}catch(e){message.textContent=e.message;message.className='error'}}
|
||||
async function deleteHost(id){if(!confirm(LANG==='zh'?`确定删除机器 ${id} 吗?`:`Delete host ${id}?`))return;try{const local=fleetHosts.find(x=>x.local),token=hostToken(local?.id||'');const d=await get('/api/v1/hosts',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({operation:'delete',id})});fleetHosts=d.hosts;currentConfigHost=local?.id||'';refreshHostSelectors();await loadMachines()}catch(e){alert(e.message)}}
|
||||
async function applyConfig(){const updates=changedUpdates();if(!Object.keys(updates).length){$('saveState').textContent=tr('没有需要保存的修改');return}const local=fleetHosts.find(x=>x.local),target=currentConfigHost;try{$('saveConfig').disabled=true;$('saveState').textContent=tr('正在保存并应用…');saveHostToken(target,$('token').value.trim());const d=await get('/api/v1/fleet/config',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${hostToken(local?.id||'')}`},body:JSON.stringify({targets:[target],updates,tokens:{[target]:hostToken(target)}})}),failed=d.results.filter(x=>!x.ok);$('saveState').textContent=failed.length?failed[0].error:(LANG==='zh'?'保存成功':'Saved');$('saveState').className=failed.length?'error':'success';if(!failed.length)setTimeout(loadConfig,1200)}catch(e){$('saveState').textContent=e.message;$('saveState').className='error'}finally{$('saveConfig').disabled=false}}
|
||||
async function applyBulkConfig(){const state=$('bulkState'),button=$('bulkApply'),targets=[...document.querySelectorAll('[data-bulk-target]:checked')].map(x=>x.dataset.bulkTarget),selected=[...document.querySelectorAll('[data-bulk-item]:checked')].map(x=>x.dataset.bulkItem);if(!targets.length){state.textContent=tr('请选择至少一台目标机器');state.className='error';return}if(!selected.length){state.textContent=tr('请选择至少一个配置项');state.className='error';return}const updates={};selected.forEach(path=>{const [section,key]=path.split('.',2);(updates[section]??={})[key]=original[section][key]});const local=fleetHosts.find(x=>x.local),tokens={};document.querySelectorAll('[data-bulk-token]').forEach(x=>{saveHostToken(x.dataset.bulkToken,x.value.trim());tokens[x.dataset.bulkToken]=x.value.trim()});try{button.disabled=true;state.textContent=tr('正在保存并应用…');const d=await get('/api/v1/fleet/config',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${hostToken(local?.id||'')}`},body:JSON.stringify({targets,updates,tokens})}),failed=d.results.filter(x=>!x.ok);state.textContent=failed.length?(LANG==='zh'?`部分失败:${failed.map(x=>`${x.id}: ${x.error}`).join(';')}`:`Partial failure: ${failed.map(x=>`${x.id}: ${x.error}`).join('; ')}`):(LANG==='zh'?`已同步 ${selected.length} 项配置到 ${targets.length} 台机器`:`Synced ${selected.length} settings to ${targets.length} hosts`);state.className=failed.length?'error':'success'}catch(e){state.textContent=e.message;state.className='error'}finally{button.disabled=false}}
|
||||
async function applyBulkConfig(){const state=$('bulkState'),button=$('bulkApply'),targets=[...document.querySelectorAll('[data-bulk-target]:checked')].map(x=>x.dataset.bulkTarget),selected=[...document.querySelectorAll('[data-bulk-item]:checked')].map(x=>x.dataset.bulkItem);if(!targets.length){state.textContent=tr('请选择至少一台目标机器');state.className='error';return}if(!selected.length){state.textContent=tr('请选择至少一个配置项');state.className='error';return}const updates={};selected.forEach(path=>{const [section,key]=path.split('.',2);(updates[section]??={})[key]=bulkOriginal[section][key]});const local=fleetHosts.find(x=>x.local),tokens={};document.querySelectorAll('[data-bulk-token]').forEach(x=>{saveHostToken(x.dataset.bulkToken,x.value.trim());tokens[x.dataset.bulkToken]=x.value.trim()});try{button.disabled=true;state.textContent=tr('正在保存并应用…');const d=await get('/api/v1/fleet/config',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${hostToken(local?.id||'')}`},body:JSON.stringify({targets,updates,tokens})}),failed=d.results.filter(x=>!x.ok);state.textContent=failed.length?(LANG==='zh'?`部分失败:${failed.map(x=>`${x.id}: ${x.error}`).join(';')}`:`Partial failure: ${failed.map(x=>`${x.id}: ${x.error}`).join('; ')}`):(LANG==='zh'?`已同步 ${selected.length} 项配置到 ${targets.length} 台机器`:`Synced ${selected.length} settings to ${targets.length} hosts`);state.className=failed.length?'error':'success'}catch(e){state.textContent=e.message;state.className='error'}finally{button.disabled=false}}
|
||||
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;loadStatus();setInterval(loadStatus,3000);
|
||||
</script></body></html>
|
||||
|
||||
Reference in New Issue
Block a user