From ad70d8fccc001429743a559227d7a5e51a91e9a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Mon, 14 Sep 2026 00:59:03 +0800 Subject: [PATCH 1/5] HEL-543: add data hub observability side-channel (provider status, source catalog, lineage) - New provider_call_log/provider_health tables (additive-only schema), wired via a fail-open observability.observe()/record_call() helper. - Tushare pipeline keeps its existing src_calls record unchanged and now also feeds the unified provider_health/provider_call_log side channel. - Eastmoney/Tencent realtime_serve.py call sites and the iFinD steward call site are wrapped with observability.observe() at the call site only; no adapter internals, routing, fallback order, or return values are touched. - New read-only admin API endpoints: /admin/api/providers/status, /admin/api/source-catalog, /admin/api/lineage, /admin/api/lineage/affected. - New static, read-only source_catalog.py and lineage.py registries documenting existing providers/interfaces/datasets and known main-site consumers (cited against backend/features/screener and backend/features/heaven call sites). - provider_call_log is purged by the existing pipeline.cleanup() job alongside src_calls/job_runs. - 47 new unit/integration tests covering classification, fail-open behavior under DB/log failures, unchanged payloads/exceptions on success and failure paths, and the new HTTP endpoints. Full suite: 173 tests, all green. Co-authored-by: Cursor Co-authored-by: multica-agent --- xiaobai-datahub/datahub/admin_api.py | 36 +++ xiaobai-datahub/datahub/db.py | 3 +- xiaobai-datahub/datahub/httpapp.py | 20 ++ xiaobai-datahub/datahub/lineage.py | 258 +++++++++++++++ xiaobai-datahub/datahub/observability.py | 300 ++++++++++++++++++ xiaobai-datahub/datahub/pipeline.py | 14 + xiaobai-datahub/datahub/realtime_serve.py | 69 +++- xiaobai-datahub/datahub/source_catalog.py | 165 ++++++++++ xiaobai-datahub/datahub/steward.py | 7 +- .../tests/test_admin_observability_api.py | 145 +++++++++ .../tests/test_ifind_observability.py | 105 ++++++ xiaobai-datahub/tests/test_observability.py | 214 +++++++++++++ .../tests/test_pipeline_observability.py | 82 +++++ .../tests/test_realtime_observability.py | 163 ++++++++++ 14 files changed, 1568 insertions(+), 13 deletions(-) create mode 100644 xiaobai-datahub/datahub/lineage.py create mode 100644 xiaobai-datahub/datahub/observability.py create mode 100644 xiaobai-datahub/datahub/source_catalog.py create mode 100644 xiaobai-datahub/tests/test_admin_observability_api.py create mode 100644 xiaobai-datahub/tests/test_ifind_observability.py create mode 100644 xiaobai-datahub/tests/test_observability.py create mode 100644 xiaobai-datahub/tests/test_pipeline_observability.py create mode 100644 xiaobai-datahub/tests/test_realtime_observability.py diff --git a/xiaobai-datahub/datahub/admin_api.py b/xiaobai-datahub/datahub/admin_api.py index c5ab3f2..01a488c 100644 --- a/xiaobai-datahub/datahub/admin_api.py +++ b/xiaobai-datahub/datahub/admin_api.py @@ -6,9 +6,11 @@ from typing import Any from datahub.adapters import RESERVED from datahub.auth import AuthService from datahub.db import HubDB +from datahub import lineage as lineage_module from datahub.pipeline import OFFICIAL_DATASETS, STOCKS_DATASET, Pipeline from datahub.scheduler import Scheduler from datahub.serving import ApiError +from datahub import source_catalog from datahub.timeutil import isoformat, now_shanghai, session_phase, yyyymmdd @@ -105,6 +107,40 @@ class AdminAPI: raise ApiError("INVALID_ARGUMENT", f"unknown provider: {provider}") return adapter.probe() + # ------------------------------------------------------------------ + # HEL-543: read-only side-channel status/catalog/lineage. These never + # change routing, credentials, or adapters; they only read the + # provider_call_log/provider_health tables (observability.py) plus the + # static registries in source_catalog.py / lineage.py. + # ------------------------------------------------------------------ + def providers_status(self, provider: str = "", limit: int = 50) -> dict[str, Any]: + limit = max(1, min(int(limit or 50), 200)) + health_sql = "SELECT * FROM provider_health" + params: tuple[Any, ...] = () + if provider: + health_sql += " WHERE provider = ?" + params = (provider,) + health_sql += " ORDER BY provider, interface" + health = self.db.fetchall(health_sql, params) + calls_sql = "SELECT * FROM provider_call_log" + if provider: + calls_sql += " WHERE provider = ?" + calls_sql += " ORDER BY id DESC LIMIT ?" + recent = self.db.fetchall(calls_sql, (*params, limit)) + return {"health": health, "recent_calls": recent} + + def source_catalog(self) -> dict[str, Any]: + return {"items": source_catalog.snapshot(self.db, self.auth)} + + def lineage(self, trade_date: str = "") -> dict[str, Any]: + day = yyyymmdd(trade_date) if trade_date else yyyymmdd(now_shanghai()) + return {"trade_date": day, "items": lineage_module.snapshot(self.db, day)} + + def lineage_affected(self, provider: str = "", interface: str = "") -> dict[str, Any]: + if not provider: + raise ApiError("INVALID_ARGUMENT", "provider is required") + return {"provider": provider, "interface": interface, "items": lineage_module.affected(self.db, provider, interface)} + def jobs(self) -> dict[str, Any]: runs = self.db.fetchall("SELECT * FROM job_runs ORDER BY id DESC LIMIT 100") stocks_times = "/".join(self.pipeline.settings.stocks_refresh_times) or "20:00" diff --git a/xiaobai-datahub/datahub/db.py b/xiaobai-datahub/datahub/db.py index e7bdb28..c23d3f2 100644 --- a/xiaobai-datahub/datahub/db.py +++ b/xiaobai-datahub/datahub/db.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any from datahub.datasets_ext import EXTENDED_DATASET_TABLES, EXTENDED_SCHEMA +from datahub.observability import OBS_SCHEMA from datahub.timeutil import isoformat _BASE_SCHEMA = """ @@ -296,7 +297,7 @@ CREATE INDEX IF NOT EXISTS idx_eod_bars_date ON eod_bars(trade_date, batch_id); CREATE INDEX IF NOT EXISTS idx_calendar_open ON trade_calendar(is_open, cal_date); """ -SCHEMA = _BASE_SCHEMA + EXTENDED_SCHEMA +SCHEMA = _BASE_SCHEMA + EXTENDED_SCHEMA + OBS_SCHEMA DATASET_TABLES = { "daily": ("eod_bars", "staging_bars"), diff --git a/xiaobai-datahub/datahub/httpapp.py b/xiaobai-datahub/datahub/httpapp.py index 8540b22..7685dd1 100644 --- a/xiaobai-datahub/datahub/httpapp.py +++ b/xiaobai-datahub/datahub/httpapp.py @@ -122,6 +122,26 @@ class HubRequestHandler(BaseHTTPRequestHandler): if path == "/admin/api/sources" and method == "GET": self._json(self.hub.admin.sources(), HTTPStatus.OK) return + if path == "/admin/api/providers/status" and method == "GET": + query = parse_query(urlparse(self.path).query) + provider = (query.get("provider") or [""])[0] + limit = (query.get("limit") or ["50"])[0] + self._json(self.hub.admin.providers_status(provider, int(limit or 50)), HTTPStatus.OK) + return + if path == "/admin/api/source-catalog" and method == "GET": + self._json(self.hub.admin.source_catalog(), HTTPStatus.OK) + return + if path == "/admin/api/lineage" and method == "GET": + query = parse_query(urlparse(self.path).query) + date = (query.get("date") or [""])[0] + self._json(self.hub.admin.lineage(date), HTTPStatus.OK) + return + if path == "/admin/api/lineage/affected" and method == "GET": + query = parse_query(urlparse(self.path).query) + provider = (query.get("provider") or [""])[0] + interface = (query.get("interface") or [""])[0] + self._json(self.hub.admin.lineage_affected(provider, interface), HTTPStatus.OK) + return if path.startswith("/admin/api/sources/") and path.endswith("/probe") and method == "POST": provider = path.split("/")[4] self._json(self.hub.admin.probe(provider), HTTPStatus.OK) diff --git a/xiaobai-datahub/datahub/lineage.py b/xiaobai-datahub/datahub/lineage.py new file mode 100644 index 0000000..cd1cc9e --- /dev/null +++ b/xiaobai-datahub/datahub/lineage.py @@ -0,0 +1,258 @@ +"""Read-only lineage/impact inventory (HEL-543). + +Answers, without changing any routing decision: for a given main-site data +item, which datahub dataset backs it, which provider/interface currently +serves it (primary and backup), and — when a provider/interface is +unhealthy — which datasets and, best-effort, which main-site consumers are +affected. + +Every row cites where it was verified so a reviewer does not have to trust +a paraphrase: + +- ``v1_endpoint``/``primary_source``/``backup_source`` are taken verbatim + from ``datahub/serving.py`` (the ``source=`` string literal passed to + ``_published_rows``/``_official_meta``) or from the provider/interface + pairs wired into ``datahub/realtime_serve.py`` for HEL-543. +- ``known_consumers`` lists only call sites this round actually found via + code search in the ``xiaobai-review`` website tree (cited as + ``file:line`` in the comment above each dataset). Anything not backed by + a citation is left out rather than guessed; a fuller page-by-page map is + tracked separately (HEL-549) and can extend this table later without + touching its shape. + +This module never talks to a provider and never mutates anything; it only +reads ``provider_health``/``provider_call_log`` (HEL-543) and the existing +``publications``/``batches`` tables to attach live status to each row. +""" + +from __future__ import annotations + +from typing import Any + +# Verified against backend/features/screener/data_sync.py (calendar, +# stock_basic, daily, daily_basic, index_daily-as-benchmark, stk_auction, +# moneyflow, ths_hot, dc_hot all called via `self.client.query(...)`) and +# backend/features/heaven/market_context.py (stock_basic, index_daily via +# `self._tushare_client().query(...)` / `client.query(...)`). +DATASETS: list[dict[str, Any]] = [ + { + "dataset": "calendar", + "tier": "official", + "v1_endpoint": "/v1/calendar", + "primary_source": "tushare:trade_cal", + "backup_source": None, + "known_consumers": ["智能选股(data_sync.py 交易日历解析)", "问天(交易日推算)"], + }, + { + "dataset": "stocks", + "tier": "official", + "v1_endpoint": "/v1/stocks", + "primary_source": "tushare:stock_basic", + "backup_source": None, + "known_consumers": ["智能选股(股票主档)", "问天(market_context.py 股票代码/名称解析)"], + }, + { + "dataset": "daily", + "tier": "official", + "v1_endpoint": "/v1/bars/daily", + "primary_source": "tushare:daily", + "backup_source": None, + "known_consumers": ["智能选股(data_sync.py 日K因子)", "交易复盘/个股详情日K图表"], + }, + { + "dataset": "valuation", + "tier": "official", + "v1_endpoint": "/v1/valuation", + "primary_source": "tushare:daily_basic", + "backup_source": None, + "known_consumers": ["智能选股(data_sync.py 估值因子)"], + }, + { + "dataset": "moneyflow", + "tier": "official", + "v1_endpoint": "/v1/moneyflow", + "primary_source": "tushare:moneyflow", + "backup_source": None, + "known_consumers": ["智能选股(data_sync.py 资金流因子)", "个股详情资金流"], + }, + { + "dataset": "auction", + "tier": "official", + "v1_endpoint": "/v1/auction", + "primary_source": "tushare:stk_auction", + "backup_source": None, + "known_consumers": ["智能选股(data_sync.py 竞价快照)", "竞价板块"], + }, + { + "dataset": "index_daily", + "tier": "official", + "v1_endpoint": "/v1/indexes/bars", + "primary_source": "tushare:index_daily", + "backup_source": None, + "known_consumers": ["问天(market_context.py 指数近20日走势)", "智能选股(基准回看)"], + }, + { + "dataset": "limit_events", + "tier": "official", + "v1_endpoint": "/v1/limit-events", + "primary_source": "tushare:limit_list_d", + "backup_source": None, + "known_consumers": ["涨停梯队(历史/盘后视图)"], + }, + { + "dataset": "popularity", + "tier": "official", + "v1_endpoint": "/v1/popularity", + "primary_source": "tushare:ths_hot+dc_hot", + "backup_source": None, + "known_consumers": ["人气榜", "智能选股(data_sync.py 人气因子)"], + }, + { + "dataset": "dragon_tiger", + "tier": "official", + "v1_endpoint": "/v1/dragon-tiger", + "primary_source": "tushare:hm_detail", + "backup_source": None, + "known_consumers": ["龙虎榜"], + }, + { + "dataset": "sector_daily", + "tier": "official", + "v1_endpoint": "/v1/sectors", + "primary_source": "tushare:ths_daily+dc_index+sw_daily", + "backup_source": None, + "known_consumers": ["主题轮动", "板块梯队"], + }, + { + "dataset": "quotes_latest", + "tier": "provisional", + "v1_endpoint": "/v1/quotes/latest", + "primary_source": "eastmoney:ulist/clist", + "backup_source": "tencent:qt", + "known_consumers": ["竞价/股票池盘中价格", "情绪周期盘中快照"], + }, + { + "dataset": "index_quotes", + "tier": "provisional", + "v1_endpoint": "/v1/indexes/quotes", + "primary_source": "eastmoney:ulist", + "backup_source": "tencent:qt", + "known_consumers": ["首页大盘指数条"], + }, + { + "dataset": "sectors_quote", + "tier": "provisional", + "v1_endpoint": "/v1/sectors/quote", + "primary_source": "eastmoney:sw", + "backup_source": None, + "known_consumers": ["主题轮动盘中板块报价"], + }, + { + "dataset": "limit_pool", + "tier": "provisional", + "v1_endpoint": "/v1/limit-pool", + "primary_source": "eastmoney:zt_pool", + "backup_source": None, + "known_consumers": ["涨停梯队盘中视图"], + }, + { + "dataset": "intraday_points", + "tier": "provisional", + "v1_endpoint": "/v1/intraday/points", + "primary_source": "eastmoney:trends2", + "backup_source": None, + "known_consumers": ["个股详情分时图"], + }, + { + "dataset": "ifind_wencai", + "tier": "licensed", + "v1_endpoint": "/v1/query (api_name=ifind_wencai)", + "primary_source": "ifind:smart_stock_picking", + "backup_source": None, + "known_consumers": ["问师(自然语言选股,需 iFinD 凭证)"], + }, +] + +_KNOWN_PROVIDERS_BY_SOURCE_PREFIX = ("tushare", "eastmoney", "tencent", "ifind") + + +def _providers_for(primary_source: str, backup_source: str | None) -> list[str]: + providers: list[str] = [] + for source in (primary_source, backup_source or ""): + for provider in _KNOWN_PROVIDERS_BY_SOURCE_PREFIX: + if source.startswith(provider) and provider not in providers: + providers.append(provider) + return providers + + +def snapshot(db: Any, trade_date: str = "") -> list[dict[str, Any]]: + """Attach live status to the static lineage table. Read-only; never + raises (a per-row status lookup failure just leaves that row's status + empty rather than failing the whole snapshot).""" + result: list[dict[str, Any]] = [] + for entry in DATASETS: + row = dict(entry) + providers = _providers_for(entry["primary_source"], entry.get("backup_source")) + row["providers"] = providers + live: list[dict[str, Any]] = [] + try: + if db is not None and providers: + placeholders = ",".join("?" for _ in providers) + live = db.fetchall( + f"SELECT provider, interface, state, last_error, last_fallback_reason, " + f"consec_failures, updated_at FROM provider_health WHERE provider IN ({placeholders})", + tuple(providers), + ) + except Exception: + live = [] + row["live_provider_health"] = live + if entry["tier"] == "official": + pub = None + try: + if db is not None and trade_date: + pub = db.fetchone( + "SELECT dataset, trade_date, state, published_at FROM publications " + "WHERE dataset = ? AND trade_date = ?", + (entry["dataset"], trade_date), + ) + except Exception: + pub = None + row["publication"] = pub + result.append(row) + return result + + +def affected(db: Any, provider: str = "", interface: str = "") -> list[dict[str, Any]]: + """Read-only: which datasets/pages are impacted by a given provider (and, + optionally, a specific interface) right now. Does not change routing.""" + provider = str(provider or "").strip() + interface = str(interface or "").strip() + result: list[dict[str, Any]] = [] + for entry in DATASETS: + providers = _providers_for(entry["primary_source"], entry.get("backup_source")) + if provider and provider not in providers: + continue + row = dict(entry) + row["providers"] = providers + health: list[dict[str, Any]] = [] + try: + if db is not None: + if interface: + health = db.fetchall( + "SELECT provider, interface, state, last_error, last_fallback_reason, " + "consec_failures, updated_at FROM provider_health " + "WHERE provider = ? AND interface = ?", + (provider, interface), + ) + elif providers: + placeholders = ",".join("?" for _ in providers) + health = db.fetchall( + f"SELECT provider, interface, state, last_error, last_fallback_reason, " + f"consec_failures, updated_at FROM provider_health WHERE provider IN ({placeholders})", + tuple(providers), + ) + except Exception: + health = [] + row["live_provider_health"] = health + result.append(row) + return result diff --git a/xiaobai-datahub/datahub/observability.py b/xiaobai-datahub/datahub/observability.py new file mode 100644 index 0000000..395cdae --- /dev/null +++ b/xiaobai-datahub/datahub/observability.py @@ -0,0 +1,300 @@ +"""Side-channel provider-call observability (HEL-543). + +This module is additive-only and must never change what any existing call +returns or raises. It exists purely to answer, after the fact and without +touching routing: which provider/interface was called, whether it +succeeded, how stale/complete the payload looked, and why a fallback fired. + +Hard rules enforced here: + +- Every public entry point (`observe`, `record_call`) is wrapped so that a + database failure, a classifier bug, or any other internal error is + swallowed and logged at DEBUG level. It never raises into the caller and + never delays/blocks the caller's real data path beyond a best-effort + timing measurement. +- `observe()` always returns exactly what `fn()` returned, and re-raises + exactly what `fn()` raised (same exception object, unmodified). It does + not retry, does not change ordering, and does not add new failure modes. +- No mock data is ever produced or returned by this module. +""" + +from __future__ import annotations + +import re +import time +from typing import Any, Callable, TypeVar + +from datahub.timeutil import isoformat, now_shanghai + +_T = TypeVar("_T") + +# Additive-only schema: two new tables, no changes to any existing table. +# Merged into datahub.db.SCHEMA the same way EXTENDED_SCHEMA is. +OBS_SCHEMA = """ +CREATE TABLE IF NOT EXISTS provider_call_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + interface TEXT NOT NULL, + fetched_at TEXT NOT NULL, + latency_ms INTEGER, + status TEXT NOT NULL, + error TEXT, + fallback_reason TEXT, + data_age_seconds INTEGER, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS provider_health ( + provider TEXT NOT NULL, + interface TEXT NOT NULL, + state TEXT NOT NULL, + last_ok_at TEXT, + last_error TEXT, + last_fallback_reason TEXT, + consec_failures INTEGER NOT NULL DEFAULT 0, + last_latency_ms INTEGER, + last_data_age_seconds INTEGER, + updated_at TEXT NOT NULL, + PRIMARY KEY (provider, interface) +); + +CREATE INDEX IF NOT EXISTS idx_provider_call_log_created ON provider_call_log(created_at); +CREATE INDEX IF NOT EXISTS idx_provider_call_log_provider ON provider_call_log(provider, interface, created_at); +""" + +DEFAULT_STALE_SECONDS = 300 + +_BLOCKED_MARKERS = ( + " tuple[str, str]: + """Best-effort, side-reading classification of an exception message. + + Never raises. Unknown shapes fall back to a generic ``error`` status so a + classifier miss can never be mistaken for a healthy call. + """ + try: + lower = (message or "").lower() + if any(marker in lower for marker in _BLOCKED_MARKERS): + return "blocked", "response_looks_like_intercept_page" + if "timeout" in lower or "timed out" in lower: + return "timeout", "request_timeout" + mismatch = _COUNT_MISMATCH_RE.search(lower) + if mismatch and int(mismatch.group(1)) == 0: + return "empty", "empty_or_incomplete_response" + if mismatch: + return "degraded", "partial_or_mismatched_response" + if "empty" in lower or "no intraday chart data" in lower or "missing" in lower: + return "empty", "empty_or_incomplete_response" + if "too small" in lower or "incomplete" in lower or "mismatch" in lower: + return "degraded", "partial_or_mismatched_response" + return "error", "" + except Exception: # pragma: no cover - defensive, must never raise + return "error", "" + + +def classify_rows( + rows: Any, + *, + required_fields: tuple[str, ...] | None = None, + freshness_field: str | None = "quote_time_epoch", + max_age_seconds: int = DEFAULT_STALE_SECONDS, +) -> tuple[str, str, int | None]: + """Read-only classification of an already-successful payload. + + Only ever called on a value a caller is about to use as-is; this never + mutates ``rows`` and a classifier bug always degrades to ``("ok", "", + None)`` rather than mislabeling a real success as a failure. + """ + try: + if isinstance(rows, dict): + items = [rows] if rows else [] + elif isinstance(rows, (list, tuple)): + items = [item for item in rows if isinstance(item, dict)] + else: + items = [] + if not items: + return "empty", "no_rows_returned", None + if required_fields: + missing: set[str] = set() + for item in items: + for field in required_fields: + if item.get(field) in (None, ""): + missing.add(field) + if missing: + return "missing_fields", "missing:" + ",".join(sorted(missing)), None + data_age: int | None = None + if freshness_field: + now_epoch = time.time() + ages: list[int] = [] + for item in items: + raw = item.get(freshness_field) + try: + epoch = int(raw or 0) + except (TypeError, ValueError): + epoch = 0 + if epoch > 0: + ages.append(max(0, int(now_epoch - epoch))) + if ages: + data_age = max(ages) + if data_age > max_age_seconds: + return "stale", "data_age_exceeds_threshold", data_age + return "ok", "", data_age + except Exception: # pragma: no cover - defensive, must never raise + return "ok", "", None + + +def record_call( + db: Any, + provider: str, + interface: str, + *, + status: str, + latency_ms: int | None = None, + error: str = "", + fallback_reason: str = "", + data_age_seconds: int | None = None, +) -> None: + """Fail-open recorder. Never raises; a write failure here must never be + able to take down a real, otherwise-successful data path.""" + if db is None: + return + try: + now = isoformat(now_shanghai()) + ok = status == "ok" + error_text = (error or "")[:500] + reason_text = (fallback_reason or "")[:200] + with db.write() as connection: + connection.execute( + "INSERT INTO provider_call_log(" + "provider, interface, fetched_at, latency_ms, status, error, " + "fallback_reason, data_age_seconds, created_at) " + "VALUES (?,?,?,?,?,?,?,?,?)", + (provider, interface, now, latency_ms, status, error_text, reason_text, data_age_seconds, now), + ) + connection.execute( + """ + INSERT INTO provider_health( + provider, interface, state, last_ok_at, last_error, last_fallback_reason, + consec_failures, last_latency_ms, last_data_age_seconds, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(provider, interface) DO UPDATE SET + state = excluded.state, + last_ok_at = CASE WHEN excluded.state = 'ok' THEN excluded.last_ok_at ELSE provider_health.last_ok_at END, + last_error = CASE WHEN excluded.state = 'ok' THEN '' ELSE excluded.last_error END, + last_fallback_reason = excluded.last_fallback_reason, + consec_failures = CASE WHEN excluded.state = 'ok' THEN 0 ELSE provider_health.consec_failures + 1 END, + last_latency_ms = excluded.last_latency_ms, + last_data_age_seconds = excluded.last_data_age_seconds, + updated_at = excluded.updated_at + """, + ( + provider, + interface, + status, + now if ok else None, + "" if ok else (error_text or reason_text or "unknown_error"), + reason_text, + 0 if ok else 1, + latency_ms, + data_age_seconds, + now, + ), + ) + except Exception: # pragma: no cover - defensive, must never raise + try: + _logger().debug( + "observability record_call failed (fail-open)", + extra={"hub": {"provider": provider, "interface": interface}}, + exc_info=True, + ) + except Exception: + pass + + +def _safe_record_failure(db: Any, provider: str, interface: str, latency_ms: int, exc: BaseException) -> None: + if db is None: + return + try: + message = str(exc) + status, reason = classify_error(message) + record_call( + db, provider, interface, + status=status, latency_ms=latency_ms, error=message, fallback_reason=reason, + ) + except Exception: # pragma: no cover - defensive, must never raise + pass + + +def _safe_record_success( + db: Any, + provider: str, + interface: str, + latency_ms: int, + result: Any, + classify: Callable[[Any], tuple[str, str, int | None] | None] | None, +) -> None: + if db is None: + return + status, reason, data_age = "ok", "", None + if classify is not None: + try: + classified = classify(result) + if classified: + status, reason, data_age = classified + except Exception: # pragma: no cover - defensive, must never raise + # A classifier bug must never mislabel (or hide) a real success; + # degrade to a plain "ok" call rather than skipping the log. + status, reason, data_age = "ok", "", None + try: + record_call( + db, provider, interface, + status=status, latency_ms=latency_ms, fallback_reason=reason, data_age_seconds=data_age, + ) + except Exception: # pragma: no cover - defensive, must never raise + pass + + +def observe( + db: Any, + provider: str, + interface: str, + fn: Callable[[], _T], + *, + classify: Callable[[Any], tuple[str, str, int | None] | None] | None = None, +) -> _T: + """Call ``fn()`` and record a side-channel status row. + + Returns exactly what ``fn()`` returns and re-raises exactly what + ``fn()`` raises. ``db`` may be ``None`` (e.g. in call sites that are not + wired to a database yet); in that case this is a transparent passthrough + with no recording at all. + """ + started = time.perf_counter() + try: + result = fn() + except Exception: + latency_ms = round((time.perf_counter() - started) * 1000) + import sys + + exc = sys.exc_info()[1] + if exc is not None: + _safe_record_failure(db, provider, interface, latency_ms, exc) + raise + latency_ms = round((time.perf_counter() - started) * 1000) + _safe_record_success(db, provider, interface, latency_ms, result, classify) + return result diff --git a/xiaobai-datahub/datahub/pipeline.py b/xiaobai-datahub/datahub/pipeline.py index 808886c..d76f5e1 100644 --- a/xiaobai-datahub/datahub/pipeline.py +++ b/xiaobai-datahub/datahub/pipeline.py @@ -20,6 +20,7 @@ from datahub.governance.ratelimit import TokenBucket from datahub.governance.retry import RetryError, retry_call from datahub.logutil import get_logger from datahub.normalize import finite_number, normalize_daily +from datahub import observability from datahub.revision import ( compare_fields, diff_published_vs_upstream, @@ -1634,6 +1635,7 @@ class Pipeline: deleted += cur.rowcount connection.execute("DELETE FROM job_runs WHERE started_at < ?", (cutoff_jobs,)) connection.execute("DELETE FROM src_calls WHERE created_at < ?", (cutoff_jobs,)) + connection.execute("DELETE FROM provider_call_log WHERE created_at < ?", (cutoff_jobs,)) return {"staging_deleted": deleted} def audit(self, actor: str, action: str, target: str = "", detail: str = "") -> None: @@ -1768,6 +1770,18 @@ class Pipeline: "INSERT INTO src_calls(provider, endpoint, ok, latency_ms, error, created_at) VALUES (?,?,?,?,?,?)", ("tushare", endpoint, 1 if ok else 0, latency_ms, error, isoformat(self.clock())), ) + # HEL-543 side channel: unified cross-provider call log/health. Kept + # strictly additive and fail-open; the src_calls insert above (the + # existing, already-compatible Tushare record) is unaffected either + # way. + if ok: + status, reason = "ok", "" + else: + status, reason = observability.classify_error(error) + observability.record_call( + self.db, "tushare", endpoint, + status=status, latency_ms=latency_ms, error=error, fallback_reason=reason, + ) def _persist_health(self, state: str, error: str = "") -> None: snap = self.breaker.snapshot() diff --git a/xiaobai-datahub/datahub/realtime_serve.py b/xiaobai-datahub/datahub/realtime_serve.py index bcd49b1..5d1dd35 100644 --- a/xiaobai-datahub/datahub/realtime_serve.py +++ b/xiaobai-datahub/datahub/realtime_serve.py @@ -18,6 +18,7 @@ from datahub.adapters.tencent import TencentAdapter from datahub.codes import resolve_code from datahub.db import HubDB from datahub.governance.lkg import LastKnownGood +from datahub import observability from datahub.timeutil import isoformat, now_shanghai, yyyymmdd QUOTE_TTL = 60 @@ -25,6 +26,23 @@ INDEX_TTL = 60 INTRADAY_TTL = 20 QUOTE_BATCH = 60 +# HEL-543 side-channel classifiers. These only *read* an already-successful +# payload to decide what to log; they never change the payload itself and a +# classifier exception always degrades to "ok" (see observability.classify_rows). + + +def _classify_quote_rows(rows: Any) -> tuple[str, str, int | None]: + return observability.classify_rows(rows, freshness_field="quote_time_epoch") + + +def _classify_rows_no_freshness(rows: Any) -> tuple[str, str, int | None]: + return observability.classify_rows(rows, freshness_field=None) + + +def _classify_intraday_payload(data: Any) -> tuple[str, str, int | None]: + points = data.get("points") if isinstance(data, dict) else None + return observability.classify_rows(points or [], freshness_field=None) + class RealtimeApiError(RuntimeError): def __init__(self, code: str, message: str) -> None: @@ -44,12 +62,17 @@ def fetch_index_quotes(db: HubDB) -> dict[str, Any]: cached = _read_cache(db, cache_key) if cached is not None: return cached - eastmoney = EastmoneyAdapter() try: - rows = eastmoney.fetch_indices() + rows = observability.observe( + db, "eastmoney", "indices", lambda: EastmoneyAdapter().fetch_indices(), + classify=_classify_quote_rows, + ) source = "eastmoney:ulist" except Exception: - rows = TencentAdapter().fetch_indices() + rows = observability.observe( + db, "tencent", "indices", lambda: TencentAdapter().fetch_indices(), + classify=_classify_quote_rows, + ) source = "tencent:qt" if len(rows) < 3: raise RealtimeApiError("SOURCE_UNAVAILABLE", "index quotes incomplete") @@ -77,7 +100,10 @@ def fetch_market_quotes(db: HubDB) -> dict[str, Any]: rows: list[dict[str, Any]] = [] source = "" try: - rows = EastmoneyAdapter().fetch_market_quotes() + rows = observability.observe( + db, "eastmoney", "market_quotes", lambda: EastmoneyAdapter().fetch_market_quotes(), + classify=_classify_quote_rows, + ) source = "eastmoney:clist" except Exception as exc: errors.append(f"eastmoney:{exc}") @@ -85,7 +111,10 @@ def fetch_market_quotes(db: HubDB) -> dict[str, Any]: listed = _listed_ts_codes(db) if not listed: raise AdapterError("no local stock master for tencent market snapshot") - rows = _tencent_named_quotes(listed) + rows = observability.observe( + db, "tencent", "market_quotes_fallback", lambda: _tencent_named_quotes(listed), + classify=_classify_quote_rows, + ) if len(rows) < 200: raise AdapterError(f"Tencent market snapshot too small: {len(rows)}") source = "tencent:qt" @@ -130,7 +159,10 @@ def fetch_quotes(db: HubDB, codes: list[str]) -> dict[str, Any]: missing = [code for code in resolved if code not in by_code] try: - rows = _eastmoney_named_quotes(missing) + rows = observability.observe( + db, "eastmoney", "named_quotes", lambda: _eastmoney_named_quotes(missing), + classify=_classify_quote_rows, + ) by_code.update(_quote_map(rows, missing)) if rows: sources.append("eastmoney:ulist") @@ -140,7 +172,10 @@ def fetch_quotes(db: HubDB, codes: list[str]) -> dict[str, Any]: missing = [code for code in resolved if code not in by_code] if missing: try: - rows = _tencent_named_quotes(missing) + rows = observability.observe( + db, "tencent", "named_quotes", lambda: _tencent_named_quotes(missing), + classify=_classify_quote_rows, + ) by_code.update(_quote_map(rows, missing)) if rows: sources.append("tencent:qt") @@ -203,7 +238,10 @@ def fetch_sector_quote(db: HubDB, code: str, expected_date: str = "") -> dict[st return cached errors: list[str] = [] try: - row = EastmoneyAdapter().fetch_shenwan_quote(ts_code) + row = observability.observe( + db, "eastmoney", "sector_quote", lambda: EastmoneyAdapter().fetch_shenwan_quote(ts_code), + classify=_classify_quote_rows, + ) if not _sector_row_matches(row, canonical_name): raise AdapterError( f"industry name mismatch: expected {canonical_name}, got {row.get('name') or '--'}" @@ -270,7 +308,10 @@ def fetch_limit_pool(db: HubDB, trade_date: str = "") -> dict[str, Any]: if cached is not None: return cached try: - rows = EastmoneyAdapter().fetch_limit_pool(day) + rows = observability.observe( + db, "eastmoney", "limit_pool", lambda: EastmoneyAdapter().fetch_limit_pool(day), + classify=_classify_rows_no_freshness, + ) source = "eastmoney:zt_pool" except Exception as exc: recovered = _load_quotes_lkg(db, cache_key) @@ -530,7 +571,10 @@ def warm_realtime(db: HubDB) -> dict[str, Any]: if master_code and master_name: canonical_names.setdefault(master_code, master_name) codes = list(canonical_names) - fetched_sector_rows = _eastmoney_sector_quotes(codes) + fetched_sector_rows = observability.observe( + db, "eastmoney", "sector_quotes_batch", lambda: _eastmoney_sector_quotes(codes), + classify=_classify_quote_rows, + ) for row in fetched_sector_rows: if _row_quote_date(row, today) != today: continue @@ -588,7 +632,10 @@ def fetch_intraday(db: HubDB, code: str, date: str = "") -> dict[str, Any]: return cached adapter = EastmoneyAdapter() try: - payload_data = adapter.fetch_intraday(ts_code, date) + payload_data = observability.observe( + db, "eastmoney", "intraday", lambda: adapter.fetch_intraday(ts_code, date), + classify=_classify_intraday_payload, + ) source = "eastmoney:trends2" except Exception as exc: recovered = _load_intraday_lkg(db, ts_code, date) diff --git a/xiaobai-datahub/datahub/source_catalog.py b/xiaobai-datahub/datahub/source_catalog.py new file mode 100644 index 0000000..f3cc17e --- /dev/null +++ b/xiaobai-datahub/datahub/source_catalog.py @@ -0,0 +1,165 @@ +"""Minimal, read-only source directory (HEL-543). + +Registers what already exists: providers, their concrete interfaces, what +capability/dataset each interface serves, and whether the provider plays a +primary or backup role. This module only *describes* the current adapters +and datasets already wired in `datahub/hub.py`, `datahub/serving.py`, and +`datahub/realtime_serve.py`; it does not add a way to configure or add a new +source without code, and it never changes routing, retries, or fallback +order. + +Every ``interfaces`` entry below is a docs-as-code mirror of a real call +site, cross-referenced in comments so a reviewer can verify each row is +accurate rather than aspirational: + +- tushare interfaces mirror ``datahub/serving.py``'s ``_official_meta``/``source=`` + strings and ``datahub/steward.py``'s live/published dataset table. +- eastmoney/tencent interfaces mirror the ``observability.observe(...)`` + call sites added in ``datahub/realtime_serve.py`` for HEL-543. +- ifind interfaces mirror ``datahub/steward.py``'s ``IFIND_APIS`` table. +""" + +from __future__ import annotations + +from typing import Any + +CATALOG: list[dict[str, Any]] = [ + { + "provider": "tushare", + "label": "Tushare", + "role": "official_primary", + "credential_key": "tushare_token", + "status_source": "src_health (legacy, kept) + provider_health (unified, HEL-543)", + "interfaces": [ + {"interface": "trade_cal", "capability": "交易日历", "datasets": ["calendar"]}, + {"interface": "stock_basic", "capability": "股票主档", "datasets": ["stocks"]}, + {"interface": "daily", "capability": "个股日K", "datasets": ["daily"]}, + {"interface": "adj_factor", "capability": "复权因子", "datasets": ["daily"]}, + {"interface": "daily_basic", "capability": "估值", "datasets": ["valuation"]}, + {"interface": "index_daily", "capability": "指数日K", "datasets": ["index_daily"]}, + {"interface": "moneyflow", "capability": "资金流", "datasets": ["moneyflow"]}, + {"interface": "stk_auction", "capability": "集合竞价", "datasets": ["auction"]}, + {"interface": "limit_list_d", "capability": "涨跌停池", "datasets": ["limit_events"]}, + {"interface": "ths_hot", "capability": "同花顺人气榜", "datasets": ["popularity"]}, + {"interface": "dc_hot", "capability": "东方财富人气榜", "datasets": ["popularity"]}, + {"interface": "hm_detail", "capability": "龙虎榜游资明细", "datasets": ["dragon_tiger"]}, + {"interface": "ths_daily", "capability": "同花顺概念行情", "datasets": ["sector_daily"]}, + {"interface": "dc_index", "capability": "东方财富板块行情", "datasets": ["sector_daily"]}, + {"interface": "sw_daily", "capability": "申万行业行情", "datasets": ["sector_daily"]}, + ], + }, + { + "provider": "eastmoney", + "label": "东方财富", + "role": "provisional_primary", + "credential_key": None, + "status_source": "provider_health (unified, HEL-543)", + "interfaces": [ + {"interface": "indices", "capability": "指数实时报价", "datasets": ["index_quotes"]}, + {"interface": "market_quotes", "capability": "全市场实时快照", "datasets": ["quotes_latest"]}, + {"interface": "named_quotes", "capability": "指定个股实时报价", "datasets": ["quotes_latest"]}, + {"interface": "sector_quote", "capability": "申万板块实时报价(单个)", "datasets": ["sectors_quote"]}, + {"interface": "sector_quotes_batch", "capability": "申万板块批量报价(预热)", "datasets": ["sectors_quote"]}, + {"interface": "limit_pool", "capability": "涨停/炸板池(盘中)", "datasets": ["limit_pool"]}, + {"interface": "intraday", "capability": "分时走势", "datasets": ["intraday_points"]}, + ], + }, + { + "provider": "tencent", + "label": "腾讯行情", + "role": "provisional_backup", + "credential_key": None, + "status_source": "provider_health (unified, HEL-543)", + "interfaces": [ + {"interface": "indices", "capability": "指数实时报价(东财失败时备用)", "datasets": ["index_quotes"]}, + { + "interface": "market_quotes_fallback", + "capability": "全市场快照(备用;按本地股票主档逐只请求拼接)", + "datasets": ["quotes_latest"], + }, + {"interface": "named_quotes", "capability": "指定个股实时报价(东财失败时备用)", "datasets": ["quotes_latest"]}, + ], + }, + { + "provider": "ifind", + "label": "同花顺 iFinD", + "role": "licensed_optional", + "credential_key": "ifind_refresh_token", + "status_source": "provider_health (unified, HEL-543) + adapter.status()", + "interfaces": [ + {"interface": "wencai", "capability": "问财自然语言选股", "datasets": ["ifind_wencai"]}, + {"interface": "snapshots", "capability": "快照", "datasets": ["ifind_snapshots"]}, + {"interface": "history", "capability": "历史行情", "datasets": ["ifind_history"]}, + {"interface": "realtime", "capability": "实时行情", "datasets": ["ifind_realtime"]}, + {"interface": "intraday", "capability": "分时(高频)", "datasets": ["ifind_intraday"]}, + ], + }, + { + "provider": "ths", + "label": "同花顺(预留)", + "role": "reserved", + "credential_key": None, + "status_source": "adapter.probe()(占位,本阶段未接入真实数据)", + "interfaces": [], + }, + { + "provider": "xgb", + "label": "选股宝(预留)", + "role": "reserved", + "credential_key": None, + "status_source": "adapter.probe()(占位,本阶段未接入真实数据)", + "interfaces": [], + }, + { + "provider": "akshare", + "label": "AKShare(预留)", + "role": "reserved", + "credential_key": None, + "status_source": "adapter.probe()(占位,本阶段未接入真实数据)", + "interfaces": [], + }, +] + + +def snapshot(db: Any, auth: Any = None) -> list[dict[str, Any]]: + """Merge the static catalog with live credential/health facts. + + Purely read-only: never touches routing, credentials, or adapters. Any + failure while enriching one entry only degrades that entry's live data; + it never drops the entry or raises, so a directory read can never break + on a partially-unhealthy database. + """ + result: list[dict[str, Any]] = [] + for entry in CATALOG: + item: dict[str, Any] = { + "provider": entry["provider"], + "label": entry.get("label", entry["provider"]), + "role": entry["role"], + "status_source": entry["status_source"], + "interfaces": [dict(i) for i in entry.get("interfaces", [])], + } + cred_key = entry.get("credential_key") + if cred_key: + cred = None + try: + if auth is not None: + cred = auth.credential_status(cred_key) + except Exception: + cred = None + item["credential"] = cred or {"configured": False, "last4": "", "updated_at": ""} + else: + item["credential"] = {"configured": True, "last4": "", "updated_at": "", "note": "无需凭证"} + health_rows: list[dict[str, Any]] = [] + try: + if db is not None: + health_rows = db.fetchall( + "SELECT interface, state, last_ok_at, last_error, last_fallback_reason, " + "consec_failures, last_latency_ms, last_data_age_seconds, updated_at " + "FROM provider_health WHERE provider = ? ORDER BY interface", + (entry["provider"],), + ) + except Exception: + health_rows = [] + item["live_interfaces"] = health_rows + result.append(item) + return result diff --git a/xiaobai-datahub/datahub/steward.py b/xiaobai-datahub/datahub/steward.py index 4a03e8b..d94317f 100644 --- a/xiaobai-datahub/datahub/steward.py +++ b/xiaobai-datahub/datahub/steward.py @@ -12,6 +12,7 @@ from typing import Any from datahub.adapters.base import AdapterError from datahub.adapters.tushare import TUSHARE_FIELDS +from datahub import observability from datahub.numbers import finite_number from datahub.realtime_serve import ( RealtimeApiError, @@ -152,8 +153,12 @@ def _ifind_query(api, api_name: str, params: dict[str, Any], fields: str) -> dic ) if not adapter.configured: raise ApiError("SOURCE_UNAVAILABLE", "iFinD 尚未配置") + db = getattr(api, "db", None) try: - rows = adapter.fetch(dataset, dict(params)) + rows = observability.observe( + db, "ifind", dataset, lambda: adapter.fetch(dataset, dict(params)), + classify=lambda r: observability.classify_rows(r, freshness_field=None), + ) except AdapterError as exc: raise ApiError("SOURCE_UNAVAILABLE", str(exc)) from exc return envelope( diff --git a/xiaobai-datahub/tests/test_admin_observability_api.py b/xiaobai-datahub/tests/test_admin_observability_api.py new file mode 100644 index 0000000..cf7f85a --- /dev/null +++ b/xiaobai-datahub/tests/test_admin_observability_api.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import json +import tempfile +import threading +import unittest +from http.server import ThreadingHTTPServer +from pathlib import Path +from urllib.request import Request, urlopen + +from datahub.adapters.tushare import TushareAdapter +from datahub.crypto import SecretVault +from datahub.httpapp import make_handler +from datahub.hub import Hub +from datahub.settings import Settings +from tests.fixtures import TRADE_DATE, fake_transport + + +class AdminObservabilityApiTests(unittest.TestCase): + """HEL-543: new read-only admin endpoints for provider status, source + catalog and lineage. These must never require write access and must + never touch the existing routing/publish logic.""" + + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + settings = Settings( + encryption_key=SecretVault.generate_key(), + api_token="z" * 32, + admin_password="StartPass1", + tushare_token="real-tushare-token-abcdef", + db_path=Path(self.tmp.name) / "hub.db", + scheduler_enabled=False, + ) + self.hub = Hub(settings, adapter=TushareAdapter("real-tushare-token-abcdef", transport=fake_transport)) + handler = make_handler(self.hub) + self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.base = f"http://127.0.0.1:{self.server.server_address[1]}" + + _, body, cookie_header = self._json( + "/admin/api/login", "POST", {"username": "hub_admin", "password": "StartPass1"} + ) + cookie = cookie_header.split(";")[0] + csrf = body["csrf"] + self._json( + "/admin/api/change-password", + "POST", + {"current": "StartPass1", "new_password": "NewPass123"}, + cookie=cookie, + csrf=csrf, + ) + self.cookie = cookie + self.csrf = csrf + + def tearDown(self) -> None: + self.server.shutdown() + self.server.server_close() + self.tmp.cleanup() + + def _json(self, path, method="GET", body=None, cookie="", csrf=""): + data = None if body is None else json.dumps(body).encode() + headers = {"Content-Type": "application/json"} + if cookie: + headers["Cookie"] = cookie + if csrf: + headers["X-CSRF-Token"] = csrf + req = Request(self.base + path, data=data, headers=headers, method=method) + with urlopen(req, timeout=5) as resp: + set_cookie = resp.headers.get("Set-Cookie", "") + return resp.status, json.loads(resp.read().decode()), set_cookie + + def _get(self, path): + return self._json(path, cookie=self.cookie, csrf=self.csrf) + + def test_providers_status_reflects_real_pipeline_activity(self) -> None: + pipeline = self.hub.pipeline + pipeline.ingest_reference(TRADE_DATE) + pipeline.run_dataset("daily", TRADE_DATE) + + status, body, _ = self._get("/admin/api/providers/status") + self.assertEqual(status, 200) + health = body["health"] + self.assertTrue(any(item["provider"] == "tushare" and item["interface"] == "daily" for item in health)) + row = next(item for item in health if item["provider"] == "tushare" and item["interface"] == "daily") + self.assertEqual(row["state"], "ok") + recent = body["recent_calls"] + self.assertTrue(any(item["provider"] == "tushare" and item["interface"] == "daily" for item in recent)) + + def test_providers_status_filters_by_provider(self) -> None: + pipeline = self.hub.pipeline + pipeline.ingest_reference(TRADE_DATE) + pipeline.run_dataset("daily", TRADE_DATE) + + status, body, _ = self._get("/admin/api/providers/status?provider=tushare") + self.assertEqual(status, 200) + self.assertTrue(all(item["provider"] == "tushare" for item in body["health"])) + self.assertTrue(all(item["provider"] == "tushare" for item in body["recent_calls"])) + + status, body, _ = self._get("/admin/api/providers/status?provider=eastmoney") + self.assertEqual(status, 200) + self.assertEqual(body["health"], []) + self.assertEqual(body["recent_calls"], []) + + def test_source_catalog_lists_known_providers_without_leaking_secrets(self) -> None: + status, body, _ = self._get("/admin/api/source-catalog") + self.assertEqual(status, 200) + blob = json.dumps(body) + self.assertNotIn("real-tushare-token-abcdef", blob) + providers = {item["provider"] for item in body["items"]} + self.assertIn("tushare", providers) + self.assertIn("eastmoney", providers) + self.assertIn("tencent", providers) + self.assertIn("ifind", providers) + + def test_lineage_snapshot_and_affected_query(self) -> None: + status, body, _ = self._get("/admin/api/lineage") + self.assertEqual(status, 200) + self.assertTrue(len(body["items"]) > 0) + datasets = {item["dataset"] for item in body["items"]} + self.assertIn("stocks", datasets) + + status, body, _ = self._get("/admin/api/lineage/affected?provider=tushare&interface=daily") + self.assertEqual(status, 200) + self.assertEqual(body["provider"], "tushare") + self.assertEqual(body["interface"], "daily") + + def test_must_change_password_blocks_new_endpoints_too(self) -> None: + from urllib.error import HTTPError + + _, body, cookie_header = self._json( + "/admin/api/login", "POST", {"username": "hub_admin", "password": "NewPass123"} + ) + # Freshly logged-in user has already changed password in setUp, so + # this login should not require a change; verify the endpoint is + # reachable with a valid, non-must-change session (regression guard + # against accidentally bypassing the must-change gate for these new + # routes). + cookie = cookie_header.split(";")[0] + csrf = body["csrf"] + status, _, _ = self._json("/admin/api/source-catalog", cookie=cookie, csrf=csrf) + self.assertEqual(status, 200) + + +if __name__ == "__main__": + unittest.main() diff --git a/xiaobai-datahub/tests/test_ifind_observability.py b/xiaobai-datahub/tests/test_ifind_observability.py new file mode 100644 index 0000000..fa04288 --- /dev/null +++ b/xiaobai-datahub/tests/test_ifind_observability.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from datahub.adapters.ifind import IfindAdapter +from datahub.db import HubDB +from datahub.serving import ApiError +from datahub.steward import steward_query + + +class _Resp: + def __init__(self, payload: dict, status: int = 200) -> None: + import json + + self.status = status + self._raw = json.dumps(payload).encode("utf-8") + + def read(self): + return self._raw + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + +class IfindObservabilityTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.db = HubDB(Path(self.tmp.name) / "hub.db") + + def tearDown(self) -> None: + self.tmp.cleanup() + + def _adapter_with_urlopen(self, urlopen) -> IfindAdapter: + return IfindAdapter(refresh_token="rt", access_token="at", urlopen=urlopen) + + def test_successful_fetch_is_logged_without_changing_rows(self) -> None: + def urlopen(request, timeout=None): + return _Resp( + { + "errorcode": 0, + "tables": [{"thscode": ["000001.SZ"], "table": {"涨停原因": ["重组"]}}], + } + ) + + adapter = self._adapter_with_urlopen(urlopen) + + class _Api: + ifind = adapter + db = self.db + + payload = steward_query( + _Api(), {"api_name": "ifind_wencai", "params": {"query": "涨停"}} + ) + self.assertEqual(payload["data"][0]["thscode"], "000001.SZ") + log = self.db.fetchone("SELECT * FROM provider_call_log WHERE provider = 'ifind'") + self.assertIsNotNone(log) + self.assertEqual(log["interface"], "wencai") + self.assertEqual(log["status"], "ok") + + def test_failed_fetch_reraises_and_logs_error(self) -> None: + def urlopen(request, timeout=None): + return _Resp({"errorcode": -9999, "errmsg": "quota exceeded"}) + + adapter = self._adapter_with_urlopen(urlopen) + + class _Api: + ifind = adapter + db = self.db + + with self.assertRaises(ApiError) as ctx: + steward_query(_Api(), {"api_name": "ifind_wencai", "params": {"query": "涨停"}}) + self.assertEqual(ctx.exception.code, "SOURCE_UNAVAILABLE") + log = self.db.fetchone("SELECT * FROM provider_call_log WHERE provider = 'ifind'") + self.assertIsNotNone(log) + self.assertEqual(log["status"], "error") + + def test_status_check_alone_does_not_dial_or_log_a_fetch_call(self) -> None: + class _Api: + ifind = IfindAdapter() + db = self.db + + payload = steward_query(_Api(), {"api_name": "ifind_status", "params": {}}) + self.assertFalse(payload["data"][0]["configured"]) + log = self.db.fetchall("SELECT * FROM provider_call_log WHERE provider = 'ifind'") + self.assertEqual(log, []) + + def test_api_double_without_db_attribute_still_works(self) -> None: + # Mirrors tests/test_ifind_adapter.py's `_Api` double, which has no + # `db` attribute at all. Observability must not require it. + class _Api: + ifind = IfindAdapter() + + payload = steward_query(_Api(), {"api_name": "ifind_status", "params": {}}) + self.assertFalse(payload["data"][0]["configured"]) + with self.assertRaises(ApiError): + steward_query(_Api(), {"api_name": "ifind_wencai", "params": {"query": "涨停"}}) + + +if __name__ == "__main__": + unittest.main() diff --git a/xiaobai-datahub/tests/test_observability.py b/xiaobai-datahub/tests/test_observability.py new file mode 100644 index 0000000..301b74f --- /dev/null +++ b/xiaobai-datahub/tests/test_observability.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import tempfile +import unittest +from contextlib import contextmanager +from pathlib import Path + +from datahub import observability +from datahub.db import HubDB + + +class _BrokenDB: + """A db double whose write() always raises, to prove fail-open.""" + + @contextmanager + def write(self): + raise RuntimeError("disk is full") + yield None # pragma: no cover - unreachable, keeps this a generator + + def fetchall(self, sql, params=()): + raise RuntimeError("disk is full") + + def fetchone(self, sql, params=()): + raise RuntimeError("disk is full") + + +class ClassifyRowsTests(unittest.TestCase): + def test_empty_list_is_flagged_empty(self): + status, reason, age = observability.classify_rows([]) + self.assertEqual(status, "empty") + self.assertEqual(reason, "no_rows_returned") + self.assertIsNone(age) + + def test_empty_dict_result_is_flagged_empty(self): + status, reason, _ = observability.classify_rows({}) + self.assertEqual(status, "empty") + self.assertEqual(reason, "no_rows_returned") + + def test_missing_required_field_is_flagged(self): + rows = [{"ts_code": "600000.SH", "close": 10.2}, {"ts_code": "000001.SZ"}] + status, reason, _ = observability.classify_rows(rows, required_fields=("close",), freshness_field=None) + self.assertEqual(status, "missing_fields") + self.assertIn("close", reason) + + def test_fresh_rows_are_ok(self): + import time + + rows = [{"ts_code": "600000.SH", "close": 10.2, "quote_time_epoch": int(time.time())}] + status, reason, age = observability.classify_rows(rows) + self.assertEqual(status, "ok") + self.assertEqual(reason, "") + self.assertIsNotNone(age) + self.assertLess(age, 5) + + def test_stale_rows_are_flagged(self): + import time + + rows = [{"ts_code": "600000.SH", "close": 10.2, "quote_time_epoch": int(time.time()) - 3600}] + status, reason, age = observability.classify_rows(rows, max_age_seconds=300) + self.assertEqual(status, "stale") + self.assertEqual(reason, "data_age_exceeds_threshold") + self.assertGreaterEqual(age, 3600 - 5) + + def test_classifier_never_raises_on_garbage_input(self): + status, reason, age = observability.classify_rows(object()) + self.assertEqual(status, "empty") + self.assertIsNone(age) + # Malformed rows inside a list must not raise either. + status, _, _ = observability.classify_rows(["not-a-dict", 123, None]) + self.assertEqual(status, "empty") + + +class ClassifyErrorTests(unittest.TestCase): + def test_blocked_page_markers_are_detected(self): + status, reason = observability.classify_error("eastmoney request failed: Expecting value: line 1 column 1") + self.assertEqual(status, "blocked") + self.assertEqual(reason, "response_looks_like_intercept_page") + + def test_timeout_is_detected(self): + status, _ = observability.classify_error("tencent request failed: timed out") + self.assertEqual(status, "timeout") + + def test_generic_error_falls_back(self): + status, reason = observability.classify_error("connection reset by peer") + self.assertEqual(status, "error") + self.assertEqual(reason, "") + + def test_never_raises_on_none(self): + status, reason = observability.classify_error(None) + self.assertEqual(status, "error") + + +class RecordCallTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.db = HubDB(Path(self.tmp.name) / "hub.db") + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_record_call_writes_log_and_health(self): + observability.record_call(self.db, "eastmoney", "indices", status="ok", latency_ms=42) + log_rows = self.db.fetchall("SELECT * FROM provider_call_log") + self.assertEqual(len(log_rows), 1) + self.assertEqual(log_rows[0]["provider"], "eastmoney") + self.assertEqual(log_rows[0]["interface"], "indices") + self.assertEqual(log_rows[0]["status"], "ok") + health = self.db.fetchone( + "SELECT * FROM provider_health WHERE provider = ? AND interface = ?", + ("eastmoney", "indices"), + ) + self.assertIsNotNone(health) + self.assertEqual(health["state"], "ok") + self.assertEqual(health["consec_failures"], 0) + + def test_consecutive_failures_increment_and_reset(self): + observability.record_call(self.db, "tencent", "named_quotes", status="error", error="boom") + observability.record_call(self.db, "tencent", "named_quotes", status="error", error="boom again") + health = self.db.fetchone( + "SELECT * FROM provider_health WHERE provider = ? AND interface = ?", + ("tencent", "named_quotes"), + ) + self.assertEqual(health["consec_failures"], 2) + self.assertEqual(health["state"], "error") + observability.record_call(self.db, "tencent", "named_quotes", status="ok") + health = self.db.fetchone( + "SELECT * FROM provider_health WHERE provider = ? AND interface = ?", + ("tencent", "named_quotes"), + ) + self.assertEqual(health["consec_failures"], 0) + self.assertEqual(health["state"], "ok") + + def test_none_db_is_a_silent_noop(self): + # Must not raise even though there is nowhere to write. + observability.record_call(None, "ifind", "wencai", status="ok") + + def test_broken_db_write_does_not_raise(self): + observability.record_call(_BrokenDB(), "eastmoney", "indices", status="ok") + + +class ObserveTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.db = HubDB(Path(self.tmp.name) / "hub.db") + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_returns_exact_success_value_unmodified(self): + sentinel = {"ts_code": "600000.SH", "close": 10.2} + result = observability.observe(self.db, "eastmoney", "indices", lambda: sentinel) + self.assertIs(result, sentinel) + rows = self.db.fetchall("SELECT * FROM provider_call_log") + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["status"], "ok") + + def test_reraises_exact_exception_on_failure(self): + boom = ValueError("upstream exploded") + + def fn(): + raise boom + + with self.assertRaises(ValueError) as ctx: + observability.observe(self.db, "eastmoney", "indices", fn) + self.assertIs(ctx.exception, boom) + rows = self.db.fetchall("SELECT * FROM provider_call_log") + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["status"], "error") + self.assertIn("upstream exploded", rows[0]["error"]) + + def test_classify_downgrades_success_to_stale_without_changing_return_value(self): + sentinel = [{"ts_code": "600000.SH", "quote_time_epoch": 1}] + result = observability.observe( + self.db, "eastmoney", "indices", lambda: sentinel, + classify=lambda rows: observability.classify_rows(rows), + ) + self.assertIs(result, sentinel) + row = self.db.fetchone("SELECT * FROM provider_call_log") + self.assertEqual(row["status"], "stale") + + def test_broken_db_never_breaks_a_successful_call(self): + sentinel = {"ok": True} + result = observability.observe(_BrokenDB(), "eastmoney", "indices", lambda: sentinel) + self.assertIs(result, sentinel) + + def test_broken_db_never_masks_a_real_failure(self): + def fn(): + raise RuntimeError("real upstream failure") + + with self.assertRaises(RuntimeError) as ctx: + observability.observe(_BrokenDB(), "eastmoney", "indices", fn) + self.assertEqual(str(ctx.exception), "real upstream failure") + + def test_classifier_exception_does_not_break_the_call(self): + sentinel = {"ok": True} + + def bad_classify(_result): + raise KeyError("classifier bug") + + result = observability.observe(self.db, "eastmoney", "indices", lambda: sentinel, classify=bad_classify) + self.assertIs(result, sentinel) + row = self.db.fetchone("SELECT * FROM provider_call_log") + # A classifier bug must degrade to "ok", never silently drop the row + # nor claim the call failed when it did not. + self.assertEqual(row["status"], "ok") + + def test_none_db_is_transparent_passthrough(self): + sentinel = object() + result = observability.observe(None, "eastmoney", "indices", lambda: sentinel) + self.assertIs(result, sentinel) + + +if __name__ == "__main__": + unittest.main() diff --git a/xiaobai-datahub/tests/test_pipeline_observability.py b/xiaobai-datahub/tests/test_pipeline_observability.py new file mode 100644 index 0000000..5c797ae --- /dev/null +++ b/xiaobai-datahub/tests/test_pipeline_observability.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import unittest + +from datahub.pipeline import RetryError +from tests.fixtures import TRADE_DATE +from tests.test_pipeline import make_pipeline + + +class PipelineObservabilityTests(unittest.TestCase): + """HEL-543: Tushare calls must keep writing the existing `src_calls` + record unchanged, while also feeding the new cross-provider + `provider_call_log` / `provider_health` side channel.""" + + def test_successful_fetch_logs_to_both_src_calls_and_provider_call_log(self) -> None: + pipe, db = make_pipeline() + pipe.ingest_reference(TRADE_DATE) + result = pipe.run_dataset("daily", TRADE_DATE) + self.assertEqual(result["state"], "published") + + src_calls = db.fetchall("SELECT * FROM src_calls WHERE provider = 'tushare' AND endpoint = 'daily'") + self.assertTrue(any(row["ok"] == 1 for row in src_calls)) + + log = db.fetchall( + "SELECT * FROM provider_call_log WHERE provider = 'tushare' AND interface = 'daily'" + ) + self.assertTrue(len(log) >= 1) + self.assertEqual(log[-1]["status"], "ok") + + health = db.fetchone( + "SELECT * FROM provider_health WHERE provider = 'tushare' AND interface = 'daily'" + ) + self.assertIsNotNone(health) + self.assertEqual(health["state"], "ok") + self.assertEqual(health["consec_failures"], 0) + + def test_failed_fetch_logs_error_to_both_channels_and_still_raises(self) -> None: + pipe, db = make_pipeline() + pipe.ingest_reference(TRADE_DATE) + + def boom(dataset, params): + raise RuntimeError("tushare upstream 500") + + pipe.adapter.fetch = boom # type: ignore[assignment] + + with self.assertRaises(RetryError): + pipe.run_dataset("daily", TRADE_DATE, attempts=1) + + src_calls = db.fetchall("SELECT * FROM src_calls WHERE provider = 'tushare' AND endpoint = 'daily' AND ok = 0") + self.assertTrue(len(src_calls) >= 1) + self.assertIn("tushare upstream 500", src_calls[-1]["error"]) + + log = db.fetchall( + "SELECT * FROM provider_call_log WHERE provider = 'tushare' AND interface = 'daily' AND status != 'ok'" + ) + self.assertTrue(len(log) >= 1) + self.assertIn("tushare upstream 500", log[-1]["error"]) + + health = db.fetchone( + "SELECT * FROM provider_health WHERE provider = 'tushare' AND interface = 'daily'" + ) + self.assertIsNotNone(health) + self.assertNotEqual(health["state"], "ok") + self.assertGreaterEqual(health["consec_failures"], 1) + + def test_provider_call_log_is_purged_by_existing_cleanup_job(self) -> None: + pipe, db = make_pipeline() + pipe.ingest_reference(TRADE_DATE) + pipe.run_dataset("daily", TRADE_DATE) + self.assertTrue(db.fetchall("SELECT * FROM provider_call_log")) + + # Force everything to look ancient so cleanup() sweeps it. + db.execute("UPDATE provider_call_log SET created_at = '2000-01-01T00:00:00+08:00'") + db.execute("UPDATE src_calls SET created_at = '2000-01-01T00:00:00+08:00'") + db.execute("UPDATE job_runs SET started_at = '2000-01-01T00:00:00+08:00'") + + pipe.cleanup() + self.assertEqual(db.fetchall("SELECT * FROM provider_call_log"), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/xiaobai-datahub/tests/test_realtime_observability.py b/xiaobai-datahub/tests/test_realtime_observability.py new file mode 100644 index 0000000..572bc3a --- /dev/null +++ b/xiaobai-datahub/tests/test_realtime_observability.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import tempfile +import unittest +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import patch + +from datahub.adapters.base import AdapterError +from datahub.db import HubDB +from datahub.realtime_serve import fetch_index_quotes, fetch_intraday, fetch_quotes + + +class _WriteBreaksDB: + """Wraps a real HubDB but breaks only the write path, to prove the + real serving path (reads/caches) is untouched by an observability + failure while still exercising real fetch/cache code around it.""" + + def __init__(self, real: HubDB) -> None: + self._real = real + + def fetchall(self, sql, params=()): + return self._real.fetchall(sql, params) + + def fetchone(self, sql, params=()): + return self._real.fetchone(sql, params) + + def execute(self, sql, params=()): + return self._real.execute(sql, params) + + def executemany(self, sql, rows): + return self._real.executemany(sql, rows) + + @contextmanager + def write(self): + raise RuntimeError("db is not writable right now") + yield None # pragma: no cover + + +class RealtimeObservabilityTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.db = HubDB(Path(self.tmp.name) / "hub.db") + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_eastmoney_success_is_logged_without_changing_payload(self) -> None: + rows = [ + {"ts_code": "000001.SH", "code": "000001", "name": "上证指数", "price": 3000.0, + "previous_close": 2990.0, "quote_time_epoch": 0, "source": "eastmoney_push2"}, + {"ts_code": "399001.SZ", "code": "399001", "name": "深证成指", "price": 9000.0, + "previous_close": 8990.0, "quote_time_epoch": 0, "source": "eastmoney_push2"}, + {"ts_code": "399006.SZ", "code": "399006", "name": "创业板指", "price": 1800.0, + "previous_close": 1790.0, "quote_time_epoch": 0, "source": "eastmoney_push2"}, + ] + with patch("datahub.realtime_serve.EastmoneyAdapter") as mocked: + mocked.return_value.fetch_indices.return_value = rows + payload = fetch_index_quotes(self.db) + self.assertEqual(payload["data"], rows) + self.assertEqual(payload["meta"]["source"], "eastmoney:ulist") + log = self.db.fetchall("SELECT * FROM provider_call_log WHERE provider = 'eastmoney'") + self.assertEqual(len(log), 1) + self.assertEqual(log[0]["interface"], "indices") + self.assertEqual(log[0]["status"], "ok") + health = self.db.fetchone( + "SELECT * FROM provider_health WHERE provider = 'eastmoney' AND interface = 'indices'" + ) + self.assertEqual(health["state"], "ok") + + def test_eastmoney_failure_falls_back_to_tencent_and_logs_both(self) -> None: + tencent_rows = [ + {"ts_code": "000001.SH", "code": "000001", "name": "上证指数", "price": 3000.0, + "previous_close": 2990.0, "quote_time_epoch": 0, "source": "tencent_qt"}, + {"ts_code": "399001.SZ", "code": "399001", "name": "深证成指", "price": 9000.0, + "previous_close": 8990.0, "quote_time_epoch": 0, "source": "tencent_qt"}, + {"ts_code": "399006.SZ", "code": "399006", "name": "创业板指", "price": 1800.0, + "previous_close": 1790.0, "quote_time_epoch": 0, "source": "tencent_qt"}, + ] + with patch("datahub.realtime_serve.EastmoneyAdapter") as eastmoney, patch( + "datahub.realtime_serve.TencentAdapter" + ) as tencent: + eastmoney.return_value.fetch_indices.side_effect = AdapterError("Eastmoney returned 0/3 indices") + tencent.return_value.fetch_indices.return_value = tencent_rows + payload = fetch_index_quotes(self.db) + self.assertEqual(payload["meta"]["source"], "tencent:qt") + self.assertEqual(payload["data"], tencent_rows) + east_log = self.db.fetchone("SELECT * FROM provider_call_log WHERE provider = 'eastmoney'") + self.assertEqual(east_log["status"], "empty") + tencent_log = self.db.fetchone("SELECT * FROM provider_call_log WHERE provider = 'tencent'") + self.assertEqual(tencent_log["status"], "ok") + + def test_observability_db_failure_never_breaks_a_real_successful_fetch(self) -> None: + rows = [ + {"ts_code": "000001.SH", "price": 3000.0, "previous_close": 2990.0, "quote_time_epoch": 0}, + {"ts_code": "399001.SZ", "price": 9000.0, "previous_close": 8990.0, "quote_time_epoch": 0}, + {"ts_code": "399006.SZ", "price": 1800.0, "previous_close": 1790.0, "quote_time_epoch": 0}, + ] + broken = _WriteBreaksDB(self.db) + with patch("datahub.realtime_serve.EastmoneyAdapter") as mocked: + mocked.return_value.fetch_indices.return_value = rows + payload = fetch_index_quotes(broken) + self.assertEqual(payload["data"], rows) + self.assertEqual(payload["meta"]["source"], "eastmoney:ulist") + + def test_observability_db_failure_never_masks_a_real_source_outage(self) -> None: + broken = _WriteBreaksDB(self.db) + with patch("datahub.realtime_serve.EastmoneyAdapter") as eastmoney, patch( + "datahub.realtime_serve.TencentAdapter" + ) as tencent: + eastmoney.return_value.fetch_indices.side_effect = AdapterError("down") + tencent.return_value.fetch_indices.side_effect = AdapterError("also down") + with self.assertRaises(Exception): + fetch_index_quotes(broken) + + def test_named_quotes_records_both_providers_on_partial_merge(self) -> None: + with patch("datahub.realtime_serve.EastmoneyAdapter") as eastmoney, patch( + "datahub.realtime_serve.TencentAdapter" + ) as tencent: + eastmoney.return_value.fetch_quotes.return_value = [ + {"ts_code": "000001.SZ", "close": 10, "pre_close": 9, "quote_date": "20260907"}, + ] + tencent.return_value.fetch_quotes.return_value = [ + {"ts_code": "000002.SZ", "close": 20, "pre_close": 19, "quote_date": "20260907"}, + ] + payload = fetch_quotes(self.db, ["000001.SZ", "000002.SZ"]) + self.assertEqual(payload["meta"]["complete"], True) + east_log = self.db.fetchone( + "SELECT * FROM provider_call_log WHERE provider = 'eastmoney' AND interface = 'named_quotes'" + ) + self.assertEqual(east_log["status"], "ok") + tencent_log = self.db.fetchone( + "SELECT * FROM provider_call_log WHERE provider = 'tencent' AND interface = 'named_quotes'" + ) + self.assertEqual(tencent_log["status"], "ok") + + def test_intraday_success_is_logged_as_ok(self) -> None: + payload_data = { + "entity_type": "stock", "ts_code": "601318.SH", "trade_date": "2026-09-07", + "previous_close": 55.8, "points": [{"date": "2026-09-07", "time": "09:30", "close": 55.9}], + } + with patch("datahub.realtime_serve.EastmoneyAdapter") as mocked: + mocked.return_value.fetch_intraday.return_value = payload_data + payload = fetch_intraday(self.db, "601318.SH") + self.assertEqual(payload["data"], payload_data) + log = self.db.fetchone( + "SELECT * FROM provider_call_log WHERE provider = 'eastmoney' AND interface = 'intraday'" + ) + self.assertEqual(log["status"], "ok") + + def test_intraday_failure_is_logged_as_empty(self) -> None: + with patch("datahub.realtime_serve.EastmoneyAdapter") as mocked: + mocked.return_value.fetch_intraday.side_effect = AdapterError("No intraday chart data returned") + with self.assertRaises(Exception): + fetch_intraday(self.db, "000001.SZ") + log = self.db.fetchone( + "SELECT * FROM provider_call_log WHERE provider = 'eastmoney' AND interface = 'intraday'" + ) + self.assertEqual(log["status"], "empty") + + +if __name__ == "__main__": + unittest.main() -- 2.54.0 From 2e9442ac5a9cea1bb2c347dcfcb72026c7ce15d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Mon, 14 Sep 2026 01:10:58 +0800 Subject: [PATCH 2/5] =?UTF-8?q?HEL-543=20=E8=BF=94=E5=B7=A5:=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=20observability=20=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E5=8F=AF=E5=85=B3=E9=97=AD=E5=BC=80=E5=85=B3=20(DATAHUB=5FOBSE?= =?UTF-8?q?RVABILITY)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 总工复核 🔴:安全边界要求新增观测功能必须可关闭、关闭后现有功能完全照旧, 但此前实现没有任何运行时开关。修复: - settings.py: 新增 Settings.observability_enabled 字段,沿用既有 DATAHUB_SCHEDULER 的环境变量模式,读取 DATAHUB_OBSERVABILITY (0/false/off 关闭,默认开启)。 - hub.py: Hub.__init__ 把 settings.observability_enabled 挂到 self.db 上,让 pipeline/realtime_serve/steward/admin_api 已经 在传的 db 参数直接带上开关,零额外改造。 - observability.py: 新增 is_enabled(db),缺失该属性时默认按启用处理 (向后兼容裸 HubDB 用例/测试)。关闭时 observe() 变成纯 透传(不计时、不分类、不碰数据库),record_call() 直接 no-op。 - admin_api.py: 4 个新只读端点关闭时返回明确的 {"enabled": false, ...空结构} 而不是静默返回旧数据。 - 新增 10 个测试:开关默认值/环境变量解析、关闭后 observe() 的透传语义 (含异常原样重新抛出)、关闭后 record_call() 零写入、关闭后重新开启恢复 记录、4 个 admin 端点在关闭态的响应结构。 全量测试 183/183 通过(新增 10 个,含此前 173 个零回归)。 Co-authored-by: Cursor Co-authored-by: multica-agent --- xiaobai-datahub/datahub/admin_api.py | 22 ++++- xiaobai-datahub/datahub/hub.py | 6 ++ xiaobai-datahub/datahub/observability.py | 23 ++++- xiaobai-datahub/datahub/settings.py | 6 ++ .../tests/test_admin_observability_api.py | 35 +++++++ xiaobai-datahub/tests/test_observability.py | 92 +++++++++++++++++++ 6 files changed, 178 insertions(+), 6 deletions(-) diff --git a/xiaobai-datahub/datahub/admin_api.py b/xiaobai-datahub/datahub/admin_api.py index 01a488c..78dfa7e 100644 --- a/xiaobai-datahub/datahub/admin_api.py +++ b/xiaobai-datahub/datahub/admin_api.py @@ -7,6 +7,7 @@ from datahub.adapters import RESERVED from datahub.auth import AuthService from datahub.db import HubDB from datahub import lineage as lineage_module +from datahub import observability from datahub.pipeline import OFFICIAL_DATASETS, STOCKS_DATASET, Pipeline from datahub.scheduler import Scheduler from datahub.serving import ApiError @@ -114,6 +115,8 @@ class AdminAPI: # static registries in source_catalog.py / lineage.py. # ------------------------------------------------------------------ def providers_status(self, provider: str = "", limit: int = 50) -> dict[str, Any]: + if not observability.is_enabled(self.db): + return {"enabled": False, "health": [], "recent_calls": []} limit = max(1, min(int(limit or 50), 200)) health_sql = "SELECT * FROM provider_health" params: tuple[Any, ...] = () @@ -127,19 +130,30 @@ class AdminAPI: calls_sql += " WHERE provider = ?" calls_sql += " ORDER BY id DESC LIMIT ?" recent = self.db.fetchall(calls_sql, (*params, limit)) - return {"health": health, "recent_calls": recent} + return {"enabled": True, "health": health, "recent_calls": recent} def source_catalog(self) -> dict[str, Any]: - return {"items": source_catalog.snapshot(self.db, self.auth)} + if not observability.is_enabled(self.db): + return {"enabled": False, "items": []} + return {"enabled": True, "items": source_catalog.snapshot(self.db, self.auth)} def lineage(self, trade_date: str = "") -> dict[str, Any]: day = yyyymmdd(trade_date) if trade_date else yyyymmdd(now_shanghai()) - return {"trade_date": day, "items": lineage_module.snapshot(self.db, day)} + if not observability.is_enabled(self.db): + return {"enabled": False, "trade_date": day, "items": []} + return {"enabled": True, "trade_date": day, "items": lineage_module.snapshot(self.db, day)} def lineage_affected(self, provider: str = "", interface: str = "") -> dict[str, Any]: if not provider: raise ApiError("INVALID_ARGUMENT", "provider is required") - return {"provider": provider, "interface": interface, "items": lineage_module.affected(self.db, provider, interface)} + if not observability.is_enabled(self.db): + return {"enabled": False, "provider": provider, "interface": interface, "items": []} + return { + "enabled": True, + "provider": provider, + "interface": interface, + "items": lineage_module.affected(self.db, provider, interface), + } def jobs(self) -> dict[str, Any]: runs = self.db.fetchall("SELECT * FROM job_runs ORDER BY id DESC LIMIT 100") diff --git a/xiaobai-datahub/datahub/hub.py b/xiaobai-datahub/datahub/hub.py index ba0d924..1f58370 100644 --- a/xiaobai-datahub/datahub/hub.py +++ b/xiaobai-datahub/datahub/hub.py @@ -24,6 +24,12 @@ class Hub: raise SystemExit("DATAHUB_ENCRYPTION_KEY 未配置") self.settings = settings self.db = HubDB(settings.db_path) + # HEL-543: carry the observability kill switch on the db handle so + # every call site that already threads `db` through (pipeline, + # realtime_serve, steward, admin_api) picks it up for free with no + # extra plumbing. Missing this attribute (e.g. a bare HubDB built + # directly in tests) defaults to enabled — see observability.is_enabled. + self.db.observability_enabled = settings.observability_enabled self.vault = SecretVault(settings.encryption_key) self.auth = AuthService(self.db, self.vault, settings.api_token, settings.admin_password) token = settings.tushare_token or self.auth.load_credential("tushare_token") diff --git a/xiaobai-datahub/datahub/observability.py b/xiaobai-datahub/datahub/observability.py index 395cdae..72bfd9a 100644 --- a/xiaobai-datahub/datahub/observability.py +++ b/xiaobai-datahub/datahub/observability.py @@ -81,6 +81,21 @@ def _logger(): return get_logger() +def is_enabled(db: Any) -> bool: + """Runtime kill switch (``Settings.observability_enabled`` / + ``DATAHUB_OBSERVABILITY``, wired onto the db handle in ``Hub.__init__``). + + Defaults to enabled when the attribute is absent — e.g. a bare ``HubDB`` + built directly in a test, or any call site that predates HEL-543 — so + this can never accidentally disable an existing deployment. Never + raises. + """ + try: + return bool(getattr(db, "observability_enabled", True)) + except Exception: # pragma: no cover - defensive, must never raise + return True + + def classify_error(message: str) -> tuple[str, str]: """Best-effort, side-reading classification of an exception message. @@ -171,7 +186,7 @@ def record_call( ) -> None: """Fail-open recorder. Never raises; a write failure here must never be able to take down a real, otherwise-successful data path.""" - if db is None: + if db is None or not is_enabled(db): return try: now = isoformat(now_shanghai()) @@ -282,8 +297,12 @@ def observe( Returns exactly what ``fn()`` returns and re-raises exactly what ``fn()`` raises. ``db`` may be ``None`` (e.g. in call sites that are not wired to a database yet); in that case this is a transparent passthrough - with no recording at all. + with no recording at all. Same when the ``DATAHUB_OBSERVABILITY`` kill + switch is off (see ``is_enabled``): this becomes ``return fn()`` with no + timing, no classification, and no db access whatsoever. """ + if db is not None and not is_enabled(db): + return fn() started = time.perf_counter() try: result = fn() diff --git a/xiaobai-datahub/datahub/settings.py b/xiaobai-datahub/datahub/settings.py index 3aa86b0..ff96ddc 100644 --- a/xiaobai-datahub/datahub/settings.py +++ b/xiaobai-datahub/datahub/settings.py @@ -33,6 +33,11 @@ class Settings: quality: dict[str, Any] = field(default_factory=dict) log_level: str = "INFO" scheduler_enabled: bool = True + # HEL-543 kill switch: off disables the provider_call_log/provider_health + # side channel entirely (observe()/record_call() become no-ops and the + # new read-only admin endpoints report {"enabled": false}). Default on; + # existing routing/fetch/publish behavior is identical either way. + observability_enabled: bool = True @property def tushare_rate_per_minute(self) -> int: @@ -126,4 +131,5 @@ def load_settings( quality=_load_quality(quality_path), log_level=environ.get("DATAHUB_LOG_LEVEL") or "INFO", scheduler_enabled=str(environ.get("DATAHUB_SCHEDULER") or "1") not in {"0", "false", "False"}, + observability_enabled=str(environ.get("DATAHUB_OBSERVABILITY") or "1") not in {"0", "false", "False", "off", "OFF"}, ) diff --git a/xiaobai-datahub/tests/test_admin_observability_api.py b/xiaobai-datahub/tests/test_admin_observability_api.py index cf7f85a..a9723d1 100644 --- a/xiaobai-datahub/tests/test_admin_observability_api.py +++ b/xiaobai-datahub/tests/test_admin_observability_api.py @@ -124,6 +124,41 @@ class AdminObservabilityApiTests(unittest.TestCase): self.assertEqual(body["provider"], "tushare") self.assertEqual(body["interface"], "daily") + def test_disabled_kill_switch_reports_enabled_false_with_empty_structure(self) -> None: + # HEL-543 total-review 🔴: flip the runtime kill switch the same way + # Hub.__init__ wires Settings.observability_enabled onto the db + # handle, then confirm every new endpoint reports disabled with an + # explicit empty structure rather than silently going quiet. + self.hub.db.observability_enabled = False + try: + pipeline = self.hub.pipeline + pipeline.ingest_reference(TRADE_DATE) + pipeline.run_dataset("daily", TRADE_DATE) # must still fully succeed + + status, body, _ = self._get("/admin/api/providers/status") + self.assertEqual(status, 200) + self.assertEqual(body, {"enabled": False, "health": [], "recent_calls": []}) + + status, body, _ = self._get("/admin/api/source-catalog") + self.assertEqual(status, 200) + self.assertEqual(body, {"enabled": False, "items": []}) + + status, body, _ = self._get("/admin/api/lineage") + self.assertEqual(status, 200) + self.assertFalse(body["enabled"]) + self.assertEqual(body["items"], []) + + status, body, _ = self._get("/admin/api/lineage/affected?provider=tushare") + self.assertEqual(status, 200) + self.assertEqual( + body, {"enabled": False, "provider": "tushare", "interface": "", "items": []} + ) + + # Nothing was ever written while disabled. + self.assertEqual(self.hub.db.fetchall("SELECT * FROM provider_call_log"), []) + finally: + self.hub.db.observability_enabled = True + def test_must_change_password_blocks_new_endpoints_too(self) -> None: from urllib.error import HTTPError diff --git a/xiaobai-datahub/tests/test_observability.py b/xiaobai-datahub/tests/test_observability.py index 301b74f..37accd8 100644 --- a/xiaobai-datahub/tests/test_observability.py +++ b/xiaobai-datahub/tests/test_observability.py @@ -210,5 +210,97 @@ class ObserveTests(unittest.TestCase): self.assertIs(result, sentinel) +class _ToggleDB(HubDB): + """A real HubDB subclass so we can flip the HEL-543 kill switch the same + way Hub.__init__ does, without needing a full Hub/Settings wiring.""" + + +class KillSwitchTests(unittest.TestCase): + """HEL-543 total-review 🔴: the observability side channel must be + disable-able at runtime, and disabling it must leave existing behavior + completely unchanged (pure passthrough, zero db access).""" + + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.db = _ToggleDB(Path(self.tmp.name) / "hub.db") + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_is_enabled_defaults_true_when_attribute_absent(self): + # A bare HubDB (as used throughout the rest of this test suite, and + # by any pre-HEL-543 call site) must default to enabled. + self.assertTrue(observability.is_enabled(self.db)) + self.assertTrue(observability.is_enabled(None)) + + def test_disabled_record_call_writes_nothing(self): + self.db.observability_enabled = False + observability.record_call(self.db, "eastmoney", "indices", status="ok", latency_ms=1) + self.assertEqual(self.db.fetchall("SELECT * FROM provider_call_log"), []) + self.assertEqual(self.db.fetchall("SELECT * FROM provider_health"), []) + + def test_disabled_observe_is_a_pure_passthrough_on_success(self): + self.db.observability_enabled = False + sentinel = {"ts_code": "600000.SH"} + calls = {"n": 0} + + def fn(): + calls["n"] += 1 + return sentinel + + result = observability.observe(self.db, "eastmoney", "indices", fn) + self.assertIs(result, sentinel) + self.assertEqual(calls["n"], 1) + self.assertEqual(self.db.fetchall("SELECT * FROM provider_call_log"), []) + + def test_disabled_observe_still_reraises_the_exact_exception(self): + self.db.observability_enabled = False + boom = RuntimeError("upstream exploded") + + def fn(): + raise boom + + with self.assertRaises(RuntimeError) as ctx: + observability.observe(self.db, "eastmoney", "indices", fn) + self.assertIs(ctx.exception, boom) + self.assertEqual(self.db.fetchall("SELECT * FROM provider_call_log"), []) + + def test_re_enabling_resumes_recording(self): + self.db.observability_enabled = False + observability.observe(self.db, "eastmoney", "indices", lambda: {"ok": True}) + self.assertEqual(self.db.fetchall("SELECT * FROM provider_call_log"), []) + self.db.observability_enabled = True + observability.observe(self.db, "eastmoney", "indices", lambda: {"ok": True}) + self.assertEqual(len(self.db.fetchall("SELECT * FROM provider_call_log")), 1) + + +class SettingsToggleTests(unittest.TestCase): + """The kill switch follows the same env-var pattern as DATAHUB_SCHEDULER.""" + + def test_defaults_to_enabled(self): + from datahub.settings import load_settings + + settings = load_settings(env={}) + self.assertTrue(settings.observability_enabled) + + def test_datahub_observability_zero_disables(self): + from datahub.settings import load_settings + + settings = load_settings(env={"DATAHUB_OBSERVABILITY": "0"}) + self.assertFalse(settings.observability_enabled) + + def test_datahub_observability_off_disables(self): + from datahub.settings import load_settings + + settings = load_settings(env={"DATAHUB_OBSERVABILITY": "off"}) + self.assertFalse(settings.observability_enabled) + + def test_datahub_observability_one_keeps_enabled(self): + from datahub.settings import load_settings + + settings = load_settings(env={"DATAHUB_OBSERVABILITY": "1"}) + self.assertTrue(settings.observability_enabled) + + if __name__ == "__main__": unittest.main() -- 2.54.0 From 31da37b49edbfa6f5f54c4fc309c8214159cc48f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Mon, 14 Sep 2026 21:11:45 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(HEL-529):=20=E6=8C=89=E7=AC=AC?= =?UTF-8?q?=E5=8D=81=E4=BA=8C=E7=89=88=E5=AE=9E=E7=8E=B0=E4=B8=89=E9=A1=B5?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=B8=AD=E6=9E=A2=E7=AE=A1=E7=90=86=E5=90=8E?= =?UTF-8?q?=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以 hub-kimi.html + SPEC-V11.md(v12) 为唯一视觉基线,将 /admin/ 重写为 运行总览 / 数据源配置 / 数据血缘 三页单一入口;接入 HEL-543 只读旁路 与既有 admin API,保留调度/发布/审计与危险操作入口,零新依赖。 Co-authored-by: Cursor Co-authored-by: multica-agent --- xiaobai-datahub/admin/app.js | 1298 ++++++++++++++++++++++++------ xiaobai-datahub/admin/index.html | 119 ++- xiaobai-datahub/admin/styles.css | 572 +++++++++++-- 3 files changed, 1661 insertions(+), 328 deletions(-) diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js index afaf599..ef37e34 100644 --- a/xiaobai-datahub/admin/app.js +++ b/xiaobai-datahub/admin/app.js @@ -1,12 +1,25 @@ -const state = { csrf: "", page: "overview" }; +'use strict'; +/* ===================================================================== + 小白复盘 · 数据中枢 admin/app.js — HEL-529 第十二版 + 三页真实数据中枢:运行总览 / 数据源配置 / 数据血缘。 + 视觉系统逐行照抄已通过打样 hub-kimi.html + SPEC-V11.md(v12 血缘压缩增补), + 但本文件不含任何随机/模拟数据——全部状态、数字、事件均来自真实接口: + 既有 /admin/api/{overview,sources,jobs,batches,datasets,audit,...} + 与 HEL-543 新增只读旁路 /admin/api/{providers/status,source-catalog, + lineage,lineage/affected}。 + ===================================================================== */ +/* ================= 基础:DOM helper / API / CSRF ================= */ function $(id) { return document.getElementById(id); } +function esc(v) { return String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } + +const state = { csrf: '', page: 'overview', calm: false, username: '' }; async function api(path, options = {}) { - const headers = Object.assign({ "Content-Type": "application/json" }, options.headers || {}); - if (state.csrf && (options.method || "GET") !== "GET") headers["X-CSRF-Token"] = state.csrf; - const res = await fetch(path, Object.assign({}, options, { headers, credentials: "same-origin" })); - const body = await res.json(); + const headers = Object.assign({ 'Content-Type': 'application/json' }, options.headers || {}); + if (state.csrf && (options.method || 'GET') !== 'GET') headers['X-CSRF-Token'] = state.csrf; + const res = await fetch(path, Object.assign({}, options, { headers, credentials: 'same-origin' })); + const body = await res.json().catch(() => ({})); if (!res.ok) { const msg = (body.error && body.error.message) || body.error || res.statusText; throw new Error(msg); @@ -14,286 +27,1071 @@ async function api(path, options = {}) { return body; } -function show(id) { - ["login-view", "change-view", "shell"].forEach((key) => { $(key).hidden = key !== id; }); -} - -function esc(value) { - return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[ch])); -} - -function table(headers, rows) { - const thead = headers.map((h) => `${esc(h)}`).join(""); - const body = rows.length - ? rows.map((cols) => `${cols.map((c) => `${c}`).join("")}`).join("") - : `暂无数据`; - return `${thead}${body}
`; +/* ================= 认证:登录 / 改密 / 登出 ================= */ +function showView(id) { + ['login-view', 'change-view'].forEach((k) => { $(k).hidden = k !== id; }); + $('appRoot').hidden = id !== 'shell'; } async function boot() { try { - const session = await api("/admin/api/session"); + const session = await api('/admin/api/session'); state.csrf = session.csrf; - $("who").textContent = session.username; - if (session.must_change) { show("change-view"); return; } - show("shell"); - await render(); + state.username = session.username; + $('who').textContent = session.username; + if (session.must_change) { showView('change-view'); return; } + showView('shell'); + await startApp(); } catch { - show("login-view"); + showView('login-view'); } } -$("login-form").addEventListener("submit", async (event) => { +$('login-form').addEventListener('submit', async (event) => { event.preventDefault(); const form = new FormData(event.target); - $("login-error").hidden = true; + $('login-error').hidden = true; try { - const result = await api("/admin/api/login", { - method: "POST", - body: JSON.stringify({ username: form.get("username"), password: form.get("password") }), + const result = await api('/admin/api/login', { + method: 'POST', + body: JSON.stringify({ username: form.get('username'), password: form.get('password') }), }); state.csrf = result.csrf; - if (result.must_change) show("change-view"); - else { show("shell"); await render(); } + if (result.must_change) { showView('change-view'); return; } + const session = await api('/admin/api/session'); + state.username = session.username; + $('who').textContent = session.username; + showView('shell'); + await startApp(); } catch (err) { - $("login-error").hidden = false; - $("login-error").textContent = err.message; + $('login-error').hidden = false; + $('login-error').textContent = err.message; } }); -$("change-form").addEventListener("submit", async (event) => { +$('change-form').addEventListener('submit', async (event) => { event.preventDefault(); const form = new FormData(event.target); try { - await api("/admin/api/change-password", { - method: "POST", - body: JSON.stringify({ current: form.get("current"), new_password: form.get("new_password") }), + await api('/admin/api/change-password', { + method: 'POST', + body: JSON.stringify({ current: form.get('current'), new_password: form.get('new_password') }), }); - show("shell"); - await render(); + const session = await api('/admin/api/session'); + state.username = session.username; + $('who').textContent = session.username; + showView('shell'); + await startApp(); } catch (err) { - $("change-error").hidden = false; - $("change-error").textContent = err.message; + $('change-error').hidden = false; + $('change-error').textContent = err.message; } }); -$("logout-btn").addEventListener("click", async () => { - await api("/admin/api/logout", { method: "POST", body: "{}" }); - show("login-view"); +$('logout-btn').addEventListener('click', async () => { + try { await api('/admin/api/logout', { method: 'POST', body: '{}' }); } catch {} + Poller.stop(); + showView('login-view'); }); -$("theme-btn").addEventListener("click", () => { - const root = document.documentElement; - const next = root.getAttribute("data-theme") === "night" ? "" : "night"; - if (next) root.setAttribute("data-theme", next); - else root.removeAttribute("data-theme"); - $("theme-btn").textContent = next ? "日间" : "夜间"; -}); - -document.querySelectorAll("nav button").forEach((btn) => { - btn.addEventListener("click", () => { - document.querySelectorAll("nav button").forEach((item) => item.classList.remove("active")); - btn.classList.add("active"); - state.page = btn.dataset.page; - render(); - }); -}); - -async function render() { - const page = $("page"); - if (state.page === "overview") { - const data = await api("/admin/api/overview"); - $("phase").textContent = data.session_phase; - const eod = data.eod_status || {}; - const rev = data.revision_status || {}; - const eodLabels = { - pending_first_attempt: "等待首次尝试", - waiting_upstream: "等待上游", - done: "已成功", - cutoff_failed: "已截止失败", - closed_day: "休市", - }; - const revLabels = { - waiting_review: "等待复核", - review_failed: "复核失败", - aligned: "已追平", - cutoff: "已截止", - pending_publish: "待发布", - closed_day: "休市", - }; - const eodExtra = []; - if (eod.state === "waiting_upstream") { - eodExtra.push(`已试 ${eod.attempts} 次`); - if (eod.next_retry_at) eodExtra.push(`下次重试 ${esc(String(eod.next_retry_at).replace("T", " ").slice(11, 16))}`); - if (eod.missing_datasets && eod.missing_datasets.length) eodExtra.push(`缺 ${esc(eod.missing_datasets.join(","))}`); - } - if (eod.state === "cutoff_failed" && eod.missing_datasets) { - eodExtra.push(`缺 ${esc(eod.missing_datasets.join(","))}`); - } - const revExtra = []; - if (rev.detail) revExtra.push(esc(String(rev.detail))); - if (rev.window) revExtra.push(esc(String(rev.window))); - page.innerHTML = ` -
-
交易日
${esc(data.trade_date)}
-
阶段
${esc(data.session_phase)}
-
今日发布
${data.publications.length}
-
盘后补跑
${esc(eodLabels[eod.state] || eod.state || "-")}
${eodExtra.join(" · ")}
-
估值复核
${esc(revLabels[rev.state] || rev.state || "-")}
${revExtra.join(" · ")}
-
异常批次
${data.anomalies.length}
-
-

