feat: Spark Workbench everywhere, autonomous Hadoop offload & LLM masking-aware
- Data Hub with Hadoop tab (HDFS/Iceberg browser, Spark, pipeline) - Databricks-style Lakehouse Workbench (Trino engine, live exec matrix, materialize to Iceberg/S3); reused & embedded in every source-DB UI - HDFS -> Kafka -> Spark -> Iceberg/S3 pipeline; WebHDFS hostname resolver - Data Flow master pulse switch (Run/Pause/Stop) gating animated edges - Data Custodian autonomous Hadoop offload loop (batch counterpart to CDC), pulsing source -> HDFS edges; toggle in Data Flow - LLM now autonomously aware of all latest platform changes (live platform context) and enforces masking policy: never reveals masked PII, still answers helpfully with aggregates/explanations
This commit is contained in:
+1
-1
@@ -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 pii_catalog.py hive_bench_seed.json .
|
COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py hdfs_api.py ssh_terminal.py pipeline_ops.py hadoop_analytics.py agent_ops.py cdc_consumer.py movements.py dataflow.py streaming_ops.py spark_workbench.py hadoop_sql.py hdfs_kafka.py webhdfs_util.py pii_catalog.py platform_context.py 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
|
||||||
|
|||||||
+88
-1
@@ -328,11 +328,98 @@ async def etl_agent_loop() -> None:
|
|||||||
await asyncio.sleep(max(30.0, float(_etl_state["interval"])))
|
await asyncio.sleep(max(30.0, float(_etl_state["interval"])))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── Custodian Hadoop offload (batch counterpart to CDC) ─────────────────────
|
||||||
|
_CUST_INTERVAL = float(os.getenv("CUSTODIAN_OFFLOAD_INTERVAL_SECONDS", "120"))
|
||||||
|
_CUST_BATCH = int(os.getenv("CUSTODIAN_OFFLOAD_BATCH", "200"))
|
||||||
|
_CUST_TARGETS = [
|
||||||
|
{"label": "postgres sales_orders", "src": "postgres_sales.public.sales_orders",
|
||||||
|
"target": "iceberg.hadoop.sales_orders_offload"},
|
||||||
|
{"label": "mysql employee_events", "src": "mysql_hr.hr.employee_events",
|
||||||
|
"target": "iceberg.hadoop.employee_events_offload"},
|
||||||
|
]
|
||||||
|
_custodian_state: dict[str, Any] = {
|
||||||
|
"enabled": os.getenv("CUSTODIAN_OFFLOAD_ENABLED", "1") not in ("0", "false", "False", ""),
|
||||||
|
"interval": _CUST_INTERVAL,
|
||||||
|
"targets": [c["target"] for c in _CUST_TARGETS],
|
||||||
|
"idx": 0,
|
||||||
|
"runs_total": 0,
|
||||||
|
"last": None,
|
||||||
|
"started": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _custodian_offload_once(idx: int | None = None) -> dict[str, Any]:
|
||||||
|
"""Offload a batch of source rows into the Hadoop Iceberg lake via Trino."""
|
||||||
|
from spark_workbench import _trino_collect
|
||||||
|
i = _custodian_state["idx"] if idx is None else idx
|
||||||
|
tgt = _CUST_TARGETS[i % len(_CUST_TARGETS)]
|
||||||
|
_custodian_state["idx"] = i + 1
|
||||||
|
await _trino_collect(
|
||||||
|
f"CREATE TABLE IF NOT EXISTS {tgt['target']} AS SELECT * FROM {tgt['src']} WHERE 1=0", 1)
|
||||||
|
ins = await _trino_collect(
|
||||||
|
f"INSERT INTO {tgt['target']} SELECT * FROM {tgt['src']} LIMIT {_CUST_BATCH}", 1)
|
||||||
|
ok = bool(ins.get("ok"))
|
||||||
|
_custodian_state["runs_total"] += 1
|
||||||
|
_custodian_state["last"] = {
|
||||||
|
"target": tgt["target"], "src": tgt["src"], "ok": ok,
|
||||||
|
"rows": _CUST_BATCH if ok else 0,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(), "error": ins.get("error"),
|
||||||
|
}
|
||||||
|
if ok:
|
||||||
|
await _emit(f"[custodian-offload] {tgt['label']} → {tgt['target']}: offloaded ~{_CUST_BATCH} rows to Hadoop", "info")
|
||||||
|
else:
|
||||||
|
await _emit(f"[custodian-offload] {tgt['label']} failed: {str(ins.get('error'))[:120]}", "err")
|
||||||
|
return _custodian_state["last"]
|
||||||
|
|
||||||
|
|
||||||
|
async def custodian_offload_loop() -> None:
|
||||||
|
_custodian_state["started"] = True
|
||||||
|
await asyncio.sleep(45)
|
||||||
|
await _emit("[custodian-offload] Autonomous Hadoop offload online — batching source data into the Iceberg lake", "info")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if _custodian_state["enabled"]:
|
||||||
|
await _custodian_offload_once()
|
||||||
|
except Exception as exc:
|
||||||
|
_custodian_state["last"] = {"error": str(exc), "ts": datetime.now(timezone.utc).isoformat()}
|
||||||
|
await asyncio.sleep(max(30.0, float(_custodian_state["interval"])))
|
||||||
|
|
||||||
|
|
||||||
|
def custodian_recent() -> bool:
|
||||||
|
last = _custodian_state.get("last") or {}
|
||||||
|
ts = last.get("ts")
|
||||||
|
if not ts or not last.get("ok"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
t = _dt.fromisoformat(str(ts).replace("Z", "+00:00"))
|
||||||
|
window = max(60.0, float(_custodian_state["interval"]) * 1.5)
|
||||||
|
return (datetime.now(timezone.utc) - t).total_seconds() < window
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── Endpoints ────────────────────────────────────────────────────────────────
|
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def status() -> JSONResponse:
|
async def status() -> JSONResponse:
|
||||||
pools = {k: len(v) for k, v in _pools.items()}
|
pools = {k: len(v) for k, v in _pools.items()}
|
||||||
return JSONResponse({"ok": True, "pools": pools, "etl": _etl_state, **_state})
|
return JSONResponse({"ok": True, "pools": pools, "etl": _etl_state, "custodian": _custodian_state, **_state})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/custodian/toggle")
|
||||||
|
async def custodian_toggle(body: dict[str, Any] = Body(default={})) -> JSONResponse:
|
||||||
|
if "enabled" in body:
|
||||||
|
_custodian_state["enabled"] = bool(body["enabled"])
|
||||||
|
else:
|
||||||
|
_custodian_state["enabled"] = not _custodian_state["enabled"]
|
||||||
|
if "interval" in body:
|
||||||
|
try:
|
||||||
|
_custodian_state["interval"] = max(30.0, float(body["interval"]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
await _emit(f"[custodian-offload] Hadoop offload {'ENABLED' if _custodian_state['enabled'] else 'PAUSED'} by operator", "warn")
|
||||||
|
return JSONResponse({"ok": True, "enabled": _custodian_state["enabled"], "interval": _custodian_state["interval"]})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/etl/toggle")
|
@router.post("/etl/toggle")
|
||||||
|
|||||||
+54
-6
@@ -38,7 +38,9 @@ NODES: list[dict[str, Any]] = [
|
|||||||
{"id": "mysql", "label": "MySQL", "sub": "employee_events", "kind": "source", "x": 28, "y": 38},
|
{"id": "mysql", "label": "MySQL", "sub": "employee_events", "kind": "source", "x": 28, "y": 38},
|
||||||
{"id": "mongodb", "label": "MongoDB", "sub": "events", "kind": "source", "x": 28, "y": 60},
|
{"id": "mongodb", "label": "MongoDB", "sub": "events", "kind": "source", "x": 28, "y": 60},
|
||||||
# col 2 — change data capture
|
# col 2 — change data capture
|
||||||
{"id": "kafka", "label": "Kafka · Debezium", "sub": "CDC topics", "kind": "stream", "x": 47, "y": 34},
|
{"id": "kafka", "label": "Kafka · Debezium", "sub": "CDC topics", "kind": "stream", "x": 47, "y": 24},
|
||||||
|
{"id": "spark", "label": "Apache Spark", "sub": "Streaming · batch", "kind": "compute", "x": 62, "y": 38,
|
||||||
|
"url": "/spark-ui/"},
|
||||||
# col 3 — storage / lakehouse
|
# col 3 — storage / lakehouse
|
||||||
{"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},
|
||||||
@@ -58,7 +60,13 @@ EDGES: list[dict[str, Any]] = [
|
|||||||
{"from": "postgres", "to": "kafka", "kind": "cdc"},
|
{"from": "postgres", "to": "kafka", "kind": "cdc"},
|
||||||
{"from": "mysql", "to": "kafka", "kind": "cdc"},
|
{"from": "mysql", "to": "kafka", "kind": "cdc"},
|
||||||
{"from": "mongodb", "to": "kafka", "kind": "cdc"},
|
{"from": "mongodb", "to": "kafka", "kind": "cdc"},
|
||||||
|
{"from": "kafka", "to": "spark", "kind": "stream"},
|
||||||
|
{"from": "spark", "to": "iceberg_curated", "kind": "movement", "movement_id": "spark_to_curated"},
|
||||||
|
{"from": "spark", "to": "s3_cdc", "kind": "movement", "movement_id": "spark_to_s3"},
|
||||||
{"from": "kafka", "to": "s3_cdc", "kind": "archive"},
|
{"from": "kafka", "to": "s3_cdc", "kind": "archive"},
|
||||||
|
{"from": "postgres", "to": "hdfs", "kind": "archive", "offload": True},
|
||||||
|
{"from": "mysql", "to": "hdfs", "kind": "archive", "offload": True},
|
||||||
|
{"from": "hdfs", "to": "kafka", "kind": "stream", "movement_id": "hdfs_to_kafka"},
|
||||||
{"from": "hdfs", "to": "iceberg_hadoop", "kind": "movement", "movement_id": "hadoop_to_trino"},
|
{"from": "hdfs", "to": "iceberg_hadoop", "kind": "movement", "movement_id": "hadoop_to_trino"},
|
||||||
{"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"},
|
||||||
@@ -112,7 +120,7 @@ def _iceberg_hadoop_count() -> int | None:
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
def _build() -> dict[str, Any]:
|
async def _build() -> dict[str, Any]:
|
||||||
# Live signals
|
# Live signals
|
||||||
try:
|
try:
|
||||||
from movements import last_runs
|
from movements import last_runs
|
||||||
@@ -129,6 +137,14 @@ def _build() -> dict[str, Any]:
|
|||||||
pii = get_pii()
|
pii = get_pii()
|
||||||
except Exception:
|
except Exception:
|
||||||
pii = {"datasets": []}
|
pii = {"datasets": []}
|
||||||
|
try:
|
||||||
|
from streaming_ops import build_streaming_status
|
||||||
|
streaming = await build_streaming_status()
|
||||||
|
except Exception:
|
||||||
|
streaming = {}
|
||||||
|
spark = streaming.get("spark") or {}
|
||||||
|
kafka = streaming.get("kafka") 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", [])}
|
||||||
|
|
||||||
nodes = []
|
nodes = []
|
||||||
@@ -138,9 +154,17 @@ def _build() -> dict[str, Any]:
|
|||||||
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"
|
||||||
if n["id"] == "kafka":
|
elif n["id"] == "kafka":
|
||||||
metric = f"{cdc.get('window_total', 0)} chg/15m · {cdc.get('consumed', 0)} total"
|
topics = len(kafka.get("topics") or [])
|
||||||
node["level"] = "ok" if cdc.get("connected") else "warn"
|
conn_n = len(kafka.get("connectors") or [])
|
||||||
|
metric = f"{cdc.get('window_total', 0)} chg/15m · {topics} topics · {conn_n} connectors"
|
||||||
|
node["level"] = "ok" if cdc.get("connected") and kafka.get("ui_ok") else "warn"
|
||||||
|
elif n["id"] == "spark":
|
||||||
|
apps = len(spark.get("active_apps") or [])
|
||||||
|
cores = spark.get("cores") or 0
|
||||||
|
used = spark.get("cores_used") or 0
|
||||||
|
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"
|
||||||
elif n["id"] in ("postgres", "mysql", "mongodb"):
|
elif n["id"] in ("postgres", "mysql", "mongodb"):
|
||||||
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":
|
||||||
@@ -161,6 +185,12 @@ def _build() -> dict[str, Any]:
|
|||||||
node["metric"] = metric
|
node["metric"] = metric
|
||||||
nodes.append(node)
|
nodes.append(node)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from streaming_ops import flow_mode
|
||||||
|
_flow = flow_mode()
|
||||||
|
except Exception:
|
||||||
|
_flow = "running"
|
||||||
|
|
||||||
edges = []
|
edges = []
|
||||||
for e in EDGES:
|
for e in EDGES:
|
||||||
edge = dict(e)
|
edge = dict(e)
|
||||||
@@ -173,6 +203,22 @@ def _build() -> dict[str, Any]:
|
|||||||
edge["active"] = lr.get("state") == "running"
|
edge["active"] = lr.get("state") == "running"
|
||||||
if e["kind"] == "cdc":
|
if 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":
|
||||||
|
edge["active"] = bool(edge_live.get("hdfs→kafka"))
|
||||||
|
elif e.get("from") == "kafka" and e.get("to") == "spark":
|
||||||
|
edge["active"] = bool(edge_live.get("kafka→spark"))
|
||||||
|
elif e.get("from") == "spark" and e.get("to") == "iceberg_curated":
|
||||||
|
edge["active"] = bool(edge_live.get("spark→iceberg")) or edge.get("active")
|
||||||
|
elif e.get("from") == "spark" and e.get("to") == "s3_cdc":
|
||||||
|
edge["active"] = bool(edge_live.get("spark→s3")) or edge.get("active")
|
||||||
|
if e.get("offload"):
|
||||||
|
try:
|
||||||
|
from agent_ops import custodian_recent
|
||||||
|
edge["active"] = custodian_recent()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if _flow != "running":
|
||||||
|
edge["active"] = False
|
||||||
edges.append(edge)
|
edges.append(edge)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -181,6 +227,8 @@ def _build() -> dict[str, Any]:
|
|||||||
"edges": edges,
|
"edges": edges,
|
||||||
"pii_summary": pii.get("summary", {}),
|
"pii_summary": pii.get("summary", {}),
|
||||||
"cdc": {"connected": cdc.get("connected"), "consumed": cdc.get("consumed"), "window_total": cdc.get("window_total")},
|
"cdc": {"connected": cdc.get("connected"), "consumed": cdc.get("consumed"), "window_total": cdc.get("window_total")},
|
||||||
|
"streaming": streaming,
|
||||||
|
"flow": _flow,
|
||||||
"ts": time.time(),
|
"ts": time.time(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +238,7 @@ async def get_dataflow(refresh: bool = False) -> JSONResponse:
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
if not refresh and _cache["data"] and now - _cache["ts"] < _TTL:
|
if not refresh and _cache["data"] and now - _cache["ts"] < _TTL:
|
||||||
return JSONResponse(_cache["data"])
|
return JSONResponse(_cache["data"])
|
||||||
data = _build()
|
data = await _build()
|
||||||
_cache["data"] = data
|
_cache["data"] = data
|
||||||
_cache["ts"] = now
|
_cache["ts"] = now
|
||||||
return JSONResponse(data)
|
return JSONResponse(data)
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Hadoop / HDFS / Iceberg catalog and sampling for the Data Hub."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from webhdfs_util import open_bytes
|
||||||
|
|
||||||
|
HDFS_PATHS = [
|
||||||
|
"/data/historical/sales_orders",
|
||||||
|
"/data/historical/sales_orders/year=2020/part-0.csv",
|
||||||
|
]
|
||||||
|
|
||||||
|
ICEBERG_SCHEMAS = ["hadoop", "curated_masked", "curated"]
|
||||||
|
|
||||||
|
HADOOP_SAMPLES: list[dict[str, str]] = [
|
||||||
|
{"id": "hd1", "label": "Iceberg historical sales", "sql": "SELECT * FROM iceberg.hadoop.historical_sales_hdfs LIMIT 20"},
|
||||||
|
{"id": "hd2", "label": "Row count historical", "sql": "SELECT count(*) FROM iceberg.hadoop.historical_sales_hdfs"},
|
||||||
|
{"id": "hd3", "label": "Curated masked sample", "sql": "SELECT * FROM iceberg.curated_masked.sales_orders_masked LIMIT 15"},
|
||||||
|
{"id": "hd4", "label": "Hive lake schemas", "sql": "SHOW SCHEMAS FROM hive"},
|
||||||
|
{"id": "hd5", "label": "Iceberg hadoop tables", "sql": "SHOW TABLES FROM iceberg.hadoop"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_hadoop(run_trino: Callable[[str, int], dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
objects: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for schema in ICEBERG_SCHEMAS:
|
||||||
|
show = run_trino(f"SHOW TABLES FROM iceberg.{schema}", 500)
|
||||||
|
if not show.get("ok"):
|
||||||
|
continue
|
||||||
|
for row in show.get("rows") or []:
|
||||||
|
name = str(row[0])
|
||||||
|
fqn = f"iceberg.{schema}.{name}"
|
||||||
|
cnt = run_trino(f"SELECT count(*) FROM iceberg.{schema}.{name}", 1)
|
||||||
|
count = None
|
||||||
|
if cnt.get("ok") and cnt.get("rows"):
|
||||||
|
try:
|
||||||
|
count = int(cnt["rows"][0][0])
|
||||||
|
except (TypeError, ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
objects.append({
|
||||||
|
"type": "table",
|
||||||
|
"schema": f"iceberg.{schema}",
|
||||||
|
"name": name,
|
||||||
|
"fqn": fqn,
|
||||||
|
"row_count": count,
|
||||||
|
})
|
||||||
|
|
||||||
|
for path in HDFS_PATHS:
|
||||||
|
label = path.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
objects.append({
|
||||||
|
"type": "file",
|
||||||
|
"schema": "hdfs",
|
||||||
|
"name": label,
|
||||||
|
"fqn": f"hdfs:{path}",
|
||||||
|
"row_count": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"engine": "hadoop", "version": "HDFS + Iceberg", "objects": objects}
|
||||||
|
|
||||||
|
|
||||||
|
def sample_hdfs_csv(path: str, limit: int, offset: int, tabular: Callable[..., dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
text = open_bytes(path).decode("utf-8", errors="replace")
|
||||||
|
reader = csv.reader(io.StringIO(text))
|
||||||
|
all_rows = list(reader)
|
||||||
|
if not all_rows:
|
||||||
|
return tabular([], [], 0, row_count=0)
|
||||||
|
columns = [c.strip() for c in all_rows[0]]
|
||||||
|
data = all_rows[1 + offset: 1 + offset + limit]
|
||||||
|
rows = [[cell.strip() for cell in row] for row in data]
|
||||||
|
return tabular(columns, rows, 0, row_count=max(0, len(all_rows) - 1))
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": str(exc)[:500]}
|
||||||
|
|
||||||
|
|
||||||
|
def sample_hadoop(
|
||||||
|
object_name: str,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
run_trino: Callable[[str, int], dict[str, Any]],
|
||||||
|
tabular: Callable[..., dict[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if object_name.startswith("hdfs:"):
|
||||||
|
return sample_hdfs_csv(object_name[5:], limit, offset, tabular)
|
||||||
|
sql = f"SELECT * FROM {object_name} OFFSET {offset} LIMIT {limit}"
|
||||||
|
return run_trino(sql, limit)
|
||||||
|
|
||||||
|
|
||||||
|
def table_row_count_hadoop(object_name: str, run_trino: Callable[[str, int], dict[str, Any]]) -> int | None:
|
||||||
|
if object_name.startswith("hdfs:"):
|
||||||
|
try:
|
||||||
|
text = open_bytes(object_name[5:]).decode("utf-8", errors="replace")
|
||||||
|
return max(0, sum(1 for _ in csv.reader(io.StringIO(text))) - 1)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
cnt = run_trino(f"SELECT count(*) FROM {object_name}", 1)
|
||||||
|
if cnt.get("ok") and cnt.get("rows"):
|
||||||
|
try:
|
||||||
|
return int(cnt["rows"][0][0])
|
||||||
|
except (TypeError, ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def health_hadoop(run_trino: Callable[[str, int], dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
from webhdfs_util import HDFS_NN_URL
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
nn_ok = False
|
||||||
|
live_dn = None
|
||||||
|
err_parts: list[str] = []
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=6.0) as client:
|
||||||
|
r = client.get(f"{HDFS_NN_URL}/jmx?qry=Hadoop:service=NameNode,name=FSNamesystemState")
|
||||||
|
beans = (r.json().get("beans") or [{}])[0]
|
||||||
|
nn_ok = r.status_code < 400
|
||||||
|
live_dn = beans.get("NumLiveDataNodes")
|
||||||
|
except Exception as exc:
|
||||||
|
err_parts.append(f"NN: {exc}")
|
||||||
|
|
||||||
|
trino_ok = False
|
||||||
|
try:
|
||||||
|
tr = run_trino("SELECT 1", 1)
|
||||||
|
trino_ok = bool(tr.get("ok"))
|
||||||
|
if not trino_ok and tr.get("error"):
|
||||||
|
err_parts.append(f"Trino: {tr['error'][:80]}")
|
||||||
|
except Exception as exc:
|
||||||
|
err_parts.append(f"Trino: {exc}")
|
||||||
|
|
||||||
|
ok = nn_ok and trino_ok
|
||||||
|
return {
|
||||||
|
"ok": ok,
|
||||||
|
"namenode": HDFS_NN_URL,
|
||||||
|
"live_datanodes": live_dn,
|
||||||
|
"trino_ok": trino_ok,
|
||||||
|
"error": "; ".join(err_parts) if err_parts else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def connection_info() -> dict[str, str]:
|
||||||
|
from webhdfs_util import HDFS_NN_URL, HDFS_USER
|
||||||
|
return {
|
||||||
|
"namenode": HDFS_NN_URL,
|
||||||
|
"user": HDFS_USER,
|
||||||
|
"trino": os.getenv("TRINO_URL", "http://10.0.21.50:8089"),
|
||||||
|
"spark": os.getenv("SPARK_UI_URL", "http://10.0.21.50:8080"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""HDFS → Kafka export and full hadoop-lake pipeline orchestration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiokafka import AIOKafkaProducer
|
||||||
|
from webhdfs_util import open_bytes
|
||||||
|
|
||||||
|
KAFKA_BOOTSTRAP = os.getenv("KAFKA_BOOTSTRAP", "10.0.21.36:9092")
|
||||||
|
DEFAULT_TRINO_TABLE = os.getenv("HADOOP_EXPORT_TABLE", "iceberg.hadoop.historical_sales_hdfs")
|
||||||
|
DEFAULT_HDFS_FILE = os.getenv("HADOOP_EXPORT_HDFS", "/data/historical/sales_orders/year=2020/part-0.csv")
|
||||||
|
|
||||||
|
_last_hdfs_export: dict[str, Any] = {"ts": 0.0, "rows": 0, "topic": None}
|
||||||
|
|
||||||
|
|
||||||
|
def hdfs_export_snapshot() -> dict[str, Any]:
|
||||||
|
age = time.time() - float(_last_hdfs_export.get("ts") or 0)
|
||||||
|
return {
|
||||||
|
**(_last_hdfs_export or {}),
|
||||||
|
"recent": age < 120,
|
||||||
|
"age_s": round(age, 1) if _last_hdfs_export.get("ts") else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_hdfs_csv(path: str, limit: int) -> tuple[list[str], list[list[str]]]:
|
||||||
|
raw = open_bytes(path).decode("utf-8", errors="replace")
|
||||||
|
rows = list(csv.reader(io.StringIO(raw)))
|
||||||
|
if not rows:
|
||||||
|
return [], []
|
||||||
|
header = [c.strip() for c in rows[0]]
|
||||||
|
data = [[cell.strip() for cell in row] for row in rows[1: 1 + limit]]
|
||||||
|
return header, data
|
||||||
|
|
||||||
|
|
||||||
|
def _read_trino_table(table: str, limit: int) -> tuple[list[str], list[list[Any]]]:
|
||||||
|
from sql_console import _run_trino
|
||||||
|
|
||||||
|
result = _run_trino(f"SELECT * FROM {table} LIMIT {limit}", limit)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise RuntimeError(result.get("error") or "Trino query failed")
|
||||||
|
return list(result.get("columns") or []), list(result.get("rows") or [])
|
||||||
|
|
||||||
|
|
||||||
|
async def export_hdfs_to_kafka(
|
||||||
|
path: str | None = None,
|
||||||
|
topic: str = "hdfs.historical.sales",
|
||||||
|
limit: int = 2000,
|
||||||
|
source: str = "trino",
|
||||||
|
table: str | None = None,
|
||||||
|
feed: Any = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
global _last_hdfs_export
|
||||||
|
header: list[str] = []
|
||||||
|
data: list[list[Any]] = []
|
||||||
|
src_label = source
|
||||||
|
|
||||||
|
try:
|
||||||
|
if source == "trino":
|
||||||
|
tbl = table or DEFAULT_TRINO_TABLE
|
||||||
|
header, data = _read_trino_table(tbl, limit)
|
||||||
|
src_label = tbl
|
||||||
|
else:
|
||||||
|
p = path or DEFAULT_HDFS_FILE
|
||||||
|
header, data = _read_hdfs_csv(p, limit)
|
||||||
|
src_label = p
|
||||||
|
except Exception as exc:
|
||||||
|
if source == "trino" and path:
|
||||||
|
try:
|
||||||
|
header, data = _read_hdfs_csv(path, limit)
|
||||||
|
src_label = path
|
||||||
|
except Exception:
|
||||||
|
return {"ok": False, "error": str(exc)[:400]}
|
||||||
|
else:
|
||||||
|
return {"ok": False, "error": str(exc)[:400]}
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return {"ok": False, "error": "No data rows to export"}
|
||||||
|
|
||||||
|
producer = AIOKafkaProducer(
|
||||||
|
bootstrap_servers=KAFKA_BOOTSTRAP,
|
||||||
|
value_serializer=lambda v: json.dumps(v, default=str).encode("utf-8"),
|
||||||
|
)
|
||||||
|
await producer.start()
|
||||||
|
rows_sent = 0
|
||||||
|
try:
|
||||||
|
for i, row in enumerate(data):
|
||||||
|
payload = {
|
||||||
|
header[j] if j < len(header) else f"col_{j}": row[j] if j < len(row) else None
|
||||||
|
for j in range(max(len(header), len(row)))
|
||||||
|
}
|
||||||
|
payload["_source"] = "hadoop"
|
||||||
|
payload["_origin"] = src_label
|
||||||
|
payload["_row"] = i + 1
|
||||||
|
await producer.send_and_wait(topic, payload)
|
||||||
|
rows_sent += 1
|
||||||
|
finally:
|
||||||
|
await producer.stop()
|
||||||
|
|
||||||
|
_last_hdfs_export = {"ts": time.time(), "rows": rows_sent, "topic": topic, "source": src_label}
|
||||||
|
if feed:
|
||||||
|
feed("hadoop-ranger", f"[hadoop→kafka] {rows_sent} rows from {src_label} → {topic}", "info")
|
||||||
|
return {"ok": True, "rows_sent": rows_sent, "topic": topic, "source": src_label, "columns": header}
|
||||||
+43
-1
@@ -26,13 +26,16 @@ from agent_terminal import (
|
|||||||
from lab_context import collect_full_lab_context, format_context_for_agent
|
from lab_context import collect_full_lab_context, format_context_for_agent
|
||||||
from presentation import build_presentation_payload, render_presentation_html
|
from presentation import build_presentation_payload, render_presentation_html
|
||||||
from presentation_upload import (
|
from presentation_upload import (
|
||||||
|
clear_live_override,
|
||||||
create_deck,
|
create_deck,
|
||||||
delete_deck,
|
delete_deck,
|
||||||
get_asset_path,
|
get_asset_path,
|
||||||
get_deck,
|
get_deck,
|
||||||
|
get_live_override,
|
||||||
list_decks,
|
list_decks,
|
||||||
save_deck,
|
save_deck,
|
||||||
save_image,
|
save_image,
|
||||||
|
save_live_override,
|
||||||
save_upload,
|
save_upload,
|
||||||
)
|
)
|
||||||
from presentation_static import get_static_deck, list_static_decks
|
from presentation_static import get_static_deck, list_static_decks
|
||||||
@@ -42,11 +45,13 @@ from pipeline_ops import router as pipeline_router
|
|||||||
from hadoop_analytics import router as hadoop_router
|
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
|
from agent_ops import router as agent_ops_router, agent_dml_loop, etl_agent_loop, custodian_offload_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
|
||||||
from dataflow import router as dataflow_router
|
from dataflow import router as dataflow_router
|
||||||
|
from streaming_ops import router as streaming_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 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
|
||||||
@@ -339,6 +344,11 @@ 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_llm_addendum
|
||||||
|
ctx = ctx + "\n\n" + build_llm_addendum()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
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
|
||||||
@@ -363,6 +373,8 @@ Rules:
|
|||||||
- Use ONLY the live data below — do not invent hosts, ports, numbers or connector names.
|
- 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").
|
- 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.
|
- 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.
|
- Be concise and helpful (max ~10 sentences); bullet lists are fine when they aid clarity.
|
||||||
|
|
||||||
--- LIVE LAB DATA (primary domain first, then full stack) ---
|
--- LIVE LAB DATA (primary domain first, then full stack) ---
|
||||||
@@ -720,6 +732,7 @@ async def lifespan(app: FastAPI):
|
|||||||
dml_task = asyncio.create_task(agent_dml_loop())
|
dml_task = asyncio.create_task(agent_dml_loop())
|
||||||
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())
|
||||||
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()
|
||||||
@@ -741,6 +754,8 @@ app.include_router(agent_ops_router)
|
|||||||
app.include_router(cdc_router)
|
app.include_router(cdc_router)
|
||||||
app.include_router(movements_router)
|
app.include_router(movements_router)
|
||||||
app.include_router(dataflow_router)
|
app.include_router(dataflow_router)
|
||||||
|
app.include_router(streaming_router)
|
||||||
|
app.include_router(spark_workbench_router)
|
||||||
app.include_router(pii_router)
|
app.include_router(pii_router)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -794,7 +809,18 @@ async def get_presentation_data(*, use_cache: bool = True) -> dict[str, Any]:
|
|||||||
gpu = await collect_gpu()
|
gpu = await collect_gpu()
|
||||||
snap = await collect_full_lab_context(gpu_data=gpu, include_inventory=False)
|
snap = await collect_full_lab_context(gpu_data=gpu, include_inventory=False)
|
||||||
data = build_presentation_payload(snap)
|
data = build_presentation_payload(snap)
|
||||||
|
override = get_live_override()
|
||||||
|
if override and override.get("slides"):
|
||||||
|
data["title"] = override.get("title") or data.get("title")
|
||||||
|
data["subtitle"] = override.get("subtitle") or data.get("subtitle", "")
|
||||||
|
data["slides"] = override["slides"]
|
||||||
|
data["slide_count"] = len(override["slides"])
|
||||||
|
data["edited"] = True
|
||||||
|
data["override_ts"] = override.get("ts")
|
||||||
|
else:
|
||||||
|
data["edited"] = False
|
||||||
data["source"] = "live"
|
data["source"] = "live"
|
||||||
|
data["id"] = "live"
|
||||||
_presentation_cache["ts"] = now
|
_presentation_cache["ts"] = now
|
||||||
_presentation_cache["data"] = data
|
_presentation_cache["data"] = data
|
||||||
return data
|
return data
|
||||||
@@ -867,6 +893,7 @@ async def create_presentation_deck(body: dict[str, Any] | None = Body(default=No
|
|||||||
"bullets": list(s.get("bullets") or []),
|
"bullets": list(s.get("bullets") or []),
|
||||||
"image": s.get("image") or "",
|
"image": s.get("image") or "",
|
||||||
"kind": s.get("kind") or "narrative",
|
"kind": s.get("kind") or "narrative",
|
||||||
|
**({"animation": s["animation"]} if s.get("animation") else {}),
|
||||||
}
|
}
|
||||||
for i, s in enumerate(src["slides"], start=1)
|
for i, s in enumerate(src["slides"], start=1)
|
||||||
]
|
]
|
||||||
@@ -878,12 +905,27 @@ async def create_presentation_deck(body: dict[str, Any] | None = Body(default=No
|
|||||||
|
|
||||||
@app.put("/api/presentation/decks/{deck_id}")
|
@app.put("/api/presentation/decks/{deck_id}")
|
||||||
async def update_presentation_deck(deck_id: str, body: dict[str, Any] = Body(...)):
|
async def update_presentation_deck(deck_id: str, body: dict[str, Any] = Body(...)):
|
||||||
|
if deck_id == "live":
|
||||||
|
save_live_override(body)
|
||||||
|
_presentation_cache["ts"] = 0
|
||||||
|
_presentation_cache["data"] = None
|
||||||
|
payload = await get_presentation_data(use_cache=False)
|
||||||
|
return {"ok": True, "deck": payload}
|
||||||
deck = save_deck(deck_id, body)
|
deck = save_deck(deck_id, body)
|
||||||
if not deck:
|
if not deck:
|
||||||
return {"error": "deck not found or not editable"}
|
return {"error": "deck not found or not editable"}
|
||||||
return {"ok": True, "deck": deck}
|
return {"ok": True, "deck": deck}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/presentation/live/reset")
|
||||||
|
async def reset_live_presentation():
|
||||||
|
clear_live_override()
|
||||||
|
_presentation_cache["ts"] = 0
|
||||||
|
_presentation_cache["data"] = None
|
||||||
|
payload = await get_presentation_data(use_cache=False)
|
||||||
|
return {"ok": True, "deck": payload}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/presentation/decks/{deck_id}")
|
@app.delete("/api/presentation/decks/{deck_id}")
|
||||||
async def remove_presentation_deck(deck_id: str):
|
async def remove_presentation_deck(deck_id: str):
|
||||||
return {"ok": delete_deck(deck_id)}
|
return {"ok": delete_deck(deck_id)}
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ MOVEMENTS: list[dict[str, Any]] = [
|
|||||||
{"id": "gen_mongodb", "label": "Generate → MongoDB", "kind": "generate",
|
{"id": "gen_mongodb", "label": "Generate → MongoDB", "kind": "generate",
|
||||||
"dag_id": "gen_mongodb", "agent": "data-custodian", "from": "generator", "to": "mongodb",
|
"dag_id": "gen_mongodb", "agent": "data-custodian", "from": "generator", "to": "mongodb",
|
||||||
"default_conf": {"rows": 3000}},
|
"default_conf": {"rows": 3000}},
|
||||||
|
{"id": "hdfs_to_kafka", "label": "HDFS → Kafka export", "kind": "stream",
|
||||||
|
"dag_id": None, "agent": "hadoop-ranger", "from": "hdfs", "to": "kafka",
|
||||||
|
"api": "/api/pipeline/streaming/hdfs/to-kafka",
|
||||||
|
"default_conf": {"source": "trino", "table": "iceberg.hadoop.historical_sales_hdfs", "topic": "hdfs.historical.sales"}},
|
||||||
{"id": "hadoop_to_trino", "label": "HDFS → Iceberg (Trino)", "kind": "movement",
|
{"id": "hadoop_to_trino", "label": "HDFS → Iceberg (Trino)", "kind": "movement",
|
||||||
"dag_id": "hadoop_to_trino", "agent": "hadoop-ranger", "from": "hdfs", "to": "iceberg_hadoop",
|
"dag_id": "hadoop_to_trino", "agent": "hadoop-ranger", "from": "hdfs", "to": "iceberg_hadoop",
|
||||||
"default_conf": {"mode": "refresh"},
|
"default_conf": {"mode": "refresh"},
|
||||||
@@ -47,6 +51,14 @@ MOVEMENTS: list[dict[str, Any]] = [
|
|||||||
"dag_id": "mask_to_curated", "agent": "lakehouse-ops", "from": "sources", "to": "iceberg_curated",
|
"dag_id": "mask_to_curated", "agent": "lakehouse-ops", "from": "sources", "to": "iceberg_curated",
|
||||||
"default_conf": {},
|
"default_conf": {},
|
||||||
"count_sql": "SELECT count(*) FROM iceberg.curated_masked.sales_orders_masked"},
|
"count_sql": "SELECT count(*) FROM iceberg.curated_masked.sales_orders_masked"},
|
||||||
|
{"id": "spark_to_s3", "label": "Spark → S3 curated", "kind": "movement",
|
||||||
|
"dag_id": "mask_to_curated", "agent": "lakehouse-ops", "from": "spark", "to": "s3_cdc",
|
||||||
|
"default_conf": {"target": "s3"},
|
||||||
|
"count_sql": "SELECT count(*) FROM iceberg.curated_masked.sales_orders_masked"},
|
||||||
|
{"id": "spark_to_curated", "label": "Spark → Iceberg curated", "kind": "movement",
|
||||||
|
"dag_id": "mask_to_curated", "agent": "lakehouse-ops", "from": "spark", "to": "iceberg_curated",
|
||||||
|
"default_conf": {},
|
||||||
|
"count_sql": "SELECT count(*) FROM iceberg.curated_masked.sales_orders_masked"},
|
||||||
]
|
]
|
||||||
MOVEMENT_BY_ID = {m["id"]: m for m in MOVEMENTS}
|
MOVEMENT_BY_ID = {m["id"]: m for m in MOVEMENTS}
|
||||||
|
|
||||||
@@ -116,6 +128,30 @@ 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}"}
|
||||||
|
if mv.get("api"):
|
||||||
|
try:
|
||||||
|
payload = {**(mv.get("default_conf") or {}), **(conf or {})}
|
||||||
|
t0 = time.time()
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
r = await client.post(f"http://127.0.0.1:8000{mv['api']}", json=payload)
|
||||||
|
dur = round(time.time() - t0, 1)
|
||||||
|
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
|
||||||
|
state = "success" if r.status_code < 400 and body.get("ok", True) else "failed"
|
||||||
|
rows = body.get("rows_sent") or body.get("rows")
|
||||||
|
run = {
|
||||||
|
"movement_id": mid, "state": state, "duration_s": dur, "rows": rows,
|
||||||
|
"ended_at": datetime.now(timezone.utc).isoformat(), "conf": payload,
|
||||||
|
}
|
||||||
|
_last_runs[mid] = run
|
||||||
|
await _publish({"type": "movement", **run})
|
||||||
|
lvl = "info" if state == "success" else "err"
|
||||||
|
_feed(agent, f"[etl] {mv['label']}: {state} in {dur}s", lvl)
|
||||||
|
return {"ok": state == "success", **run}
|
||||||
|
except Exception as exc:
|
||||||
|
_last_runs[mid] = {**_last_runs.get(mid, {}), "state": "failed", "error": str(exc)}
|
||||||
|
_feed(agent, f"[etl] {mv['label']}: error {str(exc)[:120]}", "err")
|
||||||
|
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"]
|
agent = mv["agent"]
|
||||||
count_sql = mv.get("count_sql")
|
count_sql = mv.get("count_sql")
|
||||||
|
|||||||
+6
-6
@@ -114,7 +114,7 @@ def _feed(agent_id: str, message: str, level: str = "info") -> None:
|
|||||||
async def _watch_run(source: str, dag_id: str, run_id: str, agent_id: str, rows: int | None) -> None:
|
async def _watch_run(source: str, dag_id: str, run_id: str, agent_id: str, rows: int | None) -> None:
|
||||||
"""Poll an Airflow run to completion and log the outcome to the feed."""
|
"""Poll an Airflow run to completion and log the outcome to the feed."""
|
||||||
name = AGENT_NAME.get(agent_id, agent_id)
|
name = AGENT_NAME.get(agent_id, agent_id)
|
||||||
label = f"{rows} rijen" if rows else "data"
|
label = f"{rows} rows" if rows else "data"
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
tok = await _airflow_token(client)
|
tok = await _airflow_token(client)
|
||||||
@@ -130,10 +130,10 @@ async def _watch_run(source: str, dag_id: str, run_id: str, agent_id: str, rows:
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if state == "success":
|
if state == "success":
|
||||||
_feed(agent_id, f"[datagen] {name} genereerde {label} in {source} — klaar, data stroomt via CDC naar Kafka/S3", "info")
|
_feed(agent_id, f"[datagen] {name} generated {label} in {source} — complete, data flowing via CDC to Kafka/S3", "info")
|
||||||
return
|
return
|
||||||
if state == "failed":
|
if state == "failed":
|
||||||
_feed(agent_id, f"[datagen] {name}: generatie voor {source} is mislukt (zie Airflow logs)", "err")
|
_feed(agent_id, f"[datagen] {name}: generation for {source} failed (see Airflow logs)", "err")
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -205,12 +205,12 @@ async def generate(source: str, body: dict[str, Any] = Body(default={})) -> JSON
|
|||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
_feed(agent_id, f"[datagen] {name}: kon generatie voor {source} niet starten (Airflow {r.status_code})", "err")
|
_feed(agent_id, f"[datagen] {name}: could not start generation for {source} (Airflow {r.status_code})", "err")
|
||||||
return JSONResponse({"ok": False, "error": f"Airflow {r.status_code}: {r.text[:300]}"}, status_code=200)
|
return JSONResponse({"ok": False, "error": f"Airflow {r.status_code}: {r.text[:300]}"}, status_code=200)
|
||||||
j = r.json()
|
j = r.json()
|
||||||
run_id = j.get("dag_run_id")
|
run_id = j.get("dag_run_id")
|
||||||
verb = "genereert zelf" if autonomous else "startte generatie:"
|
verb = "generating autonomously" if autonomous else "started generation of"
|
||||||
rows_txt = f"{conf['rows']} rijen" if conf.get("rows") else "data"
|
rows_txt = f"{conf['rows']} rows" if conf.get("rows") else "data"
|
||||||
_feed(agent_id, f"[datagen] {name} {verb} {rows_txt} in {source}", "info")
|
_feed(agent_id, f"[datagen] {name} {verb} {rows_txt} in {source}", "info")
|
||||||
if run_id:
|
if run_id:
|
||||||
asyncio.create_task(_watch_run(source, dag_id, run_id, agent_id, conf.get("rows")))
|
asyncio.create_task(_watch_run(source, dag_id, run_id, agent_id, conf.get("rows")))
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Live 'platform capabilities + recent changes + masking guidance' block for the LLM.
|
||||||
|
|
||||||
|
This is rebuilt on every question from live in-process state, so the assistant is
|
||||||
|
always autonomously aware of the latest things running in the lab (Data Hub,
|
||||||
|
Spark Workbench, HDFS→Kafka→Spark→S3 pipeline, autonomous agents) and of the
|
||||||
|
exact masking policy currently in force.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_ts(ts: Any) -> str:
|
||||||
|
try:
|
||||||
|
return str(ts)[:19]
|
||||||
|
except Exception:
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
|
def build_platform_section() -> str:
|
||||||
|
lines: list[str] = ["=== PLATFORM CAPABILITIES & RECENT CHANGES (live) ==="]
|
||||||
|
|
||||||
|
lines += [
|
||||||
|
"Command Center features currently deployed:",
|
||||||
|
" - Data Hub: tabbed UI with 'Source Databases' (PostgreSQL, MySQL, MongoDB, Cassandra, Neo4j) and 'Hadoop' (HDFS files, Hive/Iceberg tables, Spark, pipeline).",
|
||||||
|
" - Spark Lakehouse Workbench (Databricks-style): pick any federated table, run preview/filter/aggregate/profile/join/SQL on the distributed engine, and materialize results to Iceberg (S3/HDFS-backed). Live execution matrix: splits, rows, bytes, CPU, wall-time, peak memory, nodes.",
|
||||||
|
" - The same workbench is embedded in each source-database UI (scoped to that source's Trino catalog).",
|
||||||
|
" - Data Flow: live lineage graph with a master pulse switch (Run / Pause / Stop) that starts/stops the animated flow.",
|
||||||
|
" - Pipeline: HDFS (Iceberg historical_sales) → Kafka topic hdfs.historical.sales → Spark transform → Iceberg curated → S3.",
|
||||||
|
"Autonomous agents (run continuously, toggleable):",
|
||||||
|
" - Data Custodian (DML): generates live INSERT/UPDATE/DELETE on the source DBs so Debezium CDC streams to Kafka.",
|
||||||
|
" - Data Custodian (Hadoop offload): periodically offloads recent source rows into the Hadoop Iceberg lake (iceberg.hadoop.*_offload), the batch counterpart to CDC.",
|
||||||
|
" - ETL agent: autonomously triggers data movements (HDFS→Iceberg, mask→curated, generators).",
|
||||||
|
]
|
||||||
|
|
||||||
|
# live streaming + flow
|
||||||
|
try:
|
||||||
|
from streaming_ops import build_streaming_status, flow_snapshot # type: ignore
|
||||||
|
flow = flow_snapshot()
|
||||||
|
lines.append(f"Data Flow pulse: {flow.get('mode')}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# autonomous agent state
|
||||||
|
try:
|
||||||
|
from agent_ops import _state, _etl_state, _custodian_state # type: ignore
|
||||||
|
lines.append(
|
||||||
|
f"DML agent: {'on' if _state.get('enabled') else 'off'} "
|
||||||
|
f"(ops_total={_state.get('ops_total')}, last={_state.get('last_op')})"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f"ETL agent: {'on' if _etl_state.get('enabled') else 'off'} "
|
||||||
|
f"(runs={_etl_state.get('runs_total')}, last={_etl_state.get('last')})"
|
||||||
|
)
|
||||||
|
cust = _custodian_state
|
||||||
|
lines.append(
|
||||||
|
f"Custodian Hadoop offload: {'on' if cust.get('enabled') else 'off'} "
|
||||||
|
f"(offloads={cust.get('runs_total')}, last={cust.get('last')})"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# recent movements
|
||||||
|
try:
|
||||||
|
from movements import last_runs # type: ignore
|
||||||
|
runs = last_runs()
|
||||||
|
if runs:
|
||||||
|
lines.append("Recent data-movement runs:")
|
||||||
|
for mid, r in list(runs.items())[-6:]:
|
||||||
|
lines.append(f" - {mid}: {r.get('state')} rows={r.get('rows')} {_fmt_ts(r.get('ended_at'))}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# recent spark workbench runs
|
||||||
|
try:
|
||||||
|
from spark_workbench import _runs as wb_runs, _run_order # type: ignore
|
||||||
|
recent = [wb_runs[r] for r in _run_order[-6:] if r in wb_runs]
|
||||||
|
if recent:
|
||||||
|
lines.append("Recent Spark Workbench runs:")
|
||||||
|
for r in recent:
|
||||||
|
st = (r.get("stats") or {})
|
||||||
|
tgt = f" → {r.get('target')}" if r.get("target") else ""
|
||||||
|
lines.append(
|
||||||
|
f" - {r.get('label')}: {r.get('state')} rows={st.get('processed_rows')}{tgt}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_masking_section() -> str:
|
||||||
|
"""Exact masking policy + strict guidance so the LLM can answer about masked
|
||||||
|
data without ever revealing masked raw values."""
|
||||||
|
lines: list[str] = ["=== DATA MASKING POLICY (enforced) ==="]
|
||||||
|
masked: list[str] = []
|
||||||
|
unmasked: list[str] = []
|
||||||
|
try:
|
||||||
|
from pii_catalog import get_pii # type: ignore
|
||||||
|
data = get_pii()
|
||||||
|
for d in data.get("datasets", []):
|
||||||
|
for c in d.get("pii_columns", []):
|
||||||
|
tag = f"{d.get('label')}.{c.get('name')} [{c.get('category')}]"
|
||||||
|
(masked if c.get("masked") else unmasked).append(tag)
|
||||||
|
summ = data.get("summary", {})
|
||||||
|
lines.append(
|
||||||
|
f"PII columns: {summ.get('pii_columns', 0)} total — "
|
||||||
|
f"{summ.get('masked_columns', 0)} masked, {summ.get('unmasked_columns', 0)} visible."
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
lines.append(f"(masking catalog unavailable: {exc})")
|
||||||
|
|
||||||
|
if masked:
|
||||||
|
lines.append("MASKED columns (raw values are withheld — token 🔒 MASKED):")
|
||||||
|
for m in masked[:40]:
|
||||||
|
lines.append(f" - {m}")
|
||||||
|
if unmasked:
|
||||||
|
lines.append("Visible PII columns (operator opted out of masking):")
|
||||||
|
for u in unmasked[:40]:
|
||||||
|
lines.append(f" - {u}")
|
||||||
|
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
"How to handle masked 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.",
|
||||||
|
" 2. DO still answer helpfully: confirm the column exists and is masked for privacy/governance, and explain why (PII protection policy).",
|
||||||
|
" 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.",
|
||||||
|
" 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.",
|
||||||
|
" 5. Unmasked PII columns may be shown, but flag that they are sensitive.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_llm_addendum() -> str:
|
||||||
|
try:
|
||||||
|
platform = build_platform_section()
|
||||||
|
except Exception as exc:
|
||||||
|
platform = f"(platform section error: {exc})"
|
||||||
|
try:
|
||||||
|
masking = build_masking_section()
|
||||||
|
except Exception as exc:
|
||||||
|
masking = f"(masking section error: {exc})"
|
||||||
|
return "\n\n".join([platform, masking])
|
||||||
+164
-33
@@ -18,6 +18,30 @@ def _slide(slide_id: str, title: str, subtitle: str, bullets: list[str], **extra
|
|||||||
return {"id": slide_id, "title": title, "subtitle": subtitle, "bullets": bullets, **extra}
|
return {"id": slide_id, "title": title, "subtitle": subtitle, "bullets": bullets, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
def _node_slide(nid: str, extra_bullets: list[str] | None = None) -> dict[str, Any]:
|
||||||
|
reg = NODE_REGISTRY.get(nid, {})
|
||||||
|
bullets: list[str] = []
|
||||||
|
if reg.get("description"):
|
||||||
|
bullets.append(reg["description"])
|
||||||
|
bullets.append(f"VM: {reg.get('vm', '?')} (VMID {reg.get('vmid', '?')}) @ {reg.get('ip', '?')}")
|
||||||
|
for link in reg.get("links", [])[:5]:
|
||||||
|
bullets.append(f"{link.get('label', 'Link')}: {link.get('url', '')}")
|
||||||
|
for ep in reg.get("endpoints", [])[:6]:
|
||||||
|
bullets.append(f"{ep.get('name', '?')}: {ep.get('host', '?')}:{ep.get('port', '?')}")
|
||||||
|
if extra_bullets:
|
||||||
|
bullets.extend(extra_bullets)
|
||||||
|
kind = "gpu" if nid == "gpu" else "command" if nid == "command" else "zone"
|
||||||
|
if nid in ("openmetadata", "elastic"):
|
||||||
|
kind = "architecture"
|
||||||
|
return _slide(
|
||||||
|
f"node-{nid}",
|
||||||
|
reg.get("label", nid),
|
||||||
|
f"{reg.get('role', 'service').upper()} · {reg.get('vm', '')}",
|
||||||
|
bullets[:14],
|
||||||
|
kind=kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
|
def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
|
||||||
workload = build_workload_payload(snap)
|
workload = build_workload_payload(snap)
|
||||||
topologies = workload.get("topologies") or build_all_topologies(snap)
|
topologies = workload.get("topologies") or build_all_topologies(snap)
|
||||||
@@ -28,20 +52,24 @@ def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
|
|||||||
hadoop = snap.get("hadoop", {})
|
hadoop = snap.get("hadoop", {})
|
||||||
objectscale = snap.get("objectscale", {})
|
objectscale = snap.get("objectscale", {})
|
||||||
command = snap.get("command_center", {})
|
command = snap.get("command_center", {})
|
||||||
|
databases = snap.get("databases", {})
|
||||||
|
docker = snap.get("docker", {})
|
||||||
|
lakehouse = snap.get("lakehouse", {})
|
||||||
|
governance = snap.get("governance") or {}
|
||||||
|
|
||||||
slides: list[dict[str, Any]] = []
|
slides: list[dict[str, Any]] = []
|
||||||
|
|
||||||
slides.append(_slide(
|
slides.append(_slide(
|
||||||
"title",
|
"title",
|
||||||
"Dell ATC Data Lab",
|
"Dell ATC Data Lab",
|
||||||
"Live demo & presentation — Command Center",
|
"Live infrastructure presentation — Command Center",
|
||||||
[
|
[
|
||||||
f"Snapshot: {snap.get('ts', 'now')}",
|
f"Snapshot: {snap.get('ts', 'now')}",
|
||||||
f"Pipeline: {'ACTIVE' if totals.get('pipeline_active') else 'INACTIVE'}",
|
f"Pipeline: {'ACTIVE' if totals.get('pipeline_active') else 'INACTIVE'}",
|
||||||
f"Apps running: {totals.get('apps_running', 0)}/{totals.get('apps_total', 0)}",
|
f"Apps running: {totals.get('apps_running', 0)}/{totals.get('apps_total', 0)}",
|
||||||
f"CDC connectors: {totals.get('connectors', 0)}",
|
f"CDC connectors: {totals.get('connectors', 0)} · Source DBs: 5 engines on db02",
|
||||||
f"LLM: {gpu.get('model') or 'offline'} ({gpu.get('gpu_count', 0)}× V100)",
|
f"LLM: {gpu.get('model') or 'offline'} ({gpu.get('gpu_count', 0)}× V100)",
|
||||||
"Command Center → http://10.0.21.33/",
|
"Command Center → http://10.0.21.33/ · Data Platform tab → Presentation",
|
||||||
],
|
],
|
||||||
kind="hero",
|
kind="hero",
|
||||||
))
|
))
|
||||||
@@ -51,53 +79,151 @@ def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"Mission",
|
"Mission",
|
||||||
"End-to-end modern data platform on Dell infrastructure",
|
"End-to-end modern data platform on Dell infrastructure",
|
||||||
[
|
[
|
||||||
"Ingest change data from operational databases (PostgreSQL, MySQL, MongoDB, Cassandra)",
|
"Ingest CDC from PostgreSQL, MySQL, MongoDB (+ Cassandra & Neo4j for analytics)",
|
||||||
"Stream via Kafka & Debezium into the lakehouse (Spark, Trino, Iceberg)",
|
"Stream via Kafka & Debezium into Spark, Trino & Iceberg on the lakehouse",
|
||||||
"Land curated data on ObjectScale S3 — query with Trino & visualize in Superset",
|
"Land curated data on ObjectScale S3 — federated SQL with Trino",
|
||||||
"Parallel HDFS cluster for batch / legacy workloads",
|
"Govern with OpenMetadata — catalog, lineage, PII classification",
|
||||||
"GPU lab powers autonomous ops agents with local vLLM inference",
|
"Search & observe via Elasticsearch/Kibana · BI via Superset",
|
||||||
"This dashboard orchestrates agents, approvals, and live cluster visibility",
|
"Parallel 9-node Hadoop HDFS cluster for batch workloads",
|
||||||
|
"GPU lab (4× V100) powers autonomous agents with local vLLM inference",
|
||||||
|
"This Command Center orchestrates agents, approvals & live visibility",
|
||||||
],
|
],
|
||||||
kind="narrative",
|
kind="narrative",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
slides.append(_slide(
|
||||||
|
"command-center-ui",
|
||||||
|
"Command Center UI",
|
||||||
|
"Everything you operate from this dashboard",
|
||||||
|
[
|
||||||
|
"Data Platform — interactive topology + live presentation deck",
|
||||||
|
"Data Sources UI — browse, filter & edit all 5 source databases",
|
||||||
|
"Live Changes — real-time Debezium CDC event stream",
|
||||||
|
"Data Flow — OpenMetadata catalog, lineage & pipeline map",
|
||||||
|
"Data Quality — Docling document QA + RAG ingest",
|
||||||
|
"Knowledge Chat — GPU-backed RAG over lab documentation",
|
||||||
|
"Object Storage · HDFS · Elasticsearch · SSH terminal",
|
||||||
|
"Agent fleet · Approval inbox · Live GPU matrix",
|
||||||
|
],
|
||||||
|
kind="command",
|
||||||
|
))
|
||||||
|
|
||||||
arch = topologies.get("architecture") or workload.get("topology") or {}
|
arch = topologies.get("architecture") or workload.get("topology") or {}
|
||||||
arch_nodes = arch.get("nodes", [])
|
arch_nodes = arch.get("nodes", [])
|
||||||
slides.append(_slide(
|
slides.append(_slide(
|
||||||
"architecture",
|
"architecture",
|
||||||
"Data Platform Architecture",
|
"Data Platform Architecture",
|
||||||
arch.get("subtitle", "Sources → Ingestion → Compute → Storage → Consumers"),
|
arch.get("subtitle", "Sources → Ingestion → Compute → Storage → Consumers"),
|
||||||
[f"{n.get('label', n.get('id'))}: {n.get('subtitle', n.get('role', ''))}" for n in arch_nodes[:14]],
|
[f"{n.get('label', n.get('id'))}: {n.get('subtitle', n.get('role', ''))}" for n in arch_nodes[:16]],
|
||||||
kind="topology",
|
kind="topology",
|
||||||
topology=arch,
|
topology=arch,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
db_lines = [
|
||||||
|
f"Host atc-db02 (10.0.21.51) — {databases.get('running', 0)}/{databases.get('total', 0)} containers up",
|
||||||
|
"postgres_sales — PostgreSQL sales_orders (CDC → Debezium)",
|
||||||
|
"mysql_hr — MySQL employee_events (CDC → Debezium)",
|
||||||
|
"mongodb_supplychain — MongoDB supplychain.events (CDC → Debezium)",
|
||||||
|
"cassandra_telemetry — Cassandra device_metrics (Trino federated)",
|
||||||
|
"neo4j_graph — Product/Supplier graph · 4.5M nodes",
|
||||||
|
]
|
||||||
|
for c in (databases.get("containers") or [])[:8]:
|
||||||
|
db_lines.append(f" · {c.get('name', '?')}: {c.get('state', '?')}")
|
||||||
|
slides.append(_slide(
|
||||||
|
"source-databases",
|
||||||
|
"Source Databases",
|
||||||
|
"DB Vault · atc-db02 · 10.0.21.51",
|
||||||
|
db_lines,
|
||||||
|
kind="topology",
|
||||||
|
))
|
||||||
|
|
||||||
pipeline = topologies.get("pipeline", {})
|
pipeline = topologies.get("pipeline", {})
|
||||||
connector_lines = [
|
connector_lines = [
|
||||||
f" · {cs['name']}: {cs.get('state', '?')}"
|
f" · {cs['name']}: {cs.get('state', '?')}"
|
||||||
for cs in (etl.get("connector_status") or [])[:6]
|
for cs in (etl.get("connector_status") or [])[:8]
|
||||||
]
|
]
|
||||||
slides.append(_slide(
|
slides.append(_slide(
|
||||||
"pipeline",
|
"pipeline",
|
||||||
"CDC Pipeline",
|
"CDC Pipeline",
|
||||||
pipeline.get("subtitle", "Airflow → DB → Debezium → Kafka → Lakehouse → S3"),
|
pipeline.get("subtitle", "Airflow → DB → Debezium → Kafka → Lakehouse → S3"),
|
||||||
[
|
[
|
||||||
f"Airflow: {'healthy' if etl.get('airflow_healthy') else 'degraded'} ({etl.get('airflow_url', '')})",
|
f"Airflow: {'healthy' if etl.get('airflow_healthy') else 'degraded'} — {etl.get('airflow_url', 'http://10.0.21.55:8080')}",
|
||||||
f"Kafka UI: {'UP' if etl.get('kafka_ui_ok') else 'DOWN'}",
|
f"Kafka UI: {'UP' if etl.get('kafka_ui_ok') else 'DOWN'} — http://10.0.21.36:9000",
|
||||||
|
f"Debezium Connect: http://10.0.21.50:8083",
|
||||||
f"Connectors: {', '.join(etl.get('connectors') or []) or 'none'}",
|
f"Connectors: {', '.join(etl.get('connectors') or []) or 'none'}",
|
||||||
*connector_lines,
|
*connector_lines,
|
||||||
f"Spark UI: {'UP' if etl.get('spark_ui_ok') else 'DOWN'}",
|
f"Spark UI: {'UP' if etl.get('spark_ui_ok') else 'DOWN'} — http://10.0.21.50:8080",
|
||||||
f"ObjectScale: {'reachable' if objectscale.get('reachable') else 'down'} bucket={objectscale.get('bucket', 'data')}",
|
f"ObjectScale S3: {'reachable' if objectscale.get('reachable') else 'down'} — bucket={objectscale.get('bucket', 'data')}",
|
||||||
],
|
],
|
||||||
kind="topology",
|
kind="topology",
|
||||||
topology=pipeline,
|
topology=pipeline,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
lake_lines = [
|
||||||
|
f"atc-lake01 @ {lakehouse.get('host', '10.0.21.50')} — {lakehouse.get('running', 0)}/{lakehouse.get('total', 0)} containers",
|
||||||
|
f"Trino: {'UP' if lakehouse.get('trino_ok') else 'DOWN'} — http://10.0.21.50:8089",
|
||||||
|
"Spark — batch & streaming compute",
|
||||||
|
"Kafka Connect + Debezium — CDC ingestion",
|
||||||
|
"s3-kafka-consumer — events → ObjectScale S3",
|
||||||
|
"Iceberg catalog — bronze → silver → gold tables",
|
||||||
|
]
|
||||||
|
for c in (lakehouse.get("containers") or [])[:8]:
|
||||||
|
lake_lines.append(f" · {c.get('name', '?')}: {c.get('state', '?')}")
|
||||||
|
slides.append(_slide(
|
||||||
|
"lakehouse",
|
||||||
|
"Lakehouse Hub",
|
||||||
|
"Spark · Trino · Iceberg · Kafka Connect",
|
||||||
|
lake_lines,
|
||||||
|
kind="topology",
|
||||||
|
))
|
||||||
|
|
||||||
|
docker_lines = [
|
||||||
|
f"atc-docker01 @ 10.0.21.45 — {docker.get('running', 0)}/{docker.get('total', 0)} containers",
|
||||||
|
"Homepage — http://10.0.21.45",
|
||||||
|
"Dockhand — container management http://10.0.21.45:8082",
|
||||||
|
"Apache Superset — BI dashboards http://10.0.21.45:8088",
|
||||||
|
"Forgejo / Gitea · monitoring · nginx · redis",
|
||||||
|
]
|
||||||
|
for c in (docker.get("containers") or [])[:10]:
|
||||||
|
docker_lines.append(f" · {c.get('name', '?')}: {c.get('state', '?')}")
|
||||||
|
slides.append(_slide(
|
||||||
|
"docker-rack",
|
||||||
|
"Docker Rack & Analytics",
|
||||||
|
"Platform services on atc-docker01",
|
||||||
|
docker_lines,
|
||||||
|
kind="zone",
|
||||||
|
))
|
||||||
|
|
||||||
|
cdc = governance.get("cdc") or {}
|
||||||
|
gov_lines = [
|
||||||
|
f"OpenMetadata UI: {governance.get('openmetadata_url', 'http://10.0.21.47:8585')}",
|
||||||
|
"Ingestion Airflow: http://10.0.21.47:8080",
|
||||||
|
"Catalog · lineage · data quality · PII auto-classification (Presidio NER)",
|
||||||
|
]
|
||||||
|
if "error" not in cdc:
|
||||||
|
by_src = ", ".join(f"{k}={v}" for k, v in (cdc.get("by_source") or {}).items()) or "none"
|
||||||
|
gov_lines.append(f"CDC stream: connected={cdc.get('connected')} · {cdc.get('window_total', 0)} changes/15m ({by_src})")
|
||||||
|
ps = governance.get("pii_summary") or {}
|
||||||
|
if "error" not in ps:
|
||||||
|
gov_lines.append(f"PII: {ps.get('pii_columns', 0)} columns · {ps.get('masked_columns', 0)} masked")
|
||||||
|
for ln in (governance.get("lineage") or [])[:4]:
|
||||||
|
gov_lines.append(f"Lineage: {ln}")
|
||||||
|
slides.append(_slide(
|
||||||
|
"governance",
|
||||||
|
"Governance & Metadata",
|
||||||
|
"OpenMetadata · CDC · PII · Lineage",
|
||||||
|
gov_lines,
|
||||||
|
kind="architecture",
|
||||||
|
))
|
||||||
|
|
||||||
|
slides.append(_node_slide("openmetadata"))
|
||||||
|
slides.append(_node_slide("elastic"))
|
||||||
|
|
||||||
for zone in zones:
|
for zone in zones:
|
||||||
apps = zone.get("apps") or []
|
apps = zone.get("apps") or []
|
||||||
app_lines = [
|
app_lines = [
|
||||||
f"{a['name']}: {a['state']}" + (f" ({a.get('host', '')})" if a.get("host") else "")
|
f"{a['name']}: {a['state']}" + (f" ({a.get('host', '')})" if a.get("host") else "")
|
||||||
for a in apps[:10]
|
for a in apps[:12]
|
||||||
]
|
]
|
||||||
slides.append(_slide(
|
slides.append(_slide(
|
||||||
f"zone-{zone['id']}",
|
f"zone-{zone['id']}",
|
||||||
@@ -111,32 +237,36 @@ def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
|
|||||||
zone=zone,
|
zone=zone,
|
||||||
))
|
))
|
||||||
|
|
||||||
infra_nodes = [
|
PRESENTATION_NODES = [
|
||||||
nid for nid in NODE_REGISTRY
|
"airflow", "db", "debezium", "kafka", "lakehouse", "s3",
|
||||||
if nid not in ("mo-commander", "bart-commander", "network-watcher", "mcp-coordinator")
|
"docker", "hadoop", "gpu", "command",
|
||||||
]
|
]
|
||||||
slides.append(_slide(
|
slides.append(_slide(
|
||||||
"infrastructure",
|
"infrastructure-map",
|
||||||
"Infrastructure Map",
|
"Infrastructure Map",
|
||||||
"Proxmox VMs & services across VLAN 20/21",
|
"Proxmox VMs & services across VLAN 20/21",
|
||||||
[
|
[
|
||||||
f"{NODE_REGISTRY[nid]['label']} — {NODE_REGISTRY[nid].get('vm')} "
|
f"{NODE_REGISTRY[nid]['label']} — {NODE_REGISTRY[nid].get('vm')} "
|
||||||
f"(VMID {NODE_REGISTRY[nid].get('vmid', '?')}) @ {NODE_REGISTRY[nid].get('ip')}"
|
f"(VMID {NODE_REGISTRY[nid].get('vmid', '?')}) @ {NODE_REGISTRY[nid].get('ip')}"
|
||||||
for nid in infra_nodes
|
for nid in PRESENTATION_NODES if nid in NODE_REGISTRY
|
||||||
],
|
],
|
||||||
kind="registry",
|
kind="registry",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
for nid in PRESENTATION_NODES:
|
||||||
|
if nid in NODE_REGISTRY and nid not in ("docker", "db", "lakehouse"):
|
||||||
|
slides.append(_node_slide(nid))
|
||||||
|
|
||||||
dn_lines = [
|
dn_lines = [
|
||||||
f" · {dn['host']}: {dn.get('used_gb', 0)} GB — {dn.get('state', '')}"
|
f" · {dn['host']}: {dn.get('used_gb', 0)} GB — {dn.get('state', '')}"
|
||||||
for dn in (hadoop.get("datanodes") or [])[:5]
|
for dn in (hadoop.get("datanodes") or [])[:6]
|
||||||
]
|
]
|
||||||
slides.append(_slide(
|
slides.append(_slide(
|
||||||
"hadoop",
|
"hadoop",
|
||||||
"Hadoop HDFS",
|
"Hadoop HDFS",
|
||||||
"9-node parallel storage cluster",
|
"9-node parallel storage cluster",
|
||||||
[
|
[
|
||||||
f"NameNode: {'UP' if hadoop.get('reachable') else 'DOWN'} — {hadoop.get('namenode', '')}",
|
f"NameNode: {'UP' if hadoop.get('reachable') else 'DOWN'} — {hadoop.get('namenode', 'http://10.0.21.61:9870')}",
|
||||||
f"Capacity: {hadoop.get('capacity_used_gb', '?')} / {hadoop.get('capacity_total_gb', '?')} GB",
|
f"Capacity: {hadoop.get('capacity_used_gb', '?')} / {hadoop.get('capacity_total_gb', '?')} GB",
|
||||||
f"DataNodes: {hadoop.get('live_datanodes', 0)} live, {hadoop.get('dead_datanodes', 0)} dead",
|
f"DataNodes: {hadoop.get('live_datanodes', 0)} live, {hadoop.get('dead_datanodes', 0)} dead",
|
||||||
f"Files: {hadoop.get('files_total', 0)}, Blocks: {hadoop.get('blocks_total', 0)}",
|
f"Files: {hadoop.get('files_total', 0)}, Blocks: {hadoop.get('blocks_total', 0)}",
|
||||||
@@ -158,7 +288,8 @@ def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
|
|||||||
[
|
[
|
||||||
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: {snap.get('gpu', {}).get('vllm_url') or 'http://10.0.20.106:8001/v1'}",
|
||||||
"Manager: http://10.0.20.106:9000",
|
"GPU Lab UI: http://10.0.20.106:9000",
|
||||||
|
"Kibana/Elastic: http://10.0.21.46:5601",
|
||||||
*gpu_lines,
|
*gpu_lines,
|
||||||
],
|
],
|
||||||
kind="gpu",
|
kind="gpu",
|
||||||
@@ -171,7 +302,7 @@ def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
|
|||||||
[
|
[
|
||||||
"ETL Guardian — Airflow, Kafka, Debezium, connectors",
|
"ETL Guardian — Airflow, Kafka, Debezium, connectors",
|
||||||
"Data Custodian — PostgreSQL, MySQL, MongoDB, Cassandra, Neo4j",
|
"Data Custodian — PostgreSQL, MySQL, MongoDB, Cassandra, Neo4j",
|
||||||
"Lakehouse Ops — Spark, Trino, Iceberg, ObjectScale S3",
|
"Lakehouse Ops — Spark, Trino, Iceberg, ObjectScale S3, OpenMetadata",
|
||||||
"Hadoop Ranger — HDFS NameNode, DataNodes, block health",
|
"Hadoop Ranger — HDFS NameNode, DataNodes, block health",
|
||||||
"Infra Sentinel — Docker rack, GPU lab, Command Center",
|
"Infra Sentinel — Docker rack, GPU lab, Command Center",
|
||||||
"All agents receive LIVE cluster snapshot in every LLM prompt",
|
"All agents receive LIVE cluster snapshot in every LLM prompt",
|
||||||
@@ -182,13 +313,13 @@ def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
|
|||||||
cc_apps = [f"{c['name']}: {c['state']}" for c in (command.get("containers") or [])]
|
cc_apps = [f"{c['name']}: {c['state']}" for c in (command.get("containers") or [])]
|
||||||
slides.append(_slide(
|
slides.append(_slide(
|
||||||
"command",
|
"command",
|
||||||
"Command Center",
|
"Command Center Stack",
|
||||||
"VM 304 — this presentation runs here",
|
"VM 304 — this dashboard runs here",
|
||||||
[
|
[
|
||||||
f"Host: {command.get('host', '10.0.21.33')} (VMID {command.get('vmid', 304)})",
|
f"Host: {command.get('host', '10.0.21.33')} (VMID {command.get('vmid', 304)})",
|
||||||
f"Stack: {command.get('running', 0)}/{command.get('total', 0)} containers",
|
f"Stack: {command.get('running', 0)}/{command.get('total', 0)} containers (api, ui, caddy, redis, postgres)",
|
||||||
*cc_apps,
|
*cc_apps,
|
||||||
"WebSocket ops feed · Approval inbox · Agent terminals",
|
"WebSocket ops feed · Approval inbox · Agent terminals · Data Sources UI",
|
||||||
],
|
],
|
||||||
kind="command",
|
kind="command",
|
||||||
))
|
))
|
||||||
@@ -198,12 +329,12 @@ def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"Live Demo Tips",
|
"Live Demo Tips",
|
||||||
"Use this deck during customer presentations",
|
"Use this deck during customer presentations",
|
||||||
[
|
[
|
||||||
"Press ← → or click dots to navigate slides",
|
"Press ← → or click dots to navigate slides · F = fullscreen",
|
||||||
"F = fullscreen presentation mode",
|
"Data Platform tab → Presentation sub-tab (this deck)",
|
||||||
|
"Data Platform → Topology for interactive pipeline map",
|
||||||
"Export HTML opens a standalone deck for projectors / offline",
|
"Export HTML opens a standalone deck for projectors / offline",
|
||||||
"Ask agents in the Command Bar — they see full cluster context",
|
"Ask agents in the Command Bar — they see full cluster context",
|
||||||
"Switch to Data Platform tab for interactive topology",
|
"Trigger data generation in Data Sources UI → Generate tab",
|
||||||
"GPU Lab chat: http://10.0.20.106:9000/chat",
|
|
||||||
],
|
],
|
||||||
kind="cta",
|
kind="cta",
|
||||||
))
|
))
|
||||||
|
|||||||
+69
-20
@@ -13,6 +13,7 @@ from typing import Any
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
PRESENTATIONS_DIR = Path(os.getenv("PRESENTATIONS_DIR", "/data/presentations"))
|
PRESENTATIONS_DIR = Path(os.getenv("PRESENTATIONS_DIR", "/data/presentations"))
|
||||||
|
LIVE_OVERRIDE_DIR = PRESENTATIONS_DIR / "live-override"
|
||||||
DOCLING_URL = os.getenv("DOCLING_URL", "http://docling-serve:5001").rstrip("/")
|
DOCLING_URL = os.getenv("DOCLING_URL", "http://docling-serve:5001").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
@@ -165,6 +166,65 @@ def get_deck(deck_id: str) -> dict[str, Any] | None:
|
|||||||
return json.loads(path.read_text())
|
return json.loads(path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def get_live_override() -> dict[str, Any] | None:
|
||||||
|
path = LIVE_OVERRIDE_DIR / "meta.json"
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_live_override(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Persist user edits for the Live Cluster deck."""
|
||||||
|
LIVE_OVERRIDE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
(LIVE_OVERRIDE_DIR / "assets").mkdir(parents=True, exist_ok=True)
|
||||||
|
existing = get_live_override() or {"id": "live", "source": "live-override", "editable": True}
|
||||||
|
slides = _clean_slides(body.get("slides"))
|
||||||
|
payload = {
|
||||||
|
**existing,
|
||||||
|
"id": "live",
|
||||||
|
"source": "live-override",
|
||||||
|
"editable": True,
|
||||||
|
"title": str(body.get("title") or existing.get("title") or "Live Cluster")[:120],
|
||||||
|
"subtitle": str(body.get("subtitle") or existing.get("subtitle") or "")[:300],
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"slides": slides,
|
||||||
|
"slide_count": len(slides),
|
||||||
|
}
|
||||||
|
(LIVE_OVERRIDE_DIR / "meta.json").write_text(json.dumps(payload, indent=2, default=str))
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def clear_live_override() -> bool:
|
||||||
|
import shutil
|
||||||
|
if not LIVE_OVERRIDE_DIR.exists():
|
||||||
|
return True
|
||||||
|
shutil.rmtree(LIVE_OVERRIDE_DIR, ignore_errors=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_slides(incoming_slides: Any) -> list[dict[str, Any]]:
|
||||||
|
clean_slides: list[dict[str, Any]] = []
|
||||||
|
for i, s in enumerate(incoming_slides or [], start=1):
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
continue
|
||||||
|
bullets = [str(b).strip()[:400] for b in (s.get("bullets") or []) if str(b).strip()]
|
||||||
|
slide: dict[str, Any] = {
|
||||||
|
"id": str(s.get("id") or f"slide-{i}"),
|
||||||
|
"title": str(s.get("title") or f"Slide {i}")[:200],
|
||||||
|
"subtitle": str(s.get("subtitle") or "")[:300],
|
||||||
|
"bullets": bullets,
|
||||||
|
"image": str(s.get("image") or "")[:300],
|
||||||
|
"kind": str(s.get("kind") or "narrative")[:40],
|
||||||
|
}
|
||||||
|
if s.get("animation"):
|
||||||
|
slide["animation"] = str(s.get("animation"))[:40]
|
||||||
|
clean_slides.append(slide)
|
||||||
|
return clean_slides or [_blank_slide(1)]
|
||||||
|
|
||||||
|
|
||||||
def _blank_slide(idx: int = 1) -> dict[str, Any]:
|
def _blank_slide(idx: int = 1) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": f"slide-{idx}",
|
"id": f"slide-{idx}",
|
||||||
@@ -208,22 +268,7 @@ def save_deck(deck_id: str, body: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
existing = json.loads(meta_path.read_text())
|
existing = json.loads(meta_path.read_text())
|
||||||
|
|
||||||
incoming_slides = body.get("slides")
|
clean_slides = _clean_slides(body.get("slides"))
|
||||||
clean_slides: list[dict[str, Any]] = []
|
|
||||||
for i, s in enumerate(incoming_slides or [], start=1):
|
|
||||||
if not isinstance(s, dict):
|
|
||||||
continue
|
|
||||||
bullets = [str(b).strip()[:400] for b in (s.get("bullets") or []) if str(b).strip()]
|
|
||||||
clean_slides.append({
|
|
||||||
"id": str(s.get("id") or f"slide-{i}"),
|
|
||||||
"title": str(s.get("title") or f"Slide {i}")[:200],
|
|
||||||
"subtitle": str(s.get("subtitle") or "")[:300],
|
|
||||||
"bullets": bullets,
|
|
||||||
"image": str(s.get("image") or "")[:300],
|
|
||||||
"kind": str(s.get("kind") or "narrative")[:40],
|
|
||||||
})
|
|
||||||
if not clean_slides:
|
|
||||||
clean_slides = [_blank_slide(1)]
|
|
||||||
|
|
||||||
existing.update({
|
existing.update({
|
||||||
"title": str(body.get("title") or existing.get("title") or "Untitled deck")[:120],
|
"title": str(body.get("title") or existing.get("title") or "Untitled deck")[:120],
|
||||||
@@ -250,9 +295,12 @@ def delete_deck(deck_id: str) -> bool:
|
|||||||
|
|
||||||
def save_image(deck_id: str, filename: str, content: bytes) -> dict[str, Any] | None:
|
def save_image(deck_id: str, filename: str, content: bytes) -> dict[str, Any] | None:
|
||||||
"""Store an image in the deck's assets folder; return its served URL."""
|
"""Store an image in the deck's assets folder; return its served URL."""
|
||||||
deck_dir = PRESENTATIONS_DIR / deck_id
|
if deck_id == "live":
|
||||||
if not deck_dir.exists():
|
deck_dir = LIVE_OVERRIDE_DIR
|
||||||
return None
|
else:
|
||||||
|
deck_dir = PRESENTATIONS_DIR / deck_id
|
||||||
|
if not deck_dir.exists():
|
||||||
|
return None
|
||||||
assets = deck_dir / "assets"
|
assets = deck_dir / "assets"
|
||||||
assets.mkdir(parents=True, exist_ok=True)
|
assets.mkdir(parents=True, exist_ok=True)
|
||||||
ext = ""
|
ext = ""
|
||||||
@@ -265,7 +313,8 @@ def save_image(deck_id: str, filename: str, content: bytes) -> dict[str, Any] |
|
|||||||
|
|
||||||
def get_asset_path(deck_id: str, name: str) -> Path | None:
|
def get_asset_path(deck_id: str, name: str) -> Path | None:
|
||||||
safe = _safe_name(name)
|
safe = _safe_name(name)
|
||||||
path = PRESENTATIONS_DIR / deck_id / "assets" / safe
|
base = LIVE_OVERRIDE_DIR if deck_id == "live" else PRESENTATIONS_DIR / deck_id
|
||||||
|
path = base / "assets" / safe
|
||||||
if not path.exists() or not path.is_file():
|
if not path.exists() or not path.is_file():
|
||||||
return None
|
return None
|
||||||
return path
|
return path
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
"""Databricks-style lakehouse workbench for the Command Center.
|
||||||
|
|
||||||
|
Lets the user interactively select data from any federated source (Iceberg,
|
||||||
|
Hive/HDFS, Postgres, MySQL, Mongo, Cassandra, Kafka) and run distributed
|
||||||
|
transformations on the lakehouse compute layer:
|
||||||
|
|
||||||
|
explore -> transform (aggregate / filter / join / profile) -> materialize to Iceberg (S3/HDFS)
|
||||||
|
|
||||||
|
Execution streams live engine metrics (state, splits, rows, bytes, CPU, wall,
|
||||||
|
peak memory, nodes) so the UI can show a live "what the cluster is doing" matrix
|
||||||
|
exactly like Databricks' Spark UI. Compute runs on the lakehouse engine (Trino
|
||||||
|
coordinator on the Spark cluster host) which distributes work across the
|
||||||
|
workers; Spark batch DAGs remain available via the jobs API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
|
||||||
|
TRINO_USER = os.getenv("TRINO_USER", "mo")
|
||||||
|
SPARK_UI_URL = os.getenv("SPARK_UI_URL", "http://10.0.21.50:8080").rstrip("/")
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/spark", tags=["spark-workbench"])
|
||||||
|
|
||||||
|
_runs: dict[str, dict[str, Any]] = {}
|
||||||
|
_run_order: list[str] = []
|
||||||
|
_MAX_RUNS = 40
|
||||||
|
_MAX_RESULT_ROWS = 500
|
||||||
|
|
||||||
|
_IDENT_RE = re.compile(r"^[A-Za-z0-9_.\" ]+$")
|
||||||
|
_TABLE_RE = re.compile(r"^[A-Za-z0-9_.\"]+$")
|
||||||
|
|
||||||
|
AGG_FUNCS = {"count", "sum", "avg", "min", "max", "approx_distinct", "count_distinct"}
|
||||||
|
JOIN_TYPES = {"INNER", "LEFT", "RIGHT", "FULL"}
|
||||||
|
|
||||||
|
|
||||||
|
def _feed(agent_id: str, message: str, level: str = "info") -> None:
|
||||||
|
try:
|
||||||
|
from main import add_feed
|
||||||
|
add_feed(agent_id, message, level)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _qident(name: str) -> str:
|
||||||
|
name = name.strip().strip('"')
|
||||||
|
if not re.match(r"^[A-Za-z0-9_]+$", name):
|
||||||
|
raise ValueError(f"invalid identifier: {name}")
|
||||||
|
return f'"{name}"'
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_table(name: str) -> str:
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not name or not _TABLE_RE.match(name):
|
||||||
|
raise ValueError(f"invalid table reference: {name}")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
# ── Trino helpers ────────────────────────────────────────────────────────────
|
||||||
|
def _trino_headers() -> dict[str, str]:
|
||||||
|
return {"X-Trino-User": TRINO_USER, "Content-Type": "text/plain"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _trino_collect(sql: str, limit: int = 200, timeout: float = 60.0) -> dict[str, Any]:
|
||||||
|
"""Synchronous-style helper: run a query and return all rows (for catalog ops)."""
|
||||||
|
columns: list[str] = []
|
||||||
|
rows: list[list[Any]] = []
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
r = await client.post(f"{TRINO_URL}/v1/statement", content=sql.encode(), headers=_trino_headers())
|
||||||
|
if r.status_code >= 400:
|
||||||
|
return {"ok": False, "error": r.text[:400]}
|
||||||
|
data = r.json()
|
||||||
|
while True:
|
||||||
|
if data.get("error"):
|
||||||
|
return {"ok": False, "error": str(data["error"])[:400]}
|
||||||
|
if data.get("columns") and not columns:
|
||||||
|
columns = [c["name"] for c in data["columns"]]
|
||||||
|
for row in data.get("data") or []:
|
||||||
|
rows.append(row)
|
||||||
|
if len(rows) >= limit:
|
||||||
|
break
|
||||||
|
nxt = data.get("nextUri")
|
||||||
|
if not nxt or len(rows) >= limit:
|
||||||
|
if nxt:
|
||||||
|
try:
|
||||||
|
await client.delete(nxt, headers=_trino_headers())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
data = (await client.get(nxt, headers=_trino_headers())).json()
|
||||||
|
return {"ok": True, "columns": columns, "rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_stats(stats: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"state": stats.get("state"),
|
||||||
|
"nodes": stats.get("nodes"),
|
||||||
|
"total_splits": stats.get("totalSplits"),
|
||||||
|
"queued_splits": stats.get("queuedSplits"),
|
||||||
|
"running_splits": stats.get("runningSplits"),
|
||||||
|
"completed_splits": stats.get("completedSplits"),
|
||||||
|
"processed_rows": stats.get("processedRows"),
|
||||||
|
"processed_bytes": stats.get("processedBytes"),
|
||||||
|
"physical_input_bytes": stats.get("physicalInputBytes"),
|
||||||
|
"peak_memory_bytes": stats.get("peakMemoryBytes"),
|
||||||
|
"cpu_time_ms": stats.get("cpuTimeMillis"),
|
||||||
|
"wall_time_ms": stats.get("wallTimeMillis"),
|
||||||
|
"elapsed_ms": stats.get("elapsedTimeMillis"),
|
||||||
|
"progress_pct": round(
|
||||||
|
(stats.get("completedSplits") or 0) / stats["totalSplits"] * 100, 1
|
||||||
|
) if stats.get("totalSplits") else (100.0 if stats.get("state") == "FINISHED" else 0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_run(run_id: str, sql: str, returns_rows: bool, pre_sql: str | None = None) -> None:
|
||||||
|
run = _runs[run_id]
|
||||||
|
run["state"] = "RUNNING"
|
||||||
|
columns: list[str] = []
|
||||||
|
rows: list[list[Any]] = []
|
||||||
|
try:
|
||||||
|
if pre_sql:
|
||||||
|
pre = await _trino_collect(pre_sql, 1)
|
||||||
|
if not pre.get("ok"):
|
||||||
|
run.update(state="FAILED", error=pre.get("error"), ended_at=time.time())
|
||||||
|
_feed("lakehouse-ops", f"[workbench] {run['label']}: pre-step failed", "err")
|
||||||
|
return
|
||||||
|
async with httpx.AsyncClient(timeout=None) as client:
|
||||||
|
r = await client.post(f"{TRINO_URL}/v1/statement", content=sql.encode(), headers=_trino_headers())
|
||||||
|
if r.status_code >= 400:
|
||||||
|
run.update(state="FAILED", error=r.text[:500], ended_at=time.time())
|
||||||
|
_feed("lakehouse-ops", f"[workbench] {run['label']}: failed ({r.status_code})", "err")
|
||||||
|
return
|
||||||
|
data = r.json()
|
||||||
|
run["query_id"] = data.get("id")
|
||||||
|
while True:
|
||||||
|
if run.get("cancel_requested"):
|
||||||
|
nxt = data.get("nextUri")
|
||||||
|
if nxt:
|
||||||
|
try:
|
||||||
|
await client.delete(nxt, headers=_trino_headers())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
run.update(state="CANCELED", ended_at=time.time())
|
||||||
|
_feed("lakehouse-ops", f"[workbench] {run['label']}: canceled", "warn")
|
||||||
|
return
|
||||||
|
if data.get("stats"):
|
||||||
|
run["stats"] = _norm_stats(data["stats"])
|
||||||
|
st = data["stats"].get("state")
|
||||||
|
if st:
|
||||||
|
run["engine_state"] = st
|
||||||
|
if data.get("error"):
|
||||||
|
run.update(state="FAILED", error=str(data["error"])[:500], ended_at=time.time())
|
||||||
|
_feed("lakehouse-ops", f"[workbench] {run['label']}: {str(data['error'])[:120]}", "err")
|
||||||
|
return
|
||||||
|
if data.get("columns") and not columns:
|
||||||
|
columns = [c["name"] for c in data["columns"]]
|
||||||
|
run["columns"] = columns
|
||||||
|
for row in data.get("data") or []:
|
||||||
|
if returns_rows and len(rows) < _MAX_RESULT_ROWS:
|
||||||
|
rows.append(row)
|
||||||
|
if data.get("updateType"):
|
||||||
|
run["update_type"] = data.get("updateType")
|
||||||
|
nxt = data.get("nextUri")
|
||||||
|
if not nxt:
|
||||||
|
break
|
||||||
|
data = (await client.get(nxt, headers=_trino_headers())).json()
|
||||||
|
run["next_uri"] = nxt
|
||||||
|
run["rows"] = rows
|
||||||
|
run["row_count"] = len(rows)
|
||||||
|
run.update(state="FINISHED", ended_at=time.time())
|
||||||
|
if run.get("stats"):
|
||||||
|
run["stats"]["state"] = "FINISHED"
|
||||||
|
run["stats"]["progress_pct"] = 100.0
|
||||||
|
msg = f"[workbench] {run['label']}: finished"
|
||||||
|
if run.get("target"):
|
||||||
|
msg = f"[workbench] {run['label']}: materialized → {run['target']}"
|
||||||
|
_feed("lakehouse-ops", msg, "info")
|
||||||
|
except Exception as exc:
|
||||||
|
run.update(state="FAILED", error=str(exc)[:500], ended_at=time.time())
|
||||||
|
_feed("lakehouse-ops", f"[workbench] {run['label']}: error {str(exc)[:120]}", "err")
|
||||||
|
|
||||||
|
|
||||||
|
# ── SQL builders ─────────────────────────────────────────────────────────────
|
||||||
|
def _build_sql(body: dict[str, Any]) -> tuple[str, bool, str, str | None, str | None]:
|
||||||
|
"""Return (sql, returns_rows, label, materialize_target, pre_sql)."""
|
||||||
|
op = body.get("operation", "preview")
|
||||||
|
limit = max(1, min(int(body.get("limit", 200)), _MAX_RESULT_ROWS))
|
||||||
|
materialize = body.get("materialize") or {}
|
||||||
|
target = None
|
||||||
|
|
||||||
|
if op == "sql":
|
||||||
|
sql = (body.get("sql") or "").strip().rstrip(";")
|
||||||
|
if not sql:
|
||||||
|
raise ValueError("empty SQL")
|
||||||
|
select_sql = sql
|
||||||
|
label = "Custom SQL"
|
||||||
|
returns_rows = sql.lower().lstrip().startswith(("select", "show", "describe", "with", "explain"))
|
||||||
|
|
||||||
|
elif op == "preview":
|
||||||
|
table = _safe_table(body.get("table"))
|
||||||
|
select_sql = f"SELECT * FROM {table} LIMIT {limit}"
|
||||||
|
label = f"Preview {table}"
|
||||||
|
returns_rows = True
|
||||||
|
|
||||||
|
elif op == "filter":
|
||||||
|
table = _safe_table(body.get("table"))
|
||||||
|
where = (body.get("where") or "").strip()
|
||||||
|
clause = f" WHERE {where}" if where else ""
|
||||||
|
select_sql = f"SELECT * FROM {table}{clause} LIMIT {limit}"
|
||||||
|
label = f"Filter {table}"
|
||||||
|
returns_rows = True
|
||||||
|
|
||||||
|
elif op == "aggregate":
|
||||||
|
table = _safe_table(body.get("table"))
|
||||||
|
group_by = [c for c in (body.get("group_by") or []) if c]
|
||||||
|
metrics = body.get("metrics") or []
|
||||||
|
select_parts: list[str] = [_qident(c) for c in group_by]
|
||||||
|
for m in metrics:
|
||||||
|
fn = (m.get("fn") or "count").lower()
|
||||||
|
if fn not in AGG_FUNCS:
|
||||||
|
raise ValueError(f"unsupported function {fn}")
|
||||||
|
col = m.get("col")
|
||||||
|
alias = m.get("alias") or (f"{fn}_{col}" if col else fn)
|
||||||
|
if fn == "count" and (not col or col == "*"):
|
||||||
|
expr = "count(*)"
|
||||||
|
elif fn == "count_distinct":
|
||||||
|
expr = f"count(DISTINCT {_qident(col)})"
|
||||||
|
else:
|
||||||
|
expr = f"{fn}({_qident(col)})"
|
||||||
|
select_parts.append(f"{expr} AS {_qident(alias)}")
|
||||||
|
if not select_parts:
|
||||||
|
select_parts = ["count(*) AS cnt"]
|
||||||
|
gb = f" GROUP BY {', '.join(_qident(c) for c in group_by)}" if group_by else ""
|
||||||
|
order = ""
|
||||||
|
if group_by:
|
||||||
|
order = f" ORDER BY {', '.join(_qident(c) for c in group_by)}"
|
||||||
|
select_sql = f"SELECT {', '.join(select_parts)} FROM {table}{gb}{order} LIMIT {limit}"
|
||||||
|
label = f"Aggregate {table}"
|
||||||
|
returns_rows = True
|
||||||
|
|
||||||
|
elif op == "join":
|
||||||
|
left = _safe_table(body.get("left"))
|
||||||
|
right = _safe_table(body.get("right"))
|
||||||
|
jt = (body.get("join_type") or "INNER").upper()
|
||||||
|
if jt not in JOIN_TYPES:
|
||||||
|
raise ValueError(f"invalid join type {jt}")
|
||||||
|
lk = _qident(body.get("left_key"))
|
||||||
|
rk = _qident(body.get("right_key"))
|
||||||
|
select_sql = (
|
||||||
|
f"SELECT l.*, r.* FROM {left} l {jt} JOIN {right} r "
|
||||||
|
f"ON l.{lk} = r.{rk} LIMIT {limit}"
|
||||||
|
)
|
||||||
|
label = f"Join {left} ⋈ {right}"
|
||||||
|
returns_rows = True
|
||||||
|
|
||||||
|
elif op == "profile":
|
||||||
|
table = _safe_table(body.get("table"))
|
||||||
|
cols = [c for c in (body.get("columns") or []) if c][:12]
|
||||||
|
parts = ["count(*) AS row_count"]
|
||||||
|
for c in cols:
|
||||||
|
qc = _qident(c)
|
||||||
|
parts.append(f"approx_distinct({qc}) AS {_qident(c + '_distinct')}")
|
||||||
|
parts.append(f"count({qc}) AS {_qident(c + '_nonnull')}")
|
||||||
|
select_sql = f"SELECT {', '.join(parts)} FROM {table}"
|
||||||
|
label = f"Profile {table}"
|
||||||
|
returns_rows = True
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unknown operation {op}")
|
||||||
|
|
||||||
|
if materialize.get("enabled"):
|
||||||
|
schema = materialize.get("schema", "hadoop")
|
||||||
|
name = materialize.get("table")
|
||||||
|
if not name:
|
||||||
|
raise ValueError("materialize target table required")
|
||||||
|
target = f"iceberg.{_qident(schema).strip(chr(34))}.{_qident(name).strip(chr(34))}"
|
||||||
|
mode = (materialize.get("mode") or "create").lower()
|
||||||
|
pre = None
|
||||||
|
if mode == "replace":
|
||||||
|
pre = f"DROP TABLE IF EXISTS {target}"
|
||||||
|
ddl = f"CREATE TABLE {target} AS {select_sql}"
|
||||||
|
elif mode == "insert":
|
||||||
|
ddl = f"INSERT INTO {target} {select_sql}"
|
||||||
|
else:
|
||||||
|
ddl = f"CREATE TABLE {target} AS {select_sql}"
|
||||||
|
return ddl, False, f"Materialize → {target}", target, pre
|
||||||
|
|
||||||
|
return select_sql, returns_rows, label, None, None
|
||||||
|
|
||||||
|
|
||||||
|
# ── catalog endpoints ────────────────────────────────────────────────────────
|
||||||
|
@router.get("/catalogs")
|
||||||
|
async def list_catalogs() -> JSONResponse:
|
||||||
|
res = await _trino_collect("SHOW CATALOGS", 100)
|
||||||
|
if not res.get("ok"):
|
||||||
|
return JSONResponse(res, status_code=502)
|
||||||
|
cats = [r[0] for r in res["rows"] if r[0] not in ("system",)]
|
||||||
|
return JSONResponse({"ok": True, "catalogs": cats})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/schemas")
|
||||||
|
async def list_schemas(catalog: str = Query(...)) -> JSONResponse:
|
||||||
|
cat = _safe_table(catalog)
|
||||||
|
res = await _trino_collect(f"SHOW SCHEMAS FROM {cat}", 200)
|
||||||
|
if not res.get("ok"):
|
||||||
|
return JSONResponse(res, status_code=502)
|
||||||
|
skip = {"information_schema"}
|
||||||
|
schemas = [r[0] for r in res["rows"] if r[0] not in skip]
|
||||||
|
return JSONResponse({"ok": True, "catalog": catalog, "schemas": schemas})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tables")
|
||||||
|
async def list_tables(catalog: str = Query(...), schema: str = Query(...)) -> JSONResponse:
|
||||||
|
cat = _safe_table(catalog)
|
||||||
|
sch = _safe_table(schema)
|
||||||
|
res = await _trino_collect(f"SHOW TABLES FROM {cat}.{sch}", 500)
|
||||||
|
if not res.get("ok"):
|
||||||
|
return JSONResponse(res, status_code=502)
|
||||||
|
tables = [{"name": r[0], "fqn": f"{catalog}.{schema}.{r[0]}"} for r in res["rows"]]
|
||||||
|
return JSONResponse({"ok": True, "catalog": catalog, "schema": schema, "tables": tables})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/columns")
|
||||||
|
async def list_columns(table: str = Query(...)) -> JSONResponse:
|
||||||
|
tbl = _safe_table(table)
|
||||||
|
res = await _trino_collect(f"DESCRIBE {tbl}", 500)
|
||||||
|
if not res.get("ok"):
|
||||||
|
return JSONResponse(res, status_code=502)
|
||||||
|
cols = [{"name": r[0], "type": r[1] if len(r) > 1 else ""} for r in res["rows"]]
|
||||||
|
return JSONResponse({"ok": True, "table": table, "columns": cols})
|
||||||
|
|
||||||
|
|
||||||
|
# ── run endpoints ────────────────────────────────────────────────────────────
|
||||||
|
@router.post("/run")
|
||||||
|
async def create_run(body: dict[str, Any] = Body(...)) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
sql, returns_rows, label, target, pre_sql = _build_sql(body)
|
||||||
|
except ValueError as exc:
|
||||||
|
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
||||||
|
|
||||||
|
run_id = uuid.uuid4().hex[:12]
|
||||||
|
_runs[run_id] = {
|
||||||
|
"id": run_id,
|
||||||
|
"operation": body.get("operation", "preview"),
|
||||||
|
"label": label,
|
||||||
|
"sql": sql,
|
||||||
|
"target": target,
|
||||||
|
"state": "QUEUED",
|
||||||
|
"engine_state": "QUEUED",
|
||||||
|
"stats": {},
|
||||||
|
"columns": [],
|
||||||
|
"rows": [],
|
||||||
|
"row_count": None,
|
||||||
|
"error": None,
|
||||||
|
"started_at": time.time(),
|
||||||
|
"ended_at": None,
|
||||||
|
"cancel_requested": False,
|
||||||
|
}
|
||||||
|
_run_order.append(run_id)
|
||||||
|
while len(_run_order) > _MAX_RUNS:
|
||||||
|
old = _run_order.pop(0)
|
||||||
|
_runs.pop(old, None)
|
||||||
|
|
||||||
|
_feed("lakehouse-ops", f"[workbench] {label}: submitted", "info")
|
||||||
|
asyncio.create_task(_execute_run(run_id, sql, returns_rows, pre_sql))
|
||||||
|
return JSONResponse({"ok": True, "run_id": run_id, "sql": sql, "label": label, "target": target})
|
||||||
|
|
||||||
|
|
||||||
|
def _run_public(run: dict[str, Any], include_rows: bool = True) -> dict[str, Any]:
|
||||||
|
out = {k: v for k, v in run.items() if k not in ("next_uri", "cancel_requested")}
|
||||||
|
if not include_rows:
|
||||||
|
out.pop("rows", None)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/run/{run_id}")
|
||||||
|
async def get_run(run_id: str) -> JSONResponse:
|
||||||
|
run = _runs.get(run_id)
|
||||||
|
if not run:
|
||||||
|
return JSONResponse({"ok": False, "error": "unknown run"}, status_code=404)
|
||||||
|
return JSONResponse({"ok": True, "run": _run_public(run)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run/{run_id}/cancel")
|
||||||
|
async def cancel_run(run_id: str) -> JSONResponse:
|
||||||
|
run = _runs.get(run_id)
|
||||||
|
if not run:
|
||||||
|
return JSONResponse({"ok": False, "error": "unknown run"}, status_code=404)
|
||||||
|
run["cancel_requested"] = True
|
||||||
|
return JSONResponse({"ok": True, "run_id": run_id, "state": "canceling"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs")
|
||||||
|
async def list_runs() -> JSONResponse:
|
||||||
|
out = [_run_public(_runs[r], include_rows=False) for r in reversed(_run_order) if r in _runs]
|
||||||
|
return JSONResponse({"ok": True, "runs": out})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/live")
|
||||||
|
async def spark_live() -> JSONResponse:
|
||||||
|
"""Live cluster + active-run matrix for the workbench dashboard."""
|
||||||
|
spark: dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
from streaming_ops import collect_spark
|
||||||
|
spark = await collect_spark()
|
||||||
|
except Exception as exc:
|
||||||
|
spark = {"error": str(exc)[:200]}
|
||||||
|
|
||||||
|
active = [
|
||||||
|
_run_public(_runs[r], include_rows=False)
|
||||||
|
for r in reversed(_run_order)
|
||||||
|
if r in _runs and _runs[r]["state"] in ("QUEUED", "RUNNING")
|
||||||
|
]
|
||||||
|
recent = [
|
||||||
|
_run_public(_runs[r], include_rows=False)
|
||||||
|
for r in reversed(_run_order[-8:])
|
||||||
|
if r in _runs
|
||||||
|
]
|
||||||
|
return JSONResponse({
|
||||||
|
"ok": True,
|
||||||
|
"spark": spark,
|
||||||
|
"active_runs": active,
|
||||||
|
"recent_runs": recent,
|
||||||
|
"ts": time.time(),
|
||||||
|
})
|
||||||
+430
-24
@@ -48,6 +48,17 @@ TRINO_USER = os.getenv("TRINO_USER", "atc")
|
|||||||
SOURCE_ENGINES = ("postgres", "mysql", "mongodb", "cassandra", "neo4j")
|
SOURCE_ENGINES = ("postgres", "mysql", "mongodb", "cassandra", "neo4j")
|
||||||
ENGINES = SOURCE_ENGINES + ("trino",)
|
ENGINES = SOURCE_ENGINES + ("trino",)
|
||||||
|
|
||||||
|
from hadoop_sql import (
|
||||||
|
HADOOP_SAMPLES,
|
||||||
|
catalog_hadoop,
|
||||||
|
connection_info as hadoop_connection_info,
|
||||||
|
health_hadoop,
|
||||||
|
sample_hadoop,
|
||||||
|
table_row_count_hadoop,
|
||||||
|
)
|
||||||
|
|
||||||
|
LAKE_ENGINES = ("hadoop",)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/sql", tags=["sql"])
|
router = APIRouter(prefix="/api/sql", tags=["sql"])
|
||||||
|
|
||||||
SAMPLES: dict[str, list[dict[str, str]]] = {
|
SAMPLES: dict[str, list[dict[str, str]]] = {
|
||||||
@@ -111,6 +122,7 @@ SAMPLES: dict[str, list[dict[str, str]]] = {
|
|||||||
{"id": "nj9", "label": "Schema visualization", "sql": "CALL db.schema.visualization()"},
|
{"id": "nj9", "label": "Schema visualization", "sql": "CALL db.schema.visualization()"},
|
||||||
{"id": "nj10", "label": "Constraint info", "sql": "SHOW CONSTRAINTS"},
|
{"id": "nj10", "label": "Constraint info", "sql": "SHOW CONSTRAINTS"},
|
||||||
],
|
],
|
||||||
|
"hadoop": HADOOP_SAMPLES,
|
||||||
"trino": [
|
"trino": [
|
||||||
{"id": "tq1", "label": "Trino version", "sql": "SELECT version()"},
|
{"id": "tq1", "label": "Trino version", "sql": "SELECT version()"},
|
||||||
{"id": "tq2", "label": "Catalogs", "sql": "SHOW CATALOGS"},
|
{"id": "tq2", "label": "Catalogs", "sql": "SHOW CATALOGS"},
|
||||||
@@ -332,10 +344,17 @@ def _run_trino(sql: str, limit: int = 200) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
class SqlRequest(BaseModel):
|
class SqlRequest(BaseModel):
|
||||||
engine: str = Field(..., pattern="^(postgres|mysql|mongodb|trino|cassandra|neo4j)$")
|
engine: str = Field(..., pattern="^(postgres|mysql|mongodb|trino|cassandra|neo4j|hadoop)$")
|
||||||
sql: str = Field(..., min_length=1, max_length=8000)
|
sql: str = Field(..., min_length=1, max_length=8000)
|
||||||
|
|
||||||
|
|
||||||
|
class RowUpdateRequest(BaseModel):
|
||||||
|
engine: str = Field(..., pattern="^(postgres|mysql|mongodb|cassandra|neo4j)$")
|
||||||
|
object: str = Field(..., min_length=1, max_length=256)
|
||||||
|
pk: dict[str, Any] = Field(..., min_length=1)
|
||||||
|
changes: dict[str, Any] = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
def _connection_info(engine: str) -> dict[str, str]:
|
def _connection_info(engine: str) -> dict[str, str]:
|
||||||
if engine == "postgres":
|
if engine == "postgres":
|
||||||
return {"host": DB_HOST, "port": str(PG_PORT), "database": PG_DB, "user": PG_USER}
|
return {"host": DB_HOST, "port": str(PG_PORT), "database": PG_DB, "user": PG_USER}
|
||||||
@@ -361,6 +380,8 @@ def _dispatch(engine: str, sql: str, limit: int = 200) -> dict[str, Any]:
|
|||||||
return _run_cassandra(sql, limit)
|
return _run_cassandra(sql, limit)
|
||||||
if engine == "neo4j":
|
if engine == "neo4j":
|
||||||
return _run_neo4j(sql, limit)
|
return _run_neo4j(sql, limit)
|
||||||
|
if engine == "hadoop":
|
||||||
|
return _run_trino(sql, limit)
|
||||||
return _run_trino(sql, limit)
|
return _run_trino(sql, limit)
|
||||||
|
|
||||||
|
|
||||||
@@ -458,6 +479,15 @@ def _catalog_neo4j() -> dict[str, Any]:
|
|||||||
driver.close()
|
driver.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_hadoop() -> dict[str, Any]:
|
||||||
|
return catalog_hadoop(_run_trino)
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_hadoop(object_name: str, limit: int, offset: int = 0) -> dict[str, Any]:
|
||||||
|
return sample_hadoop(object_name, limit, offset, _run_trino, _tabular)
|
||||||
|
|
||||||
|
|
||||||
def _catalog(engine: str) -> dict[str, Any]:
|
def _catalog(engine: str) -> dict[str, Any]:
|
||||||
if engine == "postgres":
|
if engine == "postgres":
|
||||||
return _catalog_postgres()
|
return _catalog_postgres()
|
||||||
@@ -469,37 +499,74 @@ def _catalog(engine: str) -> dict[str, Any]:
|
|||||||
return _catalog_cassandra()
|
return _catalog_cassandra()
|
||||||
if engine == "neo4j":
|
if engine == "neo4j":
|
||||||
return _catalog_neo4j()
|
return _catalog_neo4j()
|
||||||
|
if engine == "hadoop":
|
||||||
|
return _catalog_hadoop()
|
||||||
raise ValueError(f"Catalog not supported for {engine}")
|
raise ValueError(f"Catalog not supported for {engine}")
|
||||||
|
|
||||||
|
|
||||||
def _sample_postgres(object_name: str, limit: int) -> dict[str, Any]:
|
def _sample_postgres(object_name: str, limit: int, offset: int = 0) -> dict[str, Any]:
|
||||||
if "." in object_name:
|
if "." in object_name:
|
||||||
schema, table = object_name.split(".", 1)
|
schema, table = object_name.split(".", 1)
|
||||||
sql = f'SELECT * FROM "{schema}"."{table}" LIMIT {limit}'
|
sql = f'SELECT * FROM "{schema}"."{table}" OFFSET {int(offset)} LIMIT {int(limit)}'
|
||||||
else:
|
else:
|
||||||
sql = f'SELECT * FROM public."{object_name}" LIMIT {limit}'
|
sql = f'SELECT * FROM public."{object_name}" OFFSET {int(offset)} LIMIT {int(limit)}'
|
||||||
return _run_postgres(sql, limit)
|
return _run_postgres(sql, limit)
|
||||||
|
|
||||||
|
|
||||||
def _sample_mysql(object_name: str, limit: int) -> dict[str, Any]:
|
def _sample_mysql(object_name: str, limit: int, offset: int = 0) -> dict[str, Any]:
|
||||||
table = object_name.split(".")[-1]
|
table = object_name.split(".")[-1]
|
||||||
return _run_mysql(f"SELECT * FROM `{table}` LIMIT {limit}", limit)
|
return _run_mysql(f"SELECT * FROM `{table}` LIMIT {int(limit)} OFFSET {int(offset)}", limit)
|
||||||
|
|
||||||
|
|
||||||
def _sample_mongodb(object_name: str, limit: int) -> dict[str, Any]:
|
def _sample_mongodb(object_name: str, limit: int, offset: int = 0) -> dict[str, Any]:
|
||||||
if "." in object_name:
|
if "." in object_name:
|
||||||
db_name, coll = object_name.split(".", 1)
|
db_name, coll = object_name.split(".", 1)
|
||||||
else:
|
else:
|
||||||
db_name, coll = MONGO_DB, object_name
|
db_name, coll = MONGO_DB, object_name
|
||||||
return _run_mongo(f"FIND {db_name}.{coll} LIMIT {limit}", limit)
|
t0 = time.perf_counter()
|
||||||
|
client = _mongo_client()
|
||||||
|
try:
|
||||||
|
docs = list(client[db_name][coll].find({}).skip(int(offset)).limit(int(limit)))
|
||||||
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
|
if not docs:
|
||||||
|
return _tabular(["result"], [["(empty)"]], elapsed_ms)
|
||||||
|
columns = sorted({k for d in docs for k in d})
|
||||||
|
rows = [[_fmt(d.get(c)) for c in columns] for d in docs]
|
||||||
|
return _tabular(columns, rows, elapsed_ms, object=object_name)
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def _sample_cassandra(object_name: str, limit: int) -> dict[str, Any]:
|
def _sample_cassandra(object_name: str, limit: int, offset: int = 0) -> dict[str, Any]:
|
||||||
if "." in object_name:
|
if "." in object_name:
|
||||||
ks, table = object_name.split(".", 1)
|
ks, table = object_name.split(".", 1)
|
||||||
else:
|
else:
|
||||||
ks, table = CASS_KS, object_name
|
ks, table = CASS_KS, object_name
|
||||||
return _run_cassandra(f"SELECT * FROM {ks}.{table} LIMIT {limit}", limit)
|
t0 = time.perf_counter()
|
||||||
|
cluster = _cass_cluster()
|
||||||
|
session = cluster.connect()
|
||||||
|
try:
|
||||||
|
stmt = f"SELECT * FROM {ks}.{table}"
|
||||||
|
result = session.execute(stmt, timeout=60)
|
||||||
|
skipped = 0
|
||||||
|
picked: list[Any] = []
|
||||||
|
columns: list[str] = []
|
||||||
|
for row in result:
|
||||||
|
if skipped < offset:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if not columns:
|
||||||
|
columns = list(row._fields)
|
||||||
|
picked.append(row)
|
||||||
|
if len(picked) >= limit:
|
||||||
|
break
|
||||||
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
|
if not picked:
|
||||||
|
return _tabular(["result"], [["(empty)"]], elapsed_ms)
|
||||||
|
rows = [[_fmt(getattr(r, c)) for c in columns] for r in picked]
|
||||||
|
return _tabular(columns, rows, elapsed_ms, truncated=len(picked) >= limit)
|
||||||
|
finally:
|
||||||
|
cluster.shutdown()
|
||||||
|
|
||||||
|
|
||||||
def _graph_neo4j(edge_limit: int = 60, rel_type: str | None = None) -> dict[str, Any]:
|
def _graph_neo4j(edge_limit: int = 60, rel_type: str | None = None) -> dict[str, Any]:
|
||||||
@@ -582,9 +649,9 @@ def _graph_neo4j(edge_limit: int = 60, rel_type: str | None = None) -> dict[str,
|
|||||||
driver.close()
|
driver.close()
|
||||||
|
|
||||||
|
|
||||||
def _sample_neo4j(object_name: str, limit: int) -> dict[str, Any]:
|
def _sample_neo4j(object_name: str, limit: int, offset: int = 0) -> dict[str, Any]:
|
||||||
label = object_name.split(".")[-1]
|
label = object_name.split(".")[-1]
|
||||||
cypher = f"MATCH (n:`{label}`) RETURN n LIMIT {limit}"
|
cypher = f"MATCH (n:`{label}`) RETURN n SKIP {int(offset)} LIMIT {int(limit)}"
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
driver = _neo4j_driver()
|
driver = _neo4j_driver()
|
||||||
try:
|
try:
|
||||||
@@ -601,24 +668,319 @@ def _sample_neo4j(object_name: str, limit: int) -> dict[str, Any]:
|
|||||||
driver.close()
|
driver.close()
|
||||||
|
|
||||||
|
|
||||||
def _sample(engine: str, object_name: str, limit: int) -> dict[str, Any]:
|
def _sample(engine: str, object_name: str, limit: int, offset: int = 0) -> dict[str, Any]:
|
||||||
if engine == "postgres":
|
if engine == "postgres":
|
||||||
return _sample_postgres(object_name, limit)
|
return _sample_postgres(object_name, limit, offset)
|
||||||
if engine == "mysql":
|
if engine == "mysql":
|
||||||
return _sample_mysql(object_name, limit)
|
return _sample_mysql(object_name, limit, offset)
|
||||||
if engine == "mongodb":
|
if engine == "mongodb":
|
||||||
return _sample_mongodb(object_name, limit)
|
return _sample_mongodb(object_name, limit, offset)
|
||||||
if engine == "cassandra":
|
if engine == "cassandra":
|
||||||
return _sample_cassandra(object_name, limit)
|
return _sample_cassandra(object_name, limit, offset)
|
||||||
if engine == "neo4j":
|
if engine == "neo4j":
|
||||||
return _sample_neo4j(object_name, limit)
|
return _sample_neo4j(object_name, limit, offset)
|
||||||
|
if engine == "hadoop":
|
||||||
|
return _sample_hadoop(object_name, limit, offset)
|
||||||
raise ValueError(f"Sample not supported for {engine}")
|
raise ValueError(f"Sample not supported for {engine}")
|
||||||
|
|
||||||
|
|
||||||
|
def _table_row_count(engine: str, object_name: str) -> int | None:
|
||||||
|
try:
|
||||||
|
if engine == "postgres":
|
||||||
|
schema, table = _parse_fqn(engine, object_name)
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=DB_HOST, port=PG_PORT, user=PG_USER, password=PG_PASS, dbname=PG_DB, connect_timeout=8,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(f'SELECT count(*) FROM "{schema}"."{table}"')
|
||||||
|
return int(cur.fetchone()[0])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if engine == "mysql":
|
||||||
|
schema, table = _parse_fqn(engine, object_name)
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=DB_HOST, port=MYSQL_PORT, user=MYSQL_USER, password=MYSQL_PASS,
|
||||||
|
database=MYSQL_DB, connect_timeout=8,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(f"SELECT count(*) FROM `{table}`")
|
||||||
|
return int(cur.fetchone()[0])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if engine == "mongodb":
|
||||||
|
db_name, coll = _parse_fqn(engine, object_name)
|
||||||
|
client = _mongo_client()
|
||||||
|
try:
|
||||||
|
return int(client[db_name][coll].estimated_document_count())
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
if engine == "neo4j":
|
||||||
|
label = object_name.split(".")[-1]
|
||||||
|
driver = _neo4j_driver()
|
||||||
|
try:
|
||||||
|
with driver.session() as session:
|
||||||
|
return int(session.run(f"MATCH (n:`{label}`) RETURN count(n) AS c").single()["c"])
|
||||||
|
finally:
|
||||||
|
driver.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_fqn(engine: str, object_name: str) -> tuple[str, str]:
|
||||||
|
if engine == "postgres":
|
||||||
|
if "." in object_name:
|
||||||
|
return object_name.split(".", 1)
|
||||||
|
return "public", object_name
|
||||||
|
if engine == "mysql":
|
||||||
|
if "." in object_name:
|
||||||
|
return object_name.split(".", 1)
|
||||||
|
return MYSQL_DB, object_name
|
||||||
|
if engine == "mongodb":
|
||||||
|
if "." in object_name:
|
||||||
|
return object_name.split(".", 1)
|
||||||
|
return MONGO_DB, object_name
|
||||||
|
if engine == "cassandra":
|
||||||
|
if "." in object_name:
|
||||||
|
return object_name.split(".", 1)
|
||||||
|
return CASS_KS, object_name
|
||||||
|
return "graph", object_name.split(".")[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _object_primary_keys(engine: str, object_name: str) -> list[str]:
|
||||||
|
try:
|
||||||
|
if engine == "postgres":
|
||||||
|
schema, table = _parse_fqn(engine, object_name)
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=DB_HOST, port=PG_PORT, user=PG_USER, password=PG_PASS, dbname=PG_DB, connect_timeout=8,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT a.attname
|
||||||
|
FROM pg_constraint c
|
||||||
|
JOIN pg_class t ON t.oid = c.conrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
|
||||||
|
WHERE c.contype = 'p' AND n.nspname = %s AND t.relname = %s
|
||||||
|
ORDER BY array_position(c.conkey, a.attnum)
|
||||||
|
""",
|
||||||
|
(schema, table),
|
||||||
|
)
|
||||||
|
return [r[0] for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if engine == "mysql":
|
||||||
|
schema, table = _parse_fqn(engine, object_name)
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=DB_HOST, port=MYSQL_PORT, user=MYSQL_USER, password=MYSQL_PASS,
|
||||||
|
database=MYSQL_DB, connect_timeout=8,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE
|
||||||
|
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND CONSTRAINT_NAME = 'PRIMARY'
|
||||||
|
ORDER BY ORDINAL_POSITION
|
||||||
|
""",
|
||||||
|
(schema, table),
|
||||||
|
)
|
||||||
|
return [r[0] for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if engine == "mongodb":
|
||||||
|
return ["_id"]
|
||||||
|
if engine == "cassandra":
|
||||||
|
ks, table = _parse_fqn(engine, object_name)
|
||||||
|
cluster = _cass_cluster()
|
||||||
|
session = cluster.connect()
|
||||||
|
try:
|
||||||
|
rows = session.execute(
|
||||||
|
"SELECT column_name FROM system_schema.columns "
|
||||||
|
"WHERE keyspace_name=%s AND table_name=%s AND kind IN ('partition_key','clustering') "
|
||||||
|
"ORDER BY position",
|
||||||
|
(ks, table),
|
||||||
|
)
|
||||||
|
return [r.column_name for r in rows]
|
||||||
|
finally:
|
||||||
|
cluster.shutdown()
|
||||||
|
if engine == "neo4j":
|
||||||
|
label = object_name.split(".")[-1]
|
||||||
|
if label == "Product":
|
||||||
|
return ["product_id"]
|
||||||
|
if label == "Supplier":
|
||||||
|
return ["supplier_id"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_value(val: Any) -> Any:
|
||||||
|
if val is None or val == "":
|
||||||
|
return None
|
||||||
|
if isinstance(val, (int, float, bool)):
|
||||||
|
return val
|
||||||
|
s = str(val)
|
||||||
|
if s.lower() == "null":
|
||||||
|
return None
|
||||||
|
if re.fullmatch(r"-?\d+", s):
|
||||||
|
try:
|
||||||
|
return int(s)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if re.fullmatch(r"-?\d+\.\d+", s):
|
||||||
|
try:
|
||||||
|
return float(s)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_ident(name: str) -> bool:
|
||||||
|
return bool(re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*", name))
|
||||||
|
|
||||||
|
|
||||||
|
def _update_postgres(object_name: str, pk: dict[str, Any], changes: dict[str, Any]) -> str:
|
||||||
|
schema, table = _parse_fqn("postgres", object_name)
|
||||||
|
if not changes or not pk:
|
||||||
|
raise ValueError("Primary key and changes required")
|
||||||
|
for c in list(changes) + list(pk):
|
||||||
|
if not _safe_ident(c):
|
||||||
|
raise ValueError(f"Invalid column name: {c}")
|
||||||
|
set_sql = ", ".join(f'"{c}"=%s' for c in changes)
|
||||||
|
where_sql = " AND ".join(f'"{c}"=%s' for c in pk)
|
||||||
|
sql = f'UPDATE "{schema}"."{table}" SET {set_sql} WHERE {where_sql}'
|
||||||
|
params = list(changes.values()) + list(pk.values())
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=DB_HOST, port=PG_PORT, user=PG_USER, password=PG_PASS, dbname=PG_DB, connect_timeout=8,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
conn.autocommit = True
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(sql, params)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise ValueError("No row matched primary key")
|
||||||
|
return sql
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _update_mysql(object_name: str, pk: dict[str, Any], changes: dict[str, Any]) -> str:
|
||||||
|
_schema, table = _parse_fqn("mysql", object_name)
|
||||||
|
if not changes or not pk:
|
||||||
|
raise ValueError("Primary key and changes required")
|
||||||
|
for c in list(changes) + list(pk):
|
||||||
|
if not _safe_ident(c):
|
||||||
|
raise ValueError(f"Invalid column name: {c}")
|
||||||
|
set_sql = ", ".join(f"`{c}`=%s" for c in changes)
|
||||||
|
where_sql = " AND ".join(f"`{c}`=%s" for c in pk)
|
||||||
|
sql = f"UPDATE `{table}` SET {set_sql} WHERE {where_sql}"
|
||||||
|
params = list(changes.values()) + list(pk.values())
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=DB_HOST, port=MYSQL_PORT, user=MYSQL_USER, password=MYSQL_PASS,
|
||||||
|
database=MYSQL_DB, connect_timeout=8,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
conn.autocommit = True
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(sql, params)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise ValueError("No row matched primary key")
|
||||||
|
return sql
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _update_mongodb(object_name: str, pk: dict[str, Any], changes: dict[str, Any]) -> str:
|
||||||
|
db_name, coll = _parse_fqn("mongodb", object_name)
|
||||||
|
if not pk or not changes:
|
||||||
|
raise ValueError("Primary key and changes required")
|
||||||
|
client = _mongo_client()
|
||||||
|
try:
|
||||||
|
from bson import ObjectId
|
||||||
|
filt = dict(pk)
|
||||||
|
if "_id" in filt and isinstance(filt["_id"], str) and len(filt["_id"]) == 24:
|
||||||
|
try:
|
||||||
|
filt["_id"] = ObjectId(filt["_id"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
res = client[db_name][coll].update_one(filt, {"$set": changes})
|
||||||
|
if res.matched_count == 0:
|
||||||
|
raise ValueError("No document matched primary key")
|
||||||
|
return f"UPDATE {db_name}.{coll} {filt}"
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _update_cassandra(object_name: str, pk: dict[str, Any], changes: dict[str, Any]) -> str:
|
||||||
|
ks, table = _parse_fqn("cassandra", object_name)
|
||||||
|
if not pk or not changes:
|
||||||
|
raise ValueError("Primary key and changes required")
|
||||||
|
set_cql = ", ".join(f"{c}=%s" for c in changes)
|
||||||
|
where_cql = " AND ".join(f"{c}=%s" for c in pk)
|
||||||
|
cql = f"UPDATE {ks}.{table} SET {set_cql} WHERE {where_cql}"
|
||||||
|
cluster = _cass_cluster()
|
||||||
|
session = cluster.connect()
|
||||||
|
try:
|
||||||
|
session.execute(cql, list(changes.values()) + list(pk.values()))
|
||||||
|
return cql
|
||||||
|
finally:
|
||||||
|
cluster.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def _update_neo4j(object_name: str, pk: dict[str, Any], changes: dict[str, Any]) -> str:
|
||||||
|
label = object_name.split(".")[-1]
|
||||||
|
if not pk or not changes:
|
||||||
|
raise ValueError("Primary key and changes required")
|
||||||
|
pk_col, pk_val = next(iter(pk.items()))
|
||||||
|
if not _safe_ident(pk_col):
|
||||||
|
raise ValueError(f"Invalid property name: {pk_col}")
|
||||||
|
for c in changes:
|
||||||
|
if not _safe_ident(c):
|
||||||
|
raise ValueError(f"Invalid property name: {c}")
|
||||||
|
set_frag = ", ".join(f"n.{c} = ${c}" for c in changes)
|
||||||
|
cypher = f"MATCH (n:`{label}` {{{pk_col}: $pk}}) SET {set_frag} RETURN n"
|
||||||
|
params: dict[str, Any] = {"pk": _coerce_value(pk_val)}
|
||||||
|
params.update({c: _coerce_value(v) for c, v in changes.items()})
|
||||||
|
driver = _neo4j_driver()
|
||||||
|
try:
|
||||||
|
with driver.session() as session:
|
||||||
|
rec = session.run(cypher, **params).single()
|
||||||
|
if not rec:
|
||||||
|
raise ValueError("No node matched primary key")
|
||||||
|
return cypher
|
||||||
|
finally:
|
||||||
|
driver.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _update_row(engine: str, object_name: str, pk: dict[str, Any], changes: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
pk = {k: _coerce_value(v) for k, v in pk.items()}
|
||||||
|
changes = {k: _coerce_value(v) for k, v in changes.items()}
|
||||||
|
if engine == "postgres":
|
||||||
|
sql = _update_postgres(object_name, pk, changes)
|
||||||
|
elif engine == "mysql":
|
||||||
|
sql = _update_mysql(object_name, pk, changes)
|
||||||
|
elif engine == "mongodb":
|
||||||
|
sql = _update_mongodb(object_name, pk, changes)
|
||||||
|
elif engine == "cassandra":
|
||||||
|
sql = _update_cassandra(object_name, pk, changes)
|
||||||
|
elif engine == "neo4j":
|
||||||
|
sql = _update_neo4j(object_name, pk, changes)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Update not supported for {engine}")
|
||||||
|
return {"ok": True, "engine": engine, "object": object_name, "statement": sql, "cdc": engine in ("postgres", "mysql", "mongodb")}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/samples/{engine}")
|
@router.get("/samples/{engine}")
|
||||||
async def get_samples(engine: str):
|
async def get_samples(engine: str):
|
||||||
if engine not in SAMPLES:
|
if engine not in SAMPLES and engine not in LAKE_ENGINES:
|
||||||
return JSONResponse({"error": "unknown engine"}, status_code=404)
|
return JSONResponse({"error": "unknown engine"}, status_code=404)
|
||||||
|
if engine == "hadoop":
|
||||||
|
return {"engine": engine, "samples": SAMPLES[engine], "connection": hadoop_connection_info()}
|
||||||
return {"engine": engine, "samples": SAMPLES[engine], "connection": _connection_info(engine)}
|
return {"engine": engine, "samples": SAMPLES[engine], "connection": _connection_info(engine)}
|
||||||
|
|
||||||
|
|
||||||
@@ -647,9 +1009,20 @@ async def sql_health():
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/{engine}")
|
||||||
|
async def sql_health_engine(engine: str):
|
||||||
|
if engine == "hadoop":
|
||||||
|
return health_hadoop(_run_trino)
|
||||||
|
if engine not in SOURCE_ENGINES:
|
||||||
|
return JSONResponse({"error": "unknown engine"}, status_code=404)
|
||||||
|
all_h = await sql_health()
|
||||||
|
return all_h.get(engine) or {"ok": False, "error": "not found"}
|
||||||
|
|
||||||
@router.get("/catalog/{engine}")
|
@router.get("/catalog/{engine}")
|
||||||
async def get_catalog(engine: str):
|
async def get_catalog(engine: str):
|
||||||
if engine not in SOURCE_ENGINES:
|
if engine not in SOURCE_ENGINES and engine not in LAKE_ENGINES:
|
||||||
return JSONResponse({"error": "unknown engine"}, status_code=404)
|
return JSONResponse({"error": "unknown engine"}, status_code=404)
|
||||||
try:
|
try:
|
||||||
return _catalog(engine)
|
return _catalog(engine)
|
||||||
@@ -658,14 +1031,31 @@ async def get_catalog(engine: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/sample/{engine}")
|
@router.get("/sample/{engine}")
|
||||||
async def get_sample(engine: str, object: str = Query(..., min_length=1), limit: int = Query(50, ge=1, le=200)):
|
async def get_sample(
|
||||||
if engine not in SOURCE_ENGINES:
|
engine: str,
|
||||||
|
object: str = Query(..., min_length=1),
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
):
|
||||||
|
if engine not in SOURCE_ENGINES and engine not in LAKE_ENGINES:
|
||||||
return JSONResponse({"error": "unknown engine"}, status_code=404)
|
return JSONResponse({"error": "unknown engine"}, status_code=404)
|
||||||
try:
|
try:
|
||||||
result = _sample(engine, object, limit)
|
result = _sample(engine, object, limit, offset)
|
||||||
if not result.get("ok"):
|
if not result.get("ok"):
|
||||||
return JSONResponse(result, status_code=422)
|
return JSONResponse(result, status_code=422)
|
||||||
return {**result, "engine": engine, "object": object}
|
pks = _object_primary_keys(engine, object)
|
||||||
|
total = _table_row_count(engine, object)
|
||||||
|
return {
|
||||||
|
**result,
|
||||||
|
"engine": engine,
|
||||||
|
"object": object,
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
"total_count": total,
|
||||||
|
"primary_keys": pks,
|
||||||
|
"cdc": engine in ("postgres", "mysql", "mongodb"),
|
||||||
|
"editable": bool(pks),
|
||||||
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return JSONResponse({"ok": False, "error": str(exc)[:500]}, status_code=502)
|
return JSONResponse({"ok": False, "error": str(exc)[:500]}, status_code=502)
|
||||||
|
|
||||||
@@ -698,6 +1088,22 @@ async def execute_sql(body: SqlRequest):
|
|||||||
return JSONResponse({"ok": False, "error": str(exc)[:500]}, status_code=502)
|
return JSONResponse({"ok": False, "error": str(exc)[:500]}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/row/update")
|
||||||
|
async def update_row(body: RowUpdateRequest):
|
||||||
|
allowed_pks = _object_primary_keys(body.engine, body.object)
|
||||||
|
if allowed_pks:
|
||||||
|
for k in body.pk:
|
||||||
|
if k not in allowed_pks:
|
||||||
|
return JSONResponse({"ok": False, "error": f"Invalid primary key column '{k}'"}, status_code=400)
|
||||||
|
for k in body.changes:
|
||||||
|
if k in body.pk:
|
||||||
|
return JSONResponse({"ok": False, "error": "Cannot change primary key columns"}, status_code=400)
|
||||||
|
try:
|
||||||
|
return _update_row(body.engine, body.object, body.pk, body.changes)
|
||||||
|
except Exception as exc:
|
||||||
|
return JSONResponse({"ok": False, "error": str(exc)[:500]}, status_code=422)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/benchmark")
|
@router.post("/benchmark")
|
||||||
async def benchmark():
|
async def benchmark():
|
||||||
"""Parallel analytics: PostgreSQL OLTP vs Trino distributed engine (5M sin() rows)."""
|
"""Parallel analytics: PostgreSQL OLTP vs Trino distributed engine (5M sin() rows)."""
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
"""Live Spark + Kafka visibility and manual job control for the Command Center."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Body
|
||||||
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
|
||||||
|
SPARK_UI_URL = os.getenv("SPARK_UI_URL", "http://10.0.21.50:8080").rstrip("/")
|
||||||
|
KAFKA_UI_URL = os.getenv("KAFKA_UI_URL", "http://10.0.21.36:9000").rstrip("/")
|
||||||
|
KAFKA_CONNECT_URL = os.getenv("KAFKA_CONNECT_URL", "http://10.0.21.50:8083").rstrip("/")
|
||||||
|
AIRFLOW_URL = os.getenv("AIRFLOW_URL", "http://10.0.21.55:8080").rstrip("/")
|
||||||
|
AIRFLOW_USER = os.getenv("AIRFLOW_USER", "admin")
|
||||||
|
AIRFLOW_PASSWORD = os.getenv("AIRFLOW_PASSWORD", "")
|
||||||
|
|
||||||
|
from hdfs_kafka import export_hdfs_to_kafka, hdfs_export_snapshot
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/pipeline/streaming", tags=["streaming"])
|
||||||
|
|
||||||
|
# Manual Spark / lakehouse jobs (Airflow DAGs on the lab)
|
||||||
|
SPARK_JOBS: dict[str, dict[str, Any]] = {
|
||||||
|
"spark_to_curated": {
|
||||||
|
"label": "Spark → Iceberg curated (mask PII)",
|
||||||
|
"dag_id": "mask_to_curated",
|
||||||
|
"agent": "lakehouse-ops",
|
||||||
|
"description": "Runs the mask_to_curated DAG — Spark/SQL transform into iceberg.curated_masked",
|
||||||
|
"default_conf": {},
|
||||||
|
"editable_fields": ["batch_size", "sources"],
|
||||||
|
},
|
||||||
|
"hadoop_to_trino": {
|
||||||
|
"label": "HDFS → Iceberg (historical)",
|
||||||
|
"dag_id": "hadoop_to_trino",
|
||||||
|
"agent": "hadoop-ranger",
|
||||||
|
"description": "Load historical_sales from HDFS into Iceberg via Trino/Spark pipeline",
|
||||||
|
"default_conf": {"mode": "refresh"},
|
||||||
|
"editable_fields": ["mode"],
|
||||||
|
},
|
||||||
|
"spark_to_s3": {
|
||||||
|
"label": "Spark → S3 curated layer",
|
||||||
|
"dag_id": "mask_to_curated",
|
||||||
|
"agent": "lakehouse-ops",
|
||||||
|
"description": "Spark transform into Iceberg curated tables mirrored to S3",
|
||||||
|
"default_conf": {"target": "s3"},
|
||||||
|
"editable_fields": ["target", "batch_size"],
|
||||||
|
},
|
||||||
|
"generate_all": {
|
||||||
|
"label": "Seed all source DBs",
|
||||||
|
"dag_id": "generate_data_all_databases",
|
||||||
|
"agent": "data-custodian",
|
||||||
|
"description": "Airflow DAG — generates rows into PostgreSQL, MySQL, MongoDB, Cassandra, Neo4j",
|
||||||
|
"default_conf": {"rows": 3000},
|
||||||
|
"editable_fields": ["rows"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Master pipeline pulse switch: running | paused | stopped
|
||||||
|
_flow_state: dict[str, Any] = {"mode": "running", "since": time.time()}
|
||||||
|
|
||||||
|
def flow_mode() -> str:
|
||||||
|
return _flow_state.get("mode", "running")
|
||||||
|
|
||||||
|
def flow_snapshot() -> dict[str, Any]:
|
||||||
|
return {"mode": _flow_state.get("mode", "running"), "since": _flow_state.get("since")}
|
||||||
|
|
||||||
|
_cache: dict[str, Any] = {"ts": 0.0, "data": None}
|
||||||
|
_TTL = 8.0
|
||||||
|
_token_cache: dict[str, Any] = {"token": None, "exp": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
def _feed(agent_id: str, message: str, level: str = "info") -> None:
|
||||||
|
try:
|
||||||
|
from main import add_feed
|
||||||
|
add_feed(agent_id, message, level)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _airflow_token(client: httpx.AsyncClient) -> str:
|
||||||
|
now = time.time()
|
||||||
|
if _token_cache["token"] and _token_cache["exp"] > now + 30:
|
||||||
|
return _token_cache["token"]
|
||||||
|
r = await client.post(
|
||||||
|
f"{AIRFLOW_URL}/auth/token",
|
||||||
|
json={"username": AIRFLOW_USER, "password": AIRFLOW_PASSWORD},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
tok = r.json()["access_token"]
|
||||||
|
_token_cache["token"] = tok
|
||||||
|
_token_cache["exp"] = now + 20 * 60
|
||||||
|
return tok
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_spark() -> dict[str, Any]:
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"ui_url": SPARK_UI_URL,
|
||||||
|
"ui_ok": False,
|
||||||
|
"status": "UNKNOWN",
|
||||||
|
"workers": [],
|
||||||
|
"alive_workers": 0,
|
||||||
|
"cores": 0,
|
||||||
|
"cores_used": 0,
|
||||||
|
"memory_mb": 0,
|
||||||
|
"memory_used_mb": 0,
|
||||||
|
"active_apps": [],
|
||||||
|
"completed_apps": [],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=6.0) as client:
|
||||||
|
r = await client.get(f"{SPARK_UI_URL}/json/")
|
||||||
|
if r.status_code >= 400:
|
||||||
|
return out
|
||||||
|
d = r.json()
|
||||||
|
out["ui_ok"] = True
|
||||||
|
out["status"] = d.get("status") or "ALIVE"
|
||||||
|
out["alive_workers"] = int(d.get("aliveworkers") or 0)
|
||||||
|
out["cores"] = int(d.get("cores") or 0)
|
||||||
|
out["cores_used"] = int(d.get("coresused") or 0)
|
||||||
|
out["memory_mb"] = int(d.get("memory") or 0)
|
||||||
|
out["memory_used_mb"] = int(d.get("memoryused") or 0)
|
||||||
|
workers = d.get("workers") or []
|
||||||
|
out["workers"] = [
|
||||||
|
{
|
||||||
|
"id": w.get("id"),
|
||||||
|
"host": w.get("host"),
|
||||||
|
"cores": w.get("cores"),
|
||||||
|
"cores_used": w.get("coresused"),
|
||||||
|
"memory_mb": w.get("memory"),
|
||||||
|
"state": w.get("state"),
|
||||||
|
"webui": w.get("webuiaddress"),
|
||||||
|
}
|
||||||
|
for w in workers
|
||||||
|
]
|
||||||
|
for app in (d.get("activeapps") or [])[:20]:
|
||||||
|
out["active_apps"].append({
|
||||||
|
"id": app.get("id"),
|
||||||
|
"name": app.get("name"),
|
||||||
|
"cores": app.get("cores"),
|
||||||
|
"memory_mb": app.get("memory"),
|
||||||
|
"submitdate": app.get("submitdate"),
|
||||||
|
"duration_ms": app.get("duration"),
|
||||||
|
"user": app.get("user"),
|
||||||
|
})
|
||||||
|
for app in (d.get("completedapps") or [])[:10]:
|
||||||
|
out["completed_apps"].append({
|
||||||
|
"id": app.get("id"),
|
||||||
|
"name": app.get("name"),
|
||||||
|
"duration_ms": app.get("duration"),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
out["error"] = str(exc)[:200]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_kafka() -> dict[str, Any]:
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"ui_url": KAFKA_UI_URL,
|
||||||
|
"ui_ok": False,
|
||||||
|
"connect_url": KAFKA_CONNECT_URL,
|
||||||
|
"connect_ok": False,
|
||||||
|
"cluster": {},
|
||||||
|
"topics": [],
|
||||||
|
"connectors": [],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
|
cr = await client.get(f"{KAFKA_UI_URL}/api/clusters")
|
||||||
|
if cr.status_code < 400:
|
||||||
|
clusters = cr.json()
|
||||||
|
out["ui_ok"] = True
|
||||||
|
if clusters:
|
||||||
|
name = clusters[0].get("name", "local")
|
||||||
|
out["cluster"] = {
|
||||||
|
"name": name,
|
||||||
|
"status": clusters[0].get("status"),
|
||||||
|
"broker_count": clusters[0].get("brokerCount"),
|
||||||
|
"topic_count": clusters[0].get("topicCount"),
|
||||||
|
"online_partitions": clusters[0].get("onlinePartitionCount"),
|
||||||
|
}
|
||||||
|
tr = await client.get(
|
||||||
|
f"{KAFKA_UI_URL}/api/clusters/{name}/topics",
|
||||||
|
params={"page": 1, "perPage": 50, "showInternal": False},
|
||||||
|
)
|
||||||
|
if tr.status_code < 400:
|
||||||
|
topics = tr.json().get("topics") or []
|
||||||
|
out["topics"] = [
|
||||||
|
{
|
||||||
|
"name": t.get("name"),
|
||||||
|
"partitions": t.get("partitionCount"),
|
||||||
|
"replicas": t.get("replicationFactor"),
|
||||||
|
"messages": t.get("messagesCount"),
|
||||||
|
}
|
||||||
|
for t in topics[:50]
|
||||||
|
]
|
||||||
|
lr = await client.get(f"{KAFKA_CONNECT_URL}/connectors")
|
||||||
|
if lr.status_code < 400:
|
||||||
|
out["connect_ok"] = True
|
||||||
|
names_raw = lr.json()
|
||||||
|
names = names_raw if isinstance(names_raw, list) else []
|
||||||
|
for cn in names[:20]:
|
||||||
|
try:
|
||||||
|
sr = await client.get(f"{KAFKA_CONNECT_URL}/connectors/{cn}/status")
|
||||||
|
st = sr.json() if sr.status_code < 400 else {}
|
||||||
|
conn = st.get("connector") or {}
|
||||||
|
tasks = st.get("tasks") or []
|
||||||
|
out["connectors"].append({
|
||||||
|
"name": cn,
|
||||||
|
"state": conn.get("state"),
|
||||||
|
"worker": conn.get("worker_id"),
|
||||||
|
"tasks": [{"id": t.get("id"), "state": t.get("state")} for t in tasks],
|
||||||
|
"type": st.get("type"),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
out["connectors"].append({"name": cn, "state": "UNKNOWN"})
|
||||||
|
except Exception as exc:
|
||||||
|
out["error"] = str(exc)[:200]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def build_streaming_status() -> dict[str, Any]:
|
||||||
|
spark, kafka = await asyncio.gather(collect_spark(), collect_kafka())
|
||||||
|
cdc_active = False
|
||||||
|
try:
|
||||||
|
from cdc_consumer import snapshot as cdc_snapshot
|
||||||
|
cdc = cdc_snapshot(15)
|
||||||
|
cdc_active = bool(cdc.get("connected")) and int(cdc.get("window_total") or 0) > 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
connectors_running = sum(
|
||||||
|
1 for c in kafka.get("connectors", [])
|
||||||
|
if (c.get("state") or "").upper() == "RUNNING"
|
||||||
|
)
|
||||||
|
spark_alive = spark.get("ui_ok") and (spark.get("status") or "").upper() == "ALIVE"
|
||||||
|
apps_running = len(spark.get("active_apps") or [])
|
||||||
|
|
||||||
|
hdfs_recent = hdfs_export_snapshot().get("recent")
|
||||||
|
|
||||||
|
edges = {
|
||||||
|
"hdfs→kafka": bool(hdfs_recent),
|
||||||
|
"kafka→spark": cdc_active and spark_alive or hdfs_recent,
|
||||||
|
"spark→iceberg": apps_running > 0 or spark_alive,
|
||||||
|
"spark→s3": apps_running > 0 or spark_alive,
|
||||||
|
"sources→kafka": cdc_active,
|
||||||
|
"connectors": connectors_running > 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
mode = flow_mode()
|
||||||
|
if mode != "running":
|
||||||
|
edges = {k: False for k in edges}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"flow": mode,
|
||||||
|
"spark": spark,
|
||||||
|
"kafka": kafka,
|
||||||
|
"edges": edges,
|
||||||
|
"jobs": [
|
||||||
|
{**{"id": k}, **{kk: vv for kk, vv in v.items() if kk != "editable_fields"}}
|
||||||
|
for k, v in SPARK_JOBS.items()
|
||||||
|
],
|
||||||
|
"ts": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def streaming_status(refresh: bool = False) -> JSONResponse:
|
||||||
|
now = time.time()
|
||||||
|
if not refresh and _cache["data"] and now - _cache["ts"] < _TTL:
|
||||||
|
return JSONResponse(_cache["data"])
|
||||||
|
data = await build_streaming_status()
|
||||||
|
_cache["data"] = data
|
||||||
|
_cache["ts"] = now
|
||||||
|
return JSONResponse(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs")
|
||||||
|
async def list_spark_jobs() -> JSONResponse:
|
||||||
|
return JSONResponse({"ok": True, "jobs": SPARK_JOBS})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/flow/{action}")
|
||||||
|
async def set_flow(action: str) -> JSONResponse:
|
||||||
|
mapping = {
|
||||||
|
"pause": "paused",
|
||||||
|
"stop": "stopped",
|
||||||
|
"resume": "running",
|
||||||
|
"start": "running",
|
||||||
|
"run": "running",
|
||||||
|
}
|
||||||
|
if action not in mapping:
|
||||||
|
return JSONResponse({"ok": False, "error": f"unknown action {action}"}, status_code=400)
|
||||||
|
_flow_state["mode"] = mapping[action]
|
||||||
|
_flow_state["since"] = time.time()
|
||||||
|
_cache["ts"] = 0
|
||||||
|
label = {"running": "resumed", "paused": "paused", "stopped": "stopped"}[mapping[action]]
|
||||||
|
_feed("lakehouse-ops", f"[pipeline] data flow {label}", "info" if mapping[action] == "running" else "warn")
|
||||||
|
return JSONResponse({"ok": True, "flow": flow_snapshot()})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/flow")
|
||||||
|
async def get_flow() -> JSONResponse:
|
||||||
|
return JSONResponse({"ok": True, "flow": flow_snapshot()})
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/trigger")
|
||||||
|
async def trigger_spark_job(job_id: str, body: dict[str, Any] = Body(default={})) -> JSONResponse:
|
||||||
|
job = SPARK_JOBS.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return JSONResponse({"ok": False, "error": f"unknown job {job_id}"}, status_code=400)
|
||||||
|
conf = {**(job.get("default_conf") or {}), **(body.get("conf") or {})}
|
||||||
|
agent = job.get("agent", "lakehouse-ops")
|
||||||
|
dag_id = job["dag_id"]
|
||||||
|
_feed(agent, f"[spark] Triggering {job['label']} conf={conf}", "info")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
tok = await _airflow_token(client)
|
||||||
|
h = {"Authorization": f"Bearer {tok}"}
|
||||||
|
r = await client.post(
|
||||||
|
f"{AIRFLOW_URL}/api/v2/dags/{dag_id}/dagRuns",
|
||||||
|
headers=h,
|
||||||
|
json={"logical_date": None, "conf": conf},
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
if r.status_code >= 400:
|
||||||
|
_feed(agent, f"[spark] {job['label']}: Airflow {r.status_code}", "err")
|
||||||
|
return JSONResponse({"ok": False, "error": f"airflow {r.status_code}: {r.text[:300]}"}, status_code=502)
|
||||||
|
run = r.json()
|
||||||
|
run_id = run.get("dag_run_id")
|
||||||
|
_feed(agent, f"[spark] {job['label']}: started run {run_id}", "info")
|
||||||
|
return JSONResponse({"ok": True, "job_id": job_id, "dag_id": dag_id, "run_id": run_id, "conf": conf})
|
||||||
|
except Exception as exc:
|
||||||
|
_feed(agent, f"[spark] {job['label']}: {str(exc)[:120]}", "err")
|
||||||
|
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/hdfs/to-kafka")
|
||||||
|
async def hdfs_to_kafka(body: dict[str, Any] = Body(default={})) -> JSONResponse:
|
||||||
|
path = body.get("path", "/data/historical/sales_orders")
|
||||||
|
topic = body.get("topic", "hdfs.historical.sales")
|
||||||
|
limit = int(body.get("limit", 2000))
|
||||||
|
try:
|
||||||
|
result = await export_hdfs_to_kafka(path=path, topic=topic, limit=limit, feed=_feed)
|
||||||
|
if not result.get("ok"):
|
||||||
|
return JSONResponse(result, status_code=502)
|
||||||
|
_cache["ts"] = 0
|
||||||
|
return JSONResponse(result)
|
||||||
|
except Exception as exc:
|
||||||
|
_feed("hadoop-ranger", f"[hdfs→kafka] failed: {str(exc)[:120]}", "err")
|
||||||
|
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/{pipeline_id}")
|
||||||
|
async def run_streaming_pipeline(pipeline_id: str) -> JSONResponse:
|
||||||
|
if pipeline_id != "hadoop-lake":
|
||||||
|
return JSONResponse({"ok": False, "error": f"unknown pipeline {pipeline_id}"}, status_code=400)
|
||||||
|
steps: list[str] = []
|
||||||
|
try:
|
||||||
|
exp = await export_hdfs_to_kafka(feed=_feed)
|
||||||
|
if not exp.get("ok"):
|
||||||
|
return JSONResponse({"ok": False, "error": exp.get("error"), "steps": steps}, status_code=502)
|
||||||
|
steps.append(f"hdfs→kafka ({exp.get('rows_sent')} rows)")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
tok = await _airflow_token(client)
|
||||||
|
h = {"Authorization": f"Bearer {tok}"}
|
||||||
|
for job_id, dag_id, agent in [
|
||||||
|
("hadoop_to_trino", "hadoop_to_trino", "hadoop-ranger"),
|
||||||
|
("spark_to_curated", "mask_to_curated", "lakehouse-ops"),
|
||||||
|
]:
|
||||||
|
r = await client.post(
|
||||||
|
f"{AIRFLOW_URL}/api/v2/dags/{dag_id}/dagRuns",
|
||||||
|
headers=h,
|
||||||
|
json={"logical_date": None, "conf": {}},
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
if r.status_code >= 400:
|
||||||
|
_feed(agent, f"[pipeline] {dag_id} failed {r.status_code}", "err")
|
||||||
|
return JSONResponse({"ok": False, "error": f"{dag_id}: {r.text[:200]}", "steps": steps}, status_code=502)
|
||||||
|
steps.append(dag_id)
|
||||||
|
_feed(agent, f"[pipeline] started {dag_id}", "info")
|
||||||
|
|
||||||
|
_cache["ts"] = 0
|
||||||
|
return JSONResponse({"ok": True, "pipeline": pipeline_id, "steps": steps, "export": exp})
|
||||||
|
except Exception as exc:
|
||||||
|
return JSONResponse({"ok": False, "error": str(exc), "steps": steps}, status_code=500)
|
||||||
|
|
||||||
|
@router.post("/kafka/connectors/{name}/restart")
|
||||||
|
async def restart_kafka_connector(name: str) -> JSONResponse:
|
||||||
|
_feed("etl-guardian", f"[kafka] Restarting connector {name}", "info")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
r = await client.post(f"{KAFKA_CONNECT_URL}/connectors/{name}/restart")
|
||||||
|
if r.status_code >= 400:
|
||||||
|
return JSONResponse({"ok": False, "error": r.text[:300]}, status_code=502)
|
||||||
|
_feed("etl-guardian", f"[kafka] Connector {name} restart requested", "info")
|
||||||
|
_cache["ts"] = 0
|
||||||
|
return JSONResponse({"ok": True, "connector": name, "action": "restart"})
|
||||||
|
except Exception as exc:
|
||||||
|
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/kafka/connectors/{name}/pause")
|
||||||
|
async def pause_kafka_connector(name: str) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
r = await client.put(f"{KAFKA_CONNECT_URL}/connectors/{name}/pause")
|
||||||
|
if r.status_code >= 400:
|
||||||
|
return JSONResponse({"ok": False, "error": r.text[:300]}, status_code=502)
|
||||||
|
_cache["ts"] = 0
|
||||||
|
return JSONResponse({"ok": True, "connector": name, "action": "pause"})
|
||||||
|
except Exception as exc:
|
||||||
|
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/kafka/connectors/{name}/resume")
|
||||||
|
async def resume_kafka_connector(name: str) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
r = await client.put(f"{KAFKA_CONNECT_URL}/connectors/{name}/resume")
|
||||||
|
if r.status_code >= 400:
|
||||||
|
return JSONResponse({"ok": False, "error": r.text[:300]}, status_code=502)
|
||||||
|
_cache["ts"] = 0
|
||||||
|
return JSONResponse({"ok": True, "connector": name, "action": "resume"})
|
||||||
|
except Exception as exc:
|
||||||
|
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Shared WebHDFS helpers — resolve datanode redirects via NameNode JMX."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import quote, urlparse, urlunparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870").rstrip("/")
|
||||||
|
HDFS_USER = os.getenv("HDFS_USER", "hdfs")
|
||||||
|
WEBHDFS = f"{HDFS_NN_URL}/webhdfs/v1"
|
||||||
|
|
||||||
|
_dn_cache: dict[str, Any] = {"ts": 0.0, "map": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def encode_path(path: str) -> str:
|
||||||
|
p = path if path.startswith("/") else f"/{path}"
|
||||||
|
return "/".join(quote(seg, safe="") for seg in p.split("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def datanode_host_map() -> dict[str, str]:
|
||||||
|
now = time.time()
|
||||||
|
if _dn_cache["map"] and now - _dn_cache["ts"] < 120:
|
||||||
|
return _dn_cache["map"]
|
||||||
|
mapping: dict[str, str] = {}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=8.0) as client:
|
||||||
|
r = client.get(f"{HDFS_NN_URL}/jmx?qry=Hadoop:service=NameNode,name=NameNodeInfo")
|
||||||
|
raw = (r.json().get("beans") or [{}])[0].get("LiveNodes") or "{}"
|
||||||
|
if isinstance(raw, str):
|
||||||
|
import json
|
||||||
|
nodes = json.loads(raw)
|
||||||
|
else:
|
||||||
|
nodes = raw
|
||||||
|
for key, info in nodes.items():
|
||||||
|
host = key.split(":")[0]
|
||||||
|
info_addr = (info or {}).get("infoAddr") or ""
|
||||||
|
if info_addr:
|
||||||
|
mapping[host] = info_addr # ip:9864
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_dn_cache["map"] = mapping
|
||||||
|
_dn_cache["ts"] = now
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_redirect(url: str) -> str:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
if not host or host.replace(".", "").isdigit():
|
||||||
|
return url
|
||||||
|
dn = datanode_host_map().get(host)
|
||||||
|
if not dn:
|
||||||
|
return url
|
||||||
|
ip, _, port = dn.partition(":")
|
||||||
|
port = parsed.port or port or "9864"
|
||||||
|
return urlunparse(parsed._replace(netloc=f"{ip}:{port}"))
|
||||||
|
|
||||||
|
|
||||||
|
def open_bytes(path: str, max_bytes: int = 8_000_000) -> bytes:
|
||||||
|
url = f"{WEBHDFS}{encode_path(path)}"
|
||||||
|
with httpx.Client(timeout=90.0, follow_redirects=False) as client:
|
||||||
|
r = client.get(url, params={"op": "OPEN", "user.name": HDFS_USER})
|
||||||
|
if r.status_code in (301, 302, 307, 308):
|
||||||
|
loc = r.headers.get("location") or ""
|
||||||
|
if loc:
|
||||||
|
r = client.get(resolve_redirect(loc), follow_redirects=True)
|
||||||
|
elif r.status_code == 200 and r.headers.get("content-type", "").startswith("application/json"):
|
||||||
|
loc = r.json().get("Location") or r.json().get("location") or ""
|
||||||
|
if loc:
|
||||||
|
r = client.get(resolve_redirect(loc), follow_redirects=True)
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise RuntimeError(r.text[:300])
|
||||||
|
return r.content[:max_bytes]
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="light">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
@@ -14,6 +14,31 @@ server {
|
|||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /spark-ui/ {
|
||||||
|
proxy_pass http://10.0.21.50:8080/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host 10.0.21.50:8080;
|
||||||
|
proxy_hide_header X-Frame-Options;
|
||||||
|
proxy_redirect http://10.0.21.50:8080/ /spark-ui/;
|
||||||
|
sub_filter_once off;
|
||||||
|
sub_filter 'href="/' 'href="/spark-ui/';
|
||||||
|
sub_filter 'src="/' 'src="/spark-ui/';
|
||||||
|
sub_filter "href='/" "href='/spark-ui/";
|
||||||
|
sub_filter_types text/css application/javascript text/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /kafka-ui/ {
|
||||||
|
proxy_pass http://10.0.21.36:9000/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host 10.0.21.36:9000;
|
||||||
|
proxy_hide_header X-Frame-Options;
|
||||||
|
proxy_redirect http://10.0.21.36:9000/ /kafka-ui/;
|
||||||
|
sub_filter_once off;
|
||||||
|
sub_filter 'href="/' 'href="/kafka-ui/';
|
||||||
|
sub_filter 'src="/' 'src="/kafka-ui/';
|
||||||
|
sub_filter_types text/html application/javascript text/css;
|
||||||
|
}
|
||||||
|
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
|
|||||||
+6
-15
@@ -11,17 +11,14 @@ import { ChatDrawer } from './components/features/ChatDrawer'
|
|||||||
import { GpuMonitor } from './components/features/GpuMonitor'
|
import { GpuMonitor } from './components/features/GpuMonitor'
|
||||||
import { InfraQuickAccess } from './components/features/InfraQuickAccess'
|
import { InfraQuickAccess } from './components/features/InfraQuickAccess'
|
||||||
import { InspectorPanel } from './components/features/InspectorPanel'
|
import { InspectorPanel } from './components/features/InspectorPanel'
|
||||||
import { PlatformTopology } from './components/features/PlatformTopology'
|
import { PlatformView } from './components/features/PlatformView'
|
||||||
import { PresentationView } from './components/features/PresentationView'
|
|
||||||
import { DataQualityView } from './components/features/DataQualityView'
|
import { DataQualityView } from './components/features/DataQualityView'
|
||||||
import { KnowledgeChatView } from './components/features/KnowledgeChatView'
|
import { KnowledgeChatView } from './components/features/KnowledgeChatView'
|
||||||
import { StorageView } from './components/features/StorageView'
|
import { StorageView } from './components/features/StorageView'
|
||||||
import { HdfsView } from './components/features/HdfsView'
|
|
||||||
import { DataGenView } from './components/features/DataGenView'
|
|
||||||
import { ChangesView } from './components/features/ChangesView'
|
import { ChangesView } from './components/features/ChangesView'
|
||||||
import { DataFlowView } from './components/features/DataFlowView'
|
import { DataFlowView } from './components/features/DataFlowView'
|
||||||
import { SearchView } from './components/features/SearchView'
|
import { SearchView } from './components/features/SearchView'
|
||||||
import { DataSourcesView } from './components/features/DataSourcesView'
|
import { DataHubView } from './components/features/DataHubView'
|
||||||
import { SshTerminal } from './components/features/SshTerminal'
|
import { SshTerminal } from './components/features/SshTerminal'
|
||||||
import { TerminalDock } from './components/features/TerminalDock'
|
import { TerminalDock } from './components/features/TerminalDock'
|
||||||
import { WorkbenchPanel } from './components/features/WorkbenchPanel'
|
import { WorkbenchPanel } from './components/features/WorkbenchPanel'
|
||||||
@@ -39,7 +36,7 @@ export default function App() {
|
|||||||
const mainScrollRef = useRef<HTMLDivElement>(null)
|
const mainScrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const isPlatform = cc.mainView === 'platform'
|
const isPlatform = cc.mainView === 'platform'
|
||||||
const isDataSources = cc.mainView === 'datasources'
|
const isDataSources = cc.mainView === 'datasources' || cc.mainView === 'hdfs'
|
||||||
|
|
||||||
const openApprovals = () => {
|
const openApprovals = () => {
|
||||||
cc.setMainView('approvals')
|
cc.setMainView('approvals')
|
||||||
@@ -123,31 +120,25 @@ export default function App() {
|
|||||||
|
|
||||||
<div className={cn('flex min-h-0 flex-col', (isPlatform || isDataSources) ? 'min-h-0 flex-1 overflow-hidden' : 'min-h-0 flex-1')}>
|
<div className={cn('flex min-h-0 flex-col', (isPlatform || isDataSources) ? 'min-h-0 flex-1 overflow-hidden' : 'min-h-0 flex-1')}>
|
||||||
{cc.mainView === 'platform' ? (
|
{cc.mainView === 'platform' ? (
|
||||||
<PlatformTopology
|
<PlatformView
|
||||||
workload={cc.workload}
|
workload={cc.workload}
|
||||||
animations={cc.anims}
|
animations={cc.anims}
|
||||||
selectedNodeId={cc.selectedNodeId}
|
selectedNodeId={cc.selectedNodeId}
|
||||||
onNodeClick={cc.selectNode}
|
onNodeClick={cc.selectNode}
|
||||||
pulse={cc.genPulse}
|
pulse={cc.genPulse}
|
||||||
/>
|
/>
|
||||||
) : cc.mainView === 'datasources' ? (
|
) : cc.mainView === 'datasources' || cc.mainView === 'hdfs' ? (
|
||||||
<DataSourcesView focusEngine={cc.dataSourceFocus} />
|
<DataHubView focusEngine={cc.dataSourceFocus} initialTab={cc.mainView === 'hdfs' ? 'hadoop' : 'sources'} onPulse={cc.pulseFlow} />
|
||||||
) : cc.mainView === 'datagen' ? (
|
|
||||||
<DataGenView onPulse={cc.pulseFlow} onOpenPlatform={() => cc.setMainView('platform')} />
|
|
||||||
) : cc.mainView === 'changes' ? (
|
) : cc.mainView === 'changes' ? (
|
||||||
<ChangesView liveChanges={cc.changes} />
|
<ChangesView liveChanges={cc.changes} />
|
||||||
) : cc.mainView === 'dataflow' ? (
|
) : cc.mainView === 'dataflow' ? (
|
||||||
<DataFlowView />
|
<DataFlowView />
|
||||||
) : cc.mainView === 'presentation' ? (
|
|
||||||
<PresentationView />
|
|
||||||
) : cc.mainView === 'dataquality' ? (
|
) : cc.mainView === 'dataquality' ? (
|
||||||
<DataQualityView />
|
<DataQualityView />
|
||||||
) : cc.mainView === 'knowledge' ? (
|
) : cc.mainView === 'knowledge' ? (
|
||||||
<KnowledgeChatView onGpuActivity={setGpuChatActive} />
|
<KnowledgeChatView onGpuActivity={setGpuChatActive} />
|
||||||
) : cc.mainView === 'storage' ? (
|
) : cc.mainView === 'storage' ? (
|
||||||
<StorageView />
|
<StorageView />
|
||||||
) : cc.mainView === 'hdfs' ? (
|
|
||||||
<HdfsView />
|
|
||||||
) : cc.mainView === 'search' ? (
|
) : cc.mainView === 'search' ? (
|
||||||
<SearchView />
|
<SearchView />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -146,9 +146,56 @@ const POSITIONS: Record<string, Record<string, { x: number; y: number }>> = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArchitectureDiagram({ animation }: { animation: string }) {
|
const COMPACT_POSITIONS: Record<string, Record<string, { x: number; y: number }>> = {
|
||||||
|
'full-stack': {
|
||||||
|
user: { x: 14, y: 18 },
|
||||||
|
caddy: { x: 32, y: 18 },
|
||||||
|
ui: { x: 50, y: 18 },
|
||||||
|
api: { x: 12, y: 48 },
|
||||||
|
dq: { x: 34, y: 48 },
|
||||||
|
rag: { x: 56, y: 48 },
|
||||||
|
docling: { x: 34, y: 78 },
|
||||||
|
chroma: { x: 72, y: 48 },
|
||||||
|
llm: { x: 88, y: 78 },
|
||||||
|
lake: { x: 12, y: 78 },
|
||||||
|
},
|
||||||
|
'rag-flow': {
|
||||||
|
upload: { x: 10, y: 22 },
|
||||||
|
store: { x: 10, y: 72 },
|
||||||
|
docling: { x: 28, y: 22 },
|
||||||
|
chunk: { x: 46, y: 22 },
|
||||||
|
embed: { x: 46, y: 47 },
|
||||||
|
chroma: { x: 46, y: 72 },
|
||||||
|
query: { x: 72, y: 22 },
|
||||||
|
retrieve: { x: 72, y: 47 },
|
||||||
|
llm: { x: 90, y: 72 },
|
||||||
|
},
|
||||||
|
'dq-flow': {
|
||||||
|
data: { x: 8, y: 50 },
|
||||||
|
docling: { x: 24, y: 22 },
|
||||||
|
pandas: { x: 24, y: 78 },
|
||||||
|
ge: { x: 46, y: 32 },
|
||||||
|
soda: { x: 46, y: 68 },
|
||||||
|
maturity: { x: 68, y: 50 },
|
||||||
|
report: { x: 88, y: 50 },
|
||||||
|
},
|
||||||
|
'lakehouse': {
|
||||||
|
pg: { x: 6, y: 22 },
|
||||||
|
mysql: { x: 6, y: 42 },
|
||||||
|
mongo: { x: 6, y: 62 },
|
||||||
|
debezium: { x: 24, y: 42 },
|
||||||
|
kafka: { x: 38, y: 42 },
|
||||||
|
spark: { x: 52, y: 42 },
|
||||||
|
iceberg: { x: 66, y: 42 },
|
||||||
|
trino: { x: 80, y: 42 },
|
||||||
|
bi: { x: 92, y: 62 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArchitectureDiagram({ animation, compact = false, present = false }: { animation: string; compact?: boolean; present?: boolean }) {
|
||||||
const flow = FLOWS[animation] || FLOWS['full-stack']
|
const flow = FLOWS[animation] || FLOWS['full-stack']
|
||||||
const positions = POSITIONS[animation] || POSITIONS['full-stack']
|
const positions = (compact ? COMPACT_POSITIONS[animation] : POSITIONS[animation])
|
||||||
|
|| (compact ? COMPACT_POSITIONS['full-stack'] : POSITIONS['full-stack'])
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -159,7 +206,12 @@ export function ArchitectureDiagram({ animation }: { animation: string }) {
|
|||||||
const activeEdge = tick % flow.edges.length
|
const activeEdge = tick % flow.edges.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mx-auto mb-6 h-[280px] w-full max-w-4xl rounded-xl border border-docker/30 bg-surface-overlay/60 p-2 md:h-[320px]">
|
<div className={cn(
|
||||||
|
'relative mx-auto w-full rounded-xl border border-docker/30 bg-surface-overlay/60 p-2',
|
||||||
|
present ? 'mb-8 h-[340px] max-w-5xl md:h-[400px]'
|
||||||
|
: compact ? 'mb-3 h-[200px] max-w-none'
|
||||||
|
: 'mb-6 h-[280px] max-w-4xl md:h-[320px]',
|
||||||
|
)}>
|
||||||
<svg className="absolute inset-0 h-full w-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
<svg className="absolute inset-0 h-full w-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
{flow.edges.map((edge, i) => {
|
{flow.edges.map((edge, i) => {
|
||||||
const from = positions[edge.from]
|
const from = positions[edge.from]
|
||||||
@@ -195,15 +247,19 @@ export function ArchitectureDiagram({ animation }: { animation: string }) {
|
|||||||
<div
|
<div
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute -translate-x-1/2 -translate-y-1/2 rounded-lg border px-2 py-1 text-center transition-all duration-500',
|
'absolute -translate-x-1/2 -translate-y-1/2 rounded-lg border text-center transition-all duration-500',
|
||||||
|
compact ? 'px-1 py-0.5' : present ? 'px-3 py-2' : 'px-2 py-1',
|
||||||
lit ? 'scale-105 border-docker shadow-docker bg-docker/20' : 'border-border bg-surface-raised/90',
|
lit ? 'scale-105 border-docker shadow-docker bg-docker/20' : 'border-border bg-surface-raised/90',
|
||||||
)}
|
)}
|
||||||
style={{ left: `${pos.x}%`, top: `${pos.y}%`, minWidth: '72px' }}
|
style={{ left: `${pos.x}%`, top: `${pos.y}%`, minWidth: compact ? '52px' : present ? '88px' : '72px', maxWidth: compact ? '64px' : present ? '120px' : '96px' }}
|
||||||
>
|
>
|
||||||
<p className="text-[9px] font-semibold leading-tight text-foreground md:text-[10px]" style={{ color: lit ? node.color : undefined }}>
|
<p className={cn(
|
||||||
|
'font-semibold leading-tight text-foreground',
|
||||||
|
compact ? 'text-[6px]' : present ? 'text-[11px] md:text-xs' : 'text-[9px] md:text-[10px]',
|
||||||
|
)} style={{ color: lit ? node.color : undefined }}>
|
||||||
{node.label}
|
{node.label}
|
||||||
</p>
|
</p>
|
||||||
{node.sub && <p className="text-[7px] text-foreground-faint md:text-[8px]">{node.sub}</p>}
|
{node.sub && <p className={cn('text-foreground-faint', compact ? 'text-[5px] leading-none' : present ? 'text-[9px] md:text-[10px]' : 'text-[7px] md:text-[8px]')}>{node.sub}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { Check, Filter, Loader2, Pencil, RotateCcw, Save, Search, X } from 'lucide-react'
|
||||||
|
import type { SourceEngine } from '../../lib/dataSourceCatalog'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||||
|
|
||||||
|
type SampleData = {
|
||||||
|
ok: boolean
|
||||||
|
columns?: string[]
|
||||||
|
rows?: unknown[][]
|
||||||
|
row_count?: number
|
||||||
|
elapsed_ms?: number
|
||||||
|
error?: string
|
||||||
|
primary_keys?: string[]
|
||||||
|
cdc?: boolean
|
||||||
|
editable?: boolean
|
||||||
|
offset?: number
|
||||||
|
limit?: number
|
||||||
|
total_count?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataEngine = SourceEngine | 'hadoop'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
engine: DataEngine
|
||||||
|
objectFqn: string
|
||||||
|
sample: SampleData | null
|
||||||
|
loading: boolean
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
onPageSizeChange: (size: number) => void
|
||||||
|
onReload: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZES = [100, 250, 500]
|
||||||
|
|
||||||
|
function cellStr(v: unknown) {
|
||||||
|
if (v === null || v === undefined) return ''
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowMatches(row: unknown[], columns: string[], filters: Record<string, string>, global: string) {
|
||||||
|
if (global) {
|
||||||
|
const hay = row.map(cellStr).join(' ').toLowerCase()
|
||||||
|
if (!hay.includes(global.toLowerCase())) return false
|
||||||
|
}
|
||||||
|
for (const col of columns) {
|
||||||
|
const f = filters[col]?.trim()
|
||||||
|
if (!f) continue
|
||||||
|
const idx = columns.indexOf(col)
|
||||||
|
const val = cellStr(row[idx]).toLowerCase()
|
||||||
|
if (!val.includes(f.toLowerCase())) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataBrowserGrid({
|
||||||
|
engine,
|
||||||
|
objectFqn,
|
||||||
|
sample,
|
||||||
|
loading,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
onReload,
|
||||||
|
}: Props) {
|
||||||
|
const [globalFilter, setGlobalFilter] = useState('')
|
||||||
|
const [colFilters, setColFilters] = useState<Record<string, string>>({})
|
||||||
|
const [editMode, setEditMode] = useState(false)
|
||||||
|
const [drafts, setDrafts] = useState<Record<number, Record<string, string>>>({})
|
||||||
|
const [savingRow, setSavingRow] = useState<number | null>(null)
|
||||||
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
||||||
|
|
||||||
|
const columns = sample?.columns || []
|
||||||
|
const rows = sample?.rows || []
|
||||||
|
const pks = sample?.primary_keys || []
|
||||||
|
const editable = sample?.editable && pks.length > 0
|
||||||
|
const totalCount = sample?.total_count ?? null
|
||||||
|
const offset = sample?.offset ?? (page - 1) * pageSize
|
||||||
|
const totalPages = totalCount != null ? Math.max(1, Math.ceil(totalCount / pageSize)) : null
|
||||||
|
const rowFrom = rows.length ? offset + 1 : 0
|
||||||
|
const rowTo = offset + rows.length
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
return rows
|
||||||
|
.map((row, idx) => ({ row, idx }))
|
||||||
|
.filter(({ row }) => rowMatches(row, columns, colFilters, globalFilter))
|
||||||
|
}, [rows, columns, colFilters, globalFilter])
|
||||||
|
|
||||||
|
const pkValuesForRow = useCallback(
|
||||||
|
(row: unknown[]) => {
|
||||||
|
const pk: Record<string, unknown> = {}
|
||||||
|
for (const k of pks) {
|
||||||
|
const i = columns.indexOf(k)
|
||||||
|
if (i >= 0) pk[k] = row[i]
|
||||||
|
}
|
||||||
|
return pk
|
||||||
|
},
|
||||||
|
[columns, pks],
|
||||||
|
)
|
||||||
|
|
||||||
|
const getDraft = (rowIdx: number, col: string, original: unknown) => {
|
||||||
|
if (drafts[rowIdx]?.[col] !== undefined) return drafts[rowIdx][col]
|
||||||
|
return cellStr(original)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDraft = (rowIdx: number, col: string, val: string) => {
|
||||||
|
setDrafts((d) => ({ ...d, [rowIdx]: { ...d[rowIdx], [col]: val } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowDirty = (rowIdx: number, row: unknown[]) => {
|
||||||
|
const d = drafts[rowIdx]
|
||||||
|
if (!d) return false
|
||||||
|
return columns.some((col, j) => {
|
||||||
|
if (pks.includes(col)) return false
|
||||||
|
return d[col] !== undefined && d[col] !== cellStr(row[j])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveRow = async (rowIdx: number, row: unknown[]) => {
|
||||||
|
const pk = pkValuesForRow(row)
|
||||||
|
if (!Object.keys(pk).length) {
|
||||||
|
setMsg({ text: 'No primary key — cannot save', ok: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const changes: Record<string, unknown> = {}
|
||||||
|
const d = drafts[rowIdx] || {}
|
||||||
|
for (const col of columns) {
|
||||||
|
if (pks.includes(col)) continue
|
||||||
|
if (d[col] !== undefined && d[col] !== cellStr(row[columns.indexOf(col)])) {
|
||||||
|
changes[col] = d[col] === '' ? null : d[col]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Object.keys(changes).length) return
|
||||||
|
|
||||||
|
setSavingRow(rowIdx)
|
||||||
|
setMsg(null)
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sql/row/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ engine, object: objectFqn, pk, changes }),
|
||||||
|
})
|
||||||
|
const j = await r.json()
|
||||||
|
if (!j.ok) {
|
||||||
|
setMsg({ text: j.error || 'Save failed', ok: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMsg({
|
||||||
|
text: j.cdc
|
||||||
|
? 'Saved — change will appear in Live Changes via CDC'
|
||||||
|
: 'Saved (this source has no CDC stream)',
|
||||||
|
ok: true,
|
||||||
|
})
|
||||||
|
setDrafts((d) => {
|
||||||
|
const next = { ...d }
|
||||||
|
delete next[rowIdx]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setTimeout(onReload, 600)
|
||||||
|
} catch {
|
||||||
|
setMsg({ text: 'API unreachable', ok: false })
|
||||||
|
} finally {
|
||||||
|
setSavingRow(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setGlobalFilter('')
|
||||||
|
setColFilters({})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sample?.ok && !loading) {
|
||||||
|
return (
|
||||||
|
<p className="p-4 text-[11px] text-danger">{sample?.error || 'Failed to load data'}</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||||
|
<div className="relative min-w-[160px] flex-1">
|
||||||
|
<Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-foreground-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search all columns…"
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
className="w-full rounded border border-border bg-surface-overlay py-1 pl-7 pr-2 text-[10px] text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(globalFilter || Object.values(colFilters).some(Boolean)) && (
|
||||||
|
<button type="button" onClick={clearFilters} className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px]', subTabIdle)}>
|
||||||
|
<X className="h-3 w-3" /> Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{editable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEditMode((v) => !v); setDrafts({}) }}
|
||||||
|
className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px]', editMode ? subTabActive : subTabIdle)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" /> {editMode ? 'Editing' : 'Edit rows'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{sample?.cdc && (
|
||||||
|
<span className="rounded bg-emerald-500/15 px-2 py-0.5 text-[9px] text-emerald-300">CDC → Live Changes</span>
|
||||||
|
)}
|
||||||
|
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin text-foreground-muted" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{msg && (
|
||||||
|
<p className={cn('shrink-0 px-3 py-1.5 text-[10px]', msg.ok ? 'text-emerald-300' : 'text-danger')}>{msg.text}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-x-auto overflow-y-auto overscroll-contain p-2">
|
||||||
|
{columns.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="mb-1 font-mono text-[9px] text-foreground-muted">
|
||||||
|
{filteredRows.length} shown
|
||||||
|
{totalCount != null
|
||||||
|
? ` · rows ${rowFrom.toLocaleString()}–${rowTo.toLocaleString()} of ${totalCount.toLocaleString()}`
|
||||||
|
: ` · page ${page}${totalPages ? ` of ${totalPages.toLocaleString()}` : ''}`}
|
||||||
|
{sample?.elapsed_ms != null && ` · ${sample.elapsed_ms}ms`}
|
||||||
|
{pks.length > 0 && ` · PK: ${pks.join(', ')}`}
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-left font-mono text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="sticky top-0 z-10 border-b border-border bg-surface-raised text-docker">
|
||||||
|
{editMode && editable && <th className="w-8 px-1 py-1" />}
|
||||||
|
{columns.map((c) => (
|
||||||
|
<th key={c} className="px-2 py-1">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className={cn(pks.includes(c) && 'text-amber-300')}>{c}{pks.includes(c) ? ' (PK)' : ''}</span>
|
||||||
|
<div className="relative">
|
||||||
|
<Filter className="pointer-events-none absolute left-1 top-1/2 h-2.5 w-2.5 -translate-y-1/2 text-foreground-faint" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="filter"
|
||||||
|
value={colFilters[c] || ''}
|
||||||
|
onChange={(e) => setColFilters((f) => ({ ...f, [c]: e.target.value }))}
|
||||||
|
className="w-full min-w-[60px] rounded border border-border/60 bg-surface py-0.5 pl-5 pr-1 text-[9px] font-normal text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredRows.map(({ row, idx }) => {
|
||||||
|
const dirty = rowDirty(idx, row)
|
||||||
|
return (
|
||||||
|
<tr key={idx} className={cn('border-b border-border/30', dirty && 'bg-violet-500/10')}>
|
||||||
|
{editMode && editable && (
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingRow === idx}
|
||||||
|
onClick={() => saveRow(idx, row)}
|
||||||
|
title="Save row"
|
||||||
|
className="rounded p-0.5 text-emerald-400 hover:bg-emerald-500/20 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{savingRow === idx ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{row.map((cell, j) => {
|
||||||
|
const col = columns[j]
|
||||||
|
const isPk = pks.includes(col)
|
||||||
|
if (editMode && editable && !isPk) {
|
||||||
|
return (
|
||||||
|
<td key={j} className="max-w-[180px] px-1 py-0.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getDraft(idx, col, cell)}
|
||||||
|
onChange={(e) => setDraft(idx, col, e.target.value)}
|
||||||
|
className="w-full rounded border border-border/60 bg-surface-overlay px-1.5 py-0.5 text-[10px] text-foreground"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<td key={j} className={cn('max-w-[200px] truncate px-2 py-1', isPk ? 'text-amber-200' : 'text-foreground')}>
|
||||||
|
{cell === null || cell === undefined ? 'NULL' : String(cell)}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{filteredRows.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-[11px] text-foreground-muted">No rows match filters</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!columns.length && !loading && (
|
||||||
|
<p className="py-8 text-center text-[11px] text-foreground-muted">Select a table, collection or label to preview data</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{columns.length > 0 && (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border/60 bg-surface-raised px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-foreground-muted">
|
||||||
|
<span>Rows per page</span>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
className="rounded border border-border bg-surface-overlay px-2 py-0.5 text-[10px] text-foreground"
|
||||||
|
>
|
||||||
|
{PAGE_SIZES.map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page <= 1 || loading}
|
||||||
|
onClick={() => onPageChange(1)}
|
||||||
|
className={cn('rounded px-2 py-1 text-[10px]', subTabIdle, 'disabled:opacity-30')}
|
||||||
|
>
|
||||||
|
First
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page <= 1 || loading}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
className={cn('rounded px-2 py-1 text-[10px]', subTabIdle, 'disabled:opacity-30')}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span className="px-2 font-mono text-[10px] text-foreground">
|
||||||
|
Page {page.toLocaleString()}{totalPages != null ? ` / ${totalPages.toLocaleString()}` : ''}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading || (totalPages != null ? page >= totalPages : rows.length < pageSize)}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
className={cn('rounded px-2 py-1 text-[10px]', subTabIdle, 'disabled:opacity-30')}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={totalPages == null || page >= totalPages || loading}
|
||||||
|
onClick={() => totalPages && onPageChange(totalPages)}
|
||||||
|
className={cn('rounded px-2 py-1 text-[10px]', subTabIdle, 'disabled:opacity-30')}
|
||||||
|
>
|
||||||
|
Last
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editMode && editable && (
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-t border-border/60 px-3 py-1.5 text-[9px] text-foreground-muted">
|
||||||
|
<Check className="h-3 w-3 text-emerald-400" />
|
||||||
|
Edit cells, then click <Save className="inline h-3 w-3" /> on a row to save.
|
||||||
|
{sample?.cdc && ' Changes on CDC sources appear in Live Changes within seconds.'}
|
||||||
|
<button type="button" onClick={() => { setDrafts({}); onReload() }} className="ml-auto inline-flex items-center gap-1 text-docker hover:underline">
|
||||||
|
<RotateCcw className="h-3 w-3" /> Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { GitBranch, Lock, LockOpen, Play, RefreshCw, ShieldAlert, ShieldCheck, Loader2, Bot } from 'lucide-react'
|
import { GitBranch, Lock, LockOpen, Play, Pause, Square, RefreshCw, ShieldAlert, ShieldCheck, Loader2, Bot } from 'lucide-react'
|
||||||
import type { DataflowEdge, DataflowGraph, DataflowNode } from '../../types'
|
import type { DataflowEdge, DataflowGraph, DataflowNode } from '../../types'
|
||||||
import { fetchDataflow, runDataflowMovement, toggleEtlAgent, fetchAgentOpsStatus, setPiiMask } from '../../lib/api'
|
import { fetchDataflow, runDataflowMovement, toggleEtlAgent, toggleCustodianOffload, fetchAgentOpsStatus, setPiiMask, setStreamingFlow } from '../../lib/api'
|
||||||
|
import { SparkKafkaPanel } from './SparkKafkaPanel'
|
||||||
import { Badge } from '../ui/Badge'
|
import { Badge } from '../ui/Badge'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ const NODE_KIND: Record<string, { ring: string; chip: string; dot: string }> = {
|
|||||||
stream: { ring: 'border-cyan-400/60', chip: 'bg-cyan-500/15 text-cyan-300 border-cyan-400/40', dot: '#22d3ee' },
|
stream: { ring: 'border-cyan-400/60', chip: 'bg-cyan-500/15 text-cyan-300 border-cyan-400/40', dot: '#22d3ee' },
|
||||||
sink: { ring: 'border-sky-400/60', chip: 'bg-sky-500/15 text-sky-300 border-sky-400/40', dot: '#38bdf8' },
|
sink: { ring: 'border-sky-400/60', chip: 'bg-sky-500/15 text-sky-300 border-sky-400/40', dot: '#38bdf8' },
|
||||||
lakehouse: { ring: 'border-blue-400/60', chip: 'bg-blue-500/15 text-blue-300 border-blue-400/40', dot: '#60a5fa' },
|
lakehouse: { ring: 'border-blue-400/60', chip: 'bg-blue-500/15 text-blue-300 border-blue-400/40', dot: '#60a5fa' },
|
||||||
|
compute: { ring: 'border-violet-400/60', chip: 'bg-violet-500/15 text-violet-300 border-violet-400/40', dot: '#a78bfa' },
|
||||||
engine: { ring: 'border-violet-400/60', chip: 'bg-violet-500/15 text-violet-300 border-violet-400/40', dot: '#a78bfa' },
|
engine: { ring: 'border-violet-400/60', chip: 'bg-violet-500/15 text-violet-300 border-violet-400/40', dot: '#a78bfa' },
|
||||||
governance: { ring: 'border-fuchsia-400/60', chip: 'bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-400/40', dot: '#d946ef' },
|
governance: { ring: 'border-fuchsia-400/60', chip: 'bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-400/40', dot: '#d946ef' },
|
||||||
}
|
}
|
||||||
@@ -70,6 +72,7 @@ export function DataFlowView() {
|
|||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
const [triggering, setTriggering] = useState<string | null>(null)
|
const [triggering, setTriggering] = useState<string | null>(null)
|
||||||
const [etlEnabled, setEtlEnabled] = useState<boolean | null>(null)
|
const [etlEnabled, setEtlEnabled] = useState<boolean | null>(null)
|
||||||
|
const [custEnabled, setCustEnabled] = useState<boolean | null>(null)
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const nodeRefs = useRef<Record<string, HTMLButtonElement | null>>({})
|
const nodeRefs = useRef<Record<string, HTMLButtonElement | null>>({})
|
||||||
@@ -91,6 +94,8 @@ export function DataFlowView() {
|
|||||||
fetchAgentOpsStatus().then((s) => {
|
fetchAgentOpsStatus().then((s) => {
|
||||||
const etl = (s as { etl?: { enabled?: boolean } })?.etl
|
const etl = (s as { etl?: { enabled?: boolean } })?.etl
|
||||||
if (etl) setEtlEnabled(!!etl.enabled)
|
if (etl) setEtlEnabled(!!etl.enabled)
|
||||||
|
const cust = (s as { custodian?: { enabled?: boolean } })?.custodian
|
||||||
|
if (cust) setCustEnabled(!!cust.enabled)
|
||||||
})
|
})
|
||||||
const iv = setInterval(() => load(), 6000)
|
const iv = setInterval(() => load(), 6000)
|
||||||
return () => clearInterval(iv)
|
return () => clearInterval(iv)
|
||||||
@@ -152,12 +157,24 @@ export function DataFlowView() {
|
|||||||
await toggleEtlAgent(next)
|
await toggleEtlAgent(next)
|
||||||
}, [etlEnabled])
|
}, [etlEnabled])
|
||||||
|
|
||||||
|
const onToggleCust = useCallback(async () => {
|
||||||
|
const next = !custEnabled
|
||||||
|
setCustEnabled(next)
|
||||||
|
await toggleCustodianOffload(next)
|
||||||
|
}, [custEnabled])
|
||||||
|
|
||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
await load(true)
|
await load(true)
|
||||||
setTimeout(() => setRefreshing(false), 400)
|
setTimeout(() => setRefreshing(false), 400)
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
|
const flowMode = (graph as unknown as { flow?: string })?.flow ?? 'running'
|
||||||
|
const onFlow = useCallback(async (action: 'pause' | 'resume' | 'stop') => {
|
||||||
|
await setStreamingFlow(action)
|
||||||
|
setTimeout(() => load(true), 300)
|
||||||
|
}, [load])
|
||||||
|
|
||||||
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) => {
|
||||||
setMaskBusy(`${key}.${column}`)
|
setMaskBusy(`${key}.${column}`)
|
||||||
@@ -202,7 +219,7 @@ export function DataFlowView() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="truncate text-xs font-semibold text-foreground">Data Flow · live lineage</h2>
|
<h2 className="truncate text-xs font-semibold text-foreground">Data Flow · live lineage</h2>
|
||||||
<p className="truncate text-[9px] text-foreground-muted">
|
<p className="truncate text-[9px] text-foreground-muted">
|
||||||
Generators → sources → CDC/Kafka → lakehouse · click a node for PII detail · trigger movements below
|
Generators → sources → CDC/Kafka → Spark → lakehouse · pulses show live flow · Spark/Kafka panel below
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,6 +257,33 @@ export function DataFlowView() {
|
|||||||
>
|
>
|
||||||
<Bot className="h-3 w-3" /> ETL agent {etlEnabled == null ? '' : etlEnabled ? 'on' : 'off'}
|
<Bot className="h-3 w-3" /> ETL agent {etlEnabled == null ? '' : etlEnabled ? 'on' : 'off'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleCust}
|
||||||
|
title="Data Custodian: autonomous batch offload of source data into the Hadoop Iceberg lake"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[9px] font-medium transition-colors',
|
||||||
|
custEnabled
|
||||||
|
? 'border-orange-400/50 bg-orange-500/20 text-orange-200'
|
||||||
|
: 'border-border bg-transparent text-foreground-muted hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Bot className="h-3 w-3" /> Hadoop offload {custEnabled == null ? '' : custEnabled ? 'on' : 'off'}
|
||||||
|
</button>
|
||||||
|
<div className="inline-flex items-center gap-0.5 rounded border border-border p-0.5" title="Master pulse control">
|
||||||
|
<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')}>
|
||||||
|
<Play className="h-3 w-3" /> Run
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onFlow('pause')}
|
||||||
|
className={cn('inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-medium transition-colors', flowMode === 'paused' ? 'bg-amber-500/25 text-amber-200' : 'text-foreground-muted hover:text-foreground')}>
|
||||||
|
<Pause className="h-3 w-3" /> Pause
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onFlow('stop')}
|
||||||
|
className={cn('inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-medium transition-colors', flowMode === 'stopped' ? 'bg-rose-500/25 text-rose-200' : 'text-foreground-muted hover:text-foreground')}>
|
||||||
|
<Square className="h-3 w-3" /> Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
@@ -333,11 +377,19 @@ export function DataFlowView() {
|
|||||||
href={selNode.url}
|
href={selNode.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="mt-1 inline-block rounded border border-fuchsia-400/40 bg-fuchsia-500/15 px-1.5 py-0.5 text-[8px] font-medium text-fuchsia-200 hover:bg-fuchsia-500/25"
|
className={cn(
|
||||||
|
'mt-1 inline-block rounded border px-1.5 py-0.5 text-[8px] font-medium hover:opacity-90',
|
||||||
|
selNode.id === 'openmetadata'
|
||||||
|
? 'border-fuchsia-400/40 bg-fuchsia-500/15 text-fuchsia-200'
|
||||||
|
: 'border-docker/40 bg-docker/15 text-docker',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Open in OpenMetadata ↗
|
Open {selNode.label} ↗
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{(selNode.id === 'spark' || selNode.id === 'kafka') && (
|
||||||
|
<p className="mt-1 text-[8px] text-docker/80">See Spark/Kafka panel below for full UI + job control.</p>
|
||||||
|
)}
|
||||||
{selNode.pii?.has_pii ? (
|
{selNode.pii?.has_pii ? (
|
||||||
<div className="mt-1.5 border-t border-border pt-1.5">
|
<div className="mt-1.5 border-t border-border pt-1.5">
|
||||||
<div className="mb-1 flex items-center justify-between gap-1 text-[9px] font-medium text-rose-300">
|
<div className="mb-1 flex items-center justify-between gap-1 text-[9px] font-medium text-rose-300">
|
||||||
@@ -400,6 +452,13 @@ export function DataFlowView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SparkKafkaPanel
|
||||||
|
embedded
|
||||||
|
selectedNodeId={selected}
|
||||||
|
streaming={graph?.streaming ?? null}
|
||||||
|
onRefreshGraph={() => load(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* movement control strip */}
|
{/* movement control strip */}
|
||||||
<div className="shrink-0 border-t border-border bg-surface/60 px-3 py-1.5">
|
<div className="shrink-0 border-t border-border bg-surface/60 px-3 py-1.5">
|
||||||
<div className="mb-1 flex items-center gap-1 text-[8px] font-semibold uppercase tracking-wide text-foreground-muted">
|
<div className="mb-1 flex items-center gap-1 text-[8px] font-semibold uppercase tracking-wide text-foreground-muted">
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Database, Boxes, Activity, Network, Layers, Play, Loader2, CheckCircle2, XCircle, RefreshCw, Bot, ScrollText } from 'lucide-react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||||
|
|
||||||
|
export type SourceKey = 'all' | 'postgres' | 'mysql' | 'mongodb' | 'cassandra' | 'neo4j'
|
||||||
|
|
||||||
|
type SourceMeta = {
|
||||||
|
key: SourceKey
|
||||||
|
label: string
|
||||||
|
icon: typeof Database
|
||||||
|
accent: string
|
||||||
|
target: string
|
||||||
|
cdc: boolean
|
||||||
|
desc: string
|
||||||
|
defaultRows: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DATA_GEN_SOURCES: SourceMeta[] = [
|
||||||
|
{ key: 'all', label: 'All sources', icon: Layers, accent: 'text-violet-400', target: 'all 5 databases', cdc: true, desc: 'Generate across all databases at once.', defaultRows: 5000 },
|
||||||
|
{ key: 'postgres', label: 'PostgreSQL', icon: Database, accent: 'text-sky-400', target: 'sales_orders', cdc: true, desc: 'Sales orders. CDC via Debezium -> Kafka -> S3.', defaultRows: 5000 },
|
||||||
|
{ key: 'mysql', label: 'MySQL', icon: Database, accent: 'text-amber-400', target: 'employee_events', cdc: true, desc: 'HR employee events. CDC via Debezium -> Kafka -> S3.', defaultRows: 5000 },
|
||||||
|
{ key: 'mongodb', label: 'MongoDB', icon: Boxes, accent: 'text-emerald-400', target: 'supplychain.events', cdc: true, desc: 'Supply chain events. CDC via Debezium -> Kafka -> S3.', defaultRows: 5000 },
|
||||||
|
{ key: 'cassandra', label: 'Cassandra', icon: Activity, accent: 'text-cyan-400', target: 'device_metrics', cdc: false, desc: 'Telemetry metrics. Queryable via Trino.', defaultRows: 5000 },
|
||||||
|
{ key: 'neo4j', label: 'Neo4j', icon: Network, accent: 'text-pink-400', target: 'Product/Supplier graph', cdc: false, desc: 'Graph data (products, suppliers, relationships).', defaultRows: 2000 },
|
||||||
|
]
|
||||||
|
|
||||||
|
type RunInfo = { run_id?: string; state?: string; start?: string; end?: string; conf?: { rows?: number } }
|
||||||
|
type AgentInfo = { agent_id: string; agent_name: string }
|
||||||
|
type ActivityItem = { id: string; ts?: string; agent_id: string; agent_name: string; message: string; level: string }
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onPulse: () => void
|
||||||
|
/** Lock to one source (embedded in Data Sources UI) */
|
||||||
|
focusSource?: Exclude<SourceKey, 'all'>
|
||||||
|
embedded?: boolean
|
||||||
|
onOpenPlatform?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const COUNT_KEYS: SourceKey[] = ['postgres', 'mysql', 'mongodb', 'cassandra']
|
||||||
|
|
||||||
|
export function DataGenPanel({ onPulse, focusSource, embedded = false, onOpenPlatform }: Props) {
|
||||||
|
const [active, setActive] = useState<SourceKey>(focusSource || 'all')
|
||||||
|
const [rows, setRows] = useState<Record<SourceKey, number>>(
|
||||||
|
Object.fromEntries(DATA_GEN_SOURCES.map((s) => [s.key, s.defaultRows])) as Record<SourceKey, number>,
|
||||||
|
)
|
||||||
|
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||||
|
const [runs, setRuns] = useState<Record<string, RunInfo[]>>({})
|
||||||
|
const [counts, setCounts] = useState<Record<string, number | null>>({})
|
||||||
|
const [msg, setMsg] = useState<string | null>(null)
|
||||||
|
const [agentMap, setAgentMap] = useState<Record<string, AgentInfo>>({})
|
||||||
|
const [activity, setActivity] = useState<ActivityItem[]>([])
|
||||||
|
const pollRef = useRef<Record<string, ReturnType<typeof setInterval>>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusSource) setActive(focusSource)
|
||||||
|
}, [focusSource])
|
||||||
|
|
||||||
|
const meta = DATA_GEN_SOURCES.find((s) => s.key === active)!
|
||||||
|
|
||||||
|
const loadCounts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/pipeline/sync')
|
||||||
|
const j = await r.json()
|
||||||
|
if (j.ok) setCounts(j.counts || {})
|
||||||
|
} catch { /* */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadActivity = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/pipeline/activity?limit=25')
|
||||||
|
const j = await r.json()
|
||||||
|
if (j.ok) setActivity(j.activity || [])
|
||||||
|
} catch { /* */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadRuns = useCallback(async (source: SourceKey) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/pipeline/runs/${source}?limit=5`)
|
||||||
|
const j = await r.json()
|
||||||
|
if (j.ok) setRuns((prev) => ({ ...prev, [source]: j.runs || [] }))
|
||||||
|
return (j.runs || [])[0] as RunInfo | undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCounts()
|
||||||
|
loadActivity()
|
||||||
|
const keys: SourceKey[] = focusSource ? [focusSource, 'all'] : DATA_GEN_SOURCES.map((s) => s.key)
|
||||||
|
keys.forEach((k) => loadRuns(k))
|
||||||
|
fetch('/api/pipeline/agents').then((r) => r.json()).then((j) => { if (j.ok) setAgentMap(j.agents || {}) }).catch(() => {})
|
||||||
|
const act = setInterval(loadActivity, 8000)
|
||||||
|
const poll = pollRef.current
|
||||||
|
return () => { Object.values(poll).forEach(clearInterval); clearInterval(act) }
|
||||||
|
}, [loadCounts, loadRuns, loadActivity, focusSource])
|
||||||
|
|
||||||
|
const startPolling = useCallback((source: SourceKey) => {
|
||||||
|
if (pollRef.current[source]) clearInterval(pollRef.current[source])
|
||||||
|
pollRef.current[source] = setInterval(async () => {
|
||||||
|
const latest = await loadRuns(source)
|
||||||
|
if (latest && (latest.state === 'success' || latest.state === 'failed')) {
|
||||||
|
clearInterval(pollRef.current[source])
|
||||||
|
delete pollRef.current[source]
|
||||||
|
setBusy((b) => ({ ...b, [source]: false }))
|
||||||
|
loadCounts()
|
||||||
|
if (latest.state === 'success') setMsg(`${source}: complete — new data generated`)
|
||||||
|
else setMsg(`${source}: run failed — check Airflow logs`)
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}, [loadRuns, loadCounts])
|
||||||
|
|
||||||
|
const generate = useCallback(async (source: SourceKey, autonomous = false) => {
|
||||||
|
setMsg(null)
|
||||||
|
setBusy((b) => ({ ...b, [source]: true }))
|
||||||
|
onPulse()
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/pipeline/generate/${source}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ rows: rows[source], autonomous }),
|
||||||
|
})
|
||||||
|
const j = await r.json()
|
||||||
|
if (!j.ok) {
|
||||||
|
setMsg(`Error: ${j.error || 'could not trigger run'}`)
|
||||||
|
setBusy((b) => ({ ...b, [source]: false }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const who = autonomous ? `${j.agent_name || 'Agent'} generating autonomously` : 'started'
|
||||||
|
setMsg(`${source}: ${who} (run ${String(j.run_id).slice(-8)})`)
|
||||||
|
startPolling(source)
|
||||||
|
setTimeout(loadActivity, 800)
|
||||||
|
} catch {
|
||||||
|
setMsg('API unreachable')
|
||||||
|
setBusy((b) => ({ ...b, [source]: false }))
|
||||||
|
}
|
||||||
|
}, [rows, onPulse, startPolling, loadActivity])
|
||||||
|
|
||||||
|
const stateBadge = (state?: string) => {
|
||||||
|
if (state === 'success') return <span className="inline-flex items-center gap-1 text-emerald-400"><CheckCircle2 className="h-3 w-3" /> success</span>
|
||||||
|
if (state === 'failed') return <span className="inline-flex items-center gap-1 text-danger"><XCircle className="h-3 w-3" /> failed</span>
|
||||||
|
if (state === 'running' || state === 'queued') return <span className="inline-flex items-center gap-1 text-amber-400"><Loader2 className="h-3 w-3 animate-spin" /> {state}</span>
|
||||||
|
return <span className="text-foreground-faint">{state || '—'}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = (runs[active] || [])[0]
|
||||||
|
const showSourceTabs = !embedded && !focusSource
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex h-full min-h-0 flex-col', embedded ? '' : 'panel flex-1 overflow-hidden')}>
|
||||||
|
{!embedded && (
|
||||||
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">Data Generation</h2>
|
||||||
|
<p className="text-[10px] text-foreground-muted">
|
||||||
|
Generate new data per database — source → Debezium → Kafka → S3
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => { loadCounts(); DATA_GEN_SOURCES.forEach((s) => loadRuns(s.key)) }} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
|
||||||
|
<RefreshCw className="inline h-3 w-3" /> Refresh
|
||||||
|
</button>
|
||||||
|
{onOpenPlatform && (
|
||||||
|
<button type="button" onClick={onOpenPlatform} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabActive)}>
|
||||||
|
View topology pulse
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSourceTabs && (
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-1 border-b border-border px-3 py-2">
|
||||||
|
{DATA_GEN_SOURCES.map((s) => {
|
||||||
|
const Icon = s.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActive(s.key)}
|
||||||
|
className={cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[11px] font-medium', active === s.key ? subTabActive : subTabIdle)}
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-3.5 w-3.5', s.accent)} />
|
||||||
|
{s.label}
|
||||||
|
{busy[s.key] && <Loader2 className="h-3 w-3 animate-spin text-amber-400" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-4">
|
||||||
|
<div className={cn('space-y-4', embedded ? 'max-w-none' : 'max-w-2xl')}>
|
||||||
|
{embedded && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!!busy.all}
|
||||||
|
onClick={() => generate('all')}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-violet-400/40 bg-violet-500/10 px-3 py-1.5 text-[10px] font-medium text-violet-300 hover:bg-violet-500/20 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{busy.all ? <Loader2 className="h-3 w-3 animate-spin" /> : <Layers className="h-3 w-3" />}
|
||||||
|
Generate all databases
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { loadCounts(); loadRuns(active); loadActivity() }} className={cn('rounded-md px-2 py-1 text-[10px]', subTabIdle)}>
|
||||||
|
<RefreshCw className="inline h-3 w-3" /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-surface-overlay/40 p-4">
|
||||||
|
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||||
|
<meta.icon className={cn('h-5 w-5', meta.accent)} />
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">{meta.label}</h3>
|
||||||
|
{meta.cdc ? (
|
||||||
|
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] text-emerald-400">CDC active</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded bg-white/10 px-1.5 py-0.5 text-[9px] text-foreground-muted">no CDC stream</span>
|
||||||
|
)}
|
||||||
|
{agentMap[active] && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-violet-500/15 px-1.5 py-0.5 text-[9px] text-violet-300">
|
||||||
|
<Bot className="h-2.5 w-2.5" /> {agentMap[active].agent_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 text-[11px] text-foreground-muted">{meta.desc} Target: <span className="font-mono text-foreground">{meta.target}</span></p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-wide text-foreground-muted">
|
||||||
|
Row count
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={2000000}
|
||||||
|
value={rows[active]}
|
||||||
|
onChange={(e) => setRows((r) => ({ ...r, [active]: Number(e.target.value) }))}
|
||||||
|
className="w-40 rounded border border-border bg-background px-2 py-1.5 text-[12px] text-foreground"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!!busy[active]}
|
||||||
|
onClick={() => generate(active)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-violet-500/90 px-4 py-2 text-[12px] font-semibold text-black hover:bg-violet-400 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{busy[active] ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||||
|
Generate data
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!!busy[active]}
|
||||||
|
onClick={() => generate(active, true)}
|
||||||
|
title="Let the assigned agent generate data autonomously"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-violet-400/50 px-3 py-2 text-[12px] font-medium text-violet-300 hover:bg-violet-500/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
Let agent generate
|
||||||
|
</button>
|
||||||
|
{active !== 'all' && COUNT_KEYS.includes(active) && (
|
||||||
|
<div className="text-[11px] text-foreground-muted">
|
||||||
|
Current rows: <span className="font-mono text-foreground">{counts[active]?.toLocaleString() ?? '…'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{msg && <p className="mt-3 text-[11px] text-foreground-muted">{msg}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border p-4">
|
||||||
|
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-foreground-muted">Recent runs — {meta.label}</h4>
|
||||||
|
{latest ? (
|
||||||
|
<table className="w-full text-left text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-[9px] uppercase text-foreground-muted">
|
||||||
|
<th className="py-1 pr-2">State</th>
|
||||||
|
<th className="py-1 pr-2">Rows</th>
|
||||||
|
<th className="py-1 pr-2">Start</th>
|
||||||
|
<th className="py-1 pr-2">End</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(runs[active] || []).map((run) => (
|
||||||
|
<tr key={run.run_id} className="border-b border-border/40">
|
||||||
|
<td className="py-1 pr-2">{stateBadge(run.state)}</td>
|
||||||
|
<td className="py-1 pr-2 font-mono text-foreground">{run.conf?.rows ?? '—'}</td>
|
||||||
|
<td className="py-1 pr-2 text-foreground-muted">{run.start?.slice(11, 19) || '—'}</td>
|
||||||
|
<td className="py-1 pr-2 text-foreground-muted">{run.end?.slice(11, 19) || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-foreground-muted">No runs yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!embedded && (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg border border-border p-4">
|
||||||
|
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-foreground-muted">Live counts (Trino)</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
{COUNT_KEYS.map((k) => (
|
||||||
|
<div key={k} className="rounded border border-border/60 px-2 py-1.5">
|
||||||
|
<div className="text-[9px] uppercase text-foreground-muted">{k}</div>
|
||||||
|
<div className="font-mono text-[12px] text-foreground">{counts[k]?.toLocaleString() ?? '…'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border p-4">
|
||||||
|
<h4 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-foreground-faint">
|
||||||
|
<ScrollText className="h-3.5 w-3.5" /> Agent activity
|
||||||
|
</h4>
|
||||||
|
{activity.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-foreground-muted">No agent actions logged yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{activity.map((a) => (
|
||||||
|
<li key={a.id} className="flex items-start gap-2 text-[11px]">
|
||||||
|
<span className="mt-0.5 text-foreground-muted">{a.ts?.slice(11, 19) || ''}</span>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-violet-500/15 px-1 text-[9px] text-violet-300">
|
||||||
|
<Bot className="h-2.5 w-2.5" />{a.agent_name}
|
||||||
|
</span>
|
||||||
|
<span className={cn('flex-1', a.level === 'err' ? 'text-danger' : 'text-foreground-muted')}>{a.message}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{embedded && (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="rounded-lg border border-border p-4">
|
||||||
|
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-foreground-muted">Live counts (Trino)</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{COUNT_KEYS.map((k) => (
|
||||||
|
<div key={k} className="rounded border border-border/60 px-2 py-1.5">
|
||||||
|
<div className="text-[9px] uppercase text-foreground-muted">{k}</div>
|
||||||
|
<div className="font-mono text-[12px] text-foreground">{counts[k]?.toLocaleString() ?? '…'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border p-4">
|
||||||
|
<h4 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-foreground-faint">
|
||||||
|
<ScrollText className="h-3.5 w-3.5" /> Agent activity
|
||||||
|
</h4>
|
||||||
|
{activity.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-foreground-muted">No agent actions yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="scrollbar-thin max-h-[140px] space-y-1 overflow-y-auto">
|
||||||
|
{activity.slice(0, 8).map((a) => (
|
||||||
|
<li key={a.id} className="flex items-start gap-2 text-[10px]">
|
||||||
|
<span className="text-foreground-muted">{a.ts?.slice(11, 19) || ''}</span>
|
||||||
|
<span className="flex-1 text-foreground">{a.message}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,303 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { DataGenPanel } from './DataGenPanel'
|
||||||
import { Database, Boxes, Activity, Cpu, Network, Layers, Play, Loader2, CheckCircle2, XCircle, RefreshCw, Bot, ScrollText } from 'lucide-react'
|
|
||||||
import { cn } from '../../lib/utils'
|
|
||||||
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
|
||||||
|
|
||||||
type SourceKey = 'all' | 'postgres' | 'mysql' | 'mongodb' | 'cassandra' | 'neo4j'
|
|
||||||
|
|
||||||
type SourceMeta = {
|
|
||||||
key: SourceKey
|
|
||||||
label: string
|
|
||||||
icon: typeof Database
|
|
||||||
accent: string
|
|
||||||
target: string
|
|
||||||
cdc: boolean
|
|
||||||
desc: string
|
|
||||||
defaultRows: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const SOURCES: SourceMeta[] = [
|
|
||||||
{ key: 'all', label: 'All sources', icon: Layers, accent: 'text-violet-400', target: 'alle 5 databases', cdc: true, desc: 'Genereer tegelijk in alle databases.', defaultRows: 5000 },
|
|
||||||
{ key: 'postgres', label: 'PostgreSQL', icon: Database, accent: 'text-sky-400', target: 'sales_orders', cdc: true, desc: 'Sales orders. CDC via Debezium -> Kafka -> S3.', defaultRows: 5000 },
|
|
||||||
{ key: 'mysql', label: 'MySQL', icon: Database, accent: 'text-amber-400', target: 'employee_events', cdc: true, desc: 'HR employee events. CDC via Debezium -> Kafka -> S3.', defaultRows: 5000 },
|
|
||||||
{ key: 'mongodb', label: 'MongoDB', icon: Boxes, accent: 'text-emerald-400', target: 'supplychain.events', cdc: true, desc: 'Supply chain events. CDC via Debezium -> Kafka -> S3.', defaultRows: 5000 },
|
|
||||||
{ key: 'cassandra', label: 'Cassandra', icon: Activity, accent: 'text-cyan-400', target: 'device_metrics', cdc: false, desc: 'Telemetry metrics. Zichtbaar via Trino.', defaultRows: 5000 },
|
|
||||||
{ key: 'neo4j', label: 'Neo4j', icon: Network, accent: 'text-pink-400', target: 'Product/Supplier graph', cdc: false, desc: 'Graafdata (producten, leveranciers, relaties).', defaultRows: 2000 },
|
|
||||||
]
|
|
||||||
|
|
||||||
type RunInfo = { run_id?: string; state?: string; start?: string; end?: string; conf?: { rows?: number } }
|
|
||||||
type AgentInfo = { agent_id: string; agent_name: string }
|
|
||||||
type ActivityItem = { id: string; ts?: string; agent_id: string; agent_name: string; message: string; level: string }
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onPulse: () => void
|
onPulse: () => void
|
||||||
onOpenPlatform: () => void
|
onOpenPlatform: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const COUNT_KEYS: SourceKey[] = ['postgres', 'mysql', 'mongodb', 'cassandra']
|
/** Standalone page wrapper — prefer Data Sources UI → Generate tab */
|
||||||
|
|
||||||
export function DataGenView({ onPulse, onOpenPlatform }: Props) {
|
export function DataGenView({ onPulse, onOpenPlatform }: Props) {
|
||||||
const [active, setActive] = useState<SourceKey>('all')
|
return <DataGenPanel onPulse={onPulse} onOpenPlatform={onOpenPlatform} />
|
||||||
const [rows, setRows] = useState<Record<SourceKey, number>>(
|
|
||||||
Object.fromEntries(SOURCES.map((s) => [s.key, s.defaultRows])) as Record<SourceKey, number>,
|
|
||||||
)
|
|
||||||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
|
||||||
const [runs, setRuns] = useState<Record<string, RunInfo[]>>({})
|
|
||||||
const [counts, setCounts] = useState<Record<string, number | null>>({})
|
|
||||||
const [msg, setMsg] = useState<string | null>(null)
|
|
||||||
const [agentMap, setAgentMap] = useState<Record<string, AgentInfo>>({})
|
|
||||||
const [activity, setActivity] = useState<ActivityItem[]>([])
|
|
||||||
const pollRef = useRef<Record<string, ReturnType<typeof setInterval>>>({})
|
|
||||||
|
|
||||||
const meta = SOURCES.find((s) => s.key === active)!
|
|
||||||
|
|
||||||
const loadCounts = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/pipeline/sync')
|
|
||||||
const j = await r.json()
|
|
||||||
if (j.ok) setCounts(j.counts || {})
|
|
||||||
} catch { /* */ }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadActivity = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/pipeline/activity?limit=25')
|
|
||||||
const j = await r.json()
|
|
||||||
if (j.ok) setActivity(j.activity || [])
|
|
||||||
} catch { /* */ }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadRuns = useCallback(async (source: SourceKey) => {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/api/pipeline/runs/${source}?limit=5`)
|
|
||||||
const j = await r.json()
|
|
||||||
if (j.ok) setRuns((prev) => ({ ...prev, [source]: j.runs || [] }))
|
|
||||||
return (j.runs || [])[0] as RunInfo | undefined
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadCounts()
|
|
||||||
loadActivity()
|
|
||||||
SOURCES.forEach((s) => loadRuns(s.key))
|
|
||||||
fetch('/api/pipeline/agents').then((r) => r.json()).then((j) => { if (j.ok) setAgentMap(j.agents || {}) }).catch(() => {})
|
|
||||||
const act = setInterval(loadActivity, 8000)
|
|
||||||
const poll = pollRef.current
|
|
||||||
return () => { Object.values(poll).forEach(clearInterval); clearInterval(act) }
|
|
||||||
}, [loadCounts, loadRuns, loadActivity])
|
|
||||||
|
|
||||||
const startPolling = useCallback((source: SourceKey) => {
|
|
||||||
if (pollRef.current[source]) clearInterval(pollRef.current[source])
|
|
||||||
pollRef.current[source] = setInterval(async () => {
|
|
||||||
const latest = await loadRuns(source)
|
|
||||||
if (latest && (latest.state === 'success' || latest.state === 'failed')) {
|
|
||||||
clearInterval(pollRef.current[source])
|
|
||||||
delete pollRef.current[source]
|
|
||||||
setBusy((b) => ({ ...b, [source]: false }))
|
|
||||||
loadCounts()
|
|
||||||
if (latest.state === 'success') setMsg(`${source}: klaar — nieuwe data gegenereerd`)
|
|
||||||
else setMsg(`${source}: run mislukt — check Airflow logs`)
|
|
||||||
}
|
|
||||||
}, 3000)
|
|
||||||
}, [loadRuns, loadCounts])
|
|
||||||
|
|
||||||
const generate = useCallback(async (source: SourceKey, autonomous = false) => {
|
|
||||||
setMsg(null)
|
|
||||||
setBusy((b) => ({ ...b, [source]: true }))
|
|
||||||
onPulse()
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/api/pipeline/generate/${source}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ rows: rows[source], autonomous }),
|
|
||||||
})
|
|
||||||
const j = await r.json()
|
|
||||||
if (!j.ok) {
|
|
||||||
setMsg(`Fout: ${j.error || 'kon niet triggeren'}`)
|
|
||||||
setBusy((b) => ({ ...b, [source]: false }))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const who = autonomous ? `${j.agent_name || 'Agent'} genereert zelf` : 'gestart'
|
|
||||||
setMsg(`${source}: ${who} (run ${String(j.run_id).slice(-8)})`)
|
|
||||||
startPolling(source)
|
|
||||||
setTimeout(loadActivity, 800)
|
|
||||||
} catch {
|
|
||||||
setMsg('API niet bereikbaar')
|
|
||||||
setBusy((b) => ({ ...b, [source]: false }))
|
|
||||||
}
|
|
||||||
}, [rows, onPulse, startPolling, loadActivity])
|
|
||||||
|
|
||||||
const stateBadge = (state?: string) => {
|
|
||||||
if (state === 'success') return <span className="inline-flex items-center gap-1 text-emerald-400"><CheckCircle2 className="h-3 w-3" /> success</span>
|
|
||||||
if (state === 'failed') return <span className="inline-flex items-center gap-1 text-danger"><XCircle className="h-3 w-3" /> failed</span>
|
|
||||||
if (state === 'running' || state === 'queued') return <span className="inline-flex items-center gap-1 text-amber-400"><Loader2 className="h-3 w-3 animate-spin" /> {state}</span>
|
|
||||||
return <span className="text-foreground-faint">{state || '—'}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
const latest = (runs[active] || [])[0]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
|
||||||
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
|
|
||||||
<div>
|
|
||||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
|
||||||
<Cpu className="h-4 w-4 text-violet-400" /> Data Generation
|
|
||||||
</h2>
|
|
||||||
<p className="text-[10px] text-foreground-muted">
|
|
||||||
Genereer per database nieuwe data en pulse de hele flow: bron -> Debezium -> Kafka -> S3
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button type="button" onClick={() => { loadCounts(); SOURCES.forEach((s) => loadRuns(s.key)) }} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
|
|
||||||
<RefreshCw className="inline h-3 w-3" /> Refresh
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={onOpenPlatform} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabActive)}>
|
|
||||||
Bekijk topology pulse
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-wrap gap-1 border-b border-border px-3 py-2">
|
|
||||||
{SOURCES.map((s) => {
|
|
||||||
const Icon = s.icon
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={s.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActive(s.key)}
|
|
||||||
className={cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[11px] font-medium', active === s.key ? subTabActive : subTabIdle)}
|
|
||||||
>
|
|
||||||
<Icon className={cn('h-3.5 w-3.5', s.accent)} />
|
|
||||||
{s.label}
|
|
||||||
{busy[s.key] && <Loader2 className="h-3 w-3 animate-spin text-amber-400" />}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-4">
|
|
||||||
<div className="max-w-2xl space-y-4">
|
|
||||||
<div className="rounded-lg border border-border bg-surface-overlay/40 p-4">
|
|
||||||
<div className="mb-1 flex items-center gap-2">
|
|
||||||
<meta.icon className={cn('h-5 w-5', meta.accent)} />
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">{meta.label}</h3>
|
|
||||||
{meta.cdc ? (
|
|
||||||
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] text-emerald-400">CDC actief</span>
|
|
||||||
) : (
|
|
||||||
<span className="rounded bg-white/10 px-1.5 py-0.5 text-[9px] text-foreground-muted">geen CDC-stream</span>
|
|
||||||
)}
|
|
||||||
{agentMap[active] && (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded bg-violet-500/15 px-1.5 py-0.5 text-[9px] text-violet-300">
|
|
||||||
<Bot className="h-2.5 w-2.5" /> {agentMap[active].agent_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mb-3 text-[11px] text-foreground-muted">{meta.desc} Doel: <span className="font-mono">{meta.target}</span></p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
|
||||||
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-wide text-foreground-faint">
|
|
||||||
Aantal rijen
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={2000000}
|
|
||||||
value={rows[active]}
|
|
||||||
onChange={(e) => setRows((r) => ({ ...r, [active]: Number(e.target.value) }))}
|
|
||||||
className="w-40 rounded border border-border bg-background px-2 py-1.5 text-[12px] text-foreground"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!!busy[active]}
|
|
||||||
onClick={() => generate(active)}
|
|
||||||
className="inline-flex items-center gap-2 rounded-md bg-violet-500/90 px-4 py-2 text-[12px] font-semibold text-black hover:bg-violet-400 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{busy[active] ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
|
||||||
Genereer data
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!!busy[active]}
|
|
||||||
onClick={() => generate(active, true)}
|
|
||||||
title="Laat de verantwoordelijke agent zelf data genereren (wordt gelogd)"
|
|
||||||
className="inline-flex items-center gap-2 rounded-md border border-violet-400/50 px-3 py-2 text-[12px] font-medium text-violet-300 hover:bg-violet-500/10 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Bot className="h-4 w-4" />
|
|
||||||
Laat agent genereren
|
|
||||||
</button>
|
|
||||||
{active !== 'all' && COUNT_KEYS.includes(active) && (
|
|
||||||
<div className="text-[11px] text-foreground-muted">
|
|
||||||
Huidige rijen: <span className="font-mono text-foreground">{counts[active]?.toLocaleString() ?? '…'}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{msg && <p className="mt-3 text-[11px] text-foreground-muted">{msg}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-border p-4">
|
|
||||||
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-foreground-faint">Recente runs — {meta.label}</h4>
|
|
||||||
{latest ? (
|
|
||||||
<table className="w-full text-left text-[11px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border text-[9px] uppercase text-foreground-faint">
|
|
||||||
<th className="py-1 pr-2">State</th>
|
|
||||||
<th className="py-1 pr-2">Rows</th>
|
|
||||||
<th className="py-1 pr-2">Start</th>
|
|
||||||
<th className="py-1 pr-2">Eind</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{(runs[active] || []).map((run) => (
|
|
||||||
<tr key={run.run_id} className="border-b border-border/40">
|
|
||||||
<td className="py-1 pr-2">{stateBadge(run.state)}</td>
|
|
||||||
<td className="py-1 pr-2 font-mono text-foreground-muted">{run.conf?.rows ?? '—'}</td>
|
|
||||||
<td className="py-1 pr-2 text-foreground-faint">{run.start?.slice(11, 19) || '—'}</td>
|
|
||||||
<td className="py-1 pr-2 text-foreground-faint">{run.end?.slice(11, 19) || '—'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
) : (
|
|
||||||
<p className="text-[11px] text-foreground-faint">Nog geen runs.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-border p-4">
|
|
||||||
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-foreground-faint">Live tellingen (Trino)</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
||||||
{COUNT_KEYS.map((k) => (
|
|
||||||
<div key={k} className="rounded border border-border/60 px-2 py-1.5">
|
|
||||||
<div className="text-[9px] uppercase text-foreground-faint">{k}</div>
|
|
||||||
<div className="font-mono text-[12px] text-foreground">{counts[k]?.toLocaleString() ?? '…'}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-border p-4">
|
|
||||||
<h4 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-foreground-faint">
|
|
||||||
<ScrollText className="h-3.5 w-3.5" /> Agent-activiteit (wat de agents deden)
|
|
||||||
</h4>
|
|
||||||
{activity.length === 0 ? (
|
|
||||||
<p className="text-[11px] text-foreground-faint">Nog geen agent-acties gelogd.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{activity.map((a) => (
|
|
||||||
<li key={a.id} className="flex items-start gap-2 text-[11px]">
|
|
||||||
<span className="mt-0.5 text-foreground-faint">{a.ts?.slice(11, 19) || ''}</span>
|
|
||||||
<span className="inline-flex items-center gap-1 rounded bg-violet-500/15 px-1 text-[9px] text-violet-300">
|
|
||||||
<Bot className="h-2.5 w-2.5" />{a.agent_name}
|
|
||||||
</span>
|
|
||||||
<span className={cn('flex-1', a.level === 'err' ? 'text-danger' : 'text-foreground-muted')}>{a.message}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Database, Server } from 'lucide-react'
|
||||||
|
import { DataSourcesView } from './DataSourcesView'
|
||||||
|
import { HadoopSourcesView } from './HadoopSourcesView'
|
||||||
|
import type { SourceEngine } from '../../lib/dataSourceCatalog'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||||
|
|
||||||
|
type HubTab = 'sources' | 'hadoop'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
focusEngine?: SourceEngine | null
|
||||||
|
initialTab?: HubTab
|
||||||
|
onPulse?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataHubView({ focusEngine, initialTab = 'sources', onPulse }: Props) {
|
||||||
|
const [tab, setTab] = useState<HubTab>(initialTab)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTab(initialTab)
|
||||||
|
}, [initialTab])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
|
<div className="panel mx-3 mt-3 flex shrink-0 items-center gap-1 px-2 py-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('sources')}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[11px] font-medium transition-all',
|
||||||
|
tab === 'sources' ? subTabActive : subTabIdle,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Database className="h-3.5 w-3.5" /> Source Databases
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('hadoop')}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[11px] font-medium transition-all',
|
||||||
|
tab === 'hadoop' ? subTabActive : subTabIdle,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Server className="h-3.5 w-3.5" /> Hadoop
|
||||||
|
</button>
|
||||||
|
<span className="ml-auto text-[9px] text-foreground-muted">
|
||||||
|
{tab === 'sources' ? 'PostgreSQL · MySQL · MongoDB · Cassandra · Neo4j' : 'HDFS · Hive · Iceberg · Spark · Kafka pipeline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
|
{tab === 'sources' ? (
|
||||||
|
<DataSourcesView focusEngine={focusEngine} onPulse={onPulse} />
|
||||||
|
) : (
|
||||||
|
<HadoopSourcesView onPulse={onPulse} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,15 +6,20 @@ import {
|
|||||||
FolderTree,
|
FolderTree,
|
||||||
Loader2,
|
Loader2,
|
||||||
Network,
|
Network,
|
||||||
|
Play,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Server,
|
Server,
|
||||||
Table2,
|
Table2,
|
||||||
TerminalSquare,
|
TerminalSquare,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Badge } from '../ui/Badge'
|
import { Badge } from '../ui/Badge'
|
||||||
|
import { DbBrandIcon, dbBrandColor } from '../ui/DbBrandIcon'
|
||||||
|
import { DataBrowserGrid } from './DataBrowserGrid'
|
||||||
|
import { DataGenPanel } from './DataGenPanel'
|
||||||
import { DbShell } from './DbShell'
|
import { DbShell } from './DbShell'
|
||||||
import { Neo4jGraphView } from './Neo4jGraphView'
|
import { Neo4jGraphView } from './Neo4jGraphView'
|
||||||
import { SqlWorkbench } from './SqlWorkbench'
|
import { SqlWorkbench } from './SqlWorkbench'
|
||||||
|
import { LakehouseWorkbench } from './SparkView'
|
||||||
import {
|
import {
|
||||||
getSourceMeta,
|
getSourceMeta,
|
||||||
SOURCE_CATALOG,
|
SOURCE_CATALOG,
|
||||||
@@ -39,16 +44,33 @@ type SampleResponse = {
|
|||||||
row_count?: number
|
row_count?: number
|
||||||
elapsed_ms?: number
|
elapsed_ms?: number
|
||||||
error?: string
|
error?: string
|
||||||
|
primary_keys?: string[]
|
||||||
|
cdc?: boolean
|
||||||
|
editable?: boolean
|
||||||
|
offset?: number
|
||||||
|
limit?: number
|
||||||
|
total_count?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
focusEngine?: SourceEngine | null
|
focusEngine?: SourceEngine | null
|
||||||
|
onPulse?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUB_TABS: { id: SourceSubTab; label: string; icon: typeof FolderTree; neo4jOnly?: boolean }[] = [
|
const ENGINE_CATALOG: Record<SourceEngine, string | undefined> = {
|
||||||
|
postgres: 'postgres_sales',
|
||||||
|
mysql: 'mysql_hr',
|
||||||
|
mongodb: 'mongodb_supplychain',
|
||||||
|
cassandra: 'cassandra_telemetry',
|
||||||
|
neo4j: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUB_TABS: { id: SourceSubTab; label: string; icon: typeof FolderTree; neo4jOnly?: boolean; hideNeo4j?: boolean }[] = [
|
||||||
{ id: 'browser', label: 'Browser', icon: FolderTree },
|
{ id: 'browser', label: 'Browser', icon: FolderTree },
|
||||||
{ id: 'graph', label: 'Graph', icon: Network, neo4jOnly: true },
|
{ id: 'graph', label: 'Graph', icon: Network, neo4jOnly: true },
|
||||||
{ id: 'console', label: 'Query Console', icon: Database },
|
{ id: 'console', label: 'Query Console', icon: Database },
|
||||||
|
{ id: 'workbench', label: 'Workbench', icon: Activity, hideNeo4j: true },
|
||||||
|
{ id: 'generate', label: 'Generate', icon: Play },
|
||||||
{ id: 'shell', label: 'Shell', icon: TerminalSquare },
|
{ id: 'shell', label: 'Shell', icon: TerminalSquare },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -59,7 +81,7 @@ function fmtCount(n?: number | null) {
|
|||||||
return String(n)
|
return String(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataSourcesView({ focusEngine }: Props) {
|
export function DataSourcesView({ focusEngine, onPulse }: Props) {
|
||||||
const [active, setActive] = useState<SourceEngine>(focusEngine || 'postgres')
|
const [active, setActive] = useState<SourceEngine>(focusEngine || 'postgres')
|
||||||
const [subTab, setSubTab] = useState<SourceSubTab>('browser')
|
const [subTab, setSubTab] = useState<SourceSubTab>('browser')
|
||||||
const [health, setHealth] = useState<HealthMap>({})
|
const [health, setHealth] = useState<HealthMap>({})
|
||||||
@@ -68,6 +90,8 @@ export function DataSourcesView({ focusEngine }: Props) {
|
|||||||
const [selectedObject, setSelectedObject] = useState<CatalogObject | null>(null)
|
const [selectedObject, setSelectedObject] = useState<CatalogObject | null>(null)
|
||||||
const [sample, setSample] = useState<SampleResponse | null>(null)
|
const [sample, setSample] = useState<SampleResponse | null>(null)
|
||||||
const [sampleLoading, setSampleLoading] = useState(false)
|
const [sampleLoading, setSampleLoading] = useState(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(100)
|
||||||
|
|
||||||
const meta = getSourceMeta(active)
|
const meta = getSourceMeta(active)
|
||||||
|
|
||||||
@@ -104,11 +128,14 @@ export function DataSourcesView({ focusEngine }: Props) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadSample = useCallback(async (engine: SourceEngine, obj: CatalogObject) => {
|
const loadSample = useCallback(async (engine: SourceEngine, obj: CatalogObject, pg = page, ps = pageSize) => {
|
||||||
setSampleLoading(true)
|
setSampleLoading(true)
|
||||||
setSample(null)
|
setSample(null)
|
||||||
|
const offset = (pg - 1) * ps
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/sql/sample/${engine}?object=${encodeURIComponent(obj.fqn)}&limit=50`)
|
const r = await fetch(
|
||||||
|
`/api/sql/sample/${engine}?object=${encodeURIComponent(obj.fqn)}&limit=${ps}&offset=${offset}`,
|
||||||
|
)
|
||||||
const j = await r.json()
|
const j = await r.json()
|
||||||
setSample(j)
|
setSample(j)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -116,7 +143,7 @@ export function DataSourcesView({ focusEngine }: Props) {
|
|||||||
} finally {
|
} finally {
|
||||||
setSampleLoading(false)
|
setSampleLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [page, pageSize])
|
||||||
|
|
||||||
useEffect(() => { loadHealth() }, [loadHealth])
|
useEffect(() => { loadHealth() }, [loadHealth])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -124,21 +151,26 @@ export function DataSourcesView({ focusEngine }: Props) {
|
|||||||
}, [active, subTab, loadCatalog])
|
}, [active, subTab, loadCatalog])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedObject && subTab === 'browser') loadSample(active, selectedObject)
|
setPage(1)
|
||||||
}, [selectedObject, active, subTab, loadSample])
|
}, [selectedObject?.fqn, active])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedObject && subTab === 'browser') loadSample(active, selectedObject, page, pageSize)
|
||||||
|
}, [selectedObject, active, subTab, page, pageSize, loadSample])
|
||||||
|
|
||||||
const refreshAll = () => {
|
const refreshAll = () => {
|
||||||
loadHealth()
|
loadHealth()
|
||||||
if (subTab === 'browser') loadCatalog(active)
|
if (subTab === 'browser') {
|
||||||
else if (subTab === 'graph' && active === 'neo4j') { /* Neo4jGraphView reloads itself */ }
|
loadCatalog(active)
|
||||||
else if (selectedObject) loadSample(active, selectedObject)
|
if (selectedObject) loadSample(active, selectedObject, page, pageSize)
|
||||||
|
} else if (subTab === 'graph' && active === 'neo4j') { /* Neo4jGraphView reloads itself */ }
|
||||||
|
else if (selectedObject) loadSample(active, selectedObject, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleSubTabs = SUB_TABS.filter((t) => !t.neo4jOnly || active === 'neo4j')
|
const visibleSubTabs = SUB_TABS.filter((t) => (!t.neo4jOnly || active === 'neo4j') && !(t.hideNeo4j && active === 'neo4j'))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-2 p-3">
|
<div className="flex h-full min-h-0 flex-col gap-2 p-3">
|
||||||
{/* Header */}
|
|
||||||
<header className="panel flex shrink-0 flex-wrap items-center justify-between gap-3 px-4 py-3">
|
<header className="panel flex shrink-0 flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||||
<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">
|
||||||
@@ -146,7 +178,7 @@ export function DataSourcesView({ focusEngine }: Props) {
|
|||||||
Data Sources UI
|
Data Sources UI
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[11px] text-foreground-muted">
|
<p className="text-[11px] text-foreground-muted">
|
||||||
Enterprise data browser — schema exploration, query console & interactive shells for all source databases
|
Browse, filter, edit & query all source databases — edits on CDC sources flow to Live Changes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={refreshAll} className={cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
|
<button type="button" onClick={refreshAll} className={cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
|
||||||
@@ -154,170 +186,159 @@ export function DataSourcesView({ focusEngine }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-2 overflow-hidden">
|
{/* Horizontal database selector */}
|
||||||
{/* Left rail — database cards */}
|
<div className="panel shrink-0 px-3 py-2">
|
||||||
<aside className="panel flex w-[220px] shrink-0 flex-col overflow-hidden">
|
<p className="mb-2 text-[9px] font-semibold uppercase tracking-widest text-foreground-muted">Source Databases</p>
|
||||||
<div className="shrink-0 border-b border-border px-3 py-2">
|
<div className="scroll-x-stable scrollbar-thin flex gap-2 pb-1">
|
||||||
<p className="text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">Source Databases</p>
|
{SOURCE_CATALOG.map((src) => {
|
||||||
</div>
|
const up = health[src.engine]?.ok
|
||||||
<div className="scrollbar-thin min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
const selected = active === src.engine
|
||||||
{SOURCE_CATALOG.map((src) => {
|
const brand = dbBrandColor(src.engine)
|
||||||
const Icon = src.icon
|
return (
|
||||||
const up = health[src.engine]?.ok
|
<button
|
||||||
const selected = active === src.engine
|
key={src.engine}
|
||||||
return (
|
type="button"
|
||||||
<button
|
onClick={() => setActive(src.engine)}
|
||||||
key={src.engine}
|
className={cn(
|
||||||
type="button"
|
'flex min-w-[148px] shrink-0 flex-col gap-1.5 rounded-lg border px-3 py-2.5 text-left transition-all',
|
||||||
onClick={() => { setActive(src.engine); setSubTab('browser') }}
|
selected
|
||||||
className={cn(
|
? 'shadow-md'
|
||||||
'flex w-full flex-col gap-1 rounded-lg border p-2.5 text-left transition-all',
|
: 'border-border/60 hover:border-border hover:bg-surface-overlay',
|
||||||
selected ? cn(src.border, src.accentBg, 'shadow-sm') : 'border-transparent hover:border-border hover:bg-surface-overlay',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className={cn('flex items-center gap-1.5 text-[12px] font-semibold', selected ? src.accent : 'text-foreground')}>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
{src.label}
|
|
||||||
</span>
|
|
||||||
<span className={cn('h-2 w-2 rounded-full', up === true ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.6)]' : up === false ? 'bg-red-400' : 'bg-foreground-faint')} title={up ? 'Online' : up === false ? 'Offline' : 'Unknown'} />
|
|
||||||
</div>
|
|
||||||
<p className="text-[9px] leading-snug text-foreground-muted">{src.description}</p>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
<Badge variant="default">{src.host}:{src.port}</Badge>
|
|
||||||
{src.cdc && <Badge variant="accent">CDC</Badge>}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main panel */}
|
|
||||||
<div className="panel flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
|
||||||
{/* Engine header */}
|
|
||||||
<div className={cn('flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-2.5', meta.accentBg)}>
|
|
||||||
<div>
|
|
||||||
<h2 className={cn('flex items-center gap-2 text-sm font-semibold', meta.accent)}>
|
|
||||||
<meta.icon className="h-4 w-4" />
|
|
||||||
{meta.label}
|
|
||||||
{catalog?.version && (
|
|
||||||
<span className="font-mono text-[10px] font-normal text-foreground-muted">v{catalog.version.split(' ')[0]?.slice(0, 20)}</span>
|
|
||||||
)}
|
)}
|
||||||
</h2>
|
style={selected ? { borderColor: brand, backgroundColor: `${brand}18`, boxShadow: `0 0 0 1px ${brand}40` } : undefined}
|
||||||
<p className="font-mono text-[10px] text-foreground-muted">
|
>
|
||||||
{meta.host}:{meta.port} · {meta.database} · container {meta.container}
|
<div className="flex items-center justify-between gap-2">
|
||||||
</p>
|
<span className="flex items-center gap-2 text-[12px] font-semibold text-foreground">
|
||||||
</div>
|
<DbBrandIcon engine={src.engine} size={22} />
|
||||||
<div className="flex gap-1">
|
{src.label}
|
||||||
{visibleSubTabs.map(({ id, label, icon: Icon }) => (
|
</span>
|
||||||
<button
|
<span
|
||||||
key={id}
|
className={cn(
|
||||||
type="button"
|
'h-2 w-2 shrink-0 rounded-full',
|
||||||
onClick={() => setSubTab(id)}
|
up === true ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.6)]' : up === false ? 'bg-red-400' : 'bg-foreground-faint',
|
||||||
className={cn('inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-[10px] font-medium', subTab === id ? subTabActive : subTabIdle)}
|
|
||||||
>
|
|
||||||
<Icon className="h-3 w-3" /> {label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sub-tab content */}
|
|
||||||
<div className="min-h-0 flex-1 overflow-hidden">
|
|
||||||
{subTab === 'browser' && (
|
|
||||||
<div className="flex h-full min-h-0">
|
|
||||||
{/* Object tree */}
|
|
||||||
<div className="flex w-[280px] shrink-0 flex-col border-r border-border/60">
|
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
|
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Objects</span>
|
|
||||||
{catalogLoading && <Loader2 className="h-3 w-3 animate-spin text-foreground-muted" />}
|
|
||||||
</div>
|
|
||||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-1.5">
|
|
||||||
{catalog?.objects?.map((obj) => (
|
|
||||||
<button
|
|
||||||
key={obj.fqn}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedObject(obj)}
|
|
||||||
className={cn(
|
|
||||||
'mb-0.5 flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-[10px] transition-colors',
|
|
||||||
selectedObject?.fqn === obj.fqn ? subTabActive : 'hover:bg-surface-overlay',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{obj.type === 'node_label' ? <Activity className="h-3 w-3 shrink-0 text-pink-400" /> : <Table2 className="h-3 w-3 shrink-0 text-foreground-muted" />}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate font-medium text-foreground">{obj.name}</p>
|
|
||||||
<p className="truncate font-mono text-[8px] text-foreground-faint">{obj.schema}{obj.type === 'relationship' ? ' · rel' : ''}</p>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 font-mono text-[9px] text-foreground-muted">{fmtCount(obj.row_count)}</span>
|
|
||||||
<ChevronRight className="h-3 w-3 shrink-0 text-foreground-faint" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{!catalogLoading && !catalog?.objects?.length && (
|
|
||||||
<p className="p-4 text-center text-[10px] text-foreground-faint">No objects found</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
title={up ? 'Online' : up === false ? 'Offline' : 'Unknown'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="line-clamp-2 text-[9px] leading-snug text-foreground-muted">{src.description}</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<Badge variant="default">{src.host}:{src.port}</Badge>
|
||||||
|
{src.cdc && <Badge variant="accent">CDC</Badge>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sample data grid */}
|
{/* Main panel — full width */}
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
<div className="panel flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
|
<div
|
||||||
<span className="text-[10px] font-semibold text-foreground">
|
className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-2.5"
|
||||||
{selectedObject ? (
|
style={{ backgroundColor: `${dbBrandColor(active)}12` }}
|
||||||
<>Sample: <span className="font-mono text-docker">{selectedObject.fqn}</span></>
|
>
|
||||||
) : 'Select an object'}
|
<div>
|
||||||
</span>
|
<h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
{sampleLoading && <Loader2 className="h-3 w-3 animate-spin" />}
|
<DbBrandIcon engine={active} size={18} />
|
||||||
</div>
|
{meta.label}
|
||||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-auto p-2">
|
{catalog?.version && (
|
||||||
{sample?.ok && sample.columns && (
|
<span className="font-mono text-[10px] font-normal text-foreground-muted">v{catalog.version.split(' ')[0]?.slice(0, 20)}</span>
|
||||||
<>
|
)}
|
||||||
<p className="mb-1 font-mono text-[9px] text-foreground-faint">
|
</h2>
|
||||||
{sample.row_count} rows · {sample.elapsed_ms}ms
|
<p className="font-mono text-[10px] text-foreground-muted">
|
||||||
</p>
|
{meta.host}:{meta.port} · {meta.database} · container {meta.container}
|
||||||
<table className="w-full text-left font-mono text-[10px]">
|
</p>
|
||||||
<thead>
|
</div>
|
||||||
<tr className="sticky top-0 border-b border-border bg-surface-raised text-docker">
|
<div className="flex flex-wrap gap-1">
|
||||||
{sample.columns.map((c) => <th key={c} className="px-2 py-1">{c}</th>)}
|
{visibleSubTabs.map(({ id, label, icon: Icon }) => (
|
||||||
</tr>
|
<button
|
||||||
</thead>
|
key={id}
|
||||||
<tbody>
|
type="button"
|
||||||
{sample.rows?.map((row, i) => (
|
onClick={() => setSubTab(id)}
|
||||||
<tr key={i} className="border-b border-border/30 hover:bg-white/5">
|
className={cn('inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-[10px] font-medium', subTab === id ? subTabActive : subTabIdle)}
|
||||||
{row.map((cell, j) => (
|
>
|
||||||
<td key={j} className="max-w-[200px] truncate px-2 py-1 text-foreground-muted">
|
<Icon className="h-3 w-3" /> {label}
|
||||||
{cell === null || cell === undefined ? 'NULL' : String(cell)}
|
</button>
|
||||||
</td>
|
))}
|
||||||
))}
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
))}
|
|
||||||
</tbody>
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
</table>
|
{subTab === 'browser' && (
|
||||||
</>
|
<div className="flex h-full min-h-0 flex-1 overflow-hidden">
|
||||||
)}
|
<div className="flex w-[260px] shrink-0 flex-col border-r border-border/60">
|
||||||
{sample && !sample.ok && (
|
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
|
||||||
<p className="p-4 text-[11px] text-danger">{sample.error || 'Failed to load sample'}</p>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-foreground-muted">Objects</span>
|
||||||
)}
|
{catalogLoading && <Loader2 className="h-3 w-3 animate-spin text-foreground-muted" />}
|
||||||
{!selectedObject && !sampleLoading && (
|
</div>
|
||||||
<p className="py-8 text-center text-[11px] text-foreground-faint">Select a table, collection or label to preview data</p>
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-1.5">
|
||||||
)}
|
{catalog?.objects?.map((obj) => (
|
||||||
</div>
|
<button
|
||||||
|
key={obj.fqn}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedObject(obj)}
|
||||||
|
className={cn(
|
||||||
|
'mb-0.5 flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-[10px] transition-colors',
|
||||||
|
selectedObject?.fqn === obj.fqn ? subTabActive : 'hover:bg-surface-overlay',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{obj.type === 'node_label' ? (
|
||||||
|
<Activity className="h-3 w-3 shrink-0" style={{ color: dbBrandColor('neo4j') }} />
|
||||||
|
) : (
|
||||||
|
<Table2 className="h-3 w-3 shrink-0 text-foreground-muted" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium text-foreground">{obj.name}</p>
|
||||||
|
<p className="truncate font-mono text-[8px] text-foreground-muted">{obj.schema}{obj.type === 'relationship' ? ' · rel' : ''}</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 font-mono text-[9px] text-foreground-muted">{fmtCount(obj.row_count)}</span>
|
||||||
|
<ChevronRight className="h-3 w-3 shrink-0 text-foreground-muted" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!catalogLoading && !catalog?.objects?.length && (
|
||||||
|
<p className="p-4 text-center text-[10px] text-foreground-muted">No objects found</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{subTab === 'graph' && active === 'neo4j' && (
|
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<Neo4jGraphView />
|
<div className="flex shrink-0 items-center border-b border-border/60 px-3 py-2">
|
||||||
)}
|
<span className="text-[10px] font-semibold text-foreground">
|
||||||
|
{selectedObject ? (
|
||||||
|
<>Data: <span className="font-mono text-docker">{selectedObject.fqn}</span></>
|
||||||
|
) : 'Select an object'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<DataBrowserGrid
|
||||||
|
engine={active}
|
||||||
|
objectFqn={selectedObject?.fqn || ''}
|
||||||
|
sample={selectedObject ? sample : null}
|
||||||
|
loading={sampleLoading}
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(ps) => { setPageSize(ps); setPage(1) }}
|
||||||
|
onReload={() => selectedObject && loadSample(active, selectedObject, page, pageSize)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{subTab === 'console' && (
|
{subTab === 'graph' && active === 'neo4j' && <Neo4jGraphView />}
|
||||||
<SqlWorkbench engine={active} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{subTab === 'shell' && (
|
{subTab === 'console' && <SqlWorkbench engine={active} />}
|
||||||
<DbShell initialCommand={meta.shellCommand} />
|
|
||||||
)}
|
{subTab === 'workbench' && (
|
||||||
</div>
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
<LakehouseWorkbench lockedCatalog={ENGINE_CATALOG[active]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subTab === 'generate' && onPulse && (
|
||||||
|
<DataGenPanel embedded focusSource={active} onPulse={onPulse} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subTab === 'shell' && <DbShell initialCommand={meta.shellCommand} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ChevronRight,
|
||||||
|
FolderTree,
|
||||||
|
GitBranch,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
Server,
|
||||||
|
Table2,
|
||||||
|
TerminalSquare,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Badge } from '../ui/Badge'
|
||||||
|
import { DataBrowserGrid } from './DataBrowserGrid'
|
||||||
|
import { HdfsView } from './HdfsView'
|
||||||
|
import { SparkView } from './SparkView'
|
||||||
|
import { SqlWorkbench } from './SqlWorkbench'
|
||||||
|
import type { CatalogObject } from '../../lib/dataSourceCatalog'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||||
|
import { runDataflowMovement, triggerStreamingPipeline } from '../../lib/api'
|
||||||
|
|
||||||
|
type HadoopSubTab = 'browser' | 'files' | 'console' | 'spark' | 'pipeline'
|
||||||
|
type CatalogResponse = { engine: string; version?: string; objects: CatalogObject[] }
|
||||||
|
type SampleResponse = {
|
||||||
|
ok: boolean
|
||||||
|
columns?: string[]
|
||||||
|
rows?: unknown[][]
|
||||||
|
error?: string
|
||||||
|
total_count?: number | null
|
||||||
|
offset?: number
|
||||||
|
limit?: number
|
||||||
|
editable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUB_TABS: { id: HadoopSubTab; label: string; icon: typeof FolderTree }[] = [
|
||||||
|
{ id: 'browser', label: 'Tables', icon: Table2 },
|
||||||
|
{ id: 'files', label: 'HDFS Files', icon: FolderTree },
|
||||||
|
{ id: 'console', label: 'Query Console', icon: TerminalSquare },
|
||||||
|
{ id: 'spark', label: 'Spark', icon: Activity },
|
||||||
|
{ id: 'pipeline', label: 'Pipeline', icon: GitBranch },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function HadoopSourcesView({ onPulse }: { onPulse?: () => void }) {
|
||||||
|
const [subTab, setSubTab] = useState<HadoopSubTab>('browser')
|
||||||
|
const [health, setHealth] = useState<{ ok?: boolean; error?: string } | null>(null)
|
||||||
|
const [catalog, setCatalog] = useState<CatalogResponse | null>(null)
|
||||||
|
const [catalogLoading, setCatalogLoading] = useState(false)
|
||||||
|
const [selectedObject, setSelectedObject] = useState<CatalogObject | null>(null)
|
||||||
|
const [sample, setSample] = useState<SampleResponse | null>(null)
|
||||||
|
const [sampleLoading, setSampleLoading] = useState(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(100)
|
||||||
|
const [pipelineBusy, setPipelineBusy] = useState<string | null>(null)
|
||||||
|
const [pipelineMsg, setPipelineMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadHealth = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sql/health/hadoop')
|
||||||
|
if (r.ok) setHealth(await r.json())
|
||||||
|
} catch { /* */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadCatalog = useCallback(async () => {
|
||||||
|
setCatalogLoading(true)
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sql/catalog/hadoop')
|
||||||
|
if (r.ok) {
|
||||||
|
const j: CatalogResponse = await r.json()
|
||||||
|
setCatalog(j)
|
||||||
|
const first = j.objects?.[0]
|
||||||
|
if (first) setSelectedObject(first)
|
||||||
|
}
|
||||||
|
} catch { /* */ } finally {
|
||||||
|
setCatalogLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadSample = useCallback(async (obj: CatalogObject, pg = page, ps = pageSize) => {
|
||||||
|
setSampleLoading(true)
|
||||||
|
const offset = (pg - 1) * ps
|
||||||
|
try {
|
||||||
|
const r = await fetch(
|
||||||
|
`/api/sql/sample/hadoop?object=${encodeURIComponent(obj.fqn)}&limit=${ps}&offset=${offset}`,
|
||||||
|
)
|
||||||
|
setSample(await r.json())
|
||||||
|
} catch {
|
||||||
|
setSample({ ok: false, error: 'Sample unavailable' })
|
||||||
|
} finally {
|
||||||
|
setSampleLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, pageSize])
|
||||||
|
|
||||||
|
useEffect(() => { loadHealth() }, [loadHealth])
|
||||||
|
useEffect(() => {
|
||||||
|
if (subTab === 'browser') loadCatalog()
|
||||||
|
}, [subTab, loadCatalog])
|
||||||
|
useEffect(() => { setPage(1) }, [selectedObject?.fqn])
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedObject && subTab === 'browser') loadSample(selectedObject, page, pageSize)
|
||||||
|
}, [selectedObject, subTab, page, pageSize, loadSample])
|
||||||
|
|
||||||
|
const refreshAll = () => {
|
||||||
|
loadHealth()
|
||||||
|
if (subTab === 'browser') {
|
||||||
|
loadCatalog()
|
||||||
|
if (selectedObject) loadSample(selectedObject, page, pageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runPipeline = async (kind: 'full' | 'hdfs_kafka' | 'spark_s3') => {
|
||||||
|
setPipelineBusy(kind)
|
||||||
|
setPipelineMsg(null)
|
||||||
|
try {
|
||||||
|
if (kind === 'full') {
|
||||||
|
const r = await triggerStreamingPipeline('hadoop-lake')
|
||||||
|
const j = await r.json()
|
||||||
|
setPipelineMsg(j.ok ? `✓ Pipeline started: ${j.steps?.join(' → ') || 'ok'}` : j.error || 'Failed')
|
||||||
|
} else if (kind === 'hdfs_kafka') {
|
||||||
|
const r = await fetch('/api/pipeline/streaming/hdfs/to-kafka', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ source: 'trino', table: 'iceberg.hadoop.historical_sales_hdfs', topic: 'hdfs.historical.sales', limit: 2000 }),
|
||||||
|
})
|
||||||
|
const j = await r.json()
|
||||||
|
setPipelineMsg(j.ok ? `✓ ${j.rows_sent} rows → Kafka topic ${j.topic}` : j.error || 'Failed')
|
||||||
|
} else {
|
||||||
|
await runDataflowMovement('spark_to_s3')
|
||||||
|
setPipelineMsg('✓ Spark → S3 job triggered')
|
||||||
|
}
|
||||||
|
onPulse?.()
|
||||||
|
} catch {
|
||||||
|
setPipelineMsg('Pipeline failed')
|
||||||
|
} finally {
|
||||||
|
setPipelineBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col gap-2 p-3 pt-1">
|
||||||
|
<header className="panel flex shrink-0 flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||||
|
<Server className="h-5 w-5 text-emerald-400" />
|
||||||
|
Hadoop Data Lake
|
||||||
|
</h1>
|
||||||
|
<p className="text-[11px] text-foreground-muted">
|
||||||
|
HDFS · Hive · Iceberg tables · Spark transforms · Kafka bridge → S3
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={health?.ok ? 'success' : 'warning'}>
|
||||||
|
{health?.ok ? 'Hadoop online' : health?.error?.slice(0, 40) || 'Checking…'}
|
||||||
|
</Badge>
|
||||||
|
<button type="button" onClick={refreshAll} className={cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="panel flex shrink-0 gap-1 px-2 py-1.5">
|
||||||
|
{SUB_TABS.map(({ id, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSubTab(id)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[10px] font-medium',
|
||||||
|
subTab === id ? subTabActive : subTabIdle,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" /> {label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subTab === 'browser' && (
|
||||||
|
<div className="panel flex min-h-0 flex-1 gap-0 overflow-hidden">
|
||||||
|
<aside className="scrollbar-thin w-52 shrink-0 overflow-y-auto border-r border-border p-2">
|
||||||
|
<p className="mb-1 text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">
|
||||||
|
{catalogLoading ? 'Loading…' : `${catalog?.objects?.length ?? 0} objects`}
|
||||||
|
</p>
|
||||||
|
{catalog?.objects?.map((obj) => (
|
||||||
|
<button
|
||||||
|
key={obj.fqn}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedObject(obj)}
|
||||||
|
className={cn(
|
||||||
|
'mb-0.5 flex w-full items-center gap-1 rounded px-2 py-1.5 text-left text-[10px] transition-colors',
|
||||||
|
selectedObject?.fqn === obj.fqn ? 'bg-emerald-500/15 text-emerald-200' : 'hover:bg-surface-overlay text-foreground-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
<span className="block truncate font-medium text-foreground">{obj.name}</span>
|
||||||
|
<span className="block truncate text-[8px] text-foreground-faint">{obj.schema}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-2">
|
||||||
|
{selectedObject ? (
|
||||||
|
<DataBrowserGrid
|
||||||
|
engine="hadoop"
|
||||||
|
objectFqn={selectedObject.fqn}
|
||||||
|
sample={sample}
|
||||||
|
loading={sampleLoading}
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(ps) => { setPageSize(ps); setPage(1) }}
|
||||||
|
onReload={() => selectedObject && loadSample(selectedObject, page, pageSize)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="py-8 text-center text-sm text-foreground-muted">Select a table or HDFS path</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subTab === 'files' && <HdfsView />}
|
||||||
|
{subTab === 'console' && <SqlWorkbench engine="hadoop" />}
|
||||||
|
{subTab === 'spark' && <SparkView embedded />}
|
||||||
|
{subTab === 'pipeline' && (
|
||||||
|
<div className="panel flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-1 text-sm font-semibold text-foreground">Hadoop → Kafka → Spark → S3</h3>
|
||||||
|
<p className="text-[11px] text-foreground-muted">
|
||||||
|
Full lakehouse pipeline: export HDFS data to Kafka, Spark transforms into Iceberg/S3 curated layer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<PipelineCard
|
||||||
|
title="1. HDFS → Kafka"
|
||||||
|
desc="Export iceberg.hadoop.historical_sales_hdfs to Kafka topic hdfs.historical.sales"
|
||||||
|
busy={pipelineBusy === 'hdfs_kafka'}
|
||||||
|
onRun={() => runPipeline('hdfs_kafka')}
|
||||||
|
/>
|
||||||
|
<PipelineCard
|
||||||
|
title="2. Spark transform"
|
||||||
|
desc="Run mask_to_curated / hadoop_to_trino via Airflow on Spark cluster"
|
||||||
|
busy={pipelineBusy === 'spark_s3'}
|
||||||
|
onRun={() => runPipeline('spark_s3')}
|
||||||
|
/>
|
||||||
|
<PipelineCard
|
||||||
|
title="Full pipeline"
|
||||||
|
desc="HDFS → Kafka → Spark → Iceberg → S3 in one orchestrated run"
|
||||||
|
busy={pipelineBusy === 'full'}
|
||||||
|
onRun={() => runPipeline('full')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{pipelineMsg && <p className="text-[11px] text-docker">{pipelineMsg}</p>}
|
||||||
|
<div className="rounded-lg border border-border bg-surface-overlay/30 p-3 font-mono text-[10px] text-foreground-muted">
|
||||||
|
hdfs:/data/historical/sales_orders → Kafka:hdfs.historical.sales → Spark → iceberg.hadoop → s3://data/hadoop/
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PipelineCard({ title, desc, busy, onRun }: { title: string; desc: string; busy: boolean; onRun: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface-overlay/20 p-3">
|
||||||
|
<h4 className="mb-1 text-[11px] font-semibold text-foreground">{title}</h4>
|
||||||
|
<p className="mb-2 min-h-[2.5rem] text-[10px] text-foreground-muted">{desc}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onRun}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-emerald-400/50 bg-emerald-500/15 px-3 py-1.5 text-[10px] font-medium text-emerald-200 hover:bg-emerald-500/25 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { LayoutDashboard, Presentation } from 'lucide-react'
|
||||||
|
import { PlatformTopology } from './PlatformTopology'
|
||||||
|
import { PresentationView } from './PresentationView'
|
||||||
|
import type { AgentAnim, WorkloadData } from '../../types'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||||
|
|
||||||
|
type PlatformTab = 'topology' | 'presentation'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
workload: WorkloadData | null
|
||||||
|
animations: Record<string, AgentAnim>
|
||||||
|
selectedNodeId: string | null
|
||||||
|
onNodeClick: (nodeId: string) => void
|
||||||
|
pulse: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: { id: PlatformTab; label: string; icon: typeof LayoutDashboard }[] = [
|
||||||
|
{ id: 'topology', label: 'Topology', icon: LayoutDashboard },
|
||||||
|
{ id: 'presentation', label: 'Presentation', icon: Presentation },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function PlatformView({ workload, animations, selectedNodeId, onNodeClick, pulse }: Props) {
|
||||||
|
const [tab, setTab] = useState<PlatformTab>('topology')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="panel flex shrink-0 items-center gap-1 px-2 py-1.5">
|
||||||
|
{TABS.map(({ id, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(id)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[10px] font-medium transition-all',
|
||||||
|
tab === id ? subTabActive : subTabIdle,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{tab === 'presentation' && (
|
||||||
|
<span className="ml-auto text-[9px] text-foreground-muted">
|
||||||
|
Live cluster deck · all running services
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden pt-1">
|
||||||
|
{tab === 'topology' ? (
|
||||||
|
<PlatformTopology
|
||||||
|
workload={workload}
|
||||||
|
animations={animations}
|
||||||
|
selectedNodeId={selectedNodeId}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
pulse={pulse}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PresentationView embedded />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import {
|
import {
|
||||||
|
Expand,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileUp,
|
FileUp,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
|
Maximize2,
|
||||||
Monitor,
|
Monitor,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -17,6 +20,136 @@ import { cn } from '../../lib/utils'
|
|||||||
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||||
|
|
||||||
type DeckSource = 'live' | 'data-maturity' | 'atc-platform' | string
|
type DeckSource = 'live' | 'data-maturity' | 'atc-platform' | string
|
||||||
|
type SlideVariant = 'embedded' | 'normal' | 'present'
|
||||||
|
type PresentMode = 'popup' | 'fullscreen' | null
|
||||||
|
|
||||||
|
function SlidePanel({
|
||||||
|
slide,
|
||||||
|
slideIdx,
|
||||||
|
slideCount,
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
slide: PresentationSlide
|
||||||
|
slideIdx: number
|
||||||
|
slideCount: number
|
||||||
|
variant: SlideVariant
|
||||||
|
}) {
|
||||||
|
const isPresent = variant === 'present'
|
||||||
|
const isEmbedded = variant === 'embedded'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('mx-auto w-full', isPresent ? 'max-w-6xl' : isEmbedded ? 'max-w-full' : 'max-w-5xl')}>
|
||||||
|
<div className={cn('flex', isPresent ? 'gap-8 lg:gap-12' : isEmbedded ? 'gap-4' : 'gap-4 md:gap-6')}>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={cn(
|
||||||
|
'mb-1 font-medium uppercase tracking-widest text-docker/80',
|
||||||
|
isPresent ? 'text-xs md:text-sm' : isEmbedded ? 'text-[9px]' : 'text-[10px]',
|
||||||
|
)}>
|
||||||
|
{slide.kind || 'slide'} · {slideIdx + 1}/{slideCount}
|
||||||
|
</p>
|
||||||
|
<h1 className={cn(
|
||||||
|
'font-bold tracking-tight text-foreground',
|
||||||
|
isPresent ? 'mb-3 text-3xl leading-tight md:text-5xl lg:text-6xl'
|
||||||
|
: isEmbedded ? 'mb-1 text-lg leading-tight'
|
||||||
|
: 'mb-2 text-2xl md:text-4xl',
|
||||||
|
)}>
|
||||||
|
{slide.title}
|
||||||
|
</h1>
|
||||||
|
{slide.subtitle && (
|
||||||
|
<p className={cn(
|
||||||
|
'text-foreground-muted',
|
||||||
|
isPresent ? 'mb-6 text-lg md:text-2xl' : isEmbedded ? 'mb-2 text-xs' : 'mb-4 text-sm md:text-base',
|
||||||
|
)}>
|
||||||
|
{slide.subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{'animation' in slide && slide.animation && (
|
||||||
|
<ArchitectureDiagram
|
||||||
|
animation={String(slide.animation)}
|
||||||
|
compact={isEmbedded}
|
||||||
|
present={isPresent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ul className={cn(
|
||||||
|
'leading-relaxed text-foreground',
|
||||||
|
isPresent ? 'space-y-3 text-lg md:text-xl lg:text-2xl'
|
||||||
|
: isEmbedded ? 'space-y-1 text-xs'
|
||||||
|
: 'space-y-2 text-sm md:text-base',
|
||||||
|
)}>
|
||||||
|
{(slide.bullets || []).map((b: string, bi: number) => (
|
||||||
|
<li key={bi} className="flex gap-2"><span className="shrink-0 text-docker">▸</span><span>{b}</span></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{slide.image && !isEmbedded && (
|
||||||
|
<div className={cn('hidden shrink-0 items-start', isPresent ? 'lg:flex' : 'md:flex')}>
|
||||||
|
<img
|
||||||
|
src={slide.image}
|
||||||
|
alt=""
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-border object-contain shadow-lg',
|
||||||
|
isPresent ? 'max-h-[55vh] max-w-[38vw]' : 'max-h-[46vh] max-w-[40vw]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{slide.image && isPresent && (
|
||||||
|
<div className="mt-6 lg:hidden">
|
||||||
|
<img src={slide.image} alt="" className="max-h-[35vh] w-full rounded-lg border border-border object-contain shadow-lg" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{slide.image && !isPresent && (
|
||||||
|
<div className={cn('mt-3', isEmbedded ? '' : 'md:hidden')}>
|
||||||
|
<img
|
||||||
|
src={slide.image}
|
||||||
|
alt=""
|
||||||
|
className={cn('rounded-lg border border-border object-contain', isEmbedded ? 'max-h-[22vh] w-full' : 'max-h-[30vh]')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlideNav({
|
||||||
|
slideIdx,
|
||||||
|
slideCount,
|
||||||
|
embedded,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
onGo,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
slideIdx: number
|
||||||
|
slideCount: number
|
||||||
|
embedded?: boolean
|
||||||
|
onPrev: () => void
|
||||||
|
onNext: () => void
|
||||||
|
onGo?: (idx: number) => void
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex shrink-0 items-center gap-2 border-t border-border bg-surface-raised/90 px-3 py-1.5', className)}>
|
||||||
|
<button type="button" disabled={slideIdx === 0} onClick={onPrev} className="rounded border border-border px-2 py-1 text-[10px] disabled:opacity-40 md:text-xs">← Prev</button>
|
||||||
|
{embedded ? (
|
||||||
|
<span className="flex-1 text-center font-mono text-[10px] text-foreground-muted md:text-xs">{slideIdx + 1} / {slideCount}</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 flex-wrap justify-center gap-1">
|
||||||
|
{Array.from({ length: slideCount }, (_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onGo?.(i)}
|
||||||
|
className={cn('h-2 w-2 rounded-full', i === slideIdx ? 'scale-125 bg-docker' : 'bg-border')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" disabled={slideIdx >= slideCount - 1} onClick={onNext} className="rounded border border-border px-2 py-1 text-[10px] disabled:opacity-40 md:text-xs">Next →</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const KIND_STYLES: Record<string, string> = {
|
const KIND_STYLES: Record<string, string> = {
|
||||||
hero: 'from-blue-600/25 via-violet-600/20 to-emerald-600/15',
|
hero: 'from-blue-600/25 via-violet-600/20 to-emerald-600/15',
|
||||||
@@ -47,7 +180,7 @@ async function fetchDeck(id: DeckSource): Promise<PresentationData | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PresentationView() {
|
export function PresentationView({ embedded = false }: { embedded?: boolean }) {
|
||||||
const [source, setSource] = useState<DeckSource>('live')
|
const [source, setSource] = useState<DeckSource>('live')
|
||||||
const [data, setData] = useState<PresentationData | null>(null)
|
const [data, setData] = useState<PresentationData | null>(null)
|
||||||
const [slideIdx, setSlideIdx] = useState(0)
|
const [slideIdx, setSlideIdx] = useState(0)
|
||||||
@@ -63,8 +196,14 @@ export function PresentationView() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [imgBusy, setImgBusy] = useState(false)
|
const [imgBusy, setImgBusy] = useState(false)
|
||||||
const imgInputRef = useRef<HTMLInputElement>(null)
|
const imgInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const presentRef = useRef<HTMLDivElement>(null)
|
||||||
|
const suppressFsPopup = useRef(false)
|
||||||
|
|
||||||
|
const [presentMode, setPresentMode] = useState<PresentMode>(null)
|
||||||
|
|
||||||
const isCustom = customDecks.some((d) => d.id === source)
|
const isCustom = customDecks.some((d) => d.id === source)
|
||||||
|
const canEditInPlace = source === 'live' || isCustom
|
||||||
|
const liveEdited = source === 'live' && Boolean(data?.edited)
|
||||||
|
|
||||||
const refreshDeckList = useCallback(async () => {
|
const refreshDeckList = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -105,20 +244,94 @@ export function PresentationView() {
|
|||||||
refreshDeckList()
|
refreshDeckList()
|
||||||
}, [source, load, refreshDeckList])
|
}, [source, load, refreshDeckList])
|
||||||
|
|
||||||
|
const slides = data?.slides || []
|
||||||
|
const slide: PresentationSlide | undefined = slides[slideIdx]
|
||||||
|
|
||||||
|
const closePresent = useCallback(() => {
|
||||||
|
suppressFsPopup.current = true
|
||||||
|
setPresentMode(null)
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
void document.exitFullscreen()
|
||||||
|
}
|
||||||
|
window.setTimeout(() => { suppressFsPopup.current = false }, 0)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openPresent = useCallback((mode: 'popup' | 'fullscreen') => {
|
||||||
|
if (!data?.slides?.length) return
|
||||||
|
setPresentMode(mode)
|
||||||
|
}, [data?.slides?.length])
|
||||||
|
|
||||||
|
const enterBrowserFullscreen = useCallback(async () => {
|
||||||
|
const el = presentRef.current
|
||||||
|
if (!el) return
|
||||||
|
try {
|
||||||
|
await el.requestFullscreen()
|
||||||
|
setPresentMode('fullscreen')
|
||||||
|
} catch {
|
||||||
|
setPresentMode('popup')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editing) return
|
if (editing || presentMode) return
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
const n = data?.slides.length || 1
|
const n = data?.slides.length || 1
|
||||||
if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); setSlideIdx((i) => Math.min(n - 1, i + 1)) }
|
if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); setSlideIdx((i) => Math.min(n - 1, i + 1)) }
|
||||||
if (e.key === 'ArrowLeft') setSlideIdx((i) => Math.max(0, i - 1))
|
if (e.key === 'ArrowLeft') setSlideIdx((i) => Math.max(0, i - 1))
|
||||||
if (e.key === 'f' || e.key === 'F') document.documentElement.requestFullscreen?.()
|
if ((e.key === 'f' || e.key === 'F') && data?.slides?.length) openPresent('fullscreen')
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [data?.slides.length, editing])
|
}, [data?.slides.length, editing, presentMode, openPresent])
|
||||||
|
|
||||||
const slides = data?.slides || []
|
useEffect(() => {
|
||||||
const slide: PresentationSlide | undefined = slides[slideIdx]
|
if (presentMode !== 'fullscreen') return
|
||||||
|
const id = window.requestAnimationFrame(() => { void enterBrowserFullscreen() })
|
||||||
|
return () => window.cancelAnimationFrame(id)
|
||||||
|
}, [presentMode, enterBrowserFullscreen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onFsChange = () => {
|
||||||
|
if (!document.fullscreenElement && !suppressFsPopup.current) {
|
||||||
|
setPresentMode((mode) => (mode === 'fullscreen' ? 'popup' : mode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('fullscreenchange', onFsChange)
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onFsChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!presentMode) return
|
||||||
|
const prev = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const n = slides.length || 1
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
closePresent()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowRight' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
setSlideIdx((i) => Math.min(n - 1, i + 1))
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowLeft') setSlideIdx((i) => Math.max(0, i - 1))
|
||||||
|
if (e.key === 'f' || e.key === 'F') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
void document.exitFullscreen()
|
||||||
|
setPresentMode('popup')
|
||||||
|
} else {
|
||||||
|
void enterBrowserFullscreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [presentMode, slides.length, closePresent, enterBrowserFullscreen])
|
||||||
|
|
||||||
const editIdx = editing ? slideIdx : null
|
const editIdx = editing ? slideIdx : null
|
||||||
const editSlides = draft?.slides || []
|
const editSlides = draft?.slides || []
|
||||||
@@ -221,6 +434,26 @@ export function PresentationView() {
|
|||||||
setDraft(null)
|
setDraft(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetLive = async () => {
|
||||||
|
if (!window.confirm('Reset Live Cluster to a fresh cluster snapshot? Your edits will be lost.')) return
|
||||||
|
setUploadMsg(null)
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/presentation/live/reset', { method: 'POST' })
|
||||||
|
const j = await r.json()
|
||||||
|
if (j.ok && j.deck) {
|
||||||
|
setData(j.deck)
|
||||||
|
setSlideIdx(0)
|
||||||
|
setEditing(false)
|
||||||
|
setDraft(null)
|
||||||
|
setUploadMsg('✓ Reset to cluster snapshot')
|
||||||
|
} else {
|
||||||
|
setUploadMsg(j.error || 'Reset failed')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUploadMsg('Reset failed — check connection')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const patchSlide = (idx: number, patch: Partial<PresentationSlide>) => {
|
const patchSlide = (idx: number, patch: Partial<PresentationSlide>) => {
|
||||||
setDraft((d) => {
|
setDraft((d) => {
|
||||||
if (!d) return d
|
if (!d) return d
|
||||||
@@ -279,74 +512,174 @@ export function PresentationView() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col overflow-hidden rounded-lg border border-border bg-surface-raised">
|
<>
|
||||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border bg-surface-raised/90 px-3 py-2">
|
<div className={cn(
|
||||||
<div>
|
'flex min-h-0 flex-col overflow-hidden bg-surface-raised',
|
||||||
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-foreground">Presentation</h2>
|
embedded ? 'h-full' : 'h-full min-h-[calc(100vh-140px)] rounded-lg border border-border',
|
||||||
<p className="text-[9px] text-foreground-muted">
|
)}>
|
||||||
Live cluster · HTML templates · PPT upload · editable decks with text & photos
|
{embedded ? (
|
||||||
</p>
|
<div className="flex shrink-0 flex-col gap-1 border-b border-border bg-surface-raised/90 px-2 py-1.5">
|
||||||
</div>
|
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||||
<div className="flex flex-wrap gap-1">
|
{!editing && (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => newDeck(false)} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
||||||
|
<Plus className="h-3 w-3" /> New
|
||||||
|
</button>
|
||||||
|
{canEditInPlace ? (
|
||||||
|
<button type="button" onClick={startEdit} className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px] text-docker hover:bg-surface-overlay">
|
||||||
|
<Pencil className="h-3 w-3" /> Edit
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => newDeck(true)} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
||||||
|
<Pencil className="h-3 w-3" /> Edit a copy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{liveEdited && (
|
||||||
|
<button type="button" onClick={resetLive} className="rounded border border-border px-2 py-0.5 text-[9px] text-foreground-muted hover:bg-surface-overlay">
|
||||||
|
Reset snapshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<a href="/dq/" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px]', subTabIdle)}>
|
||||||
|
<Monitor className="h-3 w-3" /> DQ Portal
|
||||||
|
</a>
|
||||||
|
<a href="/docling/ui/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
||||||
|
<ExternalLink className="h-3 w-3" /> Docling
|
||||||
|
</a>
|
||||||
|
<button type="button" onClick={() => load(source)} className="rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">Refresh</button>
|
||||||
|
{!loading && slides.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => openPresent('popup')} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
||||||
|
<Maximize2 className="h-3 w-3" /> Popup
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => openPresent('fullscreen')} className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px]', subTabIdle)}>
|
||||||
|
<Expand className="h-3 w-3" /> Fullscreen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={exportHtml} className={cn('rounded-md px-2 py-1 text-[9px]', subTabActive)}>Export HTML</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-center rounded bg-docker/15 px-2 py-0.5 text-[9px] font-medium text-docker">Editing</span>
|
||||||
|
<button type="button" onClick={cancelEdit} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
||||||
|
<X className="h-3 w-3" /> Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={saveDraft} disabled={saving} className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px]', subTabActive, saving && 'opacity-50')}>
|
||||||
|
<Save className="h-3 w-3" /> {saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{!editing && (
|
{!editing && (
|
||||||
<>
|
<div className="scrollbar-thin flex shrink-0 gap-1 overflow-x-auto pb-0.5">
|
||||||
<button type="button" onClick={() => newDeck(false)} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
{tabs.map((t) => (
|
||||||
<Plus className="h-3 w-3" /> New
|
<button
|
||||||
</button>
|
key={t.id}
|
||||||
{isCustom ? (
|
type="button"
|
||||||
<button type="button" onClick={startEdit} className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px] text-docker hover:bg-surface-overlay">
|
onClick={() => setSource(t.id)}
|
||||||
<Pencil className="h-3 w-3" /> Edit
|
className={cn(
|
||||||
|
'shrink-0 rounded-md px-2 py-1 text-[10px] font-medium transition-all',
|
||||||
|
source === t.id ? subTabActive : subTabIdle,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
))}
|
||||||
<button type="button" onClick={() => newDeck(true)} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
<label className={cn('ml-1 inline-flex shrink-0 cursor-pointer items-center gap-1 rounded-md border border-dashed border-border px-2 py-1 text-[10px]', uploading && 'opacity-50')}>
|
||||||
<Pencil className="h-3 w-3" /> Edit a copy
|
<Upload className="h-3 w-3" />
|
||||||
</button>
|
{uploading ? 'Uploading…' : 'PPT upload'}
|
||||||
)}
|
<input type="file" accept=".ppt,.pptx,.pdf,.docx" className="hidden" disabled={uploading} onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])} />
|
||||||
<a href="/dq/" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px]', subTabIdle)}>
|
</label>
|
||||||
<Monitor className="h-3 w-3" /> DQ Portal
|
</div>
|
||||||
</a>
|
|
||||||
<a href="/docling/ui/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
|
||||||
<ExternalLink className="h-3 w-3" /> Docling
|
|
||||||
</a>
|
|
||||||
<button type="button" onClick={() => load(source)} className="rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">Refresh</button>
|
|
||||||
<button type="button" onClick={exportHtml} className={cn('rounded-md px-2 py-1 text-[9px]', subTabActive)}>Export HTML</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{editing && (
|
|
||||||
<>
|
|
||||||
<span className="inline-flex items-center rounded bg-docker/15 px-2 py-0.5 text-[9px] font-medium text-docker">Editing</span>
|
|
||||||
<button type="button" onClick={cancelEdit} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
|
||||||
<X className="h-3 w-3" /> Cancel
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={saveDraft} disabled={saving} className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px]', subTabActive, saving && 'opacity-50')}>
|
|
||||||
<Save className="h-3 w-3" /> {saving ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border bg-surface-raised/90 px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-foreground">Presentation</h2>
|
||||||
|
<p className="text-[9px] text-foreground-muted">
|
||||||
|
Live cluster · HTML templates · PPT upload · editable decks with text & photos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{!editing && (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => newDeck(false)} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
||||||
|
<Plus className="h-3 w-3" /> New
|
||||||
|
</button>
|
||||||
|
{canEditInPlace ? (
|
||||||
|
<button type="button" onClick={startEdit} className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px] text-docker hover:bg-surface-overlay">
|
||||||
|
<Pencil className="h-3 w-3" /> Edit
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => newDeck(true)} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
||||||
|
<Pencil className="h-3 w-3" /> Edit a copy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{liveEdited && (
|
||||||
|
<button type="button" onClick={resetLive} className="rounded border border-border px-2 py-0.5 text-[9px] text-foreground-muted hover:bg-surface-overlay">
|
||||||
|
Reset snapshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<a href="/dq/" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px]', subTabIdle)}>
|
||||||
|
<Monitor className="h-3 w-3" /> DQ Portal
|
||||||
|
</a>
|
||||||
|
<a href="/docling/ui/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
||||||
|
<ExternalLink className="h-3 w-3" /> Docling
|
||||||
|
</a>
|
||||||
|
<button type="button" onClick={() => load(source)} className="rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">Refresh</button>
|
||||||
|
{!loading && slides.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => openPresent('popup')} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
||||||
|
<Maximize2 className="h-3 w-3" /> Popup
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => openPresent('fullscreen')} className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px]', subTabIdle)}>
|
||||||
|
<Expand className="h-3 w-3" /> Fullscreen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={exportHtml} className={cn('rounded-md px-2 py-1 text-[9px]', subTabActive)}>Export HTML</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-center rounded bg-docker/15 px-2 py-0.5 text-[9px] font-medium text-docker">Editing</span>
|
||||||
|
<button type="button" onClick={cancelEdit} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
||||||
|
<X className="h-3 w-3" /> Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={saveDraft} disabled={saving} className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px]', subTabActive, saving && 'opacity-50')}>
|
||||||
|
<Save className="h-3 w-3" /> {saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!editing && (
|
{!editing && (
|
||||||
<div className="flex shrink-0 flex-wrap gap-1 border-b border-border bg-surface-overlay/40 px-2 py-1.5">
|
<div className="scrollbar-thin flex shrink-0 gap-1 overflow-x-auto border-b border-border bg-surface-overlay/40 px-2 py-1.5">
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSource(t.id)}
|
onClick={() => setSource(t.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md px-2.5 py-1.5 text-[10px] font-medium transition-all',
|
'shrink-0 rounded-md px-2.5 py-1.5 text-[10px] font-medium transition-all',
|
||||||
source === t.id ? subTabActive : subTabIdle,
|
source === t.id ? subTabActive : subTabIdle,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<label className={cn('ml-auto inline-flex cursor-pointer items-center gap-1 rounded-md border border-dashed border-border px-2 py-1 text-[10px]', uploading && 'opacity-50')}>
|
<label className={cn('ml-auto inline-flex shrink-0 cursor-pointer items-center gap-1 rounded-md border border-dashed border-border px-2 py-1 text-[10px]', uploading && 'opacity-50')}>
|
||||||
<Upload className="h-3 w-3" />
|
<Upload className="h-3 w-3" />
|
||||||
{uploading ? 'Uploading…' : 'PPT upload'}
|
{uploading ? 'Uploading…' : 'PPT upload'}
|
||||||
<input type="file" accept=".ppt,.pptx,.pdf,.docx" className="hidden" disabled={uploading} onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])} />
|
<input type="file" accept=".ppt,.pptx,.pdf,.docx" className="hidden" disabled={uploading} onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{uploadMsg && <p className="shrink-0 px-3 py-1 text-[10px] text-docker">{uploadMsg}</p>}
|
{uploadMsg && <p className="shrink-0 px-3 py-1 text-[10px] text-docker">{uploadMsg}</p>}
|
||||||
@@ -493,45 +826,103 @@ export function PresentationView() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ─────────── VIEW MODE ─────────── */
|
/* ─────────── VIEW MODE ─────────── */
|
||||||
<>
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<div className={cn('relative flex min-h-0 flex-1 flex-col justify-center bg-gradient-to-br p-6 md:p-10', KIND_STYLES[slide.kind || 'narrative'] || KIND_STYLES.narrative)}>
|
<div className={cn(
|
||||||
<div className="flex max-w-5xl gap-6">
|
'scrollbar-thin min-h-0 flex-1 overflow-y-auto bg-gradient-to-br',
|
||||||
<div className="min-w-0 flex-1">
|
embedded ? 'p-3' : 'p-6 md:p-10',
|
||||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-widest text-docker/80">{slide.kind || 'slide'} · {slideIdx + 1}/{slides.length}</p>
|
KIND_STYLES[slide.kind || 'narrative'] || KIND_STYLES.narrative,
|
||||||
<h1 className="mb-2 text-2xl font-bold tracking-tight text-foreground md:text-4xl">{slide.title}</h1>
|
)}>
|
||||||
{slide.subtitle && <p className="mb-4 text-sm text-foreground-muted md:text-base">{slide.subtitle}</p>}
|
<SlidePanel slide={slide} slideIdx={slideIdx} slideCount={slides.length} variant={embedded ? 'embedded' : 'normal'} />
|
||||||
{'animation' in slide && slide.animation && (
|
|
||||||
<ArchitectureDiagram animation={String(slide.animation)} />
|
|
||||||
)}
|
|
||||||
<ul className="space-y-2 text-sm leading-relaxed text-foreground md:text-base">
|
|
||||||
{(slide.bullets || []).map((b: string, bi: number) => (
|
|
||||||
<li key={bi} className="flex gap-2"><span className="shrink-0 text-docker">▸</span><span>{b}</span></li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{slide.image && (
|
|
||||||
<div className="hidden shrink-0 items-center md:flex">
|
|
||||||
<img src={slide.image} alt="" className="max-h-[46vh] max-w-[40vw] rounded-lg border border-border object-contain shadow-lg" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{slide.image && (
|
|
||||||
<div className="mt-4 md:hidden">
|
|
||||||
<img src={slide.image} alt="" className="max-h-[30vh] rounded-lg border border-border object-contain" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2 border-t border-border bg-surface-raised/90 px-3 py-2">
|
<SlideNav
|
||||||
<button type="button" disabled={slideIdx === 0} onClick={() => setSlideIdx((i) => Math.max(0, i - 1))} className="rounded border border-border px-2 py-1 text-[10px] disabled:opacity-40">← Prev</button>
|
slideIdx={slideIdx}
|
||||||
<div className="flex flex-1 flex-wrap justify-center gap-1">
|
slideCount={slides.length}
|
||||||
{slides.map((_: PresentationSlide, i: number) => (
|
embedded={embedded}
|
||||||
<button key={i} type="button" onClick={() => setSlideIdx(i)} className={cn('h-2 w-2 rounded-full', i === slideIdx ? 'scale-125 bg-docker' : 'bg-border')} />
|
onPrev={() => setSlideIdx((i) => Math.max(0, i - 1))}
|
||||||
))}
|
onNext={() => setSlideIdx((i) => Math.min(slides.length - 1, i + 1))}
|
||||||
</div>
|
onGo={setSlideIdx}
|
||||||
<button type="button" disabled={slideIdx >= slides.length - 1} onClick={() => setSlideIdx((i) => Math.min(slides.length - 1, i + 1))} className="rounded border border-border px-2 py-1 text-[10px] disabled:opacity-40">Next →</button>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{presentMode && slide && createPortal(
|
||||||
|
<>
|
||||||
|
{presentMode === 'popup' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close presentation"
|
||||||
|
className="fixed inset-0 z-[199] bg-black/80 backdrop-blur-[2px]"
|
||||||
|
onClick={closePresent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={presentRef}
|
||||||
|
className={cn(
|
||||||
|
'fixed z-[200] flex flex-col overflow-hidden bg-surface text-foreground shadow-2xl',
|
||||||
|
presentMode === 'popup'
|
||||||
|
? 'inset-2 rounded-xl border border-border ring-1 ring-white/10 sm:inset-4 md:inset-8 lg:inset-10'
|
||||||
|
: 'inset-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border bg-surface-raised/95 px-4 py-2">
|
||||||
|
<div className="min-w-0 truncate text-xs text-foreground-muted md:text-sm">
|
||||||
|
{data?.title || 'Presentation'} · slide {slideIdx + 1}/{slides.length}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{presentMode === 'popup' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void enterBrowserFullscreen()}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] hover:bg-surface-overlay md:text-xs"
|
||||||
|
>
|
||||||
|
<Expand className="h-3.5 w-3.5" /> Fullscreen
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
suppressFsPopup.current = true
|
||||||
|
if (document.fullscreenElement) void document.exitFullscreen()
|
||||||
|
setPresentMode('popup')
|
||||||
|
window.setTimeout(() => { suppressFsPopup.current = false }, 0)
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] hover:bg-surface-overlay md:text-xs"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3.5 w-3.5" /> Popup
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closePresent}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] hover:bg-surface-overlay md:text-xs"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" /> Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
'scrollbar-thin flex min-h-0 flex-1 flex-col overflow-hidden bg-gradient-to-br',
|
||||||
|
KIND_STYLES[slide.kind || 'narrative'] || KIND_STYLES.narrative,
|
||||||
|
)}>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-8 md:p-12 lg:p-16">
|
||||||
|
<SlidePanel slide={slide} slideIdx={slideIdx} slideCount={slides.length} variant="present" />
|
||||||
|
</div>
|
||||||
|
<SlideNav
|
||||||
|
slideIdx={slideIdx}
|
||||||
|
slideCount={slides.length}
|
||||||
|
onPrev={() => setSlideIdx((i) => Math.max(0, i - 1))}
|
||||||
|
onNext={() => setSlideIdx((i) => Math.min(slides.length - 1, i + 1))}
|
||||||
|
onGo={setSlideIdx}
|
||||||
|
className="border-t border-border/80 bg-surface-raised/95 px-4 py-2.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="pointer-events-none absolute bottom-3 right-4 text-[10px] text-foreground-faint">
|
||||||
|
← → navigate · F toggle fullscreen · Esc close
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Cpu,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { StreamingStatus } from '../../types'
|
||||||
|
import {
|
||||||
|
fetchStreamingStatus,
|
||||||
|
restartKafkaConnector,
|
||||||
|
pauseKafkaConnector,
|
||||||
|
resumeKafkaConnector,
|
||||||
|
triggerStreamingJob,
|
||||||
|
} from '../../lib/api'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
type Tab = 'spark' | 'kafka' | 'jobs'
|
||||||
|
|
||||||
|
export function SparkKafkaPanel({
|
||||||
|
embedded,
|
||||||
|
selectedNodeId,
|
||||||
|
streaming: initialStreaming,
|
||||||
|
onRefreshGraph,
|
||||||
|
}: {
|
||||||
|
embedded?: boolean
|
||||||
|
selectedNodeId?: string | null
|
||||||
|
streaming?: StreamingStatus | null
|
||||||
|
onRefreshGraph?: () => void
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<Tab>('spark')
|
||||||
|
const [streaming, setStreaming] = useState<StreamingStatus | null>(initialStreaming ?? null)
|
||||||
|
const [loading, setLoading] = useState(!initialStreaming)
|
||||||
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
|
const [jobConf, setJobConf] = useState<Record<string, string>>({})
|
||||||
|
const [selectedJob, setSelectedJob] = useState('spark_to_curated')
|
||||||
|
const [showSparkUi, setShowSparkUi] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async (refresh = false) => {
|
||||||
|
setLoading(true)
|
||||||
|
const s = await fetchStreamingStatus(refresh)
|
||||||
|
if (s) setStreaming(s)
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialStreaming) setStreaming(initialStreaming)
|
||||||
|
}, [initialStreaming])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedNodeId === 'spark') setTab('spark')
|
||||||
|
if (selectedNodeId === 'kafka') setTab('kafka')
|
||||||
|
}, [selectedNodeId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const iv = setInterval(() => load(), 8000)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const spark = streaming?.spark
|
||||||
|
const kafka = streaming?.kafka
|
||||||
|
const jobs = streaming?.jobs ?? []
|
||||||
|
|
||||||
|
const onJob = async (jobId: string) => {
|
||||||
|
setBusy(`job:${jobId}`)
|
||||||
|
try {
|
||||||
|
let conf: Record<string, unknown> = {}
|
||||||
|
const raw = jobConf[jobId]
|
||||||
|
if (raw?.trim()) {
|
||||||
|
conf = JSON.parse(raw)
|
||||||
|
}
|
||||||
|
await triggerStreamingJob(jobId, conf)
|
||||||
|
onRefreshGraph?.()
|
||||||
|
await load(true)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConnector = async (name: string, action: 'restart' | 'pause' | 'resume') => {
|
||||||
|
setBusy(`conn:${name}:${action}`)
|
||||||
|
try {
|
||||||
|
if (action === 'restart') await restartKafkaConnector(name)
|
||||||
|
else if (action === 'pause') await pauseKafkaConnector(name)
|
||||||
|
else await resumeKafkaConnector(name)
|
||||||
|
await load(true)
|
||||||
|
onRefreshGraph?.()
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
'flex shrink-0 flex-col border-t border-border bg-surface/80',
|
||||||
|
embedded ? 'max-h-[42vh]' : 'max-h-[48vh]',
|
||||||
|
)}>
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-1.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(['spark', 'kafka', 'jobs'] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-2.5 py-1 text-[10px] font-medium capitalize transition-colors',
|
||||||
|
tab === t
|
||||||
|
? 'bg-docker/20 text-docker ring-1 ring-docker/40'
|
||||||
|
: 'text-foreground-muted hover:bg-surface-overlay hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t === 'jobs' ? 'Run jobs' : t === 'spark' ? 'Spark UI' : 'Kafka'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{spark?.ui_ok && (
|
||||||
|
<span className="hidden text-[9px] text-emerald-300 sm:inline">
|
||||||
|
Spark {spark.alive_workers}w · {spark.cores_used}/{spark.cores} cores
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{kafka?.connect_ok && (
|
||||||
|
<span className="hidden text-[9px] text-cyan-300 sm:inline">
|
||||||
|
{kafka.connectors?.length ?? 0} connectors
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => load(true)}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[9px] text-foreground-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-3 w-3', loading && 'animate-spin')} /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{tab === 'spark' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge ok={spark?.ui_ok} label={spark?.ui_ok ? `Cluster ${spark.status}` : 'Spark UI offline'} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSparkUi((v) => !v)}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay"
|
||||||
|
>
|
||||||
|
<Cpu className="h-3 w-3" /> {showSparkUi ? 'Hide' : 'Embed'} Spark UI
|
||||||
|
</button>
|
||||||
|
<a href="/spark-ui/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[9px] text-docker hover:underline">
|
||||||
|
Open full Spark UI <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSparkUi && (
|
||||||
|
<iframe
|
||||||
|
title="Spark Master UI"
|
||||||
|
src="/spark-ui/"
|
||||||
|
className="h-[280px] w-full rounded-lg border border-border bg-black"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
<Stat label="Workers" value={String(spark?.alive_workers ?? 0)} />
|
||||||
|
<Stat label="Cores used" value={`${spark?.cores_used ?? 0} / ${spark?.cores ?? 0}`} />
|
||||||
|
<Stat label="Memory MB" value={`${spark?.memory_used_mb ?? 0} / ${spark?.memory_mb ?? 0}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(spark?.workers?.length ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-[9px] font-semibold uppercase tracking-wide text-foreground-muted">Workers</p>
|
||||||
|
<div className="overflow-x-auto rounded border border-border">
|
||||||
|
<table className="w-full text-left text-[9px]">
|
||||||
|
<thead className="bg-surface-overlay/60 text-foreground-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1">Host</th>
|
||||||
|
<th className="px-2 py-1">State</th>
|
||||||
|
<th className="px-2 py-1">Cores</th>
|
||||||
|
<th className="px-2 py-1">Memory</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{spark!.workers!.map((w) => (
|
||||||
|
<tr key={w.id} className="border-t border-border/60">
|
||||||
|
<td className="px-2 py-1 font-mono">{w.host}</td>
|
||||||
|
<td className="px-2 py-1 text-emerald-300">{w.state}</td>
|
||||||
|
<td className="px-2 py-1">{w.cores_used}/{w.cores}</td>
|
||||||
|
<td className="px-2 py-1">{w.memory_mb} MB</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-[9px] font-semibold uppercase tracking-wide text-foreground-muted">Active applications</p>
|
||||||
|
{(spark?.active_apps?.length ?? 0) === 0 ? (
|
||||||
|
<p className="text-[10px] text-foreground-muted">No running Spark apps — trigger a job below or start streaming on lake01.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{spark!.active_apps!.map((a) => (
|
||||||
|
<li key={a.id} className="rounded border border-border bg-surface-overlay/40 px-2 py-1 text-[10px]">
|
||||||
|
<span className="font-semibold text-foreground">{a.name}</span>
|
||||||
|
<span className="ml-2 font-mono text-foreground-muted">id={a.id} · {a.cores} cores</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'kafka' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge ok={kafka?.ui_ok} label={kafka?.ui_ok ? `Cluster ${kafka.cluster?.name}` : 'Kafka UI offline'} />
|
||||||
|
<Badge ok={kafka?.connect_ok} label={`${kafka?.connectors?.length ?? 0} Connectors`} />
|
||||||
|
<a href="/kafka-ui/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[9px] text-docker hover:underline">
|
||||||
|
Kafka UI <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-[9px] font-semibold uppercase tracking-wide text-foreground-muted">Debezium connectors</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(kafka?.connectors ?? []).map((c) => {
|
||||||
|
const running = (c.state || '').toUpperCase() === 'RUNNING'
|
||||||
|
const b = busy === `conn:${c.name}:restart` || busy === `conn:${c.name}:pause` || busy === `conn:${c.name}:resume`
|
||||||
|
return (
|
||||||
|
<div key={c.name} className="flex flex-wrap items-center gap-2 rounded border border-border bg-surface-overlay/30 px-2 py-1.5">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-foreground">{c.name}</span>
|
||||||
|
<span className={cn('text-[9px] font-medium', running ? 'text-emerald-300' : 'text-amber-300')}>{c.state}</span>
|
||||||
|
<button type="button" disabled={!!busy} onClick={() => onConnector(c.name, 'restart')} className="inline-flex items-center gap-0.5 rounded border border-border px-1.5 py-0.5 text-[8px] hover:bg-surface-overlay disabled:opacity-50">
|
||||||
|
{b ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RotateCcw className="h-2.5 w-2.5" />} Restart
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!!busy} onClick={() => onConnector(c.name, running ? 'pause' : 'resume')} className="inline-flex items-center gap-0.5 rounded border border-border px-1.5 py-0.5 text-[8px] hover:bg-surface-overlay disabled:opacity-50">
|
||||||
|
{running ? <Pause className="h-2.5 w-2.5" /> : <Play className="h-2.5 w-2.5" />}
|
||||||
|
{running ? 'Pause' : 'Resume'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-[9px] font-semibold uppercase tracking-wide text-foreground-muted">Topics ({kafka?.topics?.length ?? 0})</p>
|
||||||
|
<div className="max-h-40 overflow-y-auto rounded border border-border">
|
||||||
|
<table className="w-full text-left text-[9px]">
|
||||||
|
<thead className="sticky top-0 bg-surface-overlay/90 text-foreground-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1">Topic</th>
|
||||||
|
<th className="px-2 py-1">Partitions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(kafka?.topics ?? []).slice(0, 30).map((t) => (
|
||||||
|
<tr key={t.name} className="border-t border-border/50">
|
||||||
|
<td className="max-w-[200px] truncate px-2 py-0.5 font-mono">{t.name}</td>
|
||||||
|
<td className="px-2 py-0.5">{t.partitions ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'jobs' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[10px] text-foreground-muted">
|
||||||
|
Start lakehouse transforms manually via Airflow. Edit JSON config per job, then run.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{jobs.map((j) => (
|
||||||
|
<button
|
||||||
|
key={j.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedJob(j.id)}
|
||||||
|
className={cn(
|
||||||
|
'rounded border px-2 py-1 text-[9px] font-medium',
|
||||||
|
selectedJob === j.id ? 'border-docker/50 bg-docker/15 text-docker' : 'border-border text-foreground-muted hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{j.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{jobs.filter((j) => j.id === selectedJob).map((j) => (
|
||||||
|
<div key={j.id} className="space-y-2 rounded-lg border border-border bg-surface-overlay/30 p-2">
|
||||||
|
<p className="text-[10px] text-foreground">{j.description}</p>
|
||||||
|
<p className="font-mono text-[9px] text-foreground-muted">DAG: {j.dag_id}</p>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-0.5 block text-[8px] uppercase text-foreground-faint">Job config (JSON, editable)</span>
|
||||||
|
<textarea
|
||||||
|
value={jobConf[j.id] ?? JSON.stringify(j.default_conf ?? {}, null, 2)}
|
||||||
|
onChange={(e) => setJobConf((prev) => ({ ...prev, [j.id]: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
className="w-full rounded border border-border bg-surface px-2 py-1 font-mono text-[10px] text-foreground outline-none focus:border-docker"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === `job:${j.id}`}
|
||||||
|
onClick={() => onJob(j.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-emerald-400/50 bg-emerald-500/15 px-3 py-1.5 text-[10px] font-medium text-emerald-200 hover:bg-emerald-500/25 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy === `job:${j.id}` ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Zap className="h-3.5 w-3.5" />}
|
||||||
|
Start job
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ ok, label }: { ok?: boolean; label: string }) {
|
||||||
|
return (
|
||||||
|
<span className={cn(
|
||||||
|
'inline-flex items-center rounded border px-1.5 py-0.5 text-[9px] font-medium',
|
||||||
|
ok ? 'border-emerald-400/40 bg-emerald-500/15 text-emerald-200' : 'border-amber-400/40 bg-amber-500/15 text-amber-200',
|
||||||
|
)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-border bg-surface-overlay/40 px-2 py-1.5">
|
||||||
|
<p className="text-[8px] uppercase text-foreground-faint">{label}</p>
|
||||||
|
<p className="font-mono text-sm font-semibold text-foreground">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Cpu,
|
||||||
|
Database,
|
||||||
|
ExternalLink,
|
||||||
|
Gauge,
|
||||||
|
HardDrive,
|
||||||
|
Layers,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Save,
|
||||||
|
Server,
|
||||||
|
Square,
|
||||||
|
Table2,
|
||||||
|
Trash2,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { SparkLive, SparkRun, SparkRunStats } from '../../types'
|
||||||
|
import {
|
||||||
|
cancelSparkRun,
|
||||||
|
createSparkRun,
|
||||||
|
fetchSparkCatalogs,
|
||||||
|
fetchSparkColumns,
|
||||||
|
fetchSparkLive,
|
||||||
|
fetchSparkRun,
|
||||||
|
fetchSparkRuns,
|
||||||
|
fetchSparkSchemas,
|
||||||
|
fetchSparkTables,
|
||||||
|
} from '../../lib/api'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||||
|
|
||||||
|
type Tab = 'workbench' | 'cluster' | 'runs' | 'ui'
|
||||||
|
type Operation = 'preview' | 'filter' | 'aggregate' | 'profile' | 'join' | 'sql'
|
||||||
|
type Column = { name: string; type: string }
|
||||||
|
type Metric = { fn: string; col: string; alias?: string }
|
||||||
|
|
||||||
|
const OPERATIONS: { id: Operation; label: string; desc: string }[] = [
|
||||||
|
{ id: 'preview', label: 'Preview', desc: 'Sample rows from a table' },
|
||||||
|
{ id: 'filter', label: 'Filter', desc: 'WHERE predicate on a table' },
|
||||||
|
{ id: 'aggregate', label: 'Aggregate', desc: 'Group by + sum/avg/count/min/max' },
|
||||||
|
{ id: 'profile', label: 'Profile', desc: 'Row count, distinct & non-null per column' },
|
||||||
|
{ id: 'join', label: 'Join', desc: 'Join two tables on keys' },
|
||||||
|
{ id: 'sql', label: 'SQL', desc: 'Run arbitrary distributed SQL' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const AGG_FNS = ['count', 'sum', 'avg', 'min', 'max', 'approx_distinct', 'count_distinct']
|
||||||
|
|
||||||
|
function fmtNum(n?: number | null) {
|
||||||
|
if (n == null) return '—'
|
||||||
|
if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`
|
||||||
|
if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`
|
||||||
|
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
function fmtBytes(n?: number | null) {
|
||||||
|
if (n == null) return '—'
|
||||||
|
let v = n
|
||||||
|
for (const u of ['B', 'KB', 'MB', 'GB', 'TB']) {
|
||||||
|
if (v < 1024) return `${v.toFixed(u === 'B' ? 0 : 1)} ${u}`
|
||||||
|
v /= 1024
|
||||||
|
}
|
||||||
|
return `${v.toFixed(1)} PB`
|
||||||
|
}
|
||||||
|
function fmtMs(n?: number | null) {
|
||||||
|
if (n == null) return '—'
|
||||||
|
if (n < 1000) return `${n} ms`
|
||||||
|
if (n < 60000) return `${(n / 1000).toFixed(1)} s`
|
||||||
|
return `${(n / 60000).toFixed(1)} m`
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_COLOR: Record<string, string> = {
|
||||||
|
QUEUED: 'text-amber-300 bg-amber-500/15',
|
||||||
|
RUNNING: 'text-sky-300 bg-sky-500/15',
|
||||||
|
FINISHED: 'text-emerald-300 bg-emerald-500/15',
|
||||||
|
FAILED: 'text-rose-300 bg-rose-500/15',
|
||||||
|
CANCELED: 'text-foreground-muted bg-surface-overlay',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SparkView({ embedded }: { embedded?: boolean }) {
|
||||||
|
const [tab, setTab] = useState<Tab>('workbench')
|
||||||
|
const [live, setLive] = useState<SparkLive | null>(null)
|
||||||
|
|
||||||
|
const loadLive = useCallback(async () => {
|
||||||
|
const l = await fetchSparkLive()
|
||||||
|
if (l) setLive(l)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLive()
|
||||||
|
const iv = setInterval(loadLive, 4000)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
}, [loadLive])
|
||||||
|
|
||||||
|
const spark = live?.spark
|
||||||
|
const alive = spark?.ui_ok && (spark?.status || '').toUpperCase() === 'ALIVE'
|
||||||
|
const activeRuns = live?.active_runs ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex min-h-0 flex-1 flex-col gap-2', embedded ? 'p-2' : 'p-3')}>
|
||||||
|
<header className="panel flex shrink-0 flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
|
<Zap className="h-4 w-4 text-amber-400" />
|
||||||
|
Spark Lakehouse Workbench
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-foreground-muted">
|
||||||
|
Select data · transform on the distributed engine · materialize to Iceberg / S3 · live cluster metrics
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LiveChip label="Cluster" value={alive ? 'ALIVE' : 'down'} ok={!!alive} />
|
||||||
|
<LiveChip label="Cores" value={`${spark?.cores_used ?? 0}/${spark?.cores ?? 0}`} ok={(spark?.cores_used ?? 0) > 0} />
|
||||||
|
<LiveChip label="Active jobs" value={String(activeRuns.length)} ok={activeRuns.length > 0} />
|
||||||
|
<a href="/spark-ui/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[10px] text-docker hover:bg-docker/10">
|
||||||
|
<ExternalLink className="h-3 w-3" /> Native UI
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="panel flex shrink-0 gap-1 px-2 py-1.5">
|
||||||
|
{(['workbench', 'cluster', 'runs', 'ui'] as Tab[]).map((t) => (
|
||||||
|
<button key={t} type="button" onClick={() => setTab(t)} className={cn('rounded-md px-2.5 py-1 text-[10px] font-medium capitalize', tab === t ? subTabActive : subTabIdle)}>
|
||||||
|
{t === 'ui' ? 'Spark UI' : t}
|
||||||
|
{t === 'runs' && activeRuns.length > 0 && <span className="ml-1 rounded-full bg-sky-500/30 px-1 text-[8px] text-sky-200">{activeRuns.length}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'workbench' && <LakehouseWorkbench />}
|
||||||
|
{tab === 'cluster' && <ClusterPanel live={live} />}
|
||||||
|
{tab === 'runs' && <RunsPanel live={live} />}
|
||||||
|
{tab === 'ui' && (
|
||||||
|
<div className="panel min-h-0 flex-1 overflow-hidden p-1">
|
||||||
|
<iframe title="Spark Master UI" src="/spark-ui/" className="h-full min-h-[420px] w-full rounded-md border-0 bg-black" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Workbench: data selector + operation builder + live run ─────────────── */
|
||||||
|
export function LakehouseWorkbench({ lockedCatalog }: { lockedCatalog?: string }) {
|
||||||
|
const [catalogs, setCatalogs] = useState<string[]>([])
|
||||||
|
const [catalog, setCatalog] = useState(lockedCatalog || 'iceberg')
|
||||||
|
const [schemas, setSchemas] = useState<string[]>([])
|
||||||
|
const [schema, setSchema] = useState('')
|
||||||
|
const [tables, setTables] = useState<{ name: string; fqn: string }[]>([])
|
||||||
|
const [table, setTable] = useState('')
|
||||||
|
const [columns, setColumns] = useState<Column[]>([])
|
||||||
|
|
||||||
|
const [op, setOp] = useState<Operation>('preview')
|
||||||
|
const [limit, setLimit] = useState(200)
|
||||||
|
const [where, setWhere] = useState('')
|
||||||
|
const [groupBy, setGroupBy] = useState<string[]>([])
|
||||||
|
const [metrics, setMetrics] = useState<Metric[]>([{ fn: 'count', col: '*' }])
|
||||||
|
const [profileCols, setProfileCols] = useState<string[]>([])
|
||||||
|
const [sql, setSql] = useState('SELECT region, count(*) AS orders, sum(amount) AS revenue\nFROM iceberg.hadoop.historical_sales_hdfs\nGROUP BY region\nORDER BY revenue DESC')
|
||||||
|
const [rightTable, setRightTable] = useState('')
|
||||||
|
const [leftKey, setLeftKey] = useState('')
|
||||||
|
const [rightKey, setRightKey] = useState('')
|
||||||
|
const [joinType, setJoinType] = useState('INNER')
|
||||||
|
|
||||||
|
const [matEnabled, setMatEnabled] = useState(false)
|
||||||
|
const [matSchema, setMatSchema] = useState('hadoop')
|
||||||
|
const [matTable, setMatTable] = useState('')
|
||||||
|
const [matMode, setMatMode] = useState('create')
|
||||||
|
|
||||||
|
const [run, setRun] = useState<SparkRun | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lockedCatalog) { setCatalog(lockedCatalog); return }
|
||||||
|
fetchSparkCatalogs().then((c) => {
|
||||||
|
setCatalogs(c)
|
||||||
|
if (c.length && !c.includes(catalog)) setCatalog(c[0])
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [lockedCatalog])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!catalog) return
|
||||||
|
setSchema(''); setTables([]); setTable(''); setColumns([])
|
||||||
|
fetchSparkSchemas(catalog).then((s) => { setSchemas(s); if (s.length) setSchema(s[0]) })
|
||||||
|
}, [catalog])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!catalog || !schema) return
|
||||||
|
setTable(''); setColumns([])
|
||||||
|
fetchSparkTables(catalog, schema).then((t) => { setTables(t); if (t.length) setTable(t[0].fqn) })
|
||||||
|
}, [catalog, schema])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!table) { setColumns([]); return }
|
||||||
|
fetchSparkColumns(table).then(setColumns)
|
||||||
|
setGroupBy([]); setProfileCols([])
|
||||||
|
}, [table])
|
||||||
|
|
||||||
|
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
||||||
|
|
||||||
|
const startPolling = useCallback((runId: string) => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
const j = await fetchSparkRun(runId)
|
||||||
|
if (j?.run) {
|
||||||
|
setRun(j.run)
|
||||||
|
if (['FINISHED', 'FAILED', 'CANCELED'].includes(j.run.state)) {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 700)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onRun = async () => {
|
||||||
|
setErr(null); setSubmitting(true); setRun(null)
|
||||||
|
const body: Record<string, unknown> = { operation: op, limit }
|
||||||
|
if (op !== 'sql' && op !== 'join') body.table = table
|
||||||
|
if (op === 'filter') body.where = where
|
||||||
|
if (op === 'aggregate') { body.table = table; body.group_by = groupBy; body.metrics = metrics }
|
||||||
|
if (op === 'profile') { body.table = table; body.columns = profileCols.length ? profileCols : columns.slice(0, 8).map((c) => c.name) }
|
||||||
|
if (op === 'sql') body.sql = sql
|
||||||
|
if (op === 'join') {
|
||||||
|
body.left = table; body.right = rightTable
|
||||||
|
body.left_key = leftKey; body.right_key = rightKey; body.join_type = joinType
|
||||||
|
}
|
||||||
|
if (matEnabled && matTable) body.materialize = { enabled: true, schema: matSchema, table: matTable, mode: matMode }
|
||||||
|
try {
|
||||||
|
const r = await createSparkRun(body)
|
||||||
|
if (!r.ok || !r.run_id) { setErr(r.error || 'Failed to submit'); setSubmitting(false); return }
|
||||||
|
startPolling(r.run_id)
|
||||||
|
} catch {
|
||||||
|
setErr('Submit failed')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = async () => { if (run) await cancelSparkRun(run.id) }
|
||||||
|
|
||||||
|
const colNames = columns.map((c) => c.name)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 gap-2 overflow-hidden">
|
||||||
|
{/* data selector */}
|
||||||
|
<aside className="panel flex w-60 shrink-0 flex-col gap-2 overflow-y-auto p-3">
|
||||||
|
<h3 className="flex items-center gap-1.5 text-[11px] font-semibold text-foreground">
|
||||||
|
<Database className="h-3.5 w-3.5 text-emerald-400" /> Data
|
||||||
|
</h3>
|
||||||
|
{!lockedCatalog && (
|
||||||
|
<Field label="Catalog">
|
||||||
|
<select value={catalog} onChange={(e) => setCatalog(e.target.value)} className={selectCls}>
|
||||||
|
{catalogs.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="Schema">
|
||||||
|
<select value={schema} onChange={(e) => setSchema(e.target.value)} className={selectCls}>
|
||||||
|
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Table">
|
||||||
|
<select value={table} onChange={(e) => setTable(e.target.value)} className={selectCls}>
|
||||||
|
{tables.map((t) => <option key={t.fqn} value={t.fqn}>{t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<p className="mb-1 text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">
|
||||||
|
{columns.length} columns
|
||||||
|
</p>
|
||||||
|
<div className="scrollbar-thin space-y-0.5 overflow-y-auto">
|
||||||
|
{columns.map((c) => (
|
||||||
|
<div key={c.name} className="flex items-center justify-between gap-1 rounded px-1.5 py-0.5 text-[9px] hover:bg-surface-overlay">
|
||||||
|
<span className="truncate font-mono text-foreground">{c.name}</span>
|
||||||
|
<span className="shrink-0 text-[8px] text-foreground-faint">{c.type}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* builder + run */}
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||||
|
<div className="panel shrink-0 space-y-2 p-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{OPERATIONS.map((o) => (
|
||||||
|
<button key={o.id} type="button" onClick={() => setOp(o.id)} title={o.desc}
|
||||||
|
className={cn('rounded-md px-2.5 py-1 text-[10px] font-medium', op === o.id ? subTabActive : subTabIdle)}>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{op === 'sql' && (
|
||||||
|
<textarea value={sql} onChange={(e) => setSql(e.target.value)} rows={4} spellCheck={false}
|
||||||
|
className="w-full rounded-md border border-border bg-[#0d1117] p-2 font-mono text-[11px] text-emerald-100 outline-none" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{op === 'filter' && (
|
||||||
|
<Field label="WHERE predicate">
|
||||||
|
<input value={where} onChange={(e) => setWhere(e.target.value)} placeholder="amount > 1000 AND region = 'EU'" className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{op === 'aggregate' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Field label="Group by">
|
||||||
|
<MultiChips options={colNames} selected={groupBy} onToggle={(c) =>
|
||||||
|
setGroupBy((g) => g.includes(c) ? g.filter((x) => x !== c) : [...g, c])} />
|
||||||
|
</Field>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">Metrics</p>
|
||||||
|
{metrics.map((m, i) => (
|
||||||
|
<div key={i} className="mb-1 flex items-center gap-1">
|
||||||
|
<select value={m.fn} onChange={(e) => setMetrics((ms) => ms.map((x, j) => j === i ? { ...x, fn: e.target.value } : x))} className={cn(selectCls, 'w-32')}>
|
||||||
|
{AGG_FNS.map((f) => <option key={f} value={f}>{f}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={m.col} onChange={(e) => setMetrics((ms) => ms.map((x, j) => j === i ? { ...x, col: e.target.value } : x))} className={cn(selectCls, 'flex-1')}>
|
||||||
|
<option value="*">*</option>
|
||||||
|
{colNames.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
<button type="button" onClick={() => setMetrics((ms) => ms.filter((_, j) => j !== i))} className="rounded p-1 text-foreground-muted hover:text-rose-300">
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={() => setMetrics((ms) => [...ms, { fn: 'sum', col: colNames[0] || '*' }])}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] text-foreground-muted hover:text-foreground">
|
||||||
|
<Plus className="h-3 w-3" /> Add metric
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{op === 'profile' && (
|
||||||
|
<Field label="Columns (default: first 8)">
|
||||||
|
<MultiChips options={colNames} selected={profileCols} onToggle={(c) =>
|
||||||
|
setProfileCols((g) => g.includes(c) ? g.filter((x) => x !== c) : [...g, c])} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{op === 'join' && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Field label="Right table (fqn)">
|
||||||
|
<input value={rightTable} onChange={(e) => setRightTable(e.target.value)} placeholder="postgres_sales.public.customers" className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Join type">
|
||||||
|
<select value={joinType} onChange={(e) => setJoinType(e.target.value)} className={selectCls}>
|
||||||
|
{['INNER', 'LEFT', 'RIGHT', 'FULL'].map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Left key">
|
||||||
|
<select value={leftKey} onChange={(e) => setLeftKey(e.target.value)} className={selectCls}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{colNames.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Right key">
|
||||||
|
<input value={rightKey} onChange={(e) => setRightKey(e.target.value)} placeholder="customer_id" className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* materialize + run row */}
|
||||||
|
<div className="flex flex-wrap items-end gap-2 border-t border-border pt-2">
|
||||||
|
<label className="flex items-center gap-1.5 text-[10px] text-foreground">
|
||||||
|
<input type="checkbox" checked={matEnabled} onChange={(e) => setMatEnabled(e.target.checked)} />
|
||||||
|
<Save className="h-3 w-3 text-violet-300" /> Materialize → Iceberg/S3
|
||||||
|
</label>
|
||||||
|
{matEnabled && (
|
||||||
|
<>
|
||||||
|
<input value={matSchema} onChange={(e) => setMatSchema(e.target.value)} placeholder="schema" className={cn(inputCls, 'w-24')} />
|
||||||
|
<input value={matTable} onChange={(e) => setMatTable(e.target.value)} placeholder="new_table" className={cn(inputCls, 'w-32')} />
|
||||||
|
<select value={matMode} onChange={(e) => setMatMode(e.target.value)} className={cn(selectCls, 'w-28')}>
|
||||||
|
<option value="create">create</option>
|
||||||
|
<option value="replace">replace</option>
|
||||||
|
<option value="insert">insert into</option>
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!matEnabled && (
|
||||||
|
<Field label="Limit" inline>
|
||||||
|
<input type="number" value={limit} onChange={(e) => setLimit(Math.max(1, Math.min(500, +e.target.value)))} className={cn(inputCls, 'w-20')} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
{run && run.state === 'RUNNING' && (
|
||||||
|
<button type="button" onClick={onCancel} className="inline-flex items-center gap-1 rounded-md border border-rose-400/50 bg-rose-500/15 px-3 py-1.5 text-[11px] text-rose-200 hover:bg-rose-500/25">
|
||||||
|
<Square className="h-3.5 w-3.5" /> Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={onRun} disabled={submitting}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-amber-400/50 bg-amber-500/15 px-4 py-1.5 text-[11px] font-medium text-amber-200 hover:bg-amber-500/25 disabled:opacity-50">
|
||||||
|
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||||
|
Run on Spark
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{err && <p className="text-[11px] text-rose-300">{err}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* live run + results */}
|
||||||
|
<div className="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
{run ? <RunLive run={run} /> : (
|
||||||
|
<div className="flex flex-1 items-center justify-center text-[11px] text-foreground-muted">
|
||||||
|
Build an operation and Run to see the live execution matrix.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunLive({ run }: { run: SparkRun }) {
|
||||||
|
const s = run.stats || {}
|
||||||
|
const pct = s.progress_pct ?? (run.state === 'FINISHED' ? 100 : 0)
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="shrink-0 border-b border-border p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-[11px] font-semibold text-foreground">{run.label}</p>
|
||||||
|
{run.target && <p className="truncate font-mono text-[9px] text-violet-300">→ {run.target}</p>}
|
||||||
|
</div>
|
||||||
|
<span className={cn('rounded-full px-2 py-0.5 text-[10px] font-medium', STATE_COLOR[run.state] || STATE_COLOR.QUEUED)}>
|
||||||
|
{run.state}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-overlay">
|
||||||
|
<div className="h-full rounded-full bg-gradient-to-r from-sky-400 to-emerald-400 transition-all" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<SparkMatrix stats={s} />
|
||||||
|
{run.error && <p className="mt-2 rounded bg-rose-500/10 p-2 font-mono text-[10px] text-rose-300">{run.error}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
|
{run.columns && run.columns.length > 0 ? (
|
||||||
|
<table className="w-full border-collapse text-[10px]">
|
||||||
|
<thead className="sticky top-0 bg-surface">
|
||||||
|
<tr>{run.columns.map((c) => <th key={c} className="border-b border-border px-2 py-1 text-left font-semibold text-foreground-muted">{c}</th>)}</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(run.rows || []).map((row, i) => (
|
||||||
|
<tr key={i} className="hover:bg-surface-overlay/40">
|
||||||
|
{row.map((cell, j) => <td key={j} className="border-b border-border/50 px-2 py-1 font-mono text-foreground">{cell == null ? '∅' : String(cell)}</td>)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : run.state === 'FINISHED' && run.target ? (
|
||||||
|
<p className="p-3 text-[11px] text-emerald-300">✓ Materialized to {run.target}{run.update_type ? ` (${run.update_type})` : ''}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SparkMatrix({ stats }: { stats: SparkRunStats }) {
|
||||||
|
const cells: { label: string; value: string; icon: typeof Cpu }[] = [
|
||||||
|
{ label: 'Splits', value: `${fmtNum(stats.completed_splits)}/${fmtNum(stats.total_splits)}`, icon: Layers },
|
||||||
|
{ label: 'Running', value: fmtNum(stats.running_splits), icon: Activity },
|
||||||
|
{ label: 'Rows', value: fmtNum(stats.processed_rows), icon: Table2 },
|
||||||
|
{ label: 'Input', value: fmtBytes(stats.processed_bytes), icon: HardDrive },
|
||||||
|
{ label: 'CPU', value: fmtMs(stats.cpu_time_ms), icon: Cpu },
|
||||||
|
{ label: 'Wall', value: fmtMs(stats.elapsed_ms ?? stats.wall_time_ms), icon: Gauge },
|
||||||
|
{ label: 'Peak mem', value: fmtBytes(stats.peak_memory_bytes), icon: Server },
|
||||||
|
{ label: 'Nodes', value: fmtNum(stats.nodes), icon: Cpu },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<div className="mt-2 grid grid-cols-4 gap-1.5 lg:grid-cols-8">
|
||||||
|
{cells.map((c) => (
|
||||||
|
<div key={c.label} className="rounded-md border border-border bg-surface-overlay/30 px-2 py-1.5">
|
||||||
|
<div className="flex items-center gap-1 text-[8px] uppercase tracking-wider text-foreground-muted">
|
||||||
|
<c.icon className="h-2.5 w-2.5" /> {c.label}
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-[11px] font-semibold tabular-nums text-foreground">{c.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cluster panel ───────────────────────────────────────────────────────── */
|
||||||
|
function ClusterPanel({ live }: { live: SparkLive | null }) {
|
||||||
|
const spark = live?.spark
|
||||||
|
return (
|
||||||
|
<div className="panel grid min-h-0 flex-1 gap-3 overflow-y-auto p-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Metric label="Workers" value={String(spark?.alive_workers ?? 0)} icon={Server} />
|
||||||
|
<Metric label="Cores" value={`${spark?.cores_used ?? 0} / ${spark?.cores ?? 0}`} icon={Cpu} />
|
||||||
|
<Metric label="Memory" value={`${fmtNum(spark?.memory_used_mb)} / ${fmtNum(spark?.memory_mb)} MB`} icon={Activity} />
|
||||||
|
<Metric label="Active jobs" value={String(live?.active_runs?.length ?? 0)} icon={Zap} />
|
||||||
|
<div className="col-span-full">
|
||||||
|
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-muted">Workers</h3>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{(spark?.workers || []).map((w) => (
|
||||||
|
<div key={w.id} className="rounded-lg border border-border bg-surface-overlay/30 p-3">
|
||||||
|
<p className="truncate font-mono text-[10px] text-foreground">{w.host || w.id}</p>
|
||||||
|
<p className="text-[9px] text-foreground-muted">{w.cores_used}/{w.cores} cores · {w.memory_mb} MB · {w.state}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!spark?.workers?.length && <p className="text-[10px] text-foreground-muted">No worker details</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Runs history ────────────────────────────────────────────────────────── */
|
||||||
|
function RunsPanel({ live }: { live: SparkLive | null }) {
|
||||||
|
const [runs, setRuns] = useState<SparkRun[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSparkRuns().then(setRuns)
|
||||||
|
const iv = setInterval(() => fetchSparkRuns().then(setRuns), 3000)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
}, [])
|
||||||
|
const list = runs.length ? runs : (live?.recent_runs ?? [])
|
||||||
|
return (
|
||||||
|
<div className="panel min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<p className="p-4 text-center text-[11px] text-foreground-muted">No runs yet. Submit an operation from the Workbench.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{list.map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center justify-between gap-3 rounded-lg border border-border bg-surface-overlay/20 px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-[11px] font-medium text-foreground">{r.label}</p>
|
||||||
|
<p className="truncate font-mono text-[9px] text-foreground-muted">{r.sql.slice(0, 90)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-3 text-[9px] text-foreground-muted">
|
||||||
|
<span>{fmtNum(r.stats?.processed_rows)} rows</span>
|
||||||
|
<span>{fmtMs(r.stats?.elapsed_ms)}</span>
|
||||||
|
<span className={cn('rounded-full px-2 py-0.5 font-medium', STATE_COLOR[r.state] || STATE_COLOR.QUEUED)}>{r.state}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── small UI helpers ────────────────────────────────────────────────────── */
|
||||||
|
const selectCls = 'w-full rounded-md border border-border bg-surface px-2 py-1 text-[10px] text-foreground outline-none'
|
||||||
|
const inputCls = 'rounded-md border border-border bg-surface px-2 py-1 text-[10px] text-foreground outline-none'
|
||||||
|
|
||||||
|
function Field({ label, children, inline }: { label: string; children: React.ReactNode; inline?: boolean }) {
|
||||||
|
return (
|
||||||
|
<label className={cn('text-[9px] font-semibold uppercase tracking-wider text-foreground-muted', inline ? 'flex items-center gap-1.5' : 'block')}>
|
||||||
|
{label}
|
||||||
|
<div className={inline ? '' : 'mt-1'}>{children}</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiChips({ options, selected, onToggle }: { options: string[]; selected: string[]; onToggle: (c: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex max-h-24 flex-wrap gap-1 overflow-y-auto">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button key={o} type="button" onClick={() => onToggle(o)}
|
||||||
|
className={cn('rounded border px-1.5 py-0.5 font-mono text-[9px]', selected.includes(o) ? 'border-emerald-400/50 bg-emerald-500/15 text-emerald-200' : 'border-border text-foreground-muted hover:bg-surface-overlay')}>
|
||||||
|
{o}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!options.length && <span className="text-[9px] text-foreground-faint">select a table</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value, icon: Icon }: { label: string; value: string; icon: typeof Cpu }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface-overlay/25 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 text-[9px] uppercase tracking-wider text-foreground-muted">
|
||||||
|
<Icon className="h-3 w-3" /> {label}
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-semibold tabular-nums text-foreground">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveChip({ label, value, ok }: { label: string; value: string; ok: boolean }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[9px]">
|
||||||
|
<span className={cn('h-1.5 w-1.5 rounded-full', ok ? 'bg-emerald-400' : 'bg-amber-400')} />
|
||||||
|
<span className="text-foreground-muted">{label}</span>
|
||||||
|
<span className="font-mono font-semibold text-foreground">{value}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ type SqlResult = {
|
|||||||
sql?: string
|
sql?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Engine = SourceEngine | 'trino'
|
type Engine = SourceEngine | 'trino' | 'hadoop'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
engine: Engine
|
engine: Engine
|
||||||
@@ -53,6 +53,12 @@ const ENGINE_META: Record<Engine, { title: string; sub: string; accent: string;
|
|||||||
accent: 'text-pink-400',
|
accent: 'text-pink-400',
|
||||||
queryLabel: 'Cypher',
|
queryLabel: 'Cypher',
|
||||||
},
|
},
|
||||||
|
hadoop: {
|
||||||
|
title: 'Hadoop / Trino Console',
|
||||||
|
sub: 'Lakehouse · Trino 10.0.21.50:8089 · iceberg.hadoop · hive',
|
||||||
|
accent: 'text-emerald-400',
|
||||||
|
queryLabel: 'SQL',
|
||||||
|
},
|
||||||
trino: {
|
trino: {
|
||||||
title: 'Trino SQL Console',
|
title: 'Trino SQL Console',
|
||||||
sub: 'Lakehouse · 10.0.21.50:8089 · federated queries',
|
sub: 'Lakehouse · 10.0.21.50:8089 · federated queries',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Database, DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, Server, TerminalSquare, Cpu, Activity, GitBranch } from 'lucide-react'
|
import { Database, DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Server, TerminalSquare, Activity, GitBranch } from 'lucide-react'
|
||||||
import type { GpuStatus, WorkloadData } from '../../types'
|
import type { GpuStatus, WorkloadData } from '../../types'
|
||||||
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
|
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
@@ -6,7 +6,7 @@ import { viewTabActive, viewTabIdle } from '../../lib/tabActive'
|
|||||||
import { GpuMatrixPanel } from '../features/GpuMatrixPanel'
|
import { GpuMatrixPanel } from '../features/GpuMatrixPanel'
|
||||||
import { LabHealthPanel } from '../features/LabHealthPanel'
|
import { LabHealthPanel } from '../features/LabHealthPanel'
|
||||||
|
|
||||||
type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'approvals' | 'changes' | 'dataflow' | 'datasources'
|
type MainView = 'platform' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'approvals' | 'changes' | 'dataflow' | 'datasources'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workload: WorkloadData | null
|
workload: WorkloadData | null
|
||||||
@@ -24,16 +24,13 @@ type Props = {
|
|||||||
|
|
||||||
const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [
|
const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [
|
||||||
{ id: 'platform', label: 'Data Platform', icon: LayoutDashboard },
|
{ id: 'platform', label: 'Data Platform', icon: LayoutDashboard },
|
||||||
{ id: 'datasources', label: 'Data Sources UI', icon: Database },
|
{ id: 'datasources', label: 'Data Hub', icon: Database },
|
||||||
{ id: 'datagen', label: 'Data Generation', icon: Cpu },
|
|
||||||
{ id: 'changes', label: 'Live Changes', icon: Activity },
|
{ id: 'changes', label: 'Live Changes', icon: Activity },
|
||||||
{ id: 'dataflow', label: 'Data Flow', icon: GitBranch },
|
{ id: 'dataflow', label: 'Data Flow', icon: GitBranch },
|
||||||
{ id: 'presentation', label: 'Presentation', icon: Presentation },
|
|
||||||
{ id: 'dataquality', label: 'Data Quality', icon: DatabaseZap },
|
{ id: 'dataquality', label: 'Data Quality', icon: DatabaseZap },
|
||||||
{ id: 'knowledge', label: 'Knowledge Chat', icon: MessageSquare },
|
{ id: 'knowledge', label: 'Knowledge Chat', icon: MessageSquare },
|
||||||
{ id: 'storage', label: 'Object Storage', icon: HardDrive },
|
{ id: 'storage', label: 'Object Storage', icon: HardDrive },
|
||||||
{ id: 'hdfs', label: 'Hadoop HDFS', icon: Server },
|
{ id: 'search', label: 'Elasticsearch', icon: Search },
|
||||||
{ id: 'search', label: 'Elasticsearch', icon: Search },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function SideNav({
|
export function SideNav({
|
||||||
@@ -52,10 +49,10 @@ export function SideNav({
|
|||||||
const matrixBoost = gpuBoost || mainView === 'knowledge'
|
const matrixBoost = gpuBoost || mainView === 'knowledge'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex w-[240px] shrink-0 flex-col border-r border-border bg-surface-raised">
|
<nav className="flex h-full min-h-0 w-[240px] shrink-0 flex-col border-r border-border bg-surface-raised">
|
||||||
<section className="shrink-0 border-b border-border p-3">
|
<section className="flex max-h-[42%] min-h-0 shrink-0 flex-col border-b border-border p-3">
|
||||||
<h2 className="mb-2 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">Views</h2>
|
<h2 className="mb-2 shrink-0 text-[9px] font-semibold uppercase tracking-widest text-foreground-muted">Views</h2>
|
||||||
<div className="space-y-1">
|
<div className="scrollbar-thin min-h-0 flex-1 space-y-1 overflow-y-auto">
|
||||||
{VIEWS.map(({ id, label, icon: Icon }) => (
|
{VIEWS.map(({ id, label, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
@@ -81,21 +78,25 @@ export function SideNav({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<GpuMatrixPanel
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
gpu={gpu}
|
<GpuMatrixPanel
|
||||||
live={gpuLive}
|
gpu={gpu}
|
||||||
boost={matrixBoost}
|
live={gpuLive}
|
||||||
onSelectGpu={() => onSelectZone('gpu')}
|
boost={matrixBoost}
|
||||||
/>
|
onSelectGpu={() => onSelectZone('gpu')}
|
||||||
|
/>
|
||||||
|
|
||||||
<LabHealthPanel
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto">
|
||||||
workload={workload}
|
<LabHealthPanel
|
||||||
gpu={gpu}
|
workload={workload}
|
||||||
selectedNodeId={selectedNodeId}
|
gpu={gpu}
|
||||||
approvalCount={approvalCount}
|
selectedNodeId={selectedNodeId}
|
||||||
onSelectZone={onSelectZone}
|
approvalCount={approvalCount}
|
||||||
onOpenApprovals={onOpenApprovals}
|
onSelectZone={onSelectZone}
|
||||||
/>
|
onOpenApprovals={onOpenApprovals}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import type { SourceEngine } from '../../lib/dataSourceCatalog'
|
||||||
|
|
||||||
|
const BRAND: Record<SourceEngine, { color: string; label: string }> = {
|
||||||
|
postgres: { color: '#336791', label: 'PG' },
|
||||||
|
mysql: { color: '#00758F', label: 'MY' },
|
||||||
|
mongodb: { color: '#47A248', label: 'MG' },
|
||||||
|
cassandra: { color: '#1287B1', label: 'CS' },
|
||||||
|
neo4j: { color: '#018BFF', label: 'NJ' },
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
engine: SourceEngine
|
||||||
|
size?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DbBrandIcon({ engine, size = 20, className }: Props) {
|
||||||
|
const b = BRAND[engine]
|
||||||
|
const s = size
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={s}
|
||||||
|
height={s}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className={cn('shrink-0', className)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="11" fill={b.color} />
|
||||||
|
{engine === 'postgres' && (
|
||||||
|
<path fill="#fff" d="M7 8h10v1.5H7V8zm0 3.5h10V13H7v-1.5zm0 3.5h7V16H7v-1z" opacity="0.95" />
|
||||||
|
)}
|
||||||
|
{engine === 'mysql' && (
|
||||||
|
<path fill="#F29111" d="M12 5c-3 0-5 2-5 4.5 0 2 1.5 3.5 3.5 4.5-1 .5-1.5 1.5-1.5 2.5 0 2 2 3.5 4.5 3.5s4.5-1.5 4.5-3.5c0-1-.5-2-1.5-2.5 2-1 3.5-2.5 3.5-4.5C17 7 15 5 12 5z" />
|
||||||
|
)}
|
||||||
|
{engine === 'mongodb' && (
|
||||||
|
<path fill="#fff" d="M12 6c-2.5 2-4 5-4 8.5 0 2 .5 3.5 1.5 4.5.5-2 1.5-3.5 2.5-4.5 1 1 2 2.5 2.5 4.5 1-1 1.5-2.5 1.5-4.5C16 11 14.5 8 12 6z" />
|
||||||
|
)}
|
||||||
|
{engine === 'cassandra' && (
|
||||||
|
<path fill="#fff" d="M12 5l6 3.5v7L12 19l-6-3.5v-7L12 5zm0 2.2L8.5 9v4L12 14.8l3.5-1.8V9L12 7.2z" opacity="0.95" />
|
||||||
|
)}
|
||||||
|
{engine === 'neo4j' && (
|
||||||
|
<>
|
||||||
|
<circle cx="8" cy="10" r="2.2" fill="#fff" />
|
||||||
|
<circle cx="16" cy="10" r="2.2" fill="#fff" />
|
||||||
|
<circle cx="12" cy="16" r="2.2" fill="#fff" />
|
||||||
|
<path stroke="#fff" strokeWidth="1.2" d="M9.5 10.8 11 14M14.5 10.8 13 14M9.8 10 14.2 10" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dbBrandColor(engine: SourceEngine): string {
|
||||||
|
return BRAND[engine].color
|
||||||
|
}
|
||||||
@@ -12,12 +12,12 @@ const STORAGE_KEY = 'atc-command-center-theme'
|
|||||||
|
|
||||||
function readStored(): Theme {
|
function readStored(): Theme {
|
||||||
const v = localStorage.getItem(STORAGE_KEY)
|
const v = localStorage.getItem(STORAGE_KEY)
|
||||||
return v === 'dark' || v === 'light' ? v : 'light'
|
return v === 'dark' || v === 'light' ? v : 'dark'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setTheme] = useState<Theme>(() => {
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
if (typeof window === 'undefined') return 'light'
|
if (typeof window === 'undefined') return 'dark'
|
||||||
return readStored()
|
return readStored()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function useCommandCenter() {
|
|||||||
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null)
|
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null)
|
||||||
const [nodeDetail, setNodeDetail] = useState<NodeDetail | null>(null)
|
const [nodeDetail, setNodeDetail] = useState<NodeDetail | null>(null)
|
||||||
const [nodeBusy, setNodeBusy] = useState(false)
|
const [nodeBusy, setNodeBusy] = useState(false)
|
||||||
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'datagen' | 'changes' | 'dataflow' | 'datasources'>('platform')
|
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'changes' | 'dataflow' | 'datasources'>('platform')
|
||||||
const [changes, setChanges] = useState<CdcChange[]>([])
|
const [changes, setChanges] = useState<CdcChange[]>([])
|
||||||
const [genPulse, setGenPulse] = useState(false)
|
const [genPulse, setGenPulse] = useState(false)
|
||||||
const genPulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const genPulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type {
|
|||||||
GpuStatus,
|
GpuStatus,
|
||||||
Movement,
|
Movement,
|
||||||
PiiDataset,
|
PiiDataset,
|
||||||
|
SparkRun,
|
||||||
|
SparkLive,
|
||||||
|
StreamingStatus,
|
||||||
StatusData,
|
StatusData,
|
||||||
TerminalLine,
|
TerminalLine,
|
||||||
WorkloadData,
|
WorkloadData,
|
||||||
@@ -178,3 +181,100 @@ export async function decideApproval(id: string, approved: boolean, decidedBy: s
|
|||||||
body: JSON.stringify({ approved, decided_by: decidedBy, note }),
|
body: JSON.stringify({ approved, decided_by: decidedBy, note }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchStreamingStatus(refresh = false): Promise<StreamingStatus | null> {
|
||||||
|
return fetchJson<StreamingStatus>(`/api/pipeline/streaming/status${refresh ? '?refresh=true' : ''}`, 20000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerStreamingJob(jobId: string, conf?: Record<string, unknown>) {
|
||||||
|
return fetch(`/api/pipeline/streaming/jobs/${jobId}/trigger`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ conf: conf ?? {} }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restartKafkaConnector(name: string) {
|
||||||
|
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/restart`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pauseKafkaConnector(name: string) {
|
||||||
|
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/pause`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeKafkaConnector(name: string) {
|
||||||
|
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/resume`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerStreamingPipeline(pipelineId: string) {
|
||||||
|
return fetch(`/api/pipeline/streaming/pipeline/${encodeURIComponent(pipelineId)}`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportHdfsToKafka(body?: { path?: string; topic?: string; limit?: number }) {
|
||||||
|
return fetch('/api/pipeline/streaming/hdfs/to-kafka', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStreamingFlow(action: 'pause' | 'resume' | 'stop') {
|
||||||
|
return fetch(`/api/pipeline/streaming/flow/${action}`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Spark Workbench ──────────────────────────────────────────────
|
||||||
|
export async function fetchSparkCatalogs(): Promise<string[]> {
|
||||||
|
const j = await fetchJson<{ catalogs?: string[] }>('/api/spark/catalogs', 15000)
|
||||||
|
return j?.catalogs ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSparkSchemas(catalog: string): Promise<string[]> {
|
||||||
|
const j = await fetchJson<{ schemas?: string[] }>(`/api/spark/schemas?catalog=${encodeURIComponent(catalog)}`, 15000)
|
||||||
|
return j?.schemas ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSparkTables(catalog: string, schema: string): Promise<{ name: string; fqn: string }[]> {
|
||||||
|
const j = await fetchJson<{ tables?: { name: string; fqn: string }[] }>(
|
||||||
|
`/api/spark/tables?catalog=${encodeURIComponent(catalog)}&schema=${encodeURIComponent(schema)}`, 15000)
|
||||||
|
return j?.tables ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSparkColumns(table: string): Promise<{ name: string; type: string }[]> {
|
||||||
|
const j = await fetchJson<{ columns?: { name: string; type: string }[] }>(
|
||||||
|
`/api/spark/columns?table=${encodeURIComponent(table)}`, 15000)
|
||||||
|
return j?.columns ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSparkRun(body: Record<string, unknown>): Promise<{ ok: boolean; run_id?: string; sql?: string; error?: string; target?: string | null }> {
|
||||||
|
const r = await fetch('/api/spark/run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
return r.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSparkRun(runId: string) {
|
||||||
|
return fetchJson<{ ok: boolean; run?: SparkRun }>(`/api/spark/run/${runId}`, 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelSparkRun(runId: string) {
|
||||||
|
return fetch(`/api/spark/run/${runId}/cancel`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSparkLive() {
|
||||||
|
return fetchJson<SparkLive>('/api/spark/live', 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSparkRuns(): Promise<SparkRun[]> {
|
||||||
|
const j = await fetchJson<{ runs?: SparkRun[] }>('/api/spark/runs', 15000)
|
||||||
|
return j?.runs ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleCustodianOffload(enabled?: boolean) {
|
||||||
|
return fetch('/api/agent-ops/custodian/toggle', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(enabled === undefined ? {} : { enabled }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Activity, Boxes, Database, Network } from 'lucide-react'
|
|||||||
|
|
||||||
export type SourceEngine = 'postgres' | 'mysql' | 'mongodb' | 'cassandra' | 'neo4j'
|
export type SourceEngine = 'postgres' | 'mysql' | 'mongodb' | 'cassandra' | 'neo4j'
|
||||||
|
|
||||||
export type SourceSubTab = 'browser' | 'console' | 'shell' | 'graph'
|
export type SourceSubTab = 'browser' | 'console' | 'shell' | 'graph' | 'generate' | 'workbench'
|
||||||
|
|
||||||
export type CatalogObject = {
|
export type CatalogObject = {
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
--surface-muted: 221 225 230;
|
--surface-muted: 221 225 230;
|
||||||
--border: 210 218 228;
|
--border: 210 218 228;
|
||||||
--border-strong: 180 192 208;
|
--border-strong: 180 192 208;
|
||||||
--foreground: 26 31 38;
|
--foreground: 12 18 28;
|
||||||
--foreground-muted: 95 107 122;
|
--foreground-muted: 38 48 62;
|
||||||
--foreground-faint: 139 149 165;
|
--foreground-faint: 62 72 88;
|
||||||
--shadow-panel: 0 1px 3px rgba(15, 40, 80, 0.06), 0 4px 12px rgba(36, 150, 237, 0.06);
|
--shadow-panel: 0 1px 3px rgba(15, 40, 80, 0.06), 0 4px 12px rgba(36, 150, 237, 0.06);
|
||||||
--shadow-docker: 0 0 0 1px rgba(36, 150, 237, 0.12), 0 4px 14px rgba(36, 150, 237, 0.1);
|
--shadow-docker: 0 0 0 1px rgba(36, 150, 237, 0.12), 0 4px 14px rgba(36, 150, 237, 0.1);
|
||||||
--topo-canvas: linear-gradient(145deg, #dbeafe 0%, #e0f2fe 35%, #ede9fe 70%, #ecfdf5 100%);
|
--topo-canvas: linear-gradient(145deg, #dbeafe 0%, #e0f2fe 35%, #ede9fe 70%, #ecfdf5 100%);
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
--surface-muted: 36 58 92;
|
--surface-muted: 36 58 92;
|
||||||
--border: 48 74 112;
|
--border: 48 74 112;
|
||||||
--border-strong: 64 96 140;
|
--border-strong: 64 96 140;
|
||||||
--foreground: 232 241 255;
|
--foreground: 255 255 255;
|
||||||
--foreground-muted: 148 175 212;
|
--foreground-muted: 228 234 244;
|
||||||
--foreground-faint: 100 130 168;
|
--foreground-faint: 195 205 220;
|
||||||
--shadow-panel: 0 1px 0 rgba(147, 197, 253, 0.06) inset, 0 8px 24px rgba(0, 0, 0, 0.35);
|
--shadow-panel: 0 1px 0 rgba(147, 197, 253, 0.06) inset, 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
--shadow-docker: 0 0 0 1px rgba(56, 189, 248, 0.2), 0 4px 16px rgba(14, 116, 214, 0.25);
|
--shadow-docker: 0 0 0 1px rgba(56, 189, 248, 0.2), 0 4px 16px rgba(14, 116, 214, 0.25);
|
||||||
--topo-canvas: linear-gradient(145deg, #0c1929 0%, #132f4c 40%, #1a365d 75%, #0f2847 100%);
|
--topo-canvas: linear-gradient(145deg, #0c1929 0%, #132f4c 40%, #1a365d 75%, #0f2847 100%);
|
||||||
|
|||||||
@@ -108,11 +108,61 @@ export type DataflowGraph = {
|
|||||||
ok: boolean
|
ok: boolean
|
||||||
nodes: DataflowNode[]
|
nodes: DataflowNode[]
|
||||||
edges: DataflowEdge[]
|
edges: DataflowEdge[]
|
||||||
|
flow?: string
|
||||||
pii_summary: { datasets?: number; pii_columns?: number; masked_columns?: number; unmasked_columns?: number }
|
pii_summary: { datasets?: number; pii_columns?: number; masked_columns?: number; unmasked_columns?: number }
|
||||||
cdc: { connected?: boolean; consumed?: number; window_total?: number }
|
cdc: { connected?: boolean; consumed?: number; window_total?: number }
|
||||||
|
streaming?: StreamingStatus | null
|
||||||
ts: number
|
ts: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StreamingSparkWorker = {
|
||||||
|
id?: string
|
||||||
|
host?: string
|
||||||
|
cores?: number
|
||||||
|
cores_used?: number
|
||||||
|
memory_mb?: number
|
||||||
|
state?: string
|
||||||
|
webui?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreamingSparkApp = {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
cores?: number
|
||||||
|
memory_mb?: number
|
||||||
|
submitdate?: string
|
||||||
|
duration_ms?: number
|
||||||
|
user?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreamingStatus = {
|
||||||
|
ok?: boolean
|
||||||
|
spark?: {
|
||||||
|
ui_url?: string
|
||||||
|
ui_ok?: boolean
|
||||||
|
status?: string
|
||||||
|
workers?: StreamingSparkWorker[]
|
||||||
|
alive_workers?: number
|
||||||
|
cores?: number
|
||||||
|
cores_used?: number
|
||||||
|
memory_mb?: number
|
||||||
|
memory_used_mb?: number
|
||||||
|
active_apps?: StreamingSparkApp[]
|
||||||
|
completed_apps?: StreamingSparkApp[]
|
||||||
|
}
|
||||||
|
kafka?: {
|
||||||
|
ui_url?: string
|
||||||
|
ui_ok?: boolean
|
||||||
|
connect_ok?: boolean
|
||||||
|
cluster?: { name?: string; status?: string; broker_count?: number; topic_count?: number }
|
||||||
|
topics?: { name?: string; partitions?: number; replicas?: number; messages?: number }[]
|
||||||
|
connectors?: { name: string; state?: string; worker?: string; tasks?: { id?: number; state?: string }[]; type?: string }[]
|
||||||
|
}
|
||||||
|
edges?: Record<string, boolean>
|
||||||
|
jobs?: { id: string; label: string; dag_id: string; description?: string; default_conf?: Record<string, unknown> }[]
|
||||||
|
ts?: number
|
||||||
|
}
|
||||||
|
|
||||||
export type Movement = {
|
export type Movement = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
@@ -334,6 +384,53 @@ export type PresentationData = {
|
|||||||
pipeline_active?: boolean
|
pipeline_active?: boolean
|
||||||
slides: PresentationSlide[]
|
slides: PresentationSlide[]
|
||||||
slide_count: number
|
slide_count: number
|
||||||
|
edited?: boolean
|
||||||
|
override_ts?: string
|
||||||
|
id?: string
|
||||||
|
source?: string
|
||||||
workload?: WorkloadData
|
workload?: WorkloadData
|
||||||
topologies?: Record<string, TopologyViewData>
|
topologies?: Record<string, TopologyViewData>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SparkRunStats = {
|
||||||
|
state?: string
|
||||||
|
nodes?: number
|
||||||
|
total_splits?: number
|
||||||
|
queued_splits?: number
|
||||||
|
running_splits?: number
|
||||||
|
completed_splits?: number
|
||||||
|
processed_rows?: number
|
||||||
|
processed_bytes?: number
|
||||||
|
physical_input_bytes?: number
|
||||||
|
peak_memory_bytes?: number
|
||||||
|
cpu_time_ms?: number
|
||||||
|
wall_time_ms?: number
|
||||||
|
elapsed_ms?: number
|
||||||
|
progress_pct?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SparkRun = {
|
||||||
|
id: string
|
||||||
|
operation: string
|
||||||
|
label: string
|
||||||
|
sql: string
|
||||||
|
target?: string | null
|
||||||
|
state: string
|
||||||
|
engine_state?: string
|
||||||
|
stats?: SparkRunStats
|
||||||
|
columns?: string[]
|
||||||
|
rows?: unknown[][]
|
||||||
|
row_count?: number | null
|
||||||
|
error?: string | null
|
||||||
|
started_at?: number
|
||||||
|
ended_at?: number | null
|
||||||
|
update_type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SparkLive = {
|
||||||
|
ok?: boolean
|
||||||
|
spark?: StreamingStatus['spark']
|
||||||
|
active_runs?: SparkRun[]
|
||||||
|
recent_runs?: SparkRun[]
|
||||||
|
ts?: number
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user