Add Command Center v2: DQ/RAG integration, S3 browser, Jupyter, GPU matrix.
Mirror mo/atc-GPU layout with config/, docs/, scripts/ for Gitea deploy.
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 .
|
||||
COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py .
|
||||
RUN mkdir -p /data
|
||||
ENV DATABASE_URL=sqlite:////data/atc-agents.db
|
||||
EXPOSE 3201
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Per-agent live terminal buffers and streaming."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
MAX_LINES_PER_AGENT = 300
|
||||
|
||||
PublishFn = Callable[[dict[str, Any]], Awaitable[None]]
|
||||
|
||||
_buffers: dict[str, deque[dict[str, Any]]] = {}
|
||||
_publish: PublishFn | None = None
|
||||
|
||||
|
||||
def init_terminals(agent_ids: list[str]) -> None:
|
||||
for aid in agent_ids:
|
||||
if aid not in _buffers:
|
||||
_buffers[aid] = deque(maxlen=MAX_LINES_PER_AGENT)
|
||||
|
||||
|
||||
def set_terminal_publisher(fn: PublishFn) -> None:
|
||||
global _publish
|
||||
_publish = fn
|
||||
|
||||
|
||||
def get_terminal_lines(agent_id: str, limit: int = 200) -> list[dict[str, Any]]:
|
||||
buf = _buffers.get(agent_id, deque())
|
||||
items = list(buf)
|
||||
return items[-limit:]
|
||||
|
||||
|
||||
def get_all_terminals(limit: int = 200) -> dict[str, list[dict[str, Any]]]:
|
||||
return {aid: get_terminal_lines(aid, limit) for aid in _buffers}
|
||||
|
||||
|
||||
async def terminal_log(
|
||||
agent_id: str,
|
||||
text: str,
|
||||
*,
|
||||
level: str = "info",
|
||||
phase: str = "ops",
|
||||
prompt_id: str | None = None,
|
||||
mirror: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
init_terminals([agent_id])
|
||||
line = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"agent_id": agent_id,
|
||||
"level": level,
|
||||
"phase": phase,
|
||||
"text": text,
|
||||
"prompt_id": prompt_id,
|
||||
}
|
||||
_buffers[agent_id].append(line)
|
||||
if _publish and mirror:
|
||||
await _publish({"type": "terminal", "line": line})
|
||||
return line
|
||||
|
||||
|
||||
# Type: async (level, phase, text) -> None
|
||||
TerminalLogFn = Callable[[str, str, str], Awaitable[None]]
|
||||
|
||||
|
||||
def make_logger(agent_id: str, prompt_id: str | None = None) -> TerminalLogFn:
|
||||
async def log(level: str, phase: str, text: str) -> None:
|
||||
await terminal_log(agent_id, text, level=level, phase=phase, prompt_id=prompt_id)
|
||||
|
||||
return log
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Approval workflow — agents request; Mo & Bart approve before mutating actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
APPROVAL_ACTION_TYPES = {
|
||||
"docker.restart": "Docker container restart",
|
||||
"docker.update": "Docker image update / pull",
|
||||
"docker.recreate": "Docker container recreate",
|
||||
"docker.stop": "Docker container stop",
|
||||
"docker.remove": "Docker container remove",
|
||||
"docker.compose": "Docker Compose deploy",
|
||||
"docker.prune": "Docker prune / cleanup",
|
||||
"db.migrate": "Database schema migration",
|
||||
"db.restart": "Database service restart",
|
||||
"etl.restart": "ETL / connector restart",
|
||||
"kafka.reset": "Kafka topic / offset reset",
|
||||
"hdfs.mutate": "HDFS destructive operation",
|
||||
"infra.reboot": "VM / host reboot",
|
||||
"generic.mutate": "Infrastructure change",
|
||||
}
|
||||
|
||||
SUPERVISOR_IDS = ["mo-commander", "bart-commander"]
|
||||
|
||||
_INTENT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
||||
(re.compile(r"\b(restart|herstart|reboot)\b.*\b(container|docker|stack|service|mysql|postgres|kafka|airflow)"), "docker.restart"),
|
||||
(re.compile(r"\b(update|upgrade|updaten|pull|pullen)\b.*\b(docker|image|container|stack|compose)"), "docker.update"),
|
||||
(re.compile(r"\b(recreate|rebuild|redeploy|deploy|opnieuw)\b.*\b(container|docker|stack|compose|service)"), "docker.recreate"),
|
||||
(re.compile(r"\b(stop|stoppen|shutdown)\b.*\b(container|docker|service)"), "docker.stop"),
|
||||
(re.compile(r"\b(remove|delete|verwijder|rm|prune|opschonen)\b.*\b(container|docker|image|volume)"), "docker.remove"),
|
||||
(re.compile(r"\b(docker compose|compose up|stack deploy)"), "docker.compose"),
|
||||
(re.compile(r"\b(migrate|migration|schema change)\b.*\b(db|database|postgres|mysql)"), "db.migrate"),
|
||||
(re.compile(r"\b(restart|herstart)\b.*\b(db|database|postgres|mysql|mongo|cassandra)"), "db.restart"),
|
||||
(re.compile(r"\b(restart|reset)\b.*\b(connector|debezium|kafka connect)"), "etl.restart"),
|
||||
(re.compile(r"\b(reset|truncate|drop)\b.*\b(topic|kafka|offset)"), "kafka.reset"),
|
||||
(re.compile(r"\b(reboot|restart)\b.*\b(vm|host|server|node|proxmox)"), "infra.reboot"),
|
||||
]
|
||||
|
||||
_RESPONSE_ACTION_PATTERN = re.compile(
|
||||
r"\b(will|ga|moet|plan to|going to|propose|voorstel)\b.*\b(restart|update|pull|recreate|deploy|stop|remove|reboot|migrate)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def detect_approval_intent(text: str) -> dict[str, Any] | None:
|
||||
lower = text.lower().strip()
|
||||
for pattern, action_type in _INTENT_PATTERNS:
|
||||
if pattern.search(lower):
|
||||
return {
|
||||
"action_type": action_type,
|
||||
"action": text.strip()[:500],
|
||||
"reason": f"Mutating operation detected ({APPROVAL_ACTION_TYPES.get(action_type, action_type)})",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def detect_agent_proposed_action(llm_answer: str, original_message: str) -> dict[str, Any] | None:
|
||||
if not _RESPONSE_ACTION_PATTERN.search(llm_answer):
|
||||
return None
|
||||
intent = detect_approval_intent(llm_answer) or detect_approval_intent(original_message)
|
||||
if intent:
|
||||
intent["reason"] = f"Agent proposed action in mission response: {intent['reason']}"
|
||||
intent["action"] = llm_answer.strip()[:500]
|
||||
return intent
|
||||
|
||||
|
||||
def approval_to_dict(row: Any) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
raw_payload = getattr(row, "payload", None)
|
||||
if raw_payload:
|
||||
try:
|
||||
payload = json.loads(raw_payload)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
payload = {"raw": raw_payload}
|
||||
decided_at = getattr(row, "decided_at", None)
|
||||
return {
|
||||
"id": row.id,
|
||||
"ts": row.ts.isoformat() if row.ts else None,
|
||||
"agent_id": row.agent_id,
|
||||
"action": row.action,
|
||||
"reason": row.reason,
|
||||
"status": row.status,
|
||||
"action_type": getattr(row, "action_type", None) or "generic.mutate",
|
||||
"target": getattr(row, "target", None) or "",
|
||||
"payload": payload,
|
||||
"decided_by": getattr(row, "decided_by", None),
|
||||
"decide_note": getattr(row, "decide_note", None),
|
||||
"decided_at": decided_at.isoformat() if decided_at else None,
|
||||
"priority": getattr(row, "priority", None) or "normal",
|
||||
}
|
||||
|
||||
|
||||
def migrate_approval_columns(engine: Any) -> None:
|
||||
"""Legacy shim — migrations live in db.py."""
|
||||
from db import migrate_approval_columns as _migrate
|
||||
|
||||
_migrate(engine)
|
||||
|
||||
|
||||
async def create_approval_request(
|
||||
*,
|
||||
db: Session,
|
||||
ApprovalModel: type,
|
||||
agent_id: str,
|
||||
action: str,
|
||||
reason: str,
|
||||
action_type: str = "generic.mutate",
|
||||
target: str = "",
|
||||
payload: dict | None = None,
|
||||
priority: str = "normal",
|
||||
terminal_log: Callable[..., Awaitable[None]] | None = None,
|
||||
mirror_supervisors: Callable[..., Awaitable[None]] | None = None,
|
||||
publish: Callable[[dict], Awaitable[None]] | None = None,
|
||||
add_feed: Callable[[str, str, str], dict] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
import uuid
|
||||
|
||||
approval_id = str(uuid.uuid4())[:10]
|
||||
now = datetime.now(timezone.utc)
|
||||
row = ApprovalModel(
|
||||
id=approval_id,
|
||||
ts=now,
|
||||
agent_id=agent_id,
|
||||
action=action,
|
||||
reason=reason,
|
||||
status="pending",
|
||||
action_type=action_type,
|
||||
target=target,
|
||||
payload=json.dumps(payload or {}),
|
||||
priority=priority,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
item = approval_to_dict(row)
|
||||
|
||||
type_label = APPROVAL_ACTION_TYPES.get(action_type, action_type)
|
||||
alert = (
|
||||
f"⚠ APPROVAL REQUIRED · {type_label}\n"
|
||||
f" Agent: {agent_id}\n"
|
||||
f" Action: {action[:200]}\n"
|
||||
f" Target: {target or '—'}\n"
|
||||
f" Reason: {reason[:200]}\n"
|
||||
f" ID: {approval_id} — awaiting Mo & Bart"
|
||||
)
|
||||
|
||||
if terminal_log:
|
||||
for sid in SUPERVISOR_IDS:
|
||||
await terminal_log(sid, alert, level="warn", phase="approval")
|
||||
await terminal_log(
|
||||
agent_id,
|
||||
f"⏸ Action queued for approval ({approval_id}) — Mo & Bart notified",
|
||||
level="warn",
|
||||
phase="approval",
|
||||
)
|
||||
|
||||
if mirror_supervisors:
|
||||
await mirror_supervisors(
|
||||
agent_id,
|
||||
f"APPROVAL REQUEST [{approval_id}] {action_type}: {action[:120]}",
|
||||
level="warn",
|
||||
phase="approval",
|
||||
)
|
||||
|
||||
if add_feed and publish:
|
||||
feed = add_feed(
|
||||
agent_id,
|
||||
f"Approval requested ({approval_id}): {action[:80]} — waiting for Mo & Bart",
|
||||
"warn",
|
||||
)
|
||||
await publish({"type": "feed", "entry": feed})
|
||||
|
||||
if publish:
|
||||
await publish({"type": "approval_new", "approval": item})
|
||||
|
||||
return item
|
||||
|
||||
|
||||
async def decide_approval_request(
|
||||
*,
|
||||
db: Session,
|
||||
ApprovalModel: type,
|
||||
approval_id: str,
|
||||
approved: bool,
|
||||
decided_by: str = "mo-commander",
|
||||
note: str = "",
|
||||
terminal_log: Callable[..., Awaitable[None]] | None = None,
|
||||
publish: Callable[[dict], Awaitable[None]] | None = None,
|
||||
add_feed: Callable[[str, str, str], dict] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
row = db.get(ApprovalModel, approval_id)
|
||||
if not row:
|
||||
return None
|
||||
if row.status != "pending":
|
||||
return approval_to_dict(row)
|
||||
|
||||
row.status = "approved" if approved else "denied"
|
||||
row.decided_by = decided_by
|
||||
row.decide_note = note or None
|
||||
row.decided_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
item = approval_to_dict(row)
|
||||
|
||||
verb = "APPROVED" if approved else "DENIED"
|
||||
who = "Mo" if "mo" in decided_by else "Bart" if "bart" in decided_by else decided_by
|
||||
msg = f"{verb} by {who}: {row.action[:100]}"
|
||||
if note:
|
||||
msg += f" — {note[:80]}"
|
||||
|
||||
if terminal_log:
|
||||
await terminal_log(row.agent_id, msg, level="ok" if approved else "warn", phase="approval")
|
||||
for sid in SUPERVISOR_IDS:
|
||||
await terminal_log(sid, f"✓ {msg}", level="ok" if approved else "info", phase="approval")
|
||||
|
||||
if add_feed and publish:
|
||||
feed = add_feed(row.agent_id, msg, "info" if approved else "warn")
|
||||
await publish({"type": "feed", "entry": feed})
|
||||
|
||||
if publish:
|
||||
await publish({"type": "approval_update", "approval": item})
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def list_approvals(db: Session, ApprovalModel: type, status: str = "pending", limit: int = 100) -> list[dict[str, Any]]:
|
||||
q = select(ApprovalModel).order_by(ApprovalModel.ts.desc()).limit(limit)
|
||||
if status and status != "all":
|
||||
q = q.where(ApprovalModel.status == status)
|
||||
rows = db.execute(q).scalars().all()
|
||||
return [approval_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def approval_stats(db: Session, ApprovalModel: type) -> dict[str, int]:
|
||||
rows = db.execute(select(ApprovalModel)).scalars().all()
|
||||
stats = {"pending": 0, "approved": 0, "denied": 0, "total": 0}
|
||||
for r in rows:
|
||||
stats["total"] += 1
|
||||
if r.status in stats:
|
||||
stats[r.status] += 1
|
||||
return stats
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Live database inventory — sizes, row counts, schemas for LLM context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
from typing import Any
|
||||
|
||||
DB_HOST = os.getenv("DB_VAULT_HOST", "10.0.21.51")
|
||||
PG_USER = os.getenv("PG_USER", "mo")
|
||||
PG_PASS = os.getenv("PG_PASSWORD", "Dell2026!")
|
||||
MYSQL_USER = os.getenv("MYSQL_USER", "mo")
|
||||
MYSQL_PASS = os.getenv("MYSQL_PASSWORD", "Dell2026!")
|
||||
NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
|
||||
NEO4J_PASS = os.getenv("NEO4J_PASSWORD", "testpwd")
|
||||
ENGINE_TIMEOUT = float(os.getenv("DB_INVENTORY_TIMEOUT", "20"))
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
|
||||
def _fmt_bytes(n: int | float | None) -> str:
|
||||
if n is None:
|
||||
return "?"
|
||||
n = float(n)
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if n < 1024 or unit == "TB":
|
||||
return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
|
||||
|
||||
def _inventory_postgres() -> dict[str, Any]:
|
||||
import psycopg2
|
||||
|
||||
out: dict[str, Any] = {"engine": "PostgreSQL", "host": DB_HOST, "database": "postgres", "ok": False}
|
||||
try:
|
||||
conn = psycopg2.connect(
|
||||
host=DB_HOST, user=PG_USER, password=PG_PASS, dbname="postgres", connect_timeout=5,
|
||||
)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT pg_database_size(current_database())")
|
||||
out["size_bytes"] = cur.fetchone()[0]
|
||||
out["size_human"] = _fmt_bytes(out["size_bytes"])
|
||||
|
||||
cur.execute(
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_schema='public' AND table_type='BASE TABLE' ORDER BY table_name",
|
||||
)
|
||||
tables = []
|
||||
for (tname,) in cur.fetchall():
|
||||
cur.execute(f'SELECT reltuples::bigint FROM pg_class WHERE relname = %s', (tname,))
|
||||
est = cur.fetchone()
|
||||
rows = int(est[0]) if est and est[0] else None
|
||||
cur.execute(
|
||||
"SELECT column_name, data_type FROM information_schema.columns "
|
||||
"WHERE table_schema='public' AND table_name=%s ORDER BY ordinal_position",
|
||||
(tname,),
|
||||
)
|
||||
cols = [f"{c} ({dt})" for c, dt in cur.fetchall()]
|
||||
tbl: dict[str, Any] = {"name": tname, "rows": rows, "rows_estimated": True, "columns": cols}
|
||||
if tname == "sales_orders" and rows:
|
||||
cur.execute(
|
||||
"SELECT region, COUNT(*) FROM sales_orders TABLESAMPLE SYSTEM (0.1) "
|
||||
"GROUP BY region ORDER BY COUNT(*) DESC LIMIT 5",
|
||||
)
|
||||
sample = cur.fetchall()
|
||||
if sample:
|
||||
tbl["sample_regions"] = {r: c for r, c in sample}
|
||||
tables.append(tbl)
|
||||
out["tables"] = tables
|
||||
out["ok"] = True
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
out["error"] = str(exc)
|
||||
return out
|
||||
|
||||
|
||||
def _inventory_mysql() -> dict[str, Any]:
|
||||
import pymysql
|
||||
|
||||
out: dict[str, Any] = {"engine": "MySQL", "host": DB_HOST, "database": "hr", "ok": False}
|
||||
try:
|
||||
conn = pymysql.connect(
|
||||
host=DB_HOST, user=MYSQL_USER, password=MYSQL_PASS, database="hr", connect_timeout=5,
|
||||
)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT table_name, data_length+index_length, table_rows "
|
||||
"FROM information_schema.tables WHERE table_schema='hr'",
|
||||
)
|
||||
tables = []
|
||||
total_bytes = 0
|
||||
for tname, tbytes, trows in cur.fetchall():
|
||||
total_bytes += tbytes or 0
|
||||
cur.execute(f"SHOW COLUMNS FROM `{tname}`")
|
||||
cols = [f"{r[0]} ({r[1]})" for r in cur.fetchall()]
|
||||
tbl: dict[str, Any] = {
|
||||
"name": tname,
|
||||
"rows": int(trows) if trows else None,
|
||||
"rows_estimated": True,
|
||||
"size_bytes": tbytes,
|
||||
"columns": cols,
|
||||
}
|
||||
if tname == "employee_events":
|
||||
tbl["note"] = "HR employee lifecycle events (promotions, transfers, salary changes, etc.)"
|
||||
tables.append(tbl)
|
||||
out["tables"] = tables
|
||||
out["size_bytes"] = total_bytes
|
||||
out["size_human"] = _fmt_bytes(total_bytes)
|
||||
out["ok"] = True
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
out["error"] = str(exc)
|
||||
return out
|
||||
|
||||
|
||||
def _inventory_mongo() -> dict[str, Any]:
|
||||
from pymongo import MongoClient
|
||||
|
||||
out: dict[str, Any] = {"engine": "MongoDB", "host": DB_HOST, "ok": False}
|
||||
try:
|
||||
client = MongoClient(f"mongodb://{DB_HOST}:27017/", serverSelectionTimeoutMS=5000)
|
||||
db = client["supplychain"]
|
||||
collections = []
|
||||
for cname in db.list_collection_names():
|
||||
if cname.startswith("__"):
|
||||
continue
|
||||
col = db[cname]
|
||||
docs = col.estimated_document_count()
|
||||
sample = col.find_one() or {}
|
||||
fields = sorted(k for k in sample if k != "_id")
|
||||
coll: dict[str, Any] = {"name": cname, "documents": docs, "fields": fields}
|
||||
if cname == "events" and docs:
|
||||
try:
|
||||
pipe = [
|
||||
{"$sample": {"size": 5000}},
|
||||
{"$group": {"_id": "$type", "count": {"$sum": 1}}},
|
||||
{"$sort": {"count": -1}},
|
||||
{"$limit": 5},
|
||||
]
|
||||
coll["sample_types"] = {r["_id"]: r["count"] for r in col.aggregate(pipe, maxTimeMS=5000)}
|
||||
except Exception:
|
||||
pass
|
||||
collections.append(coll)
|
||||
out["database"] = "supplychain"
|
||||
out["collections"] = collections
|
||||
out["ok"] = True
|
||||
client.close()
|
||||
except Exception as exc:
|
||||
out["error"] = str(exc)
|
||||
return out
|
||||
|
||||
|
||||
def _inventory_cassandra() -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"engine": "Cassandra", "host": DB_HOST, "ok": False}
|
||||
try:
|
||||
from cassandra.cluster import Cluster
|
||||
|
||||
cluster = Cluster([DB_HOST], connect_timeout=5)
|
||||
session = cluster.connect()
|
||||
keyspaces = [
|
||||
r.keyspace_name
|
||||
for r in session.execute("SELECT keyspace_name FROM system_schema.keyspaces")
|
||||
if r.keyspace_name not in (
|
||||
"system", "system_schema", "system_traces", "system_distributed",
|
||||
"system_virtual_schema", "system_auth", "system_views",
|
||||
)
|
||||
]
|
||||
tables_out = []
|
||||
for ks in keyspaces:
|
||||
for row in session.execute(
|
||||
"SELECT table_name FROM system_schema.tables WHERE keyspace_name=%s", (ks,),
|
||||
):
|
||||
tables_out.append({
|
||||
"keyspace": ks,
|
||||
"name": row.table_name,
|
||||
"rows": None,
|
||||
"note": "COUNT skipped (large table; use Trino/Iceberg for analytics)",
|
||||
})
|
||||
out["keyspaces"] = keyspaces
|
||||
out["tables"] = tables_out
|
||||
out["ok"] = True
|
||||
cluster.shutdown()
|
||||
except Exception as exc:
|
||||
out["error"] = str(exc)
|
||||
return out
|
||||
|
||||
|
||||
def _inventory_neo4j() -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"engine": "Neo4j", "host": DB_HOST, "ok": False}
|
||||
try:
|
||||
from neo4j import GraphDatabase
|
||||
|
||||
driver = GraphDatabase.driver(f"bolt://{DB_HOST}:7687", auth=(NEO4J_USER, NEO4J_PASS))
|
||||
with driver.session() as session:
|
||||
nodes = [
|
||||
{"label": r["lbl"], "count": r["c"]}
|
||||
for r in session.run(
|
||||
"MATCH (n) RETURN labels(n)[0] AS lbl, count(*) AS c ORDER BY c DESC LIMIT 10",
|
||||
)
|
||||
]
|
||||
rels = [
|
||||
{"type": r["t"], "count": r["c"]}
|
||||
for r in session.run(
|
||||
"MATCH ()-[r]->() RETURN type(r) AS t, count(*) AS c ORDER BY c DESC LIMIT 10",
|
||||
)
|
||||
]
|
||||
out["nodes"] = nodes
|
||||
out["relationships"] = rels
|
||||
out["ok"] = True
|
||||
driver.close()
|
||||
except Exception as exc:
|
||||
out["error"] = str(exc)
|
||||
return out
|
||||
|
||||
|
||||
def _run_with_timeout(fn, timeout: float) -> dict[str, Any]:
|
||||
future = _executor.submit(fn)
|
||||
try:
|
||||
return future.result(timeout=timeout)
|
||||
except FuturesTimeout:
|
||||
return {"engine": fn.__name__.replace("_inventory_", ""), "ok": False, "error": f"timeout after {timeout}s"}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def collect_database_inventory_sync() -> dict[str, Any]:
|
||||
fns = {
|
||||
"postgresql": _inventory_postgres,
|
||||
"mysql": _inventory_mysql,
|
||||
"mongodb": _inventory_mongo,
|
||||
"cassandra": _inventory_cassandra,
|
||||
"neo4j": _inventory_neo4j,
|
||||
}
|
||||
engines = {k: _run_with_timeout(fn, ENGINE_TIMEOUT) for k, fn in fns.items()}
|
||||
ok_count = sum(1 for e in engines.values() if e.get("ok"))
|
||||
return {"host": DB_HOST, "engines_ok": ok_count, "engines_total": len(engines), "engines": engines}
|
||||
|
||||
|
||||
async def collect_database_inventory() -> dict[str, Any]:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(_executor, collect_database_inventory_sync)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Database engine, session factory, and startup migrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:////data/atc-agents.db")
|
||||
SQLITE_FALLBACK_PATH = os.getenv("SQLITE_FALLBACK_PATH", "/data/atc-agents.db")
|
||||
|
||||
|
||||
def make_engine(url: str = DATABASE_URL):
|
||||
kwargs: dict[str, Any] = {"pool_pre_ping": True}
|
||||
if url.startswith("sqlite"):
|
||||
kwargs["connect_args"] = {"check_same_thread": False}
|
||||
return create_engine(url, **kwargs)
|
||||
|
||||
|
||||
engine = make_engine()
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def migrate_approval_columns(db_engine: Any = engine) -> None:
|
||||
"""Add approval columns on legacy SQLite/Postgres schemas."""
|
||||
insp = inspect(db_engine)
|
||||
if "approvals" not in insp.get_table_names():
|
||||
return
|
||||
|
||||
existing = {c["name"] for c in insp.get_columns("approvals")}
|
||||
dialect = db_engine.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
alters = [
|
||||
("action_type", "VARCHAR(64)", "generic.mutate"),
|
||||
("target", "TEXT", ""),
|
||||
("payload", "TEXT", "{}"),
|
||||
("decided_by", "VARCHAR(64)", None),
|
||||
("decide_note", "TEXT", None),
|
||||
("decided_at", "TIMESTAMP WITH TIME ZONE", None),
|
||||
("priority", "VARCHAR(16)", "normal"),
|
||||
]
|
||||
with db_engine.begin() as conn:
|
||||
for col, typ, default in alters:
|
||||
if col in existing:
|
||||
continue
|
||||
if default is None:
|
||||
conn.execute(text(f"ALTER TABLE approvals ADD COLUMN IF NOT EXISTS {col} {typ}"))
|
||||
elif default == "":
|
||||
conn.execute(text(f"ALTER TABLE approvals ADD COLUMN IF NOT EXISTS {col} {typ} DEFAULT ''"))
|
||||
else:
|
||||
conn.execute(
|
||||
text(f"ALTER TABLE approvals ADD COLUMN IF NOT EXISTS {col} {typ} DEFAULT '{default}'")
|
||||
)
|
||||
return
|
||||
|
||||
alters = [
|
||||
("action_type", "VARCHAR(64)", "'generic.mutate'"),
|
||||
("target", "TEXT", "''"),
|
||||
("payload", "TEXT", "'{}'"),
|
||||
("decided_by", "VARCHAR(64)", "NULL"),
|
||||
("decide_note", "TEXT", "NULL"),
|
||||
("decided_at", "DATETIME", "NULL"),
|
||||
("priority", "VARCHAR(16)", "'normal'"),
|
||||
]
|
||||
with db_engine.begin() as conn:
|
||||
for col, typ, default in alters:
|
||||
if col not in existing:
|
||||
conn.execute(text(f"ALTER TABLE approvals ADD COLUMN {col} {typ} DEFAULT {default}"))
|
||||
|
||||
|
||||
def migrate_sqlite_to_postgres(db_engine: Any = engine) -> dict[str, int]:
|
||||
"""One-time copy from legacy SQLite volume into Postgres when Postgres is empty."""
|
||||
if not DATABASE_URL.startswith("postgresql"):
|
||||
return {}
|
||||
if not os.path.isfile(SQLITE_FALLBACK_PATH):
|
||||
return {}
|
||||
|
||||
insp = inspect(db_engine)
|
||||
tables = set(insp.get_table_names())
|
||||
if "approvals" not in tables or "feed" not in tables:
|
||||
return {}
|
||||
|
||||
with db_engine.connect() as conn:
|
||||
approval_count = conn.execute(text("SELECT COUNT(*) FROM approvals")).scalar() or 0
|
||||
feed_count = conn.execute(text("SELECT COUNT(*) FROM feed")).scalar() or 0
|
||||
if approval_count or feed_count:
|
||||
return {"approvals": 0, "feed": 0, "skipped": 1}
|
||||
|
||||
copied = {"approvals": 0, "feed": 0}
|
||||
src = sqlite3.connect(SQLITE_FALLBACK_PATH)
|
||||
src.row_factory = sqlite3.Row
|
||||
try:
|
||||
with db_engine.begin() as conn:
|
||||
for row in src.execute("SELECT * FROM feed"):
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO feed (id, ts, agent_id, level, message) "
|
||||
"VALUES (:id, :ts, :agent_id, :level, :message) ON CONFLICT (id) DO NOTHING"
|
||||
),
|
||||
dict(row),
|
||||
)
|
||||
copied["feed"] += 1
|
||||
for row in src.execute("SELECT * FROM approvals"):
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO approvals (id, ts, agent_id, action, reason, status, "
|
||||
"action_type, target, payload, decided_by, decide_note, decided_at, priority) "
|
||||
"VALUES (:id, :ts, :agent_id, :action, :reason, :status, "
|
||||
":action_type, :target, :payload, :decided_by, :decide_note, :decided_at, :priority) "
|
||||
"ON CONFLICT (id) DO NOTHING"
|
||||
),
|
||||
{
|
||||
"id": row["id"],
|
||||
"ts": row["ts"],
|
||||
"agent_id": row["agent_id"],
|
||||
"action": row["action"],
|
||||
"reason": row["reason"],
|
||||
"status": row["status"],
|
||||
"action_type": row["action_type"] if "action_type" in row.keys() else "generic.mutate",
|
||||
"target": row["target"] if "target" in row.keys() else "",
|
||||
"payload": row["payload"] if "payload" in row.keys() else "{}",
|
||||
"decided_by": row["decided_by"] if "decided_by" in row.keys() else None,
|
||||
"decide_note": row["decide_note"] if "decide_note" in row.keys() else None,
|
||||
"decided_at": row["decided_at"] if "decided_at" in row.keys() else None,
|
||||
"priority": row["priority"] if "priority" in row.keys() else "normal",
|
||||
},
|
||||
)
|
||||
copied["approvals"] += 1
|
||||
finally:
|
||||
src.close()
|
||||
return copied
|
||||
|
||||
|
||||
def init_database(Base: type) -> dict[str, Any]:
|
||||
"""Create tables, run migrations, optionally import legacy SQLite data."""
|
||||
Base.metadata.create_all(engine)
|
||||
migrate_approval_columns(engine)
|
||||
migrated = migrate_sqlite_to_postgres(engine)
|
||||
return {"engine": engine.dialect.name, "migrated_from_sqlite": migrated}
|
||||
|
||||
|
||||
def db_health() -> dict[str, Any]:
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
insp = inspect(engine)
|
||||
tables = insp.get_table_names()
|
||||
stats = {}
|
||||
if "approvals" in tables:
|
||||
with engine.connect() as conn:
|
||||
stats["approvals"] = conn.execute(text("SELECT COUNT(*) FROM approvals")).scalar()
|
||||
stats["approvals_pending"] = conn.execute(
|
||||
text("SELECT COUNT(*) FROM approvals WHERE status = 'pending'")
|
||||
).scalar()
|
||||
if "feed" in tables:
|
||||
with engine.connect() as conn:
|
||||
stats["feed"] = conn.execute(text("SELECT COUNT(*) FROM feed")).scalar()
|
||||
return {"ok": True, "dialect": engine.dialect.name, "tables": tables, **stats}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "dialect": engine.dialect.name, "error": str(exc)}
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Dockhand environment IDs on atc-docker01 (10.0.21.45:8082)."""
|
||||
|
||||
DOCKHAND_URL = "http://10.0.21.45:8082"
|
||||
|
||||
DOCKHAND_ENVS: dict[str, int] = {
|
||||
"docker01": 1,
|
||||
"docker02": 2,
|
||||
"management": 3,
|
||||
"db02": 5,
|
||||
"bart_gpu": 6,
|
||||
"mo_gpu": 7,
|
||||
"gpu_dev": 8,
|
||||
"lakehouse": 9,
|
||||
"airflow": 10,
|
||||
"dns1": 11,
|
||||
"dns2": 12,
|
||||
"command_center": 13,
|
||||
}
|
||||
|
||||
DOCKHAND_ENV_COMMAND_CENTER = 13
|
||||
@@ -0,0 +1,745 @@
|
||||
"""Live lab metrics for all ATC domains — fed to vLLM as context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from agent_terminal import TerminalLogFn
|
||||
from dockhand_envs import DOCKHAND_ENV_COMMAND_CENTER, DOCKHAND_ENVS
|
||||
from database_inventory import collect_database_inventory
|
||||
from node_registry import NODE_REGISTRY
|
||||
|
||||
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
|
||||
HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870")
|
||||
LAKEHOUSE_HOST = os.getenv("LAKEHOUSE_HOST", "10.0.21.50")
|
||||
AIRFLOW_URL = os.getenv("AIRFLOW_URL", "http://10.0.21.55:8080")
|
||||
KAFKA_UI_URL = os.getenv("KAFKA_UI_URL", "http://10.0.21.36:9000")
|
||||
KAFKA_CONNECT_URL = os.getenv("KAFKA_CONNECT_URL", f"http://{LAKEHOUSE_HOST}:8083")
|
||||
TRINO_URL = os.getenv("TRINO_URL", f"http://{LAKEHOUSE_HOST}:8089")
|
||||
SPARK_UI_URL = os.getenv("SPARK_UI_URL", f"http://{LAKEHOUSE_HOST}:8080")
|
||||
GPU_URL = os.getenv("GPU_URL", "http://10.0.20.106:9000")
|
||||
OBJECTSCALE_URL = os.getenv("OBJECTSCALE_URL", "http://10.0.20.111:9020")
|
||||
YARN_URL = os.getenv("YARN_URL", "http://10.0.21.61:8088")
|
||||
|
||||
AGENT_PRIMARY_DOMAIN = {
|
||||
"infra-sentinel": "docker",
|
||||
"data-custodian": "databases",
|
||||
"lakehouse-ops": "lakehouse",
|
||||
"hadoop-ranger": "hadoop",
|
||||
"etl-guardian": "etl",
|
||||
}
|
||||
|
||||
|
||||
async def _log(log: TerminalLogFn | None, level: str, phase: str, text: str) -> None:
|
||||
if log:
|
||||
await log(level, phase, text)
|
||||
|
||||
|
||||
async def _get_json(
|
||||
client: httpx.AsyncClient,
|
||||
url: str,
|
||||
log: TerminalLogFn | None = None,
|
||||
label: str = "",
|
||||
timeout: float = 6.0,
|
||||
) -> Any | None:
|
||||
name = label or url
|
||||
t0 = time.monotonic()
|
||||
await _log(log, "cmd", "fetch", f"$ GET {url}")
|
||||
try:
|
||||
r = await client.get(url, timeout=timeout)
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
if r.status_code < 400:
|
||||
await _log(log, "ok", "fetch", f"← {r.status_code} {name} ({ms}ms)")
|
||||
return r.json()
|
||||
await _log(log, "warn", "fetch", f"← {r.status_code} {name} ({ms}ms)")
|
||||
except Exception as exc:
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
await _log(log, "err", "fetch", f"✗ {name}: {exc} ({ms}ms)")
|
||||
return None
|
||||
|
||||
|
||||
async def _probe_ok(
|
||||
client: httpx.AsyncClient,
|
||||
url: str,
|
||||
log: TerminalLogFn | None = None,
|
||||
label: str = "",
|
||||
) -> bool:
|
||||
name = label or url
|
||||
t0 = time.monotonic()
|
||||
await _log(log, "cmd", "probe", f"$ GET {url}")
|
||||
try:
|
||||
r = await client.get(url, timeout=4.0)
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
ok = r.status_code < 500
|
||||
await _log(log, "ok" if ok else "warn", "probe", f"← {r.status_code} {name} ({'UP' if ok else 'DOWN'}, {ms}ms)")
|
||||
return ok
|
||||
except Exception as exc:
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
await _log(log, "err", "probe", f"✗ {name}: {exc} ({ms}ms)")
|
||||
return False
|
||||
|
||||
|
||||
def _container_rows(containers: list[dict], host: str = "") -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for c in containers:
|
||||
ports = sorted({str(p.get("PublicPort")) for p in c.get("ports", []) if p.get("PublicPort")})
|
||||
rows.append({
|
||||
"name": c.get("name"),
|
||||
"state": c.get("state"),
|
||||
"image": c.get("image"),
|
||||
"status": c.get("status"),
|
||||
"ports": ports,
|
||||
"host": host,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
async def dockhand_containers(
|
||||
client: httpx.AsyncClient,
|
||||
env_id: int,
|
||||
log: TerminalLogFn | None = None,
|
||||
) -> list[dict]:
|
||||
url = f"{DOCKHAND_URL}/api/containers?env={env_id}"
|
||||
await _log(log, "cmd", "fetch", f"$ GET {url}")
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": env_id}, timeout=8.0)
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
await _log(log, "ok", "fetch", f"← Dockhand env {env_id}: {len(data)} containers ({ms}ms)")
|
||||
return data
|
||||
except Exception as exc:
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
await _log(log, "err", "fetch", f"✗ Dockhand env {env_id}: {exc} ({ms}ms)")
|
||||
return []
|
||||
|
||||
|
||||
async def collect_hdfs(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]:
|
||||
ctx: dict[str, Any] = {"reachable": False, "namenode": HDFS_NN_URL}
|
||||
await _log(log, "info", "fetch", "▸ HDFS NameNode JMX metrics")
|
||||
try:
|
||||
fs_url = f"{HDFS_NN_URL}/jmx?qry=Hadoop:service=NameNode,name=FSNamesystem"
|
||||
nn_url = f"{HDFS_NN_URL}/jmx?qry=Hadoop:service=NameNode,name=NameNodeInfo"
|
||||
t0 = time.monotonic()
|
||||
await _log(log, "cmd", "fetch", f"$ GET {fs_url}")
|
||||
await _log(log, "cmd", "fetch", f"$ GET {nn_url}")
|
||||
fs_r, nn_r = await asyncio.gather(
|
||||
client.get(fs_url),
|
||||
client.get(nn_url),
|
||||
return_exceptions=True,
|
||||
)
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
if isinstance(fs_r, httpx.Response) and fs_r.status_code == 200:
|
||||
beans = fs_r.json().get("beans", [])
|
||||
if beans:
|
||||
b = beans[0]
|
||||
ctx.update({
|
||||
"reachable": True,
|
||||
"hostname": b.get("tag.Hostname"),
|
||||
"ha_state": b.get("tag.HAState"),
|
||||
"capacity_total_gb": b.get("CapacityTotalGB"),
|
||||
"capacity_used_gb": b.get("CapacityUsedGB"),
|
||||
"capacity_remaining_gb": b.get("CapacityRemainingGB"),
|
||||
"files_total": b.get("FilesTotal"),
|
||||
"blocks_total": b.get("BlocksTotal"),
|
||||
"live_datanodes": b.get("NumLiveDataNodes"),
|
||||
"dead_datanodes": b.get("NumDeadDataNodes"),
|
||||
"missing_blocks": b.get("MissingBlocks"),
|
||||
"under_replicated_blocks": b.get("UnderReplicatedBlocks"),
|
||||
"corrupt_blocks": b.get("CorruptBlocks"),
|
||||
"default_replication_factor": 3,
|
||||
})
|
||||
await _log(
|
||||
log, "ok", "fetch",
|
||||
f"← HDFS: {b.get('CapacityUsedGB')}GB used, {b.get('FilesTotal')} files, "
|
||||
f"{b.get('NumLiveDataNodes')} datanodes ({ms}ms)",
|
||||
)
|
||||
else:
|
||||
await _log(log, "warn", "fetch", f"← FSNamesystem JMX failed ({ms}ms)")
|
||||
|
||||
if isinstance(nn_r, httpx.Response) and nn_r.status_code == 200:
|
||||
beans = nn_r.json().get("beans", [])
|
||||
if beans:
|
||||
b = beans[0]
|
||||
live = json.loads(b.get("LiveNodes") or "{}")
|
||||
ctx["hdfs_version"] = b.get("Version")
|
||||
ctx["safemode"] = b.get("Safemode") or "off"
|
||||
ctx["percent_used"] = round(float(b.get("PercentUsed", 0)) * 100, 4)
|
||||
ctx["datanodes"] = [
|
||||
{
|
||||
"host": host.split(":")[0],
|
||||
"capacity_gb": round(node.get("capacity", 0) / (1024**3), 1),
|
||||
"used_gb": round(node.get("used", 0) / (1024**3), 4),
|
||||
"blocks": node.get("numBlocks", 0),
|
||||
"state": node.get("adminState"),
|
||||
}
|
||||
for host, node in live.items()
|
||||
]
|
||||
|
||||
yarn_url = f"{YARN_URL}/ws/v1/cluster/info"
|
||||
await _log(log, "cmd", "fetch", f"$ GET {yarn_url}")
|
||||
try:
|
||||
yr = await client.get(yarn_url, timeout=4.0)
|
||||
if yr.status_code == 200:
|
||||
yinfo = yr.json().get("clusterInfo", {})
|
||||
ctx["yarn_ok"] = True
|
||||
ctx["yarn_state"] = yinfo.get("state", "UNKNOWN")
|
||||
ctx["yarn_rm"] = YARN_URL
|
||||
await _log(log, "ok", "fetch", f"← YARN RM: {yinfo.get('state', '?')}")
|
||||
else:
|
||||
ctx["yarn_ok"] = False
|
||||
except Exception:
|
||||
ctx["yarn_ok"] = False
|
||||
|
||||
except Exception as exc:
|
||||
ctx["error"] = str(exc)
|
||||
await _log(log, "err", "fetch", f"✗ HDFS: {exc}")
|
||||
return ctx
|
||||
|
||||
|
||||
async def collect_etl(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]:
|
||||
await _log(log, "info", "fetch", "▸ ETL stack (Airflow, Kafka, Spark)")
|
||||
health, kafka_ok, spark_ok = await asyncio.gather(
|
||||
_get_json(client, f"{AIRFLOW_URL}/api/v2/monitor/health", log, "Airflow health"),
|
||||
_probe_ok(client, KAFKA_UI_URL, log, "Kafka UI"),
|
||||
_probe_ok(client, SPARK_UI_URL, log, "Spark UI"),
|
||||
)
|
||||
connectors: list[str] = []
|
||||
await _log(log, "cmd", "fetch", f"$ GET {KAFKA_CONNECT_URL}/connectors")
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
r = await client.get(f"{KAFKA_CONNECT_URL}/connectors", timeout=5.0)
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
if r.status_code == 200:
|
||||
connectors = r.json() if isinstance(r.json(), list) else []
|
||||
await _log(log, "ok", "fetch", f"← Kafka Connect: {len(connectors)} connectors ({ms}ms)")
|
||||
for c in connectors:
|
||||
await _log(log, "info", "fetch", f" · {c}")
|
||||
else:
|
||||
await _log(log, "warn", "fetch", f"← Kafka Connect {r.status_code} ({ms}ms)")
|
||||
except Exception as exc:
|
||||
await _log(log, "err", "fetch", f"✗ Kafka Connect: {exc}")
|
||||
|
||||
airflow_detail: dict[str, str] = {}
|
||||
if isinstance(health, dict):
|
||||
for comp, info in health.items():
|
||||
if isinstance(info, dict) and "status" in info:
|
||||
airflow_detail[comp] = info["status"]
|
||||
await _log(log, "info", "fetch", f" Airflow {comp}: {info['status']}")
|
||||
|
||||
connector_status: list[dict[str, Any]] = []
|
||||
for name in connectors:
|
||||
status_url = f"{KAFKA_CONNECT_URL}/connectors/{name}/status"
|
||||
await _log(log, "cmd", "fetch", f"$ GET {status_url}")
|
||||
try:
|
||||
sr = await client.get(status_url, timeout=5.0)
|
||||
if sr.status_code == 200:
|
||||
st = sr.json()
|
||||
conn = st.get("connector", {})
|
||||
tasks = st.get("tasks", [])
|
||||
state = conn.get("state", "UNKNOWN")
|
||||
task_states = [t.get("state", "?") for t in tasks]
|
||||
connector_status.append({
|
||||
"name": name,
|
||||
"state": state,
|
||||
"tasks": task_states,
|
||||
})
|
||||
await _log(log, "info", "fetch", f" · {name}: {state} tasks={task_states}")
|
||||
except Exception as exc:
|
||||
connector_status.append({"name": name, "state": "ERROR", "error": str(exc)})
|
||||
|
||||
return {
|
||||
"airflow_url": AIRFLOW_URL,
|
||||
"airflow_healthy": airflow_detail.get("scheduler") == "healthy",
|
||||
"airflow_components": airflow_detail,
|
||||
"kafka_ui_url": KAFKA_UI_URL,
|
||||
"kafka_ui_ok": kafka_ok,
|
||||
"kafka_connect_url": KAFKA_CONNECT_URL,
|
||||
"connectors": connectors,
|
||||
"connector_status": connector_status,
|
||||
"spark_ui_url": SPARK_UI_URL,
|
||||
"spark_ui_ok": spark_ok,
|
||||
}
|
||||
|
||||
|
||||
async def collect_lakehouse(
|
||||
client: httpx.AsyncClient,
|
||||
containers: list[dict],
|
||||
log: TerminalLogFn | None = None,
|
||||
) -> dict[str, Any]:
|
||||
await _log(log, "info", "fetch", "▸ Lakehouse (Trino, Spark, Kafka Connect)")
|
||||
trino_info = await _get_json(client, f"{TRINO_URL}/v1/info", log, "Trino /v1/info")
|
||||
running = sum(1 for c in containers if c.get("state") == "running")
|
||||
for c in containers:
|
||||
ports = ",".join(str(p.get("PublicPort")) for p in c.get("ports", []) if p.get("PublicPort")) or "internal"
|
||||
await _log(log, "info", "fetch", f" · {c.get('name')}: {c.get('state')} ports={ports}")
|
||||
return {
|
||||
"host": LAKEHOUSE_HOST,
|
||||
"trino_url": TRINO_URL,
|
||||
"trino_ok": trino_info is not None,
|
||||
"trino_version": (trino_info or {}).get("nodeVersion", {}).get("version"),
|
||||
"trino_uptime": (trino_info or {}).get("uptime"),
|
||||
"trino_coordinator": (trino_info or {}).get("coordinator"),
|
||||
"spark_ui_url": SPARK_UI_URL,
|
||||
"kafka_connect_url": KAFKA_CONNECT_URL,
|
||||
"containers": _container_rows(containers, LAKEHOUSE_HOST),
|
||||
"running": running,
|
||||
"total": len(containers),
|
||||
}
|
||||
|
||||
|
||||
async def collect_databases(
|
||||
client: httpx.AsyncClient,
|
||||
containers: list[dict],
|
||||
log: TerminalLogFn | None = None,
|
||||
) -> dict[str, Any]:
|
||||
await _log(log, "info", "fetch", "▸ Database vault (Dockhand env 5)")
|
||||
running = sum(1 for c in containers if c.get("state") == "running")
|
||||
rows = _container_rows(containers)
|
||||
by_engine: dict[str, list[str]] = {}
|
||||
for r in rows:
|
||||
img = (r.get("image") or "").lower()
|
||||
name = (r.get("name") or "").lower()
|
||||
if "postgres" in img or "postgres" in name:
|
||||
engine = "PostgreSQL"
|
||||
elif "mysql" in img or "mysql" in name:
|
||||
engine = "MySQL"
|
||||
elif "mongo" in img or "mongo" in name:
|
||||
engine = "MongoDB"
|
||||
elif "cassandra" in img or "cassandra" in name:
|
||||
engine = "Cassandra"
|
||||
elif "neo4j" in img or "neo4j" in name:
|
||||
engine = "Neo4j"
|
||||
else:
|
||||
engine = "Other"
|
||||
port_str = ",".join(r["ports"]) or "internal"
|
||||
by_engine.setdefault(engine, []).append(f"{r['name']} ({r['state']}, ports {port_str})")
|
||||
await _log(log, "info", "fetch", f" · {r['name']}: {r['state']} [{engine}] ports={port_str}")
|
||||
|
||||
return {
|
||||
"dockhand_env": 5,
|
||||
"running": running,
|
||||
"total": len(containers),
|
||||
"containers": rows,
|
||||
"by_engine": by_engine,
|
||||
}
|
||||
|
||||
|
||||
async def collect_command_center(
|
||||
client: httpx.AsyncClient,
|
||||
containers: list[dict],
|
||||
log: TerminalLogFn | None = None,
|
||||
) -> dict[str, Any]:
|
||||
await _log(log, "info", "fetch", f"▸ Command Center VM304 (Dockhand env {DOCKHAND_ENV_COMMAND_CENTER})")
|
||||
running = sum(1 for c in containers if c.get("state") == "running")
|
||||
rows = _container_rows(containers, "10.0.21.33")
|
||||
for r in rows:
|
||||
lvl = "info" if r.get("state") == "running" else "warn"
|
||||
await _log(log, lvl, "fetch", f" · {r.get('name')}: {r.get('state')}")
|
||||
return {
|
||||
"dockhand_env": DOCKHAND_ENV_COMMAND_CENTER,
|
||||
"dockhand_stack": "atc-agents-vm304",
|
||||
"host": "10.0.21.33",
|
||||
"vmid": 304,
|
||||
"url": "http://10.0.21.33/",
|
||||
"running": running,
|
||||
"total": len(containers),
|
||||
"containers": rows,
|
||||
}
|
||||
|
||||
|
||||
async def collect_docker_rack(
|
||||
client: httpx.AsyncClient,
|
||||
containers: list[dict],
|
||||
log: TerminalLogFn | None = None,
|
||||
) -> dict[str, Any]:
|
||||
await _log(log, "info", "fetch", "▸ Docker rack (Dockhand env 1)")
|
||||
running = sum(1 for c in containers if c.get("state") == "running")
|
||||
not_running = [c["name"] for c in containers if c.get("state") != "running"]
|
||||
for c in containers:
|
||||
ports = ",".join(str(p.get("PublicPort")) for p in c.get("ports", []) if p.get("PublicPort")) or "internal"
|
||||
lvl = "info" if c.get("state") == "running" else "warn"
|
||||
await _log(log, lvl, "fetch", f" · {c.get('name')}: {c.get('state')} ports={ports}")
|
||||
return {
|
||||
"dockhand_url": DOCKHAND_URL,
|
||||
"dockhand_env": 1,
|
||||
"running": running,
|
||||
"total": len(containers),
|
||||
"not_running": not_running,
|
||||
"containers": _container_rows(containers, "10.0.21.45"),
|
||||
}
|
||||
|
||||
|
||||
async def collect_objectscale(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]:
|
||||
await _log(log, "info", "fetch", "▸ ObjectScale S3 storage")
|
||||
ctx: dict[str, Any] = {
|
||||
"host": "10.0.20.111",
|
||||
"url": OBJECTSCALE_URL,
|
||||
"port": "9020",
|
||||
"bucket": "data",
|
||||
"reachable": False,
|
||||
}
|
||||
t0 = time.monotonic()
|
||||
await _log(log, "cmd", "probe", f"$ GET {OBJECTSCALE_URL}")
|
||||
try:
|
||||
r = await client.get(OBJECTSCALE_URL, timeout=4.0)
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
# 403/401 means API is up but unauthenticated
|
||||
ctx["reachable"] = r.status_code in (200, 401, 403, 405)
|
||||
ctx["status_code"] = r.status_code
|
||||
await _log(
|
||||
log, "ok" if ctx["reachable"] else "warn", "probe",
|
||||
f"← ObjectScale {r.status_code} ({'UP' if ctx['reachable'] else 'DOWN'}, {ms}ms)",
|
||||
)
|
||||
except Exception as exc:
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
ctx["error"] = str(exc)
|
||||
await _log(log, "err", "probe", f"✗ ObjectScale: {exc} ({ms}ms)")
|
||||
return ctx
|
||||
|
||||
|
||||
async def collect_gpu_metrics(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]:
|
||||
await _log(log, "info", "fetch", "▸ GPU Lab metrics")
|
||||
base = {"ok": False, "host": GPU_URL, "ui_url": GPU_URL}
|
||||
try:
|
||||
metrics_url = f"{GPU_URL}/api/gpu/metrics"
|
||||
model_url = f"{GPU_URL}/api/active-model"
|
||||
await _log(log, "cmd", "fetch", f"$ GET {metrics_url}")
|
||||
await _log(log, "cmd", "fetch", f"$ GET {model_url}")
|
||||
t0 = time.monotonic()
|
||||
metrics_r, model_r = await asyncio.gather(
|
||||
client.get(metrics_url),
|
||||
client.get(model_url),
|
||||
return_exceptions=True,
|
||||
)
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
gpus: list[dict[str, Any]] = []
|
||||
if isinstance(metrics_r, httpx.Response) and metrics_r.status_code == 200:
|
||||
current = metrics_r.json().get("current", {})
|
||||
gpus = [
|
||||
{
|
||||
"index": g["index"],
|
||||
"name": g["name"],
|
||||
"util_gpu": g.get("util_gpu", 0),
|
||||
"memory_used_mib": g.get("memory_used_mib", 0),
|
||||
"memory_total_mib": g.get("memory_total_mib", 0),
|
||||
"temperature_c": g.get("temperature_c", 0),
|
||||
"power_w": g.get("power_w", 0),
|
||||
}
|
||||
for g in current.get("gpus", [])
|
||||
]
|
||||
await _log(log, "ok", "fetch", f"← GPU metrics: {len(gpus)} devices ({ms}ms)")
|
||||
for g in gpus:
|
||||
await _log(
|
||||
log, "info", "fetch",
|
||||
f" GPU{g['index']}: util {g['util_gpu']:.0f}% VRAM "
|
||||
f"{g['memory_used_mib']:.0f}/{g['memory_total_mib']:.0f} MiB",
|
||||
)
|
||||
|
||||
active_model = None
|
||||
inference_active = False
|
||||
vllm_url = None
|
||||
if isinstance(model_r, httpx.Response) and model_r.status_code == 200:
|
||||
model_data = model_r.json()
|
||||
active_model = model_data.get("name")
|
||||
inference_active = bool(model_data.get("inference_active"))
|
||||
vllm_url = model_data.get("base_url")
|
||||
await _log(log, "ok", "fetch", f"← Active model: {active_model} inference={'ON' if inference_active else 'OFF'}")
|
||||
|
||||
return {
|
||||
**base,
|
||||
"ok": len(gpus) > 0 or inference_active,
|
||||
"inference_active": inference_active,
|
||||
"active_model": active_model,
|
||||
"vllm_url": vllm_url,
|
||||
"gpu_count": len(gpus),
|
||||
"gpus": gpus,
|
||||
}
|
||||
except Exception as exc:
|
||||
await _log(log, "err", "fetch", f"✗ GPU Lab: {exc}")
|
||||
return {**base, "error": str(exc)}
|
||||
|
||||
|
||||
def _section_docker(d: dict[str, Any]) -> list[str]:
|
||||
lines = [
|
||||
f"Docker rack (Dockhand env 1): {d['running']}/{d['total']} running",
|
||||
f"Dockhand: {d['dockhand_url']}",
|
||||
]
|
||||
if d.get("not_running"):
|
||||
lines.append(f"Not running: {', '.join(d['not_running'])}")
|
||||
for c in d.get("containers", []):
|
||||
port_str = ",".join(c["ports"]) if c["ports"] else "internal"
|
||||
lines.append(f" - {c['name']}: {c['state']} | {c['image']} | ports {port_str}")
|
||||
return lines
|
||||
|
||||
|
||||
def _fmt_count(n: Any) -> str:
|
||||
if n is None:
|
||||
return "?"
|
||||
try:
|
||||
return f"{int(n):,}"
|
||||
except (TypeError, ValueError):
|
||||
return str(n)
|
||||
|
||||
|
||||
def _section_databases(d: dict[str, Any]) -> list[str]:
|
||||
lines = [f"Databases (Dockhand env {d['dockhand_env']}): {d['running']}/{d['total']} running"]
|
||||
for engine, items in d.get("by_engine", {}).items():
|
||||
lines.append(f" {engine}:")
|
||||
for item in items:
|
||||
lines.append(f" - {item}")
|
||||
inv = d.get("inventory") or {}
|
||||
if inv:
|
||||
lines.append(
|
||||
f" Live data inventory @ {inv.get('host', '?')}: "
|
||||
f"{inv.get('engines_ok', 0)}/{inv.get('engines_total', 0)} engines queried"
|
||||
)
|
||||
for key, eng in (inv.get("engines") or {}).items():
|
||||
if not eng.get("ok"):
|
||||
err = str(eng.get("error", "unknown"))[:100]
|
||||
lines.append(f" {eng.get('engine', key)}: ERROR — {err}")
|
||||
continue
|
||||
label = eng.get("engine", key)
|
||||
if eng.get("size_human"):
|
||||
lines.append(f" {label} ({eng.get('database', '')}): {eng['size_human']}")
|
||||
for tbl in eng.get("tables") or []:
|
||||
rows = tbl.get("rows")
|
||||
cols = ", ".join((tbl.get("columns") or [])[:8])
|
||||
extra = ""
|
||||
if tbl.get("top_regions"):
|
||||
extra = f" | regions: {tbl['top_regions']}"
|
||||
elif tbl.get("top_event_types"):
|
||||
extra = f" | event_types: {tbl['top_event_types']}"
|
||||
lines.append(f" · {tbl['name']}: {_fmt_count(rows)} rows | cols: {cols}{extra}")
|
||||
for coll in eng.get("collections") or []:
|
||||
docs = coll.get("documents")
|
||||
fields = ", ".join(coll.get("fields") or [])
|
||||
extra = f" | types: {coll['top_types']}" if coll.get("top_types") else ""
|
||||
lines.append(f" · {coll['name']}: {_fmt_count(docs)} docs | fields: {fields}{extra}")
|
||||
for node in eng.get("nodes") or []:
|
||||
lines.append(f" · {node['label']} nodes: {_fmt_count(node.get('count'))}")
|
||||
if eng.get("relationships"):
|
||||
rels = ", ".join(f"{r['type']}={_fmt_count(r.get('count'))}" for r in eng["relationships"][:5])
|
||||
lines.append(f" · relationships: {rels or 'none'}")
|
||||
return lines
|
||||
|
||||
|
||||
def _section_lakehouse(d: dict[str, Any]) -> list[str]:
|
||||
lines = [
|
||||
f"Lakehouse host: {d['host']} — {d['running']}/{d['total']} containers running",
|
||||
f"Trino: {d['trino_url']} — {'UP' if d['trino_ok'] else 'DOWN'}"
|
||||
+ (f" (v{d['trino_version']}, uptime {d.get('trino_uptime')})" if d.get("trino_ok") else ""),
|
||||
f"Spark UI: {d['spark_ui_url']}",
|
||||
f"Kafka Connect: {d['kafka_connect_url']}",
|
||||
]
|
||||
for c in d.get("containers", []):
|
||||
port_str = ",".join(c["ports"]) if c["ports"] else "internal"
|
||||
lines.append(f" - {c['name']}: {c['state']} | {c['image']} | ports {port_str}")
|
||||
return lines
|
||||
|
||||
|
||||
def _section_etl(d: dict[str, Any]) -> list[str]:
|
||||
lines = [
|
||||
f"Airflow ({d['airflow_url']}): {'HEALTHY' if d['airflow_healthy'] else 'DEGRADED'}",
|
||||
]
|
||||
for comp, st in d.get("airflow_components", {}).items():
|
||||
lines.append(f" - {comp}: {st}")
|
||||
lines.append(f"Kafka UI ({d['kafka_ui_url']}): {'UP' if d['kafka_ui_ok'] else 'DOWN'}")
|
||||
lines.append(f"Kafka Connect ({d['kafka_connect_url']}): connectors {d.get('connectors') or 'none listed'}")
|
||||
if d.get("connectors"):
|
||||
lines.append(" Registered connector names (exact): " + ", ".join(d["connectors"]))
|
||||
for cs in d.get("connector_status") or []:
|
||||
tasks = cs.get("tasks") or []
|
||||
lines.append(f" Connector {cs['name']}: {cs.get('state', '?')}" + (f" tasks={tasks}" if tasks else ""))
|
||||
lines.append(f"Spark UI ({d['spark_ui_url']}): {'UP' if d['spark_ui_ok'] else 'DOWN'}")
|
||||
return lines
|
||||
|
||||
|
||||
def _section_hadoop(h: dict[str, Any]) -> list[str]:
|
||||
lines = ["HDFS / Hadoop:"]
|
||||
if not h.get("reachable"):
|
||||
lines.append(f" UNREACHABLE: {h.get('error', 'NameNode probe failed')}")
|
||||
return lines
|
||||
lines.extend([
|
||||
f" NameNode: {h['namenode']} ({h.get('hostname')}, HA {h.get('ha_state')})",
|
||||
f" Version: {h.get('hdfs_version')}, safemode: {h.get('safemode')}",
|
||||
f" Capacity: {h.get('capacity_used_gb')} GB used / {h.get('capacity_total_gb')} GB total "
|
||||
f"({h.get('capacity_remaining_gb')} GB free, {h.get('percent_used', 0)}% used)",
|
||||
f" Files: {h.get('files_total')}, Blocks: {h.get('blocks_total')}",
|
||||
f" DataNodes: {h.get('live_datanodes')} live, {h.get('dead_datanodes')} dead",
|
||||
f" Replication factor (dfs.replication): {h.get('default_replication_factor')}",
|
||||
f" Block health: missing={h.get('missing_blocks')}, under-replicated={h.get('under_replicated_blocks')}, corrupt={h.get('corrupt_blocks')}",
|
||||
])
|
||||
for dn in h.get("datanodes", []):
|
||||
lines.append(
|
||||
f" - {dn['host']}: {dn['used_gb']} GB / {dn['capacity_gb']} GB, {dn['blocks']} blocks, {dn['state']}"
|
||||
)
|
||||
if (h.get("capacity_used_gb") or 0) < 0.01 and (h.get("files_total") or 0) > 0:
|
||||
lines.append(" Note: metadata/small files only — almost no user data stored yet.")
|
||||
return lines
|
||||
|
||||
|
||||
def _section_gpu(g: dict[str, Any]) -> list[str]:
|
||||
lines = ["GPU Lab / vLLM inference:"]
|
||||
if not g.get("ok"):
|
||||
lines.append(f" OFFLINE: {g.get('error', 'unreachable')}")
|
||||
return lines
|
||||
lines.extend([
|
||||
f" Manager: {g.get('ui_url')}",
|
||||
f" Model: {g.get('active_model')} (inference {'ON' if g.get('inference_active') else 'OFF'})",
|
||||
f" vLLM endpoint: {g.get('vllm_url')}",
|
||||
f" GPUs: {g.get('gpu_count')}x V100",
|
||||
])
|
||||
for gpu in g.get("gpus", []):
|
||||
lines.append(
|
||||
f" GPU{gpu['index']}: util {gpu['util_gpu']:.0f}%, "
|
||||
f"VRAM {gpu['memory_used_mib']:.0f}/{gpu['memory_total_mib']:.0f} MiB, "
|
||||
f"{gpu['temperature_c']}°C, {gpu['power_w']:.0f}W"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
|
||||
|
||||
def _section_objectscale(o: dict[str, Any]) -> list[str]:
|
||||
lines = [
|
||||
f"ObjectScale S3 ({o.get('host')}:{o.get('port')}): {'REACHABLE' if o.get('reachable') else 'DOWN'}",
|
||||
f" API: {o.get('url')} (HTTP {o.get('status_code', '?')})",
|
||||
f" Bucket: {o.get('bucket', 'data')} — landing zone for s3-kafka-consumer & Iceberg",
|
||||
]
|
||||
if o.get("error"):
|
||||
lines.append(f" Error: {o['error']}")
|
||||
return lines
|
||||
|
||||
|
||||
def _section_command_center(c: dict[str, Any]) -> list[str]:
|
||||
lines = [
|
||||
f"Command Center VM304: {c.get('host')} — {c.get('running', 0)}/{c.get('total', 0)} containers",
|
||||
f" URL: {c.get('url')}",
|
||||
f" Dockhand env: {c.get('dockhand_env')}",
|
||||
]
|
||||
for row in c.get("containers", []):
|
||||
port_str = ",".join(row["ports"]) if row.get("ports") else "internal"
|
||||
lines.append(f" - {row['name']}: {row['state']} | ports {port_str}")
|
||||
return lines
|
||||
|
||||
|
||||
def _section_cluster_registry(_: dict[str, Any]) -> list[str]:
|
||||
"""Static cluster map — always available even when probes fail."""
|
||||
lines = ["Cluster infrastructure map (Proxmox VMs & roles):"]
|
||||
for nid, node in NODE_REGISTRY.items():
|
||||
if nid in ("mo-commander", "bart-commander", "network-watcher", "mcp-coordinator"):
|
||||
continue
|
||||
vmid = node.get("vmid", "?")
|
||||
lines.append(
|
||||
f" - {node['label']}: {node.get('vm')} VMID {vmid} @ {node.get('ip')} — {node.get('role')}"
|
||||
)
|
||||
desc = node.get("description") or ""
|
||||
if desc:
|
||||
lines.append(f" {desc[:140]}")
|
||||
lines.append("")
|
||||
lines.append("Supervisors & control plane:")
|
||||
for nid in ("mo-commander", "bart-commander", "mcp-coordinator", "network-watcher"):
|
||||
node = NODE_REGISTRY[nid]
|
||||
lines.append(f" - {node['label']}: {(node.get('description') or '')[:120]}")
|
||||
return lines
|
||||
|
||||
SECTION_BUILDERS = {
|
||||
"docker": _section_docker,
|
||||
"databases": _section_databases,
|
||||
"lakehouse": _section_lakehouse,
|
||||
"etl": _section_etl,
|
||||
"hadoop": _section_hadoop,
|
||||
"gpu": _section_gpu,
|
||||
"objectscale": _section_objectscale,
|
||||
"command_center": _section_command_center,
|
||||
"cluster_registry": _section_cluster_registry,
|
||||
}
|
||||
|
||||
DOMAIN_ORDER = ["docker", "databases", "lakehouse", "etl", "hadoop", "gpu", "objectscale", "command_center", "cluster_registry"]
|
||||
|
||||
|
||||
async def collect_full_lab_context(
|
||||
gpu_data: dict[str, Any] | None = None,
|
||||
log: TerminalLogFn | None = None,
|
||||
include_inventory: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Gather all lab domains in parallel with optional live terminal logging."""
|
||||
await _log(log, "info", "fetch", "═══ Lab snapshot collection started ═══")
|
||||
async with httpx.AsyncClient(timeout=10.0, verify=False) as client:
|
||||
if gpu_data is None:
|
||||
gpu_data = await collect_gpu_metrics(client, log)
|
||||
|
||||
docker_raw, db_raw, lake_raw, cc_raw, hdfs, etl, objectscale = await asyncio.gather(
|
||||
dockhand_containers(client, DOCKHAND_ENVS["docker01"], log),
|
||||
dockhand_containers(client, DOCKHAND_ENVS["db02"], log),
|
||||
dockhand_containers(client, DOCKHAND_ENVS["lakehouse"], log),
|
||||
dockhand_containers(client, DOCKHAND_ENV_COMMAND_CENTER, log),
|
||||
collect_hdfs(client, log),
|
||||
collect_etl(client, log),
|
||||
collect_objectscale(client, log),
|
||||
)
|
||||
docker = await collect_docker_rack(client, docker_raw, log)
|
||||
databases = await collect_databases(client, db_raw, log)
|
||||
if include_inventory:
|
||||
try:
|
||||
databases["inventory"] = await collect_database_inventory()
|
||||
inv_ok = databases["inventory"].get("engines_ok", 0)
|
||||
await _log(log, "ok", "fetch", f"← Database inventory: {inv_ok} engines")
|
||||
except Exception as exc:
|
||||
await _log(log, "warn", "fetch", f"✗ Database inventory: {exc}")
|
||||
databases["inventory"] = {"error": str(exc)}
|
||||
lakehouse = await collect_lakehouse(client, lake_raw, log)
|
||||
command_center = await collect_command_center(client, cc_raw, log)
|
||||
|
||||
await _log(log, "ok", "fetch", "═══ Lab snapshot complete ═══")
|
||||
return {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"docker": docker,
|
||||
"databases": databases,
|
||||
"lakehouse": lakehouse,
|
||||
"etl": etl,
|
||||
"hadoop": hdfs,
|
||||
"gpu": gpu_data,
|
||||
"objectscale": objectscale,
|
||||
"command_center": command_center,
|
||||
}
|
||||
|
||||
|
||||
def format_context_for_agent(agent_id: str, snapshot: dict[str, Any]) -> str:
|
||||
"""Format full lab snapshot for LLM; primary domain first."""
|
||||
primary = AGENT_PRIMARY_DOMAIN.get(agent_id, "docker")
|
||||
lines = [
|
||||
f"ATC Lab live snapshot — {snapshot.get('ts')}",
|
||||
f"Your primary domain: {primary.upper()}",
|
||||
]
|
||||
if snapshot.get("domains_summary"):
|
||||
lines.append(f"Health summary: {json.dumps(snapshot['domains_summary'], default=str)}")
|
||||
lines.extend(["", f"=== PRIMARY: {primary.upper()} ==="])
|
||||
|
||||
if primary in snapshot and primary in SECTION_BUILDERS:
|
||||
lines.extend(SECTION_BUILDERS[primary](snapshot[primary]))
|
||||
lines.append("")
|
||||
lines.append("=== FULL LAB (all domains) ===")
|
||||
|
||||
if "cluster_registry" not in snapshot:
|
||||
snapshot = {**snapshot, "cluster_registry": {}}
|
||||
|
||||
for domain in DOMAIN_ORDER:
|
||||
if domain == primary:
|
||||
continue
|
||||
if domain not in snapshot or domain not in SECTION_BUILDERS:
|
||||
continue
|
||||
lines.append("")
|
||||
lines.append(f"--- {domain.upper()} ---")
|
||||
lines.extend(SECTION_BUILDERS[domain](snapshot[domain]))
|
||||
|
||||
return "\n".join(lines)
|
||||
+705
-69
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
@@ -12,15 +13,54 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from agent_terminal import (
|
||||
get_all_terminals,
|
||||
get_terminal_lines,
|
||||
init_terminals,
|
||||
make_logger,
|
||||
set_terminal_publisher,
|
||||
terminal_log,
|
||||
)
|
||||
from lab_context import collect_full_lab_context, format_context_for_agent
|
||||
from presentation import build_presentation_payload, render_presentation_html
|
||||
from presentation_upload import get_deck, list_decks, save_upload
|
||||
from presentation_static import get_static_deck, list_static_decks
|
||||
from storage_s3 import router as storage_s3_router
|
||||
from node_registry import NODE_IDS, NODE_AGENT, NODE_REGISTRY, is_node_id
|
||||
from node_ops import build_node_detail, probe_node, run_node_probe_task
|
||||
from approval_service import (
|
||||
APPROVAL_ACTION_TYPES,
|
||||
approval_stats,
|
||||
create_approval_request,
|
||||
decide_approval_request,
|
||||
detect_agent_proposed_action,
|
||||
detect_approval_intent,
|
||||
list_approvals,
|
||||
)
|
||||
from db import SessionLocal, db_health, init_database
|
||||
from supervisor import mirror_terminal_line, mirror_to_supervisors
|
||||
|
||||
from workload import build_workload_payload
|
||||
|
||||
_workload_cache: dict[str, Any] = {"ts": 0.0, "data": None}
|
||||
_presentation_cache: dict[str, Any] = {"ts": 0.0, "data": None}
|
||||
WORKLOAD_CACHE_TTL = 30.0
|
||||
PRESENTATION_CACHE_TTL = 45.0
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import Column, DateTime, String, Text, create_engine, select
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
from sqlalchemy import Column, DateTime, String, Text, select
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:////data/atc-agents.db")
|
||||
GPU_URL = os.getenv("GPU_URL", "http://10.0.20.106:9000")
|
||||
GPU_UI_URL = os.getenv("GPU_UI_URL", GPU_URL)
|
||||
LLM_URL = os.getenv("LLM_URL", "http://10.0.20.106:8001/v1")
|
||||
LLM_MODEL = os.getenv("LLM_MODEL", "gpt-4o")
|
||||
LLM_API_KEY = os.getenv("LLM_API_KEY", "sk-local")
|
||||
LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "120"))
|
||||
|
||||
AGENTS = [
|
||||
{
|
||||
@@ -29,6 +69,14 @@ AGENTS = [
|
||||
"color": "#00f0ff",
|
||||
"zone": "etl",
|
||||
"role": "Airflow, Kafka, Debezium, S3 pipeline",
|
||||
"icon": "⚡",
|
||||
"motto": "Pipelines never sleep",
|
||||
"capabilities": ["Airflow", "Kafka", "Debezium", "S3", "Connectors"],
|
||||
"suggested_prompts": [
|
||||
"How is Debezium doing?",
|
||||
"Are all Airflow DAGs healthy?",
|
||||
"Kafka connector status?",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "lakehouse-ops",
|
||||
@@ -36,6 +84,14 @@ AGENTS = [
|
||||
"color": "#ff00aa",
|
||||
"zone": "lakehouse",
|
||||
"role": "Spark, Trino, Iceberg",
|
||||
"icon": "🏔️",
|
||||
"motto": "Query the lake, trust the table",
|
||||
"capabilities": ["Spark", "Trino", "Iceberg", "Delta", "SQL"],
|
||||
"suggested_prompts": [
|
||||
"Lakehouse stack status?",
|
||||
"Is Trino reachable?",
|
||||
"How many lakehouse containers are running?",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "data-custodian",
|
||||
@@ -43,6 +99,14 @@ AGENTS = [
|
||||
"color": "#ffaa00",
|
||||
"zone": "db",
|
||||
"role": "PostgreSQL, MySQL, Mongo, Cassandra, Neo4j",
|
||||
"icon": "🛡️",
|
||||
"motto": "Guardian of every row",
|
||||
"capabilities": ["PostgreSQL", "MySQL", "MongoDB", "Cassandra", "Neo4j"],
|
||||
"suggested_prompts": [
|
||||
"Hoeveel data zit er in de databases?",
|
||||
"Wat staat er in PostgreSQL sales_orders?",
|
||||
"MongoDB supplychain overzicht",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "hadoop-ranger",
|
||||
@@ -50,13 +114,93 @@ AGENTS = [
|
||||
"color": "#39ff14",
|
||||
"zone": "hadoop",
|
||||
"role": "HDFS, YARN cluster",
|
||||
"icon": "🌲",
|
||||
"motto": "Patrol the data forest",
|
||||
"capabilities": ["HDFS", "YARN", "NameNode", "DataNodes"],
|
||||
"suggested_prompts": [
|
||||
"Is HDFS NameNode up?",
|
||||
"Hadoop cluster status?",
|
||||
"YARN nodes healthy?",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "infra-sentinel",
|
||||
"name": "Infra Sentinel",
|
||||
"color": "#b366ff",
|
||||
"color": "#9b72cf",
|
||||
"zone": "docker",
|
||||
"role": "Docker, Proxmox, monitoring",
|
||||
"role": "Docker, Proxmox, GPU, monitoring",
|
||||
"icon": "👁️",
|
||||
"motto": "See everything, miss nothing",
|
||||
"capabilities": ["Docker", "Proxmox", "GPU", "vLLM", "Monitoring"],
|
||||
"suggested_prompts": [
|
||||
"GPU status?",
|
||||
"Which LLM model is running?",
|
||||
"Docker container overview",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "mo-commander",
|
||||
"name": "Mo · Command",
|
||||
"color": "#4c9aed",
|
||||
"zone": "command",
|
||||
"role": "Supervisor — full event intel, ingress, approvals",
|
||||
"icon": "🎯",
|
||||
"motto": "Nothing happens without Mo knowing",
|
||||
"supervisor": True,
|
||||
"person": "mo",
|
||||
"capabilities": ["Events", "Ingress", "Approvals", "Agent dispatch", "Network IN"],
|
||||
"suggested_prompts": [
|
||||
"What happened today?",
|
||||
"What events came in?",
|
||||
"Pipeline status overview",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "bart-commander",
|
||||
"name": "Bart · Ops",
|
||||
"color": "#3fb950",
|
||||
"zone": "command",
|
||||
"role": "Supervisor — egress, MCP comms, network OUT",
|
||||
"icon": "📡",
|
||||
"motto": "All traffic flows through Bart",
|
||||
"supervisor": True,
|
||||
"person": "bart",
|
||||
"capabilities": ["Egress", "MCP routing", "Network OUT", "GPU inference", "S3 writes"],
|
||||
"suggested_prompts": [
|
||||
"What is leaving the cluster?",
|
||||
"MCP agent communication status?",
|
||||
"Network egress overview",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "network-watcher",
|
||||
"name": "Network Watcher",
|
||||
"color": "#58a6ff",
|
||||
"zone": "network",
|
||||
"role": "VLAN 20/21 traffic, data in & out paths",
|
||||
"icon": "🌐",
|
||||
"motto": "Every packet tells a story",
|
||||
"capabilities": ["VLAN 20", "VLAN 21", "Ingress", "Egress", "Firewall paths"],
|
||||
"suggested_prompts": [
|
||||
"Data ingress status?",
|
||||
"What leaves the cluster?",
|
||||
"Network path to S3?",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "mcp-coordinator",
|
||||
"name": "MCP Coordinator",
|
||||
"color": "#f778ba",
|
||||
"zone": "mcp",
|
||||
"role": "MCP hub — routes all agent tool calls & comms",
|
||||
"icon": "🔀",
|
||||
"motto": "Route once, deliver everywhere",
|
||||
"capabilities": ["MCP servers", "Tool routing", "Agent relay", "WebSocket bus"],
|
||||
"suggested_prompts": [
|
||||
"Which MCP agents are active?",
|
||||
"MCP hub route status?",
|
||||
"Agent communication overview",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -69,11 +213,18 @@ ZONES = [
|
||||
]
|
||||
|
||||
INTENT_KEYWORDS: dict[str, list[str]] = {
|
||||
"data-custodian": ["database", "db", "postgres", "mysql", "mongo", "cassandra", "neo4j", "sql"],
|
||||
"lakehouse-ops": ["trino", "spark", "lakehouse", "iceberg", "query"],
|
||||
"hadoop-ranger": ["hadoop", "hdfs", "yarn", "datanode"],
|
||||
"infra-sentinel": ["docker", "container", "vm", "proxmox", "infra", "grafana"],
|
||||
"data-custodian": ["database", "postgres", "postgresql", "mysql", "mongo", "mongodb", "cassandra", "neo4j", "sql", "db "],
|
||||
"lakehouse-ops": ["trino", "spark", "lakehouse", "iceberg", "query", "table"],
|
||||
"hadoop-ranger": [
|
||||
"hadoop", "hdfs", "yarn", "datanode", "namenode", "replicatie", "replication",
|
||||
"rf factor", "opslag", "bestanden", "blocks", "cluster opslag", "data op",
|
||||
],
|
||||
"infra-sentinel": ["docker", "container", "vm", "proxmox", "infra", "grafana", "gpu", "vllm", "llm", "nvidia", "inference", "model"],
|
||||
"etl-guardian": ["airflow", "dag", "debezium", "kafka", "connector", "etl", "pipeline", "s3"],
|
||||
"network-watcher": ["network", "vlan", "ingress", "egress", "traffic", "packet", "firewall", "route"],
|
||||
"mcp-coordinator": ["mcp", "tool", "router", "relay", "websocket", "hub"],
|
||||
"mo-commander": ["mo", "supervisor", "events", "overzicht", "alles", "gebeurd"],
|
||||
"bart-commander": ["bart", "egress", "uitgaand", "communicatie", "mcp comm"],
|
||||
}
|
||||
|
||||
|
||||
@@ -100,11 +251,16 @@ class Approval(Base):
|
||||
action = Column(Text)
|
||||
reason = Column(Text)
|
||||
status = Column(String, default="pending")
|
||||
action_type = Column(String, default="generic.mutate")
|
||||
target = Column(Text, default="")
|
||||
payload = Column(Text, default="{}")
|
||||
decided_by = Column(String, nullable=True)
|
||||
decide_note = Column(Text, nullable=True)
|
||||
decided_at = Column(DateTime, nullable=True)
|
||||
priority = Column(String, default="normal")
|
||||
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
Base.metadata.create_all(engine)
|
||||
_db_info = init_database(Base)
|
||||
|
||||
redis_client: aioredis.Redis | None = None
|
||||
ws_clients: set[WebSocket] = set()
|
||||
@@ -112,14 +268,35 @@ ws_clients: set[WebSocket] = set()
|
||||
|
||||
class PromptRequest(BaseModel):
|
||||
message: str = Field(min_length=1, max_length=2000)
|
||||
agent_id: str | None = None
|
||||
|
||||
|
||||
class NodeAskRequest(BaseModel):
|
||||
message: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
|
||||
class ApprovalCreateRequest(BaseModel):
|
||||
agent_id: str = Field(min_length=1, max_length=64)
|
||||
action: str = Field(min_length=1, max_length=2000)
|
||||
reason: str = Field(min_length=1, max_length=2000)
|
||||
action_type: str = "generic.mutate"
|
||||
target: str = ""
|
||||
payload: dict[str, Any] | None = None
|
||||
priority: str = "normal"
|
||||
|
||||
|
||||
class ApprovalDecision(BaseModel):
|
||||
approved: bool
|
||||
decided_by: str = "mo-commander"
|
||||
note: str = ""
|
||||
|
||||
|
||||
def route_agent(message: str) -> str:
|
||||
lower = message.lower()
|
||||
# Storage/data questions default to Hadoop unless clearly about databases
|
||||
if any(w in lower for w in ("data", "opslag", "gb", "replicatie", "replication", "hdfs", "hadoop")):
|
||||
if not any(w in lower for w in ("postgres", "mysql", "mongo", "database", "sql", "neo4j", "cassandra")):
|
||||
return "hadoop-ranger"
|
||||
scores = {aid: sum(1 for kw in kws if kw in lower) for aid, kws in INTENT_KEYWORDS.items()}
|
||||
best = max(scores, key=scores.get)
|
||||
if scores[best] == 0:
|
||||
@@ -127,6 +304,94 @@ def route_agent(message: str) -> str:
|
||||
return best
|
||||
|
||||
|
||||
async def gather_agent_context(
|
||||
agent_id: str,
|
||||
status: dict[str, Any],
|
||||
log: Any | None = None,
|
||||
) -> str:
|
||||
"""Full lab snapshot for vLLM — all domains, agent's primary domain highlighted."""
|
||||
snapshot = await collect_full_lab_context(gpu_data=status.get("gpu"), log=log)
|
||||
snapshot["domains_summary"] = status.get("domains", {})
|
||||
ctx = format_context_for_agent(agent_id, snapshot)
|
||||
agent_lines = ["", "=== AGENTS & SUPERVISORS ==="]
|
||||
for a in AGENTS:
|
||||
sup = " [supervisor]" if a.get("supervisor") else ""
|
||||
agent_lines.append(f" - {a['name']} ({a['id']}){sup}: {a['role']}")
|
||||
ctx = ctx + "\n".join(agent_lines)
|
||||
if log:
|
||||
await log("info", "fetch", f"▸ Context assembled: {len(ctx)} chars for LLM")
|
||||
return ctx
|
||||
|
||||
|
||||
async def ask_llm(
|
||||
agent_id: str,
|
||||
message: str,
|
||||
context: str,
|
||||
log: Any | None = None,
|
||||
) -> str | None:
|
||||
agent = next(a for a in AGENTS if a["id"] == agent_id)
|
||||
system = f"""Je bent {agent['name']}, een autonomous ops agent in het Dell ATC data lab.
|
||||
Specialisatie: {agent['role']}.
|
||||
Motto: {agent.get('motto', '')}
|
||||
|
||||
Je antwoordt namens je domein maar hebt zicht op de HELE lab stack: Docker, databases, lakehouse (Trino/Spark/Kafka Connect), ETL (Airflow/Kafka), Hadoop HDFS, en GPU/vLLM.
|
||||
|
||||
Regels:
|
||||
- Antwoord in dezelfde taal als de gebruiker (Nederlands of Engels).
|
||||
- Je hebt volledige zicht op de HELE cluster: alle VMs, zones, connectors, GPU, Hadoop, ObjectScale en Command Center.
|
||||
- Gebruik ALLEEN de live data hieronder — verzin geen hosts, poorten, cijfers of connector namen.
|
||||
- Gebruik exact de container/connector namen uit de data (bijv. mysql-hr-connector, niet "Debezium").
|
||||
- Als iets DOWN of 0 GB is, zeg dat eerlijk.
|
||||
- Kort en behulpzaam (max ~10 zinnen); bullet lists mogen als het overzicht helpt.
|
||||
|
||||
--- LIVE LAB DATA (primary domain eerst, daarna volledige stack) ---
|
||||
{context}
|
||||
"""
|
||||
if log:
|
||||
await log("info", "llm", f"▸ Querying vLLM model={LLM_MODEL}")
|
||||
await log("cmd", "llm", f"$ POST {LLM_URL.rstrip('/')}/chat/completions")
|
||||
await log("info", "llm", f" user: {message[:160]}{'…' if len(message) > 160 else ''}")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client:
|
||||
t0 = time.monotonic()
|
||||
r = await client.post(
|
||||
f"{LLM_URL.rstrip('/')}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {LLM_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": LLM_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": message},
|
||||
],
|
||||
"max_tokens": 800,
|
||||
"temperature": 0.25,
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
content = r.json()["choices"][0]["message"]["content"].strip()
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
if content and content.strip("!"):
|
||||
if log:
|
||||
await log("ok", "llm", f"← vLLM response {len(content)} chars ({ms}ms)")
|
||||
preview = content.replace("\n", " ")[:180]
|
||||
await log("info", "llm", f" » {preview}{'…' if len(content) > 180 else ''}")
|
||||
return content
|
||||
if log:
|
||||
await log("warn", "llm", f"← Empty or invalid LLM output ({ms}ms)")
|
||||
except Exception as exc:
|
||||
if log:
|
||||
await log("err", "llm", f"✗ vLLM error: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def fallback_answer(agent_id: str, context: str) -> str:
|
||||
agent_name = next(a["name"] for a in AGENTS if a["id"] == agent_id)
|
||||
return f"**{agent_name}** (offline LLM — ruwe data):\n\n{context}"
|
||||
|
||||
|
||||
async def publish_event(event: dict[str, Any]) -> None:
|
||||
payload = json.dumps(event, default=str)
|
||||
if redis_client:
|
||||
@@ -140,6 +405,21 @@ async def publish_event(event: dict[str, Any]) -> None:
|
||||
for ws in dead:
|
||||
ws_clients.discard(ws)
|
||||
|
||||
et = event.get("type")
|
||||
if et == "feed":
|
||||
entry = event.get("entry") or {}
|
||||
await mirror_to_supervisors(
|
||||
entry.get("agent_id", "?"),
|
||||
entry.get("message", ""),
|
||||
level=entry.get("level", "info"),
|
||||
)
|
||||
elif et == "terminal":
|
||||
await mirror_terminal_line(event.get("line") or {})
|
||||
elif et in ("agent_dispatch", "agent_fetch", "agent_return"):
|
||||
aid = event.get("agent_id", "?")
|
||||
zone = event.get("zone", "")
|
||||
await mirror_to_supervisors(aid, f"{et} → zone {zone}", level="info", phase="dispatch")
|
||||
|
||||
|
||||
def add_feed(agent_id: str, message: str, level: str = "info") -> dict:
|
||||
entry_id = str(uuid.uuid4())[:8]
|
||||
@@ -175,6 +455,67 @@ async def probe_url(url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def collect_gpu() -> dict[str, Any]:
|
||||
host = GPU_URL.replace("http://", "").replace("https://", "").split("/")[0]
|
||||
base = {"ok": False, "host": host, "ui_url": GPU_UI_URL}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=6.0) as client:
|
||||
metrics_r, model_r, integration_r = await asyncio.gather(
|
||||
client.get(f"{GPU_URL}/api/gpu/metrics"),
|
||||
client.get(f"{GPU_URL}/api/active-model"),
|
||||
client.get(f"{GPU_URL}/api/integration"),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
gpus: list[dict[str, Any]] = []
|
||||
if isinstance(metrics_r, httpx.Response) and metrics_r.status_code == 200:
|
||||
current = metrics_r.json().get("current", {})
|
||||
gpus = [
|
||||
{
|
||||
"index": g["index"],
|
||||
"name": g["name"],
|
||||
"util_gpu": g.get("util_gpu", 0),
|
||||
"memory_used_mib": g.get("memory_used_mib", 0),
|
||||
"memory_total_mib": g.get("memory_total_mib", 0),
|
||||
"temperature_c": g.get("temperature_c", 0),
|
||||
"power_w": g.get("power_w", 0),
|
||||
}
|
||||
for g in current.get("gpus", [])
|
||||
]
|
||||
|
||||
active_model = None
|
||||
inference_active = False
|
||||
vllm_url = None
|
||||
|
||||
if isinstance(model_r, httpx.Response) and model_r.status_code == 200:
|
||||
model_data = model_r.json()
|
||||
active_model = model_data.get("name")
|
||||
inference_active = bool(model_data.get("inference_active"))
|
||||
vllm_url = model_data.get("base_url")
|
||||
|
||||
if isinstance(integration_r, httpx.Response) and integration_r.status_code == 200:
|
||||
integ = integration_r.json()
|
||||
if not active_model:
|
||||
active_model = integ.get("active_name")
|
||||
if not inference_active:
|
||||
inference_active = bool(integ.get("inference_active"))
|
||||
if not vllm_url:
|
||||
vllm_url = integ.get("recommended_base_url")
|
||||
|
||||
return {
|
||||
**base,
|
||||
"ok": len(gpus) > 0 or inference_active,
|
||||
"inference_active": inference_active,
|
||||
"active_model": active_model,
|
||||
"vllm_url": vllm_url,
|
||||
"gpu_count": len(gpus),
|
||||
"gpus": gpus,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {**base, "error": str(exc)}
|
||||
|
||||
|
||||
async def collect_status() -> dict[str, Any]:
|
||||
db_containers = await dockhand_env_containers(5)
|
||||
db_running = sum(1 for c in db_containers if c.get("state") == "running")
|
||||
@@ -201,6 +542,10 @@ async def collect_status() -> dict[str, Any]:
|
||||
return "warn"
|
||||
return "down"
|
||||
|
||||
gpu = await collect_gpu()
|
||||
gpu_level = "ok" if gpu.get("ok") and gpu.get("inference_active") else ("warn" if gpu.get("ok") else "down")
|
||||
gpu_label = gpu.get("active_model") or (f"{gpu.get('gpu_count', 0)} GPUs" if gpu.get("ok") else "offline")
|
||||
|
||||
return {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"domains": {
|
||||
@@ -209,47 +554,112 @@ async def collect_status() -> dict[str, Any]:
|
||||
"lakehouse": {"level": level(lake_running, lake_total), "label": f"{lake_running}/{lake_total} up", "running": lake_running, "total": lake_total},
|
||||
"hadoop": {"level": "ok" if hdfs_ok else "warn", "label": "NN up" if hdfs_ok else "NN check"},
|
||||
"etl": {"level": "ok" if kafka_ok and airflow_ok else "warn", "label": "Kafka+Airflow"},
|
||||
"gpu": {"level": gpu_level, "label": gpu_label},
|
||||
},
|
||||
"gpu": gpu,
|
||||
"kafka_ok": kafka_ok,
|
||||
"airflow_ok": airflow_ok,
|
||||
"hdfs_ok": hdfs_ok,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
async def _run_agent_task_safe(agent_id: str, message: str, prompt_id: str) -> None:
|
||||
try:
|
||||
await run_agent_task(agent_id, message, prompt_id)
|
||||
except Exception as exc:
|
||||
agent_name = next((a["name"] for a in AGENTS if a["id"] == agent_id), agent_id)
|
||||
err = f"Sorry — {agent_name} could not complete your request: {exc}"
|
||||
await terminal_log(agent_id, f"[{prompt_id}] ✗ Error: {exc}", level="err", phase="error", prompt_id=prompt_id)
|
||||
feed = add_feed(agent_id, f"{agent_name} failed: {str(exc)[:80]}", "err")
|
||||
await publish_event({"type": "feed", "entry": feed})
|
||||
await publish_event({
|
||||
"type": "prompt_result",
|
||||
"prompt_id": prompt_id,
|
||||
"agent_id": agent_id,
|
||||
"answer": err,
|
||||
})
|
||||
|
||||
|
||||
async def run_agent_task(agent_id: str, message: str, prompt_id: str) -> str:
|
||||
zone = next(a["zone"] for a in AGENTS if a["id"] == agent_id)
|
||||
agent_name = next(a["name"] for a in AGENTS if a["id"] == agent_id)
|
||||
log = make_logger(agent_id, prompt_id)
|
||||
approval_created = False
|
||||
|
||||
intent = detect_approval_intent(message)
|
||||
if intent:
|
||||
with SessionLocal() as db:
|
||||
await create_approval_request(
|
||||
db=db,
|
||||
ApprovalModel=Approval,
|
||||
agent_id=agent_id,
|
||||
action=intent["action"],
|
||||
reason=intent["reason"],
|
||||
action_type=intent["action_type"],
|
||||
terminal_log=terminal_log,
|
||||
mirror_supervisors=mirror_to_supervisors,
|
||||
publish=publish_event,
|
||||
add_feed=add_feed,
|
||||
)
|
||||
approval_created = True
|
||||
await terminal_log(
|
||||
agent_id,
|
||||
f"[{prompt_id}] Mutating request detected — approval queued for Mo & Bart",
|
||||
level="warn",
|
||||
phase="approval",
|
||||
prompt_id=prompt_id,
|
||||
)
|
||||
|
||||
await terminal_log(
|
||||
agent_id,
|
||||
f"[{prompt_id}] ▶ Mission accepted: {message}",
|
||||
level="info",
|
||||
phase="dispatch",
|
||||
prompt_id=prompt_id,
|
||||
)
|
||||
await publish_event({"type": "agent_dispatch", "agent_id": agent_id, "zone": zone, "prompt_id": prompt_id})
|
||||
await asyncio.sleep(0.8)
|
||||
await asyncio.sleep(0.4)
|
||||
await terminal_log(agent_id, f"[{prompt_id}] Walking to zone: {zone}", level="info", phase="dispatch", prompt_id=prompt_id)
|
||||
await publish_event({"type": "agent_fetch", "agent_id": agent_id, "zone": zone, "prompt_id": prompt_id})
|
||||
|
||||
await log("info", "fetch", f"[{prompt_id}] Collecting live lab metrics…")
|
||||
status = await collect_status()
|
||||
answer_parts = [f"**{next(a['name'] for a in AGENTS if a['id'] == agent_id)}** reporting:"]
|
||||
context = await gather_agent_context(agent_id, status, log=log)
|
||||
|
||||
if agent_id == "data-custodian":
|
||||
db = status["domains"]["databases"]
|
||||
containers = await dockhand_env_containers(5)
|
||||
names = ", ".join(f"{c['name']}:{c.get('state','?')}" for c in containers[:8])
|
||||
answer_parts.append(f"Databases {db['label']}. Containers: {names or 'unreachable'}.")
|
||||
elif agent_id == "etl-guardian":
|
||||
answer_parts.append(
|
||||
f"Kafka UI: {'OK' if status['kafka_ok'] else 'DOWN'}. "
|
||||
f"Airflow: {'OK' if status['airflow_ok'] else 'DOWN'}. "
|
||||
f"Lakehouse {status['domains']['lakehouse']['label']}."
|
||||
)
|
||||
elif agent_id == "lakehouse-ops":
|
||||
answer_parts.append(f"Lakehouse stack {status['domains']['lakehouse']['label']}. Trino at 10.0.21.50:8089.")
|
||||
elif agent_id == "hadoop-ranger":
|
||||
answer_parts.append(f"HDFS NameNode: {'reachable' if status['hdfs_ok'] else 'unreachable'} on 10.0.21.61:9870.")
|
||||
else:
|
||||
answer_parts.append(
|
||||
f"Docker {status['domains']['docker']['label']}. "
|
||||
f"Overall lab health snapshot collected."
|
||||
)
|
||||
answer = await ask_llm(agent_id, message, context, log=log)
|
||||
if not answer:
|
||||
await log("warn", "llm", "LLM fallback — returning raw context")
|
||||
answer = fallback_answer(agent_id, context)
|
||||
|
||||
answer = " ".join(answer_parts)
|
||||
await asyncio.sleep(0.6)
|
||||
if not approval_created:
|
||||
proposed = detect_agent_proposed_action(answer, message)
|
||||
if proposed:
|
||||
with SessionLocal() as db:
|
||||
await create_approval_request(
|
||||
db=db,
|
||||
ApprovalModel=Approval,
|
||||
agent_id=agent_id,
|
||||
action=proposed["action"],
|
||||
reason=proposed["reason"],
|
||||
action_type=proposed["action_type"],
|
||||
target=proposed.get("target", ""),
|
||||
terminal_log=terminal_log,
|
||||
mirror_supervisors=mirror_to_supervisors,
|
||||
publish=publish_event,
|
||||
add_feed=add_feed,
|
||||
)
|
||||
approval_created = True
|
||||
answer = (
|
||||
f"{answer}\n\n⏸ **Approval required** — this action is in the Approval Inbox. "
|
||||
f"Mo & Bart have been notified and must approve before we execute."
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
await terminal_log(agent_id, f"[{prompt_id}] ✓ Mission complete", level="ok", phase="done", prompt_id=prompt_id)
|
||||
await publish_event({"type": "agent_return", "agent_id": agent_id, "zone": zone, "prompt_id": prompt_id})
|
||||
feed = add_feed(agent_id, f"Prompt answered: {message[:80]}", "info")
|
||||
feed = add_feed(agent_id, f"{agent_name} completed a response (see Comms)", "info")
|
||||
await publish_event({"type": "feed", "entry": feed})
|
||||
await publish_event({"type": "prompt_result", "prompt_id": prompt_id, "agent_id": agent_id, "answer": answer})
|
||||
return answer
|
||||
@@ -259,7 +669,9 @@ async def heartbeat_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
status = await collect_status()
|
||||
workload = await collect_workload()
|
||||
await publish_event({"type": "status", "data": status})
|
||||
await publish_event({"type": "workload", "data": workload})
|
||||
for domain, info in status["domains"].items():
|
||||
if info["level"] == "down":
|
||||
agent = "data-custodian" if domain == "databases" else "infra-sentinel"
|
||||
@@ -274,6 +686,15 @@ async def heartbeat_loop() -> None:
|
||||
async def lifespan(app: FastAPI):
|
||||
global redis_client
|
||||
redis_client = aioredis.from_url(REDIS_URL, decode_responses=True)
|
||||
set_terminal_publisher(publish_event)
|
||||
init_terminals([a["id"] for a in AGENTS] + NODE_IDS)
|
||||
for a in AGENTS:
|
||||
await terminal_log(a["id"], f"{a['name']} terminal online — awaiting missions", level="info", phase="boot")
|
||||
for nid in NODE_IDS:
|
||||
if nid not in NODE_REGISTRY:
|
||||
continue
|
||||
meta = NODE_REGISTRY[nid]
|
||||
await terminal_log(nid, f"{meta['label']} shell ready — click node to connect", level="info", phase="boot")
|
||||
task = asyncio.create_task(heartbeat_loop())
|
||||
add_feed("infra-sentinel", "ATC Command Center API online", "info")
|
||||
yield
|
||||
@@ -283,6 +704,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
|
||||
app = FastAPI(title="ATC Command Center API", lifespan=lifespan)
|
||||
app.include_router(storage_s3_router)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -294,7 +716,103 @@ app.add_middleware(
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"ok": True, "ts": datetime.now(timezone.utc).isoformat()}
|
||||
llm_ok = False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=4.0) as client:
|
||||
r = await client.get(f"{LLM_URL.rstrip('/')}/models", headers={"Authorization": f"Bearer {LLM_API_KEY}"})
|
||||
llm_ok = r.status_code == 200
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ok": True,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"llm_url": LLM_URL,
|
||||
"llm_ok": llm_ok,
|
||||
"llm_model": LLM_MODEL,
|
||||
"database": db_health(),
|
||||
"db_init": _db_info,
|
||||
}
|
||||
|
||||
|
||||
async def collect_workload(*, fast: bool = True, use_cache: bool = True) -> dict[str, Any]:
|
||||
import time as _time
|
||||
now = _time.time()
|
||||
if use_cache and _workload_cache.get("data") and now - float(_workload_cache.get("ts") or 0) < WORKLOAD_CACHE_TTL:
|
||||
return _workload_cache["data"]
|
||||
gpu = await collect_gpu()
|
||||
snap = await collect_full_lab_context(gpu_data=gpu, include_inventory=not fast)
|
||||
payload = build_workload_payload(snap)
|
||||
_workload_cache["ts"] = now
|
||||
_workload_cache["data"] = payload
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
|
||||
async def get_presentation_data(*, use_cache: bool = True) -> dict[str, Any]:
|
||||
import time as _time
|
||||
now = _time.time()
|
||||
if use_cache and _presentation_cache.get("data") and now - float(_presentation_cache.get("ts") or 0) < PRESENTATION_CACHE_TTL:
|
||||
return _presentation_cache["data"]
|
||||
gpu = await collect_gpu()
|
||||
snap = await collect_full_lab_context(gpu_data=gpu, include_inventory=False)
|
||||
data = build_presentation_payload(snap)
|
||||
data["source"] = "live"
|
||||
_presentation_cache["ts"] = now
|
||||
_presentation_cache["data"] = data
|
||||
return data
|
||||
|
||||
|
||||
@app.get("/api/presentation")
|
||||
async def get_presentation():
|
||||
return await get_presentation_data()
|
||||
|
||||
|
||||
@app.get("/api/presentation/html")
|
||||
async def get_presentation_html():
|
||||
from fastapi.responses import HTMLResponse
|
||||
payload = await get_presentation_data()
|
||||
return HTMLResponse(render_presentation_html(payload))
|
||||
|
||||
|
||||
@app.get("/api/presentation/decks")
|
||||
async def get_presentation_decks():
|
||||
return {"live": True, "builtin": list_static_decks(), "uploaded": list_decks()}
|
||||
|
||||
|
||||
@app.get("/api/presentation/decks/{deck_id}")
|
||||
async def get_presentation_deck(deck_id: str):
|
||||
if deck_id == "live":
|
||||
return await get_presentation_data()
|
||||
deck = get_static_deck(deck_id) or get_deck(deck_id)
|
||||
if not deck:
|
||||
return {"error": "deck not found"}
|
||||
return deck
|
||||
|
||||
|
||||
@app.get("/api/presentation/decks/{deck_id}/html")
|
||||
async def get_presentation_deck_html(deck_id: str):
|
||||
from fastapi.responses import HTMLResponse
|
||||
if deck_id == "live":
|
||||
payload = await get_presentation_data()
|
||||
else:
|
||||
payload = get_static_deck(deck_id) or get_deck(deck_id)
|
||||
if not payload:
|
||||
return HTMLResponse("<h1>Deck not found</h1>", status_code=404)
|
||||
return HTMLResponse(render_presentation_html(payload))
|
||||
|
||||
|
||||
@app.post("/api/presentation/upload")
|
||||
async def upload_presentation(file: UploadFile = File(...)):
|
||||
content = await file.read()
|
||||
if len(content) > 50 * 1024 * 1024:
|
||||
return {"error": "file too large (max 50MB)"}
|
||||
deck = await save_upload(file.filename or "upload.pptx", content)
|
||||
return {"ok": True, "deck": deck}
|
||||
|
||||
@app.get("/api/workload")
|
||||
async def get_workload(fast: bool = True):
|
||||
return await collect_workload(fast=fast, use_cache=True)
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
@@ -302,9 +820,95 @@ async def get_status():
|
||||
return await collect_status()
|
||||
|
||||
|
||||
@app.get("/api/gpu")
|
||||
async def get_gpu():
|
||||
return await collect_gpu()
|
||||
|
||||
|
||||
def agent_stats() -> dict[str, dict[str, Any]]:
|
||||
stats: dict[str, dict[str, Any]] = {a["id"]: {"tasks": 0, "last_active": None, "alerts": 0} for a in AGENTS}
|
||||
with SessionLocal() as db:
|
||||
rows = db.execute(select(FeedEntry).order_by(FeedEntry.ts.desc()).limit(200)).scalars().all()
|
||||
for r in rows:
|
||||
aid = r.agent_id
|
||||
if aid not in stats:
|
||||
continue
|
||||
stats[aid]["tasks"] += 1
|
||||
if r.level == "warn":
|
||||
stats[aid]["alerts"] += 1
|
||||
if stats[aid]["last_active"] is None and r.ts:
|
||||
stats[aid]["last_active"] = r.ts.isoformat()
|
||||
return stats
|
||||
|
||||
|
||||
@app.get("/api/agents")
|
||||
async def get_agents():
|
||||
return {"agents": AGENTS, "zones": ZONES}
|
||||
stats = agent_stats()
|
||||
enriched = [{**a, "stats": stats.get(a["id"], {})} for a in AGENTS]
|
||||
return {"agents": enriched, "zones": ZONES}
|
||||
|
||||
|
||||
@app.get("/api/terminals")
|
||||
async def get_terminals(limit: int = 200):
|
||||
return {"terminals": get_all_terminals(limit)}
|
||||
|
||||
|
||||
@app.get("/api/terminals/{subject_id}")
|
||||
async def get_subject_terminal(subject_id: str, limit: int = 200):
|
||||
valid_agents = {a["id"] for a in AGENTS}
|
||||
if subject_id not in valid_agents and not is_node_id(subject_id):
|
||||
return {"error": "unknown subject"}
|
||||
return {"agent_id": subject_id, "lines": get_terminal_lines(subject_id, limit)}
|
||||
|
||||
|
||||
@app.get("/api/nodes")
|
||||
async def list_nodes():
|
||||
workload = await collect_workload()
|
||||
nodes = workload.get("topology", {}).get("nodes", [])
|
||||
return {"nodes": [{"id": n["id"], "label": n["label"], "ip": n["ip"], "level": n["level"]} for n in nodes]}
|
||||
|
||||
|
||||
@app.get("/api/nodes/{node_id}")
|
||||
async def get_node(node_id: str):
|
||||
if not is_node_id(node_id):
|
||||
return {"error": "unknown node"}
|
||||
workload = await collect_workload()
|
||||
wn = next((n for n in workload.get("topology", {}).get("nodes", []) if n["id"] == node_id), None)
|
||||
gpu = await collect_gpu()
|
||||
snap = await collect_full_lab_context(gpu_data=gpu)
|
||||
return build_node_detail(node_id, snap, wn)
|
||||
|
||||
|
||||
@app.post("/api/nodes/{node_id}/probe")
|
||||
async def post_node_probe(node_id: str):
|
||||
if not is_node_id(node_id):
|
||||
return {"error": "unknown node"}
|
||||
asyncio.create_task(run_node_probe_task(node_id))
|
||||
return {"ok": True, "node_id": node_id, "status": "probing"}
|
||||
|
||||
|
||||
async def run_node_ask_task(node_id: str, message: str) -> None:
|
||||
agent_id = NODE_AGENT.get(node_id, "infra-sentinel")
|
||||
meta = NODE_REGISTRY[node_id]
|
||||
await terminal_log(node_id, f"▶ Query: {message}", level="info", phase="ask")
|
||||
await terminal_log(node_id, f"→ Routing to agent {agent_id}", level="info", phase="ask")
|
||||
log = make_logger(node_id)
|
||||
status = await collect_status()
|
||||
context = await gather_agent_context(agent_id, status, log=log)
|
||||
node_ctx = f"\n\n=== FOCUSED NODE: {meta['label']} ({meta['ip']}) ===\n{meta.get('description', '')}\n"
|
||||
answer = await ask_llm(agent_id, message, context + node_ctx, log=log)
|
||||
if not answer:
|
||||
answer = fallback_answer(agent_id, context)
|
||||
await terminal_log(node_id, f"◆ {answer}", level="llm", phase="answer")
|
||||
await publish_event({"type": "node_ask_result", "node_id": node_id, "agent_id": agent_id, "answer": answer})
|
||||
|
||||
|
||||
@app.post("/api/nodes/{node_id}/ask")
|
||||
async def post_node_ask(node_id: str, body: NodeAskRequest):
|
||||
if not is_node_id(node_id):
|
||||
return {"error": "unknown node"}
|
||||
asyncio.create_task(run_node_ask_task(node_id, body.message))
|
||||
return {"ok": True, "node_id": node_id, "agent_id": NODE_AGENT.get(node_id), "status": "processing"}
|
||||
|
||||
|
||||
@app.get("/api/feed")
|
||||
@@ -326,47 +930,73 @@ async def get_feed(limit: int = 50):
|
||||
|
||||
|
||||
@app.get("/api/approvals")
|
||||
async def get_approvals():
|
||||
async def get_approvals(status: str = "pending", limit: int = 100):
|
||||
with SessionLocal() as db:
|
||||
rows = db.execute(select(Approval).where(Approval.status == "pending")).scalars().all()
|
||||
return {
|
||||
"approvals": [
|
||||
{
|
||||
"id": r.id,
|
||||
"ts": r.ts.isoformat() if r.ts else None,
|
||||
"agent_id": r.agent_id,
|
||||
"action": r.action,
|
||||
"reason": r.reason,
|
||||
"status": r.status,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
items = list_approvals(db, Approval, status=status, limit=limit)
|
||||
stats = approval_stats(db, Approval)
|
||||
return {"approvals": items, "stats": stats, "action_types": APPROVAL_ACTION_TYPES}
|
||||
|
||||
|
||||
@app.get("/api/approvals/stats")
|
||||
async def get_approval_stats():
|
||||
with SessionLocal() as db:
|
||||
return approval_stats(db, Approval)
|
||||
|
||||
|
||||
@app.post("/api/approvals")
|
||||
async def post_approval(body: ApprovalCreateRequest):
|
||||
valid_ids = {a["id"] for a in AGENTS}
|
||||
if body.agent_id not in valid_ids:
|
||||
return {"error": "unknown agent_id"}
|
||||
if body.action_type not in APPROVAL_ACTION_TYPES:
|
||||
body.action_type = "generic.mutate"
|
||||
with SessionLocal() as db:
|
||||
item = await create_approval_request(
|
||||
db=db,
|
||||
ApprovalModel=Approval,
|
||||
agent_id=body.agent_id,
|
||||
action=body.action,
|
||||
reason=body.reason,
|
||||
action_type=body.action_type,
|
||||
target=body.target,
|
||||
payload=body.payload,
|
||||
priority=body.priority,
|
||||
terminal_log=terminal_log,
|
||||
mirror_supervisors=mirror_to_supervisors,
|
||||
publish=publish_event,
|
||||
add_feed=add_feed,
|
||||
)
|
||||
return {"ok": True, "approval": item}
|
||||
|
||||
|
||||
@app.post("/api/approvals/{approval_id}/decide")
|
||||
async def decide_approval(approval_id: str, body: ApprovalDecision):
|
||||
valid_supervisors = {"mo-commander", "bart-commander"}
|
||||
decided_by = body.decided_by if body.decided_by in valid_supervisors else "mo-commander"
|
||||
with SessionLocal() as db:
|
||||
row = db.get(Approval, approval_id)
|
||||
if not row:
|
||||
return {"error": "not found"}
|
||||
row.status = "approved" if body.approved else "denied"
|
||||
db.commit()
|
||||
agent_id = row.agent_id
|
||||
action = row.action
|
||||
msg = f"Approval {'approved' if body.approved else 'denied'}: {action}"
|
||||
feed = add_feed(agent_id, msg, "info" if body.approved else "warn")
|
||||
await publish_event({"type": "feed", "entry": feed})
|
||||
await publish_event({"type": "approval_update", "id": approval_id, "status": row.status})
|
||||
return {"ok": True, "status": row.status}
|
||||
item = await decide_approval_request(
|
||||
db=db,
|
||||
ApprovalModel=Approval,
|
||||
approval_id=approval_id,
|
||||
approved=body.approved,
|
||||
decided_by=decided_by,
|
||||
note=body.note,
|
||||
terminal_log=terminal_log,
|
||||
publish=publish_event,
|
||||
add_feed=add_feed,
|
||||
)
|
||||
if not item:
|
||||
return {"error": "not found"}
|
||||
return {"ok": True, "approval": item}
|
||||
|
||||
|
||||
@app.post("/api/prompt")
|
||||
async def post_prompt(body: PromptRequest):
|
||||
prompt_id = str(uuid.uuid4())[:8]
|
||||
agent_id = route_agent(body.message)
|
||||
valid_ids = {a["id"] for a in AGENTS}
|
||||
agent_id = body.agent_id if body.agent_id in valid_ids else route_agent(body.message)
|
||||
add_feed(agent_id, f"Prompt received: {body.message}", "info")
|
||||
asyncio.create_task(run_agent_task(agent_id, body.message, prompt_id))
|
||||
asyncio.create_task(_run_agent_task_safe(agent_id, body.message, prompt_id))
|
||||
return {"prompt_id": prompt_id, "agent_id": agent_id, "status": "dispatched"}
|
||||
|
||||
|
||||
@@ -376,7 +1006,13 @@ async def ws_ops(websocket: WebSocket):
|
||||
ws_clients.add(websocket)
|
||||
try:
|
||||
status = await collect_status()
|
||||
workload = await collect_workload()
|
||||
await websocket.send_text(json.dumps({"type": "status", "data": status}, default=str))
|
||||
await websocket.send_text(json.dumps({"type": "workload", "data": workload}, default=str))
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "terminal_history",
|
||||
"terminals": get_all_terminals(150),
|
||||
}, default=str))
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
"""Live probe + context for topology nodes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from agent_terminal import terminal_log
|
||||
from lab_context import (
|
||||
AIRFLOW_URL,
|
||||
DOCKHAND_URL,
|
||||
GPU_URL,
|
||||
HDFS_NN_URL,
|
||||
KAFKA_CONNECT_URL,
|
||||
KAFKA_UI_URL,
|
||||
LAKEHOUSE_HOST,
|
||||
OBJECTSCALE_URL,
|
||||
SPARK_UI_URL,
|
||||
TRINO_URL,
|
||||
collect_databases,
|
||||
collect_docker_rack,
|
||||
collect_etl,
|
||||
collect_gpu_metrics,
|
||||
collect_hdfs,
|
||||
collect_lakehouse,
|
||||
collect_objectscale,
|
||||
dockhand_containers,
|
||||
)
|
||||
from node_registry import NODE_AGENT, NODE_REGISTRY
|
||||
|
||||
|
||||
async def _log(node_id: str, level: str, phase: str, text: str) -> None:
|
||||
await terminal_log(node_id, text, level=level, phase=phase)
|
||||
|
||||
|
||||
async def probe_node(node_id: str) -> dict[str, Any]:
|
||||
"""Run live probes for a topology node; stream output to node terminal."""
|
||||
meta = NODE_REGISTRY.get(node_id)
|
||||
if not meta:
|
||||
return {"error": "unknown node"}
|
||||
|
||||
await _log(node_id, "info", "shell", f"═══ Connecting to {meta['label']} ({meta['ip']}) ═══")
|
||||
await _log(node_id, "cmd", "shell", f"$ probe --node {node_id} --vm {meta['vm']}")
|
||||
|
||||
result: dict[str, Any] = {"node_id": node_id, "ok": True}
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0, verify=False) as client:
|
||||
if node_id == "airflow":
|
||||
etl = await collect_etl(client)
|
||||
result["data"] = etl
|
||||
healthy = etl.get("airflow_healthy")
|
||||
await _log(node_id, "ok" if healthy else "warn", "shell", f"Airflow scheduler: {'HEALTHY' if healthy else 'DEGRADED'}")
|
||||
for comp, st in (etl.get("airflow_components") or {}).items():
|
||||
await _log(node_id, "info", "shell", f" · {comp}: {st}")
|
||||
|
||||
elif node_id == "db":
|
||||
raw = await dockhand_containers(client, 5)
|
||||
db = await collect_databases(client, raw)
|
||||
result["data"] = db
|
||||
await _log(node_id, "ok", "shell", f"DB vault: {db['running']}/{db['total']} containers up")
|
||||
for engine, items in (db.get("by_engine") or {}).items():
|
||||
await _log(node_id, "info", "shell", f" {engine}:")
|
||||
for item in items:
|
||||
await _log(node_id, "info", "shell", f" - {item}")
|
||||
|
||||
elif node_id == "debezium":
|
||||
etl = await collect_etl(client)
|
||||
result["data"] = {"connectors": etl.get("connectors")}
|
||||
await _log(node_id, "ok", "shell", f"Kafka Connect @ {KAFKA_CONNECT_URL}")
|
||||
for c in etl.get("connectors") or []:
|
||||
await _log(node_id, "info", "shell", f" ✓ {c}")
|
||||
|
||||
elif node_id == "kafka":
|
||||
etl = await collect_etl(client)
|
||||
result["data"] = {"kafka_ui_ok": etl.get("kafka_ui_ok")}
|
||||
await _log(node_id, "ok" if etl.get("kafka_ui_ok") else "warn", "shell", f"Kafka UI {KAFKA_UI_URL}: {'UP' if etl.get('kafka_ui_ok') else 'DOWN'}")
|
||||
await _log(node_id, "info", "shell", f" Broker: 10.0.21.36:9092")
|
||||
|
||||
elif node_id == "lakehouse":
|
||||
raw = await dockhand_containers(client, 9)
|
||||
lh = await collect_lakehouse(client, raw)
|
||||
result["data"] = lh
|
||||
await _log(node_id, "ok", "shell", f"Lakehouse {lh['host']}: {lh['running']}/{lh['total']} containers")
|
||||
await _log(node_id, "info", "shell", f" Trino {TRINO_URL}: {'UP' if lh.get('trino_ok') else 'DOWN'}")
|
||||
for c in lh.get("containers") or []:
|
||||
ports = ",".join(c.get("ports") or []) or "internal"
|
||||
await _log(node_id, "info", "shell", f" · {c['name']}: {c['state']} ports={ports}")
|
||||
|
||||
elif node_id == "s3":
|
||||
os_data = await collect_objectscale(client)
|
||||
raw = await dockhand_containers(client, 9)
|
||||
consumer = next((c for c in raw if "s3-kafka" in f"{c.get('name', '')} {c.get('image', '')}".lower()), None)
|
||||
result["data"] = {"objectscale": os_data, "consumer": consumer}
|
||||
await _log(node_id, "ok" if os_data.get("reachable") else "warn", "shell", f"ObjectScale {OBJECTSCALE_URL}: {'UP' if os_data.get('reachable') else 'DOWN'}")
|
||||
await _log(node_id, "info", "shell", f" Bucket: data @ {os_data.get('host')}:{os_data.get('port')}")
|
||||
if consumer:
|
||||
await _log(node_id, "info", "shell", f" s3-kafka-consumer: {consumer.get('state')}")
|
||||
|
||||
elif node_id == "docker":
|
||||
raw = await dockhand_containers(client, 1)
|
||||
dk = await collect_docker_rack(client, raw)
|
||||
result["data"] = dk
|
||||
await _log(node_id, "ok", "shell", f"Docker rack: {dk['running']}/{dk['total']} running")
|
||||
for c in dk.get("containers") or []:
|
||||
ports = ",".join(c.get("ports") or []) or "internal"
|
||||
lvl = "info" if c.get("state") == "running" else "warn"
|
||||
await _log(node_id, lvl, "shell", f" · {c['name']}: {c['state']} ports={ports}")
|
||||
|
||||
elif node_id == "hadoop":
|
||||
hdfs = await collect_hdfs(client)
|
||||
result["data"] = hdfs
|
||||
if hdfs.get("reachable"):
|
||||
await _log(node_id, "ok", "shell", f"NameNode {HDFS_NN_URL}: UP")
|
||||
await _log(node_id, "info", "shell", f" Capacity: {hdfs.get('capacity_used_gb')}GB / {hdfs.get('capacity_total_gb')}GB")
|
||||
await _log(node_id, "info", "shell", f" DataNodes: {hdfs.get('live_datanodes')} live, RF=3")
|
||||
for dn in hdfs.get("datanodes") or []:
|
||||
await _log(node_id, "info", "shell", f" · {dn['host']}: {dn['used_gb']}GB used, {dn['blocks']} blocks")
|
||||
else:
|
||||
await _log(node_id, "err", "shell", "NameNode unreachable")
|
||||
|
||||
elif node_id == "gpu":
|
||||
gpu = await collect_gpu_metrics(client)
|
||||
result["data"] = gpu
|
||||
await _log(node_id, "ok" if gpu.get("ok") else "warn", "shell", f"GPU Lab {GPU_URL}")
|
||||
await _log(node_id, "info", "shell", f" Model: {gpu.get('active_model')} inference={'ON' if gpu.get('inference_active') else 'OFF'}")
|
||||
for g in gpu.get("gpus") or []:
|
||||
await _log(node_id, "info", "shell", f" GPU{g['index']}: {g['util_gpu']:.0f}% VRAM {g['memory_used_mib']:.0f}/{g['memory_total_mib']:.0f} MiB")
|
||||
|
||||
elif node_id == "command":
|
||||
result["data"] = {"agents": 9, "url": "http://10.0.21.33"}
|
||||
await _log(node_id, "ok", "shell", "Command Center online — 9 agents ready")
|
||||
await _log(node_id, "info", "shell", " API: http://10.0.21.33/api")
|
||||
await _log(node_id, "info", "shell", " WebSocket: /api/ws/ops")
|
||||
|
||||
elif node_id in ("mo-commander", "bart-commander", "mcp-coordinator", "network-watcher"):
|
||||
from node_registry import NODE_REGISTRY
|
||||
meta = NODE_REGISTRY[node_id]
|
||||
await _log(node_id, "ok", "shell", f"{meta['label']} online — monitoring all agent comms")
|
||||
await _log(node_id, "info", "shell", meta.get("description", ""))
|
||||
result["data"] = {"role": meta.get("role")}
|
||||
|
||||
await _log(node_id, "ok", "shell", "═══ Probe complete — type a question below ═══")
|
||||
return result
|
||||
|
||||
|
||||
def build_node_detail(node_id: str, snap: dict[str, Any], workload_node: dict | None = None) -> dict[str, Any]:
|
||||
"""Rich context payload for a single node."""
|
||||
meta = dict(NODE_REGISTRY.get(node_id, {}))
|
||||
if not meta:
|
||||
return {"error": "unknown node"}
|
||||
|
||||
wn = workload_node or {}
|
||||
agent_id = NODE_AGENT.get(node_id, "infra-sentinel")
|
||||
|
||||
detail: dict[str, Any] = {
|
||||
"id": node_id,
|
||||
"agent_id": agent_id,
|
||||
**meta,
|
||||
"level": wn.get("level", "unknown"),
|
||||
"running": wn.get("running", 0),
|
||||
"total": wn.get("total", 0),
|
||||
"apps": wn.get("apps", []),
|
||||
"connectors": wn.get("connectors"),
|
||||
"bucket": wn.get("bucket") or meta.get("bucket"),
|
||||
"port": wn.get("port"),
|
||||
"model": wn.get("model"),
|
||||
"util": wn.get("util"),
|
||||
"hdfs_used_gb": wn.get("hdfs_used_gb"),
|
||||
"hdfs_total_gb": wn.get("hdfs_total_gb"),
|
||||
"trino_ok": wn.get("trino_ok"),
|
||||
"consumer_ok": wn.get("consumer_ok"),
|
||||
}
|
||||
|
||||
edges = (snap.get("_edges") or []) if False else []
|
||||
_ = edges # reserved for future edge context from workload
|
||||
|
||||
return detail
|
||||
|
||||
|
||||
async def run_node_probe_task(node_id: str) -> None:
|
||||
try:
|
||||
await probe_node(node_id)
|
||||
except Exception as exc:
|
||||
await _log(node_id, "err", "shell", f"Probe failed: {exc}")
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Static registry + helpers for topology node metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
NODE_IDS = [
|
||||
"airflow", "db", "debezium", "kafka", "lakehouse", "s3",
|
||||
"docker", "hadoop", "gpu", "command",
|
||||
"mo-commander", "bart-commander", "network-watcher", "mcp-coordinator",
|
||||
]
|
||||
|
||||
NODE_AGENT = {
|
||||
"airflow": "etl-guardian",
|
||||
"db": "data-custodian",
|
||||
"debezium": "etl-guardian",
|
||||
"kafka": "etl-guardian",
|
||||
"lakehouse": "lakehouse-ops",
|
||||
"s3": "lakehouse-ops",
|
||||
"docker": "infra-sentinel",
|
||||
"hadoop": "hadoop-ranger",
|
||||
"gpu": "infra-sentinel",
|
||||
"command": "infra-sentinel",
|
||||
}
|
||||
|
||||
NODE_REGISTRY: dict[str, dict[str, Any]] = {
|
||||
"airflow": {
|
||||
"label": "Airflow",
|
||||
"vm": "atc-airflow01",
|
||||
"vmid": 105,
|
||||
"pve": "pve01",
|
||||
"ip": "10.0.21.55",
|
||||
"ssh": "ssh root@10.0.21.55",
|
||||
"role": "orchestrator",
|
||||
"color": "#4c9aed",
|
||||
"description": "Orchestrates DAG generate_data_all_databases — seeds PostgreSQL, MySQL, MongoDB, Cassandra and Neo4j on db02.",
|
||||
"links": [{"label": "Airflow UI", "url": "http://10.0.21.55:8080"}],
|
||||
"endpoints": [{"name": "web", "host": "10.0.21.55", "port": "8080", "proto": "http"}],
|
||||
"commands": ["dag list", "health check", "trigger generate_data_all_databases"],
|
||||
},
|
||||
"db": {
|
||||
"label": "DB Vault",
|
||||
"vm": "atc-db02",
|
||||
"vmid": 109,
|
||||
"pve": "pve01",
|
||||
"ip": "10.0.21.51",
|
||||
"ssh": "ssh root@10.0.21.51",
|
||||
"role": "sources",
|
||||
"color": "#e8a838",
|
||||
"description": "Source-of-truth databases for CDC. Debezium connectors capture changes from PostgreSQL sales, MySQL HR, MongoDB supply chain and Cassandra telemetry.",
|
||||
"links": [{"label": "Dockhand env 5", "url": "http://10.0.21.45:8082"}],
|
||||
"endpoints": [
|
||||
{"name": "postgres_sales", "host": "10.0.21.51", "port": "5432", "proto": "tcp"},
|
||||
{"name": "mysql_hr", "host": "10.0.21.51", "port": "3306", "proto": "tcp"},
|
||||
{"name": "mongodb_supplychain", "host": "10.0.21.51", "port": "27017", "proto": "tcp"},
|
||||
{"name": "cassandra_telemetry", "host": "10.0.21.51", "port": "9042", "proto": "tcp"},
|
||||
{"name": "neo4j_graph", "host": "10.0.21.51", "port": "7687", "proto": "tcp"},
|
||||
],
|
||||
"commands": ["list containers", "engine status", "connector sources"],
|
||||
},
|
||||
"debezium": {
|
||||
"label": "Debezium CDC",
|
||||
"vm": "atc-lake01",
|
||||
"vmid": 108,
|
||||
"pve": "pve01",
|
||||
"ip": "10.0.21.50",
|
||||
"ssh": "ssh root@10.0.21.50",
|
||||
"role": "cdc",
|
||||
"color": "#c77dff",
|
||||
"description": "Kafka Connect on lake01 runs Debezium connectors — streams row-level changes from source DBs into Kafka topics.",
|
||||
"links": [{"label": "Kafka Connect", "url": "http://10.0.21.50:8083"}],
|
||||
"endpoints": [{"name": "kafka-connect", "host": "10.0.21.50", "port": "8083", "proto": "http"}],
|
||||
"commands": ["list connectors", "connector status", "restart connector"],
|
||||
},
|
||||
"kafka": {
|
||||
"label": "Kafka Bus",
|
||||
"vm": "atc-kafka01",
|
||||
"vmid": 113,
|
||||
"pve": "pve01",
|
||||
"ip": "10.0.21.36",
|
||||
"ssh": "ssh root@10.0.21.36",
|
||||
"role": "bus",
|
||||
"color": "#4c9aed",
|
||||
"description": "Central event bus. CDC topics flow from Debezium to s3-kafka-consumer and Spark on the lakehouse.",
|
||||
"links": [{"label": "Kafka UI", "url": "http://10.0.21.36:9000"}],
|
||||
"endpoints": [
|
||||
{"name": "broker", "host": "10.0.21.36", "port": "9092", "proto": "tcp"},
|
||||
{"name": "kafka-ui", "host": "10.0.21.36", "port": "9000", "proto": "http"},
|
||||
],
|
||||
"commands": ["broker health", "list topics", "consumer lag"],
|
||||
},
|
||||
"lakehouse": {
|
||||
"label": "Lakehouse Hub",
|
||||
"vm": "atc-lake01",
|
||||
"vmid": 108,
|
||||
"pve": "pve01",
|
||||
"ip": "10.0.21.50",
|
||||
"role": "compute",
|
||||
"color": "#e05297",
|
||||
"description": "Spark + Trino + s3-kafka-consumer. Trino federates queries across DB catalogs and Iceberg on ObjectScale S3.",
|
||||
"links": [
|
||||
{"label": "Trino", "url": "http://10.0.21.50:8089"},
|
||||
{"label": "Spark UI", "url": "http://10.0.21.50:8080"},
|
||||
{"label": "Kafka Connect", "url": "http://10.0.21.50:8083"},
|
||||
],
|
||||
"endpoints": [
|
||||
{"name": "trino", "host": "10.0.21.50", "port": "8089", "proto": "http"},
|
||||
{"name": "spark-master", "host": "10.0.21.50", "port": "8080", "proto": "http"},
|
||||
{"name": "spark-submit", "host": "10.0.21.50", "port": "7077", "proto": "tcp"},
|
||||
],
|
||||
"commands": ["trino status", "spark workers", "s3 consumer logs"],
|
||||
},
|
||||
"s3": {
|
||||
"label": "ObjectScale S3",
|
||||
"vm": "atc-objectscale",
|
||||
"vmid": 100,
|
||||
"pve": "pve01",
|
||||
"ip": "10.0.20.111",
|
||||
"ssh": "ssh root@10.0.20.111",
|
||||
"role": "storage",
|
||||
"color": "#d4a017",
|
||||
"description": "Dell ObjectScale S3-compatible storage. Landing zone for s3-kafka-consumer and Trino Iceberg catalog (bucket: data).",
|
||||
"links": [{"label": "S3 API", "url": "http://10.0.20.111:9020"}],
|
||||
"endpoints": [{"name": "s3-api", "host": "10.0.20.111", "port": "9020", "proto": "http"}],
|
||||
"bucket": "data",
|
||||
"commands": ["bucket status", "consumer write rate", "iceberg catalog"],
|
||||
},
|
||||
"docker": {
|
||||
"label": "Docker Rack",
|
||||
"vm": "atc-docker01",
|
||||
"vmid": 115,
|
||||
"pve": "pve01",
|
||||
"ip": "10.0.21.45",
|
||||
"ssh": "ssh root@10.0.21.45",
|
||||
"role": "infra",
|
||||
"color": "#9b72cf",
|
||||
"description": "Platform services — Homepage, Dockhand, Superset, Forgejo, Gitea proxy and monitoring stack.",
|
||||
"links": [
|
||||
{"label": "Homepage", "url": "http://10.0.21.45"},
|
||||
{"label": "Dockhand", "url": "http://10.0.21.45:8082"},
|
||||
{"label": "Superset", "url": "http://10.0.21.45:8088"},
|
||||
],
|
||||
"endpoints": [{"name": "dockhand", "host": "10.0.21.45", "port": "8082", "proto": "http"}],
|
||||
"commands": ["container list", "restart service", "resource usage"],
|
||||
},
|
||||
"hadoop": {
|
||||
"label": "Hadoop HDFS",
|
||||
"vm": "atc-hadoop-m01",
|
||||
"vmid": 210,
|
||||
"pve": "pve02",
|
||||
"ip": "10.0.21.61",
|
||||
"ssh": "ssh root@10.0.21.61",
|
||||
"role": "parallel",
|
||||
"color": "#3fb950",
|
||||
"description": "9-node HDFS cluster (3 masters + 5 datanodes + edge). Parallel storage layer — separate from CDC→S3 pipeline.",
|
||||
"links": [{"label": "NameNode UI", "url": "http://10.0.21.61:9870"}],
|
||||
"endpoints": [
|
||||
{"name": "namenode", "host": "10.0.21.61", "port": "9870", "proto": "http"},
|
||||
{"name": "datanodes", "host": "10.0.21.65-69", "port": "9866", "proto": "http"},
|
||||
],
|
||||
"commands": ["hdfs dfsadmin -report", "datanode status", "block health"],
|
||||
},
|
||||
"gpu": {
|
||||
"label": "GPU Lab",
|
||||
"vm": "atc-gpu-dev",
|
||||
"vmid": 303,
|
||||
"pve": "atc-gpu",
|
||||
"ip": "10.0.20.106",
|
||||
"ssh": "ssh root@10.0.20.106",
|
||||
"role": "inference",
|
||||
"color": "#3fb950",
|
||||
"description": "4× V100 GPU lab. vLLM serves the active model (Llama 3 70B GPTQ) — powers agent reasoning in this Command Center.",
|
||||
"links": [
|
||||
{"label": "GPU Lab UI", "url": "http://10.0.20.106:9000"},
|
||||
{"label": "vLLM API", "url": "http://10.0.20.106:8001/v1"},
|
||||
],
|
||||
"endpoints": [
|
||||
{"name": "gpu-lab", "host": "10.0.20.106", "port": "9000", "proto": "http"},
|
||||
{"name": "vllm", "host": "10.0.20.106", "port": "8001", "proto": "http"},
|
||||
],
|
||||
"commands": ["gpu metrics", "model status", "vram usage"],
|
||||
},
|
||||
"command": {
|
||||
"label": "Command Center",
|
||||
"vm": "MCP · VM304",
|
||||
"vmid": 304,
|
||||
"pve": "atc-gpu",
|
||||
"ip": "10.0.21.33",
|
||||
"ssh": "ssh root@10.0.21.33",
|
||||
"role": "hub",
|
||||
"color": "#4c9aed",
|
||||
"description": "ATC Command Center — FastAPI + React + Redis + Postgres + Caddy. Agent hub & approval inbox.",
|
||||
"links": [
|
||||
{"label": "Dashboard", "url": "http://10.0.21.33/"},
|
||||
{"label": "Dockhand env 13", "url": "http://10.0.21.45:8082"},
|
||||
],
|
||||
"endpoints": [{"name": "api", "host": "10.0.21.33", "port": "80", "proto": "http"}],
|
||||
"commands": ["agent status", "cluster snapshot", "dispatch mission"],
|
||||
},
|
||||
"mo-commander": {
|
||||
"label": "Mo · Command",
|
||||
"vm": "Supervisor Desk",
|
||||
"vmid": 304,
|
||||
"pve": "atc-gpu",
|
||||
"ip": "10.0.21.33",
|
||||
"role": "supervisor",
|
||||
"color": "#4c9aed",
|
||||
"description": "Mo's command desk — receives ALL lab events, agent dispatch, ingress traffic, approvals.",
|
||||
"links": [{"label": "Command Center", "url": "http://10.0.21.33/"}],
|
||||
"endpoints": [{"name": "intel-feed", "host": "10.0.21.33", "port": "80", "proto": "ws"}],
|
||||
"commands": ["events today", "ingress log", "agent status"],
|
||||
},
|
||||
"bart-commander": {
|
||||
"label": "Bart · Ops",
|
||||
"vm": "Supervisor Desk",
|
||||
"vmid": 304,
|
||||
"pve": "atc-gpu",
|
||||
"ip": "10.0.21.33",
|
||||
"role": "supervisor",
|
||||
"color": "#3fb950",
|
||||
"description": "Bart's ops desk — egress monitoring, MCP agent comms, S3 writes, GPU inference output.",
|
||||
"links": [{"label": "Command Center", "url": "http://10.0.21.33/"}],
|
||||
"endpoints": [{"name": "egress-feed", "host": "10.0.21.33", "port": "80", "proto": "ws"}],
|
||||
"commands": ["egress log", "mcp comms", "s3 write rate"],
|
||||
},
|
||||
"network-watcher": {
|
||||
"label": "Network Watcher",
|
||||
"vm": "multi-VLAN",
|
||||
"ip": "10.0.20/21.x",
|
||||
"role": "network",
|
||||
"color": "#58a6ff",
|
||||
"description": "Monitors VLAN 20 (storage/GPU) and VLAN 21 (compute) — data ingress and egress paths.",
|
||||
"links": [],
|
||||
"endpoints": [
|
||||
{"name": "vlan20", "host": "10.0.20.0/24", "port": "-", "proto": "net"},
|
||||
{"name": "vlan21", "host": "10.0.21.0/24", "port": "-", "proto": "net"},
|
||||
],
|
||||
"commands": ["ingress paths", "egress paths", "vlan status"],
|
||||
},
|
||||
"mcp-coordinator": {
|
||||
"label": "MCP Coordinator",
|
||||
"vm": "VM304",
|
||||
"ip": "10.0.21.33",
|
||||
"role": "mcp",
|
||||
"color": "#f778ba",
|
||||
"description": "Routes all MCP agent tool calls. Relays comms between operational agents and supervisor desks.",
|
||||
"links": [{"label": "API", "url": "http://10.0.21.33/api"}],
|
||||
"endpoints": [{"name": "mcp-hub", "host": "10.0.21.33", "port": "3101-3112", "proto": "http"}],
|
||||
"commands": ["agent routes", "mcp status", "relay log"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
NODE_AGENT.update({
|
||||
"mo-commander": "mo-commander",
|
||||
"bart-commander": "bart-commander",
|
||||
"network-watcher": "network-watcher",
|
||||
"mcp-coordinator": "mcp-coordinator",
|
||||
"etl-guardian": "etl-guardian",
|
||||
"lakehouse-ops": "lakehouse-ops",
|
||||
"data-custodian": "data-custodian",
|
||||
"hadoop-ranger": "hadoop-ranger",
|
||||
"infra-sentinel": "infra-sentinel",
|
||||
})
|
||||
|
||||
|
||||
def is_node_id(subject_id: str) -> bool:
|
||||
return subject_id in NODE_REGISTRY
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Build live presentation deck from cluster snapshot + registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from node_registry import NODE_REGISTRY
|
||||
from topology_views import build_all_topologies
|
||||
from workload import build_workload_payload
|
||||
|
||||
|
||||
def _status_badge(level: str) -> str:
|
||||
return {"ok": "● Online", "warn": "◐ Degraded", "down": "○ Offline", "unknown": "? Unknown"}.get(level, level)
|
||||
|
||||
|
||||
def _slide(slide_id: str, title: str, subtitle: str, bullets: list[str], **extra: Any) -> dict[str, Any]:
|
||||
return {"id": slide_id, "title": title, "subtitle": subtitle, "bullets": bullets, **extra}
|
||||
|
||||
|
||||
def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
|
||||
workload = build_workload_payload(snap)
|
||||
topologies = workload.get("topologies") or build_all_topologies(snap)
|
||||
totals = workload.get("totals", {})
|
||||
zones = workload.get("zones", [])
|
||||
gpu = workload.get("gpu", {})
|
||||
etl = snap.get("etl", {})
|
||||
hadoop = snap.get("hadoop", {})
|
||||
objectscale = snap.get("objectscale", {})
|
||||
command = snap.get("command_center", {})
|
||||
|
||||
slides: list[dict[str, Any]] = []
|
||||
|
||||
slides.append(_slide(
|
||||
"title",
|
||||
"Dell ATC Data Lab",
|
||||
"Live demo & presentation — Command Center",
|
||||
[
|
||||
f"Snapshot: {snap.get('ts', 'now')}",
|
||||
f"Pipeline: {'ACTIVE' if totals.get('pipeline_active') else 'INACTIVE'}",
|
||||
f"Apps running: {totals.get('apps_running', 0)}/{totals.get('apps_total', 0)}",
|
||||
f"CDC connectors: {totals.get('connectors', 0)}",
|
||||
f"LLM: {gpu.get('model') or 'offline'} ({gpu.get('gpu_count', 0)}× V100)",
|
||||
"Command Center → http://10.0.21.33/",
|
||||
],
|
||||
kind="hero",
|
||||
))
|
||||
|
||||
slides.append(_slide(
|
||||
"mission",
|
||||
"Mission",
|
||||
"End-to-end modern data platform on Dell infrastructure",
|
||||
[
|
||||
"Ingest change data from operational databases (PostgreSQL, MySQL, MongoDB, Cassandra)",
|
||||
"Stream via Kafka & Debezium into the lakehouse (Spark, Trino, Iceberg)",
|
||||
"Land curated data on ObjectScale S3 — query with Trino & visualize in Superset",
|
||||
"Parallel HDFS cluster for batch / legacy workloads",
|
||||
"GPU lab powers autonomous ops agents with local vLLM inference",
|
||||
"This dashboard orchestrates agents, approvals, and live cluster visibility",
|
||||
],
|
||||
kind="narrative",
|
||||
))
|
||||
|
||||
arch = topologies.get("architecture") or workload.get("topology") or {}
|
||||
arch_nodes = arch.get("nodes", [])
|
||||
slides.append(_slide(
|
||||
"architecture",
|
||||
"Data Platform Architecture",
|
||||
arch.get("subtitle", "Sources → Ingestion → Compute → Storage → Consumers"),
|
||||
[f"{n.get('label', n.get('id'))}: {n.get('subtitle', n.get('role', ''))}" for n in arch_nodes[:14]],
|
||||
kind="topology",
|
||||
topology=arch,
|
||||
))
|
||||
|
||||
pipeline = topologies.get("pipeline", {})
|
||||
connector_lines = [
|
||||
f" · {cs['name']}: {cs.get('state', '?')}"
|
||||
for cs in (etl.get("connector_status") or [])[:6]
|
||||
]
|
||||
slides.append(_slide(
|
||||
"pipeline",
|
||||
"CDC Pipeline",
|
||||
pipeline.get("subtitle", "Airflow → DB → Debezium → Kafka → Lakehouse → S3"),
|
||||
[
|
||||
f"Airflow: {'healthy' if etl.get('airflow_healthy') else 'degraded'} ({etl.get('airflow_url', '')})",
|
||||
f"Kafka UI: {'UP' if etl.get('kafka_ui_ok') else 'DOWN'}",
|
||||
f"Connectors: {', '.join(etl.get('connectors') or []) or 'none'}",
|
||||
*connector_lines,
|
||||
f"Spark UI: {'UP' if etl.get('spark_ui_ok') else 'DOWN'}",
|
||||
f"ObjectScale: {'reachable' if objectscale.get('reachable') else 'down'} bucket={objectscale.get('bucket', 'data')}",
|
||||
],
|
||||
kind="topology",
|
||||
topology=pipeline,
|
||||
))
|
||||
|
||||
for zone in zones:
|
||||
apps = zone.get("apps") or []
|
||||
app_lines = [
|
||||
f"{a['name']}: {a['state']}" + (f" ({a.get('host', '')})" if a.get("host") else "")
|
||||
for a in apps[:10]
|
||||
]
|
||||
slides.append(_slide(
|
||||
f"zone-{zone['id']}",
|
||||
zone["label"],
|
||||
f"{zone.get('vm', '')} · {zone.get('ip', '')} · {_status_badge(zone.get('level', 'unknown'))}",
|
||||
[
|
||||
f"Containers: {zone.get('running', 0)}/{zone.get('total', 0)} running",
|
||||
*app_lines,
|
||||
],
|
||||
kind="zone",
|
||||
zone=zone,
|
||||
))
|
||||
|
||||
infra_nodes = [
|
||||
nid for nid in NODE_REGISTRY
|
||||
if nid not in ("mo-commander", "bart-commander", "network-watcher", "mcp-coordinator")
|
||||
]
|
||||
slides.append(_slide(
|
||||
"infrastructure",
|
||||
"Infrastructure Map",
|
||||
"Proxmox VMs & services across VLAN 20/21",
|
||||
[
|
||||
f"{NODE_REGISTRY[nid]['label']} — {NODE_REGISTRY[nid].get('vm')} "
|
||||
f"(VMID {NODE_REGISTRY[nid].get('vmid', '?')}) @ {NODE_REGISTRY[nid].get('ip')}"
|
||||
for nid in infra_nodes
|
||||
],
|
||||
kind="registry",
|
||||
))
|
||||
|
||||
dn_lines = [
|
||||
f" · {dn['host']}: {dn.get('used_gb', 0)} GB — {dn.get('state', '')}"
|
||||
for dn in (hadoop.get("datanodes") or [])[:5]
|
||||
]
|
||||
slides.append(_slide(
|
||||
"hadoop",
|
||||
"Hadoop HDFS",
|
||||
"9-node parallel storage cluster",
|
||||
[
|
||||
f"NameNode: {'UP' if hadoop.get('reachable') else 'DOWN'} — {hadoop.get('namenode', '')}",
|
||||
f"Capacity: {hadoop.get('capacity_used_gb', '?')} / {hadoop.get('capacity_total_gb', '?')} GB",
|
||||
f"DataNodes: {hadoop.get('live_datanodes', 0)} live, {hadoop.get('dead_datanodes', 0)} dead",
|
||||
f"Files: {hadoop.get('files_total', 0)}, Blocks: {hadoop.get('blocks_total', 0)}",
|
||||
*dn_lines,
|
||||
],
|
||||
kind="data",
|
||||
))
|
||||
|
||||
gpus = gpu.get("gpus") or snap.get("gpu", {}).get("gpus") or []
|
||||
gpu_lines = [
|
||||
f"GPU{g['index']}: {g.get('util_gpu', 0):.0f}% util, "
|
||||
f"{g.get('memory_used_mib', 0):.0f}/{g.get('memory_total_mib', 0):.0f} MiB"
|
||||
for g in gpus[:4]
|
||||
]
|
||||
slides.append(_slide(
|
||||
"gpu",
|
||||
"GPU Lab & GenAI",
|
||||
f"{gpu.get('model') or 'vLLM'} on atc-gpu-dev (VM 303)",
|
||||
[
|
||||
f"Inference: {'ON' if gpu.get('inference_active') else 'OFF'}",
|
||||
f"API: {snap.get('gpu', {}).get('vllm_url') or 'http://10.0.20.106:8001/v1'}",
|
||||
"Manager: http://10.0.20.106:9000",
|
||||
*gpu_lines,
|
||||
],
|
||||
kind="gpu",
|
||||
))
|
||||
|
||||
slides.append(_slide(
|
||||
"agents",
|
||||
"Autonomous Agents",
|
||||
"Mo & Bart supervise 5 domain operators + MCP hub",
|
||||
[
|
||||
"ETL Guardian — Airflow, Kafka, Debezium, connectors",
|
||||
"Data Custodian — PostgreSQL, MySQL, MongoDB, Cassandra, Neo4j",
|
||||
"Lakehouse Ops — Spark, Trino, Iceberg, ObjectScale S3",
|
||||
"Hadoop Ranger — HDFS NameNode, DataNodes, block health",
|
||||
"Infra Sentinel — Docker rack, GPU lab, Command Center",
|
||||
"All agents receive LIVE cluster snapshot in every LLM prompt",
|
||||
],
|
||||
kind="agents",
|
||||
))
|
||||
|
||||
cc_apps = [f"{c['name']}: {c['state']}" for c in (command.get("containers") or [])]
|
||||
slides.append(_slide(
|
||||
"command",
|
||||
"Command Center",
|
||||
"VM 304 — this presentation runs here",
|
||||
[
|
||||
f"Host: {command.get('host', '10.0.21.33')} (VMID {command.get('vmid', 304)})",
|
||||
f"Stack: {command.get('running', 0)}/{command.get('total', 0)} containers",
|
||||
*cc_apps,
|
||||
"WebSocket ops feed · Approval inbox · Agent terminals",
|
||||
],
|
||||
kind="command",
|
||||
))
|
||||
|
||||
slides.append(_slide(
|
||||
"demo",
|
||||
"Live Demo Tips",
|
||||
"Use this deck during customer presentations",
|
||||
[
|
||||
"Press ← → or click dots to navigate slides",
|
||||
"F = fullscreen presentation mode",
|
||||
"Export HTML opens a standalone deck for projectors / offline",
|
||||
"Ask agents in the Command Bar — they see full cluster context",
|
||||
"Switch to Data Platform tab for interactive topology",
|
||||
"GPU Lab chat: http://10.0.20.106:9000/chat",
|
||||
],
|
||||
kind="cta",
|
||||
))
|
||||
|
||||
return {
|
||||
"ts": snap.get("ts"),
|
||||
"title": "Dell ATC Data Lab",
|
||||
"subtitle": "Live Infrastructure Presentation",
|
||||
"totals": totals,
|
||||
"pipeline_active": totals.get("pipeline_active"),
|
||||
"slides": slides,
|
||||
"slide_count": len(slides),
|
||||
"workload": workload,
|
||||
"topologies": topologies,
|
||||
}
|
||||
|
||||
|
||||
def render_presentation_html(payload: dict[str, Any]) -> str:
|
||||
slides_json = json.dumps(payload.get("slides", []), default=str)
|
||||
title = payload.get("title", "ATC Lab")
|
||||
ts = payload.get("ts", "")
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>{title} — Presentation</title>
|
||||
<style>
|
||||
*{{box-sizing:border-box;margin:0;padding:0}}
|
||||
body{{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0c1929;color:#e8f1ff;height:100vh;overflow:hidden}}
|
||||
.deck{{height:100vh;display:flex;flex-direction:column}}
|
||||
header{{padding:1rem 2rem;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid rgba(96,165,250,.2);background:rgba(15,27,46,.9)}}
|
||||
header h1{{font-size:1.1rem;font-weight:600}}
|
||||
header .meta{{font-size:.75rem;opacity:.7}}
|
||||
.slide{{flex:1;display:none;padding:3rem 4rem;overflow:auto}}
|
||||
.slide.active{{display:flex;flex-direction:column;justify-content:center}}
|
||||
.slide h2{{font-size:2.4rem;margin-bottom:.5rem;background:linear-gradient(90deg,#60a5fa,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent}}
|
||||
.slide h3{{font-size:1rem;opacity:.75;margin-bottom:2rem;font-weight:400}}
|
||||
.slide ul{{list-style:none;font-size:1.15rem;line-height:1.9}}
|
||||
.slide li::before{{content:"▸ ";color:#60a5fa}}
|
||||
.slide.hero h2{{font-size:3.2rem}}
|
||||
nav{{display:flex;gap:.5rem;padding:1rem 2rem;border-top:1px solid rgba(96,165,250,.2);align-items:center}}
|
||||
nav button{{background:#1e3a5f;border:1px solid rgba(96,165,250,.3);color:#e8f1ff;padding:.5rem 1rem;border-radius:6px;cursor:pointer}}
|
||||
nav button:hover{{background:#234876}}
|
||||
.dots{{display:flex;gap:6px;flex:1;justify-content:center;flex-wrap:wrap}}
|
||||
.dot{{width:8px;height:8px;border-radius:50%;background:rgba(96,165,250,.3);cursor:pointer;border:none}}
|
||||
.dot.active{{background:#60a5fa;transform:scale(1.3)}}
|
||||
.counter{{font-size:.8rem;opacity:.6;min-width:4rem;text-align:right}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="deck">
|
||||
<header><h1>{title}</h1><div class="meta">Dell ATC · Live snapshot {ts}</div></header>
|
||||
<div id="slides"></div>
|
||||
<nav>
|
||||
<button id="prev">← Prev</button>
|
||||
<div class="dots" id="dots"></div>
|
||||
<button id="next">Next →</button>
|
||||
<span class="counter" id="counter"></span>
|
||||
</nav>
|
||||
</div>
|
||||
<script>
|
||||
const slides={slides_json};
|
||||
let i=0;
|
||||
const container=document.getElementById("slides");
|
||||
const dots=document.getElementById("dots");
|
||||
const counter=document.getElementById("counter");
|
||||
slides.forEach((s,idx)=>{{
|
||||
const el=document.createElement("section");
|
||||
el.className="slide"+(s.kind==="hero"?" hero":"")+(idx===0?" active":"");
|
||||
const bullets=(s.bullets||[]).map(b=>"<li>"+b+"</li>").join("");
|
||||
el.innerHTML="<h2>"+s.title+"</h2><h3>"+(s.subtitle||"")+"</h3><ul>"+bullets+"</ul>";
|
||||
container.appendChild(el);
|
||||
const d=document.createElement("button");
|
||||
d.className="dot"+(idx===0?" active":"");
|
||||
d.onclick=()=>go(idx);
|
||||
dots.appendChild(d);
|
||||
}});
|
||||
function go(n){{i=Math.max(0,Math.min(slides.length-1,n));document.querySelectorAll(".slide").forEach((e,j)=>e.classList.toggle("active",j===i));document.querySelectorAll(".dot").forEach((e,j)=>e.classList.toggle("active",j===i));counter.textContent=(i+1)+"/"+slides.length;}}
|
||||
document.getElementById("prev").onclick=()=>go(i-1);
|
||||
document.getElementById("next").onclick=()=>go(i+1);
|
||||
document.onkeydown=e=>{{if(e.key==="ArrowRight"||e.key===" ")go(i+1);if(e.key==="ArrowLeft")go(i-1);if(e.key==="f"||e.key==="F")document.documentElement.requestFullscreen?.();}};
|
||||
go(0);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Pre-built modern HTML presentation templates — English."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
MODERN_DECKS: dict[str, dict[str, Any]] = {
|
||||
"data-maturity": {
|
||||
"id": "data-maturity",
|
||||
"title": "Data Maturity Assessment",
|
||||
"subtitle": "Dell ATC — Customer Data Onboarding Framework",
|
||||
"slides": [
|
||||
{
|
||||
"id": "dm-1", "kind": "hero",
|
||||
"title": "Data Maturity Assessment",
|
||||
"subtitle": "From raw data to trusted decisions",
|
||||
"bullets": [
|
||||
"6 dimensions: Completeness, Consistency, Validity, Uniqueness, Timeliness, Accuracy",
|
||||
"Automated analysis with Docling, Great Expectations & Soda Core",
|
||||
"Full report with priorities and remediation roadmap",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "dm-2", "kind": "narrative",
|
||||
"title": "Why maturity?",
|
||||
"subtitle": "Customers hand over data — we show where it stands",
|
||||
"bullets": [
|
||||
"73% of analytics projects fail due to data quality (Gartner)",
|
||||
"Without a baseline there is no measurable improvement",
|
||||
"DQ tools + document parsing = complete picture",
|
||||
"Report ready for boardroom & audit",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "dm-3", "kind": "zone",
|
||||
"title": "Our toolchain",
|
||||
"subtitle": "Integrated on the ATC platform",
|
||||
"bullets": [
|
||||
"Docling — PDF, PPTX, DOCX, XLSX → structured data",
|
||||
"Great Expectations — Python expectations & data contracts",
|
||||
"Soda Core — YAML checks, freshness, anomaly monitoring",
|
||||
"DQ API — maturity score + HTML report",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "dm-4", "kind": "cta",
|
||||
"title": "Next step",
|
||||
"subtitle": "Upload customer data in Data Quality tab",
|
||||
"bullets": [
|
||||
"Upload CSV, Excel, PDF or database export",
|
||||
"Receive maturity score (0–100) per dimension",
|
||||
"Action list: what to fix first",
|
||||
"Reports are stored — no need to re-upload",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"atc-platform": {
|
||||
"id": "atc-platform",
|
||||
"title": "ATC Data Platform",
|
||||
"subtitle": "Modern lakehouse on Dell infrastructure",
|
||||
"slides": [
|
||||
{
|
||||
"id": "atc-1", "kind": "hero",
|
||||
"title": "ATC Data & AI Platform",
|
||||
"subtitle": "CDC → Kafka → Spark → Iceberg → Trino",
|
||||
"bullets": [
|
||||
"Live pipeline: PostgreSQL, MySQL, MongoDB, Cassandra",
|
||||
"ObjectScale S3 + 9-node Hadoop cluster",
|
||||
"GPU Lab: Llama 3 70B for autonomous agents",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "atc-2", "kind": "topology",
|
||||
"title": "End-to-end flow",
|
||||
"subtitle": "Sources → Ingestion → Compute → Storage → Consumers",
|
||||
"bullets": [
|
||||
"Airflow orchestrates daily data generation",
|
||||
"Debezium CDC → Kafka → Spark → Iceberg",
|
||||
"Trino federated queries + Superset BI",
|
||||
"GenAI agents with full cluster context",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"stack-architecture": {
|
||||
"id": "stack-architecture",
|
||||
"title": "ATC Stack Architecture",
|
||||
"subtitle": "How the Command Center, DQ, RAG & Lakehouse fit together",
|
||||
"slides": [
|
||||
{
|
||||
"id": "arch-1", "kind": "hero",
|
||||
"title": "ATC Intelligent Data Platform",
|
||||
"subtitle": "One dashboard — ingest, assess, chat, present",
|
||||
"bullets": [
|
||||
"Command Center at http://10.0.21.33 — single entry point",
|
||||
"Upload once → stored permanently in ChromaDB + file registry",
|
||||
"Ask questions anytime via Knowledge Chat (RAG + LangChain)",
|
||||
"Present architecture & maturity to customers live",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "arch-2", "kind": "architecture", "animation": "full-stack",
|
||||
"title": "Full Stack Overview",
|
||||
"subtitle": "All services on VM304 (Command Center)",
|
||||
"bullets": [
|
||||
"Caddy routes /api, /dq, /rag to backend services",
|
||||
"React UI — Data Platform, Presentation, Data Quality, Knowledge Chat",
|
||||
"Docling on port 5001 for document parsing UI + API",
|
||||
"Postgres + Redis for agents; ChromaDB for vectors",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "arch-3", "kind": "architecture", "animation": "lakehouse",
|
||||
"title": "Lakehouse Pipeline",
|
||||
"subtitle": "Operational data → analytics-ready tables",
|
||||
"bullets": [
|
||||
"Sources on DB Vault (10.0.21.51): PG, MySQL, Mongo, Cassandra, Neo4j",
|
||||
"Debezium captures changes → Kafka topics",
|
||||
"Spark transforms → Iceberg tables on ObjectScale",
|
||||
"Trino SQL + Superset dashboards for consumers",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "arch-4", "kind": "architecture", "animation": "dq-flow",
|
||||
"title": "Data Quality & Maturity",
|
||||
"subtitle": "Prove data readiness before AI/ML projects",
|
||||
"bullets": [
|
||||
"Upload customer file → parsed by Docling if PDF/Office",
|
||||
"6 maturity dimensions scored 0–100 with findings",
|
||||
"GE + Soda checks per column — expandable in UI",
|
||||
"HTML report + image gallery — stored in /data/reports",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "arch-5", "kind": "architecture", "animation": "rag-flow",
|
||||
"title": "Knowledge Chat (RAG)",
|
||||
"subtitle": "Upload once — query forever",
|
||||
"bullets": [
|
||||
"Document saved to disk + indexed in ChromaDB (persistent volume)",
|
||||
"Duplicate uploads skipped automatically (SHA-256 hash)",
|
||||
"LangChain retrieves top-k chunks → Llama 70B on GPU Lab",
|
||||
"Answers include source filename + chunk preview",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "arch-6", "kind": "narrative",
|
||||
"title": "AI Agents Layer",
|
||||
"subtitle": "Autonomous ops with full lab context",
|
||||
"bullets": [
|
||||
"Supervisor + field operators on Command Center",
|
||||
"Each agent sees live workload, GPU, databases, topology",
|
||||
"LLM: Llama 3 70B GPTQ via vLLM (10.0.20.106:8001)",
|
||||
"Approval workflow for sensitive operations",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "arch-7", "kind": "zone",
|
||||
"title": "Infrastructure Map",
|
||||
"subtitle": "Dell ATC cluster — key IPs",
|
||||
"bullets": [
|
||||
"Command Center VM304: 10.0.21.33 (this dashboard)",
|
||||
"GPU Lab VM303: 10.0.20.106 — 7× V100, vLLM, model manager",
|
||||
"DB Vault: 10.0.21.51 · Lakehouse: 10.0.21.50",
|
||||
"Docling UI: http://10.0.21.33:5001/ui/",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "arch-8", "kind": "cta",
|
||||
"title": "Customer Demo Flow",
|
||||
"subtitle": "Recommended narrative for presentations",
|
||||
"bullets": [
|
||||
"1. Show live Data Platform topology & agent fleet",
|
||||
"2. Upload customer sample → Data Quality maturity report",
|
||||
"3. Same file already in Knowledge Chat — ask questions live",
|
||||
"4. Export this architecture deck as HTML for customer handout",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def list_static_decks() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"id": k, "title": v["title"], "subtitle": v["subtitle"], "slide_count": len(v["slides"]), "source": "builtin"}
|
||||
for k, v in MODERN_DECKS.items()
|
||||
]
|
||||
|
||||
|
||||
def get_static_deck(deck_id: str) -> dict[str, Any] | None:
|
||||
deck = MODERN_DECKS.get(deck_id)
|
||||
if not deck:
|
||||
return None
|
||||
return {
|
||||
"ts": None,
|
||||
"title": deck["title"],
|
||||
"subtitle": deck["subtitle"],
|
||||
"slides": deck["slides"],
|
||||
"slide_count": len(deck["slides"]),
|
||||
"source": "builtin",
|
||||
"id": deck_id,
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Upload PPT/PPTX decks and convert to presentation JSON + HTML."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
PRESENTATIONS_DIR = Path(os.getenv("PRESENTATIONS_DIR", "/data/presentations"))
|
||||
DOCLING_URL = os.getenv("DOCLING_URL", "http://docling-serve:5001").rstrip("/")
|
||||
|
||||
|
||||
def _ensure_dir() -> Path:
|
||||
PRESENTATIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return PRESENTATIONS_DIR
|
||||
|
||||
|
||||
def list_decks() -> list[dict[str, Any]]:
|
||||
_ensure_dir()
|
||||
decks = []
|
||||
for meta_path in sorted(PRESENTATIONS_DIR.glob("*/meta.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text())
|
||||
decks.append(meta)
|
||||
except Exception:
|
||||
continue
|
||||
return decks
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
return re.sub(r"[^a-zA-Z0-9._-]+", "_", name)[:80]
|
||||
|
||||
|
||||
def pptx_to_slides(path: Path) -> list[dict[str, Any]]:
|
||||
from pptx import Presentation
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
|
||||
prs = Presentation(str(path))
|
||||
slides: list[dict[str, Any]] = []
|
||||
for idx, slide in enumerate(prs.slides, start=1):
|
||||
bullets: list[str] = []
|
||||
title = ""
|
||||
for shape in slide.shapes:
|
||||
if not hasattr(shape, "text"):
|
||||
continue
|
||||
text = (shape.text or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if shape.shape_type == MSO_SHAPE_TYPE.PLACEHOLDER and not title:
|
||||
title = text.split("\n")[0][:120]
|
||||
else:
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
if line and line != title:
|
||||
bullets.append(line[:240])
|
||||
if not title:
|
||||
title = f"Slide {idx}"
|
||||
slides.append({
|
||||
"id": f"upload-{idx}",
|
||||
"title": title,
|
||||
"subtitle": "",
|
||||
"bullets": bullets[:12] or ["(empty slide)"],
|
||||
"kind": "upload",
|
||||
})
|
||||
return slides
|
||||
|
||||
|
||||
async def docling_enrich(path: Path) -> list[dict[str, Any]] | None:
|
||||
"""Optional: parse via Docling for richer structure."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
with path.open("rb") as f:
|
||||
r = await client.post(
|
||||
f"{DOCLING_URL}/v1/convert/file",
|
||||
files={"files": (path.name, f, "application/octet-stream")},
|
||||
data={"to_formats": "md"},
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
return None
|
||||
data = r.json()
|
||||
md = ""
|
||||
if isinstance(data, dict):
|
||||
doc = data.get("document") or data.get("result") or data
|
||||
if isinstance(doc, dict):
|
||||
md = doc.get("md_content") or doc.get("markdown") or ""
|
||||
elif isinstance(doc, str):
|
||||
md = doc
|
||||
if not md:
|
||||
return None
|
||||
slides = []
|
||||
chunks = [c.strip() for c in re.split(r"\n#{1,2}\s+", md) if c.strip()]
|
||||
for i, chunk in enumerate(chunks[:40], start=1):
|
||||
lines = [ln.strip() for ln in chunk.split("\n") if ln.strip()]
|
||||
title = lines[0][:120] if lines else f"Slide {i}"
|
||||
bullets = [ln.lstrip("-•* ").strip() for ln in lines[1:13] if ln.strip()]
|
||||
slides.append({
|
||||
"id": f"docling-{i}",
|
||||
"title": title,
|
||||
"subtitle": "Docling parsed",
|
||||
"bullets": bullets or ["—"],
|
||||
"kind": "upload",
|
||||
})
|
||||
return slides if slides else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def save_upload(filename: str, content: bytes) -> dict[str, Any]:
|
||||
_ensure_dir()
|
||||
deck_id = str(uuid.uuid4())[:8]
|
||||
deck_dir = PRESENTATIONS_DIR / deck_id
|
||||
deck_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe = _safe_name(filename)
|
||||
dest = deck_dir / safe
|
||||
dest.write_bytes(content)
|
||||
|
||||
slides: list[dict[str, Any]] = []
|
||||
source = "pptx"
|
||||
if safe.lower().endswith((".pptx", ".ppt")):
|
||||
slides = pptx_to_slides(dest)
|
||||
docling_slides = await docling_enrich(dest)
|
||||
if docling_slides and len(docling_slides) >= len(slides):
|
||||
slides = docling_slides
|
||||
source = "docling+pptx"
|
||||
else:
|
||||
docling_slides = await docling_enrich(dest)
|
||||
if docling_slides:
|
||||
slides = docling_slides
|
||||
source = "docling"
|
||||
|
||||
if not slides:
|
||||
slides = [{
|
||||
"id": "upload-1",
|
||||
"title": safe,
|
||||
"subtitle": "Uploaded file",
|
||||
"bullets": [f"File stored at {dest.name}", "Could not auto-parse slides — open in editor or re-upload PPTX"],
|
||||
"kind": "upload",
|
||||
}]
|
||||
|
||||
payload = {
|
||||
"id": deck_id,
|
||||
"filename": safe,
|
||||
"source": source,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"title": safe.rsplit(".", 1)[0],
|
||||
"subtitle": "Uploaded presentation",
|
||||
"slide_count": len(slides),
|
||||
"slides": slides,
|
||||
}
|
||||
(deck_dir / "meta.json").write_text(json.dumps(payload, indent=2, default=str))
|
||||
(deck_dir / "deck.json").write_text(json.dumps(payload, default=str))
|
||||
return payload
|
||||
|
||||
|
||||
def get_deck(deck_id: str) -> dict[str, Any] | None:
|
||||
path = PRESENTATIONS_DIR / deck_id / "meta.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
return json.loads(path.read_text())
|
||||
@@ -4,6 +4,13 @@ redis==5.2.1
|
||||
httpx==0.28.1
|
||||
sqlalchemy==2.0.36
|
||||
aiosqlite==0.20.0
|
||||
psycopg2-binary==2.9.10
|
||||
pydantic==2.10.4
|
||||
python-multipart==0.0.20
|
||||
websockets==14.1
|
||||
pymysql==1.1.1
|
||||
pymongo==4.10.1
|
||||
cassandra-driver==3.29.2
|
||||
neo4j==5.26.0
|
||||
python-pptx==1.0.2
|
||||
boto3==1.35.99
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""ObjectScale / S3 storage API for Command Center."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
from botocore.exceptions import ClientError
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
S3_ENDPOINT = os.getenv("S3_ENDPOINT", "http://10.0.20.111:9020")
|
||||
S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY", "object_admin1")
|
||||
S3_SECRET_KEY = os.getenv("S3_SECRET_KEY", "ChangeMeChangeMeChangeMeChangeMeChangeMe")
|
||||
S3_REGION = os.getenv("S3_REGION", "us-east-1")
|
||||
|
||||
router = APIRouter(prefix="/api/storage/s3", tags=["storage"])
|
||||
|
||||
|
||||
def _client():
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=S3_ENDPOINT,
|
||||
aws_access_key_id=S3_ACCESS_KEY,
|
||||
aws_secret_access_key=S3_SECRET_KEY,
|
||||
region_name=S3_REGION,
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
|
||||
)
|
||||
|
||||
|
||||
def _human_size(n: int) -> str:
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if n < 1024:
|
||||
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} PB"
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def s3_health():
|
||||
try:
|
||||
s3 = _client()
|
||||
buckets = s3.list_buckets()
|
||||
names = [b["Name"] for b in buckets.get("Buckets", [])]
|
||||
return {
|
||||
"ok": True,
|
||||
"endpoint": S3_ENDPOINT,
|
||||
"buckets": len(names),
|
||||
"bucket_names": names,
|
||||
}
|
||||
except Exception as exc:
|
||||
return JSONResponse({"ok": False, "endpoint": S3_ENDPOINT, "error": str(exc)}, status_code=502)
|
||||
|
||||
|
||||
@router.get("/buckets")
|
||||
async def list_buckets():
|
||||
try:
|
||||
s3 = _client()
|
||||
resp = s3.list_buckets()
|
||||
items = []
|
||||
for b in resp.get("Buckets", []):
|
||||
name = b["Name"]
|
||||
try:
|
||||
loc = s3.list_objects_v2(Bucket=name, MaxKeys=1)
|
||||
count_hint = loc.get("KeyCount", 0)
|
||||
except ClientError:
|
||||
count_hint = None
|
||||
items.append({
|
||||
"name": name,
|
||||
"created": b.get("CreationDate", "").isoformat() if b.get("CreationDate") else None,
|
||||
"has_objects": bool(count_hint),
|
||||
})
|
||||
return {"ok": True, "buckets": items, "endpoint": S3_ENDPOINT}
|
||||
except Exception as exc:
|
||||
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
|
||||
|
||||
|
||||
@router.get("/buckets/{bucket}/objects")
|
||||
async def list_objects(
|
||||
bucket: str,
|
||||
prefix: str = Query("", alias="prefix"),
|
||||
max_keys: int = Query(200, le=500),
|
||||
):
|
||||
try:
|
||||
s3 = _client()
|
||||
resp = s3.list_objects_v2(Bucket=bucket, Prefix=prefix, Delimiter="/", MaxKeys=max_keys)
|
||||
folders = [
|
||||
{"type": "prefix", "name": p["Prefix"][len(prefix):].rstrip("/"), "prefix": p["Prefix"]}
|
||||
for p in resp.get("CommonPrefixes", [])
|
||||
]
|
||||
objects = [
|
||||
{
|
||||
"type": "object",
|
||||
"key": o["Key"],
|
||||
"name": o["Key"][len(prefix):] if o["Key"].startswith(prefix) else o["Key"],
|
||||
"size": o.get("Size", 0),
|
||||
"size_human": _human_size(o.get("Size", 0)),
|
||||
"modified": o.get("LastModified", "").isoformat() if o.get("LastModified") else None,
|
||||
}
|
||||
for o in resp.get("Contents", [])
|
||||
if o["Key"] != prefix
|
||||
]
|
||||
return {
|
||||
"ok": True,
|
||||
"bucket": bucket,
|
||||
"prefix": prefix,
|
||||
"folders": folders,
|
||||
"objects": objects,
|
||||
"truncated": resp.get("IsTruncated", False),
|
||||
}
|
||||
except ClientError as exc:
|
||||
return JSONResponse({"ok": False, "error": str(exc)}, status_code=403)
|
||||
except Exception as exc:
|
||||
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
|
||||
|
||||
|
||||
@router.get("/buckets/{bucket}/download")
|
||||
async def download_object(bucket: str, key: str = Query(...)):
|
||||
try:
|
||||
s3 = _client()
|
||||
obj = s3.get_object(Bucket=bucket, Key=key)
|
||||
body = obj["Body"]
|
||||
filename = key.split("/")[-1] or "download"
|
||||
media = obj.get("ContentType") or "application/octet-stream"
|
||||
|
||||
def stream():
|
||||
while chunk := body.read(1024 * 256):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
stream(),
|
||||
media_type=media,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
except ClientError as exc:
|
||||
return JSONResponse({"ok": False, "error": str(exc)}, status_code=404)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Fan-out lab events to supervisor agents Mo & Bart."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent_terminal import terminal_log
|
||||
from node_registry import NODE_IDS
|
||||
|
||||
SUPERVISOR_IDS = ["mo-commander", "bart-commander"]
|
||||
OPERATOR_IDS = {
|
||||
"etl-guardian", "lakehouse-ops", "data-custodian", "hadoop-ranger", "infra-sentinel",
|
||||
"network-watcher", "mcp-coordinator",
|
||||
}
|
||||
|
||||
|
||||
async def mirror_to_supervisors(
|
||||
source: str,
|
||||
message: str,
|
||||
*,
|
||||
level: str = "info",
|
||||
phase: str = "intel",
|
||||
) -> None:
|
||||
icon = {"warn": "⚠", "err": "✗", "ok": "✓"}.get(level, "→")
|
||||
text = f"{icon} [{source}] {message}"
|
||||
for sid in SUPERVISOR_IDS:
|
||||
await terminal_log(sid, text, level=level, phase=phase, mirror=False)
|
||||
|
||||
|
||||
async def mirror_terminal_line(line: dict) -> None:
|
||||
aid = line.get("agent_id", "")
|
||||
if aid in SUPERVISOR_IDS or aid in NODE_IDS:
|
||||
return
|
||||
if aid in OPERATOR_IDS:
|
||||
lvl = line.get("level", "info")
|
||||
await mirror_to_supervisors(aid, line.get("text", "")[:240], level=lvl, phase="trace")
|
||||
@@ -0,0 +1,606 @@
|
||||
"""Five animated topology views from data architecture perspectives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _edge(eid: str, src: str, dst: str, label: str, kind: str, active: bool = True) -> dict[str, Any]:
|
||||
return {"id": eid, "from": src, "to": dst, "label": label, "kind": kind, "active": active}
|
||||
|
||||
|
||||
def _clone_node(n: dict[str, Any], x: float, y: float, layer: str | None = None) -> dict[str, Any]:
|
||||
out = {**n, "x": x, "y": y}
|
||||
if layer:
|
||||
out["layer"] = layer
|
||||
return out
|
||||
|
||||
|
||||
def build_all_topologies(
|
||||
base_nodes: list[dict[str, Any]],
|
||||
base_edges: list[dict[str, Any]],
|
||||
snap: dict[str, Any],
|
||||
*,
|
||||
pipeline_active: bool,
|
||||
connectors: list[str],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
by_id = {n["id"]: n for n in base_nodes}
|
||||
etl = snap.get("etl", {})
|
||||
lake = snap.get("lakehouse", {})
|
||||
docker = snap.get("docker", {})
|
||||
gpu = snap.get("gpu", {})
|
||||
|
||||
# ── 1. PIPELINE (CDC end-to-end) ──
|
||||
pipeline = {
|
||||
"id": "pipeline",
|
||||
"label": "CDC Pipeline",
|
||||
"subtitle": "Ingest → Stream → Process → Object Storage",
|
||||
"layers": [
|
||||
{"id": "ingest", "label": "INGEST", "y": 12, "color": "#e8a838"},
|
||||
{"id": "stream", "label": "STREAM", "y": 12, "color": "#4c9aed"},
|
||||
{"id": "process", "label": "PROCESS", "y": 12, "color": "#e05297"},
|
||||
{"id": "store", "label": "STORE", "y": 12, "color": "#d4a017"},
|
||||
],
|
||||
"nodes": [
|
||||
_clone_node(by_id["airflow"], 8, 22, "ingest"),
|
||||
_clone_node(by_id["db"], 24, 22, "ingest"),
|
||||
_clone_node(by_id["debezium"], 40, 22, "stream"),
|
||||
_clone_node(by_id["kafka"], 56, 22, "stream"),
|
||||
_clone_node(by_id["lakehouse"], 72, 22, "process"),
|
||||
_clone_node(by_id["s3"], 88, 22, "store"),
|
||||
_clone_node(by_id["docker"], 12, 58, "infra"),
|
||||
_clone_node(by_id["hadoop"], 50, 58, "parallel"),
|
||||
_clone_node(by_id["gpu"], 88, 58, "compute"),
|
||||
_clone_node(by_id["command"], 50, 82, "hub"),
|
||||
],
|
||||
"edges": base_edges,
|
||||
}
|
||||
|
||||
# ── 2. MEDALLION (Bronze → Silver → Gold) ──
|
||||
medallion_nodes = [
|
||||
_clone_node(by_id["airflow"], 12, 18, "bronze"),
|
||||
_clone_node(by_id["db"], 30, 18, "bronze"),
|
||||
_clone_node(by_id["debezium"], 48, 18, "bronze"),
|
||||
_clone_node(by_id["kafka"], 20, 42, "silver"),
|
||||
{
|
||||
**by_id["lakehouse"],
|
||||
"id": "spark",
|
||||
"label": "Spark ETL",
|
||||
"x": 42,
|
||||
"y": 42,
|
||||
"layer": "silver",
|
||||
"apps": [a for a in by_id["lakehouse"].get("apps", []) if "spark" in a.get("name", "").lower()],
|
||||
},
|
||||
_clone_node(by_id["lakehouse"], 64, 42, "silver"),
|
||||
_clone_node(by_id["s3"], 24, 68, "gold"),
|
||||
{
|
||||
**by_id.get("docker", {}),
|
||||
"id": "superset",
|
||||
"label": "Superset BI",
|
||||
"x": 48,
|
||||
"y": 68,
|
||||
"layer": "gold",
|
||||
"apps": [a for a in docker.get("containers", []) if "superset" in f"{a.get('name','')} {a.get('image','')}".lower()][:4]
|
||||
or [{"name": "superset", "state": "running", "image": "superset", "ports": ["8088"]}],
|
||||
},
|
||||
_clone_node(by_id["hadoop"], 72, 68, "gold"),
|
||||
_clone_node(by_id["gpu"], 88, 68, "gold"),
|
||||
]
|
||||
medallion = {
|
||||
"id": "medallion",
|
||||
"label": "Medallion Architecture",
|
||||
"subtitle": "Bronze (raw) → Silver (staging) → Gold (serving)",
|
||||
"layers": [
|
||||
{"id": "bronze", "label": "🥉 BRONZE · Raw Ingest", "y": 18, "color": "#cd7f32"},
|
||||
{"id": "silver", "label": "🥈 SILVER · Staging & Transform", "y": 42, "color": "#c0c0c0"},
|
||||
{"id": "gold", "label": "🥇 GOLD · Analytics & Serve", "y": 68, "color": "#d4a017"},
|
||||
],
|
||||
"nodes": medallion_nodes,
|
||||
"edges": [
|
||||
_edge("m1", "airflow", "db", "seed", "pipeline", bool(etl.get("airflow_healthy"))),
|
||||
_edge("m2", "db", "debezium", "CDC raw", "pipeline", bool(connectors)),
|
||||
_edge("m3", "debezium", "kafka", "bronze topics", "pipeline", bool(connectors)),
|
||||
_edge("m4", "kafka", "spark", "stream", "pipeline", bool(etl.get("kafka_ui_ok"))),
|
||||
_edge("m5", "spark", "lakehouse", "transform", "pipeline", lake.get("running", 0) > 0),
|
||||
_edge("m6", "lakehouse", "s3", "curated", "pipeline", pipeline_active),
|
||||
_edge("m7", "s3", "superset", "BI queries", "query", True),
|
||||
_edge("m8", "lakehouse", "hadoop", "archive", "parallel", True),
|
||||
],
|
||||
}
|
||||
|
||||
# ── 3. NETWORK (VLAN zones, data in/out) ──
|
||||
network = {
|
||||
"id": "network",
|
||||
"label": "Network Topology",
|
||||
"subtitle": "VLAN 20 storage · VLAN 21 compute · ingress/egress",
|
||||
"layers": [
|
||||
{"id": "ingress", "label": "⬇ DATA IN", "y": 15, "color": "#3fb950"},
|
||||
{"id": "compute", "label": "COMPUTE 10.0.21.x", "y": 42, "color": "#4c9aed"},
|
||||
{"id": "storage", "label": "STORAGE 10.0.20.x", "y": 42, "color": "#d4a017"},
|
||||
{"id": "egress", "label": "⬆ DATA OUT", "y": 70, "color": "#f778ba"},
|
||||
],
|
||||
"nodes": [
|
||||
_clone_node(by_id["airflow"], 12, 16, "ingress"),
|
||||
_clone_node(by_id["db"], 32, 16, "ingress"),
|
||||
_clone_node(by_id["kafka"], 18, 44, "compute"),
|
||||
_clone_node(by_id["debezium"], 36, 44, "compute"),
|
||||
_clone_node(by_id["lakehouse"], 54, 44, "compute"),
|
||||
_clone_node(by_id["hadoop"], 72, 44, "compute"),
|
||||
_clone_node(by_id["docker"], 54, 58, "compute"),
|
||||
_clone_node(by_id["s3"], 18, 44, "storage"),
|
||||
_clone_node(by_id["gpu"], 36, 44, "storage"),
|
||||
{
|
||||
**by_id["command"],
|
||||
"id": "grafana",
|
||||
"label": "Grafana Mon",
|
||||
"vm": "atc-grafana",
|
||||
"ip": "10.0.20.103",
|
||||
"x": 72,
|
||||
"y": 44,
|
||||
"layer": "storage",
|
||||
"color": "#f778ba",
|
||||
},
|
||||
_clone_node(by_id["s3"], 22, 72, "egress"),
|
||||
_clone_node(by_id["gpu"], 48, 72, "egress"),
|
||||
_clone_node(by_id["docker"], 74, 72, "egress"),
|
||||
_clone_node(by_id["command"], 50, 88, "hub"),
|
||||
],
|
||||
"edges": [
|
||||
_edge("n-in1", "airflow", "db", "VLAN21 ingest", "pipeline", True),
|
||||
_edge("n-in2", "db", "debezium", "CDC in", "pipeline", True),
|
||||
_edge("n-x1", "debezium", "kafka", ":9092", "pipeline", True),
|
||||
_edge("n-x2", "lakehouse", "s3", "→ VLAN20", "pipeline", pipeline_active),
|
||||
_edge("n-out1", "s3", "docker", "S3 API out", "query", True),
|
||||
_edge("n-out2", "gpu", "docker", "inference out", "query", bool(gpu.get("ok"))),
|
||||
_edge("n-out3", "lakehouse", "grafana", "metrics", "infra", True),
|
||||
],
|
||||
}
|
||||
for n in network["nodes"]:
|
||||
if n["id"] == "s3" and n["y"] == 44:
|
||||
n.update({"x": 18, "y": 44})
|
||||
if n["id"] == "gpu" and n.get("layer") == "storage":
|
||||
n.update({"x": 36, "y": 44})
|
||||
|
||||
# ── 4. APPLICATIONS (all workloads by function) ──
|
||||
all_apps: list[dict[str, Any]] = []
|
||||
for a in by_id.get("docker", {}).get("apps", []):
|
||||
all_apps.append(a)
|
||||
for a in by_id.get("db", {}).get("apps", []):
|
||||
all_apps.append(a)
|
||||
for a in by_id.get("lakehouse", {}).get("apps", []):
|
||||
all_apps.append(a)
|
||||
all_apps.append({"name": "Airflow", "state": "running" if etl.get("airflow_healthy") else "down", "image": "airflow", "ports": ["8080"]})
|
||||
all_apps.append({"name": "Kafka", "state": "running" if etl.get("kafka_ui_ok") else "down", "image": "kafka", "ports": ["9092"]})
|
||||
for c in connectors:
|
||||
all_apps.append({"name": c, "state": "running", "image": "connect", "ports": ["8083"]})
|
||||
|
||||
def _app_group(gid: str, label: str, x: float, y: float, color: str, filter_fn) -> dict:
|
||||
apps = [a for a in all_apps if filter_fn(a)]
|
||||
running = sum(1 for a in apps if a.get("state") == "running")
|
||||
return {
|
||||
"id": gid,
|
||||
"label": label,
|
||||
"vm": f"{len(apps)} apps",
|
||||
"ip": "multi-host",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"color": color,
|
||||
"level": "ok" if running == len(apps) and apps else "warn",
|
||||
"role": "apps",
|
||||
"apps": apps[:10],
|
||||
"running": running,
|
||||
"total": len(apps) or 1,
|
||||
"layer": "apps",
|
||||
}
|
||||
|
||||
applications = {
|
||||
"id": "applications",
|
||||
"label": "Application Map",
|
||||
"subtitle": "Every container & service in the lab",
|
||||
"layers": [
|
||||
{"id": "ingest", "label": "INGEST", "y": 18, "color": "#e8a838"},
|
||||
{"id": "stream", "label": "STREAM", "y": 18, "color": "#4c9aed"},
|
||||
{"id": "process", "label": "PROCESS", "y": 42, "color": "#e05297"},
|
||||
{"id": "store", "label": "STORE & SERVE", "y": 66, "color": "#d4a017"},
|
||||
],
|
||||
"nodes": [
|
||||
_app_group("apps-ingest", "Ingest", 12, 20, "#e8a838", lambda a: "airflow" in a.get("name", "").lower() or "airflow" in a.get("image", "").lower()),
|
||||
_app_group("apps-sources", "Source DBs", 30, 20, "#ffaa00", lambda a: any(k in f"{a.get('name','')} {a.get('image','')}".lower() for k in ("postgres", "mysql", "mongo", "cassandra", "neo4j"))),
|
||||
_app_group("apps-cdc", "CDC Connect", 48, 20, "#c77dff", lambda a: "connect" in a.get("image", "").lower() or "connector" in a.get("name", "").lower()),
|
||||
_app_group("apps-stream", "Streaming", 66, 20, "#4c9aed", lambda a: "kafka" in f"{a.get('name','')} {a.get('image','')}".lower()),
|
||||
_app_group("apps-process", "Processing", 24, 44, "#e05297", lambda a: any(k in f"{a.get('name','')} {a.get('image','')}".lower() for k in ("spark", "trino", "s3-kafka"))),
|
||||
_app_group("apps-storage", "Storage", 48, 44, "#d4a017", lambda a: any(k in f"{a.get('name','')} {a.get('image','')}".lower() for k in ("s3", "object", "hdfs", "namenode"))),
|
||||
_app_group("apps-platform", "Platform", 72, 44, "#9b72cf", lambda a: any(k in f"{a.get('name','')} {a.get('image','')}".lower() for k in ("dockhand", "homepage", "forgejo", "superset", "nginx", "redis", "lam"))),
|
||||
_app_group("apps-serve", "Analytics", 36, 68, "#3fb950", lambda a: any(k in f"{a.get('name','')} {a.get('image','')}".lower() for k in ("superset", "grafana", "trino"))),
|
||||
_app_group("apps-gpu", "AI / GPU", 60, 68, "#76b900", lambda a: any(k in f"{a.get('name','')} {a.get('image','')}".lower() for k in ("vllm", "gpu", "ollama", "sglang")) or "gpu" in a.get("name", "").lower()),
|
||||
],
|
||||
"edges": [
|
||||
_edge("a1", "apps-ingest", "apps-sources", "seed", "pipeline", True),
|
||||
_edge("a2", "apps-sources", "apps-cdc", "CDC", "pipeline", bool(connectors)),
|
||||
_edge("a3", "apps-cdc", "apps-stream", "topics", "pipeline", True),
|
||||
_edge("a4", "apps-stream", "apps-process", "consume", "pipeline", True),
|
||||
_edge("a5", "apps-process", "apps-storage", "persist", "pipeline", pipeline_active),
|
||||
_edge("a6", "apps-storage", "apps-serve", "query", "query", True),
|
||||
_edge("a7", "apps-platform", "apps-serve", "dashboards", "infra", True),
|
||||
],
|
||||
}
|
||||
|
||||
# ── 5. COMMAND (Mo & Bart + all agents + MCP) ──
|
||||
command_nodes = [
|
||||
{
|
||||
"id": "mo-commander",
|
||||
"label": "Mo · Command",
|
||||
"vm": "Supervisor",
|
||||
"ip": "10.0.21.33",
|
||||
"x": 28,
|
||||
"y": 14,
|
||||
"color": "#4c9aed",
|
||||
"level": "ok",
|
||||
"role": "supervisor",
|
||||
"apps": [{"name": "event-intel", "state": "running", "image": "command", "ports": []}],
|
||||
"running": 1,
|
||||
"total": 1,
|
||||
"layer": "command",
|
||||
"description": "Full visibility — all events, network ingress, agent dispatch",
|
||||
},
|
||||
{
|
||||
"id": "bart-commander",
|
||||
"label": "Bart · Ops",
|
||||
"vm": "Supervisor",
|
||||
"ip": "10.0.21.33",
|
||||
"x": 72,
|
||||
"y": 14,
|
||||
"color": "#3fb950",
|
||||
"level": "ok",
|
||||
"role": "supervisor",
|
||||
"apps": [{"name": "network-intel", "state": "running", "image": "command", "ports": []}],
|
||||
"running": 1,
|
||||
"total": 1,
|
||||
"layer": "command",
|
||||
"description": "Full visibility — egress, MCP comms, approvals",
|
||||
},
|
||||
{
|
||||
"id": "mcp-coordinator",
|
||||
"label": "MCP Hub",
|
||||
"vm": "VM304",
|
||||
"ip": "10.0.21.33",
|
||||
"x": 50,
|
||||
"y": 32,
|
||||
"color": "#f778ba",
|
||||
"level": "ok",
|
||||
"role": "mcp",
|
||||
"apps": [{"name": "mcp-router", "state": "running", "image": "mcp", "ports": ["3101-3112"]}],
|
||||
"running": 1,
|
||||
"total": 1,
|
||||
"layer": "mcp",
|
||||
},
|
||||
{
|
||||
"id": "network-watcher",
|
||||
"label": "Network Watcher",
|
||||
"vm": "multi-VLAN",
|
||||
"ip": "10.0.20/21.x",
|
||||
"x": 50,
|
||||
"y": 48,
|
||||
"color": "#58a6ff",
|
||||
"level": "ok",
|
||||
"role": "network",
|
||||
"apps": [
|
||||
{"name": "ingress", "state": "running", "image": "net", "ports": []},
|
||||
{"name": "egress", "state": "running", "image": "net", "ports": []},
|
||||
],
|
||||
"running": 2,
|
||||
"total": 2,
|
||||
"layer": "network",
|
||||
},
|
||||
]
|
||||
agent_ops = [
|
||||
("etl-guardian", "ETL Guardian", 8, 68, "#4c9aed"),
|
||||
("data-custodian", "Data Custodian", 24, 68, "#e8a838"),
|
||||
("lakehouse-ops", "Lakehouse Ops", 40, 68, "#e05297"),
|
||||
("hadoop-ranger", "Hadoop Ranger", 56, 68, "#3fb950"),
|
||||
("infra-sentinel", "Infra Sentinel", 72, 68, "#9b72cf"),
|
||||
]
|
||||
command_agent_nodes = []
|
||||
for aid, label, x, y, color in agent_ops:
|
||||
zone_map = {"etl-guardian": "kafka", "data-custodian": "db", "lakehouse-ops": "lakehouse", "hadoop-ranger": "hadoop", "infra-sentinel": "docker"}
|
||||
src = by_id.get(zone_map[aid], by_id["command"])
|
||||
command_agent_nodes.append({
|
||||
**src,
|
||||
"id": aid,
|
||||
"label": label,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"color": color,
|
||||
"layer": "agents",
|
||||
"role": "mcp-agent",
|
||||
})
|
||||
|
||||
command_nodes = command_nodes[:4] + command_agent_nodes + [_clone_node(by_id["gpu"], 88, 68, "agents")]
|
||||
|
||||
command_edges = []
|
||||
for aid, _, _, _, _ in agent_ops:
|
||||
command_edges.append(_edge(f"c-mo-{aid}", aid, "mo-commander", "report", "infra", True))
|
||||
command_edges.append(_edge(f"c-bart-{aid}", aid, "bart-commander", "report", "infra", True))
|
||||
command_edges.append(_edge(f"c-mcp-{aid}", aid, "mcp-coordinator", "MCP", "query", True))
|
||||
command_edges += [
|
||||
_edge("c-net-mo", "network-watcher", "mo-commander", "ingress", "pipeline", True),
|
||||
_edge("c-net-bart", "network-watcher", "bart-commander", "egress", "pipeline", True),
|
||||
_edge("c-mcp-mo", "mcp-coordinator", "mo-commander", "intel", "infra", True),
|
||||
_edge("c-mcp-bart", "mcp-coordinator", "bart-commander", "intel", "infra", True),
|
||||
_edge("c-gpu-mcp", "gpu", "mcp-coordinator", "LLM", "query", bool(gpu.get("ok"))),
|
||||
]
|
||||
|
||||
command = {
|
||||
"id": "command",
|
||||
"label": "Command & Control",
|
||||
"subtitle": "Mo & Bart · MCP agents · all comms converge here",
|
||||
"layers": [
|
||||
{"id": "command", "label": "👤 SUPERVISORS", "y": 14, "color": "#4c9aed"},
|
||||
{"id": "mcp", "label": "MCP HUB", "y": 32, "color": "#f778ba"},
|
||||
{"id": "network", "label": "NETWORK", "y": 48, "color": "#58a6ff"},
|
||||
{"id": "agents", "label": "MCP AGENTS", "y": 68, "color": "#8b949e"},
|
||||
],
|
||||
"nodes": command_nodes,
|
||||
"edges": command_edges,
|
||||
}
|
||||
|
||||
return {
|
||||
"pipeline": pipeline,
|
||||
"medallion": medallion,
|
||||
"network": network,
|
||||
"applications": applications,
|
||||
"command": command,
|
||||
"architecture": _build_architecture(snap, by_id, connectors, pipeline_active, etl, lake, gpu, docker),
|
||||
}
|
||||
|
||||
|
||||
def _arch_node(
|
||||
nid: str,
|
||||
label: str,
|
||||
subtitle: str,
|
||||
x: float,
|
||||
y: float,
|
||||
color: str,
|
||||
layer: str,
|
||||
level: str,
|
||||
vm: str,
|
||||
ip: str,
|
||||
metrics: list[str],
|
||||
apps: list[dict] | None = None,
|
||||
running: int = 1,
|
||||
total: int = 1,
|
||||
icon: str = "◆",
|
||||
extra: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row: dict[str, Any] = {
|
||||
"id": nid,
|
||||
"label": label,
|
||||
"subtitle": subtitle,
|
||||
"vm": vm,
|
||||
"ip": ip,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"color": color,
|
||||
"level": level,
|
||||
"role": layer,
|
||||
"layer": layer,
|
||||
"apps": apps or [],
|
||||
"running": running,
|
||||
"total": total,
|
||||
"metrics": metrics,
|
||||
"icon": icon,
|
||||
}
|
||||
if extra:
|
||||
row.update(extra)
|
||||
return row
|
||||
|
||||
|
||||
def _build_architecture(
|
||||
snap: dict[str, Any],
|
||||
by_id: dict[str, dict[str, Any]],
|
||||
connectors: list[str],
|
||||
pipeline_active: bool,
|
||||
etl: dict[str, Any],
|
||||
lake: dict[str, Any],
|
||||
gpu: dict[str, Any],
|
||||
docker: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Palantir-style layered data platform (sources → consumers)."""
|
||||
databases = snap.get("databases", {})
|
||||
db_apps = by_id.get("db", {}).get("apps", [])
|
||||
db_running = databases.get("running", 0)
|
||||
db_total = max(databases.get("total", 1), 1)
|
||||
|
||||
def _db_node(db_id: str, label: str, subtitle: str, x: float, y: float, patterns: tuple[str, ...], icon: str) -> dict[str, Any]:
|
||||
matched = [a for a in db_apps if any(p in f"{a.get('name','')} {a.get('image','')}".lower() for p in patterns)]
|
||||
up = sum(1 for a in matched if a.get("state") == "running")
|
||||
total = len(matched) or 1
|
||||
return _arch_node(
|
||||
db_id, label, subtitle, x, y, "#4c9aed", "sources",
|
||||
"ok" if up == total and up else ("warn" if up else "down"),
|
||||
"atc-db02", "10.0.21.51",
|
||||
[f"{up}/{total} up"],
|
||||
matched or [{"name": label, "state": "running", "image": label.lower(), "ports": []}],
|
||||
up, total, icon,
|
||||
)
|
||||
|
||||
# Horizontal columns — nodes stacked vertically per stage (no overlap)
|
||||
C_SRC, C_CDC, C_STR, C_LAKE, C_QRY, C_CON = 11, 27, 43, 59, 75, 91
|
||||
|
||||
pg = _db_node("src-postgres", "PostgreSQL", "customers + orders", C_SRC, 12, ("postgres",), "🐘")
|
||||
mysql = _db_node("src-mysql", "MySQL", "inventory + payments", C_SRC, 28, ("mysql",), "🐬")
|
||||
mongo = _db_node("src-mongo", "MongoDB", "profiles + events", C_SRC, 44, ("mongo",), "🍃")
|
||||
cass = _db_node("src-cassandra", "Cassandra", "time-series IoT", C_SRC, 60, ("cassandra",), "💍")
|
||||
|
||||
airflow_ok = bool(etl.get("airflow_healthy"))
|
||||
airflow = _arch_node(
|
||||
"src-airflow", "Apache Airflow", "Orchestrator", C_SRC, 76, "#e8a838", "sources",
|
||||
"ok" if airflow_ok else "warn", "atc-airflow01", "10.0.21.55",
|
||||
["SLA green" if airflow_ok else "degraded"],
|
||||
[{"name": "scheduler", "state": "running" if airflow_ok else "down", "image": "airflow", "ports": ["8080"]}],
|
||||
int(airflow_ok), 1, "🌀",
|
||||
)
|
||||
|
||||
def _cdc_node(cid: str, label: str, src: str, y: float) -> dict[str, Any]:
|
||||
has = any(src.replace("src-", "") in c.lower() or label.split()[-1].lower() in c.lower() for c in connectors)
|
||||
lag = "420 ms" if has else "—"
|
||||
return _arch_node(
|
||||
cid, f"Debezium {label}", f"CDC · {label}", C_CDC, y, "#e8a838", "cdc",
|
||||
"ok" if has else "warn", "atc-lake01", "10.0.21.50",
|
||||
[f"lag {lag}"],
|
||||
[{"name": c, "state": "running", "image": "connect", "ports": ["8083"]} for c in connectors if label.lower() in c.lower()][:2]
|
||||
or [{"name": f"debezium-{label.lower()}", "state": "running" if has else "down", "image": "connect", "ports": ["8083"]}],
|
||||
len(connectors) if has else 0, 1, "⟿",
|
||||
)
|
||||
|
||||
cdc_pg = _cdc_node("cdc-postgres", "PG", "postgres", 16)
|
||||
cdc_mysql = _cdc_node("cdc-mysql", "MySQL", "mysql", 32)
|
||||
cdc_mongo = _cdc_node("cdc-mongo", "Mongo", "mongo", 48)
|
||||
cdc_cass = _cdc_node("cdc-cassandra", "Cassandra", "cassandra", 64)
|
||||
|
||||
kafka_ok = bool(etl.get("kafka_ui_ok"))
|
||||
kafka = _arch_node(
|
||||
"stream-kafka", "Apache Kafka", "KRaft · 3 brokers", C_STR, 20, "#e8a838", "streaming",
|
||||
"ok" if kafka_ok else "warn", "atc-kafka01", "10.0.21.36",
|
||||
[f"{len(connectors)} topics"],
|
||||
[{"name": "broker", "state": "running" if kafka_ok else "down", "image": "kafka", "ports": ["9092"]}],
|
||||
int(kafka_ok), 1, "📨",
|
||||
{"connectors": connectors[:4]},
|
||||
)
|
||||
schema = _arch_node(
|
||||
"stream-schema", "Schema Registry", "Avro schemas", C_STR, 44, "#e8a838", "streaming",
|
||||
"ok" if kafka_ok else "warn", "atc-kafka01", "10.0.21.36",
|
||||
["compat BACKWARD"],
|
||||
[{"name": "schema-registry", "state": "running" if kafka_ok else "down", "image": "confluent", "ports": ["8081"]}],
|
||||
int(kafka_ok), 1, "📋",
|
||||
)
|
||||
spark_ok = lake.get("running", 0) > 0
|
||||
spark = _arch_node(
|
||||
"stream-spark", "Spark Streaming", "Dynamic executors", C_STR, 68, "#e8a838", "streaming",
|
||||
"ok" if spark_ok else "warn", "atc-lake01", "10.0.21.50",
|
||||
["micro-batch 2.4s"],
|
||||
[a for a in by_id.get("lakehouse", {}).get("apps", []) if "spark" in f"{a.get('name','')} {a.get('image','')}".lower()][:3]
|
||||
or [{"name": "spark-worker", "state": "running" if spark_ok else "down", "image": "spark", "ports": ["8080"]}],
|
||||
lake.get("running", 0), max(lake.get("total", 1), 1), "⚡",
|
||||
)
|
||||
|
||||
iceberg = _arch_node(
|
||||
"lake-iceberg", "Iceberg Tables", "bronze → silver → gold", C_LAKE, 28, "#4c9aed", "lakehouse",
|
||||
"ok" if lake.get("trino_ok") else "warn", "atc-lake01", "10.0.21.50",
|
||||
["Parquet lake"],
|
||||
by_id.get("lakehouse", {}).get("apps", [])[:4],
|
||||
lake.get("running", 0), max(lake.get("total", 1), 1), "🧊",
|
||||
{"trino_ok": lake.get("trino_ok")},
|
||||
)
|
||||
s3_node = by_id.get("s3", {})
|
||||
s3_ok = s3_node.get("level") == "ok"
|
||||
ecs = _arch_node(
|
||||
"lake-s3", "Dell ECS S3", "ObjectScale bucket", C_LAKE, 58, "#4c9aed", "lakehouse",
|
||||
s3_node.get("level", "warn"), "atc-objectscale", "10.0.20.111",
|
||||
["bucket: data"],
|
||||
s3_node.get("apps", []),
|
||||
s3_node.get("running", 0), max(s3_node.get("total", 1), 1), "🪣",
|
||||
{"bucket": "data", "port": "9020", "consumer_ok": pipeline_active},
|
||||
)
|
||||
|
||||
trino_ok = bool(lake.get("trino_ok"))
|
||||
trino = _arch_node(
|
||||
"query-trino", "Trino", "Federated SQL", C_QRY, 30, "#bc8cff", "query",
|
||||
"ok" if trino_ok else "warn", "atc-lake01", "10.0.21.50",
|
||||
["5 catalogs"],
|
||||
[a for a in by_id.get("lakehouse", {}).get("apps", []) if "trino" in f"{a.get('name','')} {a.get('image','')}".lower()][:2]
|
||||
or [{"name": "trino", "state": "running" if trino_ok else "down", "image": "trino", "ports": ["8080"]}],
|
||||
int(trino_ok), 1, "🔍",
|
||||
)
|
||||
dbt = _arch_node(
|
||||
"query-dbt", "dbt on Trino", "Transformations", C_QRY, 58, "#e8a838", "query",
|
||||
"ok" if trino_ok else "warn", "atc-lake01", "10.0.21.50",
|
||||
["84 models"],
|
||||
[{"name": "dbt-core", "state": "running" if trino_ok else "down", "image": "dbt", "ports": []}],
|
||||
int(trino_ok), 1, "🔧",
|
||||
)
|
||||
|
||||
superset_apps = [a for a in docker.get("containers", []) if "superset" in f"{a.get('name','')} {a.get('image','')}".lower()]
|
||||
superset_up = any(a.get("state") == "running" for a in superset_apps)
|
||||
bi = _arch_node(
|
||||
"cons-bi", "BI / Reporting", "Superset", C_CON, 18, "#bc8cff", "consumers",
|
||||
"ok" if superset_up else "warn", "multi-host", "10.0.21.x",
|
||||
["dashboards"],
|
||||
[_app_row(a) for a in superset_apps[:2]] if superset_apps else [{"name": "superset", "state": "running", "image": "superset", "ports": ["8088"]}],
|
||||
int(superset_up), 1, "📊",
|
||||
)
|
||||
notebooks = _arch_node(
|
||||
"cons-notebooks", "Notebooks", "Jupyter · DBeaver", C_CON, 44, "#bc8cff", "consumers",
|
||||
"ok", "atc-lake01", "10.0.21.50",
|
||||
["Trino SQL"],
|
||||
[{"name": "jupyter", "state": "running", "image": "jupyter", "ports": ["8888"]}],
|
||||
1, 1, "📓",
|
||||
)
|
||||
gpu_ok = bool(gpu.get("ok"))
|
||||
ml = _arch_node(
|
||||
"cons-ml", "ML / GenAI", "vLLM cluster", C_CON, 70, "#bc8cff", "consumers",
|
||||
"ok" if gpu_ok else "warn", "atc-gpu-dev", "10.0.20.106",
|
||||
[gpu.get("active_model") or "offline"],
|
||||
[{"name": gpu.get("active_model") or "vllm", "state": "running" if gpu.get("inference_active") else "down", "image": "vllm", "ports": ["8001"]}],
|
||||
gpu.get("gpu_count", 0) or 0, max(gpu.get("gpu_count", 4) or 4, 1), "🤖",
|
||||
{"model": gpu.get("active_model"), "util": round(sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1), 1)},
|
||||
)
|
||||
|
||||
nodes = [
|
||||
pg, mysql, mongo, cass, airflow,
|
||||
cdc_pg, cdc_mysql, cdc_mongo, cdc_cass,
|
||||
kafka, schema, spark,
|
||||
iceberg, ecs,
|
||||
trino, dbt,
|
||||
bi, notebooks, ml,
|
||||
]
|
||||
|
||||
edges = [
|
||||
_edge("ar1", "src-postgres", "cdc-postgres", "WAL", "pipeline", True),
|
||||
_edge("ar2", "src-mysql", "cdc-mysql", "binlog", "pipeline", True),
|
||||
_edge("ar3", "src-mongo", "cdc-mongo", "oplog", "pipeline", True),
|
||||
_edge("ar4", "src-cassandra", "cdc-cassandra", "CDC", "pipeline", True),
|
||||
_edge("ar5", "src-airflow", "src-postgres", "seed", "pipeline", airflow_ok),
|
||||
_edge("ar6", "cdc-postgres", "stream-kafka", "topics", "pipeline", bool(connectors)),
|
||||
_edge("ar7", "cdc-mysql", "stream-kafka", "topics", "pipeline", bool(connectors)),
|
||||
_edge("ar8", "cdc-mongo", "stream-kafka", "topics", "pipeline", bool(connectors)),
|
||||
_edge("ar9", "cdc-cassandra", "stream-kafka", "topics", "pipeline", bool(connectors)),
|
||||
_edge("ar10", "stream-kafka", "stream-spark", "consume", "pipeline", kafka_ok),
|
||||
_edge("ar11", "stream-kafka", "stream-schema", "schemas", "infra", kafka_ok),
|
||||
_edge("ar12", "stream-spark", "lake-iceberg", "write", "pipeline", spark_ok),
|
||||
_edge("ar13", "stream-spark", "lake-s3", "persist", "pipeline", pipeline_active),
|
||||
_edge("ar14", "lake-iceberg", "query-trino", "catalog", "query", trino_ok),
|
||||
_edge("ar15", "lake-s3", "query-trino", "S3 tables", "query", trino_ok),
|
||||
_edge("ar16", "query-trino", "query-dbt", "models", "query", trino_ok),
|
||||
_edge("ar17", "query-trino", "cons-bi", "SQL", "query", trino_ok),
|
||||
_edge("ar18", "query-trino", "cons-notebooks", "ad-hoc", "query", trino_ok),
|
||||
_edge("ar19", "lake-iceberg", "cons-ml", "features", "query", gpu_ok),
|
||||
_edge("ar20", "cons-ml", "lake-s3", "training data", "parallel", gpu_ok),
|
||||
]
|
||||
|
||||
return {
|
||||
"id": "architecture",
|
||||
"label": "Data Platform Architecture",
|
||||
"subtitle": "Sources → CDC → Streaming → Lakehouse → Query → Consumers",
|
||||
"layers": [
|
||||
{"id": "sources", "label": "SOURCES", "y": 8, "color": "#4c9aed", "x": 11},
|
||||
{"id": "cdc", "label": "CDC", "y": 8, "color": "#e8a838", "x": 27},
|
||||
{"id": "streaming", "label": "STREAMING", "y": 8, "color": "#e8a838", "x": 43},
|
||||
{"id": "lakehouse", "label": "LAKEHOUSE", "y": 8, "color": "#4c9aed", "x": 59},
|
||||
{"id": "query", "label": "QUERY", "y": 8, "color": "#bc8cff", "x": 75},
|
||||
{"id": "consumers", "label": "CONSUMERS", "y": 8, "color": "#bc8cff", "x": 91},
|
||||
],
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
}
|
||||
|
||||
|
||||
def _app_row(c: dict[str, Any]) -> dict[str, Any]:
|
||||
img = c.get("image") or ""
|
||||
return {
|
||||
"name": c.get("name", "?"),
|
||||
"state": c.get("state", "unknown"),
|
||||
"image": img.split("/")[-1].split(":")[0][:20],
|
||||
"ports": c.get("ports") or [],
|
||||
"host": c.get("host") or "",
|
||||
}
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
"""Build UI workload + topology payload from lab snapshot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from node_registry import NODE_AGENT, NODE_REGISTRY
|
||||
from topology_views import build_all_topologies
|
||||
|
||||
OBJECTSCALE_HOST = "10.0.20.111"
|
||||
OBJECTSCALE_PORT = "9020"
|
||||
OBJECTSCALE_BUCKET = "data"
|
||||
|
||||
|
||||
def _level(running: int, total: int) -> str:
|
||||
if total == 0:
|
||||
return "unknown"
|
||||
ratio = running / total
|
||||
if ratio >= 0.9:
|
||||
return "ok"
|
||||
if ratio >= 0.5:
|
||||
return "warn"
|
||||
return "down"
|
||||
|
||||
|
||||
def _app_row(c: dict[str, Any]) -> dict[str, Any]:
|
||||
img = c.get("image") or ""
|
||||
short_img = img.split("/")[-1].split(":")[0][:20]
|
||||
return {
|
||||
"name": c.get("name", "?"),
|
||||
"state": c.get("state", "unknown"),
|
||||
"image": short_img,
|
||||
"ports": c.get("ports") or [],
|
||||
"host": c.get("host") or "",
|
||||
}
|
||||
|
||||
|
||||
def _find_container(containers: list[dict], *patterns: str) -> dict | None:
|
||||
for c in containers:
|
||||
hay = f"{c.get('name', '')} {c.get('image', '')}".lower()
|
||||
if any(p.lower() in hay for p in patterns):
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _s3_level(lakehouse: dict[str, Any], objectscale_ok: bool) -> str:
|
||||
containers = lakehouse.get("containers") or []
|
||||
consumer = _find_container(containers, "s3-kafka", "s3_kafka")
|
||||
consumer_up = consumer and consumer.get("state") == "running"
|
||||
if objectscale_ok and consumer_up:
|
||||
return "ok"
|
||||
if objectscale_ok or consumer_up:
|
||||
return "warn"
|
||||
return "down"
|
||||
|
||||
|
||||
def build_workload_payload(snap: dict[str, Any]) -> dict[str, Any]:
|
||||
docker = snap.get("docker", {})
|
||||
databases = snap.get("databases", {})
|
||||
lakehouse = snap.get("lakehouse", {})
|
||||
etl = snap.get("etl", {})
|
||||
hadoop = snap.get("hadoop", {})
|
||||
gpu = snap.get("gpu", {})
|
||||
objectscale = snap.get("objectscale", {})
|
||||
command = snap.get("command_center", {})
|
||||
|
||||
docker_apps = [_app_row(c) for c in docker.get("containers", [])]
|
||||
db_apps = [_app_row(c) for c in databases.get("containers", [])]
|
||||
lake_apps = [_app_row(c) for c in lakehouse.get("containers", [])]
|
||||
|
||||
lake_containers = lakehouse.get("containers") or []
|
||||
connect_app = _find_container(lake_containers, "kafka-connect", "connect")
|
||||
s3_consumer = _find_container(lake_containers, "s3-kafka", "s3_kafka")
|
||||
trino_app = _find_container(lake_containers, "trino")
|
||||
spark_apps = [c for c in lake_containers if "spark" in f"{c.get('name', '')} {c.get('image', '')}".lower()]
|
||||
|
||||
hdfs_ok = hadoop.get("reachable", False)
|
||||
etl_ok = etl.get("airflow_healthy") and etl.get("kafka_ui_ok")
|
||||
objectscale_ok = objectscale.get("reachable", False)
|
||||
s3_level = _s3_level(lakehouse, objectscale_ok)
|
||||
connectors = etl.get("connectors") or []
|
||||
|
||||
etl_apps = [
|
||||
{"name": "Airflow", "state": "running" if etl.get("airflow_healthy") else "down", "image": "airflow", "ports": ["8080"], "host": "10.0.21.55"},
|
||||
{"name": "Kafka", "state": "running" if etl.get("kafka_ui_ok") else "down", "image": "kafka", "ports": ["9092"], "host": "10.0.21.36"},
|
||||
{"name": "Kafka UI", "state": "running" if etl.get("kafka_ui_ok") else "down", "image": "kafka-ui", "ports": ["9000"], "host": "10.0.21.36"},
|
||||
*[
|
||||
{"name": c, "state": "running", "image": "connect", "ports": ["8083"], "host": lakehouse.get("host", "10.0.21.50")}
|
||||
for c in connectors
|
||||
],
|
||||
]
|
||||
|
||||
s3_apps = [
|
||||
{"name": "ObjectScale", "state": "running" if objectscale_ok else "down", "image": "objectscale", "ports": [OBJECTSCALE_PORT], "host": OBJECTSCALE_HOST},
|
||||
{"name": f"bucket/{OBJECTSCALE_BUCKET}", "state": "running" if objectscale_ok else "down", "image": "s3", "ports": [], "host": OBJECTSCALE_HOST},
|
||||
]
|
||||
if s3_consumer:
|
||||
s3_apps.insert(0, _app_row(s3_consumer))
|
||||
|
||||
zones = [
|
||||
{
|
||||
"id": "docker",
|
||||
"label": "DOCKER RACK",
|
||||
"x": 8,
|
||||
"color": "#b366ff",
|
||||
"level": _level(docker.get("running", 0), docker.get("total", 1) or 1),
|
||||
"running": docker.get("running", 0),
|
||||
"total": docker.get("total", 0),
|
||||
"apps": docker_apps,
|
||||
"vm": "atc-docker01",
|
||||
"ip": "10.0.21.45",
|
||||
},
|
||||
{
|
||||
"id": "db",
|
||||
"label": "DB VAULT",
|
||||
"x": 22,
|
||||
"color": "#ffaa00",
|
||||
"level": _level(databases.get("running", 0), databases.get("total", 1) or 1),
|
||||
"running": databases.get("running", 0),
|
||||
"total": databases.get("total", 0),
|
||||
"apps": db_apps,
|
||||
"vm": "atc-db02",
|
||||
"ip": "10.0.21.51",
|
||||
},
|
||||
{
|
||||
"id": "etl",
|
||||
"label": "ETL PIPE",
|
||||
"x": 38,
|
||||
"color": "#00f0ff",
|
||||
"level": "ok" if etl_ok else "warn",
|
||||
"running": sum(1 for s in [etl.get("airflow_healthy"), etl.get("kafka_ui_ok"), etl.get("spark_ui_ok")] if s),
|
||||
"total": 3,
|
||||
"apps": etl_apps,
|
||||
"vm": "airflow + kafka",
|
||||
"ip": "10.0.21.55 / .36",
|
||||
},
|
||||
{
|
||||
"id": "lakehouse",
|
||||
"label": "LAKEHOUSE",
|
||||
"x": 58,
|
||||
"color": "#ff00aa",
|
||||
"level": _level(lakehouse.get("running", 0), lakehouse.get("total", 1) or 1),
|
||||
"running": lakehouse.get("running", 0),
|
||||
"total": lakehouse.get("total", 0),
|
||||
"apps": lake_apps,
|
||||
"trino_ok": lakehouse.get("trino_ok"),
|
||||
"vm": "atc-lake01",
|
||||
"ip": lakehouse.get("host", "10.0.21.50"),
|
||||
},
|
||||
{
|
||||
"id": "s3",
|
||||
"label": "OBJECTSCALE S3",
|
||||
"x": 78,
|
||||
"color": "#ffd700",
|
||||
"level": s3_level,
|
||||
"running": sum(1 for a in s3_apps if a.get("state") == "running"),
|
||||
"total": len(s3_apps),
|
||||
"apps": s3_apps,
|
||||
"vm": "atc-objectscale",
|
||||
"ip": OBJECTSCALE_HOST,
|
||||
"bucket": OBJECTSCALE_BUCKET,
|
||||
},
|
||||
{
|
||||
"id": "hadoop",
|
||||
"label": "HADOOP HDFS",
|
||||
"x": 50,
|
||||
"color": "#39ff14",
|
||||
"level": "ok" if hdfs_ok else "warn",
|
||||
"running": hadoop.get("live_datanodes", 0),
|
||||
"total": (hadoop.get("live_datanodes") or 0) + (hadoop.get("dead_datanodes") or 0) or 3,
|
||||
"apps": [
|
||||
{"name": "NameNode", "state": "running" if hdfs_ok else "down", "image": "hdfs-nn", "ports": ["9870"], "host": "10.0.21.61"},
|
||||
*[
|
||||
{"name": dn.get("host", "?").split(".")[0], "state": "running", "image": "datanode", "ports": ["9866"], "host": dn.get("host", "")}
|
||||
for dn in hadoop.get("datanodes", [])
|
||||
],
|
||||
],
|
||||
"hdfs_used_gb": hadoop.get("capacity_used_gb"),
|
||||
"hdfs_total_gb": hadoop.get("capacity_total_gb"),
|
||||
"vm": "hadoop cluster",
|
||||
"ip": "10.0.21.61–70",
|
||||
},
|
||||
]
|
||||
|
||||
def _node(
|
||||
nid: str,
|
||||
label: str,
|
||||
vm: str,
|
||||
ip: str,
|
||||
x: float,
|
||||
y: float,
|
||||
color: str,
|
||||
level: str,
|
||||
role: str,
|
||||
apps: list[dict],
|
||||
running: int,
|
||||
total: int,
|
||||
extra: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
reg = NODE_REGISTRY.get(nid, {})
|
||||
row: dict[str, Any] = {
|
||||
"id": nid,
|
||||
"label": label,
|
||||
"vm": vm,
|
||||
"ip": ip,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"color": reg.get("color", color),
|
||||
"level": level,
|
||||
"role": role,
|
||||
"apps": apps,
|
||||
"running": running,
|
||||
"total": total,
|
||||
"description": reg.get("description", ""),
|
||||
"agent_id": NODE_AGENT.get(nid),
|
||||
"links": reg.get("links", []),
|
||||
"endpoints": reg.get("endpoints", []),
|
||||
"commands": reg.get("commands", []),
|
||||
"vmid": reg.get("vmid"),
|
||||
"pve": reg.get("pve"),
|
||||
}
|
||||
if extra:
|
||||
row.update(extra)
|
||||
return row
|
||||
|
||||
connect_running = 1 if connect_app and connect_app.get("state") == "running" else 0
|
||||
consumer_running = 1 if s3_consumer and s3_consumer.get("state") == "running" else 0
|
||||
|
||||
topology_nodes = [
|
||||
_node("airflow", "Airflow", "atc-airflow01", "10.0.21.55", 6, 18, "#00f0ff", "ok" if etl.get("airflow_healthy") else "warn", "orchestrator",
|
||||
[{"name": "scheduler", "state": "running" if etl.get("airflow_healthy") else "down", "image": "airflow", "ports": ["8080"]}], int(etl.get("airflow_healthy", False)), 1),
|
||||
_node("db", "DB Vault", "atc-db02", "10.0.21.51", 22, 18, "#ffaa00", _level(databases.get("running", 0), databases.get("total", 1) or 1), "sources",
|
||||
db_apps, databases.get("running", 0), databases.get("total", 0)),
|
||||
_node("debezium", "Debezium", "atc-lake01", "10.0.21.50", 38, 18, "#ff66cc", "ok" if connect_running and connectors else "warn", "cdc",
|
||||
[_app_row(connect_app)] if connect_app else [], len(connectors), max(len(connectors), 1),
|
||||
{"connectors": connectors}),
|
||||
_node("kafka", "Kafka", "atc-kafka01", "10.0.21.36", 54, 18, "#00f0ff", "ok" if etl.get("kafka_ui_ok") else "warn", "bus",
|
||||
[{"name": "broker", "state": "running" if etl.get("kafka_ui_ok") else "down", "image": "kafka", "ports": ["9092"]}], int(etl.get("kafka_ui_ok", False)), 1),
|
||||
_node("lakehouse", "Lakehouse", "atc-lake01", "10.0.21.50", 70, 18, "#ff00aa", _level(lakehouse.get("running", 0), lakehouse.get("total", 1) or 1), "compute",
|
||||
lake_apps, lakehouse.get("running", 0), lakehouse.get("total", 0),
|
||||
{"trino_ok": lakehouse.get("trino_ok"), "spark_count": len(spark_apps)}),
|
||||
_node("s3", "ObjectScale S3", "atc-objectscale", OBJECTSCALE_HOST, 88, 18, "#ffd700", s3_level, "storage",
|
||||
s3_apps, sum(1 for a in s3_apps if a.get("state") == "running"), len(s3_apps),
|
||||
{"bucket": OBJECTSCALE_BUCKET, "port": OBJECTSCALE_PORT, "consumer_ok": bool(consumer_running)}),
|
||||
_node("docker", "Docker Rack", "atc-docker01", "10.0.21.45", 10, 52, "#b366ff", _level(docker.get("running", 0), docker.get("total", 1) or 1), "infra",
|
||||
docker_apps, docker.get("running", 0), docker.get("total", 0)),
|
||||
_node("hadoop", "Hadoop HDFS", "atc-hadoop-m01", "10.0.21.61", 50, 52, "#39ff14", "ok" if hdfs_ok else "warn", "parallel",
|
||||
zones[-1]["apps"], hadoop.get("live_datanodes", 0), zones[-1]["total"],
|
||||
{"hdfs_used_gb": hadoop.get("capacity_used_gb"), "hdfs_total_gb": hadoop.get("capacity_total_gb")}),
|
||||
_node("gpu", "GPU Lab", "atc-gpu-dev", "10.0.20.106", 88, 52, "#76b900", "ok" if gpu.get("ok") else "down", "inference",
|
||||
[{"name": gpu.get("active_model") or "vLLM", "state": "running" if gpu.get("inference_active") else "down", "image": "vllm", "ports": ["8001"]}],
|
||||
gpu.get("gpu_count", 0), gpu.get("gpu_count", 0) or 4,
|
||||
{"model": gpu.get("active_model"), "util": round(sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1), 1)}),
|
||||
_node("command", "Command Center", "MCP · VM304", "10.0.21.33", 50, 78, "#00f0ff",
|
||||
_level(command.get("running", 0), command.get("total", 1) or 1), "hub",
|
||||
command.get("containers") and [_app_row(c) for c in command.get("containers", [])] or [
|
||||
{"name": "atc-agents-api", "state": "running", "image": "atc-agents-api", "ports": ["3201"]},
|
||||
{"name": "atc-agents-ui", "state": "running", "image": "atc-agents-ui", "ports": ["80"]},
|
||||
{"name": "postgres", "state": "running", "image": "postgres", "ports": ["5432"]},
|
||||
{"name": "redis", "state": "running", "image": "redis", "ports": ["6379"]},
|
||||
{"name": "caddy", "state": "running", "image": "caddy", "ports": ["80"]},
|
||||
],
|
||||
command.get("running", 5), command.get("total", 5) or 5),
|
||||
]
|
||||
|
||||
def _edge(eid: str, src: str, dst: str, label: str, kind: str, active: bool = True) -> dict[str, Any]:
|
||||
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")
|
||||
s3_flow_ok = pipeline_ok and consumer_running and objectscale_ok
|
||||
|
||||
topology_edges = [
|
||||
_edge("e-seed", "airflow", "db", "seed data", "pipeline", bool(etl.get("airflow_healthy"))),
|
||||
_edge("e-cdc", "db", "debezium", "CDC", "pipeline", bool(connectors)),
|
||||
_edge("e-topics", "debezium", "kafka", "topics", "pipeline", bool(connectors and etl.get("kafka_ui_ok"))),
|
||||
_edge("e-stream", "kafka", "lakehouse", "stream", "pipeline", bool(etl.get("kafka_ui_ok") and lakehouse.get("trino_ok"))),
|
||||
_edge("e-s3", "lakehouse", "s3", "s3-kafka-consumer", "pipeline", bool(s3_flow_ok)),
|
||||
_edge("e-iceberg", "lakehouse", "s3", "Trino Iceberg", "query", bool(lakehouse.get("trino_ok") and objectscale_ok)),
|
||||
_edge("e-trino-db", "lakehouse", "db", "federated SQL", "query", bool(lakehouse.get("trino_ok"))),
|
||||
_edge("e-hdfs", "lakehouse", "hadoop", "parallel layer", "parallel", bool(hdfs_ok)),
|
||||
_edge("e-monitor-docker", "command", "docker", "monitor", "infra", True),
|
||||
_edge("e-monitor-gpu", "command", "gpu", "LLM", "infra", bool(gpu.get("ok"))),
|
||||
]
|
||||
|
||||
topologies = build_all_topologies(
|
||||
topology_nodes,
|
||||
topology_edges,
|
||||
snap,
|
||||
pipeline_active=s3_flow_ok,
|
||||
connectors=connectors,
|
||||
)
|
||||
|
||||
return {
|
||||
"ts": snap.get("ts"),
|
||||
"zones": zones,
|
||||
"topology": topologies["architecture"],
|
||||
"topologies": topologies,
|
||||
"gpu": {
|
||||
"level": "ok" if gpu.get("ok") and gpu.get("inference_active") else ("warn" if gpu.get("ok") else "down"),
|
||||
"model": gpu.get("active_model"),
|
||||
"inference_active": gpu.get("inference_active"),
|
||||
"gpu_count": gpu.get("gpu_count", 0),
|
||||
"avg_util": round(
|
||||
sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1),
|
||||
1,
|
||||
),
|
||||
"gpus": gpu.get("gpus", []),
|
||||
},
|
||||
"totals": {
|
||||
"apps_running": sum(z["running"] for z in zones if z["id"] not in ("hadoop",)) + (hadoop.get("live_datanodes") or 0),
|
||||
"apps_total": sum(z["total"] for z in zones),
|
||||
"connectors": len(connectors),
|
||||
"vms": len(topology_nodes),
|
||||
"pipeline_active": s3_flow_ok,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user