|
@@ -8,14 +8,14 @@ r"""观澜运维控制台的后端 (2026-09-12) —— 把"停服务 / 起服务
|
|
|
本模块给网关加三个东西:
|
|
本模块给网关加三个东西:
|
|
|
· `GET /ops` 一个自适应控制台页面(单文件, 无外部依赖);
|
|
· `GET /ops` 一个自适应控制台页面(单文件, 无外部依赖);
|
|
|
· `GET /ops/api/state` 真实状态: 各服务端口通不通 · 产物在不在 · 有没有任务在跑 · 上次结果;
|
|
· `GET /ops/api/state` 真实状态: 各服务端口通不通 · 产物在不在 · 有没有任务在跑 · 上次结果;
|
|
|
- · `POST /ops/api/<动作>` 停服务 · 起服务 · 重算 · 清产物 · 恢复产物。
|
|
|
|
|
|
|
+ · `POST /ops/api/<动作>` 停服务 · 起服务 · 重算 · 清产物。
|
|
|
|
|
|
|
|
## 三条设计纪律
|
|
## 三条设计纪律
|
|
|
|
|
|
|
|
1. **页面活着的服务不能被自己停掉。** 控制台由网关(28084)提供, 所以"停服务"默认**保留网关**,
|
|
1. **页面活着的服务不能被自己停掉。** 控制台由网关(28084)提供, 所以"停服务"默认**保留网关**,
|
|
|
否则按钮刚点完页面就没了、再也没法"启动服务"。要连网关一起停, 用 `include_gateway=True`,
|
|
否则按钮刚点完页面就没了、再也没法"启动服务"。要连网关一起停, 用 `include_gateway=True`,
|
|
|
这时用**分离进程**先停再起(页面会断开十几秒, 之后自动重连)。
|
|
这时用**分离进程**先停再起(页面会断开十几秒, 之后自动重连)。
|
|
|
-2. **一次只允许一个动作。** 四个动作互相冲突(重算要重启服务、清产物要挪走产物), 所以有一把锁:
|
|
|
|
|
|
|
+2. **一次只允许一个动作。** 各动作互相冲突(重算要重启服务、清产物要删产物), 所以有一把锁:
|
|
|
`run/ops_job.json` 里记着当前任务; 任务在跑时, 所有会冲突的按钮在后端**与前端都会被禁掉**
|
|
`run/ops_job.json` 里记着当前任务; 任务在跑时, 所有会冲突的按钮在后端**与前端都会被禁掉**
|
|
|
(后端拒绝 = 真生效, 前端禁用只是提示)。判断以后端为准。
|
|
(后端拒绝 = 真生效, 前端禁用只是提示)。判断以后端为准。
|
|
|
3. **日志与状态落盘。** 动作全部 `Popen` 到 `logs/ops_<动作>_<时间>.log`, 页面轮询 job 状态并把日志尾巴显示出来 ——
|
|
3. **日志与状态落盘。** 动作全部 `Popen` 到 `logs/ops_<动作>_<时间>.log`, 页面轮询 job 状态并把日志尾巴显示出来 ——
|
|
@@ -26,8 +26,7 @@ r"""观澜运维控制台的后端 (2026-09-12) —— 把"停服务 / 起服务
|
|
|
停服务 : 至少有一个组件服务在监听
|
|
停服务 : 至少有一个组件服务在监听
|
|
|
启动服务 : 至少有一个组件服务没在监听
|
|
启动服务 : 至少有一个组件服务没在监听
|
|
|
执行重算 : 没有任务在跑
|
|
执行重算 : 没有任务在跑
|
|
|
- 清除产物 : 没有任务在跑 且 产物在位 且 暂存区没有同名目录(或允许 --archive-old)
|
|
|
|
|
- 恢复产物 : 没有任务在跑 且 产物被清掉过(暂存区清单存在)
|
|
|
|
|
|
|
+ 清除产物 : 没有任务在跑 且 产物在位 (**直接删除, 不留备份** —— 2026-09-16 用户令; 无"恢复产物"按钮)
|
|
|
"""
|
|
"""
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
@@ -43,6 +42,7 @@ import time
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
|
sys.path.insert(0, str(ROOT))
|
|
sys.path.insert(0, str(ROOT))
|
|
|
from src import paths as P # noqa: E402
|
|
from src import paths as P # noqa: E402
|
|
|
|
|
+from src import proc as _proc # noqa: E402 无窗口子进程
|
|
|
|
|
|
|
|
RUN = ROOT / 'run'
|
|
RUN = ROOT / 'run'
|
|
|
LOGS = ROOT / 'logs'
|
|
LOGS = ROOT / 'logs'
|
|
@@ -112,8 +112,9 @@ def job_running() -> tuple[bool, dict]:
|
|
|
if os.name == 'nt':
|
|
if os.name == 'nt':
|
|
|
# errors='replace': tasklist 输出是控制台代码页(GBK), 而本进程可能是 PYTHONUTF8=1
|
|
# errors='replace': tasklist 输出是控制台代码页(GBK), 而本进程可能是 PYTHONUTF8=1
|
|
|
# → 严格解码会失败并使 stdout 为 None (guanlan.py 的 alive() 踩过同一个坑)
|
|
# → 严格解码会失败并使 stdout 为 None (guanlan.py 的 alive() 踩过同一个坑)
|
|
|
- out = subprocess.run(['tasklist', '/FI', f'PID eq {pid}'], capture_output=True,
|
|
|
|
|
- text=True, errors='replace').stdout
|
|
|
|
|
|
|
+ # ★ 走 src.proc.run_text: 本模块跑在网关进程里 (无控制台), 裸 spawn tasklist 会让
|
|
|
|
|
+ # Windows 新建可见控制台 —— 而 /ops 页面每 2 s 轮询一次 ⇒ **反复闪窗** (2026-09-16 实测)。
|
|
|
|
|
+ out = _proc.run_text(['tasklist', '/FI', f'PID eq {pid}']).stdout
|
|
|
alive = bool(out) and str(pid) in out
|
|
alive = bool(out) and str(pid) in out
|
|
|
else:
|
|
else:
|
|
|
os.kill(pid, 0); alive = True
|
|
os.kill(pid, 0); alive = True
|
|
@@ -148,8 +149,9 @@ def spawn(args: list, tag: str, detached=False) -> dict:
|
|
|
RUN.mkdir(exist_ok=True); LOGS.mkdir(exist_ok=True)
|
|
RUN.mkdir(exist_ok=True); LOGS.mkdir(exist_ok=True)
|
|
|
log = LOGS / f'ops_{tag}_{time.strftime("%Y%m%d_%H%M%S")}.log'
|
|
log = LOGS / f'ops_{tag}_{time.strftime("%Y%m%d_%H%M%S")}.log'
|
|
|
env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8', PYTHONUNBUFFERED='1')
|
|
env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8', PYTHONUNBUFFERED='1')
|
|
|
- r = subprocess.run([PY, 'scripts/_ops_launch.py', '--tag', tag, '--log', str(log), '--'] + list(args),
|
|
|
|
|
- cwd=str(ROOT), env=env, capture_output=True, text=True, errors='replace', timeout=60)
|
|
|
|
|
|
|
+ # 走 src.proc.run_text: 网关进程无控制台, 裸 spawn 会让这次"拉启动器"也闪一下窗
|
|
|
|
|
+ r = _proc.run_text([PY, 'scripts/_ops_launch.py', '--tag', tag, '--log', str(log), '--'] + list(args),
|
|
|
|
|
+ cwd=str(ROOT), env=env, timeout=60)
|
|
|
pid = None
|
|
pid = None
|
|
|
for line in reversed((r.stdout or '').strip().splitlines()):
|
|
for line in reversed((r.stdout or '').strip().splitlines()):
|
|
|
if line.strip().isdigit():
|
|
if line.strip().isdigit():
|
|
@@ -173,18 +175,15 @@ def products_state() -> dict:
|
|
|
stores[d.name] = sum(1 for _ in d.rglob('*') if _.is_file())
|
|
stores[d.name] = sum(1 for _ in d.rglob('*') if _.is_file())
|
|
|
prov = fam / '_provenance.json'
|
|
prov = fam / '_provenance.json'
|
|
|
prov_d = json.loads(prov.read_text(encoding='utf-8')) if prov.is_file() else None
|
|
prov_d = json.loads(prov.read_text(encoding='utf-8')) if prov.is_file() else None
|
|
|
- stash_dirs = {}
|
|
|
|
|
- if OFF.is_dir():
|
|
|
|
|
- for d in sorted(x for x in OFF.iterdir() if x.is_dir() and x.name != '_baseline_kept'):
|
|
|
|
|
- stash_dirs[d.name] = sum(1 for _ in (d / store.name).rglob('*') if _.is_file()) if (d / store.name).is_dir() else 0
|
|
|
|
|
- kept = list((OFF / '_baseline_kept').rglob('*')) if (OFF / '_baseline_kept').is_dir() else []
|
|
|
|
|
|
|
+ # ★2026-09-16 用户令"不要备份清除的产物": 清除 = 真删除, 没有暂存区可列。
|
|
|
|
|
+ # 这里只报"有没有旧设计留下的 _products_off*"(有就提示手工删掉), 不再提供"恢复产物"。
|
|
|
|
|
+ legacy = sorted(x.name for x in ROOT.glob('_products_off*') if x.is_dir())
|
|
|
return dict(
|
|
return dict(
|
|
|
fam=str(fam.relative_to(ROOT)).replace('\\', '/'), store=str(store.relative_to(ROOT)).replace('\\', '/'),
|
|
fam=str(fam.relative_to(ROOT)).replace('\\', '/'), store=str(store.relative_to(ROOT)).replace('\\', '/'),
|
|
|
files=n, in_place=n > 0, stores=stores,
|
|
files=n, in_place=n > 0, stores=stores,
|
|
|
cleared=n == 0,
|
|
cleared=n == 0,
|
|
|
- stash_has_same=any(v > 0 for v in stash_dirs.values()), stash=stash_dirs,
|
|
|
|
|
- can_restore=OFF_MANIFEST.is_file() and n == 0,
|
|
|
|
|
- baseline_kept=sum(1 for x in kept if x.is_file()),
|
|
|
|
|
|
|
+ no_backup=True, # 现口径: 清除不留备份
|
|
|
|
|
+ legacy_backups=legacy, # 旧设计残留 (按用户令不自动删, 只提示)
|
|
|
provenance=(dict(counts=prov_d.get('counts'), at=prov_d.get('at')) if prov_d else None),
|
|
provenance=(dict(counts=prov_d.get('counts'), at=prov_d.get('at')) if prov_d else None),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
@@ -214,7 +213,6 @@ def state() -> dict:
|
|
|
start_services=any_comp_down and not running,
|
|
start_services=any_comp_down and not running,
|
|
|
rebuild=not running,
|
|
rebuild=not running,
|
|
|
products_off=pr['in_place'] and not running,
|
|
products_off=pr['in_place'] and not running,
|
|
|
- products_on=(not pr['in_place']) and not running,
|
|
|
|
|
),
|
|
),
|
|
|
anchors=dict(alarms=_rows(f'{pr["fam"]}/windscada/alarms.parquet'),
|
|
anchors=dict(alarms=_rows(f'{pr["fam"]}/windscada/alarms.parquet'),
|
|
|
workorders=_rows(f'{pr["fam"]}/windscada/workorders.parquet'),
|
|
workorders=_rows(f'{pr["fam"]}/windscada/workorders.parquet'),
|
|
@@ -309,38 +307,47 @@ def act_rebuild(body: dict) -> tuple[int, dict]:
|
|
|
|
|
|
|
|
|
|
|
|
|
def act_products_off(body: dict) -> tuple[int, dict]:
|
|
def act_products_off(body: dict) -> tuple[int, dict]:
|
|
|
|
|
+ """清除产物 —— 2026-09-16 用户令: **直接删除, 不留备份**。
|
|
|
|
|
+
|
|
|
|
|
+ 原实现是 `products_state.py --off`(移动到 `_products_off/`, 可 `--on` 还原)。现口径: 真删,
|
|
|
|
|
+ 故这里传 `--yes`(脚本对不可恢复操作要求显式确认; 前端已 confirm 过一次)。
|
|
|
|
|
+ 恢复缺失的**随包件**改用交付包补齐:
|
|
|
|
|
+ `python scripts/products_restore_missing.py --stash <交付包.zip>`。
|
|
|
|
|
+ """
|
|
|
if (e := _guard('products_off')):
|
|
if (e := _guard('products_off')):
|
|
|
return 409, dict(err=e)
|
|
return 409, dict(err=e)
|
|
|
pr = products_state()
|
|
pr = products_state()
|
|
|
if not pr['in_place']:
|
|
if not pr['in_place']:
|
|
|
return 409, dict(err='产物已经是清空状态, 无需再清')
|
|
return 409, dict(err='产物已经是清空状态, 无需再清')
|
|
|
- args = ['scripts/products_state.py', '--off']
|
|
|
|
|
- if pr['stash_has_same'] or body.get('archive_old'):
|
|
|
|
|
- args.append('--archive-old') # 暂存区已有同名产物时必须加, 否则会嵌套(见该脚本注释)
|
|
|
|
|
- j = spawn(args, 'products_off')
|
|
|
|
|
- return 200, dict(ok=True, job=j, note='正在把产物挪到 _products_off/ (挪完请点"启动服务"让页面呈现空状态)')
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def act_products_on(body: dict) -> tuple[int, dict]:
|
|
|
|
|
- if (e := _guard('products_on')):
|
|
|
|
|
- return 409, dict(err=e)
|
|
|
|
|
- pr = products_state()
|
|
|
|
|
- if pr['in_place']:
|
|
|
|
|
- return 409, dict(err='产物已在位, 无需恢复')
|
|
|
|
|
- if not OFF_MANIFEST.is_file():
|
|
|
|
|
- return 409, dict(err='没有 _products_off/manifest.json (没清过或清单已删), 无法恢复')
|
|
|
|
|
- j = spawn(['scripts/products_state.py', '--on'], 'products_on')
|
|
|
|
|
- return 200, dict(ok=True, job=j, note='正在恢复产物; 恢复后请点"启动服务"')
|
|
|
|
|
|
|
+ j = spawn(['scripts/products_state.py', '--off', '--yes'], 'products_off')
|
|
|
|
|
+ return 200, dict(ok=True, job=j, note='正在**删除**产物(不留备份, 不可恢复); 清完请点"启动服务"让页面呈现空状态')
|
|
|
|
|
|
|
|
|
|
|
|
|
ACTIONS = dict(stop_services=act_stop, start_services=act_start, rebuild=act_rebuild,
|
|
ACTIONS = dict(stop_services=act_stop, start_services=act_start, rebuild=act_rebuild,
|
|
|
- products_off=act_products_off, products_on=act_products_on)
|
|
|
|
|
|
|
+ products_off=act_products_off)
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle(method: str, path: str, body: bytes) -> tuple[int, str, bytes]:
|
|
def handle(method: str, path: str, body: bytes) -> tuple[int, str, bytes]:
|
|
|
- """网关调用入口: → (http code, content-type, body bytes)。"""
|
|
|
|
|
|
|
+ """网关调用入口: → (http code, content-type, body bytes)。
|
|
|
|
|
+
|
|
|
|
|
+ 路由:
|
|
|
|
|
+ `/ops` 运维控制台整页 (服务 + 重算 + 产物 + 动作进度)
|
|
|
|
|
+ `/ops/recalc` **内嵌版**: 只保留 重算 + 产物 (+ 动作进度), 给门户菜单「数据重算」用
|
|
|
|
|
+ (2026-09-16 用户令: 把 /ops 的重算与产物搬到门户菜单里)。
|
|
|
|
|
+ 走独立路由而不是 `?embed=…`: 网关转给本模块的是 `u.path` (查询串被丢掉),
|
|
|
|
|
+ 用查询串会变成"看起来支持、实际不生效"的静默坑。
|
|
|
|
|
+ `/ops/api/...` 运维动作 JSON API (GET state / POST 各动作)
|
|
|
|
|
+ """
|
|
|
if path in ('/ops', '/ops/'):
|
|
if path in ('/ops', '/ops/'):
|
|
|
return 200, 'text/html; charset=utf-8', PAGE.encode('utf-8')
|
|
return 200, 'text/html; charset=utf-8', PAGE.encode('utf-8')
|
|
|
|
|
+ if path in ('/ops/recalc', '/ops/recalc/'):
|
|
|
|
|
+ import re as _re
|
|
|
|
|
+ html = _re.sub(r'<!--HIDE_IN_EMBED-->.*?<!--/HIDE_IN_EMBED-->', '', PAGE, flags=_re.S)
|
|
|
|
|
+ html = (html.replace('<title>观澜 · 运维控制台</title>', '<title>观澜 · 数据重算</title>')
|
|
|
|
|
+ .replace('</style>', '.wrap{max-width:100%;padding:8px 10px 24px}'
|
|
|
|
|
+ 'body{background:transparent}'
|
|
|
|
|
+ '.card{margin:0 0 12px}</style>', 1))
|
|
|
|
|
+ return 200, 'text/html; charset=utf-8', html.encode('utf-8')
|
|
|
if path == '/ops/api/state':
|
|
if path == '/ops/api/state':
|
|
|
return 200, 'application/json; charset=utf-8', json.dumps(state(), ensure_ascii=False).encode('utf-8')
|
|
return 200, 'application/json; charset=utf-8', json.dumps(state(), ensure_ascii=False).encode('utf-8')
|
|
|
if path.startswith('/ops/api/'):
|
|
if path.startswith('/ops/api/'):
|
|
@@ -385,10 +392,13 @@ th{color:var(--mut);font-weight:600}
|
|
|
.busy{background:#FFF7E6;border:1px solid #F0D9A8;color:#7A5600;padding:8px 12px;border-radius:8px;margin:0 0 12px}
|
|
.busy{background:#FFF7E6;border:1px solid #F0D9A8;color:#7A5600;padding:8px 12px;border-radius:8px;margin:0 0 12px}
|
|
|
label{font-size:13.5px;color:var(--mut);display:flex;gap:6px;align-items:center}
|
|
label{font-size:13.5px;color:var(--mut);display:flex;gap:6px;align-items:center}
|
|
|
</style></head><body><div class="wrap">
|
|
</style></head><body><div class="wrap">
|
|
|
|
|
+<!--HIDE_IN_EMBED-->
|
|
|
<h1>观澜 · 运维控制台</h1>
|
|
<h1>观澜 · 运维控制台</h1>
|
|
|
<p class="sub">停/启服务 · 执行重算 · 清除产物 —— 按钮按真实状态启用; 不可用的动作后端也会拒绝。本页由网关(端口 <span id="gw"></span>)提供。</p>
|
|
<p class="sub">停/启服务 · 执行重算 · 清除产物 —— 按钮按真实状态启用; 不可用的动作后端也会拒绝。本页由网关(端口 <span id="gw"></span>)提供。</p>
|
|
|
|
|
+<!--/HIDE_IN_EMBED-->
|
|
|
<div id="busy"></div>
|
|
<div id="busy"></div>
|
|
|
|
|
|
|
|
|
|
+<!--HIDE_IN_EMBED-->
|
|
|
<div class="card"><h2>服务</h2><div id="svc"></div>
|
|
<div class="card"><h2>服务</h2><div id="svc"></div>
|
|
|
<div class="row" style="margin-top:12px">
|
|
<div class="row" style="margin-top:12px">
|
|
|
<button id="b_start" class="ghost">启动服务(并打开门户)</button>
|
|
<button id="b_start" class="ghost">启动服务(并打开门户)</button>
|
|
@@ -397,6 +407,7 @@ label{font-size:13.5px;color:var(--mut);display:flex;gap:6px;align-items:center}
|
|
|
</div>
|
|
</div>
|
|
|
<p class="hint" id="svc_hint"></p>
|
|
<p class="hint" id="svc_hint"></p>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+<!--/HIDE_IN_EMBED-->
|
|
|
|
|
|
|
|
<div class="card"><h2>重算</h2>
|
|
<div class="card"><h2>重算</h2>
|
|
|
<div class="row">
|
|
<div class="row">
|
|
@@ -412,14 +423,14 @@ label{font-size:13.5px;color:var(--mut);display:flex;gap:6px;align-items:center}
|
|
|
|
|
|
|
|
<div class="card"><h2>产物</h2><div id="prod"></div>
|
|
<div class="card"><h2>产物</h2><div id="prod"></div>
|
|
|
<div class="row" style="margin-top:12px">
|
|
<div class="row" style="margin-top:12px">
|
|
|
- <button id="b_on" class="ghost">恢复产物</button>
|
|
|
|
|
- <button id="b_off" class="danger">清除产物(挪到 _products_off,可恢复)</button>
|
|
|
|
|
|
|
+ <button id="b_off" class="danger">清除产物(直接删除,不留备份)</button>
|
|
|
</div>
|
|
</div>
|
|
|
- <p class="hint">清除后门户首页仍可打开(它是静态交付页), 但工作台会显示"无产物"; 恢复后请点"启动服务"。</p>
|
|
|
|
|
|
|
+ <p class="hint">按用户令(2026-09-16)**清除不留备份、不可恢复**;清完请点"启动服务"让页面呈现空状态。
|
|
|
|
|
+ 要补回"包内没有生成端"的随包件:<code>python scripts/products_restore_missing.py --stash <交付包.zip></code>(从交付包按需补齐)。</p>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div class="card"><h2>最近一次动作</h2><div id="job">(无)</div><pre id="log"></pre></div>
|
|
<div class="card"><h2>最近一次动作</h2><div id="job">(无)</div><pre id="log"></pre></div>
|
|
|
-<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>
|
|
|
|
|
|
|
+<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 <交付包.zip></code>。</p>
|
|
|
</div><script>
|
|
</div><script>
|
|
|
const $ = s => document.querySelector(s);
|
|
const $ = s => document.querySelector(s);
|
|
|
let S = null, busyTimer = null;
|
|
let S = null, busyTimer = null;
|
|
@@ -428,35 +439,37 @@ async function api(path, body){
|
|
|
return [r.status, await r.json().catch(()=>({err:'非 JSON 响应'}))];
|
|
return [r.status, await r.json().catch(()=>({err:'非 JSON 响应'}))];
|
|
|
}
|
|
}
|
|
|
function pill(up){ return `<span class="pill ${up?'up':'down'}">${up?'运行中':'未运行'}</span>`; }
|
|
function pill(up){ return `<span class="pill ${up?'up':'down'}">${up?'运行中':'未运行'}</span>`; }
|
|
|
|
|
+/* set: 元素可能不存在 —— 内嵌版 (/ops/recalc) 会去掉"服务"卡与页头, 直接 $() 取值再赋值会抛错,
|
|
|
|
|
+ 而这里一抛整个 render() 就断, 页面看着像"卡住不动"(2026-09-16 加内嵌版时踩过)。 */
|
|
|
|
|
+function set(sel, fn){ const e = $(sel); if(e) fn(e); }
|
|
|
function render(){
|
|
function render(){
|
|
|
const s = S; if(!s) return;
|
|
const s = S; if(!s) return;
|
|
|
- $('#gw').textContent = s.services.gateway ? s.services.gateway.port : '?';
|
|
|
|
|
|
|
+ set('#gw', e=>e.textContent = s.services.gateway ? s.services.gateway.port : '?');
|
|
|
const rows = Object.entries(s.services).map(([n,v]) =>
|
|
const rows = Object.entries(s.services).map(([n,v]) =>
|
|
|
`<tr><td>${n}${v.is_gateway?' <span class="mut">(控制台本体)</span>':''}</td><td>${v.port}</td><td>${pill(v.up)}</td></tr>`).join('');
|
|
`<tr><td>${n}${v.is_gateway?' <span class="mut">(控制台本体)</span>':''}</td><td>${v.port}</td><td>${pill(v.up)}</td></tr>`).join('');
|
|
|
- $('#svc').innerHTML = `<table><tr><th>服务</th><th>端口</th><th>状态</th></tr>${rows}</table>`;
|
|
|
|
|
|
|
+ set('#svc', e=>e.innerHTML = `<table><tr><th>服务</th><th>端口</th><th>状态</th></tr>${rows}</table>`);
|
|
|
const p = s.products;
|
|
const p = s.products;
|
|
|
const st = Object.entries(p.stores||{}).map(([k,v])=>`${k} ${v}`).join(' · ') || '(空)';
|
|
const st = Object.entries(p.stores||{}).map(([k,v])=>`${k} ${v}`).join(' · ') || '(空)';
|
|
|
- $('#prod').innerHTML = `<table>
|
|
|
|
|
|
|
+ set('#prod', e=>e.innerHTML = `<table>
|
|
|
<tr><th>产物仓</th><td>${p.store}</td></tr>
|
|
<tr><th>产物仓</th><td>${p.store}</td></tr>
|
|
|
<tr><th>件数</th><td>${p.files} 件 ${p.in_place?'<span class="pill up">在位</span>':'<span class="pill down">已清空</span>'}</td></tr>
|
|
<tr><th>件数</th><td>${p.files} 件 ${p.in_place?'<span class="pill up">在位</span>':'<span class="pill down">已清空</span>'}</td></tr>
|
|
|
<tr><th>分布</th><td class="mut">${st}</td></tr>
|
|
<tr><th>分布</th><td class="mut">${st}</td></tr>
|
|
|
<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>
|
|
<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>
|
|
|
- <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>
|
|
|
|
|
|
|
+ <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>
|
|
|
<tr><th>验收锚点</th><td class="mut">报警 ${s.anchors.alarms??'—'} 行 · 工单 ${s.anchors.workorders??'—'} · temp_monthly ${s.anchors.temp_monthly??'—'} · 本体 ${s.anchors.objects??'—'} 对象</td></tr>
|
|
<tr><th>验收锚点</th><td class="mut">报警 ${s.anchors.alarms??'—'} 行 · 工单 ${s.anchors.workorders??'—'} · temp_monthly ${s.anchors.temp_monthly??'—'} · 本体 ${s.anchors.objects??'—'} 对象</td></tr>
|
|
|
- </table>`;
|
|
|
|
|
|
|
+ </table>`);
|
|
|
const b = s.buttons, run = s.job && s.job.status==='running';
|
|
const b = s.buttons, run = s.job && s.job.status==='running';
|
|
|
- $('#b_start').disabled = !b.start_services;
|
|
|
|
|
- $('#b_stop').disabled = !b.stop_services;
|
|
|
|
|
- $('#b_restart').disabled = !b.stop_services;
|
|
|
|
|
- $('#b_rebuild').disabled = !b.rebuild;
|
|
|
|
|
- $('#b_off').disabled = !b.products_off;
|
|
|
|
|
- $('#b_on').disabled = !b.products_on;
|
|
|
|
|
- $('#svc_hint').textContent = b.start_services ? '有服务未运行 → 可启动。' : '全部组件服务已在运行 → 启动按钮已禁用。';
|
|
|
|
|
- $('#rebuild_hint').textContent = b.rebuild ? '' : '(有任务在跑, 重算按钮已禁用)';
|
|
|
|
|
- $('#busy').innerHTML = run ? `<div class="busy">正在执行: <b>${s.job.kind}</b>(起于 ${s.job.started})${s.job.note?' — '+s.job.note:''} · 完成后本页自动刷新</div>` : '';
|
|
|
|
|
|
|
+ set('#b_start', e=>e.disabled = !b.start_services);
|
|
|
|
|
+ set('#b_stop', e=>e.disabled = !b.stop_services);
|
|
|
|
|
+ set('#b_restart', e=>e.disabled = !b.stop_services);
|
|
|
|
|
+ set('#b_rebuild', e=>e.disabled = !b.rebuild);
|
|
|
|
|
+ set('#b_off', e=>e.disabled = !b.products_off);
|
|
|
|
|
+ set('#svc_hint', e=>e.textContent = b.start_services ? '有服务未运行 → 可启动。' : '全部组件服务已在运行 → 启动按钮已禁用。');
|
|
|
|
|
+ set('#rebuild_hint', e=>e.textContent = b.rebuild ? '' : '(有任务在跑, 重算按钮已禁用)');
|
|
|
|
|
+ set('#busy', e=>e.innerHTML = run ? `<div class="busy">正在执行: <b>${s.job.kind}</b>(起于 ${s.job.started})${s.job.note?' — '+s.job.note:''} · 完成后本页自动刷新</div>` : '');
|
|
|
const j = s.job;
|
|
const j = s.job;
|
|
|
- $('#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>':''}` : '(无)';
|
|
|
|
|
- $('#log').textContent = (j && j.log_tail && j.log_tail.length) ? j.log_tail.join('\n') : '';
|
|
|
|
|
|
|
+ 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>':''}` : '(无)');
|
|
|
|
|
+ set('#log', e=>e.textContent = (j && j.log_tail && j.log_tail.length) ? j.log_tail.join('\n') : '');
|
|
|
}
|
|
}
|
|
|
async function refresh(){ const [c,d] = await api('/ops/api/state'); if(c===200){ S=d; render(); } }
|
|
async function refresh(){ const [c,d] = await api('/ops/api/state'); if(c===200){ S=d; render(); } }
|
|
|
async function fire(name, body){
|
|
async function fire(name, body){
|
|
@@ -465,12 +478,22 @@ async function fire(name, body){
|
|
|
else { if(d.note) console.log(d.note); }
|
|
else { if(d.note) console.log(d.note); }
|
|
|
await refresh();
|
|
await refresh();
|
|
|
}
|
|
}
|
|
|
-$('#b_start').onclick = ()=>fire('start_services', {open_browser:true});
|
|
|
|
|
-$('#b_stop').onclick = ()=>fire('stop_services', {include_gateway:false});
|
|
|
|
|
-$('#b_restart').onclick = ()=>{ if(confirm('完整重启会连网关一起停, 本页会断开约 15 秒后自动恢复。继续?')) fire('stop_services', {include_gateway:true}); };
|
|
|
|
|
-$('#b_rebuild').onclick = ()=>{ if(confirm('开始重算? 期间服务会被重启, 页面可能短暂打不开。')) fire('rebuild', {skip_scada: !$('#f_scada').checked, with_verify: $('#f_verify').checked}); };
|
|
|
|
|
-$('#b_off').onclick = ()=>{ if(confirm('清除产物(挪到 _products_off,可恢复)?')) fire('products_off', {}); };
|
|
|
|
|
-$('#b_on').onclick = ()=>{ if(confirm('恢复产物?')) fire('products_on', {}); };
|
|
|
|
|
-refresh(); setInterval(refresh, 2000);
|
|
|
|
|
|
|
+/* on: 与 set 同理 —— 内嵌版没有"服务"卡, 直接 $('#b_start').onclick=… 会抛 TypeError,
|
|
|
|
|
+ 而**这一抛会让后面所有绑定与 refresh() 全不执行** (2026-09-16 用户实测: 门户 #recalc 页里
|
|
|
|
|
+ "执行重算 / 清除产物"点了没反应)。容错绑定 + 下面 try/catch 兜底 = 同类问题不再静默。 */
|
|
|
|
|
+function on(sel, fn){ const e = $(sel); if(e) e.onclick = fn; }
|
|
|
|
|
+try {
|
|
|
|
|
+ on('#b_start', ()=>fire('start_services', {open_browser:true}));
|
|
|
|
|
+ on('#b_stop', ()=>fire('stop_services', {include_gateway:false}));
|
|
|
|
|
+ on('#b_restart', ()=>{ if(confirm('完整重启会连网关一起停, 本页会断开约 15 秒后自动恢复。继续?')) fire('stop_services', {include_gateway:true}); });
|
|
|
|
|
+ on('#b_rebuild', ()=>{ if(confirm('开始重算? 期间服务会被重启, 页面可能短暂打不开。')) fire('rebuild', {skip_scada: !$('#f_scada').checked, with_verify: $('#f_verify').checked}); });
|
|
|
|
|
+ on('#b_off', ()=>{ if(confirm('清除产物 = **直接删除, 不留备份, 不可恢复**。继续?')) fire('products_off', {}); });
|
|
|
|
|
+ refresh(); setInterval(refresh, 2000);
|
|
|
|
|
+} catch (err) {
|
|
|
|
|
+ /* 界面脚本自己坏了也要看得见 (否则表现就是"按钮点了没反应", 排查全靠猜) */
|
|
|
|
|
+ const b = document.getElementById('busy');
|
|
|
|
|
+ if (b) b.innerHTML = '<div class="busy">⚠ 本页脚本出错, 按钮可能不可用: ' + err + ' (请反馈该行)</div>';
|
|
|
|
|
+ else alert('本页脚本出错: ' + err);
|
|
|
|
|
+}
|
|
|
</script></body></html>
|
|
</script></body></html>
|
|
|
"""
|
|
"""
|