b46e7f01dd
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>
333 lines
11 KiB
Python
333 lines
11 KiB
Python
"""Live CDC consumer for the Command Center "Changes" dashboard.
|
|
|
|
A background aiokafka consumer subscribes to the Debezium CDC topics on the
|
|
Kafka broker, parses each change event (insert/update/delete with before/after
|
|
images), keeps a bounded in-memory ring buffer, and fans every change out over
|
|
the WebSocket bus as a ``cdc_change`` event so the UI can render new & changed
|
|
data in real time.
|
|
|
|
Endpoints:
|
|
GET /api/changes -> recent changes from the ring buffer (filterable)
|
|
GET /api/changes/stats -> volume per source/table/op + per-minute buckets
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import re
|
|
import uuid
|
|
from collections import defaultdict, deque
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Query
|
|
from fastapi.responses import JSONResponse
|
|
|
|
router = APIRouter(prefix="/api/changes", tags=["changes"])
|
|
|
|
KAFKA_BOOTSTRAP = os.getenv("KAFKA_BOOTSTRAP", "10.0.21.36:9092")
|
|
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.
|
|
PREFIX_SOURCE = {
|
|
"postgres_sales": "postgres",
|
|
"mysql_hr": "mysql",
|
|
"mongodb_supplychain": "mongodb",
|
|
"cassandra_telemetry": "cassandra",
|
|
"neo4j_graph": "neo4j",
|
|
}
|
|
TOPIC_PATTERN = re.compile(
|
|
r"^(postgres_sales|mysql_hr|mongodb_supplychain|cassandra_telemetry|neo4j_graph)\..+"
|
|
)
|
|
|
|
OP_MAP = {"c": "insert", "u": "update", "d": "delete", "r": "snapshot"}
|
|
|
|
_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] = {
|
|
"started": False,
|
|
"connected": False,
|
|
"consumed": 0,
|
|
"last_error": None,
|
|
"last_ts": None,
|
|
"topics": [],
|
|
}
|
|
|
|
|
|
def _source_of(topic: str) -> tuple[str, str]:
|
|
prefix = topic.split(".", 1)[0]
|
|
source = PREFIX_SOURCE.get(prefix, prefix)
|
|
table = topic.split(".")[-1]
|
|
return source, table
|
|
|
|
|
|
def _coerce(v: Any) -> Any:
|
|
"""Debezium/Mongo nests the document as a JSON string sometimes."""
|
|
if isinstance(v, str):
|
|
try:
|
|
return json.loads(v)
|
|
except Exception:
|
|
return v
|
|
return v
|
|
|
|
|
|
def _key_fields(source: str, table: str, after: Any, before: Any) -> str:
|
|
row = after or before or {}
|
|
if not isinstance(row, dict):
|
|
return ""
|
|
prefer = ["order_id", "event_id", "_id", "id", "region", "order_status",
|
|
"event_type", "type", "amount", "salary_change"]
|
|
parts = []
|
|
for k in prefer:
|
|
if k in row and row[k] is not None:
|
|
val = row[k]
|
|
if isinstance(val, (dict, list)):
|
|
continue
|
|
sval = str(val)
|
|
if len(sval) > 40:
|
|
sval = sval[:40] + "…"
|
|
parts.append(f"{k}={sval}")
|
|
if len(parts) >= 4:
|
|
break
|
|
return " ".join(parts)
|
|
|
|
|
|
def _parse(topic: str, value: bytes | None) -> dict[str, Any] | None:
|
|
if value is None: # tombstone
|
|
return None
|
|
try:
|
|
payload = json.loads(value.decode("utf-8"))
|
|
except Exception:
|
|
return None
|
|
# Debezium envelope: {schema, payload:{before,after,op,source,ts_ms}}
|
|
if isinstance(payload, dict) and "payload" in payload and isinstance(payload["payload"], dict):
|
|
payload = payload["payload"]
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
op = OP_MAP.get(payload.get("op"), payload.get("op") or "change")
|
|
before = _coerce(payload.get("before"))
|
|
after = _coerce(payload.get("after"))
|
|
src = payload.get("source") or {}
|
|
source, table = _source_of(topic)
|
|
if isinstance(src, dict) and src.get("table"):
|
|
table = src.get("table")
|
|
ts_ms = payload.get("ts_ms")
|
|
ts = datetime.fromtimestamp(ts_ms / 1000, timezone.utc).isoformat() if ts_ms else datetime.now(timezone.utc).isoformat()
|
|
return {
|
|
"id": uuid.uuid4().hex[:10],
|
|
"ts": ts,
|
|
"source": source,
|
|
"table": table,
|
|
"topic": topic,
|
|
"op": op,
|
|
"summary": _key_fields(source, table, after, before),
|
|
"before": before if isinstance(before, dict) else None,
|
|
"after": after if isinstance(after, dict) else 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:
|
|
"""Background loop: consume CDC topics and publish each change live."""
|
|
_state["started"] = True
|
|
await asyncio.sleep(6)
|
|
try:
|
|
from aiokafka import AIOKafkaConsumer
|
|
except Exception as exc:
|
|
_state["last_error"] = f"aiokafka import failed: {exc}"
|
|
return
|
|
|
|
while True:
|
|
consumer = None
|
|
try:
|
|
consumer = AIOKafkaConsumer(
|
|
bootstrap_servers=KAFKA_BOOTSTRAP,
|
|
group_id=f"atc-cc-cdc-{uuid.uuid4().hex[:8]}",
|
|
auto_offset_reset="latest",
|
|
enable_auto_commit=False,
|
|
client_id="atc-command-center-cdc",
|
|
)
|
|
consumer.subscribe(pattern=TOPIC_PATTERN)
|
|
await consumer.start()
|
|
_state["connected"] = True
|
|
_state["last_error"] = None
|
|
from main import publish_event
|
|
|
|
async for msg in consumer:
|
|
entry = _parse(msg.topic, msg.value)
|
|
if not entry:
|
|
continue
|
|
_ring.append(entry)
|
|
_record_metrics(entry)
|
|
_state["consumed"] += 1
|
|
_state["last_ts"] = entry["ts"]
|
|
try:
|
|
_state["topics"] = sorted(consumer.subscription() or [])
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await publish_event({"type": "cdc_change", "entry": entry})
|
|
except Exception:
|
|
pass
|
|
except Exception as exc:
|
|
_state["connected"] = False
|
|
_state["last_error"] = str(exc)
|
|
await asyncio.sleep(10) # backoff then reconnect
|
|
finally:
|
|
if consumer is not None:
|
|
try:
|
|
await consumer.stop()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def snapshot(minutes: int = 15) -> dict[str, Any]:
|
|
"""Lightweight CDC snapshot for other modules (Data Flow graph)."""
|
|
agg = _aggregate_window(minutes)
|
|
return {
|
|
"connected": _state["connected"],
|
|
"consumed": _state["consumed"],
|
|
"buffered": len(_ring),
|
|
"window_total": agg["total"],
|
|
"by_source": agg["by_source"],
|
|
}
|
|
|
|
|
|
# ── Endpoints ────────────────────────────────────────────────────────────────
|
|
@router.get("")
|
|
async def list_changes(
|
|
limit: int = Query(200, le=1000),
|
|
source: str | None = None,
|
|
op: str | None = None,
|
|
table: str | None = None,
|
|
minutes: int | None = Query(None, le=1440),
|
|
) -> JSONResponse:
|
|
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:
|
|
items = [c for c in items if c["source"] == source]
|
|
if op:
|
|
items = [c for c in items if c["op"] == op]
|
|
if table:
|
|
items = [c for c in items if c["table"] == table]
|
|
items = list(reversed(items))[:limit]
|
|
return JSONResponse({
|
|
"ok": True,
|
|
"changes": items,
|
|
"buffered": len(_ring),
|
|
"buffer_cap": RING_SIZE,
|
|
"connected": _state["connected"],
|
|
"consumed": _state["consumed"],
|
|
"last_error": _state["last_error"],
|
|
"last_ts": _state["last_ts"],
|
|
})
|
|
|
|
|
|
@router.get("/stats")
|
|
async def change_stats(minutes: int = Query(15, le=1440)) -> JSONResponse:
|
|
agg = _aggregate_window(minutes)
|
|
return JSONResponse({
|
|
"ok": True,
|
|
"window_minutes": minutes,
|
|
**agg,
|
|
"connected": _state["connected"],
|
|
"consumed": _state["consumed"],
|
|
"buffered": len(_ring),
|
|
"buffer_cap": RING_SIZE,
|
|
"last_ts": _state["last_ts"],
|
|
})
|
|
|
|
|
|
@router.get("/status")
|
|
async def changes_status() -> JSONResponse:
|
|
return JSONResponse({"ok": True, "buffered": len(_ring), "buffer_cap": RING_SIZE, **_state})
|