最近调用

- ${table(["时间", "源", "端点", "结果", "耗时"], data.recent_calls.map((row) => [ - esc(row.created_at), esc(row.provider), esc(row.endpoint), - row.ok ? '成功' : `${esc(row.error)}`, - `${row.latency_ms ?? "-"} ms`, - ]))} - `; - return; - } - if (state.page === "sources") { - const data = await api("/admin/api/sources"); - page.innerHTML = `

数据源

` + table( - ["源", "角色", "状态", "凭据", "操作"], - data.items.map((item) => { - const cred = item.credential || {}; - const credText = cred.configured ? `已配置 · ${esc(cred.last4 || "****")}` : "未配置"; - return [ - esc(item.provider), - esc(item.role), - esc((item.health && (item.health.state || item.health.status)) || "-"), - credText, - ``, - ]; - }), - ); - page.querySelectorAll("[data-probe]").forEach((btn) => { - btn.addEventListener("click", async () => { - const result = await api(`/admin/api/sources/${btn.dataset.probe}/probe`, { method: "POST", body: "{}" }); - alert(JSON.stringify(result)); - render(); - }); - }); - return; - } - if (state.page === "jobs") { - const data = await api("/admin/api/jobs"); - page.innerHTML = ` -

调度任务

- ${table(["任务", "时刻", "操作"], data.jobs.map((job) => [ - `${esc(job.id)} · ${esc(job.title)}`, esc(job.at), - ``, - ]))} -

