fix(ui): 修复F12-F16入口与追溯缺陷
build / build (push) Successful in 1m59s

Dockerfile 运行阶段补齐 dws_dingtalk_scan(F12),并新增 runtime-scripts 门禁
做脚本依赖闭包正负例;就绪探测每次解析当前端点/绑定/凭据并区分 404、未配置、
不可达(F13);路由写入来源路径与父目录,历史空值仅按同连接同 item 的意图读时
补齐(F14);目标筛选显示短 ID 与当前绑定/历史并提供快捷筛选(F15);刷新终态
移除后台运行、展示失败时间/旧快照/脱敏原因/作业 ID 与重试(F16)。

验证:make verify 17 项全过(identity 本地按设计 skipped);一次性 schema 的
Monitor 目录/路由集成用例通过;8088 部署与真实 UI 验收仍由父会话执行。
This commit is contained in:
姚章浩
2026-11-05 10:38:26 +08:00
parent dfbf3336b2
commit 1c80b07f2c
29 changed files with 1436 additions and 75 deletions
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""运行镜像必须带齐脚本闭包:Dockerfile COPY 漏一个模块 ⇒ 线上入口必败。
F12 的真实现象是 live_catalog_refresh 能导入、psql 在、根身份正常,
但 `from dws_dingtalk_scan import walk` 抛 ModuleNotFoundError。只检查主脚本
自己的 import 不够:被依赖的模块也可能再 import 兄弟模块。这里按源码算出
闭包,再与 Dockerfile 实际 COPY 进 /app/scripts 的文件比对。
纯静态检查:不起容器、不连库、不需要凭据。
"""
from __future__ import annotations
import ast
import re
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCRIPTS = ROOT / "scripts"
APP_SCRIPTS = "/app/scripts"
def copied_scripts(dockerfile_text: str) -> set[str]:
"""Dockerfile 里 COPY 到 /app/scripts 的源文件名(含续行)。"""
logical = re.sub(r"\\\r?\n\s*", " ", dockerfile_text)
copied: set[str] = set()
for line in logical.splitlines():
parts = line.split()
targets = [part.rstrip("/") for part in parts]
if not parts or parts[0].upper() != "COPY" or APP_SCRIPTS not in targets:
continue
sources = parts[1:targets.index(APP_SCRIPTS)]
for source in sources:
name = Path(source).name
if name.endswith(".py"):
copied.add(name)
return copied
def local_imports(path: Path, siblings: set[str]) -> set[str]:
"""该脚本 import 的同目录模块(只认 scripts/ 下真实存在的兄弟文件)。"""
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
found: set[str] = set()
for node in ast.walk(tree):
names: list[str] = []
if isinstance(node, ast.Import):
names = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
names = [node.module]
for name in names:
top = name.split(".")[0]
if f"{top}.py" in siblings:
found.add(f"{top}.py")
return found
def dependency_closure(scripts_dir: Path, copied: set[str]) -> set[str]:
siblings = {entry.name for entry in scripts_dir.glob("*.py")}
needed = {name for name in copied if (scripts_dir / name).is_file()}
pending = list(needed)
while pending:
current = pending.pop()
for dependency in local_imports(scripts_dir / current, siblings) - needed:
needed.add(dependency)
pending.append(dependency)
return needed
def missing_from_image(scripts_dir: Path, dockerfile: Path) -> list[str]:
copied = copied_scripts(dockerfile.read_text(encoding="utf-8"))
return sorted(dependency_closure(scripts_dir, copied) - copied)
class RuntimeScriptPackagingTests(unittest.TestCase):
def test_worker_image_ships_every_local_dependency(self):
missing = missing_from_image(SCRIPTS, ROOT / "Dockerfile")
# RED 证据:F12 就是这一行缺失(dws_dingtalk_scan.py),不是宿主环境问题。
self.assertEqual(missing, [], f"运行镜像缺少脚本依赖:{missing}")
def test_missing_module_negative_fixture_is_rejected(self):
"""去掉扫描模块的 COPY 行必须被判红——证明这个断言真的会因为漏拷而失败。"""
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
broken = dockerfile.replace(" scripts/dws_dingtalk_scan.py", "")
self.assertIn("scripts/live_catalog_refresh.py", broken)
self.assertNotIn("dws_dingtalk_scan.py", broken)
with TemporaryDockerfile(broken) as path:
self.assertEqual(missing_from_image(SCRIPTS, path), ["dws_dingtalk_scan.py"])
def test_third_party_and_runtime_paths_are_not_counted_as_shipped(self):
siblings = {entry.name for entry in SCRIPTS.glob("*.py")}
imported = local_imports(SCRIPTS / "artifact_fetch.py", siblings)
self.assertTrue({"axls_to_xlsx.py", "dws_error_map.py"} <= imported)
self.assertNotIn("requests.py", siblings)
class TemporaryDockerfile:
def __init__(self, content: str):
self.content = content
self.path = SCRIPTS / ".test-dockerfile-missing-module"
def __enter__(self) -> Path:
self.path.write_text(self.content, encoding="utf-8")
return self.path
def __exit__(self, *_exc: object) -> None:
self.path.unlink(missing_ok=True)
if __name__ == "__main__":
unittest.main(verbosity=2)