Compare commits

10 Commits

Author SHA1 Message Date
mo b54f5c7a06 feat: Nessie catalog UI + Iceberg structure explorer + live Data Flow online/offline
Add Project Nessie on lake01 with Command Center Iceberg tab (snapshots,
manifests, data files, time-travel SQL). Data Flow pulses only when endpoints
are reachable; offline nodes/edges render red.
2026-07-22 00:31:35 +00:00
mo d5fba208a3 Merge origin/main into running Command Center
Keep the deployed tree on conflict; integrate the remote Dockhand/CDC tip.
2026-07-21 23:21:23 +00:00
mo 9008fbd512 feat: Authentik login + switchable GPU prod target
Add OIDC auth for Command Center and runtime GPU endpoint selection
pointed at atc-gpu-prod (10.0.10.106), matching what is currently deployed.
2026-07-21 23:20:24 +00:00
root b46e7f01dd Fix Dockhand auth for topology flow and accurate CDC change stats.
Pass DOCKHAND_API_TOKEN to container inventory calls so pipeline_active
and topology animation work after Authentik. Replace ring-buffer-only CDC
stats with minute rollups (no 1000 cap), add 15m/1h/6h/24h window selector
on the Live Changes tab, and poll recent events on an interval.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 15:15:15 +02:00
mo f36c8906bc 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.
2026-06-29 17:43:37 +00:00
mo d066def8b4 feat(cdc): add Re-sync sources button + auto-heal for Debezium connectors
- streaming_ops: add connector status discovery, manual resync endpoint
  (POST /api/pipeline/streaming/resync), connectors status endpoint, and a
  background connector_autoheal_loop that restarts FAILED tasks automatically
- main: wire connector_autoheal_loop into app lifespan
- api.ts: add resyncSources() helper
- ChangesView: add "Re-sync sources" header button with live status
2026-06-29 13:31:11 +00:00
mo 1e2cfe80f2 feat: live per-agent terminal activity with real scripts/SQL
Agent terminals were idle (one-shot probe) while agents were busy in the
background. Now every agent streams what it is actually doing:

- agent_terminal: emit_threadsafe() so background threads can stream lines.
- agent_ops: Data Custodian DML loop logs the real INSERT/UPDATE/DELETE SQL
  (+ Mongo ops) and Hadoop-offload Trino CTAS/INSERT to its terminal; ETL
  Guardian announces each orchestrated movement.
- movements: trigger_and_watch streams the Airflow DAG / API call, conf,
  before/after Trino counts and result to the owning agent terminal.
- etl_offload: per-dataset read + pyarrow->S3 parquet writes and cycle
  summaries stream to the ETL Guardian terminal.
- agent_activity (new): round-robin live probes for Lakehouse Ops, Hadoop
  Ranger (NameNode JMX + YARN), Infra Sentinel (Dockhand inventory + host
  load) and Network Watcher (VLAN 20/21 path checks).
- fix: YARN ResourceManager runs on 10.0.21.62:8088 (was .61).
- ui: terminal dock merges the selected agent ops stream with the node probe.
2026-06-29 12:54:46 +00:00
mo b6d7d3dc74 feat: realtime ETL offload to S3 + live business dashboards
- etl_offload.py: autonomous agent backfills/tails source DBs (PG/MySQL/
  Mongo/Cassandra) to S3 as Parquet in small chunks, accumulates a live
  federated business matrix (/api/etl/status, /api/etl/business, /run, /config).
- storage_s3.py: buffer generated CDC + masked curated rows to S3, overlay
  live last-write into analytics; put_object_bytes for Parquet parts.
- trino_federated.py: capture generated rows + archive to S3; generator_active.
- dataflow.py: pulse generate + kafka/spark->S3 archive edges when active.
- StorageView: realtime ETL ingest panel; TrinoFederationView: realtime
  business KPIs/charts from /api/etl/business.
- ChangesView: top KPIs/charts now overlay the live WS stream on server stats
  so they update in lock-step with the bottom feed; faster 2.5s refresh.
- useCommandCenter: retain 800 live CDC changes.
2026-06-28 23:33:21 +00:00
mo dfd5d4da8a feat: pulse generator edges on data gen + New & Changed Data dashboard
Generated data is now visible flowing into the source systems on the Data
Flow graph: a manual "Generate data" burst (and the continuous live loop)
stamps a last_tick, and a new generator_active() helper lights up the
generator -> postgres/mysql/mongodb/cassandra edges for ~12s. Neo4j stays
dark since the live generator does not write to it.

Redesigned the Changes tab into a "New & Changed Data" dashboard driven by
the live CDC stream: animated KPI cards (new/updated/deleted + throughput),
a change-volume area chart, an operation-mix donut, per-system and
top-table breakdown bars, plus the existing filterable change feed.
2026-06-28 22:41:48 +00:00
mo aa9ee66966 fix: Run control on Data Flow now starts the live data generator
Previously Run/Pause/Stop only drove the pulse animation, so on the Data Flow
tab nothing was generated (the streamer is heartbeat-gated and only the Live
dashboard was sending heartbeats). Now:
- New POST /api/federated/live/heartbeat keep-alive endpoint.
- Run seeds an immediate burst into the sources and keeps the generator alive
  (5s heartbeat) while the tab is open; Pause/Stop halts generation.
