guanlan_ops.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""观澜运维控制台的后端 (2026-09-12) —— 把"停服务 / 起服务 / 重算 / 清产物"做成一个可视化页面。
  4. ## 它解决什么
  5. 原来这四件事都得在黑窗口里敲命令 (顺序还不能错), 且没有任何"当前能不能做"的约束。
  6. 本模块给网关加三个东西:
  7. · `GET /ops` 一个自适应控制台页面(单文件, 无外部依赖);
  8. · `GET /ops/api/state` 真实状态: 各服务端口通不通 · 产物在不在 · 有没有任务在跑 · 上次结果;
  9. · `POST /ops/api/<动作>` 停服务 · 起服务 · 重算 · 清产物 · 恢复产物。
  10. ## 三条设计纪律
  11. 1. **页面活着的服务不能被自己停掉。** 控制台由网关(28084)提供, 所以"停服务"默认**保留网关**,
  12. 否则按钮刚点完页面就没了、再也没法"启动服务"。要连网关一起停, 用 `include_gateway=True`,
  13. 这时用**分离进程**先停再起(页面会断开十几秒, 之后自动重连)。
  14. 2. **一次只允许一个动作。** 四个动作互相冲突(重算要重启服务、清产物要挪走产物), 所以有一把锁:
  15. `run/ops_job.json` 里记着当前任务; 任务在跑时, 所有会冲突的按钮在后端**与前端都会被禁掉**
  16. (后端拒绝 = 真生效, 前端禁用只是提示)。判断以后端为准。
  17. 3. **日志与状态落盘。** 动作全部 `Popen` 到 `logs/ops_<动作>_<时间>.log`, 页面轮询 job 状态并把日志尾巴显示出来 ——
  18. 用户看得见"它在干什么", 而不是按钮转圈。
  19. ## 按钮什么时候该灰 (后端 state 直接给出, 前端只管照用)
  20. 停服务 : 至少有一个组件服务在监听
  21. 启动服务 : 至少有一个组件服务没在监听
  22. 执行重算 : 没有任务在跑
  23. 清除产物 : 没有任务在跑 且 产物在位 且 暂存区没有同名目录(或允许 --archive-old)
  24. 恢复产物 : 没有任务在跑 且 产物被清掉过(暂存区清单存在)
  25. """
  26. from __future__ import annotations
  27. import datetime as dt
  28. import json
  29. import os
  30. import pathlib
  31. import socket
  32. import subprocess
  33. import sys
  34. import time
  35. ROOT = pathlib.Path(__file__).resolve().parents[1]
  36. sys.path.insert(0, str(ROOT))
  37. from src import paths as P # noqa: E402
  38. RUN = ROOT / 'run'
  39. LOGS = ROOT / 'logs'
  40. JOB = RUN / 'ops_job.json'
  41. OFF = ROOT / '_products_off'
  42. OFF_MANIFEST = OFF / 'manifest.json'
  43. PY = P.venv_python() if hasattr(P, 'venv_python') else sys.executable
  44. # 组件服务 (不含网关): 名字 → 端口。端口真源在 configs/serve.json, 这里只是网关不可用时的兜底。
  45. DEFAULT_PORTS = dict(detail=18033, cms=18020, sim=18791, sim_sys=18792, viewer=64292)
  46. GATEWAY_PORT = 28084
  47. KEEP_WHEN_STOPPING = 'gateway' # 停服务默认保留网关(控制台自己在上面)
  48. def ports() -> dict:
  49. p = ROOT / 'configs' / 'serve.json'
  50. cfg = json.loads(p.read_text(encoding='utf-8-sig')) if p.exists() else {}
  51. out = dict(DEFAULT_PORTS)
  52. out['gateway'] = int(cfg.get('gateway') or GATEWAY_PORT)
  53. host = cfg.get('host') or '127.0.0.1'
  54. for k in list(out):
  55. if k in cfg:
  56. out[k] = int(cfg[k])
  57. return dict(host=host, **out)
  58. def listening(host: str, port: int, timeout=0.35) -> bool:
  59. """端口有没有人在听。注意: 本机实测"连已关闭端口会一直等到超时"(不回 RST), 所以超时必须短。"""
  60. try:
  61. with socket.create_connection((host, port), timeout=timeout):
  62. return True
  63. except OSError:
  64. return False
  65. def _job() -> dict:
  66. try:
  67. return json.loads(JOB.read_text(encoding='utf-8'))
  68. except Exception:
  69. return {}
  70. def job_running() -> tuple[bool, dict]:
  71. """(是否在跑, 任务记录)。在跑 = 状态是 running 且执行器进程还活着。
  72. 注意: 退出码由执行器 scripts/_ops_run.py 写回 job (不在跑时才可信), 所以这里只判"活没活";
  73. 进程没了就把 running 收尾成 done(执行器正常收尾时会自己写, 这里兜的是被强杀的情况)。
  74. """
  75. j = _job()
  76. if not j or j.get('status') != 'running':
  77. return False, j
  78. pid = j.get('pid')
  79. alive = False
  80. if pid:
  81. try:
  82. if os.name == 'nt':
  83. # errors='replace': tasklist 输出是控制台代码页(GBK), 而本进程可能是 PYTHONUTF8=1
  84. # → 严格解码会失败并使 stdout 为 None (guanlan.py 的 alive() 踩过同一个坑)
  85. out = subprocess.run(['tasklist', '/FI', f'PID eq {pid}'], capture_output=True,
  86. text=True, errors='replace').stdout
  87. alive = bool(out) and str(pid) in out
  88. else:
  89. os.kill(pid, 0); alive = True
  90. except Exception:
  91. alive = False
  92. if not alive:
  93. j.update(status=j.get('status') if j.get('rc') is not None else 'done',
  94. finished=j.get('finished') or time.strftime('%Y-%m-%d %H:%M:%S'),
  95. note=(j.get('note') or '') + (' (进程已结束但没写回退出码 —— 多半是被强杀)' if j.get('rc') is None else ''))
  96. JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
  97. return False, j
  98. return True, j
  99. def _log_tail(path, n=40) -> list:
  100. if not path:
  101. return []
  102. f = pathlib.Path(path)
  103. if not f.is_file():
  104. return []
  105. lines = f.read_text(encoding='utf-8', errors='replace').splitlines()
  106. return lines[-n:]
  107. def spawn(args: list, tag: str, detached=False) -> dict:
  108. """起一个动作: 交给 scripts/_ops_run.py 执行 (它会写回真实退出码), 日志落 logs/ops_<tag>_<时间>.log。"""
  109. RUN.mkdir(exist_ok=True); LOGS.mkdir(exist_ok=True)
  110. log = LOGS / f'ops_{tag}_{time.strftime("%Y%m%d_%H%M%S")}.log'
  111. # PYTHONUNBUFFERED: 输出重定向到文件时 Python 默认块缓冲(8 KB), 页面上的"日志尾巴"会长时间不动 ——
  112. # 加了它才能边跑边看到每一行(实测: 重算跑到第二分钟, 日志尾还停在第 3 行)。
  113. env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8', PYTHONUNBUFFERED='1')
  114. flags = 0
  115. if os.name == 'nt':
  116. flags = subprocess.CREATE_NEW_PROCESS_GROUP | (subprocess.DETACHED_PROCESS if detached else 0)
  117. runner = [PY, 'scripts/_ops_run.py', '--tag', tag, '--'] + list(args)
  118. f = open(log, 'ab')
  119. p = subprocess.Popen(runner, cwd=str(ROOT), env=env, stdout=f, stderr=subprocess.STDOUT, creationflags=flags)
  120. j = dict(kind=tag, cmd=' '.join(args), pid=p.pid, log=str(log), status='running', rc=None,
  121. started=time.strftime('%Y-%m-%d %H:%M:%S'), note='')
  122. JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
  123. return j
  124. # ─────────────────────────────────────────────────────────── 状态
  125. def products_state() -> dict:
  126. store = P.store() # outputs/<场>/windscada
  127. fam = store.parent # outputs/<场> —— 产物仓的根
  128. n = sum(1 for x in fam.rglob('*') if x.is_file()) if fam.is_dir() else 0
  129. stores = {}
  130. if fam.is_dir():
  131. for d in sorted(x for x in fam.iterdir() if x.is_dir()):
  132. stores[d.name] = sum(1 for _ in d.rglob('*') if _.is_file())
  133. prov = fam / '_provenance.json'
  134. prov_d = json.loads(prov.read_text(encoding='utf-8')) if prov.is_file() else None
  135. stash_dirs = {}
  136. if OFF.is_dir():
  137. for d in sorted(x for x in OFF.iterdir() if x.is_dir() and x.name != '_baseline_kept'):
  138. stash_dirs[d.name] = sum(1 for _ in (d / store.name).rglob('*') if _.is_file()) if (d / store.name).is_dir() else 0
  139. kept = list((OFF / '_baseline_kept').rglob('*')) if (OFF / '_baseline_kept').is_dir() else []
  140. return dict(
  141. fam=str(fam.relative_to(ROOT)).replace('\\', '/'), store=str(store.relative_to(ROOT)).replace('\\', '/'),
  142. files=n, in_place=n > 0, stores=stores,
  143. cleared=n == 0,
  144. stash_has_same=any(v > 0 for v in stash_dirs.values()), stash=stash_dirs,
  145. can_restore=OFF_MANIFEST.is_file() and n == 0,
  146. baseline_kept=sum(1 for x in kept if x.is_file()),
  147. provenance=(dict(counts=prov_d.get('counts'), at=prov_d.get('at')) if prov_d else None),
  148. )
  149. def state() -> dict:
  150. pt = ports(); host = pt['host']
  151. svc = {}
  152. for name, port in pt.items():
  153. if name == 'host':
  154. continue
  155. svc[name] = dict(port=port, up=listening(host, port), is_gateway=(name == 'gateway'))
  156. comps = {k: v for k, v in svc.items() if not v['is_gateway']}
  157. gw_up = svc.get('gateway', {}).get('up', False)
  158. running, j = job_running()
  159. pr = products_state()
  160. any_comp_up = any(v['up'] for v in comps.values())
  161. any_comp_down = any(not v['up'] for v in comps.values())
  162. return dict(
  163. now=dt.datetime.now().isoformat(timespec='seconds'),
  164. host=host, services=svc, gateway_up=gw_up,
  165. products=pr,
  166. job=(dict(kind=j.get('kind'), status=j.get('status'), started=j.get('started'), note=j.get('note'),
  167. cmd=j.get('cmd'), rc=j.get('rc'), seconds=j.get('seconds'), finished=j.get('finished'),
  168. log_tail=_log_tail(j.get('log'))) if j else None),
  169. # 按钮可用性 —— 前端只照这个渲染, 后端还会再拒一次(见 check())
  170. buttons=dict(
  171. stop_services=any_comp_up and not running,
  172. start_services=any_comp_down and not running,
  173. rebuild=not running,
  174. products_off=pr['in_place'] and not running,
  175. products_on=(not pr['in_place']) and not running,
  176. ),
  177. anchors=dict(alarms=_rows(f'{pr["fam"]}/windscada/alarms.parquet'),
  178. workorders=_rows(f'{pr["fam"]}/windscada/workorders.parquet'),
  179. temp_monthly=_rows(f'{pr["fam"]}/windscada/temp_monthly.parquet'),
  180. objects=_objects_count(f'{pr["fam"]}/ontology/objects.json')),
  181. )
  182. def _rows(rel: str):
  183. """某件 parquet 的行数 (路径是相对安装根的), 用于页面上的"验收锚点"自检。"""
  184. f = ROOT / rel
  185. if not f.is_file():
  186. return None
  187. try:
  188. import pandas as pd
  189. return int(len(pd.read_parquet(f)))
  190. except Exception:
  191. return None
  192. def _objects_count(rel: str):
  193. f = ROOT / rel
  194. try:
  195. return len(json.loads(f.read_text(encoding='utf-8'))) if f.is_file() else None
  196. except Exception:
  197. return None
  198. # ─────────────────────────────────────────────────────────── 动作
  199. def _guard(what: str) -> str | None:
  200. running, j = job_running()
  201. if running:
  202. return f'已有任务在跑 ({j.get("kind")}, 起于 {j.get("started")}) —— 等它结束再操作。'
  203. return None
  204. def _svc_flags() -> tuple[bool, bool]:
  205. """(有组件在跑, 有组件没跑) —— 供动作前置检查, 与 state() 里 buttons 用的是同一判据。"""
  206. pt = ports(); host = pt['host']
  207. ups = [listening(host, port) for name, port in pt.items() if name != 'host' and name != 'gateway']
  208. return any(ups), any(not u for u in ups)
  209. def act_stop(body: dict) -> tuple[int, dict]:
  210. pt = ports(); include_gw = bool(body.get('include_gateway'))
  211. if (e := _guard('stop')):
  212. return 409, dict(err=e)
  213. any_up, _ = _svc_flags()
  214. if not any_up:
  215. # 按钮在前端就该是灰的, 但后端也必须拒 —— 否则直接打 API/重复提交就能做出无意义动作
  216. return 409, dict(err='组件服务都已停止, 没有可停的 (按钮在页面上是禁用的)。')
  217. if include_gw:
  218. # 连网关一起停: 必须分离进程"先停 → 睡一会 → 再起", 否则控制台自己没了就再也起不回来
  219. j = spawn(['-c', 'import subprocess,sys,time;'
  220. f'subprocess.run([r"{PY}", "guanlan.py", "stop"]);'
  221. 'time.sleep(2);'
  222. f'subprocess.run([r"{PY}", "guanlan.py", "serve"])'], 'restart_all', detached=True)
  223. j['note'] = '含网关的完整重启: 页面会断开约 15 秒, 之后自动重连'
  224. JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
  225. return 200, dict(ok=True, job=j, note=j['note'])
  226. j = spawn(['scripts/_ops_stop_keep_gateway.py'], 'stop_keep_gw')
  227. return 200, dict(ok=True, job=j, note='已停组件服务, 保留网关(控制台) —— 页面继续可用')
  228. def act_start(body: dict) -> tuple[int, dict]:
  229. if (e := _guard('start')):
  230. return 409, dict(err=e)
  231. _, any_down = _svc_flags()
  232. if not any_down:
  233. return 409, dict(err='组件服务都在运行, 无需启动 (按钮在页面上是禁用的)。')
  234. open_browser = body.get('open_browser', True)
  235. j = spawn(['scripts/_ops_start_and_open.py'] + ([] if open_browser else ['--no-open']), 'start')
  236. return 200, dict(ok=True, job=j,
  237. note='正在启动全部服务' + ('; 起来后自动打开 http://127.0.0.1:%d/' % ports()['gateway'] if open_browser else ''))
  238. def act_rebuild(body: dict) -> tuple[int, dict]:
  239. if (e := _guard('rebuild')):
  240. return 409, dict(err=e)
  241. args = ['scripts/rebuild_all.py']
  242. if body.get('skip_scada', True):
  243. args.append('--skip-scada')
  244. if body.get('with_verify'):
  245. args.append('--with-verify')
  246. src = (body.get('src') or '').strip()
  247. if src:
  248. args += ['--src', src]
  249. j = spawn(args, 'rebuild')
  250. return 200, dict(ok=True, job=j, note='重算已开始(含重启服务步骤); 进度看日志尾巴')
  251. def act_products_off(body: dict) -> tuple[int, dict]:
  252. if (e := _guard('products_off')):
  253. return 409, dict(err=e)
  254. pr = products_state()
  255. if not pr['in_place']:
  256. return 409, dict(err='产物已经是清空状态, 无需再清')
  257. args = ['scripts/products_state.py', '--off']
  258. if pr['stash_has_same'] or body.get('archive_old'):
  259. args.append('--archive-old') # 暂存区已有同名产物时必须加, 否则会嵌套(见该脚本注释)
  260. j = spawn(args, 'products_off')
  261. return 200, dict(ok=True, job=j, note='正在把产物挪到 _products_off/ (挪完请点"启动服务"让页面呈现空状态)')
  262. def act_products_on(body: dict) -> tuple[int, dict]:
  263. if (e := _guard('products_on')):
  264. return 409, dict(err=e)
  265. pr = products_state()
  266. if pr['in_place']:
  267. return 409, dict(err='产物已在位, 无需恢复')
  268. if not OFF_MANIFEST.is_file():
  269. return 409, dict(err='没有 _products_off/manifest.json (没清过或清单已删), 无法恢复')
  270. j = spawn(['scripts/products_state.py', '--on'], 'products_on')
  271. return 200, dict(ok=True, job=j, note='正在恢复产物; 恢复后请点"启动服务"')
  272. ACTIONS = dict(stop_services=act_stop, start_services=act_start, rebuild=act_rebuild,
  273. products_off=act_products_off, products_on=act_products_on)
  274. def handle(method: str, path: str, body: bytes) -> tuple[int, str, bytes]:
  275. """网关调用入口: → (http code, content-type, body bytes)。"""
  276. if path in ('/ops', '/ops/'):
  277. return 200, 'text/html; charset=utf-8', PAGE.encode('utf-8')
  278. if path == '/ops/api/state':
  279. return 200, 'application/json; charset=utf-8', json.dumps(state(), ensure_ascii=False).encode('utf-8')
  280. if path.startswith('/ops/api/'):
  281. name = path[len('/ops/api/'):].strip('/')
  282. if method != 'POST':
  283. return 405, 'application/json; charset=utf-8', json.dumps(
  284. dict(err='这些动作只接受 POST (避免链接被预取/刷新时误触发)'), ensure_ascii=False).encode('utf-8')
  285. fn = ACTIONS.get(name)
  286. if not fn:
  287. return 404, 'application/json; charset=utf-8', json.dumps(dict(err=f'未知动作: {name}'), ensure_ascii=False).encode('utf-8')
  288. try:
  289. payload = json.loads(body.decode('utf-8')) if body else {}
  290. except Exception:
  291. payload = {}
  292. try:
  293. code, obj = fn(payload)
  294. except Exception as e:
  295. code, obj = 500, dict(err=f'{type(e).__name__}: {e}')
  296. return code, 'application/json; charset=utf-8', json.dumps(obj, ensure_ascii=False).encode('utf-8')
  297. return 404, 'application/json; charset=utf-8', json.dumps(dict(err='not found'), ensure_ascii=False).encode('utf-8')
  298. # ─────────────────────────────────────────────────────────── 页面
  299. PAGE = r"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
  300. <meta name="viewport" content="width=device-width,initial-scale=1">
  301. <title>观澜 · 运维控制台</title><style>
  302. :root{--ink:#1B2430;--mut:#5B6B7B;--line:#DDE3E8;--bg:#F5F7F8;--pri:#1F6F8B;--ok:#1E8E5A;--warn:#B26A00;--bad:#C0392B}
  303. *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.65 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
  304. .wrap{max-width:1000px;margin:0 auto;padding:22px 20px 60px}
  305. h1{font-size:21px;margin:0 0 4px}.sub{color:var(--mut);font-size:13.5px;margin:0 0 18px}
  306. .card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin:0 0 14px}
  307. .card h2{font-size:15px;margin:0 0 10px;color:var(--pri)}.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
  308. button{font:inherit;padding:9px 16px;border-radius:9px;border:1px solid var(--pri);background:var(--pri);color:#fff;cursor:pointer}
  309. button.ghost{background:#fff;color:var(--pri)}button.danger{border-color:var(--bad);background:var(--bad)}
  310. button:disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.3)}
  311. table{width:100%;border-collapse:collapse;font-size:13.5px}td,th{text-align:left;padding:5px 8px;border-bottom:1px solid var(--line)}
  312. th{color:var(--mut);font-weight:600}
  313. .pill{display:inline-block;padding:1px 9px;border-radius:999px;font-size:12.5px;border:1px solid var(--line)}
  314. .up{background:#E8F6EF;color:var(--ok);border-color:#BFE6D3}.down{background:#FDECEA;color:var(--bad);border-color:#F5C6C0}
  315. .mut{color:var(--mut)}pre{background:#0F1720;color:#D7E2EA;padding:10px 12px;border-radius:8px;max-height:260px;overflow:auto;font-size:12.5px;margin:8px 0 0}
  316. .hint{font-size:13px;color:var(--mut);margin:8px 0 0}
  317. .busy{background:#FFF7E6;border:1px solid #F0D9A8;color:#7A5600;padding:8px 12px;border-radius:8px;margin:0 0 12px}
  318. label{font-size:13.5px;color:var(--mut);display:flex;gap:6px;align-items:center}
  319. </style></head><body><div class="wrap">
  320. <h1>观澜 · 运维控制台</h1>
  321. <p class="sub">停/启服务 · 执行重算 · 清除产物 —— 按钮按真实状态启用; 不可用的动作后端也会拒绝。本页由网关(端口 <span id="gw"></span>)提供。</p>
  322. <div id="busy"></div>
  323. <div class="card"><h2>服务</h2><div id="svc"></div>
  324. <div class="row" style="margin-top:12px">
  325. <button id="b_start" class="ghost">启动服务(并打开门户)</button>
  326. <button id="b_stop" class="ghost">停止组件服务(保留控制台)</button>
  327. <button id="b_restart" class="ghost">完整重启(含网关,页面会断开十几秒)</button>
  328. </div>
  329. <p class="hint" id="svc_hint"></p>
  330. </div>
  331. <div class="card"><h2>重算</h2>
  332. <div class="row">
  333. <label><input type="checkbox" id="f_scada"> 含 SCADA 侧 10 个构建器(逐台读 ~14 GB,约 15 分钟)</label>
  334. <label><input type="checkbox" id="f_verify"> 末尾加台账等价验收</label>
  335. </div>
  336. <div class="row" style="margin-top:10px">
  337. <button id="b_rebuild">执行重算(放数据→台账→月度件→补齐→本体→审计)</button>
  338. <span class="mut" id="rebuild_hint"></span>
  339. </div>
  340. <p class="hint">默认跳过 SCADA(只换了台账类数据时的常用档)。重算会自己重启服务。</p>
  341. </div>
  342. <div class="card"><h2>产物</h2><div id="prod"></div>
  343. <div class="row" style="margin-top:12px">
  344. <button id="b_on" class="ghost">恢复产物</button>
  345. <button id="b_off" class="danger">清除产物(挪到 _products_off,可恢复)</button>
  346. </div>
  347. <p class="hint">清除后门户首页仍可打开(它是静态交付页), 但工作台会显示"无产物"; 恢复后请点"启动服务"。</p>
  348. </div>
  349. <div class="card"><h2>最近一次动作</h2><div id="job">(无)</div><pre id="log"></pre></div>
  350. <p class="hint">命令行等价物: <code>guanlan.py stop/serve</code> · <code>scripts/rebuild_all.py</code> · <code>scripts/products_state.py --off/--on</code>(手册 §0/§5b)。</p>
  351. </div><script>
  352. const $ = s => document.querySelector(s);
  353. let S = null, busyTimer = null;
  354. async function api(path, body){
  355. const r = await fetch(path, body ? {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)} : undefined);
  356. return [r.status, await r.json().catch(()=>({err:'非 JSON 响应'}))];
  357. }
  358. function pill(up){ return `<span class="pill ${up?'up':'down'}">${up?'运行中':'未运行'}</span>`; }
  359. function render(){
  360. const s = S; if(!s) return;
  361. $('#gw').textContent = s.services.gateway ? s.services.gateway.port : '?';
  362. const rows = Object.entries(s.services).map(([n,v]) =>
  363. `<tr><td>${n}${v.is_gateway?' <span class="mut">(控制台本体)</span>':''}</td><td>${v.port}</td><td>${pill(v.up)}</td></tr>`).join('');
  364. $('#svc').innerHTML = `<table><tr><th>服务</th><th>端口</th><th>状态</th></tr>${rows}</table>`;
  365. const p = s.products;
  366. const st = Object.entries(p.stores||{}).map(([k,v])=>`${k} ${v}`).join(' · ') || '(空)';
  367. $('#prod').innerHTML = `<table>
  368. <tr><th>产物仓</th><td>${p.store}</td></tr>
  369. <tr><th>件数</th><td>${p.files} 件 ${p.in_place?'<span class="pill up">在位</span>':'<span class="pill down">已清空</span>'}</td></tr>
  370. <tr><th>分布</th><td class="mut">${st}</td></tr>
  371. <tr><th>来源台账</th><td class="mut">${p.provenance?`raw 重算 ${p.provenance.counts['raw-derived']} 件 · 随包补齐 ${p.provenance.counts.shipped} 件 (${p.provenance.at})`:'(无 _provenance.json)'}</td></tr>
  372. <tr><th>暂存区</th><td class="mut">${Object.keys(p.stash||{}).length?Object.entries(p.stash).map(([k,v])=>`${k}:${v} 件`).join(' · '):'(空)'}${p.stash_has_same?' <b>← 有同名产物, 清除时会自动存档上一代</b>':''}</td></tr>
  373. <tr><th>验收锚点</th><td class="mut">报警 ${s.anchors.alarms??'—'} 行 · 工单 ${s.anchors.workorders??'—'} · temp_monthly ${s.anchors.temp_monthly??'—'} · 本体 ${s.anchors.objects??'—'} 对象</td></tr>
  374. </table>`;
  375. const b = s.buttons, run = s.job && s.job.status==='running';
  376. $('#b_start').disabled = !b.start_services;
  377. $('#b_stop').disabled = !b.stop_services;
  378. $('#b_restart').disabled = !b.stop_services;
  379. $('#b_rebuild').disabled = !b.rebuild;
  380. $('#b_off').disabled = !b.products_off;
  381. $('#b_on').disabled = !b.products_on;
  382. $('#svc_hint').textContent = b.start_services ? '有服务未运行 → 可启动。' : '全部组件服务已在运行 → 启动按钮已禁用。';
  383. $('#rebuild_hint').textContent = b.rebuild ? '' : '(有任务在跑, 重算按钮已禁用)';
  384. $('#busy').innerHTML = run ? `<div class="busy">正在执行: <b>${s.job.kind}</b>(起于 ${s.job.started})${s.job.note?' — '+s.job.note:''} · 完成后本页自动刷新</div>` : '';
  385. const j = s.job;
  386. $('#job').innerHTML = j ? `<div>${j.kind} · <b>${j.status==='running'?'执行中':(j.rc===0?'完成 (退出码 0)':'结束 (退出码 '+j.rc+')')}</b> · ${j.started||''}${j.seconds?' · 耗时 '+j.seconds+'s':''}</div><div class="mut">${j.cmd||''}</div>${j.note?'<div class="mut">'+j.note+'</div>':''}` : '(无)';
  387. $('#log').textContent = (j && j.log_tail && j.log_tail.length) ? j.log_tail.join('\n') : '';
  388. }
  389. async function refresh(){ const [c,d] = await api('/ops/api/state'); if(c===200){ S=d; render(); } }
  390. async function fire(name, body){
  391. const [c,d] = await api('/ops/api/'+name, body||{});
  392. if(c!==200){ alert(`操作被拒绝 (HTTP ${c}): ${d.err||''}`); }
  393. else { if(d.note) console.log(d.note); }
  394. await refresh();
  395. }
  396. $('#b_start').onclick = ()=>fire('start_services', {open_browser:true});
  397. $('#b_stop').onclick = ()=>fire('stop_services', {include_gateway:false});
  398. $('#b_restart').onclick = ()=>{ if(confirm('完整重启会连网关一起停, 本页会断开约 15 秒后自动恢复。继续?')) fire('stop_services', {include_gateway:true}); };
  399. $('#b_rebuild').onclick = ()=>{ if(confirm('开始重算? 期间服务会被重启, 页面可能短暂打不开。')) fire('rebuild', {skip_scada: !$('#f_scada').checked, with_verify: $('#f_verify').checked}); };
  400. $('#b_off').onclick = ()=>{ if(confirm('清除产物(挪到 _products_off,可恢复)?')) fire('products_off', {}); };
  401. $('#b_on').onclick = ()=>{ if(confirm('恢复产物?')) fire('products_on', {}); };
  402. refresh(); setInterval(refresh, 2000);
  403. </script></body></html>
  404. """