<header><div><h1>PIGWay Pi Control</h1><divclass="sub"id="identity">正在连接树莓派…</div></div><divclass="live"><spanclass="dot"id="dot"></span><spanid="liveText">离线</span></div></header>
<header><div><h1>PIGWay Pi Control</h1><divclass="sub"id="identity">正在连接树莓派…</div></div><divclass="header-actions"><labelclass="preference"><span>语言</span><selectid="language"><optionvalue="zh">中文</option><optionvalue="en">English</option></select></label><labelclass="preference"><span>颜色模式</span><selectid="theme"><optionvalue="auto">自动</option><optionvalue="dark">深色</option><optionvalue="light">浅色</option></select></label><divclass="live"><spanclass="dot"id="dot"></span><spanid="liveText">离线</span></div></div></header>
let LANG=localStorage.getItem('pigwayLanguage')||'zh';
const EN={
'语言':'Language','颜色模式':'Appearance','中文':'中文','自动':'Auto','深色':'Dark','浅色':'Light','正在连接树莓派…':'Connecting to Raspberry Pi…','在线':'Online','离线':'Offline',
'运行状态':'Status','事件日志':'Event log','系统配置':'Settings','处理器负载':'CPU load','处理器温度':'CPU temperature','内存占用':'Memory usage','磁盘占用':'Disk usage','主网络':'Primary network','显示状态':'Display status','当前告警':'Active alerts','无告警':'No alerts','服务与进程监控':'Service and process monitors',
'查询 systemd journal 中仍在保留的全部硬件 Agent 事件。':'Query all retained hardware Agent events in the systemd journal.','今天':'Today','昨天':'Yesterday','本周':'This week','本月':'This month','全部':'All','开始日期':'Start date','结束日期':'End date','严重程度':'Severity','全部程度':'All severities','调试':'Debug','信息':'Info','注意':'Notice','警告':'Warning','错误':'Error','严重':'Critical','紧急':'Emergency','事件类型':'Event type','全部事件':'All events','关键词':'Keyword','搜索日志内容':'Search log messages','查询':'Search','重置':'Reset','时间':'Time','程度':'Severity','内容':'Message','上一页':'Previous','下一页':'Next',
'配置按用途分组,只显示当前模式需要的选项。保存时会备份配置并重启硬件 Agent。':'Settings are grouped by purpose and only relevant options are shown. Saving creates a backup and restarts the hardware Agent.','配置写入令牌(Bearer Token)':'Configuration Bearer Token','显示令牌':'Show token','隐藏令牌':'Hide token','保存并应用':'Save and apply','尚未修改':'No changes',
'灯光设置':'Lighting','正常灯效与告警颜色':'Normal effects and alert colors','告警阈值':'Alert thresholds','处理器、内存、温度、磁盘和 Wi-Fi':'CPU, memory, temperature, disk and Wi-Fi','风扇温控':'Fan control','温度档位与降档回差':'Temperature levels and hysteresis','刷新周期':'Refresh intervals','传感器、屏幕、网络和健康检查':'Sensors, display, network and health checks','屏幕布局':'Display layout','OLED 四行文字位置':'OLED four-line positions','板载指示灯':'Board LEDs','树莓派电源灯与活动灯':'Raspberry Pi power and activity LEDs','服务监控':'Service monitoring','监控 systemd 服务':'Monitor systemd services','进程监控':'Process monitoring','监控普通进程关键字':'Monitor process keywords','Web 与 API':'Web and API','监听地址、端口和日志数量':'Bind address, port and log count',
'正常运行灯效':'Normal lighting','没有告警时显示;告警出现后自动让位。':'Shown when no alert is active; alerts take priority.','告警对象颜色':'Alert category colors','告警对象决定颜色,严重程度决定闪烁速度。':'The alert category determines color; severity determines flash timing.','普通告警闪烁节奏':'Warning flash timing','严重告警闪烁节奏':'Critical flash timing','紧急告警闪烁节奏':'Emergency flash timing','硬件高级参数':'Advanced hardware settings','自定义颜色':'Custom color','点击色块打开色盘':'Click the swatch to open the color picker','颜色':'Color','红色通道':'Red channel','绿色通道':'Green channel','蓝色通道':'Blue channel','范围 0–255。':'Range: 0–255.','亮灯时间':'On time','灭灯时间':'Off time','毫秒':'ms','秒':'s','像素':'px','条':'entries',
'当前系统服务':'System services','点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。':'Click “Add monitor” to fill the form below, or enter a future service manually.','刷新列表':'Refresh list','搜索服务名称或说明':'Search service name or description','正在读取 systemd 服务…':'Loading systemd services…','已监控服务':'Monitored services','“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。':'“Pause monitor” stops only status checks; “Stop service” operates systemd.','新建服务监控':'Add service monitor','编辑服务监控':'Edit service monitor','OLED 显示名称':'OLED display name','systemd 服务名称':'systemd service name','备注说明':'Description','保存监控':'Save monitor','清空':'Clear','未监控':'Not monitored','已监控':'Monitored','已暂停':'Paused','加入监控':'Add monitor','编辑监控':'Edit monitor','暂停监控':'Pause monitor','恢复监控':'Resume monitor','停止服务':'Stop service','启动服务':'Start service','编辑':'Edit','删除监控':'Delete monitor','正在运行':'Running','未运行':'Stopped','监控已暂停':'Monitor paused','运行中':'Running','服务已停止':'Service stopped','备注:':'Note: ','未填写':'Not provided','暂无服务说明':'No service description','没有匹配的服务':'No matching services','尚未监控任何服务':'No monitored services','正在保存…':'Saving…','保存成功':'Saved','暂无配置项':'No settings','暂无数据':'No data',
'正常主页':'Normal home','处理器温度严重':'Critical CPU temperature','处理器降频':'CPU throttled','频率受限':'Frequency capped','服务停止':'Service down','进程停止':'Process down','磁盘严重不足':'Disk critically full','处理器温度偏高':'High CPU temperature','处理器负载过高':'High CPU load','内存占用过高':'High memory usage','磁盘空间不足':'Disk usage high','Wi-Fi 信号弱':'Weak Wi-Fi signal','全灭':'Off','普通告警':'Warning','严重告警':'Critical','紧急告警':'Emergency','无线网络':'Wi-Fi','有线网络':'Ethernet','正常':'Normal','停止':'Stopped','系统服务':'System service','普通进程':'Process','当前没有告警':'No active alerts','没有符合条件的日志':'No matching events','数据来源:systemd journal':'Source: systemd journal','项尚未保存':'unsaved changes','没有需要保存的修改':'No changes to save','正在保存并应用…':'Saving and applying…'
};
Object.assign(EN,{
'正常状态灯效':'Normal mode','自定义颜色常亮':'Custom solid color','自定义颜色闪烁':'Custom color flash','流水灯':'Flow','呼吸灯':'Breathe','跑马灯':'Marquee','彩虹灯':'Rainbow','炫彩灯':'Colorful','内置效果速度':'Built-in effect speed','内置效果颜色':'Built-in effect color','慢速':'Slow','中速':'Medium','快速':'Fast','红色':'Red','绿色':'Green','蓝色':'Blue','黄色':'Yellow','紫色':'Purple','青色':'Cyan','白色':'White','关闭':'Off','不干预':'Unmanaged',
'普通告警亮灯':'Warning on time','普通告警灭灯':'Warning off time','严重告警亮灯':'Critical on time','严重告警灭灯':'Critical off time','紧急告警亮灯':'Emergency on time','紧急告警灭灯':'Emergency off time','RGB 寄存器写入间隔':'RGB register write interval','处理器高负载阈值':'High CPU threshold','处理器恢复阈值':'CPU recovery threshold','处理器持续时间':'CPU duration','内存高占用阈值':'High memory threshold','内存恢复阈值':'Memory recovery threshold','内存持续时间':'Memory duration','高温告警阈值':'High temperature threshold','严重高温阈值':'Critical temperature threshold','磁盘高占用阈值':'High disk threshold','磁盘严重阈值':'Critical disk threshold','Wi-Fi 弱信号阈值':'Weak Wi-Fi threshold','Wi-Fi 恢复阈值':'Wi-Fi recovery threshold','弱信号持续时间':'Weak signal duration',
'风扇启动温度':'Fan start temperature','二档起始温度':'Level 2 temperature','三档起始温度':'Level 3 temperature','四档起始温度':'Level 4 temperature','全速起始温度':'Full-speed temperature','降档温度回差':'Downshift hysteresis','传感器采样周期':'Sensor interval','OLED 刷新周期':'OLED refresh interval','OLED 页面轮播周期':'OLED page interval','网络检查周期':'Network check interval','服务检查周期':'Service check interval','健康检查周期':'Health check interval','第一行纵坐标':'Row 1 Y position','第二行纵坐标':'Row 2 Y position','第三行纵坐标':'Row 3 Y position','第四行纵坐标':'Row 4 Y position','树莓派电源灯':'Raspberry Pi power LED','树莓派活动灯':'Raspberry Pi activity LED','监听地址':'Bind address','监听端口':'Listen port','默认日志条数':'Default log entries',
'处理器、温度与散热':'CPU, temperature and cooling','供电与欠压':'Power and undervoltage','内存':'Memory','存储':'Storage','网络与 Wi-Fi':'Network and Wi-Fi','服务与进程':'Services and processes','仅在没有当前告警时生效;告警会立即抢占。':'Only active when there are no alerts; alerts take priority immediately.','MCU 内置速度档位。':'MCU built-in speed level.','仅流水灯和呼吸灯使用。':'Used only by flow and breathe modes.','硬件校准项,通常保持 10 毫秒。':'Hardware calibration setting; normally keep 10 ms.','单位 dBm,数值越接近 0 信号越强。':'Unit: dBm. Values closer to 0 mean a stronger signal.','单位 dBm,应高于弱信号阈值。':'Unit: dBm. Must be above the weak-signal threshold.','避免临界温度附近频繁切换档位。':'Prevents rapid level changes near a threshold.','选择“关闭”时由程序熄灭板载灯。':'Choose “Off” to let the program turn off the board LED.'
});
Object.assign(EN,{'机器标识':'Host ID','机器':'Host','全部机器':'All hosts','配置机器':'Configure host','多机配置同步':'Multi-host configuration sync','勾选目标机器后,将当前页面中已修改的配置项批量同步到这些机器。':'Select target hosts to copy the changed settings on this page.','当前机器配置令牌(Bearer Token)':'Current host Bearer Token','保存到当前机器':'Save to current host','批量同步已修改项':'Sync changed settings','设备标识':'Device identity','自定义本机显示名称和稳定标识':'Set the local display name and stable identifier','显示名称':'Display name','机器列表':'Host list','登记需要聚合监控的其他节点':'Register nodes for fleet monitoring','已登记机器':'Registered hosts','尚未登记其他机器':'No remote hosts registered','添加或更新机器':'Add or update host','节点地址':'Node URL','保存机器':'Save host','正在读取主机状态…':'Loading host status…','可用':'Available','剩余':'Free','本机':'Local','连接失败':'Connection failed'});
Object.assign(EN,{'端口':'port','0.0.0.0 允许局域网访问;127.0.0.1 仅本机。':'0.0.0.0 allows LAN access; 127.0.0.1 is local only.','范围 1–65535;保存后 API 自动重启。':'Range: 1–65535. The API restarts after saving.','API 服务不能停止自身':'The API service cannot stop itself','已自动填写,可继续修改。':'Filled automatically; you can continue editing.','请先选择同步目标机器':'Select target hosts first'});
function translateNode(root=document){const map=LANG==='en'?EN:ZH,translate=value=>{const trim=value.trim();return trim&&map[trim]?value.replace(trim,map[trim]):value};const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);let n;while(n=walker.nextNode())n.nodeValue=translate(n.nodeValue);root.querySelectorAll?.('[placeholder],[title]').forEach(e=>['placeholder','title'].forEach(a=>{const v=e.getAttribute(a);if(v)e.setAttribute(a,translate(v))}))}
function applyTheme(value){const resolved=value==='auto'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):value;document.documentElement.dataset.theme=resolved}
function ownerName(id){const [type,suffix]=String(id||'').split('|',2);return(ownerNames[type]||type)+(suffix?` · ${suffix}`:'')}function rgbName(mode){if(rgbNames[mode])return rgbNames[mode];const p=String(mode||'').split('_'),groups={CPU:'处理器',POWER:'供电',MEMORY:'内存',STORAGE:'存储',NETWORK:'网络',SERVICE:'服务'},levels={WARNING:'普通告警',CRITICAL:'严重告警',EMERGENCY:'紧急告警'};return`${groups[p[0]]||p[0]} · ${levels[p[1]]||p[1]}闪烁`}
function field(section,key,value,extra=''){const id=`${section}.${key}`,opts=options[id],rule=numberRules[id],label=labels[id]||key,help=hints[id]||'',unit=units[id]||'';let control;if(opts)control=`<selectdata-section="${esc(section)}"data-key="${esc(key)}">${opts.map(([v,l])=>`<optionvalue="${v}"${String(value)===v?'selected':''}>${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?`<divclass="input-unit">${input}<spanclass="unit">${unit}</span></div>`:input}return`<divclass="field ${extra}"><label>${esc(label)}${control}</label>${help?`<divclass="hint">${esc(help)}</div>`:''}</div>`}
function rgbGroup(values,prefix,title){const r=Number(values[`${prefix}_r`]||0),g=Number(values[`${prefix}_g`]||0),b=Number(values[`${prefix}_b`]||0),hex='#'+[r,g,b].map(x=>Math.max(0,Math.min(255,x)).toString(16).padStart(2,'0')).join('');return`<divclass="group"><divclass="group-head"><h3>${title}</h3><spanclass="muted">点击色块打开色盘</span></div><divclass="rgb-row"><divclass="color-field"><label>颜色</label><divclass="swatch-wrap"><spanclass="swatch"data-swatch="${prefix}"style="background:${hex}"></span><inputclass="color-picker"type="color"data-color-prefix="${prefix}"value="${hex}"title="选择${esc(title)}"></div></div>${['r','g','b'].map(c=>field('led',`${prefix}_${c}`,values[`${prefix}_${c}`])).join('')}</div></div>`}
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=`<selectdata-section="${esc(section)}"data-key="${esc(key)}">${opts.map(([v,l])=>`<optionvalue="${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?`<divclass="input-unit">${input}<spanclass="unit">${unit}</span></div>`:input}return`<divclass="field ${extra}"><label>${esc(label)}${control}</label>${help?`<divclass="hint">${esc(help)}</div>`:''}</div>`}
function rgbGroup(values,prefix,title){const r=Number(values[`${prefix}_r`]||0),g=Number(values[`${prefix}_g`]||0),b=Number(values[`${prefix}_b`]||0),hex='#'+[r,g,b].map(x=>Math.max(0,Math.min(255,x)).toString(16).padStart(2,'0')).join('');return`<divclass="group"><divclass="group-head"><h3>${tr(title)}</h3><spanclass="muted">${tr('点击色块打开色盘')}</span></div><divclass="rgb-row"><divclass="color-field"><label>${tr('颜色')}</label><divclass="swatch-wrap"><spanclass="swatch"data-swatch="${prefix}"style="background:${hex}"></span><inputclass="color-picker"type="color"data-color-prefix="${prefix}"value="${hex}"title="${LANG==='zh'?'选择':'Choose ' }${esc(tr(title))}"></div></div>${['r','g','b'].map(c=>field('led',`${prefix}_${c}`,values[`${prefix}_${c}`])).join('')}</div></div>`}
function normalGroup(v){return`<divclass="group"><divclass="group-head"><div><h3>正常运行灯效</h3><divclass="hint">没有告警时显示;告警出现后自动让位。</div></div></div><divclass="fields">${field('led','normal_mode',v.normal_mode,'span2')}<divclass="normal-field span2"data-modes="solid flash">${rgbGroup(v,'normal','自定义颜色')}</div><divclass="normal-field"data-modes="flash">${field('led','normal_flash_on_ms',v.normal_flash_on_ms)}</div><divclass="normal-field"data-modes="flash">${field('led','normal_flash_off_ms',v.normal_flash_off_ms)}</div><divclass="normal-field"data-modes="flow breathe marquee rainbow colorful">${field('led','normal_effect_speed',v.normal_effect_speed)}</div><divclass="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)=>`<divclass="group"><h3>${['普通告警','严重告警','紧急告警'][i]}闪烁节奏</h3><divclass="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?'':`<divclass="group"><h3>硬件高级参数</h3><divclass="fields">${field('led','write_delay_ms',v.write_delay_ms)}</div></div>`;return normalGroup(v)+`<divclass="group"><divclass="group-head"><div><h3>告警对象颜色</h3><divclass="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`<divclass="group"><divclass="group-head"><div><h3>${t}</h3><divclass="hint">${d}</div></div></div><divclass="fields">${Object.entries(v).map(([k,x])=>field(s,k,x)).join('')||'<divclass="muted">暂无配置项</div>'}</div></div>`}
function renderServices(){return`<divclass="group"><divclass="group-head"><div><h3>当前系统服务</h3><divclass="hint">点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。</div></div><buttonid="refreshServices">刷新列表</button></div><inputclass="service-search"id="serviceSearch"placeholder="搜索服务名称或说明"><divclass="service-list rows"id="systemServices"><divclass="muted">正在读取 systemd 服务…</div></div></div><divclass="group"><h3>已监控服务</h3><divclass="hint">“暂停监控”只停止 PIGWay 检查,不影响服务运行;“停止服务”才会操作 systemd。</div><divclass="rows"id="monitoredServices"></div></div><divclass="group"><h3id="serviceFormTitle">新建服务监控</h3><divclass="service-form"><label>OLED 显示名称<inputid="monitorName"maxlength="64"placeholder="例如:DOCKER"></label><label>systemd 服务名称<inputid="monitorTarget"maxlength="128"placeholder="例如:docker.service"></label><labelclass="full">备注说明<inputid="monitorNote"maxlength="120"placeholder="例如:家庭相册容器服务"></label><divclass="full"><buttonid="saveMonitor">保存监控</button><buttonid="resetMonitor">清空</button><spanid="serviceMessage"class="muted"></span></div></div></div>`}
function renderLed(v){const flash=['warning','critical','emergency'].map((x,i)=>`<divclass="group"><h3>${tr(['普通告警闪烁节奏','严重告警闪烁节奏','紧急告警闪烁节奏'][i])}</h3><divclass="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?'':`<divclass="group"><h3>硬件高级参数</h3><divclass="fields">${field('led','write_delay_ms',v.write_delay_ms)}</div></div>`;return normalGroup(v)+`<divclass="group"><divclass="group-head"><div><h3>告警对象颜色</h3><divclass="hint">告警对象决定颜色,严重程度决定闪烁速度。</div></div></div>${alertColorGroups.map(([p,t])=>rgbGroup(v,p,tr(t))).join('')}</div>`+flash+advanced}
function renderGeneral(s,v){const [t,d]=sectionMeta[s]||[s,''];return`<divclass="group"><divclass="group-head"><div><h3>${tr(t)}</h3><divclass="hint">${tr(d)}</div></div></div><divclass="fields">${Object.entries(v).map(([k,x])=>field(s,k,x)).join('')||'<divclass="muted">暂无配置项</div>'}</div></div>`}
function renderServices(){return`<divclass="group"><divclass="group-head"><div><h3>当前系统服务</h3><divclass="hint">点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。</div></div><buttonid="refreshServices">刷新列表</button></div><inputclass="service-search"id="serviceSearch"placeholder="搜索服务名称或说明"><divclass="service-list rows"id="systemServices"><divclass="muted">正在读取 systemd 服务…</div></div></div><divclass="group"><h3>已监控服务</h3><divclass="hint">“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。</div><divclass="rows"id="monitoredServices"></div></div><divclass="group"><h3id="serviceFormTitle">新建服务监控</h3><divclass="service-form"><label>OLED 显示名称<inputid="monitorName"maxlength="64"placeholder="例如:DOCKER"></label><label>systemd 服务名称<inputid="monitorTarget"maxlength="128"placeholder="例如:docker.service"></label><labelclass="full">备注说明<inputid="monitorNote"maxlength="120"placeholder="例如:家庭相册容器服务"></label><divclass="full"><buttonid="saveMonitor">保存监控</button><buttonid="resetMonitor">清空</button><spanid="serviceMessage"class="muted"></span></div></div></div>`}
function renderHosts(v){const entries=Object.entries(v||{});return`<divclass="group"><h3>已登记机器</h3><divclass="rows">${entries.map(([id,url])=>`<divclass="row"><span><b>${esc(id)}</b><br><spanclass="muted">${esc(url)}</span></span><buttonclass="danger"data-delete-host="${esc(id)}">删除</button></div>`).join('')||'<divclass="muted">尚未登记其他机器</div>'}</div></div><divclass="group"><h3>添加或更新机器</h3><divclass="service-form"><label>机器标识<inputid="hostId"maxlength="64"placeholder="例如:living-room"></label><label>节点地址<inputid="hostUrl"maxlength="200"placeholder="例如:http://192.168.1.20:6001"></label><divclass="full"><buttonid="saveHost">保存机器</button><spanid="hostMessage"class="muted"></span></div></div></div>`}
function updateConditional(){const mode=document.querySelector('[data-section="led"][data-key="normal_mode"]')?.value||'off';document.querySelectorAll('.normal-field').forEach(x=>x.classList.toggle('hidden',!x.dataset.modes.split(' ').includes(mode)));updateSwatches()}function updateSwatches(){document.querySelectorAll('[data-swatch]').forEach(s=>{const p=s.dataset.swatch,read=c=>Math.max(0,Math.min(255,Number(document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`)?.value||0))),hex='#'+['r','g','b'].map(c=>Math.round(read(c)).toString(16).padStart(2,'0')).join('');s.style.background=hex;const picker=document.querySelector(`[data-color-prefix="${p}"]`);if(picker)picker.value=hex})}
function renderConfig(){const ss=sectionOrder.filter(s=>original[s]);$('configMenu').innerHTML=ss.map((s,i)=>`<buttonclass="${i===0?'active':''}"data-config-section="${s}">${sectionMeta[s]?.[0]||s}</button>`).join('');$('configRows').innerHTML=ss.map((s,i)=>`<sectionclass="config-page ${i===0?'active':''}"data-config-page="${s}"><h2>${sectionMeta[s]?.[0]||s}</h2><pclass="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'}
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=`<divclass="error">${esc(e.message)}</div>`}}
function renderConfig(){const ss=sectionOrder.filter(s=>original[s]);$('configMenu').innerHTML=ss.map((s,i)=>`<buttonclass="${i===0?'active':''}"data-config-section="${s}">${tr(sectionMeta[s]?.[0]||s)}</button>`).join('');$('configRows').innerHTML=ss.map((s,i)=>`<sectionclass="config-page ${i===0?'active':''}"data-config-page="${s}"><h2>${tr(sectionMeta[s]?.[0]||s)}</h2><pclass="muted">${tr(sectionMeta[s]?.[1]||'')}</p>${s==='led'?renderLed(original[s]):s==='services'?renderServices():s==='hosts'?renderHosts(original[s]):renderGeneral(s,original[s])}</section>`).join('');$('configMenu').onclick=e=>{const b=e.target.closest('button');if(!b)return;document.querySelectorAll('[data-config-section]').forEach(x=>x.classList.toggle('active',x===b));document.querySelectorAll('[data-config-page]').forEach(x=>x.classList.toggle('active',x.dataset.configPage===b.dataset.configSection));if(b.dataset.configSection==='services')loadServices()};$('configRows').oninput=e=>{if(e.target.matches('[data-color-prefix]')){const p=e.target.dataset.colorPrefix,v=e.target.value;['r','g','b'].forEach((c,i)=>{document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`).value=parseInt(v.slice(1+i*2,3+i*2),16)})}if(e.target.matches('[data-section="led"],[data-color-prefix]'))updateConditional();if(e.target.matches('[data-section]'))markChanged()};$('refreshServices')?.addEventListener('click',loadServices);$('serviceSearch')?.addEventListener('input',renderServiceLists);$('saveMonitor')?.addEventListener('click',saveMonitor);$('resetMonitor')?.addEventListener('click',resetMonitorForm);$('saveHost')?.addEventListener('click',saveHost);document.querySelectorAll('[data-delete-host]').forEach(b=>b.onclick=()=>deleteHost(b.dataset.deleteHost));updateConditional()}
function markChanged(){let n=0;document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(i.value!==original[i.dataset.section][i.dataset.key])n++});$('saveState').textContent=n?(LANG==='zh'?`有 ${n} 项尚未保存`:`${n} unsaved changes`):tr('尚未修改');$('saveState').className=n?'':'muted'}
tabs.forEach(b=>b.onclick=()=>{tabs.forEach(x=>x.classList.toggle('active',x===b));['overview','logs','config'].forEach(id=>$(id).classList.toggle('hidden',id!==b.dataset.tab));if(b.dataset.tab==='logs')loadLogs();if(b.dataset.tab==='config')loadConfig()});async function get(url,opt){const r=await fetch(url,opt),text=await r.text();let j;try{j=JSON.parse(text)}catch{throw Error(LANG==='zh'?`服务器返回异常 (${r.status})`:`Invalid server response (${r.status})`)}if(!r.ok)throw Error(j.error||(LANG==='zh'?`请求失败 (${r.status})`:`Request failed (${r.status})`));return j}function rows(target,items,empty='暂无数据'){$(target).innerHTML=items.length?items.join(''):`<divclass="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=`<divclass="error">${esc(e.message)}</div>`}}
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 deleteMonitor(unit){const s=serviceCache.find(x=>x.unit===unit);if(!s||!confirm(LANG==='zh'?`确定删除 ${s.monitor_name} 的监控吗?\n不会删除或停止 ${unit}。`:`Remove monitoring for ${s.monitor_name}?\nThis will not remove or stop ${unit}.`))return;try{await get('/api/v1/services',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify({operation:'delete',name:s.monitor_name,target:s.unit,note:''})});await loadConfig();document.querySelector('[data-config-section="services"]')?.click()}catch(e){alert(e.message)}}
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)}}
function localDateValue(d){return`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`}
function setLogRange(range){const now=new Date(),today=new Date(now.getFullYear(),now.getMonth(),now.getDate()),start=new Date(today),end=new Date(today);if(range==='all'){$('logStart').value='';$('logEnd').value='';return}if(range==='yesterday'){start.setDate(start.getDate()-1);end.setDate(end.getDate()-1)}else if(range==='week'){start.setDate(start.getDate()-((start.getDay()+6)%7))}else if(range==='month'){start.setDate(1)}$('logStart').value=localDateValue(start);$('logEnd').value=localDateValue(end)}
function logTimestamp(value,end=false){if(!value)return'';const d=new Date(`${value}T00:00:00`);if(end)d.setDate(d.getDate()+1);return Math.floor(d.getTime()/1000)-(end?1:0)}
async function loadConfig(){try{const d=await get('/api/v1/config');original=d.config;renderConfig();markChanged()}catch(e){$('configRows').innerHTML=`<divclass="error">${esc(e.message)}</div>`}}
function hostToken(id){return localStorage.getItem(`pigwayToken:${id}`)||localStorage.getItem('pigwayToken')||''}
function saveHostToken(id,value){localStorage.setItem(`pigwayToken:${id}`,value);if(fleetHosts.find(x=>x.id===id)?.local)localStorage.setItem('pigwayToken',value)}
function refreshHostSelectors(){if(!fleetHosts.length)return;const current=$('configHost').value||currentConfigHost||fleetHosts[0].id,checked=new Set([...document.querySelectorAll('#syncHosts input:checked')].map(x=>x.value));$('configHost').innerHTML=fleetHosts.map(h=>`<optionvalue="${esc(h.id)}"${h.id===current?'selected':''}>${esc(h.name||h.id)} · ${esc(h.id)}</option>`).join('');currentConfigHost=$('configHost').value;$('syncHosts').innerHTML=fleetHosts.map(h=>`<label><inputtype="checkbox"value="${esc(h.id)}"${checked.has(h.id)?'checked':''}>${esc(h.name||h.id)}</label>`).join('')}
function changedUpdates(){const updates={};document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(original[i.dataset.section]&&i.value!==original[i.dataset.section][i.dataset.key]){(updates[i.dataset.section]??={})[i.dataset.key]=i.value}});return updates}
async function ensureHosts(){if(fleetHosts.length)return;const d=await get('/api/v1/hosts');fleetHosts=d.hosts;refreshHostSelectors()}
async function loadConfig(){try{await ensureHosts();currentConfigHost=$('configHost').value||currentConfigHost||fleetHosts[0].id;$('token').value=hostToken(currentConfigHost);const d=await get(`/api/v1/config?host=${encodeURIComponent(currentConfigHost)}`);original=d.config;renderConfig();markChanged()}catch(e){$('configRows').innerHTML=`<divclass="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'}}
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.