feat(governance): close the 6 data-disease gaps — DQ monitoring, ownership, access posture, lineage & observability

Adds native Command Center features (no new containers) integrated as sub-tabs
in the existing Data Explorer and Data Quality views:

- Continuous Data Quality (dq_monitor.py): live completeness/uniqueness/validity/
  freshness scorecards via Trino with rolling trends → DataQuality "Live Monitoring".
- Ownership & stewardship (catalog_governance.py): owner/steward/tier matrix,
  orphan detection, business glossary; local store best-effort synced to
  OpenMetadata (owner PATCH) → Data Explorer "Ownership".
- Access & policy posture: per-dataset compliance combining PII masking, ownership,
  live DQ and observability alerts vs data contracts → Data Explorer "Access & Policies".
- Lineage (lineage.py): staged source→CDC→Spark→S3→Iceberg→Trino→serving graph with
  live row counts and column-level PII/masking tracing → Data Explorer "Lineage".
- Observability (observability.py): volume/freshness/schema-drift monitoring with
  alerts → Data Explorer "Observability".
- Shared lake_meta.py dataset registry + bounded Trino client; fast native row-count
  and PK-indexed freshness so monitors stay cheap on 25-54M-row tables.
- LLM context (lab_context.py) enriched with DQ scores, ownership and active alerts.
This commit is contained in:
mo
2026-06-29 17:43:37 +00:00
parent d066def8b4
commit f36c8906bc
15 changed files with 2479 additions and 4 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py hdfs_api.py ssh_terminal.py pipeline_ops.py hadoop_analytics.py agent_ops.py cdc_consumer.py movements.py dataflow.py streaming_ops.py spark_workbench.py hadoop_sql.py hdfs_kafka.py webhdfs_util.py pii_catalog.py platform_context.py trino_federated.py etl_offload.py agent_activity.py hive_bench_seed.json .
COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py hdfs_api.py ssh_terminal.py pipeline_ops.py hadoop_analytics.py agent_ops.py cdc_consumer.py movements.py dataflow.py streaming_ops.py spark_workbench.py hadoop_sql.py hdfs_kafka.py webhdfs_util.py pii_catalog.py platform_context.py trino_federated.py etl_offload.py agent_activity.py lake_meta.py lineage.py dq_monitor.py observability.py catalog_governance.py hive_bench_seed.json .
RUN mkdir -p /data
ENV DATABASE_URL=sqlite:////data/atc-agents.db
EXPOSE 3201
+314
View File
@@ -0,0 +1,314 @@
"""Data governance: ownership, stewardship, glossary & posture
(Diseases #3 Ownership and #5 Governance).
Ownership/steward assignments are kept in a local store (so the feature always
works for the demo regardless of OpenMetadata ingestion state) and are
best-effort mirrored to OpenMetadata. Users/teams for the assignment dropdowns
are read from OpenMetadata when reachable. The governance *posture* view
combines, per dataset: ownership, PII/masking (pii_catalog), live data-quality
score (dq_monitor), observability alerts and a simple data-contract check.
Endpoints:
GET /api/governance/datasets -> ownership matrix (+ orphan flag)
GET /api/governance/users -> assignable users/teams
POST /api/governance/assign -> set owner/steward/team/tier (native + OM)
GET /api/governance/glossary -> business glossary terms
GET /api/governance/posture -> combined governance posture per dataset
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from lake_meta import DATASETS, DATASET_BY_KEY
router = APIRouter(prefix="/api/governance", tags=["governance"])
OPENMETADATA_URL = os.getenv("OPENMETADATA_URL", "").rstrip("/")
OPENMETADATA_TOKEN = os.getenv("OPENMETADATA_TOKEN", "")
OWNERS_PATH = Path(os.getenv("GOVERNANCE_OWNERS_PATH", "/data/governance_owners.json"))
CONTRACTS_PATH = Path(os.getenv("GOVERNANCE_CONTRACTS_PATH", "/data/governance_contracts.json"))
# Default data contracts (quality SLAs) per dataset — used when none stored.
DEFAULT_CONTRACTS = {
"_default": {"min_score": 80, "min_completeness": 95, "min_freshness_min": 60,
"max_critical_alerts": 0},
}
# Seed business glossary so the term list is never empty even before OM ingestion.
SEED_GLOSSARY = [
{"name": "Customer", "description": "A person or organization that places sales orders.",
"related": ["customer_id", "customer_name", "customer_email"], "domain": "Sales"},
{"name": "Order", "description": "A sales transaction with an amount, channel and status.",
"related": ["order_id", "amount", "order_status"], "domain": "Sales"},
{"name": "Revenue", "description": "Sum of order amounts over a period.",
"related": ["amount", "currency"], "domain": "Sales"},
{"name": "Employee", "description": "A member of the workforce tracked via HR events.",
"related": ["employee_id", "department", "role_name"], "domain": "People"},
{"name": "PII", "description": "Personally Identifiable Information — masked per policy.",
"related": ["customer_email", "national_id", "billing_iban"], "domain": "Governance"},
{"name": "Telemetry", "description": "Device metric readings over time.",
"related": ["device_id", "metric_type", "metric_value"], "domain": "IoT"},
]
def _headers() -> dict[str, str]:
h = {"Accept": "application/json"}
if OPENMETADATA_TOKEN:
h["Authorization"] = f"Bearer {OPENMETADATA_TOKEN}"
return h
def _load(path: Path) -> dict[str, Any]:
try:
return json.loads(path.read_text())
except Exception:
return {}
def _save(path: Path, data: dict[str, Any]) -> None:
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2))
except Exception:
pass
# ── OpenMetadata best-effort ────────────────────────────────────────────────
def _om_users() -> list[dict[str, Any]]:
if not OPENMETADATA_URL:
return []
out: list[dict[str, Any]] = []
try:
with httpx.Client(timeout=8.0, verify=False) as c:
r = c.get(f"{OPENMETADATA_URL}/api/v1/users?limit=50&isBot=false", headers=_headers())
if r.status_code == 200:
for u in r.json().get("data", []):
out.append({"id": u.get("id"), "name": u.get("name"),
"display": u.get("displayName") or u.get("name"), "type": "user"})
rt = c.get(f"{OPENMETADATA_URL}/api/v1/teams?limit=50", headers=_headers())
if rt.status_code == 200:
for t in rt.json().get("data", []):
out.append({"id": t.get("id"), "name": t.get("name"),
"display": t.get("displayName") or t.get("name"), "type": "team"})
except Exception:
pass
return out
def _om_glossary() -> list[dict[str, Any]]:
if not OPENMETADATA_URL:
return []
try:
with httpx.Client(timeout=8.0, verify=False) as c:
r = c.get(f"{OPENMETADATA_URL}/api/v1/glossaryTerms?limit=100", headers=_headers())
if r.status_code == 200:
return [{"name": t.get("name"), "description": t.get("description", ""),
"domain": "OpenMetadata", "related": []}
for t in r.json().get("data", [])]
except Exception:
pass
return []
def _om_patch_owner(om_fqn: str, user: dict[str, Any]) -> bool:
"""Best-effort: set table owner in OpenMetadata via JSON-Patch."""
if not OPENMETADATA_URL or not om_fqn or not user.get("id"):
return False
try:
with httpx.Client(timeout=8.0, verify=False) as c:
g = c.get(f"{OPENMETADATA_URL}/api/v1/tables/name/{om_fqn}", headers=_headers())
if g.status_code != 200:
return False
patch = [{"op": "add", "path": "/owners/0",
"value": {"id": user["id"], "type": user.get("type", "user")}}]
h = {**_headers(), "Content-Type": "application/json-patch+json"}
p = c.patch(f"{OPENMETADATA_URL}/api/v1/tables/name/{om_fqn}",
headers=h, content=json.dumps(patch))
return p.status_code in (200, 201)
except Exception:
return False
# ── helpers pulling from sibling modules (all best-effort) ──────────────────
def _pii_summary(pii_key: str) -> dict[str, Any]:
try:
from pii_catalog import get_pii
for d in get_pii().get("datasets", []):
if d.get("key") == pii_key:
return {"pii_count": d.get("pii_count", 0),
"all_masked": d.get("all_masked", False),
"masked": sum(1 for c in d.get("pii_columns", []) if c.get("masked")),
"unmasked": sum(1 for c in d.get("pii_columns", []) if not c.get("masked"))}
except Exception:
pass
return {"pii_count": 0, "all_masked": False, "masked": 0, "unmasked": 0}
def _dq_card(key: str) -> dict[str, Any]:
try:
from dq_monitor import scorecards_view
for c in scorecards_view().get("cards", []):
if c.get("key") == key:
return {"score": c.get("score"), "issues": c.get("issues", []),
"freshness_age_min": c.get("freshness_age_min")}
except Exception:
pass
return {"score": None, "issues": []}
def _obs_alerts(key: str) -> list[dict[str, Any]]:
try:
from observability import _state, _lock
with _lock:
return [{"type": a["type"], "severity": a["severity"], "message": a["message"]}
for a in _state["alerts_active"].values() if a["dataset"] == key]
except Exception:
return []
# ── views ───────────────────────────────────────────────────────────────────
def datasets_view() -> dict[str, Any]:
owners = _load(OWNERS_PATH)
rows = []
orphans = 0
stewarded = 0
for ds in DATASETS:
rec = owners.get(ds["key"], {})
owner = rec.get("owner")
steward = rec.get("steward")
if not owner:
orphans += 1
if steward:
stewarded += 1
rows.append({
"key": ds["key"], "label": ds["label"], "engine": ds["engine"], "color": ds["color"],
"table": ds["fqtn"], "domain": rec.get("domain") or ds.get("domain"),
"owner": owner, "steward": steward, "team": rec.get("team"),
"tier": rec.get("tier"), "classification": rec.get("classification"),
"updated_at": rec.get("updated_at"),
"orphan": not owner,
"pii": _pii_summary(ds.get("pii_key", ds["key"])),
})
return {"ok": True, "datasets": rows,
"summary": {"total": len(rows), "orphans": orphans, "stewarded": stewarded,
"owned": len(rows) - orphans},
"om_connected": bool(OPENMETADATA_URL)}
class AssignRequest(BaseModel):
key: str
owner: str | None = None
steward: str | None = None
team: str | None = None
tier: str | None = None
classification: str | None = None
def posture_view() -> dict[str, Any]:
owners = _load(OWNERS_PATH)
contracts = _load(CONTRACTS_PATH)
default_c = DEFAULT_CONTRACTS["_default"]
out = []
compliant = 0
for ds in DATASETS:
key = ds["key"]
rec = owners.get(key, {})
contract = {**default_c, **(contracts.get(key, {}))}
pii = _pii_summary(ds.get("pii_key", key))
dq = _dq_card(key)
alerts = _obs_alerts(key)
crit = sum(1 for a in alerts if a["severity"] == "critical")
checks = []
score = dq.get("score")
checks.append({"name": "DQ score", "ok": score is not None and score >= contract["min_score"],
"value": score, "target": contract["min_score"]})
checks.append({"name": "Owner assigned", "ok": bool(rec.get("owner")),
"value": rec.get("owner") or "", "target": "assigned"})
checks.append({"name": "PII masked", "ok": pii["unmasked"] == 0,
"value": f'{pii["masked"]}/{pii["pii_count"]}', "target": "all"})
checks.append({"name": "Critical alerts", "ok": crit <= contract["max_critical_alerts"],
"value": crit, "target": contract["max_critical_alerts"]})
ok = all(c["ok"] for c in checks)
if ok:
compliant += 1
out.append({
"key": key, "label": ds["label"], "engine": ds["engine"], "color": ds["color"],
"table": ds["fqtn"], "owner": rec.get("owner"), "steward": rec.get("steward"),
"tier": rec.get("tier"), "pii": pii, "dq_score": score, "issues": dq.get("issues", []),
"alerts": alerts, "contract": contract, "checks": checks, "compliant": ok,
})
return {"ok": True, "datasets": out,
"summary": {"total": len(out), "compliant": compliant,
"non_compliant": len(out) - compliant}}
def summary_for_llm() -> dict[str, Any]:
owners = _load(OWNERS_PATH)
return {
"owners": {k: {"owner": v.get("owner"), "steward": v.get("steward"), "tier": v.get("tier")}
for k, v in owners.items()},
"orphan_datasets": [d["key"] for d in DATASETS if not owners.get(d["key"], {}).get("owner")],
}
@router.get("/datasets")
async def get_datasets() -> JSONResponse:
return JSONResponse(datasets_view())
@router.get("/users")
async def get_users() -> JSONResponse:
users = _om_users()
if not users:
users = [{"id": None, "name": n, "display": n, "type": "user"}
for n in ("admin", "bart", "mo")] + \
[{"id": None, "name": "Organization", "display": "Organization", "type": "team"}]
return JSONResponse({"ok": True, "users": users, "om_connected": bool(OPENMETADATA_URL)})
@router.post("/assign")
async def assign(body: AssignRequest) -> JSONResponse:
if body.key not in DATASET_BY_KEY:
return JSONResponse({"ok": False, "error": f"unknown dataset {body.key}"}, status_code=400)
owners = _load(OWNERS_PATH)
rec = dict(owners.get(body.key, {}))
for field in ("owner", "steward", "team", "tier", "classification"):
val = getattr(body, field)
if val is not None:
rec[field] = val or None
rec["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
owners[body.key] = rec
_save(OWNERS_PATH, owners)
om_synced = False
if body.owner:
ds = DATASET_BY_KEY[body.key]
user = next((u for u in _om_users() if u.get("display") == body.owner or u.get("name") == body.owner), None)
if user:
om_synced = _om_patch_owner(ds.get("om_fqn", ""), user)
return JSONResponse({"ok": True, "key": body.key, "record": rec, "om_synced": om_synced})
@router.get("/glossary")
async def get_glossary() -> JSONResponse:
terms = _om_glossary()
source = "openmetadata"
if not terms:
terms = SEED_GLOSSARY
source = "seed"
return JSONResponse({"ok": True, "source": source, "count": len(terms), "terms": terms})
@router.get("/posture")
async def get_posture() -> JSONResponse:
return JSONResponse(posture_view())
+345
View File
@@ -0,0 +1,345 @@
"""Continuous data-quality monitoring (Disease #2 — Dirty Data).
Where dq-api runs Great-Expectations/Soda on *uploaded* files, this agent runs
quality checks continuously against the *live* business tables through Trino and
produces a per-dataset scorecard (0-100) across five dimensions:
completeness non-null ratio across columns
uniqueness distinct/key ratio (duplicate detection)
validity domain rules (non-negative amounts, present timestamps)
freshness age of the newest record vs a threshold
volume row count + delta vs the previous cycle
A rolling score history powers trend sparklines. The loop streams the exact
SQL it runs into the Data Custodian terminal so operators can see the checks.
Endpoints:
GET /api/dq/scorecards -> all datasets, dimensions, score, trend
GET /api/dq/scorecard/{key} -> one dataset detail
POST /api/dq/run -> run one cycle now
POST /api/dq/config -> {enabled, interval_s, sample}
"""
from __future__ import annotations
import threading
import time
from collections import deque
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Body
from fastapi.responses import JSONResponse
from lake_meta import DATASETS, DATASET_BY_KEY, trino, trino_scalar, discover_columns
router = APIRouter(prefix="/api/dq", tags=["data-quality"])
FRESHNESS_THRESHOLD_MIN = 60.0 # newest row older than this => freshness degraded
MAX_COLS = 24
_lock = threading.Lock()
_state: dict[str, Any] = {
"enabled": True,
"interval_s": 180.0,
"sample": 20000,
"running": False,
"cycles": 0,
"last_cycle_ts": 0.0,
"next_run_ts": 0.0,
"started_at": None,
"cards": {}, # key -> latest scorecard
"history": {d["key"]: deque(maxlen=60) for d in DATASETS},
"cols_cache": {}, # key -> [{name,type}]
"feed": deque(maxlen=40),
}
def _now() -> datetime:
return datetime.now(timezone.utc)
def _term(text: str, level: str = "info", phase: str = "dq") -> None:
try:
from agent_terminal import emit_threadsafe
emit_threadsafe("data-custodian", text, level=level, phase=phase)
except Exception:
pass
def _feed(text: str, level: str = "info") -> None:
_state["feed"].appendleft({"ts": _now().isoformat(), "text": text, "level": level})
def _score_color(score: float) -> str:
if score >= 90:
return "#34d399"
if score >= 75:
return "#fbbf24"
if score >= 50:
return "#fb923c"
return "#f87171"
def _obs_volume_freshness(key: str) -> tuple[int | None, float | None]:
"""Read row count + freshness from the observability monitor (in-memory)."""
try:
from observability import _state, _lock
with _lock:
st = _state["datasets"].get(key) or {}
return st.get("rows"), st.get("freshness_age_min")
except Exception:
return None, None
def _columns(ds: dict[str, Any]) -> list[dict[str, str]]:
key = ds["key"]
cols = _state["cols_cache"].get(key)
if not cols:
cols = discover_columns(ds)
if cols:
_state["cols_cache"][key] = cols
return cols or []
def _assess(ds: dict[str, Any], sample: int) -> dict[str, Any]:
key = ds["key"]
fq = ds["fqtn"]
cols = _columns(ds)
col_names = [c["name"] for c in cols][:MAX_COLS]
dims: dict[str, Any] = {}
issues: list[str] = []
col_profiles: list[dict[str, Any]] = []
# ── one bounded query: completeness + uniqueness + validity over a sample ──
has_unique_key = ds.get("unique_key", True)
key_col = ds.get("key_col") if ds.get("key_col") in col_names else (col_names[0] if col_names else None)
if not has_unique_key:
key_col = None # no single-column unique key (e.g. composite PK) → skip dedup
ts_col = ds.get("ts_col") if ds.get("ts_col") in col_names else None
amt_col = ds.get("amount_col") if ds.get("amount_col") in col_names else None
selects = ["count(*) AS n"]
for i, c in enumerate(col_names):
selects.append(f'count("{c}") AS c{i}')
if key_col:
selects.append(f'count(DISTINCT "{key_col}") AS dk')
if amt_col:
selects.append(f'count_if("{amt_col}" >= 0) AS amt_ok')
if ts_col:
selects.append(f'count_if("{ts_col}" IS NOT NULL) AS ts_ok')
sql = f"SELECT {', '.join(selects)} FROM (SELECT * FROM {fq} LIMIT {sample}) s"
_term(f"$ trino: profile {key} ({len(col_names)} cols · sample {sample:,})", level="cmd", phase="dq")
cols_out, rows = trino(sql, timeout=40.0)
rec = dict(zip(cols_out, rows[0])) if rows else {}
n = int(rec.get("n") or 0)
if n > 0:
# completeness
non_null_ratios = []
for i, c in enumerate(col_names):
cnt = int(rec.get(f"c{i}") or 0)
ratio = cnt / n
non_null_ratios.append(ratio)
col_profiles.append({"name": c, "completeness": round(ratio * 100, 1),
"nulls": n - cnt})
completeness = round(100.0 * sum(non_null_ratios) / max(1, len(non_null_ratios)), 1)
dims["completeness"] = completeness
worst = sorted(col_profiles, key=lambda x: x["completeness"])[:3]
for w in worst:
if w["completeness"] < 95:
issues.append(f'{w["name"]} {100 - w["completeness"]:.0f}% null')
# uniqueness / dedup
if key_col and rec.get("dk") is not None:
dk = int(rec["dk"])
uniq = round(100.0 * dk / n, 1)
dims["uniqueness"] = uniq
dups = n - dk
if dups > 0:
issues.append(f"{dups:,} duplicate {key_col} in sample")
# validity
valid_parts = []
if amt_col and rec.get("amt_ok") is not None:
valid_parts.append(int(rec["amt_ok"]) / n)
if ts_col and rec.get("ts_ok") is not None:
valid_parts.append(int(rec["ts_ok"]) / n)
if valid_parts:
validity = round(100.0 * sum(valid_parts) / len(valid_parts), 1)
dims["validity"] = validity
if validity < 99 and amt_col:
issues.append(f"negative/invalid {amt_col}")
# ── volume + freshness: reuse the observability monitor's in-memory probes
# (it already polls count(*) and max(ts)). We deliberately do NOT issue our
# own count/max here — those are expensive full scans on 25-54M-row tables —
# so a DQ cycle stays fast and the score is driven by the cheap sample. ──
volume, fresh_age_min = _obs_volume_freshness(key)
if volume is None:
volume = n # sample size until observability reports the real count
if fresh_age_min is not None:
fresh_score = 100.0 if fresh_age_min <= FRESHNESS_THRESHOLD_MIN else max(
0.0, 100.0 - (fresh_age_min - FRESHNESS_THRESHOLD_MIN) / 5.0)
dims["freshness"] = round(fresh_score, 1)
if fresh_age_min > FRESHNESS_THRESHOLD_MIN and not ds.get("curated"):
issues.append(f"stale {fresh_age_min:.0f}m")
score = round(sum(dims.values()) / len(dims), 1) if dims else 0.0
prev = _state["cards"].get(key, {})
prev_vol = prev.get("volume")
delta = (volume - prev_vol) if (volume is not None and prev_vol is not None) else None
card = {
"key": key, "label": ds["label"], "engine": ds["engine"], "color": ds["color"],
"table": fq, "domain": ds.get("domain"),
"score": score, "score_color": _score_color(score),
"dimensions": dims, "volume": volume, "volume_delta": delta,
"freshness_age_min": round(fresh_age_min, 1) if fresh_age_min is not None else None,
"issues": issues[:5], "columns": len(col_names),
"worst_columns": sorted(col_profiles, key=lambda x: x["completeness"])[:5],
"ts": _now().isoformat(),
}
lvl = "ok" if score >= 90 else ("warn" if score >= 60 else "err")
_term(f"{key}: score {score} · " + " · ".join(f"{k} {v}" for k, v in dims.items())
+ (f" · {len(issues)} issue(s)" if issues else ""), level=lvl, phase="dq")
return card
def run_cycle() -> dict[str, Any]:
if _state["running"]:
return {"ok": True, "skipped": "already running"}
_state["running"] = True
sample = int(_state["sample"])
n_ok = 0
try:
_term(f"═══ DQ monitor cycle {_state['cycles'] + 1} — live tables via Trino ═══",
level="info", phase="cycle")
for ds in DATASETS:
if not _state["enabled"]:
break
try:
card = _assess(ds, sample)
except Exception as exc:
card = {"key": ds["key"], "label": ds["label"], "engine": ds["engine"],
"color": ds["color"], "table": ds["fqtn"], "score": None,
"score_color": "#64748b", "dimensions": {}, "error": str(exc)[:160],
"issues": [f"check failed: {str(exc)[:80]}"], "ts": _now().isoformat()}
_term(f"{ds['key']}: {str(exc)[:120]}", level="err", phase="dq")
with _lock:
_state["cards"][ds["key"]] = card
if card.get("score") is not None:
_state["history"][ds["key"]].append({"t": _now().strftime("%H:%M"), "score": card["score"]})
n_ok += 1
_state["cycles"] += 1
_state["last_cycle_ts"] = time.time()
_state["next_run_ts"] = time.time() + float(_state["interval_s"])
avg = _overall_score()
_feed(f"cycle {_state['cycles']}{n_ok}/{len(DATASETS)} datasets scored · platform DQ {avg}")
_term(f"═══ cycle {_state['cycles']} done — platform DQ score {avg} ═══", level="ok", phase="cycle")
finally:
_state["running"] = False
return {"ok": True, "scored": n_ok, "platform_score": _overall_score()}
def _overall_score() -> float | None:
vals = [c["score"] for c in _state["cards"].values() if c.get("score") is not None]
return round(sum(vals) / len(vals), 1) if vals else None
def _loop() -> None:
_state["started_at"] = _now().isoformat()
time.sleep(20)
while True:
try:
if _state["enabled"]:
run_cycle()
except Exception:
pass
time.sleep(max(30.0, float(_state["interval_s"])))
threading.Thread(target=_loop, daemon=True, name="dq-monitor").start()
def scorecards_view() -> dict[str, Any]:
with _lock:
cards = []
for ds in DATASETS:
c = _state["cards"].get(ds["key"])
if c:
cards.append({**c, "trend": list(_state["history"][ds["key"]])})
else:
cards.append({"key": ds["key"], "label": ds["label"], "engine": ds["engine"],
"color": ds["color"], "table": ds["fqtn"], "score": None,
"score_color": "#64748b", "dimensions": {}, "issues": [],
"trend": [], "pending": True})
# platform dimension averages
dim_avg: dict[str, list[float]] = {}
for c in cards:
for k, v in (c.get("dimensions") or {}).items():
dim_avg.setdefault(k, []).append(v)
return {
"ok": True,
"enabled": _state["enabled"],
"interval_s": _state["interval_s"],
"sample": _state["sample"],
"running": _state["running"],
"cycles": _state["cycles"],
"last_cycle_ts": _state["last_cycle_ts"],
"next_run_ts": _state["next_run_ts"],
"platform_score": _overall_score(),
"dimension_averages": {k: round(sum(v) / len(v), 1) for k, v in dim_avg.items()},
"cards": cards,
"feed": list(_state["feed"])[:20],
}
def summary_for_llm() -> dict[str, Any]:
with _lock:
return {
"platform_dq_score": _overall_score(),
"datasets": [{"key": c["key"], "score": c.get("score"),
"issues": c.get("issues", []), "freshness_age_min": c.get("freshness_age_min")}
for c in _state["cards"].values()],
}
@router.get("/scorecards")
async def get_scorecards() -> JSONResponse:
return JSONResponse(scorecards_view())
@router.get("/scorecard/{key}")
async def get_scorecard(key: str) -> JSONResponse:
with _lock:
c = _state["cards"].get(key)
if not c:
return JSONResponse({"ok": False, "error": "unknown or not yet scored"}, status_code=404)
return JSONResponse({"ok": True, "card": {**c, "trend": list(_state["history"].get(key, []))}})
@router.post("/run")
async def post_run() -> JSONResponse:
from starlette.concurrency import run_in_threadpool
res = await run_in_threadpool(run_cycle)
return JSONResponse({**res, "scorecards": scorecards_view()})
@router.post("/config")
async def post_config(body: dict = Body(default={})) -> JSONResponse:
if "enabled" in body:
_state["enabled"] = bool(body["enabled"])
if "interval_s" in body:
try:
_state["interval_s"] = max(30.0, min(1800.0, float(body["interval_s"])))
except Exception:
pass
if "sample" in body:
try:
_state["sample"] = max(1000, min(200000, int(body["sample"])))
except Exception:
pass
return JSONResponse({"ok": True, "enabled": _state["enabled"],
"interval_s": _state["interval_s"], "sample": _state["sample"]})
+36
View File
@@ -671,6 +671,21 @@ def collect_governance(log: TerminalLogFn | None = None) -> dict[str, Any]:
"mysql_hr.hr.employee_events -> iceberg.curated_masked.employee_events_masked (PII masked)",
"hdfs:/data/historical/sales_orders -> iceberg.hadoop.historical_sales_hdfs",
]
try:
from dq_monitor import summary_for_llm as _dq
out["data_quality"] = _dq()
except Exception as exc:
out["data_quality"] = {"error": str(exc)}
try:
from observability import summary_for_llm as _obs
out["observability"] = _obs()
except Exception as exc:
out["observability"] = {"error": str(exc)}
try:
from catalog_governance import summary_for_llm as _own
out["ownership"] = _own()
except Exception as exc:
out["ownership"] = {"error": str(exc)}
return out
@@ -700,6 +715,27 @@ def _section_governance(g: dict[str, Any]) -> list[str]:
lines.append(" Lineage:")
for ln in g.get("lineage") or []:
lines.append(f" - {ln}")
dq = g.get("data_quality") or {}
if isinstance(dq, dict) and "error" not in dq:
lines.append(f" Data quality (continuous, live tables): platform score {dq.get('platform_dq_score')}")
for d in dq.get("datasets") or []:
iss = f" issues: {', '.join(d['issues'][:3])}" if d.get("issues") else ""
lines.append(f" - {d['key']}: score {d.get('score')}{iss}")
own = g.get("ownership") or {}
if isinstance(own, dict) and "error" not in own:
orph = own.get("orphan_datasets") or []
lines.append(f" Ownership: {len(own.get('owners', {}))} assigned"
+ (f", orphans (no owner): {', '.join(orph)}" if orph else ", no orphans"))
for k, v in (own.get("owners") or {}).items():
if v.get("owner"):
lines.append(f" - {k}: owner={v.get('owner')} steward={v.get('steward') or ''} tier={v.get('tier') or ''}")
obs = g.get("observability") or {}
if isinstance(obs, dict) and "error" not in obs:
ac = obs.get("active_alerts") or {}
lines.append(f" Observability alerts: {ac.get('total', 0)} active "
f"(critical={ac.get('critical', 0)}, warning={ac.get('warning', 0)})")
for a in (obs.get("alerts") or [])[:5]:
lines.append(f" - [{a['severity']}] {a['dataset']}: {a['message']}")
return lines
+147
View File
@@ -0,0 +1,147 @@
"""Shared lakehouse metadata + Trino helpers.
Single source of truth for the business datasets the governance / data-quality /
lineage / observability features operate on, plus a small synchronous Trino
client. Imported by dq_monitor.py, observability.py, lineage.py and
catalog_governance.py so every feature reasons about the exact same tables that
the rest of the Command Center (pii_catalog, etl_offload, trino_federated)
already exposes.
"""
from __future__ import annotations
import os
from typing import Any
import httpx
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
TRINO_USER = os.getenv("TRINO_USER", "mo")
# Canonical business datasets, aligned with pii_catalog.DATASETS / etl_offload.
# fqtn = fully-qualified Trino table name (catalog.schema.table)
# om_fqn = OpenMetadata FQN best-effort (governance falls back to native store)
DATASETS: list[dict[str, Any]] = [
{"key": "orders", "label": "Sales orders", "engine": "PostgreSQL", "color": "#fbbf24",
"catalog": "postgres_sales", "schema": "public", "table": "sales_orders",
"fqtn": "postgres_sales.public.sales_orders", "pii_key": "postgres",
"om_fqn": "atc_trino.postgres_sales.public.sales_orders",
"key_col": "order_id", "ts_col": "order_ts", "amount_col": "amount",
"native_count": ("postgres", "public.sales_orders"),
"domain": "Sales", "topic": "atc.public.sales_orders"},
{"key": "hr", "label": "HR events", "engine": "MySQL", "color": "#60a5fa",
"catalog": "mysql_hr", "schema": "hr", "table": "employee_events",
"fqtn": "mysql_hr.hr.employee_events", "pii_key": "mysql",
"om_fqn": "atc_trino.mysql_hr.hr.employee_events",
"key_col": "event_id", "ts_col": "event_ts", "amount_col": "salary_change",
"native_count": ("mysql", "hr.employee_events"),
"domain": "People", "topic": "atc.hr.employee_events"},
{"key": "supply", "label": "Supply chain events", "engine": "MongoDB", "color": "#a78bfa",
"catalog": "mongodb_supplychain", "schema": "supplychain", "table": "events",
"fqtn": "mongodb_supplychain.supplychain.events", "pii_key": "mongodb",
"om_fqn": "atc_trino.mongodb_supplychain.supplychain.events",
"key_col": "event_id", "ts_col": "ts", "amount_col": "amount",
"native_count": ("mongodb", "supplychain.events"),
"domain": "Supply Chain", "topic": "atc.supplychain.events"},
{"key": "telemetry", "label": "Device telemetry", "engine": "Cassandra", "color": "#22d3ee",
"catalog": "cassandra_telemetry", "schema": "telemetry", "table": "device_metrics",
"fqtn": "cassandra_telemetry.telemetry.device_metrics", "pii_key": "cassandra",
"om_fqn": "atc_trino.cassandra_telemetry.telemetry.device_metrics",
"key_col": "device_id", "ts_col": "metric_ts", "amount_col": "metric_value",
"unique_key": False,
"domain": "IoT", "topic": "atc.telemetry.device_metrics"},
{"key": "curated", "label": "Curated masked (Iceberg)", "engine": "Iceberg / Trino", "color": "#34d399",
"catalog": "iceberg", "schema": "curated_masked", "table": "sales_orders_masked",
"fqtn": "iceberg.curated_masked.sales_orders_masked", "pii_key": "curated",
"om_fqn": "atc_trino.iceberg.curated_masked.sales_orders_masked",
"key_col": "order_id", "ts_col": "order_ts", "amount_col": "amount",
"domain": "Sales", "curated": True, "topic": None},
{"key": "hadoop", "label": "Historical sales (HDFS)", "engine": "Iceberg / HDFS", "color": "#f472b6",
"catalog": "iceberg", "schema": "hadoop", "table": "historical_sales_hdfs",
"fqtn": "iceberg.hadoop.historical_sales_hdfs", "pii_key": "hadoop",
"om_fqn": "atc_trino.iceberg.hadoop.historical_sales_hdfs",
"key_col": "order_id", "ts_col": "order_ts", "amount_col": "amount",
"domain": "Sales", "curated": True, "topic": None},
]
DATASET_BY_KEY = {d["key"]: d for d in DATASETS}
# Heuristics for picking the freshness / key column when discovering schema.
_TS_HINTS = ("_ts", "ts", "updated_at", "created_at", "event_time", "modified", "time")
_KEY_HINTS = ("_id", "id", "uuid", "key", "pk")
def trino(sql: str, timeout: float = 25.0) -> tuple[list[str], list[list[Any]]]:
"""Run a Trino statement, following nextUri pages. Returns (columns, rows).
`timeout` is both the per-request timeout AND an overall wall-clock deadline,
so a long full-scan (e.g. count(*) on a huge Cassandra table) is aborted and
cancelled instead of looping over nextUri pages for minutes and blocking the
caller's thread.
"""
import time as _t
cols: list[str] = []
rows: list[list[Any]] = []
deadline = _t.monotonic() + timeout
with httpx.Client(timeout=min(timeout, 15.0)) as client:
d = client.post(f"{TRINO_URL}/v1/statement", content=sql.encode(),
headers={"X-Trino-User": TRINO_USER}).json()
while True:
if d.get("error"):
raise RuntimeError(d["error"].get("message", "trino error"))
c = d.get("columns")
if c and not cols:
cols = [x["name"] for x in c]
rows += d.get("data") or []
nxt = d.get("nextUri")
if not nxt:
break
if _t.monotonic() > deadline:
try:
client.delete(nxt) # cancel the running query server-side
except Exception:
pass
raise TimeoutError(f"trino query exceeded {timeout}s deadline")
d = client.get(nxt).json()
return cols, rows
def trino_scalar(sql: str, timeout: float = 25.0) -> Any:
_c, rows = trino(sql, timeout=timeout)
if rows and rows[0]:
return rows[0][0]
return None
def discover_columns(ds: dict[str, Any], timeout: float = 12.0) -> list[dict[str, str]]:
"""[{name, type}] for a dataset's table via information_schema."""
sql = (f"SELECT column_name, data_type FROM {ds['catalog']}.information_schema.columns "
f"WHERE table_name = '{ds['table']}'")
if ds.get("schema"):
sql += f" AND table_schema = '{ds['schema']}'"
try:
_c, rows = trino(sql, timeout=timeout)
return [{"name": r[0], "type": r[1]} for r in rows]
except Exception:
return []
def pick_ts_col(columns: list[dict[str, str]], default: str | None = None) -> str | None:
names = [c["name"] for c in columns]
for c in columns:
if "timestamp" in (c.get("type") or "").lower() or "date" in (c.get("type") or "").lower():
return c["name"]
for n in names:
if any(h in n.lower() for h in _TS_HINTS):
return n
return default if default in names else None
def pick_key_col(columns: list[dict[str, str]], default: str | None = None) -> str | None:
names = [c["name"] for c in columns]
if default in names:
return default
for n in names:
if any(n.lower() == h or n.lower().endswith(h) for h in _KEY_HINTS):
return n
return names[0] if names else None
+248
View File
@@ -0,0 +1,248 @@
"""Data lineage graph for the Command Center (Disease #6 — Traceability).
Models the real end-to-end pipeline as an explicit, staged node/edge graph:
source DB → Debezium/Kafka (CDC) → Spark / Kafka Connect → S3 Parquet lake
→ Iceberg lakehouse (curated_masked / hadoop) → Trino federation
→ serving (Elasticsearch, RAG/ChromaDB, vLLM, Command Center chat)
Table-backed nodes are enriched with live Trino row counts. For the sales path
we expose column-level lineage with PII / masking status (reusing pii_catalog),
so an operator can trace any field from source to the masked curated layer.
OpenMetadata table lineage is layered in best-effort where the FQN resolves.
Endpoints:
GET /api/lineage/graph?dataset=<key> -> nodes + edges (+ column links)
GET /api/lineage/datasets -> selectable datasets
"""
from __future__ import annotations
import os
import time
from typing import Any
import httpx
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from lake_meta import DATASETS, DATASET_BY_KEY, trino_scalar
router = APIRouter(prefix="/api/lineage", tags=["lineage"])
OPENMETADATA_URL = os.getenv("OPENMETADATA_URL", "").rstrip("/")
OPENMETADATA_TOKEN = os.getenv("OPENMETADATA_TOKEN", "")
_cache: dict[str, Any] = {"ts": 0.0, "data": None}
_TTL = 30.0
# Which source datasets flow through the CDC → lakehouse pipeline.
_SOURCE_KEYS = ["orders", "hr", "supply", "telemetry"]
def _is_active() -> tuple[bool, bool]:
gen = arch = False
try:
from trino_federated import generator_active
gen = bool(generator_active())
except Exception:
pass
try:
from storage_s3 import archive_active
arch = bool(archive_active())
except Exception:
pass
return gen, arch
def _obs_counts() -> dict[str, int | None]:
"""Reuse the row counts the observability monitor already polls in-memory,
so the lineage graph never issues its own (potentially slow) count(*)."""
try:
from observability import _state, _lock
with _lock:
return {k: v.get("rows") for k, v in _state["datasets"].items()}
except Exception:
return {}
def _row_count(ds_key: str, counts: dict[str, int | None]) -> int | None:
return counts.get(ds_key)
def _pii_columns(ds_key: str) -> list[dict[str, Any]]:
"""Column-level detail with PII/masking flags from pii_catalog (best-effort).
ds_key is a lake_meta key; map it to the pii_catalog key first."""
pii_key = (DATASET_BY_KEY.get(ds_key, {}) or {}).get("pii_key", ds_key)
try:
from pii_catalog import get_pii
data = get_pii()
for d in data.get("datasets", []):
if d.get("key") == pii_key or d.get("node_id") == pii_key:
return [{"name": c["name"], "category": c.get("category"),
"masked": bool(c.get("masked")), "pii": True}
for c in d.get("pii_columns", [])]
except Exception:
pass
return []
def _om_lineage(fqn: str) -> dict[str, int]:
"""OpenMetadata table lineage (best-effort, short timeout). Kept out of the
hot path by default; only called when LINEAGE_OM_ENRICH=1 is set."""
if not OPENMETADATA_URL or not fqn or os.getenv("LINEAGE_OM_ENRICH", "0") != "1":
return {}
url = f"{OPENMETADATA_URL}/api/v1/lineage/table/name/{fqn}?upstreamDepth=1&downstreamDepth=1"
headers = {"Accept": "application/json"}
if OPENMETADATA_TOKEN:
headers["Authorization"] = f"Bearer {OPENMETADATA_TOKEN}"
try:
with httpx.Client(timeout=4.0, verify=False) as c:
r = c.get(url, headers=headers)
if r.status_code != 200:
return {}
j = r.json()
return {"upstream": len(j.get("upstreamEdges") or []),
"downstream": len(j.get("downstreamEdges") or [])}
except Exception:
return {}
def _build() -> dict[str, Any]:
gen_active, arch_active = _is_active()
counts = _obs_counts()
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
seen: set[str] = set()
def node(nid: str, label: str, ntype: str, stage: int, **meta: Any) -> None:
if nid in seen:
return
seen.add(nid)
nodes.append({"id": nid, "label": label, "type": ntype, "stage": stage, "meta": meta})
def edge(src: str, dst: str, label: str, kind: str, active: bool = False) -> None:
edges.append({"id": f"{src}->{dst}", "source": src, "target": dst,
"label": label, "kind": kind, "active": active})
# Stage 5/6 shared serving nodes
node("trino", "Trino", "engine", 5, engine="Trino", note="Federated SQL across every catalog")
node("es", "Elasticsearch", "serving", 6, engine="Elasticsearch", note="Business + infra search indices")
node("rag", "ChromaDB (RAG)", "serving", 6, engine="ChromaDB", note="Vector store / embeddings")
node("vllm", "vLLM", "serving", 6, engine="vLLM", note="LLM inference (GPU)")
node("cc", "Command Center", "serving", 6, engine="React", note="Knowledge Chat & dashboards")
edge("rag", "vllm", "context", "serve", active=True)
edge("vllm", "cc", "answers", "serve", active=True)
edge("trino", "es", "index business data", "serve")
edge("es", "cc", "search", "serve")
edge("trino", "cc", "dashboards", "serve")
for key in _SOURCE_KEYS:
ds = DATASET_BY_KEY[key]
src_id = f"src_{key}"
topic_id = f"topic_{key}"
lake_id = f"lake_{key}"
rows = _row_count(key, counts)
cols = _pii_columns(key)
node(src_id, f"{ds['engine']}\n{ds['table']}", "source", 0,
engine=ds["engine"], table=ds["fqtn"], rows=rows, domain=ds.get("domain"),
columns=cols, ts_col=ds.get("ts_col"), key_col=ds.get("key_col"),
om=_om_lineage(ds.get("om_fqn", "")))
# CDC path (Debezium → Kafka topic)
if ds.get("topic"):
node(topic_id, f"Kafka\n{ds['topic']}", "stream", 1,
engine="Kafka", topic=ds["topic"], note="Debezium CDC topic")
edge(src_id, topic_id, "Debezium CDC", "cdc", active=gen_active)
node("connect", "Kafka Connect", "stream", 2, engine="Kafka Connect",
note="Debezium source + HDFS/S3 sinks")
edge(topic_id, "connect", "consume", "cdc", active=gen_active)
# ETL offload (direct source → S3 Parquet lake)
node(lake_id, f"S3 lake\nlake/{key}", "storage", 3, engine="ObjectScale S3",
path=f"s3://data/lake/{key}/", note="Parquet parts (ETL offload)")
edge(src_id, lake_id, "ETL offload (Parquet)", "batch", active=arch_active)
# Spark / Connect → curated + hadoop Iceberg tables
node("spark", "Spark", "compute", 2, engine="Spark", note="Transform & mask → curated")
edge("connect", "spark", "stream", "transform", active=gen_active)
cur = DATASET_BY_KEY["curated"]
had = DATASET_BY_KEY["hadoop"]
node("iceberg_curated", f"Iceberg\n{cur['table']}", "lakehouse", 4, engine="Iceberg",
table=cur["fqtn"], rows=_row_count("curated", counts), masked_layer=True,
note="PII-masked curated layer", columns=_pii_columns("curated"),
om=_om_lineage(cur.get("om_fqn", "")))
node("iceberg_hadoop", f"Iceberg/HDFS\n{had['table']}", "lakehouse", 4, engine="Iceberg / HDFS",
table=had["fqtn"], rows=_row_count("hadoop", counts), note="Historical sales on HDFS",
om=_om_lineage(had.get("om_fqn", "")))
edge("spark", "iceberg_curated", "mask + write", "transform", active=arch_active)
edge("lake_orders", "iceberg_hadoop", "register external", "batch", active=arch_active)
edge("src_orders", "iceberg_curated", "curate (masked)", "transform")
# Lakehouse → Trino
for nid in ("iceberg_curated", "iceberg_hadoop"):
edge(nid, "trino", "query", "serve")
for key in _SOURCE_KEYS:
edge(f"src_{key}", "trino", "federate", "serve")
# Lake → RAG (documents/parquet feeding embeddings is conceptual)
edge("iceberg_curated", "rag", "embed (masked-safe)", "serve")
# Column-level links: source sales_orders → curated masked
column_links: list[dict[str, Any]] = []
src_cols = {c["name"] for c in _pii_columns("orders")}
cur_cols = {c["name"] for c in _pii_columns("curated")}
for cn in sorted(src_cols & cur_cols):
masked = any(c["name"] == cn and c["masked"] for c in _pii_columns("curated"))
column_links.append({"source": "src_orders", "target": "iceberg_curated",
"column": cn, "masked": masked})
return {
"ok": True,
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"active": {"generator": gen_active, "archive": arch_active},
"stages": ["Sources", "CDC / Stream", "Compute", "Lake storage",
"Lakehouse", "Federation", "Serving"],
"nodes": nodes,
"edges": edges,
"column_links": column_links,
"om_connected": bool(OPENMETADATA_URL),
}
def get_graph(use_cache: bool = True) -> dict[str, Any]:
now = time.time()
if use_cache and _cache["data"] and now - _cache["ts"] < _TTL:
return _cache["data"]
data = _build()
_cache["data"] = data
_cache["ts"] = now
return data
@router.get("/datasets")
async def datasets() -> JSONResponse:
return JSONResponse({"ok": True, "datasets": [
{"key": d["key"], "label": d["label"], "engine": d["engine"],
"color": d["color"], "table": d["fqtn"]}
for d in DATASETS]})
@router.get("/graph")
async def graph(dataset: str | None = None, refresh: bool = False) -> JSONResponse:
from starlette.concurrency import run_in_threadpool
data = await run_in_threadpool(get_graph, not refresh)
if dataset and dataset in DATASET_BY_KEY:
# Filter to nodes reachable from / to the selected source, keep serving spine.
keep = {f"src_{dataset}", f"topic_{dataset}", f"lake_{dataset}",
"connect", "spark", "iceberg_curated", "iceberg_hadoop",
"trino", "es", "rag", "vllm", "cc"}
nodes = [n for n in data["nodes"] if n["id"] in keep]
node_ids = {n["id"] for n in nodes}
edges = [e for e in data["edges"] if e["source"] in node_ids and e["target"] in node_ids]
out = {**data, "nodes": nodes, "edges": edges, "focus": dataset}
return JSONResponse(out)
return JSONResponse(data)
+8
View File
@@ -57,6 +57,10 @@ from spark_workbench import router as spark_workbench_router
from pii_catalog import router as pii_router
from trino_federated import router as federated_router
from etl_offload import router as etl_offload_router
from lineage import router as lineage_router
from dq_monitor import router as dq_router
from observability import router as observability_router
from catalog_governance import router as governance_router
from ssh_terminal import ssh_session
from node_registry import NODE_IDS, NODE_AGENT, NODE_REGISTRY, is_node_id
from node_ops import build_node_detail, probe_node, run_node_probe_task
@@ -768,6 +772,10 @@ app.include_router(spark_workbench_router)
app.include_router(pii_router)
app.include_router(federated_router)
app.include_router(etl_offload_router)
app.include_router(lineage_router)
app.include_router(dq_router)
app.include_router(observability_router)
app.include_router(governance_router)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
+370
View File
@@ -0,0 +1,370 @@
"""Data observability (Disease #6 — Traceability / observability).
A lightweight background monitor that, per business table, tracks over time:
volume row count and its delta between cycles
freshness age of the newest record
schema the column set; a change raises a drift alert
It derives alerts (critical/warning/info) for volume drops, stalled ingestion,
stale data and schema drift, and keeps a rolling time-series per dataset for the
trend charts in the UI.
Endpoints:
GET /api/observability/metrics -> per-dataset series + current state
GET /api/observability/alerts -> active + recent alerts
POST /api/observability/run -> run one probe cycle now
POST /api/observability/config -> {enabled, interval_s, freshness_min}
"""
from __future__ import annotations
import threading
import time
import uuid
from collections import deque
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Body
from fastapi.responses import JSONResponse
from lake_meta import DATASETS, trino_scalar, discover_columns
router = APIRouter(prefix="/api/observability", tags=["observability"])
_lock = threading.Lock()
_state: dict[str, Any] = {
"enabled": True,
"interval_s": 90.0,
"freshness_min": 30.0,
"running": False,
"cycles": 0,
"last_cycle_ts": 0.0,
"next_run_ts": 0.0,
"started_at": None,
"datasets": {
d["key"]: {"label": d["label"], "engine": d["engine"], "color": d["color"],
"table": d["fqtn"], "rows": None, "prev_rows": None, "delta": None,
"freshness_age_min": None, "columns": None, "schema_hash": None,
"series": deque(maxlen=80), "deltas": deque(maxlen=20),
"stalled_cycles": 0, "ts": None, "error": None}
for d in DATASETS
},
"alerts_active": {}, # dedup_key -> alert
"alerts_history": deque(maxlen=120),
"feed": deque(maxlen=40),
}
def _now() -> datetime:
return datetime.now(timezone.utc)
def _term(text: str, level: str = "info", phase: str = "observe") -> None:
try:
from agent_terminal import emit_threadsafe
emit_threadsafe("infra-sentinel", text, level=level, phase=phase)
except Exception:
pass
def _raise_alert(dataset: str, atype: str, severity: str, message: str) -> None:
dedup = f"{dataset}:{atype}"
existing = _state["alerts_active"].get(dedup)
if existing:
existing["count"] += 1
existing["last_ts"] = _now().isoformat()
existing["message"] = message
return
alert = {"id": uuid.uuid4().hex[:8], "dataset": dataset, "type": atype,
"severity": severity, "message": message, "count": 1,
"ts": _now().isoformat(), "last_ts": _now().isoformat(), "resolved": False}
_state["alerts_active"][dedup] = alert
_state["alerts_history"].appendleft(dict(alert))
lvl = "err" if severity == "critical" else ("warn" if severity == "warning" else "info")
_term(f" ⚠ ALERT [{severity}] {dataset}: {message}", level=lvl, phase="alert")
_state["feed"].appendleft({"ts": _now().isoformat(), "text": f"[{severity}] {dataset}: {message}", "level": lvl})
def _clear_alert(dataset: str, atype: str) -> None:
dedup = f"{dataset}:{atype}"
a = _state["alerts_active"].pop(dedup, None)
if a:
a["resolved"] = True
a["resolved_ts"] = _now().isoformat()
_state["alerts_history"].appendleft({**a, "message": f"resolved: {a['message']}"})
def _native_freshness(ds: dict[str, Any]) -> float | None:
"""Epoch seconds of the newest record, read cheaply via the PK index
(ORDER BY <pk> DESC LIMIT 1) on the source DB. Returns None if unavailable."""
nc = ds.get("native_count")
ts = ds.get("ts_col")
key = ds.get("key_col")
if not nc or not ts:
return None
eng = nc[0]
try:
import sql_console as s
if eng == "postgres" and key:
import psycopg2
conn = psycopg2.connect(host=s.DB_HOST, port=s.PG_PORT, user=s.PG_USER,
password=s.PG_PASS, dbname=s.PG_DB, connect_timeout=6)
try:
cur = conn.cursor()
cur.execute("SET statement_timeout = 6000")
cur.execute(f'SELECT extract(epoch FROM "{ts}") FROM public."{ds["table"]}" '
f'WHERE "{ts}" IS NOT NULL ORDER BY "{key}" DESC LIMIT 1')
row = cur.fetchone()
return float(row[0]) if row and row[0] is not None else None
finally:
conn.close()
if eng == "mysql" and key:
import pymysql
conn = pymysql.connect(host=s.DB_HOST, port=s.MYSQL_PORT, user=s.MYSQL_USER,
password=s.MYSQL_PASS, database=s.MYSQL_DB, connect_timeout=6)
try:
cur = conn.cursor()
cur.execute("SET SESSION MAX_EXECUTION_TIME = 6000")
cur.execute(f"SELECT UNIX_TIMESTAMP(`{ts}`) FROM `{ds['table']}` "
f"WHERE `{ts}` IS NOT NULL ORDER BY `{key}` DESC LIMIT 1")
row = cur.fetchone()
return float(row[0]) if row and row[0] is not None else None
finally:
conn.close()
if eng == "mongodb":
cli = s._mongo_client()
try:
doc = list(cli[ds["schema"]][ds["table"]].find({}, {ts: 1, "_id": 0}).sort("_id", -1).limit(1))
if doc:
import datetime as _dt
v = doc[0].get(ts)
if isinstance(v, _dt.datetime):
return v.timestamp()
finally:
cli.close()
except Exception:
return None
return None
def _probe(ds: dict[str, Any]) -> None:
key = ds["key"]
st = _state["datasets"][key]
fq = ds["fqtn"]
# volume — prefer a fast native planner estimate (postgres/mysql/mongo); use
# Trino metadata count for Iceberg; treat a slow/failed count as a soft miss.
rows = None
estimated = False
nc = ds.get("native_count")
if nc:
try:
import sql_console as _s
rows = _s._table_row_count(nc[0], nc[1])
estimated = True
except Exception:
rows = None
if rows is None:
try:
rows = int(trino_scalar(f"SELECT count(*) FROM {fq}", timeout=30.0) or 0)
except Exception as exc:
st["error"] = str(exc)[:140]
st["count_fails"] = st.get("count_fails", 0) + 1
return
st["error"] = None
st["count_fails"] = 0
st["estimated"] = estimated
prev = st["rows"]
st["prev_rows"] = prev
st["rows"] = rows
delta = (rows - prev) if prev is not None else None
st["delta"] = delta
st["series"].append({"t": _now().strftime("%H:%M:%S"), "rows": rows, "delta": delta or 0})
st["ts"] = _now().isoformat()
if delta is not None:
st["deltas"].append(delta)
# Guard against planner-estimate jitter: only alert on a material drop.
drop_threshold = max(2000, int(0.02 * (prev or 0)))
if delta < -drop_threshold:
_raise_alert(key, "volume_drop", "warning",
f"row count dropped by {abs(delta):,} ({prev:,}{rows:,})")
else:
_clear_alert(key, "volume_drop")
# stalled ingestion: no growth for several cycles on CDC sources. Skip for
# estimate-based counts (planner stats update lazily → false stalls).
if delta == 0 and not ds.get("curated") and not estimated:
st["stalled_cycles"] += 1
if st["stalled_cycles"] >= 4:
_raise_alert(key, "stalled", "warning",
f"no new rows for {st['stalled_cycles']} cycles")
else:
st["stalled_cycles"] = 0
_clear_alert(key, "stalled")
# spike (informational) — only for exact counts
pos = [d for d in st["deltas"] if d > 0]
if not estimated and pos and delta > (sum(pos) / len(pos)) * 6 and len(pos) >= 4:
_raise_alert(key, "spike", "info", f"volume spike +{delta:,} rows this cycle")
# freshness — cheap PK-indexed newest-row lookup for the CDC sources, with a
# short Trino max(ts) fallback for Iceberg tables.
if ds.get("ts_col"):
epoch = _native_freshness(ds)
if epoch is None:
try:
v = trino_scalar(f'SELECT to_unixtime(max("{ds["ts_col"]}")) FROM {fq}', timeout=10.0)
epoch = float(v) if v is not None else None
except Exception:
epoch = None
if epoch:
age = max(0.0, (time.time() - float(epoch)) / 60.0)
st["freshness_age_min"] = round(age, 1)
if age > float(_state["freshness_min"]) and not ds.get("curated"):
_raise_alert(key, "stale", "warning",
f"newest record is {age:.0f}m old (> {_state['freshness_min']:.0f}m)")
else:
_clear_alert(key, "stale")
# schema drift
cols = discover_columns(ds)
if cols:
names = sorted(c["name"] for c in cols)
h = hash(tuple(names))
if st["schema_hash"] is not None and h != st["schema_hash"]:
old = set(st["columns"] or [])
new = set(names)
added = sorted(new - old)
removed = sorted(old - new)
parts = []
if added:
parts.append(f"+{', '.join(added)}")
if removed:
parts.append(f"-{', '.join(removed)}")
_raise_alert(key, "schema_drift", "critical", "schema changed: " + " ".join(parts))
st["columns"] = names
st["schema_hash"] = h
def run_cycle() -> dict[str, Any]:
if _state["running"]:
return {"ok": True, "skipped": "already running"}
_state["running"] = True
try:
_term(f"═══ observability sweep {_state['cycles'] + 1} — volume · freshness · schema ═══",
level="info", phase="cycle")
for ds in DATASETS:
if not _state["enabled"]:
break
try:
_probe(ds)
except Exception as exc:
_state["datasets"][ds["key"]]["error"] = str(exc)[:140]
_state["cycles"] += 1
_state["last_cycle_ts"] = time.time()
_state["next_run_ts"] = time.time() + float(_state["interval_s"])
active = len(_state["alerts_active"])
_term(f"═══ sweep {_state['cycles']} done — {active} active alert(s) ═══",
level=("warn" if active else "ok"), phase="cycle")
finally:
_state["running"] = False
return {"ok": True, "active_alerts": len(_state["alerts_active"])}
def _loop() -> None:
_state["started_at"] = _now().isoformat()
time.sleep(28)
while True:
try:
if _state["enabled"]:
run_cycle()
except Exception:
pass
time.sleep(max(30.0, float(_state["interval_s"])))
threading.Thread(target=_loop, daemon=True, name="observability").start()
def metrics_view() -> dict[str, Any]:
with _lock:
datasets = []
for ds in DATASETS:
st = _state["datasets"][ds["key"]]
datasets.append({
"key": ds["key"], "label": st["label"], "engine": st["engine"],
"color": st["color"], "table": st["table"], "rows": st["rows"],
"delta": st["delta"], "freshness_age_min": st["freshness_age_min"],
"columns": len(st["columns"]) if st["columns"] else None,
"stalled_cycles": st["stalled_cycles"], "error": st["error"],
"ts": st["ts"], "series": list(st["series"]),
})
return {
"ok": True, "enabled": _state["enabled"], "interval_s": _state["interval_s"],
"freshness_min": _state["freshness_min"], "running": _state["running"],
"cycles": _state["cycles"], "last_cycle_ts": _state["last_cycle_ts"],
"next_run_ts": _state["next_run_ts"],
"alert_counts": _alert_counts(),
"datasets": datasets, "feed": list(_state["feed"])[:20],
}
def _alert_counts() -> dict[str, int]:
counts = {"critical": 0, "warning": 0, "info": 0}
for a in _state["alerts_active"].values():
counts[a["severity"]] = counts.get(a["severity"], 0) + 1
counts["total"] = len(_state["alerts_active"])
return counts
def summary_for_llm() -> dict[str, Any]:
with _lock:
return {
"active_alerts": _alert_counts(),
"alerts": [{"dataset": a["dataset"], "type": a["type"], "severity": a["severity"],
"message": a["message"]} for a in _state["alerts_active"].values()],
}
@router.get("/metrics")
async def get_metrics() -> JSONResponse:
return JSONResponse(metrics_view())
@router.get("/alerts")
async def get_alerts() -> JSONResponse:
with _lock:
return JSONResponse({
"ok": True,
"counts": _alert_counts(),
"active": sorted(_state["alerts_active"].values(),
key=lambda a: {"critical": 0, "warning": 1, "info": 2}.get(a["severity"], 3)),
"history": list(_state["alerts_history"])[:60],
})
@router.post("/run")
async def post_run() -> JSONResponse:
from starlette.concurrency import run_in_threadpool
res = await run_in_threadpool(run_cycle)
return JSONResponse({**res, "metrics": metrics_view()})
@router.post("/config")
async def post_config(body: dict = Body(default={})) -> JSONResponse:
if "enabled" in body:
_state["enabled"] = bool(body["enabled"])
if "interval_s" in body:
try:
_state["interval_s"] = max(30.0, min(900.0, float(body["interval_s"])))
except Exception:
pass
if "freshness_min" in body:
try:
_state["freshness_min"] = max(1.0, min(1440.0, float(body["freshness_min"])))
except Exception:
pass
return JSONResponse({"ok": True, "enabled": _state["enabled"],
"interval_s": _state["interval_s"], "freshness_min": _state["freshness_min"]})
@@ -16,12 +16,18 @@ import {
HardDrive,
ShieldCheck,
Radio,
GitBranch,
Lock,
} from 'lucide-react'
import { cn } from '../../lib/utils'
import { TrinoFederationView, type SubTab } from './TrinoFederationView'
import { LiveDashboard } from './LiveDashboard'
import { LineageView } from './LineageView'
import { GovernanceOwnershipView } from './GovernanceOwnershipView'
import { GovernanceAccessView } from './GovernanceAccessView'
import { ObservabilityView } from './ObservabilityView'
type ExplorerTab = 'business' | 'live' | SubTab
type ExplorerTab = 'business' | 'live' | SubTab | 'lineage' | 'ownership' | 'access' | 'observability'
const TABS: { id: ExplorerTab; label: string; icon: typeof Users; hint: string; live?: boolean }[] = [
{ id: 'business', label: 'Business Overview', icon: BarChart3, hint: 'Customers, orders, workforce, supply chain & telemetry across every source' },
@@ -29,6 +35,10 @@ const TABS: { id: ExplorerTab; label: string; icon: typeof Users; hint: string;
{ id: 'federated', label: 'Federated (Trino)', icon: Network, hint: 'One SQL across all 5 databases + region scorecard joined live' },
{ id: 'lake', label: 'Hadoop Lake', icon: HardDrive, hint: 'All business data mirrored as external Iceberg tables on HDFS' },
{ id: 'dictionary', label: 'Data Dictionary', icon: ShieldCheck, hint: 'Every table & column with PII / masking status — exactly what the assistant sees' },
{ id: 'lineage', label: 'Lineage', icon: GitBranch, hint: 'End-to-end data lineage with column-level PII tracing from source to curated layer' },
{ id: 'ownership', label: 'Ownership', icon: UserCog, hint: 'Data owners, stewards, tiers & business glossary — accountability per dataset' },
{ id: 'access', label: 'Access & Policies', icon: Lock, hint: 'Governance posture: PII masking, ownership, live DQ & alerts vs each data contract' },
{ id: 'observability', label: 'Observability', icon: Activity, hint: 'Volume, freshness & schema-drift monitoring with live alerts across every table' },
]
type Bucket = { key: string; count: number; value?: number }
@@ -295,6 +305,12 @@ export function DataExplorerView() {
{/* Trino federation / lake / dictionary tabs */}
{(view === 'federated' || view === 'lake' || view === 'dictionary') && <TrinoFederationView embedded activeTab={view} />}
{/* Governance / lineage / observability sub-tabs */}
{view === 'lineage' && <div className="flex min-h-0 flex-1 flex-col"><LineageView /></div>}
{view === 'ownership' && <div className="flex min-h-0 flex-1 flex-col"><GovernanceOwnershipView /></div>}
{view === 'access' && <div className="flex min-h-0 flex-1 flex-col"><GovernanceAccessView /></div>}
{view === 'observability' && <div className="flex min-h-0 flex-1 flex-col"><ObservabilityView /></div>}
{/* ───────── BUSINESS OVERVIEW ───────── */}
{view === 'business' && (
<>
@@ -11,9 +11,11 @@ import {
Table2,
Upload,
XCircle,
Gauge,
} from 'lucide-react'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
import { DqMonitoringPanel } from './DqMonitoringPanel'
type Dimension = {
id: string
@@ -167,13 +169,13 @@ type ReportSummary = {
columns: number
}
type Tab = 'assess' | 'docling' | 'reports'
type Tab = 'assess' | 'docling' | 'reports' | 'monitoring'
const SCORE_COLOR = (s: number) => (s >= 80 ? 'text-success' : s >= 60 ? 'text-warning' : 'text-danger')
const BAR_COLOR = (s: number) => (s >= 80 ? 'bg-success' : s >= 60 ? 'bg-warning' : 'bg-danger')
export function DataQualityView() {
const [tab, setTab] = useState<Tab>('assess')
const [tab, setTab] = useState<Tab>('monitoring')
const [caps, setCaps] = useState<Capabilities | null>(null)
const [loading, setLoading] = useState(false)
const [assess, setAssess] = useState<AssessResult | null>(null)
@@ -252,6 +254,7 @@ export function DataQualityView() {
}
const tabs: { id: Tab; label: string; icon: typeof FileSearch }[] = [
{ id: 'monitoring', label: 'Live Monitoring', icon: Gauge },
{ id: 'assess', label: 'Maturity Assessment', icon: FileSearch },
{ id: 'docling', label: 'Docling Parser', icon: FileText },
{ id: 'reports', label: 'Reports', icon: CheckCircle2 },
@@ -312,6 +315,8 @@ export function DataQualityView() {
</div>
)}
{tab === 'monitoring' && <DqMonitoringPanel />}
{tab === 'assess' && (
<div className="space-y-5">
<UploadZone
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useState } from 'react'
import { Activity, RefreshCw, Loader2, Play, Gauge, AlertTriangle, Database } from 'lucide-react'
import { cn } from '../../lib/utils'
type Trend = { t: string; score: number }
type Card = {
key: string; label: string; engine: string; color: string; table: string; domain?: string
score: number | null; score_color: string; dimensions: Record<string, number>
volume: number | null; volume_delta: number | null; freshness_age_min: number | null
issues: string[]; columns?: number; worst_columns?: { name: string; completeness: number; nulls: number }[]
trend: Trend[]; pending?: boolean; error?: string
}
type Resp = {
ok: boolean; enabled: boolean; running: boolean; cycles: number; sample: number
platform_score: number | null; dimension_averages: Record<string, number>; cards: Card[]
feed: { ts: string; text: string; level: string }[]
}
const DIMS = ['completeness', 'uniqueness', 'validity', 'freshness']
function ScoreRing({ score, color }: { score: number | null; color: string }) {
const r = 26
const c = 2 * Math.PI * r
const pct = score == null ? 0 : score / 100
return (
<div className="relative h-16 w-16 shrink-0">
<svg viewBox="0 0 64 64" className="h-16 w-16 -rotate-90">
<circle cx="32" cy="32" r={r} fill="none" stroke="currentColor" strokeWidth="6" className="text-surface-overlay" />
<circle cx="32" cy="32" r={r} fill="none" stroke={color} strokeWidth="6" strokeLinecap="round"
strokeDasharray={`${pct * c} ${c}`} />
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[13px] font-bold text-foreground">{score == null ? '—' : Math.round(score)}</span>
</div>
</div>
)
}
function TrendLine({ data, color }: { data: Trend[]; color: string }) {
const pts = data.slice(-30)
if (pts.length < 2) return <div className="h-8" />
const w = 200
const h = 32
const step = w / (pts.length - 1)
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)},${(h - (p.score / 100) * (h - 4) - 2).toFixed(1)}`).join(' ')
return (
<svg viewBox={`0 0 ${w} ${h}`} className="h-8 w-full" preserveAspectRatio="none">
<path d={line} fill="none" stroke={color} strokeWidth="1.5" />
</svg>
)
}
export function DqMonitoringPanel() {
const [data, setData] = useState<Resp | null>(null)
const [loading, setLoading] = useState(true)
const [running, setRunning] = useState(false)
const load = useCallback(async () => {
try {
const r = await fetch('/api/dq/scorecards')
if (r.ok) setData(await r.json())
} catch { /* */ } finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
useEffect(() => { const t = setInterval(load, 10000); return () => clearInterval(t) }, [load])
const runNow = async () => {
setRunning(true)
try {
const r = await fetch('/api/dq/run', { method: 'POST' })
if (r.ok) { const j = await r.json(); if (j.scorecards) setData(j.scorecards) }
} catch { /* */ } finally {
setRunning(false)
}
}
const dimColor = (v: number) => (v >= 90 ? '#34d399' : v >= 75 ? '#fbbf24' : v >= 50 ? '#fb923c' : '#f87171')
return (
<div className="scrollbar-thin flex h-full min-h-0 flex-col gap-2 overflow-y-auto">
{/* header */}
<div className="grid shrink-0 grid-cols-2 gap-2 md:grid-cols-4 xl:grid-cols-6">
<div className="panel col-span-2 flex items-center gap-3 px-3 py-2.5">
<ScoreRing score={data?.platform_score ?? null} color={data?.platform_score != null && data.platform_score >= 80 ? '#34d399' : '#fbbf24'} />
<div>
<p className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">Platform DQ score</p>
<p className="text-2xl font-bold leading-tight text-foreground">{data?.platform_score ?? '—'}</p>
<p className="text-[9px] text-foreground-faint">{data?.cycles ?? 0} cycles · live via Trino</p>
</div>
</div>
{DIMS.map((dim) => {
const v = data?.dimension_averages?.[dim]
return (
<div key={dim} className="panel flex flex-col justify-center px-3 py-2.5">
<p className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">{dim}</p>
<p className="text-lg font-bold leading-tight" style={{ color: v != null ? dimColor(v) : undefined }}>{v ?? '—'}</p>
</div>
)
})}
</div>
<div className="flex shrink-0 items-center gap-2 px-1">
<span className="text-[10px] text-foreground-faint">
Continuous quality checks on live tables (completeness · uniqueness/dedup · validity · freshness){data?.running && ' · running…'}
</span>
<div className="ml-auto flex items-center gap-2">
<button type="button" onClick={runNow} disabled={running || data?.running} className="inline-flex items-center gap-1.5 rounded-md border border-docker/40 bg-docker/10 px-2.5 py-1 text-[10px] font-medium text-docker hover:bg-docker/20 disabled:opacity-60">
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />} Run now
</button>
<button type="button" onClick={load} className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-[10px] text-foreground-muted hover:bg-surface-overlay">
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />} Refresh
</button>
</div>
</div>
{/* scorecards */}
<div className="grid gap-2 pb-2 lg:grid-cols-2">
{(data?.cards || []).map((c) => (
<div key={c.key} className="panel p-3">
<div className="flex items-start gap-3">
<ScoreRing score={c.score} color={c.score_color} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<Database className="h-3.5 w-3.5" style={{ color: c.color }} />
<span className="text-[12px] font-semibold text-foreground">{c.label}</span>
<span className="ml-auto text-[8px] text-foreground-faint">{c.engine}</span>
</div>
<p className="truncate font-mono text-[8px] text-foreground-faint">{c.table}</p>
{c.pending ? (
<p className="mt-2 text-[10px] text-foreground-faint">Awaiting first cycle</p>
) : c.error ? (
<p className="mt-2 flex items-center gap-1 text-[10px] text-rose-400"><AlertTriangle className="h-3 w-3" /> {c.error}</p>
) : (
<div className="mt-1.5 space-y-1">
{DIMS.filter((d) => c.dimensions[d] != null).map((d) => (
<div key={d} className="flex items-center gap-2 text-[9px]">
<span className="w-20 shrink-0 capitalize text-foreground-muted">{d}</span>
<div className="relative h-2 flex-1 overflow-hidden rounded bg-surface-overlay">
<div className="h-full rounded" style={{ width: `${c.dimensions[d]}%`, backgroundColor: dimColor(c.dimensions[d]) }} />
</div>
<span className="w-8 shrink-0 text-right font-mono text-foreground">{c.dimensions[d]}</span>
</div>
))}
</div>
)}
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2 text-[9px] text-foreground-muted">
{c.volume != null && <span className="flex items-center gap-1"><Gauge className="h-3 w-3" /> {c.volume.toLocaleString()} rows</span>}
{c.volume_delta != null && c.volume_delta !== 0 && (
<span className={c.volume_delta > 0 ? 'text-emerald-400' : 'text-rose-400'}>{c.volume_delta > 0 ? '+' : ''}{c.volume_delta.toLocaleString()}</span>
)}
{c.freshness_age_min != null && <span><Activity className="mr-1 inline h-3 w-3" />{c.freshness_age_min.toFixed(0)}m</span>}
</div>
<div className="w-1/3"><TrendLine data={c.trend} color={c.score_color} /></div>
</div>
{c.issues && c.issues.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{c.issues.map((iss, i) => (
<span key={i} className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[8px] text-amber-300">{iss}</span>
))}
</div>
)}
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,117 @@
import { useCallback, useEffect, useState } from 'react'
import { ShieldCheck, RefreshCw, Loader2, Lock, Unlock, Check, X, FileCheck2, AlertTriangle } from 'lucide-react'
import { cn } from '../../lib/utils'
type Check = { name: string; ok: boolean; value: unknown; target: unknown }
type Pii = { pii_count: number; all_masked: boolean; masked: number; unmasked: number }
type Alert = { type: string; severity: string; message: string }
type Posture = {
key: string; label: string; engine: string; color: string; table: string
owner?: string | null; steward?: string | null; tier?: string | null
pii: Pii; dq_score?: number | null; issues: string[]; alerts: Alert[]
contract: Record<string, number>; checks: Check[]; compliant: boolean
}
type Resp = { ok: boolean; datasets: Posture[]; summary: { total: number; compliant: number; non_compliant: number } }
export function GovernanceAccessView() {
const [data, setData] = useState<Resp | null>(null)
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await fetch('/api/governance/posture')
if (r.ok) setData(await r.json())
} catch { /* */ } finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
useEffect(() => { const t = setInterval(load, 12000); return () => clearInterval(t) }, [load])
const sev = (s: string) => (s === 'critical' ? 'text-rose-400' : s === 'warning' ? 'text-amber-400' : 'text-sky-400')
return (
<div className="scrollbar-thin flex h-full min-h-0 flex-col gap-2 overflow-y-auto">
<div className="grid shrink-0 grid-cols-2 gap-2 md:grid-cols-4">
<Kpi icon={FileCheck2} label="Compliant datasets" value={data ? `${data.summary.compliant}/${data.summary.total}` : '—'}
accent={data && data.summary.non_compliant === 0 ? '#34d399' : '#fbbf24'} />
<Kpi icon={AlertTriangle} label="Non-compliant" value={data ? String(data.summary.non_compliant) : '—'}
accent={data && data.summary.non_compliant ? '#f87171' : '#34d399'} />
<Kpi icon={Lock} label="Masking policy" value="Enforced" accent="#60a5fa" />
<Kpi icon={ShieldCheck} label="Contracts" value="Active" accent="#a78bfa" />
</div>
<div className="flex shrink-0 items-center px-1">
<span className="text-[10px] text-foreground-faint">Governance posture = ownership + PII masking + live DQ + observability alerts vs each data contract</span>
<button type="button" onClick={load} className="ml-auto inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-[10px] text-foreground-muted hover:bg-surface-overlay">
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />} Refresh
</button>
</div>
<div className="grid gap-2 pb-2 lg:grid-cols-2">
{(data?.datasets || []).map((d) => (
<div key={d.key} className={cn('panel p-3', !d.compliant && 'ring-1 ring-rose-500/30')}>
<div className="mb-2 flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: d.color }} />
<span className="text-[12px] font-semibold text-foreground">{d.label}</span>
<span className={cn('ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-medium',
d.compliant ? 'bg-emerald-500/15 text-emerald-300' : 'bg-rose-500/15 text-rose-300')}>
{d.compliant ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />} {d.compliant ? 'Compliant' : 'Action needed'}
</span>
</div>
<div className="grid grid-cols-2 gap-1.5">
{d.checks.map((c) => (
<div key={c.name} className="flex items-center gap-1.5 rounded bg-surface-overlay px-2 py-1 text-[10px]">
{c.ok ? <Check className="h-3 w-3 shrink-0 text-emerald-400" /> : <X className="h-3 w-3 shrink-0 text-rose-400" />}
<span className="flex-1 truncate text-foreground-muted">{c.name}</span>
<span className={cn('font-mono', c.ok ? 'text-foreground' : 'text-rose-300')}>{String(c.value)}</span>
</div>
))}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[9px] text-foreground-muted">
<span>Owner: <span className="text-foreground">{d.owner || '—'}</span></span>
<span>· Tier: {d.tier || '—'}</span>
<span className="flex items-center gap-1">·
{d.pii.unmasked === 0
? <><Lock className="h-3 w-3 text-emerald-400" /> {d.pii.masked}/{d.pii.pii_count} PII masked</>
: <><Unlock className="h-3 w-3 text-amber-400" /> {d.pii.unmasked} PII visible</>}
</span>
</div>
{d.alerts.length > 0 && (
<div className="mt-2 space-y-1 border-t border-border/50 pt-2">
{d.alerts.map((a, i) => (
<p key={i} className={cn('flex items-center gap-1 text-[9px]', sev(a.severity))}>
<AlertTriangle className="h-3 w-3" /> {a.message}
</p>
))}
</div>
)}
<p className="mt-2 text-[8px] text-foreground-faint">
Contract: DQ {d.contract.min_score} · completeness {d.contract.min_completeness}% · freshness {d.contract.min_freshness_min}m · crit alerts {d.contract.max_critical_alerts}
</p>
</div>
))}
</div>
</div>
)
}
function Kpi({ icon: Icon, label, value, accent }: { icon: typeof ShieldCheck; label: string; value: string; accent: string }) {
return (
<div className="panel flex items-center gap-3 px-3 py-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg" style={{ backgroundColor: `${accent}1f`, color: accent }}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">{label}</p>
<p className="truncate text-lg font-bold leading-tight text-foreground">{value}</p>
</div>
</div>
)
}
@@ -0,0 +1,246 @@
import { useCallback, useEffect, useState } from 'react'
import { UserCircle, RefreshCw, Loader2, AlertTriangle, BookOpen, ShieldCheck, Check, X } from 'lucide-react'
import { cn } from '../../lib/utils'
type Pii = { pii_count: number; all_masked: boolean; masked: number; unmasked: number }
type DsRow = {
key: string; label: string; engine: string; color: string; table: string
domain?: string; owner?: string | null; steward?: string | null; team?: string | null
tier?: string | null; classification?: string | null; updated_at?: string | null
orphan: boolean; pii: Pii
}
type Summary = { total: number; orphans: number; stewarded: number; owned: number }
type GUser = { id: string | null; name: string; display: string; type: string }
type Term = { name: string; description: string; domain?: string; related?: string[] }
const TIERS = ['Tier1', 'Tier2', 'Tier3']
const CLASSES = ['Public', 'Internal', 'Confidential', 'Restricted']
export function GovernanceOwnershipView() {
const [rows, setRows] = useState<DsRow[]>([])
const [summary, setSummary] = useState<Summary | null>(null)
const [users, setUsers] = useState<GUser[]>([])
const [glossary, setGlossary] = useState<Term[]>([])
const [glossarySource, setGlossarySource] = useState('')
const [loading, setLoading] = useState(true)
const [omConnected, setOmConnected] = useState(false)
const [editKey, setEditKey] = useState<string | null>(null)
const [form, setForm] = useState<{ owner: string; steward: string; team: string; tier: string; classification: string }>(
{ owner: '', steward: '', team: '', tier: '', classification: '' },
)
const [saving, setSaving] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const [d, u, g] = await Promise.all([
fetch('/api/governance/datasets').then((r) => r.json()),
fetch('/api/governance/users').then((r) => r.json()),
fetch('/api/governance/glossary').then((r) => r.json()),
])
setRows(d.datasets || [])
setSummary(d.summary || null)
setOmConnected(!!d.om_connected)
setUsers(u.users || [])
setGlossary(g.terms || [])
setGlossarySource(g.source || '')
} catch { /* */ } finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const openEdit = (r: DsRow) => {
setEditKey(r.key)
setForm({ owner: r.owner || '', steward: r.steward || '', team: r.team || '', tier: r.tier || '', classification: r.classification || '' })
}
const save = async () => {
if (!editKey) return
setSaving(true)
try {
await fetch('/api/governance/assign', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: editKey, ...form }),
})
setEditKey(null)
await load()
} catch { /* */ } finally {
setSaving(false)
}
}
const people = users.filter((u) => u.type === 'user')
const teams = users.filter((u) => u.type === 'team')
return (
<div className="scrollbar-thin flex h-full min-h-0 flex-col gap-2 overflow-y-auto">
{/* KPIs */}
<div className="grid shrink-0 grid-cols-2 gap-2 md:grid-cols-4">
<Kpi icon={UserCircle} label="Owned" value={summary ? `${summary.owned}/${summary.total}` : '—'} accent="#34d399" />
<Kpi icon={AlertTriangle} label="Orphan datasets" value={summary ? String(summary.orphans) : '—'} accent={summary && summary.orphans ? '#f87171' : '#34d399'} />
<Kpi icon={ShieldCheck} label="Stewarded" value={summary ? String(summary.stewarded) : '—'} accent="#60a5fa" />
<Kpi icon={BookOpen} label="Glossary terms" value={String(glossary.length)} accent="#a78bfa" />
</div>
<div className="flex shrink-0 items-center gap-2 px-1">
<span className="text-[10px] text-foreground-faint">
OpenMetadata {omConnected ? 'connected' : 'offline'} · assignments stored locally{omConnected ? ' + synced to OM' : ''}
</span>
<button type="button" onClick={load} className="ml-auto inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-[10px] text-foreground-muted hover:bg-surface-overlay">
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />} Refresh
</button>
</div>
{/* ownership matrix */}
<div className="panel min-h-0 shrink-0 overflow-x-auto p-0">
<table className="w-full text-[10px]">
<thead>
<tr className="border-b border-border text-left text-foreground-faint">
<th className="px-3 py-2 font-semibold">Dataset</th>
<th className="px-3 py-2 font-semibold">Owner</th>
<th className="px-3 py-2 font-semibold">Steward</th>
<th className="px-3 py-2 font-semibold">Team</th>
<th className="px-3 py-2 font-semibold">Tier</th>
<th className="px-3 py-2 font-semibold">PII</th>
<th className="px-3 py-2 font-semibold" />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.key} className={cn('border-b border-border/50', r.orphan && 'bg-rose-500/5')}>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: r.color }} />
<span className="font-medium text-foreground">{r.label}</span>
</div>
<span className="font-mono text-[8px] text-foreground-faint">{r.table}</span>
</td>
<td className="px-3 py-2">
{r.owner ? <span className="text-foreground">{r.owner}</span>
: <span className="flex items-center gap-1 text-rose-400"><AlertTriangle className="h-3 w-3" /> unassigned</span>}
</td>
<td className="px-3 py-2 text-foreground-muted">{r.steward || '—'}</td>
<td className="px-3 py-2 text-foreground-muted">{r.team || '—'}</td>
<td className="px-3 py-2">
{r.tier ? <span className="rounded bg-surface-overlay px-1.5 py-0.5 text-foreground-muted">{r.tier}</span> : '—'}
</td>
<td className="px-3 py-2">
{r.pii.pii_count > 0 ? (
<span className={cn('rounded px-1.5 py-0.5', r.pii.unmasked === 0 ? 'bg-emerald-500/15 text-emerald-300' : 'bg-amber-500/15 text-amber-300')}>
{r.pii.masked}/{r.pii.pii_count} masked
</span>
) : <span className="text-foreground-faint">none</span>}
</td>
<td className="px-3 py-2 text-right">
<button type="button" onClick={() => openEdit(r)} className="rounded border border-border px-2 py-0.5 text-[9px] text-foreground-muted hover:bg-surface-overlay">
Assign
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* glossary */}
<h2 className="mt-1 shrink-0 px-1 text-[10px] font-semibold uppercase tracking-widest text-foreground-muted">
Business glossary {glossarySource && <span className="text-foreground-faint">· {glossarySource}</span>}
</h2>
<div className="grid shrink-0 gap-2 pb-2 md:grid-cols-2 lg:grid-cols-3">
{glossary.map((t) => (
<div key={t.name} className="panel p-2.5">
<div className="flex items-center gap-1.5">
<BookOpen className="h-3.5 w-3.5 text-docker" />
<span className="text-[11px] font-semibold text-foreground">{t.name}</span>
{t.domain && <span className="ml-auto rounded bg-surface-overlay px-1.5 py-0.5 text-[8px] text-foreground-faint">{t.domain}</span>}
</div>
<p className="mt-1 text-[10px] text-foreground-muted">{t.description}</p>
{t.related && t.related.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{t.related.map((rl) => <span key={rl} className="rounded bg-surface-overlay px-1.5 py-0.5 font-mono text-[8px] text-foreground-faint">{rl}</span>)}
</div>
)}
</div>
))}
</div>
{/* assign modal */}
{editKey && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEditKey(null)}>
<div className="panel w-full max-w-md p-4" onClick={(e) => e.stopPropagation()}>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold text-foreground">Assign ownership {rows.find((r) => r.key === editKey)?.label}</h3>
<button type="button" onClick={() => setEditKey(null)} className="text-foreground-muted hover:text-foreground"><X className="h-4 w-4" /></button>
</div>
<div className="space-y-2.5">
<Field label="Owner">
<select value={form.owner} onChange={(e) => setForm({ ...form, owner: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""> unassigned </option>
{people.map((u) => <option key={u.name} value={u.display}>{u.display}</option>)}
</select>
</Field>
<Field label="Steward">
<select value={form.steward} onChange={(e) => setForm({ ...form, steward: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""> none </option>
{people.map((u) => <option key={u.name} value={u.display}>{u.display}</option>)}
</select>
</Field>
<Field label="Team">
<select value={form.team} onChange={(e) => setForm({ ...form, team: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""> none </option>
{teams.map((u) => <option key={u.name} value={u.display}>{u.display}</option>)}
</select>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="Tier">
<select value={form.tier} onChange={(e) => setForm({ ...form, tier: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""></option>
{TIERS.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</Field>
<Field label="Classification">
<select value={form.classification} onChange={(e) => setForm({ ...form, classification: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""></option>
{CLASSES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</Field>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={() => setEditKey(null)} className="rounded border border-border px-3 py-1.5 text-[11px] text-foreground-muted hover:bg-surface-overlay">Cancel</button>
<button type="button" onClick={save} disabled={saving} className="inline-flex items-center gap-1.5 rounded bg-docker px-3 py-1.5 text-[11px] font-medium text-white hover:bg-docker/90 disabled:opacity-60">
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />} Save
</button>
</div>
</div>
</div>
)}
</div>
)
}
function Kpi({ icon: Icon, label, value, accent }: { icon: typeof UserCircle; label: string; value: string; accent: string }) {
return (
<div className="panel flex items-center gap-3 px-3 py-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg" style={{ backgroundColor: `${accent}1f`, color: accent }}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">{label}</p>
<p className="truncate text-lg font-bold leading-tight text-foreground">{value}</p>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-1 block text-[9px] font-semibold uppercase tracking-wider text-foreground-faint">{label}</label>
{children}
</div>
)
}
+309
View File
@@ -0,0 +1,309 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { GitBranch, RefreshCw, Loader2, Database, Cpu, HardDrive, Layers, Network, Sparkles, Lock, ShieldCheck } from 'lucide-react'
import { cn } from '../../lib/utils'
type LCol = { name: string; category?: string; masked: boolean; pii?: boolean }
type LNode = {
id: string
label: string
type: string
stage: number
meta: {
engine?: string
table?: string
rows?: number | null
note?: string
columns?: LCol[]
masked_layer?: boolean
domain?: string
topic?: string
path?: string
om?: { upstream?: number; downstream?: number }
}
}
type LEdge = { id: string; source: string; target: string; label: string; kind: string; active: boolean }
type Graph = {
ok: boolean
generated_at: string
active: { generator: boolean; archive: boolean }
stages: string[]
nodes: LNode[]
edges: LEdge[]
column_links: { source: string; target: string; column: string; masked: boolean }[]
om_connected: boolean
}
type DsOpt = { key: string; label: string; engine: string; color: string; table: string }
const TYPE_ICON: Record<string, typeof Database> = {
source: Database,
stream: Network,
compute: Cpu,
storage: HardDrive,
lakehouse: Layers,
engine: Network,
serving: Sparkles,
}
const KIND_COLOR: Record<string, string> = {
cdc: '#f472b6',
batch: '#fbbf24',
transform: '#a78bfa',
serve: '#38bdf8',
}
function fmtRows(n?: number | null) {
if (n == null) return null
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
export function LineageView() {
const [graph, setGraph] = useState<Graph | null>(null)
const [datasets, setDatasets] = useState<DsOpt[]>([])
const [focus, setFocus] = useState<string>('')
const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState<string | null>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const [coords, setCoords] = useState<Map<string, { x: number; y: number; w: number; h: number }>>(new Map())
const load = useCallback(async () => {
setLoading(true)
try {
const url = focus ? `/api/lineage/graph?dataset=${focus}` : '/api/lineage/graph'
const r = await fetch(url)
if (r.ok) setGraph(await r.json())
} catch { /* */ } finally {
setLoading(false)
}
}, [focus])
useEffect(() => {
fetch('/api/lineage/datasets').then((r) => r.json()).then((d) => setDatasets(d.datasets || [])).catch(() => {})
}, [])
useEffect(() => { load() }, [load])
useEffect(() => {
const t = setInterval(load, 15000)
return () => clearInterval(t)
}, [load])
// measure node positions for edge drawing
useLayoutEffect(() => {
if (!wrapRef.current || !graph) return
const measure = () => {
const wrap = wrapRef.current
if (!wrap) return
const base = wrap.getBoundingClientRect()
const next = new Map<string, { x: number; y: number; w: number; h: number }>()
nodeRefs.current.forEach((el, id) => {
const r = el.getBoundingClientRect()
next.set(id, { x: r.left - base.left + wrap.scrollLeft, y: r.top - base.top + wrap.scrollTop, w: r.width, h: r.height })
})
setCoords(next)
}
measure()
const ro = new ResizeObserver(measure)
if (wrapRef.current) ro.observe(wrapRef.current)
nodeRefs.current.forEach((el) => ro.observe(el))
return () => ro.disconnect()
}, [graph])
const stages = graph?.stages || []
const byStage: Record<number, LNode[]> = {}
;(graph?.nodes || []).forEach((n) => { (byStage[n.stage] = byStage[n.stage] || []).push(n) })
const selNode = graph?.nodes.find((n) => n.id === selected) || null
const connectedEdges = new Set(
(graph?.edges || []).filter((e) => !selected || e.source === selected || e.target === selected).map((e) => e.id),
)
return (
<div className="flex h-full min-h-0 flex-col gap-2">
{/* controls */}
<div className="flex shrink-0 flex-wrap items-center gap-2 px-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Trace dataset</span>
<button
type="button"
onClick={() => setFocus('')}
className={cn('rounded-full border px-2.5 py-1 text-[10px] font-medium', focus === '' ? 'border-docker bg-docker/15 text-docker' : 'border-border text-foreground-muted hover:bg-surface-overlay')}
>
Full platform
</button>
{datasets.map((d) => (
<button
key={d.key}
type="button"
onClick={() => { setFocus(d.key); setSelected(null) }}
className={cn('rounded-full border px-2.5 py-1 text-[10px] font-medium', focus === d.key ? 'border-docker bg-docker/15 text-docker' : 'border-border text-foreground-muted hover:bg-surface-overlay')}
>
{d.label}
</button>
))}
<div className="ml-auto flex items-center gap-2">
{graph?.active && (
<span className="flex items-center gap-1 text-[10px] text-foreground-muted">
<span className={cn('h-2 w-2 rounded-full', graph.active.generator ? 'animate-pulse bg-emerald-400' : 'bg-foreground-faint/40')} /> CDC
<span className={cn('ml-1 h-2 w-2 rounded-full', graph.active.archive ? 'animate-pulse bg-amber-400' : 'bg-foreground-faint/40')} /> ETL
</span>
)}
<button type="button" onClick={load} className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-[10px] text-foreground-muted hover:bg-surface-overlay">
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />} Refresh
</button>
</div>
</div>
<div className="flex min-h-0 flex-1 gap-2">
{/* graph */}
<div ref={wrapRef} className="panel scrollbar-thin relative min-h-0 flex-1 overflow-auto p-4">
{/* edges overlay */}
<svg className="pointer-events-none absolute inset-0 h-full w-full" style={{ minWidth: '100%', minHeight: '100%' }}>
<defs>
<marker id="lin-arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="#64748b" />
</marker>
</defs>
{(graph?.edges || []).map((e) => {
const a = coords.get(e.source)
const b = coords.get(e.target)
if (!a || !b) return null
const x1 = a.x + a.w
const y1 = a.y + a.h / 2
const x2 = b.x
const y2 = b.y + b.h / 2
const dx = Math.max(40, Math.abs(x2 - x1) / 2)
const path = `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`
const color = KIND_COLOR[e.kind] || '#64748b'
const dim = selected && !connectedEdges.has(e.id)
return (
<g key={e.id} opacity={dim ? 0.12 : 1}>
<path d={path} fill="none" stroke={color} strokeWidth={e.active ? 2.5 : 1.5}
strokeDasharray={e.active ? '6 5' : undefined} markerEnd="url(#lin-arrow)">
{e.active && (
<animate attributeName="stroke-dashoffset" from="22" to="0" dur="0.8s" repeatCount="indefinite" />
)}
</path>
</g>
)
})}
</svg>
{/* stage columns */}
<div className="relative flex gap-6" style={{ minWidth: 'max-content' }}>
{stages.map((label, si) => (
<div key={si} className="flex w-[150px] shrink-0 flex-col gap-3">
<div className="text-center text-[9px] font-semibold uppercase tracking-wider text-foreground-faint">{label}</div>
{(byStage[si] || []).map((n) => {
const Icon = TYPE_ICON[n.type] || Database
const isSel = selected === n.id
const rows = fmtRows(n.meta.rows)
return (
<div
key={n.id}
ref={(el) => { if (el) nodeRefs.current.set(n.id, el); else nodeRefs.current.delete(n.id) }}
onClick={() => setSelected(isSel ? null : n.id)}
className={cn(
'relative z-10 cursor-pointer rounded-lg border bg-surface-raised p-2 transition-all',
isSel ? 'border-docker ring-1 ring-docker/40' : 'border-border hover:border-docker/50',
)}
>
<div className="flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 shrink-0 text-docker" />
<span className="truncate text-[10px] font-semibold text-foreground" title={n.label}>
{n.label.split('\n')[0]}
</span>
{n.meta.masked_layer && <Lock className="ml-auto h-3 w-3 shrink-0 text-emerald-400" />}
</div>
{n.label.includes('\n') && (
<p className="mt-0.5 truncate font-mono text-[8px] text-foreground-muted" title={n.label.split('\n')[1]}>
{n.label.split('\n')[1]}
</p>
)}
<div className="mt-1 flex items-center gap-1.5 text-[8px] text-foreground-faint">
{rows && <span className="rounded bg-surface-overlay px-1 py-0.5 font-mono text-foreground-muted">{rows} rows</span>}
{n.meta.columns && n.meta.columns.length > 0 && (
<span className="rounded bg-rose-500/15 px-1 py-0.5 text-rose-300">{n.meta.columns.length} PII</span>
)}
</div>
</div>
)
})}
</div>
))}
</div>
</div>
{/* detail */}
<div className="panel scrollbar-thin w-[260px] shrink-0 overflow-y-auto p-3">
{!selNode ? (
<div className="flex h-full flex-col items-center justify-center text-center text-foreground-faint">
<GitBranch className="mb-2 h-7 w-7" />
<p className="text-[11px]">Click any node to inspect its schema, row count and column-level PII lineage.</p>
{graph && (
<p className="mt-3 text-[9px]">
{graph.om_connected ? 'OpenMetadata lineage layered in.' : 'OpenMetadata not connected.'}
</p>
)}
</div>
) : (
<div className="space-y-2">
<h3 className="text-[12px] font-semibold text-foreground">{selNode.label.split('\n')[0]}</h3>
{selNode.meta.table && <p className="break-all font-mono text-[9px] text-docker">{selNode.meta.table}</p>}
<div className="grid grid-cols-2 gap-1.5 text-[9px]">
{selNode.meta.engine && <Info label="Engine" value={selNode.meta.engine} />}
{selNode.meta.rows != null && <Info label="Rows" value={fmtRows(selNode.meta.rows) || '—'} />}
{selNode.meta.domain && <Info label="Domain" value={selNode.meta.domain} />}
{selNode.meta.topic && <Info label="Topic" value={selNode.meta.topic} />}
{selNode.meta.om && (selNode.meta.om.upstream != null) && (
<Info label="OM lineage" value={`${selNode.meta.om.upstream}${selNode.meta.om.downstream}`} />
)}
</div>
{selNode.meta.note && <p className="text-[10px] text-foreground-muted">{selNode.meta.note}</p>}
{selNode.meta.columns && selNode.meta.columns.length > 0 && (
<div>
<p className="mb-1 mt-2 flex items-center gap-1 text-[10px] font-semibold text-foreground">
<ShieldCheck className="h-3 w-3 text-emerald-400" /> PII columns
</p>
<div className="space-y-1">
{selNode.meta.columns.map((c) => (
<div key={c.name} className="flex items-center gap-1.5 rounded bg-surface-overlay px-1.5 py-1 text-[9px]">
<span className="flex-1 truncate font-mono text-foreground-muted" title={c.name}>{c.name}</span>
{c.category && <span className="text-foreground-faint">{c.category}</span>}
{c.masked
? <span className="flex items-center gap-0.5 text-emerald-400"><Lock className="h-2.5 w-2.5" /> masked</span>
: <span className="text-amber-400">visible</span>}
</div>
))}
</div>
</div>
)}
{graph?.column_links && graph.column_links.length > 0 && (selNode.id === 'src_orders' || selNode.id === 'iceberg_curated') && (
<div>
<p className="mb-1 mt-2 text-[10px] font-semibold text-foreground">Column lineage curated</p>
<div className="space-y-1">
{graph.column_links.map((l) => (
<div key={l.column} className="flex items-center gap-1 text-[9px]">
<span className="flex-1 truncate font-mono text-foreground-muted">{l.column}</span>
<span className="text-foreground-faint"></span>
{l.masked ? <Lock className="h-2.5 w-2.5 text-emerald-400" /> : <span className="text-amber-400">visible</span>}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}
function Info({ label, value }: { label: string; value: string }) {
return (
<div className="rounded bg-surface-overlay px-1.5 py-1">
<p className="text-[8px] uppercase tracking-wider text-foreground-faint">{label}</p>
<p className="truncate text-[10px] text-foreground" title={value}>{value}</p>
</div>
)
}
@@ -0,0 +1,141 @@
import { useCallback, useEffect, useState } from 'react'
import { Activity, RefreshCw, Loader2, AlertTriangle, Clock, Database, TrendingUp, TrendingDown } from 'lucide-react'
import { cn } from '../../lib/utils'
type Point = { t: string; rows: number; delta: number }
type DsMetric = {
key: string; label: string; engine: string; color: string; table: string
rows: number | null; delta: number | null; freshness_age_min: number | null
columns: number | null; stalled_cycles: number; error: string | null; ts: string | null
series: Point[]
}
type Alert = { id: string; dataset: string; type: string; severity: string; message: string; count: number; ts: string; last_ts: string }
type Metrics = {
ok: boolean; enabled: boolean; cycles: number; freshness_min: number
alert_counts: { critical: number; warning: number; info: number; total: number }
datasets: DsMetric[]
}
function Spark({ data, color }: { data: Point[]; color: string }) {
const pts = data.slice(-40)
if (pts.length < 2) return <div className="h-10 w-full" />
const w = 240
const h = 40
const vals = pts.map((p) => p.rows)
const max = Math.max(...vals)
const min = Math.min(...vals)
const range = max - min || 1
const step = w / (pts.length - 1)
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)},${(h - ((p.rows - min) / range) * (h - 6) - 3).toFixed(1)}`).join(' ')
return (
<svg viewBox={`0 0 ${w} ${h}`} className="h-10 w-full" preserveAspectRatio="none">
<path d={`${line} L${w},${h} L0,${h} Z`} fill={color} fillOpacity="0.12" />
<path d={line} fill="none" stroke={color} strokeWidth="1.5" />
</svg>
)
}
export function ObservabilityView() {
const [metrics, setMetrics] = useState<Metrics | null>(null)
const [alerts, setAlerts] = useState<Alert[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const [m, a] = await Promise.all([
fetch('/api/observability/metrics').then((r) => r.json()),
fetch('/api/observability/alerts').then((r) => r.json()),
])
setMetrics(m)
setAlerts(a.active || [])
} catch { /* */ } finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
useEffect(() => { const t = setInterval(load, 8000); return () => clearInterval(t) }, [load])
const ac = metrics?.alert_counts
const sevBorder = (s: string) => (s === 'critical' ? 'border-rose-500/40 bg-rose-500/5' : s === 'warning' ? 'border-amber-500/40 bg-amber-500/5' : 'border-sky-500/40 bg-sky-500/5')
const sevText = (s: string) => (s === 'critical' ? 'text-rose-400' : s === 'warning' ? 'text-amber-400' : 'text-sky-400')
return (
<div className="scrollbar-thin flex h-full min-h-0 flex-col gap-2 overflow-y-auto">
<div className="grid shrink-0 grid-cols-2 gap-2 md:grid-cols-4">
<Kpi icon={AlertTriangle} label="Critical" value={String(ac?.critical ?? 0)} accent={ac?.critical ? '#f87171' : '#34d399'} />
<Kpi icon={AlertTriangle} label="Warnings" value={String(ac?.warning ?? 0)} accent={ac?.warning ? '#fbbf24' : '#34d399'} />
<Kpi icon={Activity} label="Sweeps" value={String(metrics?.cycles ?? 0)} accent="#60a5fa" />
<Kpi icon={Clock} label="Freshness SLA" value={`${metrics?.freshness_min ?? '—'}m`} accent="#a78bfa" />
</div>
<div className="flex shrink-0 items-center px-1">
<span className="text-[10px] text-foreground-faint">Tracking volume, freshness &amp; schema drift across every business table</span>
<button type="button" onClick={load} className="ml-auto inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-[10px] text-foreground-muted hover:bg-surface-overlay">
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />} Refresh
</button>
</div>
{/* active alerts */}
{alerts.length > 0 && (
<div className="shrink-0 space-y-1.5">
{alerts.map((a) => (
<div key={a.id} className={cn('flex items-center gap-2 rounded-md border px-3 py-2 text-[10px]', sevBorder(a.severity))}>
<AlertTriangle className={cn('h-3.5 w-3.5 shrink-0', sevText(a.severity))} />
<span className="font-semibold text-foreground">{a.dataset}</span>
<span className="text-foreground-muted">{a.message}</span>
<span className={cn('ml-auto rounded px-1.5 py-0.5 text-[8px] uppercase', sevText(a.severity))}>{a.type}</span>
{a.count > 1 && <span className="rounded bg-surface-overlay px-1.5 py-0.5 text-[8px] text-foreground-faint">×{a.count}</span>}
</div>
))}
</div>
)}
{/* per-dataset volume + freshness */}
<div className="grid gap-2 pb-2 lg:grid-cols-2 xl:grid-cols-3">
{(metrics?.datasets || []).map((d) => {
const stale = d.freshness_age_min != null && d.freshness_age_min > (metrics?.freshness_min ?? 30)
return (
<div key={d.key} className="panel p-3">
<div className="mb-1 flex items-center gap-1.5">
<Database className="h-3.5 w-3.5" style={{ color: d.color }} />
<span className="text-[11px] font-semibold text-foreground">{d.label}</span>
{d.delta != null && d.delta !== 0 && (
<span className={cn('ml-auto flex items-center gap-0.5 text-[9px]', d.delta > 0 ? 'text-emerald-400' : 'text-rose-400')}>
{d.delta > 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{d.delta > 0 ? '+' : ''}{d.delta.toLocaleString()}
</span>
)}
</div>
<Spark data={d.series} color={d.color} />
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[9px] text-foreground-muted">
<span>Rows: <span className="font-mono text-foreground">{d.rows?.toLocaleString() ?? '—'}</span></span>
<span className={cn('flex items-center gap-1', stale && 'text-amber-400')}>
<Clock className="h-3 w-3" /> {d.freshness_age_min != null ? `${d.freshness_age_min.toFixed(0)}m old` : 'n/a'}
</span>
{d.columns != null && <span>{d.columns} cols</span>}
{d.stalled_cycles > 0 && <span className="text-amber-400">stalled ×{d.stalled_cycles}</span>}
{d.error && <span className="text-rose-400">err</span>}
</div>
</div>
)
})}
</div>
</div>
)
}
function Kpi({ icon: Icon, label, value, accent }: { icon: typeof Activity; label: string; value: string; accent: string }) {
return (
<div className="panel flex items-center gap-3 px-3 py-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg" style={{ backgroundColor: `${accent}1f`, color: accent }}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">{label}</p>
<p className="truncate text-lg font-bold leading-tight text-foreground">{value}</p>
</div>
</div>
)
}