From 2e5562fb3e003e18ba5aaa0774a5ee4149276687 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 11:20:45 +0800 Subject: [PATCH] feat: improve web configuration controls --- README.md | 3 ++ SHA256SUMS | 6 +-- app/pigway_pi_control_api.py | 91 +++++++++++++++++++++++++++++++++++- web/index.html | 20 ++++++-- 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 4c645c7..22ca5e1 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ GET /api/v1/health GET /api/v1/status GET /api/v1/logs?limit=200 GET /api/v1/config +GET /api/v1/services ``` 配置写入接口: @@ -174,6 +175,8 @@ Content-Type: application/json {"updates":{"led":{"service_b":"96"},"api":{"port":"6001"}}} ``` +服务监控页面会列出本机 systemd 服务,并标识“已监控/未监控”。添加、编辑或删除监控使用 `PUT /api/v1/services`;启动或停止服务使用 `PUT /api/v1/services/control`,两者均要求 Bearer Token。SSH、向日葵、Tailscale 和 PIGWay 自身服务不能从 Web 停止,以免中断远程管理或控制进程。监控备注保存在配置文件的 `[service_notes]`,不会参与硬件 Agent 的监控判断。 + 读取 token: ```bash diff --git a/SHA256SUMS b/SHA256SUMS index f98d8ab..368250c 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,10 +1,10 @@ -a182abe8c1f188b9e9b47fdc24802fedd6a569b462a85254185521fe68936a76 README.md +5442d660875ec28909b1183e4e2aee604e9f2e82d229c3806498392e7a4546e3 README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin d71d949643d31461f2e62ded3818e1a0f1711d1420756ad0cc32eb04cde0fad3 app/pigway_pi_control.py -3a13b4e8f9d306e77f82eba78b78fe9b1932488884d6f3f976754bfa94f0153d app/pigway_pi_control_api.py +bf52a12d3208b5330f3e2980187e70330b930e950cfd5165b4e6605febfef65d app/pigway_pi_control_api.py 75fa662ed21138a8e394de83e46281dab039433cc0d7a8f787e1a049f4b47ec8 config/pigway-pi-control.conf f34eac1df180a3da3a6998d650c06435fd89728c10b090f6162df82e53986dd0 install.sh 4f6e8db3698a62f57823d3298a0e6814593de6fa3d31e4751b7ea4a76e553e35 systemd/pigway-pi-control.service aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service 9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh -53a1cbbef49d1300c5d79ab2dda9d57d6743fe8d34cbae60d0c81d7d6e6f561c web/index.html +2a3e37414845e023850af248a8ae4f5aacc06814cee88950e6e2564a34bac2c3 web/index.html diff --git a/app/pigway_pi_control_api.py b/app/pigway_pi_control_api.py index 724308b..b890dad 100644 --- a/app/pigway_pi_control_api.py +++ b/app/pigway_pi_control_api.py @@ -12,10 +12,13 @@ TOKEN=Path("/etc/pigway-pi-control-api.token") WEB=Path("/usr/local/share/pigway-pi-control/web/index.html") ALLOWED_SECTIONS={"services","processes","alerts","fan","led","timing","dark_mode","display","api"} NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$") +UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$") RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$") FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency|normal)_flash_(on|off)_ms$") NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"} EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"} +PROTECTED_UNITS={"ssh.service","sshd.service","runawesun.service","tailscaled.service", + "pigway-pi-control.service","pigway-pi-control-api.service"} def load_cfg(): cfg=configparser.ConfigParser(); cfg.read(CFG) @@ -102,6 +105,76 @@ def journal(limit): except json.JSONDecodeError: pass return rows +def service_inventory(): + cfg=load_cfg() + monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {} + notes=dict(cfg.items("service_notes")) if cfg.has_section("service_notes") else {} + files=subprocess.run(["systemctl","list-unit-files","--type=service","--no-legend","--no-pager"], + capture_output=True,text=True,timeout=10,check=True) + units={} + for line in files.stdout.splitlines(): + parts=line.split() + if len(parts)>=2 and UNIT_RE.fullmatch(parts[0]): + units[parts[0]]={"unit":parts[0],"enabled":parts[1],"active":"inactive","description":""} + states=subprocess.run(["systemctl","list-units","--all","--type=service","--no-legend","--no-pager","--plain"], + capture_output=True,text=True,timeout=10,check=True) + for line in states.stdout.splitlines(): + parts=line.split(None,4) + if len(parts)>=4 and UNIT_RE.fullmatch(parts[0]): + item=units.setdefault(parts[0],{"unit":parts[0],"enabled":"unknown"}) + item.update({"active":parts[2],"description":parts[4] if len(parts)>4 else ""}) + for name,target in monitored.values(): + item=units.setdefault(target,{"unit":target,"enabled":"not-found","active":"inactive","description":""}) + item.update({"monitored":True,"monitor_name":name.upper(),"note":notes.get(name,"")}) + for item in units.values(): + item.setdefault("monitored",False); item.setdefault("monitor_name",""); item.setdefault("note","") + item["protected"]=item["unit"] in PROTECTED_UNITS + return sorted(units.values(),key=lambda x:(not x["monitored"],x["unit"])) + +def edit_service_monitor(operation,name,target,note="",previous_name=""): + if operation not in {"save","delete"}: raise ValueError("operation must be save or delete") + if not NAME_RE.fullmatch(name): raise ValueError("invalid monitor name") + if not UNIT_RE.fullmatch(target): raise ValueError("invalid systemd service name") + if len(note)>120 or any(c in note for c in "\r\n\x00"): raise ValueError("invalid note") + if previous_name and not NAME_RE.fullmatch(previous_name): raise ValueError("invalid previous monitor name") + cfg=load_cfg(); old_target=cfg.get("services",name,fallback=None) + text=CFG.read_text() + def change(section,key,value): + nonlocal text + section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text) + if not section_match: + if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n" + return + next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():]) + end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end()) + chunk=text[section_match.end():end] + key_match=re.search(rf"(?mi)^\s*{re.escape(key)}\s*=.*(?:\n|$)",chunk) + if key_match: + start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end() + text=text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:] + elif value is not None: + text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n") + if operation=="delete": + if old_target is None: raise ValueError("monitor not found") + change("services",name,None); change("service_notes",name,None) + else: + if previous_name and previous_name.lower()!=name.lower(): + change("services",previous_name,None); change("service_notes",previous_name,None) + change("services",name,target); change("service_notes",name,note or None) + parsed=configparser.ConfigParser(); parsed.read_string(text) + backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}") + shutil.copy2(CFG,backup) + temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG) + subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10) + return str(backup) + +def control_service(unit,action): + if not UNIT_RE.fullmatch(unit): raise ValueError("invalid systemd service name") + if action not in {"start","stop"}: raise ValueError("action must be start or stop") + if unit in PROTECTED_UNITS and action=="stop": raise ValueError("protected remote-management service cannot be stopped here") + result=subprocess.run(["systemctl",action,unit],capture_output=True,text=True,timeout=20) + if result.returncode: raise RuntimeError(result.stderr.strip() or f"systemctl {action} failed") + class Handler(BaseHTTPRequestHandler): server_version="PIGWayAPI/3.6" @@ -146,14 +219,28 @@ class Handler(BaseHTTPRequestHandler): self.send_json(200,{"logs":journal(limit)}); return if parsed.path=="/api/v1/config": self.send_json(200,{"config":config_json(),"write_requires_token":True}); return + if parsed.path=="/api/v1/services": + self.send_json(200,{"services":service_inventory()}); return self.send_json(404,{"error":"not found"}) except Exception as e: self.send_json(500,{"error":str(e)}) def do_PUT(self): - if urlparse(self.path).path!="/api/v1/config": self.send_json(404,{"error":"not found"}); return + path=urlparse(self.path).path + if path not in {"/api/v1/config","/api/v1/services","/api/v1/services/control"}: self.send_json(404,{"error":"not found"}); return if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return try: - body=self.read_json(); updates=body.get("updates") + body=self.read_json() + if path=="/api/v1/services/control": + control_service(str(body.get("unit","")),str(body.get("action",""))) + print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True) + self.send_json(200,{"ok":True}); return + if path=="/api/v1/services": + backup=edit_service_monitor(str(body.get("operation","")),str(body.get("name","")), + str(body.get("target","")),str(body.get("note","")), + str(body.get("previous_name",""))) + print(f"SERVICE_MONITOR_UPDATED operation={body.get('operation')} target={body.get('target')} backup={backup}",flush=True) + self.send_json(200,{"ok":True,"backup":backup}); return + updates=body.get("updates") backup=update_ini(updates) subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10) restart_api="api" in updates diff --git a/web/index.html b/web/index.html index a75d583..00d75f7 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ PIGWay Pi Control
@@ -23,18 +23,28 @@ const labels={'led.normal_mode':'正常状态灯效','led.normal_r':'红色通 const hints={'led.normal_mode':'仅在没有当前告警时生效;告警会立即抢占。','led.normal_r':'范围 0–255。','led.normal_g':'范围 0–255。','led.normal_b':'范围 0–255。','led.normal_flash_on_ms':'范围 50–60000 毫秒。','led.normal_flash_off_ms':'范围 50–60000 毫秒。','led.normal_effect_speed':'MCU 内置速度档位。','led.normal_effect_color':'仅流水灯和呼吸灯使用。','led.write_delay_ms':'硬件校准项,通常保持 10 毫秒。','alerts.wifi_weak':'单位 dBm,数值越接近 0 信号越强。','alerts.wifi_recover':'单位 dBm,应高于弱信号阈值。','fan.hysteresis':'避免临界温度附近频繁切换档位。','api.bind':'0.0.0.0 允许局域网访问;127.0.0.1 仅本机。','api.port':'范围 1–65535;保存后 API 自动重启。','dark_mode.pi_pwr_led':'选择“关闭”时由 PIGWay 熄灭板载灯。','dark_mode.pi_act_led':'选择“关闭”时由 PIGWay 熄灭板载灯。'}; const options={'led.normal_mode':[['off','全灭'],['solid','自定义颜色常亮'],['flash','自定义颜色闪烁'],['flow','流水灯'],['breathe','呼吸灯'],['marquee','跑马灯'],['rainbow','彩虹灯'],['colorful','炫彩灯']],'led.normal_effect_speed':[['1','慢速'],['2','中速'],['3','快速']],'led.normal_effect_color':[['red','红色'],['green','绿色'],['blue','蓝色'],['yellow','黄色'],['purple','紫色'],['cyan','青色'],['white','白色']],'dark_mode.pi_pwr_led':[['off','关闭'],['on','不干预']],'dark_mode.pi_act_led':[['off','关闭'],['on','不干预']]}; const numberRules={'led.normal_r':[0,255,1],'led.normal_g':[0,255,1],'led.normal_b':[0,255,1],'led.normal_flash_on_ms':[50,60000,10],'led.normal_flash_off_ms':[50,60000,10],'led.warning_flash_on_ms':[50,60000,10],'led.warning_flash_off_ms':[50,60000,10],'led.critical_flash_on_ms':[50,60000,10],'led.critical_flash_off_ms':[50,60000,10],'led.emergency_flash_on_ms':[50,60000,10],'led.emergency_flash_off_ms':[50,60000,10],'led.write_delay_ms':[1,1000,1],'alerts.cpu_high':[0,100,1],'alerts.cpu_recover':[0,100,1],'alerts.cpu_high_seconds':[1,86400,1],'alerts.memory_high':[0,100,1],'alerts.memory_recover':[0,100,1],'alerts.memory_high_seconds':[1,86400,1],'alerts.temp_high':[0,120,.1],'alerts.temp_critical':[0,120,.1],'alerts.disk_high':[0,100,1],'alerts.disk_critical':[0,100,1],'alerts.wifi_weak':[-120,0,1],'alerts.wifi_recover':[-120,0,1],'alerts.wifi_weak_seconds':[1,86400,1],'fan.start':[0,100,.1],'fan.level2':[0,100,.1],'fan.level3':[0,100,.1],'fan.level4':[0,100,.1],'fan.max':[0,100,.1],'fan.hysteresis':[0,20,.1],'timing.sensor_interval':[.2,3600,.1],'timing.oled_refresh_interval':[.2,3600,.1],'timing.page_interval':[1,3600,.1],'timing.network_interval':[1,3600,.1],'timing.service_interval':[1,3600,.1],'timing.health_interval':[.2,3600,.1],'display.row1_y':[0,31,1],'display.row2_y':[0,31,1],'display.row3_y':[0,31,1],'display.row4_y':[0,31,1],'api.port':[1,65535,1],'api.log_limit':[1,1000,1]};['cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>numberRules[`led.${g}_${c}`]=[0,255,1])); +const units={'led.normal_flash_on_ms':'毫秒','led.normal_flash_off_ms':'毫秒','led.warning_flash_on_ms':'毫秒','led.warning_flash_off_ms':'毫秒','led.critical_flash_on_ms':'毫秒','led.critical_flash_off_ms':'毫秒','led.emergency_flash_on_ms':'毫秒','led.emergency_flash_off_ms':'毫秒','led.write_delay_ms':'毫秒','alerts.cpu_high':'%','alerts.cpu_recover':'%','alerts.cpu_high_seconds':'秒','alerts.memory_high':'%','alerts.memory_recover':'%','alerts.memory_high_seconds':'秒','alerts.temp_high':'°C','alerts.temp_critical':'°C','alerts.disk_high':'%','alerts.disk_critical':'%','alerts.wifi_weak':'dBm','alerts.wifi_recover':'dBm','alerts.wifi_weak_seconds':'秒','fan.start':'°C','fan.level2':'°C','fan.level3':'°C','fan.level4':'°C','fan.max':'°C','fan.hysteresis':'°C','timing.sensor_interval':'秒','timing.oled_refresh_interval':'秒','timing.page_interval':'秒','timing.network_interval':'秒','timing.service_interval':'秒','timing.health_interval':'秒','display.row1_y':'像素','display.row2_y':'像素','display.row3_y':'像素','display.row4_y':'像素','api.port':'端口','api.log_limit':'条'}; const sectionOrder=['led','alerts','fan','timing','display','dark_mode','services','processes','api'],alertColorGroups=[['cpu','处理器、温度与散热'],['power','供电与欠压'],['memory','内存'],['storage','存储'],['network','网络与 Wi-Fi'],['service','服务与进程']]; const ownerNames={NORMAL_HOME:'正常主页',ROOT_READONLY:'根文件系统只读',TEMP_CRITICAL:'处理器温度严重',POWER_UNDERVOLT:'供电欠压',THROTTLED:'处理器降频',FREQ_CAPPED:'频率受限',NETWORK_DOWN:'网络断开',INTERNET_DOWN:'互联网断开',SERVICE_DOWN:'服务停止',PROCESS_DOWN:'进程停止',DISK_CRITICAL:'磁盘严重不足',TEMP_HIGH:'处理器温度偏高',CPU_HIGH:'处理器负载过高',MEMORY_HIGH:'内存占用过高',DISK_HIGH:'磁盘空间不足',WIFI_WEAK:'Wi-Fi 信号弱'},rgbNames={OFF:'全灭',NORMAL_SOLID:'正常 · 自定义常亮',NORMAL_FLASH:'正常 · 自定义闪烁',NORMAL_FLOW:'正常 · 流水灯',NORMAL_BREATHE:'正常 · 呼吸灯',NORMAL_MARQUEE:'正常 · 跑马灯',NORMAL_RAINBOW:'正常 · 彩虹灯',NORMAL_COLORFUL:'正常 · 炫彩灯'},eventNames={START:'程序启动',STOP:'程序停止',ALERT_ACTIVE:'告警出现',ALERT_RECOVERED:'告警恢复',DISPLAY_OWNER:'显示对象切换',RGB_MODE:'RGB 模式切换',INTERNET_DOWN:'互联网断开',INTERNET_RECOVERED:'互联网恢复',NETWORK_DOWN:'网络断开',NETWORK_RECOVERED:'网络恢复',POWER_LOW:'供电欠压',POWER_RECOVERED:'供电恢复'}; 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]||'';const control=opts?``:``;return`
${help?`
${esc(help)}
`:''}
`} -function rgbGroup(values,prefix,title){const r=values[`${prefix}_r`],g=values[`${prefix}_g`],b=values[`${prefix}_b`];return`

