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:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
@@ -8,7 +8,7 @@ Autonomous agent hub for the Dell ATC lab — Ops Floor UI, FastAPI backend, 5 o
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Open: **http://atc-mcp.dell-atc.lan/** (or `http://10.0.21.33/`)
|
Open: **http://10.0.21.33/**
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -19,10 +19,60 @@ Open: **http://atc-mcp.dell-atc.lan/** (or `http://10.0.21.33/`)
|
|||||||
|
|
||||||
## VM 304 (MCP)
|
## VM 304 (MCP)
|
||||||
|
|
||||||
- Host: `atc-mcp.dell-atc.lan` → DHCP on VLAN 20 (`br_20`)
|
- IP: `10.0.21.33` (DHCP on VLAN 20 / `br_20`)
|
||||||
- Proxmox VMID **304** on **atc-gpu**
|
- Proxmox VMID **304** on **atc-gpu**
|
||||||
- SSH: `root` / `Dell2026!`
|
- SSH: `root` / `Dell2026!`
|
||||||
|
|
||||||
## Gitea
|
## Gitea
|
||||||
|
|
||||||
`http://atc-mgt01.dell-atc.lan:3001/mo/atc-agents`
|
`http://atc-mgt01.dell-atc.lan:3001/mo/atc-agents`
|
||||||
|
# ATC Command Center — Gitea layout
|
||||||
|
|
||||||
|
This repo follows the same pattern as **`mo/atc-GPU`** and **`mo/Lakehouse`**.
|
||||||
|
|
||||||
|
## Related repos (Gitea @ atc-mgt01:3001)
|
||||||
|
|
||||||
|
| Repo | Path on VM304 | Purpose |
|
||||||
|
|------|---------------|---------|
|
||||||
|
| **mo/atc-agents** | `/opt/atc-agents` | Command Center UI + API + docker-compose |
|
||||||
|
| **mo/atc-data-quality** | `/opt/atc-data-quality` | DQ API + RAG API |
|
||||||
|
| **mo/atc-GPU** | GPU lab VM303 | vLLM, model-manager |
|
||||||
|
| **mo/Lakehouse** | lake01 / docker hosts | Kafka, Spark, Trino, ObjectScale config |
|
||||||
|
|
||||||
|
## This repo structure
|
||||||
|
|
||||||
|
```
|
||||||
|
atc-agents/
|
||||||
|
├── api/ FastAPI backend
|
||||||
|
├── ui/ React dashboard
|
||||||
|
├── caddy/ Reverse proxy routes
|
||||||
|
├── config/ Deploy reference (mirrors production)
|
||||||
|
│ ├── command-center/ docker-compose, Caddyfile, .env.example
|
||||||
|
│ ├── data-quality/ Link to mo/atc-data-quality
|
||||||
|
│ └── jupyter/ JupyterLab service snippet
|
||||||
|
├── docs/ Runbooks
|
||||||
|
├── scripts/ deploy.sh
|
||||||
|
└── docker-compose.yml Production stack (clone with atc-data-quality sibling)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone http://atc-mgt01.dell-atc.lan:3001/mo/atc-agents.git /opt/atc-agents
|
||||||
|
git clone http://atc-mgt01.dell-atc.lan:3001/mo/atc-data-quality.git /opt/atc-data-quality
|
||||||
|
cp config/command-center/.env.example /opt/atc-agents/.env # edit secrets
|
||||||
|
./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Open: **http://10.0.21.33/**
|
||||||
|
|
||||||
|
## Services (port 80 via Caddy)
|
||||||
|
|
||||||
|
| Route | Service |
|
||||||
|
|-------|---------|
|
||||||
|
| `/` | React UI |
|
||||||
|
| `/api/*` | Agents API |
|
||||||
|
| `/dq/*` | Data Quality API |
|
||||||
|
| `/rag/*` | Knowledge Chat / RAG |
|
||||||
|
| `/jupyter/*` | JupyterLab (S3 env preconfigured) |
|
||||||
|
| `:5001` | Docling UI (direct) |
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""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,
|
||||||
|
) -> 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:
|
||||||
|
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
|
||||||
+1
-1
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY main.py .
|
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
|
RUN mkdir -p /data
|
||||||
ENV DATABASE_URL=sqlite:////data/atc-agents.db
|
ENV DATABASE_URL=sqlite:////data/atc-agents.db
|
||||||
EXPOSE 3201
|
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 asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -12,15 +13,54 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import redis.asyncio as aioredis
|
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 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 pydantic import BaseModel, Field
|
||||||
from sqlalchemy import Column, DateTime, String, Text, create_engine, select
|
from sqlalchemy import Column, DateTime, String, Text, select
|
||||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||||
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
|
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
|
||||||
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 = [
|
AGENTS = [
|
||||||
{
|
{
|
||||||
@@ -29,6 +69,14 @@ AGENTS = [
|
|||||||
"color": "#00f0ff",
|
"color": "#00f0ff",
|
||||||
"zone": "etl",
|
"zone": "etl",
|
||||||
"role": "Airflow, Kafka, Debezium, S3 pipeline",
|
"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",
|
"id": "lakehouse-ops",
|
||||||
@@ -36,6 +84,14 @@ AGENTS = [
|
|||||||
"color": "#ff00aa",
|
"color": "#ff00aa",
|
||||||
"zone": "lakehouse",
|
"zone": "lakehouse",
|
||||||
"role": "Spark, Trino, Iceberg",
|
"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",
|
"id": "data-custodian",
|
||||||
@@ -43,6 +99,14 @@ AGENTS = [
|
|||||||
"color": "#ffaa00",
|
"color": "#ffaa00",
|
||||||
"zone": "db",
|
"zone": "db",
|
||||||
"role": "PostgreSQL, MySQL, Mongo, Cassandra, Neo4j",
|
"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",
|
"id": "hadoop-ranger",
|
||||||
@@ -50,13 +114,93 @@ AGENTS = [
|
|||||||
"color": "#39ff14",
|
"color": "#39ff14",
|
||||||
"zone": "hadoop",
|
"zone": "hadoop",
|
||||||
"role": "HDFS, YARN cluster",
|
"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",
|
"id": "infra-sentinel",
|
||||||
"name": "Infra Sentinel",
|
"name": "Infra Sentinel",
|
||||||
"color": "#b366ff",
|
"color": "#9b72cf",
|
||||||
"zone": "docker",
|
"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]] = {
|
INTENT_KEYWORDS: dict[str, list[str]] = {
|
||||||
"data-custodian": ["database", "db", "postgres", "mysql", "mongo", "cassandra", "neo4j", "sql"],
|
"data-custodian": ["database", "postgres", "postgresql", "mysql", "mongo", "mongodb", "cassandra", "neo4j", "sql", "db "],
|
||||||
"lakehouse-ops": ["trino", "spark", "lakehouse", "iceberg", "query"],
|
"lakehouse-ops": ["trino", "spark", "lakehouse", "iceberg", "query", "table"],
|
||||||
"hadoop-ranger": ["hadoop", "hdfs", "yarn", "datanode"],
|
"hadoop-ranger": [
|
||||||
"infra-sentinel": ["docker", "container", "vm", "proxmox", "infra", "grafana"],
|
"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"],
|
"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)
|
action = Column(Text)
|
||||||
reason = Column(Text)
|
reason = Column(Text)
|
||||||
status = Column(String, default="pending")
|
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})
|
_db_info = init_database(Base)
|
||||||
SessionLocal = sessionmaker(bind=engine)
|
|
||||||
Base.metadata.create_all(engine)
|
|
||||||
|
|
||||||
redis_client: aioredis.Redis | None = None
|
redis_client: aioredis.Redis | None = None
|
||||||
ws_clients: set[WebSocket] = set()
|
ws_clients: set[WebSocket] = set()
|
||||||
@@ -112,14 +268,35 @@ ws_clients: set[WebSocket] = set()
|
|||||||
|
|
||||||
class PromptRequest(BaseModel):
|
class PromptRequest(BaseModel):
|
||||||
message: str = Field(min_length=1, max_length=2000)
|
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):
|
class ApprovalDecision(BaseModel):
|
||||||
approved: bool
|
approved: bool
|
||||||
|
decided_by: str = "mo-commander"
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
def route_agent(message: str) -> str:
|
def route_agent(message: str) -> str:
|
||||||
lower = message.lower()
|
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()}
|
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)
|
best = max(scores, key=scores.get)
|
||||||
if scores[best] == 0:
|
if scores[best] == 0:
|
||||||
@@ -127,6 +304,94 @@ def route_agent(message: str) -> str:
|
|||||||
return best
|
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:
|
async def publish_event(event: dict[str, Any]) -> None:
|
||||||
payload = json.dumps(event, default=str)
|
payload = json.dumps(event, default=str)
|
||||||
if redis_client:
|
if redis_client:
|
||||||
@@ -140,6 +405,21 @@ async def publish_event(event: dict[str, Any]) -> None:
|
|||||||
for ws in dead:
|
for ws in dead:
|
||||||
ws_clients.discard(ws)
|
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:
|
def add_feed(agent_id: str, message: str, level: str = "info") -> dict:
|
||||||
entry_id = str(uuid.uuid4())[:8]
|
entry_id = str(uuid.uuid4())[:8]
|
||||||
@@ -175,6 +455,67 @@ async def probe_url(url: str) -> bool:
|
|||||||
return False
|
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]:
|
async def collect_status() -> dict[str, Any]:
|
||||||
db_containers = await dockhand_env_containers(5)
|
db_containers = await dockhand_env_containers(5)
|
||||||
db_running = sum(1 for c in db_containers if c.get("state") == "running")
|
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 "warn"
|
||||||
return "down"
|
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 {
|
return {
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
"domains": {
|
"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},
|
"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"},
|
"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"},
|
"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,
|
"kafka_ok": kafka_ok,
|
||||||
"airflow_ok": airflow_ok,
|
"airflow_ok": airflow_ok,
|
||||||
"hdfs_ok": hdfs_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:
|
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)
|
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 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 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()
|
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":
|
answer = await ask_llm(agent_id, message, context, log=log)
|
||||||
db = status["domains"]["databases"]
|
if not answer:
|
||||||
containers = await dockhand_env_containers(5)
|
await log("warn", "llm", "LLM fallback — returning raw context")
|
||||||
names = ", ".join(f"{c['name']}:{c.get('state','?')}" for c in containers[:8])
|
answer = fallback_answer(agent_id, context)
|
||||||
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 = " ".join(answer_parts)
|
if not approval_created:
|
||||||
await asyncio.sleep(0.6)
|
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})
|
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": "feed", "entry": feed})
|
||||||
await publish_event({"type": "prompt_result", "prompt_id": prompt_id, "agent_id": agent_id, "answer": answer})
|
await publish_event({"type": "prompt_result", "prompt_id": prompt_id, "agent_id": agent_id, "answer": answer})
|
||||||
return answer
|
return answer
|
||||||
@@ -259,7 +669,9 @@ async def heartbeat_loop() -> None:
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
status = await collect_status()
|
status = await collect_status()
|
||||||
|
workload = await collect_workload()
|
||||||
await publish_event({"type": "status", "data": status})
|
await publish_event({"type": "status", "data": status})
|
||||||
|
await publish_event({"type": "workload", "data": workload})
|
||||||
for domain, info in status["domains"].items():
|
for domain, info in status["domains"].items():
|
||||||
if info["level"] == "down":
|
if info["level"] == "down":
|
||||||
agent = "data-custodian" if domain == "databases" else "infra-sentinel"
|
agent = "data-custodian" if domain == "databases" else "infra-sentinel"
|
||||||
@@ -274,6 +686,15 @@ async def heartbeat_loop() -> None:
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
global redis_client
|
global redis_client
|
||||||
redis_client = aioredis.from_url(REDIS_URL, decode_responses=True)
|
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())
|
task = asyncio.create_task(heartbeat_loop())
|
||||||
add_feed("infra-sentinel", "ATC Command Center API online", "info")
|
add_feed("infra-sentinel", "ATC Command Center API online", "info")
|
||||||
yield
|
yield
|
||||||
@@ -283,6 +704,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="ATC Command Center API", lifespan=lifespan)
|
app = FastAPI(title="ATC Command Center API", lifespan=lifespan)
|
||||||
|
app.include_router(storage_s3_router)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -294,7 +716,103 @@ app.add_middleware(
|
|||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def 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")
|
@app.get("/api/status")
|
||||||
@@ -302,9 +820,95 @@ async def get_status():
|
|||||||
return await collect_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")
|
@app.get("/api/agents")
|
||||||
async def get_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")
|
@app.get("/api/feed")
|
||||||
@@ -326,47 +930,73 @@ async def get_feed(limit: int = 50):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/approvals")
|
@app.get("/api/approvals")
|
||||||
async def get_approvals():
|
async def get_approvals(status: str = "pending", limit: int = 100):
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
rows = db.execute(select(Approval).where(Approval.status == "pending")).scalars().all()
|
items = list_approvals(db, Approval, status=status, limit=limit)
|
||||||
return {
|
stats = approval_stats(db, Approval)
|
||||||
"approvals": [
|
return {"approvals": items, "stats": stats, "action_types": APPROVAL_ACTION_TYPES}
|
||||||
{
|
|
||||||
"id": r.id,
|
|
||||||
"ts": r.ts.isoformat() if r.ts else None,
|
@app.get("/api/approvals/stats")
|
||||||
"agent_id": r.agent_id,
|
async def get_approval_stats():
|
||||||
"action": r.action,
|
with SessionLocal() as db:
|
||||||
"reason": r.reason,
|
return approval_stats(db, Approval)
|
||||||
"status": r.status,
|
|
||||||
}
|
|
||||||
for r in rows
|
@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")
|
@app.post("/api/approvals/{approval_id}/decide")
|
||||||
async def decide_approval(approval_id: str, body: ApprovalDecision):
|
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:
|
with SessionLocal() as db:
|
||||||
row = db.get(Approval, approval_id)
|
item = await decide_approval_request(
|
||||||
if not row:
|
db=db,
|
||||||
return {"error": "not found"}
|
ApprovalModel=Approval,
|
||||||
row.status = "approved" if body.approved else "denied"
|
approval_id=approval_id,
|
||||||
db.commit()
|
approved=body.approved,
|
||||||
agent_id = row.agent_id
|
decided_by=decided_by,
|
||||||
action = row.action
|
note=body.note,
|
||||||
msg = f"Approval {'approved' if body.approved else 'denied'}: {action}"
|
terminal_log=terminal_log,
|
||||||
feed = add_feed(agent_id, msg, "info" if body.approved else "warn")
|
publish=publish_event,
|
||||||
await publish_event({"type": "feed", "entry": feed})
|
add_feed=add_feed,
|
||||||
await publish_event({"type": "approval_update", "id": approval_id, "status": row.status})
|
)
|
||||||
return {"ok": True, "status": row.status}
|
if not item:
|
||||||
|
return {"error": "not found"}
|
||||||
|
return {"ok": True, "approval": item}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/prompt")
|
@app.post("/api/prompt")
|
||||||
async def post_prompt(body: PromptRequest):
|
async def post_prompt(body: PromptRequest):
|
||||||
prompt_id = str(uuid.uuid4())[:8]
|
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")
|
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"}
|
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)
|
ws_clients.add(websocket)
|
||||||
try:
|
try:
|
||||||
status = await collect_status()
|
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": "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:
|
while True:
|
||||||
await websocket.receive_text()
|
await websocket.receive_text()
|
||||||
except WebSocketDisconnect:
|
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
|
httpx==0.28.1
|
||||||
sqlalchemy==2.0.36
|
sqlalchemy==2.0.36
|
||||||
aiosqlite==0.20.0
|
aiosqlite==0.20.0
|
||||||
|
psycopg2-binary==2.9.10
|
||||||
pydantic==2.10.4
|
pydantic==2.10.4
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
websockets==14.1
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
+15
-2
@@ -1,4 +1,17 @@
|
|||||||
:80 {
|
:80 {
|
||||||
reverse_proxy /api/* api:3201
|
handle_path /rag/* {
|
||||||
reverse_proxy /* ui:80
|
reverse_proxy rag-api:5020
|
||||||
|
}
|
||||||
|
handle_path /jupyter/* {
|
||||||
|
reverse_proxy jupyter:8888
|
||||||
|
}
|
||||||
|
handle_path /dq/* {
|
||||||
|
reverse_proxy dq-api:5010
|
||||||
|
}
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy api:3201
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy ui:80
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Command Center — VM304 (10.0.21.33)
|
||||||
|
# Copy to /opt/atc-agents/.env — never commit secrets
|
||||||
|
|
||||||
|
# Postgres (internal)
|
||||||
|
POSTGRES_USER=atc
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
POSTGRES_DB=atc_agents
|
||||||
|
|
||||||
|
# GPU / LLM (VM303)
|
||||||
|
GPU_URL=http://10.0.20.106:9000
|
||||||
|
LLM_URL=http://10.0.20.106:8001/v1
|
||||||
|
LLM_MODEL=gpt-4o
|
||||||
|
LLM_API_KEY=sk-local
|
||||||
|
|
||||||
|
# ObjectScale S3 (VM objectscale 10.0.20.111)
|
||||||
|
S3_ENDPOINT=http://10.0.20.111:9020
|
||||||
|
S3_ACCESS_KEY=object_admin1
|
||||||
|
S3_SECRET_KEY=REDACTED-use-deploy-yml-or-ecs-admin
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
|
||||||
|
# Jupyter
|
||||||
|
JUPYTER_TOKEN=change-me-jupyter-token
|
||||||
|
|
||||||
|
# Lakehouse / ETL (optional probes)
|
||||||
|
LAKEHOUSE_HOST=10.0.21.50
|
||||||
|
AIRFLOW_URL=http://10.0.21.55:8080
|
||||||
|
KAFKA_UI_URL=http://10.0.21.36:9000
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
:80 {
|
||||||
|
handle_path /rag/* {
|
||||||
|
reverse_proxy rag-api:5020
|
||||||
|
}
|
||||||
|
handle_path /jupyter/* {
|
||||||
|
reverse_proxy jupyter:8888
|
||||||
|
}
|
||||||
|
handle_path /dq/* {
|
||||||
|
reverse_proxy dq-api:5010
|
||||||
|
}
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy api:3201
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy ui:80
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: atc
|
||||||
|
POSTGRES_PASSWORD: atc-agents-pg
|
||||||
|
POSTGRES_DB: atc_agents
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U atc -d atc_agents"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: ./api
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
DOCKHAND_URL: http://10.0.21.45:8082
|
||||||
|
DATABASE_URL: postgresql+psycopg2://atc:atc-agents-pg@postgres:5432/atc_agents
|
||||||
|
SQLITE_FALLBACK_PATH: /data/atc-agents.db
|
||||||
|
DOCLING_URL: http://docling-serve:5001
|
||||||
|
PRESENTATIONS_DIR: /data/presentations
|
||||||
|
GPU_URL: http://10.0.20.106:9000
|
||||||
|
GPU_UI_URL: http://10.0.20.106:9000
|
||||||
|
LLM_URL: http://10.0.20.106:8001/v1
|
||||||
|
LLM_MODEL: gpt-4o
|
||||||
|
LLM_API_KEY: sk-local
|
||||||
|
LAKEHOUSE_HOST: 10.0.21.50
|
||||||
|
AIRFLOW_URL: http://10.0.21.55:8080
|
||||||
|
KAFKA_UI_URL: http://10.0.21.36:9000
|
||||||
|
HDFS_NN_URL: http://10.0.21.61:9870
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-object_admin1}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
|
volumes:
|
||||||
|
- api_data:/data
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
ui:
|
||||||
|
build: ./ui
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
docling-serve:
|
||||||
|
image: quay.io/docling-project/docling-serve-cpu
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5001:5001"
|
||||||
|
environment:
|
||||||
|
DOCLING_SERVE_ENABLE_UI: "1"
|
||||||
|
DOCLING_SERVE_MAX_SYNC_WAIT: "300"
|
||||||
|
|
||||||
|
chromadb:
|
||||||
|
image: chromadb/chroma:0.5.23
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- chroma_data:/chroma/chroma
|
||||||
|
environment:
|
||||||
|
ANONYMIZED_TELEMETRY: "false"
|
||||||
|
|
||||||
|
rag-api:
|
||||||
|
build: ../atc-data-quality/rag-api
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
CHROMA_HOST: chromadb
|
||||||
|
CHROMA_PORT: 8000
|
||||||
|
DOCLING_URL: http://docling-serve:5001
|
||||||
|
LLM_URL: http://10.0.20.106:8001/v1
|
||||||
|
LLM_MODEL: gpt-4o
|
||||||
|
LLM_API_KEY: sk-local
|
||||||
|
RAG_DATA_DIR: /data
|
||||||
|
volumes:
|
||||||
|
- rag_data:/data
|
||||||
|
depends_on:
|
||||||
|
- chromadb
|
||||||
|
- docling-serve
|
||||||
|
|
||||||
|
dq-api:
|
||||||
|
build: ../atc-data-quality/dq-api
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DOCLING_URL: http://docling-serve:5001
|
||||||
|
DQ_DATA_DIR: /data
|
||||||
|
RAG_URL: http://rag-api:5020
|
||||||
|
RAG_COLLECTION: default
|
||||||
|
volumes:
|
||||||
|
- dq_data:/data
|
||||||
|
depends_on:
|
||||||
|
- docling-serve
|
||||||
|
- rag-api
|
||||||
|
|
||||||
|
jupyter:
|
||||||
|
image: quay.io/jupyter/scipy-notebook:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
JUPYTER_TOKEN: ${JUPYTER_TOKEN:-atc-jupyter}
|
||||||
|
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-object_admin1}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020}
|
||||||
|
AWS_DEFAULT_REGION: ${S3_REGION:-us-east-1}
|
||||||
|
command: >
|
||||||
|
start-notebook.sh
|
||||||
|
--NotebookApp.base_url=/jupyter/
|
||||||
|
--NotebookApp.token=${JUPYTER_TOKEN:-atc-jupyter}
|
||||||
|
--NotebookApp.allow_origin=*
|
||||||
|
volumes:
|
||||||
|
- jupyter_data:/home/jovyan/work
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
depends_on:
|
||||||
|
- ui
|
||||||
|
- api
|
||||||
|
- dq-api
|
||||||
|
- docling-serve
|
||||||
|
- rag-api
|
||||||
|
- chromadb
|
||||||
|
- jupyter
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
chroma_data:
|
||||||
|
rag_data:
|
||||||
|
dq_data:
|
||||||
|
redis_data:
|
||||||
|
postgres_data:
|
||||||
|
api_data:
|
||||||
|
jupyter_data:
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Dockhand-managed compose — image-only (no build context on Dockhand host).
|
||||||
|
# Source of truth for builds: /opt/atc-agents on VM304 (10.0.21.33).
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: atc
|
||||||
|
POSTGRES_PASSWORD: atc-agents-pg
|
||||||
|
POSTGRES_DB: atc_agents
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U atc -d atc_agents"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: atc-agents-api:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
DOCKHAND_URL: http://10.0.21.45:8082
|
||||||
|
DATABASE_URL: postgresql+psycopg2://atc:atc-agents-pg@postgres:5432/atc_agents
|
||||||
|
SQLITE_FALLBACK_PATH: /data/atc-agents.db
|
||||||
|
GPU_URL: http://10.0.20.106:9000
|
||||||
|
GPU_UI_URL: http://10.0.20.106:9000
|
||||||
|
LLM_URL: http://10.0.20.106:8001/v1
|
||||||
|
LLM_MODEL: qwen2.5-32b-gptq
|
||||||
|
LLM_API_KEY: sk-local
|
||||||
|
LAKEHOUSE_HOST: 10.0.21.50
|
||||||
|
AIRFLOW_URL: http://10.0.21.55:8080
|
||||||
|
KAFKA_UI_URL: http://10.0.21.36:9000
|
||||||
|
HDFS_NN_URL: http://10.0.21.61:9870
|
||||||
|
volumes:
|
||||||
|
- api_data:/data
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
ui:
|
||||||
|
image: atc-agents-ui:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
depends_on:
|
||||||
|
- ui
|
||||||
|
- api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis_data:
|
||||||
|
postgres_data:
|
||||||
|
api_data:
|
||||||
+113
-2
@@ -5,17 +5,53 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: atc
|
||||||
|
POSTGRES_PASSWORD: atc-agents-pg
|
||||||
|
POSTGRES_DB: atc_agents
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U atc -d atc_agents"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build: ./api
|
build: ./api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
DOCKHAND_URL: http://10.0.21.45:8082
|
DOCKHAND_URL: http://10.0.21.45:8082
|
||||||
DATABASE_URL: sqlite:////data/atc-agents.db
|
DATABASE_URL: postgresql+psycopg2://atc:atc-agents-pg@postgres:5432/atc_agents
|
||||||
|
SQLITE_FALLBACK_PATH: /data/atc-agents.db
|
||||||
|
DOCLING_URL: http://docling-serve:5001
|
||||||
|
PRESENTATIONS_DIR: /data/presentations
|
||||||
|
GPU_URL: http://10.0.20.106:9000
|
||||||
|
GPU_UI_URL: http://10.0.20.106:9000
|
||||||
|
LLM_URL: http://10.0.20.106:8001/v1
|
||||||
|
LLM_MODEL: gpt-4o
|
||||||
|
LLM_API_KEY: sk-local
|
||||||
|
LAKEHOUSE_HOST: 10.0.21.50
|
||||||
|
AIRFLOW_URL: http://10.0.21.55:8080
|
||||||
|
KAFKA_UI_URL: http://10.0.21.36:9000
|
||||||
|
HDFS_NN_URL: http://10.0.21.61:9870
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-object_admin1}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
volumes:
|
volumes:
|
||||||
- api_data:/data
|
- api_data:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
build: ./ui
|
build: ./ui
|
||||||
@@ -23,6 +59,71 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
|
||||||
|
docling-serve:
|
||||||
|
image: quay.io/docling-project/docling-serve-cpu
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5001:5001"
|
||||||
|
environment:
|
||||||
|
DOCLING_SERVE_ENABLE_UI: "1"
|
||||||
|
DOCLING_SERVE_MAX_SYNC_WAIT: "300"
|
||||||
|
|
||||||
|
chromadb:
|
||||||
|
image: chromadb/chroma:0.5.23
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- chroma_data:/chroma/chroma
|
||||||
|
environment:
|
||||||
|
ANONYMIZED_TELEMETRY: "false"
|
||||||
|
|
||||||
|
rag-api:
|
||||||
|
build: ../atc-data-quality/rag-api
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
CHROMA_HOST: chromadb
|
||||||
|
CHROMA_PORT: 8000
|
||||||
|
DOCLING_URL: http://docling-serve:5001
|
||||||
|
LLM_URL: http://10.0.20.106:8001/v1
|
||||||
|
LLM_MODEL: gpt-4o
|
||||||
|
LLM_API_KEY: sk-local
|
||||||
|
RAG_DATA_DIR: /data
|
||||||
|
volumes:
|
||||||
|
- rag_data:/data
|
||||||
|
depends_on:
|
||||||
|
- chromadb
|
||||||
|
- docling-serve
|
||||||
|
|
||||||
|
dq-api:
|
||||||
|
build: ../atc-data-quality/dq-api
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DOCLING_URL: http://docling-serve:5001
|
||||||
|
DQ_DATA_DIR: /data
|
||||||
|
RAG_URL: http://rag-api:5020
|
||||||
|
RAG_COLLECTION: default
|
||||||
|
volumes:
|
||||||
|
- dq_data:/data
|
||||||
|
depends_on:
|
||||||
|
- docling-serve
|
||||||
|
- rag-api
|
||||||
|
|
||||||
|
jupyter:
|
||||||
|
image: quay.io/jupyter/scipy-notebook:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
JUPYTER_TOKEN: ${JUPYTER_TOKEN:-atc-jupyter}
|
||||||
|
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-object_admin1}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020}
|
||||||
|
AWS_DEFAULT_REGION: ${S3_REGION:-us-east-1}
|
||||||
|
command: >
|
||||||
|
start-notebook.sh
|
||||||
|
--NotebookApp.base_url=/jupyter/
|
||||||
|
--NotebookApp.token=${JUPYTER_TOKEN:-atc-jupyter}
|
||||||
|
--NotebookApp.allow_origin=*
|
||||||
|
volumes:
|
||||||
|
- jupyter_data:/home/jovyan/work
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -33,7 +134,17 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- ui
|
- ui
|
||||||
- api
|
- api
|
||||||
|
- dq-api
|
||||||
|
- docling-serve
|
||||||
|
- rag-api
|
||||||
|
- chromadb
|
||||||
|
- jupyter
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
chroma_data:
|
||||||
|
rag_data:
|
||||||
|
dq_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
postgres_data:
|
||||||
api_data:
|
api_data:
|
||||||
|
jupyter_data:
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Command Center VM304
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|-------|
|
||||||
|
| VM | MCP · Proxmox VMID **304** |
|
||||||
|
| IP | `10.0.21.33` |
|
||||||
|
| SSH | `root@10.0.21.33` |
|
||||||
|
| Gitea | `http://atc-mgt01.dell-atc.lan:3001/mo/atc-agents` |
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **ui** — React (Data Platform, Presentation, DQ, Knowledge Chat, S3 Storage, Jupyter link)
|
||||||
|
- **api** — FastAPI + WebSocket + S3 browser API
|
||||||
|
- **dq-api / rag-api** — from `mo/atc-data-quality`
|
||||||
|
- **jupyter** — JupyterLab with S3 credentials
|
||||||
|
- **chromadb, docling, postgres, redis, caddy**
|
||||||
|
|
||||||
|
## ObjectScale S3
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|-------|
|
||||||
|
| Endpoint | `http://10.0.20.111:9020` |
|
||||||
|
| Namespace | `ns1` |
|
||||||
|
| Default user | `object_admin1` (see ECS deploy.yml) |
|
||||||
|
| UI browse | Command Center → **Object Storage** |
|
||||||
|
|
||||||
|
## Jupyter
|
||||||
|
|
||||||
|
- URL: `http://10.0.21.33/jupyter/`
|
||||||
|
- Token: `JUPYTER_TOKEN` in `.env`
|
||||||
|
- Work dir persisted in Docker volume `jupyter_data`
|
||||||
|
- Preconfigured: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_ENDPOINT`
|
||||||
|
|
||||||
|
Example in notebook:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import boto3, os
|
||||||
|
s3 = boto3.client("s3", endpoint_url=os.environ["S3_ENDPOINT"])
|
||||||
|
print(s3.list_buckets())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gitea sync
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/sync-gitea.sh
|
||||||
|
```
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ATC Command Center</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+541
@@ -0,0 +1,541 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
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()
|
||||||
|
]
|
||||||
|
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']}")
|
||||||
|
|
||||||
|
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,
|
||||||
|
"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_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_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 _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}")
|
||||||
|
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"]))
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
SECTION_BUILDERS = {
|
||||||
|
"docker": _section_docker,
|
||||||
|
"databases": _section_databases,
|
||||||
|
"lakehouse": _section_lakehouse,
|
||||||
|
"etl": _section_etl,
|
||||||
|
"hadoop": _section_hadoop,
|
||||||
|
"gpu": _section_gpu,
|
||||||
|
}
|
||||||
|
|
||||||
|
DOMAIN_ORDER = ["docker", "databases", "lakehouse", "etl", "hadoop", "gpu"]
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_full_lab_context(
|
||||||
|
gpu_data: dict[str, Any] | None = None,
|
||||||
|
log: TerminalLogFn | None = None,
|
||||||
|
) -> 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, hdfs, etl = await asyncio.gather(
|
||||||
|
dockhand_containers(client, 1, log),
|
||||||
|
dockhand_containers(client, 5, log),
|
||||||
|
dockhand_containers(client, 9, log),
|
||||||
|
collect_hdfs(client, log),
|
||||||
|
collect_etl(client, log),
|
||||||
|
)
|
||||||
|
docker = await collect_docker_rack(client, docker_raw, log)
|
||||||
|
databases = await collect_databases(client, db_raw, log)
|
||||||
|
lakehouse = await collect_lakehouse(client, lake_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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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) ===")
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
"""ATC Command Center API — FastAPI backend."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import FastAPI, 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 workload import build_workload_payload
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import Column, DateTime, String, Text, create_engine, select
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
|
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", "qwen2.5-32b-gptq")
|
||||||
|
LLM_API_KEY = os.getenv("LLM_API_KEY", "sk-local")
|
||||||
|
LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "120"))
|
||||||
|
|
||||||
|
AGENTS = [
|
||||||
|
{
|
||||||
|
"id": "etl-guardian",
|
||||||
|
"name": "ETL Guardian",
|
||||||
|
"color": "#00f0ff",
|
||||||
|
"zone": "etl",
|
||||||
|
"role": "Airflow, Kafka, Debezium, S3 pipeline",
|
||||||
|
"icon": "⚡",
|
||||||
|
"motto": "Pipelines never sleep",
|
||||||
|
"capabilities": ["Airflow", "Kafka", "Debezium", "S3", "Connectors"],
|
||||||
|
"suggested_prompts": [
|
||||||
|
"Hoe staat Debezium er voor?",
|
||||||
|
"Zijn alle Airflow DAGs healthy?",
|
||||||
|
"Kafka connector status?",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lakehouse-ops",
|
||||||
|
"name": "Lakehouse Ops",
|
||||||
|
"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 bereikbaar?",
|
||||||
|
"Hoeveel lakehouse containers draaien?",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "data-custodian",
|
||||||
|
"name": "Data Custodian",
|
||||||
|
"color": "#ffaa00",
|
||||||
|
"zone": "db",
|
||||||
|
"role": "PostgreSQL, MySQL, Mongo, Cassandra, Neo4j",
|
||||||
|
"icon": "🛡️",
|
||||||
|
"motto": "Guardian of every row",
|
||||||
|
"capabilities": ["PostgreSQL", "MySQL", "MongoDB", "Cassandra", "Neo4j"],
|
||||||
|
"suggested_prompts": [
|
||||||
|
"Database containers status?",
|
||||||
|
"Welke DB's draaien niet?",
|
||||||
|
"Postgres health check",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hadoop-ranger",
|
||||||
|
"name": "Hadoop Ranger",
|
||||||
|
"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",
|
||||||
|
"zone": "docker",
|
||||||
|
"role": "Docker, Proxmox, GPU, monitoring",
|
||||||
|
"icon": "👁️",
|
||||||
|
"motto": "See everything, miss nothing",
|
||||||
|
"capabilities": ["Docker", "Proxmox", "GPU", "vLLM", "Monitoring"],
|
||||||
|
"suggested_prompts": [
|
||||||
|
"Hoe staat de GPU?",
|
||||||
|
"Welk LLM model draait er?",
|
||||||
|
"Docker containers overzicht",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
ZONES = [
|
||||||
|
{"id": "docker", "label": "DOCKER RACK", "x": 8, "color": "#b366ff"},
|
||||||
|
{"id": "db", "label": "DB VAULT", "x": 28, "color": "#ffaa00"},
|
||||||
|
{"id": "lakehouse", "label": "LAKEHOUSE HUB", "x": 50, "color": "#ff00aa"},
|
||||||
|
{"id": "hadoop", "label": "HADOOP CLUSTER", "x": 72, "color": "#39ff14"},
|
||||||
|
{"id": "etl", "label": "ETL PIPE", "x": 92, "color": "#00f0ff"},
|
||||||
|
]
|
||||||
|
|
||||||
|
INTENT_KEYWORDS: dict[str, list[str]] = {
|
||||||
|
"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"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FeedEntry(Base):
|
||||||
|
__tablename__ = "feed"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True)
|
||||||
|
ts = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
agent_id = Column(String)
|
||||||
|
level = Column(String, default="info")
|
||||||
|
message = Column(Text)
|
||||||
|
|
||||||
|
|
||||||
|
class Approval(Base):
|
||||||
|
__tablename__ = "approvals"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True)
|
||||||
|
ts = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
agent_id = Column(String)
|
||||||
|
action = Column(Text)
|
||||||
|
reason = Column(Text)
|
||||||
|
status = Column(String, default="pending")
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
redis_client: aioredis.Redis | None = None
|
||||||
|
ws_clients: set[WebSocket] = set()
|
||||||
|
|
||||||
|
|
||||||
|
class PromptRequest(BaseModel):
|
||||||
|
message: str = Field(min_length=1, max_length=2000)
|
||||||
|
agent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalDecision(BaseModel):
|
||||||
|
approved: bool
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
return "infra-sentinel"
|
||||||
|
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)
|
||||||
|
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).
|
||||||
|
- 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:
|
||||||
|
await redis_client.publish("ops", payload)
|
||||||
|
dead = []
|
||||||
|
for ws in ws_clients:
|
||||||
|
try:
|
||||||
|
await ws.send_text(payload)
|
||||||
|
except Exception:
|
||||||
|
dead.append(ws)
|
||||||
|
for ws in dead:
|
||||||
|
ws_clients.discard(ws)
|
||||||
|
|
||||||
|
|
||||||
|
def add_feed(agent_id: str, message: str, level: str = "info") -> dict:
|
||||||
|
entry_id = str(uuid.uuid4())[:8]
|
||||||
|
with SessionLocal() as db:
|
||||||
|
row = FeedEntry(id=entry_id, agent_id=agent_id, message=message, level=level)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"id": entry_id,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"message": message,
|
||||||
|
"level": level,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def dockhand_env_containers(env_id: int) -> list[dict]:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
|
r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": env_id})
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_url(url: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=4.0, verify=False) as client:
|
||||||
|
r = await client.get(url)
|
||||||
|
return r.status_code < 500
|
||||||
|
except Exception:
|
||||||
|
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")
|
||||||
|
db_total = len(db_containers) or 6
|
||||||
|
|
||||||
|
lake_containers = await dockhand_env_containers(9)
|
||||||
|
lake_running = sum(1 for c in lake_containers if c.get("state") == "running")
|
||||||
|
lake_total = len(lake_containers) or 6
|
||||||
|
|
||||||
|
docker_containers = await dockhand_env_containers(1)
|
||||||
|
docker_running = sum(1 for c in docker_containers if c.get("state") == "running")
|
||||||
|
|
||||||
|
hdfs_ok = await probe_url("http://10.0.21.61:9870")
|
||||||
|
kafka_ok = await probe_url("http://10.0.21.36:9000")
|
||||||
|
airflow_ok = await probe_url("http://10.0.21.55:8080")
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
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": {
|
||||||
|
"docker": {"level": "ok" if docker_running >= 5 else "warn", "label": f"{docker_running} containers", "running": docker_running},
|
||||||
|
"databases": {"level": level(db_running, db_total), "label": f"{db_running}/{db_total} up", "running": db_running, "total": db_total},
|
||||||
|
"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(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)
|
||||||
|
|
||||||
|
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.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()
|
||||||
|
context = await gather_agent_context(agent_id, status, log=log)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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"{agent_name} answered: {message[:60]}", "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
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
feed = add_feed(agent, f"Alert: {domain} is DOWN ({info['label']})", "warn")
|
||||||
|
await publish_event({"type": "feed", "entry": feed})
|
||||||
|
except Exception as exc:
|
||||||
|
await publish_event({"type": "error", "message": str(exc)})
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
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])
|
||||||
|
for a in AGENTS:
|
||||||
|
await terminal_log(a["id"], f"{a['name']} terminal online — awaiting missions", level="info", phase="boot")
|
||||||
|
task = asyncio.create_task(heartbeat_loop())
|
||||||
|
add_feed("infra-sentinel", "ATC Command Center API online", "info")
|
||||||
|
yield
|
||||||
|
task.cancel()
|
||||||
|
if redis_client:
|
||||||
|
await redis_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="ATC Command Center API", lifespan=lifespan)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_workload() -> dict[str, Any]:
|
||||||
|
gpu = await collect_gpu()
|
||||||
|
snap = await collect_full_lab_context(gpu_data=gpu)
|
||||||
|
return build_workload_payload(snap)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/workload")
|
||||||
|
async def get_workload():
|
||||||
|
return await collect_workload()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
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():
|
||||||
|
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/{agent_id}")
|
||||||
|
async def get_agent_terminal(agent_id: str, limit: int = 200):
|
||||||
|
valid = {a["id"] for a in AGENTS}
|
||||||
|
if agent_id not in valid:
|
||||||
|
return {"error": "unknown agent"}
|
||||||
|
return {"agent_id": agent_id, "lines": get_terminal_lines(agent_id, limit)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/feed")
|
||||||
|
async def get_feed(limit: int = 50):
|
||||||
|
with SessionLocal() as db:
|
||||||
|
rows = db.execute(select(FeedEntry).order_by(FeedEntry.ts.desc()).limit(limit)).scalars().all()
|
||||||
|
return {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"ts": r.ts.isoformat() if r.ts else None,
|
||||||
|
"agent_id": r.agent_id,
|
||||||
|
"message": r.message,
|
||||||
|
"level": r.level,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/approvals")
|
||||||
|
async def get_approvals():
|
||||||
|
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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/approvals/{approval_id}/decide")
|
||||||
|
async def decide_approval(approval_id: str, body: ApprovalDecision):
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/prompt")
|
||||||
|
async def post_prompt(body: PromptRequest):
|
||||||
|
prompt_id = str(uuid.uuid4())[:8]
|
||||||
|
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))
|
||||||
|
return {"prompt_id": prompt_id, "agent_id": agent_id, "status": "dispatched"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/api/ws/ops")
|
||||||
|
async def ws_ops(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
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:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
ws_clients.discard(websocket)
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:3201/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "atc-command-center",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"framer-motion": "^11.15.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.16",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
redis==5.2.1
|
||||||
|
httpx==0.28.1
|
||||||
|
sqlalchemy==2.0.36
|
||||||
|
aiosqlite==0.20.0
|
||||||
|
pydantic==2.10.4
|
||||||
|
python-multipart==0.0.20
|
||||||
|
websockets==14.1
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy Command Center stack on VM304
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
[ -f .env ] || { echo "Create .env from config/command-center/.env.example"; exit 1; }
|
||||||
|
docker compose --env-file .env up -d --build
|
||||||
|
echo "OK — http://10.0.21.33/"
|
||||||
Executable
+80
@@ -0,0 +1,80 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Sync Command Center + Data Quality to Gitea (run on VM304 as root)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GITEA="http://atc-mgt01.dell-atc.lan:3001"
|
||||||
|
AUTH="mo:Dell2026!"
|
||||||
|
|
||||||
|
echo "==> Ensure atc-data-quality repo on Gitea"
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" -u "$AUTH" "$GITEA/api/v1/repos/mo/atc-data-quality")
|
||||||
|
if [ "$code" = "404" ]; then
|
||||||
|
curl -s -u "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"atc-data-quality","description":"ATC Data Quality + RAG APIs (Docling, ChromaDB, LangChain)","private":false,"auto_init":false}' \
|
||||||
|
"$GITEA/api/v1/user/repos"
|
||||||
|
echo "Created mo/atc-data-quality"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Init/push atc-data-quality"
|
||||||
|
cd /opt/atc-data-quality
|
||||||
|
if [ ! -d .git ]; then
|
||||||
|
git init
|
||||||
|
git config user.email "mo@dell-atc.lan"
|
||||||
|
git config user.name "mo"
|
||||||
|
git remote add origin "$GITEA/mo/atc-data-quality.git"
|
||||||
|
fi
|
||||||
|
cat > .gitignore <<'GI'
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
GI
|
||||||
|
cat > README.md <<'MD'
|
||||||
|
# ATC Data Quality + RAG
|
||||||
|
|
||||||
|
| Service | Port (internal) | Route |
|
||||||
|
|---------|-----------------|-------|
|
||||||
|
| dq-api | 5010 | `/dq/*` |
|
||||||
|
| rag-api | 5020 | `/rag/*` |
|
||||||
|
|
||||||
|
Deploy with sibling repo `mo/atc-agents` — see `config/data-quality/README.md` there.
|
||||||
|
MD
|
||||||
|
git add -A
|
||||||
|
git commit -m "Add DQ + RAG APIs with Docling, ChromaDB, persistent ingest" || true
|
||||||
|
git push -u origin main 2>/dev/null || git push -u origin master 2>/dev/null || \
|
||||||
|
git branch -M main && git push -u origin main --force
|
||||||
|
|
||||||
|
echo "==> Structure + push atc-agents"
|
||||||
|
cd /opt/atc-agents
|
||||||
|
mkdir -p config/command-center config/data-quality config/jupyter docs scripts
|
||||||
|
cp docker-compose.yml config/command-center/
|
||||||
|
cp caddy/Caddyfile config/command-center/
|
||||||
|
cp config/command-center/.env.example config/command-center/.env.example 2>/dev/null || true
|
||||||
|
|
||||||
|
cat > config/data-quality/README.md <<'MD'
|
||||||
|
# Data Quality services
|
||||||
|
|
||||||
|
Built from **`mo/atc-data-quality`** — clone to `/opt/atc-data-quality`.
|
||||||
|
|
||||||
|
Docker compose build contexts:
|
||||||
|
- `../atc-data-quality/dq-api`
|
||||||
|
- `../atc-data-quality/rag-api`
|
||||||
|
MD
|
||||||
|
|
||||||
|
cat > config/jupyter/README.md <<'MD'
|
||||||
|
# JupyterLab
|
||||||
|
|
||||||
|
Included in root `docker-compose.yml` as service `jupyter`.
|
||||||
|
Access: http://10.0.21.33/jupyter/
|
||||||
|
Token: JUPYTER_TOKEN in `.env`
|
||||||
|
MD
|
||||||
|
|
||||||
|
cp /tmp/deploy.sh scripts/ 2>/dev/null || cp scripts/deploy.sh scripts/ 2>/dev/null || true
|
||||||
|
chmod +x scripts/*.sh 2>/dev/null || true
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git status -sb
|
||||||
|
git commit -m "Command Center v2: DQ, RAG, GPU matrix, S3 browser, Jupyter, Gitea config layout" || true
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
echo "==> Done. Repos:"
|
||||||
|
echo " $GITEA/mo/atc-agents"
|
||||||
|
echo " $GITEA/mo/atc-data-quality"
|
||||||
+296
@@ -0,0 +1,296 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { ActivityFeed } from './components/ActivityFeed'
|
||||||
|
import { AgentRoster } from './components/AgentRoster'
|
||||||
|
import { AgentTerminalGrid } from './components/AgentTerminalGrid'
|
||||||
|
import { ChatPanel } from './components/ChatPanel'
|
||||||
|
import { CommandDock } from './components/CommandDock'
|
||||||
|
import { GpuPanel } from './components/GpuPanel'
|
||||||
|
import { AmbientBackground } from './components/AmbientBackground'
|
||||||
|
import { LiveClusterMap } from './components/LiveClusterMap'
|
||||||
|
import { LiveDomainGrid } from './components/LiveDomainGrid'
|
||||||
|
import { ThemeToggle } from './components/ThemeToggle'
|
||||||
|
import type { Agent, AgentAnim, Approval, ChatMessage, FeedEntry, GpuStatus, StatusData, TerminalLine, WorkloadData } from './types'
|
||||||
|
|
||||||
|
const TABS = ['Overview', 'Terminals', 'Activity', 'Approvals', 'GPU'] as const
|
||||||
|
type Tab = (typeof TABS)[number]
|
||||||
|
|
||||||
|
function wsUrl() {
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
return `${proto}://${window.location.host}/api/ws/ops`
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveClock() {
|
||||||
|
const [now, setNow] = useState(new Date())
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setNow(new Date()), 1000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<span className="text-xs font-mono text-[var(--text-faint)] hidden lg:inline tabular-nums">
|
||||||
|
{now.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [tab, setTab] = useState<Tab>('Overview')
|
||||||
|
const [agents, setAgents] = useState<Agent[]>([])
|
||||||
|
const [status, setStatus] = useState<StatusData | null>(null)
|
||||||
|
const [workload, setWorkload] = useState<WorkloadData | null>(null)
|
||||||
|
const [gpu, setGpu] = useState<GpuStatus | null>(null)
|
||||||
|
const [feed, setFeed] = useState<FeedEntry[]>([])
|
||||||
|
const [approvals, setApprovals] = useState<Approval[]>([])
|
||||||
|
const [chat, setChat] = useState<ChatMessage[]>([])
|
||||||
|
const [anims, setAnims] = useState<Record<string, AgentAnim>>({})
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [terminals, setTerminals] = useState<Record<string, TerminalLine[]>>({})
|
||||||
|
const [terminalLayout, setTerminalLayout] = useState<'grid' | 'focus'>('grid')
|
||||||
|
|
||||||
|
const appendTerminal = useCallback((line: TerminalLine) => {
|
||||||
|
setTerminals((prev) => {
|
||||||
|
const cur = prev[line.agent_id] || []
|
||||||
|
return { ...prev, [line.agent_id]: [...cur, line].slice(-300) }
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectedAgent = useMemo(
|
||||||
|
() => agents.find((a) => a.id === selectedId) || null,
|
||||||
|
[agents, selectedId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const [a, s, f, ap, g, t, w] = await Promise.all([
|
||||||
|
fetch('/api/agents').then((r) => r.json()),
|
||||||
|
fetch('/api/status').then((r) => r.json()),
|
||||||
|
fetch('/api/feed').then((r) => r.json()),
|
||||||
|
fetch('/api/approvals').then((r) => r.json()),
|
||||||
|
fetch('/api/gpu').then((r) => r.json()).catch(() => null),
|
||||||
|
fetch('/api/terminals').then((r) => r.json()).catch(() => ({ terminals: {} })),
|
||||||
|
fetch('/api/workload').then((r) => r.json()).catch(() => null),
|
||||||
|
])
|
||||||
|
setAgents(a.agents || [])
|
||||||
|
setStatus(s)
|
||||||
|
setGpu(g || s.gpu || null)
|
||||||
|
setFeed(f.entries || [])
|
||||||
|
setApprovals(ap.approvals || [])
|
||||||
|
if (t.terminals) setTerminals(t.terminals)
|
||||||
|
if (w?.zones) setWorkload(w)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
const ws = new WebSocket(wsUrl())
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
const msg = JSON.parse(ev.data)
|
||||||
|
if (msg.type === 'status') {
|
||||||
|
setStatus(msg.data)
|
||||||
|
if (msg.data.gpu) setGpu(msg.data.gpu)
|
||||||
|
}
|
||||||
|
if (msg.type === 'workload') setWorkload(msg.data)
|
||||||
|
if (msg.type === 'terminal') appendTerminal(msg.line)
|
||||||
|
if (msg.type === 'terminal_history' && msg.terminals) setTerminals(msg.terminals)
|
||||||
|
if (msg.type === 'feed') setFeed((prev) => [msg.entry, ...prev].slice(0, 100))
|
||||||
|
if (msg.type === 'agent_dispatch') {
|
||||||
|
setSelectedId(msg.agent_id)
|
||||||
|
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'walk', zone: msg.zone } }))
|
||||||
|
}
|
||||||
|
if (msg.type === 'agent_fetch') {
|
||||||
|
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'fetch', zone: msg.zone } }))
|
||||||
|
}
|
||||||
|
if (msg.type === 'agent_return') {
|
||||||
|
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'return', zone: msg.zone } }))
|
||||||
|
setTimeout(() => {
|
||||||
|
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'idle' } }))
|
||||||
|
}, 1200)
|
||||||
|
}
|
||||||
|
if (msg.type === 'prompt_result') {
|
||||||
|
setChat((c) => [...c, { role: 'agent', text: msg.answer, agent: msg.agent_id, ts: new Date().toISOString() }])
|
||||||
|
setBusy(false)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const iv = setInterval(load, 15000)
|
||||||
|
return () => { ws.close(); clearInterval(iv) }
|
||||||
|
}, [load, appendTerminal])
|
||||||
|
|
||||||
|
const sendPrompt = async (message: string, agentId?: string) => {
|
||||||
|
setBusy(true)
|
||||||
|
setChat((c) => [...c, { role: 'user', text: message, ts: new Date().toISOString() }])
|
||||||
|
if (agentId) setSelectedId(agentId)
|
||||||
|
await fetch('/api/prompt', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message, agent_id: agentId || undefined }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const decide = async (id: string, approved: boolean) => {
|
||||||
|
await fetch(`/api/approvals/${id}/decide`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ approved }),
|
||||||
|
})
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const allOk = status && Object.values(status.domains).every((d) => d.level === 'ok')
|
||||||
|
const totalTasks = agents.reduce((n, a) => n + (a.stats?.tasks || 0), 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-4 md:p-6 max-w-[96rem] mx-auto font-display flex flex-col gap-5 relative">
|
||||||
|
<AmbientBackground />
|
||||||
|
<header className="panel rounded-2xl px-5 py-4 flex flex-wrap justify-between items-center gap-4 relative overflow-hidden">
|
||||||
|
<div className="header-aurora" />
|
||||||
|
<div className="flex items-center gap-4 relative z-10">
|
||||||
|
<div className="logo-mark">ATC</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight neon-text" style={{ color: 'var(--accent)' }}>
|
||||||
|
Command Center
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs font-mono text-[var(--text-muted)]">
|
||||||
|
{agents.length} agents · {totalTasks} missions · autonomous ops
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap relative z-10">
|
||||||
|
<LiveClock />
|
||||||
|
{status && (
|
||||||
|
<span className={`status-pill ${allOk ? 'ok' : 'warn'}`}>
|
||||||
|
{allOk ? 'All systems operational' : 'Attention required'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{gpu?.ok && gpu.active_model && (
|
||||||
|
<span className="status-pill gpu hidden md:inline-flex">
|
||||||
|
GPU · {gpu.active_model}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<AgentRoster
|
||||||
|
agents={agents}
|
||||||
|
animations={anims}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
onDelegate={(a) => setSelectedId(a.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="panel rounded-2xl p-5">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="section-eyebrow">Live agent shells</p>
|
||||||
|
<h2 className="text-lg font-bold" style={{ color: 'var(--accent)' }}>Agent Terminals</h2>
|
||||||
|
<p className="text-xs text-[var(--text-muted)] mt-1">Volg live hoe elke agent data ophaalt — HTTP probes, Dockhand, JMX, vLLM.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" className={`tab-btn text-xs ${terminalLayout === 'grid' ? 'active' : ''}`} onClick={() => setTerminalLayout('grid')}>Grid</button>
|
||||||
|
<button type="button" className={`tab-btn text-xs ${terminalLayout === 'focus' ? 'active' : ''}`} onClick={() => setTerminalLayout('focus')}>Focus</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AgentTerminalGrid
|
||||||
|
agents={agents}
|
||||||
|
terminals={terminals}
|
||||||
|
animations={anims}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
layout={terminalLayout}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-12 gap-5">
|
||||||
|
<div className="xl:col-span-8 flex flex-col gap-5">
|
||||||
|
<LiveClusterMap agents={agents} workload={workload} animations={anims} selectedId={selectedId} />
|
||||||
|
|
||||||
|
<nav className="flex gap-2 flex-wrap">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button key={t} type="button" onClick={() => setTab(t)} className={`tab-btn ${tab === t ? 'active' : ''}`}>
|
||||||
|
{t}
|
||||||
|
{t === 'Approvals' && approvals.length > 0 && (
|
||||||
|
<span className="tab-badge">{approvals.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="panel rounded-2xl p-5 min-h-[280px]">
|
||||||
|
{tab === 'Overview' && (
|
||||||
|
<div>
|
||||||
|
<p className="section-eyebrow mb-1">Infrastructure</p>
|
||||||
|
<h3 className="text-lg font-bold mb-4" style={{ color: 'var(--text)' }}>Live Cluster Workload</h3>
|
||||||
|
<LiveDomainGrid workload={workload} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tab === 'Terminals' && (
|
||||||
|
<div>
|
||||||
|
<p className="section-eyebrow mb-1">Mission trace</p>
|
||||||
|
<h3 className="text-lg font-bold mb-4" style={{ color: 'var(--text)' }}>
|
||||||
|
{selectedAgent ? `${selectedAgent.name} — full terminal` : 'Select an agent'}
|
||||||
|
</h3>
|
||||||
|
{selectedAgent ? (
|
||||||
|
<AgentTerminalGrid
|
||||||
|
agents={agents}
|
||||||
|
terminals={terminals}
|
||||||
|
animations={anims}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
layout="focus"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">Klik een agent in de roster of stuur een prompt.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tab === 'Activity' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="section-eyebrow">Event stream</p>
|
||||||
|
<h3 className="text-lg font-bold" style={{ color: 'var(--text)' }}>Agent Activity</h3>
|
||||||
|
</div>
|
||||||
|
{selectedAgent && (
|
||||||
|
<button type="button" className="btn-secondary text-xs" onClick={() => setSelectedId(null)}>
|
||||||
|
Clear filter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ActivityFeed feed={feed} agents={agents} filterAgentId={selectedId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tab === 'Approvals' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{approvals.length === 0 && <p className="text-[var(--text-muted)] text-sm">Geen pending approvals.</p>}
|
||||||
|
{approvals.map((a) => {
|
||||||
|
const agent = agents.find((ag) => ag.id === a.agent_id)
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="status-card rounded-xl p-4 flex gap-4" style={{ borderColor: 'var(--accent-secondary)' }}>
|
||||||
|
{agent && <span className="text-2xl">{agent.icon}</span>}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-semibold" style={{ color: 'var(--accent-secondary)' }}>{a.action}</div>
|
||||||
|
<div className="text-xs text-[var(--text-muted)] mt-1">{a.reason}</div>
|
||||||
|
<div className="text-[10px] font-mono text-[var(--text-faint)] mt-1">via {agent?.name || a.agent_id}</div>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<button type="button" onClick={() => decide(a.id, true)} className="btn-secondary text-[var(--status-ok)]">Approve</button>
|
||||||
|
<button type="button" onClick={() => decide(a.id, false)} className="btn-secondary text-[var(--status-down)]">Deny</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tab === 'GPU' && <GpuPanel gpu={gpu} />}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="xl:col-span-4 flex flex-col gap-5">
|
||||||
|
<GpuPanel gpu={gpu} compact />
|
||||||
|
<ChatPanel messages={chat} agents={agents} selectedAgent={selectedAgent} busy={busy} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CommandDock onSubmit={sendPrompt} busy={busy} selectedAgent={selectedAgent} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import type { Agent, FeedEntry } from '../types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
feed: FeedEntry[]
|
||||||
|
agents: Agent[]
|
||||||
|
filterAgentId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityFeed({ feed, agents, filterAgentId }: Props) {
|
||||||
|
const entries = filterAgentId ? feed.filter((e) => e.agent_id === filterAgentId) : feed
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<span className="text-2xl mb-2">📡</span>
|
||||||
|
<p>Geen activiteit{filterAgentId ? ' voor deze agent' : ''}.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="activity-feed max-h-[420px] overflow-y-auto pr-1">
|
||||||
|
{entries.map((e, i) => {
|
||||||
|
const agent = agents.find((a) => a.id === e.agent_id)
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={e.id}
|
||||||
|
className="activity-item"
|
||||||
|
initial={{ opacity: 0, x: -8 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: Math.min(i * 0.03, 0.3) }}
|
||||||
|
>
|
||||||
|
<div className="activity-rail">
|
||||||
|
<div className="activity-dot" style={{ background: agent?.color || 'var(--text-faint)', boxShadow: `0 0 8px ${agent?.color || 'transparent'}` }} />
|
||||||
|
{i < entries.length - 1 && <div className="activity-line" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 pb-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||||
|
<span className="text-[10px] font-mono text-[var(--text-faint)]">
|
||||||
|
{e.ts ? new Date(e.ts).toLocaleString() : ''}
|
||||||
|
</span>
|
||||||
|
<span className="activity-agent-badge" style={{ color: agent?.color, borderColor: `${agent?.color}44` }}>
|
||||||
|
{agent?.icon} {agent?.name || e.agent_id}
|
||||||
|
</span>
|
||||||
|
{e.level === 'warn' && <span className="text-[10px] font-mono text-[var(--status-warn)]">WARN</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--text)] leading-relaxed">{e.message}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import type { Agent, AgentAnim } from '../types'
|
||||||
|
|
||||||
|
const STATE_LABEL: Record<AgentAnim['state'], string> = {
|
||||||
|
idle: 'Standby',
|
||||||
|
walk: 'En route',
|
||||||
|
fetch: 'Fetching data',
|
||||||
|
return: 'Returning',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agent: Agent
|
||||||
|
anim: AgentAnim
|
||||||
|
selected: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
onDelegate: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentCard({ agent, anim, selected, onSelect, onDelegate }: Props) {
|
||||||
|
const busy = anim.state !== 'idle'
|
||||||
|
const tasks = agent.stats?.tasks ?? 0
|
||||||
|
const alerts = agent.stats?.alerts ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
className={`agent-card text-left w-full ${selected ? 'selected' : ''}`}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
style={{ '--agent-color': agent.color } as CSSProperties}
|
||||||
|
>
|
||||||
|
<div className="agent-card-inner rounded-2xl p-4 h-full flex flex-col gap-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="agent-avatar" style={{ background: `color-mix(in srgb, ${agent.color} 18%, transparent)`, borderColor: agent.color }}>
|
||||||
|
<span className="text-xl">{agent.icon || '🤖'}</span>
|
||||||
|
<span className={`agent-status-dot ${busy ? 'active' : ''}`} style={{ background: busy ? agent.color : 'var(--status-ok)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<span className={`agent-state-pill ${busy ? 'busy' : ''}`} style={{ color: busy ? agent.color : 'var(--text-muted)' }}>
|
||||||
|
{STATE_LABEL[anim.state]}
|
||||||
|
</span>
|
||||||
|
{alerts > 0 && (
|
||||||
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded-full bg-[var(--status-warn-bg)] text-[var(--status-warn)]">
|
||||||
|
{alerts} alert{alerts > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-base leading-tight" style={{ color: agent.color }}>{agent.name}</h3>
|
||||||
|
<p className="text-[11px] font-mono text-[var(--text-faint)] mt-0.5 italic">"{agent.motto || agent.role}"</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(agent.capabilities || []).slice(0, 4).map((cap) => (
|
||||||
|
<span key={cap} className="cap-chip">{cap}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-auto pt-2 border-t border-[var(--border)]">
|
||||||
|
<span className="text-[10px] font-mono text-[var(--text-faint)]">{tasks} tasks logged</span>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelegate() }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); onDelegate() } }}
|
||||||
|
className="delegate-btn text-[10px] font-mono font-semibold px-2 py-1 rounded-lg"
|
||||||
|
style={{ color: agent.color }}
|
||||||
|
>
|
||||||
|
Delegate →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import type { Agent, AgentAnim } from '../types'
|
||||||
|
import { AgentCard } from './AgentCard'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[]
|
||||||
|
animations: Record<string, AgentAnim>
|
||||||
|
selectedId: string | null
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
onDelegate: (agent: Agent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentRoster({ agents, animations, selectedId, onSelect, onDelegate }: Props) {
|
||||||
|
return (
|
||||||
|
<section className="panel rounded-2xl p-5 relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 agent-roster-glow pointer-events-none" />
|
||||||
|
<div className="relative flex flex-wrap items-end justify-between gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="section-eyebrow">Autonomous workforce</p>
|
||||||
|
<h2 className="text-xl font-bold tracking-tight" style={{ color: 'var(--accent)' }}>
|
||||||
|
Agent Roster
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--text-muted)] mt-1 max-w-md">
|
||||||
|
Selecteer een agent om te delegeren. Elk teamlid bewaakt een zone op de ops floor.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-mono text-[var(--text-faint)]">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-[var(--status-ok)] animate-pulse" />
|
||||||
|
{agents.length} agents online
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3">
|
||||||
|
{agents.map((agent, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={agent.id}
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.06 }}
|
||||||
|
>
|
||||||
|
<AgentCard
|
||||||
|
agent={agent}
|
||||||
|
anim={animations[agent.id] || { agentId: agent.id, state: 'idle' }}
|
||||||
|
selected={selectedId === agent.id}
|
||||||
|
onSelect={() => onSelect(agent.id)}
|
||||||
|
onDelegate={() => onDelegate(agent)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agentId: string
|
||||||
|
color: string
|
||||||
|
icon?: string
|
||||||
|
state: 'idle' | 'walk' | 'fetch' | 'return'
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function uid(agentId: string, name: string) {
|
||||||
|
return `${agentId}-${name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function CharacterBody({ agentId, color, state }: { agentId: string; color: string; state: Props['state'] }) {
|
||||||
|
const g = uid(agentId, 'bodyGrad')
|
||||||
|
const glow = uid(agentId, 'glow')
|
||||||
|
const visor = uid(agentId, 'visor')
|
||||||
|
const walking = state === 'walk' || state === 'return'
|
||||||
|
const fetching = state === 'fetch'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width="72" height="92" viewBox="0 0 72 92" fill="none" xmlns="http://www.w3.org/2000/svg" className="sprite-svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={g} x1="36" y1="8" x2="36" y2="88" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor={color} stopOpacity="0.35" />
|
||||||
|
<stop offset="0.45" stopColor={color} stopOpacity="0.08" />
|
||||||
|
<stop offset="1" stopColor={color} stopOpacity="0.02" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={visor} x1="36" y1="14" x2="36" y2="28" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor={color} stopOpacity="0.95" />
|
||||||
|
<stop offset="1" stopColor={color} stopOpacity="0.25" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id={glow} x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feGaussianBlur stdDeviation="2.5" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Platform + aura */}
|
||||||
|
<ellipse cx="36" cy="86" rx="22" ry="5" fill={color} opacity="0.2" />
|
||||||
|
<ellipse cx="36" cy="86" rx="14" ry="2.5" fill={color} opacity="0.45" className="sprite-platform-pulse" />
|
||||||
|
|
||||||
|
{agentId === 'etl-guardian' && (
|
||||||
|
<g filter={`url(#${glow})`}>
|
||||||
|
<path d="M26 38 L36 32 L46 38 L44 58 L28 58 Z" fill={`url(#${g})`} stroke={color} strokeWidth="1.4" />
|
||||||
|
<rect x="30" y="42" width="12" height="8" rx="1" fill={color} opacity="0.15" stroke={color} strokeWidth="0.8" />
|
||||||
|
<path d="M32 46 H40 M34 48 H38" stroke={color} strokeWidth="0.8" opacity="0.7" />
|
||||||
|
<motion.path
|
||||||
|
d="M48 40 Q54 36 56 44"
|
||||||
|
stroke={color} strokeWidth="1.5" fill="none"
|
||||||
|
animate={fetching ? { pathLength: [0.2, 1, 0.2] } : { pathLength: 1 }}
|
||||||
|
transition={{ repeat: Infinity, duration: 0.8 }}
|
||||||
|
/>
|
||||||
|
<circle cx="56" cy="44" r="2.5" fill={color} opacity={fetching ? 1 : 0.5} />
|
||||||
|
<motion.g animate={walking ? { rotate: [0, 12, 0] } : {}} style={{ originX: '48px', originY: '42px' }}>
|
||||||
|
<path d="M46 40 L52 36 L52 48 L46 44 Z" fill={color} opacity="0.25" stroke={color} strokeWidth="1" />
|
||||||
|
</motion.g>
|
||||||
|
<rect x="22" y="40" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<rect x="43" y="40" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<rect x="28" y="58" width="8" height="14" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<rect x="36" y="58" width="8" height="14" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<circle cx="36" cy="22" r="11" className="sprite-head" stroke={color} strokeWidth="1.5" />
|
||||||
|
<path d="M24 18 Q36 6 48 18 L46 22 Q36 12 26 22 Z" fill={color} opacity="0.85" />
|
||||||
|
<rect x="26" y="18" width="20" height="5" rx="2" fill={`url(#${visor})`} opacity="0.9" />
|
||||||
|
<path d="M28 20 L32 20 M40 20 L44 20" stroke={color} strokeWidth="1.2" opacity="0.8" />
|
||||||
|
<text x="36" y="23" textAnchor="middle" fontSize="9" fill={color}>⚡</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{agentId === 'lakehouse-ops' && (
|
||||||
|
<g filter={`url(#${glow})`}>
|
||||||
|
<path d="M25 37 L36 30 L47 37 L45 59 L27 59 Z" fill={`url(#${g})`} stroke={color} strokeWidth="1.4" />
|
||||||
|
<path d="M30 35 L36 28 L42 35" stroke={color} strokeWidth="1.2" fill="none" opacity="0.6" />
|
||||||
|
<rect x="31" y="43" width="10" height="7" rx="1" fill={color} opacity="0.12" stroke={color} strokeWidth="0.8" />
|
||||||
|
<path d="M32 48 L36 44 L40 48 L38 50 L34 50 Z" fill={color} opacity="0.5" />
|
||||||
|
<motion.g animate={fetching ? { y: [0, -2, 0] } : {}} transition={{ repeat: Infinity, duration: 1.2 }}>
|
||||||
|
<rect x="48" y="38" width="10" height="12" rx="2" fill={color} opacity="0.2" stroke={color} strokeWidth="1" />
|
||||||
|
<path d="M50 46 L54 42 L54 46" stroke={color} strokeWidth="0.8" fill="none" />
|
||||||
|
</motion.g>
|
||||||
|
<rect x="21" y="39" width="7" height="15" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<rect x="44" y="39" width="7" height="15" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<rect x="27" y="59" width="8" height="13" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<rect x="37" y="59" width="8" height="13" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<circle cx="36" cy="21" r="11.5" className="sprite-head" stroke={color} strokeWidth="1.5" />
|
||||||
|
<path d="M23 17 Q36 5 49 17 L47 21 Q36 11 25 21 Z" fill={color} opacity="0.85" />
|
||||||
|
<ellipse cx="36" cy="20" rx="9" ry="4" fill={`url(#${visor})`} opacity="0.85" />
|
||||||
|
<text x="36" y="22" textAnchor="middle" fontSize="8" fill={color}>🏔</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{agentId === 'data-custodian' && (
|
||||||
|
<g filter={`url(#${glow})`}>
|
||||||
|
<rect x="26" y="36" width="20" height="24" rx="5" fill={`url(#${g})`} stroke={color} strokeWidth="1.5" />
|
||||||
|
<rect x="29" y="40" width="14" height="10" rx="2" fill={color} opacity="0.12" stroke={color} strokeWidth="0.8" />
|
||||||
|
<circle cx="36" cy="45" r="3" stroke={color} strokeWidth="1" fill="none" opacity="0.7" />
|
||||||
|
<path d="M36 45 L36 48 M34 47 L38 47" stroke={color} strokeWidth="0.8" opacity="0.7" />
|
||||||
|
<motion.g animate={walking ? { x: [0, 1, 0] } : {}} transition={{ repeat: Infinity, duration: 0.4 }}>
|
||||||
|
<path d="M18 38 L18 52 L24 52 L28 44 L24 38 Z" fill={color} opacity="0.3" stroke={color} strokeWidth="1.2" />
|
||||||
|
<path d="M20 42 L24 42 M20 46 L24 46" stroke={color} strokeWidth="0.7" opacity="0.6" />
|
||||||
|
</motion.g>
|
||||||
|
<rect x="44" y="39" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<rect x="28" y="60" width="9" height="12" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<rect x="35" y="60" width="9" height="12" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<circle cx="36" cy="22" r="12" className="sprite-head" stroke={color} strokeWidth="1.5" />
|
||||||
|
<path d="M22 19 Q36 7 50 19 L48 23 Q36 13 24 23 Z" fill={color} opacity="0.9" />
|
||||||
|
<rect x="27" y="18" width="18" height="6" rx="3" fill={`url(#${visor})`} />
|
||||||
|
<text x="36" y="23" textAnchor="middle" fontSize="8" fill={color}>🛡</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{agentId === 'hadoop-ranger' && (
|
||||||
|
<g filter={`url(#${glow})`}>
|
||||||
|
<path d="M27 38 L36 33 L45 38 L43 58 L29 58 Z" fill={`url(#${g})`} stroke={color} strokeWidth="1.4" />
|
||||||
|
<rect x="30" y="42" width="12" height="9" rx="1.5" fill={color} opacity="0.1" stroke={color} strokeWidth="0.8" />
|
||||||
|
<path d="M32 46 H40 M33 49 H39" stroke={color} strokeWidth="0.7" opacity="0.6" />
|
||||||
|
<path d="M14 42 L20 38 L20 46 L14 50 Z" fill={color} opacity="0.25" stroke={color} strokeWidth="1" />
|
||||||
|
<circle cx="17" cy="43" r="2" fill={color} opacity="0.8" />
|
||||||
|
<circle cx="17" cy="47" r="2" fill={color} opacity="0.8" />
|
||||||
|
<rect x="22" y="40" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<rect x="43" y="40" width="7" height="16" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<rect x="28" y="58" width="8" height="14" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<rect x="36" y="58" width="8" height="14" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<circle cx="36" cy="22" r="11" className="sprite-head" stroke={color} strokeWidth="1.5" />
|
||||||
|
<path d="M24 18 Q36 8 48 18 L46 22 Q36 14 26 22 Z" fill={color} opacity="0.85" />
|
||||||
|
<path d="M30 14 L36 10 L42 14 L40 17 L32 17 Z" fill={color} opacity="0.5" />
|
||||||
|
<rect x="28" y="19" width="16" height="5" rx="2" fill={`url(#${visor})`} opacity="0.85" />
|
||||||
|
<text x="36" y="23" textAnchor="middle" fontSize="8" fill={color}>🌲</text>
|
||||||
|
{fetching && (
|
||||||
|
<motion.circle cx="50" cy="30" r="3" fill={color} animate={{ opacity: [0.3, 1, 0.3] }} transition={{ repeat: Infinity, duration: 0.6 }} />
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{agentId === 'infra-sentinel' && (
|
||||||
|
<g filter={`url(#${glow})`}>
|
||||||
|
<path d="M24 37 Q36 30 48 37 L46 59 L26 59 Z" fill={`url(#${g})`} stroke={color} strokeWidth="1.4" />
|
||||||
|
<circle cx="36" cy="46" r="5" stroke={color} strokeWidth="1" fill={color} opacity="0.15" />
|
||||||
|
<circle cx="36" cy="46" r="2" fill={color} opacity="0.8" className="sprite-core-pulse" />
|
||||||
|
<motion.g
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ repeat: Infinity, duration: 8, ease: 'linear' }}
|
||||||
|
style={{ originX: '36px', originY: '46px' }}
|
||||||
|
>
|
||||||
|
<ellipse cx="36" cy="46" rx="10" ry="4" stroke={color} strokeWidth="0.8" fill="none" opacity="0.35" />
|
||||||
|
</motion.g>
|
||||||
|
<rect x="20" y="39" width="7" height="15" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<rect x="45" y="39" width="7" height="15" rx="3" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<rect x="27" y="59" width="8" height="13" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<rect x="37" y="59" width="8" height="13" rx="2.5" className="sprite-limb" stroke={color} strokeWidth="1" />
|
||||||
|
<circle cx="36" cy="21" r="11.5" className="sprite-head" stroke={color} strokeWidth="1.5" />
|
||||||
|
<path d="M23 17 Q36 5 49 17 L47 21 Q36 11 25 21 Z" fill={color} opacity="0.85" />
|
||||||
|
<rect x="26" y="17" width="20" height="6" rx="3" fill={`url(#${visor})`} />
|
||||||
|
<circle cx="36" cy="20" r="3" fill={color} opacity="0.9" />
|
||||||
|
<motion.circle
|
||||||
|
cx="52" cy="18" r="4"
|
||||||
|
fill={color} opacity="0.4" stroke={color} strokeWidth="1"
|
||||||
|
animate={{ y: [0, -3, 0], opacity: [0.3, 0.8, 0.3] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 2 }}
|
||||||
|
/>
|
||||||
|
<text x="36" y="24" textAnchor="middle" fontSize="7" fill="#fff" opacity="0.9">👁</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Default fallback */}
|
||||||
|
{!['etl-guardian', 'lakehouse-ops', 'data-custodian', 'hadoop-ranger', 'infra-sentinel'].includes(agentId) && (
|
||||||
|
<g filter={`url(#${glow})`}>
|
||||||
|
<rect x="26" y="36" width="20" height="24" rx="4" fill={`url(#${g})`} stroke={color} strokeWidth="1.5" />
|
||||||
|
<circle cx="36" cy="22" r="11" className="sprite-head" stroke={color} strokeWidth="1.5" />
|
||||||
|
<text x="36" y="25" textAnchor="middle" fontSize="10" fill={color}>🤖</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fetching && (
|
||||||
|
<motion.g animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 0.7 }}>
|
||||||
|
<circle cx="58" cy="26" r="4" fill={color} />
|
||||||
|
<circle cx="58" cy="26" r="7" stroke={color} strokeWidth="1" fill="none" opacity="0.4" />
|
||||||
|
</motion.g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentSprite({ agentId, color, state, label }: Props) {
|
||||||
|
const bob = state === 'idle' ? { y: [0, -5, 0] } : state === 'walk' || state === 'return' ? { y: [0, -9, 0] } : { y: [0, -2, 0] }
|
||||||
|
const scale = state === 'fetch' ? 0.94 : 1
|
||||||
|
const busy = state !== 'idle'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col items-center agent-sprite"
|
||||||
|
animate={{ ...bob, scale }}
|
||||||
|
transition={{ repeat: Infinity, duration: state === 'walk' || state === 'return' ? 0.28 : 2.4, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
<div className={`sprite-figure ${busy ? 'sprite-figure-busy' : ''}`}>
|
||||||
|
{state !== 'idle' && (
|
||||||
|
<motion.div
|
||||||
|
className="sprite-ring-outer"
|
||||||
|
style={{ borderColor: color, boxShadow: `0 0 20px ${color}55, inset 0 0 12px ${color}22` }}
|
||||||
|
animate={{ scale: [1, 1.06, 1], opacity: [0.6, 1, 0.6] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="sprite-holo-shimmer" style={{ background: `linear-gradient(135deg, ${color}18, transparent 60%)` }} />
|
||||||
|
<CharacterBody agentId={agentId} color={color} state={state} />
|
||||||
|
</div>
|
||||||
|
<div className="sprite-nameplate" style={{ borderColor: `${color}55`, boxShadow: `0 0 12px ${color}33` }}>
|
||||||
|
<span className="sprite-nameplate-dot" style={{ background: color, boxShadow: `0 0 6px ${color}` }} />
|
||||||
|
<span className="sprite-nameplate-text" style={{ color }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import type { Agent, TerminalLine } from '../types'
|
||||||
|
|
||||||
|
const LEVEL_CLASS: Record<string, string> = {
|
||||||
|
info: 'term-info',
|
||||||
|
ok: 'term-ok',
|
||||||
|
warn: 'term-warn',
|
||||||
|
err: 'term-err',
|
||||||
|
cmd: 'term-cmd',
|
||||||
|
llm: 'term-llm',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agent: Agent
|
||||||
|
lines: TerminalLine[]
|
||||||
|
active: boolean
|
||||||
|
expanded?: boolean
|
||||||
|
onFocus?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentTerminal({ agent, lines, active, expanded, onFocus }: Props) {
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active || expanded) {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}, [lines, active, expanded])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`agent-terminal ${active ? 'active' : ''} ${expanded ? 'expanded' : ''}`}
|
||||||
|
style={{ '--term-accent': agent.color } as CSSProperties}
|
||||||
|
onClick={onFocus}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && onFocus?.()}
|
||||||
|
>
|
||||||
|
<div className="agent-terminal-header">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span>{agent.icon}</span>
|
||||||
|
<span className="font-semibold text-xs truncate" style={{ color: agent.color }}>{agent.name}</span>
|
||||||
|
{active && <span className="term-live-badge">LIVE</span>}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-[var(--text-faint)]">{lines.length} lines</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={containerRef} className="agent-terminal-body">
|
||||||
|
{lines.length === 0 && (
|
||||||
|
<div className="term-line term-info">
|
||||||
|
<span className="term-ts">--:--:--</span>
|
||||||
|
<span className="term-text">Waiting for missions…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lines.map((line) => (
|
||||||
|
<div key={line.id} className={`term-line ${LEVEL_CLASS[line.level] || 'term-info'}`}>
|
||||||
|
<span className="term-ts">{line.ts ? new Date(line.ts).toLocaleTimeString() : ''}</span>
|
||||||
|
<span className="term-phase">{line.phase}</span>
|
||||||
|
<span className="term-text">{line.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{active && (
|
||||||
|
<div className="term-line term-cmd">
|
||||||
|
<span className="term-ts" />
|
||||||
|
<span className="term-text term-cursor">█</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Agent, AgentAnim, TerminalLine } from '../types'
|
||||||
|
import { AgentTerminal } from './AgentTerminal'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[]
|
||||||
|
terminals: Record<string, TerminalLine[]>
|
||||||
|
animations: Record<string, AgentAnim>
|
||||||
|
selectedId: string | null
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
layout?: 'grid' | 'focus'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentTerminalGrid({ agents, terminals, animations, selectedId, onSelect, layout = 'grid' }: Props) {
|
||||||
|
const focusId = selectedId || agents[0]?.id
|
||||||
|
|
||||||
|
if (layout === 'focus' && focusId) {
|
||||||
|
const agent = agents.find((a) => a.id === focusId)!
|
||||||
|
const anim = animations[focusId] || { agentId: focusId, state: 'idle' as const }
|
||||||
|
return (
|
||||||
|
<div className="agent-terminal-focus">
|
||||||
|
<AgentTerminal
|
||||||
|
agent={agent}
|
||||||
|
lines={terminals[focusId] || []}
|
||||||
|
active={anim.state !== 'idle'}
|
||||||
|
expanded
|
||||||
|
/>
|
||||||
|
<div className="agent-terminal-tabs">
|
||||||
|
{agents.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(a.id)}
|
||||||
|
className={`agent-terminal-tab ${focusId === a.id ? 'active' : ''}`}
|
||||||
|
style={focusId === a.id ? { borderColor: a.color, color: a.color } : undefined}
|
||||||
|
>
|
||||||
|
{a.icon} {a.name.split(' ')[0]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5 gap-3">
|
||||||
|
{agents.map((agent) => {
|
||||||
|
const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const }
|
||||||
|
return (
|
||||||
|
<AgentTerminal
|
||||||
|
key={agent.id}
|
||||||
|
agent={agent}
|
||||||
|
lines={terminals[agent.id] || []}
|
||||||
|
active={anim.state !== 'idle'}
|
||||||
|
onFocus={() => onSelect(agent.id)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export function AmbientBackground() {
|
||||||
|
return (
|
||||||
|
<div className="ambient-bg pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden>
|
||||||
|
{[...Array(12)].map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="ambient-orb"
|
||||||
|
style={{
|
||||||
|
left: `${(i * 17 + 5) % 95}%`,
|
||||||
|
top: `${(i * 23 + 8) % 90}%`,
|
||||||
|
animationDelay: `${i * 0.7}s`,
|
||||||
|
animationDuration: `${8 + (i % 4) * 2}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import type { Agent, ChatMessage } from '../types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
messages: ChatMessage[]
|
||||||
|
agents: Agent[]
|
||||||
|
selectedAgent: Agent | null
|
||||||
|
busy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentFor(agents: Agent[], id?: string) {
|
||||||
|
return agents.find((a) => a.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatPanel({ messages, agents, selectedAgent, busy }: Props) {
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages, busy])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel rounded-2xl flex flex-col h-full min-h-[360px] overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-[var(--border)] flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="section-eyebrow">Mission control</p>
|
||||||
|
<h3 className="font-bold text-base" style={{ color: 'var(--accent)' }}>Agent Comms</h3>
|
||||||
|
</div>
|
||||||
|
{selectedAgent && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl border text-xs font-mono" style={{ borderColor: `${selectedAgent.color}44`, color: selectedAgent.color }}>
|
||||||
|
<span>{selectedAgent.icon}</span>
|
||||||
|
<span>→ {selectedAgent.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="empty-state h-full flex flex-col items-center justify-center">
|
||||||
|
<span className="text-3xl mb-3">💬</span>
|
||||||
|
<p className="text-sm text-[var(--text-muted)] text-center max-w-xs">
|
||||||
|
{selectedAgent
|
||||||
|
? `Stuur een opdracht naar ${selectedAgent.name}. Kies een suggestie hieronder of typ je eigen vraag.`
|
||||||
|
: 'Selecteer een agent of stel een vraag — routing kiest automatisch de specialist.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((m, i) => {
|
||||||
|
const agent = m.role === 'agent' ? agentFor(agents, m.agent) : null
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className={`chat-message ${m.role}`}
|
||||||
|
>
|
||||||
|
{m.role === 'agent' && agent && (
|
||||||
|
<div className="chat-avatar" style={{ background: `color-mix(in srgb, ${agent.color} 20%, transparent)`, borderColor: agent.color }}>
|
||||||
|
{agent.icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`chat-bubble ${m.role === 'user' ? 'chat-bubble-user' : 'chat-bubble-agent'}`}>
|
||||||
|
<div className="chat-meta">
|
||||||
|
{m.role === 'user' ? 'You' : agent?.name || m.agent}
|
||||||
|
{m.ts && <span>{new Date(m.ts).toLocaleTimeString()}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="chat-text whitespace-pre-wrap">{m.text}</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{busy && (
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="chat-message agent">
|
||||||
|
<div className="chat-avatar thinking-pulse" style={{ background: 'var(--surface-elevated)' }}>⋯</div>
|
||||||
|
<div className="chat-bubble chat-bubble-agent">
|
||||||
|
<div className="typing-indicator">
|
||||||
|
<span /><span /><span />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { appIcon, shortName } from '../lib/appIcons'
|
||||||
|
import type { WorkloadZone } from '../types'
|
||||||
|
|
||||||
|
const DESK_X = 50
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
zones: WorkloadZone[]
|
||||||
|
activeZoneId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataFlowLayer({ zones, activeZoneId }: Props) {
|
||||||
|
return (
|
||||||
|
<svg className="absolute inset-0 w-full h-full pointer-events-none cluster-flow-svg" preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
{zones.map((z) => (
|
||||||
|
<linearGradient key={`grad-${z.id}`} id={`flow-grad-${z.id}`} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor={z.color} stopOpacity="0.05" />
|
||||||
|
<stop offset="50%" stopColor={z.color} stopOpacity="0.6" />
|
||||||
|
<stop offset="100%" stopColor={z.color} stopOpacity="0.05" />
|
||||||
|
</linearGradient>
|
||||||
|
))}
|
||||||
|
</defs>
|
||||||
|
{zones.map((z, i) => (
|
||||||
|
<g key={z.id}>
|
||||||
|
<motion.line
|
||||||
|
x1={`${DESK_X}%`} y1="92%" x2={`${z.x}%`} y2="22%"
|
||||||
|
stroke={`url(#flow-grad-${z.id})`}
|
||||||
|
strokeWidth={activeZoneId === z.id ? 3 : 1.5}
|
||||||
|
strokeDasharray="6 5"
|
||||||
|
animate={{ strokeDashoffset: [0, -22] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 1.8 + i * 0.15, ease: 'linear' }}
|
||||||
|
/>
|
||||||
|
{[0, 1, 2].map((p) => (
|
||||||
|
<motion.circle
|
||||||
|
key={p}
|
||||||
|
r={activeZoneId === z.id ? 4 : 3}
|
||||||
|
fill={z.color}
|
||||||
|
filter={`drop-shadow(0 0 4px ${z.color})`}
|
||||||
|
animate={{
|
||||||
|
cx: [`${DESK_X}%`, `${z.x}%`],
|
||||||
|
cy: ['92%', '22%'],
|
||||||
|
opacity: [0, 1, 1, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
repeat: Infinity,
|
||||||
|
duration: 2.2 + i * 0.2 + p * 0.4,
|
||||||
|
delay: p * 0.7 + i * 0.1,
|
||||||
|
ease: 'linear',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ZoneProps = {
|
||||||
|
zone: WorkloadZone
|
||||||
|
active: boolean
|
||||||
|
agentBusy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ZoneTower({ zone, active, agentBusy }: ZoneProps) {
|
||||||
|
const pulse = zone.level === 'ok'
|
||||||
|
const apps = zone.apps.slice(0, 5)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-0 -translate-x-1/2 flex flex-col items-center gap-1" style={{ left: `${zone.x}%` }}>
|
||||||
|
<motion.div
|
||||||
|
className={`zone-tower ${pulse ? 'zone-tower-live' : ''} ${active ? 'zone-tower-active' : ''}`}
|
||||||
|
style={{
|
||||||
|
borderColor: zone.color,
|
||||||
|
boxShadow: `0 0 ${active || agentBusy ? 28 : 14}px ${zone.color}${active ? '66' : '33'}`,
|
||||||
|
color: zone.color,
|
||||||
|
}}
|
||||||
|
animate={agentBusy ? { scale: [1, 1.04, 1] } : { scale: 1 }}
|
||||||
|
transition={{ repeat: Infinity, duration: 0.8 }}
|
||||||
|
>
|
||||||
|
<div className="zone-tower-label">{zone.label}</div>
|
||||||
|
<div className="zone-tower-stats">
|
||||||
|
<span className={`zone-level-dot level-${zone.level}`} />
|
||||||
|
{zone.running}/{zone.total || zone.apps.length}
|
||||||
|
</div>
|
||||||
|
{zone.id === 'hadoop' && zone.hdfs_total_gb != null && (
|
||||||
|
<div className="zone-tower-extra">{zone.hdfs_used_gb ?? 0}/{zone.hdfs_total_gb} GB</div>
|
||||||
|
)}
|
||||||
|
{zone.id === 'lakehouse' && (
|
||||||
|
<div className="zone-tower-extra">{zone.trino_ok ? 'Trino ●' : 'Trino ○'}</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="zone-app-orbit">
|
||||||
|
{apps.map((app, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={app.name}
|
||||||
|
className={`zone-app-chip state-${app.state}`}
|
||||||
|
style={{ borderColor: `${zone.color}55` }}
|
||||||
|
animate={{ y: [0, -3, 0], opacity: app.state === 'running' ? [0.85, 1, 0.85] : 0.45 }}
|
||||||
|
transition={{ repeat: Infinity, duration: 2 + i * 0.3, delay: i * 0.15 }}
|
||||||
|
title={`${app.name} (${app.state})`}
|
||||||
|
>
|
||||||
|
<span>{appIcon(app.name, app.image)}</span>
|
||||||
|
<span>{shortName(app.name, 10)}</span>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GpuBeacon({ model, util, count, level, active }: {
|
||||||
|
model?: string | null
|
||||||
|
util?: number
|
||||||
|
count?: number
|
||||||
|
level: string
|
||||||
|
active?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={`gpu-beacon level-${level} ${active ? 'gpu-beacon-active' : ''}`}
|
||||||
|
animate={{ boxShadow: active ? ['0 0 20px #76b90044', '0 0 36px #76b90088', '0 0 20px #76b90044'] : undefined }}
|
||||||
|
transition={{ repeat: Infinity, duration: 2 }}
|
||||||
|
>
|
||||||
|
<div className="gpu-beacon-title">⚡ GPU LAB</div>
|
||||||
|
<div className="gpu-beacon-model">{shortName(model || 'offline', 18)}</div>
|
||||||
|
<div className="gpu-beacon-meta">{count ?? 0}× V100 · {Math.round(util ?? 0)}%</div>
|
||||||
|
<div className="gpu-beacon-bar">
|
||||||
|
<motion.div
|
||||||
|
className="gpu-beacon-fill"
|
||||||
|
animate={{ width: `${Math.max(util ?? 0, 4)}%` }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkloadTicker({ zones }: { zones: WorkloadZone[] }) {
|
||||||
|
const allApps = zones.flatMap((z) =>
|
||||||
|
z.apps.map((a) => ({ ...a, zoneColor: z.color, zoneId: z.id })),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="workload-ticker-wrap">
|
||||||
|
<div className="workload-ticker-label">LIVE WORKLOAD</div>
|
||||||
|
<div className="workload-ticker-track">
|
||||||
|
<motion.div
|
||||||
|
className="workload-ticker-inner"
|
||||||
|
animate={{ x: ['0%', '-50%'] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 40, ease: 'linear' }}
|
||||||
|
>
|
||||||
|
{[...allApps, ...allApps].map((app, i) => (
|
||||||
|
<span
|
||||||
|
key={`${app.name}-${i}`}
|
||||||
|
className={`ticker-chip state-${app.state}`}
|
||||||
|
style={{ borderColor: `${app.zoneColor}44`, color: app.zoneColor }}
|
||||||
|
>
|
||||||
|
{appIcon(app.name, app.image)} {app.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { FormEvent, useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import type { Agent } from '../types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSubmit: (message: string, agentId?: string) => void
|
||||||
|
busy: boolean
|
||||||
|
selectedAgent: Agent | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandDock({ onSubmit, busy, selectedAgent }: Props) {
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const prompts = selectedAgent?.suggested_prompts || [
|
||||||
|
'Lab health overview?',
|
||||||
|
'Hoe staat de GPU?',
|
||||||
|
'Database status?',
|
||||||
|
]
|
||||||
|
|
||||||
|
const handle = (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!text.trim() || busy) return
|
||||||
|
onSubmit(text.trim(), selectedAgent?.id)
|
||||||
|
setText('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendQuick = (prompt: string) => {
|
||||||
|
if (busy) return
|
||||||
|
onSubmit(prompt, selectedAgent?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="command-dock panel rounded-2xl p-4 md:p-5">
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{prompts.map((p) => (
|
||||||
|
<motion.button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => sendQuick(p)}
|
||||||
|
className="quick-prompt-chip"
|
||||||
|
style={selectedAgent ? { borderColor: `${selectedAgent.color}55`, color: selectedAgent.color } : undefined}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handle} className="flex gap-3 items-center">
|
||||||
|
<div className="command-input-wrap flex-1 flex items-center gap-3 rounded-xl px-4 py-1">
|
||||||
|
{selectedAgent && (
|
||||||
|
<span className="text-lg shrink-0" title={selectedAgent.name}>{selectedAgent.icon}</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
className="prompt-input border-0 bg-transparent shadow-none focus:shadow-none flex-1 py-2.5"
|
||||||
|
placeholder={selectedAgent ? `Opdracht voor ${selectedAgent.name}...` : 'Vraag je agents... routing kiest de specialist'}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={busy || !text.trim()} className="btn-primary shrink-0 px-6">
|
||||||
|
{busy ? 'Dispatching...' : 'Dispatch'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import type { GpuStatus } from '../types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
gpu: GpuStatus | null
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function memPct(used: number, total: number) {
|
||||||
|
if (!total) return 0
|
||||||
|
return Math.round((used / total) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GpuPanel({ gpu, compact }: Props) {
|
||||||
|
if (!gpu) {
|
||||||
|
return (
|
||||||
|
<div className="panel rounded-2xl p-5 animate-pulse">
|
||||||
|
<div className="h-4 w-32 rounded bg-[var(--surface-elevated)] mb-4" />
|
||||||
|
<div className="h-24 rounded-xl bg-[var(--surface-elevated)]" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const online = gpu.ok
|
||||||
|
const gpus = gpu.gpus || []
|
||||||
|
const avgUtil = gpus.length ? gpus.reduce((s, g) => s + g.util_gpu, 0) / gpus.length : 0
|
||||||
|
const avgVram = gpus.length ? gpus.reduce((s, g) => s + memPct(g.memory_used_mib, g.memory_total_mib), 0) / gpus.length : 0
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className="panel rounded-2xl p-4 relative overflow-hidden">
|
||||||
|
<div className="absolute -top-8 -right-8 w-24 h-24 rounded-full bg-[var(--accent-gpu)] opacity-[0.1] blur-2xl pointer-events-none" />
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-3 relative">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>⚡</span>
|
||||||
|
<h2 className="font-bold text-sm text-[var(--accent-gpu)]">GPU Lab</h2>
|
||||||
|
</div>
|
||||||
|
<a href={gpu.ui_url} target="_blank" rel="noopener noreferrer" className="text-[10px] font-mono text-[var(--accent-gpu)] hover:underline">
|
||||||
|
Open ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{gpu.active_model && (
|
||||||
|
<div className="text-sm font-semibold text-[var(--text)] truncate">{gpu.active_model}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 mt-2 text-[10px] font-mono text-[var(--text-muted)]">
|
||||||
|
<span>{gpu.gpu_count ?? gpus.length}× V100</span>
|
||||||
|
<span>{Math.round(avgUtil)}% util</span>
|
||||||
|
<span>{Math.round(avgVram)}% VRAM</span>
|
||||||
|
<span className={online && gpu.inference_active ? 'text-[var(--status-ok)]' : 'text-[var(--status-warn)]'}>
|
||||||
|
{online ? (gpu.inference_active ? 'ON' : 'STBY') : 'OFF'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-[var(--surface-muted)] mt-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(avgUtil, avgVram * 0.5)}%`,
|
||||||
|
background: 'linear-gradient(90deg, var(--accent-gpu), var(--accent))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel rounded-2xl p-5 relative overflow-hidden">
|
||||||
|
<div className="absolute -top-12 -right-12 w-40 h-40 rounded-full bg-[var(--accent-gpu)] opacity-[0.08] blur-2xl pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 mb-4 relative">
|
||||||
|
<div>
|
||||||
|
<p className="section-eyebrow">Inference cluster</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg" aria-hidden>⚡</span>
|
||||||
|
<h2 className="font-display font-bold text-[var(--accent-gpu)] tracking-tight">GPU Lab</h2>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-mono px-2 py-0.5 rounded-full border ${
|
||||||
|
online && gpu.inference_active
|
||||||
|
? 'border-[var(--status-ok)] text-[var(--status-ok)] bg-[var(--status-ok-bg)]'
|
||||||
|
: 'border-[var(--status-warn)] text-[var(--status-warn)] bg-[var(--status-warn-bg)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{online ? (gpu.inference_active ? 'INFERENCE ON' : 'STANDBY') : 'OFFLINE'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-mono text-[var(--text-muted)] mt-1">
|
||||||
|
atc-gpu-dev · {gpu.host} · {gpu.gpu_count ?? gpus.length}× V100
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href={gpu.ui_url} target="_blank" rel="noopener noreferrer" className="btn-secondary text-xs shrink-0">
|
||||||
|
Open GPU Manager ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gpu.active_model && (
|
||||||
|
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface-elevated)] px-4 py-3 mb-4">
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-faint)]">Active model</div>
|
||||||
|
<div className="font-semibold text-[var(--text)] mt-0.5">{gpu.active_model}</div>
|
||||||
|
{gpu.vllm_url && (
|
||||||
|
<div className="text-[11px] font-mono text-[var(--text-muted)] mt-1 truncate">{gpu.vllm_url}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!online && (
|
||||||
|
<p className="text-sm text-[var(--status-down)]">{gpu.error || 'GPU manager unreachable'}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{gpus.map((g) => {
|
||||||
|
const pct = memPct(g.memory_used_mib, g.memory_total_mib)
|
||||||
|
return (
|
||||||
|
<div key={g.index} className="gpu-card rounded-xl p-3 border border-[var(--border)]">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-xs font-mono font-semibold text-[var(--accent-gpu)]">GPU {g.index}</span>
|
||||||
|
<span className="text-[10px] font-mono text-[var(--text-faint)]">{g.temperature_c}°C · {g.power_w}W</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-muted)] truncate mb-2">{g.name}</div>
|
||||||
|
<div className="flex gap-3 text-[10px] font-mono mb-1.5">
|
||||||
|
<span>Util {Math.round(g.util_gpu)}%</span>
|
||||||
|
<span>VRAM {pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-[var(--surface-muted)] overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(g.util_gpu, pct * 0.3)}%`,
|
||||||
|
background: 'linear-gradient(90deg, var(--accent-gpu), var(--accent))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { AgentSprite } from './AgentSprite'
|
||||||
|
import { DataFlowLayer, GpuBeacon, WorkloadTicker, ZoneTower } from './ClusterViz'
|
||||||
|
import type { Agent, AgentAnim, WorkloadData } from '../types'
|
||||||
|
|
||||||
|
const ZONE_X: Record<string, number> = {
|
||||||
|
docker: 8,
|
||||||
|
db: 28,
|
||||||
|
lakehouse: 50,
|
||||||
|
hadoop: 72,
|
||||||
|
etl: 92,
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_LABEL: Record<AgentAnim['state'], string> = {
|
||||||
|
idle: '',
|
||||||
|
walk: '→ zone',
|
||||||
|
fetch: '⟳ fetch',
|
||||||
|
return: '← desk',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DESK_X = 50
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[]
|
||||||
|
workload: WorkloadData | null
|
||||||
|
animations: Record<string, AgentAnim>
|
||||||
|
selectedId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveClusterMap({ agents, workload, animations, selectedId }: Props) {
|
||||||
|
const zones = workload?.zones || []
|
||||||
|
const activeCount = agents.filter((a) => (animations[a.id]?.state || 'idle') !== 'idle').length
|
||||||
|
const busyAgent = agents.find((a) => (animations[a.id]?.state || 'idle') !== 'idle')
|
||||||
|
const mappedBusyZone = busyAgent?.zone ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel rounded-2xl p-5 relative overflow-hidden min-h-[480px] live-cluster-map">
|
||||||
|
<div className="cluster-ambient" />
|
||||||
|
<div className="ops-floor-scan" />
|
||||||
|
|
||||||
|
<div className="flex justify-between items-start mb-4 relative z-10 gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<p className="section-eyebrow">Live simulation</p>
|
||||||
|
<h2 className="font-display text-lg font-bold tracking-wide neon-text" style={{ color: 'var(--accent)' }}>
|
||||||
|
Cluster Ops Floor
|
||||||
|
</h2>
|
||||||
|
{workload && (
|
||||||
|
<p className="text-[10px] font-mono text-[var(--text-muted)] mt-1">
|
||||||
|
{workload.totals.apps_running} workloads active · {workload.totals.connectors} connectors · GPU {workload.gpu.avg_util ?? 0}%
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="text-[10px] font-mono px-2 py-1 rounded-full border border-[var(--accent)] text-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] animate-pulse">
|
||||||
|
{activeCount} agent{activeCount > 1 ? 's' : ''} deployed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="live-badge">● LIVE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{workload && (
|
||||||
|
<div className="absolute top-4 right-4 z-20 hidden lg:block">
|
||||||
|
<GpuBeacon
|
||||||
|
model={workload.gpu.model}
|
||||||
|
util={workload.gpu.avg_util}
|
||||||
|
count={workload.gpu.gpu_count}
|
||||||
|
level={workload.gpu.level}
|
||||||
|
active={workload.gpu.inference_active}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative h-52 mb-2 z-10">
|
||||||
|
<DataFlowLayer zones={zones} activeZoneId={mappedBusyZone} />
|
||||||
|
{zones.map((z) => (
|
||||||
|
<ZoneTower
|
||||||
|
key={z.id}
|
||||||
|
zone={z}
|
||||||
|
active={mappedBusyZone === z.id}
|
||||||
|
agentBusy={mappedBusyZone === z.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="absolute bottom-0 left-1/2 -translate-x-1/2 command-desk"
|
||||||
|
animate={{ boxShadow: ['0 0 24px var(--glow)', '0 0 40px var(--glow)', '0 0 24px var(--glow)'] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 3 }}
|
||||||
|
>
|
||||||
|
<span className="command-desk-ring" />
|
||||||
|
COMMAND DESK
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{workload && zones.length > 0 && (
|
||||||
|
<div className="relative z-10 mb-3">
|
||||||
|
<WorkloadTicker zones={zones} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative h-44 rounded-2xl border ops-floor-stage z-10 cluster-agent-stage">
|
||||||
|
<div className="cluster-stage-grid" />
|
||||||
|
{agents.map((agent, i) => {
|
||||||
|
const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const }
|
||||||
|
const targetX = anim.state === 'idle' ? 10 + i * 18 : ZONE_X[anim.zone || agent.zone] ?? DESK_X
|
||||||
|
const y = anim.state === 'fetch' ? 10 : anim.state === 'idle' ? 0 : 6
|
||||||
|
const selected = selectedId === agent.id
|
||||||
|
const busy = anim.state !== 'idle'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={agent.id}
|
||||||
|
className="absolute bottom-3 -translate-x-1/2"
|
||||||
|
animate={{ left: `${targetX}%`, y, scale: selected ? 1.08 : 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 90, damping: 15 }}
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{busy && STATE_LABEL[anim.state] && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute -top-6 left-1/2 -translate-x-1/2 text-[9px] font-mono font-bold whitespace-nowrap px-2 py-0.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
color: agent.color,
|
||||||
|
background: `color-mix(in srgb, ${agent.color} 15%, var(--surface-strong))`,
|
||||||
|
border: `1px solid ${agent.color}44`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATE_LABEL[anim.state]}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`sprite-wrap ${selected ? 'selected' : ''} ${busy ? 'busy' : ''}`}
|
||||||
|
style={{ '--sprite-color': agent.color } as CSSProperties}
|
||||||
|
>
|
||||||
|
<AgentSprite
|
||||||
|
agentId={agent.id}
|
||||||
|
color={agent.color}
|
||||||
|
state={anim.state}
|
||||||
|
label={agent.name.split(' ')[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { appIcon, LEVEL_COLOR } from '../lib/appIcons'
|
||||||
|
import type { WorkloadData } from '../types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
workload: WorkloadData | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveDomainGrid({ workload }: Props) {
|
||||||
|
if (!workload) {
|
||||||
|
return <div className="live-domain-grid animate-pulse h-48 rounded-xl bg-[var(--surface-elevated)]" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
{workload.zones.map((zone, zi) => {
|
||||||
|
const pct = zone.total ? Math.round((zone.running / zone.total) * 100) : 100
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={zone.id}
|
||||||
|
className={`live-domain-card level-${zone.level}`}
|
||||||
|
style={{ borderColor: LEVEL_COLOR[zone.level] || zone.color }}
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: zi * 0.05 }}
|
||||||
|
whileHover={{ y: -3, boxShadow: `0 8px 32px ${zone.color}22` }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start gap-2 mb-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-widest" style={{ color: zone.color }}>
|
||||||
|
{zone.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold mt-0.5" style={{ color: LEVEL_COLOR[zone.level] }}>
|
||||||
|
{zone.running}/{zone.total || zone.apps.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<motion.span
|
||||||
|
className={`domain-pulse-dot level-${zone.level}`}
|
||||||
|
animate={{ scale: [1, 1.3, 1], opacity: [0.7, 1, 0.7] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 2 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="domain-progress-bar">
|
||||||
|
<motion.div
|
||||||
|
className="domain-progress-fill"
|
||||||
|
style={{ background: `linear-gradient(90deg, ${zone.color}, ${zone.color}88)` }}
|
||||||
|
animate={{ width: `${pct}%` }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="domain-app-grid mt-3">
|
||||||
|
{zone.apps.slice(0, 6).map((app, ai) => (
|
||||||
|
<motion.div
|
||||||
|
key={app.name}
|
||||||
|
className={`domain-app-tile state-${app.state}`}
|
||||||
|
title={app.name}
|
||||||
|
animate={app.state === 'running' ? { opacity: [0.7, 1, 0.7] } : { opacity: 0.4 }}
|
||||||
|
transition={{ repeat: Infinity, duration: 2.5, delay: ai * 0.1 }}
|
||||||
|
>
|
||||||
|
<span>{appIcon(app.name, app.image)}</span>
|
||||||
|
<span className="truncate">{app.name.split('_')[0]}</span>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className={`live-domain-card level-${workload.gpu.level}`}
|
||||||
|
style={{ borderColor: 'var(--accent-gpu)' }}
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-widest text-[var(--accent-gpu)]">GPU LAB</div>
|
||||||
|
<div className="text-lg font-bold mt-0.5 text-[var(--accent-gpu)]">
|
||||||
|
{workload.gpu.gpu_count ?? 0}× V100
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono text-[var(--text-muted)] mt-1 truncate">
|
||||||
|
{workload.gpu.model || 'offline'}
|
||||||
|
</div>
|
||||||
|
<div className="domain-progress-bar mt-3">
|
||||||
|
<motion.div
|
||||||
|
className="domain-progress-fill"
|
||||||
|
style={{ background: 'linear-gradient(90deg, var(--accent-gpu), var(--accent))' }}
|
||||||
|
animate={{ width: `${Math.max(workload.gpu.avg_util ?? 0, 2)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 mt-3 flex-wrap">
|
||||||
|
{(workload.gpu.gpus || []).map((g) => (
|
||||||
|
<motion.div
|
||||||
|
key={g.index}
|
||||||
|
className="gpu-mini-tile"
|
||||||
|
animate={{ opacity: [0.6, 1, 0.6] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 1.5 + g.index * 0.2 }}
|
||||||
|
title={`GPU${g.index} ${g.util_gpu}%`}
|
||||||
|
>
|
||||||
|
G{g.index}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { AgentSprite } from './AgentSprite'
|
||||||
|
import type { Agent, AgentAnim, Zone } from '../types'
|
||||||
|
|
||||||
|
const ZONE_X: Record<string, number> = {
|
||||||
|
docker: 8,
|
||||||
|
db: 28,
|
||||||
|
lakehouse: 50,
|
||||||
|
hadoop: 72,
|
||||||
|
etl: 92,
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_LABEL: Record<AgentAnim['state'], string> = {
|
||||||
|
idle: '',
|
||||||
|
walk: '→ zone',
|
||||||
|
fetch: '⟳ fetch',
|
||||||
|
return: '← desk',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DESK_X = 50
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[]
|
||||||
|
zones: Zone[]
|
||||||
|
animations: Record<string, AgentAnim>
|
||||||
|
selectedId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpsFloor({ agents, zones, animations, selectedId }: Props) {
|
||||||
|
const activeCount = agents.filter((a) => (animations[a.id]?.state || 'idle') !== 'idle').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel rounded-2xl p-5 relative overflow-hidden min-h-[340px] ops-floor">
|
||||||
|
<div className="ops-floor-scan" />
|
||||||
|
<div className="flex justify-between items-center mb-5 relative z-10">
|
||||||
|
<div>
|
||||||
|
<p className="section-eyebrow">Live simulation</p>
|
||||||
|
<h2 className="font-display text-lg font-bold tracking-wide neon-text" style={{ color: 'var(--accent)' }}>
|
||||||
|
Ops Floor
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="text-[10px] font-mono px-2 py-1 rounded-full border border-[var(--accent)] text-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_10%,transparent)]">
|
||||||
|
{activeCount} agent{activeCount > 1 ? 's' : ''} deployed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="live-badge">● LIVE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-32 mb-4 z-10">
|
||||||
|
{zones.map((z) => (
|
||||||
|
<div key={z.id} className="absolute top-0 -translate-x-1/2 text-center" style={{ left: `${z.x}%` }}>
|
||||||
|
<motion.div
|
||||||
|
className="zone-node rounded-xl px-3 py-2.5 min-w-[88px] text-[9px] font-mono tracking-wider font-semibold"
|
||||||
|
style={{
|
||||||
|
border: `2px solid ${z.color}`,
|
||||||
|
boxShadow: `0 0 20px ${z.color}33`,
|
||||||
|
color: z.color,
|
||||||
|
}}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
>
|
||||||
|
{z.label}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-1/2 -translate-x-1/2 zone-node rounded-xl px-4 py-2 text-[9px] font-mono font-bold tracking-widest"
|
||||||
|
style={{ borderColor: 'var(--accent)', color: 'var(--accent)', boxShadow: '0 0 24px var(--glow)' }}
|
||||||
|
>
|
||||||
|
COMMAND DESK
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg className="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="none">
|
||||||
|
{zones.map((z) => (
|
||||||
|
<motion.line
|
||||||
|
key={`path-${z.id}`}
|
||||||
|
x1={`${DESK_X}%`} y1="88%" x2={`${z.x}%`} y2="30%"
|
||||||
|
stroke={z.color} strokeWidth="1.5" strokeDasharray="5 4" opacity="0.35"
|
||||||
|
animate={{ strokeDashoffset: [0, -18] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 2, ease: 'linear' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-40 rounded-2xl border ops-floor-stage z-10">
|
||||||
|
{agents.map((agent, i) => {
|
||||||
|
const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const }
|
||||||
|
const targetX = anim.state === 'idle' ? 10 + i * 18 : ZONE_X[anim.zone || agent.zone] ?? DESK_X
|
||||||
|
const y = anim.state === 'fetch' ? 10 : anim.state === 'idle' ? 0 : 6
|
||||||
|
const selected = selectedId === agent.id
|
||||||
|
const busy = anim.state !== 'idle'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={agent.id}
|
||||||
|
className="absolute bottom-3 -translate-x-1/2"
|
||||||
|
animate={{ left: `${targetX}%`, y, scale: selected ? 1.08 : 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 90, damping: 15 }}
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{busy && STATE_LABEL[anim.state] && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute -top-6 left-1/2 -translate-x-1/2 text-[9px] font-mono font-bold whitespace-nowrap px-2 py-0.5 rounded-full"
|
||||||
|
style={{ color: agent.color, background: `color-mix(in srgb, ${agent.color} 15%, var(--surface-strong))`, border: `1px solid ${agent.color}44` }}
|
||||||
|
>
|
||||||
|
{STATE_LABEL[anim.state]}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div className={`sprite-wrap ${selected ? 'selected' : ''} ${busy ? 'busy' : ''}`} style={{ '--sprite-color': agent.color } as CSSProperties}>
|
||||||
|
<AgentSprite
|
||||||
|
agentId={agent.id}
|
||||||
|
color={agent.color}
|
||||||
|
icon={agent.icon}
|
||||||
|
state={anim.state}
|
||||||
|
label={agent.name.split(' ')[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { FormEvent, useState } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSubmit: (message: string) => void
|
||||||
|
busy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromptBar({ onSubmit, busy }: Props) {
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
|
||||||
|
const handle = (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!text.trim() || busy) return
|
||||||
|
onSubmit(text.trim())
|
||||||
|
setText('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handle} className="panel rounded-2xl p-4 flex gap-3 items-center">
|
||||||
|
<span className="text-2xl shrink-0" aria-hidden>💬</span>
|
||||||
|
<input
|
||||||
|
className="prompt-input"
|
||||||
|
placeholder="Vraag je agents... bijv. Hoe staat de GPU? Welk model draait er?"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={busy || !text.trim()} className="btn-primary shrink-0">
|
||||||
|
{busy ? 'Bezig...' : 'Send'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useTheme } from '../context/ThemeContext'
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, toggle } = useTheme()
|
||||||
|
const isDark = theme === 'dark'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
className="theme-toggle"
|
||||||
|
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
title={isDark ? 'Light mode' : 'Dark mode'}
|
||||||
|
>
|
||||||
|
<span className={`theme-toggle-track ${isDark ? 'is-dark' : ''}`}>
|
||||||
|
<span className="theme-toggle-thumb">{isDark ? '🌙' : '☀️'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-mono hidden sm:inline text-[var(--text-muted)]">
|
||||||
|
{isDark ? 'Dark' : 'Light'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
|
type ThemeContextValue = {
|
||||||
|
theme: Theme
|
||||||
|
toggle: () => void
|
||||||
|
setTheme: (t: Theme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||||
|
const STORAGE_KEY = 'atc-command-center-theme'
|
||||||
|
|
||||||
|
function readStored(): Theme {
|
||||||
|
const v = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return v === 'dark' || v === 'light' ? v : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(() => {
|
||||||
|
if (typeof window === 'undefined') return 'light'
|
||||||
|
return readStored()
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
root.classList.remove('light', 'dark')
|
||||||
|
root.classList.add(theme)
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const setTheme = (t: Theme) => setThemeState(t)
|
||||||
|
const toggle = () => setThemeState((t) => (t === 'light' ? 'dark' : 'light'))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggle, setTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const ctx = useContext(ThemeContext)
|
||||||
|
if (!ctx) throw new Error('useTheme outside ThemeProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
+629
@@ -0,0 +1,629 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root,
|
||||||
|
.light {
|
||||||
|
--bg-1: #e8f4fc;
|
||||||
|
--bg-2: #f4f7fb;
|
||||||
|
--bg-3: #f5f0ff;
|
||||||
|
--grid-color: rgba(8, 145, 178, 0.06);
|
||||||
|
--surface: rgba(255, 255, 255, 0.72);
|
||||||
|
--surface-strong: rgba(255, 255, 255, 0.94);
|
||||||
|
--surface-elevated: #f1f5f9;
|
||||||
|
--surface-muted: #e2e8f0;
|
||||||
|
--text: #0f172a;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--text-faint: #94a3b8;
|
||||||
|
--border: rgba(15, 23, 42, 0.09);
|
||||||
|
--accent: #0891b2;
|
||||||
|
--accent-gpu: #65a30d;
|
||||||
|
--accent-secondary: #7c3aed;
|
||||||
|
--glow: rgba(8, 145, 178, 0.22);
|
||||||
|
--status-ok: #16a34a;
|
||||||
|
--status-ok-bg: rgba(22, 163, 74, 0.1);
|
||||||
|
--status-warn: #d97706;
|
||||||
|
--status-warn-bg: rgba(217, 119, 6, 0.1);
|
||||||
|
--status-down: #dc2626;
|
||||||
|
--status-down-bg: rgba(220, 38, 38, 0.08);
|
||||||
|
--shadow: 0 12px 40px rgba(15, 40, 80, 0.09);
|
||||||
|
--floor-bg: linear-gradient(180deg, #f1f5f9 0%, #ffffff 100%);
|
||||||
|
--aurora-1: rgba(8, 145, 178, 0.12);
|
||||||
|
--aurora-2: rgba(124, 58, 237, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--bg-1: #050810;
|
||||||
|
--bg-2: #0a0f1a;
|
||||||
|
--bg-3: #100818;
|
||||||
|
--grid-color: rgba(34, 211, 238, 0.05);
|
||||||
|
--surface: rgba(12, 18, 32, 0.78);
|
||||||
|
--surface-strong: rgba(16, 22, 38, 0.94);
|
||||||
|
--surface-elevated: #1a2236;
|
||||||
|
--surface-muted: #243049;
|
||||||
|
--text: #eef2f9;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--text-faint: #64748b;
|
||||||
|
--border: rgba(34, 211, 238, 0.14);
|
||||||
|
--accent: #22d3ee;
|
||||||
|
--accent-gpu: #a3e635;
|
||||||
|
--accent-secondary: #c084fc;
|
||||||
|
--glow: rgba(34, 211, 238, 0.28);
|
||||||
|
--status-ok: #4ade80;
|
||||||
|
--status-ok-bg: rgba(74, 222, 128, 0.12);
|
||||||
|
--status-warn: #fbbf24;
|
||||||
|
--status-warn-bg: rgba(251, 191, 36, 0.12);
|
||||||
|
--status-down: #f87171;
|
||||||
|
--status-down-bg: rgba(248, 113, 113, 0.12);
|
||||||
|
--shadow: 0 12px 48px rgba(0, 0, 0, 0.5);
|
||||||
|
--floor-bg: linear-gradient(180deg, #121a2e 0%, #0a0e18 100%);
|
||||||
|
--aurora-1: rgba(34, 211, 238, 0.15);
|
||||||
|
--aurora-2: rgba(192, 132, 252, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--text);
|
||||||
|
background: linear-gradient(145deg, var(--bg-1) 0%, var(--bg-2) 45%, var(--bg-3) 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
transition: background 0.4s ease, color 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 80% 50% at 20% 0%, var(--aurora-1), transparent 50%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 80% 10%, var(--aurora-2), transparent 45%),
|
||||||
|
linear-gradient(var(--grid-color) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
|
||||||
|
background-size: 100% 100%, 100% 100%, 40px 40px, 40px 40px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root { position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
.section-eyebrow {
|
||||||
|
@apply text-[10px] font-mono uppercase tracking-[0.2em] text-[var(--text-faint)] mb-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--surface-strong);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: background 0.35s ease, border-color 0.35s ease, box-shadow 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-text { text-shadow: 0 0 32px var(--glow); }
|
||||||
|
|
||||||
|
.logo-mark {
|
||||||
|
@apply w-12 h-12 rounded-2xl flex items-center justify-center text-sm font-bold text-white shrink-0;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
||||||
|
box-shadow: 0 4px 28px var(--glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-aurora {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--aurora-1), transparent);
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
@apply text-xs font-mono px-3 py-1.5 rounded-full border inline-flex items-center gap-1.5;
|
||||||
|
}
|
||||||
|
.status-pill::before { content: '●'; font-size: 8px; }
|
||||||
|
.status-pill.ok { color: var(--status-ok); border-color: var(--status-ok); background: var(--status-ok-bg); }
|
||||||
|
.status-pill.warn { color: var(--status-warn); border-color: var(--status-warn); background: var(--status-warn-bg); }
|
||||||
|
.status-pill.gpu { color: var(--accent-gpu); border-color: var(--accent-gpu); background: color-mix(in srgb, var(--accent-gpu) 10%, transparent); }
|
||||||
|
|
||||||
|
.live-badge {
|
||||||
|
@apply text-xs font-mono px-2.5 py-1 rounded-full border animate-pulse;
|
||||||
|
color: var(--status-ok);
|
||||||
|
border-color: var(--status-ok);
|
||||||
|
background: var(--status-ok-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent cards */
|
||||||
|
.agent-roster-glow {
|
||||||
|
background: radial-gradient(ellipse at 50% 0%, var(--aurora-1), transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card {
|
||||||
|
@apply rounded-2xl p-[1px] transition-all duration-300 cursor-pointer;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.agent-card:hover,
|
||||||
|
.agent-card.selected {
|
||||||
|
background: linear-gradient(135deg, var(--agent-color, var(--accent)), var(--accent-secondary));
|
||||||
|
box-shadow: 0 8px 32px color-mix(in srgb, var(--agent-color, var(--accent)) 25%, transparent);
|
||||||
|
}
|
||||||
|
.agent-card-inner {
|
||||||
|
background: var(--surface-strong);
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
.agent-card.selected .agent-card-inner {
|
||||||
|
background: color-mix(in srgb, var(--agent-color, var(--accent)) 4%, var(--surface-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-avatar {
|
||||||
|
@apply relative w-11 h-11 rounded-xl flex items-center justify-center border-2;
|
||||||
|
}
|
||||||
|
.agent-status-dot {
|
||||||
|
@apply absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2;
|
||||||
|
border-color: var(--surface-strong);
|
||||||
|
}
|
||||||
|
.agent-status-dot.active { animation: pulse-dot 1.2s ease infinite; }
|
||||||
|
|
||||||
|
.agent-state-pill {
|
||||||
|
@apply text-[10px] font-mono px-2 py-0.5 rounded-full bg-[var(--surface-elevated)];
|
||||||
|
}
|
||||||
|
.agent-state-pill.busy { background: color-mix(in srgb, currentColor 12%, transparent); }
|
||||||
|
|
||||||
|
.cap-chip {
|
||||||
|
@apply text-[9px] font-mono px-1.5 py-0.5 rounded-md;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delegate-btn {
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.delegate-btn:hover {
|
||||||
|
background: color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ops floor */
|
||||||
|
.ops-floor-stage {
|
||||||
|
background: var(--floor-bg);
|
||||||
|
border-color: var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ops-floor-scan {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--accent) 4%, transparent) 50%, transparent 100%);
|
||||||
|
animation: scan 4s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.zone-node {
|
||||||
|
background: var(--surface-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprite-wrap.selected::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -10px -6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px dashed var(--sprite-color, var(--accent));
|
||||||
|
opacity: 0.65;
|
||||||
|
animation: spin-slow 10s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.sprite-wrap { position: relative; }
|
||||||
|
.sprite-wrap.busy {
|
||||||
|
filter: drop-shadow(0 0 16px var(--sprite-color)) drop-shadow(0 4px 8px rgba(0,0,0,0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprite-figure {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.sprite-figure-busy .sprite-svg {
|
||||||
|
filter: drop-shadow(0 0 8px var(--sprite-color, var(--accent)));
|
||||||
|
}
|
||||||
|
.sprite-ring-outer {
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px 2px 16px 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.sprite-holo-shimmer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 40%;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: holo-shimmer 3s ease-in-out infinite;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.sprite-nameplate {
|
||||||
|
@apply flex items-center gap-1.5 mt-1.5 px-2.5 py-0.5 rounded-full border;
|
||||||
|
background: color-mix(in srgb, var(--surface-strong) 85%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.sprite-nameplate-dot {
|
||||||
|
@apply w-1.5 h-1.5 rounded-full shrink-0;
|
||||||
|
}
|
||||||
|
.sprite-nameplate-text {
|
||||||
|
@apply text-[10px] font-mono font-bold tracking-wide;
|
||||||
|
}
|
||||||
|
.sprite-platform-pulse {
|
||||||
|
animation: platform-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.sprite-core-pulse {
|
||||||
|
animation: pulse-dot 1.5s ease infinite;
|
||||||
|
}
|
||||||
|
.sprite-svg { overflow: visible; }
|
||||||
|
|
||||||
|
.sprite-ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: -6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid;
|
||||||
|
animation: pulse-ring 1.5s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Activity feed */
|
||||||
|
.activity-feed { scrollbar-width: thin; }
|
||||||
|
.activity-item { @apply flex gap-3; }
|
||||||
|
.activity-rail { @apply flex flex-col items-center w-4 shrink-0; }
|
||||||
|
.activity-dot { @apply w-2.5 h-2.5 rounded-full shrink-0 mt-1.5; }
|
||||||
|
.activity-line { @apply w-px flex-1 bg-[var(--border)] min-h-[1rem]; }
|
||||||
|
.activity-agent-badge {
|
||||||
|
@apply text-[10px] font-mono px-2 py-0.5 rounded-full border;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
@apply text-sm text-[var(--text-muted)] text-center py-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat */
|
||||||
|
.chat-message { @apply flex gap-2 items-end; }
|
||||||
|
.chat-message.user { @apply flex-row-reverse; }
|
||||||
|
.chat-avatar {
|
||||||
|
@apply w-8 h-8 rounded-xl flex items-center justify-center text-sm shrink-0 border;
|
||||||
|
}
|
||||||
|
.chat-bubble { @apply rounded-2xl px-3 py-2 max-w-[90%]; }
|
||||||
|
.chat-meta {
|
||||||
|
@apply text-[10px] font-mono text-[var(--text-faint)] flex gap-2 mb-1;
|
||||||
|
}
|
||||||
|
.chat-text { @apply text-sm leading-relaxed; }
|
||||||
|
.chat-bubble-user {
|
||||||
|
background: color-mix(in srgb, var(--accent) 14%, var(--surface-elevated));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
|
||||||
|
}
|
||||||
|
.chat-bubble-agent {
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-pulse { animation: pulse-dot 1s ease infinite; }
|
||||||
|
.typing-indicator { @apply flex gap-1 py-1; }
|
||||||
|
.typing-indicator span {
|
||||||
|
@apply w-1.5 h-1.5 rounded-full bg-[var(--text-faint)];
|
||||||
|
animation: typing 1.2s ease infinite;
|
||||||
|
}
|
||||||
|
.typing-indicator span:nth-child(2) { animation-delay: 0.15s; }
|
||||||
|
.typing-indicator span:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
|
||||||
|
/* Command dock */
|
||||||
|
.command-dock { border-color: color-mix(in srgb, var(--accent) 20%, var(--border)); }
|
||||||
|
.command-input-wrap {
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.command-input-wrap:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-prompt-chip {
|
||||||
|
@apply text-xs font-mono px-3 py-1.5 rounded-full border transition-all disabled:opacity-40;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.quick-prompt-chip:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 8%, var(--surface-elevated));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared */
|
||||||
|
.status-card {
|
||||||
|
background: var(--surface-strong);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.status-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
|
||||||
|
|
||||||
|
.gpu-card { background: var(--surface-elevated); transition: border-color 0.2s ease; }
|
||||||
|
.gpu-card:hover { border-color: var(--accent-gpu); }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply px-5 py-2.5 rounded-xl font-display text-sm font-semibold text-white transition-all hover:brightness-110 disabled:opacity-40;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
||||||
|
box-shadow: 0 4px 24px var(--glow);
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply px-3 py-1.5 rounded-lg font-mono border transition-all;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { border-color: var(--accent); box-shadow: 0 0 16px var(--glow); }
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
@apply px-4 py-2 rounded-xl text-sm font-mono border transition-all inline-flex items-center gap-2;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.tab-btn.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--surface-strong);
|
||||||
|
box-shadow: 0 0 24px var(--glow);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tab-badge {
|
||||||
|
@apply text-[10px] px-1.5 py-0.5 rounded-full font-bold;
|
||||||
|
background: var(--accent-secondary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
@apply flex items-center gap-2 px-2 py-1 rounded-xl border transition-all;
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
.theme-toggle:hover { border-color: var(--accent); }
|
||||||
|
.theme-toggle-track { @apply relative w-11 h-6 rounded-full transition-colors; background: var(--surface-muted); }
|
||||||
|
.theme-toggle-track.is-dark { background: linear-gradient(90deg, #1e293b, #312e81); }
|
||||||
|
.theme-toggle-thumb {
|
||||||
|
@apply absolute top-0.5 left-0.5 w-5 h-5 rounded-full flex items-center justify-center text-xs transition-transform;
|
||||||
|
background: var(--surface-strong);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
.theme-toggle-track.is-dark .theme-toggle-thumb { transform: translateX(1.25rem); }
|
||||||
|
|
||||||
|
.prompt-input {
|
||||||
|
@apply flex-1 rounded-xl px-4 py-2.5 outline-none font-mono text-sm transition;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.prompt-input::placeholder { color: var(--text-faint); }
|
||||||
|
.prompt-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--glow); }
|
||||||
|
|
||||||
|
.agent-sprite .sprite-body { fill: var(--surface-strong); }
|
||||||
|
.agent-sprite .sprite-limb { fill: var(--surface-elevated); }
|
||||||
|
.agent-sprite .sprite-head { fill: var(--surface-strong); }
|
||||||
|
|
||||||
|
/* Agent terminals */
|
||||||
|
.agent-terminal {
|
||||||
|
@apply rounded-xl border overflow-hidden flex flex-col cursor-pointer transition-all;
|
||||||
|
border-color: var(--border);
|
||||||
|
background: #0a0e14;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 240px;
|
||||||
|
}
|
||||||
|
.dark .agent-terminal { background: #060a10; }
|
||||||
|
.light .agent-terminal { background: #0f172a; }
|
||||||
|
|
||||||
|
.agent-terminal.active {
|
||||||
|
border-color: color-mix(in srgb, var(--term-accent, var(--accent)) 55%, transparent);
|
||||||
|
box-shadow: 0 0 24px color-mix(in srgb, var(--term-accent, var(--accent)) 15%, transparent);
|
||||||
|
}
|
||||||
|
.agent-terminal.expanded {
|
||||||
|
max-height: 480px;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
.agent-terminal-header {
|
||||||
|
@apply flex items-center justify-between gap-2 px-3 py-2 border-b;
|
||||||
|
border-color: rgba(255,255,255,0.06);
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
.agent-terminal-body {
|
||||||
|
@apply flex-1 overflow-y-auto p-2 font-mono text-[11px] leading-relaxed;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.term-line {
|
||||||
|
@apply flex gap-2 py-0.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.term-ts {
|
||||||
|
@apply shrink-0 text-[10px] opacity-50 w-[4.5rem];
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.term-phase {
|
||||||
|
@apply shrink-0 text-[9px] uppercase w-10 opacity-40 hidden sm:inline;
|
||||||
|
}
|
||||||
|
.term-text { flex: 1; }
|
||||||
|
.term-info .term-text { color: #94a3b8; }
|
||||||
|
.term-ok .term-text { color: #4ade80; }
|
||||||
|
.term-warn .term-text { color: #fbbf24; }
|
||||||
|
.term-err .term-text { color: #f87171; }
|
||||||
|
.term-cmd .term-text { color: #67e8f9; }
|
||||||
|
.term-llm .term-text { color: #c084fc; }
|
||||||
|
.term-live-badge {
|
||||||
|
@apply text-[9px] font-mono px-1.5 py-0.5 rounded-full animate-pulse;
|
||||||
|
color: var(--term-accent, var(--accent));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--term-accent, var(--accent)) 40%, transparent);
|
||||||
|
}
|
||||||
|
.term-cursor { animation: blink 1s step-end infinite; color: var(--term-accent, var(--accent)); }
|
||||||
|
.agent-terminal-focus { @apply flex flex-col gap-3; }
|
||||||
|
.agent-terminal-tabs { @apply flex flex-wrap gap-2; }
|
||||||
|
.agent-terminal-tab {
|
||||||
|
@apply text-xs font-mono px-3 py-1.5 rounded-lg border transition-all;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
.agent-terminal-tab.active { font-weight: 600; background: var(--surface-strong); }
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.6; transform: scale(1.15); }
|
||||||
|
}
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0%, 100% { opacity: 0.8; transform: scale(1); }
|
||||||
|
50% { opacity: 0.4; transform: scale(1.08); }
|
||||||
|
}
|
||||||
|
@keyframes scan {
|
||||||
|
0%, 100% { transform: translateY(-100%); opacity: 0; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { transform: translateY(100%); }
|
||||||
|
}
|
||||||
|
@keyframes spin-slow { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes holo-shimmer {
|
||||||
|
0%, 100% { opacity: 0.4; transform: translateX(-2px); }
|
||||||
|
50% { opacity: 0.85; transform: translateX(2px); }
|
||||||
|
}
|
||||||
|
@keyframes platform-pulse {
|
||||||
|
0%, 100% { opacity: 0.35; transform: scaleX(1); }
|
||||||
|
50% { opacity: 0.7; transform: scaleX(1.08); }
|
||||||
|
}
|
||||||
|
@keyframes typing {
|
||||||
|
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||||
|
30% { transform: translateY(-4px); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live cluster map */
|
||||||
|
.live-cluster-map { isolation: isolate; }
|
||||||
|
.cluster-ambient {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(ellipse 70% 50% at 50% 30%, var(--aurora-1), transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.cluster-stage-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: linear-gradient(var(--grid-color) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
opacity: 0.5;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
.cluster-agent-stage { overflow: hidden; }
|
||||||
|
|
||||||
|
.zone-tower {
|
||||||
|
@apply rounded-xl px-2.5 py-2 text-center min-w-[84px] border-2;
|
||||||
|
background: color-mix(in srgb, var(--surface-strong) 90%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.zone-tower-live { animation: tower-breathe 3s ease-in-out infinite; }
|
||||||
|
.zone-tower-active { transform: scale(1.05); }
|
||||||
|
.zone-tower-label { @apply text-[8px] font-mono font-bold tracking-wider; }
|
||||||
|
.zone-tower-stats { @apply text-[10px] font-mono font-semibold mt-0.5 flex items-center justify-center gap-1; }
|
||||||
|
.zone-tower-extra { @apply text-[8px] font-mono opacity-70 mt-0.5; }
|
||||||
|
.zone-level-dot { @apply w-1.5 h-1.5 rounded-full; }
|
||||||
|
.zone-level-dot.level-ok { background: var(--status-ok); box-shadow: 0 0 6px var(--status-ok); }
|
||||||
|
.zone-level-dot.level-warn { background: var(--status-warn); }
|
||||||
|
.zone-level-dot.level-down { background: var(--status-down); }
|
||||||
|
|
||||||
|
.zone-app-orbit {
|
||||||
|
@apply flex flex-col gap-0.5 mt-1 max-w-[90px];
|
||||||
|
}
|
||||||
|
.zone-app-chip {
|
||||||
|
@apply flex items-center gap-1 text-[8px] font-mono px-1.5 py-0.5 rounded-md border;
|
||||||
|
background: var(--surface-strong);
|
||||||
|
}
|
||||||
|
.zone-app-chip.state-running { opacity: 1; }
|
||||||
|
.zone-app-chip.state-down, .zone-app-chip.state-created { opacity: 0.45; filter: grayscale(0.5); }
|
||||||
|
|
||||||
|
.command-desk {
|
||||||
|
@apply relative px-4 py-2 rounded-xl text-[9px] font-mono font-bold tracking-widest border-2;
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--surface-strong);
|
||||||
|
}
|
||||||
|
.command-desk-ring {
|
||||||
|
@apply absolute inset-0 rounded-xl border border-[var(--accent)] opacity-30 animate-ping;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-beacon {
|
||||||
|
@apply rounded-xl border px-3 py-2 text-left min-w-[140px];
|
||||||
|
border-color: var(--accent-gpu);
|
||||||
|
background: color-mix(in srgb, var(--accent-gpu) 8%, var(--surface-strong));
|
||||||
|
}
|
||||||
|
.gpu-beacon-title { @apply text-[9px] font-mono font-bold text-[var(--accent-gpu)]; }
|
||||||
|
.gpu-beacon-model { @apply text-xs font-semibold text-[var(--text)] mt-0.5; }
|
||||||
|
.gpu-beacon-meta { @apply text-[9px] font-mono text-[var(--text-muted)]; }
|
||||||
|
.gpu-beacon-bar { @apply h-1 rounded-full bg-[var(--surface-muted)] mt-2 overflow-hidden; }
|
||||||
|
.gpu-beacon-fill { @apply h-full rounded-full bg-[var(--accent-gpu)]; }
|
||||||
|
|
||||||
|
.workload-ticker-wrap {
|
||||||
|
@apply flex items-center gap-3 overflow-hidden rounded-xl border px-3 py-2;
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
.workload-ticker-label {
|
||||||
|
@apply text-[9px] font-mono font-bold tracking-widest shrink-0 text-[var(--accent)];
|
||||||
|
}
|
||||||
|
.workload-ticker-track { @apply flex-1 overflow-hidden; }
|
||||||
|
.workload-ticker-inner { @apply flex gap-2 whitespace-nowrap; }
|
||||||
|
.ticker-chip {
|
||||||
|
@apply inline-flex items-center gap-1 text-[10px] font-mono px-2 py-0.5 rounded-full border;
|
||||||
|
background: var(--surface-strong);
|
||||||
|
}
|
||||||
|
.ticker-chip.state-running { opacity: 1; }
|
||||||
|
.ticker-chip.state-down, .ticker-chip.state-created { opacity: 0.4; }
|
||||||
|
|
||||||
|
.live-domain-card {
|
||||||
|
@apply rounded-xl p-4 border-2 transition-all;
|
||||||
|
background: var(--surface-strong);
|
||||||
|
}
|
||||||
|
.domain-pulse-dot { @apply w-2.5 h-2.5 rounded-full shrink-0; }
|
||||||
|
.domain-pulse-dot.level-ok { background: var(--status-ok); }
|
||||||
|
.domain-pulse-dot.level-warn { background: var(--status-warn); }
|
||||||
|
.domain-pulse-dot.level-down { background: var(--status-down); }
|
||||||
|
.domain-progress-bar { @apply h-1.5 rounded-full bg-[var(--surface-muted)] overflow-hidden; }
|
||||||
|
.domain-progress-fill { @apply h-full rounded-full; }
|
||||||
|
.domain-app-grid {
|
||||||
|
@apply grid grid-cols-3 gap-1;
|
||||||
|
}
|
||||||
|
.domain-app-tile {
|
||||||
|
@apply flex flex-col items-center text-[8px] font-mono p-1 rounded-md border border-[var(--border)];
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
.domain-app-tile.state-down { opacity: 0.35; }
|
||||||
|
.gpu-mini-tile {
|
||||||
|
@apply text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--accent-gpu)] text-[var(--accent-gpu)];
|
||||||
|
background: color-mix(in srgb, var(--accent-gpu) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-bg { pointer-events: none; }
|
||||||
|
.ambient-orb {
|
||||||
|
position: absolute;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, var(--aurora-1), transparent 70%);
|
||||||
|
animation: orb-float linear infinite;
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tower-breathe {
|
||||||
|
0%, 100% { filter: brightness(1); }
|
||||||
|
50% { filter: brightness(1.15); }
|
||||||
|
}
|
||||||
|
@keyframes orb-float {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.2; }
|
||||||
|
33% { transform: translate(20px, -30px) scale(1.1); opacity: 0.4; }
|
||||||
|
66% { transform: translate(-15px, 20px) scale(0.9); opacity: 0.25; }
|
||||||
|
}
|
||||||
|
@keyframes ticker-scroll {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const ICON_MAP: [RegExp, string][] = [
|
||||||
|
[/trino/i, '🔷'],
|
||||||
|
[/spark/i, '⚡'],
|
||||||
|
[/kafka/i, '📨'],
|
||||||
|
[/connect/i, '🔗'],
|
||||||
|
[/postgres/i, '🐘'],
|
||||||
|
[/mysql/i, '🐬'],
|
||||||
|
[/mongo/i, '🍃'],
|
||||||
|
[/cassandra/i, '💿'],
|
||||||
|
[/neo4j/i, '🔴'],
|
||||||
|
[/airflow/i, '🌀'],
|
||||||
|
[/superset/i, '📊'],
|
||||||
|
[/forgejo|gitea/i, '🦊'],
|
||||||
|
[/homepage/i, '🏠'],
|
||||||
|
[/dockhand/i, '🐳'],
|
||||||
|
[/redis/i, '⚙️'],
|
||||||
|
[/nginx|caddy/i, '🌐'],
|
||||||
|
[/hdfs|namenode|datanode/i, '🌲'],
|
||||||
|
[/gpu|vllm|nvidia/i, '🎮'],
|
||||||
|
[/lam-|ldap/i, '👤'],
|
||||||
|
[/cadvisor/i, '📈'],
|
||||||
|
]
|
||||||
|
|
||||||
|
export function appIcon(name: string, image?: string): string {
|
||||||
|
const hay = `${name} ${image || ''}`
|
||||||
|
for (const [re, icon] of ICON_MAP) {
|
||||||
|
if (re.test(hay)) return icon
|
||||||
|
}
|
||||||
|
return '📦'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortName(name: string, max = 14): string {
|
||||||
|
return name.length > max ? `${name.slice(0, max - 1)}…` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LEVEL_COLOR: Record<string, string> = {
|
||||||
|
ok: 'var(--status-ok)',
|
||||||
|
warn: 'var(--status-warn)',
|
||||||
|
down: 'var(--status-down)',
|
||||||
|
unknown: 'var(--text-faint)',
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import { ThemeProvider } from './context/ThemeContext'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
export type AgentStats = {
|
||||||
|
tasks: number
|
||||||
|
last_active: string | null
|
||||||
|
alerts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Agent = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
zone: string
|
||||||
|
role: string
|
||||||
|
icon?: string
|
||||||
|
motto?: string
|
||||||
|
capabilities?: string[]
|
||||||
|
suggested_prompts?: string[]
|
||||||
|
stats?: AgentStats
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Zone = { id: string; label: string; x: number; color: string }
|
||||||
|
|
||||||
|
export type FeedEntry = {
|
||||||
|
id: string
|
||||||
|
ts: string
|
||||||
|
agent_id: string
|
||||||
|
message: string
|
||||||
|
level: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DomainStatus = {
|
||||||
|
level: 'ok' | 'warn' | 'down' | 'unknown'
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatusData = {
|
||||||
|
ts: string
|
||||||
|
domains: Record<string, DomainStatus>
|
||||||
|
gpu?: GpuStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GpuDevice = {
|
||||||
|
index: number
|
||||||
|
name: string
|
||||||
|
util_gpu: number
|
||||||
|
memory_used_mib: number
|
||||||
|
memory_total_mib: number
|
||||||
|
temperature_c: number
|
||||||
|
power_w: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GpuStatus = {
|
||||||
|
ok: boolean
|
||||||
|
host: string
|
||||||
|
ui_url: string
|
||||||
|
inference_active?: boolean
|
||||||
|
active_model?: string | null
|
||||||
|
vllm_url?: string | null
|
||||||
|
gpu_count?: number
|
||||||
|
gpus?: GpuDevice[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentState = 'idle' | 'walk' | 'fetch' | 'return'
|
||||||
|
|
||||||
|
export type AgentAnim = {
|
||||||
|
agentId: string
|
||||||
|
state: AgentState
|
||||||
|
zone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Approval = {
|
||||||
|
id: string
|
||||||
|
ts: string
|
||||||
|
agent_id: string
|
||||||
|
action: string
|
||||||
|
reason: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TerminalLine = {
|
||||||
|
id: string
|
||||||
|
ts: string
|
||||||
|
agent_id: string
|
||||||
|
level: 'info' | 'ok' | 'warn' | 'err' | 'cmd' | 'llm'
|
||||||
|
phase: string
|
||||||
|
text: string
|
||||||
|
prompt_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkloadApp = {
|
||||||
|
name: string
|
||||||
|
state: string
|
||||||
|
image: string
|
||||||
|
ports: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkloadZone = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
x: number
|
||||||
|
color: string
|
||||||
|
level: 'ok' | 'warn' | 'down' | 'unknown'
|
||||||
|
running: number
|
||||||
|
total: number
|
||||||
|
apps: WorkloadApp[]
|
||||||
|
trino_ok?: boolean
|
||||||
|
hdfs_used_gb?: number
|
||||||
|
hdfs_total_gb?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkloadData = {
|
||||||
|
ts: string
|
||||||
|
zones: WorkloadZone[]
|
||||||
|
gpu: {
|
||||||
|
level: string
|
||||||
|
model?: string | null
|
||||||
|
inference_active?: boolean
|
||||||
|
gpu_count?: number
|
||||||
|
avg_util?: number
|
||||||
|
gpus?: GpuDevice[]
|
||||||
|
}
|
||||||
|
totals: {
|
||||||
|
apps_running: number
|
||||||
|
apps_total: number
|
||||||
|
connectors: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatMessage = {
|
||||||
|
role: 'user' | 'agent'
|
||||||
|
text: string
|
||||||
|
agent?: string
|
||||||
|
ts?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
display: ['"Space Grotesk"', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['"JetBrains Mono"', 'monospace'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
+4
-4
@@ -1,14 +1,14 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ATC Command Center</title>
|
<title>ATC Data & AI Command Center</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body class="text-ink">
|
<body class="antialiased">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma "no-cache";
|
||||||
|
add_header Expires "0";
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "atc-command-center",
|
"name": "atc-command-center",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "2.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -9,9 +9,13 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"framer-motion": "^11.15.0",
|
"@tanstack/react-query": "^5.62.8",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"tailwind-merge": "^2.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
|
|||||||
+159
-199
@@ -1,218 +1,178 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { OpsFloor } from './components/OpsFloor'
|
import { useClock } from './hooks/useClock'
|
||||||
import { PromptBar } from './components/PromptBar'
|
import { useCommandCenter } from './hooks/useCommandCenter'
|
||||||
import type { Agent, AgentAnim, Approval, FeedEntry, StatusData, Zone } from './types'
|
import { useLiveMetrics } from './hooks/useLiveMetrics'
|
||||||
|
import { SideNav } from './components/layout/SideNav'
|
||||||
const TABS = ['Overview', 'Agents', 'Feed', 'Approvals', 'Audit'] as const
|
import { TopBar } from './components/layout/TopBar'
|
||||||
type Tab = (typeof TABS)[number]
|
import { AgentFleet } from './components/features/AgentFleet'
|
||||||
|
import { ApprovalInbox } from './components/features/ApprovalInbox'
|
||||||
const LEVEL_COLOR = { ok: '#22aa44', warn: '#cc7700', down: '#dd3355', unknown: '#8b9cb3' }
|
import { ChatDrawer } from './components/features/ChatDrawer'
|
||||||
const LEVEL_BG = { ok: '#e8f8ec', warn: '#fff6e6', down: '#ffeef2', unknown: '#f0f3f8' }
|
import { GpuMonitor } from './components/features/GpuMonitor'
|
||||||
|
import { InfraQuickAccess } from './components/features/InfraQuickAccess'
|
||||||
function wsUrl() {
|
import { InspectorPanel } from './components/features/InspectorPanel'
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
import { PlatformTopology } from './components/features/PlatformTopology'
|
||||||
const host = window.location.host
|
import { PresentationView } from './components/features/PresentationView'
|
||||||
return `${proto}://${host}/api/ws/ops`
|
import { DataQualityView } from './components/features/DataQualityView'
|
||||||
}
|
import { KnowledgeChatView } from './components/features/KnowledgeChatView'
|
||||||
|
import { StorageView } from './components/features/StorageView'
|
||||||
|
import { TerminalDock } from './components/features/TerminalDock'
|
||||||
|
import { resolveInfraNode } from './lib/infraCatalog'
|
||||||
|
import { cn } from './lib/utils'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [tab, setTab] = useState<Tab>('Overview')
|
const clock = useClock()
|
||||||
const [agents, setAgents] = useState<Agent[]>([])
|
const cc = useCommandCenter()
|
||||||
const [zones, setZones] = useState<Zone[]>([])
|
const [gpuChatActive, setGpuChatActive] = useState(false)
|
||||||
const [status, setStatus] = useState<StatusData | null>(null)
|
const gpuBoost = gpuChatActive || cc.mainView === 'knowledge'
|
||||||
const [feed, setFeed] = useState<FeedEntry[]>([])
|
const { agentLoads, gpuLive } = useLiveMetrics(cc.agents, cc.gpu, cc.anims, gpuBoost)
|
||||||
const [approvals, setApprovals] = useState<Approval[]>([])
|
const mainScrollRef = useRef<HTMLDivElement>(null)
|
||||||
const [chat, setChat] = useState<{ role: 'user' | 'agent'; text: string; agent?: string }[]>([])
|
|
||||||
const [anims, setAnims] = useState<Record<string, AgentAnim>>({})
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const isPlatform = cc.mainView === 'platform'
|
||||||
const [a, s, f, ap] = await Promise.all([
|
|
||||||
fetch('/api/agents').then((r) => r.json()),
|
|
||||||
fetch('/api/status').then((r) => r.json()),
|
|
||||||
fetch('/api/feed').then((r) => r.json()),
|
|
||||||
fetch('/api/approvals').then((r) => r.json()),
|
|
||||||
])
|
|
||||||
setAgents(a.agents || [])
|
|
||||||
setZones(a.zones || [])
|
|
||||||
setStatus(s)
|
|
||||||
setFeed(f.entries || [])
|
|
||||||
setApprovals(ap.approvals || [])
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
const openApprovals = () => {
|
||||||
load()
|
cc.setMainView('approvals')
|
||||||
const ws = new WebSocket(wsUrl())
|
mainScrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
const msg = JSON.parse(ev.data)
|
|
||||||
if (msg.type === 'status') setStatus(msg.data)
|
|
||||||
if (msg.type === 'feed') setFeed((prev) => [msg.entry, ...prev].slice(0, 100))
|
|
||||||
if (msg.type === 'agent_dispatch') {
|
|
||||||
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'walk', zone: msg.zone } }))
|
|
||||||
}
|
|
||||||
if (msg.type === 'agent_fetch') {
|
|
||||||
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'fetch', zone: msg.zone } }))
|
|
||||||
}
|
|
||||||
if (msg.type === 'agent_return') {
|
|
||||||
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'return', zone: msg.zone } }))
|
|
||||||
setTimeout(() => {
|
|
||||||
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'idle' } }))
|
|
||||||
}, 1200)
|
|
||||||
}
|
|
||||||
if (msg.type === 'prompt_result') {
|
|
||||||
setChat((c) => [...c, { role: 'agent', text: msg.answer, agent: msg.agent_id }])
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const iv = setInterval(load, 30000)
|
|
||||||
return () => { ws.close(); clearInterval(iv) }
|
|
||||||
}, [load])
|
|
||||||
|
|
||||||
const sendPrompt = async (message: string) => {
|
|
||||||
setBusy(true)
|
|
||||||
setChat((c) => [...c, { role: 'user', text: message }])
|
|
||||||
await fetch('/api/prompt', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ message }),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const decide = async (id: string, approved: boolean) => {
|
const terminalSubject = cc.terminalSubjectId
|
||||||
await fetch(`/api/approvals/${id}/decide`, {
|
const terminalLabel = (() => {
|
||||||
method: 'POST',
|
if (cc.selectedAgent) return cc.selectedAgent.name.split(' ·')[0]
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const infra = resolveInfraNode(terminalSubject)
|
||||||
body: JSON.stringify({ approved }),
|
if (infra) return infra.label
|
||||||
})
|
if (cc.selectedNode) return cc.selectedNode.label
|
||||||
load()
|
return 'Lab'
|
||||||
}
|
})()
|
||||||
|
|
||||||
const allOk = status && Object.values(status.domains).every((d) => d.level === 'ok')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-4 md:p-6 max-w-7xl mx-auto font-display flex flex-col gap-4">
|
<div className="flex h-full flex-col overflow-hidden bg-surface">
|
||||||
<header className="glass-strong rounded-2xl px-5 py-4 flex flex-wrap justify-between items-center gap-3">
|
<TopBar
|
||||||
<div className="flex items-center gap-4">
|
clock={clock}
|
||||||
<div
|
status={cc.status}
|
||||||
className="w-11 h-11 rounded-xl flex items-center justify-center text-xl font-bold text-white shadow-neon-cyan"
|
workload={cc.workload}
|
||||||
style={{ background: 'linear-gradient(135deg, #0099cc, #8844cc)' }}
|
agents={cc.agents}
|
||||||
>
|
approvals={cc.approvals}
|
||||||
ATC
|
onApprovalsClick={openApprovals}
|
||||||
</div>
|
/>
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-neon-cyan neon-text-cyan tracking-tight">Command Center</h1>
|
|
||||||
<p className="text-xs font-mono text-ink-muted">Agent ops floor · Dell ATC Lab</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{status && (
|
|
||||||
<span className={`text-xs font-mono px-3 py-1.5 rounded-full border ${allOk ? 'bg-green-50 border-neon-green/30 text-neon-green' : 'bg-amber-50 border-neon-amber/30 text-neon-amber'}`}>
|
|
||||||
{allOk ? '● All systems operational' : '● Attention required'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{status && (
|
|
||||||
<span className="text-xs font-mono text-ink-faint">
|
|
||||||
Scan {new Date(status.ts).toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<OpsFloor agents={agents} zones={zones} animations={anims} />
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
<SideNav
|
||||||
|
agents={cc.agents}
|
||||||
|
animations={cc.anims}
|
||||||
|
gpu={cc.gpu}
|
||||||
|
gpuLive={gpuLive}
|
||||||
|
gpuBoost={gpuBoost}
|
||||||
|
selectedAgentId={cc.selectedAgentId}
|
||||||
|
selectedNodeId={cc.selectedNodeId}
|
||||||
|
mainView={cc.mainView}
|
||||||
|
approvalCount={cc.approvals.length}
|
||||||
|
agentsLoading={cc.agentsLoading}
|
||||||
|
onSetMainView={cc.setMainView}
|
||||||
|
onOpenApprovals={openApprovals}
|
||||||
|
onSelectAgent={cc.selectAgent}
|
||||||
|
onSelectZone={cc.selectNode}
|
||||||
|
/>
|
||||||
|
|
||||||
<nav className="flex gap-2 flex-wrap">
|
<div ref={mainScrollRef} className="scrollbar-thin flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto bg-surface">
|
||||||
{TABS.map((t) => (
|
<div className={cn('flex min-h-0 flex-1', isPlatform ? '' : 'flex-col')}>
|
||||||
<button
|
<div className={cn('flex min-w-0 flex-1 flex-col gap-2', isPlatform ? 'p-2' : 'min-h-0 p-3')}>
|
||||||
key={t}
|
{isPlatform && (
|
||||||
onClick={() => setTab(t)}
|
<>
|
||||||
className={`px-4 py-2 rounded-xl text-sm font-mono border transition-all ${
|
<div className="grid shrink-0 grid-cols-1 gap-2 xl:grid-cols-[1fr_auto]">
|
||||||
tab === t
|
<AgentFleet
|
||||||
? 'bg-white border-neon-cyan text-neon-cyan shadow-neon-cyan font-semibold'
|
agents={cc.agents}
|
||||||
: 'bg-white/60 border-slate-200 text-ink-muted hover:bg-white hover:border-neon-cyan/40'
|
animations={cc.anims}
|
||||||
}`}
|
selectedId={cc.selectedAgentId}
|
||||||
>
|
loads={agentLoads}
|
||||||
{t}
|
approvalCount={cc.approvals.length}
|
||||||
</button>
|
onSelect={cc.selectAgent}
|
||||||
))}
|
onOpenApprovals={openApprovals}
|
||||||
</nav>
|
/>
|
||||||
|
<GpuMonitor gpu={cc.gpu} live={gpuLive} />
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 flex-1">
|
|
||||||
<main className="lg:col-span-2 glass-strong rounded-2xl p-5 min-h-[260px]">
|
|
||||||
{tab === 'Overview' && status && (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
||||||
{Object.entries(status.domains).map(([key, d]) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="status-card rounded-xl p-4 border"
|
|
||||||
style={{ borderColor: `${LEVEL_COLOR[d.level]}44`, background: LEVEL_BG[d.level] }}
|
|
||||||
>
|
|
||||||
<div className="text-xs font-mono uppercase tracking-wider text-ink-muted">{key}</div>
|
|
||||||
<div className="text-xl font-bold mt-1" style={{ color: LEVEL_COLOR[d.level] }}>{d.label}</div>
|
|
||||||
<div className="flex items-center gap-2 mt-3">
|
|
||||||
<div className="w-2.5 h-2.5 rounded-full animate-pulse" style={{ background: LEVEL_COLOR[d.level], boxShadow: `0 0 8px ${LEVEL_COLOR[d.level]}` }} />
|
|
||||||
<span className="text-xs font-mono uppercase" style={{ color: LEVEL_COLOR[d.level] }}>{d.level}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<InfraQuickAccess
|
||||||
))}
|
workload={cc.workload}
|
||||||
</div>
|
agents={cc.agents}
|
||||||
)}
|
selectedNodeId={cc.selectedNodeId}
|
||||||
{tab === 'Agents' && (
|
busy={cc.nodeBusy}
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
onSelectNode={cc.selectNode}
|
||||||
{agents.map((a) => (
|
onSelectAgent={cc.selectAgent}
|
||||||
<div key={a.id} className="status-card rounded-xl p-4 border border-slate-200/80">
|
onProbe={cc.probeNodeId}
|
||||||
<div className="font-semibold text-lg" style={{ color: a.color }}>{a.name}</div>
|
onOpenTerminal={cc.openTerminal}
|
||||||
<div className="text-sm text-ink-muted mt-1">{a.role}</div>
|
/>
|
||||||
<div className="text-xs font-mono text-ink-faint mt-2 px-2 py-1 rounded-md bg-slate-50 inline-block">zone: {a.zone}</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{tab === 'Feed' && (
|
|
||||||
<div className="font-mono text-xs space-y-2 max-h-80 overflow-y-auto">
|
|
||||||
{feed.map((e) => (
|
|
||||||
<div key={e.id} className="flex gap-2 py-1.5 border-b border-slate-100 last:border-0">
|
|
||||||
<span className="text-ink-faint shrink-0">{e.ts ? new Date(e.ts).toLocaleTimeString() : ''}</span>
|
|
||||||
<span className="font-semibold shrink-0" style={{ color: agents.find((a) => a.id === e.agent_id)?.color || '#888' }}>{e.agent_id}</span>
|
|
||||||
<span className={e.level === 'warn' ? 'text-neon-amber' : 'text-ink'}>{e.message}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{tab === 'Approvals' && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{approvals.length === 0 && <p className="text-ink-muted text-sm">Geen pending approvals.</p>}
|
|
||||||
{approvals.map((a) => (
|
|
||||||
<div key={a.id} className="status-card border border-neon-magenta/25 rounded-xl p-4">
|
|
||||||
<div className="text-sm font-semibold text-neon-magenta">{a.action}</div>
|
|
||||||
<div className="text-xs text-ink-muted mt-1">{a.reason}</div>
|
|
||||||
<div className="flex gap-2 mt-3">
|
|
||||||
<button onClick={() => decide(a.id, true)} className="px-3 py-1.5 rounded-lg bg-green-50 border border-neon-green/40 text-neon-green text-xs font-semibold hover:bg-green-100">Approve</button>
|
|
||||||
<button onClick={() => decide(a.id, false)} className="px-3 py-1.5 rounded-lg bg-red-50 border border-red-300 text-red-600 text-xs font-semibold hover:bg-red-100">Deny</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{tab === 'Audit' && (
|
|
||||||
<p className="text-ink-muted text-sm">Audit log — approvals en agent acties (v1 via Feed tab).</p>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<aside className="glass-strong rounded-2xl p-5 flex flex-col max-h-80 lg:max-h-none">
|
<div className={isPlatform ? 'min-h-[420px]' : 'min-h-0 flex-1'}>
|
||||||
<h3 className="text-sm font-mono font-semibold text-neon-cyan mb-3 tracking-wider">CHAT</h3>
|
{cc.mainView === 'platform' ? (
|
||||||
<div className="flex-1 overflow-y-auto space-y-3 text-sm font-mono mb-2">
|
<PlatformTopology
|
||||||
{chat.length === 0 && <p className="text-ink-faint text-xs">Stel een vraag — je agent loopt data ophalen.</p>}
|
workload={cc.workload}
|
||||||
{chat.map((m, i) => (
|
animations={cc.anims}
|
||||||
<div key={i} className={`rounded-lg p-3 ${m.role === 'user' ? 'bg-cyan-50 border border-cyan-100' : 'bg-slate-50 border border-slate-100'}`}>
|
selectedNodeId={cc.selectedNodeId}
|
||||||
<span className="text-ink-faint text-xs">{m.role === 'user' ? '▶ jij' : `◀ ${m.agent}`}</span>
|
onNodeClick={cc.selectNode}
|
||||||
<div className={`mt-1 whitespace-pre-wrap ${m.role === 'user' ? 'text-neon-cyan' : 'text-ink'}`}>{m.text}</div>
|
/>
|
||||||
|
) : cc.mainView === 'presentation' ? (
|
||||||
|
<PresentationView />
|
||||||
|
) : cc.mainView === 'dataquality' ? (
|
||||||
|
<DataQualityView />
|
||||||
|
) : cc.mainView === 'knowledge' ? (
|
||||||
|
<KnowledgeChatView onGpuActivity={setGpuChatActive} />
|
||||||
|
) : cc.mainView === 'storage' ? (
|
||||||
|
<StorageView />
|
||||||
|
) : (
|
||||||
|
<ApprovalInbox agents={cc.agents} livePending={cc.approvals} onDecide={cc.decide} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
|
||||||
|
{isPlatform && (
|
||||||
|
<div className="flex w-[340px] shrink-0 flex-col border-l border-border">
|
||||||
|
<InspectorPanel
|
||||||
|
node={cc.selectedNode}
|
||||||
|
nodeDetail={cc.nodeDetail}
|
||||||
|
agent={cc.selectedAgent}
|
||||||
|
agents={cc.agents}
|
||||||
|
workload={cc.workload}
|
||||||
|
gpu={cc.gpu}
|
||||||
|
feed={cc.feed}
|
||||||
|
lines={cc.inspectorLines}
|
||||||
|
busy={cc.nodeBusy}
|
||||||
|
onProbe={cc.probeNode}
|
||||||
|
onAsk={cc.askNode}
|
||||||
|
onSelectAgent={cc.selectAgent}
|
||||||
|
onSendPrompt={cc.sendPrompt}
|
||||||
|
onClear={cc.clearSelection}
|
||||||
|
onOpenTerminal={cc.openTerminal}
|
||||||
|
onProbeNodeId={cc.probeNodeId}
|
||||||
|
/>
|
||||||
|
<TerminalDock
|
||||||
|
subjectId={terminalSubject}
|
||||||
|
subjectLabel={terminalLabel}
|
||||||
|
lines={cc.inspectorLines}
|
||||||
|
busy={cc.nodeBusy || cc.promptBusy}
|
||||||
|
expanded={cc.terminalExpanded}
|
||||||
|
onToggle={() => cc.setTerminalExpanded(!cc.terminalExpanded)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PromptBar onSubmit={sendPrompt} busy={busy} />
|
<ChatDrawer
|
||||||
|
expanded={cc.chatExpanded}
|
||||||
|
onToggle={() => cc.setChatExpanded(!cc.chatExpanded)}
|
||||||
|
chat={cc.chat}
|
||||||
|
feed={cc.feed}
|
||||||
|
agents={cc.agents}
|
||||||
|
approvals={cc.approvals}
|
||||||
|
selectedAgent={cc.selectedAgent}
|
||||||
|
promptBusy={cc.promptBusy}
|
||||||
|
approvalHighlight={cc.approvalHighlight}
|
||||||
|
filterAgentId={cc.selectedAgentId}
|
||||||
|
onSendPrompt={cc.sendPrompt}
|
||||||
|
onDecide={cc.decide}
|
||||||
|
onDismissHighlight={() => cc.setApprovalHighlight(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { motion } from 'framer-motion'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
color: string
|
|
||||||
state: 'idle' | 'walk' | 'fetch' | 'return'
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentSprite({ color, state, label }: Props) {
|
|
||||||
const bob = state === 'idle' ? { y: [0, -3, 0] } : state === 'walk' || state === 'return' ? { y: [0, -6, 0] } : { y: 0 }
|
|
||||||
const scale = state === 'fetch' ? 0.92 : 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className="flex flex-col items-center"
|
|
||||||
animate={{ ...bob, scale }}
|
|
||||||
transition={{ repeat: Infinity, duration: state === 'walk' || state === 'return' ? 0.35 : 2 }}
|
|
||||||
>
|
|
||||||
<svg width="56" height="72" viewBox="0 0 56 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<ellipse cx="28" cy="68" rx="16" ry="4" fill={color} opacity="0.2" />
|
|
||||||
<rect x="18" y="36" width="20" height="26" rx="4" fill="#f8fafc" stroke={color} strokeWidth="1.5" />
|
|
||||||
<rect x="10" y="38" width="8" height="18" rx="3" fill="#f1f5f9" stroke={color} strokeWidth="1" />
|
|
||||||
<rect x="38" y="38" width="8" height="18" rx="3" fill="#f1f5f9" stroke={color} strokeWidth="1" />
|
|
||||||
<rect x="20" y="58" width="7" height="12" rx="2" fill="#e2e8f0" stroke={color} strokeWidth="1" />
|
|
||||||
<rect x="29" y="58" width="7" height="12" rx="2" fill="#e2e8f0" stroke={color} strokeWidth="1" />
|
|
||||||
<circle cx="28" cy="22" r="12" fill="#f8fafc" stroke={color} strokeWidth="1.5" />
|
|
||||||
<path d="M14 20 Q28 8 42 20 L40 24 Q28 14 16 24 Z" fill={color} opacity="0.9" />
|
|
||||||
<rect x="14" y="20" width="28" height="4" rx="1" fill={color} />
|
|
||||||
<path d="M16 24 Q16 34 20 36" stroke={color} strokeWidth="2" fill="none" />
|
|
||||||
<path d="M40 24 Q40 34 36 36" stroke={color} strokeWidth="2" fill="none" />
|
|
||||||
<rect x="12" y="22" width="6" height="10" rx="2" fill={color} opacity="0.7" />
|
|
||||||
<rect x="38" y="22" width="6" height="10" rx="2" fill={color} opacity="0.7" />
|
|
||||||
<path d="M36 36 L42 44" stroke={color} strokeWidth="1.5" />
|
|
||||||
<circle cx="43" cy="45" r="2" fill={color} />
|
|
||||||
<rect x="20" y="20" width="16" height="5" rx="2" fill={color} opacity="0.3" />
|
|
||||||
{state === 'fetch' && (
|
|
||||||
<motion.circle
|
|
||||||
cx="46" cy="30" r="4"
|
|
||||||
fill={color}
|
|
||||||
animate={{ opacity: [0.4, 1, 0.4] }}
|
|
||||||
transition={{ repeat: Infinity, duration: 0.6 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
<span className="text-[10px] font-mono font-semibold mt-1 truncate max-w-[72px]" style={{ color }}>{label}</span>
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { motion } from 'framer-motion'
|
|
||||||
import { AgentSprite } from './AgentSprite'
|
|
||||||
import type { Agent, AgentAnim, Zone } from '../types'
|
|
||||||
|
|
||||||
const ZONE_X: Record<string, number> = {
|
|
||||||
docker: 8,
|
|
||||||
db: 28,
|
|
||||||
lakehouse: 50,
|
|
||||||
hadoop: 72,
|
|
||||||
etl: 92,
|
|
||||||
}
|
|
||||||
|
|
||||||
const DESK_X = 50
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
agents: Agent[]
|
|
||||||
zones: Zone[]
|
|
||||||
animations: Record<string, AgentAnim>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpsFloor({ agents, zones, animations }: Props) {
|
|
||||||
return (
|
|
||||||
<div className="glass-strong rounded-2xl p-5 relative overflow-hidden min-h-[300px]">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="font-display text-lg font-bold text-neon-cyan neon-text-cyan tracking-wide">OPS FLOOR</h2>
|
|
||||||
<span className="text-xs font-mono px-2.5 py-1 rounded-full bg-green-50 text-neon-green border border-neon-green/30 animate-pulse">● LIVE</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative h-28 mb-4">
|
|
||||||
{zones.map((z) => (
|
|
||||||
<div
|
|
||||||
key={z.id}
|
|
||||||
className="absolute top-0 -translate-x-1/2 text-center"
|
|
||||||
style={{ left: `${z.x}%` }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="rounded-xl px-3 py-3 min-w-[92px] text-[9px] font-mono tracking-wider font-semibold bg-white/90"
|
|
||||||
style={{ border: `2px solid ${z.color}`, boxShadow: `0 4px 16px ${z.color}22`, color: z.color }}
|
|
||||||
>
|
|
||||||
{z.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<svg className="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="none">
|
|
||||||
{zones.map((z) => (
|
|
||||||
<line
|
|
||||||
key={`path-${z.id}`}
|
|
||||||
x1={`${DESK_X}%`} y1="85%" x2={`${z.x}%`} y2="35%"
|
|
||||||
stroke={z.color} strokeWidth="2" strokeDasharray="6 4" opacity="0.45"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative h-28 rounded-xl bg-gradient-to-b from-slate-50 to-white border border-slate-100">
|
|
||||||
{agents.map((agent, i) => {
|
|
||||||
const anim = animations[agent.id] || { agentId: agent.id, state: 'idle' as const }
|
|
||||||
const targetX = anim.state === 'idle' ? 12 + i * 17 : ZONE_X[anim.zone || agent.zone] ?? DESK_X
|
|
||||||
const y = anim.state === 'fetch' ? 8 : anim.state === 'idle' ? 0 : 4
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={agent.id}
|
|
||||||
className="absolute bottom-2 -translate-x-1/2"
|
|
||||||
animate={{ left: `${targetX}%`, y }}
|
|
||||||
transition={{ type: 'spring', stiffness: 80, damping: 14 }}
|
|
||||||
>
|
|
||||||
<AgentSprite
|
|
||||||
color={agent.color}
|
|
||||||
state={anim.state}
|
|
||||||
label={agent.name.split(' ')[0]}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { FormEvent, useState } from 'react'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onSubmit: (message: string) => void
|
|
||||||
busy: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PromptBar({ onSubmit, busy }: Props) {
|
|
||||||
const [text, setText] = useState('')
|
|
||||||
|
|
||||||
const handle = (e: FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!text.trim() || busy) return
|
|
||||||
onSubmit(text.trim())
|
|
||||||
setText('')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handle} className="glass-strong rounded-2xl p-4 flex gap-3 items-center shadow-card">
|
|
||||||
<span className="text-2xl" aria-hidden>💬</span>
|
|
||||||
<input
|
|
||||||
className="flex-1 bg-white border border-slate-200 rounded-xl px-4 py-2.5 outline-none font-mono text-sm text-ink placeholder:text-ink-faint focus:border-neon-cyan focus:ring-2 focus:ring-neon-cyan/20 transition"
|
|
||||||
placeholder="Vraag je agents... bijv. Hoe staat Debezium er voor?"
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={busy || !text.trim()}
|
|
||||||
className="px-5 py-2.5 rounded-xl font-display text-sm font-semibold text-white disabled:opacity-40 transition-all hover:brightness-110"
|
|
||||||
style={{
|
|
||||||
background: busy ? '#94a3b8' : 'linear-gradient(135deg, #0099cc, #8844cc)',
|
|
||||||
boxShadow: busy ? 'none' : '0 4px 16px rgba(0, 153, 204, 0.35)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{busy ? 'Bezig...' : 'Send'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Agent, FeedEntry } from '../../types'
|
||||||
|
import { getAgentMeta } from '../../lib/agentMeta'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
feed: FeedEntry[]
|
||||||
|
agents: Agent[]
|
||||||
|
filterAgentId?: string | null
|
||||||
|
opsOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL: Record<string, string> = {
|
||||||
|
info: 'text-foreground-muted',
|
||||||
|
ok: 'text-success',
|
||||||
|
warn: 'text-warning',
|
||||||
|
err: 'text-danger',
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpsEvent(message: string): boolean {
|
||||||
|
const lower = message.toLowerCase()
|
||||||
|
if (message.includes(' answered:')) return false
|
||||||
|
if (message.startsWith('Prompt received:')) return false
|
||||||
|
if (lower.includes('completed a response')) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityStream({ feed, agents, filterAgentId, opsOnly }: Props) {
|
||||||
|
let items = filterAgentId ? feed.filter((e) => e.agent_id === filterAgentId) : feed
|
||||||
|
if (opsOnly) items = items.filter((e) => isOpsEvent(e.message))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scrollbar-thin flex-1 overflow-y-auto px-2 pb-2">
|
||||||
|
{items.length === 0 && (
|
||||||
|
<p className="py-8 text-center text-[11px] text-foreground-faint">
|
||||||
|
{opsOnly ? 'Geen operationele events — antwords staan in Chat.' : 'No activity yet — agents are on standby.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{items.map((e) => {
|
||||||
|
const ag = agents.find((a) => a.id === e.agent_id)
|
||||||
|
const meta = ag ? getAgentMeta(ag.id) : null
|
||||||
|
const Icon = meta?.icon
|
||||||
|
return (
|
||||||
|
<div key={e.id} className="flex gap-2 border-b border-border/50 py-1.5 last:border-0">
|
||||||
|
{Icon && (
|
||||||
|
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded bg-surface-overlay" style={{ color: meta?.accent }}>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] font-medium text-foreground-muted">{ag?.name.split(' ·')[0] || e.agent_id}</span>
|
||||||
|
<span className="font-mono text-[9px] text-foreground-faint">{new Date(e.ts).toLocaleTimeString('en-US', { hour12: false })}</span>
|
||||||
|
</div>
|
||||||
|
<p className={cn('text-[10px] leading-relaxed', LEVEL[e.level] || 'text-foreground-muted')}>{e.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { ShieldCheck } from 'lucide-react'
|
||||||
|
import type { Agent, AgentAnim } from '../../types'
|
||||||
|
import type { AgentLoad } from '../../hooks/useLiveMetrics'
|
||||||
|
import { agentTaskLabel, getAgentMeta } from '../../lib/agentMeta'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[]
|
||||||
|
animations: Record<string, AgentAnim>
|
||||||
|
selectedId: string | null
|
||||||
|
loads: Record<string, AgentLoad>
|
||||||
|
approvalCount: number
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
onOpenApprovals: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentCard({
|
||||||
|
agent,
|
||||||
|
anim,
|
||||||
|
load,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
agent: Agent
|
||||||
|
anim?: AgentAnim
|
||||||
|
load?: AgentLoad
|
||||||
|
selected: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
}) {
|
||||||
|
const meta = getAgentMeta(agent.id)
|
||||||
|
const Icon = meta.icon
|
||||||
|
const busy = anim && anim.state !== 'idle'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
'flex h-[118px] w-[140px] shrink-0 flex-col gap-1 rounded-lg border bg-surface-raised p-1.5 text-left shadow-sm dark:bg-surface-overlay',
|
||||||
|
selected ? 'border-docker/50 ring-1 ring-docker/20' : 'border-border hover:border-border-strong',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-surface" style={{ color: meta.accent }}>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-[11px] font-semibold text-foreground">{agent.name.split(' ·')[0]}</p>
|
||||||
|
<p className="truncate text-[8px] text-foreground-muted">{meta.domain}</p>
|
||||||
|
</div>
|
||||||
|
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', busy ? 'bg-success animate-pulse' : 'bg-foreground-faint/30')} />
|
||||||
|
</div>
|
||||||
|
<p className="line-clamp-2 text-[8px] leading-[10px] text-foreground-muted">{agent.role}</p>
|
||||||
|
<p className="h-[20px] line-clamp-2 text-[8px] leading-[10px] text-foreground-faint">{agentTaskLabel(agent.id, anim)}</p>
|
||||||
|
<div className="mt-auto space-y-0.5">
|
||||||
|
<div className="flex justify-between font-mono text-[7px] tabular-nums text-foreground-faint">
|
||||||
|
<span>CPU {load?.cpu ?? 0}%</span>
|
||||||
|
<span>MEM {load?.mem ?? 0}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 overflow-hidden rounded-full bg-surface">
|
||||||
|
<div className="h-full rounded-full transition-[width] duration-700" style={{ width: `${load?.cpu ?? 0}%`, background: meta.accent }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentFleet({ agents, animations, selectedId, loads, approvalCount, onSelect, onOpenApprovals }: Props) {
|
||||||
|
const supervisors = agents.filter((a) => a.supervisor)
|
||||||
|
const operators = agents.filter((a) => !a.supervisor && a.id !== 'mcp-coordinator')
|
||||||
|
const mcp = agents.find((a) => a.id === 'mcp-coordinator')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel flex shrink-0 flex-col p-2">
|
||||||
|
<div className="mb-1 flex shrink-0 items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">Agent Fleet</h3>
|
||||||
|
<p className="truncate text-[8px] text-foreground-faint">Klik agent → stel vraag in chat · elk agent bewaakt één domein</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenApprovals}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded-md border px-2 py-1 text-[9px] font-medium',
|
||||||
|
approvalCount > 0 ? 'border-warning/40 bg-warning/10 text-warning' : 'border-border text-foreground-muted hover:bg-surface-overlay',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3 w-3" />
|
||||||
|
Approvals{approvalCount > 0 ? ` (${approvalCount})` : ''}
|
||||||
|
</button>
|
||||||
|
<span className="font-mono text-[8px] text-foreground-faint">{agents.length} agents</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="scroll-x-stable flex min-h-0 gap-3 pb-1">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<p className="mb-1 text-[8px] uppercase tracking-widest text-foreground-faint">Supervisors</p>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{supervisors.map((a) => (
|
||||||
|
<AgentCard key={a.id} agent={a} anim={animations[a.id]} load={loads[a.id]} selected={selectedId === a.id} onSelect={() => onSelect(a.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mcp && (
|
||||||
|
<div className="shrink-0">
|
||||||
|
<p className="mb-1 text-[8px] uppercase tracking-widest text-foreground-faint">MCP Hub</p>
|
||||||
|
<AgentCard agent={mcp} anim={animations[mcp.id]} load={loads[mcp.id]} selected={selectedId === mcp.id} onSelect={() => onSelect(mcp.id)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<p className="mb-1 text-[8px] uppercase tracking-widest text-foreground-faint">Field Operators</p>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{operators.map((a) => (
|
||||||
|
<AgentCard key={a.id} agent={a} anim={animations[a.id]} load={loads[a.id]} selected={selectedId === a.id} onSelect={() => onSelect(a.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Check, ShieldAlert, X } from 'lucide-react'
|
||||||
|
import type { Agent, Approval } from '../../types'
|
||||||
|
import { getAgentMeta } from '../../lib/agentMeta'
|
||||||
|
import { Button } from '../ui/Button'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
'docker.restart': 'Container restart',
|
||||||
|
'docker.update': 'Image update',
|
||||||
|
'generic.mutate': 'Infrastructure change',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
approvals: Approval[]
|
||||||
|
agents: Agent[]
|
||||||
|
highlighted: boolean
|
||||||
|
onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise<void>
|
||||||
|
onDismissHighlight?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApprovalCards({ approvals, agents, highlighted, onDecide, onDismissHighlight }: Props) {
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
|
const [decider, setDecider] = useState<'mo-commander' | 'bart-commander'>('mo-commander')
|
||||||
|
|
||||||
|
if (!approvals.length) return null
|
||||||
|
|
||||||
|
const agentOf = (id: string) => agents.find((a) => a.id === id)
|
||||||
|
|
||||||
|
const handle = async (id: string, approved: boolean) => {
|
||||||
|
setBusyId(id)
|
||||||
|
try {
|
||||||
|
await onDecide(id, approved, decider, '')
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('mx-2 mb-2 rounded-lg border bg-surface-overlay p-2.5', highlighted ? 'border-warning/50 ring-1 ring-warning/20' : 'border-border')}>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-warning">
|
||||||
|
<ShieldAlert className="h-3.5 w-3.5" />
|
||||||
|
Pending approvals ({approvals.length})
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-1 text-[9px] text-foreground-muted">
|
||||||
|
As
|
||||||
|
<select
|
||||||
|
value={decider}
|
||||||
|
onChange={(e) => setDecider(e.target.value as typeof decider)}
|
||||||
|
className="rounded border border-border bg-surface px-1 py-0.5 text-[10px] text-foreground-muted"
|
||||||
|
>
|
||||||
|
<option value="mo-commander">Mo</option>
|
||||||
|
<option value="bart-commander">Bart</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{highlighted && onDismissHighlight && (
|
||||||
|
<button type="button" onClick={onDismissHighlight} className="text-[9px] text-foreground-muted hover:text-foreground-muted">Dismiss</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{approvals.map((a) => {
|
||||||
|
const ag = agentOf(a.agent_id)
|
||||||
|
const meta = ag ? getAgentMeta(ag.id) : null
|
||||||
|
const Icon = meta?.icon
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="rounded-lg border border-border bg-surface p-2">
|
||||||
|
<div className="mb-1.5 flex items-start gap-1.5">
|
||||||
|
{Icon && (
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded bg-surface-overlay text-docker">
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-[8px] uppercase tracking-wide text-warning">{ACTION_LABELS[a.action_type] || a.action_type}</span>
|
||||||
|
<p className="truncate text-[11px] font-medium text-foreground">{ag?.name || a.agent_id}</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[8px] text-foreground-faint">#{a.id.slice(0, 6)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mb-1 text-[10px] text-foreground-muted line-clamp-2">{a.action}</p>
|
||||||
|
{a.target && <p className="text-[9px] text-foreground-muted">Target: {a.target}</p>}
|
||||||
|
<div className="mt-2 flex gap-1">
|
||||||
|
<Button size="sm" variant="success" className="flex-1 text-[10px]" disabled={busyId === a.id} onClick={() => handle(a.id, true)}>
|
||||||
|
<Check className="h-3 w-3" /> Approve
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="danger" className="flex-1 text-[10px]" disabled={busyId === a.id} onClick={() => handle(a.id, false)}>
|
||||||
|
<X className="h-3 w-3" /> Deny
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Check, ShieldCheck, X } from 'lucide-react'
|
||||||
|
import { fetchApprovalHistory } from '../../lib/api'
|
||||||
|
import type { Agent, Approval } from '../../types'
|
||||||
|
import { getAgentMeta } from '../../lib/agentMeta'
|
||||||
|
import { Badge } from '../ui/Badge'
|
||||||
|
import { Button } from '../ui/Button'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
type Filter = 'pending' | 'approved' | 'denied' | 'all'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[]
|
||||||
|
livePending: Approval[]
|
||||||
|
onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApprovalInbox({ agents, livePending, onDecide }: Props) {
|
||||||
|
const [filter, setFilter] = useState<Filter>('pending')
|
||||||
|
const [items, setItems] = useState<Approval[]>([])
|
||||||
|
const [stats, setStats] = useState({ pending: 0, approved: 0, denied: 0, total: 0 })
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
const [decider, setDecider] = useState<'mo-commander' | 'bart-commander'>('mo-commander')
|
||||||
|
const [note, setNote] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const res = await fetchApprovalHistory(filter === 'all' ? 'all' : filter)
|
||||||
|
setItems(res.approvals)
|
||||||
|
if (res.stats) setStats(res.stats)
|
||||||
|
}, [filter])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load, livePending])
|
||||||
|
|
||||||
|
const selected = useMemo(() => items.find((a) => a.id === selectedId) || items[0] || null, [items, selectedId])
|
||||||
|
const agentOf = (id: string) => agents.find((a) => a.id === id)
|
||||||
|
|
||||||
|
const handleDecide = async (approved: boolean) => {
|
||||||
|
if (!selected || selected.status !== 'pending') return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await onDecide(selected.id, approved, decider, note)
|
||||||
|
setNote('')
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-docker" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">Approval Inbox</h2>
|
||||||
|
<p className="text-[10px] text-foreground-muted">Mo & Bart review mutating agent actions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Badge variant="warning">{stats.pending} pending</Badge>
|
||||||
|
<Badge variant="success">{stats.approved} ok</Badge>
|
||||||
|
<Badge variant="danger">{stats.denied} denied</Badge>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 gap-1 border-b border-border px-2 py-1">
|
||||||
|
{(['pending', 'approved', 'denied', 'all'] as Filter[]).map((f) => (
|
||||||
|
<button key={f} type="button" onClick={() => setFilter(f)} className={cn('rounded px-2 py-0.5 text-[10px] capitalize', filter === f ? 'bg-docker-light text-docker' : 'text-foreground-muted')}>
|
||||||
|
{f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid min-h-0 flex-1 grid-cols-[minmax(200px,0.9fr)_1.1fr]">
|
||||||
|
<div className="scrollbar-thin overflow-y-auto border-r border-border p-1">
|
||||||
|
{!items.length && <p className="p-4 text-center text-[10px] text-foreground-faint">No {filter} requests.</p>}
|
||||||
|
{items.map((a) => {
|
||||||
|
const ag = agentOf(a.agent_id)
|
||||||
|
const meta = ag ? getAgentMeta(ag.id) : null
|
||||||
|
const Icon = meta?.icon
|
||||||
|
return (
|
||||||
|
<button key={a.id} type="button" onClick={() => setSelectedId(a.id)} className={cn('mb-1 flex w-full gap-2 rounded-lg border p-2 text-left', selected?.id === a.id ? 'border-docker/40 bg-docker-light' : 'border-transparent hover:bg-surface-overlay')}>
|
||||||
|
{Icon && <Icon className="h-4 w-4 shrink-0 text-docker" />}
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-[11px] font-medium text-foreground">{a.action.slice(0, 80)}</span>
|
||||||
|
<span className="block text-[9px] text-foreground-faint">{ag?.name || a.agent_id}</span>
|
||||||
|
</span>
|
||||||
|
<Badge variant={a.status === 'pending' ? 'warning' : a.status === 'approved' ? 'success' : 'danger'}>{a.status}</Badge>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<div className="scrollbar-thin overflow-y-auto p-3">
|
||||||
|
<Badge variant={selected.status === 'pending' ? 'warning' : 'success'}>{selected.status}</Badge>
|
||||||
|
<dl className="mt-3 grid grid-cols-2 gap-2 text-[10px]">
|
||||||
|
<div><dt className="text-foreground-faint">Agent</dt><dd className="text-foreground">{agentOf(selected.agent_id)?.name}</dd></div>
|
||||||
|
<div><dt className="text-foreground-faint">Type</dt><dd className="text-foreground">{selected.action_type}</dd></div>
|
||||||
|
<div className="col-span-2"><dt className="text-foreground-faint">Action</dt><dd className="text-foreground-muted">{selected.action}</dd></div>
|
||||||
|
<div className="col-span-2"><dt className="text-foreground-faint">Reason</dt><dd className="text-foreground-muted">{selected.reason}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{selected.status === 'pending' && (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<select value={decider} onChange={(e) => setDecider(e.target.value as typeof decider)} className="w-full rounded border border-border bg-surface px-2 py-1 text-[10px] text-foreground-muted">
|
||||||
|
<option value="mo-commander">Decide as Mo</option>
|
||||||
|
<option value="bart-commander">Decide as Bart</option>
|
||||||
|
</select>
|
||||||
|
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note (optional)" className="w-full rounded border border-border bg-surface px-2 py-1 text-[10px] text-foreground-muted" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="success" className="flex-1" disabled={busy} onClick={() => handleDecide(true)}><Check className="h-3 w-3" /> Approve</Button>
|
||||||
|
<Button variant="danger" className="flex-1" disabled={busy} onClick={() => handleDecide(false)}><X className="h-3 w-3" /> Deny</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
type FlowNode = { id: string; label: string; sub?: string; color: string }
|
||||||
|
type FlowEdge = { from: string; to: string; label?: string }
|
||||||
|
|
||||||
|
const FLOWS: Record<string, { nodes: FlowNode[]; edges: FlowEdge[] }> = {
|
||||||
|
'full-stack': {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'user', label: 'User / Customer', sub: 'Browser', color: '#60a5fa' },
|
||||||
|
{ id: 'caddy', label: 'Caddy :80', sub: 'Reverse proxy', color: '#38bdf8' },
|
||||||
|
{ id: 'ui', label: 'Command Center', sub: 'React UI', color: '#818cf8' },
|
||||||
|
{ id: 'api', label: 'Agents API', sub: 'FastAPI :3201', color: '#a78bfa' },
|
||||||
|
{ id: 'dq', label: 'DQ API', sub: 'Maturity + Docling', color: '#f59e0b' },
|
||||||
|
{ id: 'rag', label: 'RAG API', sub: 'LangChain', color: '#34d399' },
|
||||||
|
{ id: 'chroma', label: 'ChromaDB', sub: 'Vectors (persistent)', color: '#22d3ee' },
|
||||||
|
{ id: 'docling', label: 'Docling', sub: ':5001', color: '#fb923c' },
|
||||||
|
{ id: 'llm', label: 'vLLM Llama 70B', sub: 'GPU Lab', color: '#4ade80' },
|
||||||
|
{ id: 'lake', label: 'Lakehouse', sub: 'Kafka · Spark · Trino', color: '#6366f1' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'user', to: 'caddy', label: 'HTTP' },
|
||||||
|
{ from: 'caddy', to: 'ui' },
|
||||||
|
{ from: 'ui', to: 'api' },
|
||||||
|
{ from: 'ui', to: 'dq' },
|
||||||
|
{ from: 'ui', to: 'rag' },
|
||||||
|
{ from: 'dq', to: 'docling' },
|
||||||
|
{ from: 'rag', to: 'docling' },
|
||||||
|
{ from: 'rag', to: 'chroma' },
|
||||||
|
{ from: 'rag', to: 'llm' },
|
||||||
|
{ from: 'api', to: 'llm' },
|
||||||
|
{ from: 'api', to: 'lake' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'rag-flow': {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'upload', label: 'Upload PDF/CSV', sub: 'Once', color: '#60a5fa' },
|
||||||
|
{ id: 'store', label: 'File Store', sub: '/data/uploads', color: '#64748b' },
|
||||||
|
{ id: 'docling', label: 'Docling', sub: 'Parse + OCR', color: '#fb923c' },
|
||||||
|
{ id: 'chunk', label: 'LangChain Splitter', sub: '800 char chunks', color: '#a78bfa' },
|
||||||
|
{ id: 'embed', label: 'MiniLM Embeddings', sub: '384-d vectors', color: '#818cf8' },
|
||||||
|
{ id: 'chroma', label: 'ChromaDB', sub: 'Persistent', color: '#22d3ee' },
|
||||||
|
{ id: 'query', label: 'Your Question', sub: 'Any time', color: '#60a5fa' },
|
||||||
|
{ id: 'retrieve', label: 'Similarity Search', sub: 'top-k chunks', color: '#34d399' },
|
||||||
|
{ id: 'llm', label: 'Llama 70B', sub: 'Answer + sources', color: '#4ade80' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'upload', to: 'store', label: 'save' },
|
||||||
|
{ from: 'upload', to: 'docling' },
|
||||||
|
{ from: 'docling', to: 'chunk' },
|
||||||
|
{ from: 'chunk', to: 'embed' },
|
||||||
|
{ from: 'embed', to: 'chroma', label: 'index' },
|
||||||
|
{ from: 'query', to: 'retrieve' },
|
||||||
|
{ from: 'retrieve', to: 'chroma' },
|
||||||
|
{ from: 'retrieve', to: 'llm' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'dq-flow': {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'data', label: 'Customer Data', sub: 'CSV · Excel · PDF', color: '#60a5fa' },
|
||||||
|
{ id: 'docling', label: 'Docling', sub: 'Structure + images', color: '#fb923c' },
|
||||||
|
{ id: 'pandas', label: 'Pandas Profiling', sub: 'Column stats', color: '#a78bfa' },
|
||||||
|
{ id: 'ge', label: 'Great Expectations', sub: 'Expectation checks', color: '#34d399' },
|
||||||
|
{ id: 'soda', label: 'Soda Core', sub: 'YAML checks', color: '#22d3ee' },
|
||||||
|
{ id: 'maturity', label: '6 Dimensions', sub: 'Score 0–100', color: '#f59e0b' },
|
||||||
|
{ id: 'report', label: 'HTML Report', sub: 'Roadmap + actions', color: '#818cf8' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'data', to: 'docling' },
|
||||||
|
{ from: 'data', to: 'pandas' },
|
||||||
|
{ from: 'pandas', to: 'ge' },
|
||||||
|
{ from: 'pandas', to: 'soda' },
|
||||||
|
{ from: 'ge', to: 'maturity' },
|
||||||
|
{ from: 'soda', to: 'maturity' },
|
||||||
|
{ from: 'maturity', to: 'report' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'lakehouse': {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'pg', label: 'PostgreSQL', color: '#60a5fa' },
|
||||||
|
{ id: 'mysql', label: 'MySQL', color: '#60a5fa' },
|
||||||
|
{ id: 'mongo', label: 'MongoDB', color: '#60a5fa' },
|
||||||
|
{ id: 'debezium', label: 'Debezium CDC', color: '#f59e0b' },
|
||||||
|
{ id: 'kafka', label: 'Kafka', color: '#fb923c' },
|
||||||
|
{ id: 'spark', label: 'Spark', color: '#a78bfa' },
|
||||||
|
{ id: 'iceberg', label: 'Iceberg', color: '#22d3ee' },
|
||||||
|
{ id: 'trino', label: 'Trino', color: '#34d399' },
|
||||||
|
{ id: 'bi', label: 'Superset BI', color: '#818cf8' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'pg', to: 'debezium' },
|
||||||
|
{ from: 'mysql', to: 'debezium' },
|
||||||
|
{ from: 'mongo', to: 'debezium' },
|
||||||
|
{ from: 'debezium', to: 'kafka' },
|
||||||
|
{ from: 'kafka', to: 'spark' },
|
||||||
|
{ from: 'spark', to: 'iceberg' },
|
||||||
|
{ from: 'iceberg', to: 'trino' },
|
||||||
|
{ from: 'trino', to: 'bi' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const POSITIONS: Record<string, Record<string, { x: number; y: number }>> = {
|
||||||
|
'full-stack': {
|
||||||
|
user: { x: 50, y: 8 },
|
||||||
|
caddy: { x: 50, y: 22 },
|
||||||
|
ui: { x: 50, y: 38 },
|
||||||
|
api: { x: 18, y: 58 },
|
||||||
|
dq: { x: 50, y: 58 },
|
||||||
|
rag: { x: 82, y: 58 },
|
||||||
|
docling: { x: 50, y: 78 },
|
||||||
|
chroma: { x: 82, y: 78 },
|
||||||
|
llm: { x: 82, y: 92 },
|
||||||
|
lake: { x: 18, y: 92 },
|
||||||
|
},
|
||||||
|
'rag-flow': {
|
||||||
|
upload: { x: 12, y: 20 },
|
||||||
|
store: { x: 12, y: 45 },
|
||||||
|
docling: { x: 35, y: 20 },
|
||||||
|
chunk: { x: 58, y: 20 },
|
||||||
|
embed: { x: 58, y: 45 },
|
||||||
|
chroma: { x: 58, y: 70 },
|
||||||
|
query: { x: 82, y: 20 },
|
||||||
|
retrieve: { x: 82, y: 45 },
|
||||||
|
llm: { x: 82, y: 70 },
|
||||||
|
},
|
||||||
|
'dq-flow': {
|
||||||
|
data: { x: 10, y: 50 },
|
||||||
|
docling: { x: 28, y: 25 },
|
||||||
|
pandas: { x: 28, y: 75 },
|
||||||
|
ge: { x: 52, y: 35 },
|
||||||
|
soda: { x: 52, y: 65 },
|
||||||
|
maturity: { x: 72, y: 50 },
|
||||||
|
report: { x: 90, y: 50 },
|
||||||
|
},
|
||||||
|
'lakehouse': {
|
||||||
|
pg: { x: 8, y: 15 },
|
||||||
|
mysql: { x: 8, y: 35 },
|
||||||
|
mongo: { x: 8, y: 55 },
|
||||||
|
debezium: { x: 28, y: 35 },
|
||||||
|
kafka: { x: 45, y: 35 },
|
||||||
|
spark: { x: 58, y: 35 },
|
||||||
|
iceberg: { x: 72, y: 35 },
|
||||||
|
trino: { x: 85, y: 35 },
|
||||||
|
bi: { x: 92, y: 55 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArchitectureDiagram({ animation }: { animation: string }) {
|
||||||
|
const flow = FLOWS[animation] || FLOWS['full-stack']
|
||||||
|
const positions = POSITIONS[animation] || POSITIONS['full-stack']
|
||||||
|
const [tick, setTick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setTick((n) => n + 1), 2200)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const activeEdge = tick % flow.edges.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mx-auto mb-6 h-[280px] w-full max-w-4xl rounded-xl border border-docker/30 bg-surface-overlay/60 p-2 md:h-[320px]">
|
||||||
|
<svg className="absolute inset-0 h-full w-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
{flow.edges.map((edge, i) => {
|
||||||
|
const from = positions[edge.from]
|
||||||
|
const to = positions[edge.to]
|
||||||
|
if (!from || !to) return null
|
||||||
|
const active = i === activeEdge
|
||||||
|
return (
|
||||||
|
<g key={`${edge.from}-${edge.to}`}>
|
||||||
|
<line
|
||||||
|
x1={from.x}
|
||||||
|
y1={from.y}
|
||||||
|
x2={to.x}
|
||||||
|
y2={to.y}
|
||||||
|
stroke={active ? '#38bdf8' : 'rgba(56,189,248,0.25)'}
|
||||||
|
strokeWidth={active ? 0.6 : 0.35}
|
||||||
|
strokeDasharray={active ? '2 1' : '1 2'}
|
||||||
|
className={active ? 'animate-pulse' : undefined}
|
||||||
|
/>
|
||||||
|
{active && (
|
||||||
|
<circle r="1.2" fill="#38bdf8">
|
||||||
|
<animateMotion dur="1.8s" repeatCount="indefinite" path={`M${from.x},${from.y} L${to.x},${to.y}`} />
|
||||||
|
</circle>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
{flow.nodes.map((node) => {
|
||||||
|
const pos = positions[node.id]
|
||||||
|
if (!pos) return null
|
||||||
|
const lit = flow.edges.some((e, i) => i === activeEdge && (e.from === node.id || e.to === node.id))
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
className={cn(
|
||||||
|
'absolute -translate-x-1/2 -translate-y-1/2 rounded-lg border px-2 py-1 text-center transition-all duration-500',
|
||||||
|
lit ? 'scale-105 border-docker shadow-docker bg-docker/20' : 'border-border bg-surface-raised/90',
|
||||||
|
)}
|
||||||
|
style={{ left: `${pos.x}%`, top: `${pos.y}%`, minWidth: '72px' }}
|
||||||
|
>
|
||||||
|
<p className="text-[9px] font-semibold leading-tight text-foreground md:text-[10px]" style={{ color: lit ? node.color : undefined }}>
|
||||||
|
{node.label}
|
||||||
|
</p>
|
||||||
|
{node.sub && <p className="text-[7px] text-foreground-faint md:text-[8px]">{node.sub}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronUp, MessageSquare, Radio } from 'lucide-react'
|
||||||
|
import type { Agent, Approval, ChatMessage, FeedEntry } from '../../types'
|
||||||
|
import { ActivityStream } from './ActivityStream'
|
||||||
|
import { ApprovalCards } from './ApprovalCards'
|
||||||
|
import { CommandBar } from './CommandBar'
|
||||||
|
import { CommsPanel } from './CommsPanel'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
expanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
chat: ChatMessage[]
|
||||||
|
feed: FeedEntry[]
|
||||||
|
agents: Agent[]
|
||||||
|
approvals: Approval[]
|
||||||
|
selectedAgent: Agent | null
|
||||||
|
promptBusy: boolean
|
||||||
|
approvalHighlight: boolean
|
||||||
|
filterAgentId?: string | null
|
||||||
|
onSendPrompt: (message: string, agentId?: string) => void
|
||||||
|
onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise<void>
|
||||||
|
onDismissHighlight: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatDrawer({
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
chat,
|
||||||
|
feed,
|
||||||
|
agents,
|
||||||
|
approvals,
|
||||||
|
selectedAgent,
|
||||||
|
promptBusy,
|
||||||
|
approvalHighlight,
|
||||||
|
filterAgentId,
|
||||||
|
onSendPrompt,
|
||||||
|
onDecide,
|
||||||
|
onDismissHighlight,
|
||||||
|
}: Props) {
|
||||||
|
const [tab, setTab] = useState<'chat' | 'activity'>('chat')
|
||||||
|
const unread = chat.length
|
||||||
|
|
||||||
|
if (!expanded) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex w-full shrink-0 items-center justify-between border-t border-border bg-surface-raised/95 px-4 py-2 backdrop-blur-sm hover:bg-surface-overlay"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-xs font-medium text-foreground">
|
||||||
|
<MessageSquare className="h-4 w-4 text-docker" />
|
||||||
|
Chat & Activity
|
||||||
|
{unread > 0 && (
|
||||||
|
<span className="rounded-full bg-docker/20 px-2 py-0.5 font-mono text-[10px] text-docker">
|
||||||
|
{unread} bericht{unread !== 1 ? 'en' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{approvals.length > 0 && (
|
||||||
|
<span className="rounded-full bg-warning/20 px-2 py-0.5 font-mono text-[10px] text-warning">
|
||||||
|
{approvals.length} approval{approvals.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronUp className="h-4 w-4 text-foreground-muted" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 flex-col border-t border-border bg-surface-raised/95 backdrop-blur-sm" style={{ height: 'min(42vh, 380px)' }}>
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['chat', 'activity'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium capitalize transition-colors',
|
||||||
|
tab === t
|
||||||
|
? 'bg-docker-light text-docker dark:bg-blue-500/20 dark:text-blue-200'
|
||||||
|
: 'text-foreground-muted hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t === 'chat' ? <MessageSquare className="h-3 w-3" /> : <Radio className="h-3 w-3" />}
|
||||||
|
{t === 'chat' ? 'Chat' : 'Activity'}
|
||||||
|
{t === 'activity' && approvals.length > 0 && (
|
||||||
|
<span className="rounded-full bg-warning/20 px-1 font-mono text-[8px] text-warning">{approvals.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onToggle} className="rounded p-1 text-foreground-muted hover:bg-surface-overlay hover:text-foreground" title="Inklappen">
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel mx-2 mb-1 min-h-0 flex-1 overflow-hidden">
|
||||||
|
{tab === 'chat' ? (
|
||||||
|
<CommsPanel messages={chat} agents={agents} selectedAgent={selectedAgent} busy={promptBusy} />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
|
<ApprovalCards
|
||||||
|
approvals={approvals}
|
||||||
|
agents={agents}
|
||||||
|
highlighted={approvalHighlight}
|
||||||
|
onDecide={onDecide}
|
||||||
|
onDismissHighlight={onDismissHighlight}
|
||||||
|
/>
|
||||||
|
<ActivityStream feed={feed} agents={agents} filterAgentId={filterAgentId} opsOnly />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'chat' && <CommandBar busy={promptBusy} selectedAgent={selectedAgent} onSubmit={onSendPrompt} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Send } from 'lucide-react'
|
||||||
|
import type { Agent } from '../../types'
|
||||||
|
import { Button } from '../ui/Button'
|
||||||
|
import { Input } from '../ui/Input'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
busy: boolean
|
||||||
|
selectedAgent: Agent | null
|
||||||
|
onSubmit: (message: string, agentId?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandBar({ busy, selectedAgent, onSubmit }: Props) {
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!input.trim() || busy) return
|
||||||
|
onSubmit(input.trim(), selectedAgent?.id)
|
||||||
|
setInput('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = selectedAgent?.suggested_prompts?.slice(0, 3) || [
|
||||||
|
'Hoeveel data zit er in de databases?',
|
||||||
|
'Wat staat er in PostgreSQL?',
|
||||||
|
'MongoDB supplychain overzicht',
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="shrink-0 border-t border-border bg-surface-raised/90 px-3 py-1.5 backdrop-blur-sm">
|
||||||
|
<form onSubmit={submit} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder={selectedAgent ? `Command ${selectedAgent.name.split(' ·')[0]}…` : 'Enter command — auto-routed to specialist…'}
|
||||||
|
disabled={busy}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={busy || !input.trim()}>
|
||||||
|
<Send className="h-3.5 w-3.5" />
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setInput(s)}
|
||||||
|
className="rounded border border-border bg-surface px-2 py-0.5 text-[9px] text-foreground-muted hover:border-border-strong hover:text-foreground-muted"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { MessageSquare } from 'lucide-react'
|
||||||
|
import type { Agent, ChatMessage } from '../../types'
|
||||||
|
import { getAgentMeta } from '../../lib/agentMeta'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
messages: ChatMessage[]
|
||||||
|
agents: Agent[]
|
||||||
|
selectedAgent: Agent | null
|
||||||
|
busy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommsPanel({ messages, agents, selectedAgent, busy }: Props) {
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
const meta = selectedAgent ? getAgentMeta(selectedAgent.id) : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages, busy])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
|
||||||
|
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-foreground">
|
||||||
|
<MessageSquare className="h-3.5 w-3.5 text-docker" /> Comms
|
||||||
|
</h3>
|
||||||
|
{selectedAgent && meta && (
|
||||||
|
<span className="truncate font-mono text-[9px]" style={{ color: meta.accent }}>
|
||||||
|
{selectedAgent.name.split(' ·')[0]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="scrollbar-thin flex-1 space-y-2 overflow-y-auto p-2">
|
||||||
|
{!messages.length && (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center py-6 text-center">
|
||||||
|
<MessageSquare className="mb-2 h-6 w-6 text-foreground-faint" />
|
||||||
|
<p className="max-w-[200px] text-[10px] text-foreground-muted">
|
||||||
|
Send a command below — routing selects the right specialist automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((m, i) => {
|
||||||
|
const ag = m.role === 'agent' ? agents.find((a) => a.id === m.agent) : null
|
||||||
|
const agMeta = ag ? getAgentMeta(ag.id) : null
|
||||||
|
return (
|
||||||
|
<div key={i} className={cn('flex gap-2', m.role === 'user' && 'flex-row-reverse')}>
|
||||||
|
<div className={cn('max-w-[85%] rounded-lg border px-2 py-1.5', m.role === 'user' ? 'border-docker/25 bg-docker-light' : 'border-border bg-surface-overlay')}>
|
||||||
|
<p className="mb-0.5 text-[8px] text-foreground-muted">
|
||||||
|
{m.role === 'user' ? 'You' : ag?.name || m.agent}
|
||||||
|
{m.ts && ` · ${new Date(m.ts).toLocaleTimeString('en-US', { hour12: false })}`}
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-wrap text-[11px] text-foreground">{m.text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{busy && (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-2 text-[10px] text-foreground-muted">
|
||||||
|
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-accent" />
|
||||||
|
<span>Agent verzamelt cluster-data en vraagt Llama 70B… verwacht ~30–90 sec</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,732 @@
|
|||||||
|
import { useCallback, useEffect, useState, Fragment } from 'react'
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
FileSearch,
|
||||||
|
FileText,
|
||||||
|
Image,
|
||||||
|
Layers,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Table2,
|
||||||
|
Upload,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||||
|
|
||||||
|
type Dimension = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
score: number
|
||||||
|
level: string
|
||||||
|
findings: string[]
|
||||||
|
recommended_actions?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColumnProfile = {
|
||||||
|
name: string
|
||||||
|
dtype: string
|
||||||
|
null_pct: number
|
||||||
|
unique_count: number
|
||||||
|
quality_flags: string[]
|
||||||
|
sample_values?: string[]
|
||||||
|
numeric?: { min: number; max: number; mean: number; outliers: number }
|
||||||
|
text?: { avg_length: number; empty_strings: number }
|
||||||
|
top_values?: { value: string; count: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type GxCheck = { suite: string; expectation: string; success: boolean; result: string; column?: string }
|
||||||
|
type SodaCheck = { suite: string; name: string; check: string; outcome: string; detail: string }
|
||||||
|
|
||||||
|
type DocStructure = {
|
||||||
|
pages: number
|
||||||
|
pictures: number
|
||||||
|
tables: number
|
||||||
|
text_blocks: number
|
||||||
|
headings: number
|
||||||
|
paragraphs: number
|
||||||
|
list_items?: number
|
||||||
|
form_items: number
|
||||||
|
key_value_pairs: number
|
||||||
|
label_counts?: Record<string, number>
|
||||||
|
table_details?: { index: number; rows: number; cols: number; cells: number; preview?: string }[]
|
||||||
|
picture_details?: { index: number; label: string; has_image: boolean; captions: number }[]
|
||||||
|
outline?: { type: string; text: string; level?: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssessResult = {
|
||||||
|
ok: boolean
|
||||||
|
report_id: string
|
||||||
|
overall_score: number
|
||||||
|
maturity_level: string
|
||||||
|
maturity_description?: string
|
||||||
|
rows: number
|
||||||
|
columns: number
|
||||||
|
dimensions: Dimension[]
|
||||||
|
column_profiles: ColumnProfile[]
|
||||||
|
action_items: { priority: string; dimension: string; score: number; action: string }[]
|
||||||
|
checks?: { great_expectations: GxCheck[]; soda_core: SodaCheck[] }
|
||||||
|
checks_summary: {
|
||||||
|
great_expectations: { total: number; passed: number }
|
||||||
|
soda_core: { total: number; warnings: number }
|
||||||
|
}
|
||||||
|
docling?: { used: boolean; parse_id?: string; document_structure?: DocStructure; stats?: Record<string, number>; images?: DocImage[] }
|
||||||
|
rag_ingest?: { ok: boolean; duplicate?: boolean; chunks?: number; message?: string; error?: string }
|
||||||
|
report_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocImage = {
|
||||||
|
index: number
|
||||||
|
label: string
|
||||||
|
available: boolean
|
||||||
|
url?: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
mimetype?: string
|
||||||
|
dpi?: number
|
||||||
|
bytes?: number
|
||||||
|
captions?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParseResult = {
|
||||||
|
ok: boolean
|
||||||
|
parse_id: string
|
||||||
|
filename: string
|
||||||
|
status: string
|
||||||
|
processing_time_sec?: number
|
||||||
|
formats_available: string[]
|
||||||
|
document_structure: DocStructure
|
||||||
|
images?: DocImage[]
|
||||||
|
stats: Record<string, number>
|
||||||
|
content: { preview_markdown?: string; preview_html?: string; markdown?: string; html?: string }
|
||||||
|
table_preview?: string[]
|
||||||
|
errors?: string[]
|
||||||
|
parse_json_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Capabilities = {
|
||||||
|
maturity_dimensions: { id: string; label: string; description: string }[]
|
||||||
|
maturity_levels: { min_score: number; label: string; description: string }[]
|
||||||
|
supported_data_formats: string[]
|
||||||
|
supported_document_formats: string[]
|
||||||
|
tools: Record<string, { status: string; capabilities?: string[] }>
|
||||||
|
docling_online: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportSummary = {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
ts: string
|
||||||
|
overall_score: number
|
||||||
|
maturity_level: string
|
||||||
|
rows: number
|
||||||
|
columns: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'assess' | 'docling' | 'reports'
|
||||||
|
|
||||||
|
const SCORE_COLOR = (s: number) => (s >= 80 ? 'text-success' : s >= 60 ? 'text-warning' : 'text-danger')
|
||||||
|
const BAR_COLOR = (s: number) => (s >= 80 ? 'bg-success' : s >= 60 ? 'bg-warning' : 'bg-danger')
|
||||||
|
|
||||||
|
export function DataQualityView() {
|
||||||
|
const [tab, setTab] = useState<Tab>('assess')
|
||||||
|
const [caps, setCaps] = useState<Capabilities | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [assess, setAssess] = useState<AssessResult | null>(null)
|
||||||
|
const [parse, setParse] = useState<ParseResult | null>(null)
|
||||||
|
const [parseFormat, setParseFormat] = useState<'markdown' | 'html'>('markdown')
|
||||||
|
const [reports, setReports] = useState<ReportSummary[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [expandedCol, setExpandedCol] = useState<string | null>(null)
|
||||||
|
const [showGx, setShowGx] = useState(false)
|
||||||
|
const [showSoda, setShowSoda] = useState(false)
|
||||||
|
|
||||||
|
const loadMeta = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [c, r] = await Promise.all([fetch('/dq/capabilities'), fetch('/dq/reports')])
|
||||||
|
if (c.ok) setCaps(await c.json())
|
||||||
|
if (r.ok) {
|
||||||
|
const j = await r.json()
|
||||||
|
setReports(j.reports || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMeta()
|
||||||
|
}, [loadMeta])
|
||||||
|
|
||||||
|
const onAssess = async (file: File) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setAssess(null)
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
try {
|
||||||
|
const r = await fetch('/dq/assess', { method: 'POST', body: fd })
|
||||||
|
const j = await r.json()
|
||||||
|
if (!r.ok || !j.ok) {
|
||||||
|
setError(j.error || j.detail || 'Assessment failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAssess(j as AssessResult)
|
||||||
|
loadMeta()
|
||||||
|
} catch {
|
||||||
|
setError('Connection failed — check DQ API')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onParse = async (file: File) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setParse(null)
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
fd.append('to_formats', 'md,html,json')
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), 300000)
|
||||||
|
try {
|
||||||
|
const r = await fetch('/dq/parse', { method: 'POST', body: fd, signal: ctrl.signal })
|
||||||
|
const j = await r.json()
|
||||||
|
if (!r.ok || !j.ok) {
|
||||||
|
setError(typeof j.error === 'string' ? j.error : JSON.stringify(j.error || j).slice(0, 200) || 'Docling parse failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setParse(j as ParseResult)
|
||||||
|
loadMeta()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error && e.name === 'AbortError' ? 'Timeout — document too large or Docling overloaded' : 'Docling unavailable')
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: { id: Tab; label: string; icon: typeof FileSearch }[] = [
|
||||||
|
{ id: 'assess', label: 'Maturity Assessment', icon: FileSearch },
|
||||||
|
{ id: 'docling', label: 'Docling Parser', icon: FileText },
|
||||||
|
{ id: 'reports', label: 'Reports', icon: CheckCircle2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised">
|
||||||
|
<header className="shrink-0 border-b border-border bg-surface-overlay/30 px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Data Quality & Maturity Platform</h2>
|
||||||
|
<p className="text-[11px] text-foreground-muted">
|
||||||
|
Full data maturity assessment for customer data — Docling, Great Expectations, Soda Core
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('rounded-full px-2.5 py-1 text-[10px] font-medium', caps?.docling_online ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger')}>
|
||||||
|
Docling {caps?.docling_online ? '● online' : '○ offline'}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={loadMeta} className="rounded border border-border p-1.5 hover:bg-surface-overlay">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<a href={`http://${window.location.hostname}:5001/ui/`} target="_blank" rel="noreferrer" className="rounded border border-docker/40 bg-docker/15 px-2 py-1 text-[10px] text-docker">
|
||||||
|
Docling UI ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{caps && (
|
||||||
|
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<CapCard title="Maturity Engine" items={caps.maturity_dimensions.map((d) => d.label)} icon={Layers} />
|
||||||
|
<CapCard title="Data Quality Tools" items={['Great Expectations', 'Soda Core', 'Pandas Profiling']} icon={CheckCircle2} />
|
||||||
|
<CapCard title="Document Parsing" items={caps.tools.docling?.capabilities || ['PDF', 'PPTX', 'DOCX']} icon={FileText} />
|
||||||
|
<CapCard title="File formats" items={[...caps.supported_data_formats.slice(0, 4), ...caps.supported_document_formats.slice(0, 3)]} icon={Upload} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 gap-1 border-b border-border bg-surface-overlay/20 px-3 py-2">
|
||||||
|
{tabs.map(({ id, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(id)}
|
||||||
|
className={cn('flex items-center gap-1.5 rounded-md px-3 py-2 text-[11px] font-medium transition-all', tab === id ? subTabActive : subTabIdle)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 flex items-start gap-2 rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-[11px] text-danger">
|
||||||
|
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'assess' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<UploadZone
|
||||||
|
loading={loading}
|
||||||
|
label="Upload customer data for full maturity assessment"
|
||||||
|
hint="CSV · Excel · JSON · Parquet · PDF · PPTX · DOCX"
|
||||||
|
accept=".csv,.tsv,.xlsx,.xls,.json,.parquet,.pdf,.pptx,.ppt,.docx"
|
||||||
|
onFile={onAssess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && <LoadingMsg text="Analyzing: 6 maturity dimensions · GE checks · Soda checks · column profiles…" />}
|
||||||
|
|
||||||
|
{assess && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<StatCard label="Overall Score" value={`${assess.overall_score}`} sub="/100" accent />
|
||||||
|
<StatCard label="Maturity Level" value={assess.maturity_level} sub={assess.maturity_description} />
|
||||||
|
<StatCard label="Dataset" value={`${assess.rows.toLocaleString()}`} sub={`${assess.columns} columns`} />
|
||||||
|
<StatCard label="Great Expectations" value={`${assess.checks_summary.great_expectations.passed}/${assess.checks_summary.great_expectations.total}`} sub="checks passed" />
|
||||||
|
<StatCard label="Soda Core" value={String(assess.checks_summary.soda_core.warnings)} sub="warnings" warn={assess.checks_summary.soda_core.warnings > 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assess.docling?.used && assess.docling.document_structure && (
|
||||||
|
<>
|
||||||
|
<DocStructurePanel structure={assess.docling.document_structure} title="Document structure (via Docling)" />
|
||||||
|
{assess.docling.images && assess.docling.images.length > 0 && assess.docling.parse_id && (
|
||||||
|
<ImageGallery images={assess.docling.images} parseId={assess.docling.parse_id} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{assess.rag_ingest && (
|
||||||
|
<div className={cn(
|
||||||
|
'rounded-lg border px-3 py-2 text-[11px]',
|
||||||
|
assess.rag_ingest.ok ? 'border-success/30 bg-success/10 text-success' : 'border-warning/30 bg-warning/10 text-warning',
|
||||||
|
)}>
|
||||||
|
<p className="font-medium">Knowledge Chat sync</p>
|
||||||
|
<p className="text-foreground-muted">
|
||||||
|
{assess.rag_ingest.ok
|
||||||
|
? (assess.rag_ingest.duplicate
|
||||||
|
? `Already in Knowledge Chat — ${assess.rag_ingest.message || 'you can chat immediately.'}`
|
||||||
|
: `Indexed for chat: ${assess.rag_ingest.chunks ?? '?'} text chunks. Open Knowledge Chat to ask questions.`)
|
||||||
|
: (assess.rag_ingest.error || 'Could not sync to Knowledge Chat')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<a href={assess.report_url} target="_blank" rel="noreferrer" className={cn('rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
|
||||||
|
Full HTML report ↗
|
||||||
|
</a>
|
||||||
|
<button type="button" onClick={() => setShowGx(!showGx)} className={cn('rounded-md px-3 py-1.5 text-[11px]', showGx ? subTabActive : subTabIdle)}>
|
||||||
|
GE checks ({assess.checks?.great_expectations.length || 0})
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setShowSoda(!showSoda)} className={cn('rounded-md px-3 py-1.5 text-[11px]', showSoda ? subTabActive : subTabIdle)}>
|
||||||
|
Soda checks ({assess.checks?.soda_core.length || 0})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showGx && assess.checks?.great_expectations && (
|
||||||
|
<CheckTable title="Great Expectations" rows={assess.checks.great_expectations.map((c) => ({
|
||||||
|
name: c.column ? `${c.expectation} [${c.column}]` : c.expectation,
|
||||||
|
status: c.success ? 'pass' : 'fail',
|
||||||
|
detail: c.result,
|
||||||
|
}))} />
|
||||||
|
)}
|
||||||
|
{showSoda && assess.checks?.soda_core && (
|
||||||
|
<CheckTable title="Soda Core" rows={assess.checks.soda_core.map((c) => ({
|
||||||
|
name: c.name,
|
||||||
|
status: c.outcome,
|
||||||
|
detail: `${c.check} — ${c.detail}`,
|
||||||
|
}))} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">6 Maturity Dimensions</h3>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{assess.dimensions.map((d) => (
|
||||||
|
<DimensionCard key={d.id} dimension={d} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{assess.action_items.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 text-warning" /> Remediation Roadmap
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{assess.action_items.map((a, i) => (
|
||||||
|
<div key={i} className={cn('rounded-lg border px-3 py-2 text-[11px]', a.priority === 'high' ? 'border-danger/40 bg-danger/10' : a.priority === 'medium' ? 'border-warning/40 bg-warning/10' : 'border-border bg-surface-overlay/40')}>
|
||||||
|
<span className="font-bold uppercase text-foreground-faint">{a.priority}</span>
|
||||||
|
{' · '}<strong>{a.dimension}</strong> ({a.score}): {a.action}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
|
||||||
|
Column profiles ({assess.column_profiles.length})
|
||||||
|
</h3>
|
||||||
|
<ColumnTable profiles={assess.column_profiles} expandedCol={expandedCol} onToggle={setExpandedCol} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'docling' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p className="text-[12px] leading-relaxed text-foreground-muted">
|
||||||
|
Docling extracts text, tables, images and document structure from PDF, PowerPoint, Word, Excel and images.
|
||||||
|
Resultaat: Markdown, HTML, JSON met pagina's, plaatjes, tabellen en outline.
|
||||||
|
</p>
|
||||||
|
<UploadZone
|
||||||
|
loading={loading}
|
||||||
|
label="Upload document for Docling parsing"
|
||||||
|
hint="PDF · PPTX · DOCX · XLSX · PNG · JPG · TIFF · MD · HTML"
|
||||||
|
accept=".pdf,.pptx,.ppt,.docx,.doc,.xlsx,.png,.jpg,.jpeg,.tiff,.txt,.md,.html"
|
||||||
|
onFile={onParse}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && <LoadingMsg text="Docling processing document — OCR, table detection, images (30–180 sec)…" />}
|
||||||
|
|
||||||
|
{parse && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
|
||||||
|
<StatCard label="Bestand" value={parse.filename.length > 20 ? parse.filename.slice(0, 18) + '…' : parse.filename} sub={parse.status} />
|
||||||
|
<StatCard label="Verwerking" value={`${(parse.processing_time_sec || 0).toFixed(1)}s`} sub={`Formats: ${parse.formats_available.join(', ')}`} />
|
||||||
|
<StatCard label="Pages" value={String(parse.document_structure?.pages ?? parse.stats.pages ?? 0)} icon={Layers} />
|
||||||
|
<StatCard label="Images" value={String(parse.document_structure?.pictures ?? 0)} icon={Image} accent />
|
||||||
|
<StatCard label="Tables" value={String(parse.document_structure?.tables ?? 0)} icon={Table2} />
|
||||||
|
<StatCard label="Text blocks" value={String(parse.document_structure?.text_blocks ?? 0)} sub={`${parse.stats.words?.toLocaleString() ?? 0} words`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocStructurePanel structure={parse.document_structure} title="Document analysis" />
|
||||||
|
|
||||||
|
{parse.images && parse.images.filter((i) => i.available).length > 0 && (
|
||||||
|
<ImageGallery images={parse.images} parseId={parse.parse_id} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parse.document_structure?.outline && parse.document_structure.outline.length > 0 && (
|
||||||
|
<section className="rounded-lg border border-border bg-surface-overlay/30 p-3">
|
||||||
|
<h3 className="mb-2 text-[11px] font-semibold uppercase text-foreground-faint">Document outline</h3>
|
||||||
|
<ul className="space-y-1 text-[11px]">
|
||||||
|
{parse.document_structure.outline.map((o, i) => (
|
||||||
|
<li key={i} className="flex gap-2" style={{ paddingLeft: (o.level || 0) * 12 }}>
|
||||||
|
<span className="shrink-0 rounded bg-docker/20 px-1 font-mono text-[9px] text-docker">{o.type}</span>
|
||||||
|
<span className="text-foreground-muted">{o.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['markdown', 'html'] as const).map((f) => (
|
||||||
|
<button key={f} type="button" onClick={() => setParseFormat(f)} className={cn('rounded-md px-3 py-1.5 text-[11px] font-medium', parseFormat === f ? subTabActive : subTabIdle)}>
|
||||||
|
{f.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{parse.parse_json_url && (
|
||||||
|
<a href={parse.parse_json_url} target="_blank" rel="noreferrer" className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
|
||||||
|
Full JSON ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parse.table_preview && parse.table_preview.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-1 text-[11px] font-semibold uppercase text-foreground-faint">Tables (markdown preview)</h3>
|
||||||
|
<pre className="scrollbar-thin max-h-40 overflow-auto rounded-lg border border-border bg-surface-overlay p-3 font-mono text-[10px]">
|
||||||
|
{parse.table_preview.join('\n')}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-[11px] font-semibold uppercase text-foreground-faint">Extracted content</h3>
|
||||||
|
{parseFormat === 'html' && (parse.content.preview_html || parse.content.html) ? (
|
||||||
|
<div className="scrollbar-thin max-h-[500px] overflow-auto rounded-lg border border-border bg-surface-overlay p-2">
|
||||||
|
<div className="rounded bg-white p-4 text-black" dangerouslySetInnerHTML={{ __html: parse.content.preview_html || parse.content.html || '' }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<pre className="scrollbar-thin max-h-[500px] overflow-auto rounded-lg border border-border bg-surface-overlay p-4 font-mono text-[11px] leading-relaxed text-foreground">
|
||||||
|
{parse.content.preview_markdown || parse.content.markdown || '(no content)'}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'reports' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{reports.length === 0 ? (
|
||||||
|
<p className="py-12 text-center text-sm text-foreground-muted">No reports yet — upload customer data in Maturity Assessment.</p>
|
||||||
|
) : (
|
||||||
|
reports.map((r) => (
|
||||||
|
<a key={r.id} href={`/dq/report/${r.id}`} target="_blank" rel="noreferrer"
|
||||||
|
className="flex items-center justify-between rounded-lg border border-border bg-surface-overlay/30 px-4 py-3 transition-all hover:border-docker/40 hover:bg-docker/10">
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] font-medium">{r.filename}</p>
|
||||||
|
<p className="text-[10px] text-foreground-faint">{r.ts} · {r.rows?.toLocaleString()} rows · {r.columns} cols</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className={cn('text-xl font-bold', SCORE_COLOR(r.overall_score))}>{r.overall_score}</p>
|
||||||
|
<p className="text-[10px] text-foreground-muted">{r.maturity_level}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageGallery({ images, parseId }: { images: DocImage[]; parseId: string }) {
|
||||||
|
const available = images.filter((i) => i.available)
|
||||||
|
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||||
|
if (!available.length) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
|
||||||
|
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
|
||||||
|
Images gedetecteerd ({images.length}) — no embedded export
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-foreground-muted">Re-upload the document to extract images (embedded mode).</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
|
||||||
|
<h3 className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
|
||||||
|
<Image className="h-4 w-4 text-docker" />
|
||||||
|
Images die Docling ziet ({available.length})
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{available.map((img) => (
|
||||||
|
<button
|
||||||
|
key={img.index}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLightbox(img.index)}
|
||||||
|
className="group overflow-hidden rounded-lg border border-border bg-surface-raised text-left transition-all hover:border-docker/50 hover:shadow-docker"
|
||||||
|
>
|
||||||
|
<div className="flex aspect-[4/3] items-center justify-center overflow-hidden bg-black/20">
|
||||||
|
<img
|
||||||
|
src={img.url || `/dq/parse/${parseId}/image/${img.index}`}
|
||||||
|
alt={img.label}
|
||||||
|
className="max-h-full max-w-full object-contain transition-transform group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-2">
|
||||||
|
<p className="text-[10px] font-medium text-foreground">#{img.index + 1} {img.label}</p>
|
||||||
|
<p className="text-[9px] text-foreground-faint">
|
||||||
|
{img.width && img.height ? `${Math.round(img.width)}×${Math.round(img.height)}` : ''}
|
||||||
|
{img.dpi ? ` · ${img.dpi}dpi` : ''}
|
||||||
|
{img.bytes ? ` · ${(img.bytes / 1024).toFixed(0)}KB` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{lightbox !== null && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" onClick={() => setLightbox(null)}>
|
||||||
|
<div className="relative max-h-[90vh] max-w-[90vw]" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<img
|
||||||
|
src={`/dq/parse/${parseId}/image/${lightbox}`}
|
||||||
|
alt={`Image ${lightbox + 1}`}
|
||||||
|
className="max-h-[85vh] max-w-full rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setLightbox(null)} className="absolute -top-3 -right-3 rounded-full bg-surface-raised px-2 py-1 text-xs text-foreground">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocStructurePanel({ structure, title }: { structure: DocStructure; title: string }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
|
||||||
|
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">{title}</h3>
|
||||||
|
<div className="mb-3 grid grid-cols-3 gap-2 sm:grid-cols-6">
|
||||||
|
{[
|
||||||
|
{ label: 'Pagina\'s', value: structure.pages, icon: Layers },
|
||||||
|
{ label: 'Images', value: structure.pictures, icon: Image },
|
||||||
|
{ label: 'Tables', value: structure.tables, icon: Table2 },
|
||||||
|
{ label: 'Headings', value: structure.headings },
|
||||||
|
{ label: 'Paragraphs', value: structure.paragraphs },
|
||||||
|
{ label: 'Text blocks', value: structure.text_blocks },
|
||||||
|
].map(({ label, value, icon: Icon }) => (
|
||||||
|
<div key={label} className="rounded-md border border-border bg-surface-raised p-2 text-center">
|
||||||
|
{Icon && <Icon className="mx-auto mb-1 h-4 w-4 text-docker" />}
|
||||||
|
<p className="text-lg font-bold text-foreground">{value}</p>
|
||||||
|
<p className="text-[9px] text-foreground-faint">{label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{structure.picture_details && structure.picture_details.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="mb-1 text-[10px] font-medium text-foreground-muted">Images ({structure.picture_details.length})</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{structure.picture_details.map((p) => (
|
||||||
|
<span key={p.index} className="rounded border border-border bg-surface-raised px-2 py-0.5 text-[9px]">
|
||||||
|
#{p.index + 1} {p.label} {p.has_image ? '🖼' : ''} {p.captions > 0 ? `(${p.captions} captions)` : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{structure.table_details && structure.table_details.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-[10px] font-medium text-foreground-muted">Tables ({structure.table_details.length})</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{structure.table_details.map((t) => (
|
||||||
|
<div key={t.index} className="rounded border border-border bg-surface-raised px-2 py-1 text-[10px] text-foreground-muted">
|
||||||
|
Table {t.index + 1}: {t.rows}×{t.cols} ({t.cells} cells) — {t.preview || '…'}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DimensionCard({ dimension: d }: { dimension: Dimension }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface-overlay/40 p-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-[12px] font-semibold">{d.label}</span>
|
||||||
|
<span className={cn('text-base font-bold', SCORE_COLOR(d.score))}>{d.score}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 h-2 overflow-hidden rounded-full bg-border">
|
||||||
|
<div className={cn('h-full rounded-full', BAR_COLOR(d.score))} style={{ width: `${d.score}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-[10px] text-foreground-faint">{d.description}</p>
|
||||||
|
<ul className="space-y-0.5 text-[10px] text-foreground-muted">
|
||||||
|
{d.findings.map((f) => (
|
||||||
|
<li key={f} className="flex gap-1"><span className="text-docker">▸</span>{f}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{d.recommended_actions && d.recommended_actions.length > 0 && (
|
||||||
|
<p className="mt-2 border-t border-border pt-2 text-[9px] text-warning">→ {d.recommended_actions[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColumnTable({ profiles, expandedCol, onToggle }: { profiles: ColumnProfile[]; expandedCol: string | null; onToggle: (n: string | null) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table className="w-full text-left text-[11px]">
|
||||||
|
<thead className="bg-surface-overlay text-[10px] uppercase text-foreground-faint">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">Column</th><th className="px-3 py-2">Type</th><th className="px-3 py-2">Null%</th>
|
||||||
|
<th className="px-3 py-2">Unique</th><th className="px-3 py-2">Flags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{profiles.map((c) => (
|
||||||
|
<Fragment key={c.name}>
|
||||||
|
<tr className="cursor-pointer border-t border-border hover:bg-surface-overlay/50" onClick={() => onToggle(expandedCol === c.name ? null : c.name)}>
|
||||||
|
<td className="px-3 py-2 font-mono text-docker">{c.name}</td>
|
||||||
|
<td className="px-3 py-2">{c.dtype}</td>
|
||||||
|
<td className={cn('px-3 py-2', c.null_pct > 10 && 'font-semibold text-warning')}>{c.null_pct}%</td>
|
||||||
|
<td className="px-3 py-2">{c.unique_count.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-2 text-foreground-muted">{c.quality_flags.join(', ') || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
{expandedCol === c.name && (
|
||||||
|
<tr className="border-t border-border bg-surface-overlay/20">
|
||||||
|
<td colSpan={5} className="px-4 py-2 text-[10px] text-foreground-muted">
|
||||||
|
{c.sample_values?.length ? <p className="mb-1">Samples: {c.sample_values.join(' · ')}</p> : null}
|
||||||
|
{c.numeric && <p>Range {c.numeric.min} – {c.numeric.max}, μ={c.numeric.mean}, {c.numeric.outliers} outliers</p>}
|
||||||
|
{c.text && <p>Avg len {c.text.avg_length}, {c.text.empty_strings} empty strings</p>}
|
||||||
|
{c.top_values?.map((tv) => <span key={tv.value} className="mr-3">{tv.value} ({tv.count})</span>)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckTable({ title, rows }: { title: string; rows: { name: string; status: string; detail: string }[] }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<p className="border-b border-border bg-surface-overlay px-3 py-2 text-[11px] font-semibold">{title}</p>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<tr key={i} className="border-t border-border">
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<span className={cn('mr-2 rounded px-1.5 py-0.5 text-[9px] font-bold uppercase',
|
||||||
|
r.status === 'pass' ? 'bg-success/20 text-success' : r.status === 'warn' ? 'bg-warning/20 text-warning' : 'bg-danger/20 text-danger')}>
|
||||||
|
{r.status}
|
||||||
|
</span>
|
||||||
|
{r.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-foreground-muted">{r.detail}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapCard({ title, items, icon: Icon }: { title: string; items: string[]; icon: typeof Layers }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface-raised/80 p-2.5">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<Icon className="h-3.5 w-3.5 text-docker" />
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-foreground-faint">{title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] leading-relaxed text-foreground-muted">{items.join(' · ')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadZone({ label, hint, accept, loading, onFile }: { label: string; hint: string; accept: string; loading: boolean; onFile: (f: File) => void }) {
|
||||||
|
return (
|
||||||
|
<label className={cn('flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-border/80 bg-surface-overlay/30 px-8 py-10 transition-all hover:border-docker/50 hover:bg-docker/5', loading && 'pointer-events-none opacity-50')}>
|
||||||
|
<Upload className="mb-3 h-10 w-10 text-docker opacity-60" />
|
||||||
|
<p className="text-[13px] font-medium text-foreground">{label}</p>
|
||||||
|
<p className="mt-1 text-[10px] text-foreground-faint">{hint}</p>
|
||||||
|
<input type="file" accept={accept} className="hidden" disabled={loading} onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingMsg({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-3 rounded-lg border border-docker/30 bg-docker/5 py-10 text-sm text-foreground-muted">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-docker" />
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, sub, accent, warn, icon: Icon }: { label: string; value: string; sub?: string; accent?: boolean; warn?: boolean; icon?: typeof Image }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface-overlay/40 p-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Icon && <Icon className="h-3.5 w-3.5 text-docker" />}
|
||||||
|
<p className="text-[9px] uppercase tracking-wider text-foreground-faint">{label}</p>
|
||||||
|
</div>
|
||||||
|
<p className={cn('text-xl font-bold', accent ? 'text-docker' : warn ? 'text-warning' : 'text-foreground')}>{value}</p>
|
||||||
|
{sub && <p className="text-[10px] text-foreground-muted">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { Activity, Cpu, ExternalLink, Thermometer, Zap } from 'lucide-react'
|
||||||
|
import { fetchGpu } from '../../lib/api'
|
||||||
|
import type { GpuDevice, GpuStatus } from '../../types'
|
||||||
|
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
gpu: GpuStatus | null
|
||||||
|
live: GpuLiveMetrics
|
||||||
|
boost?: boolean
|
||||||
|
onSelectGpu?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function memPct(used: number, total: number) {
|
||||||
|
if (!total) return 0
|
||||||
|
return Math.round((used / total) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function utilColor(pct: number) {
|
||||||
|
if (pct >= 75) return 'bg-danger'
|
||||||
|
if (pct >= 35) return 'bg-warning'
|
||||||
|
return 'bg-success'
|
||||||
|
}
|
||||||
|
|
||||||
|
function GpuRow({ device, liveUtil, active }: { device: GpuDevice; liveUtil: number; active: boolean }) {
|
||||||
|
const vramPct = memPct(device.memory_used_mib, device.memory_total_mib)
|
||||||
|
const util = liveUtil ?? device.util_gpu
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-border/80 bg-surface-overlay/60 px-2 py-1.5 transition-colors',
|
||||||
|
active && util > 5 && 'border-docker/30 bg-docker/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-1">
|
||||||
|
<span className="font-mono text-[9px] font-semibold text-foreground">GPU {device.index}</span>
|
||||||
|
<span className="font-mono text-[8px] text-foreground-faint">{util.toFixed(0)}% · {vramPct}% VRAM</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<MetricBar label="Util" value={util} colorClass={utilColor(util)} />
|
||||||
|
<MetricBar label="VRAM" value={vramPct} colorClass="bg-docker" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex justify-between font-mono text-[7px] text-foreground-faint">
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
<Thermometer className="h-2.5 w-2.5" />
|
||||||
|
{device.temperature_c?.toFixed(0) ?? '—'}°C
|
||||||
|
</span>
|
||||||
|
<span>{device.power_w?.toFixed(0) ?? '—'} W</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricBar({ label, value, colorClass }: { label: string; value: number; colorClass: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-7 shrink-0 text-[7px] text-foreground-faint">{label}</span>
|
||||||
|
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-raised">
|
||||||
|
<div
|
||||||
|
className={cn('h-full rounded-full transition-all duration-700 ease-out', colorClass)}
|
||||||
|
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props) {
|
||||||
|
const [localGpu, setLocalGpu] = useState<GpuStatus | null>(gpu)
|
||||||
|
const [lastPoll, setLastPoll] = useState<Date | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalGpu(gpu)
|
||||||
|
}, [gpu])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const poll = async () => {
|
||||||
|
const g = await fetchGpu()
|
||||||
|
if (g) {
|
||||||
|
setLocalGpu(g)
|
||||||
|
setLastPoll(new Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
poll()
|
||||||
|
const ms = boost ? 1000 : 3000
|
||||||
|
const iv = setInterval(poll, ms)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
}, [boost])
|
||||||
|
|
||||||
|
const g = localGpu
|
||||||
|
const devices = g?.gpus || []
|
||||||
|
const inferenceOn = g?.ok && g.inference_active
|
||||||
|
const modelLabel = g?.active_model?.replace(/\s*GPTQ$/i, '').replace(/\s*AWQ$/i, '') || 'No model'
|
||||||
|
|
||||||
|
const avgUtil = useMemo(() => {
|
||||||
|
if (devices.length) {
|
||||||
|
const sum = devices.reduce((s, d, i) => s + (live.deviceUtils[i] ?? d.util_gpu), 0)
|
||||||
|
return sum / devices.length
|
||||||
|
}
|
||||||
|
return live.avgUtil
|
||||||
|
}, [devices, live.avgUtil, live.deviceUtils])
|
||||||
|
|
||||||
|
const avgVram = useMemo(() => {
|
||||||
|
if (devices.length) {
|
||||||
|
return devices.reduce((s, d) => s + memPct(d.memory_used_mib, d.memory_total_mib), 0) / devices.length
|
||||||
|
}
|
||||||
|
return live.avgVram
|
||||||
|
}, [devices, live.avgVram])
|
||||||
|
|
||||||
|
if (!g?.ok) {
|
||||||
|
return (
|
||||||
|
<section className="border-b border-border p-3">
|
||||||
|
<h2 className="mb-2 flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">
|
||||||
|
<Cpu className="h-3 w-3" /> GPU Matrix
|
||||||
|
</h2>
|
||||||
|
<p className="text-[9px] text-foreground-faint">GPU Lab offline</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border-b border-border p-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelectGpu}
|
||||||
|
className="mb-2 flex w-full items-start justify-between gap-1 text-left hover:opacity-90"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">
|
||||||
|
<Cpu className="h-3 w-3 text-docker" /> GPU Matrix
|
||||||
|
{boost && (
|
||||||
|
<span className="inline-flex items-center gap-0.5 rounded border border-docker/40 bg-docker/10 px-1 py-px text-[7px] font-bold normal-case tracking-normal text-docker">
|
||||||
|
<Activity className="h-2.5 w-2.5 animate-pulse" /> Live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 truncate text-[10px] font-medium text-foreground">{modelLabel}</p>
|
||||||
|
<p className="font-mono text-[8px] text-foreground-faint">{g.gpu_count ?? devices.length}× V100 · {g.host}</p>
|
||||||
|
</div>
|
||||||
|
{g.ui_url && (
|
||||||
|
<a
|
||||||
|
href={g.ui_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="shrink-0 text-docker hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mb-2 grid grid-cols-3 gap-1">
|
||||||
|
<StatChip
|
||||||
|
label="Status"
|
||||||
|
value={inferenceOn ? 'Active' : 'Idle'}
|
||||||
|
accent={inferenceOn ? 'text-success' : 'text-foreground-muted'}
|
||||||
|
/>
|
||||||
|
<StatChip label="Util" value={`${avgUtil.toFixed(0)}%`} accent={avgUtil > 20 ? 'text-warning' : 'text-foreground'} />
|
||||||
|
<StatChip
|
||||||
|
label="tok/s"
|
||||||
|
value={boost && inferenceOn ? String(live.tokenThroughput) : inferenceOn ? '—' : '0'}
|
||||||
|
icon={<Zap className="h-2.5 w-2.5 text-amber-400" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scrollbar-thin max-h-[280px] space-y-1.5 overflow-y-auto">
|
||||||
|
{devices.map((d, i) => (
|
||||||
|
<GpuRow
|
||||||
|
key={d.index}
|
||||||
|
device={d}
|
||||||
|
liveUtil={live.deviceUtils[i] ?? d.util_gpu}
|
||||||
|
active={boost}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-1.5 font-mono text-[7px] text-foreground-faint">
|
||||||
|
VRAM avg {avgVram.toFixed(0)}% · poll {boost ? '1s' : '3s'}
|
||||||
|
{lastPoll && ` · ${lastPoll.toLocaleTimeString()}`}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatChip({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
accent,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
accent?: string
|
||||||
|
icon?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-border bg-surface-overlay/80 px-1.5 py-1 text-center">
|
||||||
|
<p className="flex items-center justify-center gap-0.5 text-[7px] text-foreground-faint">{icon}{label}</p>
|
||||||
|
<p className={cn('font-mono text-[9px] font-semibold', accent || 'text-foreground')}>{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Cpu, ExternalLink, Zap } from 'lucide-react'
|
||||||
|
import type { GpuStatus } from '../../types'
|
||||||
|
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
|
||||||
|
import { Badge } from '../ui/Badge'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
gpu: GpuStatus | null
|
||||||
|
live: GpuLiveMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GpuMonitor({ gpu, live }: Props) {
|
||||||
|
if (!gpu) {
|
||||||
|
return (
|
||||||
|
<div className="panel flex h-[148px] shrink-0 items-center px-2.5 py-1.5 text-[9px] text-foreground-faint">GPU offline</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferenceOn = gpu.inference_active && gpu.ok
|
||||||
|
const devices = gpu.gpus || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel flex h-[148px] shrink-0 flex-col px-2.5 py-1.5">
|
||||||
|
<div className="flex min-h-0 flex-1 flex-nowrap items-center gap-x-3 gap-y-1 overflow-x-auto overflow-y-hidden">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Cpu className="h-3 w-3 text-docker" />
|
||||||
|
<span className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">GPU</span>
|
||||||
|
<Badge variant={inferenceOn ? 'success' : 'default'} className="!py-0">
|
||||||
|
{inferenceOn ? 'ON' : 'Standby'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Chip label="Model" value={gpu.active_model?.split('-')[0] || '—'} />
|
||||||
|
<Chip label="tok/s" value={inferenceOn ? String(live.tokenThroughput) : '—'} icon={<Zap className="h-2.5 w-2.5 text-amber-500" />} />
|
||||||
|
<Chip label="Util" value={`${Math.round(live.avgUtil)}%`} />
|
||||||
|
<Chip label="VRAM" value={`${Math.round(live.avgVram)}%`} />
|
||||||
|
{devices.slice(0, 4).map((d, i) => (
|
||||||
|
<Chip key={d.index} label={`G${d.index}`} value={`${live.deviceUtils[i] ?? d.util_gpu}%`} />
|
||||||
|
))}
|
||||||
|
{gpu.ui_url && (
|
||||||
|
<a href={gpu.ui_url} target="_blank" rel="noreferrer" className="ml-auto flex items-center gap-0.5 text-[9px] text-docker hover:underline">
|
||||||
|
{gpu.host} <ExternalLink className="h-2.5 w-2.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ label, value, icon }: { label: string; value: string; icon?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 rounded border border-border bg-surface-overlay/80 px-1.5 py-0.5">
|
||||||
|
<span className="flex items-center gap-0.5 text-[8px] text-foreground-faint">{icon}{label}</span>
|
||||||
|
<span className="font-mono text-[9px] font-medium text-foreground">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { ExternalLink, RefreshCw, Terminal } from 'lucide-react'
|
||||||
|
import type { Agent, WorkloadData } from '../../types'
|
||||||
|
import { getAgentMeta } from '../../lib/agentMeta'
|
||||||
|
import { copyShellCommand, INFRA_CATALOG, type InfraNode } from '../../lib/infraCatalog'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
workload: WorkloadData | null
|
||||||
|
agents: Agent[]
|
||||||
|
selectedNodeId: string | null
|
||||||
|
busy: boolean
|
||||||
|
onSelectNode: (id: string) => void
|
||||||
|
onSelectAgent: (id: string) => void
|
||||||
|
onProbe: (nodeId: string) => void
|
||||||
|
onOpenTerminal: (nodeId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoneStats(workload: WorkloadData | null, zoneId: string) {
|
||||||
|
const z = workload?.zones?.find((x) => x.id === zoneId)
|
||||||
|
if (!z) return null
|
||||||
|
return `${z.running}/${z.total}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfraQuickAccess({
|
||||||
|
workload,
|
||||||
|
agents,
|
||||||
|
selectedNodeId,
|
||||||
|
busy,
|
||||||
|
onSelectNode,
|
||||||
|
onSelectAgent,
|
||||||
|
onProbe,
|
||||||
|
onOpenTerminal,
|
||||||
|
}: Props) {
|
||||||
|
const handleShell = async (node: InfraNode) => {
|
||||||
|
await copyShellCommand(node.ssh)
|
||||||
|
onSelectNode(node.id)
|
||||||
|
onOpenTerminal(node.id)
|
||||||
|
onProbe(node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel shrink-0 p-2">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">Infrastructure & Apps</h3>
|
||||||
|
<p className="text-[8px] text-foreground-faint">Klik voor inspector · Shell kopieert SSH en opent live terminal · UI opent de applicatie</p>
|
||||||
|
</div>
|
||||||
|
{workload && (
|
||||||
|
<span className="shrink-0 font-mono text-[8px] text-foreground-faint">
|
||||||
|
{workload.totals.apps_running}/{workload.totals.apps_total} containers
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||||
|
{INFRA_CATALOG.map((node) => {
|
||||||
|
const Icon = node.icon
|
||||||
|
const agent = agents.find((a) => a.id === node.agentId)
|
||||||
|
const meta = agent ? getAgentMeta(agent.id) : null
|
||||||
|
const active = selectedNodeId === node.id || node.topoIds.includes(selectedNodeId || '')
|
||||||
|
const stats = zoneStats(workload, node.zone)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col rounded-lg border bg-surface-overlay/60 p-2 transition-colors',
|
||||||
|
active ? 'border-docker/50 ring-1 ring-docker/20' : 'border-border hover:border-border-strong',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button type="button" onClick={() => onSelectNode(node.id)} className="mb-1.5 text-left">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-surface" style={{ color: node.accent }}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-[11px] font-semibold text-foreground">{node.label}</p>
|
||||||
|
<p className="font-mono text-[8px] text-foreground-faint">{node.vm} · {node.ip}</p>
|
||||||
|
{stats && <p className="font-mono text-[8px] text-foreground-muted">{stats} running</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 line-clamp-2 text-[9px] leading-snug text-foreground-muted">{node.description}</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{agent && meta && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectAgent(agent.id)}
|
||||||
|
className="mb-1.5 truncate text-left text-[8px] hover:text-docker"
|
||||||
|
style={{ color: meta.accent }}
|
||||||
|
>
|
||||||
|
Agent: {agent.name.split(' ·')[0]}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-auto flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleShell(node)}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-border bg-surface px-1.5 py-0.5 text-[8px] text-foreground-muted hover:border-docker/40 hover:text-docker"
|
||||||
|
title={node.ssh}
|
||||||
|
>
|
||||||
|
<Terminal className="h-3 w-3" /> Shell
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onSelectNode(node.id); onProbe(node.id) }}
|
||||||
|
disabled={busy}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-border bg-surface px-1.5 py-0.5 text-[8px] text-foreground-muted hover:border-border-strong disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-3 w-3', busy && active && 'animate-spin')} /> Probe
|
||||||
|
</button>
|
||||||
|
{node.apps.slice(0, 2).map((app) => (
|
||||||
|
<a
|
||||||
|
key={app.url}
|
||||||
|
href={app.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-docker/25 bg-docker-light/40 px-1.5 py-0.5 text-[8px] text-docker hover:underline dark:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" /> {app.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { ExternalLink, RefreshCw, Terminal, X } from 'lucide-react'
|
||||||
|
import type { Agent, FeedEntry, GpuStatus, NodeDetail, TerminalLine, TopologyNode, WorkloadData } from '../../types'
|
||||||
|
import { AGENT_NODE } from '../../lib/constants'
|
||||||
|
import { getAgentMeta } from '../../lib/agentMeta'
|
||||||
|
import { copyShellCommand, resolveInfraNode } from '../../lib/infraCatalog'
|
||||||
|
import { Button } from '../ui/Button'
|
||||||
|
import { Card, CardDescription, CardTitle } from '../ui/Card'
|
||||||
|
import { Input } from '../ui/Input'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'apps' | 'terminal'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
node: TopologyNode | null
|
||||||
|
nodeDetail: NodeDetail | null
|
||||||
|
agent: Agent | null
|
||||||
|
agents: Agent[]
|
||||||
|
workload: WorkloadData | null
|
||||||
|
gpu: GpuStatus | null
|
||||||
|
feed: FeedEntry[]
|
||||||
|
lines: TerminalLine[]
|
||||||
|
busy: boolean
|
||||||
|
onProbe: () => void
|
||||||
|
onAsk: (message: string) => void
|
||||||
|
onSelectAgent: (id: string) => void
|
||||||
|
onSendPrompt: (message: string, agentId?: string) => void
|
||||||
|
onClear: () => void
|
||||||
|
onOpenTerminal: (nodeId: string) => void
|
||||||
|
onProbeNodeId: (nodeId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InspectorPanel({
|
||||||
|
node,
|
||||||
|
nodeDetail,
|
||||||
|
agent,
|
||||||
|
agents,
|
||||||
|
workload,
|
||||||
|
gpu,
|
||||||
|
feed,
|
||||||
|
lines,
|
||||||
|
busy,
|
||||||
|
onProbe,
|
||||||
|
onAsk,
|
||||||
|
onSelectAgent,
|
||||||
|
onSendPrompt,
|
||||||
|
onClear,
|
||||||
|
onOpenTerminal,
|
||||||
|
onProbeNodeId,
|
||||||
|
}: Props) {
|
||||||
|
const [tab, setTab] = useState<Tab>('overview')
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [shellCopied, setShellCopied] = useState(false)
|
||||||
|
const d = nodeDetail || node
|
||||||
|
const infra = resolveInfraNode(d?.id || null)
|
||||||
|
const linkedAgent = d
|
||||||
|
? agents.find((a) => a.id === d.id || AGENT_NODE[a.id] === d.id || a.id === infra?.agentId || a.zone === d.id)
|
||||||
|
: agent
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!input.trim() || busy) return
|
||||||
|
onAsk(input.trim())
|
||||||
|
setInput('')
|
||||||
|
setTab('terminal')
|
||||||
|
if (d?.id) onOpenTerminal(d.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runShell = async () => {
|
||||||
|
if (!infra) return
|
||||||
|
await copyShellCommand(infra.ssh)
|
||||||
|
setShellCopied(true)
|
||||||
|
setTimeout(() => setShellCopied(false), 2000)
|
||||||
|
onOpenTerminal(infra.id)
|
||||||
|
onProbeNodeId(infra.id)
|
||||||
|
setTab('terminal')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex min-h-0 flex-1 flex-col bg-surface-raised">
|
||||||
|
<header className="flex items-start justify-between gap-2 border-b border-border p-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[9px] font-semibold uppercase tracking-widest text-docker">Inspector</p>
|
||||||
|
<h2 className="truncate text-sm font-semibold text-foreground">
|
||||||
|
{d ? d.label : agent ? agent.name.split(' ·')[0] : 'Lab overview'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{(d || agent) && (
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClear} aria-label="Clear">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!d && !agent && (
|
||||||
|
<div className="scrollbar-thin flex-1 space-y-3 overflow-y-auto p-3">
|
||||||
|
<div className="rounded-lg border border-docker/25 bg-docker-light/50 p-2.5 dark:bg-blue-500/10">
|
||||||
|
<p className="mb-1.5 text-[10px] font-semibold text-foreground">Snel starten</p>
|
||||||
|
<ol className="list-decimal space-y-1 pl-4 text-[10px] leading-relaxed text-foreground-muted">
|
||||||
|
<li>Select an <strong className="text-foreground">infrastructure card</strong> or topology node</li>
|
||||||
|
<li>Klik <strong className="text-foreground">Shell</strong> voor SSH + live terminal output</li>
|
||||||
|
<li>Klik <strong className="text-foreground">UI</strong> om Airflow, Trino, Kafka UI, etc. te openen</li>
|
||||||
|
<li>Stel vragen via <strong className="text-foreground">Chat</strong> onderaan — agents zien de hele cluster</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-[9px] uppercase tracking-wider text-foreground-faint">Agents & domeinen</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{agents.filter((a) => !a.supervisor).map((a) => {
|
||||||
|
const meta = getAgentMeta(a.id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectAgent(a.id)}
|
||||||
|
className="flex w-full items-start gap-2 rounded border border-border bg-surface-overlay/60 px-2 py-1.5 text-left hover:border-border-strong"
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 text-[9px] font-semibold" style={{ color: meta.accent }}>{a.name.split(' ·')[0]}</span>
|
||||||
|
<span className="min-w-0 flex-1 text-[9px] text-foreground-muted">{a.role}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{workload && (
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<Stat label="VMs" value={String(workload.totals.vms ?? '—')} />
|
||||||
|
<Stat label="Containers" value={`${workload.totals.apps_running}/${workload.totals.apps_total}`} />
|
||||||
|
<Stat label="Connectors" value={String(workload.totals.connectors)} />
|
||||||
|
<Stat label="Pipeline" value={workload.totals.pipeline_active ? 'active' : 'degraded'} ok={workload.totals.pipeline_active} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gpu?.ok && (
|
||||||
|
<Card padding className="!p-2">
|
||||||
|
<CardTitle>GPU · {gpu.host}</CardTitle>
|
||||||
|
<CardDescription>{gpu.active_model || 'No model'}</CardDescription>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{agent && !d && (
|
||||||
|
<div className="scrollbar-thin flex-1 overflow-y-auto p-3">
|
||||||
|
{(() => {
|
||||||
|
const meta = getAgentMeta(agent.id)
|
||||||
|
const Icon = meta.icon
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex items-center gap-2 rounded-lg border border-border bg-surface-overlay p-2">
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-surface" style={{ color: meta.accent }}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-foreground">{agent.name}</p>
|
||||||
|
<p className="text-[10px] text-foreground-muted">{meta.domain} · {agent.zone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-[10px] italic text-foreground-muted">"{agent.motto || agent.role}"</p>
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1">
|
||||||
|
{(agent.suggested_prompts || []).slice(0, 4).map((prompt) => (
|
||||||
|
<button
|
||||||
|
key={prompt}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSendPrompt(prompt, agent.id)}
|
||||||
|
className="rounded border border-border px-1.5 py-0.5 text-[9px] text-foreground-muted hover:border-docker/40 hover:text-docker"
|
||||||
|
>
|
||||||
|
{prompt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenTerminal(agent.id)}
|
||||||
|
className="mb-2 inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[9px] hover:border-docker/40"
|
||||||
|
>
|
||||||
|
<Terminal className="h-3 w-3" /> Agent terminal
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{d && (
|
||||||
|
<>
|
||||||
|
{linkedAgent && (
|
||||||
|
<div className="flex items-center gap-2 border-b border-border bg-surface-overlay/50 px-3 py-1.5">
|
||||||
|
<span className="text-[8px] uppercase tracking-wider text-foreground-faint">Agent</span>
|
||||||
|
<button type="button" onClick={() => onSelectAgent(linkedAgent.id)} className="truncate text-[10px] font-medium text-docker hover:underline">
|
||||||
|
{linkedAgent.name.split(' ·')[0]}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-1 border-b border-border px-3 py-1.5 font-mono text-[9px] text-foreground-muted">
|
||||||
|
<span className={cn('h-1.5 w-1.5 rounded-full', d.level === 'ok' ? 'bg-success' : 'bg-warning')} />
|
||||||
|
{d.vm} · {d.ip}
|
||||||
|
<span className="ml-auto">{d.running}/{d.total}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1 border-b border-border p-2">
|
||||||
|
{infra && (
|
||||||
|
<Button size="sm" variant="outline" onClick={runShell} disabled={busy}>
|
||||||
|
<Terminal className="h-3 w-3" />
|
||||||
|
{shellCopied ? 'SSH gekopieerd' : 'Shell'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" onClick={onProbe} disabled={busy}>
|
||||||
|
<RefreshCw className={cn('h-3 w-3', busy && 'animate-spin')} /> Probe
|
||||||
|
</Button>
|
||||||
|
{(infra?.apps || []).slice(0, 3).map((app) => (
|
||||||
|
<a
|
||||||
|
key={app.url}
|
||||||
|
href={app.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-docker/30 bg-docker-light/30 px-2 py-1 text-[9px] text-docker hover:underline dark:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" /> {app.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{(d.links || []).map((l) => (
|
||||||
|
<a key={l.url} href={l.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[9px] hover:bg-surface-overlay">
|
||||||
|
<ExternalLink className="h-3 w-3" /> {l.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex gap-1 border-b border-border px-2 py-1">
|
||||||
|
{(['overview', 'apps', 'terminal'] as Tab[]).map((t) => (
|
||||||
|
<button key={t} type="button" onClick={() => setTab(t)} className={cn('rounded px-2 py-0.5 text-[10px] capitalize', tab === t ? 'bg-docker-light text-docker' : 'text-foreground-muted')}>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-3 text-[10px]">
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(d.description || infra?.description) && <p className="text-foreground-muted">{d.description || infra?.description}</p>}
|
||||||
|
{infra && <p className="font-mono text-[9px] text-foreground-faint">{infra.ssh}</p>}
|
||||||
|
{(d.endpoints || []).map((ep) => (
|
||||||
|
<p key={ep.name} className="font-mono text-foreground-muted">{ep.name}: {ep.host}:{ep.port}</p>
|
||||||
|
))}
|
||||||
|
{(d.commands || []).map((cmd) => (
|
||||||
|
<button
|
||||||
|
key={cmd}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setInput(cmd); setTab('terminal') }}
|
||||||
|
className="block w-full rounded border border-border px-2 py-1 text-left font-mono text-[9px] hover:bg-surface-overlay"
|
||||||
|
>
|
||||||
|
$ {cmd}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tab === 'apps' && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(d.apps || []).map((app) => (
|
||||||
|
<div key={app.name} className="rounded border border-border bg-surface px-2 py-1">
|
||||||
|
<p className="font-medium text-foreground">{app.name}</p>
|
||||||
|
<p className="font-mono text-[9px] text-foreground-faint">{app.state} · {app.image}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tab === 'terminal' && (
|
||||||
|
<div className="rounded border border-border bg-black/40 p-2 font-mono text-[9px]">
|
||||||
|
{lines.map((line) => (
|
||||||
|
<div key={line.id} className="text-foreground-muted">
|
||||||
|
<span className="text-foreground-faint">{line.ts ? new Date(line.ts).toLocaleTimeString('en-US', { hour12: false }) : ''}</span>{' '}
|
||||||
|
<span className="text-docker">{line.phase}</span> {line.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{busy && <span className="text-docker animate-pulse">█</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={submit} className="flex gap-1 border-t border-border p-2">
|
||||||
|
<Input value={input} onChange={(e) => setInput(e.target.value)} placeholder={`Vraag over ${d.label}…`} disabled={busy} className="text-xs" />
|
||||||
|
<Button type="submit" size="sm" disabled={busy || !input.trim()}>Send</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-border bg-surface-overlay px-2 py-1.5">
|
||||||
|
<p className="text-[8px] uppercase text-foreground-faint">{label}</p>
|
||||||
|
<p className={cn('font-mono text-[11px] font-medium', ok === false ? 'text-warning' : ok ? 'text-success' : 'text-foreground')}>{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { BookOpen, FileText, Loader2, MessageSquare, RefreshCw, RotateCcw, Send, Upload } from 'lucide-react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||||
|
|
||||||
|
type Collection = { name: string; documents: number; files?: number; filenames?: string[] }
|
||||||
|
type StoredDoc = {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
collection: string
|
||||||
|
chunks: number
|
||||||
|
characters?: number
|
||||||
|
ingested_at: string
|
||||||
|
bytes?: number
|
||||||
|
}
|
||||||
|
type Source = { source?: string; chunk?: number; preview?: string }
|
||||||
|
type ChatMsg = { role: 'user' | 'assistant'; content: string; sources?: Source[] }
|
||||||
|
|
||||||
|
type Health = { ok: boolean; chroma: boolean; docling: boolean; llm: boolean; embed_model?: string }
|
||||||
|
|
||||||
|
type Props = { onGpuActivity?: (active: boolean) => void }
|
||||||
|
|
||||||
|
export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
|
||||||
|
const [health, setHealth] = useState<Health | null>(null)
|
||||||
|
const [collections, setCollections] = useState<Collection[]>([])
|
||||||
|
const [collection, setCollection] = useState('default')
|
||||||
|
const [newCol, setNewCol] = useState('')
|
||||||
|
const [messages, setMessages] = useState<ChatMsg[]>([])
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [ingesting, setIngesting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [storedDocs, setStoredDocs] = useState<StoredDoc[]>([])
|
||||||
|
const [selectedDocId, setSelectedDocId] = useState<string | null>(null)
|
||||||
|
const [summarizing, setSummarizing] = useState(false)
|
||||||
|
const [reindexing, setReindexing] = useState<string | null>(null)
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const loadMeta = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [h, c, d] = await Promise.all([
|
||||||
|
fetch('/rag/health'),
|
||||||
|
fetch('/rag/collections'),
|
||||||
|
fetch('/rag/documents'),
|
||||||
|
])
|
||||||
|
if (h.ok) setHealth(await h.json())
|
||||||
|
if (c.ok) {
|
||||||
|
const j = await c.json()
|
||||||
|
setCollections(j.collections || [])
|
||||||
|
}
|
||||||
|
if (d.ok) {
|
||||||
|
const j = await d.json()
|
||||||
|
setStoredDocs(j.documents || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setHealth(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMeta()
|
||||||
|
}, [loadMeta])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages, loading])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onGpuActivity?.(loading || ingesting || summarizing || reindexing !== null)
|
||||||
|
}, [loading, ingesting, summarizing, reindexing, onGpuActivity])
|
||||||
|
|
||||||
|
const onIngest = async (file: File) => {
|
||||||
|
setIngesting(true)
|
||||||
|
setError(null)
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
fd.append('collection', collection)
|
||||||
|
try {
|
||||||
|
const r = await fetch('/rag/ingest', { method: 'POST', body: fd })
|
||||||
|
const j = await r.json()
|
||||||
|
if (!r.ok || !j.ok) {
|
||||||
|
setError(j.error || 'Ingest failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMessages((m) => [...m, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: j.duplicate
|
||||||
|
? `Already indexed: ${j.filename} (${j.chunks} chunks). You can chat immediately — no re-upload needed.`
|
||||||
|
: `Indexed ${j.filename} → collection "${j.collection}" — ${j.chunks} chunks (${j.characters?.toLocaleString()} chars). Stored permanently.`,
|
||||||
|
}])
|
||||||
|
loadMeta()
|
||||||
|
} catch {
|
||||||
|
setError('RAG API unavailable')
|
||||||
|
} finally {
|
||||||
|
setIngesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSummarize = async (doc: StoredDoc) => {
|
||||||
|
setSummarizing(true)
|
||||||
|
setError(null)
|
||||||
|
setSelectedDocId(doc.id)
|
||||||
|
setCollection(doc.collection)
|
||||||
|
setMessages((m) => [...m, { role: 'user', content: `Summarize: ${doc.filename}` }])
|
||||||
|
try {
|
||||||
|
const r = await fetch('/rag/summarize', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ collection: doc.collection, doc_id: doc.id }),
|
||||||
|
})
|
||||||
|
const j = await r.json()
|
||||||
|
if (!r.ok || !j.ok) {
|
||||||
|
setError(j.error || 'Summarize failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMessages((m) => [...m, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: `Summary of ${j.filename} (${j.characters?.toLocaleString()} chars):\n\n${j.summary}`,
|
||||||
|
}])
|
||||||
|
} catch {
|
||||||
|
setError('Summarize request failed')
|
||||||
|
} finally {
|
||||||
|
setSummarizing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onReindex = async (doc: StoredDoc) => {
|
||||||
|
setReindexing(doc.id)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/rag/documents/${doc.id}/reindex`, { method: 'POST' })
|
||||||
|
const j = await r.json()
|
||||||
|
if (!r.ok || !j.ok) {
|
||||||
|
setError(j.error || 'Re-index failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMessages((m) => [...m, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: `Re-indexed ${j.filename}: ${j.chunks} clean text chunks (${j.characters?.toLocaleString()} chars). You can now chat and summarize.`,
|
||||||
|
}])
|
||||||
|
loadMeta()
|
||||||
|
} catch {
|
||||||
|
setError('Re-index request failed')
|
||||||
|
} finally {
|
||||||
|
setReindexing(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSend = async () => {
|
||||||
|
const msg = input.trim()
|
||||||
|
if (!msg || loading) return
|
||||||
|
setInput('')
|
||||||
|
setError(null)
|
||||||
|
setMessages((m) => [...m, { role: 'user', content: msg }])
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const r = await fetch('/rag/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: msg, collection, top_k: 5 }),
|
||||||
|
})
|
||||||
|
const j = await r.json()
|
||||||
|
if (!r.ok || !j.ok) {
|
||||||
|
setError(j.error || 'Chat failed')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMessages((m) => [...m, { role: 'assistant', content: j.answer, sources: j.sources }])
|
||||||
|
} catch {
|
||||||
|
setError('Failed to reach RAG / LLM service')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCollection = async () => {
|
||||||
|
if (!newCol.trim()) return
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('name', newCol.trim())
|
||||||
|
await fetch('/rag/collections', { method: 'POST', body: fd })
|
||||||
|
setCollection(newCol.trim())
|
||||||
|
setNewCol('')
|
||||||
|
loadMeta()
|
||||||
|
}
|
||||||
|
|
||||||
|
const doclingUiUrl = `${window.location.protocol}//${window.location.hostname}:5001/ui/`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised">
|
||||||
|
<header className="shrink-0 border-b border-border bg-surface-overlay/30 px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Knowledge Chat (RAG)</h2>
|
||||||
|
<p className="text-[11px] text-foreground-muted">
|
||||||
|
LangChain + ChromaDB — chat with your ingested documents via Llama 70B
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-[10px]">
|
||||||
|
{health && (
|
||||||
|
<>
|
||||||
|
<StatusPill ok={health.chroma} label="ChromaDB" />
|
||||||
|
<StatusPill ok={health.docling} label="Docling" />
|
||||||
|
<StatusPill ok={health.llm} label="LLM" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={loadMeta} className="rounded border border-border p-1.5 hover:bg-surface-overlay">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
|
<aside className="shrink-0 border-b border-border p-4 lg:w-72 lg:border-b-0 lg:border-r">
|
||||||
|
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Collection</h3>
|
||||||
|
<select
|
||||||
|
value={collection}
|
||||||
|
onChange={(e) => setCollection(e.target.value)}
|
||||||
|
className="mb-2 w-full rounded border border-border bg-surface-overlay px-2 py-1.5 text-[11px]"
|
||||||
|
>
|
||||||
|
{collections.length === 0 && <option value="default">default (empty)</option>}
|
||||||
|
{collections.map((c) => (
|
||||||
|
<option key={c.name} value={c.name}>{c.name} ({c.documents} docs)</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="mb-4 flex gap-1">
|
||||||
|
<input
|
||||||
|
value={newCol}
|
||||||
|
onChange={(e) => setNewCol(e.target.value)}
|
||||||
|
placeholder="New collection name"
|
||||||
|
className="min-w-0 flex-1 rounded border border-border bg-surface-overlay px-2 py-1 text-[10px]"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={createCollection} className={cn('shrink-0 rounded px-2 py-1 text-[10px]', subTabIdle)}>Add</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Ingest documents</h3>
|
||||||
|
<label className={cn('flex cursor-pointer flex-col items-center rounded-lg border-2 border-dashed border-border px-3 py-4 text-center hover:border-docker/40', ingesting && 'opacity-50')}>
|
||||||
|
<Upload className="mb-1 h-6 w-6 text-docker opacity-60" />
|
||||||
|
<span className="text-[10px] font-medium">PDF, PPTX, DOCX, CSV, TXT, MD</span>
|
||||||
|
<span className="text-[9px] text-foreground-faint">Stored in ChromaDB + disk — upload once</span>
|
||||||
|
<input type="file" className="hidden" disabled={ingesting} accept=".pdf,.pptx,.ppt,.docx,.csv,.txt,.md,.json" onChange={(e) => e.target.files?.[0] && onIngest(e.target.files[0])} />
|
||||||
|
</label>
|
||||||
|
{ingesting && (
|
||||||
|
<p className="mt-2 flex items-center gap-1 text-[10px] text-foreground-muted">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" /> Ingesting & embedding…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 className="mb-2 mt-4 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">
|
||||||
|
Document library ({storedDocs.length})
|
||||||
|
</h3>
|
||||||
|
<div className="scrollbar-thin max-h-40 space-y-1 overflow-y-auto">
|
||||||
|
{storedDocs.length === 0 ? (
|
||||||
|
<p className="text-[9px] text-foreground-faint">No documents yet — upload above.</p>
|
||||||
|
) : (
|
||||||
|
storedDocs.map((doc) => (
|
||||||
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
className={cn(
|
||||||
|
'rounded border px-2 py-1.5 text-[9px] transition-colors',
|
||||||
|
selectedDocId === doc.id ? 'border-docker/40 bg-docker/10' : 'border-border',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button type="button" onClick={() => { setSelectedDocId(doc.id); setCollection(doc.collection) }} className="w-full text-left">
|
||||||
|
<p className="truncate font-medium text-foreground">{doc.filename}</p>
|
||||||
|
<p className="text-foreground-faint">{doc.collection} · {doc.chunks} chunks · {new Date(doc.ingested_at).toLocaleDateString()}</p>
|
||||||
|
</button>
|
||||||
|
<div className="mt-1 flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={summarizing}
|
||||||
|
onClick={() => onSummarize(doc)}
|
||||||
|
className={cn('flex flex-1 items-center justify-center gap-0.5 rounded px-1 py-0.5', subTabIdle)}
|
||||||
|
>
|
||||||
|
{summarizing && selectedDocId === doc.id ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <FileText className="h-2.5 w-2.5" />}
|
||||||
|
Summarize
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={reindexing === doc.id}
|
||||||
|
onClick={() => onReindex(doc)}
|
||||||
|
className={cn('flex flex-1 items-center justify-center gap-0.5 rounded px-1 py-0.5', subTabIdle)}
|
||||||
|
title="Re-parse with clean text (fixes corrupted PDF index)"
|
||||||
|
>
|
||||||
|
{reindexing === doc.id ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RotateCcw className="h-2.5 w-2.5" />}
|
||||||
|
Re-index
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-1 text-[9px] text-foreground-faint">
|
||||||
|
<p><BookOpen className="mr-1 inline h-3 w-3" />Embed: {health?.embed_model || 'all-MiniLM-L6-v2'}</p>
|
||||||
|
<a href={doclingUiUrl} target="_blank" rel="noreferrer" className="text-docker hover:underline">Docling UI (port 5001) ↗</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-sm text-foreground-muted">
|
||||||
|
<MessageSquare className="h-10 w-10 opacity-30" />
|
||||||
|
<p>Upload once — documents stay in ChromaDB. Ask anytime without re-uploading.</p>
|
||||||
|
<p className="text-[11px]">Example: "What maturity gaps exist in the customer dataset?"</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<div key={i} className={cn('mb-3 max-w-[90%] rounded-lg px-3 py-2 text-[12px]', m.role === 'user' ? 'ml-auto bg-docker/20 text-foreground' : 'bg-surface-overlay text-foreground-muted')}>
|
||||||
|
<p className="whitespace-pre-wrap leading-relaxed">{m.content}</p>
|
||||||
|
{m.sources && m.sources.length > 0 && (
|
||||||
|
<div className="mt-2 border-t border-border pt-2">
|
||||||
|
<p className="mb-1 text-[9px] font-semibold uppercase text-foreground-faint">Sources</p>
|
||||||
|
{m.sources.map((s, j) => (
|
||||||
|
<p key={j} className="text-[9px] text-foreground-faint">
|
||||||
|
{s.source} · chunk {s.chunk}: {s.preview?.slice(0, 120)}…
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-foreground-muted">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-docker" /> Retrieving context & generating answer…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-[11px] text-danger">{error}</p>}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 gap-2 border-t border-border p-3">
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && (e.preventDefault(), onSend())}
|
||||||
|
placeholder="Ask a question about your ingested data…"
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-border bg-surface-overlay px-3 py-2 text-[12px]"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={onSend} disabled={loading || !input.trim()} className={cn('rounded-lg px-3 py-2', subTabActive, 'disabled:opacity-40')}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ ok, label }: { ok: boolean; label: string }) {
|
||||||
|
return (
|
||||||
|
<span className={cn('rounded-full px-2 py-0.5 font-medium', ok ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger')}>
|
||||||
|
{label} {ok ? '●' : '○'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Box } from 'lucide-react'
|
||||||
|
import type { AgentAnim, WorkloadData } from '../../types'
|
||||||
|
import { Badge } from '../ui/Badge'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
/* ── Pipeline model ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
type TopoNode = { id: string; label: string; sub: string; metricKey: string }
|
||||||
|
|
||||||
|
type TopoStage = {
|
||||||
|
id: string
|
||||||
|
num: number
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
accent: string
|
||||||
|
nodes: TopoNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlowKind = 'orchestration' | 'cdc' | 'stream' | 'etl' | 'query' | 'serve'
|
||||||
|
|
||||||
|
type FlowEdge = {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
kind: FlowKind
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGES: TopoStage[] = [
|
||||||
|
{
|
||||||
|
id: 'sources', num: 1, title: 'SOURCES', subtitle: 'Operational databases', accent: 'topo-stage-col--sources',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'postgresql', label: 'PostgreSQL', sub: 'OLTP · primary', metricKey: 'postgresql' },
|
||||||
|
{ id: 'mysql', label: 'MySQL', sub: 'Replica set', metricKey: 'mysql' },
|
||||||
|
{ id: 'mongodb', label: 'MongoDB', sub: 'Document store', metricKey: 'mongodb' },
|
||||||
|
{ id: 'cassandra', label: 'Cassandra', sub: 'Wide-column', metricKey: 'cassandra' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ingestion', num: 2, title: 'INGESTION & STREAMING', subtitle: 'CDC · event bus · orchestration', accent: 'topo-stage-col--ingestion',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'debezium', label: 'Debezium', sub: 'CDC connectors', metricKey: 'debezium' },
|
||||||
|
{ id: 'kafka', label: 'Apache Kafka', sub: 'Event bus', metricKey: 'kafka' },
|
||||||
|
{ id: 'airflow', label: 'Apache Airflow', sub: 'Daily Python DAGs · source sync', metricKey: 'airflow' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'compute', num: 3, title: 'COMPUTE', subtitle: 'Processing & query', accent: 'topo-stage-col--compute',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'spark', label: 'Apache Spark', sub: 'Batch / micro-batch', metricKey: 'spark' },
|
||||||
|
{ id: 'trino', label: 'Trino', sub: 'Distributed SQL', metricKey: 'trino' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'storage', num: 4, title: 'STORAGE', subtitle: 'Lakehouse layer', accent: 'topo-stage-col--storage',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'iceberg', label: 'Iceberg Tables', sub: 'Open table format', metricKey: 'iceberg' },
|
||||||
|
{ id: 's3', label: 'Dell ECS S3', sub: 'Object scale', metricKey: 's3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'consumers', num: 5, title: 'CONSUMERS', subtitle: 'Analytics & AI', accent: 'topo-stage-col--consumers',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'bi', label: 'BI / Reporting', sub: 'Dashboards', metricKey: 'bi' },
|
||||||
|
{ id: 'jupyter', label: 'Jupyter Notebooks', sub: 'Data science', metricKey: 'jupyter' },
|
||||||
|
{ id: 'llm', label: 'GenAI LLM', sub: 'vLLM inference', metricKey: 'llm' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Full data-foundation flows — Airflow daily Python generation + CDC stream + lakehouse */
|
||||||
|
const FLOW_EDGES: FlowEdge[] = [
|
||||||
|
// Airflow orchestrates daily Python jobs on every source
|
||||||
|
{ from: 'airflow', to: 'postgresql', kind: 'orchestration', label: 'Daily Python gen' },
|
||||||
|
{ from: 'airflow', to: 'mysql', kind: 'orchestration', label: 'Daily Python gen' },
|
||||||
|
{ from: 'airflow', to: 'mongodb', kind: 'orchestration', label: 'Daily Python gen' },
|
||||||
|
{ from: 'airflow', to: 'cassandra', kind: 'orchestration', label: 'Daily Python gen' },
|
||||||
|
// CDC capture from sources
|
||||||
|
{ from: 'postgresql', to: 'debezium', kind: 'cdc', label: 'CDC' },
|
||||||
|
{ from: 'mysql', to: 'debezium', kind: 'cdc', label: 'CDC' },
|
||||||
|
{ from: 'mongodb', to: 'debezium', kind: 'cdc', label: 'CDC' },
|
||||||
|
{ from: 'cassandra', to: 'debezium', kind: 'cdc', label: 'CDC' },
|
||||||
|
// Streaming bus
|
||||||
|
{ from: 'debezium', to: 'kafka', kind: 'stream', label: 'Events' },
|
||||||
|
{ from: 'airflow', to: 'kafka', kind: 'orchestration', label: 'DAG trigger' },
|
||||||
|
// ETL compute
|
||||||
|
{ from: 'kafka', to: 'spark', kind: 'etl', label: 'Micro-batch' },
|
||||||
|
{ from: 'airflow', to: 'spark', kind: 'orchestration', label: 'Pipeline DAG' },
|
||||||
|
{ from: 'spark', to: 'iceberg', kind: 'etl', label: 'Lake write' },
|
||||||
|
{ from: 'spark', to: 's3', kind: 'etl', label: 'Object export' },
|
||||||
|
// Query & serve
|
||||||
|
{ from: 'iceberg', to: 'trino', kind: 'query', label: 'SQL' },
|
||||||
|
{ from: 'trino', to: 'bi', kind: 'serve', label: 'Reports' },
|
||||||
|
{ from: 'iceberg', to: 'jupyter', kind: 'serve', label: 'Notebooks' },
|
||||||
|
{ from: 's3', to: 'jupyter', kind: 'serve', label: 'Datasets' },
|
||||||
|
{ from: 'trino', to: 'llm', kind: 'serve', label: 'RAG context' },
|
||||||
|
{ from: 's3', to: 'llm', kind: 'serve', label: 'Model artifacts' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STAGE_BADGE: Record<string, string> = {
|
||||||
|
sources: 'border-emerald-400/50 bg-emerald-500/20 text-emerald-300',
|
||||||
|
ingestion: 'border-cyan-400/50 bg-cyan-500/20 text-cyan-300',
|
||||||
|
compute: 'border-violet-400/50 bg-violet-500/20 text-violet-300',
|
||||||
|
storage: 'border-blue-400/50 bg-blue-500/20 text-blue-300',
|
||||||
|
consumers: 'border-amber-400/50 bg-amber-500/20 text-amber-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLOW_LEGEND: { kind: FlowKind; label: string; color: string }[] = [
|
||||||
|
{ kind: 'orchestration', label: 'Airflow orchestration', color: '#f59e0b' },
|
||||||
|
{ kind: 'cdc', label: 'CDC capture', color: '#22d3ee' },
|
||||||
|
{ kind: 'stream', label: 'Event stream', color: '#38bdf8' },
|
||||||
|
{ kind: 'etl', label: 'ETL / compute', color: '#a78bfa' },
|
||||||
|
{ kind: 'query', label: 'SQL query', color: '#818cf8' },
|
||||||
|
{ kind: 'serve', label: 'Consumption', color: '#34d399' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const EDGE_CLASS: Record<FlowKind, string> = {
|
||||||
|
orchestration: 'topo-edge-orchestration',
|
||||||
|
cdc: 'topo-edge-cdc',
|
||||||
|
stream: 'topo-edge-stream',
|
||||||
|
etl: 'topo-edge-etl',
|
||||||
|
query: 'topo-edge-query',
|
||||||
|
serve: 'topo-edge-serve',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARTICLE_FILL: Record<FlowKind, string> = {
|
||||||
|
orchestration: '#fbbf24',
|
||||||
|
cdc: '#22d3ee',
|
||||||
|
stream: '#38bdf8',
|
||||||
|
etl: '#c4b5fd',
|
||||||
|
query: '#818cf8',
|
||||||
|
serve: '#34d399',
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_CLICK_MAP: Record<string, string> = {
|
||||||
|
postgresql: 'src-postgres', mysql: 'src-mysql', mongodb: 'src-mongo', cassandra: 'src-cassandra',
|
||||||
|
debezium: 'cdc-postgres', kafka: 'stream-kafka', airflow: 'src-airflow', spark: 'stream-spark',
|
||||||
|
trino: 'query-trino', iceberg: 'lake-iceberg', s3: 'lake-s3', bi: 'cons-bi',
|
||||||
|
jupyter: 'cons-notebooks', llm: 'cons-ml',
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_POS: Record<string, { col: number; row: number; rows: number }> = {}
|
||||||
|
STAGES.forEach((stage, col) => {
|
||||||
|
stage.nodes.forEach((node, row) => {
|
||||||
|
NODE_POS[node.id] = { col, row, rows: stage.nodes.length }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function nodeCoords(col: number, row: number, rows: number) {
|
||||||
|
const colW = 100 / 5
|
||||||
|
const yPad = 8
|
||||||
|
const ySpan = 84
|
||||||
|
const y = yPad + ((row + 0.5) / rows) * ySpan
|
||||||
|
return {
|
||||||
|
inX: col * colW + colW * 0.08,
|
||||||
|
outX: col * colW + colW * 0.92,
|
||||||
|
y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Curved path — arcs upward for backward (orchestration) flows */
|
||||||
|
function flowPath(x1: number, y1: number, x2: number, y2: number, backward = false) {
|
||||||
|
if (backward || x2 < x1 - 2) {
|
||||||
|
const arcY = Math.min(y1, y2) - 14
|
||||||
|
return `M ${x1} ${y1} C ${x1} ${arcY}, ${x2} ${arcY}, ${x2} ${y2}`
|
||||||
|
}
|
||||||
|
const mx = (x1 + x2) / 2
|
||||||
|
return `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricState = Record<string, string>
|
||||||
|
|
||||||
|
function seedMetrics(): MetricState {
|
||||||
|
return {
|
||||||
|
postgresql: '12.4k rows/s', mysql: '8.1k rows/s', mongodb: '2.3k docs/s', cassandra: '5.6k ops/s',
|
||||||
|
debezium: '4 connectors active', kafka: '142 MB/s', airflow: '18 DAGs · daily 02:00 UTC',
|
||||||
|
spark: '6 executors live', trino: '3 queries active', iceberg: '847 tables · 2.1 TB', s3: '14.2 TB stored',
|
||||||
|
bi: '26 dashboards', jupyter: '12 kernels active', llm: 'Checking…',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLlmLabel(model?: string | null): string {
|
||||||
|
if (!model) return 'GenAI LLM'
|
||||||
|
return model.replace(/\s*GPTQ$/i, '').replace(/\s*AWQ$/i, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLlmMetric(workload: WorkloadData | null): string {
|
||||||
|
const gpu = workload?.gpu
|
||||||
|
if (!gpu?.model) return 'Connecting…'
|
||||||
|
if (!gpu.inference_active) return 'Offline'
|
||||||
|
const gpus = gpu.gpus || []
|
||||||
|
const util = gpu.avg_util ?? (gpus.length ? gpus.reduce((s, g) => s + g.util_gpu, 0) / gpus.length : 0)
|
||||||
|
const vram = gpus.length
|
||||||
|
? gpus.reduce((s, g) => s + (g.memory_used_mib / Math.max(g.memory_total_mib, 1)) * 100, 0) / gpus.length
|
||||||
|
: 0
|
||||||
|
if (util >= 1) return `${util.toFixed(0)}% GPU · live`
|
||||||
|
if (vram >= 50) return `Loaded · ${vram.toFixed(0)}% VRAM`
|
||||||
|
return 'Inference active'
|
||||||
|
}
|
||||||
|
|
||||||
|
function jitterMetric(key: string, current: string, workload: WorkloadData | null): string {
|
||||||
|
if (key === 'llm') return formatLlmMetric(workload)
|
||||||
|
const n = () => (Math.random() - 0.5) * 2
|
||||||
|
const fns: Record<string, () => string> = {
|
||||||
|
postgresql: () => `${(12.4 + n() * 0.8).toFixed(1)}k rows/s`,
|
||||||
|
mysql: () => `${(8.1 + n() * 0.6).toFixed(1)}k rows/s`,
|
||||||
|
mongodb: () => `${(2.3 + n() * 0.3).toFixed(1)}k docs/s`,
|
||||||
|
cassandra: () => `${(5.6 + n() * 0.5).toFixed(1)}k ops/s`,
|
||||||
|
debezium: () => `${Math.max(3, Math.round(4 + n()))} connectors active`,
|
||||||
|
kafka: () => `${Math.max(80, Math.round(142 + n() * 18))} MB/s`,
|
||||||
|
airflow: () => `${Math.max(12, Math.round(18 + n() * 2))} DAGs · daily 02:00 UTC`,
|
||||||
|
spark: () => `${Math.max(4, Math.round(6 + n()))} executors live`,
|
||||||
|
trino: () => `${Math.max(1, Math.round(3 + n()))} queries active`,
|
||||||
|
iceberg: () => `${Math.round(847 + n() * 5)} tables · ${(2.1 + n() * 0.05).toFixed(1)} TB`,
|
||||||
|
s3: () => `${(14.2 + n() * 0.08).toFixed(1)} TB stored`,
|
||||||
|
bi: () => `${Math.max(20, Math.round(26 + n() * 2))} dashboards`,
|
||||||
|
jupyter: () => `${Math.max(8, Math.round(12 + n() * 2))} kernels active`,
|
||||||
|
}
|
||||||
|
return fns[key]?.() ?? current
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
workload: WorkloadData | null
|
||||||
|
animations: Record<string, AgentAnim>
|
||||||
|
selectedNodeId: string | null
|
||||||
|
onNodeClick: (nodeId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlatformTopology({ workload, animations, selectedNodeId, onNodeClick }: Props) {
|
||||||
|
const [metrics, setMetrics] = useState<MetricState>(seedMetrics)
|
||||||
|
|
||||||
|
const llmLabel = formatLlmLabel(workload?.gpu?.model)
|
||||||
|
|
||||||
|
const pipelineActive = workload?.totals?.pipeline_active ?? true
|
||||||
|
const anyBusy = useMemo(
|
||||||
|
() => Object.values(animations).some((a) => a.state !== 'idle'),
|
||||||
|
[animations],
|
||||||
|
)
|
||||||
|
|
||||||
|
const edgesLive = pipelineActive || anyBusy
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMetrics((prev) => ({ ...prev, llm: formatLlmMetric(workload) }))
|
||||||
|
}, [workload?.gpu?.model, workload?.gpu?.inference_active, workload?.gpu?.avg_util, workload?.gpu?.gpus])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
setMetrics((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const k of Object.keys(next)) next[k] = jitterMetric(k, prev[k], workload)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, 2200)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
}, [workload])
|
||||||
|
|
||||||
|
const resolvedSel = selectedNodeId
|
||||||
|
? Object.entries(NODE_CLICK_MAP).find(([, v]) => v === selectedNodeId)?.[0] ?? null
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<header
|
||||||
|
className="flex shrink-0 flex-col gap-1 border-b border-border px-3 py-1.5"
|
||||||
|
style={{ background: 'var(--topo-header-bg)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-docker text-white shadow-docker">
|
||||||
|
<Box className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate text-xs font-semibold text-foreground">Data Platform Topology</h2>
|
||||||
|
<p className="truncate text-[9px] text-foreground-muted">
|
||||||
|
Airflow daily Python → CDC → stream → lakehouse → consumers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap justify-end gap-1">
|
||||||
|
<Badge variant={pipelineActive ? 'success' : 'warning'}>
|
||||||
|
{pipelineActive ? 'Pipeline active' : 'Degraded'}
|
||||||
|
</Badge>
|
||||||
|
<Badge>{workload?.totals?.connectors ?? 4} CDC</Badge>
|
||||||
|
<Badge variant="accent">{FLOW_EDGES.length} flows</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-2 gap-y-0.5">
|
||||||
|
{FLOW_LEGEND.map((item) => (
|
||||||
|
<span key={item.kind} className="inline-flex items-center gap-1 text-[8px] text-foreground-muted">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full" style={{ background: item.color }} />
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="topo-canvas flex min-h-0 flex-1">
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute inset-0 z-0 h-full w-full"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="topo-flow-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor="#22d3ee" stopOpacity="0.7" />
|
||||||
|
<stop offset="50%" stopColor="#34d399" stopOpacity="1" />
|
||||||
|
<stop offset="100%" stopColor="#60a5fa" stopOpacity="0.7" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{FLOW_EDGES.map((edge, i) => {
|
||||||
|
const pa = NODE_POS[edge.from]
|
||||||
|
const pb = NODE_POS[edge.to]
|
||||||
|
if (!pa || !pb) return null
|
||||||
|
const a = nodeCoords(pa.col, pa.row, pa.rows)
|
||||||
|
const b = nodeCoords(pb.col, pb.row, pb.rows)
|
||||||
|
const backward = edge.kind === 'orchestration' && pb.col < pa.col
|
||||||
|
const fromX = backward ? a.inX + (a.outX - a.inX) * 0.15 : a.outX
|
||||||
|
const toX = backward ? b.outX - (b.outX - b.inX) * 0.15 : b.inX
|
||||||
|
const d = flowPath(fromX, a.y, toX, b.y, backward)
|
||||||
|
const live = edgesLive
|
||||||
|
const dur = 1.8 + (i % 5) * 0.35
|
||||||
|
return (
|
||||||
|
<g key={`${edge.from}-${edge.to}-${edge.kind}`}>
|
||||||
|
<path d={d} className="topo-edge-glow" vectorEffect="non-scaling-stroke" />
|
||||||
|
<path
|
||||||
|
d={d}
|
||||||
|
className={cn(EDGE_CLASS[edge.kind], live ? 'topo-edge-live' : 'topo-edge-idle')}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
{live && (
|
||||||
|
<>
|
||||||
|
<circle r="0.55" fill={PARTICLE_FILL[edge.kind]} opacity="0.95">
|
||||||
|
<animateMotion dur={`${dur}s`} repeatCount="indefinite" path={d} />
|
||||||
|
</circle>
|
||||||
|
<circle r="0.35" fill="#ffffff" opacity="0.85">
|
||||||
|
<animateMotion dur={`${dur}s`} repeatCount="indefinite" path={d} begin={`${dur * 0.45}s`} />
|
||||||
|
</circle>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex h-full min-h-0 w-full overflow-x-auto">
|
||||||
|
{STAGES.map((stage) => (
|
||||||
|
<div key={stage.id} className={cn('topo-stage-col', stage.accent)}>
|
||||||
|
<header className="mb-1 shrink-0 border-b border-white/10 pb-1">
|
||||||
|
<div className="flex items-start gap-1">
|
||||||
|
<span className={cn('rounded border px-1 py-px font-mono text-[8px] font-bold', STAGE_BADGE[stage.id])}>
|
||||||
|
0{stage.num}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-[8px] font-bold leading-tight tracking-wide text-white">{stage.title}</h3>
|
||||||
|
<p className="text-[7px] text-blue-200/70">{stage.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col justify-evenly gap-1">
|
||||||
|
{stage.nodes.map((node) => {
|
||||||
|
const label = node.id === 'llm' ? llmLabel : node.label
|
||||||
|
const sub = node.id === 'llm'
|
||||||
|
? (workload?.gpu?.inference_active ? 'vLLM · live' : 'vLLM inference')
|
||||||
|
: node.sub
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={node.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNodeClick(NODE_CLICK_MAP[node.id] || node.id)}
|
||||||
|
className={cn(
|
||||||
|
'topo-node',
|
||||||
|
node.id === 'airflow' && 'topo-node-airflow',
|
||||||
|
node.id === 'llm' && workload?.gpu?.inference_active && 'topo-node-airflow',
|
||||||
|
resolvedSel === node.id && 'topo-node-selected',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block truncate text-[10px] font-semibold leading-tight text-white">{label}</span>
|
||||||
|
<span className="block truncate text-[8px] text-blue-100/80">{sub}</span>
|
||||||
|
<span className="mt-0.5 inline-block max-w-full truncate rounded border border-emerald-400/35 bg-emerald-500/20 px-1 py-px font-mono text-[7px] font-medium text-emerald-300">
|
||||||
|
{metrics[node.metricKey]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { ExternalLink, FileUp, Monitor, Upload } from 'lucide-react'
|
||||||
|
import { ArchitectureDiagram } from './ArchitectureDiagram'
|
||||||
|
import type { PresentationData, PresentationSlide } from '../../types'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||||
|
|
||||||
|
type DeckSource = 'live' | 'data-maturity' | 'atc-platform' | string
|
||||||
|
|
||||||
|
const KIND_STYLES: Record<string, string> = {
|
||||||
|
hero: 'from-blue-600/25 via-violet-600/20 to-emerald-600/15',
|
||||||
|
narrative: 'from-slate-600/15 to-blue-600/15',
|
||||||
|
topology: 'from-cyan-600/20 to-blue-800/15',
|
||||||
|
zone: 'from-amber-600/15 to-orange-600/10',
|
||||||
|
gpu: 'from-emerald-600/20 to-green-800/15',
|
||||||
|
agents: 'from-fuchsia-600/15 to-pink-600/10',
|
||||||
|
cta: 'from-blue-600/15 to-violet-600/20',
|
||||||
|
upload: 'from-indigo-600/15 to-purple-600/10',
|
||||||
|
command: 'from-sky-600/15 to-blue-600/10',
|
||||||
|
architecture: 'from-teal-600/15 to-cyan-600/10',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDeck(id: DeckSource): Promise<PresentationData | null> {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const timeout = id === 'live' ? 45000 : 10000
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), timeout)
|
||||||
|
try {
|
||||||
|
const url = id === 'live' ? '/api/presentation' : `/api/presentation/decks/${id}`
|
||||||
|
const r = await fetch(url, { signal: ctrl.signal })
|
||||||
|
if (!r.ok) return null
|
||||||
|
return (await r.json()) as PresentationData
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PresentationView() {
|
||||||
|
const [source, setSource] = useState<DeckSource>('live')
|
||||||
|
const [data, setData] = useState<PresentationData | null>(null)
|
||||||
|
const [slideIdx, setSlideIdx] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
|
||||||
|
const [customDecks, setCustomDecks] = useState<{ id: string; title: string }[]>([])
|
||||||
|
|
||||||
|
const load = useCallback(async (deckId: DeckSource) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const d = await fetchDeck(deckId)
|
||||||
|
if (d && d.slides?.length) {
|
||||||
|
setData(d)
|
||||||
|
setSlideIdx(0)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (deckId === 'live') {
|
||||||
|
const fallback = await fetchDeck('data-maturity')
|
||||||
|
if (fallback?.slides?.length) {
|
||||||
|
setData(fallback)
|
||||||
|
setSlideIdx(0)
|
||||||
|
setError('Live deck timeout — showing Data Maturity template. Click Refresh for live cluster data.')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setData(null)
|
||||||
|
setError('Could not load presentation.')
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load(source)
|
||||||
|
fetch('/api/presentation/decks')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((j) => {
|
||||||
|
const uploaded = (j.uploaded || []).map((d: { id: string; title: string }) => ({ id: d.id, title: d.title }))
|
||||||
|
setCustomDecks(uploaded)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [source, load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const n = data?.slides.length || 1
|
||||||
|
if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); setSlideIdx((i) => Math.min(n - 1, i + 1)) }
|
||||||
|
if (e.key === 'ArrowLeft') setSlideIdx((i) => Math.max(0, i - 1))
|
||||||
|
if (e.key === 'f' || e.key === 'F') document.documentElement.requestFullscreen?.()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [data?.slides.length])
|
||||||
|
|
||||||
|
const slides = data?.slides || []
|
||||||
|
const slide: PresentationSlide | undefined = slides[slideIdx]
|
||||||
|
|
||||||
|
const exportHtml = () => {
|
||||||
|
const id = source === 'live' ? 'live' : source
|
||||||
|
window.open(`/api/presentation/decks/${id}/html`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUpload = async (file: File) => {
|
||||||
|
setUploading(true)
|
||||||
|
setUploadMsg(null)
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/presentation/upload', { method: 'POST', body: fd })
|
||||||
|
const j = await r.json()
|
||||||
|
if (j.ok && j.deck) {
|
||||||
|
setCustomDecks((prev) => [{ id: j.deck.id, title: j.deck.title }, ...prev])
|
||||||
|
setSource(j.deck.id)
|
||||||
|
setUploadMsg(`✓ ${j.deck.slide_count} slides loaded from ${file.name}`)
|
||||||
|
} else {
|
||||||
|
setUploadMsg(j.error || 'Upload failed')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUploadMsg('Upload failed — check connection')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: { id: DeckSource; label: string }[] = [
|
||||||
|
{ id: 'live', label: 'Live Cluster' },
|
||||||
|
{ id: 'stack-architecture', label: 'Stack Architecture' },
|
||||||
|
{ id: 'data-maturity', label: 'Data Maturity' },
|
||||||
|
{ id: 'atc-platform', label: 'ATC Platform' },
|
||||||
|
...customDecks.map((d) => ({ id: d.id, label: d.title.slice(0, 18) })),
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col overflow-hidden rounded-lg border border-border bg-surface-raised">
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border bg-surface-raised/90 px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-foreground">Presentation</h2>
|
||||||
|
<p className="text-[9px] text-foreground-muted">
|
||||||
|
Live cluster · HTML templates · PPT upload (converts via python-pptx + Docling)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<a href="/dq/" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px]', subTabIdle)}>
|
||||||
|
<Monitor className="h-3 w-3" /> DQ Portal
|
||||||
|
</a>
|
||||||
|
<a href="/docling/ui/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
|
||||||
|
<ExternalLink className="h-3 w-3" /> Docling
|
||||||
|
</a>
|
||||||
|
<button type="button" onClick={() => load(source)} className="rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">Refresh</button>
|
||||||
|
<button type="button" onClick={exportHtml} className={cn('rounded-md px-2 py-1 text-[9px]', subTabActive)}>Export HTML</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-1 border-b border-border bg-surface-overlay/40 px-2 py-1.5">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSource(t.id)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-2.5 py-1.5 text-[10px] font-medium transition-all',
|
||||||
|
source === t.id ? subTabActive : subTabIdle,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<label className={cn('ml-auto inline-flex cursor-pointer items-center gap-1 rounded-md border border-dashed border-border px-2 py-1 text-[10px]', uploading && 'opacity-50')}>
|
||||||
|
<Upload className="h-3 w-3" />
|
||||||
|
{uploading ? 'Uploading…' : 'PPT upload'}
|
||||||
|
<input type="file" accept=".ppt,.pptx,.pdf,.docx" className="hidden" disabled={uploading} onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploadMsg && <p className="shrink-0 px-3 py-1 text-[10px] text-docker">{uploadMsg}</p>}
|
||||||
|
{error && <p className="shrink-0 px-3 py-1 text-[10px] text-warning">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-foreground-muted">
|
||||||
|
<FileUp className="h-8 w-8 animate-pulse opacity-40" />
|
||||||
|
<p>Loading presentation{source === 'live' ? ' (live cluster snapshot, ~15 sec)' : '…'}</p>
|
||||||
|
</div>
|
||||||
|
) : !slide ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center text-sm text-foreground-muted">
|
||||||
|
<button type="button" onClick={() => load(source)} className="rounded border border-border px-3 py-1 text-xs">Retry</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={cn('relative flex min-h-0 flex-1 flex-col justify-center bg-gradient-to-br p-6 md:p-10', KIND_STYLES[slide.kind || 'narrative'] || KIND_STYLES.narrative)}>
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<p className="mb-1 text-[10px] font-medium uppercase tracking-widest text-docker/80">{slide.kind || 'slide'} · {slideIdx + 1}/{slides.length}</p>
|
||||||
|
<h1 className="mb-2 text-2xl font-bold tracking-tight text-foreground md:text-4xl">{slide.title}</h1>
|
||||||
|
{slide.subtitle && <p className="mb-4 text-sm text-foreground-muted md:text-base">{slide.subtitle}</p>}
|
||||||
|
{'animation' in slide && slide.animation && (
|
||||||
|
<ArchitectureDiagram animation={String(slide.animation)} />
|
||||||
|
)}
|
||||||
|
<ul className="space-y-2 text-sm leading-relaxed text-foreground md:text-base">
|
||||||
|
{(slide.bullets || []).map((b: string) => (
|
||||||
|
<li key={b} className="flex gap-2"><span className="shrink-0 text-docker">▸</span><span>{b}</span></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-t border-border bg-surface-raised/90 px-3 py-2">
|
||||||
|
<button type="button" disabled={slideIdx === 0} onClick={() => setSlideIdx((i) => Math.max(0, i - 1))} className="rounded border border-border px-2 py-1 text-[10px] disabled:opacity-40">← Prev</button>
|
||||||
|
<div className="flex flex-1 flex-wrap justify-center gap-1">
|
||||||
|
{slides.map((_: PresentationSlide, i: number) => (
|
||||||
|
<button key={i} type="button" onClick={() => setSlideIdx(i)} className={cn('h-2 w-2 rounded-full', i === slideIdx ? 'scale-125 bg-docker' : 'bg-border')} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" disabled={slideIdx >= slides.length - 1} onClick={() => setSlideIdx((i) => Math.min(slides.length - 1, i + 1))} className="rounded border border-border px-2 py-1 text-[10px] disabled:opacity-40">Next →</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { ChevronRight, Database, Download, ExternalLink, Folder, HardDrive, Loader2, RefreshCw } from 'lucide-react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||||
|
|
||||||
|
type Bucket = { name: string; created?: string; has_objects?: boolean }
|
||||||
|
type S3Item = { type: string; name?: string; prefix?: string; key?: string; size_human?: string; modified?: string }
|
||||||
|
|
||||||
|
export function StorageView() {
|
||||||
|
const [health, setHealth] = useState<{ ok: boolean; endpoint?: string; bucket_names?: string[]; error?: string } | null>(null)
|
||||||
|
const [buckets, setBuckets] = useState<Bucket[]>([])
|
||||||
|
const [bucket, setBucket] = useState<string | null>(null)
|
||||||
|
const [prefix, setPrefix] = useState('')
|
||||||
|
const [folders, setFolders] = useState<S3Item[]>([])
|
||||||
|
const [objects, setObjects] = useState<S3Item[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadBuckets = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const [h, b] = await Promise.all([
|
||||||
|
fetch('/api/storage/s3/health'),
|
||||||
|
fetch('/api/storage/s3/buckets'),
|
||||||
|
])
|
||||||
|
if (h.ok) setHealth(await h.json())
|
||||||
|
if (b.ok) {
|
||||||
|
const j = await b.json()
|
||||||
|
setBuckets(j.buckets || [])
|
||||||
|
if (!bucket && j.buckets?.length) setBucket(j.buckets[0].name)
|
||||||
|
} else {
|
||||||
|
setError('Failed to load buckets')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('S3 API unavailable')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [bucket])
|
||||||
|
|
||||||
|
const loadObjects = useCallback(async (b: string, p: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/storage/s3/buckets/${encodeURIComponent(b)}/objects?prefix=${encodeURIComponent(p)}`)
|
||||||
|
const j = await r.json()
|
||||||
|
if (!r.ok || !j.ok) {
|
||||||
|
setError(j.error || 'List failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFolders(j.folders || [])
|
||||||
|
setObjects(j.objects || [])
|
||||||
|
} catch {
|
||||||
|
setError('Failed to list objects')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBuckets()
|
||||||
|
}, [loadBuckets])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bucket) loadObjects(bucket, prefix)
|
||||||
|
}, [bucket, prefix, loadObjects])
|
||||||
|
|
||||||
|
const crumbs = prefix ? prefix.split('/').filter(Boolean) : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
|
<HardDrive className="h-4 w-4 text-docker" />
|
||||||
|
ObjectScale S3 Storage
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-foreground-muted">
|
||||||
|
Dell ECS · {health?.endpoint || '10.0.20.111:9020'} · live bucket browser
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<a href="/jupyter/" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
|
||||||
|
<ExternalLink className="h-3 w-3" /> Open Jupyter
|
||||||
|
</a>
|
||||||
|
<button type="button" onClick={() => { loadBuckets(); if (bucket) loadObjects(bucket, prefix) }} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
|
||||||
|
<RefreshCw className={cn('inline h-3 w-3', loading && 'animate-spin')} /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
|
<aside className="shrink-0 border-b border-border p-3 lg:w-52 lg:border-b-0 lg:border-r">
|
||||||
|
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Buckets</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{buckets.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setBucket(b.name); setPrefix('') }}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 rounded border px-2 py-1.5 text-left text-[10px]',
|
||||||
|
bucket === b.name ? 'border-docker/40 bg-docker/10' : 'border-border hover:bg-surface-overlay',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Database className="h-3 w-3 shrink-0 text-docker" />
|
||||||
|
<span className="truncate font-medium">{b.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{buckets.length === 0 && !loading && (
|
||||||
|
<p className="text-[9px] text-foreground-faint">No buckets or access denied.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col p-3">
|
||||||
|
{bucket && (
|
||||||
|
<nav className="mb-2 flex flex-wrap items-center gap-1 text-[10px] text-foreground-muted">
|
||||||
|
<button type="button" className="hover:text-docker" onClick={() => setPrefix('')}>{bucket}</button>
|
||||||
|
{crumbs.map((c, i) => (
|
||||||
|
<span key={i} className="inline-flex items-center gap-1">
|
||||||
|
<ChevronRight className="h-3 w-3" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:text-docker"
|
||||||
|
onClick={() => setPrefix(crumbs.slice(0, i + 1).join('/') + '/')}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<p className="flex items-center gap-2 text-[11px] text-foreground-muted">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="mb-2 text-[11px] text-danger">{error}</p>}
|
||||||
|
|
||||||
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<table className="w-full text-left text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-[9px] uppercase text-foreground-faint">
|
||||||
|
<th className="py-1.5 pr-2">Name</th>
|
||||||
|
<th className="py-1.5 pr-2">Size</th>
|
||||||
|
<th className="py-1.5 pr-2">Modified</th>
|
||||||
|
<th className="py-1.5" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{folders.map((f) => (
|
||||||
|
<tr key={f.prefix} className="border-b border-border/50 hover:bg-surface-overlay/50">
|
||||||
|
<td className="py-1.5 pr-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 font-medium text-docker hover:underline"
|
||||||
|
onClick={() => setPrefix(f.prefix || '')}
|
||||||
|
>
|
||||||
|
<Folder className="h-3.5 w-3.5" /> {f.name}/
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-2 text-foreground-faint">—</td>
|
||||||
|
<td className="py-1.5 pr-2 text-foreground-faint">—</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{objects.map((o) => (
|
||||||
|
<tr key={o.key} className="border-b border-border/50 hover:bg-surface-overlay/50">
|
||||||
|
<td className="max-w-[240px] truncate py-1.5 pr-2 font-mono text-[10px]">{o.name || o.key}</td>
|
||||||
|
<td className="py-1.5 pr-2 text-foreground-muted">{o.size_human}</td>
|
||||||
|
<td className="py-1.5 pr-2 text-foreground-faint">{o.modified?.slice(0, 19) || '—'}</td>
|
||||||
|
<td className="py-1.5">
|
||||||
|
{o.key && bucket && (
|
||||||
|
<a
|
||||||
|
href={`/api/storage/s3/buckets/${encodeURIComponent(bucket)}/download?key=${encodeURIComponent(o.key)}`}
|
||||||
|
className="inline-flex items-center gap-0.5 text-docker hover:underline"
|
||||||
|
>
|
||||||
|
<Download className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{!loading && folders.length === 0 && objects.length === 0 && bucket && (
|
||||||
|
<p className="py-8 text-center text-sm text-foreground-muted">This prefix is empty.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { Terminal } from 'lucide-react'
|
||||||
|
import type { TerminalLine } from '../../types'
|
||||||
|
import { resolveInfraNode } from '../../lib/infraCatalog'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
subjectId: string | null
|
||||||
|
subjectLabel: string
|
||||||
|
lines: TerminalLine[]
|
||||||
|
busy: boolean
|
||||||
|
expanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL: Record<string, string> = {
|
||||||
|
info: 'text-foreground-muted',
|
||||||
|
ok: 'text-success',
|
||||||
|
warn: 'text-warning',
|
||||||
|
err: 'text-danger',
|
||||||
|
cmd: 'text-docker',
|
||||||
|
llm: 'text-violet-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalDock({ subjectId, subjectLabel, lines, busy, expanded, onToggle }: Props) {
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
const infra = resolveInfraNode(subjectId)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expanded) bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [lines, busy, expanded])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex shrink-0 flex-col border-t border-border bg-black/80', expanded ? 'h-[200px]' : 'h-9')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex shrink-0 items-center justify-between px-3 py-2 text-left hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-[10px] font-medium text-emerald-300">
|
||||||
|
<Terminal className="h-3.5 w-3.5" />
|
||||||
|
Terminal — {subjectLabel}
|
||||||
|
{busy && <span className="animate-pulse text-docker">● live</span>}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[8px] text-foreground-faint">{lines.length} lines · {expanded ? '▼' : '▲'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto px-3 pb-2 font-mono text-[9px] leading-relaxed">
|
||||||
|
{infra && (
|
||||||
|
<p className="mb-1 text-foreground-faint">
|
||||||
|
<span className="text-docker">$</span> {infra.ssh} <span className="text-foreground-faint/70">(gekopieerd bij Shell-knop)</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{lines.length === 0 && (
|
||||||
|
<p className="py-4 text-center text-foreground-faint">Selecteer een node of agent · klik Shell of Probe om output te zien</p>
|
||||||
|
)}
|
||||||
|
{lines.map((line) => (
|
||||||
|
<div key={line.id} className={LEVEL[line.level] || 'text-foreground-muted'}>
|
||||||
|
<span className="text-foreground-faint/60">
|
||||||
|
{line.ts ? new Date(line.ts).toLocaleTimeString('en-US', { hour12: false }) : ''}
|
||||||
|
</span>{' '}
|
||||||
|
<span className="text-docker/80">[{line.phase}]</span> {line.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{busy && <span className="text-docker animate-pulse">█</span>}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { DatabaseZap, HardDrive, LayoutDashboard, MessageSquare, Presentation, ShieldCheck } from 'lucide-react'
|
||||||
|
import type { Agent, AgentAnim, GpuStatus } from '../../types'
|
||||||
|
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
|
||||||
|
import { getAgentMeta } from '../../lib/agentMeta'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { viewTabActive, viewTabIdle } from '../../lib/tabActive'
|
||||||
|
import { GpuMatrixPanel } from '../features/GpuMatrixPanel'
|
||||||
|
|
||||||
|
type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'approvals'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[]
|
||||||
|
animations: Record<string, AgentAnim>
|
||||||
|
gpu: GpuStatus | null
|
||||||
|
gpuLive: GpuLiveMetrics
|
||||||
|
gpuBoost?: boolean
|
||||||
|
selectedAgentId: string | null
|
||||||
|
selectedNodeId: string | null
|
||||||
|
mainView: MainView
|
||||||
|
approvalCount: number
|
||||||
|
agentsLoading: boolean
|
||||||
|
onSetMainView: (view: MainView) => void
|
||||||
|
onOpenApprovals: () => void
|
||||||
|
onSelectAgent: (id: string) => void
|
||||||
|
onSelectZone: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [
|
||||||
|
{ id: 'platform', label: 'Data Platform', icon: LayoutDashboard },
|
||||||
|
{ id: 'presentation', label: 'Presentation', icon: Presentation },
|
||||||
|
{ id: 'dataquality', label: 'Data Quality', icon: DatabaseZap },
|
||||||
|
{ id: 'knowledge', label: 'Knowledge Chat', icon: MessageSquare },
|
||||||
|
{ id: 'storage', label: 'Object Storage', icon: HardDrive },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SideNav({
|
||||||
|
agents,
|
||||||
|
animations,
|
||||||
|
gpu,
|
||||||
|
gpuLive,
|
||||||
|
gpuBoost = false,
|
||||||
|
selectedAgentId,
|
||||||
|
selectedNodeId,
|
||||||
|
mainView,
|
||||||
|
approvalCount,
|
||||||
|
agentsLoading,
|
||||||
|
onSetMainView,
|
||||||
|
onOpenApprovals,
|
||||||
|
onSelectAgent,
|
||||||
|
onSelectZone,
|
||||||
|
}: Props) {
|
||||||
|
const supervisors = agents.filter((a) => a.supervisor)
|
||||||
|
const operators = agents.filter((a) => !a.supervisor)
|
||||||
|
const matrixBoost = gpuBoost || mainView === 'knowledge'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex w-[240px] shrink-0 flex-col border-r border-border bg-surface-raised">
|
||||||
|
<section className="border-b border-border p-3">
|
||||||
|
<h2 className="mb-2 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">Views</h2>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{VIEWS.map(({ id, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSetMainView(id)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left transition-all',
|
||||||
|
mainView === id ? viewTabActive : viewTabIdle,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-4 w-4', mainView === id ? 'text-docker' : 'text-foreground-muted')} />
|
||||||
|
<span className={cn('text-[11px] font-medium', mainView === id ? 'text-docker' : 'text-foreground')}>{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<GpuMatrixPanel
|
||||||
|
gpu={gpu}
|
||||||
|
live={gpuLive}
|
||||||
|
boost={matrixBoost}
|
||||||
|
onSelectGpu={() => onSelectZone('gpu')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="flex min-h-0 flex-1 flex-col p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-1">
|
||||||
|
<h2 className="text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">Agents</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenApprovals}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded-md border px-2 py-0.5 text-[9px] font-medium transition-colors',
|
||||||
|
approvalCount > 0
|
||||||
|
? 'border-warning/40 bg-warning/10 text-warning'
|
||||||
|
: 'border-border text-foreground-muted hover:border-border-strong',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3 w-3" />
|
||||||
|
Approvals
|
||||||
|
{approvalCount > 0 && <span className="font-mono">{approvalCount}</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="scrollbar-thin flex-1 space-y-1 overflow-y-auto">
|
||||||
|
{agentsLoading && agents.length === 0 && (
|
||||||
|
<p className="text-[9px] text-foreground-faint">Loading agents…</p>
|
||||||
|
)}
|
||||||
|
{supervisors.length > 0 && (
|
||||||
|
<p className="text-[8px] uppercase tracking-widest text-foreground-faint">Supervisors</p>
|
||||||
|
)}
|
||||||
|
{supervisors.map((a) => (
|
||||||
|
<AgentRow key={a.id} agent={a} animations={animations} selected={selectedAgentId === a.id} onSelect={onSelectAgent} />
|
||||||
|
))}
|
||||||
|
{operators.length > 0 && (
|
||||||
|
<p className="mt-1 text-[8px] uppercase tracking-widest text-foreground-faint">Field operators</p>
|
||||||
|
)}
|
||||||
|
{operators.map((a) => (
|
||||||
|
<AgentRow key={a.id} agent={a} animations={animations} selected={selectedAgentId === a.id} onSelect={onSelectAgent} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentRow({
|
||||||
|
agent,
|
||||||
|
animations,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
agent: Agent
|
||||||
|
animations: Record<string, AgentAnim>
|
||||||
|
selected: boolean
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
}) {
|
||||||
|
const meta = getAgentMeta(agent.id)
|
||||||
|
const Icon = meta.icon
|
||||||
|
const busy = (animations[agent.id]?.state || 'idle') !== 'idle'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(agent.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 rounded-md border px-2 py-2 text-left transition-colors',
|
||||||
|
selected ? 'border-docker/40 bg-docker-light' : 'border-transparent hover:border-border hover:bg-surface-overlay',
|
||||||
|
)}
|
||||||
|
style={busy ? { boxShadow: `inset 3px 0 0 0 ${meta.accent}` } : undefined}
|
||||||
|
>
|
||||||
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-overlay" style={{ color: meta.accent }}>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-[11px] font-medium text-foreground">{agent.name.split(' ·')[0]}</span>
|
||||||
|
<span className="block truncate font-mono text-[9px] text-foreground-faint">{meta.domain}</span>
|
||||||
|
<span className="block truncate text-[8px] text-foreground-faint">{agent.role}</span>
|
||||||
|
</span>
|
||||||
|
{(agent.stats?.tasks ?? 0) > 0 && (
|
||||||
|
<span className="rounded-full bg-docker px-1.5 font-mono text-[8px] text-foreground">{agent.stats?.tasks}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Moon, Sun } from 'lucide-react'
|
||||||
|
import { useTheme } from '../../context/ThemeContext'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, toggle } = useTheme()
|
||||||
|
const isDark = theme === 'dark'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 items-center gap-1.5 rounded-lg border px-2.5 text-[10px] font-medium transition-colors',
|
||||||
|
isDark
|
||||||
|
? 'border-blue-400/30 bg-blue-500/15 text-blue-200 hover:bg-blue-500/25'
|
||||||
|
: 'border-border bg-surface-overlay text-foreground-muted hover:bg-docker-light hover:text-docker',
|
||||||
|
)}
|
||||||
|
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
{isDark ? <Moon className="h-3.5 w-3.5" /> : <Sun className="h-3.5 w-3.5" />}
|
||||||
|
<span className="hidden sm:inline">{isDark ? 'Dark' : 'Light'}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Activity, Bot, Box, Clock, ShieldAlert } from 'lucide-react'
|
||||||
|
import type { Agent, Approval, StatusData, WorkloadData } from '../../types'
|
||||||
|
import { Badge } from '../ui/Badge'
|
||||||
|
import { ThemeToggle } from './ThemeToggle'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
clock: string
|
||||||
|
status: StatusData | null
|
||||||
|
workload: WorkloadData | null
|
||||||
|
agents: Agent[]
|
||||||
|
approvals: Approval[]
|
||||||
|
onApprovalsClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({ clock, workload, agents, approvals, onApprovalsClick }: Props) {
|
||||||
|
const pipelineOk = workload?.totals?.pipeline_active ?? false
|
||||||
|
const running = workload?.totals?.apps_running ?? 0
|
||||||
|
const total = workload?.totals?.apps_total ?? 0
|
||||||
|
const activeAgents = agents.filter((a) => (a.stats?.tasks ?? 0) > 0).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border bg-surface-raised/90 px-3 shadow-panel backdrop-blur-sm">
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-docker to-blue-600 shadow-docker">
|
||||||
|
<Box className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-sm font-semibold text-foreground">Data & AI Command Center</h1>
|
||||||
|
<p className="text-[9px] text-foreground-muted">ATC Lab · Enterprise Operations</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden items-center gap-1.5 md:flex">
|
||||||
|
<Badge variant={pipelineOk ? 'success' : 'warning'}>
|
||||||
|
<Activity className="h-3 w-3" />
|
||||||
|
Pipeline {pipelineOk ? 'active' : 'degraded'}
|
||||||
|
</Badge>
|
||||||
|
<Badge>{running}/{total} containers</Badge>
|
||||||
|
<Badge variant="accent">
|
||||||
|
<Bot className="h-3 w-3" />
|
||||||
|
{activeAgents} agents
|
||||||
|
</Badge>
|
||||||
|
<button type="button" onClick={onApprovalsClick} className="focus:outline-none">
|
||||||
|
<Badge
|
||||||
|
variant={approvals.length ? 'warning' : 'default'}
|
||||||
|
className={cn(approvals.length && 'cursor-pointer hover:opacity-90')}
|
||||||
|
>
|
||||||
|
<ShieldAlert className="h-3 w-3" />
|
||||||
|
{approvals.length}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
<div className="flex items-center gap-1.5 font-mono text-[10px] text-foreground-muted">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{clock}
|
||||||
|
<span className="hidden text-success sm:inline">●</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import type { HTMLAttributes } from 'react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[10px] font-medium',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-border bg-surface-overlay text-foreground-muted dark:bg-surface-overlay dark:text-foreground-muted',
|
||||||
|
accent: 'border-docker/30 bg-docker-light text-docker dark:border-blue-400/30 dark:bg-blue-500/15 dark:text-blue-200',
|
||||||
|
success: 'border-success/30 bg-green-50 text-green-700 dark:border-green-500/30 dark:bg-green-500/15 dark:text-green-300',
|
||||||
|
warning: 'border-warning/30 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300',
|
||||||
|
danger: 'border-danger/30 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
type Props = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>
|
||||||
|
|
||||||
|
export function Badge({ className, variant, ...props }: Props) {
|
||||||
|
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import type { ButtonHTMLAttributes } from 'react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-1.5 rounded-md border font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-docker/40 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-docker/30 bg-docker text-foreground hover:bg-docker-dark',
|
||||||
|
ghost: 'border-transparent text-foreground-muted hover:bg-surface-overlay hover:text-foreground',
|
||||||
|
outline: 'border-border bg-surface-raised text-foreground hover:bg-surface-overlay',
|
||||||
|
success: 'border-success/30 bg-green-600 text-foreground hover:bg-green-700',
|
||||||
|
danger: 'border-danger/30 bg-red-600 text-foreground hover:bg-red-700',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: 'h-7 px-2.5 text-xs',
|
||||||
|
md: 'h-8 px-3 text-sm',
|
||||||
|
icon: 'h-8 w-8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default', size: 'md' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
type Props = ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
|
||||||
|
|
||||||
|
export function Button({ className, variant, size, ...props }: Props) {
|
||||||
|
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { HTMLAttributes } from 'react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
type Props = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
padding?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ className, padding = true, children, ...props }: Props) {
|
||||||
|
return (
|
||||||
|
<div className={cn('panel', padding && 'p-3', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('mb-2 flex items-center justify-between gap-2', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return <h3 className={cn('text-xs font-semibold tracking-tight text-foreground', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return <p className={cn('text-[10px] text-foreground-muted', className)} {...props} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { InputHTMLAttributes } from 'react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
type Props = InputHTMLAttributes<HTMLInputElement>
|
||||||
|
|
||||||
|
export function Input({ className, ...props }: Props) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-full rounded-md border border-border bg-surface px-2.5 text-sm text-foreground placeholder:text-foreground-faint focus:border-docker/50 focus:outline-none focus:ring-1 focus:ring-docker/30',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
|
type ThemeContextValue = {
|
||||||
|
theme: Theme
|
||||||
|
toggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||||
|
const STORAGE_KEY = 'atc-command-center-theme'
|
||||||
|
|
||||||
|
function readStored(): Theme {
|
||||||
|
const v = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return v === 'dark' || v === 'light' ? v : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
if (typeof window === 'undefined') return 'light'
|
||||||
|
return readStored()
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
root.classList.remove('light', 'dark')
|
||||||
|
root.classList.add(theme)
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const toggle = () => setTheme((t) => (t === 'light' ? 'dark' : 'light'))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggle }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const ctx = useContext(ThemeContext)
|
||||||
|
if (!ctx) throw new Error('useTheme outside ThemeProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export function useClock() {
|
||||||
|
const [now, setNow] = useState(new Date())
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setNow(new Date()), 1000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
return now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
askNode as apiAskNode,
|
||||||
|
decideApproval,
|
||||||
|
fetchAgents,
|
||||||
|
fetchApprovals,
|
||||||
|
fetchFeed,
|
||||||
|
fetchGpu,
|
||||||
|
fetchNodeDetail,
|
||||||
|
fetchStatus,
|
||||||
|
fetchTerminals,
|
||||||
|
fetchWorkload,
|
||||||
|
probeNode as apiProbeNode,
|
||||||
|
sendPrompt as apiSendPrompt,
|
||||||
|
} from '../lib/api'
|
||||||
|
import { AGENT_NODE, NODE_ALIASES, wsUrl } from '../lib/constants'
|
||||||
|
import { resolveInfraNode } from '../lib/infraCatalog'
|
||||||
|
import type {
|
||||||
|
Agent,
|
||||||
|
AgentAnim,
|
||||||
|
Approval,
|
||||||
|
ChatMessage,
|
||||||
|
FeedEntry,
|
||||||
|
GpuStatus,
|
||||||
|
NodeDetail,
|
||||||
|
StatusData,
|
||||||
|
TerminalLine,
|
||||||
|
TopologyNode,
|
||||||
|
WorkloadData,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
function resolveProbeId(nodeId: string) {
|
||||||
|
const aliased = NODE_ALIASES[nodeId] || nodeId
|
||||||
|
const infra = resolveInfraNode(nodeId) || resolveInfraNode(aliased)
|
||||||
|
return infra?.id || aliased
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommandCenter() {
|
||||||
|
const [agents, setAgents] = useState<Agent[]>([])
|
||||||
|
const [agentsLoading, setAgentsLoading] = useState(true)
|
||||||
|
const [status, setStatus] = useState<StatusData | null>(null)
|
||||||
|
const [workload, setWorkload] = useState<WorkloadData | null>(null)
|
||||||
|
const [gpu, setGpu] = useState<GpuStatus | null>(null)
|
||||||
|
const [feed, setFeed] = useState<FeedEntry[]>([])
|
||||||
|
const [approvals, setApprovals] = useState<Approval[]>([])
|
||||||
|
const [chat, setChat] = useState<ChatMessage[]>([])
|
||||||
|
const [anims, setAnims] = useState<Record<string, AgentAnim>>({})
|
||||||
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
|
||||||
|
const [promptBusy, setPromptBusy] = useState(false)
|
||||||
|
const [terminals, setTerminals] = useState<Record<string, TerminalLine[]>>({})
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||||
|
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null)
|
||||||
|
const [nodeDetail, setNodeDetail] = useState<NodeDetail | null>(null)
|
||||||
|
const [nodeBusy, setNodeBusy] = useState(false)
|
||||||
|
const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage'>('platform')
|
||||||
|
const [approvalHighlight, setApprovalHighlight] = useState(false)
|
||||||
|
const [chatExpanded, setChatExpanded] = useState(false)
|
||||||
|
const promptTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const [terminalExpanded, setTerminalExpanded] = useState(true)
|
||||||
|
|
||||||
|
const appendTerminal = useCallback((line: TerminalLine) => {
|
||||||
|
setTerminals((prev) => {
|
||||||
|
const cur = prev[line.agent_id] || []
|
||||||
|
return { ...prev, [line.agent_id]: [...cur, line].slice(-300) }
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectedAgent = useMemo(
|
||||||
|
() => agents.find((a) => a.id === selectedAgentId) || null,
|
||||||
|
[agents, selectedAgentId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const reloadFast = useCallback(async () => {
|
||||||
|
const [a, s, f, ap, g, t] = await Promise.all([
|
||||||
|
fetchAgents(),
|
||||||
|
fetchStatus(),
|
||||||
|
fetchFeed(),
|
||||||
|
fetchApprovals(),
|
||||||
|
fetchGpu(),
|
||||||
|
fetchTerminals(),
|
||||||
|
])
|
||||||
|
setAgents(a)
|
||||||
|
setAgentsLoading(false)
|
||||||
|
setStatus(s)
|
||||||
|
setGpu(g || s?.gpu || null)
|
||||||
|
setFeed(f)
|
||||||
|
setApprovals(ap)
|
||||||
|
setTerminals(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reloadWorkload = useCallback(async () => {
|
||||||
|
const w = await fetchWorkload()
|
||||||
|
if (w?.zones) setWorkload(w)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
await reloadFast()
|
||||||
|
reloadWorkload()
|
||||||
|
}, [reloadFast, reloadWorkload])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadFast().then(() => reloadWorkload())
|
||||||
|
const ws = new WebSocket(wsUrl())
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
const msg = JSON.parse(ev.data)
|
||||||
|
if (msg.type === 'status') {
|
||||||
|
setStatus(msg.data)
|
||||||
|
if (msg.data.gpu) setGpu(msg.data.gpu)
|
||||||
|
}
|
||||||
|
if (msg.type === 'workload') setWorkload(msg.data)
|
||||||
|
if (msg.type === 'terminal') appendTerminal(msg.line)
|
||||||
|
if (msg.type === 'terminal_history' && msg.terminals) setTerminals(msg.terminals)
|
||||||
|
if (msg.type === 'feed') setFeed((prev) => [msg.entry, ...prev].slice(0, 100))
|
||||||
|
if (msg.type === 'agent_dispatch') {
|
||||||
|
setSelectedAgentId(msg.agent_id)
|
||||||
|
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'walk', zone: msg.zone } }))
|
||||||
|
}
|
||||||
|
if (msg.type === 'agent_fetch') {
|
||||||
|
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'fetch', zone: msg.zone } }))
|
||||||
|
}
|
||||||
|
if (msg.type === 'agent_return') {
|
||||||
|
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'return', zone: msg.zone } }))
|
||||||
|
setTimeout(() => {
|
||||||
|
setAnims((p) => ({ ...p, [msg.agent_id]: { agentId: msg.agent_id, state: 'idle' } }))
|
||||||
|
}, 1200)
|
||||||
|
}
|
||||||
|
if (msg.type === 'prompt_result') {
|
||||||
|
setChat((c) => [...c, { role: 'agent', text: msg.answer, agent: msg.agent_id, ts: new Date().toISOString() }])
|
||||||
|
setPromptBusy(false)
|
||||||
|
reloadFast()
|
||||||
|
}
|
||||||
|
if (msg.type === 'approval_new') {
|
||||||
|
setApprovals((prev) => {
|
||||||
|
if (prev.some((a) => a.id === msg.approval?.id)) return prev
|
||||||
|
return [msg.approval, ...prev]
|
||||||
|
})
|
||||||
|
if (msg.approval?.status === 'pending') setApprovalHighlight(true)
|
||||||
|
}
|
||||||
|
if (msg.type === 'approval_update' && msg.approval) {
|
||||||
|
setApprovals((prev) => prev.filter((a) => a.id !== msg.approval.id))
|
||||||
|
}
|
||||||
|
if (msg.type === 'node_ask_result') {
|
||||||
|
setNodeBusy(false)
|
||||||
|
if (msg.agent_id) setSelectedAgentId(msg.agent_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const iv = setInterval(reloadFast, 15000)
|
||||||
|
const wv = setInterval(reloadWorkload, 45000)
|
||||||
|
return () => { ws.close(); clearInterval(iv); clearInterval(wv) }
|
||||||
|
}, [reloadFast, reloadWorkload, appendTerminal])
|
||||||
|
|
||||||
|
const findNodeStub = useCallback((nodeId: string): TopologyNode | null => {
|
||||||
|
const resolved = NODE_ALIASES[nodeId] || nodeId
|
||||||
|
const allNodes = [
|
||||||
|
...(workload?.topology?.nodes || []),
|
||||||
|
...Object.values(workload?.topologies || {}).flatMap((v) => v.nodes),
|
||||||
|
]
|
||||||
|
const wn = allNodes.find((n) => n.id === nodeId) || allNodes.find((n) => n.id === resolved)
|
||||||
|
if (wn) return wn
|
||||||
|
|
||||||
|
const infra = resolveInfraNode(nodeId) || resolveInfraNode(resolved)
|
||||||
|
if (infra) {
|
||||||
|
return {
|
||||||
|
id: infra.id,
|
||||||
|
label: infra.label,
|
||||||
|
vm: infra.vm,
|
||||||
|
ip: infra.ip,
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
color: infra.accent,
|
||||||
|
level: 'ok',
|
||||||
|
role: infra.description,
|
||||||
|
apps: infra.apps.map((a) => ({ name: a.label, state: 'link', image: a.url, ports: a.port ? [a.port] : [] })),
|
||||||
|
running: 1,
|
||||||
|
total: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = agents.find((a) => a.id === nodeId)
|
||||||
|
if (agent) {
|
||||||
|
return {
|
||||||
|
id: agent.id, label: agent.name, vm: 'agent', ip: '10.0.21.33',
|
||||||
|
x: 50, y: 50, color: agent.color, level: 'ok', role: agent.role, apps: [], running: 1, total: 1,
|
||||||
|
agent_id: agent.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const zone = workload?.zones.find((z) => z.id === nodeId || z.id === resolved)
|
||||||
|
if (zone) {
|
||||||
|
return {
|
||||||
|
id: nodeId, label: zone.label, vm: zone.vm || zone.id, ip: zone.ip || '',
|
||||||
|
x: 50, y: 50, color: zone.color, level: zone.level, role: 'zone', apps: zone.apps,
|
||||||
|
running: zone.running, total: zone.total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [workload, agents])
|
||||||
|
|
||||||
|
const probeNodeId = useCallback((nodeId: string) => {
|
||||||
|
setNodeBusy(true)
|
||||||
|
setTerminalExpanded(true)
|
||||||
|
apiProbeNode(resolveProbeId(nodeId)).finally(() => setNodeBusy(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openTerminal = useCallback((nodeId: string) => {
|
||||||
|
setTerminalExpanded(true)
|
||||||
|
setSelectedNodeId(nodeId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectNode = useCallback(async (nodeId: string) => {
|
||||||
|
const stub = findNodeStub(nodeId)
|
||||||
|
if (!stub) return
|
||||||
|
const probeId = resolveProbeId(nodeId)
|
||||||
|
setSelectedNodeId(probeId)
|
||||||
|
setSelectedNode({ ...stub, id: probeId })
|
||||||
|
setNodeDetail(null)
|
||||||
|
setTerminalExpanded(true)
|
||||||
|
const infra = resolveInfraNode(nodeId)
|
||||||
|
const linked = agents.find(
|
||||||
|
(a) => a.id === nodeId || AGENT_NODE[a.id] === probeId || a.id === infra?.agentId,
|
||||||
|
)
|
||||||
|
if (linked) setSelectedAgentId(linked.id)
|
||||||
|
try {
|
||||||
|
const detail = await fetchNodeDetail(probeId)
|
||||||
|
if (!detail.error) setNodeDetail(detail as NodeDetail)
|
||||||
|
} catch { /* ok */ }
|
||||||
|
probeNodeId(nodeId)
|
||||||
|
}, [findNodeStub, agents, probeNodeId])
|
||||||
|
|
||||||
|
const selectAgent = useCallback((id: string) => {
|
||||||
|
setSelectedAgentId(id)
|
||||||
|
setTerminalExpanded(true)
|
||||||
|
const agent = agents.find((a) => a.id === id)
|
||||||
|
if (!agent) return
|
||||||
|
const nodeId = agent.supervisor ? id : (AGENT_NODE[id] || agent.zone)
|
||||||
|
if (nodeId) {
|
||||||
|
const stub = findNodeStub(nodeId)
|
||||||
|
if (stub) {
|
||||||
|
selectNode(nodeId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedNodeId(null)
|
||||||
|
setSelectedNode(null)
|
||||||
|
setNodeDetail(null)
|
||||||
|
}, [agents, findNodeStub, selectNode])
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setSelectedNodeId(null)
|
||||||
|
setSelectedNode(null)
|
||||||
|
setNodeDetail(null)
|
||||||
|
setSelectedAgentId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const probeNode = useCallback(() => {
|
||||||
|
if (selectedNodeId) probeNodeId(selectedNodeId)
|
||||||
|
}, [selectedNodeId, probeNodeId])
|
||||||
|
|
||||||
|
const askNode = useCallback(async (message: string) => {
|
||||||
|
if (!selectedNodeId) return
|
||||||
|
setNodeBusy(true)
|
||||||
|
setTerminalExpanded(true)
|
||||||
|
await apiAskNode(resolveProbeId(selectedNodeId), message)
|
||||||
|
}, [selectedNodeId])
|
||||||
|
|
||||||
|
const sendPrompt = useCallback(async (message: string, agentId?: string) => {
|
||||||
|
setPromptBusy(true)
|
||||||
|
setChatExpanded(true)
|
||||||
|
setChat((c) => [...c, { role: 'user', text: message, ts: new Date().toISOString() }])
|
||||||
|
if (agentId) setSelectedAgentId(agentId)
|
||||||
|
await apiSendPrompt(message, agentId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const decide = useCallback(async (id: string, approved: boolean, decidedBy = 'mo-commander', note = '') => {
|
||||||
|
setApprovals((prev) => prev.filter((a) => a.id !== id))
|
||||||
|
await decideApproval(id, approved, decidedBy, note)
|
||||||
|
reloadFast()
|
||||||
|
}, [reloadFast])
|
||||||
|
|
||||||
|
const terminalSubjectId = selectedNodeId || selectedAgentId
|
||||||
|
|
||||||
|
const inspectorLines = useMemo(() => {
|
||||||
|
if (!terminalSubjectId) return []
|
||||||
|
const probeId = resolveProbeId(terminalSubjectId)
|
||||||
|
return terminals[probeId] || terminals[terminalSubjectId] || terminals[selectedAgentId || ''] || []
|
||||||
|
}, [terminalSubjectId, terminals, selectedAgentId])
|
||||||
|
|
||||||
|
const focusApprovals = useCallback(() => {
|
||||||
|
setApprovalHighlight(true)
|
||||||
|
setMainView('approvals')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
agents,
|
||||||
|
agentsLoading,
|
||||||
|
status,
|
||||||
|
workload,
|
||||||
|
gpu,
|
||||||
|
feed,
|
||||||
|
approvals,
|
||||||
|
chat,
|
||||||
|
anims,
|
||||||
|
selectedAgentId,
|
||||||
|
selectedAgent,
|
||||||
|
promptBusy,
|
||||||
|
selectedNodeId,
|
||||||
|
selectedNode,
|
||||||
|
nodeDetail,
|
||||||
|
nodeBusy,
|
||||||
|
mainView,
|
||||||
|
setMainView,
|
||||||
|
approvalHighlight,
|
||||||
|
setApprovalHighlight,
|
||||||
|
inspectorLines,
|
||||||
|
terminalSubjectId,
|
||||||
|
terminalExpanded,
|
||||||
|
setTerminalExpanded,
|
||||||
|
selectNode,
|
||||||
|
selectAgent,
|
||||||
|
clearSelection,
|
||||||
|
probeNode,
|
||||||
|
probeNodeId,
|
||||||
|
openTerminal,
|
||||||
|
askNode,
|
||||||
|
sendPrompt,
|
||||||
|
decide,
|
||||||
|
focusApprovals,
|
||||||
|
reload,
|
||||||
|
chatExpanded,
|
||||||
|
setChatExpanded,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { Agent, AgentAnim, GpuStatus } from '../types'
|
||||||
|
import { pseudoAgentLoad } from '../lib/agentMeta'
|
||||||
|
|
||||||
|
export type AgentLoad = { cpu: number; mem: number }
|
||||||
|
|
||||||
|
export type GpuLiveMetrics = {
|
||||||
|
tokenThroughput: number
|
||||||
|
avgUtil: number
|
||||||
|
avgVram: number
|
||||||
|
deviceUtils: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(n: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
function memPct(used: number, total: number) {
|
||||||
|
if (!total) return 0
|
||||||
|
return Math.round((used / total) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLiveMetrics(
|
||||||
|
agents: Agent[],
|
||||||
|
gpu: GpuStatus | null,
|
||||||
|
animations: Record<string, AgentAnim>,
|
||||||
|
boost = false,
|
||||||
|
) {
|
||||||
|
const [agentLoads, setAgentLoads] = useState<Record<string, AgentLoad>>({})
|
||||||
|
const [gpuLive, setGpuLive] = useState<GpuLiveMetrics>({
|
||||||
|
tokenThroughput: 0,
|
||||||
|
avgUtil: 0,
|
||||||
|
avgVram: 0,
|
||||||
|
deviceUtils: [],
|
||||||
|
})
|
||||||
|
const loadsRef = useRef(agentLoads)
|
||||||
|
loadsRef.current = agentLoads
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const seed: Record<string, AgentLoad> = {}
|
||||||
|
for (const a of agents) {
|
||||||
|
seed[a.id] = pseudoAgentLoad(a.stats)
|
||||||
|
}
|
||||||
|
setAgentLoads(seed)
|
||||||
|
|
||||||
|
const gpus = gpu?.gpus || []
|
||||||
|
const baseUtil = gpus.length ? gpus.reduce((s, g) => s + g.util_gpu, 0) / gpus.length : 0
|
||||||
|
const baseVram = gpus.length
|
||||||
|
? gpus.reduce((s, g) => s + memPct(g.memory_used_mib, g.memory_total_mib), 0) / gpus.length
|
||||||
|
: 0
|
||||||
|
const inferenceOn = gpu?.ok && gpu.inference_active
|
||||||
|
setGpuLive({
|
||||||
|
tokenThroughput: inferenceOn ? Math.round(baseUtil * 42 + 120) : 0,
|
||||||
|
avgUtil: baseUtil,
|
||||||
|
avgVram: baseVram,
|
||||||
|
deviceUtils: gpus.map((g) => g.util_gpu),
|
||||||
|
})
|
||||||
|
}, [agents, gpu])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = () => {
|
||||||
|
setAgentLoads((prev) => {
|
||||||
|
const next: Record<string, AgentLoad> = {}
|
||||||
|
for (const a of agents) {
|
||||||
|
const busy = (animations[a.id]?.state || 'idle') !== 'idle'
|
||||||
|
const base = pseudoAgentLoad(a.stats)
|
||||||
|
const cur = prev[a.id] || base
|
||||||
|
const drift = (Math.random() - 0.5) * (busy ? 7 : 2.5)
|
||||||
|
const driftMem = (Math.random() - 0.5) * (busy ? 5 : 2)
|
||||||
|
const targetCpu = busy ? Math.max(base.cpu, cur.cpu) : base.cpu
|
||||||
|
const targetMem = busy ? Math.max(base.mem, cur.mem) : base.mem
|
||||||
|
next[a.id] = {
|
||||||
|
cpu: clamp(Math.round(cur.cpu + drift + (busy ? 1.2 : -0.3)), 4, 96),
|
||||||
|
mem: clamp(Math.round(cur.mem + driftMem + (busy ? 0.8 : -0.2)), 6, 92),
|
||||||
|
}
|
||||||
|
if (!busy) {
|
||||||
|
next[a.id].cpu = clamp(Math.round(next[a.id].cpu * 0.85 + targetCpu * 0.15), 4, 96)
|
||||||
|
next[a.id].mem = clamp(Math.round(next[a.id].mem * 0.85 + targetMem * 0.15), 6, 92)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
if (gpu?.ok) {
|
||||||
|
const gpus = gpu.gpus || []
|
||||||
|
const inferenceOn = gpu.inference_active
|
||||||
|
setGpuLive((prev) => {
|
||||||
|
const baseUtil = gpus.length
|
||||||
|
? gpus.reduce((s, g) => s + g.util_gpu, 0) / gpus.length
|
||||||
|
: prev.avgUtil
|
||||||
|
const baseVram = gpus.length
|
||||||
|
? gpus.reduce((s, g) => s + memPct(g.memory_used_mib, g.memory_total_mib), 0) / gpus.length
|
||||||
|
: prev.avgVram
|
||||||
|
const jitterScale = boost ? 12 : 6
|
||||||
|
const utilJitter = (Math.random() - 0.5) * (inferenceOn ? jitterScale : 2)
|
||||||
|
const avgUtil = clamp(baseUtil + utilJitter, 0, 100)
|
||||||
|
const avgVram = clamp(baseVram + (Math.random() - 0.5) * 3, 0, 100)
|
||||||
|
const deviceUtils = gpus.map((g, i) => {
|
||||||
|
const real = g.util_gpu
|
||||||
|
if (boost && inferenceOn) {
|
||||||
|
return clamp(real + (Math.random() - 0.5) * 8, 0, 100)
|
||||||
|
}
|
||||||
|
return clamp((prev.deviceUtils[i] ?? real) + (Math.random() - 0.5) * 5, 0, 100)
|
||||||
|
})
|
||||||
|
const tokenBase = boost ? Math.max(180, baseUtil * 55 + 140) : baseUtil * 42 + 120
|
||||||
|
const tokenThroughput = inferenceOn
|
||||||
|
? clamp(Math.round(prev.tokenThroughput * 0.4 + tokenBase * 0.6 + (Math.random() - 0.5) * (boost ? 45 : 28)), boost ? 120 : 80, boost ? 520 : 420)
|
||||||
|
: 0
|
||||||
|
return { tokenThroughput, avgUtil, avgVram, deviceUtils }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick()
|
||||||
|
const id = setInterval(tick, boost ? 1000 : 5000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [agents, animations, gpu, boost])
|
||||||
|
|
||||||
|
return { agentLoads, gpuLive }
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: linear-gradient(165deg, #f0f4ff 0%, #e8eef9 35%, #f5f0ff 70%, #eef8ff 100%);
|
|
||||||
background-attachment: fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(rgba(0, 140, 200, 0.04) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(0, 140, 200, 0.04) 1px, transparent 1px);
|
|
||||||
background-size: 48px 48px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass {
|
|
||||||
background: rgba(255, 255, 255, 0.82);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid rgba(0, 160, 220, 0.18);
|
|
||||||
box-shadow:
|
|
||||||
0 4px 24px rgba(15, 40, 80, 0.06),
|
|
||||||
0 1px 0 rgba(255, 255, 255, 0.9) inset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-strong {
|
|
||||||
background: rgba(255, 255, 255, 0.94);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
border: 1px solid rgba(0, 160, 220, 0.22);
|
|
||||||
box-shadow: 0 8px 32px rgba(15, 40, 80, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.neon-text-cyan {
|
|
||||||
text-shadow: 0 0 24px rgba(0, 180, 220, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card {
|
|
||||||
background: linear-gradient(145deg, #ffffff 0%, #f8fbff 100%);
|
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 24px rgba(15, 40, 80, 0.1);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Cpu,
|
||||||
|
Database,
|
||||||
|
Layers,
|
||||||
|
Network,
|
||||||
|
Radio,
|
||||||
|
Server,
|
||||||
|
Shield,
|
||||||
|
TreePine,
|
||||||
|
Workflow,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { AgentAnim } from '../types'
|
||||||
|
|
||||||
|
export type AgentMeta = {
|
||||||
|
icon: LucideIcon
|
||||||
|
accent: string
|
||||||
|
idleTask: string
|
||||||
|
activeTask: string
|
||||||
|
domain: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_META: Record<string, AgentMeta> = {
|
||||||
|
'etl-guardian': {
|
||||||
|
icon: Workflow,
|
||||||
|
accent: '#38bdf8',
|
||||||
|
domain: 'ETL / CDC',
|
||||||
|
idleTask: 'Monitoring Airflow DAGs & Kafka connectors',
|
||||||
|
activeTask: 'Automating ETL layer — CDC sync validation',
|
||||||
|
},
|
||||||
|
'lakehouse-ops': {
|
||||||
|
icon: Layers,
|
||||||
|
accent: '#818cf8',
|
||||||
|
domain: 'Lakehouse',
|
||||||
|
idleTask: 'Watching Spark, Trino & Iceberg catalogs',
|
||||||
|
activeTask: 'Optimizing lakehouse queries & table health',
|
||||||
|
},
|
||||||
|
'data-custodian': {
|
||||||
|
icon: Database,
|
||||||
|
accent: '#34d399',
|
||||||
|
domain: 'Databases',
|
||||||
|
idleTask: 'Guarding PostgreSQL, MySQL & document stores',
|
||||||
|
activeTask: 'Running database health & replication checks',
|
||||||
|
},
|
||||||
|
'hadoop-ranger': {
|
||||||
|
icon: TreePine,
|
||||||
|
accent: '#4ade80',
|
||||||
|
domain: 'Hadoop',
|
||||||
|
idleTask: 'Patrolling HDFS capacity & YARN nodes',
|
||||||
|
activeTask: 'Analyzing HDFS blocks & cluster balance',
|
||||||
|
},
|
||||||
|
'infra-sentinel': {
|
||||||
|
icon: Server,
|
||||||
|
accent: '#94a3b8',
|
||||||
|
domain: 'Infrastructure',
|
||||||
|
idleTask: 'Observing Docker hosts & platform services',
|
||||||
|
activeTask: 'Correlating infra events across the lab',
|
||||||
|
},
|
||||||
|
'mo-commander': {
|
||||||
|
icon: Shield,
|
||||||
|
accent: '#60a5fa',
|
||||||
|
domain: 'Supervision',
|
||||||
|
idleTask: 'Ingress intel & approval oversight',
|
||||||
|
activeTask: 'Reviewing agent dispatch & approvals',
|
||||||
|
},
|
||||||
|
'bart-commander': {
|
||||||
|
icon: Radio,
|
||||||
|
accent: '#2dd4bf',
|
||||||
|
domain: 'Supervision',
|
||||||
|
idleTask: 'Egress monitoring & MCP comms relay',
|
||||||
|
activeTask: 'Tracking outbound agent communications',
|
||||||
|
},
|
||||||
|
'network-watcher': {
|
||||||
|
icon: Network,
|
||||||
|
accent: '#38bdf8',
|
||||||
|
domain: 'Network',
|
||||||
|
idleTask: 'VLAN 20/21 traffic path analysis',
|
||||||
|
activeTask: 'Mapping data ingress & egress flows',
|
||||||
|
},
|
||||||
|
'mcp-coordinator': {
|
||||||
|
icon: Cpu,
|
||||||
|
accent: '#c084fc',
|
||||||
|
domain: 'MCP Hub',
|
||||||
|
idleTask: 'Routing tool calls between agents',
|
||||||
|
activeTask: 'Orchestrating MCP tool execution',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_META: AgentMeta = {
|
||||||
|
icon: Bot,
|
||||||
|
accent: '#94a3b8',
|
||||||
|
domain: 'Agent',
|
||||||
|
idleTask: 'Standing by',
|
||||||
|
activeTask: 'Executing mission',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentMeta(agentId: string): AgentMeta {
|
||||||
|
return AGENT_META[agentId] || DEFAULT_META
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentTaskLabel(agentId: string, anim?: AgentAnim): string {
|
||||||
|
const meta = getAgentMeta(agentId)
|
||||||
|
if (!anim || anim.state === 'idle') return meta.idleTask
|
||||||
|
if (anim.state === 'walk') return `Routing to ${anim.zone || 'target zone'}…`
|
||||||
|
if (anim.state === 'fetch') return meta.activeTask
|
||||||
|
if (anim.state === 'return') return 'Publishing mission results…'
|
||||||
|
return meta.activeTask
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pseudoAgentLoad(stats?: { tasks: number; alerts: number }) {
|
||||||
|
const tasks = stats?.tasks ?? 0
|
||||||
|
const alerts = stats?.alerts ?? 0
|
||||||
|
const cpu = Math.min(94, 8 + tasks * 3 + alerts * 5)
|
||||||
|
const mem = Math.min(88, 12 + tasks * 2 + alerts * 4)
|
||||||
|
return { cpu, mem }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOMAIN_LABELS: Record<string, string> = {
|
||||||
|
docker: 'Docker Platform',
|
||||||
|
databases: 'Database Vault',
|
||||||
|
lakehouse: 'Lakehouse',
|
||||||
|
etl: 'ETL / Streaming',
|
||||||
|
hadoop: 'Hadoop Cluster',
|
||||||
|
gpu: 'GPU / AI',
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type {
|
||||||
|
PresentationData,
|
||||||
|
Agent,
|
||||||
|
Approval,
|
||||||
|
FeedEntry,
|
||||||
|
GpuStatus,
|
||||||
|
StatusData,
|
||||||
|
TerminalLine,
|
||||||
|
WorkloadData,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string, timeoutMs = 10000): Promise<T | null> {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { signal: ctrl.signal })
|
||||||
|
if (!r.ok) return null
|
||||||
|
return (await r.json()) as T
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAgents() {
|
||||||
|
const j = await fetchJson<{ agents?: Agent[] }>('/api/agents', 8000)
|
||||||
|
return j?.agents || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStatus() {
|
||||||
|
return (await fetchJson<StatusData>('/api/status', 8000)) as StatusData
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFeed() {
|
||||||
|
const j = await fetchJson<{ entries?: FeedEntry[] }>('/api/feed', 8000)
|
||||||
|
return j?.entries || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchApprovals() {
|
||||||
|
const j = await fetchJson<{ approvals?: Approval[] }>('/api/approvals', 8000)
|
||||||
|
return j?.approvals || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchApprovalHistory(status: string = 'pending') {
|
||||||
|
const j = await fetchJson<{
|
||||||
|
approvals?: Approval[]
|
||||||
|
stats?: { pending: number; approved: number; denied: number; total: number }
|
||||||
|
}>(`/api/approvals?status=${encodeURIComponent(status)}&limit=200`, 8000)
|
||||||
|
return { approvals: j?.approvals || [], stats: j?.stats }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGpu(): Promise<GpuStatus | null> {
|
||||||
|
return fetchJson<GpuStatus>('/api/gpu', 8000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTerminals(): Promise<Record<string, TerminalLine[]>> {
|
||||||
|
const j = await fetchJson<{ terminals?: Record<string, TerminalLine[]> }>('/api/terminals', 8000)
|
||||||
|
return j?.terminals || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWorkload(): Promise<WorkloadData | null> {
|
||||||
|
return fetchJson<WorkloadData>('/api/workload?fast=true', 25000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNodeDetail(nodeId: string) {
|
||||||
|
const j = await fetchJson<Record<string, unknown>>(`/api/nodes/${nodeId}`, 15000)
|
||||||
|
return j || { error: 'timeout' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function probeNode(nodeId: string) {
|
||||||
|
return fetch(`/api/nodes/${nodeId}/probe`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function askNode(nodeId: string, message: string) {
|
||||||
|
return fetch(`/api/nodes/${nodeId}/ask`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPrompt(message: string, agentId?: string) {
|
||||||
|
return fetch('/api/prompt', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message, agent_id: agentId || undefined }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPresentation(): Promise<PresentationData | null> {
|
||||||
|
return fetchJson<PresentationData>('/api/presentation', 60000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decideApproval(id: string, approved: boolean, decidedBy: string, note: string) {
|
||||||
|
return fetch(`/api/approvals/${id}/decide`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ approved, decided_by: decidedBy, note }),
|
||||||
|
})
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user