2026-06-28 22:21:24 +00:00
57 changed files with 10005 additions and 316 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/* RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r 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 hive_bench_seed.json . COPY main.py auth.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 gpu_config.py nessie_iceberg.py hive_bench_seed.json .
RUN mkdir -p /data RUN mkdir -p /data
ENV DATABASE_URL=sqlite:////data/atc-agents.db ENV DATABASE_URL=sqlite:////data/atc-agents.db
EXPOSE 3201 EXPOSE 3201
+206
View File
@@ -0,0 +1,206 @@
"""Continuous autonomous activity for the field agents that don't drive a
data-movement loop of their own (Lakehouse Ops, Hadoop Ranger, Infra Sentinel,
Network Watcher).
Every tick the loop runs ONE real, lightweight probe for the next agent in the
rotation and streams the exact command + result into that agent's terminal, so
the operator can always see what each agent is doing in the background instead
of an idle prompt. All probes hit live endpoints (Trino, WebHDFS JMX, YARN,
Dockhand, VLAN hosts) and are individually guarded so a single failure never
breaks the loop.
"""
from __future__ import annotations
import asyncio
import os
import time
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")
HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870").rstrip("/")
YARN_URL = os.getenv("YARN_URL", "http://10.0.21.62:8088").rstrip("/")
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082").rstrip("/")
DOCKHAND_API_TOKEN = os.getenv("DOCKHAND_API_TOKEN", "")
OBJECTSCALE_URL = os.getenv("OBJECTSCALE_URL", os.getenv("S3_ENDPOINT", "http://10.0.20.111:9020")).rstrip("/")
TICK_SECONDS = float(os.getenv("AGENT_ACTIVITY_TICK_SECONDS", "8"))
# VLAN data paths the Network Watcher keeps an eye on.
_NET_TARGETS = [
("Kafka UI", "http://10.0.21.36:9000"),
("Trino", f"{TRINO_URL}/v1/info"),
("HDFS NameNode", f"{HDFS_NN_URL}/dfshealth.html"),
("Airflow", os.getenv("AIRFLOW_URL", "http://10.0.21.55:8080")),
("ObjectScale S3", OBJECTSCALE_URL),
]
async def _term(agent_id: str, text: str, level: str = "info", phase: str = "ops") -> None:
try:
from agent_terminal import terminal_log
await terminal_log(agent_id, text, level=level, phase=phase)
except Exception:
pass
async def _trino(sql: str, timeout: float = 8.0) -> list[list[Any]]:
rows: list[list[Any]] = []
async with httpx.AsyncClient(timeout=timeout) as client:
d = (await client.post(f"{TRINO_URL}/v1/statement", content=sql.encode(),
headers={"X-Trino-User": TRINO_USER})).json()
for _ in range(40):
if d.get("error"):
raise RuntimeError(d["error"].get("message", "trino error"))
rows += d.get("data") or []
nxt = d.get("nextUri")
if not nxt:
break
d = (await client.get(nxt)).json()
return rows
# ── per-agent probes ─────────────────────────────────────────────────────────
async def _lakehouse_ops() -> None:
aid = "lakehouse-ops"
try:
async with httpx.AsyncClient(timeout=6.0) as client:
info = (await client.get(f"{TRINO_URL}/v1/info")).json()
ver = info.get("nodeVersion", {}).get("version", "?")
up = info.get("uptime", "?")
await _term(aid, f"$ curl -s {TRINO_URL}/v1/info # Trino coordinator health", level="cmd", phase="trino")
await _term(aid, f" ← Trino {ver} · uptime {up} · serving federated queries", level="ok", phase="trino")
except Exception as exc:
await _term(aid, f" ✗ Trino unreachable: {str(exc)[:100]}", level="err", phase="trino")
try:
cats = await _trino("SHOW CATALOGS")
names = ", ".join(sorted(c[0] for c in cats))
await _term(aid, "$ trino --execute 'SHOW CATALOGS'", level="cmd", phase="trino")
await _term(aid, f"{len(cats)} catalogs federated: {names}", level="ok", phase="trino")
except Exception as exc:
await _term(aid, f" ✗ SHOW CATALOGS failed: {str(exc)[:100]}", level="err", phase="trino")
try:
sql = "SELECT count(*) FROM iceberg.curated_masked.sales_orders_masked"
rows = await _trino(sql, timeout=12.0)
n = int(rows[0][0]) if rows else 0
await _term(aid, f"$ trino --execute '{sql}' # curated Iceberg lakehouse", level="cmd", phase="iceberg")
await _term(aid, f"{n:,} masked rows in iceberg.curated_masked (PII-safe layer)", level="ok", phase="iceberg")
except Exception as exc:
await _term(aid, f" ✗ Iceberg count failed: {str(exc)[:100]}", level="err", phase="iceberg")
def _g(d: dict, *keys) -> Any:
for k in keys:
if k in d:
return d[k]
return None
async def _hadoop_ranger() -> None:
aid = "hadoop-ranger"
try:
async with httpx.AsyncClient(timeout=6.0) as client:
fs = (await client.get(f"{HDFS_NN_URL}/jmx?qry=Hadoop:service=NameNode,name=FSNamesystem")).json()
state = (await client.get(f"{HDFS_NN_URL}/jmx?qry=Hadoop:service=NameNode,name=FSNamesystemState")).json()
fsb = (fs.get("beans") or [{}])[0]
stb = (state.get("beans") or [{}])[0]
cap_total = float(_g(fsb, "CapacityTotalGB") or 0)
cap_used = float(_g(fsb, "CapacityUsedGB") or 0)
blocks = int(_g(fsb, "BlocksTotal", "TotalBlocks") or _g(stb, "BlocksTotal") or 0)
live = int(_g(stb, "NumLiveDataNodes") or 0)
dead = int(_g(stb, "NumDeadDataNodes") or 0)
pct = (100.0 * cap_used / cap_total) if cap_total else 0.0
await _term(aid, f"$ curl -s {HDFS_NN_URL}/jmx?qry=...FSNamesystemState # NameNode health", level="cmd", phase="hdfs")
await _term(aid, f" ← live datanodes={live} dead={dead} · blocks={blocks:,} · "
f"used {cap_used:.1f}/{cap_total:.1f} GB ({pct:.0f}%)", level="ok", phase="hdfs")
except Exception as exc:
await _term(aid, f" ✗ NameNode JMX unreachable: {str(exc)[:100]}", level="err", phase="hdfs")
try:
async with httpx.AsyncClient(timeout=6.0) as client:
m = (await client.get(f"{YARN_URL}/ws/v1/cluster/metrics")).json().get("clusterMetrics", {})
await _term(aid, f"$ yarn application -list # ResourceManager {YARN_URL}", level="cmd", phase="yarn")
await _term(aid, f" ← apps running={m.get('appsRunning', 0)} pending={m.get('appsPending', 0)} · "
f"available {round(m.get('availableMB', 0) / 1024, 1)} GB / "
f"{m.get('totalNodes', 0)} nodes", level="ok", phase="yarn")
except Exception as exc:
await _term(aid, f" ⚠ YARN RM not responding ({str(exc)[:60]}) — HDFS storage layer still healthy", level="warn", phase="yarn")
async def _infra_sentinel() -> None:
aid = "infra-sentinel"
await _term(aid, f"$ dockhand ps --all-envs # container inventory via {DOCKHAND_URL}", level="cmd", phase="docker")
try:
from dockhand_envs import DOCKHAND_ENVS
envs = DOCKHAND_ENVS
except Exception:
envs = {"docker01": 1, "docker02": 2, "lakehouse": 9, "airflow": 10, "db02": 5}
total = running = 0
reached = 0
try:
async with httpx.AsyncClient(timeout=4.0) as client:
async def _one(name: str, eid: int):
try:
hdr = {"Authorization": f"Bearer {DOCKHAND_API_TOKEN}"} if DOCKHAND_API_TOKEN else {}
r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": eid}, headers=hdr)
if r.status_code >= 400:
return None
d = r.json()
return d if isinstance(d, list) else d.get("containers", [])
except Exception:
return None
results = await asyncio.gather(*[_one(n, e) for n, e in envs.items()])
for conts in results:
if conts is None:
continue
reached += 1
total += len(conts)
running += sum(1 for c in conts
if str(c.get("state", c.get("status", ""))).lower().startswith(("run", "up")))
if reached:
await _term(aid, f"{running}/{total} containers up across {reached} Dockhand environments", level="ok", phase="docker")
else:
await _term(aid, " ✗ Dockhand returned no environments", level="warn", phase="docker")
except Exception as exc:
await _term(aid, f" ✗ Dockhand unreachable: {str(exc)[:100]}", level="err", phase="docker")
try:
load = os.getloadavg()
await _term(aid, "$ cat /proc/loadavg # command-center host", level="cmd", phase="host")
await _term(aid, f" ← load avg {load[0]:.2f} {load[1]:.2f} {load[2]:.2f} (1/5/15m)", level="ok", phase="host")
except Exception:
pass
async def _network_watcher() -> None:
aid = "network-watcher"
await _term(aid, "$ probe VLAN 20/21 data paths # ingress/egress reachability", level="cmd", phase="net")
async with httpx.AsyncClient(timeout=4.0, verify=False) as client:
for label, url in _NET_TARGETS:
t0 = time.time()
try:
r = await client.get(url)
ms = int((time.time() - t0) * 1000)
lvl = "ok" if r.status_code < 500 else "warn"
await _term(aid, f"{label:<16} {url} {r.status_code} {ms}ms", level=lvl, phase="net")
except Exception as exc:
await _term(aid, f"{label:<16} {url} DOWN ({str(exc)[:60]})", level="err", phase="net")
_ROTATION = [_lakehouse_ops, _hadoop_ranger, _infra_sentinel, _network_watcher]
async def agent_activity_loop() -> None:
"""Round-robin: run one agent's live probe per tick so each agent terminal
shows fresh real activity roughly every (len(rotation) * TICK_SECONDS)s."""
await asyncio.sleep(15) # let the platform settle
idx = 0
while True:
probe = _ROTATION[idx % len(_ROTATION)]
idx += 1
try:
await probe()
except Exception:
pass
await asyncio.sleep(max(3.0, TICK_SECONDS))
+126 -48
View File
@@ -103,6 +103,15 @@ async def _emit(message: str, level: str = "info") -> None:
pass pass
async def _term(agent_id: str, message: str, level: str = "info", phase: str = "ops") -> None:
"""Stream a line to a specific agent terminal (no feed entry)."""
try:
from agent_terminal import terminal_log
await terminal_log(agent_id, message, level=level, phase=phase)
except Exception:
pass
# ── Synchronous DB operations (run in a thread) ────────────────────────────── # ── Synchronous DB operations (run in a thread) ──────────────────────────────
def _pg_conn(): def _pg_conn():
import psycopg2 import psycopg2
@@ -152,7 +161,7 @@ def _resolve_op(op: str, pool: deque) -> str:
return op return op
def _pg_dml(op: str) -> str: def _pg_dml(op: str) -> dict[str, str]:
pool = _pools["postgres"] pool = _pools["postgres"]
op = _resolve_op(op, pool) op = _resolve_op(op, pool)
conn = _pg_conn() conn = _pg_conn()
@@ -161,35 +170,39 @@ def _pg_dml(op: str) -> str:
with conn.cursor() as cur: with conn.cursor() as cur:
if op == "insert": if op == "insert":
rid = uuid.uuid4().hex[:8] rid = uuid.uuid4().hex[:8]
cid, pid = random.randint(1, 50000), random.randint(1, 2000)
region, channel = random.choice(REGIONS), random.choice(CHANNELS)
amt, curr, status = round(random.uniform(10, 9999), 2), random.choice(CURRENCIES), random.choice(ORDER_STATUS)
cur.execute( cur.execute(
"""INSERT INTO public.sales_orders """INSERT INTO public.sales_orders
(customer_id, product_id, region, sales_channel, order_ts, amount, currency, order_status, notes) (customer_id, product_id, region, sales_channel, order_ts, amount, currency, order_status, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING order_id""", VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING order_id""",
( (cid, pid, region, channel, datetime.now(timezone.utc), amt, curr, status, _tag(rid)),
random.randint(1, 50000), random.randint(1, 2000),
random.choice(REGIONS), random.choice(CHANNELS),
datetime.now(timezone.utc), round(random.uniform(10, 9999), 2),
random.choice(CURRENCIES), random.choice(ORDER_STATUS), _tag(rid),
),
) )
oid = cur.fetchone()[0] oid = cur.fetchone()[0]
pool.append(oid) pool.append(oid)
return f"INSERT sales_orders order_id={oid} ({_tag(rid)})" sql = (f"INSERT INTO public.sales_orders (customer_id,product_id,region,sales_channel,"
f"amount,currency,order_status,notes) VALUES ({cid},{pid},'{region}','{channel}',"
f"{amt},'{curr}','{status}','{_tag(rid)}');")
return {"op": "insert", "detail": f"INSERT sales_orders order_id={oid} ({_tag(rid)})", "sql": sql}
if op == "update": if op == "update":
oid = random.choice(list(pool)) oid = random.choice(list(pool))
status, mult = random.choice(ORDER_STATUS), round(random.uniform(0.9, 1.2), 2)
cur.execute( cur.execute(
"UPDATE public.sales_orders SET order_status=%s, amount=round(amount*%s,2) WHERE order_id=%s", "UPDATE public.sales_orders SET order_status=%s, amount=round(amount*%s,2) WHERE order_id=%s",
(random.choice(ORDER_STATUS), round(random.uniform(0.9, 1.2), 2), oid), (status, mult, oid),
) )
return f"UPDATE sales_orders order_id={oid}" sql = f"UPDATE public.sales_orders SET order_status='{status}', amount=round(amount*{mult},2) WHERE order_id={oid};"
return {"op": "update", "detail": f"UPDATE sales_orders order_id={oid}", "sql": sql}
oid = pool.popleft() oid = pool.popleft()
cur.execute("DELETE FROM public.sales_orders WHERE order_id=%s", (oid,)) cur.execute("DELETE FROM public.sales_orders WHERE order_id=%s", (oid,))
return f"DELETE sales_orders order_id={oid}" return {"op": "delete", "detail": f"DELETE sales_orders order_id={oid}",
"sql": f"DELETE FROM public.sales_orders WHERE order_id={oid};"}
finally: finally:
conn.close() conn.close()
def _mysql_dml(op: str) -> str: def _mysql_dml(op: str) -> dict[str, str]:
pool = _pools["mysql"] pool = _pools["mysql"]
op = _resolve_op(op, pool) op = _resolve_op(op, pool)
conn = _mysql_conn() conn = _mysql_conn()
@@ -197,64 +210,64 @@ def _mysql_dml(op: str) -> str:
with conn.cursor() as cur: with conn.cursor() as cur:
if op == "insert": if op == "insert":
rid = uuid.uuid4().hex[:8] rid = uuid.uuid4().hex[:8]
eid_in, dept, role = random.randint(1, 20000), random.choice(DEPARTMENTS), random.choice(ROLES)
region, etype, sal = random.choice(REGIONS), random.choice(EVENT_TYPES), round(random.uniform(-5000, 15000), 2)
cur.execute( cur.execute(
"""INSERT INTO hr.employee_events """INSERT INTO hr.employee_events
(employee_id, department, role_name, region, event_type, salary_change, event_ts, notes) (employee_id, department, role_name, region, event_type, salary_change, event_ts, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""", VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""",
( (eid_in, dept, role, region, etype, sal, datetime.now(timezone.utc), _tag(rid)),
random.randint(1, 20000), random.choice(DEPARTMENTS), random.choice(ROLES),
random.choice(REGIONS), random.choice(EVENT_TYPES),
round(random.uniform(-5000, 15000), 2), datetime.now(timezone.utc), _tag(rid),
),
) )
eid = cur.lastrowid eid = cur.lastrowid
pool.append(eid) pool.append(eid)
return f"INSERT employee_events event_id={eid} ({_tag(rid)})" sql = (f"INSERT INTO hr.employee_events (employee_id,department,role_name,region,event_type,"
f"salary_change,notes) VALUES ({eid_in},'{dept}','{role}','{region}','{etype}',{sal},'{_tag(rid)}');")
return {"op": "insert", "detail": f"INSERT employee_events event_id={eid} ({_tag(rid)})", "sql": sql}
if op == "update": if op == "update":
eid = random.choice(list(pool)) eid = random.choice(list(pool))
sal, etype = round(random.uniform(-5000, 15000), 2), random.choice(EVENT_TYPES)
cur.execute( cur.execute(
"UPDATE hr.employee_events SET salary_change=%s, event_type=%s WHERE event_id=%s", "UPDATE hr.employee_events SET salary_change=%s, event_type=%s WHERE event_id=%s",
(round(random.uniform(-5000, 15000), 2), random.choice(EVENT_TYPES), eid), (sal, etype, eid),
) )
return f"UPDATE employee_events event_id={eid}" sql = f"UPDATE hr.employee_events SET salary_change={sal}, event_type='{etype}' WHERE event_id={eid};"
return {"op": "update", "detail": f"UPDATE employee_events event_id={eid}", "sql": sql}
eid = pool.popleft() eid = pool.popleft()
cur.execute("DELETE FROM hr.employee_events WHERE event_id=%s", (eid,)) cur.execute("DELETE FROM hr.employee_events WHERE event_id=%s", (eid,))
return f"DELETE employee_events event_id={eid}" return {"op": "delete", "detail": f"DELETE employee_events event_id={eid}",
"sql": f"DELETE FROM hr.employee_events WHERE event_id={eid};"}
finally: finally:
conn.close() conn.close()
def _mongo_dml(op: str) -> str: def _mongo_dml(op: str) -> dict[str, str]:
pool = _pools["mongodb"] pool = _pools["mongodb"]
op = _resolve_op(op, pool) op = _resolve_op(op, pool)
client, coll = _mongo_coll() client, coll = _mongo_coll()
try: try:
if op == "insert": if op == "insert":
rid = uuid.uuid4().hex[:8] rid = uuid.uuid4().hex[:8]
mtype, region, msrc = random.choice(MONGO_TYPES), random.choice(REGIONS), random.choice(MONGO_SOURCES)
amt = round(random.uniform(10, 50000), 4)
doc = { doc = {
"event_id": str(uuid.uuid4()), "event_id": str(uuid.uuid4()), "type": mtype, "region": region, "source": msrc,
"type": random.choice(MONGO_TYPES), "amount": amt, "ts": datetime.now(timezone.utc), "payload": "X" * 200,
"region": random.choice(REGIONS), "atc_agent": True, "agent_run": rid,
"source": random.choice(MONGO_SOURCES),
"amount": round(random.uniform(10, 50000), 4),
"ts": datetime.now(timezone.utc),
"payload": "X" * 200,
"atc_agent": True,
"agent_run": rid,
} }
res = coll.insert_one(doc) res = coll.insert_one(doc)
pool.append(res.inserted_id) pool.append(res.inserted_id)
return f"INSERT events _id={res.inserted_id} (agent_run={rid})" sql = (f"db.events.insertOne({{type:'{mtype}', region:'{region}', source:'{msrc}', "
f"amount:{amt}, atc_agent:true, agent_run:'{rid}'}})")
return {"op": "insert", "detail": f"INSERT events _id={res.inserted_id} (agent_run={rid})", "sql": sql}
if op == "update": if op == "update":
oid = random.choice(list(pool)) oid = random.choice(list(pool))
coll.update_one( mtype, amt = random.choice(MONGO_TYPES), round(random.uniform(10, 50000), 4)
{"_id": oid}, coll.update_one({"_id": oid}, {"$set": {"type": mtype, "amount": amt}})
{"$set": {"type": random.choice(MONGO_TYPES), "amount": round(random.uniform(10, 50000), 4)}}, sql = f"db.events.updateOne({{_id:{oid!r}}}, {{$set:{{type:'{mtype}', amount:{amt}}}}})"
) return {"op": "update", "detail": f"UPDATE events _id={oid}", "sql": sql}
return f"UPDATE events _id={oid}"
oid = pool.popleft() oid = pool.popleft()
coll.delete_one({"_id": oid}) coll.delete_one({"_id": oid})
return f"DELETE events _id={oid}" return {"op": "delete", "detail": f"DELETE events _id={oid}", "sql": f"db.events.deleteOne({{_id:{oid!r}}})"}
finally: finally:
client.close() client.close()
@@ -268,6 +281,9 @@ def _pick_op() -> str:
return random.choices(["insert", "update", "delete"], weights=[0.5, 0.3, 0.2], k=1)[0] return random.choices(["insert", "update", "delete"], weights=[0.5, 0.3, 0.2], k=1)[0]
_CLIENT_CMD = {"postgres": "psql sales", "mysql": "mysql hr", "mongodb": "mongosh supplychain"}
async def _run_one(source: str | None = None, op: str | None = None) -> dict[str, Any]: async def _run_one(source: str | None = None, op: str | None = None) -> dict[str, Any]:
source = source or random.choice(list(_DISPATCH.keys())) source = source or random.choice(list(_DISPATCH.keys()))
op = op or _pick_op() op = op or _pick_op()
@@ -275,18 +291,25 @@ async def _run_one(source: str | None = None, op: str | None = None) -> dict[str
if not fn: if not fn:
return {"ok": False, "error": f"unknown source {source}"} return {"ok": False, "error": f"unknown source {source}"}
try: try:
detail = await asyncio.to_thread(fn, op) # Show the operator exactly what the Data Custodian is about to run.
await _term(DML_AGENT, f"$ {_CLIENT_CMD.get(source, source)} # autonomous DML on {_SRC_LABEL[source]}",
level="cmd", phase="dml")
res = await asyncio.to_thread(fn, op)
detail, sql, actual_op = res["detail"], res["sql"], res["op"]
await _term(DML_AGENT, f" {sql}", level="cmd", phase="dml")
_state["ops_total"] += 1 _state["ops_total"] += 1
_state["by_source"][source] = _state["by_source"].get(source, 0) + 1 _state["by_source"][source] = _state["by_source"].get(source, 0) + 1
actual_op = detail.split(" ", 1)[0].lower()
if actual_op in _state["by_op"]: if actual_op in _state["by_op"]:
_state["by_op"][actual_op] += 1 _state["by_op"][actual_op] += 1
_state["last_op"] = {"source": source, "op": op, "detail": detail, "ts": datetime.now(timezone.utc).isoformat()} _state["last_op"] = {"source": source, "op": actual_op, "detail": detail, "ts": datetime.now(timezone.utc).isoformat()}
_state["last_error"] = None _state["last_error"] = None
await _term(DML_AGENT, f"{detail} · Debezium CDC will stream this to Kafka", level="ok", phase="dml")
# Keep the supervisor feed concise (single summary entry).
await _emit(f"[agent-dml] {_SRC_LABEL[source]}: {detail} — Debezium will capture this change", "info") await _emit(f"[agent-dml] {_SRC_LABEL[source]}: {detail} — Debezium will capture this change", "info")
return {"ok": True, "source": source, "op": op, "detail": detail} return {"ok": True, "source": source, "op": actual_op, "detail": detail}
except Exception as exc: except Exception as exc:
_state["last_error"] = str(exc) _state["last_error"] = str(exc)
await _term(DML_AGENT, f"{source} {op} failed: {str(exc)[:140]}", level="err", phase="dml")
await _emit(f"[agent-dml] {_SRC_LABEL.get(source, source)}: operation failed: {str(exc)[:120]}", "err") await _emit(f"[agent-dml] {_SRC_LABEL.get(source, source)}: operation failed: {str(exc)[:120]}", "err")
return {"ok": False, "source": source, "op": op, "error": str(exc)} return {"ok": False, "source": source, "op": op, "error": str(exc)}
@@ -318,7 +341,14 @@ async def etl_agent_loop() -> None:
mid = _etl_state["rotation"][_etl_state["idx"] % len(_etl_state["rotation"])] mid = _etl_state["rotation"][_etl_state["idx"] % len(_etl_state["rotation"])]
_etl_state["idx"] += 1 _etl_state["idx"] += 1
if mid in MOVEMENT_BY_ID: if mid in MOVEMENT_BY_ID:
mv = MOVEMENT_BY_ID[mid]
await _term("etl-guardian",
f"$ orchestrate movement '{mid}' ({mv.get('label')}) {mv.get('from')}{mv.get('to')}",
level="cmd", phase="orchestrate")
result = await trigger_and_watch(mid, autonomous=True) result = await trigger_and_watch(mid, autonomous=True)
await _term("etl-guardian",
f"{mv.get('label')}: {result.get('state')} · {result.get('rows')} rows in {result.get('duration_s')}s",
level="ok" if result.get("ok") else "err", phase="orchestrate")
_etl_state["runs_total"] += 1 _etl_state["runs_total"] += 1
_etl_state["last"] = {"movement_id": mid, "state": result.get("state"), _etl_state["last"] = {"movement_id": mid, "state": result.get("state"),
"rows": result.get("rows"), "duration_s": result.get("duration_s"), "rows": result.get("rows"), "duration_s": result.get("duration_s"),
@@ -334,10 +364,36 @@ _CUST_INTERVAL = float(os.getenv("CUSTODIAN_OFFLOAD_INTERVAL_SECONDS", "120"))
_CUST_BATCH = int(os.getenv("CUSTODIAN_OFFLOAD_BATCH", "200")) _CUST_BATCH = int(os.getenv("CUSTODIAN_OFFLOAD_BATCH", "200"))
_CUST_TARGETS = [ _CUST_TARGETS = [
{"label": "postgres sales_orders", "src": "postgres_sales.public.sales_orders", {"label": "postgres sales_orders", "src": "postgres_sales.public.sales_orders",
"target": "iceberg.hadoop.sales_orders_offload"}, "target": "iceberg.hadoop.sales_orders_offload",
"create_sql": (
"CREATE TABLE iceberg.hadoop.sales_orders_offload AS "
"SELECT * FROM postgres_sales.public.sales_orders WHERE 1=0"
),
"insert_sql": (
"INSERT INTO iceberg.hadoop.sales_orders_offload "
"SELECT * FROM postgres_sales.public.sales_orders LIMIT {batch}"
)},
{"label": "mysql employee_events", "src": "mysql_hr.hr.employee_events", {"label": "mysql employee_events", "src": "mysql_hr.hr.employee_events",
"target": "iceberg.hadoop.employee_events_offload"}, "target": "iceberg.hadoop.employee_events_offload",
"create_sql": (
"CREATE TABLE iceberg.hadoop.employee_events_offload AS SELECT "
"event_id, employee_id, department, role_name, region, event_type, "
"CAST(salary_change AS double) AS salary_change, "
"CAST(event_ts AS timestamp(6)) AS event_ts, notes, "
"employee_name, employee_email, employee_phone, national_id, home_address, "
"CAST(date_of_birth AS date) AS date_of_birth "
"FROM mysql_hr.hr.employee_events WHERE 1=0"
),
"insert_sql": (
"INSERT INTO iceberg.hadoop.employee_events_offload SELECT "
"event_id, employee_id, department, role_name, region, event_type, "
"CAST(salary_change AS double), CAST(event_ts AS timestamp(6)), notes, "
"employee_name, employee_email, employee_phone, national_id, home_address, "
"CAST(date_of_birth AS date) "
"FROM mysql_hr.hr.employee_events LIMIT {batch}"
)},
] ]
_custodian_state: dict[str, Any] = { _custodian_state: dict[str, Any] = {
"enabled": os.getenv("CUSTODIAN_OFFLOAD_ENABLED", "1") not in ("0", "false", "False", ""), "enabled": os.getenv("CUSTODIAN_OFFLOAD_ENABLED", "1") not in ("0", "false", "False", ""),
"interval": _CUST_INTERVAL, "interval": _CUST_INTERVAL,
@@ -355,10 +411,30 @@ async def _custodian_offload_once(idx: int | None = None) -> dict[str, Any]:
i = _custodian_state["idx"] if idx is None else idx i = _custodian_state["idx"] if idx is None else idx
tgt = _CUST_TARGETS[i % len(_CUST_TARGETS)] tgt = _CUST_TARGETS[i % len(_CUST_TARGETS)]
_custodian_state["idx"] = i + 1 _custodian_state["idx"] = i + 1
await _trino_collect( await _trino_collect("CREATE SCHEMA IF NOT EXISTS iceberg.hadoop", 1)
f"CREATE TABLE IF NOT EXISTS {tgt['target']} AS SELECT * FROM {tgt['src']} WHERE 1=0", 1) probe = await _trino_collect(f"SELECT 1 FROM {tgt['target']} WHERE 1=0", 1)
ins = await _trino_collect( if not probe.get("ok"):
f"INSERT INTO {tgt['target']} SELECT * FROM {tgt['src']} LIMIT {_CUST_BATCH}", 1) ddl = tgt["create_sql"]
await _term(DML_AGENT, f"$ trino --catalog iceberg # Hadoop offload: {tgt['label']}{tgt['target']}",
level="cmd", phase="offload")
await _term(DML_AGENT, f" {ddl};", level="cmd", phase="offload")
created = await _trino_collect(ddl, 1)
if not created.get("ok"):
err = created.get("error")
await _term(DML_AGENT, f" ✗ offload failed: {str(err)[:140]}", level="err", phase="offload")
await _emit(f"[custodian-offload] {tgt['label']} failed: {str(err)[:120]}", "err")
_custodian_state["runs_total"] += 1
_custodian_state["last"] = {
"target": tgt["target"], "src": tgt["src"], "ok": False,
"rows": 0, "ts": datetime.now(timezone.utc).isoformat(), "error": err,
}
return _custodian_state["last"]
else:
await _term(DML_AGENT, f"$ trino --catalog iceberg # Hadoop offload: {tgt['label']}{tgt['target']}",
level="cmd", phase="offload")
dml = tgt["insert_sql"].format(batch=_CUST_BATCH)
await _term(DML_AGENT, f" {dml};", level="cmd", phase="offload")
ins = await _trino_collect(dml, 1)
ok = bool(ins.get("ok")) ok = bool(ins.get("ok"))
_custodian_state["runs_total"] += 1 _custodian_state["runs_total"] += 1
_custodian_state["last"] = { _custodian_state["last"] = {
@@ -367,8 +443,10 @@ async def _custodian_offload_once(idx: int | None = None) -> dict[str, Any]:
"ts": datetime.now(timezone.utc).isoformat(), "error": ins.get("error"), "ts": datetime.now(timezone.utc).isoformat(), "error": ins.get("error"),
} }
if ok: if ok:
await _term(DML_AGENT, f" ← offloaded ~{_CUST_BATCH} rows into the Hadoop Iceberg lake", level="ok", phase="offload")
await _emit(f"[custodian-offload] {tgt['label']}{tgt['target']}: offloaded ~{_CUST_BATCH} rows to Hadoop", "info") await _emit(f"[custodian-offload] {tgt['label']}{tgt['target']}: offloaded ~{_CUST_BATCH} rows to Hadoop", "info")
else: else:
await _term(DML_AGENT, f" ✗ offload failed: {str(ins.get('error'))[:140]}", level="err", phase="offload")
await _emit(f"[custodian-offload] {tgt['label']} failed: {str(ins.get('error'))[:120]}", "err") await _emit(f"[custodian-offload] {tgt['label']} failed: {str(ins.get('error'))[:120]}", "err")
return _custodian_state["last"] return _custodian_state["last"]
+33 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import uuid import uuid
from collections import deque from collections import deque
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -13,6 +14,7 @@ PublishFn = Callable[[dict[str, Any]], Awaitable[None]]
_buffers: dict[str, deque[dict[str, Any]]] = {} _buffers: dict[str, deque[dict[str, Any]]] = {}
_publish: PublishFn | None = None _publish: PublishFn | None = None
_loop: asyncio.AbstractEventLoop | None = None
def init_terminals(agent_ids: list[str]) -> None: def init_terminals(agent_ids: list[str]) -> None:
@@ -22,8 +24,38 @@ def init_terminals(agent_ids: list[str]) -> None:
def set_terminal_publisher(fn: PublishFn) -> None: def set_terminal_publisher(fn: PublishFn) -> None:
global _publish global _publish, _loop
_publish = fn _publish = fn
try: # capture the main event loop so background threads can stream too
_loop = asyncio.get_running_loop()
except RuntimeError:
_loop = None
def _build_line(agent_id: str, text: str, level: str, phase: str, prompt_id: str | None) -> dict[str, Any]:
return {
"id": str(uuid.uuid4())[:8],
"ts": datetime.now(timezone.utc).isoformat(),
"agent_id": agent_id,
"level": level,
"phase": phase,
"text": text,
"prompt_id": prompt_id,
}
def emit_threadsafe(agent_id: str, text: str, *, level: str = "info", phase: str = "ops") -> dict[str, Any]:
"""Append a terminal line and broadcast it from a non-async context (e.g. a
background ``threading.Thread``). Safe to call from any thread."""
init_terminals([agent_id])
line = _build_line(agent_id, text, level, phase, None)
_buffers[agent_id].append(line)
if _publish and _loop and not _loop.is_closed():
try:
asyncio.run_coroutine_threadsafe(_publish({"type": "terminal", "line": line}), _loop)
except Exception:
pass
return line
def get_terminal_lines(agent_id: str, limit: int = 200) -> list[dict[str, Any]]: def get_terminal_lines(agent_id: str, limit: int = 200) -> list[dict[str, Any]]:
+229
View File
@@ -0,0 +1,229 @@
"""Authentik OIDC login and session cookie for Command Center."""
from __future__ import annotations
import logging
import os
from typing import Any
from urllib.parse import quote, urlencode
import httpx
from authlib.common.security import generate_token
from authlib.integrations.starlette_client import OAuth
from authlib.oauth2.rfc7636 import create_s256_code_challenge
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
log = logging.getLogger("atc-agents.auth")
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() in ("1", "true", "yes")
AUTHENTIK_ISSUER = os.getenv(
"AUTHENTIK_ISSUER",
"http://atc-mgt01.dell-atc.lan:9000/application/o/command-center/",
).rstrip("/") + "/"
AUTHENTIK_CLIENT_ID = os.getenv("AUTHENTIK_CLIENT_ID", "")
AUTHENTIK_CLIENT_SECRET = os.getenv("AUTHENTIK_CLIENT_SECRET", "")
AUTHENTIK_REDIRECT_URI = os.getenv(
"AUTHENTIK_REDIRECT_URI",
"http://10.0.21.33/auth/callback",
)
SESSION_SECRET = os.getenv("SESSION_SECRET", "dev-insecure-change-me")
SESSION_COOKIE = "cc_session"
oauth = OAuth()
_oauth_ready = False
PUBLIC_PREFIXES = (
"/auth/",
"/api/health",
"/api/auth/me",
)
def is_auth_enabled() -> bool:
return AUTH_ENABLED
def build_session_user(claims: dict[str, Any]) -> dict[str, Any]:
name = claims.get("name") or claims.get("preferred_username") or claims.get("email") or "user"
email = claims.get("email") or ""
username = claims.get("preferred_username") or claims.get("nickname") or email or str(claims.get("sub") or "user")
return {
"sub": claims.get("sub"),
"email": email,
"name": name,
"preferred_username": username,
}
def get_session_user(request: Request) -> dict[str, Any] | None:
if not AUTH_ENABLED:
return {
"sub": "dev:local",
"email": "",
"name": "Dev User",
"preferred_username": "dev",
"dev": True,
}
user = request.session.get("user")
return user if isinstance(user, dict) else None
def auth_me_payload(request: Request) -> dict[str, Any]:
user = get_session_user(request)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return {
"user": user.get("sub"),
"email": user.get("email"),
"name": user.get("name"),
"preferred_username": user.get("preferred_username"),
"auth_enabled": AUTH_ENABLED,
}
def _ensure_oauth() -> None:
global _oauth_ready
if _oauth_ready or not AUTH_ENABLED:
return
if not AUTHENTIK_CLIENT_ID or not AUTHENTIK_CLIENT_SECRET:
log.warning("AUTH_ENABLED but Authentik client credentials missing")
return
meta_url = AUTHENTIK_ISSUER + ".well-known/openid-configuration"
oauth.register(
name="authentik",
client_id=AUTHENTIK_CLIENT_ID,
client_secret=AUTHENTIK_CLIENT_SECRET,
server_metadata_url=meta_url,
client_kwargs={"scope": "openid profile email"},
)
_oauth_ready = True
def _is_public_path(path: str) -> bool:
return any(path == p or path.startswith(p) for p in PUBLIC_PREFIXES)
async def auth_guard_middleware(request: Request, call_next):
if not AUTH_ENABLED:
return await call_next(request)
path = request.url.path
if _is_public_path(path):
return await call_next(request)
if path.startswith("/api/"):
if get_session_user(request) is None:
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
return await call_next(request)
def init_auth_middleware(app: FastAPI) -> None:
"""Session must wrap auth guard so request.session is populated first."""
app.middleware("http")(auth_guard_middleware)
app.add_middleware(
SessionMiddleware,
secret_key=SESSION_SECRET,
session_cookie=SESSION_COOKIE,
max_age=86400 * 7,
same_site="lax",
https_only=False,
)
def setup_auth(app: FastAPI) -> None:
_ensure_oauth()
_register_routes(app)
def _token_endpoint() -> str:
return AUTHENTIK_ISSUER.rstrip("/").rsplit("/application/o/", 1)[0] + "/application/o/token/"
def _userinfo_endpoint() -> str:
return AUTHENTIK_ISSUER.rstrip("/").rsplit("/application/o/", 1)[0] + "/application/o/userinfo/"
async def _exchange_code_for_userinfo(code: str, code_verifier: str | None) -> dict[str, Any]:
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": AUTHENTIK_REDIRECT_URI,
"client_id": AUTHENTIK_CLIENT_ID,
"client_secret": AUTHENTIK_CLIENT_SECRET,
}
if code_verifier:
data["code_verifier"] = code_verifier
async with httpx.AsyncClient(timeout=20.0) as client:
tok = await client.post(_token_endpoint(), data=data)
if tok.status_code >= 400:
log.warning("Token exchange failed: %s %s", tok.status_code, tok.text[:300])
tok.raise_for_status()
payload = tok.json()
access = payload.get("access_token")
if not access:
raise RuntimeError("No access_token in token response")
ui = await client.get(
_userinfo_endpoint(),
headers={"Authorization": f"Bearer {access}"},
)
if ui.status_code >= 400:
log.warning("Userinfo failed: %s %s", ui.status_code, ui.text[:300])
ui.raise_for_status()
return ui.json()
def _register_routes(app: FastAPI) -> None:
@app.get("/api/auth/me")
async def api_auth_me(request: Request):
return auth_me_payload(request)
@app.get("/auth/login")
async def auth_login(request: Request):
if not AUTH_ENABLED:
return RedirectResponse("/", status_code=302)
_ensure_oauth()
if not _oauth_ready:
raise HTTPException(503, "Authentik not configured")
code_verifier = generate_token(48)
request.session["pkce_code_verifier"] = code_verifier
return await oauth.authentik.authorize_redirect(
request,
AUTHENTIK_REDIRECT_URI,
code_challenge=create_s256_code_challenge(code_verifier),
code_challenge_method="S256",
code_verifier=code_verifier,
)
@app.get("/auth/callback")
async def auth_callback(request: Request):
if not AUTH_ENABLED:
return RedirectResponse("/", status_code=302)
err = request.query_params.get("error")
if err:
desc = request.query_params.get("error_description") or err
log.warning("OIDC provider error: %s%s", err, desc)
return RedirectResponse(f"/?error={quote(desc)}", status_code=302)
code = request.query_params.get("code")
if not code:
return RedirectResponse("/?error=missing_code", status_code=302)
state = request.query_params.get("state")
if state:
request.session.pop(f"_state_authentik_{state}", None)
code_verifier = request.session.pop("pkce_code_verifier", None)
try:
userinfo = await _exchange_code_for_userinfo(code, code_verifier)
except Exception as e:
log.warning("OIDC callback failed: %s", e)
return RedirectResponse("/?error=login_failed", status_code=302)
request.session["user"] = build_session_user(userinfo or {})
return RedirectResponse("/", status_code=302)
@app.get("/auth/logout")
async def auth_logout(request: Request):
request.session.clear()
if not AUTH_ENABLED:
return RedirectResponse("/", status_code=302)
end_session = AUTHENTIK_ISSUER + "end-session/"
post_logout = "http://10.0.21.33/"
params = urlencode({"post_logout_redirect_uri": post_logout})
return RedirectResponse(f"{end_session}?{params}", status_code=302)
+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())
+112 -43
View File
@@ -18,7 +18,7 @@ import json
import os import os
import re import re
import uuid import uuid
from collections import deque from collections import defaultdict, deque
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
@@ -28,7 +28,8 @@ from fastapi.responses import JSONResponse
router = APIRouter(prefix="/api/changes", tags=["changes"]) router = APIRouter(prefix="/api/changes", tags=["changes"])
KAFKA_BOOTSTRAP = os.getenv("KAFKA_BOOTSTRAP", "10.0.21.36:9092") KAFKA_BOOTSTRAP = os.getenv("KAFKA_BOOTSTRAP", "10.0.21.36:9092")
RING_SIZE = int(os.getenv("CDC_RING_SIZE", "1000")) RING_SIZE = int(os.getenv("CDC_RING_SIZE", "5000"))
METRICS_RETENTION_MIN = int(os.getenv("CDC_METRICS_RETENTION_MIN", str(24 * 60)))
# Active CDC topic prefixes -> logical source. Matches Debezium topic.prefix. # Active CDC topic prefixes -> logical source. Matches Debezium topic.prefix.
PREFIX_SOURCE = { PREFIX_SOURCE = {
@@ -45,6 +46,8 @@ TOPIC_PATTERN = re.compile(
OP_MAP = {"c": "insert", "u": "update", "d": "delete", "r": "snapshot"} OP_MAP = {"c": "insert", "u": "update", "d": "delete", "r": "snapshot"}
_ring: deque[dict[str, Any]] = deque(maxlen=RING_SIZE) _ring: deque[dict[str, Any]] = deque(maxlen=RING_SIZE)
# Minute-level rollups — accurate counts beyond the display ring buffer cap.
_minute_rollups: dict[int, dict[str, Any]] = {}
_state: dict[str, Any] = { _state: dict[str, Any] = {
"started": False, "started": False,
"connected": False, "connected": False,
@@ -127,6 +130,83 @@ def _parse(topic: str, value: bytes | None) -> dict[str, Any] | None:
} }
def _record_metrics(entry: dict[str, Any]) -> None:
"""Track per-minute aggregates for accurate window stats (not ring-limited)."""
try:
ts = datetime.fromisoformat(entry["ts"].replace("Z", "+00:00"))
except Exception:
ts = datetime.now(timezone.utc)
minute_key = int(ts.timestamp()) // 60
bucket = _minute_rollups.setdefault(
minute_key,
{"total": 0, "by_source": defaultdict(int), "by_op": defaultdict(int), "by_table": defaultdict(int)},
)
bucket["total"] += 1
bucket["by_source"][entry["source"]] += 1
bucket["by_op"][entry["op"]] += 1
bucket["by_table"][entry["table"]] += 1
cutoff = minute_key - METRICS_RETENTION_MIN
for old_key in list(_minute_rollups.keys()):
if old_key < cutoff:
del _minute_rollups[old_key]
def _aggregate_window(minutes: int) -> dict[str, Any]:
"""Aggregate rollup buckets for the requested time window."""
now = datetime.now(timezone.utc)
cutoff_minute = int((now.timestamp() - minutes * 60) // 60)
use_hour_buckets = minutes >= 120
by_source: dict[str, int] = defaultdict(int)
by_op: dict[str, int] = defaultdict(int)
by_table: dict[str, int] = defaultdict(int)
buckets: dict[str, int] = defaultdict(int)
total = 0
for minute_key, bucket in _minute_rollups.items():
if minute_key < cutoff_minute:
continue
total += bucket["total"]
for k, v in bucket["by_source"].items():
by_source[k] += v
for k, v in bucket["by_op"].items():
by_op[k] += v
for k, v in bucket["by_table"].items():
by_table[k] += v
ts = datetime.fromtimestamp(minute_key * 60, timezone.utc)
label = ts.strftime("%H:00") if use_hour_buckets else ts.strftime("%H:%M")
buckets[label] += bucket["total"]
# Fill chart timeline with zero buckets so the window span is visible.
if use_hour_buckets:
span = max(1, (minutes + 59) // 60)
filled: dict[str, int] = {}
for i in range(span):
t = datetime.fromtimestamp(now.timestamp() - (span - 1 - i) * 3600, timezone.utc)
filled[t.strftime("%H:00")] = buckets.get(t.strftime("%H:00"), 0)
buckets = filled
else:
span = max(1, minutes)
filled = {}
for i in range(span):
t = datetime.fromtimestamp(now.timestamp() - (span - 1 - i) * 60, timezone.utc)
filled[t.strftime("%H:%M")] = buckets.get(t.strftime("%H:%M"), 0)
buckets = filled
return {
"total": total,
"by_source": dict(by_source),
"by_op": dict(by_op),
"by_table": dict(by_table),
"buckets": [{"t": k, "n": v} for k, v in sorted(buckets.items())],
"inserts": by_op.get("insert", 0),
"updates": by_op.get("update", 0),
"deletes": by_op.get("delete", 0),
"rate_per_min": round(total / max(1, minutes), 1),
}
async def cdc_consumer_loop() -> None: async def cdc_consumer_loop() -> None:
"""Background loop: consume CDC topics and publish each change live.""" """Background loop: consume CDC topics and publish each change live."""
_state["started"] = True _state["started"] = True
@@ -158,6 +238,7 @@ async def cdc_consumer_loop() -> None:
if not entry: if not entry:
continue continue
_ring.append(entry) _ring.append(entry)
_record_metrics(entry)
_state["consumed"] += 1 _state["consumed"] += 1
_state["last_ts"] = entry["ts"] _state["last_ts"] = entry["ts"]
try: try:
@@ -182,30 +263,36 @@ async def cdc_consumer_loop() -> None:
def snapshot(minutes: int = 15) -> dict[str, Any]: def snapshot(minutes: int = 15) -> dict[str, Any]:
"""Lightweight CDC snapshot for other modules (Data Flow graph).""" """Lightweight CDC snapshot for other modules (Data Flow graph)."""
cutoff = datetime.now(timezone.utc).timestamp() - minutes * 60 agg = _aggregate_window(minutes)
by_source: dict[str, int] = {} return {
total = 0 "connected": _state["connected"],
for c in _ring: "consumed": _state["consumed"],
try: "buffered": len(_ring),
if datetime.fromisoformat(c["ts"]).timestamp() < cutoff: "window_total": agg["total"],
continue "by_source": agg["by_source"],
except Exception: }
continue
total += 1
by_source[c["source"]] = by_source.get(c["source"], 0) + 1
return {"connected": _state["connected"], "consumed": _state["consumed"],
"buffered": len(_ring), "window_total": total, "by_source": by_source}
# ── Endpoints ──────────────────────────────────────────────────────────────── # ── Endpoints ────────────────────────────────────────────────────────────────
@router.get("") @router.get("")
async def list_changes( async def list_changes(
limit: int = Query(100, le=500), limit: int = Query(200, le=1000),
source: str | None = None, source: str | None = None,
op: str | None = None, op: str | None = None,
table: str | None = None, table: str | None = None,
minutes: int | None = Query(None, le=1440),
) -> JSONResponse: ) -> JSONResponse:
items = list(_ring) items = list(_ring)
if minutes is not None:
cutoff = datetime.now(timezone.utc).timestamp() - minutes * 60
filtered_by_time: list[dict[str, Any]] = []
for c in items:
try:
if datetime.fromisoformat(c["ts"].replace("Z", "+00:00")).timestamp() >= cutoff:
filtered_by_time.append(c)
except Exception:
continue
items = filtered_by_time
if source: if source:
items = [c for c in items if c["source"] == source] items = [c for c in items if c["source"] == source]
if op: if op:
@@ -217,47 +304,29 @@ async def list_changes(
"ok": True, "ok": True,
"changes": items, "changes": items,
"buffered": len(_ring), "buffered": len(_ring),
"buffer_cap": RING_SIZE,
"connected": _state["connected"], "connected": _state["connected"],
"consumed": _state["consumed"], "consumed": _state["consumed"],
"last_error": _state["last_error"], "last_error": _state["last_error"],
"last_ts": _state["last_ts"],
}) })
@router.get("/stats") @router.get("/stats")
async def change_stats(minutes: int = Query(15, le=240)) -> JSONResponse: async def change_stats(minutes: int = Query(15, le=1440)) -> JSONResponse:
now = datetime.now(timezone.utc) agg = _aggregate_window(minutes)
cutoff = now.timestamp() - minutes * 60
by_source: dict[str, int] = {}
by_op: dict[str, int] = {}
by_table: dict[str, int] = {}
buckets: dict[str, int] = {}
total = 0
for c in _ring:
try:
t = datetime.fromisoformat(c["ts"]).timestamp()
except Exception:
continue
if t < cutoff:
continue
total += 1
by_source[c["source"]] = by_source.get(c["source"], 0) + 1
by_op[c["op"]] = by_op.get(c["op"], 0) + 1
by_table[c["table"]] = by_table.get(c["table"], 0) + 1
bucket = datetime.fromtimestamp(t, timezone.utc).strftime("%H:%M")
buckets[bucket] = buckets.get(bucket, 0) + 1
return JSONResponse({ return JSONResponse({
"ok": True, "ok": True,
"window_minutes": minutes, "window_minutes": minutes,
"total": total, **agg,
"by_source": by_source,
"by_op": by_op,
"by_table": by_table,
"buckets": [{"t": k, "n": v} for k, v in sorted(buckets.items())],
"connected": _state["connected"], "connected": _state["connected"],
"consumed": _state["consumed"], "consumed": _state["consumed"],
"buffered": len(_ring),
"buffer_cap": RING_SIZE,
"last_ts": _state["last_ts"],
}) })
@router.get("/status") @router.get("/status")
async def changes_status() -> JSONResponse: async def changes_status() -> JSONResponse:
return JSONResponse({"ok": True, "buffered": len(_ring), **_state}) return JSONResponse({"ok": True, "buffered": len(_ring), "buffer_cap": RING_SIZE, **_state})
+119 -3
View File
@@ -26,6 +26,76 @@ router = APIRouter(prefix="/api/dataflow", tags=["dataflow"])
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/") TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
TRINO_USER = os.getenv("TRINO_USER", "mo") TRINO_USER = os.getenv("TRINO_USER", "mo")
NESSIE_URL = os.getenv("NESSIE_URL", "http://10.0.21.50:19120").rstrip("/")
SRC_DB_HOST = os.getenv("SRC_DB_HOST", "10.0.21.51")
SPARK_UI_URL = os.getenv("SPARK_UI_URL", "http://10.0.21.50:8081").rstrip("/")
KAFKA_UI_URL = os.getenv("KAFKA_UI_URL", "http://10.0.21.36:9000").rstrip("/")
HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870").rstrip("/")
S3_ENDPOINT = os.getenv("S3_ENDPOINT", "http://10.0.20.111:9020").rstrip("/")
# TCP ports for source DB reachability on the shared db host
_SOURCE_PORTS = {
"postgres": 5432,
"mysql": 3306,
"mongodb": 27017,
"cassandra": 9042,
"neo4j": 7687,
}
async def _tcp_open(host: str, port: int, timeout: float = 1.2) -> bool:
import asyncio
try:
conn = asyncio.open_connection(host, port)
reader, writer = await asyncio.wait_for(conn, timeout=timeout)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
return True
except Exception:
return False
async def _http_ok(url: str, timeout: float = 2.0) -> bool:
try:
async with httpx.AsyncClient(timeout=timeout) as client:
r = await client.get(url)
return r.status_code < 500
except Exception:
return False
async def _probe_online() -> dict[str, bool]:
"""Best-effort live reachability for dataflow nodes."""
online: dict[str, bool] = {}
for sid, port in _SOURCE_PORTS.items():
online[sid] = await _tcp_open(SRC_DB_HOST, port)
online["trino"] = await _http_ok(f"{TRINO_URL}/v1/info")
online["nessie"] = await _http_ok(f"{NESSIE_URL}/api/v2/config")
online["spark"] = await _http_ok(f"{SPARK_UI_URL}/json/")
online["kafka"] = await _http_ok(KAFKA_UI_URL)
online["hdfs"] = await _http_ok(HDFS_NN_URL)
# S3: TCP to endpoint host:port
try:
from urllib.parse import urlparse
u = urlparse(S3_ENDPOINT)
online["s3_cdc"] = await _tcp_open(u.hostname or "10.0.20.111", u.port or 9020)
except Exception:
online["s3_cdc"] = False
# lakehouse iceberg nodes follow trino catalog availability
online["iceberg_hadoop"] = online.get("trino", False)
online["iceberg_curated"] = online.get("trino", False)
online["generator"] = True # logical
online["openmetadata"] = await _http_ok("http://10.0.21.47:8585")
online["chromadb"] = True
online["rag"] = True
online["vllm"] = await _http_ok(os.getenv("LLM_URL", "http://10.0.10.106:8001/v1").rstrip("/") + "/models")
online["chat"] = True
return online
# Static landscape (x,y in 0..100). kind drives UI styling. # Static landscape (x,y in 0..100). kind drives UI styling.
# Laid out as clean left→right pipeline stages so lineage reads in order: # Laid out as clean left→right pipeline stages so lineage reads in order:
# producers (col 0) → sources (col 1) → CDC (col 2) → storage/lakehouse (col 3) # producers (col 0) → sources (col 1) → CDC (col 2) → storage/lakehouse (col 3)
@@ -48,6 +118,8 @@ NODES: list[dict[str, Any]] = [
{"id": "s3_cdc", "label": "S3 CDC Archive", "sub": "object store", "kind": "sink", "x": 67, "y": 13}, {"id": "s3_cdc", "label": "S3 CDC Archive", "sub": "object store", "kind": "sink", "x": 67, "y": 13},
{"id": "iceberg_curated", "label": "Iceberg · curated_masked", "sub": "masked PII", "kind": "lakehouse", "x": 67, "y": 45}, {"id": "iceberg_curated", "label": "Iceberg · curated_masked", "sub": "masked PII", "kind": "lakehouse", "x": 67, "y": 45},
{"id": "iceberg_hadoop", "label": "Iceberg · hadoop", "sub": "historical_sales_hdfs", "kind": "lakehouse", "x": 67, "y": 80}, {"id": "iceberg_hadoop", "label": "Iceberg · hadoop", "sub": "historical_sales_hdfs", "kind": "lakehouse", "x": 67, "y": 80},
{"id": "nessie", "label": "Nessie", "sub": "Iceberg catalog · Git-like", "kind": "catalog", "x": 78, "y": 62,
"url": "http://10.0.21.50:19120"},
# col 4 — query engine # col 4 — query engine
{"id": "trino", "label": "Trino", "sub": "query engine", "kind": "engine", "x": 88, "y": 45}, {"id": "trino", "label": "Trino", "sub": "query engine", "kind": "engine", "x": 88, "y": 45},
# governance — bottom centre # governance — bottom centre
@@ -83,6 +155,10 @@ EDGES: list[dict[str, Any]] = [
{"from": "postgres", "to": "iceberg_curated", "kind": "mask", "movement_id": "mask_to_curated"}, {"from": "postgres", "to": "iceberg_curated", "kind": "mask", "movement_id": "mask_to_curated"},
{"from": "mysql", "to": "iceberg_curated", "kind": "mask", "movement_id": "mask_to_curated"}, {"from": "mysql", "to": "iceberg_curated", "kind": "mask", "movement_id": "mask_to_curated"},
{"from": "mongodb", "to": "iceberg_curated", "kind": "mask", "movement_id": "mask_to_curated"}, {"from": "mongodb", "to": "iceberg_curated", "kind": "mask", "movement_id": "mask_to_curated"},
{"from": "iceberg_hadoop", "to": "nessie", "kind": "catalog"},
{"from": "iceberg_curated", "to": "nessie", "kind": "catalog"},
{"from": "nessie", "to": "s3_cdc", "kind": "archive"},
{"from": "nessie", "to": "trino", "kind": "query"},
{"from": "iceberg_hadoop", "to": "trino", "kind": "query"}, {"from": "iceberg_hadoop", "to": "trino", "kind": "query"},
{"from": "iceberg_curated", "to": "trino", "kind": "query"}, {"from": "iceberg_curated", "to": "trino", "kind": "query"},
{"from": "kafka", "to": "trino", "kind": "query"}, {"from": "kafka", "to": "trino", "kind": "query"},
@@ -173,6 +249,16 @@ async def _build() -> dict[str, Any]:
cdc = cdc_snapshot(15) cdc = cdc_snapshot(15)
except Exception: except Exception:
cdc = {"connected": False, "consumed": 0, "by_source": {}, "window_total": 0} cdc = {"connected": False, "consumed": 0, "by_source": {}, "window_total": 0}
try:
from trino_federated import generator_active
gen_active = generator_active()
except Exception:
gen_active = False
try:
from storage_s3 import archive_active
arch_active = archive_active()
except Exception:
arch_active = False
try: try:
from pii_catalog import get_pii from pii_catalog import get_pii
pii = get_pii() pii = get_pii()
@@ -188,10 +274,14 @@ async def _build() -> dict[str, Any]:
edge_live = streaming.get("edges") or {} edge_live = streaming.get("edges") or {}
pii_by_node = {d["node_id"]: d for d in pii.get("datasets", [])} pii_by_node = {d["node_id"]: d for d in pii.get("datasets", [])}
online = await _probe_online()
nodes = [] nodes = []
for n in NODES: for n in NODES:
node = dict(n) node = dict(n)
node["level"] = "ok" is_up = online.get(n["id"], True)
node["online"] = is_up
node["level"] = "ok" if is_up else "err"
metric = None metric = None
if n["id"] == "openmetadata": if n["id"] == "openmetadata":
metric = f"{pii.get('summary', {}).get('pii_columns', 0)} PII cols cataloged" metric = f"{pii.get('summary', {}).get('pii_columns', 0)} PII cols cataloged"
@@ -207,10 +297,15 @@ async def _build() -> dict[str, Any]:
metric = f"{spark.get('alive_workers', 0)} workers · {used}/{cores} cores · {apps} apps" metric = f"{spark.get('alive_workers', 0)} workers · {used}/{cores} cores · {apps} apps"
node["level"] = "ok" if spark.get("ui_ok") and (spark.get("status") or "").upper() == "ALIVE" else "warn" node["level"] = "ok" if spark.get("ui_ok") and (spark.get("status") or "").upper() == "ALIVE" else "warn"
elif n["id"] in ("postgres", "mysql", "mongodb", "cassandra", "neo4j"): elif n["id"] in ("postgres", "mysql", "mongodb", "cassandra", "neo4j"):
if not is_up:
metric = "offline"
else:
metric = f"{cdc.get('by_source', {}).get(n['id'], 0)} CDC/15m" metric = f"{cdc.get('by_source', {}).get(n['id'], 0)} CDC/15m"
elif n["id"] == "iceberg_hadoop": elif n["id"] == "iceberg_hadoop":
c = _iceberg_hadoop_count() c = _iceberg_hadoop_count()
metric = f"{c:,} rows" if c is not None else "iceberg table" metric = f"{c:,} rows" if c is not None else "iceberg table"
elif n["id"] == "nessie":
metric = "catalog commits" if online.get("nessie") else "offline"
elif n["id"] == "generator": elif n["id"] == "generator":
metric = "Airflow gen DAGs" metric = "Airflow gen DAGs"
elif n["id"] in ("vllm", "rag", "chromadb"): elif n["id"] in ("vllm", "rag", "chromadb"):
@@ -255,7 +350,12 @@ async def _build() -> dict[str, Any]:
edge["last_rows"] = lr.get("rows") edge["last_rows"] = lr.get("rows")
edge["last_duration_s"] = lr.get("duration_s") edge["last_duration_s"] = lr.get("duration_s")
edge["active"] = lr.get("state") == "running" edge["active"] = lr.get("state") == "running"
if e["kind"] == "cdc": if e["kind"] == "generate":
# The live generator (continuous loop or the 'Generate data' burst)
# writes into these four sources; pulse the edge while it is active.
if e["to"] in ("postgres", "mysql", "mongodb", "cassandra"):
edge["active"] = gen_active or bool(edge.get("active"))
elif e["kind"] == "cdc":
edge["active"] = cdc.get("by_source", {}).get(e["from"], 0) > 0 edge["active"] = cdc.get("by_source", {}).get(e["from"], 0) > 0
elif e.get("from") == "hdfs" and e.get("to") == "kafka": elif e.get("from") == "hdfs" and e.get("to") == "kafka":
edge["active"] = bool(edge_live.get("hdfs→kafka")) edge["active"] = bool(edge_live.get("hdfs→kafka"))
@@ -264,7 +364,14 @@ async def _build() -> dict[str, Any]:
elif e.get("from") == "spark" and e.get("to") == "iceberg_curated": elif e.get("from") == "spark" and e.get("to") == "iceberg_curated":
edge["active"] = bool(edge_live.get("spark→iceberg")) or edge.get("active") edge["active"] = bool(edge_live.get("spark→iceberg")) or edge.get("active")
elif e.get("from") == "spark" and e.get("to") == "s3_cdc": elif e.get("from") == "spark" and e.get("to") == "s3_cdc":
edge["active"] = bool(edge_live.get("spark→s3")) or edge.get("active") edge["active"] = bool(edge_live.get("spark→s3")) or edge.get("active") or arch_active
elif e["kind"] == "archive" and e.get("from") == "kafka" and e.get("to") == "s3_cdc":
# Kafka → S3 CDC archive pulses while the pipeline lands objects in S3
edge["active"] = arch_active
elif e["kind"] == "catalog" and e.get("to") == "nessie":
edge["active"] = bool(online.get("nessie")) and bool(online.get(e.get("from"), True))
elif e["kind"] == "catalog":
edge["active"] = bool(online.get(e.get("from"), True)) and bool(online.get(e.get("to"), True))
elif e["kind"] in ("context", "retrieve", "prompt", "answer"): elif e["kind"] in ("context", "retrieve", "prompt", "answer"):
# AI serving lane pulses while governed data is being served to the LLM # AI serving lane pulses while governed data is being served to the LLM
edge["active"] = bool(_rag_info()) edge["active"] = bool(_rag_info())
@@ -276,6 +383,15 @@ async def _build() -> dict[str, Any]:
pass pass
if _flow != "running": if _flow != "running":
edge["active"] = False edge["active"] = False
# Realtime online/offline: never pulse if an endpoint is down
frm_up = online.get(e.get("from"), True)
to_up = online.get(e.get("to"), True)
if not (frm_up and to_up):
edge["active"] = False
edge["offline"] = True
else:
edge["offline"] = False
edges.append(edge) edges.append(edge)
return { return {
+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"]})
+583
View File
@@ -0,0 +1,583 @@
"""Autonomous ETL offload agent — source databases → S3 Parquet lake.
A background agent ("Lakehouse Loader") that, on a fixed cadence, pulls the next
small chunk of rows from every source database and writes it to the S3 object
store as a partitioned Parquet part:
lake/<dataset>/dt=YYYY-MM-DD/part-<ts>.parquet
It progressively *backfills* the entire history in small chunks (so a 50M-row
table lands as thousands of small files) and then *tails* newly generated rows,
so the object store keeps filling and the analytics stay realtime. While
offloading it also accumulates a live federated business matrix (revenue by
region/status/channel, HR by dept, supply by type, telemetry by metric, …)
straight from the rows it actually moved — which therefore always reflects the
newest generated data, and powers the realtime Trino / Object-store dashboards.
Endpoints:
GET /api/etl/status -> per-dataset progress, ingest rate, recent parts, feed
GET /api/etl/business -> accumulated realtime federated business matrix
POST /api/etl/run -> trigger one offload cycle now
POST /api/etl/config -> {enabled, interval_s, chunk}
"""
from __future__ import annotations
import io
import os
import json
import random
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
router = APIRouter(prefix="/api/etl", tags=["etl"])
DATASETS = [
{"key": "orders", "label": "Sales orders", "engine": "PostgreSQL", "color": "#fbbf24"},
{"key": "hr_events", "label": "HR events", "engine": "MySQL", "color": "#60a5fa"},
{"key": "supply_events", "label": "Supply events", "engine": "MongoDB", "color": "#a78bfa"},
{"key": "telemetry", "label": "Device telemetry", "engine": "Cassandra", "color": "#22d3ee"},
]
_lock = threading.Lock()
_state: dict[str, Any] = {
"enabled": os.getenv("ETL_OFFLOAD_ENABLED", "1") not in ("0", "false", "False"),
"interval_s": float(os.getenv("ETL_OFFLOAD_INTERVAL_SECONDS", "30")),
"chunk": int(os.getenv("ETL_OFFLOAD_CHUNK", "20000")),
"running_cycle": False,
"started_at": None,
"last_cycle_ts": 0.0,
"last_cycle_rows": 0,
"next_run_ts": 0.0,
"cycles": 0,
"datasets": {
d["key"]: {"label": d["label"], "engine": d["engine"], "color": d["color"],
"parts": 0, "rows": 0, "bytes": 0, "backfilled": False, "cursor": None,
"keycol": None, "mode": None, "total_source": None, "last_ts": None,
"last_rows": 0, "last_key": None, "error": None}
for d in DATASETS
},
"feed": deque(maxlen=60),
"minute": deque(maxlen=60), # (minute_epoch, rows, bytes)
"series": deque(maxlen=48), # per-cycle points for the realtime chart
}
# Accumulated federated business matrix, built from the rows we actually offload.
_business: dict[str, Any] = {
"orders": {"count": 0, "revenue": 0.0, "by_region": {}, "by_status": {}, "by_channel": {}, "by_currency": {}},
"hr": {"count": 0, "by_department": {}, "by_event": {}, "by_region": {}},
"supply": {"count": 0, "amount": 0.0, "by_type": {}, "by_region": {}, "by_source": {}},
"telemetry": {"count": 0, "by_metric": {}},
"ts": deque(maxlen=48), # {t, orders, revenue, rows}
}
# ── helpers ───────────────────────────────────────────────────────────────────
def _now() -> datetime:
return datetime.now(timezone.utc)
def _bump(d: dict, k: Any, n: int = 1, val: float | None = None) -> None:
if k is None or k == "":
return
k = str(k)
if val is None:
d[k] = d.get(k, 0) + n
else:
cur = d.get(k) or {"count": 0, "value": 0.0}
cur["count"] += n
cur["value"] += val
d[k] = cur
def _feed(text: str, level: str = "info") -> None:
_state["feed"].appendleft({"ts": _now().isoformat(), "text": text, "level": level})
try:
from main import add_feed
add_feed("etl-guardian", f"[lakehouse] {text}", level)
except Exception:
pass
def _term(text: str, level: str = "info", phase: str = "offload") -> None:
"""Stream a line to the ETL Guardian terminal from this background thread."""
try:
from agent_terminal import emit_threadsafe
emit_threadsafe("etl-guardian", text, level=level, phase=phase)
except Exception:
pass
def _scalar(v: Any) -> Any:
import datetime as _dt
import decimal
if v is None or isinstance(v, (str, int, float, bool)):
return v
if isinstance(v, decimal.Decimal):
return float(v)
if isinstance(v, (_dt.datetime, _dt.date)):
return v.isoformat()
if isinstance(v, (dict, list)):
return json.dumps(v, default=str)
return str(v)
def _normalize(rows: list[dict]) -> list[dict]:
keys: list[str] = []
seen: set[str] = set()
for r in rows:
for k in r.keys():
if k not in seen:
seen.add(k)
keys.append(k)
return [{k: _scalar(r.get(k)) for k in keys} for r in rows]
def _write_parquet(dataset_key: str, rows: list[dict]) -> tuple[str, int]:
import pyarrow as pa
import pyarrow.parquet as pq
table = pa.Table.from_pylist(_normalize(rows))
buf = io.BytesIO()
pq.write_table(table, buf, compression="snappy")
body = buf.getvalue()
now = _now()
key = (f"lake/{dataset_key}/dt={now.strftime('%Y-%m-%d')}/"
f"part-{int(now.timestamp() * 1000)}-{random.randint(1000, 9999)}.parquet")
from storage_s3 import put_object_bytes
put_object_bytes(key, body, "application/vnd.apache.parquet")
return key, len(body)
# ── source readers: return (rows, new_cursor, done) ─────────────────────────────
def _read_orders(cursor, n):
import psycopg2
import psycopg2.extras
import sql_console as s
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=8)
try:
conn.autocommit = True
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
wm = int(cursor or 0)
cur.execute("SELECT * FROM public.sales_orders WHERE order_id > %s ORDER BY order_id LIMIT %s", (wm, n))
rows = [dict(r) for r in cur.fetchall()]
new_cursor = rows[-1].get("order_id", wm) if rows else wm
return rows, new_cursor, len(rows) < n
finally:
conn.close()
def _mysql_keycol(cur) -> tuple[str, str]:
import sql_console as s
try:
cur.execute(
"SELECT column_name FROM information_schema.columns WHERE table_schema=%s AND table_name='employee_events' "
"AND extra LIKE '%%auto_increment%%' LIMIT 1", (s.MYSQL_DB,))
r = cur.fetchone()
if r:
return (list(r.values())[0] if isinstance(r, dict) else r[0]), "key"
cur.execute(
"SELECT column_name FROM information_schema.key_column_usage WHERE table_schema=%s "
"AND table_name='employee_events' AND constraint_name='PRIMARY' ORDER BY ordinal_position LIMIT 1",
(s.MYSQL_DB,))
r = cur.fetchone()
if r:
return (list(r.values())[0] if isinstance(r, dict) else r[0]), "key"
except Exception:
pass
return "", "offset"
def _read_hr(cursor, n, st):
import pymysql
import sql_console as s
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=8, cursorclass=pymysql.cursors.DictCursor)
try:
cur = conn.cursor()
if not st.get("keycol") and st.get("mode") is None:
kc, mode = _mysql_keycol(cur)
st["keycol"] = kc
st["mode"] = mode
if st.get("mode") == "key" and st.get("keycol"):
kc = st["keycol"]
wm = int(cursor or 0)
cur.execute(f"SELECT * FROM employee_events WHERE `{kc}` > %s ORDER BY `{kc}` LIMIT %s", (wm, n))
rows = list(cur.fetchall())
new_cursor = rows[-1].get(kc, wm) if rows else wm
else: # offset fallback
off = int(cursor or 0)
cur.execute("SELECT * FROM employee_events LIMIT %s OFFSET %s", (n, off))
rows = list(cur.fetchall())
new_cursor = off + len(rows)
return rows, new_cursor, len(rows) < n
finally:
conn.close()
def _read_supply(cursor, n):
from bson import ObjectId
import sql_console as s
cli = s._mongo_client()
try:
col = cli[s.MONGO_DB]["events"]
q = {"_id": {"$gt": ObjectId(cursor)}} if cursor else {}
docs = list(col.find(q).sort("_id", 1).limit(n))
new_cursor = str(docs[-1]["_id"]) if docs else cursor
for d in docs:
d["_id"] = str(d["_id"])
return docs, new_cursor, len(docs) < n
finally:
cli.close()
def _read_telemetry(cursor, n):
from cassandra.query import SimpleStatement
import sql_console as s
cluster = s._cass_cluster()
try:
sess = cluster.connect()
stmt = SimpleStatement(f"SELECT * FROM {s.CASS_KS}.device_metrics", fetch_size=n)
kwargs = {}
if cursor:
try:
kwargs["paging_state"] = bytes.fromhex(cursor)
except Exception:
kwargs = {}
rs = sess.execute(stmt, **kwargs)
rows = [dict(r._asdict()) for r in rs.current_rows]
ps = rs.paging_state
new_cursor = ps.hex() if ps else None
return rows, new_cursor, new_cursor is None
finally:
cluster.shutdown()
# ── business matrix accumulation ────────────────────────────────────────────────
def _num(v) -> float:
try:
return float(v)
except Exception:
return 0.0
def _agg(dataset_key: str, rows: list[dict]) -> tuple[int, float]:
"""Fold a freshly-offloaded chunk into the live business matrix. Returns
(order_rows, revenue) for the realtime time-series."""
o_rows = 0
o_rev = 0.0
with _lock:
if dataset_key == "orders":
b = _business["orders"]
for r in rows:
amt = _num(r.get("amount") if r.get("amount") is not None else r.get("total_amount"))
b["count"] += 1
b["revenue"] += amt
_bump(b["by_region"], r.get("region"), val=amt)
_bump(b["by_status"], r.get("order_status") or r.get("status"))
_bump(b["by_channel"], r.get("sales_channel") or r.get("channel"), val=amt)
_bump(b["by_currency"], r.get("currency"))
o_rows += 1
o_rev += amt
elif dataset_key == "hr_events":
b = _business["hr"]
for r in rows:
b["count"] += 1
_bump(b["by_department"], r.get("department"))
_bump(b["by_event"], r.get("event_type") or r.get("event"))
_bump(b["by_region"], r.get("region"))
elif dataset_key == "supply_events":
b = _business["supply"]
for r in rows:
amt = _num(r.get("amount"))
b["count"] += 1
b["amount"] += amt
_bump(b["by_type"], r.get("type"), val=amt)
_bump(b["by_region"], r.get("region"))
_bump(b["by_source"], r.get("source"))
elif dataset_key == "telemetry":
b = _business["telemetry"]
for r in rows:
b["count"] += 1
mt = r.get("metric_type") or r.get("metric_name")
if mt:
cur = b["by_metric"].get(str(mt)) or {"count": 0, "sum": 0.0}
cur["count"] += 1
cur["sum"] += _num(r.get("metric_value"))
b["by_metric"][str(mt)] = cur
return o_rows, o_rev
_READERS = {"orders": _read_orders, "hr_events": _read_hr,
"supply_events": _read_supply, "telemetry": _read_telemetry}
def _offload_dataset(key: str) -> dict[str, Any]:
st = _state["datasets"][key]
chunk = int(_state["chunk"])
res = {"rows": 0, "bytes": 0, "order_rows": 0, "revenue": 0.0}
cur_txt = st["cursor"] if st.get("cursor") not in (None, "") else "<start>"
_term(f"$ read {st.get('engine', key)} · {st.get('label', key)} [cursor={cur_txt} · LIMIT {chunk}]",
level="cmd", phase="extract")
try:
if key == "hr_events":
rows, new_cursor, done = _read_hr(st["cursor"], chunk, st)
else:
rows, new_cursor, done = _READERS[key](st["cursor"], chunk)
except Exception as exc:
st["error"] = str(exc)[:160]
_term(f" ✗ extract failed: {str(exc)[:140]}", level="err", phase="extract")
return res
st["error"] = None
if not rows:
if done:
st["backfilled"] = True
if key == "telemetry": # no global order — loop back to keep tailing
st["cursor"] = None
return res
try:
obj_key, nbytes = _write_parquet(key, rows)
except Exception as exc:
st["error"] = f"parquet/s3: {str(exc)[:140]}"
_term(f" ✗ parquet/s3 write failed: {str(exc)[:140]}", level="err", phase="load")
return res
_term(f" ← pyarrow.write_table → s3://data/{obj_key} ({len(rows)} rows · {nbytes / 1024:.1f} KB)",
level="ok", phase="load")
o_rows, o_rev = _agg(key, rows)
with _lock:
st["parts"] += 1
st["rows"] += len(rows)
st["bytes"] += nbytes
st["cursor"] = new_cursor
st["backfilled"] = bool(done)
st["last_ts"] = _now().isoformat()
st["last_rows"] = len(rows)
st["last_key"] = obj_key
if key == "telemetry" and done:
st["cursor"] = None
res.update({"rows": len(rows), "bytes": nbytes, "order_rows": o_rows, "revenue": o_rev})
return res
def _source_totals() -> None:
"""Best-effort backfill targets so the dashboard can show progress %."""
import sql_console as s
d = _state["datasets"]
try:
if d["orders"]["total_source"] is None:
d["orders"]["total_source"] = s._table_row_count("postgres", "public.sales_orders")
except Exception:
pass
try:
if d["hr_events"]["total_source"] is None:
d["hr_events"]["total_source"] = s._table_row_count("mysql", "hr.employee_events")
except Exception:
pass
try:
if d["supply_events"]["total_source"] is None:
d["supply_events"]["total_source"] = s._table_row_count("mongodb", "supplychain.events")
except Exception:
pass
def run_cycle() -> dict[str, Any]:
if _state["running_cycle"]:
return {"ok": True, "skipped": "cycle already running"}
_state["running_cycle"] = True
total = 0
cbytes = 0
order_rows = 0
revenue = 0.0
try:
_term(f"═══ ETL offload cycle {_state['cycles'] + 1} — source DBs → S3 Parquet lake ═══",
level="info", phase="cycle")
_source_totals()
for d in DATASETS:
if not _state["enabled"]:
break
r = _offload_dataset(d["key"])
total += r["rows"]
cbytes += r["bytes"]
order_rows += r["order_rows"]
revenue += r["revenue"]
now = time.time()
_state["cycles"] += 1
_state["last_cycle_ts"] = now
_state["last_cycle_rows"] = total
_state["next_run_ts"] = now + float(_state["interval_s"])
if total:
minute = int(now // 60) * 60
if _state["minute"] and _state["minute"][-1][0] == minute:
m, r0, b0 = _state["minute"][-1]
_state["minute"][-1] = (minute, r0 + total, b0 + cbytes)
else:
_state["minute"].append((minute, total, cbytes))
point = {"t": _now().strftime("%H:%M:%S"), "rows": total, "bytes": cbytes,
"orders": order_rows, "revenue": round(revenue, 2)}
_state["series"].append(point)
with _lock:
_business["ts"].append(point)
_term(f"═══ cycle {_state['cycles']} done — {total:,} rows · {cbytes / 1024:.1f} KB · €{int(revenue):,} revenue ═══",
level="ok", phase="cycle")
_feed(f"offloaded {total:,} rows → S3 Parquet · €{int(revenue):,} (cycle {_state['cycles']})")
else:
_term(f" cycle {_state['cycles']} — no new rows (all datasets caught up, tailing)",
level="info", phase="cycle")
finally:
_state["running_cycle"] = False
return {"ok": True, "rows": total, "bytes": cbytes, "revenue": round(revenue, 2)}
def _loop() -> None:
_state["started_at"] = _now().isoformat()
time.sleep(12) # let the API + sources settle
while True:
try:
if _state["enabled"]:
run_cycle()
except Exception as exc:
try:
_feed(f"cycle error: {str(exc)[:160]}", level="err")
except Exception:
pass
time.sleep(max(10.0, float(_state["interval_s"])))
threading.Thread(target=_loop, daemon=True, name="etl-offload").start()
# ── views ───────────────────────────────────────────────────────────────────────
def _top(d: dict, n: int = 12, value: bool = False) -> list[dict]:
if value:
items = sorted(d.items(), key=lambda kv: -(kv[1].get("value", 0) if isinstance(kv[1], dict) else kv[1]))
return [{"key": k, "count": v.get("count", 0), "value": round(v.get("value", 0.0), 2)} for k, v in items[:n]]
items = sorted(d.items(), key=lambda kv: -kv[1])
return [{"key": k, "count": v} for k, v in items[:n]]
def status_view() -> dict[str, Any]:
with _lock:
datasets = []
tot_parts = tot_rows = tot_bytes = 0
for d in DATASETS:
st = _state["datasets"][d["key"]]
total_src = st.get("total_source")
pct = None
if total_src and total_src > 0:
pct = min(100.0, round(100.0 * st["rows"] / total_src, 1))
tot_parts += st["parts"]
tot_rows += st["rows"]
tot_bytes += st["bytes"]
datasets.append({"key": d["key"], **{k: st[k] for k in (
"label", "engine", "color", "parts", "rows", "bytes", "backfilled",
"total_source", "last_ts", "last_rows", "last_key", "error")},
"progress_pct": pct})
now_min = int(time.time() // 60) * 60
rate = 0
for m, r, _b in _state["minute"]:
if m >= now_min - 60:
rate += r
return {
"ok": True,
"enabled": _state["enabled"],
"interval_s": _state["interval_s"],
"chunk": _state["chunk"],
"running_cycle": _state["running_cycle"],
"cycles": _state["cycles"],
"started_at": _state["started_at"],
"last_cycle_ts": _state["last_cycle_ts"],
"last_cycle_rows": _state["last_cycle_rows"],
"next_run_ts": _state["next_run_ts"],
"totals": {"parts": tot_parts, "rows": tot_rows, "bytes": tot_bytes},
"rate_rows_per_min": rate,
"datasets": datasets,
"series": list(_state["series"]),
"feed": list(_state["feed"])[:24],
}
def business_view() -> dict[str, Any]:
with _lock:
o = _business["orders"]
hr = _business["hr"]
sup = _business["supply"]
tel = _business["telemetry"]
tel_metrics = sorted(
({"key": k, "count": v["count"], "avg": round(v["sum"] / v["count"], 2) if v["count"] else 0}
for k, v in tel["by_metric"].items()), key=lambda x: -x["count"])[:12]
# combine into a region matrix across datasets
regions: dict[str, dict] = {}
for k, v in o["by_region"].items():
regions.setdefault(k, {})["orders"] = v.get("count", 0)
regions[k]["revenue"] = round(v.get("value", 0.0), 2)
for k, v in hr["by_region"].items():
regions.setdefault(k, {})["hr_events"] = v
for k, v in sup["by_region"].items():
regions.setdefault(k, {})["supply_events"] = v
region_matrix = sorted(
({"region": k, "orders": v.get("orders", 0), "revenue": v.get("revenue", 0),
"hr_events": v.get("hr_events", 0), "supply_events": v.get("supply_events", 0)}
for k, v in regions.items() if k), key=lambda r: -(r["revenue"] or 0))
return {
"ok": True,
"generated_at": _now().isoformat(),
"kpis": {
"orders": o["count"], "revenue": round(o["revenue"], 2),
"avg_order": round(o["revenue"] / o["count"], 2) if o["count"] else 0,
"hr_events": hr["count"], "supply_events": sup["count"],
"supply_amount": round(sup["amount"], 2), "telemetry": tel["count"],
"rows_total": o["count"] + hr["count"] + sup["count"] + tel["count"],
},
"orders_by_region": _top(o["by_region"], 12, value=True),
"orders_by_status": _top(o["by_status"], 8),
"orders_by_channel": _top(o["by_channel"], 8, value=True),
"orders_by_currency": _top(o["by_currency"], 6),
"hr_by_department": _top(hr["by_department"], 10),
"hr_by_event": _top(hr["by_event"], 8),
"supply_by_type": _top(sup["by_type"], 10, value=True),
"telemetry_by_metric": tel_metrics,
"region_matrix": region_matrix,
"ts": list(_business["ts"]),
}
@router.get("/status")
async def get_status() -> JSONResponse:
return JSONResponse(status_view())
@router.get("/business")
async def get_business() -> JSONResponse:
return JSONResponse(business_view())
@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, "status": status_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(10.0, min(900.0, float(body["interval_s"])))
except Exception:
pass
if "chunk" in body:
try:
_state["chunk"] = max(200, min(50000, int(body["chunk"])))
except Exception:
pass
_feed(f"config updated · interval={_state['interval_s']}s · chunk={_state['chunk']} · enabled={_state['enabled']}")
return JSONResponse({"ok": True, "enabled": _state["enabled"],
"interval_s": _state["interval_s"], "chunk": _state["chunk"]})
+304
View File
@@ -0,0 +1,304 @@
"""Runtime GPU / LLM endpoint selection with DB override above env defaults."""
from __future__ import annotations
import os
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
import httpx
from sqlalchemy import Column, DateTime, String, Text, select
from sqlalchemy.orm import DeclarativeBase
from db import SessionLocal, engine
GPU_UI_PORT = int(os.getenv("GPU_UI_PORT", "9000"))
LLM_PORT = int(os.getenv("LLM_PORT", "8001"))
LLM_PATH = os.getenv("LLM_PATH", "/v1")
ENV_GPU_URL = os.getenv("GPU_URL", "http://10.0.10.106:9000")
ENV_GPU_UI_URL = os.getenv("GPU_UI_URL", ENV_GPU_URL)
ENV_LLM_URL = os.getenv("LLM_URL", "http://10.0.10.106:8001/v1")
class _Base(DeclarativeBase):
pass
class SystemSetting(_Base):
__tablename__ = "system_settings"
key = Column(String(64), primary_key=True)
value = Column(Text, nullable=False, default="")
updated_at = Column(DateTime(timezone=True), nullable=True)
GPU_PRESETS: list[dict[str, Any]] = [
{
"id": "gpu-prod",
"label": "atc-gpu-prod (VM306)",
"vm": "atc-gpu-prod",
"vmid": 306,
"host": "10.0.10.106",
"gpu_ui_port": 9000,
"llm_port": 8001,
"description": "4× V100 — shared production GPU lab",
},
{
"id": "gpu-dev",
"label": "atc-gpu-dev (VM303, legacy)",
"vm": "atc-gpu-dev",
"vmid": 303,
"host": "10.0.20.106",
"gpu_ui_port": 9000,
"llm_port": 8001,
"description": "Legacy dev VM — GPU passthrough removed",
},
{
"id": "gpu-bart",
"label": "atc-gpu-bart (VM301)",
"vm": "atc-gpu-bart",
"vmid": 301,
"host": "10.0.11.66",
"gpu_ui_port": 9000,
"llm_port": 8001,
"description": "Bart GPU VM — 2× V100",
},
]
def _ensure_table() -> None:
SystemSetting.metadata.create_all(engine, tables=[SystemSetting.__table__])
def _get_setting(key: str) -> str | None:
_ensure_table()
with SessionLocal() as db:
row = db.get(SystemSetting, key)
return row.value if row else None
def _set_settings(values: dict[str, str]) -> None:
_ensure_table()
now = datetime.now(timezone.utc)
with SessionLocal() as db:
for key, value in values.items():
row = db.get(SystemSetting, key)
if row:
row.value = value
row.updated_at = now
else:
db.add(SystemSetting(key=key, value=value, updated_at=now))
db.commit()
def _clear_settings(keys: list[str]) -> None:
_ensure_table()
with SessionLocal() as db:
for key in keys:
row = db.get(SystemSetting, key)
if row:
db.delete(row)
db.commit()
def _build_urls(host: str, gpu_ui_port: int, llm_port: int) -> dict[str, str]:
host = host.strip().replace("http://", "").replace("https://", "").split("/")[0]
if ":" in host:
base_host = host.split(":")[0]
else:
base_host = host
gpu_url = f"http://{base_host}:{gpu_ui_port}"
llm_url = f"http://{base_host}:{llm_port}{LLM_PATH}"
return {
"host": base_host,
"gpu_url": gpu_url,
"gpu_ui_url": gpu_url,
"llm_url": llm_url,
}
def _env_defaults() -> dict[str, Any]:
parsed = urlparse(ENV_GPU_URL)
host = parsed.hostname or "10.0.10.106"
return {
"source": "env",
"preset_id": "env",
"label": "Environment default",
**_build_urls(host, parsed.port or GPU_UI_PORT, LLM_PORT),
"env_gpu_url": ENV_GPU_URL,
"env_llm_url": ENV_LLM_URL,
}
def get_gpu_urls() -> dict[str, str]:
"""Effective GPU/LLM URLs — DB override wins over env."""
cfg = get_gpu_config()
return {
"gpu_url": cfg["gpu_url"],
"gpu_ui_url": cfg["gpu_ui_url"],
"llm_url": cfg["llm_url"],
"host": cfg["host"],
}
def resolve_gpu_identity(gpu: dict[str, Any] | None = None) -> dict[str, Any]:
"""Canonical GPU host/VM/URLs for topology, registry links, and presentation."""
urls = get_gpu_urls()
cfg = get_gpu_config()
g = gpu or {}
host = str(g.get("ip") or g.get("host") or urls["host"]).strip()
preset_id = g.get("preset_id") or cfg.get("preset_id")
preset = next((p for p in GPU_PRESETS if p["id"] == preset_id), None)
if preset is None:
preset = next((p for p in GPU_PRESETS if p["host"] == host), None)
if preset is None:
preset = GPU_PRESETS[0]
ui_url = str(g.get("ui_url") or urls["gpu_ui_url"])
llm_url = str(g.get("vllm_url") or urls["llm_url"])
return {
"host": host,
"ip": host,
"vm": preset.get("vm") or "atc-gpu-prod",
"vmid": preset.get("vmid") or 306,
"ui_url": ui_url,
"llm_url": llm_url,
"preset_id": preset.get("id") or preset_id or "gpu-prod",
"label": preset.get("label") or cfg.get("label") or preset.get("vm"),
}
def get_gpu_config() -> dict[str, Any]:
override_host = _get_setting("gpu_host")
if not override_host:
return _env_defaults()
preset_id = _get_setting("gpu_preset_id") or "custom"
gpu_ui_port = int(_get_setting("gpu_ui_port") or GPU_UI_PORT)
llm_port = int(_get_setting("llm_port") or LLM_PORT)
urls = _build_urls(override_host, gpu_ui_port, llm_port)
preset = next((p for p in GPU_PRESETS if p["id"] == preset_id), None)
return {
"source": "override",
"preset_id": preset_id,
"label": preset["label"] if preset else f"Custom ({override_host})",
**urls,
"env_gpu_url": ENV_GPU_URL,
"env_llm_url": ENV_LLM_URL,
"updated_at": _get_setting("gpu_updated_at"),
}
def get_gpu_config_payload() -> dict[str, Any]:
cfg = get_gpu_config()
return {
"active": cfg,
"presets": GPU_PRESETS,
"defaults": _env_defaults(),
}
def save_gpu_config(
*,
preset_id: str | None = None,
host: str | None = None,
gpu_ui_port: int | None = None,
llm_port: int | None = None,
) -> dict[str, Any]:
if preset_id and preset_id != "custom":
preset = next((p for p in GPU_PRESETS if p["id"] == preset_id), None)
if not preset:
raise ValueError(f"Unknown preset: {preset_id}")
host = preset["host"]
gpu_ui_port = preset.get("gpu_ui_port", GPU_UI_PORT)
llm_port = preset.get("llm_port", LLM_PORT)
if not host:
raise ValueError("host is required for custom GPU target")
gpu_ui_port = gpu_ui_port or GPU_UI_PORT
llm_port = llm_port or LLM_PORT
now = datetime.now(timezone.utc).isoformat()
_set_settings(
{
"gpu_host": host.strip(),
"gpu_ui_port": str(gpu_ui_port),
"llm_port": str(llm_port),
"gpu_preset_id": preset_id or "custom",
"gpu_updated_at": now,
}
)
return get_gpu_config()
def reset_gpu_config() -> dict[str, Any]:
_clear_settings(["gpu_host", "gpu_ui_port", "llm_port", "gpu_preset_id", "gpu_updated_at"])
return _env_defaults()
async def test_gpu_target(
host: str | None = None,
gpu_ui_port: int | None = None,
llm_port: int | None = None,
preset_id: str | None = None,
) -> dict[str, Any]:
if preset_id and preset_id != "custom":
preset = next((p for p in GPU_PRESETS if p["id"] == preset_id), None)
if preset:
host = preset["host"]
gpu_ui_port = preset.get("gpu_ui_port", GPU_UI_PORT)
llm_port = preset.get("llm_port", LLM_PORT)
if not host:
cfg = get_gpu_config()
host = cfg["host"]
gpu_ui_port = gpu_ui_port or GPU_UI_PORT
llm_port = llm_port or LLM_PORT
urls = _build_urls(host, gpu_ui_port or GPU_UI_PORT, llm_port or LLM_PORT)
result: dict[str, Any] = {
"ok": False,
"host": urls["host"],
"gpu_url": urls["gpu_url"],
"llm_url": urls["llm_url"],
"metrics_ok": False,
"llm_ok": False,
"gpu_count": 0,
"inference_active": False,
"active_model": None,
"errors": [],
}
async with httpx.AsyncClient(timeout=8.0) as client:
try:
mr = await client.get(f"{urls['gpu_url']}/api/gpu/metrics")
if mr.status_code == 200:
result["metrics_ok"] = True
gpus = mr.json().get("current", {}).get("gpus", [])
result["gpu_count"] = len(gpus)
else:
result["errors"].append(f"metrics HTTP {mr.status_code}")
except Exception as exc:
result["errors"].append(f"metrics: {exc}")
try:
model_r = await client.get(f"{urls['gpu_url']}/api/active-model")
if model_r.status_code == 200:
md = model_r.json()
result["inference_active"] = bool(md.get("inference_active"))
result["active_model"] = md.get("name")
except Exception as exc:
result["errors"].append(f"active-model: {exc}")
try:
lr = await client.get(f"{urls['llm_url']}/models")
if lr.status_code == 200:
result["llm_ok"] = True
else:
result["errors"].append(f"llm HTTP {lr.status_code}")
except Exception as exc:
result["errors"].append(f"llm: {exc}")
result["ok"] = result["metrics_ok"] and (
result["gpu_count"] > 0 or result["inference_active"] or result["llm_ok"]
)
return result
+76 -8
View File
@@ -17,6 +17,14 @@ from database_inventory import collect_database_inventory
from node_registry import NODE_REGISTRY from node_registry import NODE_REGISTRY
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082") DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
DOCKHAND_API_TOKEN = os.getenv("DOCKHAND_API_TOKEN", "")
def _dockhand_headers() -> dict[str, str]:
if DOCKHAND_API_TOKEN:
return {"Authorization": f"Bearer {DOCKHAND_API_TOKEN}"}
return {}
HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870") HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870")
LAKEHOUSE_HOST = os.getenv("LAKEHOUSE_HOST", "10.0.21.50") LAKEHOUSE_HOST = os.getenv("LAKEHOUSE_HOST", "10.0.21.50")
AIRFLOW_URL = os.getenv("AIRFLOW_URL", "http://10.0.21.55:8080") AIRFLOW_URL = os.getenv("AIRFLOW_URL", "http://10.0.21.55:8080")
@@ -24,9 +32,14 @@ KAFKA_UI_URL = os.getenv("KAFKA_UI_URL", "http://10.0.21.36:9000")
KAFKA_CONNECT_URL = os.getenv("KAFKA_CONNECT_URL", f"http://{LAKEHOUSE_HOST}:8083") KAFKA_CONNECT_URL = os.getenv("KAFKA_CONNECT_URL", f"http://{LAKEHOUSE_HOST}:8083")
TRINO_URL = os.getenv("TRINO_URL", f"http://{LAKEHOUSE_HOST}:8089") TRINO_URL = os.getenv("TRINO_URL", f"http://{LAKEHOUSE_HOST}:8089")
SPARK_UI_URL = os.getenv("SPARK_UI_URL", f"http://{LAKEHOUSE_HOST}:8080") SPARK_UI_URL = os.getenv("SPARK_UI_URL", f"http://{LAKEHOUSE_HOST}:8080")
GPU_URL = os.getenv("GPU_URL", "http://10.0.20.106:9000") try:
from gpu_config import get_gpu_urls as _get_gpu_urls
except Exception:
_get_gpu_urls = None # type: ignore
GPU_URL = os.getenv("GPU_URL", "http://10.0.10.106:9000")
OBJECTSCALE_URL = os.getenv("OBJECTSCALE_URL", "http://10.0.20.111:9020") OBJECTSCALE_URL = os.getenv("OBJECTSCALE_URL", "http://10.0.20.111:9020")
YARN_URL = os.getenv("YARN_URL", "http://10.0.21.61:8088") YARN_URL = os.getenv("YARN_URL", "http://10.0.21.62:8088")
AGENT_PRIMARY_DOMAIN = { AGENT_PRIMARY_DOMAIN = {
"infra-sentinel": "docker", "infra-sentinel": "docker",
@@ -110,7 +123,7 @@ async def dockhand_containers(
await _log(log, "cmd", "fetch", f"$ GET {url}") await _log(log, "cmd", "fetch", f"$ GET {url}")
t0 = time.monotonic() t0 = time.monotonic()
try: try:
r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": env_id}, timeout=8.0) r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": env_id}, headers=_dockhand_headers(), timeout=8.0)
ms = int((time.monotonic() - t0) * 1000) ms = int((time.monotonic() - t0) * 1000)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
@@ -407,11 +420,20 @@ async def collect_objectscale(client: httpx.AsyncClient, log: TerminalLogFn | No
async def collect_gpu_metrics(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]: async def collect_gpu_metrics(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]:
await _log(log, "info", "fetch", "▸ GPU Lab metrics") gpu_url = GPU_URL
base = {"ok": False, "host": GPU_URL, "ui_url": GPU_URL} host = GPU_URL
if _get_gpu_urls is not None:
try: try:
metrics_url = f"{GPU_URL}/api/gpu/metrics" u = _get_gpu_urls()
model_url = f"{GPU_URL}/api/active-model" gpu_url = u["gpu_url"]
host = u["host"]
except Exception:
pass
await _log(log, "info", "fetch", f"▸ GPU Lab metrics @ {host}")
base = {"ok": False, "host": host, "ui_url": gpu_url}
try:
metrics_url = f"{gpu_url}/api/gpu/metrics"
model_url = f"{gpu_url}/api/active-model"
await _log(log, "cmd", "fetch", f"$ GET {metrics_url}") await _log(log, "cmd", "fetch", f"$ GET {metrics_url}")
await _log(log, "cmd", "fetch", f"$ GET {model_url}") await _log(log, "cmd", "fetch", f"$ GET {model_url}")
t0 = time.monotonic() t0 = time.monotonic()
@@ -671,6 +693,21 @@ def collect_governance(log: TerminalLogFn | None = None) -> dict[str, Any]:
"mysql_hr.hr.employee_events -> iceberg.curated_masked.employee_events_masked (PII masked)", "mysql_hr.hr.employee_events -> iceberg.curated_masked.employee_events_masked (PII masked)",
"hdfs:/data/historical/sales_orders -> iceberg.hadoop.historical_sales_hdfs", "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 return out
@@ -700,6 +737,27 @@ def _section_governance(g: dict[str, Any]) -> list[str]:
lines.append(" Lineage:") lines.append(" Lineage:")
for ln in g.get("lineage") or []: for ln in g.get("lineage") or []:
lines.append(f" - {ln}") 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 return lines
@@ -750,11 +808,12 @@ async def collect_full_lab_context(
if gpu_data is None: if gpu_data is None:
gpu_data = await collect_gpu_metrics(client, log) gpu_data = await collect_gpu_metrics(client, log)
docker_raw, db_raw, lake_raw, cc_raw, hdfs, etl, objectscale = await asyncio.gather( docker_raw, db_raw, lake_raw, cc_raw, gpu_raw, hdfs, etl, objectscale = await asyncio.gather(
dockhand_containers(client, DOCKHAND_ENVS["docker01"], log), dockhand_containers(client, DOCKHAND_ENVS["docker01"], log),
dockhand_containers(client, DOCKHAND_ENVS["db02"], log), dockhand_containers(client, DOCKHAND_ENVS["db02"], log),
dockhand_containers(client, DOCKHAND_ENVS["lakehouse"], log), dockhand_containers(client, DOCKHAND_ENVS["lakehouse"], log),
dockhand_containers(client, DOCKHAND_ENV_COMMAND_CENTER, log), dockhand_containers(client, DOCKHAND_ENV_COMMAND_CENTER, log),
dockhand_containers(client, DOCKHAND_ENVS["gpu_dev"], log),
collect_hdfs(client, log), collect_hdfs(client, log),
collect_etl(client, log), collect_etl(client, log),
collect_objectscale(client, log), collect_objectscale(client, log),
@@ -771,6 +830,15 @@ async def collect_full_lab_context(
databases["inventory"] = {"error": str(exc)} databases["inventory"] = {"error": str(exc)}
lakehouse = await collect_lakehouse(client, lake_raw, log) lakehouse = await collect_lakehouse(client, lake_raw, log)
command_center = await collect_command_center(client, cc_raw, log) command_center = await collect_command_center(client, cc_raw, log)
if gpu_data is not None and gpu_raw:
gpu_running = sum(1 for c in gpu_raw if c.get("state") == "running")
gpu_data = {
**gpu_data,
"dockhand_env": DOCKHAND_ENVS["gpu_dev"],
"dockhand_containers": _container_rows(gpu_raw, GPU_URL.replace("http://", "").split(":")[0]),
"dockhand_running": gpu_running,
"dockhand_total": len(gpu_raw),
}
try: try:
governance = collect_governance(log) governance = collect_governance(log)
+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)
+367 -41
View File
@@ -15,6 +15,7 @@ import httpx
import redis.asyncio as aioredis import redis.asyncio as aioredis
from fastapi import Body, FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect from fastapi import Body, FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import auth as cockpit_auth
from agent_terminal import ( from agent_terminal import (
get_all_terminals, get_all_terminals,
get_terminal_lines, get_terminal_lines,
@@ -46,6 +47,8 @@ from hadoop_analytics import router as hadoop_router
from elasticsearch_api import router as elasticsearch_router from elasticsearch_api import router as elasticsearch_router
from sql_console import router as sql_router from sql_console import router as sql_router
from agent_ops import router as agent_ops_router, agent_dml_loop, etl_agent_loop, custodian_offload_loop from agent_ops import router as agent_ops_router, agent_dml_loop, etl_agent_loop, custodian_offload_loop
from agent_activity import agent_activity_loop
from streaming_ops import connector_autoheal_loop
from cdc_consumer import router as cdc_router, cdc_consumer_loop from cdc_consumer import router as cdc_router, cdc_consumer_loop
from movements import router as movements_router from movements import router as movements_router
from movements import MOVEMENT_BY_ID, trigger_and_watch from movements import MOVEMENT_BY_ID, trigger_and_watch
@@ -54,6 +57,12 @@ from streaming_ops import router as streaming_router
from spark_workbench import router as spark_workbench_router from spark_workbench import router as spark_workbench_router
from pii_catalog import router as pii_router from pii_catalog import router as pii_router
from trino_federated import router as federated_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 nessie_iceberg import router as nessie_router
from ssh_terminal import ssh_session from ssh_terminal import ssh_session
from node_registry import NODE_IDS, NODE_AGENT, NODE_REGISTRY, is_node_id 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 from node_ops import build_node_detail, probe_node, run_node_probe_task
@@ -70,6 +79,14 @@ from db import SessionLocal, db_health, init_database
from supervisor import mirror_terminal_line, mirror_to_supervisors from supervisor import mirror_terminal_line, mirror_to_supervisors
from workload import build_workload_payload from workload import build_workload_payload
from gpu_config import (
get_gpu_config,
get_gpu_config_payload,
get_gpu_urls,
reset_gpu_config,
save_gpu_config,
test_gpu_target,
)
_workload_cache: dict[str, Any] = {"ts": 0.0, "data": None} _workload_cache: dict[str, Any] = {"ts": 0.0, "data": None}
_presentation_cache: dict[str, Any] = {"ts": 0.0, "data": None} _presentation_cache: dict[str, Any] = {"ts": 0.0, "data": None}
@@ -82,12 +99,130 @@ from sqlalchemy.orm import DeclarativeBase
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082") DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
GPU_URL = os.getenv("GPU_URL", "http://10.0.20.106:9000") DOCKHAND_API_TOKEN = os.getenv("DOCKHAND_API_TOKEN", "")
def _dockhand_headers() -> dict[str, str]:
if DOCKHAND_API_TOKEN:
return {"Authorization": f"Bearer {DOCKHAND_API_TOKEN}"}
return {}
GPU_URL = os.getenv("GPU_URL", "http://10.0.10.106:9000")
GPU_UI_URL = os.getenv("GPU_UI_URL", GPU_URL) GPU_UI_URL = os.getenv("GPU_UI_URL", GPU_URL)
LLM_URL = os.getenv("LLM_URL", "http://10.0.20.106:8001/v1") LLM_URL = os.getenv("LLM_URL", "http://10.0.10.106:8001/v1")
LLM_MODEL = os.getenv("LLM_MODEL", "gpt-4o") LLM_MODEL = os.getenv("LLM_MODEL", "gpt-4o")
LLM_API_KEY = os.getenv("LLM_API_KEY", "sk-local") LLM_API_KEY = os.getenv("LLM_API_KEY", "sk-local")
LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "120")) LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "120"))
# Llama-3-70B GPTQ on V100 is capped at 4096; keep a hard safety budget.
LLM_MAX_MODEL_LEN = int(os.getenv("LLM_MAX_MODEL_LEN", "4096"))
# Conservative estimate: Llama tokenizers often use ~2.22.8 chars/token on English+lab text.
LLM_CHARS_PER_TOKEN = float(os.getenv("LLM_CHARS_PER_TOKEN", "2.4"))
LLM_CONTEXT_MARGIN = int(os.getenv("LLM_CONTEXT_MARGIN", "160"))
LLM_MAX_OUTPUT = int(os.getenv("LLM_MAX_OUTPUT", "256"))
LLM_MAX_CONTEXT_CHARS = int(os.getenv("LLM_MAX_CONTEXT_CHARS", "5500"))
def _estimate_tokens(text: str) -> int:
# Slightly inflate so we never underestimate vs vLLM's tokenizer.
return max(1, int(len(text) / LLM_CHARS_PER_TOKEN) + 32)
def _compact_lab_context(context: str) -> str:
"""Keep primary section + short per-domain summaries; drop verbose inventory lines."""
lines = context.splitlines()
out: list[str] = []
in_full = False
detail = 0
max_detail = 4
for line in lines:
if line.startswith("=== FULL LAB"):
in_full = True
out.append(line)
continue
if line.startswith("=== PRIMARY"):
in_full = False
detail = 0
out.append(line)
continue
if line.startswith("--- "):
detail = 0
out.append(line)
continue
# Drop long platform-capabilities essays if present — keep a one-liner marker
if line.startswith("=== PLATFORM CAPABILITIES"):
out.append(line)
out.append(" (see Command Center UI for full feature list)")
continue
if out and out[-1].startswith(" (see Command Center"):
if line.startswith("===") or line.startswith("--- ") or line.startswith("=== AGENTS") or line.startswith("=== DATA MASKING") or line.startswith("=== PII MASKING"):
pass
else:
continue
# Always keep masking / PII evidence sections in full (demo-critical)
if line.startswith("=== DATA MASKING") or line.startswith("=== PII MASKING"):
# flush remaining lines of this section without detail limits by marking
out.append(line)
continue
# Limit bullet detail in general lab dump, but keep masking evidence intact
keep_full = any(s in "\n".join(out[-5:]) for s in ("=== DATA MASKING", "=== PII MASKING"))
if (line.startswith(" - ") or line.startswith(" - ")) and not keep_full:
detail += 1
if detail > max_detail:
if detail == max_detail + 1:
out.append("")
continue
out.append(line)
return "\n".join(out)
def _truncate_for_llm(context: str, max_chars: int) -> str:
context = _compact_lab_context(context)
if len(context) <= max_chars:
return context
# Prefer keeping PRIMARY section; cut FULL LAB first
primary_end = context.find("=== FULL LAB")
if primary_end > 200:
head = context[:primary_end].rstrip()
tail_budget = max(400, max_chars - len(head) - 80)
tail = context[primary_end: primary_end + tail_budget]
trimmed = head + "\n" + tail
else:
trimmed = context[:max_chars]
if len(trimmed) > max_chars:
trimmed = trimmed[: max_chars - 60].rsplit("\n", 1)[0]
if len(context) > len(trimmed):
trimmed += f"\n\n[… truncated for {LLM_MAX_MODEL_LEN}-token model window …]"
return trimmed
def _fit_llm_payload(system_rules: str, context: str, user_message: str) -> tuple[str, int]:
"""Fit prompt+completion into the served model length with a safety margin."""
user_tok = _estimate_tokens(user_message)
rules_tok = _estimate_tokens(system_rules)
budget = LLM_MAX_MODEL_LEN - LLM_CONTEXT_MARGIN
max_out = min(LLM_MAX_OUTPUT, 256)
# Absolute char cap first (independent of estimate errors)
context = _truncate_for_llm(context, LLM_MAX_CONTEXT_CHARS)
for _ in range(6):
ctx_budget_tok = budget - user_tok - rules_tok - max_out
if ctx_budget_tok < 200:
max_out = max(64, max_out // 2)
continue
ctx_max_chars = max(600, int(ctx_budget_tok * LLM_CHARS_PER_TOKEN * 0.85))
fitted = _truncate_for_llm(context, ctx_max_chars)
total = rules_tok + _estimate_tokens(fitted) + user_tok + max_out
if total <= budget:
return fitted, max_out
# Still too big — shrink context harder, then output
context = fitted
LLM_MAX = max(800, int(len(fitted) * 0.7))
context = _truncate_for_llm(context, LLM_MAX)
max_out = max(64, max_out - 32)
return _truncate_for_llm(context, 800), 64
AGENTS = [ AGENTS = [
{ {
@@ -240,7 +375,11 @@ ZONES = [
] ]
INTENT_KEYWORDS: dict[str, list[str]] = { INTENT_KEYWORDS: dict[str, list[str]] = {
"data-custodian": ["database", "postgres", "postgresql", "mysql", "mongo", "mongodb", "cassandra", "neo4j", "sql", "db "], "data-custodian": [
"database", "postgres", "postgresql", "mysql", "mongo", "mongodb", "cassandra", "neo4j", "sql", "db ",
"pii", "mask", "masked", "masking", "email", "e-mail", "phone", "iban", "address", "customer",
"employee", "gdpr", "privacy", "sensitive", "personal", "name", "ssn", "national_id",
],
"lakehouse-ops": ["trino", "spark", "lakehouse", "iceberg", "query", "table"], "lakehouse-ops": ["trino", "spark", "lakehouse", "iceberg", "query", "table"],
"hadoop-ranger": [ "hadoop-ranger": [
"hadoop", "hdfs", "yarn", "datanode", "namenode", "replicatie", "replication", "hadoop", "hdfs", "yarn", "datanode", "namenode", "replicatie", "replication",
@@ -320,10 +459,24 @@ class ApprovalDecision(BaseModel):
def route_agent(message: str) -> str: def route_agent(message: str) -> str:
lower = message.lower() lower = message.lower()
# PII / privacy questions always go to Data Custodian (masking demo path)
pii_words = (
"pii", "mask", "masked", "masking", "email", "e-mail", "mail adres", "mail address",
"phone", "telefoon", "iban", "address", "adres", "customer name", "employee",
"gdpr", "privacy", "sensitive", "personal", "national_id", "ssn", "gevoelig",
)
if any(w in lower for w in pii_words):
return "data-custodian"
# Storage/data questions default to Hadoop unless clearly about databases # Storage/data questions default to Hadoop unless clearly about databases
if any(w in lower for w in ("data", "opslag", "gb", "replicatie", "replication", "hdfs", "hadoop")): if any(w in lower for w in ("opslag", "gb", "replicatie", "replication", "hdfs", "hadoop", "datanode", "namenode")):
if not any(w in lower for w in ("postgres", "mysql", "mongo", "database", "sql", "neo4j", "cassandra")): if not any(w in lower for w in ("postgres", "mysql", "mongo", "database", "sql", "neo4j", "cassandra", "pii", "email")):
return "hadoop-ranger" return "hadoop-ranger"
if any(w in lower for w in ("gpu", "vllm", "llm", "nvidia", "inference", "vram", "model")):
return "infra-sentinel"
if any(w in lower for w in ("kafka", "airflow", "debezium", "connector", "etl", "pipeline", "dag")):
return "etl-guardian"
if any(w in lower for w in ("trino", "spark", "iceberg", "lakehouse")):
return "lakehouse-ops"
scores = {aid: sum(1 for kw in kws if kw in lower) for aid, kws in INTENT_KEYWORDS.items()} scores = {aid: sum(1 for kw in kws if kw in lower) for aid, kws in INTENT_KEYWORDS.items()}
best = max(scores, key=scores.get) best = max(scores, key=scores.get)
if scores[best] == 0: if scores[best] == 0:
@@ -331,13 +484,34 @@ def route_agent(message: str) -> str:
return best return best
def _is_pii_question(message: str) -> bool:
lower = message.lower()
return any(w in lower for w in (
"pii", "mask", "masked", "masking", "unmask", "visible", "email", "e-mail", "phone",
"iban", "address", "adres", "customer", "employee", "privacy", "gdpr", "sensitive",
"personal", "gevoelig", "name", "telefoon", "mail", "data flow", "national_id",
"ssn", "bsn", "geboorte", "birth",
))
async def _pii_evidence_block(message: str, log: Any | None = None) -> str:
"""Live policy + samples synced with Data Flow masking toggles."""
try:
from pii_catalog import build_policy_evidence
return build_policy_evidence()
except Exception as exc:
return f"=== PII MASKING EVIDENCE ===\n(unavailable: {exc})"
async def gather_agent_context( async def gather_agent_context(
agent_id: str, agent_id: str,
status: dict[str, Any], status: dict[str, Any],
log: Any | None = None, log: Any | None = None,
message: str | None = None,
) -> str: ) -> str:
"""Full lab snapshot for vLLM — all domains, agent's primary domain highlighted.""" """Full lab snapshot for vLLM — all domains, agent's primary domain highlighted."""
snapshot = await collect_full_lab_context(gpu_data=status.get("gpu"), log=log) snapshot = await collect_full_lab_context(gpu_data=status.get("gpu"), log=log, include_inventory=False)
snapshot["domains_summary"] = status.get("domains", {}) snapshot["domains_summary"] = status.get("domains", {})
ctx = format_context_for_agent(agent_id, snapshot) ctx = format_context_for_agent(agent_id, snapshot)
agent_lines = ["", "=== AGENTS & SUPERVISORS ==="] agent_lines = ["", "=== AGENTS & SUPERVISORS ==="]
@@ -345,11 +519,25 @@ async def gather_agent_context(
sup = " [supervisor]" if a.get("supervisor") else "" sup = " [supervisor]" if a.get("supervisor") else ""
agent_lines.append(f" - {a['name']} ({a['id']}){sup}: {a['role']}") agent_lines.append(f" - {a['name']} ({a['id']}){sup}: {a['role']}")
ctx = ctx + "\n".join(agent_lines) ctx = ctx + "\n".join(agent_lines)
try:
from platform_context import build_masking_section
# Fresh policy so chat mirrors Data Flow toggles (skip huge business catalog).
ctx = ctx + "\n\n" + build_masking_section(fresh=True)
except Exception:
try: try:
from platform_context import build_llm_addendum from platform_context import build_llm_addendum
ctx = ctx + "\n\n" + build_llm_addendum() ctx = ctx + "\n\n" + build_llm_addendum()
except Exception: except Exception:
pass pass
if message and _is_pii_question(message):
try:
evidence = await _pii_evidence_block(message, log=log)
ctx = ctx + "\n\n" + evidence
if log:
await log("ok", "fetch", "▸ PII masking evidence attached (synced with Data Flow)")
except Exception as exc:
if log:
await log("warn", "fetch", f"▸ PII evidence skipped: {exc}")
if log: if log:
await log("info", "fetch", f"▸ Context assembled: {len(ctx)} chars for LLM") await log("info", "fetch", f"▸ Context assembled: {len(ctx)} chars for LLM")
return ctx return ctx
@@ -362,34 +550,44 @@ async def ask_llm(
log: Any | None = None, log: Any | None = None,
) -> str | None: ) -> str | None:
agent = next(a for a in AGENTS if a["id"] == agent_id) agent = next(a for a in AGENTS if a["id"] == agent_id)
system = f"""You are {agent['name']}, an autonomous ops agent in the Dell ATC data lab. # Deterministic PII path: always mirror Data Flow masked vs visible toggles.
Specialization: {agent['role']}. if _is_pii_question(message):
Motto: {agent.get('motto', '')}. try:
from pii_catalog import format_pii_chat_answer
You respond on behalf of your domain but have visibility into the FULL lab stack: Docker, databases, lakehouse (Trino/Spark/Kafka Connect), ETL (Airflow/Kafka), Hadoop HDFS, and GPU/vLLM. answer = format_pii_chat_answer(message)
Rules:
- Always respond in English.
- You have full visibility into the entire cluster: all VMs, zones, connectors, GPU, Hadoop, ObjectScale and Command Center.
- Use ONLY the live data below — do not invent hosts, ports, numbers or connector names.
- Use exact container/connector names from the data (e.g. mysql-hr-connector, not "Debezium").
- If something is DOWN or 0 GB, say so honestly.
- Respect the data masking policy: NEVER reveal, guess or reconstruct raw values of MASKED columns (they arrive as the token 🔒 MASKED). You MUST still answer helpfully — confirm the column is masked for privacy/governance, explain why, and you may use non-sensitive aggregates/counts over it.
- You are fully aware of all latest platform changes via the section PLATFORM CAPABILITIES & RECENT CHANGES below; use it to answer questions about recent changes, the Spark Workbench, the Hadoop pipeline, the Data Flow pulse switch and the autonomous agents (DML, ETL, Custodian Hadoop offload).
- Be concise and helpful (max ~10 sentences); bullet lists are fine when they aid clarity.
--- LIVE LAB DATA (primary domain first, then full stack) ---
{context}
"""
if log: if log:
await log("info", "llm", f"Querying vLLM model={LLM_MODEL}") await log("ok", "pii", "Returning Data Flowsynced masking answer (masked + visible)")
await log("cmd", "llm", f"$ POST {LLM_URL.rstrip('/')}/chat/completions") return answer
except Exception as exc:
if log:
await log("warn", "pii", f"▸ PII answer builder failed: {exc}")
rules = f"""You are {agent['name']} ({agent['role']}) in the Dell ATC data lab.
Answer in English, briefly (max ~8 sentences). Use ONLY the live data below — never invent hosts/ports/numbers.
If data is missing or DOWN, say so.
PII / masking rules (critical — synced with Data Flow tab):
- MASKED columns: NEVER reveal raw values; quote the token 🔒 MASKED when present.
- VISIBLE columns (operator opted out in Data Flow): you MAY report the real sample values and say they are visible by policy.
- Never invent emails, phones, names, IBANs, or addresses that are not in the live samples.
- If asked what is masked vs visible, list columns from the DATA MASKING POLICY / PII EVIDENCE sections.
--- LIVE LAB DATA ---"""
fitted_ctx, max_tokens = _fit_llm_payload(rules, context, message)
system = rules + "\n" + fitted_ctx
urls = get_gpu_urls()
llm_url = urls["llm_url"]
if log:
est = _estimate_tokens(system) + _estimate_tokens(message)
await log("info", "llm", f"▸ Querying vLLM model={LLM_MODEL} @ {urls['host']} (~{est}+{max_tokens} tok)")
if len(context) > len(fitted_ctx):
await log("warn", "llm", f" context trimmed {len(context)}{len(fitted_ctx)} chars")
await log("cmd", "llm", f"$ POST {llm_url.rstrip('/')}/chat/completions")
await log("info", "llm", f" user: {message[:160]}{'' if len(message) > 160 else ''}") await log("info", "llm", f" user: {message[:160]}{'' if len(message) > 160 else ''}")
try: try:
async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client: async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client:
t0 = time.monotonic() t0 = time.monotonic()
r = await client.post( r = await client.post(
f"{LLM_URL.rstrip('/')}/chat/completions", f"{llm_url.rstrip('/')}/chat/completions",
headers={ headers={
"Authorization": f"Bearer {LLM_API_KEY}", "Authorization": f"Bearer {LLM_API_KEY}",
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -400,7 +598,7 @@ Rules:
{"role": "system", "content": system}, {"role": "system", "content": system},
{"role": "user", "content": message}, {"role": "user", "content": message},
], ],
"max_tokens": 800, "max_tokens": max_tokens,
"temperature": 0.25, "temperature": 0.25,
}, },
) )
@@ -415,15 +613,78 @@ Rules:
return content return content
if log: if log:
await log("warn", "llm", f"← Empty or invalid LLM output ({ms}ms)") await log("warn", "llm", f"← Empty or invalid LLM output ({ms}ms)")
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:200] if exc.response is not None else str(exc)
if log:
await log("err", "llm", f"✗ vLLM HTTP {exc.response.status_code}: {detail}")
# One hard retry with a minimal context if we blew the window.
if exc.response is not None and exc.response.status_code == 400 and "maximum context length" in detail:
tiny = _truncate_for_llm(context, 1200)
system2 = rules + "\n" + tiny
max2 = 128
if log:
await log("warn", "llm", f" retry with tiny context ({len(tiny)} chars, max_tokens={max2})")
try:
async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client:
r2 = await client.post(
f"{llm_url.rstrip('/')}/chat/completions",
headers={
"Authorization": f"Bearer {LLM_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": LLM_MODEL,
"messages": [
{"role": "system", "content": system2},
{"role": "user", "content": message},
],
"max_tokens": max2,
"temperature": 0.25,
},
)
r2.raise_for_status()
content2 = r2.json()["choices"][0]["message"]["content"].strip()
if content2:
if log:
await log("ok", "llm", f"← vLLM retry OK {len(content2)} chars")
return content2
except Exception as exc2:
if log:
await log("err", "llm", f"✗ vLLM retry failed: {exc2}")
except Exception as exc: except Exception as exc:
if log: if log:
await log("err", "llm", f"✗ vLLM error: {exc}") await log("err", "llm", f"✗ vLLM error: {exc}")
return None return None
def fallback_answer(agent_id: str, context: str) -> str: def fallback_answer(agent_id: str, context: str, user_message: str = "") -> str:
agent_name = next(a["name"] for a in AGENTS if a["id"] == agent_id) agent_name = next(a["name"] for a in AGENTS if a["id"] == agent_id)
return f"**{agent_name}** (offline LLM — ruwe data):\n\n{context}" if user_message and _is_pii_question(user_message):
try:
from pii_catalog import format_pii_chat_answer
return f"**{agent_name}**\n\n" + format_pii_chat_answer(user_message)
except Exception:
marker = "=== PII MASKING EVIDENCE"
if marker in context:
return (
f"**{agent_name}** — masking policy (synced with Data Flow):\n\n"
+ context[context.index(marker):].strip()
)
preview_lines: list[str] = []
for line in context.splitlines():
if line.startswith(("=== PRIMARY", "Health summary", "ATC Lab", "--- ")):
preview_lines.append(line)
if len(preview_lines) >= 14:
break
hint = "\n".join(preview_lines) if preview_lines else "Lab snapshot collected; LLM unavailable."
q = f"\n\nYour question: _{user_message[:200]}_" if user_message else ""
return (
f"**{agent_name}** — I could not get a reply from the GPU LLM "
f"(context window or vLLM error).{q}\n\n"
"Try a short, specific question "
"(e.g. *How many GPUs are online?* or *Is Kafka healthy?*).\n\n"
f"Quick snapshot:\n{hint}"
)
async def publish_event(event: dict[str, Any]) -> None: async def publish_event(event: dict[str, Any]) -> None:
@@ -473,7 +734,11 @@ def add_feed(agent_id: str, message: str, level: str = "info") -> dict:
async def dockhand_env_containers(env_id: int) -> list[dict]: async def dockhand_env_containers(env_id: int) -> list[dict]:
try: try:
async with httpx.AsyncClient(timeout=8.0) as client: async with httpx.AsyncClient(timeout=8.0) as client:
r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": env_id}) r = await client.get(
f"{DOCKHAND_URL}/api/containers",
params={"env": env_id},
headers=_dockhand_headers(),
)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except Exception: except Exception:
@@ -490,15 +755,26 @@ async def probe_url(url: str) -> bool:
async def collect_gpu() -> dict[str, Any]: async def collect_gpu() -> dict[str, Any]:
host = GPU_URL.replace("http://", "").replace("https://", "").split("/")[0] urls = get_gpu_urls()
base = {"ok": False, "host": host, "ui_url": GPU_UI_URL} cfg = get_gpu_config()
gpu_url = urls["gpu_url"]
host = urls["host"]
base = {
"ok": False,
"host": host,
"ip": host,
"ui_url": urls["gpu_ui_url"],
"config_source": cfg.get("source", "env"),
"preset_id": cfg.get("preset_id"),
"config_label": cfg.get("label"),
}
try: try:
async with httpx.AsyncClient(timeout=6.0) as client: async with httpx.AsyncClient(timeout=6.0) as client:
metrics_r, model_r, integration_r = await asyncio.gather( metrics_r, model_r, integration_r = await asyncio.gather(
client.get(f"{GPU_URL}/api/gpu/metrics"), client.get(f"{gpu_url}/api/gpu/metrics"),
client.get(f"{GPU_URL}/api/active-model"), client.get(f"{gpu_url}/api/active-model"),
client.get(f"{GPU_URL}/api/integration"), client.get(f"{gpu_url}/api/integration"),
return_exceptions=True, return_exceptions=True,
) )
@@ -660,12 +936,12 @@ async def run_agent_task(agent_id: str, message: str, prompt_id: str) -> str:
await log("info", "fetch", f"[{prompt_id}] Collecting live lab metrics…") await log("info", "fetch", f"[{prompt_id}] Collecting live lab metrics…")
status = await collect_status() status = await collect_status()
context = await gather_agent_context(agent_id, status, log=log) context = await gather_agent_context(agent_id, status, log=log, message=message)
answer = await ask_llm(agent_id, message, context, log=log) answer = await ask_llm(agent_id, message, context, log=log)
if not answer: if not answer:
await log("warn", "llm", "LLM fallback — returning raw context") await log("warn", "llm", "LLM fallback — returning raw context")
answer = fallback_answer(agent_id, context) answer = fallback_answer(agent_id, context, message)
if not approval_created: if not approval_created:
proposed = detect_agent_proposed_action(answer, message) proposed = detect_agent_proposed_action(answer, message)
@@ -734,12 +1010,17 @@ async def lifespan(app: FastAPI):
cdc_task = asyncio.create_task(cdc_consumer_loop()) cdc_task = asyncio.create_task(cdc_consumer_loop())
etl_task = asyncio.create_task(etl_agent_loop()) etl_task = asyncio.create_task(etl_agent_loop())
cust_task = asyncio.create_task(custodian_offload_loop()) cust_task = asyncio.create_task(custodian_offload_loop())
act_task = asyncio.create_task(agent_activity_loop())
heal_task = asyncio.create_task(connector_autoheal_loop())
add_feed("infra-sentinel", "ATC Command Center API online", "info") add_feed("infra-sentinel", "ATC Command Center API online", "info")
yield yield
task.cancel() task.cancel()
dml_task.cancel() dml_task.cancel()
cdc_task.cancel() cdc_task.cancel()
etl_task.cancel() etl_task.cancel()
cust_task.cancel()
act_task.cancel()
heal_task.cancel()
if redis_client: if redis_client:
await redis_client.close() await redis_client.close()
@@ -759,6 +1040,12 @@ app.include_router(streaming_router)
app.include_router(spark_workbench_router) app.include_router(spark_workbench_router)
app.include_router(pii_router) app.include_router(pii_router)
app.include_router(federated_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.include_router(nessie_router)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
@@ -767,6 +1054,10 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Authentik OIDC session + API guard
cockpit_auth.init_auth_middleware(app)
cockpit_auth.setup_auth(app)
@app.get("/api/health") @app.get("/api/health")
async def health(): async def health():
@@ -966,6 +1257,41 @@ async def get_status():
async def get_gpu(): async def get_gpu():
return await collect_gpu() return await collect_gpu()
@app.get("/api/gpu/config")
async def get_gpu_config_endpoint():
return get_gpu_config_payload()
@app.post("/api/gpu/config")
async def post_gpu_config(body: dict[str, Any]):
try:
saved = save_gpu_config(
preset_id=body.get("preset_id"),
host=body.get("host"),
gpu_ui_port=body.get("gpu_ui_port"),
llm_port=body.get("llm_port"),
)
return {"ok": True, "active": saved, "presets": get_gpu_config_payload()["presets"]}
except ValueError as exc:
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400)
@app.post("/api/gpu/config/test")
async def post_gpu_config_test(body: dict[str, Any]):
return await test_gpu_target(
preset_id=body.get("preset_id"),
host=body.get("host"),
gpu_ui_port=body.get("gpu_ui_port"),
llm_port=body.get("llm_port"),
)
@app.delete("/api/gpu/config")
async def delete_gpu_config():
active = reset_gpu_config()
return {"ok": True, "active": active}
def agent_stats() -> dict[str, dict[str, Any]]: def agent_stats() -> dict[str, dict[str, Any]]:
stats: dict[str, dict[str, Any]] = {a["id"]: {"tasks": 0, "last_active": None, "alerts": 0} for a in AGENTS} stats: dict[str, dict[str, Any]] = {a["id"]: {"tasks": 0, "last_active": None, "alerts": 0} for a in AGENTS}
@@ -1036,11 +1362,11 @@ async def run_node_ask_task(node_id: str, message: str) -> None:
await terminal_log(node_id, f"→ Routing to agent {agent_id}", level="info", phase="ask") await terminal_log(node_id, f"→ Routing to agent {agent_id}", level="info", phase="ask")
log = make_logger(node_id) log = make_logger(node_id)
status = await collect_status() status = await collect_status()
context = await gather_agent_context(agent_id, status, log=log) context = await gather_agent_context(agent_id, status, log=log, message=message)
node_ctx = f"\n\n=== FOCUSED NODE: {meta['label']} ({meta['ip']}) ===\n{meta.get('description', '')}\n" node_ctx = f"\n\n=== FOCUSED NODE: {meta['label']} ({meta['ip']}) ===\n{meta.get('description', '')}\n"
answer = await ask_llm(agent_id, message, context + node_ctx, log=log) answer = await ask_llm(agent_id, message, context + node_ctx, log=log)
if not answer: if not answer:
answer = fallback_answer(agent_id, context) answer = fallback_answer(agent_id, context, message)
await terminal_log(node_id, f"{answer}", level="llm", phase="answer") await terminal_log(node_id, f"{answer}", level="llm", phase="answer")
await publish_event({"type": "node_ask_result", "node_id": node_id, "agent_id": agent_id, "answer": answer}) await publish_event({"type": "node_ask_result", "node_id": node_id, "agent_id": agent_id, "answer": answer})
+24 -2
View File
@@ -83,6 +83,15 @@ async def _publish(event: dict[str, Any]) -> None:
pass pass
async def _term(agent_id: str, text: str, level: str = "info", phase: str = "etl") -> None:
"""Stream a movement step to the owning agent's terminal."""
try:
from agent_terminal import terminal_log
await terminal_log(agent_id, text, level=level, phase=phase)
except Exception:
pass
async def _airflow_token(client: httpx.AsyncClient) -> str: async def _airflow_token(client: httpx.AsyncClient) -> str:
now = time.time() now = time.time()
if _token_cache["token"] and _token_cache["exp"] > now + 30: if _token_cache["token"] and _token_cache["exp"] > now + 30:
@@ -128,9 +137,12 @@ async def trigger_and_watch(mid: str, conf: dict[str, Any] | None = None, *, aut
mv = MOVEMENT_BY_ID.get(mid) mv = MOVEMENT_BY_ID.get(mid)
if not mv: if not mv:
return {"ok": False, "error": f"unknown movement {mid}"} return {"ok": False, "error": f"unknown movement {mid}"}
agent = mv["agent"]
if mv.get("api"): if mv.get("api"):
try: try:
payload = {**(mv.get("default_conf") or {}), **(conf or {})} payload = {**(mv.get("default_conf") or {}), **(conf or {})}
await _term(agent, f"$ POST {mv['api']} # {mv['label']}", level="cmd")
await _term(agent, f" payload={payload}", level="cmd")
t0 = time.time() t0 = time.time()
async with httpx.AsyncClient(timeout=120.0) as client: async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.post(f"http://127.0.0.1:8000{mv['api']}", json=payload) r = await client.post(f"http://127.0.0.1:8000{mv['api']}", json=payload)
@@ -145,21 +157,26 @@ async def trigger_and_watch(mid: str, conf: dict[str, Any] | None = None, *, aut
_last_runs[mid] = run _last_runs[mid] = run
await _publish({"type": "movement", **run}) await _publish({"type": "movement", **run})
lvl = "info" if state == "success" else "err" lvl = "info" if state == "success" else "err"
await _term(agent, f"{state} · {rows if rows is not None else '?'} rows in {dur}s", level="ok" if state == "success" else "err")
_feed(agent, f"[etl] {mv['label']}: {state} in {dur}s", lvl) _feed(agent, f"[etl] {mv['label']}: {state} in {dur}s", lvl)
return {"ok": state == "success", **run} return {"ok": state == "success", **run}
except Exception as exc: except Exception as exc:
_last_runs[mid] = {**_last_runs.get(mid, {}), "state": "failed", "error": str(exc)} _last_runs[mid] = {**_last_runs.get(mid, {}), "state": "failed", "error": str(exc)}
await _term(agent, f" ✗ error {str(exc)[:140]}", level="err")
_feed(agent, f"[etl] {mv['label']}: error {str(exc)[:120]}", "err") _feed(agent, f"[etl] {mv['label']}: error {str(exc)[:120]}", "err")
return {"ok": False, "error": str(exc)} return {"ok": False, "error": str(exc)}
conf = {**(mv.get("default_conf") or {}), **(conf or {})} conf = {**(mv.get("default_conf") or {}), **(conf or {})}
agent = mv["agent"]
count_sql = mv.get("count_sql") count_sql = mv.get("count_sql")
verb = "autonomously triggered" if autonomous else "triggered"
await _term(agent, f"$ airflow dags trigger {mv['dag_id']} # {mv['label']} ({verb})", level="cmd")
await _term(agent, f" conf={conf}", level="cmd")
before = await _trino_scalar(count_sql) if count_sql else None before = await _trino_scalar(count_sql) if count_sql else None
if count_sql:
await _term(agent, f" trino: {count_sql}{before if before is not None else '?'} rows (before)", level="info")
_last_runs[mid] = {**_last_runs.get(mid, {}), "state": "running", "started_at": datetime.now(timezone.utc).isoformat()} _last_runs[mid] = {**_last_runs.get(mid, {}), "state": "running", "started_at": datetime.now(timezone.utc).isoformat()}
await _publish({"type": "movement", "movement_id": mid, "state": "running"}) await _publish({"type": "movement", "movement_id": mid, "state": "running"})
verb = "autonomously triggered" if autonomous else "triggered"
_feed(agent, f"[etl] {mv['label']}: {verb} (conf={conf})", "info") _feed(agent, f"[etl] {mv['label']}: {verb} (conf={conf})", "info")
try: try:
@@ -182,8 +199,10 @@ async def trigger_and_watch(mid: str, conf: dict[str, Any] | None = None, *, aut
if state in ("success", "failed"): if state in ("success", "failed"):
break break
dur = round(time.time() - t0, 1) dur = round(time.time() - t0, 1)
await _term(agent, f" ← Airflow run {run_id} finished state={state} in {dur}s", level="ok" if state == "success" else "err")
except Exception as exc: except Exception as exc:
_last_runs[mid] = {**_last_runs[mid], "state": "failed", "error": str(exc)} _last_runs[mid] = {**_last_runs[mid], "state": "failed", "error": str(exc)}
await _term(agent, f" ✗ error {str(exc)[:140]}", level="err")
_feed(agent, f"[etl] {mv['label']}: error {str(exc)[:120]}", "err") _feed(agent, f"[etl] {mv['label']}: error {str(exc)[:120]}", "err")
return {"ok": False, "error": str(exc)} return {"ok": False, "error": str(exc)}
@@ -201,6 +220,9 @@ async def trigger_and_watch(mid: str, conf: dict[str, Any] | None = None, *, aut
await _publish({"type": "movement", **run}) await _publish({"type": "movement", **run})
rtxt = f"{rows} rows" if rows is not None else "data" rtxt = f"{rows} rows" if rows is not None else "data"
lvl = "info" if state == "success" else "err" lvl = "info" if state == "success" else "err"
if count_sql:
await _term(agent, f" trino: count {before if before is not None else '?'}{after if after is not None else '?'} ({rtxt})", level="info")
await _term(agent, f"{mv['label']}: {state}{rtxt} in {dur}s", level="ok" if state == "success" else "err")
_feed(agent, f"[etl] {mv['label']}: {state}{rtxt} in {dur}s", lvl) _feed(agent, f"[etl] {mv['label']}: {state}{rtxt} in {dur}s", lvl)
return {"ok": state == "success", **run} return {"ok": state == "success", **run}
+378
View File
@@ -0,0 +1,378 @@
"""Nessie catalog + Iceberg table structure for Command Center.
Nessie (http://lake01:19120) holds Git-like catalog commits. Trino's existing
`iceberg` catalog exposes $snapshots / $manifests / $files for structure.
Trino 405 cannot use iceberg.catalog.type=nessie, so we combine both.
"""
from __future__ import annotations
import os
import time
from typing import Annotated, Any
from urllib.parse import quote
import httpx
from fastapi import APIRouter, Body, Query
from fastapi.responses import JSONResponse
router = APIRouter(prefix="/api/nessie", tags=["nessie"])
NESSIE_URL = os.getenv("NESSIE_URL", "http://10.0.21.50:19120").rstrip("/")
NESSIE_UI_URL = os.getenv("NESSIE_UI_URL", NESSIE_URL)
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
TRINO_USER = os.getenv("TRINO_USER", "mo")
ICEBERG_CATALOG = os.getenv("ICEBERG_CATALOG", "iceberg")
S3_ENDPOINT = os.getenv("S3_ENDPOINT", "http://10.0.20.111:9020")
S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY", "")
S3_SECRET_KEY = os.getenv("S3_SECRET_KEY", "")
S3_BUCKET = os.getenv("S3_ARCHIVE_BUCKET", "data")
_cache: dict[str, Any] = {"ts": 0.0, "health": None}
_TTL = 8.0
async def _nessie(client: httpx.AsyncClient, path: str) -> Any:
r = await client.get(f"{NESSIE_URL}{path}", timeout=12.0)
r.raise_for_status()
return r.json()
async def _trino_rows(sql: str, deadline_s: float = 45.0) -> list[list[Any]]:
end = time.time() + deadline_s
rows: list[list[Any]] = []
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.post(
f"{TRINO_URL}/v1/statement",
content=sql.encode(),
headers={"X-Trino-User": TRINO_USER},
)
data = r.json()
rows.extend(data.get("data") or [])
nxt = data.get("nextUri")
while nxt:
if time.time() > end:
try:
await client.delete(nxt, timeout=3.0)
except Exception:
pass
break
rr = await client.get(nxt)
data = rr.json()
rows.extend(data.get("data") or [])
nxt = data.get("nextUri")
if data.get("stats", {}).get("state") in ("FAILED", "FINISHED"):
if data.get("error"):
raise RuntimeError(str(data["error"].get("message") or data["error"]))
if not nxt:
break
return rows
def _fqn(schema: str, table: str) -> str:
return f'{ICEBERG_CATALOG}.{schema}."{table}"'
def _meta_table(schema: str, table: str, kind: str) -> str:
# Trino Iceberg metadata tables: schema."table$snapshots"
return f'{ICEBERG_CATALOG}.{schema}."{table}${kind}"'
@router.get("/health")
async def health() -> JSONResponse:
now = time.time()
if _cache["health"] and now - _cache["ts"] < _TTL:
return JSONResponse(_cache["health"])
out: dict[str, Any] = {
"ok": False,
"nessie_url": NESSIE_URL,
"nessie_ui": NESSIE_UI_URL,
"trino_url": TRINO_URL,
"iceberg_catalog": ICEBERG_CATALOG,
"nessie_ok": False,
"trino_ok": False,
"default_branch": None,
"note": "Trino 405 has no native Nessie catalog; structure comes from iceberg.* metadata tables.",
}
try:
async with httpx.AsyncClient(timeout=8.0) as client:
cfg = await _nessie(client, "/api/v2/config")
out["nessie_ok"] = True
out["default_branch"] = cfg.get("defaultBranch", "main")
info = await client.get(f"{TRINO_URL}/v1/info")
out["trino_ok"] = info.status_code == 200
except Exception as exc:
out["error"] = str(exc)[:200]
out["ok"] = bool(out["nessie_ok"])
_cache["health"] = out
_cache["ts"] = now
return JSONResponse(out)
@router.get("/trees")
async def trees() -> JSONResponse:
try:
async with httpx.AsyncClient(timeout=12.0) as client:
data = await _nessie(client, "/api/v2/trees")
refs = []
for item in data.get("references") or data.get("tokens") or []:
# v2 may return {references: [...]} or paginated
ref = item.get("reference") if isinstance(item, dict) and "reference" in item else item
if not isinstance(ref, dict):
continue
refs.append({
"type": ref.get("type"),
"name": ref.get("name"),
"hash": ref.get("hash"),
})
if not refs:
# fallback: main only
async with httpx.AsyncClient(timeout=12.0) as client:
main = await _nessie(client, "/api/v2/trees/main")
ref = main.get("reference") or main
refs = [{"type": ref.get("type"), "name": ref.get("name"), "hash": ref.get("hash")}]
return JSONResponse({"ok": True, "references": refs, "ui": NESSIE_UI_URL})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)[:240], "references": []})
@router.get("/history")
async def history(ref: Annotated[str, Query()] = "main", max_records: Annotated[int, Query(ge=1, le=200)] = 50) -> JSONResponse:
try:
async with httpx.AsyncClient(timeout=15.0) as client:
data = await _nessie(client, f"/api/v2/trees/{quote(str(ref), safe='')}/history?maxRecords={max_records}")
entries = data.get("logEntries") or data.get("commits") or []
out = []
for e in entries:
meta = e.get("commitMeta") or e.get("commit") or {}
out.append({
"hash": e.get("hash") or e.get("commitHash") or meta.get("hash"),
"message": meta.get("message"),
"author": (meta.get("authors") or [meta.get("author")])[0] if (meta.get("authors") or meta.get("author")) else None,
"commitTime": meta.get("commitTime") or meta.get("authorTime"),
})
return JSONResponse({"ok": True, "ref": ref, "commits": out})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)[:240], "commits": []})
@router.get("/contents")
async def contents(ref: Annotated[str, Query()] = "main") -> JSONResponse:
try:
async with httpx.AsyncClient(timeout=15.0) as client:
data = await _nessie(client, f"/api/v2/trees/{quote(str(ref), safe='')}/entries")
tables = []
namespaces = []
for e in data.get("entries") or []:
name = e.get("name") or {}
elements = name.get("elements") or []
typ = e.get("type")
if typ == "NAMESPACE":
namespaces.append(".".join(elements))
elif typ == "ICEBERG_TABLE" and len(elements) >= 2:
tables.append({
"schema": elements[0],
"table": elements[1],
"key": ".".join(elements),
"contentId": e.get("contentId"),
"trino_fqn": _fqn(elements[0], elements[1]),
})
return JSONResponse({
"ok": True,
"ref": ref,
"namespaces": namespaces,
"tables": tables,
"ui": NESSIE_UI_URL,
})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)[:240], "tables": []})
@router.get("/tables/{schema}/{table}/structure")
async def table_structure(
schema: str,
table: str,
snapshot_id: Annotated[str | None, Query()] = None,
) -> JSONResponse:
"""Iceberg structure via Trino metadata tables (works with file metastore)."""
result: dict[str, Any] = {
"ok": False,
"schema": schema,
"table": table,
"fqn": _fqn(schema, table),
"snapshots": [],
"manifests": [],
"files": [],
"selected_snapshot": None,
"as_of_sql": None,
}
try:
snap_sql = (
f'SELECT snapshot_id, committed_at, operation, summary '
f'FROM {_meta_table(schema, table, "snapshots")} '
f'ORDER BY committed_at DESC LIMIT 40'
)
snaps = await _trino_rows(snap_sql)
snapshots = []
for row in snaps:
snapshots.append({
"snapshot_id": str(row[0]),
"committed_at": str(row[1]) if row[1] is not None else None,
"operation": row[2],
"summary": (str(row[3]) if row[3] is not None else None) if len(row) > 3 else None,
})
result["snapshots"] = snapshots
current = snapshot_id or (snapshots[0]["snapshot_id"] if snapshots else None)
result["selected_snapshot"] = current
if current:
result["as_of_sql"] = (
f'SELECT * FROM {_fqn(schema, table)} FOR VERSION AS OF {current} LIMIT 100'
)
# manifests
try:
man_sql = (
f'SELECT path, length, partition_spec_id, added_snapshot_id, '
f'added_data_files_count, existing_data_files_count, deleted_data_files_count '
f'FROM {_meta_table(schema, table, "manifests")} LIMIT 200'
)
mans = await _trino_rows(man_sql)
result["manifests"] = [
{
"path": r[0],
"length": r[1],
"partition_spec_id": r[2],
"added_snapshot_id": str(r[3]) if r[3] is not None else None,
"added_data_files_count": r[4],
"existing_data_files_count": r[5],
"deleted_data_files_count": r[6],
}
for r in mans
]
except Exception as exc:
result["manifests_error"] = str(exc)[:200]
# data files (current snapshot view)
try:
files_sql = (
f'SELECT file_path, file_format, record_count, file_size_in_bytes '
f'FROM {_meta_table(schema, table, "files")} LIMIT 500'
)
files = await _trino_rows(files_sql)
result["files"] = [
{
"path": r[0],
"format": r[1],
"record_count": r[2],
"size_bytes": r[3],
"is_s3": str(r[0]).startswith("s3") if r[0] else False,
}
for r in files
]
except Exception as exc:
result["files_error"] = str(exc)[:200]
result["ok"] = True
result["restore_warning"] = (
"Restore re-points table metadata to an older snapshot. "
"It cannot undelete files already removed from S3/disk."
)
return JSONResponse(result)
except Exception as exc:
result["error"] = str(exc)[:300]
return JSONResponse(result)
@router.post("/tables/{schema}/{table}/restore")
async def restore_snapshot(
schema: str,
table: str,
body: dict[str, Any] = Body(default={}),
) -> JSONResponse:
"""Rollback Iceberg table to a previous snapshot via Trino CALL (if supported) or documented SQL.
Trino 405 may not expose system.rollback; we attempt CALL and fall back to guidance.
"""
snapshot_id = str((body or {}).get("snapshot_id") or "").strip()
if not snapshot_id:
return JSONResponse({"ok": False, "error": "snapshot_id required"}, status_code=400)
fqn = _fqn(schema, table)
# Prefer Iceberg procedure when available
attempts = [
f"CALL {ICEBERG_CATALOG}.system.rollback_to_snapshot('{schema}', '{table}', {snapshot_id})",
f"ALTER TABLE {fqn} EXECUTE rollback_to_snapshot({snapshot_id})",
]
errors: list[str] = []
for sql in attempts:
try:
await _trino_rows(sql, deadline_s=60.0)
return JSONResponse({
"ok": True,
"schema": schema,
"table": table,
"snapshot_id": snapshot_id,
"method": sql,
"warning": "Old data files must still exist on storage for this to succeed.",
})
except Exception as exc:
errors.append(f"{sql}: {exc}")
return JSONResponse({
"ok": False,
"error": "Rollback procedure not available on this Trino version",
"hint": f'Query historically with: SELECT * FROM {fqn} FOR VERSION AS OF {snapshot_id}',
"as_of_sql": f'SELECT * FROM {fqn} FOR VERSION AS OF {snapshot_id} LIMIT 100',
"details": errors[:3],
})
@router.get("/s3/parquet")
async def list_s3_parquet(prefix: Annotated[str, Query()] = "lake/,iceberg/", limit: Annotated[int, Query(ge=1, le=1000)] = 200) -> JSONResponse:
"""List parquet (and orc) objects under lake/ and iceberg/ prefixes."""
if not S3_ACCESS_KEY or not S3_SECRET_KEY:
return JSONResponse({"ok": False, "error": "S3 credentials not configured", "objects": []})
try:
import boto3
from botocore.client import Config
s3 = boto3.client(
"s3",
endpoint_url=S3_ENDPOINT,
aws_access_key_id=S3_ACCESS_KEY,
aws_secret_access_key=S3_SECRET_KEY,
region_name=os.getenv("S3_REGION", "us-east-1"),
config=Config(s3={"addressing_style": "path"}, signature_version="s3v4"),
)
prefixes = [p.strip() for p in prefix.split(",") if p.strip()]
objects: list[dict[str, Any]] = []
for pref in prefixes:
token = None
while len(objects) < limit:
kw: dict[str, Any] = {"Bucket": S3_BUCKET, "Prefix": pref, "MaxKeys": min(500, limit - len(objects))}
if token:
kw["ContinuationToken"] = token
resp = s3.list_objects_v2(**kw)
for o in resp.get("Contents") or []:
key = o["Key"]
low = key.lower()
if not (low.endswith(".parquet") or low.endswith(".orc") or "/data/" in low):
continue
objects.append({
"bucket": S3_BUCKET,
"key": key,
"size": o.get("Size"),
"last_modified": o["LastModified"].isoformat() if o.get("LastModified") else None,
"format": "parquet" if low.endswith(".parquet") else ("orc" if low.endswith(".orc") else "data"),
})
if len(objects) >= limit:
break
if not resp.get("IsTruncated"):
break
token = resp.get("NextContinuationToken")
return JSONResponse({
"ok": True,
"bucket": S3_BUCKET,
"prefixes": prefixes,
"objects": objects,
"note": "Raw S3 objects — not all are Iceberg-managed; lake/ ETL parquet has no snapshot history until registered as Iceberg.",
})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)[:240], "objects": []})
+19
View File
@@ -153,6 +153,25 @@ def build_node_detail(node_id: str, snap: dict[str, Any], workload_node: dict |
wn = workload_node or {} wn = workload_node or {}
agent_id = NODE_AGENT.get(node_id, "infra-sentinel") agent_id = NODE_AGENT.get(node_id, "infra-sentinel")
if node_id == "gpu":
try:
from gpu_config import resolve_gpu_identity
gid = resolve_gpu_identity(snap.get("gpu") if isinstance(snap, dict) else None)
meta["ip"] = gid["ip"]
meta["vm"] = gid["vm"]
meta["vmid"] = gid["vmid"]
meta["ssh"] = f"ssh root@{gid['ip']}"
meta["links"] = [
{"label": "GPU Lab UI", "url": gid["ui_url"]},
{"label": "vLLM API", "url": gid["llm_url"]},
]
meta["endpoints"] = [
{"name": "gpu-lab", "host": gid["ip"], "port": "9000", "proto": "http"},
{"name": "vllm", "host": gid["ip"], "port": "8001", "proto": "http"},
]
except Exception:
pass
detail: dict[str, Any] = { detail: dict[str, Any] = {
"id": node_id, "id": node_id,
"agent_id": agent_id, "agent_id": agent_id,
+9 -9
View File
@@ -183,21 +183,21 @@ NODE_REGISTRY: dict[str, dict[str, Any]] = {
}, },
"gpu": { "gpu": {
"label": "GPU Lab", "label": "GPU Lab",
"vm": "atc-gpu-dev", "vm": "atc-gpu-prod",
"vmid": 303, "vmid": 306,
"pve": "atc-gpu", "pve": "atc-gpu",
"ip": "10.0.20.106", "ip": "10.0.10.106",
"ssh": "ssh root@10.0.20.106", "ssh": "ssh root@10.0.10.106",
"role": "inference", "role": "inference",
"color": "#3fb950", "color": "#3fb950",
"description": "4× V100 GPU lab. vLLM serves the active model (Llama 3 70B GPTQ) — powers agent reasoning in this Command Center.", "description": "4× V100 GPU lab (VM306). vLLM serves the active model (Llama 3 70B GPTQ) — powers agent reasoning in this Command Center.",
"links": [ "links": [
{"label": "GPU Lab UI", "url": "http://10.0.20.106:9000"}, {"label": "GPU Lab UI", "url": "http://10.0.10.106:9000"},
{"label": "vLLM API", "url": "http://10.0.20.106:8001/v1"}, {"label": "vLLM API", "url": "http://10.0.10.106:8001/v1"},
], ],
"endpoints": [ "endpoints": [
{"name": "gpu-lab", "host": "10.0.20.106", "port": "9000", "proto": "http"}, {"name": "gpu-lab", "host": "10.0.10.106", "port": "9000", "proto": "http"},
{"name": "vllm", "host": "10.0.20.106", "port": "8001", "proto": "http"}, {"name": "vllm", "host": "10.0.10.106", "port": "8001", "proto": "http"},
], ],
"commands": ["gpu metrics", "model status", "vram usage"], "commands": ["gpu metrics", "model status", "vram usage"],
}, },
+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"]})
+251 -4
View File
@@ -34,6 +34,7 @@ POLICY_PATH = Path(os.getenv("MASKING_POLICY_PATH", "/data/masking_policy.json")
DEFAULT_MASKED = True DEFAULT_MASKED = True
MASK_TOKEN = "🔒 MASKED (masking policy ON)" MASK_TOKEN = "🔒 MASKED (masking policy ON)"
_policy_cache: dict[str, bool] | None = None _policy_cache: dict[str, bool] | None = None
_policy_mtime: float = -1.0
# Datasets we surface in the PII overlay. node_id matches dataflow.py node ids. # Datasets we surface in the PII overlay. node_id matches dataflow.py node ids.
# om_fqn = OpenMetadata table FQN (service.database.schema.table) for tag lookup. # om_fqn = OpenMetadata table FQN (service.database.schema.table) for tag lookup.
@@ -77,21 +78,42 @@ DATASET_BY_KEY = {ds["key"]: ds for ds in DATASETS}
def _load_policy() -> dict[str, bool]: def _load_policy() -> dict[str, bool]:
global _policy_cache """Load policy from disk; re-read when the file mtime changes (Data Flow toggles)."""
if _policy_cache is None: global _policy_cache, _policy_mtime
try:
mtime = POLICY_PATH.stat().st_mtime
except Exception:
mtime = 0.0
if _policy_cache is None or mtime != _policy_mtime:
try: try:
_policy_cache = {k: bool(v) for k, v in json.loads(POLICY_PATH.read_text()).items()} _policy_cache = {k: bool(v) for k, v in json.loads(POLICY_PATH.read_text()).items()}
except Exception: except Exception:
if _policy_cache is None:
_policy_cache = {} _policy_cache = {}
_policy_mtime = mtime
return _policy_cache return _policy_cache
def _save_policy(p: dict[str, bool]) -> None: def _save_policy(p: dict[str, bool]) -> None:
global _policy_cache global _policy_cache, _policy_mtime
_policy_cache = p _policy_cache = p
try: try:
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True) POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
POLICY_PATH.write_text(json.dumps(p, indent=2)) POLICY_PATH.write_text(json.dumps(p, indent=2))
_policy_mtime = POLICY_PATH.stat().st_mtime
except Exception:
_policy_mtime = time.time()
def invalidate_pii_caches() -> None:
"""Force catalog rebuild so chat/Data Flow see the same mask flags immediately."""
_cache["data"] = None
_cache["ts"] = 0.0
try:
import trino_federated as tf
if hasattr(tf, "_dict_cache"):
tf._dict_cache["data"] = None
tf._dict_cache["at"] = 0.0
except Exception: except Exception:
pass pass
@@ -325,10 +347,235 @@ async def set_policy(body: MaskPolicyRequest) -> JSONResponse:
for col in cols: for col in cols:
p[f"{key}.{col}"] = bool(body.masked) p[f"{key}.{col}"] = bool(body.masked)
_save_policy(p) _save_policy(p)
_cache["data"] = None # force rebuild so masked flags reflect the new policy invalidate_pii_caches() # chat + Data Flow must reflect the toggle immediately
return JSONResponse({"ok": True, "key": key, "columns": cols, "masked": bool(body.masked)}) return JSONResponse({"ok": True, "key": key, "columns": cols, "masked": bool(body.masked)})
def policy_column_lists(*, use_cache: bool = False) -> dict[str, Any]:
"""Live masked vs visible PII columns — same source as the Data Flow tab."""
catalog = get_pii(use_cache=use_cache)
masked: list[dict[str, str]] = []
visible: list[dict[str, str]] = []
for d in catalog.get("datasets", []):
key = d.get("key") or ""
label = d.get("label") or key
locked = bool(d.get("masked_layer") or d.get("policy_locked"))
for c in d.get("pii_columns", []):
entry = {
"key": key,
"label": label,
"column": c.get("name") or "",
"category": c.get("category") or "PII",
"locked": locked,
}
(masked if c.get("masked") else visible).append(entry)
summ = catalog.get("summary") or {}
return {
"catalog": catalog,
"masked": masked,
"visible": visible,
"summary": summ,
"mask_token": MASK_TOKEN,
}
def _interest_categories(message: str) -> list[str]:
lower = (message or "").lower()
cat_map = [
(("email", "e-mail", "mail"), "EMAIL"),
(("phone", "telefoon", "mobile"), "PHONE"),
(("name", "naam"), "NAME"),
(("iban", "bank", "card"), "FINANCIAL"),
(("ssn", "bsn", "national", "passport"), "NATIONAL_ID"),
(("address", "adres"), "ADDRESS"),
(("birth", "dob", "geboorte"), "DOB"),
(("ip",), "IP"),
]
out: list[str] = []
for words, cat in cat_map:
if any(w in lower for w in words):
out.append(cat)
return out
def _sample_rows_for_chat(
*,
categories: list[str] | None = None,
max_datasets: int = 2,
rows_per: int = 2,
) -> list[dict[str, Any]]:
"""Fetch a few live rows; values already policy-masked."""
catalog = get_pii(use_cache=False)
prefer = ["mysql", "postgres", "mongodb", "cassandra", "neo4j", "curated"]
samples: list[dict[str, Any]] = []
for key in prefer:
dset = next((d for d in catalog.get("datasets", []) if d.get("key") == key), None)
if not dset or not dset.get("pii_columns"):
continue
ds = DATASET_BY_KEY.get(key)
if not ds:
continue
pii_cols = dset["pii_columns"]
if categories:
focus = [c for c in pii_cols if c.get("category") in categories]
# Always keep one id-like visible column for context when focusing
ids = [c for c in pii_cols if c.get("category") == "IDENTIFIER" and not c.get("masked")]
pick = (ids[:1] + focus) if focus else pii_cols
else:
pick = pii_cols
if not pick:
continue
# de-dupe preserving order
seen: set[str] = set()
select_cols: list[str] = []
masked_map: dict[str, bool] = {}
for c in pick:
name = c["name"]
if name in seen:
continue
seen.add(name)
select_cols.append(name)
masked_map[name] = bool(c.get("masked"))
if len(select_cols) >= 6:
break
name_col = next((c["name"] for c in pii_cols if c.get("category") == "NAME"), None)
try:
cols, rows = _lookup_rows(ds, select_cols, name_col, None, rows_per)
except Exception:
continue
if not rows:
continue
rendered = []
for row in rows[:rows_per]:
rendered.append({
cname: (MASK_TOKEN if masked_map.get(cname) else val)
for cname, val in zip(cols, row)
})
samples.append({
"key": key,
"label": dset.get("label", key),
"table": ds.get("table"),
"rows": rendered,
"masked_cols": [c for c, m in masked_map.items() if m],
"visible_cols": [c for c, m in masked_map.items() if not m],
})
if len(samples) >= max_datasets:
break
return samples
def build_policy_evidence(*, max_datasets: int = 2, rows_per: int = 2) -> str:
"""Compact internal evidence for LLM context (not shown raw to users)."""
snap = policy_column_lists(use_cache=False)
masked = snap["masked"]
visible = snap["visible"]
summ = snap["summary"]
lines = [
"=== PII POLICY (Data Flow synced) ===",
f"Masked {summ.get('masked_columns', len(masked))}/{summ.get('pii_columns', 0)} · "
f"visible {summ.get('unmasked_columns', len(visible))}. Token: {MASK_TOKEN}",
"Masked: " + ", ".join(f"{m['key']}.{m['column']}" for m in masked[:25]) + (
f" …(+{len(masked)-25})" if len(masked) > 25 else ""
),
"Visible: " + (", ".join(f"{v['key']}.{v['column']}" for v in visible[:25]) or "(none)"),
]
for s in _sample_rows_for_chat(max_datasets=max_datasets, rows_per=rows_per):
lines.append(f"Sample {s['label']}:")
for row in s["rows"]:
lines.append(" " + " | ".join(f"{k}={v}" for k, v in row.items()))
return "\n".join(lines)
def format_pii_chat_answer(message: str = "") -> str:
"""Short personal reply: masked → say masked; visible → show values. Never list field names."""
snap = policy_column_lists(use_cache=False)
masked = snap["masked"]
visible = snap["visible"]
lower = (message or "").lower()
interest = _interest_categories(message)
greeting = any(w in lower for w in ("hi", "hello", "hey", "hallo", "goedemorgen", "goedemiddag"))
hi = "Hi! " if greeting else ""
label = {
"EMAIL": "email",
"PHONE": "phone number",
"NAME": "name",
"FINANCIAL": "bank / IBAN details",
"NATIONAL_ID": "national ID",
"ADDRESS": "address",
"DOB": "date of birth",
"IP": "IP address",
}
# No specific PII type asked — keep it vague, never enumerate columns
if not interest:
if any(w in lower for w in ("mask", "pii", "sensitive", "privacy", "personal")):
return (
f"{hi}Personal data is protected by the masking policy. "
f"Ask for something specific (an email, a phone number, a name…) and I'll tell you "
f"whether I can share it — or only `{MASK_TOKEN}`."
)
return (
f"{hi}I can't share personal data that's masked. "
f"Ask me for an email, phone number, or name if you want to check."
)
topic = ", ".join(label.get(c, c.lower()) for c in interest)
interested_masked = [c for c in masked if c["category"] in interest]
interested_visible = [c for c in visible if c["category"] in interest]
# Collect visible sample *values* only (no column names in the reply)
values: list[str] = []
if interested_visible:
samples = _sample_rows_for_chat(categories=interest, max_datasets=2, rows_per=2)
for s in samples:
for row in s["rows"]:
for col in interested_visible:
if col["column"] in row:
val = row[col["column"]]
if val is None or val == "" or val == MASK_TOKEN:
continue
values.append(str(val))
# unique, preserve order
seen: set[str] = set()
uniq: list[str] = []
for v in values:
if v not in seen:
seen.add(v)
uniq.append(v)
values = uniq[:5]
# Fully masked for this ask
if interested_masked and not interested_visible:
return (
f"{hi}No — that {topic} is masked (`{MASK_TOKEN}`). "
"I can't share it."
)
# Fully visible
if interested_visible and not interested_masked:
if values:
listed = ", ".join(values)
return f"{hi}Sure — here's what I can share: {listed}."
return f"{hi}That {topic} isn't masked, but I don't have a sample value right now."
# Mixed: some sources masked, some visible — still don't name columns
if interested_visible and interested_masked:
if values:
listed = ", ".join(values)
return (
f"{hi}Some of that is masked (`{MASK_TOKEN}`); "
f"what I can share: {listed}."
)
return (
f"{hi}Some of that {topic} is masked (`{MASK_TOKEN}`). "
"I can't share the protected parts."
)
return f"{hi}I don't have that personal data available."
def _lookup_rows(ds: dict[str, Any], select: list[str], name_col: str | None, def _lookup_rows(ds: dict[str, Any], select: list[str], name_col: str | None,
search: str | None, limit: int) -> tuple[list[str], list[list[Any]]]: search: str | None, limit: int) -> tuple[list[str], list[list[Any]]]:
"""Fetch rows from the source. Native DB queries (fast, early LIMIT) for """Fetch rows from the source. Native DB queries (fast, early LIMIT) for
+17 -12
View File
@@ -90,15 +90,15 @@ def build_platform_section() -> str:
return "\n".join(lines) return "\n".join(lines)
def build_masking_section() -> str: def build_masking_section(fresh: bool = False) -> str:
"""Exact masking policy + strict guidance so the LLM can answer about masked """Exact masking policy + strict guidance so the LLM can answer about masked
data without ever revealing masked raw values.""" data without ever revealing masked raw values. Synced with Data Flow toggles."""
lines: list[str] = ["=== DATA MASKING POLICY (enforced) ==="] lines: list[str] = ["=== DATA MASKING POLICY (enforced — synced with Data Flow) ==="]
masked: list[str] = [] masked: list[str] = []
unmasked: list[str] = [] unmasked: list[str] = []
try: try:
from pii_catalog import get_pii # type: ignore from pii_catalog import get_pii # type: ignore
data = get_pii() data = get_pii(use_cache=not fresh)
for d in data.get("datasets", []): for d in data.get("datasets", []):
for c in d.get("pii_columns", []): for c in d.get("pii_columns", []):
tag = f"{d.get('label')}.{c.get('name')} [{c.get('category')}]" tag = f"{d.get('label')}.{c.get('name')} [{c.get('category')}]"
@@ -112,22 +112,27 @@ def build_masking_section() -> str:
lines.append(f"(masking catalog unavailable: {exc})") lines.append(f"(masking catalog unavailable: {exc})")
if masked: if masked:
lines.append("MASKED columns (raw values are withheld — token 🔒 MASKED):") lines.append("MASKED columns (raw values withheld — token 🔒 MASKED):")
for m in masked[:40]: for m in masked[:40]:
lines.append(f" - {m}") lines.append(f" - {m}")
else:
lines.append("MASKED columns: (none)")
if unmasked: if unmasked:
lines.append("Visible PII columns (operator opted out of masking):") lines.append("VISIBLE columns (operator opted out of masking in Data Flow — real values OK):")
for u in unmasked[:40]: for u in unmasked[:40]:
lines.append(f" - {u}") lines.append(f" - {u}")
else:
lines.append("VISIBLE columns: (none — all PII masked)")
lines += [ lines += [
"", "",
"How to handle masked data when answering:", "How to handle masked vs visible data when answering:",
" 1. NEVER reveal, guess, reconstruct or print the raw value of a MASKED column. If a value comes in as '🔒 MASKED', keep it masked.", " 1. MASKED: NEVER reveal, guess, or reconstruct raw values. Quote '🔒 MASKED' when present.",
" 2. DO still answer helpfully: confirm the column exists and is masked for privacy/governance, and explain why (PII protection policy).", " 2. VISIBLE: you MAY show the real sample values and state that the operator made them visible in Data Flow.",
" 3. You MAY use and report non-sensitive aggregates, counts, distributions and derived metrics over masked columns (e.g. 'there are N distinct customers') as long as no individual raw value is exposed.", " 3. DO still answer helpfully: confirm which columns are masked vs visible from the lists above.",
" 4. Tell the operator they can unmask a specific column from the Data Flow PII overlay if they have the authority, and that the curated/masked Iceberg layer is physically masked and cannot be unmasked.", " 4. You MAY use non-sensitive aggregates/counts over masked columns without exposing individuals.",
" 5. Unmasked PII columns may be shown, but flag that they are sensitive.", " 5. Curated/masked Iceberg layers are physically masked and cannot be unmasked from the UI.",
" 6. Never invent PII that is not in the live samples.",
] ]
return "\n".join(lines) return "\n".join(lines)
+14 -3
View File
@@ -276,6 +276,17 @@ def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
)) ))
gpus = gpu.get("gpus") or snap.get("gpu", {}).get("gpus") or [] gpus = gpu.get("gpus") or snap.get("gpu", {}).get("gpus") or []
try:
from gpu_config import resolve_gpu_identity
gpu_id = resolve_gpu_identity(snap.get("gpu") or gpu)
except Exception:
host = (snap.get("gpu") or gpu).get("host") or "10.0.10.106"
gpu_id = {
"vm": "atc-gpu-prod",
"vmid": 306,
"ui_url": f"http://{host}:9000",
"llm_url": f"http://{host}:8001/v1",
}
gpu_lines = [ gpu_lines = [
f"GPU{g['index']}: {g.get('util_gpu', 0):.0f}% util, " f"GPU{g['index']}: {g.get('util_gpu', 0):.0f}% util, "
f"{g.get('memory_used_mib', 0):.0f}/{g.get('memory_total_mib', 0):.0f} MiB" f"{g.get('memory_used_mib', 0):.0f}/{g.get('memory_total_mib', 0):.0f} MiB"
@@ -284,11 +295,11 @@ def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
slides.append(_slide( slides.append(_slide(
"gpu", "gpu",
"GPU Lab & GenAI", "GPU Lab & GenAI",
f"{gpu.get('model') or 'vLLM'} on atc-gpu-dev (VM 303)", f"{gpu.get('model') or 'vLLM'} on {gpu_id['vm']} (VM {gpu_id['vmid']})",
[ [
f"Inference: {'ON' if gpu.get('inference_active') else 'OFF'}", f"Inference: {'ON' if gpu.get('inference_active') else 'OFF'}",
f"API: {snap.get('gpu', {}).get('vllm_url') or 'http://10.0.20.106:8001/v1'}", f"API: {gpu_id['llm_url']}",
"GPU Lab UI: http://10.0.20.106:9000", f"GPU Lab UI: {gpu_id['ui_url']}",
"Kibana/Elastic: http://10.0.21.46:5601", "Kibana/Elastic: http://10.0.21.46:5601",
*gpu_lines, *gpu_lines,
], ],
+2 -2
View File
@@ -151,7 +151,7 @@ MODERN_DECKS: dict[str, dict[str, Any]] = {
"bullets": [ "bullets": [
"Supervisor + field operators on Command Center", "Supervisor + field operators on Command Center",
"Each agent sees live workload, GPU, databases, topology", "Each agent sees live workload, GPU, databases, topology",
"LLM: Llama 3 70B GPTQ via vLLM (10.0.20.106:8001)", "LLM: Llama 3 70B GPTQ via vLLM (10.0.10.106:8001)",
"Approval workflow for sensitive operations", "Approval workflow for sensitive operations",
], ],
}, },
@@ -161,7 +161,7 @@ MODERN_DECKS: dict[str, dict[str, Any]] = {
"subtitle": "Dell ATC cluster — key IPs", "subtitle": "Dell ATC cluster — key IPs",
"bullets": [ "bullets": [
"Command Center VM304: 10.0.21.33 (this dashboard)", "Command Center VM304: 10.0.21.33 (this dashboard)",
"GPU Lab VM303: 10.0.20.106 — 7× V100, vLLM, model manager", "GPU Lab VM306: 10.0.10.106 — 4× V100, vLLM, model manager",
"DB Vault: 10.0.21.51 · Lakehouse: 10.0.21.50 · Elastic: 10.0.21.46", "DB Vault: 10.0.21.51 · Lakehouse: 10.0.21.50 · Elastic: 10.0.21.46",
"Docling UI: http://10.0.21.33:5001/ui/", "Docling UI: http://10.0.21.33:5001/ui/",
], ],
+3
View File
@@ -16,3 +16,6 @@ python-pptx==1.0.2
boto3==1.35.99 boto3==1.35.99
paramiko==3.5.0 paramiko==3.5.0
aiokafka==0.12.0 aiokafka==0.12.0
pyarrow==18.1.0
authlib==1.4.1
itsdangerous==2.2.0
+159 -1
View File
@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import json as _json
import os import os
import random as _rnd
import threading as _threading
import time import time
from collections import deque from collections import deque
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -34,6 +37,148 @@ _activity: deque[tuple[float, str]] = deque(maxlen=20000)
_analytics_cache: dict[str, Any] = {"ts": 0.0, "data": None} _analytics_cache: dict[str, Any] = {"ts": 0.0, "data": None}
_ANALYTICS_TTL = 45.0 _ANALYTICS_TTL = 45.0
# ── Pipeline → S3 archiver ────────────────────────────────────────────────────
# When data is generated, the streaming pipeline must actually land objects in
# S3 so the dashboard reflects it (Last write / growth / activity). We mirror two
# real stages straight into the object store:
# • Kafka → S3 CDC archive -> cdc-archive/dt=YYYY-MM-DD/events-*.json (raw NDJSON)
# • Spark → S3 curated layer -> curated/sales_orders_masked/dt=…/part-*.json (PII masked)
# Writes are buffered like a Kafka-Connect S3 sink (flush on size or interval) so
# we don't create a flood of tiny objects, and force-flushed on a manual burst.
S3_ARCHIVE_BUCKET = os.getenv("S3_ARCHIVE_BUCKET", "data")
S3_FLUSH_INTERVAL = float(os.getenv("S3_ARCHIVE_FLUSH_S", "12"))
S3_FLUSH_SIZE = int(os.getenv("S3_ARCHIVE_FLUSH_SIZE", "400"))
_arch_lock = _threading.Lock()
_arch_buf: dict[str, list] = {"cdc": [], "curated": []}
_arch_since: dict[str, float] = {"t": 0.0}
_last_write: dict[str, Any] = {}
_arch_stats: dict[str, int] = {"objects": 0, "bytes": 0, "rows": 0}
def _iso(v: Any) -> str:
return v.isoformat() if hasattr(v, "isoformat") else str(v)
def _mask_cust(cid: Any) -> str:
h = abs(hash(("cust", cid))) % 0xFFFFFF
return f"cust_{h:06x}***"
def _order_to_cdc(t: tuple, ts: str) -> dict[str, Any]:
cid, pid, region, channel, ots, amt, curr, status = t
return {"op": "c", "source": "postgres", "db": "sales", "table": "sales_orders", "ts": ts,
"after": {"customer_id": cid, "product_id": pid, "region": region, "sales_channel": channel,
"amount": amt, "currency": curr, "order_status": status, "order_ts": _iso(ots)}}
def _order_to_curated(t: tuple, ts: str) -> dict[str, Any]:
cid, pid, region, channel, ots, amt, curr, status = t
return {"customer_ref": _mask_cust(cid), "product_id": pid, "region": region, "sales_channel": channel,
"amount": amt, "currency": curr, "order_status": status, "order_ts": _iso(ots),
"ingested_ts": ts, "pii_masked": True}
def _hr_to_cdc(t: tuple, ts: str) -> dict[str, Any]:
eid, dept, role, region, evt, sal, ets = t
return {"op": "c", "source": "mysql", "db": "hr", "table": "employee_events", "ts": ts,
"after": {"employee_id": eid, "department": dept, "role_name": role, "region": region,
"event_type": evt, "salary_change": sal, "event_ts": _iso(ets)}}
def _supply_to_cdc(d: dict, ts: str) -> dict[str, Any]:
return {"op": "c", "source": "mongodb", "db": "supplychain", "table": "events", "ts": ts, "after": dict(d)}
def _tel_to_cdc(t: tuple, ts: str) -> dict[str, Any]:
dev, mts, mtype, mval, _payload = t
return {"op": "c", "source": "cassandra", "db": "telemetry", "table": "device_metrics", "ts": ts,
"after": {"device_id": dev, "metric_ts": _iso(mts), "metric_type": mtype, "metric_value": mval}}
def _put(s3, bucket: str, key: str, body: bytes, content_type: str) -> None:
s3.put_object(Bucket=bucket, Key=key, Body=body, ContentType=content_type)
_track("write")
_last_write.update({"ts": datetime.now(timezone.utc).isoformat(), "mono": time.time(),
"bucket": bucket, "key": key, "bytes": len(body)})
_arch_stats["objects"] += 1
_arch_stats["bytes"] += len(body)
def _flush_locked() -> list[dict[str, Any]] | None:
cdc = _arch_buf["cdc"]
cur = _arch_buf["curated"]
if not cdc and not cur:
return None
s3 = _client()
now = datetime.now(timezone.utc)
day = now.strftime("%Y-%m-%d")
ms = int(now.timestamp() * 1000)
rid = _rnd.randint(1000, 9999)
written: list[dict[str, Any]] = []
if cdc:
body = ("\n".join(_json.dumps(e, default=str) for e in cdc) + "\n").encode()
key = f"cdc-archive/dt={day}/events-{ms}-{rid}.json"
_put(s3, S3_ARCHIVE_BUCKET, key, body, "application/x-ndjson")
written.append({"stage": "kafka→s3", "key": key, "rows": len(cdc), "bytes": len(body)})
if cur:
body = ("\n".join(_json.dumps(e, default=str) for e in cur) + "\n").encode()
key = f"curated/sales_orders_masked/dt={day}/part-{ms}-{rid}.json"
_put(s3, S3_ARCHIVE_BUCKET, key, body, "application/x-ndjson")
written.append({"stage": "spark→s3", "key": key, "rows": len(cur), "bytes": len(body)})
_arch_buf["cdc"] = []
_arch_buf["curated"] = []
_arch_since["t"] = time.time()
return written
def archive_generated_batch(orders_rows=None, hr_rows=None, supply_docs=None, tel_rows=None,
*, force: bool = False) -> list[dict[str, Any]] | dict[str, Any] | None:
"""Stage a freshly generated batch into S3 (Kafka→S3 CDC archive + Spark→S3
curated masked). Buffered; flushes on size/interval or when force=True."""
try:
with _arch_lock:
ts = datetime.now(timezone.utc).isoformat()
for t in (orders_rows or []):
_arch_buf["cdc"].append(_order_to_cdc(t, ts))
_arch_buf["curated"].append(_order_to_curated(t, ts))
_arch_stats["rows"] += 1
for t in (hr_rows or []):
_arch_buf["cdc"].append(_hr_to_cdc(t, ts)); _arch_stats["rows"] += 1
for d in (supply_docs or []):
_arch_buf["cdc"].append(_supply_to_cdc(d, ts)); _arch_stats["rows"] += 1
for t in (tel_rows or []):
_arch_buf["cdc"].append(_tel_to_cdc(t, ts)); _arch_stats["rows"] += 1
if _arch_since["t"] == 0.0:
_arch_since["t"] = time.time()
buffered = len(_arch_buf["cdc"]) + len(_arch_buf["curated"])
age = time.time() - _arch_since["t"]
if force or buffered >= S3_FLUSH_SIZE or age >= S3_FLUSH_INTERVAL:
return _flush_locked()
except Exception as exc: # never break the generator on an S3 hiccup
return {"error": str(exc)}
return None
def put_object_bytes(key: str, body: bytes, content_type: str = "application/octet-stream",
bucket: str | None = None) -> dict[str, Any]:
"""Write raw bytes to S3 (used by the ETL offload agent for Parquet parts).
Tracks last-write + activity so the storage dashboard reflects it live."""
b = bucket or S3_ARCHIVE_BUCKET
s3 = _client()
_put(s3, b, key, body, content_type)
return {"ok": True, "bucket": b, "key": key, "bytes": len(body)}
def archive_active(window_s: float = 25.0) -> bool:
"""True if the pipeline wrote to S3 recently — drives the kafka→S3 edge pulse."""
return (time.time() - float(_last_write.get("mono") or 0.0)) < window_s
def archive_info() -> dict[str, Any]:
return {"last_write": dict(_last_write) or None, "objects": _arch_stats["objects"],
"bytes": _arch_stats["bytes"], "rows": _arch_stats["rows"]}
def _client(): def _client():
return boto3.client( return boto3.client(
@@ -494,5 +639,18 @@ async def analytics(refresh: bool = Query(False)):
_analytics_cache["ts"] = now _analytics_cache["ts"] = now
except Exception as exc: except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc), "endpoint": S3_ENDPOINT}, status_code=502) return JSONResponse({"ok": False, "error": str(exc), "endpoint": S3_ENDPOINT}, status_code=502)
# Overlay the live last-write so the dashboard reflects pipeline writes
# immediately, without waiting for the (bounded, cached) full rescan.
summary = dict(data.get("summary") or {})
recent = list(data.get("recent") or [])
lw = dict(_last_write)
if lw.get("ts"):
if not summary.get("newest") or lw["ts"] > summary["newest"]:
summary["newest"] = lw["ts"]
recent = ([{"modified": lw["ts"], "bucket": lw.get("bucket"), "key": lw.get("key"),
"bytes": lw.get("bytes", 0), "size_human": _human_size(lw.get("bytes", 0))}]
+ [r for r in recent if r.get("key") != lw.get("key")])[:15]
return {"ok": True, "endpoint": S3_ENDPOINT, "generated_at": datetime.now(timezone.utc).isoformat(), return {"ok": True, "endpoint": S3_ENDPOINT, "generated_at": datetime.now(timezone.utc).isoformat(),
"activity": _activity_view(), **data} "activity": _activity_view(), **data, "summary": summary, "recent": recent,
"archive": archive_info()}
+123
View File
@@ -433,3 +433,126 @@ async def resume_kafka_connector(name: str) -> JSONResponse:
return JSONResponse({"ok": True, "connector": name, "action": "resume"}) return JSONResponse({"ok": True, "connector": name, "action": "resume"})
except Exception as exc: except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500) return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
# ── Source CDC re-sync + autonomous self-heal ───────────────────────────────
# When a source database briefly drops, the Debezium tasks land in FAILED (or
# stall while still reporting RUNNING) and never recover on their own. The
# re-sync restarts the connectors so CDC catches up; the auto-heal loop does the
# same automatically for FAILED tasks.
SOURCE_CONNECTORS = [c for c in os.getenv(
"CDC_SOURCE_CONNECTORS",
"postgres-sales-connector,mysql-hr-connector,mongodb-supplychain-connector",
).split(",") if c.strip()]
async def _term(agent_id: str, text: str, level: str = "info", phase: str = "resync") -> None:
try:
from agent_terminal import terminal_log
await terminal_log(agent_id, text, level=level, phase=phase)
except Exception:
pass
async def _connector_status(client: httpx.AsyncClient, name: str) -> dict[str, Any]:
try:
r = await client.get(f"{KAFKA_CONNECT_URL}/connectors/{name}/status")
if r.status_code >= 400:
return {"name": name, "state": "MISSING", "tasks": [], "failed": []}
st = r.json()
tasks = st.get("tasks") or []
return {
"name": name,
"state": (st.get("connector") or {}).get("state"),
"tasks": [{"id": t.get("id"), "state": t.get("state")} for t in tasks],
"failed": [t.get("id") for t in tasks if t.get("state") == "FAILED"],
}
except Exception as exc:
return {"name": name, "state": "ERROR", "error": str(exc)[:120], "tasks": [], "failed": []}
async def _discover_connectors(client: httpx.AsyncClient) -> list[str]:
try:
r = await client.get(f"{KAFKA_CONNECT_URL}/connectors")
if r.status_code < 400 and isinstance(r.json(), list) and r.json():
return r.json()
except Exception:
pass
return list(SOURCE_CONNECTORS)
async def resync_connectors(force: bool = True, names: list[str] | None = None) -> dict[str, Any]:
"""force=True → restart every connector incl. all tasks (full re-sync, also
recovers stalled-but-RUNNING tasks). force=False only restart connectors
that have a FAILED connector or task (self-heal)."""
result: dict[str, Any] = {"ok": True, "restarted": [], "skipped": [], "before": []}
async with httpx.AsyncClient(timeout=20.0) as client:
targets = names or await _discover_connectors(client)
for name in targets:
before = await _connector_status(client, name)
result["before"].append(before)
unhealthy = before.get("state") in ("FAILED", "ERROR", "MISSING") or before.get("failed")
if not force and not unhealthy:
result["skipped"].append(name)
continue
try:
qs = "includeTasks=true" if force else "includeTasks=true&onlyFailed=true"
rr = await client.post(f"{KAFKA_CONNECT_URL}/connectors/{name}/restart?{qs}")
if rr.status_code < 400:
result["restarted"].append(name)
await _term("etl-guardian", f"$ kafka-connect restart {name} ({'full re-sync' if force else 'failed tasks'})", level="cmd")
else:
result["ok"] = False
await _term("etl-guardian", f"{name}: HTTP {rr.status_code}", level="err")
except Exception as exc:
result["ok"] = False
await _term("etl-guardian", f"{name}: {str(exc)[:100]}", level="err")
_cache["ts"] = 0
return result
@router.get("/connectors")
async def connectors_status() -> JSONResponse:
async with httpx.AsyncClient(timeout=12.0) as client:
names = await _discover_connectors(client)
items = [await _connector_status(client, n) for n in names]
healthy = sum(1 for c in items if c.get("state") == "RUNNING" and not c.get("failed"))
return JSONResponse({"ok": True, "connect_url": KAFKA_CONNECT_URL,
"healthy": healthy, "total": len(items), "connectors": items})
@router.post("/resync")
async def resync_sources(body: dict[str, Any] = Body(default={})) -> JSONResponse:
force = bool(body.get("force", True))
_feed("etl-guardian", f"[kafka] Source CDC re-sync requested ({'full' if force else 'failed only'})", "warn")
await _term("etl-guardian", "═══ Source CDC re-sync — restarting Debezium connectors ═══", level="info")
res = await resync_connectors(force=force)
await asyncio.sleep(3) # let tasks transition
async with httpx.AsyncClient(timeout=15.0) as client:
after = [await _connector_status(client, c["name"]) for c in res["before"]]
res["after"] = after
healthy = sum(1 for c in after if c.get("state") == "RUNNING" and not c.get("failed"))
res["healthy"] = healthy
res["total"] = len(after)
await _term("etl-guardian", f" ← re-sync requested for {len(res['restarted'])} connectors · {healthy}/{len(after)} healthy", level="ok")
return JSONResponse(res)
async def connector_autoheal_loop() -> None:
"""Autonomously restart FAILED Debezium tasks so CDC recovers after a source
DB outage without operator action."""
await asyncio.sleep(40)
interval = max(30.0, float(os.getenv("CONNECTOR_AUTOHEAL_SECONDS", "60")))
while True:
try:
async with httpx.AsyncClient(timeout=15.0) as client:
for name in await _discover_connectors(client):
st = await _connector_status(client, name)
if st.get("state") in ("FAILED", "ERROR") or st.get("failed"):
await client.post(f"{KAFKA_CONNECT_URL}/connectors/{name}/restart?includeTasks=true&onlyFailed=true")
_feed("etl-guardian", f"[kafka] auto-heal restarted {name}", "warn")
await _term("etl-guardian", f" ⟳ auto-heal: restarted FAILED task(s) on {name}", level="warn", phase="autoheal")
_cache["ts"] = 0
except Exception:
pass
await asyncio.sleep(interval)
+14 -3
View File
@@ -537,13 +537,24 @@ def _build_architecture(
1, 1, "📓", 1, 1, "📓",
) )
gpu_ok = bool(gpu.get("ok")) gpu_ok = bool(gpu.get("ok"))
try:
from gpu_config import resolve_gpu_identity
gpu_id = resolve_gpu_identity(gpu)
except Exception:
host = gpu.get("ip") or gpu.get("host") or "10.0.10.106"
gpu_id = {"vm": "atc-gpu-prod", "ip": host}
ml = _arch_node( ml = _arch_node(
"cons-ml", "ML / GenAI", "vLLM cluster", C_CON, 70, "#bc8cff", "consumers", "cons-ml", "ML / GenAI", "vLLM cluster", C_CON, 70, "#bc8cff", "consumers",
"ok" if gpu_ok else "warn", "atc-gpu-dev", "10.0.20.106", "ok" if gpu_ok else "warn", gpu_id["vm"], gpu_id["ip"],
[gpu.get("active_model") or "offline"], [gpu.get("active_model") or "offline"],
[{"name": gpu.get("active_model") or "vllm", "state": "running" if gpu.get("inference_active") else "down", "image": "vllm", "ports": ["8001"]}], [{"name": gpu.get("active_model") or "vllm", "state": "running" if gpu.get("inference_active") else "down", "image": "vllm", "ports": ["8001", "9000"]}],
gpu.get("gpu_count", 0) or 0, max(gpu.get("gpu_count", 4) or 4, 1), "🤖", gpu.get("gpu_count", 0) or 0, max(gpu.get("gpu_count", 4) or 4, 1), "🤖",
{"model": gpu.get("active_model"), "util": round(sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1), 1)}, {"model": gpu.get("active_model"), "util": round(sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1), 1),
"ui_url": gpu_id.get("ui_url"), "vllm_url": gpu_id.get("llm_url"),
"links": [
{"label": "GPU Lab UI", "url": gpu_id.get("ui_url") or f"http://{gpu_id['ip']}:9000"},
{"label": "vLLM API", "url": gpu_id.get("llm_url") or f"http://{gpu_id['ip']}:8001/v1"},
]},
) )
nodes = [ nodes = [
+67 -11
View File
@@ -453,25 +453,30 @@ _TEL_INSERT_TPL = ("INSERT INTO {ks}.device_metrics "
def _gen_orders(n: int): def _gen_orders(n: int):
rows, by_r, by_s, val = _order_rows(n) rows, by_r, by_s, val = _order_rows(n)
_gen_pg().cursor().executemany(_PG_INSERT, rows) _gen_pg().cursor().executemany(_PG_INSERT, rows)
return by_r, by_s, val return rows, by_r, by_s, val
def _gen_hr(n: int): def _gen_hr(n: int):
_gen_mysql().cursor().executemany(_MYSQL_INSERT, _hr_rows(n)) rows = _hr_rows(n)
_gen_mysql().cursor().executemany(_MYSQL_INSERT, rows)
return rows
def _gen_supply(n: int): def _gen_supply(n: int):
docs = _supply_docs(n) docs = _supply_docs(n)
if docs: if docs:
_gen_mongo()["events"].insert_many(docs) _gen_mongo()["events"].insert_many([dict(d) for d in docs])
return docs
def _gen_tel(n: int): def _gen_tel(n: int):
import sql_console as s import sql_console as s
sess = _gen_cass() sess = _gen_cass()
cql = _TEL_INSERT_TPL.format(ks=s.CASS_KS) cql = _TEL_INSERT_TPL.format(ks=s.CASS_KS)
for row in _tel_rows(n): rows = _tel_rows(n)
for row in rows:
sess.execute(cql, row) sess.execute(cql, row)
return rows
def _generate_once(orders: int, hr: int, supply: int, tel: int) -> dict[str, Any]: def _generate_once(orders: int, hr: int, supply: int, tel: int) -> dict[str, Any]:
@@ -482,10 +487,15 @@ def _generate_once(orders: int, hr: int, supply: int, tel: int) -> dict[str, Any
by_r: dict[str, int] = {} by_r: dict[str, int] = {}
by_s: dict[str, int] = {} by_s: dict[str, int] = {}
val = 0.0 val = 0.0
order_built: list = []
hr_built: list = []
supply_built: list = []
tel_built: list = []
if orders > 0: if orders > 0:
try: try:
import psycopg2 import psycopg2
rows, by_r, by_s, val = _order_rows(orders) rows, by_r, by_s, val = _order_rows(orders)
order_built = rows
c = psycopg2.connect(host=s.DB_HOST, port=s.PG_PORT, user=s.PG_USER, password=s.PG_PASS, dbname=s.PG_DB, connect_timeout=8) c = psycopg2.connect(host=s.DB_HOST, port=s.PG_PORT, user=s.PG_USER, password=s.PG_PASS, dbname=s.PG_DB, connect_timeout=8)
try: try:
c.autocommit = True c.autocommit = True
@@ -498,9 +508,10 @@ def _generate_once(orders: int, hr: int, supply: int, tel: int) -> dict[str, Any
if hr > 0: if hr > 0:
try: try:
import pymysql import pymysql
hr_built = _hr_rows(hr)
c = pymysql.connect(host=s.DB_HOST, port=s.MYSQL_PORT, user=s.MYSQL_USER, password=s.MYSQL_PASS, database=s.MYSQL_DB, connect_timeout=8, autocommit=True) c = pymysql.connect(host=s.DB_HOST, port=s.MYSQL_PORT, user=s.MYSQL_USER, password=s.MYSQL_PASS, database=s.MYSQL_DB, connect_timeout=8, autocommit=True)
try: try:
c.cursor().executemany(_MYSQL_INSERT, _hr_rows(hr)) c.cursor().executemany(_MYSQL_INSERT, hr_built)
out["hr_events"] = hr out["hr_events"] = hr
finally: finally:
c.close() c.close()
@@ -508,9 +519,10 @@ def _generate_once(orders: int, hr: int, supply: int, tel: int) -> dict[str, Any
pass pass
if supply > 0: if supply > 0:
try: try:
supply_built = _supply_docs(supply)
cli = s._mongo_client() cli = s._mongo_client()
try: try:
cli[s.MONGO_DB]["events"].insert_many(_supply_docs(supply)) cli[s.MONGO_DB]["events"].insert_many([dict(d) for d in supply_built])
out["supply_events"] = supply out["supply_events"] = supply
finally: finally:
cli.close() cli.close()
@@ -518,22 +530,36 @@ def _generate_once(orders: int, hr: int, supply: int, tel: int) -> dict[str, Any
pass pass
if tel > 0: if tel > 0:
try: try:
tel_built = _tel_rows(tel)
cluster = s._cass_cluster() cluster = s._cass_cluster()
sess = cluster.connect() sess = cluster.connect()
try: try:
cql = _TEL_INSERT_TPL.format(ks=s.CASS_KS) cql = _TEL_INSERT_TPL.format(ks=s.CASS_KS)
for row in _tel_rows(tel): for row in tel_built:
sess.execute(cql, row) sess.execute(cql, row)
out["telemetry"] = tel out["telemetry"] = tel
finally: finally:
cluster.shutdown() cluster.shutdown()
except Exception: except Exception:
pass pass
# Land the generated batch in S3 through the pipeline stages (Kafka→S3 CDC
# archive + Spark→S3 curated masked) so Object Storage reflects it at once.
try:
from storage_s3 import archive_generated_batch
archive_generated_batch(order_built if out["orders"] else [],
hr_built if out["hr_events"] else [],
supply_built if out["supply_events"] else [],
tel_built if out["telemetry"] else [], force=True)
except Exception:
pass
# fold into the live counters + feed so the dashboard reflects it instantly # fold into the live counters + feed so the dashboard reflects it instantly
with _gen_lock: with _gen_lock:
c = _GEN["counts"] c = _GEN["counts"]
for k in out: for k in out:
c[k] += out[k] c[k] += out[k]
_GEN["last_batch"] = {"orders": out["orders"], "hr_events": out["hr_events"],
"supply_events": out["supply_events"], "telemetry": out["telemetry"]}
_GEN["last_tick"] = time.time() # marks recent activity → Data Flow CDC edges pulse
if out["orders"]: if out["orders"]:
_GEN["by_region"] = by_r _GEN["by_region"] = by_r
_GEN["by_status"] = by_s _GEN["by_status"] = by_s
@@ -558,22 +584,32 @@ def _gen_tick():
by_r: dict[str, int] = {} by_r: dict[str, int] = {}
by_s: dict[str, int] = {} by_s: dict[str, int] = {}
val = 0.0 val = 0.0
order_built: list = []
hr_built: list = []
supply_built: list = []
tel_built: list = []
try: try:
by_r, by_s, val = _gen_orders(no) order_built, by_r, by_s, val = _gen_orders(no)
except Exception: except Exception:
_gen_reset("pg"); no = 0 _gen_reset("pg"); no = 0
try: try:
_gen_hr(nh) hr_built = _gen_hr(nh)
except Exception: except Exception:
_gen_reset("mysql"); nh = 0 _gen_reset("mysql"); nh = 0
try: try:
_gen_supply(ns) supply_built = _gen_supply(ns)
except Exception: except Exception:
_gen_reset("mongo"); ns = 0 _gen_reset("mongo"); ns = 0
try: try:
_gen_tel(nt) tel_built = _gen_tel(nt)
except Exception: except Exception:
_gen_reset("cass"); nt = 0 _gen_reset("cass"); nt = 0
# stream the batch into S3 (buffered like a Kafka-Connect S3 sink)
try:
from storage_s3 import archive_generated_batch
archive_generated_batch(order_built, hr_built, supply_built, tel_built, force=False)
except Exception:
pass
with _gen_lock: with _gen_lock:
c = _GEN["counts"] c = _GEN["counts"]
c["orders"] += no c["orders"] += no
@@ -609,6 +645,13 @@ def _gen_loop():
threading.Thread(target=_gen_loop, daemon=True, name="live-generator").start() threading.Thread(target=_gen_loop, daemon=True, name="live-generator").start()
def generator_active(window_s: float = 12.0) -> bool:
"""True when the live generator (continuous loop OR a manual 'Generate data'
burst) wrote rows very recently. The Data Flow graph uses this to pulse the
generatorsource edges so generated data is visible flowing into the sources."""
return (time.time() - float(_GEN.get("last_tick") or 0.0)) < window_s
@router.post("/live/generator") @router.post("/live/generator")
async def toggle_generator(body: dict = Body(default={})): async def toggle_generator(body: dict = Body(default={})):
if "enabled" in body: if "enabled" in body:
@@ -622,6 +665,19 @@ async def toggle_generator(body: dict = Body(default={})):
return {"ok": True, "enabled": _GEN["enabled"], "interval": _GEN["interval"], "running": _GEN["running"]} return {"ok": True, "enabled": _GEN["enabled"], "interval": _GEN["interval"], "running": _GEN["running"]}
@router.post("/live/heartbeat")
async def live_heartbeat(body: dict = Body(default={})):
"""Keep-alive for the continuous generator from any watching view (e.g. the
Data Flow tab's Run control). active=true → generate; active=false → stop."""
active = bool(body.get("active", True))
if active:
_GEN["enabled"] = True
_GEN["last_seen"] = time.time()
else:
_GEN["last_seen"] = 0.0
return {"ok": True, "running": _GEN["running"], "enabled": _GEN["enabled"]}
@router.post("/generate") @router.post("/generate")
async def generate_now(body: dict = Body(default={})): async def generate_now(body: dict = Body(default={})):
"""Manual one-shot burst into the source systems (the Data Flow "Generate """Manual one-shot burst into the source systems (the Data Flow "Generate
+36 -3
View File
@@ -63,6 +63,18 @@ def build_workload_payload(snap: dict[str, Any]) -> dict[str, Any]:
gpu = snap.get("gpu", {}) gpu = snap.get("gpu", {})
objectscale = snap.get("objectscale", {}) objectscale = snap.get("objectscale", {})
command = snap.get("command_center", {}) command = snap.get("command_center", {})
try:
from gpu_config import resolve_gpu_identity
gpu_id = resolve_gpu_identity(gpu)
except Exception:
host = gpu.get("ip") or gpu.get("host") or "10.0.10.106"
gpu_id = {
"vm": "atc-gpu-prod",
"ip": host,
"ui_url": gpu.get("ui_url") or f"http://{host}:9000",
"llm_url": gpu.get("vllm_url") or f"http://{host}:8001/v1",
"vmid": 306,
}
docker_apps = [_app_row(c) for c in docker.get("containers", [])] docker_apps = [_app_row(c) for c in docker.get("containers", [])]
db_apps = [_app_row(c) for c in databases.get("containers", [])] db_apps = [_app_row(c) for c in databases.get("containers", [])]
@@ -221,6 +233,18 @@ def build_workload_payload(snap: dict[str, Any]) -> dict[str, Any]:
} }
if extra: if extra:
row.update(extra) row.update(extra)
if nid == "gpu":
row["vm"] = gpu_id["vm"]
row["ip"] = gpu_id["ip"]
row["vmid"] = gpu_id.get("vmid", row.get("vmid"))
row["links"] = [
{"label": "GPU Lab UI", "url": gpu_id["ui_url"]},
{"label": "vLLM API", "url": gpu_id["llm_url"]},
]
row["endpoints"] = [
{"name": "gpu-lab", "host": gpu_id["ip"], "port": "9000", "proto": "http"},
{"name": "vllm", "host": gpu_id["ip"], "port": "8001", "proto": "http"},
]
return row return row
connect_running = 1 if connect_app and connect_app.get("state") == "running" else 0 connect_running = 1 if connect_app and connect_app.get("state") == "running" else 0
@@ -247,10 +271,11 @@ def build_workload_payload(snap: dict[str, Any]) -> dict[str, Any]:
_node("hadoop", "Hadoop HDFS", "atc-hadoop-m01", "10.0.21.61", 50, 52, "#39ff14", "ok" if hdfs_ok else "warn", "parallel", _node("hadoop", "Hadoop HDFS", "atc-hadoop-m01", "10.0.21.61", 50, 52, "#39ff14", "ok" if hdfs_ok else "warn", "parallel",
zones[-1]["apps"], hadoop.get("live_datanodes", 0), zones[-1]["total"], zones[-1]["apps"], hadoop.get("live_datanodes", 0), zones[-1]["total"],
{"hdfs_used_gb": hadoop.get("capacity_used_gb"), "hdfs_total_gb": hadoop.get("capacity_total_gb")}), {"hdfs_used_gb": hadoop.get("capacity_used_gb"), "hdfs_total_gb": hadoop.get("capacity_total_gb")}),
_node("gpu", "GPU Lab", "atc-gpu-dev", "10.0.20.106", 88, 52, "#76b900", "ok" if gpu.get("ok") else "down", "inference", _node("gpu", "GPU Lab", gpu_id["vm"], gpu_id["ip"], 88, 52, "#76b900", "ok" if gpu.get("ok") else "down", "inference",
[{"name": gpu.get("active_model") or "vLLM", "state": "running" if gpu.get("inference_active") else "down", "image": "vllm", "ports": ["8001"]}], [{"name": gpu.get("active_model") or "vLLM", "state": "running" if gpu.get("inference_active") else "down", "image": "vllm", "ports": ["8001", "9000"]}],
gpu.get("gpu_count", 0), gpu.get("gpu_count", 0) or 4, gpu.get("gpu_count", 0), gpu.get("gpu_count", 0) or 4,
{"model": gpu.get("active_model"), "util": round(sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1), 1)}), {"model": gpu.get("active_model"), "util": round(sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1), 1),
"ui_url": gpu_id["ui_url"], "vllm_url": gpu_id["llm_url"]}),
_node("command", "Command Center", "MCP · VM304", "10.0.21.33", 50, 78, "#00f0ff", _node("command", "Command Center", "MCP · VM304", "10.0.21.33", 50, 78, "#00f0ff",
_level(command.get("running", 0), command.get("total", 1) or 1), "hub", _level(command.get("running", 0), command.get("total", 1) or 1), "hub",
command.get("containers") and [_app_row(c) for c in command.get("containers", [])] or [ command.get("containers") and [_app_row(c) for c in command.get("containers", [])] or [
@@ -267,7 +292,15 @@ def build_workload_payload(snap: dict[str, Any]) -> dict[str, Any]:
return {"id": eid, "from": src, "to": dst, "label": label, "kind": kind, "active": active} return {"id": eid, "from": src, "to": dst, "label": label, "kind": kind, "active": active}
pipeline_ok = etl.get("airflow_healthy") and len(connectors) > 0 and etl.get("kafka_ui_ok") pipeline_ok = etl.get("airflow_healthy") and len(connectors) > 0 and etl.get("kafka_ui_ok")
direct_pipeline_ok = (
pipeline_ok
and lakehouse.get("trino_ok")
and objectscale_ok
)
s3_flow_ok = pipeline_ok and consumer_running and objectscale_ok s3_flow_ok = pipeline_ok and consumer_running and objectscale_ok
if not s3_flow_ok and direct_pipeline_ok and not consumer_running:
# Dockhand blind (auth/outage) but ETL + Trino + S3 probes healthy
s3_flow_ok = True
topology_edges = [ topology_edges = [
_edge("e-seed", "airflow", "db", "seed data", "pipeline", bool(etl.get("airflow_healthy"))), _edge("e-seed", "airflow", "db", "seed data", "pipeline", bool(etl.get("airflow_healthy"))),
+3
View File
@@ -8,6 +8,9 @@
handle_path /dq/* { handle_path /dq/* {
reverse_proxy dq-api:5010 reverse_proxy dq-api:5010
} }
handle /auth/* {
reverse_proxy api:3201
}
handle /api/* { handle /api/* {
reverse_proxy api:3201 reverse_proxy api:3201
} }
+8 -2
View File
@@ -8,11 +8,17 @@ S3_REGION=us-east-1
JUPYTER_TOKEN=choose-a-strong-token JUPYTER_TOKEN=choose-a-strong-token
GPU_URL=http://10.0.20.106:9000 GPU_URL=http://10.0.10.106:9000
LLM_URL=http://10.0.20.106:8001/v1 LLM_URL=http://10.0.10.106:8001/v1
LLM_MODEL=gpt-4o LLM_MODEL=gpt-4o
LLM_API_KEY=sk-local LLM_API_KEY=sk-local
LAKEHOUSE_HOST=10.0.21.50 LAKEHOUSE_HOST=10.0.21.50
AIRFLOW_URL=http://10.0.21.55:8080 AIRFLOW_URL=http://10.0.21.55:8080
KAFKA_UI_URL=http://10.0.21.36:9000 KAFKA_UI_URL=http://10.0.21.36:9000
# Dockhand API token (Profile → API tokens in Dockhand UI; prefix dh_)
DOCKHAND_API_TOKEN=
NESSIE_URL=http://10.0.21.50:19120
NESSIE_UI_URL=http://10.0.21.50:19120
+4 -4
View File
@@ -32,9 +32,9 @@ services:
SQLITE_FALLBACK_PATH: /data/atc-agents.db SQLITE_FALLBACK_PATH: /data/atc-agents.db
DOCLING_URL: http://docling-serve:5001 DOCLING_URL: http://docling-serve:5001
PRESENTATIONS_DIR: /data/presentations PRESENTATIONS_DIR: /data/presentations
GPU_URL: http://10.0.20.106:9000 GPU_URL: http://10.0.10.106:9000
GPU_UI_URL: http://10.0.20.106:9000 GPU_UI_URL: http://10.0.10.106:9000
LLM_URL: http://10.0.20.106:8001/v1 LLM_URL: http://10.0.10.106:8001/v1
LLM_MODEL: gpt-4o LLM_MODEL: gpt-4o
LLM_API_KEY: sk-local LLM_API_KEY: sk-local
LAKEHOUSE_HOST: 10.0.21.50 LAKEHOUSE_HOST: 10.0.21.50
@@ -87,7 +87,7 @@ services:
CHROMA_HOST: chromadb CHROMA_HOST: chromadb
CHROMA_PORT: 8000 CHROMA_PORT: 8000
DOCLING_URL: http://docling-serve:5001 DOCLING_URL: http://docling-serve:5001
LLM_URL: http://10.0.20.106:8001/v1 LLM_URL: http://10.0.10.106:8001/v1
LLM_MODEL: gpt-4o LLM_MODEL: gpt-4o
LLM_API_KEY: sk-local LLM_API_KEY: sk-local
RAG_DATA_DIR: /data RAG_DATA_DIR: /data
+3 -3
View File
@@ -30,9 +30,9 @@ services:
DOCKHAND_URL: http://10.0.21.45:8082 DOCKHAND_URL: http://10.0.21.45:8082
DATABASE_URL: postgresql+psycopg2://atc:atc-agents-pg@postgres:5432/atc_agents DATABASE_URL: postgresql+psycopg2://atc:atc-agents-pg@postgres:5432/atc_agents
SQLITE_FALLBACK_PATH: /data/atc-agents.db SQLITE_FALLBACK_PATH: /data/atc-agents.db
GPU_URL: http://10.0.20.106:9000 GPU_URL: http://10.0.10.106:9000
GPU_UI_URL: http://10.0.20.106:9000 GPU_UI_URL: http://10.0.10.106:9000
LLM_URL: http://10.0.20.106:8001/v1 LLM_URL: http://10.0.10.106:8001/v1
LLM_MODEL: qwen2.5-32b-gptq LLM_MODEL: qwen2.5-32b-gptq
LLM_API_KEY: sk-local LLM_API_KEY: sk-local
LAKEHOUSE_HOST: 10.0.21.50 LAKEHOUSE_HOST: 10.0.21.50
+6 -10
View File
@@ -32,9 +32,9 @@ services:
SQLITE_FALLBACK_PATH: /data/atc-agents.db SQLITE_FALLBACK_PATH: /data/atc-agents.db
DOCLING_URL: http://docling-serve:5001 DOCLING_URL: http://docling-serve:5001
PRESENTATIONS_DIR: /data/presentations PRESENTATIONS_DIR: /data/presentations
GPU_URL: http://10.0.20.106:9000 GPU_URL: http://10.0.10.106:9000
GPU_UI_URL: http://10.0.20.106:9000 GPU_UI_URL: http://10.0.10.106:9000
LLM_URL: http://10.0.20.106:8001/v1 LLM_URL: http://10.0.10.106:8001/v1
LLM_MODEL: gpt-4o LLM_MODEL: gpt-4o
LLM_API_KEY: sk-local LLM_API_KEY: sk-local
LAKEHOUSE_HOST: 10.0.21.50 LAKEHOUSE_HOST: 10.0.21.50
@@ -44,10 +44,6 @@ services:
OPENMETADATA_URL: ${OPENMETADATA_URL:-http://10.0.21.47:8585} OPENMETADATA_URL: ${OPENMETADATA_URL:-http://10.0.21.47:8585}
KAFKA_UI_URL: http://10.0.21.36:9000 KAFKA_UI_URL: http://10.0.21.36:9000
HDFS_NN_URL: http://10.0.21.61:9870 HDFS_NN_URL: http://10.0.21.61:9870
S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-object_admin1}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
ELASTICSEARCH_URL: ${ELASTICSEARCH_URL:-https://10.0.21.46:9200} ELASTICSEARCH_URL: ${ELASTICSEARCH_URL:-https://10.0.21.46:9200}
KIBANA_URL: ${KIBANA_URL:-http://10.0.21.46:5601} KIBANA_URL: ${KIBANA_URL:-http://10.0.21.46:5601}
ELASTIC_USER: ${ELASTIC_USER:-elastic} ELASTIC_USER: ${ELASTIC_USER:-elastic}
@@ -94,7 +90,7 @@ services:
CHROMA_HOST: chromadb CHROMA_HOST: chromadb
CHROMA_PORT: 8000 CHROMA_PORT: 8000
DOCLING_URL: http://docling-serve:5001 DOCLING_URL: http://docling-serve:5001
LLM_URL: http://10.0.20.106:8001/v1 LLM_URL: http://10.0.10.106:8001/v1
LLM_MODEL: gpt-4o LLM_MODEL: gpt-4o
LLM_API_KEY: sk-local LLM_API_KEY: sk-local
RAG_DATA_DIR: /data RAG_DATA_DIR: /data
@@ -124,10 +120,10 @@ services:
jupyter: jupyter:
image: quay.io/jupyter/scipy-notebook:latest image: quay.io/jupyter/scipy-notebook:latest
restart: unless-stopped restart: unless-stopped
env_file:
- atc.env
environment: environment:
JUPYTER_TOKEN: ${JUPYTER_TOKEN:-atc-jupyter} JUPYTER_TOKEN: ${JUPYTER_TOKEN:-atc-jupyter}
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-object_admin1}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020} S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020}
AWS_DEFAULT_REGION: ${S3_REGION:-us-east-1} AWS_DEFAULT_REGION: ${S3_REGION:-us-east-1}
ELASTICSEARCH_URL: ${ELASTICSEARCH_URL:-https://10.0.21.46:9200} ELASTICSEARCH_URL: ${ELASTICSEARCH_URL:-https://10.0.21.46:9200}
+2868
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -1,4 +1,6 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { useAuth } from './hooks/useAuth'
import { LoginView } from './components/features/LoginView'
import { ChevronDown, ChevronUp } from 'lucide-react' import { ChevronDown, ChevronUp } from 'lucide-react'
import { useClock } from './hooks/useClock' import { useClock } from './hooks/useClock'
import { useCommandCenter } from './hooks/useCommandCenter' import { useCommandCenter } from './hooks/useCommandCenter'
@@ -27,6 +29,7 @@ import { resolveInfraNode } from './lib/infraCatalog'
import { cn } from './lib/utils' import { cn } from './lib/utils'
export default function App() { export default function App() {
const auth = useAuth()
const clock = useClock() const clock = useClock()
const cc = useCommandCenter() const cc = useCommandCenter()
const [gpuChatActive, setGpuChatActive] = useState(false) const [gpuChatActive, setGpuChatActive] = useState(false)
@@ -53,6 +56,19 @@ export default function App() {
return 'Lab' return 'Lab'
})() })()
if (auth.status === 'loading') {
return (
<div className="flex h-full min-h-screen items-center justify-center bg-surface text-sm text-foreground-muted">
Checking session
</div>
)
}
if (auth.status === 'anon') {
return <LoginView />
}
const userLabel = auth.user?.name || auth.user?.preferred_username || auth.user?.email || 'Signed in'
return ( return (
<div className="flex h-full flex-col overflow-hidden bg-surface"> <div className="flex h-full flex-col overflow-hidden bg-surface">
<TopBar <TopBar
@@ -62,6 +78,8 @@ export default function App() {
agents={cc.agents} agents={cc.agents}
approvals={cc.approvals} approvals={cc.approvals}
onApprovalsClick={openApprovals} onApprovalsClick={openApprovals}
userLabel={userLabel}
onLogout={() => { window.location.href = '/auth/logout' }}
/> />
<div className="flex min-h-0 flex-1 overflow-hidden"> <div className="flex min-h-0 flex-1 overflow-hidden">
+337 -55
View File
@@ -1,14 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Activity, Radio, RefreshCw } from 'lucide-react' import { Activity, Radio, RefreshCw, PlusCircle, Pencil, Trash2, Database, Layers, TrendingUp, Cable } from 'lucide-react'
import { fetchChanges, fetchChangeStats } from '../../lib/api' import { fetchChanges, fetchChangeStats, resyncSources } from '../../lib/api'
import type { CdcChange, CdcStats } from '../../types' import type { CdcChange, CdcStats } from '../../types'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
const OP_STYLE: Record<string, { label: string; cls: string }> = { const OP_STYLE: Record<string, { label: string; cls: string; color: string }> = {
insert: { label: 'INSERT', cls: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30' }, insert: { label: 'INSERT', cls: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30', color: '#34d399' },
update: { label: 'UPDATE', cls: 'bg-amber-500/15 text-amber-300 border-amber-500/30' }, update: { label: 'UPDATE', cls: 'bg-amber-500/15 text-amber-300 border-amber-500/30', color: '#fbbf24' },
delete: { label: 'DELETE', cls: 'bg-rose-500/15 text-rose-300 border-rose-500/30' }, delete: { label: 'DELETE', cls: 'bg-rose-500/15 text-rose-300 border-rose-500/30', color: '#fb7185' },
snapshot: { label: 'SNAPSHOT', cls: 'bg-sky-500/15 text-sky-300 border-sky-500/30' }, snapshot: { label: 'SNAPSHOT', cls: 'bg-sky-500/15 text-sky-300 border-sky-500/30', color: '#38bdf8' },
} }
const SOURCE_COLOR: Record<string, string> = { const SOURCE_COLOR: Record<string, string> = {
@@ -21,9 +21,15 @@ const SOURCE_COLOR: Record<string, string> = {
const SOURCES = ['all', 'postgres', 'mysql', 'mongodb', 'cassandra', 'neo4j'] const SOURCES = ['all', 'postgres', 'mysql', 'mongodb', 'cassandra', 'neo4j']
const OPS = ['all', 'insert', 'update', 'delete'] const OPS = ['all', 'insert', 'update', 'delete']
const TIME_WINDOWS = [
{ label: '15 min', minutes: 15 },
{ label: '1 hour', minutes: 60 },
{ label: '6 hours', minutes: 360 },
{ label: '24 hours', minutes: 1440 },
] as const
function opOf(o: string) { function opOf(o: string) {
return OP_STYLE[o] || { label: o.toUpperCase(), cls: 'bg-slate-500/15 text-slate-300 border-slate-500/30' } return OP_STYLE[o] || { label: o.toUpperCase(), cls: 'bg-slate-500/15 text-slate-300 border-slate-500/30', color: '#94a3b8' }
} }
function timeAgo(ts: string) { function timeAgo(ts: string) {
@@ -34,6 +40,147 @@ function timeAgo(ts: string) {
return `${Math.floor(d / 3600000)}h ago` return `${Math.floor(d / 3600000)}h ago`
} }
// Smoothly animates a number toward its target so counters tick up nicely.
function useTween(target: number, ms = 700) {
const [val, setVal] = useState(target)
const from = useRef(target)
const start = useRef(0)
const raf = useRef(0)
useEffect(() => {
from.current = val
start.current = performance.now()
const step = (now: number) => {
const t = Math.min(1, (now - start.current) / ms)
const eased = 1 - Math.pow(1 - t, 3)
setVal(from.current + (target - from.current) * eased)
if (t < 1) raf.current = requestAnimationFrame(step)
}
raf.current = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf.current)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target, ms])
return val
}
function KpiCard({ label, value, accent, icon: Icon, sub }: {
label: string; value: number; accent: string; icon: typeof PlusCircle; sub?: string
}) {
const v = useTween(value)
return (
<div className="relative overflow-hidden rounded-lg border border-border/60 bg-surface-raised p-3">
<div className="absolute -right-3 -top-3 h-14 w-14 rounded-full opacity-[0.12] blur-xl" style={{ background: accent }} />
<div className="flex items-center gap-1.5 text-[9px] uppercase tracking-wide text-foreground-faint">
<Icon className="h-3 w-3" style={{ color: accent }} /> {label}
</div>
<div className="mt-1 text-2xl font-semibold tabular-nums text-foreground" style={{ textShadow: `0 0 18px ${accent}22` }}>
{Math.round(v).toLocaleString()}
</div>
{sub && <div className="text-[10px] text-foreground-muted">{sub}</div>}
</div>
)
}
// SVG donut for the operation mix.
function Donut({ segments, total }: { segments: { label: string; value: number; color: string }[]; total: number }) {
const R = 42
const C = 2 * Math.PI * R
let offset = 0
return (
<div className="flex items-center gap-4">
<svg viewBox="0 0 110 110" className="h-28 w-28 shrink-0 -rotate-90">
<circle cx="55" cy="55" r={R} fill="none" stroke="rgba(148,163,184,0.12)" strokeWidth="13" />
{total > 0 && segments.map((s) => {
const frac = s.value / total
const dash = frac * C
const el = (
<circle key={s.label} cx="55" cy="55" r={R} fill="none" stroke={s.color} strokeWidth="13"
strokeDasharray={`${dash} ${C - dash}`} strokeDashoffset={-offset} strokeLinecap="butt"
style={{ transition: 'stroke-dasharray .6s ease, stroke-dashoffset .6s ease' }} />
)
offset += dash
return el
})}
<g className="rotate-90" style={{ transformOrigin: '55px 55px' }}>
<text x="55" y="51" textAnchor="middle" className="fill-foreground text-[16px] font-semibold tabular-nums">{total.toLocaleString()}</text>
<text x="55" y="65" textAnchor="middle" className="fill-foreground-faint text-[7px] uppercase tracking-wider">changes</text>
</g>
</svg>
<div className="flex-1 space-y-1.5">
{segments.map((s) => (
<div key={s.label} className="flex items-center gap-2 text-[11px]">
<span className="h-2.5 w-2.5 rounded-sm" style={{ background: s.color }} />
<span className="capitalize text-foreground-muted">{s.label}</span>
<span className="ml-auto font-mono text-foreground">{s.value.toLocaleString()}</span>
<span className="w-9 text-right font-mono text-foreground-faint">{total ? Math.round((s.value / total) * 100) : 0}%</span>
</div>
))}
</div>
</div>
)
}
// Smooth area chart of per-minute change volume.
function VolumeArea({ buckets }: { buckets: { t: string; n: number }[] }) {
const w = 600
const h = 120
const pad = 6
const data = buckets.length ? buckets : [{ t: '', n: 0 }]
const max = Math.max(1, ...data.map((b) => b.n))
const stepX = data.length > 1 ? (w - pad * 2) / (data.length - 1) : 0
const pts = data.map((b, i) => {
const x = pad + i * stepX
const y = h - pad - (b.n / max) * (h - pad * 2)
return [x, y] as const
})
const line = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ')
const area = `${line} L${pts[pts.length - 1][0].toFixed(1)},${h - pad} L${pts[0][0].toFixed(1)},${h - pad} Z`
const last = data[data.length - 1]
return (
<div>
<div className="relative">
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="h-28 w-full">
<defs>
<linearGradient id="cdcvol" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#38bdf8" stopOpacity="0.45" />
<stop offset="100%" stopColor="#38bdf8" stopOpacity="0" />
</linearGradient>
</defs>
{[0.25, 0.5, 0.75].map((g) => (
<line key={g} x1={pad} x2={w - pad} y1={pad + g * (h - pad * 2)} y2={pad + g * (h - pad * 2)} stroke="rgba(148,163,184,0.08)" strokeWidth="1" />
))}
{buckets.length > 0 && <path d={area} fill="url(#cdcvol)" />}
{buckets.length > 0 && <path d={line} fill="none" stroke="#38bdf8" strokeWidth="2" vectorEffect="non-scaling-stroke" />}
{buckets.length > 0 && (
<circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r="3.5" fill="#38bdf8">
<animate attributeName="r" values="3.5;6;3.5" dur="1.6s" repeatCount="indefinite" />
</circle>
)}
</svg>
<div className="pointer-events-none absolute left-1.5 top-1 text-[9px] font-mono text-foreground-faint">{max}/min</div>
</div>
<div className="mt-1 flex justify-between text-[9px] font-mono text-foreground-faint">
<span>{data[0]?.t || '—'}</span>
<span className="text-docker">now · {last?.n ?? 0}/min</span>
</div>
{buckets.length === 0 && <div className="mt-1 text-center text-[10px] text-foreground-faint">No changes in the window yet</div>}
</div>
)
}
function BarRow({ label, value, max, color }: { label: string; value: number; max: number; color: string }) {
return (
<div className="flex items-center gap-2">
<span className="flex w-24 shrink-0 items-center gap-1.5 text-[11px] capitalize text-foreground-muted">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: color }} /> {label}
</span>
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-surface">
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${Math.max(3, (value / max) * 100)}%`, background: color }} />
</div>
<span className="w-12 shrink-0 text-right font-mono text-[11px] text-foreground">{value.toLocaleString()}</span>
</div>
)
}
function Diff({ change }: { change: CdcChange }) { function Diff({ change }: { change: CdcChange }) {
const keys = useMemo(() => { const keys = useMemo(() => {
const set = new Set<string>() const set = new Set<string>()
@@ -82,19 +229,82 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
const [op, setOp] = useState('all') const [op, setOp] = useState('all')
const [expanded, setExpanded] = useState<string | null>(null) const [expanded, setExpanded] = useState<string | null>(null)
const [connected, setConnected] = useState(false) const [connected, setConnected] = useState(false)
const [flash, setFlash] = useState(false)
const [resyncing, setResyncing] = useState(false)
const [resyncMsg, setResyncMsg] = useState<string | null>(null)
const [windowMin, setWindowMin] = useState(15)
// Live overlay: CDC events counted straight off the WebSocket stream since the
// last server stats snapshot. The top KPIs/charts = authoritative server stats
// (refreshed every 2.5s) + this overlay, so they move in lock-step with the
// bottom feed instead of lagging behind it.
const emptyOverlay = { total: 0, by_op: {} as Record<string, number>, by_source: {} as Record<string, number>, by_table: {} as Record<string, number> }
const [overlay, setOverlay] = useState(emptyOverlay)
const lastSeenId = useRef<string | null>(null)
const primed = useRef(false)
const applyStats = useCallback((s: CdcStats | null) => {
if (!s) return
setStats(s)
setOverlay({ total: 0, by_op: {}, by_source: {}, by_table: {} }) // server is now authoritative
}, [])
const load = useCallback(async () => { const load = useCallback(async () => {
const [c, s] = await Promise.all([fetchChanges({ limit: 150 }), fetchChangeStats(15)]) const [c, s] = await Promise.all([fetchChanges({ limit: 200, minutes: windowMin }), fetchChangeStats(windowMin)])
setSeed(c.changes) setSeed(c.changes)
setConnected(c.connected) setConnected(c.connected)
if (s) setStats(s) applyStats(s)
}, []) }, [applyStats, windowMin])
const doResync = useCallback(async () => {
setResyncing(true)
setResyncMsg('Restarting Debezium connectors…')
try {
const res = await resyncSources(true)
if (res) {
setResyncMsg(`Re-synced · ${res.healthy ?? 0}/${res.total ?? 0} connectors healthy`)
setTimeout(() => load(), 2500)
} else {
setResyncMsg('Re-sync failed — check ETL Guardian terminal')
}
} catch {
setResyncMsg('Re-sync failed — check ETL Guardian terminal')
} finally {
setResyncing(false)
setTimeout(() => setResyncMsg(null), 7000)
}
}, [load])
useEffect(() => { useEffect(() => {
load() load()
const iv = setInterval(() => fetchChangeStats(15).then((s) => s && setStats(s)), 5000) const iv = setInterval(() => fetchChangeStats(windowMin).then((s) => applyStats(s)), 2500)
return () => clearInterval(iv) return () => clearInterval(iv)
}, [load]) }, [load, applyStats, windowMin])
// Fold freshly-arrived WS changes into the overlay → instant top-of-page update.
useEffect(() => {
if (!liveChanges.length) return
if (!primed.current) {
primed.current = true
lastSeenId.current = liveChanges[0].id
return
}
const idx = liveChanges.findIndex((c) => c.id === lastSeenId.current)
const fresh = idx === -1 ? liveChanges : liveChanges.slice(0, idx)
if (!fresh.length) return
lastSeenId.current = liveChanges[0].id
setOverlay((o) => {
const next = { total: o.total + fresh.length, by_op: { ...o.by_op }, by_source: { ...o.by_source }, by_table: { ...o.by_table } }
for (const c of fresh) {
next.by_op[c.op] = (next.by_op[c.op] || 0) + 1
next.by_source[c.source] = (next.by_source[c.source] || 0) + 1
next.by_table[c.table] = (next.by_table[c.table] || 0) + 1
}
return next
})
setFlash(true)
const id = setTimeout(() => setFlash(false), 800)
return () => clearTimeout(id)
}, [liveChanges])
// Merge live (WS) with seeded backlog, dedupe by id, newest first. // Merge live (WS) with seeded backlog, dedupe by id, newest first.
const merged = useMemo(() => { const merged = useMemo(() => {
@@ -109,78 +319,150 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
[merged, source, op], [merged, source, op],
) )
const maxBucket = Math.max(1, ...(stats?.buckets || []).map((b) => b.n)) const opVal = (k: string) => (stats?.by_op?.[k] || 0) + (overlay.by_op[k] || 0)
const inserts = opVal('insert')
const updates = opVal('update')
const deletes = opVal('delete')
const total = (stats?.total ?? 0) + overlay.total
const perMin = total / Math.max(1, stats?.window_minutes ?? 15)
const opSegments = useMemo(() => (
['insert', 'update', 'delete', 'snapshot']
.map((k) => ({ label: k, value: (stats?.by_op?.[k] || 0) + (overlay.by_op[k] || 0), color: opOf(k).color }))
.filter((s) => s.value > 0)
), [stats?.by_op, overlay])
const sourceRows = useMemo(() => {
const m: Record<string, number> = { ...(stats?.by_source || {}) }
for (const [k, v] of Object.entries(overlay.by_source)) m[k] = (m[k] || 0) + v
return Object.entries(m).sort((a, b) => b[1] - a[1])
}, [stats?.by_source, overlay])
const maxSource = Math.max(1, ...sourceRows.map(([, v]) => v))
const tableRows = useMemo(() => {
const m: Record<string, number> = { ...(stats?.by_table || {}) }
for (const [k, v] of Object.entries(overlay.by_table)) m[k] = (m[k] || 0) + v
return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 7)
}, [stats?.by_table, overlay])
const maxTable = Math.max(1, ...tableRows.map(([, v]) => v))
// Volume chart: bump the current-minute bar with the live overlay so the curve
// visibly rises as changes stream in.
const liveBuckets = useMemo(() => {
const b = (stats?.buckets || []).map((x) => ({ ...x }))
if (overlay.total) {
if (b.length) b[b.length - 1] = { ...b[b.length - 1], n: b[b.length - 1].n + overlay.total }
else b.push({ t: 'now', n: overlay.total })
}
return b
}, [stats?.buckets, overlay.total])
return ( return (
<div className="flex h-full min-h-0 flex-col gap-3"> <div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto scrollbar-thin pr-1">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex shrink-0 items-center justify-between">
<div> <div>
<h1 className="flex items-center gap-2 text-base font-semibold text-foreground"> <h1 className="flex items-center gap-2 text-base font-semibold text-foreground">
<Activity className="h-4 w-4 text-docker" /> Live Changes · CDC Stream <Activity className={cn('h-4 w-4 text-docker', flash && 'animate-pulse')} /> New &amp; Changed Data
</h1> </h1>
<p className="text-[11px] text-foreground-muted"> <p className="text-[11px] text-foreground-muted">
Real-time Debezium change data capture from all source databases via Kafka Live Debezium change data capture every insert, update &amp; delete across all source databases, streamed via Kafka
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-1 rounded border border-border/60 p-0.5">
{TIME_WINDOWS.map((w) => (
<button
key={w.minutes}
type="button"
onClick={() => setWindowMin(w.minutes)}
className={cn(
'rounded px-2 py-0.5 text-[10px]',
windowMin === w.minutes
? 'bg-docker/20 text-docker'
: 'text-foreground-muted hover:text-foreground',
)}
>
{w.label}
</button>
))}
</div>
<span className={cn('flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-medium', <span className={cn('flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-medium',
connected ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-300' : 'border-rose-500/40 bg-rose-500/10 text-rose-300')}> connected ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-300' : 'border-rose-500/40 bg-rose-500/10 text-rose-300')}>
<Radio className={cn('h-3 w-3', connected && 'animate-pulse')} /> {connected ? 'STREAMING' : 'OFFLINE'} <Radio className={cn('h-3 w-3', connected && 'animate-pulse')} /> {connected ? 'STREAMING' : 'OFFLINE'}
</span> </span>
{resyncMsg && (
<span className="hidden text-[10px] text-amber-300/90 md:inline">{resyncMsg}</span>
)}
<button
type="button"
onClick={doResync}
disabled={resyncing}
title="Restart all Debezium source connectors so CDC catches up after a database outage"
className="flex items-center gap-1 rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1 text-[10px] font-medium text-amber-300 hover:bg-amber-500/20 disabled:opacity-60"
>
<Cable className={cn('h-3 w-3', resyncing && 'animate-spin')} /> {resyncing ? 'Re-syncing…' : 'Re-sync sources'}
</button>
<button type="button" onClick={load} className="flex items-center gap-1 rounded border border-border/60 px-2 py-1 text-[10px] text-foreground-muted hover:text-docker"> <button type="button" onClick={load} className="flex items-center gap-1 rounded border border-border/60 px-2 py-1 text-[10px] text-foreground-muted hover:text-docker">
<RefreshCw className="h-3 w-3" /> Refresh <RefreshCw className="h-3 w-3" /> Refresh
</button> </button>
</div> </div>
</div> </div>
{/* Stat cards */} {/* KPI row */}
<div className="grid shrink-0 grid-cols-2 gap-2 lg:grid-cols-4"> <div className="grid shrink-0 grid-cols-2 gap-2 lg:grid-cols-4">
<div className="rounded-lg border border-border/60 bg-surface-raised p-3"> <KpiCard label="New records" value={inserts} accent="#34d399" icon={PlusCircle} sub={`inserts · last ${windowMin}m`} />
<div className="text-[9px] uppercase tracking-wide text-foreground-faint">Changes / 15 min</div> <KpiCard label="Updates" value={updates} accent="#fbbf24" icon={Pencil} sub={`modified rows · ${windowMin}m`} />
<div className="text-xl font-semibold text-foreground">{stats?.total ?? 0}</div> <KpiCard label="Deletes" value={deletes} accent="#fb7185" icon={Trash2} sub={`removed rows · ${windowMin}m`} />
<KpiCard label="Throughput" value={Math.round(perMin)} accent="#38bdf8" icon={TrendingUp} sub={`changes/min · ${(stats?.consumed ?? 0).toLocaleString()} total consumed`} />
</div>
{/* Volume + operation mix */}
<div className="grid shrink-0 grid-cols-1 gap-2 lg:grid-cols-3">
<div className="rounded-lg border border-border/60 bg-surface-raised p-3 lg:col-span-2">
<div className="mb-1 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-foreground-faint">
<TrendingUp className="h-3 w-3" /> Change volume selected window
</div>
<VolumeArea buckets={liveBuckets} />
</div> </div>
<div className="rounded-lg border border-border/60 bg-surface-raised p-3"> <div className="rounded-lg border border-border/60 bg-surface-raised p-3">
<div className="text-[9px] uppercase tracking-wide text-foreground-faint">Total consumed</div> <div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-foreground-faint">
<div className="text-xl font-semibold text-foreground">{stats?.consumed ?? 0}</div> <Layers className="h-3 w-3" /> Operation mix
</div>
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
<div className="text-[9px] uppercase tracking-wide text-foreground-faint">By operation</div>
<div className="mt-1 flex flex-wrap gap-1">
{Object.entries(stats?.by_op || {}).map(([k, v]) => (
<span key={k} className={cn('rounded border px-1.5 py-0.5 text-[9px]', opOf(k).cls)}>{opOf(k).label} {v}</span>
))}
{!Object.keys(stats?.by_op || {}).length && <span className="text-[10px] text-foreground-faint"></span>}
</div>
</div>
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
<div className="text-[9px] uppercase tracking-wide text-foreground-faint">By source</div>
<div className="mt-1 flex flex-wrap gap-1">
{Object.entries(stats?.by_source || {}).map(([k, v]) => (
<span key={k} className="flex items-center gap-1 rounded border border-border/60 px-1.5 py-0.5 text-[9px] text-foreground-muted">
<span className="h-2 w-2 rounded-full" style={{ background: SOURCE_COLOR[k] || '#94a3b8' }} />{k} {v}
</span>
))}
{!Object.keys(stats?.by_source || {}).length && <span className="text-[10px] text-foreground-faint"></span>}
</div> </div>
{opSegments.length ? <Donut segments={opSegments} total={total} /> : (
<div className="flex h-28 items-center justify-center text-[10px] text-foreground-faint">No changes yet</div>
)}
</div> </div>
</div> </div>
{/* Volume sparkbars */} {/* By system + top tables */}
<div className="shrink-0 rounded-lg border border-border/60 bg-surface-raised p-3"> <div className="grid shrink-0 grid-cols-1 gap-2 lg:grid-cols-2">
<div className="mb-1.5 text-[9px] uppercase tracking-wide text-foreground-faint">Change volume per minute (last 15m)</div> <div className="rounded-lg border border-border/60 bg-surface-raised p-3">
<div className="flex h-16 items-end gap-0.5"> <div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-foreground-faint">
{(stats?.buckets || []).map((b) => ( <Database className="h-3 w-3" /> New &amp; changed by system
<div key={b.t} className="group relative flex-1" title={`${b.t}: ${b.n}`}> </div>
<div className="w-full rounded-t bg-docker/70 transition-all group-hover:bg-docker" style={{ height: `${Math.max(4, (b.n / maxBucket) * 100)}%` }} /> <div className="space-y-2">
{sourceRows.length ? sourceRows.map(([k, v]) => (
<BarRow key={k} label={k} value={v} max={maxSource} color={SOURCE_COLOR[k] || '#94a3b8'} />
)) : <div className="text-[10px] text-foreground-faint">No source activity in the window</div>}
</div>
</div>
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
<div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-foreground-faint">
<Layers className="h-3 w-3" /> Most active tables / collections
</div>
<div className="space-y-2">
{tableRows.length ? tableRows.map(([k, v]) => (
<BarRow key={k} label={k} value={v} max={maxTable} color="#818cf8" />
)) : <div className="text-[10px] text-foreground-faint">No table activity yet</div>}
</div> </div>
))}
{!(stats?.buckets || []).length && <div className="text-[10px] text-foreground-faint">No changes in the window yet</div>}
</div> </div>
</div> </div>
{/* Filters */} {/* Filters */}
<div className="flex shrink-0 flex-wrap items-center gap-2"> <div className="flex shrink-0 flex-wrap items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wide text-foreground-muted">Latest changes</span>
<span className="mx-1 h-3 w-px bg-border/60" />
<span className="text-[9px] uppercase tracking-wide text-foreground-faint">Source</span> <span className="text-[9px] uppercase tracking-wide text-foreground-faint">Source</span>
{SOURCES.map((s) => ( {SOURCES.map((s) => (
<button key={s} type="button" onClick={() => setSource(s)} <button key={s} type="button" onClick={() => setSource(s)}
@@ -200,10 +482,10 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
</div> </div>
{/* Live list */} {/* Live list */}
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin rounded-lg border border-border/60 bg-surface-raised"> <div className="min-h-[180px] rounded-lg border border-border/60 bg-surface-raised">
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="p-6 text-center text-[11px] text-foreground-faint"> <div className="p-6 text-center text-[11px] text-foreground-faint">
Waiting for changes trigger data generation or agent DML to see live CDC events. Waiting for changes trigger data generation (Data Flow Generate data) or agent DML to see live CDC events.
</div> </div>
)} )}
{filtered.map((c) => ( {filtered.map((c) => (
@@ -16,19 +16,32 @@ import {
HardDrive, HardDrive,
ShieldCheck, ShieldCheck,
Radio, Radio,
GitBranch,
Lock,
Layers,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { TrinoFederationView, type SubTab } from './TrinoFederationView' import { TrinoFederationView, type SubTab } from './TrinoFederationView'
import { LiveDashboard } from './LiveDashboard' import { LiveDashboard } from './LiveDashboard'
import { LineageView } from './LineageView'
import { GovernanceOwnershipView } from './GovernanceOwnershipView'
import { GovernanceAccessView } from './GovernanceAccessView'
import { ObservabilityView } from './ObservabilityView'
import { IcebergNessieView } from './IcebergNessieView'
type ExplorerTab = 'business' | 'live' | SubTab type ExplorerTab = 'business' | 'live' | SubTab | 'lineage' | 'ownership' | 'access' | 'observability' | 'iceberg'
const TABS: { id: ExplorerTab; label: string; icon: typeof Users; hint: string; live?: boolean }[] = [ 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' }, { id: 'business', label: 'Business Overview', icon: BarChart3, hint: 'Customers, orders, workforce, supply chain & telemetry across every source' },
{ id: 'live', label: 'Live', icon: Radio, hint: 'Realtime business activity — live counters, ingestion throughput & region matrix', live: true }, { id: 'live', label: 'Live', icon: Radio, hint: 'Realtime business activity — live counters, ingestion throughput & region matrix', live: true },
{ id: 'federated', label: 'Federated (Trino)', icon: Network, hint: 'One SQL across all 5 databases + region scorecard joined live' }, { 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: 'lake', label: 'Hadoop Lake', icon: HardDrive, hint: 'All business data mirrored as external Iceberg tables on HDFS' },
{ id: 'iceberg', label: 'Iceberg', icon: Layers, hint: 'Nessie catalog · snapshots · manifests · data files · time travel' },
{ id: 'dictionary', label: 'Data Dictionary', icon: ShieldCheck, hint: 'Every table & column with PII / masking status — exactly what the assistant sees' }, { 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 } type Bucket = { key: string; count: number; value?: number }
@@ -295,6 +308,13 @@ export function DataExplorerView() {
{/* Trino federation / lake / dictionary tabs */} {/* Trino federation / lake / dictionary tabs */}
{(view === 'federated' || view === 'lake' || view === 'dictionary') && <TrinoFederationView embedded activeTab={view} />} {(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>}
{view === 'iceberg' && <div className="flex min-h-0 flex-1 flex-col"><IcebergNessieView /></div>}
{/* ───────── BUSINESS OVERVIEW ───────── */} {/* ───────── BUSINESS OVERVIEW ───────── */}
{view === 'business' && ( {view === 'business' && (
<> <>
+51 -14
View File
@@ -22,6 +22,7 @@ const NODE_KIND: Record<string, { ring: string; chip: string; dot: string }> = {
rag: { ring: 'border-pink-400/60', chip: 'bg-pink-500/15 text-pink-300 border-pink-400/40', dot: '#f472b6' }, rag: { ring: 'border-pink-400/60', chip: 'bg-pink-500/15 text-pink-300 border-pink-400/40', dot: '#f472b6' },
llm: { ring: 'border-rose-400/70', chip: 'bg-rose-500/15 text-rose-200 border-rose-400/50', dot: '#fb7185' }, llm: { ring: 'border-rose-400/70', chip: 'bg-rose-500/15 text-rose-200 border-rose-400/50', dot: '#fb7185' },
chat: { ring: 'border-indigo-400/60', chip: 'bg-indigo-500/15 text-indigo-300 border-indigo-400/40', dot: '#818cf8' }, chat: { ring: 'border-indigo-400/60', chip: 'bg-indigo-500/15 text-indigo-300 border-indigo-400/40', dot: '#818cf8' },
catalog: { ring: 'border-lime-400/60', chip: 'bg-lime-500/15 text-lime-300 border-lime-400/40', dot: '#a3e635' },
} }
const EDGE_COLOR: Record<string, string> = { const EDGE_COLOR: Record<string, string> = {
@@ -206,10 +207,43 @@ export function DataFlowView() {
}, [genRows, load]) }, [genRows, load])
const flowMode = (graph as unknown as { flow?: string })?.flow ?? 'running' const flowMode = (graph as unknown as { flow?: string })?.flow ?? 'running'
const heartbeat = useCallback((active: boolean) => {
return fetch('/api/federated/live/heartbeat', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ active }),
}).catch(() => {})
}, [])
const onFlow = useCallback(async (action: 'pause' | 'resume' | 'stop') => { const onFlow = useCallback(async (action: 'pause' | 'resume' | 'stop') => {
await setStreamingFlow(action) await setStreamingFlow(action)
setTimeout(() => load(true), 300) // The master pulse also drives the live data generator: Run starts the
}, [load]) // stream into the sources, Pause/Stop halts it.
await heartbeat(action === 'resume')
if (action === 'resume') {
try {
const r = await fetch('/api/federated/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: 800 }),
})
const d = await r.json()
const i = d?.inserted || {}
setGenToast(`Generator running — seeded +${(i.orders ?? 0).toLocaleString()} orders, +${(i.telemetry ?? 0).toLocaleString()} telemetry, +${(i.hr_events ?? 0).toLocaleString()} HR, +${(i.supply_events ?? 0).toLocaleString()} supply; now streaming every few seconds via CDC`)
setTimeout(() => setGenToast(null), 7000)
} catch { /* ignore */ }
}
setTimeout(() => load(true), 400)
}, [load, heartbeat])
// While the flow is running and this tab is open, keep the generator alive
// (it stops a few seconds after you pause/stop or leave the tab).
useEffect(() => {
if (flowMode !== 'running') {
heartbeat(false)
return
}
heartbeat(true)
const t = setInterval(() => heartbeat(true), 5000)
return () => clearInterval(t)
}, [flowMode, heartbeat])
const [maskBusy, setMaskBusy] = useState<string | null>(null) const [maskBusy, setMaskBusy] = useState<string | null>(null)
const onToggleMask = useCallback(async (key: string, column: string, masked: boolean) => { const onToggleMask = useCallback(async (key: string, column: string, masked: boolean) => {
@@ -334,7 +368,7 @@ export function DataFlowView() {
> >
<FileCode2 className="h-3 w-3" /> Scripts <FileCode2 className="h-3 w-3" /> Scripts
</button> </button>
<div className="inline-flex items-center gap-0.5 rounded border border-border p-0.5" title="Master pulse control"> <div className="inline-flex items-center gap-0.5 rounded border border-border p-0.5" title="Master pulse + live generator: Run streams data into the sources, Pause/Stop halts it">
<button type="button" onClick={() => onFlow('resume')} <button type="button" onClick={() => onFlow('resume')}
className={cn('inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-medium transition-colors', flowMode === 'running' ? 'bg-emerald-500/25 text-emerald-200' : 'text-foreground-muted hover:text-foreground')}> className={cn('inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-medium transition-colors', flowMode === 'running' ? 'bg-emerald-500/25 text-emerald-200' : 'text-foreground-muted hover:text-foreground')}>
<Play className="h-3 w-3" /> Run <Play className="h-3 w-3" /> Run
@@ -392,9 +426,10 @@ export function DataFlowView() {
const b = anchors[edge.to] const b = anchors[edge.to]
if (!a || !b) return null if (!a || !b) return null
const d = edgePath(a, b) const d = edgePath(a, b)
const color = EDGE_COLOR[edge.kind] || '#64748b' const offline = Boolean((edge as any).offline)
const running = edge.state === 'running' const color = offline ? '#f43f5e' : (EDGE_COLOR[edge.kind] || '#64748b')
const active = edge.active || running const running = edge.state === 'running' && !offline
const active = Boolean(edge.active) && !offline
const dur = 1.6 + (i % 5) * 0.3 const dur = 1.6 + (i % 5) * 0.3
return ( return (
<g key={`${edge.from}-${edge.to}-${edge.kind}`}> <g key={`${edge.from}-${edge.to}-${edge.kind}`}>
@@ -402,9 +437,9 @@ export function DataFlowView() {
d={d} d={d}
fill="none" fill="none"
stroke={color} stroke={color}
strokeWidth={running ? 2.6 : 1.4} strokeWidth={running ? 2.6 : offline ? 1.8 : 1.4}
strokeOpacity={active ? 0.85 : 0.28} strokeOpacity={active ? 0.85 : offline ? 0.75 : 0.28}
strokeDasharray={edge.movement_id && !active ? '4 4' : undefined} strokeDasharray={offline || (edge.movement_id && !active) ? '4 4' : undefined}
/> />
{active && ( {active && (
<> <>
@@ -596,6 +631,7 @@ function NodeCard({
const kind = NODE_KIND[node.kind] || NODE_KIND.source const kind = NODE_KIND[node.kind] || NODE_KIND.source
const pii = node.pii const pii = node.pii
const showPii = piiOverlay && pii?.has_pii const showPii = piiOverlay && pii?.has_pii
const offline = (node as any).online === false || node.level === 'err'
return ( return (
<button <button
ref={setRef} ref={setRef}
@@ -603,9 +639,9 @@ function NodeCard({
onClick={onClick} onClick={onClick}
className={cn( className={cn(
'absolute z-10 flex w-[116px] -translate-x-1/2 -translate-y-1/2 flex-col items-start gap-0.5 rounded-lg border bg-[#0f1830]/90 px-2 py-1.5 text-left shadow-md backdrop-blur transition-all hover:scale-[1.03]', 'absolute z-10 flex w-[116px] -translate-x-1/2 -translate-y-1/2 flex-col items-start gap-0.5 rounded-lg border bg-[#0f1830]/90 px-2 py-1.5 text-left shadow-md backdrop-blur transition-all hover:scale-[1.03]',
kind.ring, offline ? 'border-rose-500 ring-2 ring-rose-500/70' : kind.ring,
selected && 'ring-2 ring-emerald-400/70', selected && !offline && 'ring-2 ring-emerald-400/70',
showPii && !pii?.all_masked && 'ring-2 ring-rose-400/60', showPii && !pii?.all_masked && !offline && 'ring-2 ring-rose-400/60',
)} )}
style={{ left: `${node.x}%`, top: `${node.y}%` }} style={{ left: `${node.x}%`, top: `${node.y}%` }}
> >
@@ -619,8 +655,9 @@ function NodeCard({
</div> </div>
<span className="block w-full truncate text-[7px] leading-tight text-blue-100/70">{node.sub}</span> <span className="block w-full truncate text-[7px] leading-tight text-blue-100/70">{node.sub}</span>
{node.metric && ( {node.metric && (
<span className={cn('inline-block max-w-full truncate rounded border px-0.5 py-px font-mono text-[6px] leading-tight', kind.chip)}> <span className={cn('inline-block max-w-full truncate rounded border px-0.5 py-px font-mono text-[6px] leading-tight',
{node.metric} offline ? 'border-rose-400/50 bg-rose-500/20 text-rose-200' : kind.chip)}>
{offline ? `offline · ${node.metric}` : node.metric}
</span> </span>
)} )}
{showPii && ( {showPii && (
@@ -11,9 +11,11 @@ import {
Table2, Table2,
Upload, Upload,
XCircle, XCircle,
Gauge,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive' import { subTabActive, subTabIdle } from '../../lib/tabActive'
import { DqMonitoringPanel } from './DqMonitoringPanel'
type Dimension = { type Dimension = {
id: string id: string
@@ -167,13 +169,13 @@ type ReportSummary = {
columns: number 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 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') const BAR_COLOR = (s: number) => (s >= 80 ? 'bg-success' : s >= 60 ? 'bg-warning' : 'bg-danger')
export function DataQualityView() { export function DataQualityView() {
const [tab, setTab] = useState<Tab>('assess') const [tab, setTab] = useState<Tab>('monitoring')
const [caps, setCaps] = useState<Capabilities | null>(null) const [caps, setCaps] = useState<Capabilities | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [assess, setAssess] = useState<AssessResult | null>(null) const [assess, setAssess] = useState<AssessResult | null>(null)
@@ -252,6 +254,7 @@ export function DataQualityView() {
} }
const tabs: { id: Tab; label: string; icon: typeof FileSearch }[] = [ const tabs: { id: Tab; label: string; icon: typeof FileSearch }[] = [
{ id: 'monitoring', label: 'Live Monitoring', icon: Gauge },
{ id: 'assess', label: 'Maturity Assessment', icon: FileSearch }, { id: 'assess', label: 'Maturity Assessment', icon: FileSearch },
{ id: 'docling', label: 'Docling Parser', icon: FileText }, { id: 'docling', label: 'Docling Parser', icon: FileText },
{ id: 'reports', label: 'Reports', icon: CheckCircle2 }, { id: 'reports', label: 'Reports', icon: CheckCircle2 },
@@ -312,6 +315,8 @@ export function DataQualityView() {
</div> </div>
)} )}
{tab === 'monitoring' && <DqMonitoringPanel />}
{tab === 'assess' && ( {tab === 'assess' && (
<div className="space-y-5"> <div className="space-y-5">
<UploadZone <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>
)
}
+213 -5
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Activity, ChevronDown, ChevronUp, Cpu, ExternalLink, Zap } from 'lucide-react' import { Activity, ChevronDown, ChevronUp, Cpu, ExternalLink, Settings2, Zap } from 'lucide-react'
import { fetchGpu } from '../../lib/api' import { fetchGpu, fetchGpuConfig, resetGpuConfig, saveGpuConfig, testGpuConfig } from '../../lib/api'
import type { GpuDevice, GpuStatus } from '../../types' import type { GpuConfigPayload, GpuConfigTestResult, GpuDevice, GpuStatus } from '../../types'
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics' import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
@@ -53,6 +53,13 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
const [localGpu, setLocalGpu] = useState<GpuStatus | null>(gpu) const [localGpu, setLocalGpu] = useState<GpuStatus | null>(gpu)
const [lastPoll, setLastPoll] = useState<Date | null>(null) const [lastPoll, setLastPoll] = useState<Date | null>(null)
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [gpuConfig, setGpuConfig] = useState<GpuConfigPayload | null>(null)
const [selectedPreset, setSelectedPreset] = useState('gpu-prod')
const [customHost, setCustomHost] = useState('')
const [configBusy, setConfigBusy] = useState(false)
const [testResult, setTestResult] = useState<GpuConfigTestResult | null>(null)
const [configMsg, setConfigMsg] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
setLocalGpu(gpu) setLocalGpu(gpu)
@@ -62,6 +69,19 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
if (boost) setExpanded(true) if (boost) setExpanded(true)
}, [boost]) }, [boost])
useEffect(() => {
if (!showSettings) return
fetchGpuConfig().then((cfg) => {
if (!cfg) return
setGpuConfig(cfg)
const active = cfg.active
setSelectedPreset(active.preset_id === 'env' ? 'gpu-prod' : active.preset_id)
if (active.preset_id === 'custom' || active.source === 'override') {
setCustomHost(active.host)
}
})
}, [showSettings])
useEffect(() => { useEffect(() => {
const poll = async () => { const poll = async () => {
const g = await fetchGpu() const g = await fetchGpu()
@@ -76,6 +96,49 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
return () => clearInterval(iv) return () => clearInterval(iv)
}, [boost]) }, [boost])
const handleTestTarget = async () => {
setConfigBusy(true)
setTestResult(null)
setConfigMsg(null)
const body =
selectedPreset === 'custom'
? { preset_id: 'custom', host: customHost.trim() }
: { preset_id: selectedPreset }
const result = await testGpuConfig(body)
setTestResult(result)
setConfigBusy(false)
}
const handleSaveTarget = async () => {
setConfigBusy(true)
setConfigMsg(null)
const body =
selectedPreset === 'custom'
? { preset_id: 'custom', host: customHost.trim() }
: { preset_id: selectedPreset }
const res = await saveGpuConfig(body)
setConfigBusy(false)
if (res?.active) {
setConfigMsg(`Saved → ${res.active.label}`)
const g = await fetchGpu()
if (g) setLocalGpu(g)
} else {
setConfigMsg(res?.detail || 'Save failed')
}
}
const handleResetTarget = async () => {
setConfigBusy(true)
await resetGpuConfig()
setSelectedPreset('gpu-prod')
setCustomHost('')
setTestResult(null)
setConfigMsg('Reset to environment default')
const g = await fetchGpu()
if (g) setLocalGpu(g)
setConfigBusy(false)
}
const g = localGpu const g = localGpu
const devices = g?.gpus || [] const devices = g?.gpus || []
const inferenceOn = g?.ok && g.inference_active const inferenceOn = g?.ok && g.inference_active
@@ -102,7 +165,31 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
<h2 className="flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint"> <h2 className="flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">
<Cpu className="h-3 w-3" /> GPU Matrix <Cpu className="h-3 w-3" /> GPU Matrix
</h2> </h2>
<p className="mt-1 text-[9px] text-foreground-faint">GPU Lab offline</p> <p className="mt-1 text-[9px] text-foreground-faint">
GPU Lab offline{g?.host ? ` · ${g.ip || g.host}` : ''}
</p>
<button
type="button"
onClick={() => setShowSettings((v) => !v)}
className="mt-1 flex items-center gap-1 text-[8px] text-docker hover:underline"
>
<Settings2 className="h-2.5 w-2.5" /> GPU target
</button>
{showSettings && gpuConfig && (
<GpuTargetSettings
gpuConfig={gpuConfig}
selectedPreset={selectedPreset}
customHost={customHost}
configBusy={configBusy}
testResult={testResult}
configMsg={configMsg}
onPresetChange={setSelectedPreset}
onCustomHostChange={setCustomHost}
onTest={handleTestTarget}
onSave={handleSaveTarget}
onReset={handleResetTarget}
/>
)}
</section> </section>
) )
} }
@@ -130,6 +217,17 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
)} )}
</button> </button>
<div className="flex shrink-0 items-center gap-0.5"> <div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
onClick={() => setShowSettings((v) => !v)}
className={cn(
'rounded p-1 hover:bg-surface-overlay',
showSettings ? 'text-docker' : 'text-foreground-muted hover:text-foreground',
)}
title="GPU target settings"
>
<Settings2 className="h-3 w-3" />
</button>
{g.ui_url && ( {g.ui_url && (
<a <a
href={g.ui_url} href={g.ui_url}
@@ -207,11 +305,121 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
</div> </div>
<p className="font-mono text-[7px] text-foreground-faint"> <p className="font-mono text-[7px] text-foreground-faint">
{g.gpu_count ?? devices.length}× V100 · {g.host} · poll {boost ? '1s' : '3s'} {g.gpu_count ?? devices.length}× V100 · {g.ip || g.host}
{g.config_label && g.config_source === 'override' ? ` · ${g.config_label}` : ''}
{' · poll '}{boost ? '1s' : '3s'}
{lastPoll && ` · ${lastPoll.toLocaleTimeString()}`} {lastPoll && ` · ${lastPoll.toLocaleTimeString()}`}
</p> </p>
{showSettings && gpuConfig && (
<GpuTargetSettings
gpuConfig={gpuConfig}
selectedPreset={selectedPreset}
customHost={customHost}
configBusy={configBusy}
testResult={testResult}
configMsg={configMsg}
onPresetChange={setSelectedPreset}
onCustomHostChange={setCustomHost}
onTest={handleTestTarget}
onSave={handleSaveTarget}
onReset={handleResetTarget}
/>
)}
</div> </div>
)} )}
</section> </section>
) )
} }
type GpuTargetSettingsProps = {
gpuConfig: GpuConfigPayload
selectedPreset: string
customHost: string
configBusy: boolean
testResult: GpuConfigTestResult | null
configMsg: string | null
onPresetChange: (id: string) => void
onCustomHostChange: (host: string) => void
onTest: () => void
onSave: () => void
onReset: () => void
}
function GpuTargetSettings({
gpuConfig,
selectedPreset,
customHost,
configBusy,
testResult,
configMsg,
onPresetChange,
onCustomHostChange,
onTest,
onSave,
onReset,
}: GpuTargetSettingsProps) {
return (
<div className="mt-1.5 rounded border border-border/80 bg-surface-overlay/40 p-2 space-y-1.5">
<p className="text-[8px] font-semibold uppercase tracking-wider text-foreground-faint">GPU Target</p>
<select
value={selectedPreset}
onChange={(e) => onPresetChange(e.target.value)}
className="w-full rounded border border-border bg-surface px-1.5 py-1 font-mono text-[9px] text-foreground"
>
{gpuConfig.presets.map((p) => (
<option key={p.id} value={p.id}>
{p.label} ({p.host})
</option>
))}
<option value="custom">Custom IP</option>
</select>
{selectedPreset === 'custom' && (
<input
type="text"
value={customHost}
onChange={(e) => onCustomHostChange(e.target.value)}
placeholder="10.0.x.x"
className="w-full rounded border border-border bg-surface px-1.5 py-1 font-mono text-[9px] text-foreground"
/>
)}
<div className="flex flex-wrap gap-1">
<button
type="button"
disabled={configBusy}
onClick={onTest}
className="rounded border border-border px-2 py-0.5 font-mono text-[8px] text-foreground-muted hover:bg-surface-overlay disabled:opacity-50"
>
Test
</button>
<button
type="button"
disabled={configBusy}
onClick={onSave}
className="rounded border border-docker/40 bg-docker/10 px-2 py-0.5 font-mono text-[8px] text-docker hover:bg-docker/20 disabled:opacity-50"
>
Save
</button>
<button
type="button"
disabled={configBusy}
onClick={onReset}
className="rounded border border-border px-2 py-0.5 font-mono text-[8px] text-foreground-faint hover:bg-surface-overlay disabled:opacity-50"
>
Reset
</button>
</div>
{testResult && (
<p className={cn('font-mono text-[8px]', testResult.ok ? 'text-success' : 'text-warning')}>
{testResult.ok
? `OK · ${testResult.gpu_count} GPU(s) · ${testResult.active_model || 'no model'}`
: `Failed · ${testResult.errors.join('; ') || 'unreachable'}`}
</p>
)}
{configMsg && <p className="font-mono text-[8px] text-foreground-muted">{configMsg}</p>}
<p className="font-mono text-[7px] text-foreground-faint">
Active: {gpuConfig.active.label} ({gpuConfig.active.host})
</p>
</div>
)
}
@@ -0,0 +1,302 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ExternalLink,
GitBranch,
Layers,
Loader2,
RefreshCw,
RotateCcw,
FileBox,
HardDrive,
AlertTriangle,
} from 'lucide-react'
import { cn } from '../../lib/utils'
type NessieTable = { schema: string; table: string; key: string; trino_fqn: string }
type Snapshot = { snapshot_id: string; committed_at?: string | null; operation?: string | null }
type Manifest = {
path: string
length?: number
added_snapshot_id?: string | null
added_data_files_count?: number
existing_data_files_count?: number
}
type DataFile = { path: string; format?: string; record_count?: number; size_bytes?: number; is_s3?: boolean }
type Commit = { hash?: string; message?: string; author?: string; commitTime?: string }
type S3Obj = { key: string; size?: number; last_modified?: string | null; format?: string }
function fmtBytes(n?: number | null) {
if (n == null) return '—'
if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GB`
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} MB`
if (n >= 1e3) return `${(n / 1e3).toFixed(1)} KB`
return `${n} B`
}
export function IcebergNessieView() {
const [loading, setLoading] = useState(true)
const [health, setHealth] = useState<any>(null)
const [tables, setTables] = useState<NessieTable[]>([])
const [commits, setCommits] = useState<Commit[]>([])
const [sel, setSel] = useState<NessieTable | null>(null)
const [structure, setStructure] = useState<any>(null)
const [snapId, setSnapId] = useState<string | null>(null)
const [s3objs, setS3objs] = useState<S3Obj[]>([])
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setErr(null)
try {
const [h, c, hist, s3] = await Promise.all([
fetch('/api/nessie/health').then((r) => r.json()),
fetch('/api/nessie/contents').then((r) => r.json()),
fetch('/api/nessie/history?max_records=30').then((r) => r.json()),
fetch('/api/nessie/s3/parquet?limit=80').then((r) => r.json()),
])
setHealth(h)
setTables(c.tables || [])
setCommits(hist.commits || [])
setS3objs(s3.objects || [])
if (!sel && (c.tables || []).length) setSel(c.tables[0])
} catch (e: any) {
setErr(String(e?.message || e))
} finally {
setLoading(false)
}
}, [sel])
const loadStructure = useCallback(async (t: NessieTable, snapshot?: string | null) => {
setBusy(true)
try {
const q = snapshot ? `?snapshot_id=${encodeURIComponent(snapshot)}` : ''
const data = await fetch(`/api/nessie/tables/${t.schema}/${t.table}/structure${q}`).then((r) => r.json())
setStructure(data)
setSnapId(data.selected_snapshot || null)
} catch (e: any) {
setStructure({ ok: false, error: String(e?.message || e) })
} finally {
setBusy(false)
}
}, [])
useEffect(() => {
void load()
}, [])
useEffect(() => {
if (sel) void loadStructure(sel, null)
}, [sel?.key])
const restore = async () => {
if (!sel || !snapId) return
if (!confirm(`Restore ${sel.schema}.${sel.table} to snapshot ${snapId}?\n\nOnly works if old data files still exist. Nessie cannot undelete wiped S3 objects.`)) return
setBusy(true)
try {
const res = await fetch(`/api/nessie/tables/${sel.schema}/${sel.table}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ snapshot_id: snapId }),
}).then((r) => r.json())
setToast(res.ok ? `Restored to ${snapId}` : (res.hint || res.error || 'Restore failed'))
if (res.as_of_sql) setToast((t) => `${t || ''}\n${res.as_of_sql}`)
await loadStructure(sel, snapId)
} finally {
setBusy(false)
}
}
const uiUrl = health?.nessie_ui || 'http://10.0.21.50:19120'
const snapshots: Snapshot[] = structure?.snapshots || []
const manifests: Manifest[] = structure?.manifests || []
const files: DataFile[] = structure?.files || []
return (
<div className="flex min-h-0 flex-1 flex-col gap-2 p-2">
<div className="flex shrink-0 items-center gap-2 border-b border-border pb-2">
<Layers className="h-4 w-4 text-docker" />
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold text-foreground">Iceberg · Nessie catalog</div>
<div className="truncate text-[10px] text-foreground-muted">
Catalog versioning (Nessie) + manifest data files (Iceberg via Trino)
{health?.nessie_ok ? ' · Nessie up' : ' · Nessie down'}
{health?.trino_ok ? ' · Trino up' : ' · Trino down'}
</div>
</div>
<a
href={uiUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-docker hover:bg-docker/10"
>
Nessie UI <ExternalLink className="h-3 w-3" />
</a>
<button
type="button"
onClick={() => void load()}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] hover:bg-surface-overlay"
>
<RefreshCw className={cn('h-3 w-3', loading && 'animate-spin')} /> Refresh
</button>
</div>
{err && (
<div className="rounded border border-rose-500/40 bg-rose-500/10 px-2 py-1 text-[10px] text-rose-200">{err}</div>
)}
{toast && (
<div className="whitespace-pre-wrap rounded border border-amber-400/30 bg-amber-500/10 px-2 py-1 text-[10px] text-amber-100">{toast}</div>
)}
{loading ? (
<div className="flex flex-1 items-center justify-center text-[10px] text-foreground-muted">
<Loader2 className="mr-1 h-3 w-3 animate-spin" /> loading Nessie / Iceberg
</div>
) : (
<div className="grid min-h-0 flex-1 gap-2 lg:grid-cols-[220px_1fr_1fr]">
{/* Catalog */}
<div className="flex min-h-0 flex-col rounded border border-border bg-surface/40">
<div className="border-b border-border px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-foreground-muted">
Tables on main
</div>
<div className="min-h-0 flex-1 overflow-auto p-1">
{tables.length === 0 && (
<div className="p-2 text-[10px] text-foreground-faint">No ICEBERG_TABLE entries in Nessie yet.</div>
)}
{tables.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setSel(t)}
className={cn(
'mb-0.5 w-full rounded px-2 py-1.5 text-left text-[11px]',
sel?.key === t.key ? 'bg-docker/15 text-docker' : 'hover:bg-surface-overlay text-foreground-muted',
)}
>
<div className="font-medium text-foreground">{t.table}</div>
<div className="text-[9px] opacity-70">{t.schema}</div>
</button>
))}
</div>
<div className="border-t border-border px-2 py-1.5">
<div className="mb-1 flex items-center gap-1 text-[9px] font-semibold uppercase text-foreground-muted">
<GitBranch className="h-3 w-3" /> Nessie commits
</div>
<div className="max-h-28 overflow-auto space-y-1">
{commits.map((c, i) => (
<div key={c.hash || i} className="rounded bg-surface-overlay/60 px-1.5 py-1 text-[9px]">
<div className="truncate text-foreground">{c.message || '(no message)'}</div>
<div className="font-mono text-foreground-faint">{(c.hash || '').slice(0, 10)} · {c.commitTime || '—'}</div>
</div>
))}
</div>
</div>
</div>
{/* Timeline + structure */}
<div className="flex min-h-0 flex-col rounded border border-border bg-surface/40">
<div className="flex items-center gap-2 border-b border-border px-2 py-1.5">
<div className="min-w-0 flex-1 text-[10px] font-semibold uppercase tracking-wide text-foreground-muted">
Timeline · {sel ? `${sel.schema}.${sel.table}` : '—'}
</div>
{busy && <Loader2 className="h-3 w-3 animate-spin text-foreground-muted" />}
<button
type="button"
disabled={!snapId || busy}
onClick={() => void restore()}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] hover:bg-rose-500/10 disabled:opacity-40"
title="Rollback table pointer to selected snapshot"
>
<RotateCcw className="h-3 w-3" /> Restore
</button>
</div>
<div className="max-h-36 shrink-0 overflow-auto border-b border-border p-1">
{snapshots.length === 0 && (
<div className="p-2 text-[10px] text-foreground-faint">No snapshots (table missing in Trino iceberg catalog?)</div>
)}
{snapshots.map((s) => (
<button
key={s.snapshot_id}
type="button"
onClick={() => {
setSnapId(s.snapshot_id)
if (sel) void loadStructure(sel, s.snapshot_id)
}}
className={cn(
'mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[10px]',
snapId === s.snapshot_id ? 'bg-emerald-500/15 text-emerald-200' : 'hover:bg-surface-overlay',
)}
>
<span className="font-mono text-[9px] opacity-70">{s.snapshot_id.slice(0, 12)}</span>
<span className="flex-1 truncate">{s.operation || '—'}</span>
<span className="text-foreground-faint">{s.committed_at || ''}</span>
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-auto p-2">
<div className="mb-2 flex items-start gap-1 rounded border border-amber-400/20 bg-amber-500/5 px-2 py-1 text-[9px] text-amber-100/90">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
{structure?.restore_warning ||
'Restore only re-points metadata. Hard-deleted S3/disk files cannot be recovered via Nessie.'}
</div>
<div className="mb-1 text-[10px] font-semibold text-foreground-muted">Manifests ({manifests.length})</div>
{manifests.slice(0, 40).map((m, i) => (
<div key={i} className="mb-1 rounded bg-surface-overlay/50 px-2 py-1 font-mono text-[9px] text-foreground-muted">
<div className="truncate text-foreground">{m.path}</div>
<div>
+{m.added_data_files_count ?? 0} / exist {m.existing_data_files_count ?? 0} · {fmtBytes(m.length)}
</div>
</div>
))}
{structure?.as_of_sql && (
<pre className="mt-2 overflow-auto rounded border border-border bg-black/30 p-2 text-[9px] text-emerald-200/90">
{structure.as_of_sql}
</pre>
)}
{structure?.error && <div className="text-[10px] text-rose-300">{structure.error}</div>}
</div>
</div>
{/* Files */}
<div className="flex min-h-0 flex-col gap-2">
<div className="flex min-h-0 flex-1 flex-col rounded border border-border bg-surface/40">
<div className="border-b border-border px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-foreground-muted">
<span className="inline-flex items-center gap-1"><FileBox className="h-3 w-3" /> Data files ({files.length})</span>
</div>
<div className="min-h-0 flex-1 overflow-auto p-1">
{files.map((f, i) => (
<div key={i} className="mb-1 rounded px-2 py-1 text-[10px] hover:bg-surface-overlay">
<div className="truncate font-mono text-[9px] text-foreground">{f.path}</div>
<div className="text-foreground-muted">
{f.format || '?'} · {f.record_count?.toLocaleString?.() ?? f.record_count} rows · {fmtBytes(f.size_bytes)}
{f.is_s3 ? ' · S3' : ' · local'}
</div>
</div>
))}
{!files.length && <div className="p-2 text-[10px] text-foreground-faint">No data files for this table view.</div>}
</div>
</div>
<div className="flex max-h-48 flex-col rounded border border-border bg-surface/40">
<div className="border-b border-border px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-foreground-muted">
<span className="inline-flex items-center gap-1"><HardDrive className="h-3 w-3" /> Raw S3 parquet/orc</span>
</div>
<div className="min-h-0 flex-1 overflow-auto p-1">
{s3objs.map((o) => (
<div key={o.key} className="truncate px-2 py-0.5 font-mono text-[9px] text-foreground-muted">
{o.key} <span className="text-foreground-faint">· {fmtBytes(o.size)}</span>
</div>
))}
{!s3objs.length && (
<div className="p-2 text-[10px] text-foreground-faint">
No objects under lake/,iceberg/ (or S3 creds missing). Use Object Storage for full bucket browse.
</div>
)}
</div>
</div>
</div>
</div>
)}
</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>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { Box, LogIn } from 'lucide-react'
import { useMemo } from 'react'
export function LoginView() {
const error = useMemo(() => {
try {
const p = new URLSearchParams(window.location.search)
const err = p.get('error')
if (!err) return null
if (err === 'login_failed') return 'Sign-in failed — try again.'
return err
} catch {
return null
}
}, [])
return (
<div className="flex h-full min-h-screen flex-col items-center justify-center bg-surface px-4">
<div className="w-full max-w-md border border-border bg-surface-raised/90 p-8 shadow-panel backdrop-blur-sm">
<div className="mb-6 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-docker to-blue-600 shadow-docker">
<Box className="h-5 w-5 text-white" />
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-foreground-muted">ATC Lab</p>
<h1 className="text-lg font-semibold text-foreground">Data & AI Command Center</h1>
</div>
</div>
<p className="mb-6 text-sm text-foreground-muted">
Sign in with Authentik to open the ops glass. Lab environment not an official Dell product.
</p>
<a
href="/auth/login"
className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-docker px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90"
>
<LogIn className="h-4 w-4" />
Continue with Authentik
</a>
{error ? (
<p className="mt-4 text-sm text-warning" role="alert">
{error}
</p>
) : null}
</div>
</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>
)
}
+116 -1
View File
@@ -184,6 +184,118 @@ function Panel({ title, icon: Icon, children, className, right }: { title: strin
/* ── Main ────────────────────────────────────────────────────────────────── */ /* ── Main ────────────────────────────────────────────────────────────────── */
type EtlDataset = {
key: string; label: string; engine: string; color: string; parts: number; rows: number; bytes: number
backfilled: boolean; total_source: number | null; last_ts: string | null; last_rows: number
last_key: string | null; error: string | null; progress_pct: number | null
}
type EtlStatus = {
ok: boolean; enabled: boolean; interval_s: number; chunk: number; running_cycle: boolean; cycles: number
last_cycle_rows: number; totals: { parts: number; rows: number; bytes: number }; rate_rows_per_min: number
datasets: EtlDataset[]; series: { t: string; rows: number; bytes: number; orders: number; revenue: number }[]
feed: { ts: string; text: string; level: string }[]
}
function EtlIngestPanel() {
const [etl, setEtl] = useState<EtlStatus | null>(null)
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
try { const r = await fetch('/api/etl/status'); if (r.ok) setEtl(await r.json()) } catch { /* */ }
}, [])
useEffect(() => { load(); const t = setInterval(load, 5000); return () => clearInterval(t) }, [load])
const runNow = async () => {
setBusy(true)
try { await fetch('/api/etl/run', { method: 'POST' }) } catch { /* */ }
setTimeout(() => { load(); setBusy(false) }, 900)
}
const cfg = async (body: Record<string, unknown>) => {
await fetch('/api/etl/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
load()
}
const ds = etl?.datasets || []
return (
<Panel
title="Lakehouse ETL · source databases → S3 Parquet (realtime offload)"
icon={Boxes}
right={
<span className="flex items-center gap-2 text-[9px]">
<span className={cn('inline-flex items-center gap-1 rounded-full border px-2 py-0.5',
etl?.running_cycle ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-300'
: etl?.enabled ? 'border-sky-500/40 bg-sky-500/10 text-sky-300'
: 'border-border text-foreground-faint')}>
<span className={cn('h-1.5 w-1.5 rounded-full', etl?.running_cycle ? 'animate-ping bg-emerald-400' : etl?.enabled ? 'bg-sky-400' : 'bg-foreground-faint')} />
{etl?.running_cycle ? 'OFFLOADING' : etl?.enabled ? 'STREAMING' : 'PAUSED'}
</span>
<span className="text-foreground-faint">{etl?.cycles ?? 0} cycles · {fmtNum(etl?.rate_rows_per_min || 0)} rows/min</span>
</span>
}
>
<div className="mb-2 flex flex-wrap items-center gap-2 text-[10px]">
<span className="text-foreground-muted">A background ETL agent pulls small chunks from every source and writes partitioned Parquet to <span className="font-mono text-docker">s3://data/lake/</span> every</span>
<select value={etl?.interval_s ?? 60} onChange={(e) => cfg({ interval_s: Number(e.target.value) })}
className="rounded border border-border bg-surface px-1.5 py-0.5 font-mono text-foreground">
{[30, 60, 120, 300, 600].map((v) => <option key={v} value={v}>{v >= 60 ? `${v / 60} min` : `${v}s`}</option>)}
</select>
<button type="button" onClick={() => cfg({ enabled: !etl?.enabled })}
className={cn('rounded border px-2 py-0.5', etl?.enabled ? 'border-amber-500/40 text-amber-300' : 'border-emerald-500/40 text-emerald-300')}>
{etl?.enabled ? 'Pause' : 'Resume'}
</button>
<button type="button" onClick={runNow} disabled={busy}
className="inline-flex items-center gap-1 rounded border border-docker/40 bg-docker/10 px-2 py-0.5 text-docker disabled:opacity-50">
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Activity className="h-3 w-3" />} Offload now
</button>
<span className="ml-auto font-mono text-foreground-faint">
{fmtNum(etl?.totals.parts || 0)} parts · {fmtNum(etl?.totals.rows || 0)} rows · {fmtBytes(etl?.totals.bytes || 0)}
</span>
</div>
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
{ds.map((d) => (
<div key={d.key} className="rounded-lg border border-border/60 bg-surface-overlay/40 p-2.5">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-[10px] font-semibold text-foreground">
<span className="h-2 w-2 rounded-full" style={{ background: d.color }} /> {d.label}
</span>
{d.backfilled
? <span className="rounded bg-emerald-500/15 px-1 py-0.5 text-[8px] font-medium text-emerald-300">TAILING</span>
: <span className="rounded bg-sky-500/15 px-1 py-0.5 text-[8px] font-medium text-sky-300">BACKFILL</span>}
</div>
<p className="mt-0.5 text-[8px] uppercase tracking-wide text-foreground-faint">{d.engine}</p>
<p className="mt-1 font-mono text-base font-bold leading-none text-foreground">{fmtNum(d.rows)}</p>
<p className="text-[9px] text-foreground-faint">rows · {fmtNum(d.parts)} parts · {fmtBytes(d.bytes)}</p>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-surface">
<div className="h-full rounded-full transition-all" style={{ width: `${d.progress_pct ?? (d.backfilled ? 100 : 3)}%`, background: d.color }} />
</div>
<p className="mt-0.5 flex justify-between text-[8px] text-foreground-faint">
<span>{d.progress_pct != null ? `${d.progress_pct}% of ${fmtNum(d.total_source || 0)}` : 'streaming'}</span>
{d.last_rows ? <span className="text-emerald-400">+{fmtNum(d.last_rows)}</span> : null}
</p>
{d.error && <p className="mt-0.5 truncate text-[8px] text-danger" title={d.error}>{d.error}</p>}
</div>
))}
</div>
<div className="mt-2 grid grid-cols-1 gap-2 lg:grid-cols-3">
<div className="lg:col-span-2">
<p className="mb-1 text-[9px] uppercase tracking-wide text-foreground-faint">Rows offloaded per cycle (realtime)</p>
<Sparkline values={(etl?.series || []).map((p) => p.rows)} color="#34d399" />
</div>
<div>
<p className="mb-1 text-[9px] uppercase tracking-wide text-foreground-faint">ETL agent activity</p>
<div className="max-h-[78px] space-y-0.5 overflow-y-auto scrollbar-thin">
{(etl?.feed || []).slice(0, 6).map((f, i) => (
<p key={i} className="truncate text-[9px] text-foreground-muted" title={f.text}>
<span className="text-foreground-faint">{f.ts.slice(11, 19)}</span> {f.text}
</p>
))}
{!etl?.feed?.length && <Empty label="Warming up…" />}
</div>
</div>
</div>
</Panel>
)
}
export function StorageView() { export function StorageView() {
const [tab, setTab] = useState<'overview' | 'browser'>('overview') const [tab, setTab] = useState<'overview' | 'browser'>('overview')
const [an, setAn] = useState<Analytics | null>(null) const [an, setAn] = useState<Analytics | null>(null)
@@ -207,7 +319,7 @@ export function StorageView() {
useEffect(() => { useEffect(() => {
loadAnalytics() loadAnalytics()
const t = setInterval(() => loadAnalytics(), 30000) const t = setInterval(() => loadAnalytics(), 12000)
return () => clearInterval(t) return () => clearInterval(t)
}, [loadAnalytics]) }, [loadAnalytics])
@@ -264,6 +376,9 @@ export function StorageView() {
<Kpi icon={Clock} label="Last write" value={s?.newest ? new Date(s.newest).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'} accent="#22d3ee" /> <Kpi icon={Clock} label="Last write" value={s?.newest ? new Date(s.newest).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'} accent="#22d3ee" />
</div> </div>
{/* Realtime ETL offload (source → S3 Parquet) */}
<EtlIngestPanel />
{/* Growth + bucket distribution */} {/* Growth + bucket distribution */}
<div className="grid grid-cols-1 gap-3 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-3 xl:grid-cols-3">
<Panel title="Data growth (cumulative size · daily ingest)" icon={TrendingUp} className="xl:col-span-2" <Panel title="Data growth (cumulative size · daily ingest)" icon={TrendingUp} className="xl:col-span-2"
@@ -117,6 +117,31 @@ function Kpi({ icon: Icon, label, value, sub, accent }: { icon: typeof Users; la
) )
} }
function MiniArea({ values, color = '#34d399', label }: { values: number[]; color?: string; label?: string }) {
const w = 280, h = 46, pad = 3
const d = values.length ? values : [0]
const max = Math.max(1, ...d)
const step = d.length > 1 ? (w - pad * 2) / (d.length - 1) : 0
const pts = d.map((v, i) => [pad + i * step, h - pad - (v / max) * (h - pad * 2)] as const)
const line = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ')
const area = `${line} L${pts[pts.length - 1][0].toFixed(1)},${h - pad} L${pts[0][0].toFixed(1)},${h - pad} Z`
return (
<div>
{label && <p className="mb-0.5 text-[9px] uppercase tracking-wide text-foreground-faint">{label}</p>}
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="h-11 w-full">
<defs>
<linearGradient id={`ma-${color}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.4" /><stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
{values.length > 0 && <path d={area} fill={`url(#ma-${color})`} />}
{values.length > 0 && <path d={line} fill="none" stroke={color} strokeWidth="1.5" vectorEffect="non-scaling-stroke" />}
{values.length > 0 && <circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r="2.5" fill={color}><animate attributeName="r" values="2.5;5;2.5" dur="1.6s" repeatCount="indefinite" /></circle>}
</svg>
</div>
)
}
export function TrinoFederationView({ embedded = false, activeTab }: { embedded?: boolean; activeTab?: SubTab } = {}) { export function TrinoFederationView({ embedded = false, activeTab }: { embedded?: boolean; activeTab?: SubTab } = {}) {
const [tabState, setTab] = useState<SubTab>('federated') const [tabState, setTab] = useState<SubTab>('federated')
const tab = embedded ? activeTab ?? 'federated' : tabState const tab = embedded ? activeTab ?? 'federated' : tabState
@@ -124,9 +149,26 @@ export function TrinoFederationView({ embedded = false, activeTab }: { embedded?
const [marquee, setMarquee] = useState<any>(null) const [marquee, setMarquee] = useState<any>(null)
const [lake, setLake] = useState<any>(null) const [lake, setLake] = useState<any>(null)
const [dict, setDict] = useState<any>(null) const [dict, setDict] = useState<any>(null)
const [biz, setBiz] = useState<any>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [matRunning, setMatRunning] = useState(false) const [matRunning, setMatRunning] = useState(false)
const loadBiz = useCallback(async () => {
try {
const r = await fetch('/api/etl/business')
if (r.ok) setBiz(await r.json())
} catch { /* */ }
}, [])
// Poll the live federated business model (built from the ETL lakehouse offload)
// while the federated tab is open, so the graphs move with newly generated data.
useEffect(() => {
if (tab !== 'federated') return
loadBiz()
const t = setInterval(loadBiz, 5000)
return () => clearInterval(t)
}, [tab, loadBiz])
const loadFederated = useCallback(async () => { const loadFederated = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
@@ -198,6 +240,66 @@ export function TrinoFederationView({ embedded = false, activeTab }: { embedded?
{/* ───────── FEDERATED ───────── */} {/* ───────── FEDERATED ───────── */}
{tab === 'federated' && ( {tab === 'federated' && (
<> <>
{/* ───────── REALTIME FEDERATED BUSINESS MODEL ───────── */}
<Panel
title="Realtime federated business model"
subtitle={biz?.generated_at ? `updated ${new Date(biz.generated_at).toLocaleTimeString()}` : 'live'}
icon={Activity}
>
<p className="mb-2 flex items-center gap-1.5 text-[10px] text-foreground-muted">
<span className="h-1.5 w-1.5 animate-ping rounded-full bg-emerald-400" />
Business matrices built continuously from the lakehouse offload across <span className="text-docker">all five data points</span> orders, HR, supply &amp; telemetry and they move as new data is generated &amp; streamed to S3.
</p>
<div className="mb-2 grid grid-cols-2 gap-2 md:grid-cols-3 xl:grid-cols-6">
<Kpi icon={ShoppingCart} label="Orders analyzed" value={fmtNum(biz?.kpis?.orders)} accent="#fbbf24" />
<Kpi icon={DollarSign} label="Revenue" value={fmtMoney(biz?.kpis?.revenue)} sub={`avg ${fmtMoney(biz?.kpis?.avg_order)}`} accent="#34d399" />
<Kpi icon={Users} label="HR events" value={fmtNum(biz?.kpis?.hr_events)} accent="#60a5fa" />
<Kpi icon={Boxes} label="Supply events" value={fmtNum(biz?.kpis?.supply_events)} sub={fmtMoney(biz?.kpis?.supply_amount)} accent="#a78bfa" />
<Kpi icon={Activity} label="Telemetry pts" value={fmtNum(biz?.kpis?.telemetry)} accent="#22d3ee" />
<Kpi icon={Layers} label="Rows in model" value={fmtNum(biz?.kpis?.rows_total)} sub="federated" accent="#dd00a1" />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<MiniArea label="Orders ingested / cycle" values={(biz?.ts || []).map((p: any) => p.orders)} color="#fbbf24" />
<MiniArea label="Revenue / cycle (€)" values={(biz?.ts || []).map((p: any) => p.revenue)} color="#34d399" />
</div>
</Panel>
<div className="grid shrink-0 gap-2 lg:grid-cols-2">
<Panel title="Revenue by region" subtitle="live" icon={DollarSign}><BarsH data={biz?.orders_by_region} valueKind="money" colorByIndex /></Panel>
<Panel title="Region matrix — orders · revenue · HR · supply" icon={Network}>
{biz?.region_matrix?.length ? (
<div className="overflow-x-auto">
<table className="w-full text-[10px]">
<thead><tr className="border-b border-border text-left text-foreground-muted">
<th className="py-1 pr-3">Region</th><th className="py-1 pr-3 text-right">Orders</th>
<th className="py-1 pr-3 text-right">Revenue</th><th className="py-1 pr-3 text-right">HR</th><th className="py-1 pr-3 text-right">Supply</th>
</tr></thead>
<tbody>
{biz.region_matrix.map((r: any, i: number) => (
<tr key={i} className="border-b border-border/40">
<td className="py-1 pr-3 font-medium text-foreground">{r.region}</td>
<td className="py-1 pr-3 text-right font-mono text-foreground">{fmtNum(r.orders)}</td>
<td className="py-1 pr-3 text-right font-mono text-emerald-400">{fmtMoney(r.revenue)}</td>
<td className="py-1 pr-3 text-right font-mono text-foreground">{fmtNum(r.hr_events)}</td>
<td className="py-1 pr-3 text-right font-mono text-foreground">{fmtNum(r.supply_events)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : <p className="py-4 text-center text-[10px] text-foreground-faint">Building the model from the lakehouse offload</p>}
</Panel>
<div className="grid gap-2 sm:grid-cols-2">
<Panel title="Orders by status"><Donut data={biz?.orders_by_status} /></Panel>
<Panel title="Revenue by channel"><BarsH data={biz?.orders_by_channel} valueKind="money" colorByIndex /></Panel>
</div>
<Panel title="HR events by department" icon={Users}><BarsH data={biz?.hr_by_department} colorByIndex /></Panel>
<Panel title="Supply value by type" icon={Boxes}><BarsH data={biz?.supply_by_type} valueKind="money" colorByIndex /></Panel>
<Panel title="Telemetry — avg value by metric" icon={Activity}>
<BarsH data={(biz?.telemetry_by_metric || []).map((x: any) => ({ key: x.key, count: x.count, value: x.avg }))} valueKind="num" />
</Panel>
</div>
<div className="grid shrink-0 grid-cols-2 gap-2 md:grid-cols-4"> <div className="grid shrink-0 grid-cols-2 gap-2 md:grid-cols-4">
<Kpi icon={Layers} label="Federated catalogs" value={String(catalogs?.count ?? '—')} sub="one Trino engine" accent="#dd00a1" /> <Kpi icon={Layers} label="Federated catalogs" value={String(catalogs?.count ?? '—')} sub="one Trino engine" accent="#dd00a1" />
<Kpi icon={ShoppingCart} label="Orders" value={fmtNum(totals.orders)} sub="PostgreSQL (live)" accent="#fbbf24" /> <Kpi icon={ShoppingCart} label="Orders" value={fmtNum(totals.orders)} sub="PostgreSQL (live)" accent="#fbbf24" />
+20 -2
View File
@@ -1,4 +1,4 @@
import { Activity, Bot, Box, Clock, ShieldAlert } from 'lucide-react' import { Activity, Bot, Box, Clock, LogOut, ShieldAlert } from 'lucide-react'
import type { Agent, Approval, StatusData, WorkloadData } from '../../types' import type { Agent, Approval, StatusData, WorkloadData } from '../../types'
import { Badge } from '../ui/Badge' import { Badge } from '../ui/Badge'
import { ThemeToggle } from './ThemeToggle' import { ThemeToggle } from './ThemeToggle'
@@ -11,9 +11,11 @@ type Props = {
agents: Agent[] agents: Agent[]
approvals: Approval[] approvals: Approval[]
onApprovalsClick: () => void onApprovalsClick: () => void
userLabel?: string
onLogout?: () => void
} }
export function TopBar({ clock, workload, agents, approvals, onApprovalsClick }: Props) { export function TopBar({ clock, workload, agents, approvals, onApprovalsClick, userLabel, onLogout }: Props) {
const pipelineOk = workload?.totals?.pipeline_active ?? false const pipelineOk = workload?.totals?.pipeline_active ?? false
const running = workload?.totals?.apps_running ?? 0 const running = workload?.totals?.apps_running ?? 0
const total = workload?.totals?.apps_total ?? 0 const total = workload?.totals?.apps_total ?? 0
@@ -53,6 +55,22 @@ export function TopBar({ clock, workload, agents, approvals, onApprovalsClick }:
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{userLabel ? (
<span className="hidden max-w-[10rem] truncate text-[11px] text-foreground-muted sm:inline" title={userLabel}>
{userLabel}
</span>
) : null}
{onLogout ? (
<button
type="button"
onClick={onLogout}
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[10px] text-foreground-muted hover:bg-surface hover:text-foreground"
title="Logout"
>
<LogOut className="h-3 w-3" />
Logout
</button>
) : null}
<ThemeToggle /> <ThemeToggle />
<div className="flex items-center gap-1.5 font-mono text-[10px] text-foreground-muted"> <div className="flex items-center gap-1.5 font-mono text-[10px] text-foreground-muted">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from 'react'
export type AuthUser = {
user?: string
email?: string
name?: string
preferred_username?: string
auth_enabled?: boolean
}
type AuthState =
| { status: 'loading'; user: null }
| { status: 'anon'; user: null }
| { status: 'authed'; user: AuthUser }
export function useAuth(): AuthState & { refresh: () => Promise<void> } {
const [state, setState] = useState<AuthState>({ status: 'loading', user: null })
const refresh = useCallback(async () => {
try {
const r = await fetch('/api/auth/me', { credentials: 'same-origin' })
if (r.status === 401) {
setState({ status: 'anon', user: null })
return
}
if (!r.ok) {
setState({ status: 'anon', user: null })
return
}
const me = (await r.json()) as AuthUser
setState({ status: 'authed', user: me })
} catch {
setState({ status: 'anon', user: null })
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
return { ...state, refresh }
}
+17 -2
View File
@@ -129,7 +129,7 @@ export function useCommandCenter() {
if (msg.type === 'terminal') appendTerminal(msg.line) if (msg.type === 'terminal') appendTerminal(msg.line)
if (msg.type === 'terminal_history' && msg.terminals) setTerminals(msg.terminals) if (msg.type === 'terminal_history' && msg.terminals) setTerminals(msg.terminals)
if (msg.type === 'feed') setFeed((prev) => [msg.entry, ...prev].slice(0, 100)) if (msg.type === 'feed') setFeed((prev) => [msg.entry, ...prev].slice(0, 100))
if (msg.type === 'cdc_change' && msg.entry) setChanges((prev) => [msg.entry, ...prev].slice(0, 400)) if (msg.type === 'cdc_change' && msg.entry) setChanges((prev) => [msg.entry, ...prev].slice(0, 800))
if (msg.type === 'agent_dispatch') { if (msg.type === 'agent_dispatch') {
setSelectedAgentId(msg.agent_id) setSelectedAgentId(msg.agent_id)
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'walk', zone: msg.zone } })) setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'walk', zone: msg.zone } }))
@@ -313,7 +313,22 @@ export function useCommandCenter() {
const inspectorLines = useMemo(() => { const inspectorLines = useMemo(() => {
if (!terminalSubjectId) return [] if (!terminalSubjectId) return []
const probeId = resolveProbeId(terminalSubjectId) const probeId = resolveProbeId(terminalSubjectId)
return terminals[probeId] || terminals[terminalSubjectId] || terminals[selectedAgentId || ''] || [] const nodeLines = terminals[probeId] || terminals[terminalSubjectId] || []
const agentLines = selectedAgentId ? terminals[selectedAgentId] || [] : []
// When an agent is selected we also show its own autonomous ops stream
// (DML/ETL/probes) merged chronologically with the node probe output, so
// the terminal reflects what the agent is doing in the background — not just
// a one-shot probe.
if (!agentLines.length) return nodeLines
if (!nodeLines.length) return agentLines
const seen = new Set<string>()
const merged = [...nodeLines, ...agentLines].filter((l) => {
if (seen.has(l.id)) return false
seen.add(l.id)
return true
})
merged.sort((a, b) => ((a.ts || '') < (b.ts || '') ? -1 : (a.ts || '') > (b.ts || '') ? 1 : 0))
return merged.slice(-300)
}, [terminalSubjectId, terminals, selectedAgentId]) }, [terminalSubjectId, terminals, selectedAgentId])
const focusApprovals = useCallback(() => { const focusApprovals = useCallback(() => {
+75 -12
View File
@@ -6,6 +6,8 @@ import type {
CdcStats, CdcStats,
DataflowGraph, DataflowGraph,
FeedEntry, FeedEntry,
GpuConfigPayload,
GpuConfigTestResult,
GpuStatus, GpuStatus,
Movement, Movement,
PiiDataset, PiiDataset,
@@ -21,7 +23,7 @@ async function fetchJson<T>(url: string, timeoutMs = 10000): Promise<T | null> {
const ctrl = new AbortController() const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), timeoutMs) const timer = setTimeout(() => ctrl.abort(), timeoutMs)
try { try {
const r = await fetch(url, { signal: ctrl.signal }) const r = await fetch(url, { signal: ctrl.signal, credentials: 'same-origin' })
if (!r.ok) return null if (!r.ok) return null
return (await r.json()) as T return (await r.json()) as T
} catch { } catch {
@@ -62,6 +64,54 @@ export async function fetchGpu(): Promise<GpuStatus | null> {
return fetchJson<GpuStatus>('/api/gpu', 8000) return fetchJson<GpuStatus>('/api/gpu', 8000)
} }
export async function fetchGpuConfig(): Promise<GpuConfigPayload | null> {
return fetchJson<GpuConfigPayload>('/api/gpu/config', 8000)
}
export async function saveGpuConfig(body: {
preset_id?: string
host?: string
gpu_ui_port?: number
llm_port?: number
}) {
const r = await fetch('/api/gpu/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return r.json()
}
export async function testGpuConfig(body: {
preset_id?: string
host?: string
gpu_ui_port?: number
llm_port?: number
}): Promise<GpuConfigTestResult | null> {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 12000)
try {
const r = await fetch('/api/gpu/config/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: ctrl.signal,
})
if (!r.ok) return null
return (await r.json()) as GpuConfigTestResult
} catch {
return null
} finally {
clearTimeout(timer)
}
}
export async function resetGpuConfig() {
const r = await fetch('/api/gpu/config', { method: 'DELETE' , credentials: 'same-origin' })
return r.json()
}
export async function fetchTerminals(): Promise<Record<string, TerminalLine[]>> { export async function fetchTerminals(): Promise<Record<string, TerminalLine[]>> {
const j = await fetchJson<{ terminals?: Record<string, TerminalLine[]> }>('/api/terminals', 8000) const j = await fetchJson<{ terminals?: Record<string, TerminalLine[]> }>('/api/terminals', 8000)
return j?.terminals || {} return j?.terminals || {}
@@ -77,7 +127,7 @@ export async function fetchNodeDetail(nodeId: string) {
} }
export function probeNode(nodeId: string) { export function probeNode(nodeId: string) {
return fetch(`/api/nodes/${nodeId}/probe`, { method: 'POST' }) return fetch(`/api/nodes/${nodeId}/probe`, { method: 'POST' , credentials: 'same-origin' })
} }
export function askNode(nodeId: string, message: string) { export function askNode(nodeId: string, message: string) {
@@ -100,21 +150,34 @@ export async function fetchPresentation(): Promise<PresentationData | null> {
return fetchJson<PresentationData>('/api/presentation', 60000) return fetchJson<PresentationData>('/api/presentation', 60000)
} }
export async function fetchChanges(opts: { source?: string; op?: string; limit?: number } = {}) { export async function fetchChanges(opts: { source?: string; op?: string; limit?: number; minutes?: number } = {}) {
const p = new URLSearchParams() const p = new URLSearchParams()
if (opts.source) p.set('source', opts.source) if (opts.source) p.set('source', opts.source)
if (opts.op) p.set('op', opts.op) if (opts.op) p.set('op', opts.op)
p.set('limit', String(opts.limit ?? 150)) p.set('limit', String(opts.limit ?? 200))
const j = await fetchJson<{ changes?: CdcChange[]; connected?: boolean; consumed?: number }>( if (opts.minutes) p.set('minutes', String(opts.minutes))
const j = await fetchJson<{ changes?: CdcChange[]; connected?: boolean; consumed?: number; last_ts?: string | null }>(
`/api/changes?${p.toString()}`, 8000, `/api/changes?${p.toString()}`, 8000,
) )
return { changes: j?.changes || [], connected: !!j?.connected, consumed: j?.consumed || 0 } return { changes: j?.changes || [], connected: !!j?.connected, consumed: j?.consumed || 0, last_ts: j?.last_ts }
} }
export async function fetchChangeStats(minutes = 15): Promise<CdcStats | null> { export async function fetchChangeStats(minutes = 15): Promise<CdcStats | null> {
return fetchJson<CdcStats>(`/api/changes/stats?minutes=${minutes}`, 8000) return fetchJson<CdcStats>(`/api/changes/stats?minutes=${minutes}`, 8000)
} }
export type ConnectorState = { name: string; state?: string; failed?: number[] }
export type ResyncResult = { ok: boolean; restarted?: string[]; healthy?: number; total?: number; after?: ConnectorState[] }
export async function resyncSources(force = true): Promise<ResyncResult | null> {
const r = await fetch('/api/pipeline/streaming/resync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force }),
})
return r.ok ? ((await r.json()) as ResyncResult) : null
}
export async function fetchAgentOpsStatus() { export async function fetchAgentOpsStatus() {
return fetchJson<Record<string, unknown>>('/api/agent-ops/status', 8000) return fetchJson<Record<string, unknown>>('/api/agent-ops/status', 8000)
} }
@@ -195,19 +258,19 @@ export function triggerStreamingJob(jobId: string, conf?: Record<string, unknown
} }
export function restartKafkaConnector(name: string) { export function restartKafkaConnector(name: string) {
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/restart`, { method: 'POST' }) return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/restart`, { method: 'POST' , credentials: 'same-origin' })
} }
export function pauseKafkaConnector(name: string) { export function pauseKafkaConnector(name: string) {
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/pause`, { method: 'POST' }) return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/pause`, { method: 'POST' , credentials: 'same-origin' })
} }
export function resumeKafkaConnector(name: string) { export function resumeKafkaConnector(name: string) {
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/resume`, { method: 'POST' }) return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/resume`, { method: 'POST' , credentials: 'same-origin' })
} }
export function triggerStreamingPipeline(pipelineId: string) { export function triggerStreamingPipeline(pipelineId: string) {
return fetch(`/api/pipeline/streaming/pipeline/${encodeURIComponent(pipelineId)}`, { method: 'POST' }) return fetch(`/api/pipeline/streaming/pipeline/${encodeURIComponent(pipelineId)}`, { method: 'POST' , credentials: 'same-origin' })
} }
export function exportHdfsToKafka(body?: { path?: string; topic?: string; limit?: number }) { export function exportHdfsToKafka(body?: { path?: string; topic?: string; limit?: number }) {
@@ -219,7 +282,7 @@ export function exportHdfsToKafka(body?: { path?: string; topic?: string; limit?
} }
export function setStreamingFlow(action: 'pause' | 'resume' | 'stop') { export function setStreamingFlow(action: 'pause' | 'resume' | 'stop') {
return fetch(`/api/pipeline/streaming/flow/${action}`, { method: 'POST' }) return fetch(`/api/pipeline/streaming/flow/${action}`, { method: 'POST' , credentials: 'same-origin' })
} }
// ── Spark Workbench ────────────────────────────────────────────── // ── Spark Workbench ──────────────────────────────────────────────
@@ -259,7 +322,7 @@ export async function fetchSparkRun(runId: string) {
} }
export function cancelSparkRun(runId: string) { export function cancelSparkRun(runId: string) {
return fetch(`/api/spark/run/${runId}/cancel`, { method: 'POST' }) return fetch(`/api/spark/run/${runId}/cancel`, { method: 'POST' , credentials: 'same-origin' })
} }
export async function fetchSparkLive() { export async function fetchSparkLive() {
+5 -5
View File
@@ -158,17 +158,17 @@ export const INFRA_CATALOG: InfraNode[] = [
{ {
id: 'gpu', id: 'gpu',
label: 'GPU Lab', label: 'GPU Lab',
vm: 'atc-gpu-dev', vm: 'atc-gpu-prod',
ip: '10.0.20.106', ip: '10.0.10.106',
zone: 'gpu', zone: 'gpu',
agentId: 'infra-sentinel', agentId: 'infra-sentinel',
icon: Sparkles, icon: Sparkles,
accent: '#3fb950', accent: '#3fb950',
description: 'vLLM inference — Llama 3 70B on 4× V100', description: 'vLLM inference — Llama 3 70B on 4× V100',
ssh: 'ssh root@10.0.20.106', ssh: 'ssh root@10.0.10.106',
apps: [ apps: [
{ label: 'GPU Lab UI', url: 'http://10.0.20.106:9000', port: '9000' }, { label: 'GPU Lab UI', url: 'http://10.0.10.106:9000', port: '9000' },
{ label: 'vLLM API', url: 'http://10.0.20.106:8001/v1', port: '8001' }, { label: 'vLLM API', url: 'http://10.0.10.106:8001/v1', port: '8001' },
], ],
topoIds: ['llm', 'cons-ml'], topoIds: ['llm', 'cons-ml'],
}, },
+55
View File
@@ -62,12 +62,19 @@ export type CdcStats = {
ok: boolean ok: boolean
window_minutes: number window_minutes: number
total: number total: number
inserts?: number
updates?: number
deletes?: number
rate_per_min?: number
by_source: Record<string, number> by_source: Record<string, number>
by_op: Record<string, number> by_op: Record<string, number>
by_table: Record<string, number> by_table: Record<string, number>
buckets: { t: string; n: number }[] buckets: { t: string; n: number }[]
connected: boolean connected: boolean
consumed: number consumed: number
buffered?: number
buffer_cap?: number
last_ts?: string | null
} }
export type PiiColumn = { name: string; category: string; masked: boolean; policy_locked?: boolean } export type PiiColumn = { name: string; category: string; masked: boolean; policy_locked?: boolean }
@@ -217,15 +224,63 @@ export type GpuDevice = {
export type GpuStatus = { export type GpuStatus = {
ok: boolean ok: boolean
host: string host: string
ip?: string
ui_url: string ui_url: string
inference_active?: boolean inference_active?: boolean
active_model?: string | null active_model?: string | null
vllm_url?: string | null vllm_url?: string | null
gpu_count?: number gpu_count?: number
gpus?: GpuDevice[] gpus?: GpuDevice[]
config_source?: string
preset_id?: string
config_label?: string
error?: string error?: string
} }
export type GpuPreset = {
id: string
label: string
vm: string
vmid: number
host: string
gpu_ui_port: number
llm_port: number
description: string
}
export type GpuTargetConfig = {
source: string
preset_id: string
label: string
host: string
gpu_url: string
gpu_ui_url: string
llm_url: string
env_gpu_url?: string
env_llm_url?: string
updated_at?: string
}
export type GpuConfigPayload = {
active: GpuTargetConfig
presets: GpuPreset[]
defaults: GpuTargetConfig
}
export type GpuConfigTestResult = {
ok: boolean
host: string
gpu_url: string
llm_url: string
metrics_ok: boolean
llm_ok: boolean
gpu_count: number
inference_active: boolean
active_model: string | null
errors: string[]
}
export type AgentState = 'idle' | 'walk' | 'fetch' | 'return' export type AgentState = 'idle' | 'walk' | 'fetch' | 'return'
export type AgentAnim = { export type AgentAnim = {