${title}

RGB 自定义颜色
${['r','g','b'].map(c=>field('led',`${prefix}_${c}`,values[`${prefix}_${c}`])).join('')}
`} +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=``;else{const input=``;control=unit?`
${input}${unit}
`:input}return`
${help?`
${esc(help)}
`:''}
`} +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`

${title}

点击色块打开色盘
RGB 0–255
${['r','g','b'].map(c=>field('led',`${prefix}_${c}`,values[`${prefix}_${c}`])).join('')}
`} function normalGroup(v){return`

正常运行灯效

没有告警时显示;告警出现后自动让位。
${field('led','normal_mode',v.normal_mode,'span2')}
${rgbGroup(v,'normal','自定义颜色')}
${field('led','normal_flash_on_ms',v.normal_flash_on_ms)}
${field('led','normal_flash_off_ms',v.normal_flash_off_ms)}
${field('led','normal_effect_speed',v.normal_effect_speed)}
${field('led','normal_effect_color',v.normal_effect_color)}
`} function renderLed(v){const flash=['warning','critical','emergency'].map((x,i)=>`

${['普通告警','严重告警','紧急告警'][i]}闪烁节奏

${field('led',`${x}_flash_on_ms`,v[`${x}_flash_on_ms`])}${field('led',`${x}_flash_off_ms`,v[`${x}_flash_off_ms`])}
`).join(''),advanced=v.write_delay_ms===undefined?'':`

