From 07c9e200d02651c12136dbc09737a0972175f4e8 Mon Sep 17 00:00:00 2001 From: OoO Date: Tue, 5 May 2026 15:04:21 +0800 Subject: [PATCH] test(observability): add UI regression guard --- docs/guides/observability_ui_governance.md | 18 +++ scripts/check_observability_ui.py | 151 +++++++++++++++++++++ static/css/observability-system.css | 2 + 3 files changed, 171 insertions(+) create mode 100644 scripts/check_observability_ui.py diff --git a/docs/guides/observability_ui_governance.md b/docs/guides/observability_ui_governance.md index 2a88e9c..a699b6f 100644 --- a/docs/guides/observability_ui_governance.md +++ b/docs/guides/observability_ui_governance.md @@ -36,6 +36,24 @@ AI 觀測台是「AI 中樞控制室」,不是 Bootstrap 報表頁。任何新 ## 驗收指令 +### 1. Repo 靜態 UI guard + +先跑本地 guard,避免把已知 UI/UX 回歸重新帶進 repo: + +```bash +python3 scripts/check_observability_ui.py +``` + +Guard 會檢查: + +- Times / Georgia 等非規範標題字型。 +- SQL/Jinja exception 文案外露。 +- 圖表 `style="height:..."` 與靜態 `style="width:..."`。 +- 過大標題 clamp 與純白 hover/card surface。 +- 觀測台 CSS 必要 token / utility class 是否仍存在。 + +### 2. Production 10 頁 HTTP 巡檢 + ```bash python3 - <<'PY' import urllib.request diff --git a/scripts/check_observability_ui.py b/scripts/check_observability_ui.py new file mode 100644 index 0000000..a716e7d --- /dev/null +++ b/scripts/check_observability_ui.py @@ -0,0 +1,151 @@ +#!/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 + + +ROOT = Path(__file__).resolve().parents[1] + +TEMPLATE_PATHS = [ + "templates/admin/observability_overview.html", + "templates/admin/agent_orchestration.html", + "templates/admin/business_intel.html", + "templates/admin/host_health.html", + "templates/admin/ai_calls_dashboard.html", + "templates/admin/budget.html", + "templates/admin/promotion_review.html", + "templates/admin/rag_queries.html", + "templates/admin/quality_trend.html", + "templates/admin/ppt_audit_history.html", +] + +CSS_PATH = Path("static/css/observability-system.css") + + +@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+\")" + ), + "不得把 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", +] + + +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}`") + + 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()) + + 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)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/static/css/observability-system.css b/static/css/observability-system.css index 1390c0c..140b5b0 100644 --- a/static/css/observability-system.css +++ b/static/css/observability-system.css @@ -259,6 +259,7 @@ .momo-observability-mode .rag-panel, .momo-observability-mode .quality-panel, .momo-observability-mode .ppt-panel, +.momo-observability-mode .obs-table-shell, .momo-observability-mode [class$="-table-shell"] { position: relative; border: 1px solid var(--obs-line) !important; @@ -453,6 +454,7 @@ } /* Data surface polish: tables, matrices, lists */ +.momo-observability-mode .obs-table-shell, .momo-observability-mode [class$="-table-shell"], .momo-observability-mode .table-responsive { background: