ops.py 30 KB

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