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
This commit is contained in:
mo
2026-06-26 00:47:49 +00:00
parent fefe3016ad
commit 46b51a891c
21 changed files with 1957 additions and 328 deletions
+1 -1
View File
@@ -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
+197
View File
@@ -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)}
+80 -2
View File
@@ -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()
-1
View File
@@ -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", ""))
+3 -1
View File
@@ -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=>"<li>"+b+"</li>").join("");
el.innerHTML="<h2>"+s.title+"</h2><h3>"+(s.subtitle||"")+"</h3><ul>"+bullets+"</ul>";
const img=s.image?'<img class="slide-img" src="'+s.image+'" alt=""/>':"";
el.innerHTML="<h2>"+s.title+"</h2><h3>"+(s.subtitle||"")+"</h3><ul>"+bullets+"</ul>"+img;
container.appendChild(el);
const d=document.createElement("button");
d.className="dot"+(idx===0?" active":"");
+106
View File
@@ -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
+1
View File
@@ -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
+146
View File
@@ -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
+3
View File
@@ -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/ {
+2
View File
@@ -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",
+9 -5
View File
@@ -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<HTMLDivElement>(null)
@@ -60,20 +63,17 @@ export default function App() {
<div className="flex min-h-0 flex-1 overflow-hidden">
<SideNav
agents={cc.agents}
animations={cc.anims}
workload={cc.workload}
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}
onOpenSsh={() => setSshOpen(true)}
/>
<div ref={mainScrollRef} className={cn('flex min-h-0 min-w-0 flex-1 flex-col bg-surface', isPlatform ? 'overflow-hidden' : 'scrollbar-thin overflow-y-auto')}>
@@ -132,6 +132,8 @@ export default function App() {
<KnowledgeChatView onGpuActivity={setGpuChatActive} />
) : cc.mainView === 'storage' ? (
<StorageView />
) : cc.mainView === 'hdfs' ? (
<HdfsView />
) : cc.mainView === 'search' ? (
<SearchView />
) : (
@@ -201,6 +203,8 @@ export default function App() {
onDecide={cc.decide}
onDismissHighlight={() => cc.setApprovalHighlight(false)}
/>
<SshTerminal open={sshOpen} onClose={() => setSshOpen(false)} />
</div>
)
}
+118 -106
View File
@@ -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 (
<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 className="rounded border border-border/70 bg-surface-overlay/50 px-1.5 py-1">
<div className="mb-0.5 flex items-center justify-between gap-1 font-mono text-[8px]">
<span className="font-semibold text-foreground">GPU {device.index}</span>
<span className="text-foreground-faint">{util.toFixed(0)}% · {vramPct}% VR</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 className="flex gap-1">
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-raised" title={`Util ${util.toFixed(0)}%`}>
<div className={cn('h-full rounded-full transition-all duration-700', utilColor(util))} style={{ width: `${util}%` }} />
</div>
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-raised" title={`VRAM ${vramPct}%`}>
<div className="h-full rounded-full bg-docker transition-all duration-700" style={{ width: `${vramPct}%` }} />
</div>
</div>
<p className="mt-0.5 font-mono text-[7px] text-foreground-faint">
{device.temperature_c?.toFixed(0) ?? '—'}°C · {device.power_w?.toFixed(0) ?? '—'} W
</p>
</div>
)
}
@@ -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<GpuStatus | null>(gpu)
const [lastPoll, setLastPoll] = useState<Date | null>(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 (
<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">
<section className="shrink-0 border-b border-border px-3 py-2">
<h2 className="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>
<p className="mt-1 text-[9px] text-foreground-faint">GPU Lab offline</p>
</section>
)
}
const tokLabel = boost && inferenceOn ? String(live.tokenThroughput) : inferenceOn ? '—' : '0'
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">
<section className="shrink-0 border-b border-border px-3 py-2">
<div className="flex items-start gap-1">
<button
type="button"
onClick={onSelectGpu}
className="min-w-0 flex-1 text-left hover:opacity-90"
>
<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 && (
@@ -136,70 +125,93 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
</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"
{!expanded && (
<p className="mt-0.5 truncate text-[10px] font-medium text-foreground">{modelLabel}</p>
)}
</button>
<div className="flex shrink-0 items-center gap-0.5">
{g.ui_url && (
<a
href={g.ui_url}
target="_blank"
rel="noreferrer"
className="rounded p-1 text-docker hover:bg-surface-overlay"
title="Open GPU UI"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="rounded p-1 text-foreground-muted hover:bg-surface-overlay hover:text-foreground"
title={expanded ? 'Collapse GPU details' : 'Expand GPU details'}
aria-expanded={expanded}
>
<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" />}
/>
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
</div>
</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>
{!expanded ? (
<button
type="button"
onClick={() => setExpanded(true)}
className="mt-1.5 w-full rounded-md border border-border/80 bg-surface-overlay/50 px-2 py-1.5 text-left transition-colors hover:border-border-strong hover:bg-surface-overlay"
>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[8px] text-foreground-muted">
<span className={inferenceOn ? 'text-success' : 'text-foreground-faint'}>
{inferenceOn ? 'Active' : 'Idle'}
</span>
<span>{avgUtil.toFixed(0)}% util</span>
<span>{avgVram.toFixed(0)}% VRAM</span>
{inferenceOn && (
<span className="inline-flex items-center gap-0.5">
<Zap className="h-2.5 w-2.5 text-amber-400" />
{tokLabel} tok/s
</span>
)}
</div>
<div className="mt-1 flex items-center gap-1.5">
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-raised">
<div className="h-full rounded-full bg-docker transition-all duration-700" style={{ width: `${avgVram}%` }} />
</div>
<span className="shrink-0 font-mono text-[7px] text-foreground-faint">
{g.gpu_count ?? devices.length}× V100
</span>
</div>
</button>
) : (
<div className="mt-1.5 space-y-1.5">
<p className="truncate text-[10px] font-medium text-foreground">{modelLabel}</p>
<div className="flex flex-wrap gap-x-2 gap-y-0.5 font-mono text-[8px] text-foreground-muted">
<span className={inferenceOn ? 'text-success' : 'text-foreground-faint'}>
{inferenceOn ? 'Active' : 'Idle'}
</span>
<span>{avgUtil.toFixed(0)}% util avg</span>
<span>{avgVram.toFixed(0)}% VRAM avg</span>
<span className="inline-flex items-center gap-0.5">
<Zap className="h-2.5 w-2.5 text-amber-400" />
{tokLabel} tok/s
</span>
</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>
<div className="grid grid-cols-2 gap-1">
{devices.map((d, i) => (
<GpuCardCompact
key={d.index}
device={d}
liveUtil={live.deviceUtils[i] ?? d.util_gpu}
/>
))}
</div>
<p className="font-mono text-[7px] text-foreground-faint">
{g.gpu_count ?? devices.length}× V100 · {g.host} · poll {boost ? '1s' : '3s'}
{lastPoll && ` · ${lastPoll.toLocaleTimeString()}`}
</p>
</div>
)}
</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>
)
}
+230
View File
@@ -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<Health | null>(null)
const [path, setPath] = useState('/')
const [folders, setFolders] = useState<HdfsEntry[]>([])
const [files, setFiles] = useState<HdfsEntry[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<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">
<Server className="h-4 w-4 text-emerald-400" />
Hadoop HDFS
</h2>
<p className="text-[10px] text-foreground-muted">
{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}%)` : ''}
</p>
</div>
<div className="flex flex-wrap gap-2">
<a href="http://10.0.21.61:9870" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
NameNode UI
</a>
<button type="button" onClick={() => { loadHealth(); loadList(path) }} 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 p-3">
<nav className="mb-2 flex flex-wrap items-center gap-1 text-[10px] text-foreground-muted">
<button type="button" className="font-medium hover:text-emerald-400" onClick={() => setPath('/')}>
HDFS root
</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-emerald-400"
onClick={() => setPath('/' + 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">Owner</th>
<th className="py-1.5 pr-2">Perms</th>
<th className="py-1.5 pr-2">Modified</th>
<th className="py-1.5" />
</tr>
</thead>
<tbody>
{folders.map((f) => (
<tr key={f.path} 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-emerald-400 hover:underline"
onClick={() => setPath(f.path)}
>
<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-muted">{f.owner}</td>
<td className="py-1.5 pr-2 font-mono text-[9px] text-foreground-faint">{f.perms}</td>
<td className="py-1.5 pr-2 text-foreground-faint">{f.modified?.slice(0, 19).replace('T', ' ') || '—'}</td>
<td />
</tr>
))}
{files.map((o) => (
<tr key={o.path} className="border-b border-border/50 hover:bg-surface-overlay/50">
<td className="max-w-[280px] truncate py-1.5 pr-2">
<span className="inline-flex items-center gap-1 font-mono text-[10px]">
<FileText className="h-3.5 w-3.5 shrink-0 text-foreground-faint" /> {o.name}
</span>
</td>
<td className="py-1.5 pr-2 text-foreground-muted">{o.size_human}</td>
<td className="py-1.5 pr-2 text-foreground-muted">{o.owner}</td>
<td className="py-1.5 pr-2 font-mono text-[9px] text-foreground-faint">{o.perms}</td>
<td className="py-1.5 pr-2 text-foreground-faint">{o.modified?.slice(0, 19).replace('T', ' ') || '—'}</td>
<td className="py-1.5">
<div className="flex items-center gap-2">
<button type="button" title="Preview" className="text-foreground-muted hover:text-emerald-400" onClick={() => openPreview(o.path)}>
<Eye className="h-3.5 w-3.5" />
</button>
<a
title="Download"
href={`/api/storage/hdfs/download?path=${encodeURIComponent(o.path)}`}
className="text-foreground-muted hover:text-emerald-400"
>
<Download className="h-3.5 w-3.5" />
</a>
</div>
</td>
</tr>
))}
</tbody>
</table>
{!loading && folders.length === 0 && files.length === 0 && !error && (
<p className="py-8 text-center text-sm text-foreground-muted">This directory is empty.</p>
)}
</div>
</div>
{preview && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={() => setPreview(null)}>
<div className="flex max-h-[80vh] w-full max-w-3xl flex-col overflow-hidden rounded-lg border border-border bg-surface" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="truncate font-mono text-[11px] text-foreground">{preview.path}</span>
<button type="button" onClick={() => setPreview(null)} className="text-foreground-muted hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
{preview.binary && <p className="px-4 pt-2 text-[10px] text-amber-400">Binary file showing decoded preview.</p>}
<pre className="scrollbar-thin min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words p-4 font-mono text-[11px] leading-relaxed text-foreground-muted">
{previewing ? 'Loading…' : preview.text}
{preview.truncated && '\n\n… (truncated)'}
</pre>
</div>
</div>
)}
</div>
)
}
@@ -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<string, { icon: LucideIcon; shortLabel: string; abbrev: string }> = {
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 (
<section className={cn('flex flex-col p-3', expanded ? 'min-h-0 flex-1' : 'shrink-0')}>
<div className="mb-2 flex shrink-0 items-start justify-between gap-1">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="min-w-0 flex-1 text-left"
>
<h2 className="flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">
Lab Health
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</h2>
{!expanded && totals && (
<p className="mt-0.5 font-mono text-[8px] leading-snug text-foreground-muted">
<span className="text-success">{healthSummary.ok} ok</span>
{healthSummary.warn > 0 && (
<>
<span className="text-foreground-faint"> · </span>
<span className="text-warning">{healthSummary.warn} warn</span>
</>
)}
{healthSummary.down > 0 && (
<>
<span className="text-foreground-faint"> · </span>
<span className="text-danger">{healthSummary.down} down</span>
</>
)}
<span className="text-foreground-faint"> · </span>
<span className={totals.pipeline_active ? 'text-success' : 'text-warning'}>
{totals.pipeline_active ? 'pipeline ok' : 'degraded'}
</span>
</p>
)}
{expanded && totals && (
<p className="mt-0.5 font-mono text-[8px] leading-snug text-foreground-muted">
<span>{totals.apps_running}/{totals.apps_total} containers</span>
<span className="text-foreground-faint"> · </span>
<span>{totals.connectors} CDC</span>
<span className="text-foreground-faint"> · </span>
<span className={totals.pipeline_active ? 'text-success' : 'text-warning'}>
{totals.pipeline_active ? 'pipeline active' : 'degraded'}
</span>
</p>
)}
{!totals && (
<p className="mt-0.5 text-[8px] text-foreground-faint">Loading workload</p>
)}
</button>
<button
type="button"
onClick={onOpenApprovals}
className={cn(
'flex shrink-0 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" />
{approvalCount > 0 && <span className="font-mono">{approvalCount}</span>}
</button>
</div>
{!expanded ? (
<div className="flex flex-wrap gap-1">
{allZones.map(({ id, zone }) => {
const meta = ZONE_META[id] || { abbrev: id, shortLabel: zone.label, icon: Server }
const active = activeZone === id
return (
<button
key={id}
type="button"
onClick={() => onSelectZone(id)}
className={cn(
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[8px] font-medium transition-colors',
active
? 'border-docker/45 bg-docker-light/80 text-docker dark:bg-blue-500/15'
: 'border-border/70 bg-surface-overlay/40 text-foreground-muted hover:border-border-strong hover:text-foreground',
)}
>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', levelDot(zone.level))} />
{meta.abbrev}
</button>
)
})}
</div>
) : (
<>
<div className="scrollbar-thin min-h-0 flex-1 space-y-1 overflow-y-auto">
{ordered.map((zone) => (
<ZoneRow
key={zone.id}
zone={zone}
active={activeZone === zone.id}
onSelect={() => onSelectZone(zone.id)}
/>
))}
<ZoneRow
zone={gpuZone}
metric={gpuMetric(gpu, workload?.gpu)}
active={activeZone === 'gpu'}
onSelect={() => onSelectZone('gpu')}
/>
{!workload && (
<p className="py-2 text-center text-[9px] text-foreground-faint">Waiting for lab snapshot</p>
)}
</div>
<p className="mt-2 shrink-0 text-[7px] leading-relaxed text-foreground-faint">
Click a zone inspector · agents via Agent Fleet
</p>
</>
)}
</section>
)
}
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 (
<button
type="button"
onClick={onSelect}
className={cn(
'group flex w-full items-center gap-2 rounded-md border px-2 py-2 text-left transition-all',
active
? 'border-docker/45 bg-docker-light/80 shadow-sm dark:bg-blue-500/10'
: 'border-transparent bg-surface-overlay/30 hover:border-border hover:bg-surface-overlay',
)}
>
<span
className={cn('h-2 w-2 shrink-0 rounded-full transition-transform group-hover:scale-110', levelDot(zone.level))}
title={zone.level}
/>
<span
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border/60 bg-surface"
style={{ color: zone.color }}
>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-1">
<span className="truncate text-[11px] font-medium text-foreground">{meta.shortLabel}</span>
<span className="shrink-0 font-mono text-[9px] tabular-nums text-foreground-muted">{right}</span>
</span>
{subtitle && (
<span className="block truncate font-mono text-[8px] text-foreground-faint">{subtitle}</span>
)}
</span>
</button>
)
}
+111 -63
View File
@@ -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<FlowKind, string> = {
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',
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<string, { col: number; row: number; rows: number }> = {}
const NODE_COL: Record<string, number> = {}
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, () => 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<MetricState>(seedMetrics)
const canvasRef = useRef<HTMLDivElement>(null)
const nodeRefs = useRef<Record<string, HTMLButtonElement | null>>({})
const [anchors, setAnchors] = useState<Record<string, NodeAnchor>>({})
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<string, NodeAnchor> = {}
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 (
<section className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<header
@@ -308,7 +356,7 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
<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">
Click PostgreSQL / MySQL / MongoDB / Trino live console · agents terminal below
Click PostgreSQL / MySQL / MongoDB / Trino live console · Hadoop historical archive · agents terminal below
</p>
</div>
</div>
@@ -330,11 +378,10 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
</div>
</header>
<div className="topo-canvas flex min-h-0 flex-1">
<div ref={canvasRef} 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"
viewBox={`0 0 ${canvasSize.w} ${canvasSize.h}`}
aria-hidden
>
<defs>
@@ -345,31 +392,30 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
</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, 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 (
<g key={`${edge.from}-${edge.to}-${edge.kind}`}>
<path d={d} className="topo-edge-glow" vectorEffect="non-scaling-stroke" />
<path d={d} className="topo-edge-glow" />
<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">
<circle r="2.5" 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">
<circle r="1.5" fill="#ffffff" opacity="0.85">
<animateMotion dur={`${dur}s`} repeatCount="indefinite" path={d} begin={`${dur * 0.45}s`} />
</circle>
</>
@@ -382,18 +428,18 @@ export function PlatformTopology({ workload, animations, selectedNodeId, onNodeC
<div className="relative z-10 flex h-full min-h-0 w-full">
{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])}>
<header className="mb-0.5 shrink-0 border-b border-white/10 pb-0.5">
<div className="flex items-start gap-0.5">
<span className={cn('rounded border px-0.5 py-px font-mono text-[7px] 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>
<h3 className="text-[7px] font-bold leading-tight tracking-wide text-white">{stage.title}</h3>
<p className="text-[6px] leading-tight text-blue-200/70">{stage.subtitle}</p>
</div>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col justify-evenly gap-1">
<div className="flex min-h-0 flex-1 flex-col justify-evenly gap-0.5">
{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 (
<button
key={node.id}
ref={setNodeRef(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 === 'hadoop' && 'topo-node-hadoop',
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">
<span className="block truncate text-[9px] font-semibold leading-tight text-white">{label}</span>
<span className="block truncate text-[7px] leading-tight text-blue-100/80">{sub}</span>
<span className="mt-px inline-block max-w-full truncate rounded border border-emerald-400/35 bg-emerald-500/20 px-0.5 py-px font-mono text-[6px] font-medium leading-tight text-emerald-300">
{metrics[node.metricKey]}
</span>
</button>
+370 -51
View File
@@ -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<string | null>(null)
const [customDecks, setCustomDecks] = useState<{ id: string; title: string }[]>([])
// ── edit state ──
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState<PresentationData | null>(null)
const [saving, setSaving] = useState(false)
const [imgBusy, setImgBusy] = useState(false)
const imgInputRef = useRef<HTMLInputElement>(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<string, unknown> = {}
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<PresentationSlide>) => {
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() {
<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)
Live cluster · HTML templates · PPT upload · editable decks with text &amp; photos
</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>
{!editing && (
<>
<button type="button" onClick={() => newDeck(false)} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
<Plus className="h-3 w-3" /> New
</button>
{isCustom ? (
<button type="button" onClick={startEdit} className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px] text-docker hover:bg-surface-overlay">
<Pencil className="h-3 w-3" /> Edit
</button>
) : (
<button type="button" onClick={() => newDeck(true)} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
<Pencil className="h-3 w-3" /> Edit a copy
</button>
)}
<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>
</>
)}
{editing && (
<>
<span className="inline-flex items-center rounded bg-docker/15 px-2 py-0.5 text-[9px] font-medium text-docker">Editing</span>
<button type="button" onClick={cancelEdit} className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[9px] hover:bg-surface-overlay">
<X className="h-3 w-3" /> Cancel
</button>
<button type="button" onClick={saveDraft} disabled={saving} className={cn('inline-flex items-center gap-1 rounded-md px-2 py-1 text-[9px]', subTabActive, saving && 'opacity-50')}>
<Save className="h-3 w-3" /> {saving ? 'Saving…' : 'Save'}
</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>
{!editing && (
<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>}
@@ -181,26 +357,169 @@ export function PresentationView() {
<FileUp className="h-8 w-8 animate-pulse opacity-40" />
<p>Loading presentation{source === 'live' ? ' (live cluster snapshot, ~15 sec)' : '…'}</p>
</div>
) : editing && editSlide ? (
/* ─────────── EDIT MODE ─────────── */
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="scrollbar-thin flex-1 overflow-y-auto p-4 md:p-6">
<div className="mx-auto max-w-3xl space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-[10px] font-medium uppercase tracking-widest text-docker/80">Slide {editIdx! + 1} / {editSlides.length}</p>
<div className="flex gap-1">
<button type="button" onClick={addSlide} className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] hover:bg-surface-overlay">
<Plus className="h-3 w-3" /> Slide
</button>
<button type="button" onClick={deleteSlide} disabled={editSlides.length <= 1} className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-danger hover:bg-surface-overlay disabled:opacity-40">
<Trash2 className="h-3 w-3" /> Delete
</button>
</div>
</div>
<label className="block">
<span className="mb-1 block text-[9px] uppercase tracking-wider text-foreground-faint">Title</span>
<input
value={editSlide.title}
onChange={(e) => patchSlide(editIdx!, { title: e.target.value })}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-lg font-bold text-foreground outline-none focus:border-docker"
placeholder="Slide title"
/>
</label>
<label className="block">
<span className="mb-1 block text-[9px] uppercase tracking-wider text-foreground-faint">Subtitle</span>
<input
value={editSlide.subtitle || ''}
onChange={(e) => patchSlide(editIdx!, { subtitle: e.target.value })}
className="w-full rounded-md border border-border bg-surface px-3 py-1.5 text-sm text-foreground-muted outline-none focus:border-docker"
placeholder="Optional subtitle"
/>
</label>
<div>
<span className="mb-1 block text-[9px] uppercase tracking-wider text-foreground-faint">Bullet points</span>
<div className="space-y-1.5">
{(editSlide.bullets || []).map((b, bi) => (
<div key={bi} className="flex items-center gap-1.5">
<span className="shrink-0 text-docker"></span>
<input
value={b}
onChange={(e) => {
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"
/>
<button
type="button"
onClick={() => patchSlide(editIdx!, { bullets: (editSlide.bullets || []).filter((_, j) => j !== bi) })}
className="shrink-0 rounded border border-border p-1 text-foreground-muted hover:bg-surface-overlay hover:text-danger"
aria-label="Remove bullet"
>
<X className="h-3 w-3" />
</button>
</div>
))}
<button
type="button"
onClick={() => patchSlide(editIdx!, { bullets: [...(editSlide.bullets || []), ''] })}
className="inline-flex items-center gap-1 rounded border border-dashed border-border px-2 py-1 text-[10px] text-foreground-muted hover:bg-surface-overlay"
>
<Plus className="h-3 w-3" /> Add bullet
</button>
</div>
</div>
<div>
<span className="mb-1 block text-[9px] uppercase tracking-wider text-foreground-faint">Image</span>
{editSlide.image ? (
<div className="space-y-2">
<img src={editSlide.image} alt="" className="max-h-56 rounded-lg border border-border object-contain" />
<div className="flex gap-1">
<button type="button" onClick={() => imgInputRef.current?.click()} disabled={imgBusy} className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] hover:bg-surface-overlay disabled:opacity-50">
<ImagePlus className="h-3 w-3" /> Replace
</button>
<button type="button" onClick={() => patchSlide(editIdx!, { image: '' })} className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-danger hover:bg-surface-overlay">
<Trash2 className="h-3 w-3" /> Remove
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => imgInputRef.current?.click()}
disabled={imgBusy}
className="inline-flex items-center gap-1 rounded-md border border-dashed border-border px-3 py-2 text-[10px] text-foreground-muted hover:bg-surface-overlay disabled:opacity-50"
>
<ImagePlus className="h-3.5 w-3.5" /> {imgBusy ? 'Uploading…' : 'Add image'}
</button>
)}
<input
ref={imgInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => e.target.files?.[0] && onPickImage(e.target.files[0])}
/>
</div>
</div>
</div>
{/* slide rail */}
<div className="scrollbar-thin w-40 shrink-0 space-y-1 overflow-y-auto border-l border-border bg-surface-overlay/30 p-2">
{editSlides.map((s, i) => (
<button
key={s.id || i}
type="button"
onClick={() => setSlideIdx(i)}
className={cn(
'block w-full truncate rounded border px-2 py-1.5 text-left text-[10px] transition-colors',
i === slideIdx ? 'border-docker/50 bg-docker-light/60 text-docker dark:bg-blue-500/15' : 'border-border bg-surface hover:bg-surface-overlay',
)}
>
<span className="mr-1 font-mono text-foreground-faint">{i + 1}</span>
{s.title || 'Untitled'}
{s.image && <ImagePlus className="ml-1 inline h-2.5 w-2.5 text-foreground-faint" />}
</button>
))}
<button type="button" onClick={addSlide} className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-border px-2 py-1.5 text-[10px] text-foreground-muted hover:bg-surface-overlay">
<Plus className="h-3 w-3" /> Add slide
</button>
</div>
</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>
) : (
/* ─────────── VIEW MODE ─────────── */
<>
<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)} />
<div className="flex max-w-5xl gap-6">
<div className="min-w-0 flex-1">
<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, bi: number) => (
<li key={bi} className="flex gap-2"><span className="shrink-0 text-docker"></span><span>{b}</span></li>
))}
</ul>
</div>
{slide.image && (
<div className="hidden shrink-0 items-center md:flex">
<img src={slide.image} alt="" className="max-h-[46vh] max-w-[40vw] rounded-lg border border-border object-contain shadow-lg" />
</div>
)}
<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>
{slide.image && (
<div className="mt-4 md:hidden">
<img src={slide.image} alt="" className="max-h-[30vh] rounded-lg border border-border object-contain" />
</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>
+240
View File
@@ -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<ConnState>('form')
const [statusMsg, setStatusMsg] = useState<string | null>(null)
const [pos, setPos] = useState({ x: 0, y: 0 })
const termRef = useRef<HTMLDivElement | null>(null)
const term = useRef<Terminal | null>(null)
const fit = useRef<FitAddon | null>(null)
const ws = useRef<WebSocket | null>(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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div
className="flex h-[600px] max-h-[90vh] w-[900px] max-w-[95vw] flex-col overflow-hidden rounded-lg border border-border bg-surface shadow-2xl"
style={{ transform: `translate(${pos.x}px, ${pos.y}px)` }}
>
<header
className="flex shrink-0 cursor-move items-center justify-between border-b border-border bg-surface-overlay px-3 py-2"
onPointerDown={startDrag}
>
<span className="flex items-center gap-2 text-[12px] font-semibold text-foreground">
<TerminalSquare className="h-4 w-4 text-emerald-400" />
SSH Terminal
{(state === 'connected' || state === 'closed') && (
<span className="font-mono text-[10px] text-foreground-muted">{username}@{host}:{port}</span>
)}
{state === 'connected' && <span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] text-emerald-400">live</span>}
{state === 'closed' && <span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[9px] text-amber-400">closed</span>}
</span>
<div className="flex items-center gap-2">
{(state === 'connected' || state === 'closed') && (
<button type="button" onClick={disconnect} title="New connection" className="text-foreground-muted hover:text-foreground">
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
<button type="button" onClick={onClose} title="Close" className="text-foreground-muted hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
</header>
{state === 'form' ? (
<form
className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-5"
onSubmit={(e) => { e.preventDefault(); if (host && password) connect() }}
>
<p className="text-[11px] text-foreground-muted">Connect to any host reachable from the platform. Credentials are used for this session only and never stored.</p>
<div className="grid grid-cols-[1fr_110px] gap-2">
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-wide text-foreground-faint">
Host
<input value={host} onChange={(e) => setHost(e.target.value)} className="rounded border border-border bg-background px-2 py-1.5 text-[12px] text-foreground" placeholder="10.0.21.33" />
</label>
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-wide text-foreground-faint">
Port
<input value={port} onChange={(e) => setPort(e.target.value)} className="rounded border border-border bg-background px-2 py-1.5 text-[12px] text-foreground" />
</label>
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-wide text-foreground-faint">
Username
<input value={username} onChange={(e) => setUsername(e.target.value)} className="rounded border border-border bg-background px-2 py-1.5 text-[12px] text-foreground" />
</label>
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-wide text-foreground-faint">
Password
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus className="rounded border border-border bg-background px-2 py-1.5 text-[12px] text-foreground" />
</label>
</div>
<div>
<p className="mb-1 text-[10px] uppercase tracking-wide text-foreground-faint">Lab presets</p>
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<button key={p.ip} type="button" onClick={() => setHost(p.ip)} className="rounded border border-border px-2 py-1 text-[10px] text-foreground-muted hover:border-emerald-400/40 hover:text-emerald-400">
{p.label}
</button>
))}
</div>
</div>
<button type="submit" disabled={!host || !password} className="mt-1 inline-flex items-center justify-center gap-2 rounded-md bg-emerald-500/90 px-4 py-2 text-[12px] font-semibold text-black hover:bg-emerald-400 disabled:opacity-40">
<Plug className="h-4 w-4" /> Connect
</button>
</form>
) : (
<div className="relative flex min-h-0 flex-1 flex-col bg-[#0a0e14]">
{state === 'connecting' && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#0a0e14]/80 text-[12px] text-foreground-muted">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {statusMsg || 'Connecting…'}
</div>
)}
<div ref={termRef} className="min-h-0 flex-1 p-2" />
</div>
)}
</div>
</div>
)
}
+26 -92
View File
@@ -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<string, AgentAnim>
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 (
<nav className="flex w-[240px] shrink-0 flex-col border-r border-border bg-surface-raised">
<section className="border-b border-border p-3">
<section className="shrink-0 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 }) => (
@@ -73,6 +66,14 @@ export function SideNav({
<span className={cn('text-[11px] font-medium', mainView === id ? 'text-docker' : 'text-foreground')}>{label}</span>
</button>
))}
<button
type="button"
onClick={onOpenSsh}
className={cn('flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left transition-all', viewTabIdle)}
>
<TerminalSquare className="h-4 w-4 text-emerald-400" />
<span className="text-[11px] font-medium text-foreground">SSH Terminal</span>
</button>
</div>
</section>
@@ -83,81 +84,14 @@ export function SideNav({
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>
<LabHealthPanel
workload={workload}
gpu={gpu}
selectedNodeId={selectedNodeId}
approvalCount={approvalCount}
onSelectZone={onSelectZone}
onOpenApprovals={onOpenApprovals}
/>
</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>
)
}
+1 -1
View File
@@ -68,7 +68,7 @@ export function useCommandCenter() {
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' | '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)
+10 -5
View File
@@ -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);
}
+1
View File
@@ -223,6 +223,7 @@ export type PresentationSlide = {
subtitle?: string
bullets: string[]
kind?: string
image?: string
animation?: string
topology?: TopologyViewData
zone?: WorkloadZone