From 46b51a891c32629feddd4b9560090b55f2626fe5 Mon Sep 17 00:00:00 2001 From: mo Date: Fri, 26 Jun 2026 00:47:49 +0000 Subject: [PATCH] Add HDFS browser, SSH terminal, presentation editor, lab health panel - HDFS WebHDFS file browser (api/hdfs_api.py + HdfsView) - In-browser SSH terminal via paramiko WebSocket bridge (api/ssh_terminal.py + SshTerminal, xterm.js) - Presentation deck editor (text + image upload) and CRUD endpoints - Collapsible GPU matrix + new LabHealthPanel in SideNav - Topology fixes (edge alignment, Hadoop node, compact nodes) - nginx ws timeout bump for long-lived SSH sessions --- api/Dockerfile | 2 +- api/hdfs_api.py | 197 ++++++++ api/main.py | 82 +++- api/node_ops.py | 1 - api/presentation.py | 4 +- api/presentation_upload.py | 106 +++++ api/requirements.txt | 1 + api/ssh_terminal.py | 146 ++++++ ui/nginx.conf | 3 + ui/package.json | 2 + ui/src/App.tsx | 14 +- ui/src/components/features/GpuMatrixPanel.tsx | 224 +++++----- ui/src/components/features/HdfsView.tsx | 230 ++++++++++ ui/src/components/features/LabHealthPanel.tsx | 302 +++++++++++++ .../components/features/PlatformTopology.tsx | 174 +++++--- .../components/features/PresentationView.tsx | 421 +++++++++++++++--- ui/src/components/features/SshTerminal.tsx | 240 ++++++++++ ui/src/components/layout/SideNav.tsx | 118 ++--- ui/src/hooks/useCommandCenter.ts | 2 +- ui/src/styles/globals.css | 15 +- ui/src/types.ts | 1 + 21 files changed, 1957 insertions(+), 328 deletions(-) create mode 100644 api/hdfs_api.py create mode 100644 api/ssh_terminal.py create mode 100644 ui/src/components/features/HdfsView.tsx create mode 100644 ui/src/components/features/LabHealthPanel.tsx create mode 100644 ui/src/components/features/SshTerminal.tsx diff --git a/api/Dockerfile b/api/Dockerfile index 2bc9dad..143d313 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py . +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 elasticsearch_api.py sql_console.py hdfs_api.py ssh_terminal.py . RUN mkdir -p /data ENV DATABASE_URL=sqlite:////data/atc-agents.db EXPOSE 3201 diff --git a/api/hdfs_api.py b/api/hdfs_api.py new file mode 100644 index 0000000..68b53f8 --- /dev/null +++ b/api/hdfs_api.py @@ -0,0 +1,197 @@ +"""HDFS (WebHDFS) file browser API for Command Center — mirrors the S3 browser.""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import Any +from urllib.parse import quote + +import httpx +from fastapi import APIRouter, Query +from fastapi.responses import JSONResponse, StreamingResponse + +HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870").rstrip("/") +HDFS_USER = os.getenv("HDFS_USER", "hdfs") +WEBHDFS = f"{HDFS_NN_URL}/webhdfs/v1" + +router = APIRouter(prefix="/api/storage/hdfs", tags=["hdfs"]) + + +def _human_size(n: int | float | None) -> str: + if not n: + return "0 B" + n = float(n) + 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" + + +def _perm_to_rwx(perm: str, is_dir: bool) -> str: + try: + bits = int(str(perm), 8) + except ValueError: + return ("d" if is_dir else "-") + "---------" + chars = ["d" if is_dir else "-"] + for shift in (6, 3, 0): + triplet = (bits >> shift) & 0o7 + chars.append("r" if triplet & 4 else "-") + chars.append("w" if triplet & 2 else "-") + chars.append("x" if triplet & 1 else "-") + return "".join(chars) + + +def _ms_to_iso(ms: int | None) -> str: + if not ms: + return "" + try: + return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat() + except Exception: + return "" + + +def _norm(path: str) -> str: + p = (path or "/").strip() + if not p.startswith("/"): + p = "/" + p + if len(p) > 1 and p.endswith("/"): + p = p.rstrip("/") + return p or "/" + + +def _encode(path: str) -> str: + # keep the slash structure, encode each segment + return "/".join(quote(seg, safe="") for seg in path.split("/")) + + +@router.get("/health") +async def hdfs_health() -> dict[str, Any]: + try: + async with httpx.AsyncClient(timeout=6.0) as client: + jmx = f"{HDFS_NN_URL}/jmx?qry=Hadoop:service=NameNode,name=FSNamesystemState" + r = await client.get(jmx) + beans = (r.json().get("beans") or [{}])[0] + cap = beans.get("CapacityTotal") or 0 + used = beans.get("CapacityUsed") or 0 + return { + "ok": True, + "namenode": HDFS_NN_URL, + "user": HDFS_USER, + "live_datanodes": beans.get("NumLiveDataNodes"), + "dead_datanodes": beans.get("NumDeadDataNodes"), + "capacity_total_human": _human_size(cap), + "capacity_used_human": _human_size(used), + "capacity_used_pct": round(used / cap * 100, 1) if cap else 0, + } + except Exception as exc: + return {"ok": False, "namenode": HDFS_NN_URL, "error": str(exc)} + + +@router.get("/list") +async def hdfs_list(path: str = Query("/")) -> JSONResponse: + p = _norm(path) + url = f"{WEBHDFS}{_encode(p)}" + try: + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.get(url, params={"op": "LISTSTATUS", "user.name": HDFS_USER}) + if r.status_code >= 400: + try: + msg = r.json().get("RemoteException", {}).get("message", r.text[:200]) + except Exception: + msg = r.text[:200] + return JSONResponse({"ok": False, "path": p, "error": msg}, status_code=200) + statuses = (r.json().get("FileStatuses") or {}).get("FileStatus") or [] + except Exception as exc: + return JSONResponse({"ok": False, "path": p, "error": str(exc)}, status_code=200) + + folders: list[dict[str, Any]] = [] + files: list[dict[str, Any]] = [] + for s in statuses: + name = s.get("pathSuffix") or "" + is_dir = s.get("type") == "DIRECTORY" + full = (p.rstrip("/") + "/" + name) if name else p + entry = { + "name": name, + "path": full, + "type": "directory" if is_dir else "file", + "size": s.get("length", 0), + "size_human": _human_size(s.get("length", 0)), + "modified": _ms_to_iso(s.get("modificationTime")), + "owner": s.get("owner"), + "group": s.get("group"), + "perms": _perm_to_rwx(s.get("permission", "000"), is_dir), + "replication": s.get("replication"), + } + (folders if is_dir else files).append(entry) + + folders.sort(key=lambda e: e["name"].lower()) + files.sort(key=lambda e: e["name"].lower()) + return JSONResponse({"ok": True, "path": p, "folders": folders, "files": files}) + + +@router.get("/summary") +async def hdfs_summary(path: str = Query("/")) -> dict[str, Any]: + p = _norm(path) + url = f"{WEBHDFS}{_encode(p)}" + try: + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.get(url, params={"op": "GETCONTENTSUMMARY", "user.name": HDFS_USER}) + cs = r.json().get("ContentSummary") or {} + return { + "ok": True, + "path": p, + "directories": cs.get("directoryCount"), + "files": cs.get("fileCount"), + "length": cs.get("length"), + "length_human": _human_size(cs.get("length")), + "space_consumed": cs.get("spaceConsumed"), + "space_consumed_human": _human_size(cs.get("spaceConsumed")), + } + except Exception as exc: + return {"ok": False, "path": p, "error": str(exc)} + + +@router.get("/download") +async def hdfs_download(path: str = Query(...)): + p = _norm(path) + url = f"{WEBHDFS}{_encode(p)}" + fname = p.rsplit("/", 1)[-1] or "download" + + async def stream(): + async with httpx.AsyncClient(timeout=None, follow_redirects=True, verify=False) as client: + async with client.stream("GET", url, params={"op": "OPEN", "user.name": HDFS_USER}) as resp: + if resp.status_code >= 400: + return + async for chunk in resp.aiter_bytes(chunk_size=65536): + yield chunk + + return StreamingResponse( + stream(), + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{fname}"'}, + ) + + +@router.get("/preview") +async def hdfs_preview(path: str = Query(...), max_bytes: int = Query(65536)) -> dict[str, Any]: + """Return the first chunk of a file as text (for quick inspection).""" + p = _norm(path) + url = f"{WEBHDFS}{_encode(p)}" + cap = max(1024, min(max_bytes, 512 * 1024)) + try: + async with httpx.AsyncClient(timeout=20.0, follow_redirects=True, verify=False) as client: + r = await client.get(url, params={"op": "OPEN", "user.name": HDFS_USER, "length": cap}) + if r.status_code >= 400: + return {"ok": False, "path": p, "error": f"HTTP {r.status_code}"} + raw = r.content[:cap] + try: + text = raw.decode("utf-8") + binary = False + except UnicodeDecodeError: + text = raw[:4096].decode("latin-1", errors="replace") + binary = True + return {"ok": True, "path": p, "binary": binary, "truncated": len(r.content) >= cap, "text": text} + except Exception as exc: + return {"ok": False, "path": p, "error": str(exc)} diff --git a/api/main.py b/api/main.py index 1a91ead..15140a9 100644 --- a/api/main.py +++ b/api/main.py @@ -13,7 +13,7 @@ from typing import Any import httpx import redis.asyncio as aioredis -from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect +from fastapi import Body, FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from agent_terminal import ( get_all_terminals, @@ -25,11 +25,22 @@ from agent_terminal import ( ) 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_upload import ( + create_deck, + delete_deck, + get_asset_path, + get_deck, + list_decks, + save_deck, + save_image, + save_upload, +) from presentation_static import get_static_deck, list_static_decks from storage_s3 import router as storage_s3_router +from hdfs_api import router as hdfs_router from elasticsearch_api import router as elasticsearch_router from sql_console import router as sql_router +from ssh_terminal import ssh_session from node_registry import NODE_IDS, NODE_AGENT, NODE_REGISTRY, is_node_id from node_ops import build_node_detail, probe_node, run_node_probe_task from approval_service import ( @@ -707,6 +718,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="ATC Command Center API", lifespan=lifespan) app.include_router(storage_s3_router) +app.include_router(hdfs_router) app.include_router(elasticsearch_router) app.include_router(sql_router) app.add_middleware( @@ -814,6 +826,67 @@ async def upload_presentation(file: UploadFile = File(...)): deck = await save_upload(file.filename or "upload.pptx", content) return {"ok": True, "deck": deck} + +@app.post("/api/presentation/decks/new") +async def create_presentation_deck(body: dict[str, Any] | None = Body(default=None)): + body = body or {} + title = str(body.get("title") or "Untitled deck") + seed_id = body.get("from") + slides = None + if seed_id: + src = get_static_deck(seed_id) or get_deck(seed_id) + if seed_id == "live": + src = await get_presentation_data() + if src and src.get("slides"): + slides = [ + { + "id": s.get("id") or f"slide-{i}", + "title": s.get("title") or f"Slide {i}", + "subtitle": s.get("subtitle") or "", + "bullets": list(s.get("bullets") or []), + "image": s.get("image") or "", + "kind": s.get("kind") or "narrative", + } + for i, s in enumerate(src["slides"], start=1) + ] + if not title or title == "Untitled deck": + title = f"{src.get('title', 'Deck')} (copy)" + deck = create_deck(title, slides) + return {"ok": True, "deck": deck} + + +@app.put("/api/presentation/decks/{deck_id}") +async def update_presentation_deck(deck_id: str, body: dict[str, Any] = Body(...)): + deck = save_deck(deck_id, body) + if not deck: + return {"error": "deck not found or not editable"} + return {"ok": True, "deck": deck} + + +@app.delete("/api/presentation/decks/{deck_id}") +async def remove_presentation_deck(deck_id: str): + return {"ok": delete_deck(deck_id)} + + +@app.post("/api/presentation/decks/{deck_id}/image") +async def upload_presentation_image(deck_id: str, file: UploadFile = File(...)): + content = await file.read() + if len(content) > 12 * 1024 * 1024: + return {"error": "image too large (max 12MB)"} + result = save_image(deck_id, file.filename or "image.png", content) + if not result: + return {"error": "deck not found"} + return {"ok": True, **result} + + +@app.get("/api/presentation/decks/{deck_id}/assets/{name}") +async def get_presentation_asset(deck_id: str, name: str): + from fastapi.responses import FileResponse, Response + path = get_asset_path(deck_id, name) + if not path: + return Response(status_code=404) + return FileResponse(str(path)) + @app.get("/api/workload") async def get_workload(fast: bool = True): return await collect_workload(fast=fast, use_cache=True) @@ -1004,6 +1077,11 @@ async def post_prompt(body: PromptRequest): return {"prompt_id": prompt_id, "agent_id": agent_id, "status": "dispatched"} +@app.websocket("/api/ws/ssh") +async def ws_ssh(websocket: WebSocket): + await ssh_session(websocket) + + @app.websocket("/api/ws/ops") async def ws_ops(websocket: WebSocket): await websocket.accept() diff --git a/api/node_ops.py b/api/node_ops.py index dfe4fb5..264cfbd 100644 --- a/api/node_ops.py +++ b/api/node_ops.py @@ -135,7 +135,6 @@ async def probe_node(node_id: str) -> dict[str, Any]: 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", "")) diff --git a/api/presentation.py b/api/presentation.py index 5001acf..79a2a44 100644 --- a/api/presentation.py +++ b/api/presentation.py @@ -245,6 +245,7 @@ header .meta{{font-size:.75rem;opacity:.7}} .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}} +.slide img.slide-img{{max-height:42vh;max-width:100%;margin-top:1.5rem;border-radius:10px;border:1px solid rgba(96,165,250,.25);box-shadow:0 8px 30px rgba(0,0,0,.4);object-fit:contain}} 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}} @@ -275,7 +276,8 @@ 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=>"
  • "+b+"
  • ").join(""); - el.innerHTML="

    "+s.title+"

    "+(s.subtitle||"")+"

    "; + const img=s.image?'':""; + el.innerHTML="

    "+s.title+"

    "+(s.subtitle||"")+"

    "+img; container.appendChild(el); const d=document.createElement("button"); d.className="dot"+(idx===0?" active":""); diff --git a/api/presentation_upload.py b/api/presentation_upload.py index af147af..21fd5a8 100644 --- a/api/presentation_upload.py +++ b/api/presentation_upload.py @@ -163,3 +163,109 @@ def get_deck(deck_id: str) -> dict[str, Any] | None: if not path.exists(): return None return json.loads(path.read_text()) + + +def _blank_slide(idx: int = 1) -> dict[str, Any]: + return { + "id": f"slide-{idx}", + "title": f"Slide {idx}", + "subtitle": "", + "bullets": ["Double-click to edit this point"], + "image": "", + "kind": "narrative", + } + + +def create_deck(title: str = "Untitled deck", slides: list[dict[str, Any]] | None = None) -> dict[str, Any]: + """Create a new editable deck (optionally seeded from existing slides).""" + _ensure_dir() + deck_id = str(uuid.uuid4())[:8] + deck_dir = PRESENTATIONS_DIR / deck_id + (deck_dir / "assets").mkdir(parents=True, exist_ok=True) + + seeded = [dict(s) for s in (slides or [])] or [_blank_slide(1)] + payload = { + "id": deck_id, + "filename": "", + "source": "editor", + "editable": True, + "ts": datetime.now(timezone.utc).isoformat(), + "title": (title or "Untitled deck").strip()[:120] or "Untitled deck", + "subtitle": "Editable deck", + "slide_count": len(seeded), + "slides": seeded, + } + (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 save_deck(deck_id: str, body: dict[str, Any]) -> dict[str, Any] | None: + """Overwrite an existing editable deck's content. Returns None if missing.""" + deck_dir = PRESENTATIONS_DIR / deck_id + meta_path = deck_dir / "meta.json" + if not meta_path.exists(): + return None + existing = json.loads(meta_path.read_text()) + + incoming_slides = body.get("slides") + clean_slides: list[dict[str, Any]] = [] + for i, s in enumerate(incoming_slides or [], start=1): + if not isinstance(s, dict): + continue + bullets = [str(b).strip()[:400] for b in (s.get("bullets") or []) if str(b).strip()] + clean_slides.append({ + "id": str(s.get("id") or f"slide-{i}"), + "title": str(s.get("title") or f"Slide {i}")[:200], + "subtitle": str(s.get("subtitle") or "")[:300], + "bullets": bullets, + "image": str(s.get("image") or "")[:300], + "kind": str(s.get("kind") or "narrative")[:40], + }) + if not clean_slides: + clean_slides = [_blank_slide(1)] + + existing.update({ + "title": str(body.get("title") or existing.get("title") or "Untitled deck")[:120], + "subtitle": str(body.get("subtitle") or existing.get("subtitle") or "")[:300], + "editable": True, + "source": existing.get("source") or "editor", + "ts": datetime.now(timezone.utc).isoformat(), + "slides": clean_slides, + "slide_count": len(clean_slides), + }) + meta_path.write_text(json.dumps(existing, indent=2, default=str)) + (deck_dir / "deck.json").write_text(json.dumps(existing, default=str)) + return existing + + +def delete_deck(deck_id: str) -> bool: + import shutil + deck_dir = PRESENTATIONS_DIR / deck_id + if not deck_dir.exists(): + return False + shutil.rmtree(deck_dir, ignore_errors=True) + return True + + +def save_image(deck_id: str, filename: str, content: bytes) -> dict[str, Any] | None: + """Store an image in the deck's assets folder; return its served URL.""" + deck_dir = PRESENTATIONS_DIR / deck_id + if not deck_dir.exists(): + return None + assets = deck_dir / "assets" + assets.mkdir(parents=True, exist_ok=True) + ext = "" + if "." in filename: + ext = "." + _safe_name(filename.rsplit(".", 1)[-1].lower())[:8] + name = f"{uuid.uuid4().hex[:12]}{ext}" + (assets / name).write_bytes(content) + return {"name": name, "url": f"/api/presentation/decks/{deck_id}/assets/{name}"} + + +def get_asset_path(deck_id: str, name: str) -> Path | None: + safe = _safe_name(name) + path = PRESENTATIONS_DIR / deck_id / "assets" / safe + if not path.exists() or not path.is_file(): + return None + return path diff --git a/api/requirements.txt b/api/requirements.txt index 4d1d03d..c94c875 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -14,3 +14,4 @@ cassandra-driver==3.29.2 neo4j==5.26.0 python-pptx==1.0.2 boto3==1.35.99 +paramiko==3.5.0 diff --git a/api/ssh_terminal.py b/api/ssh_terminal.py new file mode 100644 index 0000000..3df63d5 --- /dev/null +++ b/api/ssh_terminal.py @@ -0,0 +1,146 @@ +"""Interactive SSH terminal bridge over WebSocket (paramiko PTY <-> xterm.js).""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import paramiko +from fastapi import WebSocket, WebSocketDisconnect + + +async def _send(ws: WebSocket, type_: str, **kw: Any) -> None: + try: + await ws.send_text(json.dumps({"type": type_, **kw})) + except Exception: + pass + + +def _open_ssh(cfg: dict[str, Any]) -> tuple[paramiko.SSHClient, paramiko.Channel]: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + connect_kwargs: dict[str, Any] = { + "hostname": cfg["host"], + "port": int(cfg.get("port") or 22), + "username": cfg.get("username") or "root", + "timeout": 12, + "banner_timeout": 12, + "auth_timeout": 12, + "look_for_keys": False, + "allow_agent": False, + } + password = cfg.get("password") + key_data = cfg.get("private_key") + if key_data: + from io import StringIO + pkey = None + for loader in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey): + try: + pkey = loader.from_private_key(StringIO(key_data), password=password or None) + break + except Exception: + continue + if pkey is None: + raise ValueError("Unsupported or invalid private key") + connect_kwargs["pkey"] = pkey + elif password: + connect_kwargs["password"] = password + else: + raise ValueError("No password or private key provided") + + client.connect(**connect_kwargs) + cols = int(cfg.get("cols") or 120) + rows = int(cfg.get("rows") or 32) + chan = client.invoke_shell(term="xterm-256color", width=cols, height=rows) + chan.settimeout(0.0) + return client, chan + + +async def ssh_session(ws: WebSocket) -> None: + await ws.accept() + client: paramiko.SSHClient | None = None + chan: paramiko.Channel | None = None + loop = asyncio.get_event_loop() + + try: + # First message must be the connect config + first = await ws.receive_text() + cfg = json.loads(first) + if cfg.get("type") != "connect": + await _send(ws, "error", message="Expected connect message") + await ws.close() + return + + await _send(ws, "status", message=f"Connecting to {cfg.get('username','root')}@{cfg.get('host')}:{cfg.get('port',22)}…") + try: + client, chan = await loop.run_in_executor(None, _open_ssh, cfg) + except paramiko.AuthenticationException: + await _send(ws, "error", message="Authentication failed — check username/password") + await ws.close() + return + except Exception as exc: + await _send(ws, "error", message=f"Connection failed: {exc}") + await ws.close() + return + + await _send(ws, "connected", message="connected") + + async def pump_out() -> None: + assert chan is not None + while True: + if chan.closed or chan.exit_status_ready() and not chan.recv_ready(): + if not chan.recv_ready(): + break + if chan.recv_ready(): + try: + data = await loop.run_in_executor(None, chan.recv, 65536) + except Exception: + break + if not data: + break + await ws.send_text(json.dumps({"type": "data", "data": data.decode("utf-8", errors="replace")})) + else: + await asyncio.sleep(0.02) + await _send(ws, "closed", message="Session closed") + + out_task = asyncio.create_task(pump_out()) + + try: + while True: + msg = await ws.receive_text() + try: + parsed = json.loads(msg) + except json.JSONDecodeError: + parsed = {"type": "data", "data": msg} + mtype = parsed.get("type") + if mtype == "data" and chan and not chan.closed: + chan.send(parsed.get("data", "")) + elif mtype == "resize" and chan and not chan.closed: + try: + chan.resize_pty(width=int(parsed.get("cols", 120)), height=int(parsed.get("rows", 32))) + except Exception: + pass + elif mtype == "disconnect": + break + finally: + out_task.cancel() + except WebSocketDisconnect: + pass + except Exception as exc: + await _send(ws, "error", message=str(exc)) + finally: + try: + if chan is not None: + chan.close() + except Exception: + pass + try: + if client is not None: + client.close() + except Exception: + pass + try: + await ws.close() + except Exception: + pass diff --git a/ui/nginx.conf b/ui/nginx.conf index 3fc9183..018b038 100644 --- a/ui/nginx.conf +++ b/ui/nginx.conf @@ -9,6 +9,9 @@ server { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_buffering off; } location /assets/ { diff --git a/ui/package.json b/ui/package.json index 75298d3..c4e8ab0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@tanstack/react-query": "^5.62.8", + "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.469.0", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 35c2ffb..78de735 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -16,7 +16,9 @@ import { PresentationView } from './components/features/PresentationView' import { DataQualityView } from './components/features/DataQualityView' import { KnowledgeChatView } from './components/features/KnowledgeChatView' import { StorageView } from './components/features/StorageView' +import { HdfsView } from './components/features/HdfsView' import { SearchView } from './components/features/SearchView' +import { SshTerminal } from './components/features/SshTerminal' import { TerminalDock } from './components/features/TerminalDock' import { WorkbenchPanel } from './components/features/WorkbenchPanel' import { resolveInfraNode } from './lib/infraCatalog' @@ -27,6 +29,7 @@ export default function App() { const cc = useCommandCenter() const [gpuChatActive, setGpuChatActive] = useState(false) const [infraOpen, setInfraOpen] = useState(false) + const [sshOpen, setSshOpen] = useState(false) const gpuBoost = gpuChatActive || cc.mainView === 'knowledge' const { agentLoads, gpuLive } = useLiveMetrics(cc.agents, cc.gpu, cc.anims, gpuBoost) const mainScrollRef = useRef(null) @@ -60,20 +63,17 @@ export default function App() {
    setSshOpen(true)} />
    @@ -132,6 +132,8 @@ export default function App() { ) : cc.mainView === 'storage' ? ( + ) : cc.mainView === 'hdfs' ? ( + ) : cc.mainView === 'search' ? ( ) : ( @@ -201,6 +203,8 @@ export default function App() { onDecide={cc.decide} onDismissHighlight={() => cc.setApprovalHighlight(false)} /> + + setSshOpen(false)} />
    ) } diff --git a/ui/src/components/features/GpuMatrixPanel.tsx b/ui/src/components/features/GpuMatrixPanel.tsx index 1026a73..d64b334 100644 --- a/ui/src/components/features/GpuMatrixPanel.tsx +++ b/ui/src/components/features/GpuMatrixPanel.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState, type ReactNode } from 'react' -import { Activity, Cpu, ExternalLink, Thermometer, Zap } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { Activity, ChevronDown, ChevronUp, Cpu, ExternalLink, Zap } from 'lucide-react' import { fetchGpu } from '../../lib/api' import type { GpuDevice, GpuStatus } from '../../types' import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics' @@ -23,46 +23,28 @@ function utilColor(pct: number) { return 'bg-success' } -function GpuRow({ device, liveUtil, active }: { device: GpuDevice; liveUtil: number; active: boolean }) { +/** Compact card — fits 4 GPUs in a 2×2 grid without scrolling */ +function GpuCardCompact({ device, liveUtil }: { device: GpuDevice; liveUtil: number }) { const vramPct = memPct(device.memory_used_mib, device.memory_total_mib) const util = liveUtil ?? device.util_gpu return ( -
    5 && 'border-docker/30 bg-docker/5', - )} - > -
    - GPU {device.index} - {util.toFixed(0)}% · {vramPct}% VRAM +
    +
    + GPU {device.index} + {util.toFixed(0)}% · {vramPct}% VR
    -
    - - -
    -
    - - - {device.temperature_c?.toFixed(0) ?? '—'}°C - - {device.power_w?.toFixed(0) ?? '—'} W -
    -
    - ) -} - -function MetricBar({ label, value, colorClass }: { label: string; value: number; colorClass: string }) { - return ( -
    - {label} -
    -
    +
    +
    +
    +
    +
    +
    +
    +

    + {device.temperature_c?.toFixed(0) ?? '—'}°C · {device.power_w?.toFixed(0) ?? '—'} W +

    ) } @@ -70,11 +52,16 @@ function MetricBar({ label, value, colorClass }: { label: string; value: number; export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props) { const [localGpu, setLocalGpu] = useState(gpu) const [lastPoll, setLastPoll] = useState(null) + const [expanded, setExpanded] = useState(false) useEffect(() => { setLocalGpu(gpu) }, [gpu]) + useEffect(() => { + if (boost) setExpanded(true) + }, [boost]) + useEffect(() => { const poll = async () => { const g = await fetchGpu() @@ -111,23 +98,25 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props) if (!g?.ok) { return ( -
    -

    +
    +

    GPU Matrix

    -

    GPU Lab offline

    +

    GPU Lab offline

    ) } + const tokLabel = boost && inferenceOn ? String(live.tokenThroughput) : inferenceOn ? '—' : '0' + return ( -
    -

    - {g.ui_url && ( - e.stopPropagation()} - className="shrink-0 text-docker hover:underline" + {!expanded && ( +

    {modelLabel}

    + )} + +
    + {g.ui_url && ( + + + + )} + - -
    - - 20 ? 'text-warning' : 'text-foreground'} /> - } - /> + {expanded ? : } + +
    -
    - {devices.map((d, i) => ( - - ))} -
    + {!expanded ? ( + + ) : ( +
    +

    {modelLabel}

    +
    + + {inferenceOn ? 'Active' : 'Idle'} + + {avgUtil.toFixed(0)}% util avg + {avgVram.toFixed(0)}% VRAM avg + + + {tokLabel} tok/s + +
    -

    - VRAM avg {avgVram.toFixed(0)}% · poll {boost ? '1s' : '3s'} - {lastPoll && ` · ${lastPoll.toLocaleTimeString()}`} -

    +
    + {devices.map((d, i) => ( + + ))} +
    + +

    + {g.gpu_count ?? devices.length}× V100 · {g.host} · poll {boost ? '1s' : '3s'} + {lastPoll && ` · ${lastPoll.toLocaleTimeString()}`} +

    +
    + )} ) } - -function StatChip({ - label, - value, - accent, - icon, -}: { - label: string - value: string - accent?: string - icon?: ReactNode -}) { - return ( -
    -

    {icon}{label}

    -

    {value}

    -
    - ) -} diff --git a/ui/src/components/features/HdfsView.tsx b/ui/src/components/features/HdfsView.tsx new file mode 100644 index 0000000..9ee1244 --- /dev/null +++ b/ui/src/components/features/HdfsView.tsx @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useState } from 'react' +import { ChevronRight, Download, Eye, FileText, Folder, Loader2, RefreshCw, Server, X } from 'lucide-react' +import { cn } from '../../lib/utils' +import { subTabActive, subTabIdle } from '../../lib/tabActive' + +type HdfsEntry = { + name: string + path: string + type: 'directory' | 'file' + size?: number + size_human?: string + modified?: string + owner?: string + group?: string + perms?: string + replication?: number +} + +type Health = { + ok: boolean + namenode?: string + live_datanodes?: number + dead_datanodes?: number + capacity_total_human?: string + capacity_used_human?: string + capacity_used_pct?: number + error?: string +} + +export function HdfsView() { + const [health, setHealth] = useState(null) + const [path, setPath] = useState('/') + const [folders, setFolders] = useState([]) + const [files, setFiles] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [preview, setPreview] = useState<{ path: string; text: string; binary: boolean; truncated: boolean } | null>(null) + const [previewing, setPreviewing] = useState(false) + + const loadHealth = useCallback(async () => { + try { + const r = await fetch('/api/storage/hdfs/health') + if (r.ok) setHealth(await r.json()) + } catch { + /* ignore */ + } + }, []) + + const loadList = useCallback(async (p: string) => { + setLoading(true) + setError(null) + try { + const r = await fetch(`/api/storage/hdfs/list?path=${encodeURIComponent(p)}`) + const j = await r.json() + if (!j.ok) { + setError(j.error || 'List failed') + setFolders([]) + setFiles([]) + return + } + setFolders(j.folders || []) + setFiles(j.files || []) + } catch { + setError('HDFS API unavailable') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + loadHealth() + }, [loadHealth]) + + useEffect(() => { + loadList(path) + }, [path, loadList]) + + const openPreview = useCallback(async (p: string) => { + setPreviewing(true) + setPreview({ path: p, text: '', binary: false, truncated: false }) + try { + const r = await fetch(`/api/storage/hdfs/preview?path=${encodeURIComponent(p)}`) + const j = await r.json() + if (j.ok) setPreview({ path: p, text: j.text, binary: j.binary, truncated: j.truncated }) + else setPreview({ path: p, text: j.error || 'Preview failed', binary: false, truncated: false }) + } catch { + setPreview({ path: p, text: 'Preview failed', binary: false, truncated: false }) + } finally { + setPreviewing(false) + } + }, []) + + const crumbs = path === '/' ? [] : path.split('/').filter(Boolean) + + return ( +
    +
    +
    +

    + + Hadoop HDFS +

    +

    + {health?.namenode || '10.0.21.61:9870'} · {health?.live_datanodes ?? '—'} datanodes ·{' '} + {health?.capacity_used_human || '—'} / {health?.capacity_total_human || '—'} + {typeof health?.capacity_used_pct === 'number' ? ` (${health.capacity_used_pct}%)` : ''} +

    +
    +
    + + NameNode UI + + +
    +
    + +
    + + + {loading && ( +

    + Loading… +

    + )} + {error &&

    {error}

    } + +
    + + + + + + + + + + + + {folders.map((f) => ( + + + + + + + + ))} + {files.map((o) => ( + + + + + + + + + ))} + +
    NameSizeOwnerPermsModified +
    + + {f.owner}{f.perms}{f.modified?.slice(0, 19).replace('T', ' ') || '—'} +
    + + {o.name} + + {o.size_human}{o.owner}{o.perms}{o.modified?.slice(0, 19).replace('T', ' ') || '—'} +
    + + + + +
    +
    + {!loading && folders.length === 0 && files.length === 0 && !error && ( +

    This directory is empty.

    + )} +
    +
    + + {preview && ( +
    setPreview(null)}> +
    e.stopPropagation()}> +
    + {preview.path} + +
    + {preview.binary &&

    Binary file — showing decoded preview.

    } +
    +              {previewing ? 'Loading…' : preview.text}
    +              {preview.truncated && '\n\n… (truncated)'}
    +            
    +
    +
    + )} +
    + ) +} diff --git a/ui/src/components/features/LabHealthPanel.tsx b/ui/src/components/features/LabHealthPanel.tsx new file mode 100644 index 0000000..afc27f2 --- /dev/null +++ b/ui/src/components/features/LabHealthPanel.tsx @@ -0,0 +1,302 @@ +import { useMemo, useState } from 'react' +import { + Box, + ChevronDown, + ChevronUp, + Cpu, + Database, + HardDrive, + Layers, + Server, + ShieldCheck, + Workflow, +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import type { GpuStatus, WorkloadData, WorkloadZone } from '../../types' +import { NODE_ALIASES } from '../../lib/constants' +import { cn } from '../../lib/utils' + +type Props = { + workload: WorkloadData | null + gpu: GpuStatus | null + selectedNodeId: string | null + approvalCount: number + onSelectZone: (id: string) => void + onOpenApprovals: () => void +} + +const ZONE_ORDER = ['docker', 'db', 'etl', 'lakehouse', 's3', 'hadoop'] as const + +const ZONE_META: Record = { + docker: { icon: Box, shortLabel: 'Docker Rack', abbrev: 'Docker' }, + db: { icon: Database, shortLabel: 'DB Vault', abbrev: 'DB' }, + etl: { icon: Workflow, shortLabel: 'ETL Pipe', abbrev: 'ETL' }, + lakehouse: { icon: Layers, shortLabel: 'Lakehouse', abbrev: 'Lake' }, + s3: { icon: HardDrive, shortLabel: 'ObjectScale S3', abbrev: 'S3' }, + hadoop: { icon: Server, shortLabel: 'Hadoop HDFS', abbrev: 'HDFS' }, + gpu: { icon: Cpu, shortLabel: 'GPU Lab', abbrev: 'GPU' }, +} + +const ETL_NODE_IDS = new Set([ + 'etl', + 'kafka', + 'airflow', + 'debezium', + 'stream-kafka', + 'src-airflow', + 'cdc-postgres', + 'cdc-mysql', + 'cdc-mongo', + 'cdc-cassandra', +]) + +function levelDot(level: string) { + if (level === 'ok') return 'bg-success shadow-[0_0_6px_rgba(52,211,153,0.45)]' + if (level === 'warn') return 'bg-warning shadow-[0_0_6px_rgba(245,158,11,0.4)]' + if (level === 'down') return 'bg-danger shadow-[0_0_6px_rgba(239,68,68,0.4)]' + return 'bg-foreground-faint' +} + +function resolveActiveZone(selectedNodeId: string | null): string | null { + if (!selectedNodeId) return null + if (selectedNodeId === 'gpu' || selectedNodeId === 'cons-ml') return 'gpu' + if ((ZONE_ORDER as readonly string[]).includes(selectedNodeId)) return selectedNodeId + if (ETL_NODE_IDS.has(selectedNodeId)) return 'etl' + + const alias = NODE_ALIASES[selectedNodeId] + if (!alias) return null + if (alias === 'gpu') return 'gpu' + if (ETL_NODE_IDS.has(alias)) return 'etl' + if ((ZONE_ORDER as readonly string[]).includes(alias)) return alias + return null +} + +function zoneMetric(zone: WorkloadZone): string { + if (zone.id === 'hadoop' && zone.hdfs_used_gb != null && zone.hdfs_total_gb != null && zone.hdfs_total_gb > 0) { + return `${zone.hdfs_used_gb.toFixed(1)} / ${zone.hdfs_total_gb.toFixed(1)} TB` + } + if (zone.id === 's3' && zone.bucket) return zone.bucket + return `${zone.running}/${zone.total}` +} + +function gpuMetric(gpu: GpuStatus | null, wlGpu: WorkloadData['gpu'] | undefined): string { + if (!gpu?.ok) return 'Offline' + const model = gpu.active_model?.replace(/\s*GPTQ$/i, '').replace(/\s*AWQ$/i, '').trim() + if (gpu.inference_active && model) return model.split('-').slice(0, 2).join(' ') + if (model) return 'Loaded' + if (wlGpu?.gpu_count) return `${wlGpu.gpu_count}× GPU` + return 'Standby' +} + +function gpuLevel(gpu: GpuStatus | null, wlGpu: WorkloadData['gpu'] | undefined): string { + if (!gpu?.ok) return 'down' + if (gpu.inference_active) return 'ok' + return wlGpu?.level || 'warn' +} + +type ZoneEntry = { id: string; zone: WorkloadZone; metric?: string } + +export function LabHealthPanel({ + workload, + gpu, + selectedNodeId, + approvalCount, + onSelectZone, + onOpenApprovals, +}: Props) { + const [expanded, setExpanded] = useState(false) + const activeZone = resolveActiveZone(selectedNodeId) + const zones = workload?.zones ?? [] + const ordered = ZONE_ORDER.map((id) => zones.find((z) => z.id === id)).filter(Boolean) as WorkloadZone[] + const totals = workload?.totals + + const gpuZone: WorkloadZone = { + id: 'gpu', + label: 'GPU LAB', + x: 0, + color: '#4c9aed', + level: gpuLevel(gpu, workload?.gpu) as WorkloadZone['level'], + running: gpu?.gpu_count ?? workload?.gpu?.gpu_count ?? 0, + total: gpu?.gpu_count ?? workload?.gpu?.gpu_count ?? 0, + apps: [], + vm: gpu?.host, + ip: gpu?.host, + } + + const allZones: ZoneEntry[] = [ + ...ordered.map((zone) => ({ id: zone.id, zone })), + { id: 'gpu', zone: gpuZone, metric: gpuMetric(gpu, workload?.gpu) }, + ] + + const healthSummary = useMemo(() => { + const ok = allZones.filter((z) => z.zone.level === 'ok').length + const warn = allZones.filter((z) => z.zone.level === 'warn').length + const down = allZones.filter((z) => z.zone.level === 'down').length + return { ok, warn, down, total: allZones.length } + }, [allZones]) + + return ( +
    +
    + + +
    + + {!expanded ? ( +
    + {allZones.map(({ id, zone }) => { + const meta = ZONE_META[id] || { abbrev: id, shortLabel: zone.label, icon: Server } + const active = activeZone === id + return ( + + ) + })} +
    + ) : ( + <> +
    + {ordered.map((zone) => ( + onSelectZone(zone.id)} + /> + ))} + onSelectZone('gpu')} + /> + {!workload && ( +

    Waiting for lab snapshot…

    + )} +
    +

    + Click a zone → inspector · agents via Agent Fleet +

    + + )} +
    + ) +} + +function ZoneRow({ + zone, + metric, + active, + onSelect, +}: { + zone: WorkloadZone + metric?: string + active: boolean + onSelect: () => void +}) { + const meta = ZONE_META[zone.id] || { icon: Server, shortLabel: zone.label, abbrev: zone.id } + const Icon = meta.icon + const right = metric ?? zoneMetric(zone) + const subtitle = [zone.vm, zone.ip].filter(Boolean).join(' · ') + + return ( + + ) +} diff --git a/ui/src/components/features/PlatformTopology.tsx b/ui/src/components/features/PlatformTopology.tsx index 7d7f762..8c138e4 100644 --- a/ui/src/components/features/PlatformTopology.tsx +++ b/ui/src/components/features/PlatformTopology.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Box } from 'lucide-react' import type { AgentAnim, WorkloadData } from '../../types' import { Badge } from '../ui/Badge' @@ -52,10 +52,11 @@ const STAGES: TopoStage[] = [ ], }, { - id: 'storage', num: 4, title: 'STORAGE', subtitle: 'Lakehouse layer', accent: 'topo-stage-col--storage', + id: 'storage', num: 4, title: 'STORAGE', subtitle: 'Lakehouse · object · historical archive', 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: 'hadoop', label: 'Hadoop HDFS', sub: 'Historical archive', metricKey: 'hadoop' }, ], }, { @@ -90,8 +91,12 @@ const FLOW_EDGES: FlowEdge[] = [ { 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' }, + { from: 'spark', to: 'hadoop', kind: 'etl', label: 'Historical archive' }, + { from: 'airflow', to: 'hadoop', kind: 'orchestration', label: 'Archive DAG' }, + { from: 'iceberg', to: 'hadoop', kind: 'etl', label: 'Cold archive' }, // Query & serve { from: 'iceberg', to: 'trino', kind: 'query', label: 'SQL' }, + { from: 'hadoop', to: 'trino', kind: 'query', label: 'Historical 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' }, @@ -141,54 +146,40 @@ const PARTICLE_FILL: Record = { const NODE_CLICK_MAP: Record = { 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', + trino: 'query-trino', iceberg: 'lake-iceberg', s3: 'lake-s3', hadoop: 'hadoop', bi: 'cons-bi', jupyter: 'cons-notebooks', elasticsearch: 'cons-elastic', kibana: 'cons-kibana', llm: 'cons-ml', } -const NODE_POS: Record = {} +const NODE_COL: Record = {} STAGES.forEach((stage, col) => { - stage.nodes.forEach((node, row) => { - NODE_POS[node.id] = { col, row, rows: stage.nodes.length } + stage.nodes.forEach((node) => { + NODE_COL[node.id] = col }) }) -function nodeCoords(col: number, row: number, rows: number) { - const colCount = 5 - const gap = 3.2 - const colW = (100 - gap * (colCount - 1)) / colCount - const xCenter = col * (colW + gap) + colW / 2 - const nodeHalf = colW * 0.34 - const yPad = 5 - const ySpan = 90 - const y = yPad + ((row + 0.5) / rows) * ySpan - return { - inX: xCenter - nodeHalf, - outX: xCenter + nodeHalf, - y, - } -} +type NodeAnchor = { x: number; y: number; inX: number; outX: number; col: number } -/** Curved path — route edges to avoid dangling stubs over intermediate nodes */ +/** SVG path in pixel coordinates between measured node anchors */ function flowPath( x1: number, y1: number, x2: number, y2: number, - opts: { backward?: boolean; sameCol?: boolean } = {}, + opts: { backward?: boolean; longArc?: boolean } = {}, ) { - const { backward = false, sameCol = false } = opts - if (backward || x2 < x1 - 2) { - const arcY = Math.min(y1, y2) - 14 + const { backward = false, longArc = false } = opts + if (backward) { + const arcY = Math.min(y1, y2) - 36 return `M ${x1} ${y1} C ${x1} ${arcY}, ${x2} ${arcY}, ${x2} ${y2}` } - if (sameCol) { - const cx = (x1 + x2) / 2 - return `M ${x1} ${y1} C ${cx} ${y1}, ${cx} ${y2}, ${x2} ${y2}` + const dx = x2 - x1 + if (Math.abs(dx) < 6) { + const midY = (y1 + y2) / 2 + return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}` } - const span = x2 - x1 - const dy = Math.abs(y2 - y1) - if (span > 16 && dy > 4) { - const dipY = Math.max(y1, y2) + Math.min(12, 6 + span * 0.08) - return `M ${x1} ${y1} C ${x1 + span * 0.2} ${dipY}, ${x2 - span * 0.2} ${dipY}, ${x2} ${y2}` + if (longArc) { + const sag = Math.max(32, Math.min(72, Math.abs(dx) * 0.14)) + const midY = Math.max(y1, y2) + sag + return `M ${x1} ${y1} Q ${(x1 + x2) / 2} ${midY} ${x2} ${y2}` } - const mx = (x1 + x2) / 2 + const mx = x1 + dx * 0.5 return `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}` } @@ -199,10 +190,23 @@ function seedMetrics(): MetricState { 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', + hadoop: 'Historical archive', bi: '26 dashboards', jupyter: '12 kernels active', elasticsearch: 'atc-lakehouse', kibana: 'available', llm: 'Checking…', } } +function formatHadoopMetric(workload: WorkloadData | null): string { + const hz = workload?.zones?.find((z) => z.id === 'hadoop') + if (!hz) return 'Historical archive' + const used = hz.hdfs_used_gb + const total = hz.hdfs_total_gb + if (used != null && total != null && total > 0) { + return `${used.toFixed(1)} / ${total.toFixed(1)} TB HDFS` + } + if (hz.running > 0) return `${hz.running} datanodes · archive tier` + return hz.level === 'ok' ? 'Historical archive · ready' : 'NameNode check' +} + function formatLlmLabel(model?: string | null): string { if (!model) return 'GenAI LLM' return model.replace(/\s*GPTQ$/i, '').replace(/\s*AWQ$/i, '').trim() @@ -224,6 +228,7 @@ function formatLlmMetric(workload: WorkloadData | null): string { function jitterMetric(key: string, current: string, workload: WorkloadData | null): string { if (key === 'llm') return formatLlmMetric(workload) + if (key === 'hadoop') return formatHadoopMetric(workload) const n = () => (Math.random() - 0.5) * 2 const fns: Record string> = { postgresql: () => `${(12.4 + n() * 0.8).toFixed(1)}k rows/s`, @@ -252,6 +257,32 @@ type Props = { export function PlatformTopology({ workload, animations, selectedNodeId, onNodeClick }: Props) { const [metrics, setMetrics] = useState(seedMetrics) + const canvasRef = useRef(null) + const nodeRefs = useRef>({}) + const [anchors, setAnchors] = useState>({}) + const [canvasSize, setCanvasSize] = useState({ w: 800, h: 400 }) + + const setNodeRef = useCallback((id: string) => (el: HTMLButtonElement | null) => { + nodeRefs.current[id] = el + }, []) + + const measureAnchors = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const rect = canvas.getBoundingClientRect() + if (rect.width < 10 || rect.height < 10) return + setCanvasSize({ w: rect.width, h: rect.height }) + const next: Record = {} + for (const [id, el] of Object.entries(nodeRefs.current)) { + if (!el) continue + const r = el.getBoundingClientRect() + const x = (r.left + r.right) / 2 - rect.left + const y = (r.top + r.bottom) / 2 - rect.top + const hw = r.width / 2 + next[id] = { x, y, inX: x - hw, outX: x + hw, col: NODE_COL[id] ?? 0 } + } + setAnchors(next) + }, []) const llmLabel = formatLlmLabel(workload?.gpu?.model) @@ -276,8 +307,8 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC }, []) useEffect(() => { - setMetrics((prev) => ({ ...prev, llm: formatLlmMetric(workload) })) - }, [workload?.gpu?.model, workload?.gpu?.inference_active, workload?.gpu?.avg_util, workload?.gpu?.gpus]) + setMetrics((prev) => ({ ...prev, llm: formatLlmMetric(workload), hadoop: formatHadoopMetric(workload) })) + }, [workload?.gpu?.model, workload?.gpu?.inference_active, workload?.gpu?.avg_util, workload?.gpu?.gpus, workload?.zones]) useEffect(() => { const iv = setInterval(() => { @@ -294,6 +325,23 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC ? Object.entries(NODE_CLICK_MAP).find(([, v]) => v === selectedNodeId)?.[0] ?? null : null + useLayoutEffect(() => { + measureAnchors() + const canvas = canvasRef.current + if (!canvas) return + const ro = new ResizeObserver(() => measureAnchors()) + ro.observe(canvas) + window.addEventListener('resize', measureAnchors) + return () => { + ro.disconnect() + window.removeEventListener('resize', measureAnchors) + } + }, [measureAnchors]) + + useLayoutEffect(() => { + measureAnchors() + }, [measureAnchors, metrics, llmLabel]) + return (

    Data Platform Topology

    - Click PostgreSQL / MySQL / MongoDB / Trino → live console · agents → terminal below + Click PostgreSQL / MySQL / MongoDB / Trino → live console · Hadoop → historical archive · agents → terminal below

    @@ -330,11 +378,10 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
    -
    +
    @@ -345,31 +392,30 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC {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, sameCol: pa.col === pb.col }) + const a = anchors[edge.from] + const b = anchors[edge.to] + if (!a || !b) return null + const backward = b.col < a.col + const fromX = backward ? a.inX : a.outX + const toX = backward ? b.outX : b.inX + const colGap = Math.abs(b.col - a.col) + const longArc = colGap > 1 || (Math.abs(b.y - a.y) > 48 && Math.abs(toX - fromX) > 120) + const d = flowPath(fromX, a.y, toX, b.y, { backward, longArc }) const live = edgesLive const dur = 1.8 + (i % 5) * 0.35 return ( - + {live && ( <> - + - + @@ -382,18 +428,18 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
    {STAGES.map((stage) => (
    -
    -
    - +
    +
    + 0{stage.num}
    -

    {stage.title}

    -

    {stage.subtitle}

    +

    {stage.title}

    +

    {stage.subtitle}

    -
    +
    {stage.nodes.map((node) => { const label = node.id === 'llm' ? llmLabel : node.label const sub = node.id === 'llm' @@ -402,18 +448,20 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC return ( diff --git a/ui/src/components/features/PresentationView.tsx b/ui/src/components/features/PresentationView.tsx index 2b058b9..3711b55 100644 --- a/ui/src/components/features/PresentationView.tsx +++ b/ui/src/components/features/PresentationView.tsx @@ -1,5 +1,16 @@ -import { useCallback, useEffect, useState } from 'react' -import { ExternalLink, FileUp, Monitor, Upload } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + ExternalLink, + FileUp, + ImagePlus, + Monitor, + Pencil, + Plus, + Save, + Trash2, + Upload, + X, +} from 'lucide-react' import { ArchitectureDiagram } from './ArchitectureDiagram' import type { PresentationData, PresentationSlide } from '../../types' import { cn } from '../../lib/utils' @@ -46,6 +57,23 @@ export function PresentationView() { const [uploadMsg, setUploadMsg] = useState(null) const [customDecks, setCustomDecks] = useState<{ id: string; title: string }[]>([]) + // ── edit state ── + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(null) + const [saving, setSaving] = useState(false) + const [imgBusy, setImgBusy] = useState(false) + const imgInputRef = useRef(null) + + const isCustom = customDecks.some((d) => d.id === source) + + const refreshDeckList = useCallback(async () => { + try { + const j = await (await fetch('/api/presentation/decks')).json() + const uploaded = (j.uploaded || []).map((d: { id: string; title: string }) => ({ id: d.id, title: d.title })) + setCustomDecks(uploaded) + } catch { /* ignore */ } + }, []) + const load = useCallback(async (deckId: DeckSource) => { setLoading(true) setError(null) @@ -72,17 +100,13 @@ export function PresentationView() { }, []) useEffect(() => { + setEditing(false) 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]) + refreshDeckList() + }, [source, load, refreshDeckList]) useEffect(() => { + if (editing) return 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)) } @@ -91,11 +115,15 @@ export function PresentationView() { } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [data?.slides.length]) + }, [data?.slides.length, editing]) const slides = data?.slides || [] const slide: PresentationSlide | undefined = slides[slideIdx] + const editIdx = editing ? slideIdx : null + const editSlides = draft?.slides || [] + const editSlide = editIdx != null ? editSlides[editIdx] : undefined + const exportHtml = () => { const id = source === 'live' ? 'live' : source window.open(`/api/presentation/decks/${id}/html`, '_blank') @@ -123,6 +151,125 @@ export function PresentationView() { } } + // ── editing actions ── + const startEdit = () => { + if (!data) return + setDraft(JSON.parse(JSON.stringify(data))) + setEditing(true) + } + + const newDeck = async (fromCurrent = false) => { + setUploadMsg(null) + try { + const body: Record = {} + if (fromCurrent) { + body.from = source + body.title = `${data?.title || 'Deck'} (copy)` + } else { + body.title = 'Untitled deck' + } + const r = await fetch('/api/presentation/decks/new', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const j = await r.json() + if (j.ok && j.deck) { + await refreshDeckList() + setCustomDecks((prev) => prev.some((d) => d.id === j.deck.id) ? prev : [{ id: j.deck.id, title: j.deck.title }, ...prev]) + setSource(j.deck.id) + setData(j.deck) + setSlideIdx(0) + setDraft(JSON.parse(JSON.stringify(j.deck))) + setEditing(true) + } else { + setUploadMsg(j.error || 'Could not create deck') + } + } catch { + setUploadMsg('Could not create deck — check connection') + } + } + + const saveDraft = async () => { + if (!draft) return + setSaving(true) + try { + const r = await fetch(`/api/presentation/decks/${source}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(draft), + }) + const j = await r.json() + if (j.ok && j.deck) { + setData(j.deck) + setEditing(false) + setSlideIdx((i) => Math.min(i, (j.deck.slides?.length || 1) - 1)) + await refreshDeckList() + setUploadMsg('✓ Saved') + } else { + setUploadMsg(j.error || 'Save failed') + } + } catch { + setUploadMsg('Save failed — check connection') + } finally { + setSaving(false) + } + } + + const cancelEdit = () => { + setEditing(false) + setDraft(null) + } + + const patchSlide = (idx: number, patch: Partial) => { + setDraft((d) => { + if (!d) return d + const next = { ...d, slides: d.slides.map((s, i) => (i === idx ? { ...s, ...patch } : s)) } + return next + }) + } + + const addSlide = () => { + setDraft((d) => { + if (!d) return d + const n = d.slides.length + 1 + const blank: PresentationSlide = { id: `slide-${Date.now()}`, title: `Slide ${n}`, subtitle: '', bullets: ['New point'], image: '', kind: 'narrative' } + const slidesNext = [...d.slides] + slidesNext.splice(slideIdx + 1, 0, blank) + return { ...d, slides: slidesNext, slide_count: slidesNext.length } + }) + setSlideIdx((i) => i + 1) + } + + const deleteSlide = () => { + setDraft((d) => { + if (!d || d.slides.length <= 1) return d + const slidesNext = d.slides.filter((_, i) => i !== slideIdx) + return { ...d, slides: slidesNext, slide_count: slidesNext.length } + }) + setSlideIdx((i) => Math.max(0, i - 1)) + } + + const onPickImage = async (file: File) => { + if (!file || editIdx == null) return + setImgBusy(true) + const fd = new FormData() + fd.append('file', file) + try { + const r = await fetch(`/api/presentation/decks/${source}/image`, { method: 'POST', body: fd }) + const j = await r.json() + if (j.ok && j.url) { + patchSlide(editIdx, { image: j.url }) + } else { + setUploadMsg(j.error || 'Image upload failed') + } + } catch { + setUploadMsg('Image upload failed — check connection') + } finally { + setImgBusy(false) + } + } + const tabs: { id: DeckSource; label: string }[] = [ { id: 'live', label: 'Live Cluster' }, { id: 'stack-architecture', label: 'Stack Architecture' }, @@ -137,41 +284,70 @@ export function PresentationView() {

    Presentation

    - Live cluster · HTML templates · PPT upload (converts via python-pptx + Docling) + Live cluster · HTML templates · PPT upload · editable decks with text & photos

    - - DQ Portal - - - Docling - - - + {!editing && ( + <> + + {isCustom ? ( + + ) : ( + + )} + + DQ Portal + + + Docling + + + + + )} + {editing && ( + <> + Editing + + + + )}
    -
    - {tabs.map((t) => ( - - ))} - -
    + {!editing && ( +
    + {tabs.map((t) => ( + + ))} + +
    + )} {uploadMsg &&

    {uploadMsg}

    } {error &&

    {error}

    } @@ -181,26 +357,169 @@ export function PresentationView() {

    Loading presentation{source === 'live' ? ' (live cluster snapshot, ~15 sec)' : '…'}

    + ) : editing && editSlide ? ( + /* ─────────── EDIT MODE ─────────── */ +
    +
    +
    +
    +

    Slide {editIdx! + 1} / {editSlides.length}

    +
    + + +
    +
    + + + + + +
    + Bullet points +
    + {(editSlide.bullets || []).map((b, bi) => ( +
    + + { + const next = [...(editSlide.bullets || [])] + next[bi] = e.target.value + patchSlide(editIdx!, { bullets: next }) + }} + className="flex-1 rounded-md border border-border bg-surface px-2 py-1.5 text-sm text-foreground outline-none focus:border-docker" + placeholder="Bullet text" + /> + +
    + ))} + +
    +
    + +
    + Image + {editSlide.image ? ( +
    + +
    + + +
    +
    + ) : ( + + )} + e.target.files?.[0] && onPickImage(e.target.files[0])} + /> +
    +
    +
    + + {/* slide rail */} +
    + {editSlides.map((s, i) => ( + + ))} + +
    +
    ) : !slide ? (
    ) : ( + /* ─────────── VIEW MODE ─────────── */ <>
    -
    -

    {slide.kind || 'slide'} · {slideIdx + 1}/{slides.length}

    -

    {slide.title}

    - {slide.subtitle &&

    {slide.subtitle}

    } - {'animation' in slide && slide.animation && ( - +
    +
    +

    {slide.kind || 'slide'} · {slideIdx + 1}/{slides.length}

    +

    {slide.title}

    + {slide.subtitle &&

    {slide.subtitle}

    } + {'animation' in slide && slide.animation && ( + + )} +
      + {(slide.bullets || []).map((b: string, bi: number) => ( +
    • {b}
    • + ))} +
    +
    + {slide.image && ( +
    + +
    )} -
      - {(slide.bullets || []).map((b: string) => ( -
    • {b}
    • - ))} -
    + {slide.image && ( +
    + +
    + )}
    diff --git a/ui/src/components/features/SshTerminal.tsx b/ui/src/components/features/SshTerminal.tsx new file mode 100644 index 0000000..98ec92f --- /dev/null +++ b/ui/src/components/features/SshTerminal.tsx @@ -0,0 +1,240 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Loader2, Plug, RotateCcw, TerminalSquare, X } from 'lucide-react' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import '@xterm/xterm/css/xterm.css' +import { INFRA_CATALOG } from '../../lib/infraCatalog' + +type Props = { + open: boolean + onClose: () => void +} + +type ConnState = 'form' | 'connecting' | 'connected' | 'closed' + +const PRESETS = INFRA_CATALOG.map((n) => ({ label: `${n.label} · ${n.ip}`, ip: n.ip })) + +export function SshTerminal({ open, onClose }: Props) { + const [host, setHost] = useState('10.0.21.33') + const [port, setPort] = useState('22') + const [username, setUsername] = useState('root') + const [password, setPassword] = useState('') + const [state, setState] = useState('form') + const [statusMsg, setStatusMsg] = useState(null) + const [pos, setPos] = useState({ x: 0, y: 0 }) + + const termRef = useRef(null) + const term = useRef(null) + const fit = useRef(null) + const ws = useRef(null) + const dragging = useRef<{ dx: number; dy: number } | null>(null) + + const teardown = useCallback(() => { + try { ws.current?.close() } catch { /* */ } + ws.current = null + try { term.current?.dispose() } catch { /* */ } + term.current = null + fit.current = null + }, []) + + const connect = useCallback(() => { + setState('connecting') + setStatusMsg(`Connecting to ${username}@${host}:${port}…`) + + const t = new Terminal({ + cursorBlink: true, + fontSize: 13, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + theme: { background: '#0a0e14', foreground: '#d6deeb', cursor: '#7ee787' }, + }) + const fitAddon = new FitAddon() + t.loadAddon(fitAddon) + term.current = t + fit.current = fitAddon + + // open after the DOM node is mounted + requestAnimationFrame(() => { + if (!termRef.current) return + t.open(termRef.current) + try { fitAddon.fit() } catch { /* */ } + + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + const socket = new WebSocket(`${proto}://${window.location.host}/api/ws/ssh`) + ws.current = socket + + socket.onopen = () => { + socket.send(JSON.stringify({ + type: 'connect', + host, + port: Number(port) || 22, + username, + password, + cols: t.cols, + rows: t.rows, + })) + } + socket.onmessage = (ev) => { + let msg: any + try { msg = JSON.parse(ev.data) } catch { return } + if (msg.type === 'data') { + t.write(msg.data) + } else if (msg.type === 'status') { + setStatusMsg(msg.message) + } else if (msg.type === 'connected') { + setState('connected') + setStatusMsg(null) + setPassword('') + t.focus() + } else if (msg.type === 'error') { + setState('closed') + setStatusMsg(msg.message) + t.writeln(`\r\n\x1b[31m${msg.message}\x1b[0m`) + } else if (msg.type === 'closed') { + setState('closed') + t.writeln('\r\n\x1b[33m*** Session closed ***\x1b[0m') + } + } + socket.onclose = () => { + setState((s) => (s === 'connected' ? 'closed' : s)) + } + + t.onData((d) => { + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'data', data: d })) + }) + }) + }, [host, port, username, password]) + + const disconnect = useCallback(() => { + try { ws.current?.send(JSON.stringify({ type: 'disconnect' })) } catch { /* */ } + teardown() + setState('form') + setStatusMsg(null) + }, [teardown]) + + // resize handling + useEffect(() => { + if (state !== 'connected' && state !== 'closed') return + const onResize = () => { + try { + fit.current?.fit() + const t = term.current + if (t && ws.current?.readyState === WebSocket.OPEN) { + ws.current.send(JSON.stringify({ type: 'resize', cols: t.cols, rows: t.rows })) + } + } catch { /* */ } + } + const ro = new ResizeObserver(onResize) + if (termRef.current) ro.observe(termRef.current) + window.addEventListener('resize', onResize) + onResize() + return () => { ro.disconnect(); window.removeEventListener('resize', onResize) } + }, [state]) + + useEffect(() => { + if (!open) { teardown(); setState('form'); setStatusMsg(null); setPos({ x: 0, y: 0 }) } + }, [open, teardown]) + + useEffect(() => () => teardown(), [teardown]) + + const onDragMove = useCallback((e: PointerEvent) => { + if (!dragging.current) return + setPos({ x: e.clientX - dragging.current.dx, y: e.clientY - dragging.current.dy }) + }, []) + const onDragUp = useCallback(() => { + dragging.current = null + window.removeEventListener('pointermove', onDragMove) + window.removeEventListener('pointerup', onDragUp) + }, [onDragMove]) + const startDrag = useCallback((e: React.PointerEvent) => { + dragging.current = { dx: e.clientX - pos.x, dy: e.clientY - pos.y } + window.addEventListener('pointermove', onDragMove) + window.addEventListener('pointerup', onDragUp) + }, [pos, onDragMove, onDragUp]) + + if (!open) return null + + return ( +
    e.target === e.currentTarget && onClose()}> +
    +
    + + + SSH Terminal + {(state === 'connected' || state === 'closed') && ( + {username}@{host}:{port} + )} + {state === 'connected' && live} + {state === 'closed' && closed} + +
    + {(state === 'connected' || state === 'closed') && ( + + )} + +
    +
    + + {state === 'form' ? ( +
    { e.preventDefault(); if (host && password) connect() }} + > +

    Connect to any host reachable from the platform. Credentials are used for this session only and never stored.

    +
    + + +
    +
    + + +
    +
    +

    Lab presets

    +
    + {PRESETS.map((p) => ( + + ))} +
    +
    + +
    + ) : ( +
    + {state === 'connecting' && ( +
    + {statusMsg || 'Connecting…'} +
    + )} +
    +
    + )} +
    +
    + ) +} diff --git a/ui/src/components/layout/SideNav.tsx b/ui/src/components/layout/SideNav.tsx index 7ef88a0..0aab8b7 100644 --- a/ui/src/components/layout/SideNav.tsx +++ b/ui/src/components/layout/SideNav.tsx @@ -1,28 +1,25 @@ -import { DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, ShieldCheck } from 'lucide-react' -import type { Agent, AgentAnim, GpuStatus } from '../../types' +import { DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Presentation, Server, TerminalSquare } from 'lucide-react' +import type { GpuStatus, WorkloadData } 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' +import { LabHealthPanel } from '../features/LabHealthPanel' -type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'search' | 'approvals' +type MainView = 'platform' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search' | 'approvals' type Props = { - agents: Agent[] - animations: Record + workload: WorkloadData | null 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 + onOpenSsh: () => void } const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [ @@ -31,32 +28,28 @@ const VIEWS: { id: MainView; label: string; icon: typeof LayoutDashboard }[] = [ { id: 'dataquality', label: 'Data Quality', icon: DatabaseZap }, { id: 'knowledge', label: 'Knowledge Chat', icon: MessageSquare }, { id: 'storage', label: 'Object Storage', icon: HardDrive }, + { id: 'hdfs', label: 'Hadoop HDFS', icon: Server }, { id: 'search', label: 'Elasticsearch', icon: Search }, ] export function SideNav({ - agents, - animations, + workload, gpu, gpuLive, gpuBoost = false, - selectedAgentId, selectedNodeId, mainView, approvalCount, - agentsLoading, onSetMainView, onOpenApprovals, - onSelectAgent, onSelectZone, + onOpenSsh, }: Props) { - const supervisors = agents.filter((a) => a.supervisor) - const operators = agents.filter((a) => !a.supervisor) const matrixBoost = gpuBoost || mainView === 'knowledge' return ( ) } - -function AgentRow({ - agent, - animations, - selected, - onSelect, -}: { - agent: Agent - animations: Record - selected: boolean - onSelect: (id: string) => void -}) { - const meta = getAgentMeta(agent.id) - const Icon = meta.icon - const busy = (animations[agent.id]?.state || 'idle') !== 'idle' - return ( - - ) -} diff --git a/ui/src/hooks/useCommandCenter.ts b/ui/src/hooks/useCommandCenter.ts index ce8342c..4dcec49 100644 --- a/ui/src/hooks/useCommandCenter.ts +++ b/ui/src/hooks/useCommandCenter.ts @@ -68,7 +68,7 @@ export function useCommandCenter() { const [selectedNode, setSelectedNode] = useState(null) const [nodeDetail, setNodeDetail] = useState(null) const [nodeBusy, setNodeBusy] = useState(false) - const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'search'>('platform') + const [mainView, setMainView] = useState<'platform' | 'approvals' | 'presentation' | 'dataquality' | 'knowledge' | 'storage' | 'hdfs' | 'search'>('platform') const [approvalHighlight, setApprovalHighlight] = useState(false) const [workbenchMode, setWorkbenchMode] = useState<'agent' | 'sql-postgres' | 'sql-mysql' | 'sql-mongodb' | 'sql-trino' | null>(null) const [chatExpanded, setChatExpanded] = useState(false) diff --git a/ui/src/styles/globals.css b/ui/src/styles/globals.css index ce4ef4b..26df2e9 100644 --- a/ui/src/styles/globals.css +++ b/ui/src/styles/globals.css @@ -87,14 +87,14 @@ } .topo-edge-idle { - stroke: rgba(100, 140, 180, 0.45); - stroke-width: 2; + stroke: rgba(100, 140, 180, 0.35); + stroke-width: 1.5; stroke-dasharray: 4 8; fill: none; } .topo-edge-live { - stroke-width: 2.5; + stroke-width: 2; stroke-dasharray: 8 12; fill: none; animation: flow-dash 1.2s linear infinite; @@ -129,8 +129,13 @@ box-shadow: 0 0 12px rgba(245, 158, 11, 0.25), var(--topo-node-shadow); } + .topo-node-hadoop { + border-color: rgba(57, 255, 20, 0.45) !important; + box-shadow: 0 0 12px rgba(57, 255, 20, 0.2), var(--topo-node-shadow); + } + .topo-node { - @apply mx-auto w-[84%] rounded-md border px-1.5 py-1 text-left transition-all; + @apply mx-auto w-[72%] max-w-[104px] rounded border px-1.5 py-1 text-left transition-all; background: var(--topo-node-bg); border-color: var(--topo-node-border); box-shadow: var(--topo-node-shadow); @@ -146,7 +151,7 @@ } .topo-stage-col { - @apply flex min-w-0 flex-1 flex-col px-0.5 py-1 last:border-r-0; + @apply flex min-w-[100px] flex-1 flex-col px-1 py-1.5 last:border-r-0; border-right: 1px dashed var(--topo-stage-border); } diff --git a/ui/src/types.ts b/ui/src/types.ts index 9087e9e..391d209 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -223,6 +223,7 @@ export type PresentationSlide = { subtitle?: string bullets: string[] kind?: string + image?: string animation?: string topology?: TopologyViewData zone?: WorkloadZone