guanlan_ops.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  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. 清除产物 : 没有任务在跑 且 产物在位 (**直接删除, 不留备份** —— 2026-09-16 用户令; 无"恢复产物"按钮)
  24. """
  25. from __future__ import annotations
  26. import datetime as dt
  27. import json
  28. import os
  29. import pathlib
  30. import socket
  31. import subprocess
  32. import sys
  33. import time
  34. ROOT = pathlib.Path(__file__).resolve().parents[1]
  35. sys.path.insert(0, str(ROOT))
  36. from src import paths as P # noqa: E402
  37. from src import proc as _proc # 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 = P.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. # 心跳优先: 执行器每 15 s 更新 job 文件; 超过 STALE_S 没动静 = 被强杀(pid 可能被复用, 只看 pid 会误判)
  80. STALE_S = 150
  81. try:
  82. age = time.time() - JOB.stat().st_mtime
  83. except OSError:
  84. age = 0
  85. if age > STALE_S:
  86. j.update(status='done', rc=None, finished=time.strftime('%Y-%m-%d %H:%M:%S'),
  87. note=(j.get('note') or '') + f' (心跳停了 {int(age)} s —— 任务多半被强杀, 未拿到退出码)')
  88. JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
  89. return False, j
  90. alive = False
  91. if pid:
  92. try:
  93. if os.name == 'nt':
  94. # errors='replace': tasklist 输出是控制台代码页(GBK), 而本进程可能是 PYTHONUTF8=1
  95. # → 严格解码会失败并使 stdout 为 None (guanlan.py 的 alive() 踩过同一个坑)
  96. # ★ 走 src.proc.run_text: 本模块跑在网关进程里 (无控制台), 裸 spawn tasklist 会让
  97. # Windows 新建可见控制台 —— 而 /ops 页面每 2 s 轮询一次 ⇒ **反复闪窗** (2026-09-16 实测)。
  98. out = _proc.run_text(['tasklist', '/FI', f'PID eq {pid}']).stdout
  99. alive = bool(out) and str(pid) in out
  100. else:
  101. os.kill(pid, 0); alive = True
  102. except Exception:
  103. alive = False
  104. if not alive:
  105. j.update(status=j.get('status') if j.get('rc') is not None else 'done',
  106. finished=j.get('finished') or time.strftime('%Y-%m-%d %H:%M:%S'),
  107. note=(j.get('note') or '') + (' (进程已结束但没写回退出码 —— 多半是被强杀)' if j.get('rc') is None else ''))
  108. JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
  109. return False, j
  110. return True, j
  111. def _log_tail(path, n=40) -> list:
  112. if not path:
  113. return []
  114. f = pathlib.Path(path)
  115. if not f.is_file():
  116. return []
  117. lines = f.read_text(encoding='utf-8', errors='replace').splitlines()
  118. return lines[-n:]
  119. def spawn(args: list, tag: str, detached=False) -> dict:
  120. """起一个动作: 经 `_ops_launch.py` 二次启动 `_ops_run.py`。
  121. 两层是**必须的**(见 _ops_launch.py 注释): 直接 Popen 的话, 任务是网关的子进程, 而
  122. `guanlan.py stop` 用 `taskkill /T` —— 停网关时会连带杀掉正在执行的任务(实测把自己杀了)。
  123. 执行器负责写回真实退出码; 日志落 `logs/ops/<tag>_<时间>.log`
  124. (用户令 2 统一日志: 动作日志集中到 logs/ops/, 写前按保留策略清理, 见 src/logfile.py)。
  125. """
  126. from src import logfile as _lf
  127. RUN.mkdir(exist_ok=True)
  128. _lf.ensure_dirs()
  129. _lf.prune_actions() # 保留最近 20 份 / 30 天
  130. log = _lf.action_log(f'ops_{tag}')
  131. env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8', PYTHONUNBUFFERED='1')
  132. # 走 src.proc.run_text: 网关进程无控制台, 裸 spawn 会让这次"拉启动器"也闪一下窗
  133. r = _proc.run_text([PY, 'scripts/_ops_launch.py', '--tag', tag, '--log', str(log), '--'] + list(args),
  134. cwd=str(ROOT), env=env, timeout=60)
  135. pid = None
  136. for line in reversed((r.stdout or '').strip().splitlines()):
  137. if line.strip().isdigit():
  138. pid = int(line.strip()); break
  139. if pid is None:
  140. print(' 启动器输出异常:', r.stdout, r.stderr)
  141. j = dict(kind=tag, cmd=' '.join(args), pid=pid, log=str(log), status='running', rc=None,
  142. started=time.strftime('%Y-%m-%d %H:%M:%S'), note='')
  143. JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
  144. return j
  145. # ─────────────────────────────────────────────────────────── 状态
  146. def products_state() -> dict:
  147. store = P.store() # outputs/<场>/windscada
  148. fam = store.parent # outputs/<场> —— 产物仓的根
  149. n = sum(1 for x in fam.rglob('*') if x.is_file()) if fam.is_dir() else 0
  150. stores = {}
  151. if fam.is_dir():
  152. for d in sorted(x for x in fam.iterdir() if x.is_dir()):
  153. stores[d.name] = sum(1 for _ in d.rglob('*') if _.is_file())
  154. prov = fam / '_provenance.json'
  155. prov_d = json.loads(prov.read_text(encoding='utf-8')) if prov.is_file() else None
  156. # ★2026-09-16 用户令"不要备份清除的产物": 清除 = 真删除, 没有暂存区可列。
  157. # 这里只报"有没有旧设计留下的 _products_off*"(有就提示手工删掉), 不再提供"恢复产物"。
  158. legacy = sorted(x.name for x in ROOT.glob('_products_off*') if x.is_dir())
  159. return dict(
  160. fam=str(fam.relative_to(ROOT)).replace('\\', '/'), store=str(store.relative_to(ROOT)).replace('\\', '/'),
  161. files=n, in_place=n > 0, stores=stores,
  162. cleared=n == 0,
  163. no_backup=True, # 现口径: 清除不留备份
  164. legacy_backups=legacy, # 旧设计残留 (按用户令不自动删, 只提示)
  165. provenance=(dict(counts=prov_d.get('counts'), at=prov_d.get('at')) if prov_d else None),
  166. )
  167. def state() -> dict:
  168. pt = ports(); host = pt['host']
  169. svc = {}
  170. for name, port in pt.items():
  171. if name == 'host':
  172. continue
  173. svc[name] = dict(port=port, up=listening(host, port), is_gateway=(name == 'gateway'))
  174. comps = {k: v for k, v in svc.items() if not v['is_gateway']}
  175. gw_up = svc.get('gateway', {}).get('up', False)
  176. running, j = job_running()
  177. pr = products_state()
  178. any_comp_up = any(v['up'] for v in comps.values())
  179. any_comp_down = any(not v['up'] for v in comps.values())
  180. return dict(
  181. now=dt.datetime.now().isoformat(timespec='seconds'),
  182. host=host, services=svc, gateway_up=gw_up,
  183. products=pr,
  184. job=(dict(kind=j.get('kind'), status=j.get('status'), started=j.get('started'), note=j.get('note'),
  185. cmd=j.get('cmd'), rc=j.get('rc'), seconds=j.get('seconds'), finished=j.get('finished'),
  186. log_tail=_log_tail(j.get('log'))) if j else None),
  187. # 按钮可用性 —— 前端只照这个渲染, 后端还会再拒一次(见 check())
  188. buttons=dict(
  189. stop_services=any_comp_up and not running,
  190. start_services=any_comp_down and not running,
  191. rebuild=not running,
  192. products_off=pr['in_place'] and not running,
  193. ),
  194. anchors=dict(alarms=_rows(f'{pr["fam"]}/windscada/alarms.parquet'),
  195. workorders=_rows(f'{pr["fam"]}/windscada/workorders.parquet'),
  196. temp_monthly=_rows(f'{pr["fam"]}/windscada/temp_monthly.parquet'),
  197. objects=_objects_count(f'{pr["fam"]}/ontology/objects.json')),
  198. )
  199. def _rows(rel: str):
  200. """某件 parquet 的行数 (路径是相对安装根的), 用于页面上的"验收锚点"自检。"""
  201. f = ROOT / rel
  202. if not f.is_file():
  203. return None
  204. try:
  205. import pandas as pd
  206. return int(len(pd.read_parquet(f)))
  207. except Exception:
  208. return None
  209. def _objects_count(rel: str):
  210. f = ROOT / rel
  211. try:
  212. return len(json.loads(f.read_text(encoding='utf-8'))) if f.is_file() else None
  213. except Exception:
  214. return None
  215. # ─────────────────────────────────────────────────────────── 动作
  216. def _guard(what: str) -> str | None:
  217. running, j = job_running()
  218. if running:
  219. return f'已有任务在跑 ({j.get("kind")}, 起于 {j.get("started")}) —— 等它结束再操作。'
  220. return None
  221. def _svc_flags() -> tuple[bool, bool]:
  222. """(有组件在跑, 有组件没跑) —— 供动作前置检查, 与 state() 里 buttons 用的是同一判据。"""
  223. pt = ports(); host = pt['host']
  224. ups = [listening(host, port) for name, port in pt.items() if name != 'host' and name != 'gateway']
  225. return any(ups), any(not u for u in ups)
  226. def act_stop(body: dict) -> tuple[int, dict]:
  227. pt = ports(); include_gw = bool(body.get('include_gateway'))
  228. if (e := _guard('stop')):
  229. return 409, dict(err=e)
  230. any_up, _ = _svc_flags()
  231. if not any_up:
  232. # 按钮在前端就该是灰的, 但后端也必须拒 —— 否则直接打 API/重复提交就能做出无意义动作
  233. return 409, dict(err='组件服务都已停止, 没有可停的 (按钮在页面上是禁用的)。')
  234. if include_gw:
  235. # 连网关一起停: 必须分离进程"先停 → 睡一会 → 再起", 否则控制台自己没了就再也起不回来。
  236. # 现在经 _ops_launch 二次启动 → 这个任务不是网关的子孙, stop 里的 taskkill /T 杀不到它,
  237. # 所以"起回来"那半一定执行得到(此前会让整个栈停在半路)。
  238. j = spawn(['-c', 'import subprocess,sys,time;'
  239. f'subprocess.run([r"{PY}", "guanlan.py", "stop"]);'
  240. 'time.sleep(2);'
  241. f'subprocess.run([r"{PY}", "guanlan.py", "serve"])'], 'restart_all')
  242. j['note'] = '含网关的完整重启: 页面会断开约 15 秒, 之后自动重连'
  243. JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
  244. return 200, dict(ok=True, job=j, note=j['note'])
  245. j = spawn(['scripts/_ops_stop_keep_gateway.py'], 'stop_keep_gw')
  246. return 200, dict(ok=True, job=j, note='已停组件服务, 保留网关(控制台) —— 页面继续可用')
  247. def act_start(body: dict) -> tuple[int, dict]:
  248. if (e := _guard('start')):
  249. return 409, dict(err=e)
  250. _, any_down = _svc_flags()
  251. if not any_down:
  252. return 409, dict(err='组件服务都在运行, 无需启动 (按钮在页面上是禁用的)。')
  253. open_browser = body.get('open_browser', True)
  254. j = spawn(['scripts/_ops_start_and_open.py'] + ([] if open_browser else ['--no-open']), 'start')
  255. return 200, dict(ok=True, job=j,
  256. note='正在启动全部服务' + ('; 起来后自动打开 http://127.0.0.1:%d/' % ports()['gateway'] if open_browser else ''))
  257. def act_rebuild(body: dict) -> tuple[int, dict]:
  258. if (e := _guard('rebuild')):
  259. return 409, dict(err=e)
  260. args = ['scripts/rebuild_all.py']
  261. if body.get('skip_scada', True):
  262. args.append('--skip-scada')
  263. if body.get('with_verify'):
  264. args.append('--with-verify')
  265. src = (body.get('src') or '').strip()
  266. if src:
  267. args += ['--src', src]
  268. j = spawn(args, 'rebuild')
  269. return 200, dict(ok=True, job=j, note='重算已开始(含重启服务步骤); 进度看日志尾巴')
  270. def act_products_off(body: dict) -> tuple[int, dict]:
  271. """清除产物 —— 2026-09-16 用户令: **直接删除, 不留备份**。
  272. 原实现是 `products_state.py --off`(移动到 `_products_off/`, 可 `--on` 还原)。现口径: 真删,
  273. 故这里传 `--yes`(脚本对不可恢复操作要求显式确认; 前端已 confirm 过一次)。
  274. 恢复缺失的**随包件**改用交付包补齐:
  275. `python scripts/products_restore_missing.py --stash <交付包.zip>`。
  276. """
  277. if (e := _guard('products_off')):
  278. return 409, dict(err=e)
  279. pr = products_state()
  280. if not pr['in_place']:
  281. return 409, dict(err='产物已经是清空状态, 无需再清')
  282. j = spawn(['scripts/products_state.py', '--off', '--yes'], 'products_off')
  283. return 200, dict(ok=True, job=j, note='正在**删除**产物(不留备份, 不可恢复); 清完请点"启动服务"让页面呈现空状态')
  284. ACTIONS = dict(stop_services=act_stop, start_services=act_start, rebuild=act_rebuild,
  285. products_off=act_products_off)
  286. def handle(method: str, path: str, body: bytes) -> tuple[int, str, bytes]:
  287. """网关调用入口: → (http code, content-type, body bytes)。
  288. 路由:
  289. `/ops` 运维控制台整页 (服务 + 重算 + 产物 + 动作进度)
  290. `/ops/recalc` **内嵌版**: 只保留 重算 + 产物 (+ 动作进度), 给门户菜单「数据重算」用
  291. (2026-09-16 用户令: 把 /ops 的重算与产物搬到门户菜单里)。
  292. 走独立路由而不是 `?embed=…`: 网关转给本模块的是 `u.path` (查询串被丢掉),
  293. 用查询串会变成"看起来支持、实际不生效"的静默坑。
  294. `/ops/api/...` 运维动作 JSON API (GET state / POST 各动作)
  295. """
  296. if path in ('/ops', '/ops/'):
  297. return 200, 'text/html; charset=utf-8', PAGE.encode('utf-8')
  298. if path in ('/ops/recalc', '/ops/recalc/'):
  299. import re as _re
  300. html = _re.sub(r'<!--HIDE_IN_EMBED-->.*?<!--/HIDE_IN_EMBED-->', '', PAGE, flags=_re.S)
  301. html = (html.replace('<title>观澜 · 运维控制台</title>', '<title>观澜 · 数据重算</title>')
  302. .replace('</style>', '.wrap{max-width:100%;padding:8px 10px 24px}'
  303. 'body{background:transparent}'
  304. '.card{margin:0 0 12px}</style>', 1))
  305. return 200, 'text/html; charset=utf-8', html.encode('utf-8')
  306. if path == '/ops/api/state':
  307. return 200, 'application/json; charset=utf-8', json.dumps(state(), ensure_ascii=False).encode('utf-8')
  308. if path.startswith('/ops/api/'):
  309. name = path[len('/ops/api/'):].strip('/')
  310. if method != 'POST':
  311. return 405, 'application/json; charset=utf-8', json.dumps(
  312. dict(err='这些动作只接受 POST (避免链接被预取/刷新时误触发)'), ensure_ascii=False).encode('utf-8')
  313. fn = ACTIONS.get(name)
  314. if not fn:
  315. return 404, 'application/json; charset=utf-8', json.dumps(dict(err=f'未知动作: {name}'), ensure_ascii=False).encode('utf-8')
  316. try:
  317. payload = json.loads(body.decode('utf-8')) if body else {}
  318. except Exception:
  319. payload = {}
  320. try:
  321. code, obj = fn(payload)
  322. except Exception as e:
  323. code, obj = 500, dict(err=f'{type(e).__name__}: {e}')
  324. return code, 'application/json; charset=utf-8', json.dumps(obj, ensure_ascii=False).encode('utf-8')
  325. return 404, 'application/json; charset=utf-8', json.dumps(dict(err='not found'), ensure_ascii=False).encode('utf-8')
  326. # ─────────────────────────────────────────────────────────── 页面
  327. PAGE = r"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
  328. <meta name="viewport" content="width=device-width,initial-scale=1">
  329. <title>观澜 · 运维控制台</title><style>
  330. :root{--ink:#1B2430;--mut:#5B6B7B;--line:#DDE3E8;--bg:#F5F7F8;--pri:#1F6F8B;--ok:#1E8E5A;--warn:#B26A00;--bad:#C0392B}
  331. *{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}
  332. .wrap{max-width:1000px;margin:0 auto;padding:22px 20px 60px}
  333. h1{font-size:21px;margin:0 0 4px}.sub{color:var(--mut);font-size:13.5px;margin:0 0 18px}
  334. .card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin:0 0 14px}
  335. .card h2{font-size:15px;margin:0 0 10px;color:var(--pri)}.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
  336. button{font:inherit;padding:9px 16px;border-radius:9px;border:1px solid var(--pri);background:var(--pri);color:#fff;cursor:pointer}
  337. button.ghost{background:#fff;color:var(--pri)}button.danger{border-color:var(--bad);background:var(--bad)}
  338. button:disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.3)}
  339. table{width:100%;border-collapse:collapse;font-size:13.5px}td,th{text-align:left;padding:5px 8px;border-bottom:1px solid var(--line)}
  340. th{color:var(--mut);font-weight:600}
  341. .pill{display:inline-block;padding:1px 9px;border-radius:999px;font-size:12.5px;border:1px solid var(--line)}
  342. .up{background:#E8F6EF;color:var(--ok);border-color:#BFE6D3}.down{background:#FDECEA;color:var(--bad);border-color:#F5C6C0}
  343. .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}
  344. .hint{font-size:13px;color:var(--mut);margin:8px 0 0}
  345. .busy{background:#FFF7E6;border:1px solid #F0D9A8;color:#7A5600;padding:8px 12px;border-radius:8px;margin:0 0 12px}
  346. label{font-size:13.5px;color:var(--mut);display:flex;gap:6px;align-items:center}
  347. </style></head><body><div class="wrap">
  348. <!--HIDE_IN_EMBED-->
  349. <h1>观澜 · 运维控制台</h1>
  350. <p class="sub">停/启服务 · 执行重算 · 清除产物 —— 按钮按真实状态启用; 不可用的动作后端也会拒绝。本页由网关(端口 <span id="gw"></span>)提供。</p>
  351. <!--/HIDE_IN_EMBED-->
  352. <div id="busy"></div>
  353. <!--HIDE_IN_EMBED-->
  354. <div class="card"><h2>服务</h2><div id="svc"></div>
  355. <div class="row" style="margin-top:12px">
  356. <button id="b_start" class="ghost">启动服务(并打开门户)</button>
  357. <button id="b_stop" class="ghost">停止组件服务(保留控制台)</button>
  358. <button id="b_restart" class="ghost">完整重启(含网关,页面会断开十几秒)</button>
  359. </div>
  360. <p class="hint" id="svc_hint"></p>
  361. </div>
  362. <!--/HIDE_IN_EMBED-->
  363. <div class="card"><h2>重算</h2>
  364. <div class="row">
  365. <label><input type="checkbox" id="f_scada"> 含 SCADA 侧 10 个构建器(逐台读 ~14 GB,约 15 分钟)</label>
  366. <label><input type="checkbox" id="f_verify"> 末尾加台账等价验收</label>
  367. </div>
  368. <div class="row" style="margin-top:10px">
  369. <button id="b_rebuild">执行重算(放数据→台账→月度件→补齐→本体→审计)</button>
  370. <span class="mut" id="rebuild_hint"></span>
  371. </div>
  372. <p class="hint">默认跳过 SCADA(只换了台账类数据时的常用档)。重算会自己重启服务。</p>
  373. </div>
  374. <div class="card"><h2>产物</h2><div id="prod"></div>
  375. <div class="row" style="margin-top:12px">
  376. <button id="b_off" class="danger">清除产物(直接删除,不留备份)</button>
  377. </div>
  378. <p class="hint">按用户令(2026-09-16)**清除不留备份、不可恢复**;清完请点"启动服务"让页面呈现空状态。
  379. 要补回"包内没有生成端"的随包件:<code>python scripts/products_restore_missing.py --stash &lt;交付包.zip&gt;</code>(从交付包按需补齐)。</p>
  380. </div>
  381. <div class="card"><h2>最近一次动作</h2><div id="job">(无)</div><pre id="log"></pre></div>
  382. <p class="hint">命令行等价物: <code>guanlan.py stop/serve</code> · <code>scripts/rebuild_all.py</code> · <code>scripts/products_state.py --off --yes/--status</code>(手册 §0/§5b)。清除产物**不留备份**(用户令 2026-09-16);要补回缺失的随包件用 <code>scripts/products_restore_missing.py --stash &lt;交付包.zip&gt;</code>。</p>
  383. </div><script>
  384. const $ = s => document.querySelector(s);
  385. let S = null, busyTimer = null;
  386. async function api(path, body){
  387. const r = await fetch(path, body ? {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)} : undefined);
  388. return [r.status, await r.json().catch(()=>({err:'非 JSON 响应'}))];
  389. }
  390. function pill(up){ return `<span class="pill ${up?'up':'down'}">${up?'运行中':'未运行'}</span>`; }
  391. /* set: 元素可能不存在 —— 内嵌版 (/ops/recalc) 会去掉"服务"卡与页头, 直接 $() 取值再赋值会抛错,
  392. 而这里一抛整个 render() 就断, 页面看着像"卡住不动"(2026-09-16 加内嵌版时踩过)。 */
  393. function set(sel, fn){ const e = $(sel); if(e) fn(e); }
  394. function render(){
  395. const s = S; if(!s) return;
  396. set('#gw', e=>e.textContent = s.services.gateway ? s.services.gateway.port : '?');
  397. const rows = Object.entries(s.services).map(([n,v]) =>
  398. `<tr><td>${n}${v.is_gateway?' <span class="mut">(控制台本体)</span>':''}</td><td>${v.port}</td><td>${pill(v.up)}</td></tr>`).join('');
  399. set('#svc', e=>e.innerHTML = `<table><tr><th>服务</th><th>端口</th><th>状态</th></tr>${rows}</table>`);
  400. const p = s.products;
  401. const st = Object.entries(p.stores||{}).map(([k,v])=>`${k} ${v}`).join(' · ') || '(空)';
  402. set('#prod', e=>e.innerHTML = `<table>
  403. <tr><th>产物仓</th><td>${p.store}</td></tr>
  404. <tr><th>件数</th><td>${p.files} 件 ${p.in_place?'<span class="pill up">在位</span>':'<span class="pill down">已清空</span>'}</td></tr>
  405. <tr><th>分布</th><td class="mut">${st}</td></tr>
  406. <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>
  407. <tr><th>备份</th><td class="mut">${p.no_backup?'**不留备份**(用户令 2026-09-16: 清除 = 直接删除)':''}${(p.legacy_backups&&p.legacy_backups.length)?` <b>← 旧设计残留 ${p.legacy_backups.join(' · ')} —— 确认不需要后手工删掉</b>`:''}</td></tr>
  408. <tr><th>验收锚点</th><td class="mut">报警 ${s.anchors.alarms??'—'} 行 · 工单 ${s.anchors.workorders??'—'} · temp_monthly ${s.anchors.temp_monthly??'—'} · 本体 ${s.anchors.objects??'—'} 对象</td></tr>
  409. </table>`);
  410. const b = s.buttons, run = s.job && s.job.status==='running';
  411. set('#b_start', e=>e.disabled = !b.start_services);
  412. set('#b_stop', e=>e.disabled = !b.stop_services);
  413. set('#b_restart', e=>e.disabled = !b.stop_services);
  414. set('#b_rebuild', e=>e.disabled = !b.rebuild);
  415. set('#b_off', e=>e.disabled = !b.products_off);
  416. set('#svc_hint', e=>e.textContent = b.start_services ? '有服务未运行 → 可启动。' : '全部组件服务已在运行 → 启动按钮已禁用。');
  417. set('#rebuild_hint', e=>e.textContent = b.rebuild ? '' : '(有任务在跑, 重算按钮已禁用)');
  418. set('#busy', e=>e.innerHTML = run ? `<div class="busy">正在执行: <b>${s.job.kind}</b>(起于 ${s.job.started})${s.job.note?' — '+s.job.note:''} · 完成后本页自动刷新</div>` : '');
  419. const j = s.job;
  420. set('#job', e=>e.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>':''}` : '(无)');
  421. set('#log', e=>e.textContent = (j && j.log_tail && j.log_tail.length) ? j.log_tail.join('\n') : '');
  422. }
  423. async function refresh(){ const [c,d] = await api('/ops/api/state'); if(c===200){ S=d; render(); } }
  424. async function fire(name, body){
  425. const [c,d] = await api('/ops/api/'+name, body||{});
  426. if(c!==200){ alert(`操作被拒绝 (HTTP ${c}): ${d.err||''}`); }
  427. else { if(d.note) console.log(d.note); }
  428. await refresh();
  429. }
  430. /* on: 与 set 同理 —— 内嵌版没有"服务"卡, 直接 $('#b_start').onclick=… 会抛 TypeError,
  431. 而**这一抛会让后面所有绑定与 refresh() 全不执行** (2026-09-16 用户实测: 门户 #recalc 页里
  432. "执行重算 / 清除产物"点了没反应)。容错绑定 + 下面 try/catch 兜底 = 同类问题不再静默。 */
  433. function on(sel, fn){ const e = $(sel); if(e) e.onclick = fn; }
  434. try {
  435. on('#b_start', ()=>fire('start_services', {open_browser:true}));
  436. on('#b_stop', ()=>fire('stop_services', {include_gateway:false}));
  437. on('#b_restart', ()=>{ if(confirm('完整重启会连网关一起停, 本页会断开约 15 秒后自动恢复。继续?')) fire('stop_services', {include_gateway:true}); });
  438. on('#b_rebuild', ()=>{ if(confirm('开始重算? 期间服务会被重启, 页面可能短暂打不开。')) fire('rebuild', {skip_scada: !$('#f_scada').checked, with_verify: $('#f_verify').checked}); });
  439. on('#b_off', ()=>{ if(confirm('清除产物 = **直接删除, 不留备份, 不可恢复**。继续?')) fire('products_off', {}); });
  440. refresh(); setInterval(refresh, 2000);
  441. } catch (err) {
  442. /* 界面脚本自己坏了也要看得见 (否则表现就是"按钮点了没反应", 排查全靠猜) */
  443. const b = document.getElementById('busy');
  444. if (b) b.innerHTML = '<div class="busy">⚠ 本页脚本出错, 按钮可能不可用: ' + err + ' (请反馈该行)</div>';
  445. else alert('本页脚本出错: ' + err);
  446. }
  447. </script></body></html>
  448. """