Files
ewoooc/scripts/check_observability_ui.py
OoO cdcbcf1d80
All checks were successful
CD Pipeline / deploy (push) Successful in 1m33s
chore(observability): centralize QA page contract
2026-05-05 22:19:25 +08:00

321 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Guardrail checks for AI observability UI templates.
This script is intentionally lightweight: it scans the observability templates
and shared CSS for regression signals that previously hurt the war-room UI,
including unreadable typography, raw SQL errors, oversized titles, and inline
chart sizing.
"""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from observability_contract import OBSERVABILITY_PAGES
ROOT = Path(__file__).resolve().parents[1]
TEMPLATE_PATHS = [page.template for page in OBSERVABILITY_PAGES]
CSS_PATH = Path("static/css/observability-system.css")
WEB_CSS_PATH = Path("web/static/css/observability-system.css")
SHELL_PATH = Path("templates/components/_ewoooc_shell.html")
BASE_PATH = Path("templates/ewoooc_base.html")
ROUTE_PATH = Path("routes/admin_observability_routes.py")
@dataclass(frozen=True)
class Rule:
code: str
pattern: re.Pattern[str]
message: str
TEMPLATE_RULES = [
Rule(
"legacy_serif_font",
re.compile(r"\b(Georgia|Times New Roman)\b", re.IGNORECASE),
"觀測台標題不得回退到 Times/Georgia使用 Noto Sans TC / Inter 與 CSS token。",
),
Rule(
"raw_error_copy",
re.compile(
r"(查詢失敗:|ProgrammingError|UndefinedError|Traceback|Internal Server Error|relation\s+"|relation\s+\"|alert\(['\"]Error[:]|載入錯誤:\$\{e\}|unknown)"
),
"不得把 SQL/Jinja exception 或 raw failure 文案直接顯示給使用者。",
),
Rule(
"inline_height",
re.compile(r"style=\"[^\"]*height\s*:", re.IGNORECASE),
"圖表/區塊高度請使用 .obs-chart-frame 系列或 CSS class不要 inline height。",
),
Rule(
"static_inline_width",
re.compile(r"style=\"(?=[^\"]*width\s*:)(?![^\"]*\{\{)[^\"]*\"", re.IGNORECASE),
"靜態寬度請移到 CSS class僅允許 progress 類動態 width={{ ... }}。",
),
Rule(
"oversized_title",
re.compile(r"font-size:\s*clamp\([^;]*(3\.8rem|4\.45rem)", re.IGNORECASE),
"頁面大標不得使用過大 clamp使用 --obs-title-size token。",
),
Rule(
"pure_white_surface",
re.compile(r"background\s*:\s*#fff\s*;", re.IGNORECASE),
"觀測台 hover/card surface 不用純白;使用暖色 paper/surface token。",
),
Rule(
"wrong_business_active_page",
re.compile(r"active_page\s*=\s*['\"]obs_business['\"]"),
"商業戰情頁 active_page 必須是 obs_business_intel否則觀測台 CSS 不會載入。",
),
]
REQUIRED_CSS_SNIPPETS = [
"--obs-title-size",
"--obs-value-size",
".momo-observability-mode",
".obs-chart-frame",
".obs-chart-frame-tall",
".obs-chart-frame-slim",
".obs-modal-preview",
".obs-progress-xs",
".obs-table-shell",
]
REQUIRED_SHELL_SNIPPETS = [
"AI 中樞",
"AI 觀測台",
"戰情室",
"系統與成本",
"RAG 與品質",
"momo-nav-tree",
"momo-nav-subtree",
"momo-nav-subtitle",
"momo-nav-sublink",
"rgba(255, 248, 238, 0.72)",
"linear-gradient(180deg",
]
FORBIDDEN_SHELL_PATTERNS = [
Rule(
"pure_black_sidebar",
re.compile(r"\.momo-sidebar\s*\{[^}]*background\s*:\s*(#000|black)\b", re.IGNORECASE | re.DOTALL),
"側欄不得回退成純黑背景;必須維持暖深咖啡漸層。",
),
Rule(
"low_contrast_sublink",
re.compile(r"\.momo-nav-sublink\s*\{[^}]*color\s*:\s*rgba\([^)]*,\s*0\.(?:[0-5][0-9]|60)\)", re.IGNORECASE | re.DOTALL),
"第二/三層導覽文字透明度過低,會看不清楚;需維持足夠對比。",
),
]
REQUIRED_BASE_SNIPPETS = [
"observability-system.css",
"momo-observability-mode",
"/observability/overview",
"/observability/api/health_indicator",
"momo-obs-link",
"momo-obs-badge",
"classList.add('is-alert')",
]
FORBIDDEN_BASE_PATTERNS = [
Rule(
"inline_obs_badge_style",
re.compile(r"id=\"momo-obs-badge\"[^>]*style=", re.IGNORECASE | re.DOTALL),
"Topbar 觀測台 badge 不得使用 inline style請使用 .momo-obs-badge。",
),
Rule(
"hardcoded_obs_alert_color",
re.compile(r"(#dc3545|link\.style\.color|badge\.style\.display)", re.IGNORECASE),
"Topbar 觀測台告警狀態不得用 JS/inline 硬改色;請切換 is-alert / hidden。",
),
]
def line_number(text: str, index: int) -> int:
return text.count("\n", 0, index) + 1
def scan_file(relative_path: Path) -> list[str]:
path = ROOT / relative_path
if not path.exists():
return [f"{relative_path}: missing required observability file"]
text = path.read_text(encoding="utf-8")
findings: list[str] = []
for rule in TEMPLATE_RULES:
for match in rule.pattern.finditer(text):
line = line_number(text, match.start())
snippet = match.group(0).replace("\n", " ")[:90]
findings.append(
f"{relative_path}:{line}: [{rule.code}] {rule.message} ({snippet})"
)
return findings
def scan_css() -> list[str]:
path = ROOT / CSS_PATH
if not path.exists():
return [f"{CSS_PATH}: missing required observability CSS"]
text = path.read_text(encoding="utf-8")
findings: list[str] = []
for snippet in REQUIRED_CSS_SNIPPETS:
if snippet not in text:
findings.append(f"{CSS_PATH}: missing required token/class `{snippet}`")
web_path = ROOT / WEB_CSS_PATH
if not web_path.exists():
findings.append(
f"{WEB_CSS_PATH}: missing mirrored CSS served by Flask static route"
)
else:
web_text = web_path.read_text(encoding="utf-8")
if web_text != text:
findings.append(
f"{WEB_CSS_PATH}: must match {CSS_PATH} so production /static CSS is in sync; run `python3 scripts/sync_observability_css.py`"
)
return findings
def scan_required_snippets(relative_path: Path, snippets: list[str], label: str) -> list[str]:
path = ROOT / relative_path
if not path.exists():
return [f"{relative_path}: missing required {label} file"]
text = path.read_text(encoding="utf-8")
findings: list[str] = []
for snippet in snippets:
if snippet not in text:
findings.append(f"{relative_path}: missing required {label} snippet `{snippet}`")
return findings
def scan_base_topbar() -> list[str]:
path = ROOT / BASE_PATH
if not path.exists():
return [f"{BASE_PATH}: missing required base/topbar file"]
text = path.read_text(encoding="utf-8")
findings = scan_required_snippets(BASE_PATH, REQUIRED_BASE_SNIPPETS, "base/topbar")
for rule in FORBIDDEN_BASE_PATTERNS:
for match in rule.pattern.finditer(text):
line = line_number(text, match.start())
snippet = match.group(0).replace("\n", " ")[:90]
findings.append(
f"{BASE_PATH}:{line}: [{rule.code}] {rule.message} ({snippet})"
)
return findings
def scan_shell() -> list[str]:
path = ROOT / SHELL_PATH
if not path.exists():
return [f"{SHELL_PATH}: missing required shell file"]
text = path.read_text(encoding="utf-8")
findings = scan_required_snippets(SHELL_PATH, REQUIRED_SHELL_SNIPPETS, "sidebar/nav")
for rule in FORBIDDEN_SHELL_PATTERNS:
for match in rule.pattern.finditer(text):
line = line_number(text, match.start())
snippet = match.group(0).replace("\n", " ")[:90]
findings.append(
f"{SHELL_PATH}:{line}: [{rule.code}] {rule.message} ({snippet})"
)
return findings
def scan_nav_contract() -> list[str]:
shell_path = ROOT / SHELL_PATH
base_path = ROOT / BASE_PATH
route_path = ROOT / ROUTE_PATH
if not shell_path.exists() or not base_path.exists():
return []
shell_text = shell_path.read_text(encoding="utf-8")
base_text = base_path.read_text(encoding="utf-8")
route_text = route_path.read_text(encoding="utf-8") if route_path.exists() else ""
findings: list[str] = []
for page in OBSERVABILITY_PAGES:
template = ROOT / page.template
if not template.exists():
findings.append(f"{page.template}: missing observability template for `{page.label}`")
continue
template_text = template.read_text(encoding="utf-8")
active_pattern = re.compile(
r"set\s+active_page\s*=\s*['\"]" + re.escape(page.active_page) + r"['\"]"
)
route_has_contract = (
page.template.replace("templates/", "") in route_text
and page.active_page in route_text
)
if not active_pattern.search(template_text) and not route_has_contract:
findings.append(
f"{page.template}: active_page must be `{page.active_page}` in template or route for `{page.label}`"
)
if page.active_page not in shell_text:
findings.append(f"{SHELL_PATH}: sidebar missing active_page `{page.active_page}`")
if page.url not in shell_text:
findings.append(f"{SHELL_PATH}: sidebar missing URL `{page.url}` for `{page.label}`")
if page.label not in shell_text:
findings.append(f"{SHELL_PATH}: sidebar missing label `{page.label}`")
if page.active_page not in base_text:
findings.append(f"{BASE_PATH}: momo-observability-mode list missing `{page.active_page}`")
route_pattern = re.compile(
r"@admin_observability_bp\.route\(\s*['\"]"
+ re.escape(page.route_suffix)
+ r"['\"]"
)
if route_text and not route_pattern.search(route_text):
findings.append(f"{ROUTE_PATH}: missing route `{page.route_suffix}` for `{page.label}`")
sidebar_observability_links = set(re.findall(r'href="(/observability/[^"]+)"', shell_text))
expected_links = {page.url for page in OBSERVABILITY_PAGES}
extra_links = sorted(sidebar_observability_links - expected_links)
missing_links = sorted(expected_links - sidebar_observability_links)
for url in extra_links:
findings.append(f"{SHELL_PATH}: unexpected observability sidebar URL `{url}`")
for url in missing_links:
findings.append(f"{SHELL_PATH}: missing observability sidebar URL `{url}`")
return findings
def main() -> int:
findings: list[str] = []
for template_path in TEMPLATE_PATHS:
findings.extend(scan_file(Path(template_path)))
findings.extend(scan_css())
findings.extend(scan_shell())
findings.extend(scan_base_topbar())
findings.extend(scan_nav_contract())
if findings:
print("Observability UI guard: FAIL")
for finding in findings:
print(f"- {finding}")
return 1
print("Observability UI guard: PASS")
print(f"- templates checked: {len(TEMPLATE_PATHS)}")
print(f"- css guardrails checked: {len(REQUIRED_CSS_SNIPPETS)}")
print(f"- sidebar/nav guardrails checked: {len(REQUIRED_SHELL_SNIPPETS)}")
print(f"- base/topbar guardrails checked: {len(REQUIRED_BASE_SNIPPETS) + len(FORBIDDEN_BASE_PATTERNS)}")
print(f"- nav contract checked: {len(OBSERVABILITY_PAGES)} pages")
return 0
if __name__ == "__main__":
sys.exit(main())