feat: Nessie catalog UI + Iceberg structure explorer + live Data Flow online/offline
Add Project Nessie on lake01 with Command Center Iceberg tab (snapshots, manifests, data files, time-travel SQL). Data Flow pulses only when endpoints are reachable; offline nodes/edges render red.
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/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY main.py auth.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py hdfs_api.py ssh_terminal.py pipeline_ops.py hadoop_analytics.py agent_ops.py cdc_consumer.py movements.py dataflow.py streaming_ops.py spark_workbench.py hadoop_sql.py hdfs_kafka.py webhdfs_util.py pii_catalog.py platform_context.py trino_federated.py etl_offload.py agent_activity.py lake_meta.py lineage.py dq_monitor.py observability.py catalog_governance.py gpu_config.py hive_bench_seed.json .
|
||||
COPY main.py auth.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py hdfs_api.py ssh_terminal.py pipeline_ops.py hadoop_analytics.py agent_ops.py cdc_consumer.py movements.py dataflow.py streaming_ops.py spark_workbench.py hadoop_sql.py hdfs_kafka.py webhdfs_util.py pii_catalog.py platform_context.py trino_federated.py etl_offload.py agent_activity.py lake_meta.py lineage.py dq_monitor.py observability.py catalog_governance.py gpu_config.py nessie_iceberg.py hive_bench_seed.json .
|
||||
RUN mkdir -p /data
|
||||
ENV DATABASE_URL=sqlite:////data/atc-agents.db
|
||||
EXPOSE 3201
|
||||
|
||||
+100
-2
@@ -26,6 +26,76 @@ router = APIRouter(prefix="/api/dataflow", tags=["dataflow"])
|
||||
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
|
||||
TRINO_USER = os.getenv("TRINO_USER", "mo")
|
||||
|
||||
NESSIE_URL = os.getenv("NESSIE_URL", "http://10.0.21.50:19120").rstrip("/")
|
||||
SRC_DB_HOST = os.getenv("SRC_DB_HOST", "10.0.21.51")
|
||||
SPARK_UI_URL = os.getenv("SPARK_UI_URL", "http://10.0.21.50:8081").rstrip("/")
|
||||
KAFKA_UI_URL = os.getenv("KAFKA_UI_URL", "http://10.0.21.36:9000").rstrip("/")
|
||||
HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870").rstrip("/")
|
||||
S3_ENDPOINT = os.getenv("S3_ENDPOINT", "http://10.0.20.111:9020").rstrip("/")
|
||||
|
||||
# TCP ports for source DB reachability on the shared db host
|
||||
_SOURCE_PORTS = {
|
||||
"postgres": 5432,
|
||||
"mysql": 3306,
|
||||
"mongodb": 27017,
|
||||
"cassandra": 9042,
|
||||
"neo4j": 7687,
|
||||
}
|
||||
|
||||
|
||||
async def _tcp_open(host: str, port: int, timeout: float = 1.2) -> bool:
|
||||
import asyncio
|
||||
try:
|
||||
conn = asyncio.open_connection(host, port)
|
||||
reader, writer = await asyncio.wait_for(conn, timeout=timeout)
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def _http_ok(url: str, timeout: float = 2.0) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
r = await client.get(url)
|
||||
return r.status_code < 500
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def _probe_online() -> dict[str, bool]:
|
||||
"""Best-effort live reachability for dataflow nodes."""
|
||||
online: dict[str, bool] = {}
|
||||
for sid, port in _SOURCE_PORTS.items():
|
||||
online[sid] = await _tcp_open(SRC_DB_HOST, port)
|
||||
online["trino"] = await _http_ok(f"{TRINO_URL}/v1/info")
|
||||
online["nessie"] = await _http_ok(f"{NESSIE_URL}/api/v2/config")
|
||||
online["spark"] = await _http_ok(f"{SPARK_UI_URL}/json/")
|
||||
online["kafka"] = await _http_ok(KAFKA_UI_URL)
|
||||
online["hdfs"] = await _http_ok(HDFS_NN_URL)
|
||||
# S3: TCP to endpoint host:port
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
u = urlparse(S3_ENDPOINT)
|
||||
online["s3_cdc"] = await _tcp_open(u.hostname or "10.0.20.111", u.port or 9020)
|
||||
except Exception:
|
||||
online["s3_cdc"] = False
|
||||
# lakehouse iceberg nodes follow trino catalog availability
|
||||
online["iceberg_hadoop"] = online.get("trino", False)
|
||||
online["iceberg_curated"] = online.get("trino", False)
|
||||
online["generator"] = True # logical
|
||||
online["openmetadata"] = await _http_ok("http://10.0.21.47:8585")
|
||||
online["chromadb"] = True
|
||||
online["rag"] = True
|
||||
online["vllm"] = await _http_ok(os.getenv("LLM_URL", "http://10.0.10.106:8001/v1").rstrip("/") + "/models")
|
||||
online["chat"] = True
|
||||
return online
|
||||
|
||||
|
||||
# Static landscape (x,y in 0..100). kind drives UI styling.
|
||||
# Laid out as clean left→right pipeline stages so lineage reads in order:
|
||||
# producers (col 0) → sources (col 1) → CDC (col 2) → storage/lakehouse (col 3)
|
||||
@@ -48,6 +118,8 @@ NODES: list[dict[str, Any]] = [
|
||||
{"id": "s3_cdc", "label": "S3 CDC Archive", "sub": "object store", "kind": "sink", "x": 67, "y": 13},
|
||||
{"id": "iceberg_curated", "label": "Iceberg · curated_masked", "sub": "masked PII", "kind": "lakehouse", "x": 67, "y": 45},
|
||||
{"id": "iceberg_hadoop", "label": "Iceberg · hadoop", "sub": "historical_sales_hdfs", "kind": "lakehouse", "x": 67, "y": 80},
|
||||
{"id": "nessie", "label": "Nessie", "sub": "Iceberg catalog · Git-like", "kind": "catalog", "x": 78, "y": 62,
|
||||
"url": "http://10.0.21.50:19120"},
|
||||
# col 4 — query engine
|
||||
{"id": "trino", "label": "Trino", "sub": "query engine", "kind": "engine", "x": 88, "y": 45},
|
||||
# governance — bottom centre
|
||||
@@ -83,6 +155,10 @@ EDGES: list[dict[str, Any]] = [
|
||||
{"from": "postgres", "to": "iceberg_curated", "kind": "mask", "movement_id": "mask_to_curated"},
|
||||
{"from": "mysql", "to": "iceberg_curated", "kind": "mask", "movement_id": "mask_to_curated"},
|
||||
{"from": "mongodb", "to": "iceberg_curated", "kind": "mask", "movement_id": "mask_to_curated"},
|
||||
{"from": "iceberg_hadoop", "to": "nessie", "kind": "catalog"},
|
||||
{"from": "iceberg_curated", "to": "nessie", "kind": "catalog"},
|
||||
{"from": "nessie", "to": "s3_cdc", "kind": "archive"},
|
||||
{"from": "nessie", "to": "trino", "kind": "query"},
|
||||
{"from": "iceberg_hadoop", "to": "trino", "kind": "query"},
|
||||
{"from": "iceberg_curated", "to": "trino", "kind": "query"},
|
||||
{"from": "kafka", "to": "trino", "kind": "query"},
|
||||
@@ -198,10 +274,14 @@ async def _build() -> dict[str, Any]:
|
||||
edge_live = streaming.get("edges") or {}
|
||||
pii_by_node = {d["node_id"]: d for d in pii.get("datasets", [])}
|
||||
|
||||
online = await _probe_online()
|
||||
|
||||
nodes = []
|
||||
for n in NODES:
|
||||
node = dict(n)
|
||||
node["level"] = "ok"
|
||||
is_up = online.get(n["id"], True)
|
||||
node["online"] = is_up
|
||||
node["level"] = "ok" if is_up else "err"
|
||||
metric = None
|
||||
if n["id"] == "openmetadata":
|
||||
metric = f"{pii.get('summary', {}).get('pii_columns', 0)} PII cols cataloged"
|
||||
@@ -217,10 +297,15 @@ async def _build() -> dict[str, Any]:
|
||||
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", "cassandra", "neo4j"):
|
||||
metric = f"{cdc.get('by_source', {}).get(n['id'], 0)} CDC/15m"
|
||||
if not is_up:
|
||||
metric = "offline"
|
||||
else:
|
||||
metric = f"{cdc.get('by_source', {}).get(n['id'], 0)} CDC/15m"
|
||||
elif n["id"] == "iceberg_hadoop":
|
||||
c = _iceberg_hadoop_count()
|
||||
metric = f"{c:,} rows" if c is not None else "iceberg table"
|
||||
elif n["id"] == "nessie":
|
||||
metric = "catalog commits" if online.get("nessie") else "offline"
|
||||
elif n["id"] == "generator":
|
||||
metric = "Airflow gen DAGs"
|
||||
elif n["id"] in ("vllm", "rag", "chromadb"):
|
||||
@@ -283,6 +368,10 @@ async def _build() -> dict[str, Any]:
|
||||
elif e["kind"] == "archive" and e.get("from") == "kafka" and e.get("to") == "s3_cdc":
|
||||
# Kafka → S3 CDC archive pulses while the pipeline lands objects in S3
|
||||
edge["active"] = arch_active
|
||||
elif e["kind"] == "catalog" and e.get("to") == "nessie":
|
||||
edge["active"] = bool(online.get("nessie")) and bool(online.get(e.get("from"), True))
|
||||
elif e["kind"] == "catalog":
|
||||
edge["active"] = bool(online.get(e.get("from"), True)) and bool(online.get(e.get("to"), True))
|
||||
elif e["kind"] in ("context", "retrieve", "prompt", "answer"):
|
||||
# AI serving lane pulses while governed data is being served to the LLM
|
||||
edge["active"] = bool(_rag_info())
|
||||
@@ -294,6 +383,15 @@ async def _build() -> dict[str, Any]:
|
||||
pass
|
||||
if _flow != "running":
|
||||
edge["active"] = False
|
||||
|
||||
# Realtime online/offline: never pulse if an endpoint is down
|
||||
frm_up = online.get(e.get("from"), True)
|
||||
to_up = online.get(e.get("to"), True)
|
||||
if not (frm_up and to_up):
|
||||
edge["active"] = False
|
||||
edge["offline"] = True
|
||||
else:
|
||||
edge["offline"] = False
|
||||
edges.append(edge)
|
||||
|
||||
return {
|
||||
|
||||
@@ -62,6 +62,7 @@ from lineage import router as lineage_router
|
||||
from dq_monitor import router as dq_router
|
||||
from observability import router as observability_router
|
||||
from catalog_governance import router as governance_router
|
||||
from nessie_iceberg import router as nessie_router
|
||||
from ssh_terminal import ssh_session
|
||||
from node_registry import NODE_IDS, NODE_AGENT, NODE_REGISTRY, is_node_id
|
||||
from node_ops import build_node_detail, probe_node, run_node_probe_task
|
||||
@@ -1044,6 +1045,7 @@ app.include_router(lineage_router)
|
||||
app.include_router(dq_router)
|
||||
app.include_router(observability_router)
|
||||
app.include_router(governance_router)
|
||||
app.include_router(nessie_router)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Nessie catalog + Iceberg table structure for Command Center.
|
||||
|
||||
Nessie (http://lake01:19120) holds Git-like catalog commits. Trino's existing
|
||||
`iceberg` catalog exposes $snapshots / $manifests / $files for structure.
|
||||
Trino 405 cannot use iceberg.catalog.type=nessie, so we combine both.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Annotated, Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(prefix="/api/nessie", tags=["nessie"])
|
||||
|
||||
NESSIE_URL = os.getenv("NESSIE_URL", "http://10.0.21.50:19120").rstrip("/")
|
||||
NESSIE_UI_URL = os.getenv("NESSIE_UI_URL", NESSIE_URL)
|
||||
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
|
||||
TRINO_USER = os.getenv("TRINO_USER", "mo")
|
||||
ICEBERG_CATALOG = os.getenv("ICEBERG_CATALOG", "iceberg")
|
||||
S3_ENDPOINT = os.getenv("S3_ENDPOINT", "http://10.0.20.111:9020")
|
||||
S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY", "")
|
||||
S3_SECRET_KEY = os.getenv("S3_SECRET_KEY", "")
|
||||
S3_BUCKET = os.getenv("S3_ARCHIVE_BUCKET", "data")
|
||||
|
||||
_cache: dict[str, Any] = {"ts": 0.0, "health": None}
|
||||
_TTL = 8.0
|
||||
|
||||
|
||||
async def _nessie(client: httpx.AsyncClient, path: str) -> Any:
|
||||
r = await client.get(f"{NESSIE_URL}{path}", timeout=12.0)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _trino_rows(sql: str, deadline_s: float = 45.0) -> list[list[Any]]:
|
||||
end = time.time() + deadline_s
|
||||
rows: list[list[Any]] = []
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
r = await client.post(
|
||||
f"{TRINO_URL}/v1/statement",
|
||||
content=sql.encode(),
|
||||
headers={"X-Trino-User": TRINO_USER},
|
||||
)
|
||||
data = r.json()
|
||||
rows.extend(data.get("data") or [])
|
||||
nxt = data.get("nextUri")
|
||||
while nxt:
|
||||
if time.time() > end:
|
||||
try:
|
||||
await client.delete(nxt, timeout=3.0)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
rr = await client.get(nxt)
|
||||
data = rr.json()
|
||||
rows.extend(data.get("data") or [])
|
||||
nxt = data.get("nextUri")
|
||||
if data.get("stats", {}).get("state") in ("FAILED", "FINISHED"):
|
||||
if data.get("error"):
|
||||
raise RuntimeError(str(data["error"].get("message") or data["error"]))
|
||||
if not nxt:
|
||||
break
|
||||
return rows
|
||||
|
||||
|
||||
def _fqn(schema: str, table: str) -> str:
|
||||
return f'{ICEBERG_CATALOG}.{schema}."{table}"'
|
||||
|
||||
|
||||
def _meta_table(schema: str, table: str, kind: str) -> str:
|
||||
# Trino Iceberg metadata tables: schema."table$snapshots"
|
||||
return f'{ICEBERG_CATALOG}.{schema}."{table}${kind}"'
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> JSONResponse:
|
||||
now = time.time()
|
||||
if _cache["health"] and now - _cache["ts"] < _TTL:
|
||||
return JSONResponse(_cache["health"])
|
||||
out: dict[str, Any] = {
|
||||
"ok": False,
|
||||
"nessie_url": NESSIE_URL,
|
||||
"nessie_ui": NESSIE_UI_URL,
|
||||
"trino_url": TRINO_URL,
|
||||
"iceberg_catalog": ICEBERG_CATALOG,
|
||||
"nessie_ok": False,
|
||||
"trino_ok": False,
|
||||
"default_branch": None,
|
||||
"note": "Trino 405 has no native Nessie catalog; structure comes from iceberg.* metadata tables.",
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
cfg = await _nessie(client, "/api/v2/config")
|
||||
out["nessie_ok"] = True
|
||||
out["default_branch"] = cfg.get("defaultBranch", "main")
|
||||
info = await client.get(f"{TRINO_URL}/v1/info")
|
||||
out["trino_ok"] = info.status_code == 200
|
||||
except Exception as exc:
|
||||
out["error"] = str(exc)[:200]
|
||||
out["ok"] = bool(out["nessie_ok"])
|
||||
_cache["health"] = out
|
||||
_cache["ts"] = now
|
||||
return JSONResponse(out)
|
||||
|
||||
|
||||
@router.get("/trees")
|
||||
async def trees() -> JSONResponse:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||
data = await _nessie(client, "/api/v2/trees")
|
||||
refs = []
|
||||
for item in data.get("references") or data.get("tokens") or []:
|
||||
# v2 may return {references: [...]} or paginated
|
||||
ref = item.get("reference") if isinstance(item, dict) and "reference" in item else item
|
||||
if not isinstance(ref, dict):
|
||||
continue
|
||||
refs.append({
|
||||
"type": ref.get("type"),
|
||||
"name": ref.get("name"),
|
||||
"hash": ref.get("hash"),
|
||||
})
|
||||
if not refs:
|
||||
# fallback: main only
|
||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||
main = await _nessie(client, "/api/v2/trees/main")
|
||||
ref = main.get("reference") or main
|
||||
refs = [{"type": ref.get("type"), "name": ref.get("name"), "hash": ref.get("hash")}]
|
||||
return JSONResponse({"ok": True, "references": refs, "ui": NESSIE_UI_URL})
|
||||
except Exception as exc:
|
||||
return JSONResponse({"ok": False, "error": str(exc)[:240], "references": []})
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def history(ref: Annotated[str, Query()] = "main", max_records: Annotated[int, Query(ge=1, le=200)] = 50) -> JSONResponse:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
data = await _nessie(client, f"/api/v2/trees/{quote(str(ref), safe='')}/history?maxRecords={max_records}")
|
||||
entries = data.get("logEntries") or data.get("commits") or []
|
||||
out = []
|
||||
for e in entries:
|
||||
meta = e.get("commitMeta") or e.get("commit") or {}
|
||||
out.append({
|
||||
"hash": e.get("hash") or e.get("commitHash") or meta.get("hash"),
|
||||
"message": meta.get("message"),
|
||||
"author": (meta.get("authors") or [meta.get("author")])[0] if (meta.get("authors") or meta.get("author")) else None,
|
||||
"commitTime": meta.get("commitTime") or meta.get("authorTime"),
|
||||
})
|
||||
return JSONResponse({"ok": True, "ref": ref, "commits": out})
|
||||
except Exception as exc:
|
||||
return JSONResponse({"ok": False, "error": str(exc)[:240], "commits": []})
|
||||
|
||||
|
||||
@router.get("/contents")
|
||||
async def contents(ref: Annotated[str, Query()] = "main") -> JSONResponse:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
data = await _nessie(client, f"/api/v2/trees/{quote(str(ref), safe='')}/entries")
|
||||
tables = []
|
||||
namespaces = []
|
||||
for e in data.get("entries") or []:
|
||||
name = e.get("name") or {}
|
||||
elements = name.get("elements") or []
|
||||
typ = e.get("type")
|
||||
if typ == "NAMESPACE":
|
||||
namespaces.append(".".join(elements))
|
||||
elif typ == "ICEBERG_TABLE" and len(elements) >= 2:
|
||||
tables.append({
|
||||
"schema": elements[0],
|
||||
"table": elements[1],
|
||||
"key": ".".join(elements),
|
||||
"contentId": e.get("contentId"),
|
||||
"trino_fqn": _fqn(elements[0], elements[1]),
|
||||
})
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"ref": ref,
|
||||
"namespaces": namespaces,
|
||||
"tables": tables,
|
||||
"ui": NESSIE_UI_URL,
|
||||
})
|
||||
except Exception as exc:
|
||||
return JSONResponse({"ok": False, "error": str(exc)[:240], "tables": []})
|
||||
|
||||
|
||||
@router.get("/tables/{schema}/{table}/structure")
|
||||
async def table_structure(
|
||||
schema: str,
|
||||
table: str,
|
||||
snapshot_id: Annotated[str | None, Query()] = None,
|
||||
) -> JSONResponse:
|
||||
"""Iceberg structure via Trino metadata tables (works with file metastore)."""
|
||||
result: dict[str, Any] = {
|
||||
"ok": False,
|
||||
"schema": schema,
|
||||
"table": table,
|
||||
"fqn": _fqn(schema, table),
|
||||
"snapshots": [],
|
||||
"manifests": [],
|
||||
"files": [],
|
||||
"selected_snapshot": None,
|
||||
"as_of_sql": None,
|
||||
}
|
||||
try:
|
||||
snap_sql = (
|
||||
f'SELECT snapshot_id, committed_at, operation, summary '
|
||||
f'FROM {_meta_table(schema, table, "snapshots")} '
|
||||
f'ORDER BY committed_at DESC LIMIT 40'
|
||||
)
|
||||
snaps = await _trino_rows(snap_sql)
|
||||
snapshots = []
|
||||
for row in snaps:
|
||||
snapshots.append({
|
||||
"snapshot_id": str(row[0]),
|
||||
"committed_at": str(row[1]) if row[1] is not None else None,
|
||||
"operation": row[2],
|
||||
"summary": (str(row[3]) if row[3] is not None else None) if len(row) > 3 else None,
|
||||
})
|
||||
result["snapshots"] = snapshots
|
||||
current = snapshot_id or (snapshots[0]["snapshot_id"] if snapshots else None)
|
||||
result["selected_snapshot"] = current
|
||||
if current:
|
||||
result["as_of_sql"] = (
|
||||
f'SELECT * FROM {_fqn(schema, table)} FOR VERSION AS OF {current} LIMIT 100'
|
||||
)
|
||||
|
||||
# manifests
|
||||
try:
|
||||
man_sql = (
|
||||
f'SELECT path, length, partition_spec_id, added_snapshot_id, '
|
||||
f'added_data_files_count, existing_data_files_count, deleted_data_files_count '
|
||||
f'FROM {_meta_table(schema, table, "manifests")} LIMIT 200'
|
||||
)
|
||||
mans = await _trino_rows(man_sql)
|
||||
result["manifests"] = [
|
||||
{
|
||||
"path": r[0],
|
||||
"length": r[1],
|
||||
"partition_spec_id": r[2],
|
||||
"added_snapshot_id": str(r[3]) if r[3] is not None else None,
|
||||
"added_data_files_count": r[4],
|
||||
"existing_data_files_count": r[5],
|
||||
"deleted_data_files_count": r[6],
|
||||
}
|
||||
for r in mans
|
||||
]
|
||||
except Exception as exc:
|
||||
result["manifests_error"] = str(exc)[:200]
|
||||
|
||||
# data files (current snapshot view)
|
||||
try:
|
||||
files_sql = (
|
||||
f'SELECT file_path, file_format, record_count, file_size_in_bytes '
|
||||
f'FROM {_meta_table(schema, table, "files")} LIMIT 500'
|
||||
)
|
||||
files = await _trino_rows(files_sql)
|
||||
result["files"] = [
|
||||
{
|
||||
"path": r[0],
|
||||
"format": r[1],
|
||||
"record_count": r[2],
|
||||
"size_bytes": r[3],
|
||||
"is_s3": str(r[0]).startswith("s3") if r[0] else False,
|
||||
}
|
||||
for r in files
|
||||
]
|
||||
except Exception as exc:
|
||||
result["files_error"] = str(exc)[:200]
|
||||
|
||||
result["ok"] = True
|
||||
result["restore_warning"] = (
|
||||
"Restore re-points table metadata to an older snapshot. "
|
||||
"It cannot undelete files already removed from S3/disk."
|
||||
)
|
||||
return JSONResponse(result)
|
||||
except Exception as exc:
|
||||
result["error"] = str(exc)[:300]
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@router.post("/tables/{schema}/{table}/restore")
|
||||
async def restore_snapshot(
|
||||
schema: str,
|
||||
table: str,
|
||||
body: dict[str, Any] = Body(default={}),
|
||||
) -> JSONResponse:
|
||||
"""Rollback Iceberg table to a previous snapshot via Trino CALL (if supported) or documented SQL.
|
||||
|
||||
Trino 405 may not expose system.rollback; we attempt CALL and fall back to guidance.
|
||||
"""
|
||||
snapshot_id = str((body or {}).get("snapshot_id") or "").strip()
|
||||
if not snapshot_id:
|
||||
return JSONResponse({"ok": False, "error": "snapshot_id required"}, status_code=400)
|
||||
fqn = _fqn(schema, table)
|
||||
# Prefer Iceberg procedure when available
|
||||
attempts = [
|
||||
f"CALL {ICEBERG_CATALOG}.system.rollback_to_snapshot('{schema}', '{table}', {snapshot_id})",
|
||||
f"ALTER TABLE {fqn} EXECUTE rollback_to_snapshot({snapshot_id})",
|
||||
]
|
||||
errors: list[str] = []
|
||||
for sql in attempts:
|
||||
try:
|
||||
await _trino_rows(sql, deadline_s=60.0)
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"schema": schema,
|
||||
"table": table,
|
||||
"snapshot_id": snapshot_id,
|
||||
"method": sql,
|
||||
"warning": "Old data files must still exist on storage for this to succeed.",
|
||||
})
|
||||
except Exception as exc:
|
||||
errors.append(f"{sql}: {exc}")
|
||||
return JSONResponse({
|
||||
"ok": False,
|
||||
"error": "Rollback procedure not available on this Trino version",
|
||||
"hint": f'Query historically with: SELECT * FROM {fqn} FOR VERSION AS OF {snapshot_id}',
|
||||
"as_of_sql": f'SELECT * FROM {fqn} FOR VERSION AS OF {snapshot_id} LIMIT 100',
|
||||
"details": errors[:3],
|
||||
})
|
||||
|
||||
|
||||
@router.get("/s3/parquet")
|
||||
async def list_s3_parquet(prefix: Annotated[str, Query()] = "lake/,iceberg/", limit: Annotated[int, Query(ge=1, le=1000)] = 200) -> JSONResponse:
|
||||
"""List parquet (and orc) objects under lake/ and iceberg/ prefixes."""
|
||||
if not S3_ACCESS_KEY or not S3_SECRET_KEY:
|
||||
return JSONResponse({"ok": False, "error": "S3 credentials not configured", "objects": []})
|
||||
try:
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=S3_ENDPOINT,
|
||||
aws_access_key_id=S3_ACCESS_KEY,
|
||||
aws_secret_access_key=S3_SECRET_KEY,
|
||||
region_name=os.getenv("S3_REGION", "us-east-1"),
|
||||
config=Config(s3={"addressing_style": "path"}, signature_version="s3v4"),
|
||||
)
|
||||
prefixes = [p.strip() for p in prefix.split(",") if p.strip()]
|
||||
objects: list[dict[str, Any]] = []
|
||||
for pref in prefixes:
|
||||
token = None
|
||||
while len(objects) < limit:
|
||||
kw: dict[str, Any] = {"Bucket": S3_BUCKET, "Prefix": pref, "MaxKeys": min(500, limit - len(objects))}
|
||||
if token:
|
||||
kw["ContinuationToken"] = token
|
||||
resp = s3.list_objects_v2(**kw)
|
||||
for o in resp.get("Contents") or []:
|
||||
key = o["Key"]
|
||||
low = key.lower()
|
||||
if not (low.endswith(".parquet") or low.endswith(".orc") or "/data/" in low):
|
||||
continue
|
||||
objects.append({
|
||||
"bucket": S3_BUCKET,
|
||||
"key": key,
|
||||
"size": o.get("Size"),
|
||||
"last_modified": o["LastModified"].isoformat() if o.get("LastModified") else None,
|
||||
"format": "parquet" if low.endswith(".parquet") else ("orc" if low.endswith(".orc") else "data"),
|
||||
})
|
||||
if len(objects) >= limit:
|
||||
break
|
||||
if not resp.get("IsTruncated"):
|
||||
break
|
||||
token = resp.get("NextContinuationToken")
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"bucket": S3_BUCKET,
|
||||
"prefixes": prefixes,
|
||||
"objects": objects,
|
||||
"note": "Raw S3 objects — not all are Iceberg-managed; lake/ ETL parquet has no snapshot history until registered as Iceberg.",
|
||||
})
|
||||
except Exception as exc:
|
||||
return JSONResponse({"ok": False, "error": str(exc)[:240], "objects": []})
|
||||
@@ -19,3 +19,6 @@ KAFKA_UI_URL=http://10.0.21.36:9000
|
||||
|
||||
# Dockhand API token (Profile → API tokens in Dockhand UI; prefix dh_)
|
||||
DOCKHAND_API_TOKEN=
|
||||
|
||||
NESSIE_URL=http://10.0.21.50:19120
|
||||
NESSIE_UI_URL=http://10.0.21.50:19120
|
||||
|
||||
@@ -232,6 +232,7 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
|
||||
const [flash, setFlash] = useState(false)
|
||||
const [resyncing, setResyncing] = useState(false)
|
||||
const [resyncMsg, setResyncMsg] = useState<string | null>(null)
|
||||
const [windowMin, setWindowMin] = useState(15)
|
||||
// Live overlay: CDC events counted straight off the WebSocket stream since the
|
||||
// last server stats snapshot. The top KPIs/charts = authoritative server stats
|
||||
// (refreshed every 2.5s) + this overlay, so they move in lock-step with the
|
||||
@@ -248,11 +249,11 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [c, s] = await Promise.all([fetchChanges({ limit: 200 }), fetchChangeStats(15)])
|
||||
const [c, s] = await Promise.all([fetchChanges({ limit: 200, minutes: windowMin }), fetchChangeStats(windowMin)])
|
||||
setSeed(c.changes)
|
||||
setConnected(c.connected)
|
||||
applyStats(s)
|
||||
}, [applyStats])
|
||||
}, [applyStats, windowMin])
|
||||
|
||||
const doResync = useCallback(async () => {
|
||||
setResyncing(true)
|
||||
@@ -275,9 +276,9 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const iv = setInterval(() => fetchChangeStats(15).then((s) => applyStats(s)), 2500)
|
||||
const iv = setInterval(() => fetchChangeStats(windowMin).then((s) => applyStats(s)), 2500)
|
||||
return () => clearInterval(iv)
|
||||
}, [load, applyStats])
|
||||
}, [load, applyStats, windowMin])
|
||||
|
||||
// Fold freshly-arrived WS changes into the overlay → instant top-of-page update.
|
||||
useEffect(() => {
|
||||
@@ -410,9 +411,9 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="grid shrink-0 grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<KpiCard label="New records" value={inserts} accent="#34d399" icon={PlusCircle} sub="inserts · last 15m" />
|
||||
<KpiCard label="Updates" value={updates} accent="#fbbf24" icon={Pencil} sub="modified rows · 15m" />
|
||||
<KpiCard label="Deletes" value={deletes} accent="#fb7185" icon={Trash2} sub="removed rows · 15m" />
|
||||
<KpiCard label="New records" value={inserts} accent="#34d399" icon={PlusCircle} sub={`inserts · last ${windowMin}m`} />
|
||||
<KpiCard label="Updates" value={updates} accent="#fbbf24" icon={Pencil} sub={`modified rows · ${windowMin}m`} />
|
||||
<KpiCard label="Deletes" value={deletes} accent="#fb7185" icon={Trash2} sub={`removed rows · ${windowMin}m`} />
|
||||
<KpiCard label="Throughput" value={Math.round(perMin)} accent="#38bdf8" icon={TrendingUp} sub={`changes/min · ${(stats?.consumed ?? 0).toLocaleString()} total consumed`} />
|
||||
</div>
|
||||
|
||||
@@ -420,7 +421,7 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
|
||||
<div className="grid shrink-0 grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div className="rounded-lg border border-border/60 bg-surface-raised p-3 lg:col-span-2">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-foreground-faint">
|
||||
<TrendingUp className="h-3 w-3" /> Change volume — last 15 minutes
|
||||
<TrendingUp className="h-3 w-3" /> Change volume — selected window
|
||||
</div>
|
||||
<VolumeArea buckets={liveBuckets} />
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Radio,
|
||||
GitBranch,
|
||||
Lock,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { TrinoFederationView, type SubTab } from './TrinoFederationView'
|
||||
@@ -26,14 +27,16 @@ import { LineageView } from './LineageView'
|
||||
import { GovernanceOwnershipView } from './GovernanceOwnershipView'
|
||||
import { GovernanceAccessView } from './GovernanceAccessView'
|
||||
import { ObservabilityView } from './ObservabilityView'
|
||||
import { IcebergNessieView } from './IcebergNessieView'
|
||||
|
||||
type ExplorerTab = 'business' | 'live' | SubTab | 'lineage' | 'ownership' | 'access' | 'observability'
|
||||
type ExplorerTab = 'business' | 'live' | SubTab | 'lineage' | 'ownership' | 'access' | 'observability' | 'iceberg'
|
||||
|
||||
const TABS: { id: ExplorerTab; label: string; icon: typeof Users; hint: string; live?: boolean }[] = [
|
||||
{ id: 'business', label: 'Business Overview', icon: BarChart3, hint: 'Customers, orders, workforce, supply chain & telemetry across every source' },
|
||||
{ id: 'live', label: 'Live', icon: Radio, hint: 'Realtime business activity — live counters, ingestion throughput & region matrix', live: true },
|
||||
{ id: 'federated', label: 'Federated (Trino)', icon: Network, hint: 'One SQL across all 5 databases + region scorecard joined live' },
|
||||
{ id: 'lake', label: 'Hadoop Lake', icon: HardDrive, hint: 'All business data mirrored as external Iceberg tables on HDFS' },
|
||||
{ id: 'iceberg', label: 'Iceberg', icon: Layers, hint: 'Nessie catalog · snapshots · manifests · data files · time travel' },
|
||||
{ id: 'dictionary', label: 'Data Dictionary', icon: ShieldCheck, hint: 'Every table & column with PII / masking status — exactly what the assistant sees' },
|
||||
{ id: 'lineage', label: 'Lineage', icon: GitBranch, hint: 'End-to-end data lineage with column-level PII tracing from source to curated layer' },
|
||||
{ id: 'ownership', label: 'Ownership', icon: UserCog, hint: 'Data owners, stewards, tiers & business glossary — accountability per dataset' },
|
||||
@@ -310,6 +313,7 @@ export function DataExplorerView() {
|
||||
{view === 'ownership' && <div className="flex min-h-0 flex-1 flex-col"><GovernanceOwnershipView /></div>}
|
||||
{view === 'access' && <div className="flex min-h-0 flex-1 flex-col"><GovernanceAccessView /></div>}
|
||||
{view === 'observability' && <div className="flex min-h-0 flex-1 flex-col"><ObservabilityView /></div>}
|
||||
{view === 'iceberg' && <div className="flex min-h-0 flex-1 flex-col"><IcebergNessieView /></div>}
|
||||
|
||||
{/* ───────── BUSINESS OVERVIEW ───────── */}
|
||||
{view === 'business' && (
|
||||
|
||||
@@ -22,6 +22,7 @@ const NODE_KIND: Record<string, { ring: string; chip: string; dot: string }> = {
|
||||
rag: { ring: 'border-pink-400/60', chip: 'bg-pink-500/15 text-pink-300 border-pink-400/40', dot: '#f472b6' },
|
||||
llm: { ring: 'border-rose-400/70', chip: 'bg-rose-500/15 text-rose-200 border-rose-400/50', dot: '#fb7185' },
|
||||
chat: { ring: 'border-indigo-400/60', chip: 'bg-indigo-500/15 text-indigo-300 border-indigo-400/40', dot: '#818cf8' },
|
||||
catalog: { ring: 'border-lime-400/60', chip: 'bg-lime-500/15 text-lime-300 border-lime-400/40', dot: '#a3e635' },
|
||||
}
|
||||
|
||||
const EDGE_COLOR: Record<string, string> = {
|
||||
@@ -425,9 +426,10 @@ export function DataFlowView() {
|
||||
const b = anchors[edge.to]
|
||||
if (!a || !b) return null
|
||||
const d = edgePath(a, b)
|
||||
const color = EDGE_COLOR[edge.kind] || '#64748b'
|
||||
const running = edge.state === 'running'
|
||||
const active = edge.active || running
|
||||
const offline = Boolean((edge as any).offline)
|
||||
const color = offline ? '#f43f5e' : (EDGE_COLOR[edge.kind] || '#64748b')
|
||||
const running = edge.state === 'running' && !offline
|
||||
const active = Boolean(edge.active) && !offline
|
||||
const dur = 1.6 + (i % 5) * 0.3
|
||||
return (
|
||||
<g key={`${edge.from}-${edge.to}-${edge.kind}`}>
|
||||
@@ -435,9 +437,9 @@ export function DataFlowView() {
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={running ? 2.6 : 1.4}
|
||||
strokeOpacity={active ? 0.85 : 0.28}
|
||||
strokeDasharray={edge.movement_id && !active ? '4 4' : undefined}
|
||||
strokeWidth={running ? 2.6 : offline ? 1.8 : 1.4}
|
||||
strokeOpacity={active ? 0.85 : offline ? 0.75 : 0.28}
|
||||
strokeDasharray={offline || (edge.movement_id && !active) ? '4 4' : undefined}
|
||||
/>
|
||||
{active && (
|
||||
<>
|
||||
@@ -629,6 +631,7 @@ function NodeCard({
|
||||
const kind = NODE_KIND[node.kind] || NODE_KIND.source
|
||||
const pii = node.pii
|
||||
const showPii = piiOverlay && pii?.has_pii
|
||||
const offline = (node as any).online === false || node.level === 'err'
|
||||
return (
|
||||
<button
|
||||
ref={setRef}
|
||||
@@ -636,9 +639,9 @@ function NodeCard({
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'absolute z-10 flex w-[116px] -translate-x-1/2 -translate-y-1/2 flex-col items-start gap-0.5 rounded-lg border bg-[#0f1830]/90 px-2 py-1.5 text-left shadow-md backdrop-blur transition-all hover:scale-[1.03]',
|
||||
kind.ring,
|
||||
selected && 'ring-2 ring-emerald-400/70',
|
||||
showPii && !pii?.all_masked && 'ring-2 ring-rose-400/60',
|
||||
offline ? 'border-rose-500 ring-2 ring-rose-500/70' : kind.ring,
|
||||
selected && !offline && 'ring-2 ring-emerald-400/70',
|
||||
showPii && !pii?.all_masked && !offline && 'ring-2 ring-rose-400/60',
|
||||
)}
|
||||
style={{ left: `${node.x}%`, top: `${node.y}%` }}
|
||||
>
|
||||
@@ -652,8 +655,9 @@ function NodeCard({
|
||||
</div>
|
||||
<span className="block w-full truncate text-[7px] leading-tight text-blue-100/70">{node.sub}</span>
|
||||
{node.metric && (
|
||||
<span className={cn('inline-block max-w-full truncate rounded border px-0.5 py-px font-mono text-[6px] leading-tight', kind.chip)}>
|
||||
{node.metric}
|
||||
<span className={cn('inline-block max-w-full truncate rounded border px-0.5 py-px font-mono text-[6px] leading-tight',
|
||||
offline ? 'border-rose-400/50 bg-rose-500/20 text-rose-200' : kind.chip)}>
|
||||
{offline ? `offline · ${node.metric}` : node.metric}
|
||||
</span>
|
||||
)}
|
||||
{showPii && (
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Layers,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
FileBox,
|
||||
HardDrive,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
type NessieTable = { schema: string; table: string; key: string; trino_fqn: string }
|
||||
type Snapshot = { snapshot_id: string; committed_at?: string | null; operation?: string | null }
|
||||
type Manifest = {
|
||||
path: string
|
||||
length?: number
|
||||
added_snapshot_id?: string | null
|
||||
added_data_files_count?: number
|
||||
existing_data_files_count?: number
|
||||
}
|
||||
type DataFile = { path: string; format?: string; record_count?: number; size_bytes?: number; is_s3?: boolean }
|
||||
type Commit = { hash?: string; message?: string; author?: string; commitTime?: string }
|
||||
type S3Obj = { key: string; size?: number; last_modified?: string | null; format?: string }
|
||||
|
||||
function fmtBytes(n?: number | null) {
|
||||
if (n == null) return '—'
|
||||
if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GB`
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} MB`
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1)} KB`
|
||||
return `${n} B`
|
||||
}
|
||||
|
||||
export function IcebergNessieView() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [health, setHealth] = useState<any>(null)
|
||||
const [tables, setTables] = useState<NessieTable[]>([])
|
||||
const [commits, setCommits] = useState<Commit[]>([])
|
||||
const [sel, setSel] = useState<NessieTable | null>(null)
|
||||
const [structure, setStructure] = useState<any>(null)
|
||||
const [snapId, setSnapId] = useState<string | null>(null)
|
||||
const [s3objs, setS3objs] = useState<S3Obj[]>([])
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const [h, c, hist, s3] = await Promise.all([
|
||||
fetch('/api/nessie/health').then((r) => r.json()),
|
||||
fetch('/api/nessie/contents').then((r) => r.json()),
|
||||
fetch('/api/nessie/history?max_records=30').then((r) => r.json()),
|
||||
fetch('/api/nessie/s3/parquet?limit=80').then((r) => r.json()),
|
||||
])
|
||||
setHealth(h)
|
||||
setTables(c.tables || [])
|
||||
setCommits(hist.commits || [])
|
||||
setS3objs(s3.objects || [])
|
||||
if (!sel && (c.tables || []).length) setSel(c.tables[0])
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [sel])
|
||||
|
||||
const loadStructure = useCallback(async (t: NessieTable, snapshot?: string | null) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const q = snapshot ? `?snapshot_id=${encodeURIComponent(snapshot)}` : ''
|
||||
const data = await fetch(`/api/nessie/tables/${t.schema}/${t.table}/structure${q}`).then((r) => r.json())
|
||||
setStructure(data)
|
||||
setSnapId(data.selected_snapshot || null)
|
||||
} catch (e: any) {
|
||||
setStructure({ ok: false, error: String(e?.message || e) })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (sel) void loadStructure(sel, null)
|
||||
}, [sel?.key])
|
||||
|
||||
const restore = async () => {
|
||||
if (!sel || !snapId) return
|
||||
if (!confirm(`Restore ${sel.schema}.${sel.table} to snapshot ${snapId}?\n\nOnly works if old data files still exist. Nessie cannot undelete wiped S3 objects.`)) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch(`/api/nessie/tables/${sel.schema}/${sel.table}/restore`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ snapshot_id: snapId }),
|
||||
}).then((r) => r.json())
|
||||
setToast(res.ok ? `Restored to ${snapId}` : (res.hint || res.error || 'Restore failed'))
|
||||
if (res.as_of_sql) setToast((t) => `${t || ''}\n${res.as_of_sql}`)
|
||||
await loadStructure(sel, snapId)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const uiUrl = health?.nessie_ui || 'http://10.0.21.50:19120'
|
||||
const snapshots: Snapshot[] = structure?.snapshots || []
|
||||
const manifests: Manifest[] = structure?.manifests || []
|
||||
const files: DataFile[] = structure?.files || []
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 p-2">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border pb-2">
|
||||
<Layers className="h-4 w-4 text-docker" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-semibold text-foreground">Iceberg · Nessie catalog</div>
|
||||
<div className="truncate text-[10px] text-foreground-muted">
|
||||
Catalog versioning (Nessie) + manifest → data files (Iceberg via Trino)
|
||||
{health?.nessie_ok ? ' · Nessie up' : ' · Nessie down'}
|
||||
{health?.trino_ok ? ' · Trino up' : ' · Trino down'}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={uiUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-docker hover:bg-docker/10"
|
||||
>
|
||||
Nessie UI <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] hover:bg-surface-overlay"
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', loading && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="rounded border border-rose-500/40 bg-rose-500/10 px-2 py-1 text-[10px] text-rose-200">{err}</div>
|
||||
)}
|
||||
{toast && (
|
||||
<div className="whitespace-pre-wrap rounded border border-amber-400/30 bg-amber-500/10 px-2 py-1 text-[10px] text-amber-100">{toast}</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-1 items-center justify-center text-[10px] text-foreground-muted">
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" /> loading Nessie / Iceberg…
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-h-0 flex-1 gap-2 lg:grid-cols-[220px_1fr_1fr]">
|
||||
{/* Catalog */}
|
||||
<div className="flex min-h-0 flex-col rounded border border-border bg-surface/40">
|
||||
<div className="border-b border-border px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-foreground-muted">
|
||||
Tables on main
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-1">
|
||||
{tables.length === 0 && (
|
||||
<div className="p-2 text-[10px] text-foreground-faint">No ICEBERG_TABLE entries in Nessie yet.</div>
|
||||
)}
|
||||
{tables.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setSel(t)}
|
||||
className={cn(
|
||||
'mb-0.5 w-full rounded px-2 py-1.5 text-left text-[11px]',
|
||||
sel?.key === t.key ? 'bg-docker/15 text-docker' : 'hover:bg-surface-overlay text-foreground-muted',
|
||||
)}
|
||||
>
|
||||
<div className="font-medium text-foreground">{t.table}</div>
|
||||
<div className="text-[9px] opacity-70">{t.schema}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border px-2 py-1.5">
|
||||
<div className="mb-1 flex items-center gap-1 text-[9px] font-semibold uppercase text-foreground-muted">
|
||||
<GitBranch className="h-3 w-3" /> Nessie commits
|
||||
</div>
|
||||
<div className="max-h-28 overflow-auto space-y-1">
|
||||
{commits.map((c, i) => (
|
||||
<div key={c.hash || i} className="rounded bg-surface-overlay/60 px-1.5 py-1 text-[9px]">
|
||||
<div className="truncate text-foreground">{c.message || '(no message)'}</div>
|
||||
<div className="font-mono text-foreground-faint">{(c.hash || '').slice(0, 10)} · {c.commitTime || '—'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline + structure */}
|
||||
<div className="flex min-h-0 flex-col rounded border border-border bg-surface/40">
|
||||
<div className="flex items-center gap-2 border-b border-border px-2 py-1.5">
|
||||
<div className="min-w-0 flex-1 text-[10px] font-semibold uppercase tracking-wide text-foreground-muted">
|
||||
Timeline · {sel ? `${sel.schema}.${sel.table}` : '—'}
|
||||
</div>
|
||||
{busy && <Loader2 className="h-3 w-3 animate-spin text-foreground-muted" />}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!snapId || busy}
|
||||
onClick={() => void restore()}
|
||||
className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] hover:bg-rose-500/10 disabled:opacity-40"
|
||||
title="Rollback table pointer to selected snapshot"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" /> Restore
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-36 shrink-0 overflow-auto border-b border-border p-1">
|
||||
{snapshots.length === 0 && (
|
||||
<div className="p-2 text-[10px] text-foreground-faint">No snapshots (table missing in Trino iceberg catalog?)</div>
|
||||
)}
|
||||
{snapshots.map((s) => (
|
||||
<button
|
||||
key={s.snapshot_id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSnapId(s.snapshot_id)
|
||||
if (sel) void loadStructure(sel, s.snapshot_id)
|
||||
}}
|
||||
className={cn(
|
||||
'mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[10px]',
|
||||
snapId === s.snapshot_id ? 'bg-emerald-500/15 text-emerald-200' : 'hover:bg-surface-overlay',
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-[9px] opacity-70">{s.snapshot_id.slice(0, 12)}</span>
|
||||
<span className="flex-1 truncate">{s.operation || '—'}</span>
|
||||
<span className="text-foreground-faint">{s.committed_at || ''}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
<div className="mb-2 flex items-start gap-1 rounded border border-amber-400/20 bg-amber-500/5 px-2 py-1 text-[9px] text-amber-100/90">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
|
||||
{structure?.restore_warning ||
|
||||
'Restore only re-points metadata. Hard-deleted S3/disk files cannot be recovered via Nessie.'}
|
||||
</div>
|
||||
<div className="mb-1 text-[10px] font-semibold text-foreground-muted">Manifests ({manifests.length})</div>
|
||||
{manifests.slice(0, 40).map((m, i) => (
|
||||
<div key={i} className="mb-1 rounded bg-surface-overlay/50 px-2 py-1 font-mono text-[9px] text-foreground-muted">
|
||||
<div className="truncate text-foreground">{m.path}</div>
|
||||
<div>
|
||||
+{m.added_data_files_count ?? 0} / exist {m.existing_data_files_count ?? 0} · {fmtBytes(m.length)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{structure?.as_of_sql && (
|
||||
<pre className="mt-2 overflow-auto rounded border border-border bg-black/30 p-2 text-[9px] text-emerald-200/90">
|
||||
{structure.as_of_sql}
|
||||
</pre>
|
||||
)}
|
||||
{structure?.error && <div className="text-[10px] text-rose-300">{structure.error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Files */}
|
||||
<div className="flex min-h-0 flex-col gap-2">
|
||||
<div className="flex min-h-0 flex-1 flex-col rounded border border-border bg-surface/40">
|
||||
<div className="border-b border-border px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-foreground-muted">
|
||||
<span className="inline-flex items-center gap-1"><FileBox className="h-3 w-3" /> Data files ({files.length})</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-1">
|
||||
{files.map((f, i) => (
|
||||
<div key={i} className="mb-1 rounded px-2 py-1 text-[10px] hover:bg-surface-overlay">
|
||||
<div className="truncate font-mono text-[9px] text-foreground">{f.path}</div>
|
||||
<div className="text-foreground-muted">
|
||||
{f.format || '?'} · {f.record_count?.toLocaleString?.() ?? f.record_count} rows · {fmtBytes(f.size_bytes)}
|
||||
{f.is_s3 ? ' · S3' : ' · local'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!files.length && <div className="p-2 text-[10px] text-foreground-faint">No data files for this table view.</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-h-48 flex-col rounded border border-border bg-surface/40">
|
||||
<div className="border-b border-border px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-foreground-muted">
|
||||
<span className="inline-flex items-center gap-1"><HardDrive className="h-3 w-3" /> Raw S3 parquet/orc</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-1">
|
||||
{s3objs.map((o) => (
|
||||
<div key={o.key} className="truncate px-2 py-0.5 font-mono text-[9px] text-foreground-muted">
|
||||
{o.key} <span className="text-foreground-faint">· {fmtBytes(o.size)}</span>
|
||||
</div>
|
||||
))}
|
||||
{!s3objs.length && (
|
||||
<div className="p-2 text-[10px] text-foreground-faint">
|
||||
No objects under lake/,iceberg/ (or S3 creds missing). Use Object Storage for full bucket browse.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user