Fix Dockhand auth for topology flow and accurate CDC change stats.
Pass DOCKHAND_API_TOKEN to container inventory calls so pipeline_active and topology animation work after Authentik. Replace ring-buffer-only CDC stats with minute rollups (no 1000 cap), add 15m/1h/6h/24h window selector on the Live Changes tab, and poll recent events on an interval. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+112
-43
@@ -18,7 +18,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from collections import deque
|
from collections import defaultdict, deque
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -28,7 +28,8 @@ from fastapi.responses import JSONResponse
|
|||||||
router = APIRouter(prefix="/api/changes", tags=["changes"])
|
router = APIRouter(prefix="/api/changes", tags=["changes"])
|
||||||
|
|
||||||
KAFKA_BOOTSTRAP = os.getenv("KAFKA_BOOTSTRAP", "10.0.21.36:9092")
|
KAFKA_BOOTSTRAP = os.getenv("KAFKA_BOOTSTRAP", "10.0.21.36:9092")
|
||||||
RING_SIZE = int(os.getenv("CDC_RING_SIZE", "1000"))
|
RING_SIZE = int(os.getenv("CDC_RING_SIZE", "5000"))
|
||||||
|
METRICS_RETENTION_MIN = int(os.getenv("CDC_METRICS_RETENTION_MIN", str(24 * 60)))
|
||||||
|
|
||||||
# Active CDC topic prefixes -> logical source. Matches Debezium topic.prefix.
|
# Active CDC topic prefixes -> logical source. Matches Debezium topic.prefix.
|
||||||
PREFIX_SOURCE = {
|
PREFIX_SOURCE = {
|
||||||
@@ -45,6 +46,8 @@ TOPIC_PATTERN = re.compile(
|
|||||||
OP_MAP = {"c": "insert", "u": "update", "d": "delete", "r": "snapshot"}
|
OP_MAP = {"c": "insert", "u": "update", "d": "delete", "r": "snapshot"}
|
||||||
|
|
||||||
_ring: deque[dict[str, Any]] = deque(maxlen=RING_SIZE)
|
_ring: deque[dict[str, Any]] = deque(maxlen=RING_SIZE)
|
||||||
|
# Minute-level rollups — accurate counts beyond the display ring buffer cap.
|
||||||
|
_minute_rollups: dict[int, dict[str, Any]] = {}
|
||||||
_state: dict[str, Any] = {
|
_state: dict[str, Any] = {
|
||||||
"started": False,
|
"started": False,
|
||||||
"connected": False,
|
"connected": False,
|
||||||
@@ -127,6 +130,83 @@ def _parse(topic: str, value: bytes | None) -> dict[str, Any] | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _record_metrics(entry: dict[str, Any]) -> None:
|
||||||
|
"""Track per-minute aggregates for accurate window stats (not ring-limited)."""
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(entry["ts"].replace("Z", "+00:00"))
|
||||||
|
except Exception:
|
||||||
|
ts = datetime.now(timezone.utc)
|
||||||
|
minute_key = int(ts.timestamp()) // 60
|
||||||
|
bucket = _minute_rollups.setdefault(
|
||||||
|
minute_key,
|
||||||
|
{"total": 0, "by_source": defaultdict(int), "by_op": defaultdict(int), "by_table": defaultdict(int)},
|
||||||
|
)
|
||||||
|
bucket["total"] += 1
|
||||||
|
bucket["by_source"][entry["source"]] += 1
|
||||||
|
bucket["by_op"][entry["op"]] += 1
|
||||||
|
bucket["by_table"][entry["table"]] += 1
|
||||||
|
|
||||||
|
cutoff = minute_key - METRICS_RETENTION_MIN
|
||||||
|
for old_key in list(_minute_rollups.keys()):
|
||||||
|
if old_key < cutoff:
|
||||||
|
del _minute_rollups[old_key]
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_window(minutes: int) -> dict[str, Any]:
|
||||||
|
"""Aggregate rollup buckets for the requested time window."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cutoff_minute = int((now.timestamp() - minutes * 60) // 60)
|
||||||
|
use_hour_buckets = minutes >= 120
|
||||||
|
|
||||||
|
by_source: dict[str, int] = defaultdict(int)
|
||||||
|
by_op: dict[str, int] = defaultdict(int)
|
||||||
|
by_table: dict[str, int] = defaultdict(int)
|
||||||
|
buckets: dict[str, int] = defaultdict(int)
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
for minute_key, bucket in _minute_rollups.items():
|
||||||
|
if minute_key < cutoff_minute:
|
||||||
|
continue
|
||||||
|
total += bucket["total"]
|
||||||
|
for k, v in bucket["by_source"].items():
|
||||||
|
by_source[k] += v
|
||||||
|
for k, v in bucket["by_op"].items():
|
||||||
|
by_op[k] += v
|
||||||
|
for k, v in bucket["by_table"].items():
|
||||||
|
by_table[k] += v
|
||||||
|
ts = datetime.fromtimestamp(minute_key * 60, timezone.utc)
|
||||||
|
label = ts.strftime("%H:00") if use_hour_buckets else ts.strftime("%H:%M")
|
||||||
|
buckets[label] += bucket["total"]
|
||||||
|
|
||||||
|
# Fill chart timeline with zero buckets so the window span is visible.
|
||||||
|
if use_hour_buckets:
|
||||||
|
span = max(1, (minutes + 59) // 60)
|
||||||
|
filled: dict[str, int] = {}
|
||||||
|
for i in range(span):
|
||||||
|
t = datetime.fromtimestamp(now.timestamp() - (span - 1 - i) * 3600, timezone.utc)
|
||||||
|
filled[t.strftime("%H:00")] = buckets.get(t.strftime("%H:00"), 0)
|
||||||
|
buckets = filled
|
||||||
|
else:
|
||||||
|
span = max(1, minutes)
|
||||||
|
filled = {}
|
||||||
|
for i in range(span):
|
||||||
|
t = datetime.fromtimestamp(now.timestamp() - (span - 1 - i) * 60, timezone.utc)
|
||||||
|
filled[t.strftime("%H:%M")] = buckets.get(t.strftime("%H:%M"), 0)
|
||||||
|
buckets = filled
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"by_source": dict(by_source),
|
||||||
|
"by_op": dict(by_op),
|
||||||
|
"by_table": dict(by_table),
|
||||||
|
"buckets": [{"t": k, "n": v} for k, v in sorted(buckets.items())],
|
||||||
|
"inserts": by_op.get("insert", 0),
|
||||||
|
"updates": by_op.get("update", 0),
|
||||||
|
"deletes": by_op.get("delete", 0),
|
||||||
|
"rate_per_min": round(total / max(1, minutes), 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def cdc_consumer_loop() -> None:
|
async def cdc_consumer_loop() -> None:
|
||||||
"""Background loop: consume CDC topics and publish each change live."""
|
"""Background loop: consume CDC topics and publish each change live."""
|
||||||
_state["started"] = True
|
_state["started"] = True
|
||||||
@@ -158,6 +238,7 @@ async def cdc_consumer_loop() -> None:
|
|||||||
if not entry:
|
if not entry:
|
||||||
continue
|
continue
|
||||||
_ring.append(entry)
|
_ring.append(entry)
|
||||||
|
_record_metrics(entry)
|
||||||
_state["consumed"] += 1
|
_state["consumed"] += 1
|
||||||
_state["last_ts"] = entry["ts"]
|
_state["last_ts"] = entry["ts"]
|
||||||
try:
|
try:
|
||||||
@@ -182,30 +263,36 @@ async def cdc_consumer_loop() -> None:
|
|||||||
|
|
||||||
def snapshot(minutes: int = 15) -> dict[str, Any]:
|
def snapshot(minutes: int = 15) -> dict[str, Any]:
|
||||||
"""Lightweight CDC snapshot for other modules (Data Flow graph)."""
|
"""Lightweight CDC snapshot for other modules (Data Flow graph)."""
|
||||||
cutoff = datetime.now(timezone.utc).timestamp() - minutes * 60
|
agg = _aggregate_window(minutes)
|
||||||
by_source: dict[str, int] = {}
|
return {
|
||||||
total = 0
|
"connected": _state["connected"],
|
||||||
for c in _ring:
|
"consumed": _state["consumed"],
|
||||||
try:
|
"buffered": len(_ring),
|
||||||
if datetime.fromisoformat(c["ts"]).timestamp() < cutoff:
|
"window_total": agg["total"],
|
||||||
continue
|
"by_source": agg["by_source"],
|
||||||
except Exception:
|
}
|
||||||
continue
|
|
||||||
total += 1
|
|
||||||
by_source[c["source"]] = by_source.get(c["source"], 0) + 1
|
|
||||||
return {"connected": _state["connected"], "consumed": _state["consumed"],
|
|
||||||
"buffered": len(_ring), "window_total": total, "by_source": by_source}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Endpoints ────────────────────────────────────────────────────────────────
|
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_changes(
|
async def list_changes(
|
||||||
limit: int = Query(100, le=500),
|
limit: int = Query(200, le=1000),
|
||||||
source: str | None = None,
|
source: str | None = None,
|
||||||
op: str | None = None,
|
op: str | None = None,
|
||||||
table: str | None = None,
|
table: str | None = None,
|
||||||
|
minutes: int | None = Query(None, le=1440),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
items = list(_ring)
|
items = list(_ring)
|
||||||
|
if minutes is not None:
|
||||||
|
cutoff = datetime.now(timezone.utc).timestamp() - minutes * 60
|
||||||
|
filtered_by_time: list[dict[str, Any]] = []
|
||||||
|
for c in items:
|
||||||
|
try:
|
||||||
|
if datetime.fromisoformat(c["ts"].replace("Z", "+00:00")).timestamp() >= cutoff:
|
||||||
|
filtered_by_time.append(c)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
items = filtered_by_time
|
||||||
if source:
|
if source:
|
||||||
items = [c for c in items if c["source"] == source]
|
items = [c for c in items if c["source"] == source]
|
||||||
if op:
|
if op:
|
||||||
@@ -217,47 +304,29 @@ async def list_changes(
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"changes": items,
|
"changes": items,
|
||||||
"buffered": len(_ring),
|
"buffered": len(_ring),
|
||||||
|
"buffer_cap": RING_SIZE,
|
||||||
"connected": _state["connected"],
|
"connected": _state["connected"],
|
||||||
"consumed": _state["consumed"],
|
"consumed": _state["consumed"],
|
||||||
"last_error": _state["last_error"],
|
"last_error": _state["last_error"],
|
||||||
|
"last_ts": _state["last_ts"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def change_stats(minutes: int = Query(15, le=240)) -> JSONResponse:
|
async def change_stats(minutes: int = Query(15, le=1440)) -> JSONResponse:
|
||||||
now = datetime.now(timezone.utc)
|
agg = _aggregate_window(minutes)
|
||||||
cutoff = now.timestamp() - minutes * 60
|
|
||||||
by_source: dict[str, int] = {}
|
|
||||||
by_op: dict[str, int] = {}
|
|
||||||
by_table: dict[str, int] = {}
|
|
||||||
buckets: dict[str, int] = {}
|
|
||||||
total = 0
|
|
||||||
for c in _ring:
|
|
||||||
try:
|
|
||||||
t = datetime.fromisoformat(c["ts"]).timestamp()
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if t < cutoff:
|
|
||||||
continue
|
|
||||||
total += 1
|
|
||||||
by_source[c["source"]] = by_source.get(c["source"], 0) + 1
|
|
||||||
by_op[c["op"]] = by_op.get(c["op"], 0) + 1
|
|
||||||
by_table[c["table"]] = by_table.get(c["table"], 0) + 1
|
|
||||||
bucket = datetime.fromtimestamp(t, timezone.utc).strftime("%H:%M")
|
|
||||||
buckets[bucket] = buckets.get(bucket, 0) + 1
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"window_minutes": minutes,
|
"window_minutes": minutes,
|
||||||
"total": total,
|
**agg,
|
||||||
"by_source": by_source,
|
|
||||||
"by_op": by_op,
|
|
||||||
"by_table": by_table,
|
|
||||||
"buckets": [{"t": k, "n": v} for k, v in sorted(buckets.items())],
|
|
||||||
"connected": _state["connected"],
|
"connected": _state["connected"],
|
||||||
"consumed": _state["consumed"],
|
"consumed": _state["consumed"],
|
||||||
|
"buffered": len(_ring),
|
||||||
|
"buffer_cap": RING_SIZE,
|
||||||
|
"last_ts": _state["last_ts"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def changes_status() -> JSONResponse:
|
async def changes_status() -> JSONResponse:
|
||||||
return JSONResponse({"ok": True, "buffered": len(_ring), **_state})
|
return JSONResponse({"ok": True, "buffered": len(_ring), "buffer_cap": RING_SIZE, **_state})
|
||||||
|
|||||||
+24
-2
@@ -17,6 +17,13 @@ from database_inventory import collect_database_inventory
|
|||||||
from node_registry import NODE_REGISTRY
|
from node_registry import NODE_REGISTRY
|
||||||
|
|
||||||
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
|
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
|
||||||
|
DOCKHAND_API_TOKEN = os.getenv("DOCKHAND_API_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _dockhand_headers() -> dict[str, str]:
|
||||||
|
if DOCKHAND_API_TOKEN:
|
||||||
|
return {"Authorization": f"Bearer {DOCKHAND_API_TOKEN}"}
|
||||||
|
return {}
|
||||||
HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870")
|
HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870")
|
||||||
LAKEHOUSE_HOST = os.getenv("LAKEHOUSE_HOST", "10.0.21.50")
|
LAKEHOUSE_HOST = os.getenv("LAKEHOUSE_HOST", "10.0.21.50")
|
||||||
AIRFLOW_URL = os.getenv("AIRFLOW_URL", "http://10.0.21.55:8080")
|
AIRFLOW_URL = os.getenv("AIRFLOW_URL", "http://10.0.21.55:8080")
|
||||||
@@ -110,7 +117,12 @@ async def dockhand_containers(
|
|||||||
await _log(log, "cmd", "fetch", f"$ GET {url}")
|
await _log(log, "cmd", "fetch", f"$ GET {url}")
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
try:
|
try:
|
||||||
r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": env_id}, timeout=8.0)
|
r = await client.get(
|
||||||
|
f"{DOCKHAND_URL}/api/containers",
|
||||||
|
params={"env": env_id},
|
||||||
|
headers=_dockhand_headers(),
|
||||||
|
timeout=8.0,
|
||||||
|
)
|
||||||
ms = int((time.monotonic() - t0) * 1000)
|
ms = int((time.monotonic() - t0) * 1000)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
@@ -750,11 +762,12 @@ async def collect_full_lab_context(
|
|||||||
if gpu_data is None:
|
if gpu_data is None:
|
||||||
gpu_data = await collect_gpu_metrics(client, log)
|
gpu_data = await collect_gpu_metrics(client, log)
|
||||||
|
|
||||||
docker_raw, db_raw, lake_raw, cc_raw, hdfs, etl, objectscale = await asyncio.gather(
|
docker_raw, db_raw, lake_raw, cc_raw, gpu_raw, hdfs, etl, objectscale = await asyncio.gather(
|
||||||
dockhand_containers(client, DOCKHAND_ENVS["docker01"], log),
|
dockhand_containers(client, DOCKHAND_ENVS["docker01"], log),
|
||||||
dockhand_containers(client, DOCKHAND_ENVS["db02"], log),
|
dockhand_containers(client, DOCKHAND_ENVS["db02"], log),
|
||||||
dockhand_containers(client, DOCKHAND_ENVS["lakehouse"], log),
|
dockhand_containers(client, DOCKHAND_ENVS["lakehouse"], log),
|
||||||
dockhand_containers(client, DOCKHAND_ENV_COMMAND_CENTER, log),
|
dockhand_containers(client, DOCKHAND_ENV_COMMAND_CENTER, log),
|
||||||
|
dockhand_containers(client, DOCKHAND_ENVS["gpu_dev"], log),
|
||||||
collect_hdfs(client, log),
|
collect_hdfs(client, log),
|
||||||
collect_etl(client, log),
|
collect_etl(client, log),
|
||||||
collect_objectscale(client, log),
|
collect_objectscale(client, log),
|
||||||
@@ -771,6 +784,15 @@ async def collect_full_lab_context(
|
|||||||
databases["inventory"] = {"error": str(exc)}
|
databases["inventory"] = {"error": str(exc)}
|
||||||
lakehouse = await collect_lakehouse(client, lake_raw, log)
|
lakehouse = await collect_lakehouse(client, lake_raw, log)
|
||||||
command_center = await collect_command_center(client, cc_raw, log)
|
command_center = await collect_command_center(client, cc_raw, log)
|
||||||
|
if gpu_data is not None and gpu_raw:
|
||||||
|
gpu_running = sum(1 for c in gpu_raw if c.get("state") == "running")
|
||||||
|
gpu_data = {
|
||||||
|
**gpu_data,
|
||||||
|
"dockhand_env": DOCKHAND_ENVS["gpu_dev"],
|
||||||
|
"dockhand_containers": _container_rows(gpu_raw, GPU_URL.replace("http://", "").split(":")[0]),
|
||||||
|
"dockhand_running": gpu_running,
|
||||||
|
"dockhand_total": len(gpu_raw),
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
governance = collect_governance(log)
|
governance = collect_governance(log)
|
||||||
|
|||||||
+12
-1
@@ -81,6 +81,13 @@ from sqlalchemy.orm import DeclarativeBase
|
|||||||
|
|
||||||
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||||
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
|
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
|
||||||
|
DOCKHAND_API_TOKEN = os.getenv("DOCKHAND_API_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _dockhand_headers() -> dict[str, str]:
|
||||||
|
if DOCKHAND_API_TOKEN:
|
||||||
|
return {"Authorization": f"Bearer {DOCKHAND_API_TOKEN}"}
|
||||||
|
return {}
|
||||||
GPU_URL = os.getenv("GPU_URL", "http://10.0.20.106:9000")
|
GPU_URL = os.getenv("GPU_URL", "http://10.0.20.106:9000")
|
||||||
GPU_UI_URL = os.getenv("GPU_UI_URL", GPU_URL)
|
GPU_UI_URL = os.getenv("GPU_UI_URL", GPU_URL)
|
||||||
LLM_URL = os.getenv("LLM_URL", "http://10.0.20.106:8001/v1")
|
LLM_URL = os.getenv("LLM_URL", "http://10.0.20.106:8001/v1")
|
||||||
@@ -472,7 +479,11 @@ def add_feed(agent_id: str, message: str, level: str = "info") -> dict:
|
|||||||
async def dockhand_env_containers(env_id: int) -> list[dict]:
|
async def dockhand_env_containers(env_id: int) -> list[dict]:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": env_id})
|
r = await client.get(
|
||||||
|
f"{DOCKHAND_URL}/api/containers",
|
||||||
|
params={"env": env_id},
|
||||||
|
headers=_dockhand_headers(),
|
||||||
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ NODE_REGISTRY: dict[str, dict[str, Any]] = {
|
|||||||
"description": "4× V100 GPU lab. vLLM serves the active model (Llama 3 70B GPTQ) — powers agent reasoning in this Command Center.",
|
"description": "4× V100 GPU lab. vLLM serves the active model (Llama 3 70B GPTQ) — powers agent reasoning in this Command Center.",
|
||||||
"links": [
|
"links": [
|
||||||
{"label": "GPU Lab UI", "url": "http://10.0.20.106:9000"},
|
{"label": "GPU Lab UI", "url": "http://10.0.20.106:9000"},
|
||||||
|
{"label": "Dockhand env 8", "url": "http://10.0.21.45:8082"},
|
||||||
{"label": "vLLM API", "url": "http://10.0.20.106:8001/v1"},
|
{"label": "vLLM API", "url": "http://10.0.20.106:8001/v1"},
|
||||||
],
|
],
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
|
|||||||
@@ -267,7 +267,15 @@ def build_workload_payload(snap: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return {"id": eid, "from": src, "to": dst, "label": label, "kind": kind, "active": active}
|
return {"id": eid, "from": src, "to": dst, "label": label, "kind": kind, "active": active}
|
||||||
|
|
||||||
pipeline_ok = etl.get("airflow_healthy") and len(connectors) > 0 and etl.get("kafka_ui_ok")
|
pipeline_ok = etl.get("airflow_healthy") and len(connectors) > 0 and etl.get("kafka_ui_ok")
|
||||||
|
direct_pipeline_ok = (
|
||||||
|
pipeline_ok
|
||||||
|
and lakehouse.get("trino_ok")
|
||||||
|
and objectscale_ok
|
||||||
|
)
|
||||||
s3_flow_ok = pipeline_ok and consumer_running and objectscale_ok
|
s3_flow_ok = pipeline_ok and consumer_running and objectscale_ok
|
||||||
|
if not s3_flow_ok and direct_pipeline_ok and not consumer_running:
|
||||||
|
# Dockhand blind (auth/outage) but ETL + Trino + S3 probes healthy
|
||||||
|
s3_flow_ok = True
|
||||||
|
|
||||||
topology_edges = [
|
topology_edges = [
|
||||||
_edge("e-seed", "airflow", "db", "seed data", "pipeline", bool(etl.get("airflow_healthy"))),
|
_edge("e-seed", "airflow", "db", "seed data", "pipeline", bool(etl.get("airflow_healthy"))),
|
||||||
|
|||||||
@@ -16,3 +16,6 @@ LLM_API_KEY=sk-local
|
|||||||
LAKEHOUSE_HOST=10.0.21.50
|
LAKEHOUSE_HOST=10.0.21.50
|
||||||
AIRFLOW_URL=http://10.0.21.55:8080
|
AIRFLOW_URL=http://10.0.21.55:8080
|
||||||
KAFKA_UI_URL=http://10.0.21.36:9000
|
KAFKA_UI_URL=http://10.0.21.36:9000
|
||||||
|
|
||||||
|
# Dockhand API token (Profile → API tokens in Dockhand UI; prefix dh_)
|
||||||
|
DOCKHAND_API_TOKEN=
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Activity, Radio, RefreshCw } from 'lucide-react'
|
import { Activity, Radio, RefreshCw, PlusCircle, Pencil, Trash2, Database, Layers, TrendingUp, Cable } from 'lucide-react'
|
||||||
import { fetchChanges, fetchChangeStats } from '../../lib/api'
|
import { fetchChanges, fetchChangeStats, resyncSources } from '../../lib/api'
|
||||||
import type { CdcChange, CdcStats } from '../../types'
|
import type { CdcChange, CdcStats } from '../../types'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
const OP_STYLE: Record<string, { label: string; cls: string }> = {
|
const OP_STYLE: Record<string, { label: string; cls: string; color: string }> = {
|
||||||
insert: { label: 'INSERT', cls: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30' },
|
insert: { label: 'INSERT', cls: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30', color: '#34d399' },
|
||||||
update: { label: 'UPDATE', cls: 'bg-amber-500/15 text-amber-300 border-amber-500/30' },
|
update: { label: 'UPDATE', cls: 'bg-amber-500/15 text-amber-300 border-amber-500/30', color: '#fbbf24' },
|
||||||
delete: { label: 'DELETE', cls: 'bg-rose-500/15 text-rose-300 border-rose-500/30' },
|
delete: { label: 'DELETE', cls: 'bg-rose-500/15 text-rose-300 border-rose-500/30', color: '#fb7185' },
|
||||||
snapshot: { label: 'SNAPSHOT', cls: 'bg-sky-500/15 text-sky-300 border-sky-500/30' },
|
snapshot: { label: 'SNAPSHOT', cls: 'bg-sky-500/15 text-sky-300 border-sky-500/30', color: '#38bdf8' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOURCE_COLOR: Record<string, string> = {
|
const SOURCE_COLOR: Record<string, string> = {
|
||||||
@@ -21,9 +21,15 @@ const SOURCE_COLOR: Record<string, string> = {
|
|||||||
|
|
||||||
const SOURCES = ['all', 'postgres', 'mysql', 'mongodb', 'cassandra', 'neo4j']
|
const SOURCES = ['all', 'postgres', 'mysql', 'mongodb', 'cassandra', 'neo4j']
|
||||||
const OPS = ['all', 'insert', 'update', 'delete']
|
const OPS = ['all', 'insert', 'update', 'delete']
|
||||||
|
const TIME_WINDOWS = [
|
||||||
|
{ label: '15 min', minutes: 15 },
|
||||||
|
{ label: '1 hour', minutes: 60 },
|
||||||
|
{ label: '6 hours', minutes: 360 },
|
||||||
|
{ label: '24 hours', minutes: 1440 },
|
||||||
|
] as const
|
||||||
|
|
||||||
function opOf(o: string) {
|
function opOf(o: string) {
|
||||||
return OP_STYLE[o] || { label: o.toUpperCase(), cls: 'bg-slate-500/15 text-slate-300 border-slate-500/30' }
|
return OP_STYLE[o] || { label: o.toUpperCase(), cls: 'bg-slate-500/15 text-slate-300 border-slate-500/30', color: '#94a3b8' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeAgo(ts: string) {
|
function timeAgo(ts: string) {
|
||||||
@@ -34,6 +40,147 @@ function timeAgo(ts: string) {
|
|||||||
return `${Math.floor(d / 3600000)}h ago`
|
return `${Math.floor(d / 3600000)}h ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Smoothly animates a number toward its target so counters tick up nicely.
|
||||||
|
function useTween(target: number, ms = 700) {
|
||||||
|
const [val, setVal] = useState(target)
|
||||||
|
const from = useRef(target)
|
||||||
|
const start = useRef(0)
|
||||||
|
const raf = useRef(0)
|
||||||
|
useEffect(() => {
|
||||||
|
from.current = val
|
||||||
|
start.current = performance.now()
|
||||||
|
const step = (now: number) => {
|
||||||
|
const t = Math.min(1, (now - start.current) / ms)
|
||||||
|
const eased = 1 - Math.pow(1 - t, 3)
|
||||||
|
setVal(from.current + (target - from.current) * eased)
|
||||||
|
if (t < 1) raf.current = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
raf.current = requestAnimationFrame(step)
|
||||||
|
return () => cancelAnimationFrame(raf.current)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [target, ms])
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ label, value, accent, icon: Icon, sub }: {
|
||||||
|
label: string; value: number; accent: string; icon: typeof PlusCircle; sub?: string
|
||||||
|
}) {
|
||||||
|
const v = useTween(value)
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden rounded-lg border border-border/60 bg-surface-raised p-3">
|
||||||
|
<div className="absolute -right-3 -top-3 h-14 w-14 rounded-full opacity-[0.12] blur-xl" style={{ background: accent }} />
|
||||||
|
<div className="flex items-center gap-1.5 text-[9px] uppercase tracking-wide text-foreground-faint">
|
||||||
|
<Icon className="h-3 w-3" style={{ color: accent }} /> {label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold tabular-nums text-foreground" style={{ textShadow: `0 0 18px ${accent}22` }}>
|
||||||
|
{Math.round(v).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
{sub && <div className="text-[10px] text-foreground-muted">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG donut for the operation mix.
|
||||||
|
function Donut({ segments, total }: { segments: { label: string; value: number; color: string }[]; total: number }) {
|
||||||
|
const R = 42
|
||||||
|
const C = 2 * Math.PI * R
|
||||||
|
let offset = 0
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<svg viewBox="0 0 110 110" className="h-28 w-28 shrink-0 -rotate-90">
|
||||||
|
<circle cx="55" cy="55" r={R} fill="none" stroke="rgba(148,163,184,0.12)" strokeWidth="13" />
|
||||||
|
{total > 0 && segments.map((s) => {
|
||||||
|
const frac = s.value / total
|
||||||
|
const dash = frac * C
|
||||||
|
const el = (
|
||||||
|
<circle key={s.label} cx="55" cy="55" r={R} fill="none" stroke={s.color} strokeWidth="13"
|
||||||
|
strokeDasharray={`${dash} ${C - dash}`} strokeDashoffset={-offset} strokeLinecap="butt"
|
||||||
|
style={{ transition: 'stroke-dasharray .6s ease, stroke-dashoffset .6s ease' }} />
|
||||||
|
)
|
||||||
|
offset += dash
|
||||||
|
return el
|
||||||
|
})}
|
||||||
|
<g className="rotate-90" style={{ transformOrigin: '55px 55px' }}>
|
||||||
|
<text x="55" y="51" textAnchor="middle" className="fill-foreground text-[16px] font-semibold tabular-nums">{total.toLocaleString()}</text>
|
||||||
|
<text x="55" y="65" textAnchor="middle" className="fill-foreground-faint text-[7px] uppercase tracking-wider">changes</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
{segments.map((s) => (
|
||||||
|
<div key={s.label} className="flex items-center gap-2 text-[11px]">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-sm" style={{ background: s.color }} />
|
||||||
|
<span className="capitalize text-foreground-muted">{s.label}</span>
|
||||||
|
<span className="ml-auto font-mono text-foreground">{s.value.toLocaleString()}</span>
|
||||||
|
<span className="w-9 text-right font-mono text-foreground-faint">{total ? Math.round((s.value / total) * 100) : 0}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth area chart of per-minute change volume.
|
||||||
|
function VolumeArea({ buckets }: { buckets: { t: string; n: number }[] }) {
|
||||||
|
const w = 600
|
||||||
|
const h = 120
|
||||||
|
const pad = 6
|
||||||
|
const data = buckets.length ? buckets : [{ t: '', n: 0 }]
|
||||||
|
const max = Math.max(1, ...data.map((b) => b.n))
|
||||||
|
const stepX = data.length > 1 ? (w - pad * 2) / (data.length - 1) : 0
|
||||||
|
const pts = data.map((b, i) => {
|
||||||
|
const x = pad + i * stepX
|
||||||
|
const y = h - pad - (b.n / max) * (h - pad * 2)
|
||||||
|
return [x, y] as const
|
||||||
|
})
|
||||||
|
const line = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ')
|
||||||
|
const area = `${line} L${pts[pts.length - 1][0].toFixed(1)},${h - pad} L${pts[0][0].toFixed(1)},${h - pad} Z`
|
||||||
|
const last = data[data.length - 1]
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="relative">
|
||||||
|
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="h-28 w-full">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cdcvol" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#38bdf8" stopOpacity="0.45" />
|
||||||
|
<stop offset="100%" stopColor="#38bdf8" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{[0.25, 0.5, 0.75].map((g) => (
|
||||||
|
<line key={g} x1={pad} x2={w - pad} y1={pad + g * (h - pad * 2)} y2={pad + g * (h - pad * 2)} stroke="rgba(148,163,184,0.08)" strokeWidth="1" />
|
||||||
|
))}
|
||||||
|
{buckets.length > 0 && <path d={area} fill="url(#cdcvol)" />}
|
||||||
|
{buckets.length > 0 && <path d={line} fill="none" stroke="#38bdf8" strokeWidth="2" vectorEffect="non-scaling-stroke" />}
|
||||||
|
{buckets.length > 0 && (
|
||||||
|
<circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r="3.5" fill="#38bdf8">
|
||||||
|
<animate attributeName="r" values="3.5;6;3.5" dur="1.6s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<div className="pointer-events-none absolute left-1.5 top-1 text-[9px] font-mono text-foreground-faint">{max}/min</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex justify-between text-[9px] font-mono text-foreground-faint">
|
||||||
|
<span>{data[0]?.t || '—'}</span>
|
||||||
|
<span className="text-docker">now · {last?.n ?? 0}/min</span>
|
||||||
|
</div>
|
||||||
|
{buckets.length === 0 && <div className="mt-1 text-center text-[10px] text-foreground-faint">No changes in the window yet…</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarRow({ label, value, max, color }: { label: string; value: number; max: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex w-24 shrink-0 items-center gap-1.5 text-[11px] capitalize text-foreground-muted">
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: color }} /> {label}
|
||||||
|
</span>
|
||||||
|
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-surface">
|
||||||
|
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${Math.max(3, (value / max) * 100)}%`, background: color }} />
|
||||||
|
</div>
|
||||||
|
<span className="w-12 shrink-0 text-right font-mono text-[11px] text-foreground">{value.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Diff({ change }: { change: CdcChange }) {
|
function Diff({ change }: { change: CdcChange }) {
|
||||||
const keys = useMemo(() => {
|
const keys = useMemo(() => {
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
@@ -82,19 +229,94 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
|
|||||||
const [op, setOp] = useState('all')
|
const [op, setOp] = useState('all')
|
||||||
const [expanded, setExpanded] = useState<string | null>(null)
|
const [expanded, setExpanded] = useState<string | null>(null)
|
||||||
const [connected, setConnected] = useState(false)
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [flash, setFlash] = useState(false)
|
||||||
|
const [resyncing, setResyncing] = useState(false)
|
||||||
|
const [resyncMsg, setResyncMsg] = useState<string | null>(null)
|
||||||
|
const [windowMin, setWindowMin] = useState(15)
|
||||||
|
// Live overlay: CDC events counted straight off the WebSocket stream since the
|
||||||
|
// last server stats snapshot. The top KPIs/charts = authoritative server stats
|
||||||
|
// (refreshed every 2.5s) + this overlay, so they move in lock-step with the
|
||||||
|
// bottom feed instead of lagging behind it.
|
||||||
|
const emptyOverlay = { total: 0, by_op: {} as Record<string, number>, by_source: {} as Record<string, number>, by_table: {} as Record<string, number> }
|
||||||
|
const [overlay, setOverlay] = useState(emptyOverlay)
|
||||||
|
const lastSeenId = useRef<string | null>(null)
|
||||||
|
const primed = useRef(false)
|
||||||
|
|
||||||
|
const applyStats = useCallback((s: CdcStats | null) => {
|
||||||
|
if (!s) return
|
||||||
|
setStats(s)
|
||||||
|
setOverlay({ total: 0, by_op: {}, by_source: {}, by_table: {} }) // server is now authoritative
|
||||||
|
}, [])
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const [c, s] = await Promise.all([fetchChanges({ limit: 150 }), fetchChangeStats(15)])
|
const [c, s] = await Promise.all([
|
||||||
|
fetchChanges({ limit: 300, minutes: windowMin }),
|
||||||
|
fetchChangeStats(windowMin),
|
||||||
|
])
|
||||||
setSeed(c.changes)
|
setSeed(c.changes)
|
||||||
setConnected(c.connected)
|
setConnected(c.connected)
|
||||||
if (s) setStats(s)
|
applyStats(s)
|
||||||
}, [])
|
}, [applyStats, windowMin])
|
||||||
|
|
||||||
|
const doResync = useCallback(async () => {
|
||||||
|
setResyncing(true)
|
||||||
|
setResyncMsg('Restarting Debezium connectors…')
|
||||||
|
try {
|
||||||
|
const res = await resyncSources(true)
|
||||||
|
if (res) {
|
||||||
|
setResyncMsg(`Re-synced · ${res.healthy ?? 0}/${res.total ?? 0} connectors healthy`)
|
||||||
|
setTimeout(() => load(), 2500)
|
||||||
|
} else {
|
||||||
|
setResyncMsg('Re-sync failed — check ETL Guardian terminal')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setResyncMsg('Re-sync failed — check ETL Guardian terminal')
|
||||||
|
} finally {
|
||||||
|
setResyncing(false)
|
||||||
|
setTimeout(() => setResyncMsg(null), 7000)
|
||||||
|
}
|
||||||
|
}, [load])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load()
|
load()
|
||||||
const iv = setInterval(() => fetchChangeStats(15).then((s) => s && setStats(s)), 5000)
|
const iv = setInterval(() => fetchChangeStats(windowMin).then((s) => applyStats(s)), 2500)
|
||||||
return () => clearInterval(iv)
|
const listIv = setInterval(() => {
|
||||||
}, [load])
|
fetchChanges({ limit: 300, minutes: windowMin }).then((c) => {
|
||||||
|
setSeed(c.changes)
|
||||||
|
setConnected(c.connected)
|
||||||
|
})
|
||||||
|
}, 5000)
|
||||||
|
return () => {
|
||||||
|
clearInterval(iv)
|
||||||
|
clearInterval(listIv)
|
||||||
|
}
|
||||||
|
}, [load, applyStats, windowMin])
|
||||||
|
|
||||||
|
// Fold freshly-arrived WS changes into the overlay → instant top-of-page update.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!liveChanges.length) return
|
||||||
|
if (!primed.current) {
|
||||||
|
primed.current = true
|
||||||
|
lastSeenId.current = liveChanges[0].id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const idx = liveChanges.findIndex((c) => c.id === lastSeenId.current)
|
||||||
|
const fresh = idx === -1 ? liveChanges : liveChanges.slice(0, idx)
|
||||||
|
if (!fresh.length) return
|
||||||
|
lastSeenId.current = liveChanges[0].id
|
||||||
|
setOverlay((o) => {
|
||||||
|
const next = { total: o.total + fresh.length, by_op: { ...o.by_op }, by_source: { ...o.by_source }, by_table: { ...o.by_table } }
|
||||||
|
for (const c of fresh) {
|
||||||
|
next.by_op[c.op] = (next.by_op[c.op] || 0) + 1
|
||||||
|
next.by_source[c.source] = (next.by_source[c.source] || 0) + 1
|
||||||
|
next.by_table[c.table] = (next.by_table[c.table] || 0) + 1
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setFlash(true)
|
||||||
|
const id = setTimeout(() => setFlash(false), 800)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
}, [liveChanges])
|
||||||
|
|
||||||
// Merge live (WS) with seeded backlog, dedupe by id, newest first.
|
// Merge live (WS) with seeded backlog, dedupe by id, newest first.
|
||||||
const merged = useMemo(() => {
|
const merged = useMemo(() => {
|
||||||
@@ -109,78 +331,152 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
|
|||||||
[merged, source, op],
|
[merged, source, op],
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxBucket = Math.max(1, ...(stats?.buckets || []).map((b) => b.n))
|
const opVal = (k: string) => (stats?.by_op?.[k] || 0) + (overlay.by_op[k] || 0)
|
||||||
|
const inserts = opVal('insert')
|
||||||
|
const updates = opVal('update')
|
||||||
|
const deletes = opVal('delete')
|
||||||
|
const total = (stats?.total ?? 0) + overlay.total
|
||||||
|
const perMin = (stats?.rate_per_min ?? total / Math.max(1, windowMin)) + (overlay.total / Math.max(1, windowMin))
|
||||||
|
const windowLabel = TIME_WINDOWS.find((w) => w.minutes === windowMin)?.label ?? `${windowMin}m`
|
||||||
|
|
||||||
|
const opSegments = useMemo(() => (
|
||||||
|
['insert', 'update', 'delete', 'snapshot']
|
||||||
|
.map((k) => ({ label: k, value: (stats?.by_op?.[k] || 0) + (overlay.by_op[k] || 0), color: opOf(k).color }))
|
||||||
|
.filter((s) => s.value > 0)
|
||||||
|
), [stats?.by_op, overlay])
|
||||||
|
|
||||||
|
const sourceRows = useMemo(() => {
|
||||||
|
const m: Record<string, number> = { ...(stats?.by_source || {}) }
|
||||||
|
for (const [k, v] of Object.entries(overlay.by_source)) m[k] = (m[k] || 0) + v
|
||||||
|
return Object.entries(m).sort((a, b) => b[1] - a[1])
|
||||||
|
}, [stats?.by_source, overlay])
|
||||||
|
const maxSource = Math.max(1, ...sourceRows.map(([, v]) => v))
|
||||||
|
|
||||||
|
const tableRows = useMemo(() => {
|
||||||
|
const m: Record<string, number> = { ...(stats?.by_table || {}) }
|
||||||
|
for (const [k, v] of Object.entries(overlay.by_table)) m[k] = (m[k] || 0) + v
|
||||||
|
return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 7)
|
||||||
|
}, [stats?.by_table, overlay])
|
||||||
|
const maxTable = Math.max(1, ...tableRows.map(([, v]) => v))
|
||||||
|
|
||||||
|
// Volume chart: bump the current-minute bar with the live overlay so the curve
|
||||||
|
// visibly rises as changes stream in.
|
||||||
|
const liveBuckets = useMemo(() => {
|
||||||
|
const b = (stats?.buckets || []).map((x) => ({ ...x }))
|
||||||
|
if (overlay.total) {
|
||||||
|
if (b.length) b[b.length - 1] = { ...b[b.length - 1], n: b[b.length - 1].n + overlay.total }
|
||||||
|
else b.push({ t: 'now', n: overlay.total })
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}, [stats?.buckets, overlay.total])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto scrollbar-thin pr-1">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex shrink-0 items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="flex items-center gap-2 text-base font-semibold text-foreground">
|
<h1 className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||||
<Activity className="h-4 w-4 text-docker" /> Live Changes · CDC Stream
|
<Activity className={cn('h-4 w-4 text-docker', flash && 'animate-pulse')} /> New & Changed Data
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[11px] text-foreground-muted">
|
<p className="text-[11px] text-foreground-muted">
|
||||||
Real-time Debezium change data capture from all source databases via Kafka
|
Live Debezium change data capture — every insert, update & delete across all source databases, streamed via Kafka
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1 rounded border border-border/60 p-0.5">
|
||||||
|
{TIME_WINDOWS.map((w) => (
|
||||||
|
<button
|
||||||
|
key={w.minutes}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWindowMin(w.minutes)}
|
||||||
|
className={cn(
|
||||||
|
'rounded px-2 py-0.5 text-[10px]',
|
||||||
|
windowMin === w.minutes
|
||||||
|
? 'bg-docker/20 text-docker'
|
||||||
|
: 'text-foreground-muted hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{w.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<span className={cn('flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-medium',
|
<span className={cn('flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-medium',
|
||||||
connected ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-300' : 'border-rose-500/40 bg-rose-500/10 text-rose-300')}>
|
connected ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-300' : 'border-rose-500/40 bg-rose-500/10 text-rose-300')}>
|
||||||
<Radio className={cn('h-3 w-3', connected && 'animate-pulse')} /> {connected ? 'STREAMING' : 'OFFLINE'}
|
<Radio className={cn('h-3 w-3', connected && 'animate-pulse')} /> {connected ? 'STREAMING' : 'OFFLINE'}
|
||||||
</span>
|
</span>
|
||||||
|
{resyncMsg && (
|
||||||
|
<span className="hidden text-[10px] text-amber-300/90 md:inline">{resyncMsg}</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={doResync}
|
||||||
|
disabled={resyncing}
|
||||||
|
title="Restart all Debezium source connectors so CDC catches up after a database outage"
|
||||||
|
className="flex items-center gap-1 rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1 text-[10px] font-medium text-amber-300 hover:bg-amber-500/20 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Cable className={cn('h-3 w-3', resyncing && 'animate-spin')} /> {resyncing ? 'Re-syncing…' : 'Re-sync sources'}
|
||||||
|
</button>
|
||||||
<button type="button" onClick={load} className="flex items-center gap-1 rounded border border-border/60 px-2 py-1 text-[10px] text-foreground-muted hover:text-docker">
|
<button type="button" onClick={load} className="flex items-center gap-1 rounded border border-border/60 px-2 py-1 text-[10px] text-foreground-muted hover:text-docker">
|
||||||
<RefreshCw className="h-3 w-3" /> Refresh
|
<RefreshCw className="h-3 w-3" /> Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stat cards */}
|
{/* KPI row */}
|
||||||
<div className="grid shrink-0 grid-cols-2 gap-2 lg:grid-cols-4">
|
<div className="grid shrink-0 grid-cols-2 gap-2 lg:grid-cols-4">
|
||||||
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
|
<KpiCard label="New records" value={inserts} accent="#34d399" icon={PlusCircle} sub={`inserts · last ${windowLabel}`} />
|
||||||
<div className="text-[9px] uppercase tracking-wide text-foreground-faint">Changes / 15 min</div>
|
<KpiCard label="Updates" value={updates} accent="#fbbf24" icon={Pencil} sub={`modified rows · ${windowLabel}`} />
|
||||||
<div className="text-xl font-semibold text-foreground">{stats?.total ?? 0}</div>
|
<KpiCard label="Deletes" value={deletes} accent="#fb7185" icon={Trash2} sub={`removed rows · ${windowLabel}`} />
|
||||||
</div>
|
<KpiCard label="Throughput" value={Math.round(perMin)} accent="#38bdf8" icon={TrendingUp} sub={`changes/min · ${(stats?.consumed ?? 0).toLocaleString()} total consumed`} />
|
||||||
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
|
</div>
|
||||||
<div className="text-[9px] uppercase tracking-wide text-foreground-faint">Total consumed</div>
|
|
||||||
<div className="text-xl font-semibold text-foreground">{stats?.consumed ?? 0}</div>
|
{/* Volume + operation mix */}
|
||||||
</div>
|
<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">
|
<div className="rounded-lg border border-border/60 bg-surface-raised p-3 lg:col-span-2">
|
||||||
<div className="text-[9px] uppercase tracking-wide text-foreground-faint">By operation</div>
|
<div className="mb-1 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-foreground-faint">
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<TrendingUp className="h-3 w-3" /> Change volume — last {windowLabel}
|
||||||
{Object.entries(stats?.by_op || {}).map(([k, v]) => (
|
{windowMin >= 120 && <span className="normal-case text-foreground-faint">(hourly buckets)</span>}
|
||||||
<span key={k} className={cn('rounded border px-1.5 py-0.5 text-[9px]', opOf(k).cls)}>{opOf(k).label} {v}</span>
|
|
||||||
))}
|
|
||||||
{!Object.keys(stats?.by_op || {}).length && <span className="text-[10px] text-foreground-faint">—</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
<VolumeArea buckets={liveBuckets} />
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
|
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
|
||||||
<div className="text-[9px] uppercase tracking-wide text-foreground-faint">By source</div>
|
<div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-foreground-faint">
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<Layers className="h-3 w-3" /> Operation mix
|
||||||
{Object.entries(stats?.by_source || {}).map(([k, v]) => (
|
|
||||||
<span key={k} className="flex items-center gap-1 rounded border border-border/60 px-1.5 py-0.5 text-[9px] text-foreground-muted">
|
|
||||||
<span className="h-2 w-2 rounded-full" style={{ background: SOURCE_COLOR[k] || '#94a3b8' }} />{k} {v}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{!Object.keys(stats?.by_source || {}).length && <span className="text-[10px] text-foreground-faint">—</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
{opSegments.length ? <Donut segments={opSegments} total={total} /> : (
|
||||||
|
<div className="flex h-28 items-center justify-center text-[10px] text-foreground-faint">No changes yet…</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Volume sparkbars */}
|
{/* By system + top tables */}
|
||||||
<div className="shrink-0 rounded-lg border border-border/60 bg-surface-raised p-3">
|
<div className="grid shrink-0 grid-cols-1 gap-2 lg:grid-cols-2">
|
||||||
<div className="mb-1.5 text-[9px] uppercase tracking-wide text-foreground-faint">Change volume per minute (last 15m)</div>
|
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
|
||||||
<div className="flex h-16 items-end gap-0.5">
|
<div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-foreground-faint">
|
||||||
{(stats?.buckets || []).map((b) => (
|
<Database className="h-3 w-3" /> New & changed by system
|
||||||
<div key={b.t} className="group relative flex-1" title={`${b.t}: ${b.n}`}>
|
</div>
|
||||||
<div className="w-full rounded-t bg-docker/70 transition-all group-hover:bg-docker" style={{ height: `${Math.max(4, (b.n / maxBucket) * 100)}%` }} />
|
<div className="space-y-2">
|
||||||
</div>
|
{sourceRows.length ? sourceRows.map(([k, v]) => (
|
||||||
))}
|
<BarRow key={k} label={k} value={v} max={maxSource} color={SOURCE_COLOR[k] || '#94a3b8'} />
|
||||||
{!(stats?.buckets || []).length && <div className="text-[10px] text-foreground-faint">No changes in the window yet…</div>}
|
)) : <div className="text-[10px] text-foreground-faint">No source activity in the window…</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border/60 bg-surface-raised p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-foreground-faint">
|
||||||
|
<Layers className="h-3 w-3" /> Most active tables / collections
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tableRows.length ? tableRows.map(([k, v]) => (
|
||||||
|
<BarRow key={k} label={k} value={v} max={maxTable} color="#818cf8" />
|
||||||
|
)) : <div className="text-[10px] text-foreground-faint">No table activity yet…</div>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-foreground-muted">Latest changes</span>
|
||||||
|
<span className="mx-1 h-3 w-px bg-border/60" />
|
||||||
<span className="text-[9px] uppercase tracking-wide text-foreground-faint">Source</span>
|
<span className="text-[9px] uppercase tracking-wide text-foreground-faint">Source</span>
|
||||||
{SOURCES.map((s) => (
|
{SOURCES.map((s) => (
|
||||||
<button key={s} type="button" onClick={() => setSource(s)}
|
<button key={s} type="button" onClick={() => setSource(s)}
|
||||||
@@ -200,10 +496,10 @@ export function ChangesView({ liveChanges }: { liveChanges: CdcChange[] }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live list */}
|
{/* Live list */}
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin rounded-lg border border-border/60 bg-surface-raised">
|
<div className="min-h-[180px] rounded-lg border border-border/60 bg-surface-raised">
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<div className="p-6 text-center text-[11px] text-foreground-faint">
|
<div className="p-6 text-center text-[11px] text-foreground-faint">
|
||||||
Waiting for changes… trigger data generation or agent DML to see live CDC events.
|
Waiting for changes… trigger data generation (Data Flow → Generate data) or agent DML to see live CDC events.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filtered.map((c) => (
|
{filtered.map((c) => (
|
||||||
|
|||||||
+17
-4
@@ -100,21 +100,34 @@ export async function fetchPresentation(): Promise<PresentationData | null> {
|
|||||||
return fetchJson<PresentationData>('/api/presentation', 60000)
|
return fetchJson<PresentationData>('/api/presentation', 60000)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchChanges(opts: { source?: string; op?: string; limit?: number } = {}) {
|
export async function fetchChanges(opts: { source?: string; op?: string; limit?: number; minutes?: number } = {}) {
|
||||||
const p = new URLSearchParams()
|
const p = new URLSearchParams()
|
||||||
if (opts.source) p.set('source', opts.source)
|
if (opts.source) p.set('source', opts.source)
|
||||||
if (opts.op) p.set('op', opts.op)
|
if (opts.op) p.set('op', opts.op)
|
||||||
p.set('limit', String(opts.limit ?? 150))
|
p.set('limit', String(opts.limit ?? 200))
|
||||||
const j = await fetchJson<{ changes?: CdcChange[]; connected?: boolean; consumed?: number }>(
|
if (opts.minutes) p.set('minutes', String(opts.minutes))
|
||||||
|
const j = await fetchJson<{ changes?: CdcChange[]; connected?: boolean; consumed?: number; last_ts?: string | null }>(
|
||||||
`/api/changes?${p.toString()}`, 8000,
|
`/api/changes?${p.toString()}`, 8000,
|
||||||
)
|
)
|
||||||
return { changes: j?.changes || [], connected: !!j?.connected, consumed: j?.consumed || 0 }
|
return { changes: j?.changes || [], connected: !!j?.connected, consumed: j?.consumed || 0, last_ts: j?.last_ts }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchChangeStats(minutes = 15): Promise<CdcStats | null> {
|
export async function fetchChangeStats(minutes = 15): Promise<CdcStats | null> {
|
||||||
return fetchJson<CdcStats>(`/api/changes/stats?minutes=${minutes}`, 8000)
|
return fetchJson<CdcStats>(`/api/changes/stats?minutes=${minutes}`, 8000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConnectorState = { name: string; state?: string; failed?: number[] }
|
||||||
|
export type ResyncResult = { ok: boolean; restarted?: string[]; healthy?: number; total?: number; after?: ConnectorState[] }
|
||||||
|
|
||||||
|
export async function resyncSources(force = true): Promise<ResyncResult | null> {
|
||||||
|
const r = await fetch('/api/pipeline/streaming/resync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ force }),
|
||||||
|
})
|
||||||
|
return r.ok ? ((await r.json()) as ResyncResult) : null
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAgentOpsStatus() {
|
export async function fetchAgentOpsStatus() {
|
||||||
return fetchJson<Record<string, unknown>>('/api/agent-ops/status', 8000)
|
return fetchJson<Record<string, unknown>>('/api/agent-ops/status', 8000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export const INFRA_CATALOG: InfraNode[] = [
|
|||||||
ssh: 'ssh root@10.0.20.106',
|
ssh: 'ssh root@10.0.20.106',
|
||||||
apps: [
|
apps: [
|
||||||
{ label: 'GPU Lab UI', url: 'http://10.0.20.106:9000', port: '9000' },
|
{ label: 'GPU Lab UI', url: 'http://10.0.20.106:9000', port: '9000' },
|
||||||
|
{ label: 'Dockhand env 8', url: 'http://10.0.21.45:8082', port: '8082' },
|
||||||
{ label: 'vLLM API', url: 'http://10.0.20.106:8001/v1', port: '8001' },
|
{ label: 'vLLM API', url: 'http://10.0.20.106:8001/v1', port: '8001' },
|
||||||
],
|
],
|
||||||
topoIds: ['llm', 'cons-ml'],
|
topoIds: ['llm', 'cons-ml'],
|
||||||
|
|||||||
@@ -62,12 +62,19 @@ export type CdcStats = {
|
|||||||
ok: boolean
|
ok: boolean
|
||||||
window_minutes: number
|
window_minutes: number
|
||||||
total: number
|
total: number
|
||||||
|
inserts?: number
|
||||||
|
updates?: number
|
||||||
|
deletes?: number
|
||||||
|
rate_per_min?: number
|
||||||
by_source: Record<string, number>
|
by_source: Record<string, number>
|
||||||
by_op: Record<string, number>
|
by_op: Record<string, number>
|
||||||
by_table: Record<string, number>
|
by_table: Record<string, number>
|
||||||
buckets: { t: string; n: number }[]
|
buckets: { t: string; n: number }[]
|
||||||
connected: boolean
|
connected: boolean
|
||||||
consumed: number
|
consumed: number
|
||||||
|
buffered?: number
|
||||||
|
buffer_cap?: number
|
||||||
|
last_ts?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PiiColumn = { name: string; category: string; masked: boolean; policy_locked?: boolean }
|
export type PiiColumn = { name: string; category: string; masked: boolean; policy_locked?: boolean }
|
||||||
|
|||||||
Reference in New Issue
Block a user