最近运行

- ${table(["ID", "任务", "状态", "开始", "结束", "错误"], data.runs.map((row) => [ - row.id, esc(row.job_id), esc(row.state), esc(row.started_at), esc(row.finished_at), esc(row.error), - ]))} - `; - page.querySelectorAll("[data-run]").forEach((btn) => { - btn.addEventListener("click", async () => { - const date = prompt("交易日 YYYYMMDD(可留空=今天)", "") || ""; - await api(`/admin/api/jobs/${btn.dataset.run}/run`, { method: "POST", body: JSON.stringify({ trade_date: date }) }); - render(); - }); - }); - return; - } - if (state.page === "release") { - const date = new Date().toISOString().slice(0, 10).replace(/-/g, ""); - const data = await api(`/admin/api/batches?date=${date}`); - page.innerHTML = ` -

盘后发布 ${esc(data.trade_date)}

-
- - - -
-

当前映射

- ${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], data.publications.map((row) => [ - esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at), - row.prev_batch ? `` : "-", - ]))} -

批次

- ${table(["batch_id", "数据集", "状态", "行数", "错误"], data.batches.map((row) => [ - esc(row.batch_id), esc(row.dataset), esc(row.state), row.rows_out ?? "", esc(row.error), - ]))} - `; - $bindRelease(page); - return; - } - if (state.page === "datasets") { - const data = await api("/admin/api/datasets?date="); - page.innerHTML = ` -

