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:
+1
-1
@@ -4,7 +4,7 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY main.py 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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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":"");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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/ {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -223,6 +223,7 @@ export type PresentationSlide = {
|
||||
subtitle?: string
|
||||
bullets: string[]
|
||||
kind?: string
|
||||
image?: string
|
||||
animation?: string
|
||||
topology?: TopologyViewData
|
||||
zone?: WorkloadZone
|
||||
|
||||
Reference in New Issue
Block a user