硬件高级参数

${field('led','write_delay_ms',v.write_delay_ms)}
`;return normalGroup(v)+`

告警对象颜色

告警对象决定颜色,严重程度决定闪烁速度。
${alertColorGroups.map(([p,t])=>rgbGroup(v,p,t)).join('')}
`+flash+advanced} function renderGeneral(s,v){const [t,d]=sectionMeta[s]||[s,''];return`

${t}

${d}
${Object.entries(v).map(([k,x])=>field(s,k,x)).join('')||'
暂无配置项
'}
`} -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=>Number(document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`)?.value||0);s.style.background=`rgb(${read('r')},${read('g')},${read('b')})`})} -function renderConfig(){const ss=sectionOrder.filter(s=>original[s]);$('configMenu').innerHTML=ss.map((s,i)=>``).join('');$('configRows').innerHTML=ss.map((s,i)=>`

${sectionMeta[s]?.[0]||s}

${sectionMeta[s]?.[1]||''}

${s==='led'?renderLed(original[s]):renderGeneral(s,original[s])}
`).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))};$('configRows').oninput=e=>{if(e.target.matches('[data-section="led"]'))updateConditional();markChanged()};updateConditional()} +function renderServices(){return`

当前系统服务

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

已监控服务

可启动或停止非保护服务、编辑备注或删除监控。删除监控不会删除 systemd 服务。

新建服务监控

`} +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)=>``).join('');$('configRows').innerHTML=ss.map((s,i)=>`

${sectionMeta[s]?.[0]||s}

${sectionMeta[s]?.[1]||''}

${s==='led'?renderLed(original[s]):s==='services'?renderServices():renderGeneral(s,original[s])}
`).join('');$('configMenu').onclick=e=>{const b=e.target.closest('button');if(!b)return;document.querySelectorAll('[data-config-section]').forEach(x=>x.classList.toggle('active',x===b));document.querySelectorAll('[data-config-page]').forEach(x=>x.classList.toggle('active',x.dataset.configPage===b.dataset.configSection));if(b.dataset.configSection==='services')loadServices()};$('configRows').oninput=e=>{if(e.target.matches('[data-color-prefix]')){const p=e.target.dataset.colorPrefix,v=e.target.value;['r','g','b'].forEach((c,i)=>{document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`).value=parseInt(v.slice(1+i*2,3+i*2),16)})}if(e.target.matches('[data-section="led"],[data-color-prefix]'))updateConditional();if(e.target.matches('[data-section]'))markChanged()};$('refreshServices')?.addEventListener('click',loadServices);$('serviceSearch')?.addEventListener('input',renderServiceLists);$('saveMonitor')?.addEventListener('click',saveMonitor);$('resetMonitor')?.addEventListener('click',resetMonitorForm);updateConditional()} function markChanged(){let n=0;document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(i.value!==original[i.dataset.section][i.dataset.key])n++});$('saveState').textContent=n?`有 ${n} 项尚未保存`:'尚未修改';$('saveState').className=n?'':'muted'} tabs.forEach(b=>b.onclick=()=>{tabs.forEach(x=>x.classList.toggle('active',x===b));['overview','logs','config'].forEach(id=>$(id).classList.toggle('hidden',id!==b.dataset.tab));if(b.dataset.tab==='logs')loadLogs();if(b.dataset.tab==='config')loadConfig()});async function get(url,opt){const r=await fetch(url,opt),text=await r.text();let j;try{j=JSON.parse(text)}catch{throw Error(`服务器返回异常 (${r.status})`)}if(!r.ok)throw Error(j.error||`请求失败 (${r.status})`);return j}function rows(target,items,empty='暂无数据'){$(target).innerHTML=items.length?items.join(''):`
${empty}
`} +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=`
${esc(e.message)}
`}} +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=>`
${esc(s.unit)}
${esc(s.description||'暂无服务说明')} · ${esc(s.active==='active'?'正在运行':'未运行')}
${s.monitored?'已监控':'未监控'}
`),'没有匹配的服务');const monitored=serviceCache.filter(s=>s.monitored);rows('monitoredServices',monitored.map(s=>`
${esc(s.monitor_name)} ${s.active==='active'?'运行中':'已停止'}
${esc(s.unit)}${s.note?` · ${esc(s.note)}`:''}
`),'尚未监控任何服务');document.querySelectorAll('[data-select-service]').forEach(b=>b.onclick=()=>selectService(b.dataset.selectService));document.querySelectorAll('[data-service-action]').forEach(b=>b.onclick=()=>serviceAction(b.dataset.unit,b.dataset.serviceAction));document.querySelectorAll('[data-delete-monitor]').forEach(b=>b.onclick=()=>deleteMonitor(b.dataset.deleteMonitor))} +function selectService(unit){const s=serviceCache.find(x=>x.unit===unit);if(!s)return;editingServiceName=s.monitor_name||'';$('monitorTarget').value=s.unit;$('monitorName').value=s.monitor_name||s.unit.replace(/\.service$/,'').replace(/[^A-Za-z0-9_.-]/g,'_').slice(0,16).toUpperCase();$('monitorNote').value=s.note||s.description||'';$('serviceFormTitle').textContent=s.monitored?'编辑服务监控':'新建服务监控';$('serviceMessage').textContent='已自动填写,可继续修改。';$('monitorName').scrollIntoView({behavior:'smooth',block:'center'})} +function resetMonitorForm(){editingServiceName='';$('monitorName').value='';$('monitorTarget').value='';$('monitorNote').value='';$('serviceFormTitle').textContent='新建服务监控';$('serviceMessage').textContent='';$('serviceMessage').className='muted'} +async function saveMonitor(){const body={operation:'save',name:$('monitorName').value.trim(),target:$('monitorTarget').value.trim(),note:$('monitorNote').value.trim(),previous_name:editingServiceName};try{$('serviceMessage').textContent='正在保存…';await get('/api/v1/services',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify(body)});resetMonitorForm();await loadServices();$('serviceMessage').textContent='保存成功';$('serviceMessage').className='success'}catch(e){$('serviceMessage').textContent=e.message;$('serviceMessage').className='error'}} +async function deleteMonitor(unit){const s=serviceCache.find(x=>x.unit===unit);if(!s||!confirm(`确定删除 ${s.monitor_name} 的监控吗?\n不会删除或停止 ${unit}。`))return;try{await get('/api/v1/services',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify({operation:'delete',name:s.monitor_name,target:s.unit,note:''})});await loadConfig();document.querySelector('[data-config-section="services"]')?.click()}catch(e){alert(e.message)}} +async function serviceAction(unit,action){if(!confirm(`确定${action==='start'?'启动':'停止'} ${unit} 吗?`))return;try{await get('/api/v1/services/control',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify({unit,action})});setTimeout(loadServices,700)}catch(e){alert(e.message)}} async function loadStatus(){try{const s=await get('/api/v1/status'),y=s.system,n=s.network,d=s.display;$('dot').classList.add('ok');$('liveText').textContent='在线';$('identity').textContent=`版本 ${s.version} · 已运行 ${Math.floor(s.uptime_seconds/3600)} 小时 · ${new Date(s.timestamp*1000).toLocaleTimeString()}`;$('cpu').textContent=`${y.cpu_percent}%`;$('freq').textContent=`频率 ${y.cpu_freq_mhz} MHz`;$('temp').textContent=`${y.temperature_c}°C`;$('fan').textContent=`风扇 ${({OFF:'关闭',L1:'一档',L2:'二档',L3:'三档',L4:'四档',MAX:'全速'})[y.fan_name]||y.fan_name}`;$('memory').textContent=`${y.memory_percent}%`;$('disk').textContent=`${y.disk_percent}%`;$('network').textContent=`${n.kind==='WIF'?'无线网络':n.kind==='ETH'?'有线网络':n.kind} ${n.metric}`;$('address').textContent=`${n.interface} · ${n.ip_label==='IP4'?'IPv4':'IPv6'} ${n.ip} · 互联网${n.internet?'正常':'断开'}`;$('owner').textContent=ownerName(d.owner);$('rgb').textContent=`OLED:${ownerName(d.oled_page)} · RGB:${rgbName(d.rgb_mode)}`;rows('alerts',d.alerts.map(a=>`
${esc(ownerName(a.id))}
${esc(a.l2)} ${esc(a.l3)}
等级 ${a.severity} · 优先级 ${a.priority}
`),'当前没有告警');rows('watches',s.watches.map(w=>`
${esc(w.name)}
${w.type==='service'?'系统服务':'普通进程'} · ${esc(w.target)}
${w.up?'正常':'停止'}
`))}catch(e){$('dot').classList.remove('ok');$('liveText').textContent='离线';$('identity').textContent=e.message}} async function loadLogs(){try{const d=await get('/api/v1/logs?limit=200');rows('logRows',d.logs.reverse().map(x=>{const m=String(x.message).match(/event=([^ ]+)/),label=m?(eventNames[m[1]]||m[1]):'系统事件';return`
${esc(label)}${esc(x.message)}
`}))}catch(e){$('logRows').innerHTML=`
${esc(e.message)}
`}}async function loadConfig(){try{const d=await get('/api/v1/config');original=d.config;renderConfig();markChanged()}catch(e){$('configRows').innerHTML=`
${esc(e.message)}
`}} $('refreshLogs').onclick=loadLogs;$('token').value=localStorage.getItem('pigwayToken')||'';$('toggleToken').onclick=()=>{const i=$('token'),show=i.type==='password';i.type=show?'text':'password';$('toggleToken').textContent=show?'隐藏令牌':'显示令牌'};$('saveConfig').onclick=async()=>{const token=$('token').value.trim();localStorage.setItem('pigwayToken',token);const updates={};document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(i.value!==original[i.dataset.section][i.dataset.key]){(updates[i.dataset.section]??={})[i.dataset.key]=i.value}});if(!Object.keys(updates).length){$('saveState').textContent='没有需要保存的修改';return}try{$('saveConfig').disabled=true;$('saveState').textContent='正在保存并应用…';const d=await get('/api/v1/config',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({updates})});$('saveState').textContent=`保存成功,已创建备份 ${d.backup}`;$('saveState').className='success';setTimeout(loadConfig,1500)}catch(e){$('saveState').textContent=e.message;$('saveState').className='error'}finally{$('saveConfig').disabled=false}};loadStatus();setInterval(loadStatus,3000);