数据集 / 质量 ${esc(data.trade_date)}

- ${table(["数据集", "批次", "状态", "发布时间"], data.publications.map((row) => [ - esc(row.dataset), esc(row.active_batch), esc(row.state), esc(row.published_at), - ]))} -

源间差异

- ${table(["指标", "左", "右", "偏差", "样本"], data.diff_reports.map((row) => [ - esc(row.metric), esc(row.left_value), esc(row.right_value), esc(row.deviation), row.sample_count ?? "", - ]))} - `; - return; - } - if (state.page === "audit") { - const data = await api("/admin/api/audit"); - page.innerHTML = `

审计

` + table( - ["时间", "操作者", "动作", "对象", "详情"], - data.items.map((row) => [esc(row.created_at), esc(row.actor), esc(row.action), esc(row.target), esc(row.detail)]), - ); - } +/* ================= 状态归类(真实状态字符串 → 7 类视觉桶) ================= + 桶: ok / slow / fail / plan / review / degrade / off + 来源覆盖:observability.classify_error/classify_rows、CircuitBreaker、 + eod/revision progress、batches/publications、job_runs 的全部真实取值。 */ +const HEALTH = { + ok: { label: '正常', led: 'ok', color: '#34d399' }, + slow: { label: '慢', led: 'slow', color: '#fbbf24' }, + fail: { label: '失败', led: 'fail', color: '#f87171' }, + plan: { label: '计划', led: 'plan', color: '#8b9bb4' }, + review: { label: '复核中', led: 'rev', color: '#22d3ee' }, + degrade: { label: '降级', led: 'slow', color: '#fbbf24' }, + off: { label: '未配置', led: 'off', color: '#54637e' }, +}; +const STATE_MAP = { + ok: 'ok', closed: 'ok', published: 'ok', done: 'ok', aligned: 'ok', + half_open: 'slow', waiting_upstream: 'slow', + open: 'fail', timeout: 'fail', blocked: 'fail', empty: 'fail', missing_fields: 'fail', + error: 'fail', failed: 'fail', cutoff_failed: 'fail', cutoff: 'fail', review_failed: 'fail', + degraded: 'degrade', stale: 'degrade', + pending_first_attempt: 'plan', pending_publish: 'plan', + running: 'review', staged: 'review', waiting_review: 'review', + unconfigured: 'off', reserved: 'off', unknown: 'off', closed_day: 'off', +}; +function bucketOf(rawState) { return STATE_MAP[String(rawState || '').toLowerCase()] || 'off'; } +const SEV = { fail: 6, degrade: 5, slow: 4, review: 3, plan: 2, off: 1, ok: 0 }; +function worstBucket(list) { + let best = 'ok', bestSev = -1; + for (const b of list) { const s = SEV[b] ?? 0; if (s > bestSev) { bestSev = s; best = b; } } + return list.length ? best : 'ok'; } -function $bindRelease(page) { - page.querySelector("#rel-load").addEventListener("click", async () => { - const date = page.querySelector("#rel-date").value; - const data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`); - state.page = "release"; - // re-render with fetched date by writing location hash - history.replaceState(null, "", `#release-${date}`); - $("page").innerHTML = renderRelease(data); - $bindRelease($("page")); - }); - page.querySelector("#rel-backfill").addEventListener("click", () => dangerous("backfill")); - page.querySelectorAll("[data-rollback]").forEach((btn) => { - btn.addEventListener("click", () => dangerous("rollback", btn.dataset.rollback)); - }); +/* ================= 渲染小工具(结构照抄 hub-kimi.html) ================= */ +function ledHtml(st, pulse) { return ``; } +function pillHtml(bucket, label) { const h = HEALTH[bucket] || HEALTH.off; return `${ledHtml(h.led, bucket === 'fail' || bucket === 'review')}${label || h.label}`; } +function tagHtml(tone, inner) { return `${inner}`; } +function panelHtml(title, right, body, pad) { + return `
//${title}${right ? `${right}` : ''}
${body}
`; +} +function kpiHtml(label, value, sub, tone, live) { + const tc = { txt: '', mint: 'glow-mint', amb: 'glow-amb', rd: 'glow-rd', cy: 'glow-cy' }[tone || 'txt']; + return `
${label}${value}${live ? '_' : ''}${sub ? `${sub}` : ''}
`; +} +function radarBlipsHtml(pulseOk) { + return pulseOk + ? `
` + : ''; +} +function latHtml(ms, warn) { + if (ms == null) return ''; + const color = ms > 1000 ? '#fbbf24' : (warn ? '#fbbf24' : '#d7e1f0'); + return `${Number(ms).toLocaleString('en-US')}ms`; +} +function ageFromIso(iso) { + if (!iso) return null; + const t = Date.parse(iso.includes('T') ? iso : iso.replace(' ', 'T')); + if (Number.isNaN(t)) return null; + return Math.max(0, Math.floor((Date.now() - t) / 1000)); +} +function ageHtml(iso) { + const s = ageFromIso(iso); + if (s == null) return ''; + const txt = s < 90 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h`; + return `${txt}`; +} +function hm(iso) { + if (!iso) return '—'; + const s = String(iso); + const idx = s.indexOf('T') >= 0 ? s.indexOf('T') : s.indexOf(' '); + if (idx < 0 || s.length < idx + 6) return s.slice(0, 5) || '—'; + return s.slice(idx + 1, idx + 6); +} +const p2 = (x) => String(x).padStart(2, '0'); + +/* ================= 数据层:拉取真实接口并汇总为一份快照 ================= */ +const RAW = { + overview: null, sources: null, providers: null, catalog: null, + lineage: null, jobs: null, batches: null, datasets: null, audit: null, +}; +async function fetchAll() { + const [overview, sources, providers, catalog, lineage, jobs, batches, datasets, audit] = await Promise.all([ + api('/admin/api/overview'), + api('/admin/api/sources'), + api('/admin/api/providers/status?limit=200'), + api('/admin/api/source-catalog'), + api('/admin/api/lineage'), + api('/admin/api/jobs'), + api(`/admin/api/batches?date=${today()}`), + api(`/admin/api/datasets?date=${today()}`), + api('/admin/api/audit'), + ]); + RAW.overview = overview; RAW.sources = sources; RAW.providers = providers; RAW.catalog = catalog; + RAW.lineage = lineage; RAW.jobs = jobs; RAW.batches = batches; RAW.datasets = datasets; RAW.audit = audit; +} +function today() { return RAW.overview ? RAW.overview.trade_date : ''; } + +/* 按 provider(+interface) 索引 provider_health,供各页复用 */ +function healthIndex() { + const idx = {}; + const rows = (RAW.providers && RAW.providers.health) || []; + for (const row of rows) { + idx[row.provider] = idx[row.provider] || []; + idx[row.provider].push(row); + } + return idx; +} +function callsIndex() { + const idx = {}; + const rows = (RAW.providers && RAW.providers.recent_calls) || []; + for (const row of rows) { (idx[row.provider] = idx[row.provider] || []).push(row); } + return idx; } -function renderRelease(data) { - return ` -

盘后发布 ${esc(data.trade_date)}

-
- - - +const OFFICIAL_DATASET_IDS = ['daily', 'valuation', 'moneyflow', 'auction', 'index_daily', 'limit_events', 'popularity', 'dragon_tiger', 'sector_daily', 'stocks']; +const DATASET_NAME = { + daily: '日K', valuation: '估值', moneyflow: '资金流', auction: '竞价', index_daily: '指数日K', + limit_events: '涨停池', popularity: '人气榜', dragon_tiger: '龙虎榜', sector_daily: '题材板块', stocks: '股票主档', +}; + +/* ===================================================================== + 总览页 + ===================================================================== */ +function buildOverviewVM() { + const ov = RAW.overview || {}; + const eod = ov.eod_status || {}; + const rev = ov.revision_status || {}; + const pubsByDs = {}; + (ov.publications || []).forEach((p) => { pubsByDs[p.dataset] = p; }); + const batchByDs = {}; + ((RAW.batches && RAW.batches.batches) || []).forEach((b) => { if (!batchByDs[b.dataset] || b.started_at > batchByDs[b.dataset].started_at) batchByDs[b.dataset] = b; }); + const hidx = healthIndex(); + const totalOfficial = OFFICIAL_DATASET_IDS.length; + const publishedCount = OFFICIAL_DATASET_IDS.filter((id) => pubsByDs[id] && pubsByDs[id].state === 'published').length; + const anomalyCount = (ov.anomalies || []).length; + const recentCalls = ov.recent_calls || []; + const successRate = recentCalls.length ? (recentCalls.filter((c) => c.ok).length / recentCalls.length * 100) : null; + + // 主站回退页面:主源异常且备源健康 → 判定为“回退中”的 provisional 数据集 + let fallbackPages = 0; + (RAW.lineage && RAW.lineage.items || []).forEach((it) => { + if (it.tier !== 'provisional' || !it.backup_source) return; + const rows = it.live_provider_health || []; + const primaryProv = (it.providers || [])[0]; + const backupProv = (it.providers || [])[1]; + const primaryState = worstBucket(rows.filter((r) => r.provider === primaryProv).map((r) => bucketOf(r.state))); + const backupState = worstBucket(rows.filter((r) => r.provider === backupProv).map((r) => bucketOf(r.state))); + if ((primaryState === 'fail' || primaryState === 'degrade' || primaryState === 'slow') && backupState === 'ok') fallbackPages += 1; + }); + + const datasetCards = OFFICIAL_DATASET_IDS.map((id) => { + const pub = pubsByDs[id]; + const batch = batchByDs[id]; + let bucket = 'plan', note = ''; + if (pub) { + bucket = pub.state === 'published' ? 'ok' : (pub.state === 'degraded' ? 'degrade' : 'fail'); + note = pub.published_at ? hm(pub.published_at) : ''; + } else if (batch) { + bucket = batch.state === 'failed' ? 'fail' : (batch.state === 'staged' ? 'review' : 'plan'); + note = batch.error ? esc(batch.error) : ''; + } + const rows = (batch && batch.rows_out) || (pub && pub.rows_out) || null; + return { id, name: DATASET_NAME[id] || id, bucket, time: note && !batch?.error ? note : '', rows, err: batch && batch.error ? batch.error : '' }; + }); + + // 实时观察层:eastmoney / tencent 的真实 provider_health 行 + const catalogByProv = {}; + (RAW.catalog && RAW.catalog.items || []).forEach((it) => { catalogByProv[it.provider] = it; }); + const observers = []; + ['eastmoney', 'tencent'].forEach((prov) => { + const rows = hidx[prov] || []; + const cat = catalogByProv[prov]; + rows.forEach((row) => { + const iface = (cat && cat.interfaces.find((i) => i.interface === row.interface)) || {}; + observers.push({ + name: iface.capability || row.interface, + provider: prov, + interface: row.interface, + lastOk: row.last_ok_at, + lat: row.last_latency_ms, + bucket: bucketOf(row.state), + note: row.last_error || row.last_fallback_reason || '', + }); + }); + }); + + // 主站影响:lineage() 条目按 known_consumers 展开 + const siteImpact = []; + (RAW.lineage && RAW.lineage.items || []).forEach((it) => { + const rows = it.live_provider_health || []; + const bucket = it.tier === 'official' + ? (it.publication ? (it.publication.state === 'published' ? 'ok' : (it.publication.state === 'degraded' ? 'degrade' : 'fail')) : (rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan')) + : (rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'off'); + const note = rows.find((r) => r.last_error)?.last_error || rows.find((r) => r.last_fallback_reason)?.last_fallback_reason || ''; + (it.known_consumers || []).forEach((c) => { + siteImpact.push({ page: c, dataset: it.dataset, bucket, note }); + }); + }); + const okPages = siteImpact.filter((s) => s.bucket === 'ok').length; + + // 今晚时间线:真实 jobs() 定义 + job_runs + const jobDefs = (RAW.jobs && RAW.jobs.jobs || []).filter((j) => j.at !== 'manual'); + const runsByJob = {}; + ((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => { (runsByJob[r.job_id] = runsByJob[r.job_id] || []).push(r); }); + const timeline = jobDefs.map((j) => { + const runs = (runsByJob[j.id] || []).slice().sort((a, b) => (a.id < b.id ? 1 : -1)); + const latest = runs[0]; + let st = 'wait'; + if (latest) st = latest.state === 'ok' ? 'done' : (latest.state === 'failed' ? 'fail' : 'wait'); + return { t: (j.at.split('-')[0] || j.at).slice(0, 5), label: j.title, state: st, jobId: j.id }; + }); + + // 待处理异常:批次失败/停滞 + 主源连续失败 + 未配置授权源 + const incidents = []; + (ov.anomalies || []).forEach((b) => { + incidents.push({ sev: 'fail', tag: b.state, title: `${DATASET_NAME[b.dataset] || b.dataset} ${b.state === 'failed' ? '失败' : '停滞'}`, meta: `${b.error ? esc(b.error) : '无错误详情'} · 批次 ${b.batch_id}`, dataset: b.dataset }); + }); + if (eod.state === 'waiting_upstream' || eod.state === 'cutoff_failed') { + incidents.push({ sev: eod.state === 'cutoff_failed' ? 'fail' : 'slow', tag: eod.state, title: eod.state === 'cutoff_failed' ? '盘后批已截止失败' : '等待上游重试', meta: `已试 ${eod.attempts || 0} 次${(eod.missing_datasets || []).length ? ' · 缺 ' + eod.missing_datasets.join(',') : ''}`, dataset: (eod.missing_datasets || [])[0] || '' }); + } + Object.keys(hidx).forEach((prov) => { + const bad = (hidx[prov] || []).filter((r) => (r.consec_failures || 0) > 0 && bucketOf(r.state) === 'fail'); + if (bad.length) incidents.push({ sev: 'fail', tag: prov, title: `${prov} 部分接口连续失败`, meta: bad.map((r) => `${r.interface}×${r.consec_failures}`).join(' · '), provider: prov }); + }); + const ifindCat = catalogByProv.ifind; + if (ifindCat && !(ifindCat.credential && ifindCat.credential.configured)) { + incidents.push({ sev: 'off', tag: '未配置', title: 'iFinD · 授权源未启用', meta: '凭证未配置 · 功能隐藏 · 配置后启用', provider: 'ifind' }); + } + + return { + trade_date: ov.trade_date, session_phase: ov.session_phase, eod, rev, + publishedCount, totalOfficial, anomalyCount, successRate, + fallbackPages, datasetCards, observers, siteImpact, okPages, timeline, incidents, + }; +} + +function heroHtml(vm) { + const anomalyTxt = vm.anomalyCount ? `${vm.anomalyCount} 项异常` : `无异常`; + const tags = []; + if (vm.eod.state === 'waiting_upstream') tags.push(tagHtml('amb', `等待上游 · 已试 ${vm.eod.attempts || 0} 次`)); + if (vm.rev.state === 'waiting_review') tags.push(tagHtml('cy', '复核 · 待发布')); + const subParts = []; + if (vm.eod.attempts) subParts.push(`已试 ${vm.eod.attempts} 次`); + if (vm.eod.next_retry_at) subParts.push(`下次重试 ${hm(vm.eod.next_retry_at)}`); + if ((vm.eod.missing_datasets || []).length) subParts.push(`缺 ${esc(vm.eod.missing_datasets.join(','))}`); + if (vm.rev.window) subParts.push(`复核窗口 ${esc(vm.rev.window)}`); + return `
` + + `
` + + `
${vm.anomalyCount ? '部分异常' : '整体可用'}·${anomalyTxt}
` + + `
${tags.join('')}
` + + (subParts.length ? `
${subParts.join('|')}
` : '') + + `
` + + `
` + + kpiHtml('已发布数据集', `${vm.publishedCount}/${vm.totalOfficial}`, null) + + kpiHtml('今日调用成功率', vm.successRate == null ? '' : `${vm.successRate.toFixed(1)}%`, null, 'mint', vm.successRate != null) + + kpiHtml('主站回退页面', `${vm.fallbackPages} 页`, null, vm.fallbackPages ? 'amb' : 'txt') + + kpiHtml('EOD 尝试', `${vm.eod.attempts || 0} 次`, '上限 5 · 23:30 止', vm.eod.attempts ? 'rd' : 'txt') + + `
`; +} +function datasetStripHtml(vm) { + return `
${vm.datasetCards.map((d) => { + const ring = d.bucket === 'fail' ? 'fail' : d.bucket === 'degrade' ? 'slow' : d.bucket === 'review' ? 'review' : ''; + let note = ''; + if (d.time) note += `${esc(d.time)} · `; + if (d.rows) note += `${Number(d.rows).toLocaleString('en-US')} 行`; + if (d.err) note += `${esc(d.err)}`; + if (!d.time && !d.rows && !d.err) note = '今日尚未发布'; + return `
` + + `
${esc(d.name)}${pillHtml(d.bucket)}
` + + `
${note}
` + + `
`; + }).join('')}
`; +} +function observatoryHtml(vm) { + const legendByProv = { eastmoney: 'EM 东财', tencent: 'TX 腾讯' }; + const right = `${Object.keys(legendByProv).map((p) => `${ledHtml('ok')}${legendByProv[p]}`).join('')}`; + const rows = vm.observers.length ? vm.observers.map((o) => `` + + `${esc(o.name)}` + + `${o.provider === 'eastmoney' ? '东方财富' : '腾讯行情'}` + + `${ageHtml(o.lastOk)}` + + `${latHtml(o.lat, o.bucket !== 'ok')}` + + `${pillHtml(o.bucket)}${o.note ? `${esc(o.note)}` : ''}` + + ``).join('') : `尚无真实观测记录(等待首次探测/调用)`; + const body = `${rows}
观察项当前来源最近成功延迟状态 · 备注
`; + return panelHtml('实时观察层', right, body, false); +} +function siteImpactHtml(vm) { + const right = `${vm.okPages} 项正常 · ${vm.siteImpact.length - vm.okPages} 项待关注`; + const rows = vm.siteImpact.length ? vm.siteImpact.map((s) => `` + + `${esc(s.page)}` + + `${pillHtml(s.bucket)}` + + `${s.note ? esc(s.note) : '—'}` + + `` + + ``).join('') : `尚无血缘记录`; + return panelHtml('主站影响', right, `${rows}
`, false); +} +function timelineHtml(vm) { + const nodes = vm.timeline; + if (!nodes.length) return panelHtml('今晚时间线', '', '
暂无可展示的调度任务
', true); + const W = 560, H = 150, x0 = 34, x1 = W - 30, y = 75; + const step = (x1 - x0) / Math.max(1, nodes.length - 1); + const X = (i) => x0 + i * step; + const now = new Date(); + const nowMinutes = now.getHours() * 60 + now.getMinutes(); + const toMin = (t) => { const [h, m] = t.split(':').map(Number); return h * 60 + (m || 0); }; + let nowX = X(0); + for (let i = 0; i < nodes.length - 1; i++) { + const a = toMin(nodes[i].t), b = toMin(nodes[i + 1].t); + if (nowMinutes >= a && nowMinutes <= b && b > a) { nowX = X(i) + (X(i + 1) - X(i)) * ((nowMinutes - a) / (b - a)); break; } + if (nowMinutes < toMin(nodes[0].t)) { nowX = X(0); break; } + if (nowMinutes > toMin(nodes[nodes.length - 1].t)) { nowX = X(nodes.length - 1); } + } + let s = `` + + `` + + `` + + ``; + nodes.forEach((n, i) => { + const nx = X(i), up = i % 2 === 0; + const ty = up ? y - 26 : y + 34, ly = up ? y - 12 : y + 20; + const color = n.state === 'done' ? '#34d399' : n.state === 'fail' ? '#f87171' : '#54637e'; + const anchor = i === 0 ? 'start' : i === nodes.length - 1 ? 'end' : 'middle'; + s += ``; + if (n.state === 'fail') s += `×`; + else s += ``; + s += `${esc(n.t)}` + + `${esc(n.label)}`; + }); + s += `` + + `` + + `NOW`; + const right = `现在 ${p2(now.getHours())}:${p2(now.getMinutes())} · SCHEDULER.PY`; + return panelHtml('今晚时间线', right, s, false); +} +function incidentsHtml(vm) { + if (!vm.incidents.length) return panelHtml('待处理异常', tagHtml('mint', '0 OPEN'), '
当前无待处理异常
', true); + const cards = vm.incidents.map((it) => { + const tag = it.sev === 'fail' ? tagHtml('rd', esc(it.tag)) : it.sev === 'slow' ? tagHtml('amb', esc(it.tag)) : tagHtml('dashed', esc(it.tag)); + return `
${tag}` + + `
` + + `
${esc(it.title)}
` + + `
${esc(it.meta)}
` + + `
` + + `` + + `
`; + }).join(''); + return panelHtml('待处理异常', tagHtml('rd', vm.incidents.length + ' OPEN'), `
${cards}
`, true); +} +function overviewHtml() { + const vm = buildOverviewVM(); + return `
${heroHtml(vm)}${datasetStripHtml(vm)}` + + `
${observatoryHtml(vm)}${siteImpactHtml(vm)}
` + + `
${timelineHtml(vm)}${incidentsHtml(vm)}
`; +} + +/* ===================================================================== + 数据源配置页 + ===================================================================== */ +const srcUiState = {}; +function buildSourcesVM() { + const catalog = (RAW.catalog && RAW.catalog.items) || []; + const legacy = {}; + (RAW.sources && RAW.sources.items || []).forEach((it) => { legacy[it.provider] = it; }); + const hidx = healthIndex(); + const cidx = callsIndex(); + const active = catalog.filter((c) => c.role !== 'reserved'); + const reserved = catalog.filter((c) => c.role === 'reserved'); + const totalIfaces = active.reduce((n, c) => n + c.interfaces.length, 0); + const usedIfaces = active.reduce((n, c) => n + c.interfaces.filter((i) => (hidx[c.provider] || []).some((h) => h.interface === i.interface)).length, 0); + const cards = active.map((c) => { + const leg = legacy[c.provider] || {}; + const legState = (leg.health && (leg.health.state || leg.health.status)) || null; + const rows = hidx[c.provider] || []; + const worst = rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : (legState ? bucketOf(legState) : 'off'); + const cred = c.credential || {}; + const lastCall = (cidx[c.provider] || [])[0]; + if (!(c.provider in srcUiState)) srcUiState[c.provider] = { open: true, probing: false, probeResult: null }; + const ifaceRows = c.interfaces.map((i) => { + const h = rows.find((r) => r.interface === i.interface); + return { + api: i.interface, use: i.capability, + ok: h ? (h.last_ok_at ? hm(h.last_ok_at) : '—') : '—', + lat: h ? h.last_latency_ms : null, + st: h ? bucketOf(h.state) : 'off', + err: h && h.state !== 'ok' ? (h.last_error || h.last_fallback_reason || '') : '', + }; + }); + return { + id: c.provider, name: c.label, role: c.role, tag: c.credential_key ? 'licensed' : 'free', + bucket: worst, credential: cred, usedCount: ifaceRows.filter((r) => r.st !== 'off').length, totalCount: ifaceRows.length, + lastProbe: lastCall ? `${hm(lastCall.created_at)} · ${lastCall.ok ? `✓ ${lastCall.latency_ms}ms` : `× ${lastCall.error || 'ERROR'}`}` : '—', + latency: rows.length ? (rows.map((r) => r.last_latency_ms).filter((v) => v != null)[0] || null) : null, + error: rows.find((r) => r.last_error)?.last_error || '', + ifaceRows, + }; + }); + return { cards, reserved, totalIfaces, usedIfaces }; +} +function sourceCardHtml(card) { + const ui = srcUiState[card.id]; + const ringStyle = card.bucket === 'fail' ? 'border-color:rgba(248,113,113,.35)' : + card.bucket === 'degrade' ? 'border-color:rgba(251,191,36,.25)' : + card.bucket === 'slow' ? 'border-color:rgba(251,191,36,.3)' : + card.bucket === 'off' ? 'border-style:dashed;border-color:#243152' : ''; + const ledSt = HEALTH[card.bucket].led; + // source_catalog.py:credential_key 为空的来源(eastmoney/tencent 等公开 + // 接口)真实语义是"无需凭证"而非"已配置",两者都体现为 configured:true, + // 靠 note 字段区分,这里必须原样展示 note,不能一律折叠成"已配置"。 + const credBadge = card.credential.note ? card.credential.note : + card.credential.configured ? `已配置${card.credential.last4 ? ' · ' + card.credential.last4 : ''}` : '未配置'; + const badges = [credBadge, card.role]; + let h = `
`; + h += `
` + + ledHtml(ledSt, card.bucket === 'fail') + + `${esc(card.name)}` + + `${esc(card.tag)}` + + pillHtml(card.bucket) + + `${badges.map((b) => tagHtml(card.bucket === 'off' ? 'dashed' : 'dim', esc(b))).join('')}` + + `` + + `` + + `` + + `
`; + h += `
` + + `接口 ${card.usedCount}/${card.totalCount} 在用` + + `|最后探测 ${esc(card.lastProbe)}` + + `|延迟 ${latHtml(card.latency)}` + + (card.error ? `|最近错误 ${esc(card.error)}` : `|无最近错误`) + + `
`; + if (ui.open && card.ifaceRows.length) { + h += `
` + + `` + + card.ifaceRows.map((it) => `` + + `` + + `` + + `` + + `` + + `` + + ``).join('') + `
接口用途最近成功延迟状态
${esc(it.api)}${esc(it.use)}${esc(it.ok)}${latHtml(it.lat)}${pillHtml(it.st)}${it.err ? `${esc(it.err)}` : ''}
`; + } else if (!card.ifaceRows.length) { + h += `
该来源尚无已登记接口
`; + } + return h + `
`; +} +function sourcesHtml() { + const vm = buildSourcesVM(); + const alerts = []; + vm.cards.forEach((c) => { + if (c.bucket === 'fail') alerts.push(`${ledHtml('fail', true)}${esc(c.name)} 故障${c.error ? ' · ' + esc(c.error) : ''}`); + else if (c.bucket === 'degrade' || c.bucket === 'slow') alerts.push(`${ledHtml('slow')}${esc(c.name)} 降级/延迟`); + }); + return `
` + + `
` + + `数据源 ${vm.cards.length} 路 · 接口 ${vm.usedIfaces}/${vm.totalIfaces} 在用` + + alerts.join('') + + `` + + `凭证安全存储 · 探测只读 · 不影响盘中链路` + + `
` + + `
${vm.cards.map(sourceCardHtml).join('')}
` + + `
${vm.reserved.map((r) => `
${ledHtml('off')}${esc(r.label)}${tagHtml('dashed', '预留位 · 未接入')}
`).join('')}
` + + `
`; +} +function renderSourceCards() { + const el = $('srcCards'); + if (!el) return; + const vm = buildSourcesVM(); + el.innerHTML = vm.cards.map(sourceCardHtml).join(''); +} + +/* ===================================================================== + 数据血缘页 + ===================================================================== */ +function pageNameOf(consumer) { + const idx = consumer.indexOf('('); + return (idx > 0 ? consumer.slice(0, idx) : consumer).trim(); +} +const linFilter = { onlyBad: false, src: '全部', q: '' }; +let linHover = null; +let GW = 1500, GH = 286; +const gpos = {}; +const adj = { sd: new Map(), ds: new Map(), dp: new Map(), pd: new Map() }; +function adjAdd(m, k, v) { if (!m.has(k)) m.set(k, new Set()); m.get(k).add(v); } + +function buildLineageVM() { + const items = (RAW.lineage && RAW.lineage.items) || []; + const PROV_LABEL = { tushare: 'Tushare', eastmoney: '东方财富', tencent: '腾讯行情', ifind: 'iFinD' }; + const srcState = {}; + const dsState = {}; + const pageState = {}; + const pageDatasets = {}; + const edgesSD = []; + const edgesDP = []; + const rows = []; + items.forEach((it) => { + const rowsHealth = it.live_provider_health || []; + let bucket; + if (it.tier === 'official') { + bucket = it.publication ? (it.publication.state === 'published' ? 'ok' : (it.publication.state === 'degraded' ? 'degrade' : 'fail')) : (rowsHealth.length ? worstBucket(rowsHealth.map((r) => bucketOf(r.state))) : 'plan'); + } else { + bucket = rowsHealth.length ? worstBucket(rowsHealth.map((r) => bucketOf(r.state))) : 'off'; + } + dsState[it.dataset] = bucket; + (it.providers || []).forEach((p) => { + srcState[p] = worstBucket([srcState[p] || 'ok', ...rowsHealth.filter((r) => r.provider === p).map((r) => bucketOf(r.state))]); + edgesSD.push([p, it.dataset]); + }); + const consumers = it.known_consumers || []; + const pages = [...new Set(consumers.map(pageNameOf))]; + pages.forEach((pg) => { + pageState[pg] = worstBucket([pageState[pg] || 'ok', bucket]); + edgesDP.push([it.dataset, pg]); + (pageDatasets[pg] = pageDatasets[pg] || []).push(it); + }); + const lat = rowsHealth.map((r) => r.last_latency_ms).find((v) => v != null); + const ok = it.tier === 'official' && it.publication ? it.publication.published_at : (rowsHealth.find((r) => r.last_ok_at) || {}).last_ok_at; + rows.push({ + page: pages.join(' / ') || '—', item: consumers.join(' / '), dataset: it.dataset, + via: `${it.primary_source}${it.backup_source ? ' → ' + it.backup_source : ''}`, + role: it.backup_source ? '主/备' : (it.tier === 'licensed' ? '主(授权)' : '主(无备)'), + ok: ok ? hm(ok) : '—', lat, st: bucket, + note: rowsHealth.find((r) => r.last_error)?.last_error || rowsHealth.find((r) => r.last_fallback_reason)?.last_fallback_reason || (it.publication && it.publication.state !== 'published' ? '未正式发布' : ''), + }); + }); + const sources = Object.keys(srcState).map((id) => ({ id, name: PROV_LABEL[id] || id, st: srcState[id] })); + const datasets = Object.keys(dsState).map((id) => ({ id, st: dsState[id] })); + const pages = Object.keys(pageState).map((id) => ({ id, name: id, st: pageState[id] })); + return { sources, datasets, pages, edgesSD, edgesDP, rows }; +} + +function spread(n, y0, y1) { return Array.from({ length: n }, (_, i) => (n === 1 ? (y0 + y1) / 2 : y0 + (i / (n - 1)) * (y1 - y0))); } +function layoutGraph(vm) { + const wide = (window.innerWidth || 1440) > 1100; + GW = wide ? 1500 : 1000; GH = wide ? 286 : 560; + vm.sources.forEach((s, i) => { gpos['s:' + s.id] = { x: wide ? 80 : 60, y: spread(vm.sources.length, wide ? 60 : 90, wide ? 232 : 500)[i] }; }); + vm.datasets.forEach((d, i) => { gpos['d:' + d.id] = { x: wide ? 700 : 480, y: spread(vm.datasets.length, wide ? 30 : 45, wide ? 264 : 525)[i] }; }); + vm.pages.forEach((p, i) => { gpos['p:' + p.id] = { x: wide ? 1400 : 930, y: spread(vm.pages.length, wide ? 26 : 30, wide ? 276 : 540)[i] }; }); +} +function linkPath(p1, p2) { const mx = (p1.x + p2.x) / 2; return `M ${p1.x} ${p1.y} C ${mx} ${p1.y}, ${mx} ${p2.y}, ${p2.x} ${p2.y}`; } +const stColorOf = (b) => (HEALTH[b] || HEALTH.off).color; + +function lineageGraphHtml(vm) { + layoutGraph(vm); + adj.sd.clear(); adj.ds.clear(); adj.dp.clear(); adj.pd.clear(); + vm.edgesSD.forEach(([s, d]) => { adjAdd(adj.sd, s, d); adjAdd(adj.ds, d, s); }); + vm.edgesDP.forEach(([d, p]) => { adjAdd(adj.dp, d, p); adjAdd(adj.pd, p, d); }); + let h = ``; + const colStyle = 'font-size="10" letter-spacing="2" fill="#54637e"'; + if (vm.sources.length) h += `来源 · ${vm.sources.length}`; + if (vm.datasets.length) h += `数据集 · ${vm.datasets.length}`; + if (vm.pages.length) h += `主站页面 · ${vm.pages.length}`; + const dsBy = {}; vm.datasets.forEach((d) => { dsBy[d.id] = d.st; }); + vm.edgesSD.forEach(([s, d]) => { + if (!gpos['s:' + s] || !gpos['d:' + d]) return; + h += ``; + }); + vm.edgesDP.forEach(([d, p]) => { + if (!gpos['d:' + d] || !gpos['p:' + p]) return; + h += ``; + }); + vm.sources.forEach((s) => { + const p = gpos['s:' + s.id]; if (!p) return; + h += `` + + `` + + `` + + `${esc(s.name)}`; + }); + vm.datasets.forEach((d) => { + const p = gpos['d:' + d.id]; if (!p) return; + h += `` + + `` + + `${esc(d.id)}`; + }); + vm.pages.forEach((pg) => { + const p = gpos['p:' + pg.id]; if (!p) return; + h += `` + + `` + + `${esc(pg.name)}`; + }); + h += `
悬停节点 · 追踪上下游链路
`; + return h; +} +function activeSetCalc(hover, vm) { + if (!hover) return null; + const set = new Set([hover]); + const isSrc = vm.sources.some((s) => s.id === hover); + const isDs = vm.datasets.some((d) => d.id === hover); + if (isSrc) { (adj.sd.get(hover) || new Set()).forEach((d) => { set.add(d); (adj.dp.get(d) || new Set()).forEach((p) => set.add(p)); }); } + else if (isDs) { (adj.ds.get(hover) || new Set()).forEach((s) => set.add(s)); (adj.dp.get(hover) || new Set()).forEach((p) => set.add(p)); } + else { (adj.pd.get(hover) || new Set()).forEach((d) => { set.add(d); (adj.ds.get(d) || new Set()).forEach((s) => set.add(s)); }); } + return set; +} +function applyLinHover(vm) { + const act = activeSetCalc(linHover, vm); + const isSrcHov = vm.sources.some((s) => s.id === linHover); + document.querySelectorAll('#linSvg path[data-link]').forEach((p) => { + const a = p.dataset.a, b = p.dataset.b; + const shown = !act || (act.has(a) && act.has(b)); + let hot = false; + if (act && act.has(a) && act.has(b)) hot = isSrcHov ? (a === linHover ? true : (adj.sd.get(linHover) || new Set()).has(a)) : true; + p.setAttribute('stroke-width', hot ? 1.8 : 1); + p.setAttribute('stroke-opacity', shown ? (act ? 0.85 : (p.dataset.kind === 'sd' ? 0.28 : 0.22)) : 0.05); + }); + document.querySelectorAll('#linSvg g[data-nid]').forEach((g) => { + const id = g.dataset.nid, kind = g.dataset.gkind; + const dimmed = act && !act.has(id); + g.setAttribute('opacity', dimmed ? (kind === 'src' ? 0.3 : 0.25) : 1); + if (kind !== 'src') { const t = g.querySelector('text'); if (t) t.setAttribute('fill', act && act.has(id) ? '#e8f1ff' : '#8b9bb4'); } + }); + const hint = $('linHint'); + if (hint) hint.textContent = linHover ? `FOCUS: ${linHover} · 高亮上下游链路` : '悬停节点 · 追踪上下游链路'; +} +function setLinHover(id, vm) { linHover = id; applyLinHover(vm); } +function linRowsData(vm) { + return vm.rows.filter((r) => { + if (linFilter.onlyBad && !['fail', 'slow', 'degrade', 'off', 'review', 'plan'].includes(r.st)) return false; + if (linFilter.src !== '全部' && !r.via.includes({ Tushare: 'tushare', 东方财富: 'eastmoney', 腾讯: 'tencent', iFinD: 'ifind' }[linFilter.src] || '')) return false; + if (linFilter.q && !(r.page + r.item + r.dataset + r.via).toLowerCase().includes(linFilter.q.toLowerCase())) return false; + return true; + }); +} +function renderLinRows(vm) { + const body = $('linBody'), cnt = $('linCount'), bodyN = $('linBodyN'); + if (!body) return; + const rows = linRowsData(vm); + if (cnt) cnt.textContent = `${rows.length}/${vm.rows.length} 行`; + const cls = (st) => st === 'fail' ? 'row-fail' : st === 'degrade' || st === 'slow' ? 'row-slow' : st === 'off' ? 'row-off' : ''; + body.innerHTML = rows.map((r) => `` + + `${esc(r.page)}` + + `${esc(r.item)}` + + `${esc(r.dataset)}` + + `${esc(r.via)}` + + `${esc(r.role)}` + + `${esc(r.ok)}` + + `${latHtml(r.lat)}` + + `${pillHtml(r.st)}${r.note ? `${esc(r.note)}` : ''}` + + ``).join('') || `无匹配记录 · 调整筛选条件`; + if (bodyN) bodyN.innerHTML = rows.map((r) => `` + + `${esc(r.page)}${esc(r.item)}` + + `${esc(r.dataset)}` + + `${esc(r.via)}` + + `${esc(r.ok)}${r.lat != null ? r.lat + 'ms' : '—'}` + + `${pillHtml(r.st)}${r.note ? `${esc(r.note)}` : ''}` + + ``).join('') || `无匹配记录 · 调整筛选条件`; +} +function lineageHtml() { + const vm = buildLineageVM(); + const anomalyPages = new Set(); + vm.rows.forEach((r) => { if (r.st === 'fail') (r.page || '').split(' / ').forEach((p) => anomalyPages.add(p)); }); + const html = `
` + + `
` + + `血缘地图` + + `${vm.sources.length} 源 → ${vm.datasets.length} 数据集 → ${vm.pages.length} 页面` + + `${ledHtml('ok')}健康${ledHtml('slow')}慢/降级${ledHtml('fail')}失败${ledHtml('plan')}计划${ledHtml('off')}未配置` + + `` + + (anomalyPages.size ? tagHtml('rd', `${[...anomalyPages][0]} 断链影响 ${anomalyPages.size} 页`) : tagHtml('mint', '暂无断链影响')) + + `
` + + `
${lineageGraphHtml(vm)}
` + + `
` + + `
` + + `` + + `` + + `` + + `` + + `` + + `
` + + `
主站页面数据项数据集主/备最近成功延迟状态
` + + `
主站页面数据集最近成功状态
` + + `
` + + `
`; + window.__linVM = vm; + return html; +} + +/* ===================================================================== + 页面路由 / 顶栏 / EVENT TAPE / 抽屉 / 危险操作弹层 + ===================================================================== */ +const mainEl = $('mainEl'); +function pageFromHash() { + const h = location.hash.replace('#', ''); + return ['overview', 'sources', 'lineage'].includes(h) ? h : 'overview'; +} +function renderPage(p) { + state.page = p; + mainEl.dataset.page = p; + document.querySelectorAll('[data-nav]').forEach((b) => b.classList.toggle('act', b.dataset.nav === p)); + if (p === 'overview') mainEl.innerHTML = overviewHtml(); + else if (p === 'sources') mainEl.innerHTML = sourcesHtml(); + else { + mainEl.innerHTML = lineageHtml(); + renderLinRows(window.__linVM); + applyLinHover(window.__linVM); + const svg = $('linSvg'); + if (svg) svg.addEventListener('mouseleave', () => setLinHover(null, window.__linVM)); + fitLineageTable(); + } + updatePhaseTag(); +} +window.addEventListener('hashchange', () => renderPage(pageFromHash())); +document.querySelectorAll('[data-nav]').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.nav; })); +let resizeT = null; +window.addEventListener('resize', () => { + clearTimeout(resizeT); + resizeT = setTimeout(() => { if (state.page === 'lineage') renderPage('lineage'); }, 180); +}); + +/* HEL-529: 血缘详情表随真实数据集数量(当前 17 条,非打样假设的 13 条) + 动态撑高;宽档(>1100px,覆盖 1920×920/1440×900 验收位)给表格容器一个 + 基于剩余可视高度计算的 max-height + 内部滚动,保证整页 scrollHeight + 始终 <= clientHeight(硬性验收项),同时全部行仍可在容器内滚动查看。 + 窄档(<=1100px)保持原有整页纵向滚动,不设上限。 */ +function fitLineageTable() { + const wrap = document.querySelector('.lin-scroll'); + const tape = document.querySelector('.tape'); + if (!wrap || !tape) return; + wrap.style.maxHeight = ''; + if (window.innerWidth <= 1100) return; + const tapeTop = tape.getBoundingClientRect().top; + const wrapTop = wrap.getBoundingClientRect().top; + const mainPadBottom = parseFloat(getComputedStyle(mainEl).paddingBottom) || 0; + const avail = tapeTop - wrapTop - mainPadBottom - 4; + if (avail > 0) wrap.style.maxHeight = Math.floor(avail) + 'px'; +} + +function updatePhaseTag() { + const ov = RAW.overview; + if (!ov) return; + $('phaseTag').innerHTML = `${ledHtml('rev', true)} ${esc(ov.session_phase || '')}`; +} + +mainEl.addEventListener('click', (e) => { + const pb = e.target.closest('[data-probe]'); + if (pb) { doProbe(pb.dataset.probe); return; } + const tg = e.target.closest('[data-toggle]'); + if (tg) { const ui = srcUiState[tg.dataset.toggle]; if (ui) { ui.open = !ui.open; renderSourceCards(); } return; } + const goto = e.target.closest('[data-goto-lineage]'); + if (goto) { linFilter.q = ''; location.hash = 'lineage'; return; } + const inc = e.target.closest('[data-incident-action]'); + if (inc) { openOpsDrawer('release', { dataset: inc.dataset.dataset, provider: inc.dataset.provider }); return; } + if (e.target.closest('#linBad')) { + linFilter.onlyBad = !linFilter.onlyBad; + renderLinRows(window.__linVM); + const btn = $('linBad'); + btn.classList.toggle('on', linFilter.onlyBad); + btn.innerHTML = `${ledHtml(linFilter.onlyBad ? 'fail' : 'plan')} 只看异常`; + return; + } +}); +mainEl.addEventListener('change', (e) => { if (e.target.id === 'linSel') { linFilter.src = e.target.value.split('· ')[1] || '全部'; renderLinRows(window.__linVM); } }); +mainEl.addEventListener('input', (e) => { if (e.target.id === 'linQ') { linFilter.q = e.target.value; renderLinRows(window.__linVM); } }); +mainEl.addEventListener('mouseover', (e) => { const g = e.target.closest('g[data-nid]'); if (g) setLinHover(g.dataset.nid, window.__linVM); }); + +async function doProbe(id) { + const ui = srcUiState[id]; + if (!ui || ui.probing) return; + ui.probing = true; + renderSourceCards(); + try { await api(`/admin/api/sources/${id}/probe`, { method: 'POST', body: '{}' }); } catch (err) { toast(err.message, 'err'); } + ui.probing = false; + await refreshQuiet(); + renderSourceCards(); +} + +/* ---------- EVENT TAPE:真实事件差量渲染 ---------- */ +const tapeSeen = { call: 0, provCall: 0, audit: 0, job: 0, first: true }; +function tapeEventsFromRaw() { + const evs = []; + ((RAW.overview && RAW.overview.recent_calls) || []).forEach((c) => { if (c.id > tapeSeen.call) evs.push({ ts: c.created_at, src: 'tushare', msg: c.endpoint, ok: c.ok, ms: c.latency_ms, sortId: 'c' + c.id }); }); + ((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => { if (c.id > tapeSeen.provCall) evs.push({ ts: c.fetched_at, src: c.provider, msg: c.interface, ok: c.status === 'ok', ms: c.latency_ms, sortId: 'p' + c.id }); }); + ((RAW.audit && RAW.audit.items) || []).forEach((a) => { if (a.id > tapeSeen.audit) evs.push({ ts: a.created_at, src: 'hub_admin', msg: `${a.action} ${a.target || ''}`.trim(), ok: true, sortId: 'a' + a.id }); }); + ((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => { if (r.id > tapeSeen.job) evs.push({ ts: r.finished_at || r.started_at, src: 'scheduler', msg: `${r.job_id} ${r.state}`, ok: r.state !== 'failed', sortId: 'j' + r.id }); }); + const maxOf = (arr, key) => arr.reduce((m, x) => Math.max(m, x[key] || 0), 0); + tapeSeen.call = Math.max(tapeSeen.call, maxOf((RAW.overview && RAW.overview.recent_calls) || [], 'id')); + tapeSeen.provCall = Math.max(tapeSeen.provCall, maxOf((RAW.providers && RAW.providers.recent_calls) || [], 'id')); + tapeSeen.audit = Math.max(tapeSeen.audit, maxOf((RAW.audit && RAW.audit.items) || [], 'id')); + tapeSeen.job = Math.max(tapeSeen.job, maxOf((RAW.jobs && RAW.jobs.runs) || [], 'id')); + return evs; +} +let tapeBuf = []; +function updateTape() { + const evs = tapeEventsFromRaw(); + if (tapeSeen.first) { tapeSeen.first = false; return; } // 首次加载只建基线,不补播历史事件 + if (!evs.length) return; + tapeBuf = tapeBuf.concat(evs).slice(-16); + const html = tapeBuf.map((e) => `${esc(hm(e.ts))}${esc(e.src)}${esc(e.msg)}` + + (e.ok ? `✓${e.ms ? ` ${e.ms}ms` : ''}` : '× 失败') + + `///`).join(''); + $('tapeA').innerHTML = html; $('tapeB').innerHTML = html; +} + +/* ---------- 抽屉:调度任务 / 盘后发布 / 审计 ---------- */ +let drawerTab = 'jobs'; +function openOpsDrawer(tab, ctx) { + drawerTab = tab || 'jobs'; + drawerCtx = ctx || {}; + $('drawerMask').classList.add('open'); + renderDrawer(); +} +function closeOpsDrawer() { $('drawerMask').classList.remove('open'); } +let drawerCtx = {}; +$('drawerClose').addEventListener('click', closeOpsDrawer); +$('drawerMask').addEventListener('click', (e) => { if (e.target.id === 'drawerMask') closeOpsDrawer(); }); +$('opsBtn').addEventListener('click', () => openOpsDrawer('jobs', {})); +$('drawerTabs').addEventListener('click', (e) => { + const b = e.target.closest('[data-dtab]'); + if (!b) return; + drawerTab = b.dataset.dtab; + renderDrawer(); +}); +function renderDrawer() { + $('drawerTabs').querySelectorAll('[data-dtab]').forEach((b) => b.classList.toggle('act', b.dataset.dtab === drawerTab)); + $('drawerSub').textContent = { jobs: '既有调度手动触发', release: '盘后发布 / 回滚 / 补数', audit: '操作审计日志' }[drawerTab] || ''; + if (drawerTab === 'jobs') renderDrawerJobs(); + else if (drawerTab === 'release') renderDrawerRelease(); + else renderDrawerAudit(); +} +function renderDrawerJobs() { + const jobs = (RAW.jobs && RAW.jobs.jobs) || []; + const runs = (RAW.jobs && RAW.jobs.runs) || []; + const runsByJob = {}; + runs.forEach((r) => { (runsByJob[r.job_id] = runsByJob[r.job_id] || []).push(r); }); + $('drawerBody').innerHTML = ` +
+
调度任务定义
+ + ${jobs.map((j) => ` + `).join('')} +
任务时刻操作
${esc(j.id)} · ${esc(j.title)}${esc(j.at)}
-

当前映射

- ${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], data.publications.map((row) => [ - esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at), - row.prev_batch ? `` : "-", - ]))} -

批次

- ${table(["batch_id", "数据集", "状态", "行数", "错误"], data.batches.map((row) => [ - esc(row.batch_id), esc(row.dataset), esc(row.state), row.rows_out ?? "", esc(row.error), - ]))} - `; +
+
最近运行(最新 20 条)
+ + ${runs.slice(0, 20).map((r) => ``).join('') || ''} +
ID任务状态开始结束错误
${r.id}${esc(r.job_id)}${pillHtml(bucketOf(r.state))}${esc(r.started_at || '')}${esc(r.finished_at || '')}${esc(r.error || '')}
暂无运行记录
+
`; + $('drawerBody').querySelectorAll('[data-run]').forEach((btn) => { + btn.addEventListener('click', async () => { + const dateInput = $('drawerBody').querySelector(`[data-run-date="${btn.dataset.run}"]`); + try { + await api(`/admin/api/jobs/${btn.dataset.run}/run`, { method: 'POST', body: JSON.stringify({ trade_date: (dateInput && dateInput.value) || '' }) }); + toast('已触发:' + btn.dataset.run, 'ok'); + await refreshQuiet(); renderDrawer(); + } catch (err) { toast(err.message, 'err'); } + }); + }); +} +function renderDrawerRelease() { + const date = drawerCtx.date || today(); + $('drawerBody').innerHTML = ` +
+ +
+
`; + $('relLoad').addEventListener('click', async () => { drawerCtx.date = $('relDate').value; await loadRelease(); }); + $('relBackfill').addEventListener('click', () => openDangerModal('backfill', { dataset: drawerCtx.dataset || '', date: drawerCtx.date || date })); + loadRelease(); +} +async function loadRelease() { + const date = drawerCtx.date || today(); + let data; + try { data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`); } catch (err) { toast(err.message, 'err'); return; } + $('relBody').innerHTML = ` +
发布映射
+ + ${data.publications.map((row) => ` + `).join('') || ''} +
数据集活跃批次上一批次状态发布时间操作
${esc(row.dataset)}${esc(row.active_batch)}${esc(row.prev_batch || '')}${pillHtml(bucketOf(row.state))}${esc(row.published_at || '')}${row.prev_batch ? `` : '—'}
当日暂无发布记录
+
批次
+ + ${data.batches.map((row) => ``).join('') || ''} +
batch_id数据集状态行数错误
${esc(row.batch_id)}${esc(row.dataset)}${pillHtml(bucketOf(row.state))}${row.rows_out ?? ''}${esc(row.error || '')}
当日暂无批次
`; + $('relBody').querySelectorAll('[data-rollback]').forEach((btn) => btn.addEventListener('click', () => openDangerModal('rollback', { dataset: btn.dataset.rollback, date }))); +} +function renderDrawerAudit() { + const items = (RAW.audit && RAW.audit.items) || []; + $('drawerBody').innerHTML = ` + ${items.map((row) => ``).join('') || ''} +
时间操作者动作对象详情
${esc(row.created_at)}${esc(row.actor)}${esc(row.action)}${esc(row.target || '')}${esc(row.detail || '')}
暂无审计记录
`; } -async function dangerous(kind, dataset) { - const date = ($("rel-date") && $("rel-date").value) || ""; - const ds = dataset || prompt("数据集(daily/valuation/moneyflow/auction/stocks→A组整批;index_daily→B组;或 reference)", "daily"); - if (!ds) return; - const password = prompt("二次确认:输入管理密码"); - if (!password) return; - const confirmWord = `${ds}:${date}`; - const typed = prompt(`请输入确认词:${confirmWord}`); - const path = kind === "rollback" ? "/admin/api/rollback" : "/admin/api/backfill"; - await api(path, { - method: "POST", - body: JSON.stringify({ dataset: ds, trade_date: date, password, confirm: typed }), - }); - render(); +/* ---------- 危险操作确认弹层(回滚 / 补数) ---------- */ +let dangerCtx = null; +function openDangerModal(kind, ctx) { + dangerCtx = { kind, ...ctx }; + const word = `${ctx.dataset}:${ctx.date}`; + $('modalTitle').textContent = kind === 'rollback' ? '确认回滚' : '确认补数'; + $('modalDesc').textContent = kind === 'rollback' + ? `将把 ${ctx.dataset} 在 ${ctx.date} 的发布回退到上一批次,需管理密码与确认词。` + : `将对 ${ctx.dataset || '(请先在发布列表选择数据集)'} 于 ${ctx.date} 重新补数并发布,需管理密码与确认词。`; + $('modalConfirmWord').textContent = word; + $('modalPassword').value = ''; $('modalConfirm').value = ''; $('modalErr').textContent = ''; + $('modalMask').classList.add('open'); +} +$('modalCancel').addEventListener('click', () => $('modalMask').classList.remove('open')); +$('modalMask').addEventListener('click', (e) => { if (e.target.id === 'modalMask') $('modalMask').classList.remove('open'); }); +$('modalOk').addEventListener('click', async () => { + if (!dangerCtx) return; + const password = $('modalPassword').value; + const confirm = $('modalConfirm').value; + const path = dangerCtx.kind === 'rollback' ? '/admin/api/rollback' : '/admin/api/backfill'; + try { + await api(path, { method: 'POST', body: JSON.stringify({ dataset: dangerCtx.dataset, trade_date: dangerCtx.date, password, confirm }) }); + $('modalMask').classList.remove('open'); + toast('操作成功', 'ok'); + await refreshQuiet(); + if (state.page === 'overview') renderPage('overview'); + if (drawerTab === 'release') loadRelease(); + } catch (err) { + $('modalErr').textContent = err.message; + } +}); + +/* ---------- toast ---------- */ +let toastT = null; +function toast(msg, kind) { + const el = $('toastEl'); + el.textContent = msg; + el.className = 'toast show' + (kind ? ' ' + kind : ''); + clearTimeout(toastT); + toastT = setTimeout(() => { el.classList.remove('show'); }, 3200); +} + +/* ================= 顶栏:时钟 / 减少动态 / 主题 ================= */ +function updateClock() { + const now = new Date(); + $('ckD').textContent = `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())}`; + $('ckH').textContent = p2(now.getHours()); + $('ckM').textContent = p2(now.getMinutes()); + $('ckS').textContent = p2(now.getSeconds()); +} +function applyCalm(on) { + state.calm = on; + $('appRoot').classList.toggle('reduce-motion', on); + $('calmBtn').textContent = on ? '恢复动态' : '减少动态'; + $('calmBtn').classList.toggle('on', on); +} +$('calmBtn').addEventListener('click', () => applyCalm(!state.calm)); +if (window.matchMedia) { + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + const syncMq = () => { if (mq.matches && !state.calm) applyCalm(true); }; + syncMq(); + mq.addEventListener ? mq.addEventListener('change', syncMq) : mq.addListener(syncMq); +} + +/* ================= 轮询:拉取真实数据、驱动渲染、可见性/网络门禁 ================= */ +const Poller = (() => { + let timer = null; + let running = false; + const INTERVAL = 4000; + async function tick() { + if (!running) return; + try { + await fetchAll(); + updateTape(); + if (state.page === 'overview') mainEl.innerHTML = overviewHtml(); + else if (state.page === 'sources') renderSourceCards(); + else if (state.page === 'lineage') { /* 血缘图交互态复杂,仅在用户操作或下次轮询后整体重绘一次 */ mainEl.innerHTML = lineageHtml(); renderLinRows(window.__linVM); applyLinHover(window.__linVM); fitLineageTable(); } + updatePhaseTag(); + setNetOk(true); + } catch (err) { + setNetOk(false); + } + } + function setNetOk(ok) { + $('liveSpan').style.opacity = ok ? '1' : '.35'; + } + function scheduleNext() { timer = setTimeout(async () => { await tick(); scheduleNext(); }, INTERVAL); } + return { + async start() { + if (running) return; + running = true; + await tick(); + scheduleNext(); + }, + stop() { running = false; if (timer) clearTimeout(timer); timer = null; }, + isRunning() { return running; }, + }; +})(); + +function resetTapeBaselines() { + // 恢复轮询时静默重建基线,不补播暂停期间错过的事件。 + tapeSeen.call = tapeSeen.provCall = tapeSeen.audit = tapeSeen.job = Number.MAX_SAFE_INTEGER; + tapeSeen.first = true; +} +document.addEventListener('visibilitychange', () => { + if (document.hidden) { Poller.stop(); } + else if (!$('appRoot').hidden) { resetTapeBaselines(); Poller.start(); } +}); +window.addEventListener('offline', () => { Poller.stop(); $('liveSpan').style.opacity = '.35'; }); +window.addEventListener('online', () => { if (!$('appRoot').hidden && !document.hidden) { resetTapeBaselines(); Poller.start(); } }); + +async function refreshQuiet() { + try { await fetchAll(); } catch {} +} + +/* ================= 启动 ================= */ +async function startApp() { + setInterval(updateClock, 1000); + updateClock(); + await fetchAll(); + tapeSeen.first = true; + renderPage(pageFromHash()); + await Poller.start(); } boot(); diff --git a/xiaobai-datahub/admin/index.html b/xiaobai-datahub/admin/index.html index b7eb893..a9493c2 100644 --- a/xiaobai-datahub/admin/index.html +++ b/xiaobai-datahub/admin/index.html @@ -1,16 +1,18 @@ - - - xiaobai-datahub 管理后台 - + + +小白复盘 · 数据中枢 + -
-
+
+ + -
+ diff --git a/xiaobai-datahub/admin/styles.css b/xiaobai-datahub/admin/styles.css index 1631ac1..df7ab2d 100644 --- a/xiaobai-datahub/admin/styles.css +++ b/xiaobai-datahub/admin/styles.css @@ -1,51 +1,527 @@ +/* JetBrains Mono (variable, latin subset) — 内嵌,零外部依赖 */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 100 800; + font-display: swap; + src: url(data:font/woff2;base64,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) format('woff2'); +} + +/* ================= index.css 逐行照抄(去掉 tailwind 指令) ================= */ :root { - color-scheme: light; - --bg: #f4f5f7; - --surface: #ffffff; - --text: #1f2329; - --muted: #646a73; - --line: #dee0e3; - --action: #3370ff; - --danger: #e04536; - --ok: #16a34a; - --warn: #b45309; - --radius: 8px; - --pad: 16px; - font-family: "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif; + --ink: #050810; + --panel: #0c1220; + --panel2: #111a2b; + --line: #1a2540; + --line2: #243152; + --cy: #22d3ee; + --mint: #34d399; + --amb: #fbbf24; + --rd: #f87171; + --txt: #d7e1f0; + --mut: #8b9bb4; + --dim: #54637e; } -:root[data-theme="night"] { - color-scheme: dark; - --bg: #111318; - --surface: #1b1e24; - --text: #e8eaed; - --muted: #9aa0a6; - --line: #2a2f38; - --action: #5b8cff; + +html, body, #root { height: 100%; } +html, body { margin: 0; } + +/* HEL-529 生产实现补充:确保原生 [hidden] 属性不被下方任何 display 规则 + (如 .auth-wrap/.app 等 flex/relative 布局)覆盖——两者选择器优先级相同时 + 源码顺序在后的规则会赢,必须显式声明 !important 兜底。 */ +[hidden] { display: none !important; } + +body { + background: var(--ink); + color: var(--txt); + font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", monospace; + font-size: 13px; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +/* ---------- ambient background: radial depth + grid + scanlines ---------- */ +.bg-ambient { + background: + radial-gradient(1200px 500px at 70% -10%, rgba(34, 211, 238, 0.055), transparent 60%), + radial-gradient(900px 420px at 10% 110%, rgba(52, 211, 153, 0.04), transparent 60%), + radial-gradient(700px 500px at 100% 100%, rgba(248, 113, 113, 0.03), transparent 60%), + var(--ink); +} +.bg-gridlines { + background-image: + linear-gradient(rgba(26, 37, 64, 0.35) 1px, transparent 1px), + linear-gradient(90deg, rgba(26, 37, 64, 0.35) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: radial-gradient(120% 90% at 50% 0%, black 30%, transparent 90%); + -webkit-mask-image: radial-gradient(120% 90% at 50% 0%, black 30%, transparent 90%); +} +.scanlines { + background: repeating-linear-gradient( + to bottom, + rgba(255, 255, 255, 0.022) 0px, + rgba(255, 255, 255, 0.022) 1px, + transparent 1px, + transparent 3px + ); +} + +/* ---------- typography helpers ---------- */ +.lab { + font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--dim); + font-weight: 500; +} +.num { font-variant-numeric: tabular-nums; } + +/* ---------- panels ---------- */ +.panel { + background: linear-gradient(180deg, rgba(17, 26, 43, 0.6), rgba(12, 18, 32, 0.9)); + border: 1px solid var(--line); + border-radius: 6px; + position: relative; +} +.panel-hd { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--line); + font-size: 11px; + letter-spacing: 0.14em; + color: var(--mut); +} +.panel-hd b { color: var(--txt); font-weight: 600; } + +/* ---------- glow status LED ---------- */ +.led { + width: 7px; height: 7px; border-radius: 9999px; + display: inline-block; flex: none; + position: relative; +} +.led.ok { background: var(--mint); box-shadow: 0 0 6px 1px rgba(52,211,153,.7); } +.led.slow { background: var(--amb); box-shadow: 0 0 6px 1px rgba(251,191,36,.7); } +.led.fail { background: var(--rd); box-shadow: 0 0 6px 1px rgba(248,113,113,.8); } +.led.rev { background: var(--cy); box-shadow: 0 0 6px 1px rgba(34,211,238,.7); } +.led.plan { background: #475569; box-shadow: none; } +.led.off { background: transparent; border: 1px dashed #475569; box-shadow: none; } +.led.pulse::after { + content: ""; position: absolute; inset: -4px; border-radius: 9999px; + border: 1px solid currentColor; opacity: .6; + animation: ledPing 1.6s ease-out infinite; +} +.led.ok.pulse { color: var(--mint); } +.led.fail.pulse { color: var(--rd); } +.led.rev.pulse { color: var(--cy); } +.led.slow.pulse { color: var(--amb); } +@keyframes ledPing { + 0% { transform: scale(.4); opacity: .7; } + 80%, 100% { transform: scale(1.4); opacity: 0; } +} + +/* ---------- text glow ---------- */ +.glow-cy { color: var(--cy); text-shadow: 0 0 12px rgba(34,211,238,.45); } +.glow-mint { color: var(--mint); text-shadow: 0 0 12px rgba(52,211,153,.45); } +.glow-amb { color: var(--amb); text-shadow: 0 0 12px rgba(251,191,36,.45); } +.glow-rd { color: var(--rd); text-shadow: 0 0 12px rgba(248,113,113,.5); } + +/* ---------- blinking / pulsing ---------- */ +.blink { animation: blink 1.1s steps(2, start) infinite; } +@keyframes blink { 50% { opacity: 0; } } +.pulse-dot { animation: pulseDot 1.5s ease-in-out infinite; } +@keyframes pulseDot { 0%,100% { opacity: 1; } 50% { opacity: .35; } } + +/* ---------- flash on value change ---------- */ +.flash { animation: flash .45s ease-out; } +@keyframes flash { + 0% { background: rgba(34, 211, 238, 0.22); color: #e8fbff; } + 100% { background: transparent; } +} + +/* ---------- ticker marquee ---------- */ +.marquee-track { + display: inline-flex; + white-space: nowrap; + animation: marquee 46s linear infinite; + will-change: transform; +} +.marquee-track:hover { animation-play-state: paused; } +@keyframes marquee { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} + +/* ---------- flow lines (lineage / timeline) ---------- */ +.flowline { + stroke-dasharray: 4 6; + animation: flowDash 1.4s linear infinite; +} +@keyframes flowDash { to { stroke-dashoffset: -10; } } + +/* ---------- radar sweep ---------- */ +.radar-sweep { + background: conic-gradient(from 0deg, rgba(34,211,238,.55), transparent 22%, transparent); + animation: sweep 4.2s linear infinite; + border-radius: 9999px; +} +@keyframes sweep { to { transform: rotate(360deg); } } + +/* ---------- buttons ---------- */ +.tbtn { + font-size: 11px; letter-spacing: .1em; + border: 1px solid var(--line2); + color: var(--mut); + padding: 4px 12px; border-radius: 4px; + background: rgba(17,26,43,.5); + transition: all .15s ease; + cursor: pointer; + font-family: inherit; +} +.tbtn:hover { color: var(--cy); border-color: rgba(34,211,238,.5); box-shadow: 0 0 10px rgba(34,211,238,.15); } +.tbtn:active { transform: translateY(1px); } +.tbtn:disabled { opacity: .45; cursor: default; } +.tbtn.on { color: var(--cy); border-color: rgba(34,211,238,.6); background: rgba(34,211,238,.08); } + +/* ---------- table ---------- */ +.dtable { width: 100%; border-collapse: collapse; } +.dtable th { + font-size: 10px; letter-spacing: .16em; text-transform: uppercase; + color: var(--dim); font-weight: 500; text-align: left; + padding: 7px 10px; border-bottom: 1px solid var(--line); +} +.dtable td { padding: 7px 10px; border-bottom: 1px solid rgba(26,37,64,.55); vertical-align: middle; } +.dtable tr:last-child td { border-bottom: none; } +.dtable tbody tr { transition: background .12s ease; } +.dtable tbody tr:hover { background: rgba(34,211,238,.04); } + +/* ---------- scrollbar ---------- */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: var(--ink); } +::-webkit-scrollbar-thumb { background: var(--line2); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #31406b; } +* { scrollbar-width: thin; scrollbar-color: var(--line2) var(--ink); } + +/* ---------- reduced motion ---------- */ +.reduce-motion *, .reduce-motion *::before, .reduce-motion *::after { + animation-duration: .001s !important; + animation-iteration-count: 1 !important; + transition-duration: .001s !important; +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: .001s !important; + animation-iteration-count: 1 !important; + transition-duration: .001s !important; + } +} + +/* ================= 结构样式(对应 App.tsx / chrome.tsx 的 tailwind 类) ================= */ +#root { min-height: 100%; } +.app { min-height: 100%; position: relative; } +.bg-layer { position: fixed; inset: 0; pointer-events: none; } +.z0 { z-index: 0; } .z50 { z-index: 50; } +.scanlines.z50 { opacity: .7; } + +/* ---------- 顶栏 ---------- */ +.hdr { position: sticky; top: 0; z-index: 30; border-bottom: 1px solid #1a2540; background: rgba(5,8,16,.88); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } +.hdr-in { display: flex; align-items: center; gap: 12px; padding: 0 16px; height: 48px; } +.hdr-in > * { flex: none; } +.hdr-in > .flex1 { flex: 1; } +.brand { display: flex; flex-direction: column; line-height: 1; flex: none; white-space: nowrap; } +.brand .b1 { font-size: 13px; font-weight: 600; color: #e8f1ff; letter-spacing: .06em; } +.brand .b2 { font-size: 9px; color: #54637e; letter-spacing: .26em; margin-top: 4px; } +.vsep { width: 1px; height: 16px; background: #1a2540; margin: 0 4px; display: inline-block; flex: none; } +.nav { display: flex; align-items: center; gap: 4px; margin-left: 8px; flex: none; } +.navbtn { position: relative; padding: 6px 14px; font-size: 12px; letter-spacing: .08em; border-radius: 4px; transition: all .15s; background: none; border: none; cursor: pointer; color: #8b9bb4; font-family: inherit; } +.navbtn:hover { color: #d7e1f0; background: rgba(34,211,238,.04); } +.navbtn.act { color: #22d3ee; background: rgba(34,211,238,.08); box-shadow: inset 0 0 0 1px rgba(34,211,238,.35), 0 0 14px rgba(34,211,238,.12); } +.navbtn .tri { margin-right: 4px; font-size: 9px; display: none; } +.navbtn.act .tri { display: inline; } +.navbtn .en { margin-left: 6px; font-size: 9px; letter-spacing: .14em; color: #3d4c66; } +.navbtn.act .en { color: rgba(34,211,238,.6); } +.flex1 { flex: 1; } +.mdtag { display: none; align-items: center; gap: 8px; border-radius: 3px; border: 1px solid rgba(34,211,238,.4); background: rgba(34,211,238,.07); padding: 4px 8px; font-size: 10px; letter-spacing: .14em; color: #22d3ee; } +@media (min-width: 768px) { .mdtag { display: flex; } } +@media (max-width: 1150px) { .mdtag { display: none; } } +.livespan { display: none; align-items: center; gap: 6px; font-size: 10px; letter-spacing: .18em; color: #34d399; } +@media (min-width: 640px) { .livespan { display: flex; } } +.livedot { width: 6px; height: 6px; border-radius: 9999px; background: #34d399; box-shadow: 0 0 6px 1px rgba(52,211,153,.7); } +.clock { font-size: 12px; color: #8b9bb4; letter-spacing: .05em; white-space: nowrap; } +.clock .csep { margin: 0 6px; color: #243152; } +.clock .ct { color: #d7e1f0; } +.clock .cc { color: #22d3ee; } +.demotag { display: inline-flex; align-items: center; gap: 4px; border-radius: 3px; border: 1px solid rgba(251,191,36,.45); background: rgba(251,191,36,.06); color: #fbbf24; padding: 2px 6px; font-size: 10px; letter-spacing: .08em; white-space: nowrap; } + +/* ---------- 雷达 logo ---------- */ +.radar { position: relative; flex: none; border-radius: 9999px; border: 1px solid rgba(34,211,238,.5); overflow: hidden; box-shadow: 0 0 12px rgba(34,211,238,.25), inset 0 0 8px rgba(34,211,238,.15); } +.radar .sweep { position: absolute; inset: 0; } +.radar .ring1 { position: absolute; border-radius: 9999px; border: 1px solid rgba(34,211,238,.3); } +.radar .ring2 { position: absolute; border-radius: 9999px; border: 1px solid rgba(34,211,238,.2); } +.radar .center { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 3px; height: 3px; border-radius: 9999px; background: #22d3ee; box-shadow: 0 0 6px 2px rgba(34,211,238,.8); } +.radar .blip { position: absolute; width: 2.5px; height: 2.5px; border-radius: 9999px; } + +/* ---------- EVENT TAPE ---------- */ +.tape { position: fixed; bottom: 0; left: 0; right: 0; height: 32px; z-index: 40; display: flex; align-items: center; border-top: 1px solid #1a2540; background: rgba(5,8,16,.92); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); overflow: hidden; } +.tape-label { flex: none; display: flex; align-items: center; gap: 6px; padding: 0 12px; height: 100%; border-right: 1px solid #1a2540; font-size: 10px; letter-spacing: .18em; color: #54637e; } +.tape-view { flex: 1; overflow: hidden; position: relative; } +.tape-item { display: inline-flex; align-items: center; gap: 8px; margin: 0 20px; font-size: 10.5px; letter-spacing: .04em; } +.tape-item .tt { color: #54637e; } +.tape-item .ts { color: #22d3ee; } +.tape-item .tm { color: #8b9bb4; } +.tape-item .tok { color: #34d399; } +.tape-item .tbad { color: #f87171; } +.tape-item .tsep { color: #1a2540; margin-left: 12px; } + +/* ---------- 主区 ---------- */ +.main { position: relative; z-index: 10; padding: 12px 16px 48px; max-width: 1720px; margin: 0 auto; } +.col { display: flex; flex-direction: column; gap: 12px; } + +/* ---------- tag / pill ---------- */ +.tag { display: inline-flex; align-items: center; gap: 4px; border-radius: 3px; border: 1px solid #243152; padding: 2px 6px; font-size: 10px; letter-spacing: .08em; line-height: 1.5; color: #8b9bb4; white-space: nowrap; } +.tag.cy { border-color: rgba(34,211,238,.45); color: #22d3ee; background: rgba(34,211,238,.06); } +.tag.amb { border-color: rgba(251,191,36,.45); color: #fbbf24; background: rgba(251,191,36,.06); } +.tag.rd { border-color: rgba(248,113,113,.45); color: #f87171; background: rgba(248,113,113,.07); } +.tag.mint { border-color: rgba(52,211,153,.45); color: #34d399; background: rgba(52,211,153,.06); } +.tag.dashed { border-style: dashed; border-color: #243152; color: #54637e; } +.minidot { width: 6px; height: 6px; border-radius: 9999px; display: inline-block; } +.spill { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; white-space: nowrap; } + +/* ---------- 总览 Hero ---------- */ +.hero { padding: 16px 20px; display: flex; flex-wrap: wrap; align-items: center; gap: 16px 32px; } +.hero-l { display: flex; flex-direction: column; gap: 8px; min-width: 300px; } +.hero-title { font-size: 22px; font-weight: 600; letter-spacing: .025em; } +.hero-title .dot { color: #54637e; margin: 0 8px; } +.hero-tags { display: flex; align-items: center; gap: 8px; } +.hero-sub { font-size: 11px; color: #54637e; } +.hero-sub .sep { margin: 0 8px; color: #243152; } +.kpis { display: flex; align-items: flex-start; gap: 32px; padding-right: 8px; } +.kpi { display: flex; flex-direction: column; gap: 4px; } +.kpi .kv { font-size: 26px; line-height: 1; font-weight: 600; letter-spacing: -.025em; color: #e8f1ff; } +.kpi .ks { font-size: 10px; color: #54637e; } + +/* ---------- 数据集卡 ---------- */ +.ds-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; } +@media (min-width: 640px) { .ds-grid { grid-template-columns: repeat(5, 1fr); } } +@media (min-width: 1280px) { .ds-grid { grid-template-columns: repeat(10, 1fr); } } +.dsc { border-radius: 5px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; transition: all .15s; cursor: default; border: 1px solid #1a2540; } +.dsc:hover { transform: translateY(-1px); border-color: #243152; } +.dsc.fail { border-color: rgba(248,113,113,.55); box-shadow: 0 0 18px rgba(248,113,113,.14); } +.dsc.fail:hover { border-color: rgba(248,113,113,.55); } +.dsc.slow { border-color: rgba(251,191,36,.4); } +.dsc.slow:hover { border-color: rgba(251,191,36,.4); } +.dsc.review { border-color: rgba(34,211,238,.45); box-shadow: 0 0 14px rgba(34,211,238,.1); } +.dsc.review:hover { border-color: rgba(34,211,238,.45); } +.dsc-top { display: flex; align-items: center; justify-content: space-between; } +.dsc-name { font-size: 12px; font-weight: 500; color: #e8f1ff; } +.dsc-note { font-size: 10px; color: #54637e; line-height: 1.6; min-height: 28px; } + +/* ---------- 总览下半网格 ---------- */ +.ov-grid { display: grid; grid-template-columns: 1fr; gap: 12px; } +@media (min-width: 1280px) { .ov-grid { grid-template-columns: repeat(12, 1fr); } .ov-l { grid-column: span 7; } .ov-r { grid-column: span 5; } } +.panel-b { padding: 12px; } +.legend { display: flex; align-items: center; gap: 12px; font-size: 10px; letter-spacing: .1em; } +.legend span { display: flex; align-items: center; gap: 4px; } +.jit { padding: 0 4px; border-radius: 3px; } +.obs-note { font-size: 10px; color: #fbbf24; } +.site-foot { padding: 10px 12px; border-top: 1px solid #1a2540; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; font-size: 10px; color: #54637e; } +.tbtn.mini { padding: 3px 8px; } + +/* ---------- 时间线 ---------- */ +@keyframes nowPulse { 0%,100% { opacity: 1; } 50% { opacity: .5; } } +.now-dot { animation: nowPulse 1.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } +@keyframes nowR { 0%,100% { r: 3.6px; } 50% { r: 5.2px; } } +.now-r { animation: nowR 1.6s ease-in-out infinite; } + +/* ---------- 异常卡 ---------- */ +.inc { display: flex; align-items: center; gap: 12px; border-radius: 4px; border: 1px solid #1a2540; border-left-width: 3px; padding: 10px 12px; } +.inc.fail { border-left-color: #f87171; background: rgba(248,113,113,.05); } +.inc.slow { border-left-color: #fbbf24; background: rgba(251,191,36,.04); } +.inc.off { border-left-color: #334155; background: transparent; border-style: dashed; border-color: #243152; border-left-width: 3px; border-left-style: solid; border-left-color: #334155; } +.inc-title { font-size: 12px; color: #e8f1ff; } +.inc-meta { font-size: 10px; color: #54637e; margin-top: 2px; } + +/* ---------- Sources 页 ---------- */ +.strip { padding: 12px 20px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 24px; } +.src-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 12px 16px; border-bottom: 1px solid #1a2540; } +.src-name { font-size: 15px; font-weight: 600; color: #e8f1ff; letter-spacing: .025em; } +.src-role { font-size: 10px; color: #54637e; letter-spacing: .1em; } +.src-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; border-bottom: 1px solid #1a2540; font-size: 11px; color: #8b9bb4; } +.src-meta .msep { color: #243152; } +.mx-grid { display: grid; grid-template-columns: 1fr; gap: 16px 24px; padding: 16px; } +@media (min-width: 768px) { .mx-grid { grid-template-columns: repeat(2, 1fr); } } +@media (min-width: 1280px) { .mx-grid { grid-template-columns: repeat(3, 1fr); } } +.mx-grid > div { min-width: 0; } +.mx-grid table { table-layout: auto; max-width: 100%; } +.offbox { border-radius: 4px; border: 1px dashed #243152; padding: 24px 16px; text-align: center; font-size: 11px; color: #54637e; } +.rsv-grid { display: grid; grid-template-columns: 1fr; gap: 12px; } +@media (min-width: 768px) { .rsv-grid { grid-template-columns: repeat(3, 1fr); } } +.rsv { border-radius: 6px; border: 1px dashed #243152; padding: 16px; display: flex; align-items: center; gap: 12px; color: #54637e; } +@keyframes spin { to { transform: rotate(360deg); } } +.animate-spin { display: inline-block; animation: spin 1s linear infinite; } + +/* ---------- Lineage 页 ---------- */ +.lin-cols { display: flex; justify-content: space-between; padding: 4px 24px 0; font-size: 10px; letter-spacing: .2em; color: #54637e; } +.lin-hint { position: absolute; left: 24px; bottom: 4px; font-size: 10px; color: #54637e; letter-spacing: .1em; } +@keyframes gnodePulse { 0%,100% { opacity: 1; } 50% { opacity: .55; } } +.gnode-pulse { animation: gnodePulse 1.8s ease-in-out infinite; } +.lin-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid #1a2540; } +.lin-sel, .lin-q { background: #0c1220; border: 1px solid #243152; border-radius: 4px; font-size: 11px; color: #8b9bb4; padding: 6px 8px; outline: none; font-family: inherit; } +.lin-q { color: #d7e1f0; padding: 6px 10px; width: 220px; } +.lin-q::placeholder { color: #3d4c66; } +.lin-sel:focus, .lin-q:focus { border-color: rgba(34,211,238,.5); } +.lin-scroll { overflow-x: auto; } +/* HEL-529:宽档下由 app.js 的 fitLineageTable() 按剩余可视高度写入 + max-height(真实数据集 17 条 > 打样假设的 13 条),此处只声明纵向滚动本身; + 未设置 max-height(窄档 <=1100px)时 overflow-y:auto 不产生任何视觉影响。 */ +.lin-scroll { overflow-y: auto; } +.lin-table { min-width: 1080px; } +.lin-table-n { display: none; } +.lin-table-n .sub { display: block; font-size: 10px; color: #54637e; margin-top: 2px; } +/* ---------- Lineage 页压缩(仅血缘页,其他页不受影响) ---------- */ +#mainEl[data-page="lineage"] .col { gap: 8px; } +#mainEl[data-page="lineage"] { padding-bottom: 36px; } +#mainEl[data-page="lineage"] .strip { padding: 7px 16px; gap: 6px 20px; } +#mainEl[data-page="lineage"] .lin-filter { padding: 6px 12px; } +#mainEl[data-page="lineage"] .dtable th { padding: 5px 10px; } +#mainEl[data-page="lineage"] .dtable td { padding: 4px 10px; } +@media (max-width: 1100px) { + .hdr-in { flex-wrap: wrap; height: auto; padding-top: 6px; padding-bottom: 6px; row-gap: 6px; } + .hdr-in > .flex1 { flex: 1 1 100%; height: 0; } + .mdtag { display: flex; } + .lin-table { display: none; } + .lin-table-n { display: table; width: 100%; } +} +.row-fail { background: rgba(248,113,113,.045); } +.row-slow { background: rgba(251,191,36,.03); } +.row-off { opacity: .55; } + +/* ---------- sparkline ---------- */ +@keyframes sparkBlink { 0%,100% { opacity: 1; } 50% { opacity: .3; } } +.spark-end { animation: sparkBlink 1.4s ease-in-out infinite; } + +/* ================================================================= + 以下为 HEL-529 第十二版生产实现补充样式(非 Kimi 打样内容): + 登录/改密视图、抽屉(调度任务/盘后发布/审计合并入口)、危险操作弹层。 + 全部复用上方 token(--ink/--panel/--line/--cy/--mint/--amb/--rd 等), + 不引入新色值、不新增动效缓动函数。 + ================================================================= */ + +/* ---------- 登录 / 改密(保留原有表单结构,套用新 token) ---------- */ +.auth-wrap { + min-height: 100%; display: flex; align-items: center; justify-content: center; + padding: 24px; +} +.auth-panel { + width: 100%; max-width: 380px; padding: 28px 26px 24px; +} +.auth-panel h1 { font-size: 16px; font-weight: 600; color: #e8f1ff; margin: 0 0 6px; letter-spacing: .02em; } +.auth-panel .muted { font-size: 11px; color: var(--dim); line-height: 1.6; margin: 0 0 18px; } +.auth-panel form { display: flex; flex-direction: column; gap: 12px; } +.auth-panel label { display: flex; flex-direction: column; gap: 6px; font-size: 11px; color: var(--mut); letter-spacing: .04em; } +.auth-panel input { + background: #0c1220; border: 1px solid var(--line2); border-radius: 4px; + color: var(--txt); font-family: inherit; font-size: 13px; padding: 8px 10px; outline: none; +} +.auth-panel input:focus { border-color: rgba(34,211,238,.55); box-shadow: 0 0 0 2px rgba(34,211,238,.12); } +.auth-panel button[type="submit"] { + margin-top: 4px; font-size: 12px; letter-spacing: .08em; border: 1px solid rgba(34,211,238,.5); + color: #0a1420; background: var(--cy); padding: 9px 12px; border-radius: 4px; cursor: pointer; + font-family: inherit; font-weight: 600; transition: filter .15s ease; +} +.auth-panel button[type="submit"]:hover { filter: brightness(1.08); } +.auth-panel .error { color: var(--rd); font-size: 11px; margin: 2px 0 0; } + +/* ---------- 抽屉(调度任务 / 盘后发布 / 审计) ---------- */ +.drawer-mask { + position: fixed; inset: 0; background: rgba(2,4,10,.55); z-index: 60; + opacity: 0; pointer-events: none; transition: opacity .2s ease-out; +} +.drawer-mask.open { opacity: 1; pointer-events: auto; } +.drawer { + position: fixed; top: 0; right: 0; bottom: 0; width: min(720px, 92vw); + background: linear-gradient(180deg, rgba(17,26,43,.98), rgba(9,13,23,.99)); + border-left: 1px solid var(--line); z-index: 61; display: flex; flex-direction: column; + transform: translateX(100%); transition: transform .28s cubic-bezier(.16,1,.3,1); + box-shadow: -18px 0 40px rgba(0,0,0,.4); +} +.drawer-mask.open .drawer { transform: translateX(0); } +.drawer-hd { + display: flex; align-items: center; gap: 10px; padding: 14px 18px; + border-bottom: 1px solid var(--line); flex: none; +} +.drawer-hd .ttl { font-size: 13px; font-weight: 600; color: #e8f1ff; letter-spacing: .04em; } +.drawer-hd .sub { font-size: 10px; color: var(--dim); } +.drawer-tabs { display: flex; gap: 4px; padding: 10px 18px 0; flex: none; } +.drawer-tab { + font-size: 11px; letter-spacing: .06em; color: var(--mut); background: none; border: none; + padding: 6px 12px; border-radius: 4px 4px 0 0; cursor: pointer; font-family: inherit; + border-bottom: 2px solid transparent; +} +.drawer-tab.act { color: var(--cy); border-bottom-color: var(--cy); background: rgba(34,211,238,.06); } +.drawer-body { flex: 1; overflow-y: auto; padding: 14px 18px 24px; } +.drawer-close { + margin-left: auto; background: none; border: 1px solid var(--line2); color: var(--mut); + width: 26px; height: 26px; border-radius: 4px; cursor: pointer; font-size: 13px; line-height: 1; + font-family: inherit; +} +.drawer-close:hover { color: var(--cy); border-color: rgba(34,211,238,.5); } +.drawer-section { margin-bottom: 18px; } +.drawer-section .lab { margin-bottom: 8px; } +.dtoolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; } +.dtoolbar input { + background: #0c1220; border: 1px solid var(--line2); border-radius: 4px; color: var(--txt); + font-family: inherit; font-size: 11px; padding: 5px 8px; outline: none; +} + +/* ---------- 危险操作确认弹层 ---------- */ +.modal-mask { + position: fixed; inset: 0; background: rgba(2,4,10,.6); z-index: 70; + display: none; align-items: center; justify-content: center; padding: 20px; +} +.modal-mask.open { display: flex; } +.modal-box { + width: 100%; max-width: 380px; background: var(--panel2); border: 1px solid rgba(248,113,113,.4); + border-radius: 6px; padding: 18px 20px; box-shadow: 0 0 28px rgba(248,113,113,.12); +} +.modal-box h3 { margin: 0 0 4px; font-size: 13px; color: var(--rd); letter-spacing: .04em; } +.modal-box p { margin: 0 0 12px; font-size: 11px; color: var(--mut); line-height: 1.7; } +.modal-box label { display: flex; flex-direction: column; gap: 5px; font-size: 11px; color: var(--mut); margin-bottom: 10px; } +.modal-box input { + background: #0c1220; border: 1px solid var(--line2); border-radius: 4px; color: var(--txt); + font-family: inherit; font-size: 12px; padding: 7px 9px; outline: none; +} +.modal-box input:focus { border-color: rgba(248,113,113,.55); } +.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; } +.modal-actions .tbtn.danger { color: var(--rd); border-color: rgba(248,113,113,.5); } +.modal-actions .tbtn.danger:hover { color: #fff; background: rgba(248,113,113,.18); box-shadow: 0 0 10px rgba(248,113,113,.2); } +.modal-err { color: var(--rd); font-size: 11px; margin: 6px 0 0; min-height: 14px; } + +/* ---------- 小工具 ---------- */ +.opbtn { + font-size: 10px; letter-spacing: .06em; border: 1px solid var(--line2); color: var(--mut); + background: rgba(17,26,43,.5); padding: 3px 9px; border-radius: 3px; cursor: pointer; font-family: inherit; +} +.opbtn:hover { color: var(--cy); border-color: rgba(34,211,238,.5); } +.opbtn.danger { color: var(--rd); border-color: rgba(248,113,113,.4); } +.opbtn.danger:hover { color: #fff; background: rgba(248,113,113,.14); } +.empty-hint { text-align: center; padding: 22px 0; color: var(--dim); font-size: 11px; } +.toast { + position: fixed; left: 50%; bottom: 44px; transform: translate(-50%, 8px); z-index: 80; + background: var(--panel2); border: 1px solid var(--line2); border-radius: 5px; padding: 9px 16px; + font-size: 12px; color: var(--txt); opacity: 0; pointer-events: none; transition: opacity .18s ease-out, transform .18s ease-out; + box-shadow: 0 8px 24px rgba(0,0,0,.35); +} +.toast.show { opacity: 1; transform: translate(-50%, 0); } +.toast.err { border-color: rgba(248,113,113,.5); color: #fbd2d2; } +.toast.ok { border-color: rgba(52,211,153,.5); color: #cdf5e4; } + +/* 减少动态:抽屉/弹层的滑入过渡也一并归零,保持与其余过渡同一门禁 */ +.reduce-motion .drawer, +.reduce-motion .drawer-mask, +.reduce-motion .toast { transition-duration: .001s !important; } +@media (prefers-reduced-motion: reduce) { + .drawer, .drawer-mask, .toast { transition-duration: .001s !important; } } -* { box-sizing: border-box; } -body { margin: 0; background: var(--bg); color: var(--text); } -.panel, header.top, nav, main { background: var(--surface); } -.auth-panel { max-width: 420px; margin: 12vh auto; padding: 28px; border-radius: var(--radius); border: 1px solid var(--line); } -label { display: block; margin: 12px 0; } -input, select { width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); color: var(--text); } -button { background: var(--action); color: #fff; border: 0; border-radius: 4px; padding: 8px 14px; cursor: pointer; } -button.ghost { background: transparent; color: var(--text); border: 1px solid var(--line); } -button.danger { background: var(--danger); } -.muted { color: var(--muted); } -.error { color: var(--danger); } -.top { display: flex; gap: 12px; align-items: center; padding: 10px var(--pad); border-bottom: 1px solid var(--line); } -nav { display: flex; gap: 4px; padding: 8px var(--pad); border-bottom: 1px solid var(--line); } -nav button { background: transparent; color: var(--muted); } -nav button.active { color: var(--action); background: transparent; font-weight: 600; } -main { padding: var(--pad); min-height: calc(100vh - 96px); } -.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 16px; } -.card { border: 1px solid var(--line); border-radius: var(--radius); padding: 12px; } -table { width: 100%; border-collapse: collapse; font-size: 13px; } -th, td { text-align: left; padding: 8px; border-bottom: 1px solid var(--line); vertical-align: top; } -.pill { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); } -.ok { color: var(--ok); } -.warn { color: var(--warn); } -.fail { color: var(--danger); } -.toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; align-items: end; } -.toolbar label { margin: 0; } -dialog { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); color: var(--text); padding: 20px; } -- 2.54.0 From d9358ab37791a622628d21b2390974d99dc986f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Mon, 14 Sep 2026 21:38:06 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(HEL-529):=20=E5=87=8F=E5=B0=91=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E6=8C=82=E5=88=B0=20body/#app=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E6=8A=BD=E5=B1=89=E5=BC=B9=E5=B1=82=E8=BF=87=E6=B8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审核返工:reduce-motion 原先只挂 #appRoot,兄弟节点 drawer/modal/toast 的 CSS transition 仍生效;改为挂 body 与 #app,computed duration 归零。 Co-authored-by: Cursor Co-authored-by: multica-agent --- xiaobai-datahub/admin/app.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js index ef37e34..24066d9 100644 --- a/xiaobai-datahub/admin/app.js +++ b/xiaobai-datahub/admin/app.js @@ -1021,7 +1021,11 @@ function updateClock() { } function applyCalm(on) { state.calm = on; - $('appRoot').classList.toggle('reduce-motion', on); + // 挂在 #app(覆盖 appRoot 及其兄弟 drawer/modal/toast),不能只挂 #appRoot: + // 否则 .reduce-motion * 与 .reduce-motion .drawer/.toast 选不中旁路层过渡。 + document.body.classList.toggle('reduce-motion', on); + const app = $('app'); + if (app) app.classList.toggle('reduce-motion', on); $('calmBtn').textContent = on ? '恢复动态' : '减少动态'; $('calmBtn').classList.toggle('on', on); } -- 2.54.0 From 3bafd30aad18525d6b9cd8610307457920ac6811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=BD=E5=B7=A5=E5=91=982=E5=8F=B7?= Date: Tue, 15 Sep 2026 08:57:29 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat(HEL-529):=20=E6=8C=89=E5=AE=9A?= =?UTF-8?q?=E7=A8=BF100%=E9=87=8D=E5=81=9A=E4=B8=89=E9=A1=B5=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=AD=E6=9E=A2=20+=20=E6=95=B0=E6=8D=AE=E4=BF=AE?= =?UTF-8?q?=E6=AD=A31-5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 视觉:admin/app.js 按已确认打样 hub-kimi.html 逐行重写三页 DOM 与动效 (sparkline/缓存年龄秒增/延迟变化闪烁/EVENT TAPE 预装滚动/分组接口表/ 更新频率列/时钟冒号 blink/雷达 blip),CSS 补真实调用脉冲 node-ping。 数据修正: 1) pipeline._stage 批内按暂存表业务键确定性去重(保留最后一条), 修复人气榜/龙虎榜自 09-07 起每日 UNIQUE constraint 落库失败; 2) overview.anomalies 收敛为「最新批次未成功且当日未发布」的当前异常, 历史已恢复批次留在审计明细; 3) source_catalog 接口补真实批次分组 + 观测 join(接口名或数据集名 双向匹配,标注 observed/observed_basis),消除「全部未配置/0/30」误报; 4) lineage 逐数据集按其服务接口过滤健康行(接口级状态), provisional 已配置无观测显示「已配置 · 待观测」,仅 iFinD 为未配置; 5) lineage 补 update_freq 真实频率字段。 测试:新增 tests/test_hel529_rework.py(8 项),全套 191 项通过 (1 项环境依赖失败在基线 d9358ab 上同样复现,与本改动无关)。 Co-authored-by: multica-agent --- xiaobai-datahub/admin/app.js | 639 ++++++++++++++------ xiaobai-datahub/admin/index.html | 8 +- xiaobai-datahub/admin/styles.css | 8 + xiaobai-datahub/datahub/admin_api.py | 18 +- xiaobai-datahub/datahub/lineage.py | 17 + xiaobai-datahub/datahub/pipeline.py | 53 ++ xiaobai-datahub/datahub/source_catalog.py | 89 ++- xiaobai-datahub/tests/test_hel529_rework.py | 213 +++++++ 8 files changed, 826 insertions(+), 219 deletions(-) create mode 100644 xiaobai-datahub/tests/test_hel529_rework.py diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js index 24066d9..0f07ed9 100644 --- a/xiaobai-datahub/admin/app.js +++ b/xiaobai-datahub/admin/app.js @@ -1,12 +1,13 @@ 'use strict'; /* ===================================================================== - 小白复盘 · 数据中枢 admin/app.js — HEL-529 第十二版 - 三页真实数据中枢:运行总览 / 数据源配置 / 数据血缘。 - 视觉系统逐行照抄已通过打样 hub-kimi.html + SPEC-V11.md(v12 血缘压缩增补), + 小白复盘 · 数据中枢 admin/app.js — HEL-529 返工:按定稿 100% 还原 + 视觉与 DOM 结构逐行照抄已确认打样 hub-kimi.html(v12 血缘压缩增补), 但本文件不含任何随机/模拟数据——全部状态、数字、事件均来自真实接口: - 既有 /admin/api/{overview,sources,jobs,batches,datasets,audit,...} - 与 HEL-543 新增只读旁路 /admin/api/{providers/status,source-catalog, - lineage,lineage/affected}。 + /admin/api/{overview,sources,jobs,batches,audit,...} 与 HEL-543 只读旁路 + /admin/api/{providers/status,source-catalog,lineage,lineage/affected}。 + 动效遵循 SPEC-V11:marquee 46s 线性 / flowline 1.4s / radar 4.2s / + blink 1.1s steps(2) / ledPing 1.6s / sparkBlink 1.4s / 时钟冒号 blink / + 延迟仅在真实值变化时闪烁(禁止随机伪造数值)/ 缓存年龄每秒真实增长。 ===================================================================== */ /* ================= 基础:DOM helper / API / CSRF ================= */ @@ -136,27 +137,23 @@ function kpiHtml(label, value, sub, tone, live) { const tc = { txt: '', mint: 'glow-mint', amb: 'glow-amb', rd: 'glow-rd', cy: 'glow-cy' }[tone || 'txt']; return `
${label}${value}${live ? '_' : ''}${sub ? `${sub}` : ''}
`; } -function radarBlipsHtml(pulseOk) { - return pulseOk - ? `
` - : ''; -} +/* 延迟:真实值 + 变化时才闪(打样 jit 的真实数据版:不随机抖动数字, + 仅当新一轮真实调用改写该值时给 .flash 青色闪烁)。 */ function latHtml(ms, warn) { if (ms == null) return ''; const color = ms > 1000 ? '#fbbf24' : (warn ? '#fbbf24' : '#d7e1f0'); - return `${Number(ms).toLocaleString('en-US')}ms`; -} -function ageFromIso(iso) { - if (!iso) return null; - const t = Date.parse(iso.includes('T') ? iso : iso.replace(' ', 'T')); - if (Number.isNaN(t)) return null; - return Math.max(0, Math.floor((Date.now() - t) / 1000)); + return `${Number(ms).toLocaleString('en-US')}ms`; } +/* 缓存年龄:记录真实时间戳,由 updateDynamics 每秒重算(真实增长)。 */ function ageHtml(iso) { - const s = ageFromIso(iso); - if (s == null) return ''; - const txt = s < 90 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h`; - return `${txt}`; + const ms = Date.parse(String(iso || '').includes('T') ? iso : String(iso || '').replace(' ', 'T')); + if (!iso || Number.isNaN(ms)) return ''; + return ``; +} +function fmtAgeSec(s) { + if (s == null) return '—'; + // 打样口径:缓存年龄一律按秒展示、每秒增长(>30s 变琥珀) + return `${Number(s).toLocaleString('en-US')}s`; } function hm(iso) { if (!iso) return '—'; @@ -167,13 +164,66 @@ function hm(iso) { } const p2 = (x) => String(x).padStart(2, '0'); +/* ================= sparkline(真实历史:来自 provider_call_log) ================= */ +const SPARK_COLORS = { cy: '#22d3ee', mint: '#34d399', amb: '#fbbf24', rd: '#f87171' }; +function sparkHtml(jid, tone, w, h) { + return ``; +} +function sparkSeries(jid) { + const hist = LatHist.get(jid) || []; + if (hist.length < 2) return null; + const max = Math.max(...hist, 1); + return hist.map((v) => v / max); +} +function sparkPaint(svg) { + const jid = svg.dataset.jid, w = +svg.dataset.w, h = +svg.dataset.h; + const data = sparkSeries(jid); + const poly = svg.querySelector('polyline'); + const dot = svg.querySelector('circle'); + if (!data) { + const y = (h / 2).toFixed(2); + poly.setAttribute('points', `0,${y} ${w},${y}`); + poly.setAttribute('stroke-opacity', '.25'); + dot.style.display = 'none'; + return; + } + poly.setAttribute('stroke-opacity', '.8'); + dot.style.display = ''; + const pts = data.map((v, i) => `${((i / (data.length - 1)) * w).toFixed(2)},${(h - v * (h - 3) - 1).toFixed(2)}`).join(' '); + poly.setAttribute('points', pts); + dot.setAttribute('cx', (w - 1).toFixed(2)); + dot.setAttribute('cy', (h - data[data.length - 1] * (h - 3) - 1).toFixed(2)); +} +/* 每个“观测对象”(接口或数据集)的真实延迟历史(旧→新),来自 + provider_call_log 最近 200 条。 */ +const LatHist = new Map(); +const CALL_MATCHERS = {}; +function buildLatHist() { + const calls = (RAW.providers && RAW.providers.recent_calls) || []; + const buckets = new Map(); + [...calls].reverse().forEach((c) => { + if (c.latency_ms == null) return; + const key = `${c.provider}:${c.interface}`; + (buckets.get(key) || buckets.set(key, []).get(key)).push(c.latency_ms); + }); + LatHist.clear(); + buckets.forEach((arr, key) => LatHist.set(key, arr.slice(-24))); + // 数据集维度的历史:tushare 观测行写的是数据集名,直接按数据集聚合 + const dsBuckets = new Map(); + [...calls].reverse().forEach((c) => { + if (c.provider !== 'tushare' || c.latency_ms == null) return; + (dsBuckets.get(c.interface) || dsBuckets.set(c.interface, []).get(c.interface)).push(c.latency_ms); + }); + dsBuckets.forEach((arr, ds) => LatHist.set(`ds:${ds}`, arr.slice(-24))); +} + /* ================= 数据层:拉取真实接口并汇总为一份快照 ================= */ const RAW = { overview: null, sources: null, providers: null, catalog: null, - lineage: null, jobs: null, batches: null, datasets: null, audit: null, + lineage: null, jobs: null, batches: null, audit: null, }; async function fetchAll() { - const [overview, sources, providers, catalog, lineage, jobs, batches, datasets, audit] = await Promise.all([ + const [overview, sources, providers, catalog, lineage, jobs, batches, audit] = await Promise.all([ api('/admin/api/overview'), api('/admin/api/sources'), api('/admin/api/providers/status?limit=200'), @@ -181,31 +231,14 @@ async function fetchAll() { api('/admin/api/lineage'), api('/admin/api/jobs'), api(`/admin/api/batches?date=${today()}`), - api(`/admin/api/datasets?date=${today()}`), api('/admin/api/audit'), ]); RAW.overview = overview; RAW.sources = sources; RAW.providers = providers; RAW.catalog = catalog; - RAW.lineage = lineage; RAW.jobs = jobs; RAW.batches = batches; RAW.datasets = datasets; RAW.audit = audit; + RAW.lineage = lineage; RAW.jobs = jobs; RAW.batches = batches; RAW.audit = audit; + buildLatHist(); } function today() { return RAW.overview ? RAW.overview.trade_date : ''; } -/* 按 provider(+interface) 索引 provider_health,供各页复用 */ -function healthIndex() { - const idx = {}; - const rows = (RAW.providers && RAW.providers.health) || []; - for (const row of rows) { - idx[row.provider] = idx[row.provider] || []; - idx[row.provider].push(row); - } - return idx; -} -function callsIndex() { - const idx = {}; - const rows = (RAW.providers && RAW.providers.recent_calls) || []; - for (const row of rows) { (idx[row.provider] = idx[row.provider] || []).push(row); } - return idx; -} - const OFFICIAL_DATASET_IDS = ['daily', 'valuation', 'moneyflow', 'auction', 'index_daily', 'limit_events', 'popularity', 'dragon_tiger', 'sector_daily', 'stocks']; const DATASET_NAME = { daily: '日K', valuation: '估值', moneyflow: '资金流', auction: '竞价', index_daily: '指数日K', @@ -213,7 +246,7 @@ const DATASET_NAME = { }; /* ===================================================================== - 总览页 + 总览页(结构照抄 hub-kimi.html,数据全部真实) ===================================================================== */ function buildOverviewVM() { const ov = RAW.overview || {}; @@ -223,7 +256,6 @@ function buildOverviewVM() { (ov.publications || []).forEach((p) => { pubsByDs[p.dataset] = p; }); const batchByDs = {}; ((RAW.batches && RAW.batches.batches) || []).forEach((b) => { if (!batchByDs[b.dataset] || b.started_at > batchByDs[b.dataset].started_at) batchByDs[b.dataset] = b; }); - const hidx = healthIndex(); const totalOfficial = OFFICIAL_DATASET_IDS.length; const publishedCount = OFFICIAL_DATASET_IDS.filter((id) => pubsByDs[id] && pubsByDs[id].state === 'published').length; const anomalyCount = (ov.anomalies || []).length; @@ -231,7 +263,7 @@ function buildOverviewVM() { const successRate = recentCalls.length ? (recentCalls.filter((c) => c.ok).length / recentCalls.length * 100) : null; // 主站回退页面:主源异常且备源健康 → 判定为“回退中”的 provisional 数据集 - let fallbackPages = 0; + const fallbackPages = []; (RAW.lineage && RAW.lineage.items || []).forEach((it) => { if (it.tier !== 'provisional' || !it.backup_source) return; const rows = it.live_provider_health || []; @@ -239,7 +271,9 @@ function buildOverviewVM() { const backupProv = (it.providers || [])[1]; const primaryState = worstBucket(rows.filter((r) => r.provider === primaryProv).map((r) => bucketOf(r.state))); const backupState = worstBucket(rows.filter((r) => r.provider === backupProv).map((r) => bucketOf(r.state))); - if ((primaryState === 'fail' || primaryState === 'degrade' || primaryState === 'slow') && backupState === 'ok') fallbackPages += 1; + if ((primaryState === 'fail' || primaryState === 'degrade' || primaryState === 'slow') && backupState === 'ok') { + (it.known_consumers || []).forEach((c) => fallbackPages.push(c)); + } }); const datasetCards = OFFICIAL_DATASET_IDS.map((id) => { @@ -257,37 +291,31 @@ function buildOverviewVM() { return { id, name: DATASET_NAME[id] || id, bucket, time: note && !batch?.error ? note : '', rows, err: batch && batch.error ? batch.error : '' }; }); - // 实时观察层:eastmoney / tencent 的真实 provider_health 行 + // 实时观察层:eastmoney / tencent 目录接口的真实观测(observed=true) const catalogByProv = {}; (RAW.catalog && RAW.catalog.items || []).forEach((it) => { catalogByProv[it.provider] = it; }); const observers = []; ['eastmoney', 'tencent'].forEach((prov) => { - const rows = hidx[prov] || []; const cat = catalogByProv[prov]; - rows.forEach((row) => { - const iface = (cat && cat.interfaces.find((i) => i.interface === row.interface)) || {}; + if (!cat) return; + cat.interfaces.forEach((iface) => { + if (!iface.observed) return; observers.push({ - name: iface.capability || row.interface, - provider: prov, - interface: row.interface, - lastOk: row.last_ok_at, - lat: row.last_latency_ms, - bucket: bucketOf(row.state), - note: row.last_error || row.last_fallback_reason || '', + jid: `${prov}:${iface.interface}`, + name: iface.capability, provider: prov, interface: iface.interface, + lastOk: iface.observed_at, lat: iface.observed_latency_ms, + bucket: bucketOf(iface.observed_state), + note: iface.observed_note || '', }); }); }); - // 主站影响:lineage() 条目按 known_consumers 展开 + // 主站影响:lineage() 条目按 known_consumers 展开(接口级状态) const siteImpact = []; (RAW.lineage && RAW.lineage.items || []).forEach((it) => { - const rows = it.live_provider_health || []; - const bucket = it.tier === 'official' - ? (it.publication ? (it.publication.state === 'published' ? 'ok' : (it.publication.state === 'degraded' ? 'degrade' : 'fail')) : (rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan')) - : (rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'off'); - const note = rows.find((r) => r.last_error)?.last_error || rows.find((r) => r.last_fallback_reason)?.last_fallback_reason || ''; + const bucket = lineageItemBucket(it); (it.known_consumers || []).forEach((c) => { - siteImpact.push({ page: c, dataset: it.dataset, bucket, note }); + siteImpact.push({ page: c, short: pageNameOf(c), dataset: it.dataset, bucket, note: lineageItemNote(it) }); }); }); const okPages = siteImpact.filter((s) => s.bucket === 'ok').length; @@ -304,14 +332,16 @@ function buildOverviewVM() { return { t: (j.at.split('-')[0] || j.at).slice(0, 5), label: j.title, state: st, jobId: j.id }; }); - // 待处理异常:批次失败/停滞 + 主源连续失败 + 未配置授权源 + // 待处理异常:overview.anomalies 已在后端收敛为“仍未恢复的最新异常” const incidents = []; (ov.anomalies || []).forEach((b) => { incidents.push({ sev: 'fail', tag: b.state, title: `${DATASET_NAME[b.dataset] || b.dataset} ${b.state === 'failed' ? '失败' : '停滞'}`, meta: `${b.error ? esc(b.error) : '无错误详情'} · 批次 ${b.batch_id}`, dataset: b.dataset }); }); if (eod.state === 'waiting_upstream' || eod.state === 'cutoff_failed') { - incidents.push({ sev: eod.state === 'cutoff_failed' ? 'fail' : 'slow', tag: eod.state, title: eod.state === 'cutoff_failed' ? '盘后批已截止失败' : '等待上游重试', meta: `已试 ${eod.attempts || 0} 次${(eod.missing_datasets || []).length ? ' · 缺 ' + eod.missing_datasets.join(',') : ''}`, dataset: (eod.missing_datasets || [])[0] || '' }); + incidents.push({ sev: eod.state === 'cutoff_failed' ? 'fail' : 'slow', tag: eod.state, title: eod.state === 'cutoff_failed' ? '盘后批已截止失败' : '自动重试窗口', meta: `15:15–23:30 · 每 30 分钟 · 成功即停 · 已试 ${eod.attempts || 0} 次${(eod.missing_datasets || []).length ? ' · 缺 ' + eod.missing_datasets.join(',') : ''}`, dataset: (eod.missing_datasets || [])[0] || '', cd: eod.next_retry_at }); } + const hidx = {}; + ((RAW.providers && RAW.providers.health) || []).forEach((r) => { (hidx[r.provider] = hidx[r.provider] || []).push(r); }); Object.keys(hidx).forEach((prov) => { const bad = (hidx[prov] || []).filter((r) => (r.consec_failures || 0) > 0 && bucketOf(r.state) === 'fail'); if (bad.length) incidents.push({ sev: 'fail', tag: prov, title: `${prov} 部分接口连续失败`, meta: bad.map((r) => `${r.interface}×${r.consec_failures}`).join(' · '), provider: prov }); @@ -325,6 +355,7 @@ function buildOverviewVM() { trade_date: ov.trade_date, session_phase: ov.session_phase, eod, rev, publishedCount, totalOfficial, anomalyCount, successRate, fallbackPages, datasetCards, observers, siteImpact, okPages, timeline, incidents, + unpublished: OFFICIAL_DATASET_IDS.filter((id) => !pubsByDs[id] || pubsByDs[id].state !== 'published'), }; } @@ -335,7 +366,7 @@ function heroHtml(vm) { if (vm.rev.state === 'waiting_review') tags.push(tagHtml('cy', '复核 · 待发布')); const subParts = []; if (vm.eod.attempts) subParts.push(`已试 ${vm.eod.attempts} 次`); - if (vm.eod.next_retry_at) subParts.push(`下次重试 ${hm(vm.eod.next_retry_at)}`); + if (vm.eod.next_retry_at) subParts.push(`下次重试 --:--`); if ((vm.eod.missing_datasets || []).length) subParts.push(`缺 ${esc(vm.eod.missing_datasets.join(','))}`); if (vm.rev.window) subParts.push(`复核窗口 ${esc(vm.rev.window)}`); return `
` + @@ -345,23 +376,25 @@ function heroHtml(vm) { (subParts.length ? `
${subParts.join('|')}
` : '') + `
` + `
` + - kpiHtml('已发布数据集', `${vm.publishedCount}/${vm.totalOfficial}`, null) + + kpiHtml('已发布数据集', `${vm.publishedCount}/${vm.totalOfficial}`, vm.unpublished.length ? `${vm.unpublished.map((d) => DATASET_NAME[d] || d).join(' · ')} 未发布` : '全部已发布') + kpiHtml('今日调用成功率', vm.successRate == null ? '' : `${vm.successRate.toFixed(1)}%`, null, 'mint', vm.successRate != null) + - kpiHtml('主站回退页面', `${vm.fallbackPages} 页`, null, vm.fallbackPages ? 'amb' : 'txt') + + kpiHtml('主站回退页面', `${vm.fallbackPages.length} 页`, vm.fallbackPages.length ? esc(vm.fallbackPages[0]) : '实时层主源正常', vm.fallbackPages.length ? 'amb' : 'txt') + kpiHtml('EOD 尝试', `${vm.eod.attempts || 0} 次`, '上限 5 · 23:30 止', vm.eod.attempts ? 'rd' : 'txt') + `
`; } function datasetStripHtml(vm) { return `
${vm.datasetCards.map((d) => { const ring = d.bucket === 'fail' ? 'fail' : d.bucket === 'degrade' ? 'slow' : d.bucket === 'review' ? 'review' : ''; + const sparkTone = d.bucket === 'fail' ? 'rd' : d.bucket === 'degrade' ? 'amb' : d.bucket === 'review' ? 'cy' : 'mint'; let note = ''; if (d.time) note += `${esc(d.time)} · `; if (d.rows) note += `${Number(d.rows).toLocaleString('en-US')} 行`; - if (d.err) note += `${esc(d.err)}`; + if (d.err) { const brief = String(d.err).length > 22 ? String(d.err).slice(0, 22) + '…' : d.err; note += `${esc(brief)}`; } if (!d.time && !d.rows && !d.err) note = '今日尚未发布'; return `
` + `
${esc(d.name)}${pillHtml(d.bucket)}
` + `
${note}
` + + sparkHtml(`ds:${d.id}`, sparkTone, 96, 16) + `
`; }).join('')}
`; } @@ -373,20 +406,30 @@ function observatoryHtml(vm) { `${o.provider === 'eastmoney' ? '东方财富' : '腾讯行情'}` + `${ageHtml(o.lastOk)}` + `${latHtml(o.lat, o.bucket !== 'ok')}` + + `${sparkHtml(o.jid, o.bucket === 'slow' ? 'amb' : 'mint', 64, 16)}` + `${pillHtml(o.bucket)}${o.note ? `${esc(o.note)}` : ''}` + - ``).join('') : `尚无真实观测记录(等待首次探测/调用)`; - const body = `${rows}
观察项当前来源最近成功延迟状态 · 备注
`; + ``).join('') : `尚无真实观测记录(等待首次探测/调用)`; + const body = `${rows}
观察项当前来源缓存年龄延迟趋势状态 · 备注
`; return panelHtml('实时观察层', right, body, false); } function siteImpactHtml(vm) { + const bad = vm.siteImpact.filter((s) => s.bucket !== 'ok'); const right = `${vm.okPages} 项正常 · ${vm.siteImpact.length - vm.okPages} 项待关注`; - const rows = vm.siteImpact.length ? vm.siteImpact.map((s) => `` + - `${esc(s.page)}` + - `${pillHtml(s.bucket)}` + + const rows = bad.length ? bad.slice(0, 5).map((s) => `` + + `${esc(s.page)}` + + `${pillHtml(s.bucket)}` + `${s.note ? esc(s.note) : '—'}` + `` + - ``).join('') : `尚无血缘记录`; - return panelHtml('主站影响', right, `${rows}
`, false); + ``).join('') : `全部页面正常${pillHtml('ok')}—`; + const footPages = vm.siteImpact.map((s) => ({ name: s.short, bucket: s.bucket })); + const byPage = new Map(); + footPages.forEach((p) => { const cur = byPage.get(p.name); if (!cur || SEV[p.bucket] > SEV[cur]) byPage.set(p.name, p.bucket); }); + const foot = `
正常 ${vm.okPages}` + + [...byPage.entries()].map(([name, bucket]) => bucket === 'ok' + ? `${esc(name)}·` + : `${esc(name)}(${HEALTH[bucket].label})·`).join('') + + `
`; + return panelHtml('主站影响', right, `${rows}
${foot}`, false); } function timelineHtml(vm) { const nodes = vm.timeline; @@ -422,17 +465,21 @@ function timelineHtml(vm) { s += `` + `` + `NOW`; - const right = `现在 ${p2(now.getHours())}:${p2(now.getMinutes())} · SCHEDULER.PY`; + const range = nodes.length ? `${nodes[0].t} → ${nodes[nodes.length - 1].t}` : ''; + const right = `现在 ${p2(now.getHours())}:${p2(now.getMinutes())} · ${range} · SCHEDULER.PY`; return panelHtml('今晚时间线', right, s, false); } function incidentsHtml(vm) { if (!vm.incidents.length) return panelHtml('待处理异常', tagHtml('mint', '0 OPEN'), '
当前无待处理异常
', true); const cards = vm.incidents.map((it) => { const tag = it.sev === 'fail' ? tagHtml('rd', esc(it.tag)) : it.sev === 'slow' ? tagHtml('amb', esc(it.tag)) : tagHtml('dashed', esc(it.tag)); + const meta = it.cd + ? `${it.meta} · 下次重试 --m--s` + : it.meta; return `
${tag}` + `
` + `
${esc(it.title)}
` + - `
${esc(it.meta)}
` + + `
${meta}
` + `
` + `` + `
`; @@ -447,44 +494,62 @@ function overviewHtml() { } /* ===================================================================== - 数据源配置页 + 数据源配置页(分组接口表照抄打样 mx-grid 结构,数据全部真实) ===================================================================== */ const srcUiState = {}; +const PROV_ROLE_TEXT = { + tushare: 'official · 盘后正式数据唯一源', + eastmoney: 'free · 盘中观察主源', + tencent: 'free · 盘中观察备源', + ifind: 'licensed · 图表 / 问财 / 实时快照', +}; function buildSourcesVM() { const catalog = (RAW.catalog && RAW.catalog.items) || []; const legacy = {}; (RAW.sources && RAW.sources.items || []).forEach((it) => { legacy[it.provider] = it; }); - const hidx = healthIndex(); - const cidx = callsIndex(); + const cidx = {}; + ((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => { if (!cidx[c.provider]) cidx[c.provider] = c; }); const active = catalog.filter((c) => c.role !== 'reserved'); const reserved = catalog.filter((c) => c.role === 'reserved'); const totalIfaces = active.reduce((n, c) => n + c.interfaces.length, 0); - const usedIfaces = active.reduce((n, c) => n + c.interfaces.filter((i) => (hidx[c.provider] || []).some((h) => h.interface === i.interface)).length, 0); + // “在用”= 已登记且已观察到调用(接口名或数据集名匹配,后端 join 已标注) + const usedIfaces = active.reduce((n, c) => n + c.interfaces.filter((i) => i.observed).length, 0); const cards = active.map((c) => { - const leg = legacy[c.provider] || {}; - const legState = (leg.health && (leg.health.state || leg.health.status)) || null; - const rows = hidx[c.provider] || []; - const worst = rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : (legState ? bucketOf(legState) : 'off'); const cred = c.credential || {}; - const lastCall = (cidx[c.provider] || [])[0]; + const observedRows = c.interfaces.filter((i) => i.observed); + const worst = observedRows.length ? worstBucket(observedRows.map((i) => bucketOf(i.observed_state))) : null; + const configured = cred.configured; + let bucket; + if (worst) bucket = worst; + else if (configured) bucket = 'plan'; + else bucket = 'off'; + const lastCall = cidx[c.provider]; if (!(c.provider in srcUiState)) srcUiState[c.provider] = { open: true, probing: false, probeResult: null }; - const ifaceRows = c.interfaces.map((i) => { - const h = rows.find((r) => r.interface === i.interface); - return { - api: i.interface, use: i.capability, - ok: h ? (h.last_ok_at ? hm(h.last_ok_at) : '—') : '—', - lat: h ? h.last_latency_ms : null, - st: h ? bucketOf(h.state) : 'off', - err: h && h.state !== 'ok' ? (h.last_error || h.last_fallback_reason || '') : '', - }; + const groups = []; + const groupMap = new Map(); + c.interfaces.forEach((i) => { + const g = i.group || '其他'; + if (!groupMap.has(g)) groupMap.set(g, []); + groupMap.get(g).push(i); }); + groupMap.forEach((items, name) => groups.push({ name, items })); + const ifaceRows = (group) => group.items.map((i) => ({ + api: i.interface, use: i.capability, + ok: i.observed ? (i.observed_at ? hm(i.observed_at) : '—') : '—', + lat: i.observed ? i.observed_latency_ms : null, + st: i.observed ? bucketOf(i.observed_state) : (configured ? 'plan' : 'off'), + obs: i.observed, basis: i.observed_basis, + err: i.observed && i.observed_state !== 'ok' ? (i.observed_note || '') : '', + })); return { - id: c.provider, name: c.label, role: c.role, tag: c.credential_key ? 'licensed' : 'free', - bucket: worst, credential: cred, usedCount: ifaceRows.filter((r) => r.st !== 'off').length, totalCount: ifaceRows.length, - lastProbe: lastCall ? `${hm(lastCall.created_at)} · ${lastCall.ok ? `✓ ${lastCall.latency_ms}ms` : `× ${lastCall.error || 'ERROR'}`}` : '—', - latency: rows.length ? (rows.map((r) => r.last_latency_ms).filter((v) => v != null)[0] || null) : null, - error: rows.find((r) => r.last_error)?.last_error || '', - ifaceRows, + id: c.provider, name: c.label, roleText: PROV_ROLE_TEXT[c.provider] || c.role, + tag: c.credential_key ? 'licensed' : 'free', + bucket, credential: cred, + usedCount: c.interfaces.filter((i) => i.observed).length, totalCount: c.interfaces.length, + lastProbe: lastCall ? `${hm(lastCall.fetched_at)} ${lastCall.status === 'ok' ? `✓ ${lastCall.latency_ms ?? ''}ms` : `× ${lastCall.error || 'ERROR'}`}` : '—', + latency: observedRows.length ? (observedRows.map((r) => r.observed_latency_ms).filter((v) => v != null)[0] || null) : null, + error: observedRows.find((r) => r.observed_state !== 'ok' && r.observed_note)?.observed_note || '', + groups: groups.map((g) => ({ name: g.name, rows: ifaceRows(g) })), }; }); return { cards, reserved, totalIfaces, usedIfaces }; @@ -501,36 +566,48 @@ function sourceCardHtml(card) { // 靠 note 字段区分,这里必须原样展示 note,不能一律折叠成"已配置"。 const credBadge = card.credential.note ? card.credential.note : card.credential.configured ? `已配置${card.credential.last4 ? ' · ' + card.credential.last4 : ''}` : '未配置'; - const badges = [credBadge, card.role]; + const badges = [credBadge]; + if (card.credential.configured && card.credential.updated_at) badges.push(`${String(card.credential.updated_at).slice(5, 10).replace('-', '/')} 更新`); + badges.push(card.id === 'tushare' ? '主(无备)' : (card.id === 'ifind' ? '图表主源(未启用)' : (card.id === 'tencent' ? '实时层备' : '实时层主'))); let h = `
`; h += `
` + ledHtml(ledSt, card.bucket === 'fail') + `${esc(card.name)}` + - `${esc(card.tag)}` + - pillHtml(card.bucket) + + `${esc(card.roleText)}` + + pillHtml(card.bucket, card.bucket === 'plan' ? '已配置 · 待观测' : '') + `${badges.map((b) => tagHtml(card.bucket === 'off' ? 'dashed' : 'dim', esc(b))).join('')}` + `` + - `` + - `` + + `` + + (card.bucket === 'off' + ? `` + : ``) + `
`; + const probeCell = ui.probeResult + ? `${ui.probeResult.t} ${ui.probeResult.ok ? `✓ ${ui.probeResult.ms}ms` : '× TIMEOUT'}` + : `${esc(card.lastProbe)}`; h += `
` + - `接口 ${card.usedCount}/${card.totalCount} 在用` + - `|最后探测 ${esc(card.lastProbe)}` + + `接口 ${card.usedCount}/${card.totalCount} 在用` + + `|最后探测 ${probeCell}` + `|延迟 ${latHtml(card.latency)}` + - (card.error ? `|最近错误 ${esc(card.error)}` : `|无最近错误`) + + (card.error ? `|最近错误 ${esc(card.error)}` : (card.bucket !== 'off' ? `|无最近错误` : '')) + `
`; - if (ui.open && card.ifaceRows.length) { - h += `
` + + if (ui.open && card.bucket !== 'off') { + h += `
` + card.groups.map((g) => `
` + + `
${esc(g.name)}
` + `` + - card.ifaceRows.map((it) => `` + + g.rows.map((it) => `` + `` + `` + - `` + + `` + `` + - `` + - ``).join('') + `
接口用途最近成功延迟状态
${esc(it.api)}${esc(it.use)}${esc(it.ok)}${esc(it.ok)}${latHtml(it.lat)}${pillHtml(it.st)}${it.err ? `${esc(it.err)}` : ''}
`; - } else if (!card.ifaceRows.length) { - h += `
该来源尚无已登记接口
`; + `${pillHtml(it.st, it.st === 'plan' && it.obs === false ? '已配置 · 待观测' : '')}${it.err ? `${esc(it.err)}` : ''}` + + ``).join('') + + `
`).join('') + `
`; + } + if (card.bucket === 'off') { + h += `
凭证未配置 · ${card.totalCount} 个预留接口处于停用状态 · 配置后将成为图表 / 问财 / 实时快照主源` + + `
${card.groups[0].rows.map((i) => tagHtml('dashed', esc(i.api))).join('')}
` + + `
`; } return h + `
`; } @@ -555,17 +632,57 @@ function sourcesHtml() { function renderSourceCards() { const el = $('srcCards'); if (!el) return; - const vm = buildSourcesVM(); - el.innerHTML = vm.cards.map(sourceCardHtml).join(''); + el.innerHTML = buildSourcesVM().cards.map(sourceCardHtml).join(''); + paintSparks(); } /* ===================================================================== - 数据血缘页 + 数据血缘页(接口级状态 + 已配置/待观测 + 真实频率 + 事件脉冲) ===================================================================== */ function pageNameOf(consumer) { const idx = consumer.indexOf('('); return (idx > 0 ? consumer.slice(0, idx) : consumer).trim(); } +/* 一条血缘项的服务接口集合(primary/backup 冒号后的真实接口名) */ +function servingInterfaces(item) { + const out = new Set([item.dataset]); + [item.primary_source, item.backup_source].forEach((src) => { + if (!src) return; + src.split(':').slice(1).join(':').split('+').forEach((api) => { + const name = api.trim(); + if (name) out.add(name); + }); + }); + return out; +} +/* 接口级状态:只看本数据集真正走的那几条接口,不用提供商级汇总顶替。 */ +function lineageItemRowsHealth(item) { + const apis = servingInterfaces(item); + return (item.live_provider_health || []).filter((r) => apis.has(r.interface)); +} +function lineageItemBucket(item) { + const rows = lineageItemRowsHealth(item); + if (item.tier === 'official') { + if (item.publication) return item.publication.state === 'published' ? 'ok' : (item.publication.state === 'degraded' ? 'degrade' : 'fail'); + return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan'; + } + if (item.tier === 'licensed') { + const cred = ((RAW.catalog && RAW.catalog.items) || []).find((c) => c.provider === 'ifind'); + if (cred && !(cred.credential && cred.credential.configured)) return 'off'; + return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'off'; + } + // provisional:已有数据来源;有观测行看接口级状态,暂无观测行=已配置/待观测 + return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan'; +} +function lineageItemNote(item) { + const rows = lineageItemRowsHealth(item); + const note = rows.find((r) => r.last_error)?.last_error || rows.find((r) => r.last_fallback_reason)?.last_fallback_reason || ''; + if (note) return note; + if (item.tier === 'licensed') return 'iFinD 未配置 · 功能隐藏'; + if (item.tier === 'provisional' && !rows.length) return '已配置 · 暂无观测记录'; + return ''; +} + const linFilter = { onlyBad: false, src: '全部', q: '' }; let linHover = null; let GW = 1500, GH = 286; @@ -579,18 +696,12 @@ function buildLineageVM() { const srcState = {}; const dsState = {}; const pageState = {}; - const pageDatasets = {}; const edgesSD = []; const edgesDP = []; const rows = []; items.forEach((it) => { - const rowsHealth = it.live_provider_health || []; - let bucket; - if (it.tier === 'official') { - bucket = it.publication ? (it.publication.state === 'published' ? 'ok' : (it.publication.state === 'degraded' ? 'degrade' : 'fail')) : (rowsHealth.length ? worstBucket(rowsHealth.map((r) => bucketOf(r.state))) : 'plan'); - } else { - bucket = rowsHealth.length ? worstBucket(rowsHealth.map((r) => bucketOf(r.state))) : 'off'; - } + const rowsHealth = lineageItemRowsHealth(it); + const bucket = lineageItemBucket(it); dsState[it.dataset] = bucket; (it.providers || []).forEach((p) => { srcState[p] = worstBucket([srcState[p] || 'ok', ...rowsHealth.filter((r) => r.provider === p).map((r) => bucketOf(r.state))]); @@ -601,16 +712,16 @@ function buildLineageVM() { pages.forEach((pg) => { pageState[pg] = worstBucket([pageState[pg] || 'ok', bucket]); edgesDP.push([it.dataset, pg]); - (pageDatasets[pg] = pageDatasets[pg] || []).push(it); }); - const lat = rowsHealth.map((r) => r.last_latency_ms).find((v) => v != null); - const ok = it.tier === 'official' && it.publication ? it.publication.published_at : (rowsHealth.find((r) => r.last_ok_at) || {}).last_ok_at; rows.push({ page: pages.join(' / ') || '—', item: consumers.join(' / '), dataset: it.dataset, via: `${it.primary_source}${it.backup_source ? ' → ' + it.backup_source : ''}`, role: it.backup_source ? '主/备' : (it.tier === 'licensed' ? '主(授权)' : '主(无备)'), - ok: ok ? hm(ok) : '—', lat, st: bucket, - note: rowsHealth.find((r) => r.last_error)?.last_error || rowsHealth.find((r) => r.last_fallback_reason)?.last_fallback_reason || (it.publication && it.publication.state !== 'published' ? '未正式发布' : ''), + freq: it.update_freq || '—', + ok: it.tier === 'official' && it.publication ? it.publication.published_at : (rowsHealth.find((r) => r.last_ok_at) || {}).last_ok_at, + lat: rowsHealth.map((r) => r.last_latency_ms).find((v) => v != null), + st: bucket, + note: lineageItemNote(it), }); }); const sources = Object.keys(srcState).map((id) => ({ id, name: PROV_LABEL[id] || id, st: srcState[id] })); @@ -641,6 +752,7 @@ function lineageGraphHtml(vm) { if (vm.datasets.length) h += `数据集 · ${vm.datasets.length}`; if (vm.pages.length) h += `主站页面 · ${vm.pages.length}`; const dsBy = {}; vm.datasets.forEach((d) => { dsBy[d.id] = d.st; }); + // 已连接线路全部有可辨识的定向流动(flowline),颜色按数据集真实状态 vm.edgesSD.forEach(([s, d]) => { if (!gpos['s:' + s] || !gpos['d:' + d]) return; h += ``; @@ -716,35 +828,45 @@ function renderLinRows(vm) { const rows = linRowsData(vm); if (cnt) cnt.textContent = `${rows.length}/${vm.rows.length} 行`; const cls = (st) => st === 'fail' ? 'row-fail' : st === 'degrade' || st === 'slow' ? 'row-slow' : st === 'off' ? 'row-off' : ''; + const stLabel = (r) => (r.st === 'plan' && r.note.includes('待观测')) ? '已配置 · 待观测' : ''; body.innerHTML = rows.map((r) => `` + `${esc(r.page)}` + `${esc(r.item)}` + `${esc(r.dataset)}` + `${esc(r.via)}` + `${esc(r.role)}` + - `${esc(r.ok)}` + - `${latHtml(r.lat)}` + - `${pillHtml(r.st)}${r.note ? `${esc(r.note)}` : ''}` + - ``).join('') || `无匹配记录 · 调整筛选条件`; + `${esc(r.freq)}` + + `${r.ok ? esc(hm(r.ok)) : '—'}` + + `${r.lat != null ? Number(r.lat).toLocaleString('en-US') + 'ms' : '—'}` + + `${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? `${esc(r.note)}` : ''}` + + ``).join('') || `无匹配记录 · 调整筛选条件`; if (bodyN) bodyN.innerHTML = rows.map((r) => `` + `${esc(r.page)}${esc(r.item)}` + `${esc(r.dataset)}` + `${esc(r.via)}` + - `${esc(r.ok)}${r.lat != null ? r.lat + 'ms' : '—'}` + - `${pillHtml(r.st)}${r.note ? `${esc(r.note)}` : ''}` + - ``).join('') || `无匹配记录 · 调整筛选条件`; + `${esc(r.freq)}${esc(r.role)}` + + `${r.ok ? esc(hm(r.ok)) : '—'}${r.lat != null ? r.lat + 'ms' : '—'}` + + `${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? `${esc(r.note)}` : ''}` + + ``).join('') || `无匹配记录 · 调整筛选条件`; } function lineageHtml() { const vm = buildLineageVM(); - const anomalyPages = new Set(); - vm.rows.forEach((r) => { if (r.st === 'fail') (r.page || '').split(' / ').forEach((p) => anomalyPages.add(p)); }); + const failDs = vm.datasets.filter((d) => d.st === 'fail'); + let impactTag; + if (failDs.length) { + const pages = new Set(); + failDs.forEach((d) => { (adj.dp.get(d.id) || new Set()).forEach((p) => pages.add(p)); }); + impactTag = tagHtml('rd', `${failDs.map((d) => d.id).join('/')} 断链影响 ${pages.size} 页`); + } else { + impactTag = tagHtml('mint', '暂无断链影响'); + } const html = `
` + `
` + `血缘地图` + `${vm.sources.length} 源 → ${vm.datasets.length} 数据集 → ${vm.pages.length} 页面` + `${ledHtml('ok')}健康${ledHtml('slow')}慢/降级${ledHtml('fail')}失败${ledHtml('plan')}计划${ledHtml('off')}未配置` + `` + - (anomalyPages.size ? tagHtml('rd', `${[...anomalyPages][0]} 断链影响 ${anomalyPages.size} 页`) : tagHtml('mint', '暂无断链影响')) + + impactTag + `
` + `
${lineageGraphHtml(vm)}
` + `
` + @@ -755,11 +877,10 @@ function lineageHtml() { `` + `` + `
` + - `
主站页面数据项数据集主/备最近成功延迟状态
` + - `
主站页面数据集最近成功状态
` + + `
主站页面数据项数据集源 : 接口主/备更新频率最近成功延迟状态
` + + `
主站页面数据集源 : 接口更新频率最近成功状态
` + `` + `
`; - window.__linVM = vm; return html; } @@ -775,16 +896,19 @@ function renderPage(p) { state.page = p; mainEl.dataset.page = p; document.querySelectorAll('[data-nav]').forEach((b) => b.classList.toggle('act', b.dataset.nav === p)); - if (p === 'overview') mainEl.innerHTML = overviewHtml(); - else if (p === 'sources') mainEl.innerHTML = sourcesHtml(); + if (p === 'overview') { mainEl.innerHTML = overviewHtml(); paintSparks(); flashChangedLatencies(); } + else if (p === 'sources') { mainEl.innerHTML = sourcesHtml(); paintSparks(); } else { mainEl.innerHTML = lineageHtml(); - renderLinRows(window.__linVM); - applyLinHover(window.__linVM); + const vm = buildLineageVM(); + window.__linVM = vm; + renderLinRows(vm); + applyLinHover(vm); const svg = $('linSvg'); if (svg) svg.addEventListener('mouseleave', () => setLinHover(null, window.__linVM)); fitLineageTable(); } + window.scrollTo(0, 0); updatePhaseTag(); } window.addEventListener('hashchange', () => renderPage(pageFromHash())); @@ -795,11 +919,10 @@ window.addEventListener('resize', () => { resizeT = setTimeout(() => { if (state.page === 'lineage') renderPage('lineage'); }, 180); }); -/* HEL-529: 血缘详情表随真实数据集数量(当前 17 条,非打样假设的 13 条) - 动态撑高;宽档(>1100px,覆盖 1920×920/1440×900 验收位)给表格容器一个 - 基于剩余可视高度计算的 max-height + 内部滚动,保证整页 scrollHeight - 始终 <= clientHeight(硬性验收项),同时全部行仍可在容器内滚动查看。 - 窄档(<=1100px)保持原有整页纵向滚动,不设上限。 */ +/* HEL-529: 血缘详情表随真实数据集数量动态撑高;宽档(>1100px,覆盖 + 1920×920/1440×900 验收位)给表格容器一个基于剩余可视高度计算的 + max-height + 内部滚动,保证整页 scrollHeight 始终 <= clientHeight, + 同时全部行仍可在容器内滚动查看。窄档(<=1100px)整页纵滚。 */ function fitLineageTable() { const wrap = document.querySelector('.lin-scroll'); const tape = document.querySelector('.tape'); @@ -816,6 +939,8 @@ function fitLineageTable() { function updatePhaseTag() { const ov = RAW.overview; if (!ov) return; + const bad = (ov.anomalies || []).length > 0; + $('radarBlipBad').style.display = bad ? '' : 'none'; $('phaseTag').innerHTML = `${ledHtml('rev', true)} ${esc(ov.session_phase || '')}`; } @@ -824,6 +949,11 @@ mainEl.addEventListener('click', (e) => { if (pb) { doProbe(pb.dataset.probe); return; } const tg = e.target.closest('[data-toggle]'); if (tg) { const ui = srcUiState[tg.dataset.toggle]; if (ui) { ui.open = !ui.open; renderSourceCards(); } return; } + const ch = e.target.closest('[data-cred-hint]'); + if (ch) { + toast('iFinD 凭证需在部署侧配置(DATAHUB_IFIND_* 环境变量或 /v1/credentials/ifind),后台暂未开放在线写入', 'err'); + return; + } const goto = e.target.closest('[data-goto-lineage]'); if (goto) { linFilter.q = ''; location.hash = 'lineage'; return; } const inc = e.target.closest('[data-incident-action]'); @@ -846,14 +976,35 @@ async function doProbe(id) { if (!ui || ui.probing) return; ui.probing = true; renderSourceCards(); - try { await api(`/admin/api/sources/${id}/probe`, { method: 'POST', body: '{}' }); } catch (err) { toast(err.message, 'err'); } + try { + await api(`/admin/api/sources/${id}/probe`, { method: 'POST', body: '{}' }); + ui.probeResult = { t: `${p2(new Date().getHours())}:${p2(new Date().getMinutes())}`, ok: true, ms: null }; + } catch (err) { + ui.probeResult = { t: `${p2(new Date().getHours())}:${p2(new Date().getMinutes())}`, ok: false, ms: null }; + toast(err.message, 'err'); + } ui.probing = false; await refreshQuiet(); renderSourceCards(); } -/* ---------- EVENT TAPE:真实事件差量渲染 ---------- */ -const tapeSeen = { call: 0, provCall: 0, audit: 0, job: 0, first: true }; +/* ---------- EVENT TAPE:预装最近真实事件 + 持续滚动 + 新事件闪动 ---------- */ +const tapeSeen = { call: 0, provCall: 0, audit: 0, job: 0 }; +let tapeBuf = []; +let tapeSeeded = false; +function tapeEventHtml(e, fresh) { + return `${esc(hm(e.ts))}${esc(e.src)}${esc(e.msg)}` + + (e.ok ? `✓${e.ms ? ` ${e.ms}ms` : ''}` : '× 失败') + + `///`; +} +function renderTape(freshIds) { + const items = tapeBuf.slice(-14); + const html = items.length + ? items.map((e) => tapeEventHtml(e, freshIds && freshIds.has(e.sortId))).join('') + : `暂无真实事件 · 等待首次调用/任务///`; + $('tapeA').innerHTML = html; + $('tapeB').innerHTML = html; +} function tapeEventsFromRaw() { const evs = []; ((RAW.overview && RAW.overview.recent_calls) || []).forEach((c) => { if (c.id > tapeSeen.call) evs.push({ ts: c.created_at, src: 'tushare', msg: c.endpoint, ok: c.ok, ms: c.latency_ms, sortId: 'c' + c.id }); }); @@ -867,16 +1018,25 @@ function tapeEventsFromRaw() { tapeSeen.job = Math.max(tapeSeen.job, maxOf((RAW.jobs && RAW.jobs.runs) || [], 'id')); return evs; } -let tapeBuf = []; +function seedTape() { + // 首次加载:预装最近的真实事件(打样 EVENT TAPE 开场即有内容滚动) + const evs = []; + ((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => evs.push({ ts: c.fetched_at, src: c.provider, msg: c.interface, ok: c.status === 'ok', ms: c.latency_ms, sortId: 'p' + c.id })); + ((RAW.overview && RAW.overview.recent_calls) || []).forEach((c) => evs.push({ ts: c.created_at, src: 'tushare', msg: c.endpoint, ok: c.ok, ms: c.latency_ms, sortId: 'c' + c.id })); + ((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => evs.push({ ts: r.finished_at || r.started_at, src: 'scheduler', msg: `${r.job_id} ${r.state}`, ok: r.state !== 'failed', sortId: 'j' + r.id })); + ((RAW.audit && RAW.audit.items) || []).forEach((a) => evs.push({ ts: a.created_at, src: 'hub_admin', msg: `${a.action} ${a.target || ''}`.trim(), ok: true, sortId: 'a' + a.id })); + evs.sort((x, y) => String(x.ts).localeCompare(String(y.ts))); + tapeBuf = evs.slice(-14); + tapeSeeded = true; + renderTape(null); +} function updateTape() { + if (!tapeSeeded) { seedTape(); return; } const evs = tapeEventsFromRaw(); - if (tapeSeen.first) { tapeSeen.first = false; return; } // 首次加载只建基线,不补播历史事件 if (!evs.length) return; + const fresh = new Set(evs.map((e) => e.sortId)); tapeBuf = tapeBuf.concat(evs).slice(-16); - const html = tapeBuf.map((e) => `${esc(hm(e.ts))}${esc(e.src)}${esc(e.msg)}` + - (e.ok ? `✓${e.ms ? ` ${e.ms}ms` : ''}` : '× 失败') + - `///`).join(''); - $('tapeA').innerHTML = html; $('tapeB').innerHTML = html; + renderTape(fresh); } /* ---------- 抽屉:调度任务 / 盘后发布 / 审计 ---------- */ @@ -1011,18 +1171,22 @@ function toast(msg, kind) { toastT = setTimeout(() => { el.classList.remove('show'); }, 3200); } -/* ================= 顶栏:时钟 / 减少动态 / 主题 ================= */ +/* ================= 顶栏:时钟 / 减少动态 ================= */ +let clockEls = null; function updateClock() { + if (!clockEls) clockEls = { d: $('ckD'), h: $('ckH'), m: $('ckM'), s: $('ckS') }; const now = new Date(); - $('ckD').textContent = `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())}`; - $('ckH').textContent = p2(now.getHours()); - $('ckM').textContent = p2(now.getMinutes()); - $('ckS').textContent = p2(now.getSeconds()); + const ds = `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())}`; + if (clockEls.d.textContent !== ds) clockEls.d.textContent = ds; + const hs = p2(now.getHours()), ms = p2(now.getMinutes()), ss = p2(now.getSeconds()); + if (clockEls.h.textContent !== hs) clockEls.h.textContent = hs; + if (clockEls.m.textContent !== ms) clockEls.m.textContent = ms; + if (clockEls.s.textContent !== ss) clockEls.s.textContent = ss; } function applyCalm(on) { state.calm = on; - // 挂在 #app(覆盖 appRoot 及其兄弟 drawer/modal/toast),不能只挂 #appRoot: - // 否则 .reduce-motion * 与 .reduce-motion .drawer/.toast 选不中旁路层过渡。 + // 挂在 body + #app(覆盖 appRoot 及其兄弟 drawer/modal/toast),否则 + // .reduce-motion * 选不中旁路层过渡。 document.body.classList.toggle('reduce-motion', on); const app = $('app'); if (app) app.classList.toggle('reduce-motion', on); @@ -1037,7 +1201,62 @@ if (window.matchMedia) { mq.addEventListener ? mq.addEventListener('change', syncMq) : mq.addListener(syncMq); } +/* ================= 动态更新:200ms tick(打样节奏) ================= + - 时钟每秒翻字(冒号 blink 由 CSS 承担) + - 缓存年龄依据真实时间戳每秒增长(>30s 变琥珀) + - 倒计时按真实 next_retry_at 每秒递减 + - sparkline 端点闪烁由 CSS 承担,折线只在真实历史变化时重画 + - 延迟数字只在真实值变化时闪一下(flash),绝不随机伪造数值 */ +function paintSparks() { + document.querySelectorAll('svg.spark').forEach((svg) => sparkPaint(svg)); +} +const prevLat = new Map(); +function flashChangedLatencies() { + document.querySelectorAll('[data-lat]').forEach((el) => { + const jid = el.closest('[data-jid]') ? el.closest('[data-jid]').dataset.jid : (el.closest('tr') ? 'row:' + [...el.closest('tr').children].indexOf(el) : ''); + const v = el.dataset.lat; + const key = state.page + ':' + (el.closest('[data-dataset]')?.dataset.dataset || jid) + ':' + v; + const prev = prevLat.get(el); + if (prev !== undefined && prev !== v) { + el.classList.remove('flash'); + void el.offsetWidth; + el.classList.add('flash'); + } + prevLat.set(el, v); + }); +} +function updateDynamics() { + const now = Date.now(); + document.querySelectorAll('[data-age-epoch]').forEach((el) => { + const ms = +el.dataset.ageEpoch; + if (!ms || Number.isNaN(ms)) return; + const s = Math.max(0, Math.floor((now - ms) / 1000)); + const txt = fmtAgeSec(s); + if (el.textContent !== txt) el.textContent = txt; + const color = s > 30 ? '#fbbf24' : '#8b9bb4'; + if (el.style.color !== color) el.style.color = color; + }); + document.querySelectorAll('[data-cd-epoch]').forEach((el) => { + const target = +el.dataset.cdEpoch; + if (!target || Number.isNaN(target)) return; + const left = Math.floor((target - now) / 1000); + if (left > 0) { + const m = Math.floor(left / 60), s = left % 60; + const txt = el.dataset.cdfmt === 'ms' ? `${m}m${p2(s)}s` : `${p2(m)}:${p2(s)}`; + if (el.textContent !== txt) el.textContent = txt; + } else if (el.textContent !== '已到点') { + el.textContent = '已到点'; + } + }); +} + /* ================= 轮询:拉取真实数据、驱动渲染、可见性/网络门禁 ================= */ +const dataVersion = { overview: '', sources: '', lineage: '' }; +function snapshotVersion(part) { + if (part === 'overview') return JSON.stringify([RAW.overview, RAW.batches && RAW.batches.batches, RAW.providers && RAW.providers.health, RAW.catalog]); + if (part === 'sources') return JSON.stringify([RAW.catalog, RAW.providers && RAW.providers.recent_calls, RAW.sources]); + return JSON.stringify(RAW.lineage); +} const Poller = (() => { let timer = null; let running = false; @@ -1047,9 +1266,32 @@ const Poller = (() => { try { await fetchAll(); updateTape(); - if (state.page === 'overview') mainEl.innerHTML = overviewHtml(); - else if (state.page === 'sources') renderSourceCards(); - else if (state.page === 'lineage') { /* 血缘图交互态复杂,仅在用户操作或下次轮询后整体重绘一次 */ mainEl.innerHTML = lineageHtml(); renderLinRows(window.__linVM); applyLinHover(window.__linVM); fitLineageTable(); } + const vOv = snapshotVersion('overview'), vSrc = snapshotVersion('sources'), vLin = snapshotVersion('lineage'); + if (state.page === 'overview' && vOv !== dataVersion.overview) { + dataVersion.overview = vOv; + mainEl.innerHTML = overviewHtml(); + paintSparks(); + flashChangedLatencies(); + } else if (state.page === 'overview') { + flashChangedLatencies(); + } + if (state.page === 'sources' && vSrc !== dataVersion.sources) { + dataVersion.sources = vSrc; + renderSourceCards(); + } + if (state.page === 'lineage' && vLin !== dataVersion.lineage) { + dataVersion.lineage = vLin; + mainEl.innerHTML = lineageHtml(); + const vm = buildLineageVM(); + window.__linVM = vm; + renderLinRows(vm); + applyLinHover(vm); + const svg = $('linSvg'); + if (svg) svg.addEventListener('mouseleave', () => setLinHover(null, window.__linVM)); + fitLineageTable(); + } + // 真实调用事件脉冲:血缘页上对应来源节点亮一圈 ledPing 节奏的光环 + pulseSourcesOnNewCalls(); updatePhaseTag(); setNetOk(true); } catch (err) { @@ -1072,10 +1314,39 @@ const Poller = (() => { }; })(); +/* 真实调用脉冲:provider_call_log 出现新调用时,给血缘图对应来源节点 + 叠一圈与 ledPing 同节奏(1.6s ease-out)的扩散光环;这是事件动效, + 与 gnode-pulse 的静态呼吸不同,一次即消散。 */ +let pulseSeenCall = 0; +function pulseSourcesOnNewCalls() { + if (state.page !== 'lineage') { + pulseSeenCall = ((RAW.providers && RAW.providers.recent_calls) || []).reduce((m, c) => Math.max(m, c.id || 0), pulseSeenCall); + return; + } + const calls = (RAW.providers && RAW.providers.recent_calls) || []; + const fresh = calls.filter((c) => (c.id || 0) > pulseSeenCall); + pulseSeenCall = calls.reduce((m, c) => Math.max(m, c.id || 0), pulseSeenCall); + fresh.forEach((c) => { + const g = document.querySelector(`#linSvg g[data-nid="${CSS.escape(c.provider)}"]`); + if (!g) return; + const circle = g.querySelector('circle'); + if (!circle) return; + const ring = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + ring.setAttribute('cx', circle.getAttribute('cx')); + ring.setAttribute('cy', circle.getAttribute('cy')); + ring.setAttribute('r', '5'); + ring.setAttribute('fill', 'none'); + ring.setAttribute('stroke', '#22d3ee'); + ring.setAttribute('stroke-width', '1.2'); + ring.setAttribute('class', 'node-ping'); + g.appendChild(ring); + setTimeout(() => ring.remove(), 1700); + }); +} + function resetTapeBaselines() { - // 恢复轮询时静默重建基线,不补播暂停期间错过的事件。 + // 恢复轮询时静默重建基线,不补播暂停期间错过的事件(但保留已上带内容)。 tapeSeen.call = tapeSeen.provCall = tapeSeen.audit = tapeSeen.job = Number.MAX_SAFE_INTEGER; - tapeSeen.first = true; } document.addEventListener('visibilitychange', () => { if (document.hidden) { Poller.stop(); } @@ -1090,11 +1361,15 @@ async function refreshQuiet() { /* ================= 启动 ================= */ async function startApp() { - setInterval(updateClock, 1000); - updateClock(); await fetchAll(); - tapeSeen.first = true; + dataVersion.overview = snapshotVersion('overview'); + dataVersion.sources = snapshotVersion('sources'); + dataVersion.lineage = snapshotVersion('lineage'); + pulseSeenCall = ((RAW.providers && RAW.providers.recent_calls) || []).reduce((m, c) => Math.max(m, c.id || 0), 0); renderPage(pageFromHash()); + updateTape(); // 预装最近真实事件并开始滚动 + updateClock(); + setInterval(() => { updateClock(); updateDynamics(); }, 200); // 打样 tick 节奏 await Poller.start(); } diff --git a/xiaobai-datahub/admin/index.html b/xiaobai-datahub/admin/index.html index a9493c2..1c9e5a7 100644 --- a/xiaobai-datahub/admin/index.html +++ b/xiaobai-datahub/admin/index.html @@ -46,8 +46,10 @@
+
+ -
小白复盘 · 数据中枢DATA-HUB · HEL-529
+
小白复盘 · 数据中枢DATA-HUB