feat: Authentik login + switchable GPU prod target

Add OIDC auth for Command Center and runtime GPU endpoint selection
pointed at atc-gpu-prod (10.0.10.106), matching what is currently deployed.
This commit is contained in:
mo
2026-07-21 23:20:24 +00:00
parent f36c8906bc
commit 9008fbd512
31 changed files with 4667 additions and 139 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY main.py 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 pipeline_ops.py hadoop_analytics.py agent_ops.py cdc_consumer.py movements.py dataflow.py streaming_ops.py spark_workbench.py hadoop_sql.py hdfs_kafka.py webhdfs_util.py pii_catalog.py platform_context.py trino_federated.py etl_offload.py agent_activity.py lake_meta.py lineage.py dq_monitor.py observability.py catalog_governance.py hive_bench_seed.json . COPY main.py auth.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 pipeline_ops.py hadoop_analytics.py agent_ops.py cdc_consumer.py movements.py dataflow.py streaming_ops.py spark_workbench.py hadoop_sql.py hdfs_kafka.py webhdfs_util.py pii_catalog.py platform_context.py trino_federated.py etl_offload.py agent_activity.py lake_meta.py lineage.py dq_monitor.py observability.py catalog_governance.py gpu_config.py hive_bench_seed.json .
RUN mkdir -p /data RUN mkdir -p /data
ENV DATABASE_URL=sqlite:////data/atc-agents.db ENV DATABASE_URL=sqlite:////data/atc-agents.db
EXPOSE 3201 EXPOSE 3201
+3 -1
View File
@@ -24,6 +24,7 @@ TRINO_USER = os.getenv("TRINO_USER", "mo")
HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870").rstrip("/") HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870").rstrip("/")
YARN_URL = os.getenv("YARN_URL", "http://10.0.21.62:8088").rstrip("/") YARN_URL = os.getenv("YARN_URL", "http://10.0.21.62:8088").rstrip("/")
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082").rstrip("/") DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082").rstrip("/")
DOCKHAND_API_TOKEN = os.getenv("DOCKHAND_API_TOKEN", "")
OBJECTSCALE_URL = os.getenv("OBJECTSCALE_URL", os.getenv("S3_ENDPOINT", "http://10.0.20.111:9020")).rstrip("/") OBJECTSCALE_URL = os.getenv("OBJECTSCALE_URL", os.getenv("S3_ENDPOINT", "http://10.0.20.111:9020")).rstrip("/")
TICK_SECONDS = float(os.getenv("AGENT_ACTIVITY_TICK_SECONDS", "8")) TICK_SECONDS = float(os.getenv("AGENT_ACTIVITY_TICK_SECONDS", "8"))
@@ -142,7 +143,8 @@ async def _infra_sentinel() -> None:
async with httpx.AsyncClient(timeout=4.0) as client: async with httpx.AsyncClient(timeout=4.0) as client:
async def _one(name: str, eid: int): async def _one(name: str, eid: int):
try: try:
r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": eid}) hdr = {"Authorization": f"Bearer {DOCKHAND_API_TOKEN}"} if DOCKHAND_API_TOKEN else {}
r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": eid}, headers=hdr)
if r.status_code >= 400: if r.status_code >= 400:
return None return None
d = r.json() d = r.json()
+47 -5
View File
@@ -364,10 +364,36 @@ _CUST_INTERVAL = float(os.getenv("CUSTODIAN_OFFLOAD_INTERVAL_SECONDS", "120"))
_CUST_BATCH = int(os.getenv("CUSTODIAN_OFFLOAD_BATCH", "200")) _CUST_BATCH = int(os.getenv("CUSTODIAN_OFFLOAD_BATCH", "200"))
_CUST_TARGETS = [ _CUST_TARGETS = [
{"label": "postgres sales_orders", "src": "postgres_sales.public.sales_orders", {"label": "postgres sales_orders", "src": "postgres_sales.public.sales_orders",
"target": "iceberg.hadoop.sales_orders_offload"}, "target": "iceberg.hadoop.sales_orders_offload",
"create_sql": (
"CREATE TABLE iceberg.hadoop.sales_orders_offload AS "
"SELECT * FROM postgres_sales.public.sales_orders WHERE 1=0"
),
"insert_sql": (
"INSERT INTO iceberg.hadoop.sales_orders_offload "
"SELECT * FROM postgres_sales.public.sales_orders LIMIT {batch}"
)},
{"label": "mysql employee_events", "src": "mysql_hr.hr.employee_events", {"label": "mysql employee_events", "src": "mysql_hr.hr.employee_events",
"target": "iceberg.hadoop.employee_events_offload"}, "target": "iceberg.hadoop.employee_events_offload",
"create_sql": (
"CREATE TABLE iceberg.hadoop.employee_events_offload AS SELECT "
"event_id, employee_id, department, role_name, region, event_type, "
"CAST(salary_change AS double) AS salary_change, "
"CAST(event_ts AS timestamp(6)) AS event_ts, notes, "
"employee_name, employee_email, employee_phone, national_id, home_address, "
"CAST(date_of_birth AS date) AS date_of_birth "
"FROM mysql_hr.hr.employee_events WHERE 1=0"
),
"insert_sql": (
"INSERT INTO iceberg.hadoop.employee_events_offload SELECT "
"event_id, employee_id, department, role_name, region, event_type, "
"CAST(salary_change AS double), CAST(event_ts AS timestamp(6)), notes, "
"employee_name, employee_email, employee_phone, national_id, home_address, "
"CAST(date_of_birth AS date) "
"FROM mysql_hr.hr.employee_events LIMIT {batch}"
)},
] ]
_custodian_state: dict[str, Any] = { _custodian_state: dict[str, Any] = {
"enabled": os.getenv("CUSTODIAN_OFFLOAD_ENABLED", "1") not in ("0", "false", "False", ""), "enabled": os.getenv("CUSTODIAN_OFFLOAD_ENABLED", "1") not in ("0", "false", "False", ""),
"interval": _CUST_INTERVAL, "interval": _CUST_INTERVAL,
@@ -385,12 +411,28 @@ async def _custodian_offload_once(idx: int | None = None) -> dict[str, Any]:
i = _custodian_state["idx"] if idx is None else idx i = _custodian_state["idx"] if idx is None else idx
tgt = _CUST_TARGETS[i % len(_CUST_TARGETS)] tgt = _CUST_TARGETS[i % len(_CUST_TARGETS)]
_custodian_state["idx"] = i + 1 _custodian_state["idx"] = i + 1
ddl = f"CREATE TABLE IF NOT EXISTS {tgt['target']} AS SELECT * FROM {tgt['src']} WHERE 1=0" await _trino_collect("CREATE SCHEMA IF NOT EXISTS iceberg.hadoop", 1)
dml = f"INSERT INTO {tgt['target']} SELECT * FROM {tgt['src']} LIMIT {_CUST_BATCH}" probe = await _trino_collect(f"SELECT 1 FROM {tgt['target']} WHERE 1=0", 1)
if not probe.get("ok"):
ddl = tgt["create_sql"]
await _term(DML_AGENT, f"$ trino --catalog iceberg # Hadoop offload: {tgt['label']}{tgt['target']}", await _term(DML_AGENT, f"$ trino --catalog iceberg # Hadoop offload: {tgt['label']}{tgt['target']}",
level="cmd", phase="offload") level="cmd", phase="offload")
await _term(DML_AGENT, f" {ddl};", level="cmd", phase="offload") await _term(DML_AGENT, f" {ddl};", level="cmd", phase="offload")
await _trino_collect(ddl, 1) created = await _trino_collect(ddl, 1)
if not created.get("ok"):
err = created.get("error")
await _term(DML_AGENT, f" ✗ offload failed: {str(err)[:140]}", level="err", phase="offload")
await _emit(f"[custodian-offload] {tgt['label']} failed: {str(err)[:120]}", "err")
_custodian_state["runs_total"] += 1
_custodian_state["last"] = {
"target": tgt["target"], "src": tgt["src"], "ok": False,
"rows": 0, "ts": datetime.now(timezone.utc).isoformat(), "error": err,
}
return _custodian_state["last"]
else:
await _term(DML_AGENT, f"$ trino --catalog iceberg # Hadoop offload: {tgt['label']}{tgt['target']}",
level="cmd", phase="offload")
dml = tgt["insert_sql"].format(batch=_CUST_BATCH)
await _term(DML_AGENT, f" {dml};", level="cmd", phase="offload") await _term(DML_AGENT, f" {dml};", level="cmd", phase="offload")
ins = await _trino_collect(dml, 1) ins = await _trino_collect(dml, 1)
ok = bool(ins.get("ok")) ok = bool(ins.get("ok"))
+229
View File
@@ -0,0 +1,229 @@
"""Authentik OIDC login and session cookie for Command Center."""
from __future__ import annotations
import logging
import os
from typing import Any
from urllib.parse import quote, urlencode
import httpx
from authlib.common.security import generate_token
from authlib.integrations.starlette_client import OAuth
from authlib.oauth2.rfc7636 import create_s256_code_challenge
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
log = logging.getLogger("atc-agents.auth")
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() in ("1", "true", "yes")
AUTHENTIK_ISSUER = os.getenv(
"AUTHENTIK_ISSUER",
"http://atc-mgt01.dell-atc.lan:9000/application/o/command-center/",
).rstrip("/") + "/"
AUTHENTIK_CLIENT_ID = os.getenv("AUTHENTIK_CLIENT_ID", "")
AUTHENTIK_CLIENT_SECRET = os.getenv("AUTHENTIK_CLIENT_SECRET", "")
AUTHENTIK_REDIRECT_URI = os.getenv(
"AUTHENTIK_REDIRECT_URI",
"http://10.0.21.33/auth/callback",
)
SESSION_SECRET = os.getenv("SESSION_SECRET", "dev-insecure-change-me")
SESSION_COOKIE = "cc_session"
oauth = OAuth()
_oauth_ready = False
PUBLIC_PREFIXES = (
"/auth/",
"/api/health",
"/api/auth/me",
)
def is_auth_enabled() -> bool:
return AUTH_ENABLED
def build_session_user(claims: dict[str, Any]) -> dict[str, Any]:
name = claims.get("name") or claims.get("preferred_username") or claims.get("email") or "user"
email = claims.get("email") or ""
username = claims.get("preferred_username") or claims.get("nickname") or email or str(claims.get("sub") or "user")
return {
"sub": claims.get("sub"),
"email": email,
"name": name,
"preferred_username": username,
}
def get_session_user(request: Request) -> dict[str, Any] | None:
if not AUTH_ENABLED:
return {
"sub": "dev:local",
"email": "",
"name": "Dev User",
"preferred_username": "dev",
"dev": True,
}
user = request.session.get("user")
return user if isinstance(user, dict) else None
def auth_me_payload(request: Request) -> dict[str, Any]:
user = get_session_user(request)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return {
"user": user.get("sub"),
"email": user.get("email"),
"name": user.get("name"),
"preferred_username": user.get("preferred_username"),
"auth_enabled": AUTH_ENABLED,
}
def _ensure_oauth() -> None:
global _oauth_ready
if _oauth_ready or not AUTH_ENABLED:
return
if not AUTHENTIK_CLIENT_ID or not AUTHENTIK_CLIENT_SECRET:
log.warning("AUTH_ENABLED but Authentik client credentials missing")
return
meta_url = AUTHENTIK_ISSUER + ".well-known/openid-configuration"
oauth.register(
name="authentik",
client_id=AUTHENTIK_CLIENT_ID,
client_secret=AUTHENTIK_CLIENT_SECRET,
server_metadata_url=meta_url,
client_kwargs={"scope": "openid profile email"},
)
_oauth_ready = True
def _is_public_path(path: str) -> bool:
return any(path == p or path.startswith(p) for p in PUBLIC_PREFIXES)
async def auth_guard_middleware(request: Request, call_next):
if not AUTH_ENABLED:
return await call_next(request)
path = request.url.path
if _is_public_path(path):
return await call_next(request)
if path.startswith("/api/"):
if get_session_user(request) is None:
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
return await call_next(request)
def init_auth_middleware(app: FastAPI) -> None:
"""Session must wrap auth guard so request.session is populated first."""
app.middleware("http")(auth_guard_middleware)
app.add_middleware(
SessionMiddleware,
secret_key=SESSION_SECRET,
session_cookie=SESSION_COOKIE,
max_age=86400 * 7,
same_site="lax",
https_only=False,
)
def setup_auth(app: FastAPI) -> None:
_ensure_oauth()
_register_routes(app)
def _token_endpoint() -> str:
return AUTHENTIK_ISSUER.rstrip("/").rsplit("/application/o/", 1)[0] + "/application/o/token/"
def _userinfo_endpoint() -> str:
return AUTHENTIK_ISSUER.rstrip("/").rsplit("/application/o/", 1)[0] + "/application/o/userinfo/"
async def _exchange_code_for_userinfo(code: str, code_verifier: str | None) -> dict[str, Any]:
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": AUTHENTIK_REDIRECT_URI,
"client_id": AUTHENTIK_CLIENT_ID,
"client_secret": AUTHENTIK_CLIENT_SECRET,
}
if code_verifier:
data["code_verifier"] = code_verifier
async with httpx.AsyncClient(timeout=20.0) as client:
tok = await client.post(_token_endpoint(), data=data)
if tok.status_code >= 400:
log.warning("Token exchange failed: %s %s", tok.status_code, tok.text[:300])
tok.raise_for_status()
payload = tok.json()
access = payload.get("access_token")
if not access:
raise RuntimeError("No access_token in token response")
ui = await client.get(
_userinfo_endpoint(),
headers={"Authorization": f"Bearer {access}"},
)
if ui.status_code >= 400:
log.warning("Userinfo failed: %s %s", ui.status_code, ui.text[:300])
ui.raise_for_status()
return ui.json()
def _register_routes(app: FastAPI) -> None:
@app.get("/api/auth/me")
async def api_auth_me(request: Request):
return auth_me_payload(request)
@app.get("/auth/login")
async def auth_login(request: Request):
if not AUTH_ENABLED:
return RedirectResponse("/", status_code=302)
_ensure_oauth()
if not _oauth_ready:
raise HTTPException(503, "Authentik not configured")
code_verifier = generate_token(48)
request.session["pkce_code_verifier"] = code_verifier
return await oauth.authentik.authorize_redirect(
request,
AUTHENTIK_REDIRECT_URI,
code_challenge=create_s256_code_challenge(code_verifier),
code_challenge_method="S256",
code_verifier=code_verifier,
)
@app.get("/auth/callback")
async def auth_callback(request: Request):
if not AUTH_ENABLED:
return RedirectResponse("/", status_code=302)
err = request.query_params.get("error")
if err:
desc = request.query_params.get("error_description") or err
log.warning("OIDC provider error: %s%s", err, desc)
return RedirectResponse(f"/?error={quote(desc)}", status_code=302)
code = request.query_params.get("code")
if not code:
return RedirectResponse("/?error=missing_code", status_code=302)
state = request.query_params.get("state")
if state:
request.session.pop(f"_state_authentik_{state}", None)
code_verifier = request.session.pop("pkce_code_verifier", None)
try:
userinfo = await _exchange_code_for_userinfo(code, code_verifier)
except Exception as e:
log.warning("OIDC callback failed: %s", e)
return RedirectResponse("/?error=login_failed", status_code=302)
request.session["user"] = build_session_user(userinfo or {})
return RedirectResponse("/", status_code=302)
@app.get("/auth/logout")
async def auth_logout(request: Request):
request.session.clear()
if not AUTH_ENABLED:
return RedirectResponse("/", status_code=302)
end_session = AUTHENTIK_ISSUER + "end-session/"
post_logout = "http://10.0.21.33/"
params = urlencode({"post_logout_redirect_uri": post_logout})
return RedirectResponse(f"{end_session}?{params}", status_code=302)
+7 -3
View File
@@ -24,6 +24,7 @@ Endpoints:
from __future__ import annotations from __future__ import annotations
import io import io
import os
import json import json
import random import random
import threading import threading
@@ -46,9 +47,9 @@ DATASETS = [
_lock = threading.Lock() _lock = threading.Lock()
_state: dict[str, Any] = { _state: dict[str, Any] = {
"enabled": True, "enabled": os.getenv("ETL_OFFLOAD_ENABLED", "1") not in ("0", "false", "False"),
"interval_s": 60.0, "interval_s": float(os.getenv("ETL_OFFLOAD_INTERVAL_SECONDS", "30")),
"chunk": 5000, "chunk": int(os.getenv("ETL_OFFLOAD_CHUNK", "20000")),
"running_cycle": False, "running_cycle": False,
"started_at": None, "started_at": None,
"last_cycle_ts": 0.0, "last_cycle_ts": 0.0,
@@ -440,6 +441,9 @@ def _loop() -> None:
try: try:
if _state["enabled"]: if _state["enabled"]:
run_cycle() run_cycle()
except Exception as exc:
try:
_feed(f"cycle error: {str(exc)[:160]}", level="err")
except Exception: except Exception:
pass pass
time.sleep(max(10.0, float(_state["interval_s"]))) time.sleep(max(10.0, float(_state["interval_s"])))
+304
View File
@@ -0,0 +1,304 @@
"""Runtime GPU / LLM endpoint selection with DB override above env defaults."""
from __future__ import annotations
import os
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
import httpx
from sqlalchemy import Column, DateTime, String, Text, select
from sqlalchemy.orm import DeclarativeBase
from db import SessionLocal, engine
GPU_UI_PORT = int(os.getenv("GPU_UI_PORT", "9000"))
LLM_PORT = int(os.getenv("LLM_PORT", "8001"))
LLM_PATH = os.getenv("LLM_PATH", "/v1")
ENV_GPU_URL = os.getenv("GPU_URL", "http://10.0.10.106:9000")
ENV_GPU_UI_URL = os.getenv("GPU_UI_URL", ENV_GPU_URL)
ENV_LLM_URL = os.getenv("LLM_URL", "http://10.0.10.106:8001/v1")
class _Base(DeclarativeBase):
pass
class SystemSetting(_Base):
__tablename__ = "system_settings"
key = Column(String(64), primary_key=True)
value = Column(Text, nullable=False, default="")
updated_at = Column(DateTime(timezone=True), nullable=True)
GPU_PRESETS: list[dict[str, Any]] = [
{
"id": "gpu-prod",
"label": "atc-gpu-prod (VM306)",
"vm": "atc-gpu-prod",
"vmid": 306,
"host": "10.0.10.106",
"gpu_ui_port": 9000,
"llm_port": 8001,
"description": "4× V100 — shared production GPU lab",
},
{
"id": "gpu-dev",
"label": "atc-gpu-dev (VM303, legacy)",
"vm": "atc-gpu-dev",
"vmid": 303,
"host": "10.0.20.106",
"gpu_ui_port": 9000,
"llm_port": 8001,
"description": "Legacy dev VM — GPU passthrough removed",
},
{
"id": "gpu-bart",
"label": "atc-gpu-bart (VM301)",
"vm": "atc-gpu-bart",
"vmid": 301,
"host": "10.0.11.66",
"gpu_ui_port": 9000,
"llm_port": 8001,
"description": "Bart GPU VM — 2× V100",
},
]
def _ensure_table() -> None:
SystemSetting.metadata.create_all(engine, tables=[SystemSetting.__table__])
def _get_setting(key: str) -> str | None:
_ensure_table()
with SessionLocal() as db:
row = db.get(SystemSetting, key)
return row.value if row else None
def _set_settings(values: dict[str, str]) -> None:
_ensure_table()
now = datetime.now(timezone.utc)
with SessionLocal() as db:
for key, value in values.items():
row = db.get(SystemSetting, key)
if row:
row.value = value
row.updated_at = now
else:
db.add(SystemSetting(key=key, value=value, updated_at=now))
db.commit()
def _clear_settings(keys: list[str]) -> None:
_ensure_table()
with SessionLocal() as db:
for key in keys:
row = db.get(SystemSetting, key)
if row:
db.delete(row)
db.commit()
def _build_urls(host: str, gpu_ui_port: int, llm_port: int) -> dict[str, str]:
host = host.strip().replace("http://", "").replace("https://", "").split("/")[0]
if ":" in host:
base_host = host.split(":")[0]
else:
base_host = host
gpu_url = f"http://{base_host}:{gpu_ui_port}"
llm_url = f"http://{base_host}:{llm_port}{LLM_PATH}"
return {
"host": base_host,
"gpu_url": gpu_url,
"gpu_ui_url": gpu_url,
"llm_url": llm_url,
}
def _env_defaults() -> dict[str, Any]:
parsed = urlparse(ENV_GPU_URL)
host = parsed.hostname or "10.0.10.106"
return {
"source": "env",
"preset_id": "env",
"label": "Environment default",
**_build_urls(host, parsed.port or GPU_UI_PORT, LLM_PORT),
"env_gpu_url": ENV_GPU_URL,
"env_llm_url": ENV_LLM_URL,
}
def get_gpu_urls() -> dict[str, str]:
"""Effective GPU/LLM URLs — DB override wins over env."""
cfg = get_gpu_config()
return {
"gpu_url": cfg["gpu_url"],
"gpu_ui_url": cfg["gpu_ui_url"],
"llm_url": cfg["llm_url"],
"host": cfg["host"],
}
def resolve_gpu_identity(gpu: dict[str, Any] | None = None) -> dict[str, Any]:
"""Canonical GPU host/VM/URLs for topology, registry links, and presentation."""
urls = get_gpu_urls()
cfg = get_gpu_config()
g = gpu or {}
host = str(g.get("ip") or g.get("host") or urls["host"]).strip()
preset_id = g.get("preset_id") or cfg.get("preset_id")
preset = next((p for p in GPU_PRESETS if p["id"] == preset_id), None)
if preset is None:
preset = next((p for p in GPU_PRESETS if p["host"] == host), None)
if preset is None:
preset = GPU_PRESETS[0]
ui_url = str(g.get("ui_url") or urls["gpu_ui_url"])
llm_url = str(g.get("vllm_url") or urls["llm_url"])
return {
"host": host,
"ip": host,
"vm": preset.get("vm") or "atc-gpu-prod",
"vmid": preset.get("vmid") or 306,
"ui_url": ui_url,
"llm_url": llm_url,
"preset_id": preset.get("id") or preset_id or "gpu-prod",
"label": preset.get("label") or cfg.get("label") or preset.get("vm"),
}
def get_gpu_config() -> dict[str, Any]:
override_host = _get_setting("gpu_host")
if not override_host:
return _env_defaults()
preset_id = _get_setting("gpu_preset_id") or "custom"
gpu_ui_port = int(_get_setting("gpu_ui_port") or GPU_UI_PORT)
llm_port = int(_get_setting("llm_port") or LLM_PORT)
urls = _build_urls(override_host, gpu_ui_port, llm_port)
preset = next((p for p in GPU_PRESETS if p["id"] == preset_id), None)
return {
"source": "override",
"preset_id": preset_id,
"label": preset["label"] if preset else f"Custom ({override_host})",
**urls,
"env_gpu_url": ENV_GPU_URL,
"env_llm_url": ENV_LLM_URL,
"updated_at": _get_setting("gpu_updated_at"),
}
def get_gpu_config_payload() -> dict[str, Any]:
cfg = get_gpu_config()
return {
"active": cfg,
"presets": GPU_PRESETS,
"defaults": _env_defaults(),
}
def save_gpu_config(
*,
preset_id: str | None = None,
host: str | None = None,
gpu_ui_port: int | None = None,
llm_port: int | None = None,
) -> dict[str, Any]:
if preset_id and preset_id != "custom":
preset = next((p for p in GPU_PRESETS if p["id"] == preset_id), None)
if not preset:
raise ValueError(f"Unknown preset: {preset_id}")
host = preset["host"]
gpu_ui_port = preset.get("gpu_ui_port", GPU_UI_PORT)
llm_port = preset.get("llm_port", LLM_PORT)
if not host:
raise ValueError("host is required for custom GPU target")
gpu_ui_port = gpu_ui_port or GPU_UI_PORT
llm_port = llm_port or LLM_PORT
now = datetime.now(timezone.utc).isoformat()
_set_settings(
{
"gpu_host": host.strip(),
"gpu_ui_port": str(gpu_ui_port),
"llm_port": str(llm_port),
"gpu_preset_id": preset_id or "custom",
"gpu_updated_at": now,
}
)
return get_gpu_config()
def reset_gpu_config() -> dict[str, Any]:
_clear_settings(["gpu_host", "gpu_ui_port", "llm_port", "gpu_preset_id", "gpu_updated_at"])
return _env_defaults()
async def test_gpu_target(
host: str | None = None,
gpu_ui_port: int | None = None,
llm_port: int | None = None,
preset_id: str | None = None,
) -> dict[str, Any]:
if preset_id and preset_id != "custom":
preset = next((p for p in GPU_PRESETS if p["id"] == preset_id), None)
if preset:
host = preset["host"]
gpu_ui_port = preset.get("gpu_ui_port", GPU_UI_PORT)
llm_port = preset.get("llm_port", LLM_PORT)
if not host:
cfg = get_gpu_config()
host = cfg["host"]
gpu_ui_port = gpu_ui_port or GPU_UI_PORT
llm_port = llm_port or LLM_PORT
urls = _build_urls(host, gpu_ui_port or GPU_UI_PORT, llm_port or LLM_PORT)
result: dict[str, Any] = {
"ok": False,
"host": urls["host"],
"gpu_url": urls["gpu_url"],
"llm_url": urls["llm_url"],
"metrics_ok": False,
"llm_ok": False,
"gpu_count": 0,
"inference_active": False,
"active_model": None,
"errors": [],
}
async with httpx.AsyncClient(timeout=8.0) as client:
try:
mr = await client.get(f"{urls['gpu_url']}/api/gpu/metrics")
if mr.status_code == 200:
result["metrics_ok"] = True
gpus = mr.json().get("current", {}).get("gpus", [])
result["gpu_count"] = len(gpus)
else:
result["errors"].append(f"metrics HTTP {mr.status_code}")
except Exception as exc:
result["errors"].append(f"metrics: {exc}")
try:
model_r = await client.get(f"{urls['gpu_url']}/api/active-model")
if model_r.status_code == 200:
md = model_r.json()
result["inference_active"] = bool(md.get("inference_active"))
result["active_model"] = md.get("name")
except Exception as exc:
result["errors"].append(f"active-model: {exc}")
try:
lr = await client.get(f"{urls['llm_url']}/models")
if lr.status_code == 200:
result["llm_ok"] = True
else:
result["errors"].append(f"llm HTTP {lr.status_code}")
except Exception as exc:
result["errors"].append(f"llm: {exc}")
result["ok"] = result["metrics_ok"] and (
result["gpu_count"] > 0 or result["inference_active"] or result["llm_ok"]
)
return result
+28 -6
View File
@@ -17,6 +17,14 @@ from database_inventory import collect_database_inventory
from node_registry import NODE_REGISTRY from node_registry import NODE_REGISTRY
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082") DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
DOCKHAND_API_TOKEN = os.getenv("DOCKHAND_API_TOKEN", "")
def _dockhand_headers() -> dict[str, str]:
if DOCKHAND_API_TOKEN:
return {"Authorization": f"Bearer {DOCKHAND_API_TOKEN}"}
return {}
HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870") HDFS_NN_URL = os.getenv("HDFS_NN_URL", "http://10.0.21.61:9870")
LAKEHOUSE_HOST = os.getenv("LAKEHOUSE_HOST", "10.0.21.50") LAKEHOUSE_HOST = os.getenv("LAKEHOUSE_HOST", "10.0.21.50")
AIRFLOW_URL = os.getenv("AIRFLOW_URL", "http://10.0.21.55:8080") AIRFLOW_URL = os.getenv("AIRFLOW_URL", "http://10.0.21.55:8080")
@@ -24,7 +32,12 @@ KAFKA_UI_URL = os.getenv("KAFKA_UI_URL", "http://10.0.21.36:9000")
KAFKA_CONNECT_URL = os.getenv("KAFKA_CONNECT_URL", f"http://{LAKEHOUSE_HOST}:8083") KAFKA_CONNECT_URL = os.getenv("KAFKA_CONNECT_URL", f"http://{LAKEHOUSE_HOST}:8083")
TRINO_URL = os.getenv("TRINO_URL", f"http://{LAKEHOUSE_HOST}:8089") TRINO_URL = os.getenv("TRINO_URL", f"http://{LAKEHOUSE_HOST}:8089")
SPARK_UI_URL = os.getenv("SPARK_UI_URL", f"http://{LAKEHOUSE_HOST}:8080") SPARK_UI_URL = os.getenv("SPARK_UI_URL", f"http://{LAKEHOUSE_HOST}:8080")
GPU_URL = os.getenv("GPU_URL", "http://10.0.20.106:9000") try:
from gpu_config import get_gpu_urls as _get_gpu_urls
except Exception:
_get_gpu_urls = None # type: ignore
GPU_URL = os.getenv("GPU_URL", "http://10.0.10.106:9000")
OBJECTSCALE_URL = os.getenv("OBJECTSCALE_URL", "http://10.0.20.111:9020") OBJECTSCALE_URL = os.getenv("OBJECTSCALE_URL", "http://10.0.20.111:9020")
YARN_URL = os.getenv("YARN_URL", "http://10.0.21.62:8088") YARN_URL = os.getenv("YARN_URL", "http://10.0.21.62:8088")
@@ -110,7 +123,7 @@ async def dockhand_containers(
await _log(log, "cmd", "fetch", f"$ GET {url}") await _log(log, "cmd", "fetch", f"$ GET {url}")
t0 = time.monotonic() t0 = time.monotonic()
try: try:
r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": env_id}, timeout=8.0) r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": env_id}, headers=_dockhand_headers(), timeout=8.0)
ms = int((time.monotonic() - t0) * 1000) ms = int((time.monotonic() - t0) * 1000)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
@@ -407,11 +420,20 @@ async def collect_objectscale(client: httpx.AsyncClient, log: TerminalLogFn | No
async def collect_gpu_metrics(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]: async def collect_gpu_metrics(client: httpx.AsyncClient, log: TerminalLogFn | None = None) -> dict[str, Any]:
await _log(log, "info", "fetch", "▸ GPU Lab metrics") gpu_url = GPU_URL
base = {"ok": False, "host": GPU_URL, "ui_url": GPU_URL} host = GPU_URL
if _get_gpu_urls is not None:
try: try:
metrics_url = f"{GPU_URL}/api/gpu/metrics" u = _get_gpu_urls()
model_url = f"{GPU_URL}/api/active-model" gpu_url = u["gpu_url"]
host = u["host"]
except Exception:
pass
await _log(log, "info", "fetch", f"▸ GPU Lab metrics @ {host}")
base = {"ok": False, "host": host, "ui_url": gpu_url}
try:
metrics_url = f"{gpu_url}/api/gpu/metrics"
model_url = f"{gpu_url}/api/active-model"
await _log(log, "cmd", "fetch", f"$ GET {metrics_url}") await _log(log, "cmd", "fetch", f"$ GET {metrics_url}")
await _log(log, "cmd", "fetch", f"$ GET {model_url}") await _log(log, "cmd", "fetch", f"$ GET {model_url}")
t0 = time.monotonic() t0 = time.monotonic()
+348 -41
View File
@@ -15,6 +15,7 @@ import httpx
import redis.asyncio as aioredis import redis.asyncio as aioredis
from fastapi import Body, FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect from fastapi import Body, FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import auth as cockpit_auth
from agent_terminal import ( from agent_terminal import (
get_all_terminals, get_all_terminals,
get_terminal_lines, get_terminal_lines,
@@ -77,6 +78,14 @@ from db import SessionLocal, db_health, init_database
from supervisor import mirror_terminal_line, mirror_to_supervisors from supervisor import mirror_terminal_line, mirror_to_supervisors
from workload import build_workload_payload from workload import build_workload_payload
from gpu_config import (
get_gpu_config,
get_gpu_config_payload,
get_gpu_urls,
reset_gpu_config,
save_gpu_config,
test_gpu_target,
)
_workload_cache: dict[str, Any] = {"ts": 0.0, "data": None} _workload_cache: dict[str, Any] = {"ts": 0.0, "data": None}
_presentation_cache: dict[str, Any] = {"ts": 0.0, "data": None} _presentation_cache: dict[str, Any] = {"ts": 0.0, "data": None}
@@ -89,12 +98,130 @@ from sqlalchemy.orm import DeclarativeBase
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082") DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
GPU_URL = os.getenv("GPU_URL", "http://10.0.20.106:9000") DOCKHAND_API_TOKEN = os.getenv("DOCKHAND_API_TOKEN", "")
def _dockhand_headers() -> dict[str, str]:
if DOCKHAND_API_TOKEN:
return {"Authorization": f"Bearer {DOCKHAND_API_TOKEN}"}
return {}
GPU_URL = os.getenv("GPU_URL", "http://10.0.10.106:9000")
GPU_UI_URL = os.getenv("GPU_UI_URL", GPU_URL) GPU_UI_URL = os.getenv("GPU_UI_URL", GPU_URL)
LLM_URL = os.getenv("LLM_URL", "http://10.0.20.106:8001/v1") LLM_URL = os.getenv("LLM_URL", "http://10.0.10.106:8001/v1")
LLM_MODEL = os.getenv("LLM_MODEL", "gpt-4o") LLM_MODEL = os.getenv("LLM_MODEL", "gpt-4o")
LLM_API_KEY = os.getenv("LLM_API_KEY", "sk-local") LLM_API_KEY = os.getenv("LLM_API_KEY", "sk-local")
LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "120")) LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "120"))
# Llama-3-70B GPTQ on V100 is capped at 4096; keep a hard safety budget.
LLM_MAX_MODEL_LEN = int(os.getenv("LLM_MAX_MODEL_LEN", "4096"))
# Conservative estimate: Llama tokenizers often use ~2.22.8 chars/token on English+lab text.
LLM_CHARS_PER_TOKEN = float(os.getenv("LLM_CHARS_PER_TOKEN", "2.4"))
LLM_CONTEXT_MARGIN = int(os.getenv("LLM_CONTEXT_MARGIN", "160"))
LLM_MAX_OUTPUT = int(os.getenv("LLM_MAX_OUTPUT", "256"))
LLM_MAX_CONTEXT_CHARS = int(os.getenv("LLM_MAX_CONTEXT_CHARS", "5500"))
def _estimate_tokens(text: str) -> int:
# Slightly inflate so we never underestimate vs vLLM's tokenizer.
return max(1, int(len(text) / LLM_CHARS_PER_TOKEN) + 32)
def _compact_lab_context(context: str) -> str:
"""Keep primary section + short per-domain summaries; drop verbose inventory lines."""
lines = context.splitlines()
out: list[str] = []
in_full = False
detail = 0
max_detail = 4
for line in lines:
if line.startswith("=== FULL LAB"):
in_full = True
out.append(line)
continue
if line.startswith("=== PRIMARY"):
in_full = False
detail = 0
out.append(line)
continue
if line.startswith("--- "):
detail = 0
out.append(line)
continue
# Drop long platform-capabilities essays if present — keep a one-liner marker
if line.startswith("=== PLATFORM CAPABILITIES"):
out.append(line)
out.append(" (see Command Center UI for full feature list)")
continue
if out and out[-1].startswith(" (see Command Center"):
if line.startswith("===") or line.startswith("--- ") or line.startswith("=== AGENTS") or line.startswith("=== DATA MASKING") or line.startswith("=== PII MASKING"):
pass
else:
continue
# Always keep masking / PII evidence sections in full (demo-critical)
if line.startswith("=== DATA MASKING") or line.startswith("=== PII MASKING"):
# flush remaining lines of this section without detail limits by marking
out.append(line)
continue
# Limit bullet detail in general lab dump, but keep masking evidence intact
keep_full = any(s in "\n".join(out[-5:]) for s in ("=== DATA MASKING", "=== PII MASKING"))
if (line.startswith(" - ") or line.startswith(" - ")) and not keep_full:
detail += 1
if detail > max_detail:
if detail == max_detail + 1:
out.append("")
continue
out.append(line)
return "\n".join(out)
def _truncate_for_llm(context: str, max_chars: int) -> str:
context = _compact_lab_context(context)
if len(context) <= max_chars:
return context
# Prefer keeping PRIMARY section; cut FULL LAB first
primary_end = context.find("=== FULL LAB")
if primary_end > 200:
head = context[:primary_end].rstrip()
tail_budget = max(400, max_chars - len(head) - 80)
tail = context[primary_end: primary_end + tail_budget]
trimmed = head + "\n" + tail
else:
trimmed = context[:max_chars]
if len(trimmed) > max_chars:
trimmed = trimmed[: max_chars - 60].rsplit("\n", 1)[0]
if len(context) > len(trimmed):
trimmed += f"\n\n[… truncated for {LLM_MAX_MODEL_LEN}-token model window …]"
return trimmed
def _fit_llm_payload(system_rules: str, context: str, user_message: str) -> tuple[str, int]:
"""Fit prompt+completion into the served model length with a safety margin."""
user_tok = _estimate_tokens(user_message)
rules_tok = _estimate_tokens(system_rules)
budget = LLM_MAX_MODEL_LEN - LLM_CONTEXT_MARGIN
max_out = min(LLM_MAX_OUTPUT, 256)
# Absolute char cap first (independent of estimate errors)
context = _truncate_for_llm(context, LLM_MAX_CONTEXT_CHARS)
for _ in range(6):
ctx_budget_tok = budget - user_tok - rules_tok - max_out
if ctx_budget_tok < 200:
max_out = max(64, max_out // 2)
continue
ctx_max_chars = max(600, int(ctx_budget_tok * LLM_CHARS_PER_TOKEN * 0.85))
fitted = _truncate_for_llm(context, ctx_max_chars)
total = rules_tok + _estimate_tokens(fitted) + user_tok + max_out
if total <= budget:
return fitted, max_out
# Still too big — shrink context harder, then output
context = fitted
LLM_MAX = max(800, int(len(fitted) * 0.7))
context = _truncate_for_llm(context, LLM_MAX)
max_out = max(64, max_out - 32)
return _truncate_for_llm(context, 800), 64
AGENTS = [ AGENTS = [
{ {
@@ -247,7 +374,11 @@ ZONES = [
] ]
INTENT_KEYWORDS: dict[str, list[str]] = { INTENT_KEYWORDS: dict[str, list[str]] = {
"data-custodian": ["database", "postgres", "postgresql", "mysql", "mongo", "mongodb", "cassandra", "neo4j", "sql", "db "], "data-custodian": [
"database", "postgres", "postgresql", "mysql", "mongo", "mongodb", "cassandra", "neo4j", "sql", "db ",
"pii", "mask", "masked", "masking", "email", "e-mail", "phone", "iban", "address", "customer",
"employee", "gdpr", "privacy", "sensitive", "personal", "name", "ssn", "national_id",
],
"lakehouse-ops": ["trino", "spark", "lakehouse", "iceberg", "query", "table"], "lakehouse-ops": ["trino", "spark", "lakehouse", "iceberg", "query", "table"],
"hadoop-ranger": [ "hadoop-ranger": [
"hadoop", "hdfs", "yarn", "datanode", "namenode", "replicatie", "replication", "hadoop", "hdfs", "yarn", "datanode", "namenode", "replicatie", "replication",
@@ -327,10 +458,24 @@ class ApprovalDecision(BaseModel):
def route_agent(message: str) -> str: def route_agent(message: str) -> str:
lower = message.lower() lower = message.lower()
# PII / privacy questions always go to Data Custodian (masking demo path)
pii_words = (
"pii", "mask", "masked", "masking", "email", "e-mail", "mail adres", "mail address",
"phone", "telefoon", "iban", "address", "adres", "customer name", "employee",
"gdpr", "privacy", "sensitive", "personal", "national_id", "ssn", "gevoelig",
)
if any(w in lower for w in pii_words):
return "data-custodian"
# Storage/data questions default to Hadoop unless clearly about databases # Storage/data questions default to Hadoop unless clearly about databases
if any(w in lower for w in ("data", "opslag", "gb", "replicatie", "replication", "hdfs", "hadoop")): if any(w in lower for w in ("opslag", "gb", "replicatie", "replication", "hdfs", "hadoop", "datanode", "namenode")):
if not any(w in lower for w in ("postgres", "mysql", "mongo", "database", "sql", "neo4j", "cassandra")): if not any(w in lower for w in ("postgres", "mysql", "mongo", "database", "sql", "neo4j", "cassandra", "pii", "email")):
return "hadoop-ranger" return "hadoop-ranger"
if any(w in lower for w in ("gpu", "vllm", "llm", "nvidia", "inference", "vram", "model")):
return "infra-sentinel"
if any(w in lower for w in ("kafka", "airflow", "debezium", "connector", "etl", "pipeline", "dag")):
return "etl-guardian"
if any(w in lower for w in ("trino", "spark", "iceberg", "lakehouse")):
return "lakehouse-ops"
scores = {aid: sum(1 for kw in kws if kw in lower) for aid, kws in INTENT_KEYWORDS.items()} scores = {aid: sum(1 for kw in kws if kw in lower) for aid, kws in INTENT_KEYWORDS.items()}
best = max(scores, key=scores.get) best = max(scores, key=scores.get)
if scores[best] == 0: if scores[best] == 0:
@@ -338,13 +483,34 @@ def route_agent(message: str) -> str:
return best return best
def _is_pii_question(message: str) -> bool:
lower = message.lower()
return any(w in lower for w in (
"pii", "mask", "masked", "masking", "unmask", "visible", "email", "e-mail", "phone",
"iban", "address", "adres", "customer", "employee", "privacy", "gdpr", "sensitive",
"personal", "gevoelig", "name", "telefoon", "mail", "data flow", "national_id",
"ssn", "bsn", "geboorte", "birth",
))
async def _pii_evidence_block(message: str, log: Any | None = None) -> str:
"""Live policy + samples synced with Data Flow masking toggles."""
try:
from pii_catalog import build_policy_evidence
return build_policy_evidence()
except Exception as exc:
return f"=== PII MASKING EVIDENCE ===\n(unavailable: {exc})"
async def gather_agent_context( async def gather_agent_context(
agent_id: str, agent_id: str,
status: dict[str, Any], status: dict[str, Any],
log: Any | None = None, log: Any | None = None,
message: str | None = None,
) -> str: ) -> str:
"""Full lab snapshot for vLLM — all domains, agent's primary domain highlighted.""" """Full lab snapshot for vLLM — all domains, agent's primary domain highlighted."""
snapshot = await collect_full_lab_context(gpu_data=status.get("gpu"), log=log) snapshot = await collect_full_lab_context(gpu_data=status.get("gpu"), log=log, include_inventory=False)
snapshot["domains_summary"] = status.get("domains", {}) snapshot["domains_summary"] = status.get("domains", {})
ctx = format_context_for_agent(agent_id, snapshot) ctx = format_context_for_agent(agent_id, snapshot)
agent_lines = ["", "=== AGENTS & SUPERVISORS ==="] agent_lines = ["", "=== AGENTS & SUPERVISORS ==="]
@@ -352,11 +518,25 @@ async def gather_agent_context(
sup = " [supervisor]" if a.get("supervisor") else "" sup = " [supervisor]" if a.get("supervisor") else ""
agent_lines.append(f" - {a['name']} ({a['id']}){sup}: {a['role']}") agent_lines.append(f" - {a['name']} ({a['id']}){sup}: {a['role']}")
ctx = ctx + "\n".join(agent_lines) ctx = ctx + "\n".join(agent_lines)
try:
from platform_context import build_masking_section
# Fresh policy so chat mirrors Data Flow toggles (skip huge business catalog).
ctx = ctx + "\n\n" + build_masking_section(fresh=True)
except Exception:
try: try:
from platform_context import build_llm_addendum from platform_context import build_llm_addendum
ctx = ctx + "\n\n" + build_llm_addendum() ctx = ctx + "\n\n" + build_llm_addendum()
except Exception: except Exception:
pass pass
if message and _is_pii_question(message):
try:
evidence = await _pii_evidence_block(message, log=log)
ctx = ctx + "\n\n" + evidence
if log:
await log("ok", "fetch", "▸ PII masking evidence attached (synced with Data Flow)")
except Exception as exc:
if log:
await log("warn", "fetch", f"▸ PII evidence skipped: {exc}")
if log: if log:
await log("info", "fetch", f"▸ Context assembled: {len(ctx)} chars for LLM") await log("info", "fetch", f"▸ Context assembled: {len(ctx)} chars for LLM")
return ctx return ctx
@@ -369,34 +549,44 @@ async def ask_llm(
log: Any | None = None, log: Any | None = None,
) -> str | None: ) -> str | None:
agent = next(a for a in AGENTS if a["id"] == agent_id) agent = next(a for a in AGENTS if a["id"] == agent_id)
system = f"""You are {agent['name']}, an autonomous ops agent in the Dell ATC data lab. # Deterministic PII path: always mirror Data Flow masked vs visible toggles.
Specialization: {agent['role']}. if _is_pii_question(message):
Motto: {agent.get('motto', '')}. try:
from pii_catalog import format_pii_chat_answer
You respond on behalf of your domain but have visibility into the FULL lab stack: Docker, databases, lakehouse (Trino/Spark/Kafka Connect), ETL (Airflow/Kafka), Hadoop HDFS, and GPU/vLLM. answer = format_pii_chat_answer(message)
Rules:
- Always respond in English.
- You have full visibility into the entire cluster: all VMs, zones, connectors, GPU, Hadoop, ObjectScale and Command Center.
- Use ONLY the live data below — do not invent hosts, ports, numbers or connector names.
- Use exact container/connector names from the data (e.g. mysql-hr-connector, not "Debezium").
- If something is DOWN or 0 GB, say so honestly.
- Respect the data masking policy: NEVER reveal, guess or reconstruct raw values of MASKED columns (they arrive as the token 🔒 MASKED). You MUST still answer helpfully — confirm the column is masked for privacy/governance, explain why, and you may use non-sensitive aggregates/counts over it.
- You are fully aware of all latest platform changes via the section PLATFORM CAPABILITIES & RECENT CHANGES below; use it to answer questions about recent changes, the Spark Workbench, the Hadoop pipeline, the Data Flow pulse switch and the autonomous agents (DML, ETL, Custodian Hadoop offload).
- Be concise and helpful (max ~10 sentences); bullet lists are fine when they aid clarity.
--- LIVE LAB DATA (primary domain first, then full stack) ---
{context}
"""
if log: if log:
await log("info", "llm", f"Querying vLLM model={LLM_MODEL}") await log("ok", "pii", "Returning Data Flowsynced masking answer (masked + visible)")
await log("cmd", "llm", f"$ POST {LLM_URL.rstrip('/')}/chat/completions") return answer
except Exception as exc:
if log:
await log("warn", "pii", f"▸ PII answer builder failed: {exc}")
rules = f"""You are {agent['name']} ({agent['role']}) in the Dell ATC data lab.
Answer in English, briefly (max ~8 sentences). Use ONLY the live data below — never invent hosts/ports/numbers.
If data is missing or DOWN, say so.
PII / masking rules (critical — synced with Data Flow tab):
- MASKED columns: NEVER reveal raw values; quote the token 🔒 MASKED when present.
- VISIBLE columns (operator opted out in Data Flow): you MAY report the real sample values and say they are visible by policy.
- Never invent emails, phones, names, IBANs, or addresses that are not in the live samples.
- If asked what is masked vs visible, list columns from the DATA MASKING POLICY / PII EVIDENCE sections.
--- LIVE LAB DATA ---"""
fitted_ctx, max_tokens = _fit_llm_payload(rules, context, message)
system = rules + "\n" + fitted_ctx
urls = get_gpu_urls()
llm_url = urls["llm_url"]
if log:
est = _estimate_tokens(system) + _estimate_tokens(message)
await log("info", "llm", f"▸ Querying vLLM model={LLM_MODEL} @ {urls['host']} (~{est}+{max_tokens} tok)")
if len(context) > len(fitted_ctx):
await log("warn", "llm", f" context trimmed {len(context)}{len(fitted_ctx)} chars")
await log("cmd", "llm", f"$ POST {llm_url.rstrip('/')}/chat/completions")
await log("info", "llm", f" user: {message[:160]}{'' if len(message) > 160 else ''}") await log("info", "llm", f" user: {message[:160]}{'' if len(message) > 160 else ''}")
try: try:
async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client: async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client:
t0 = time.monotonic() t0 = time.monotonic()
r = await client.post( r = await client.post(
f"{LLM_URL.rstrip('/')}/chat/completions", f"{llm_url.rstrip('/')}/chat/completions",
headers={ headers={
"Authorization": f"Bearer {LLM_API_KEY}", "Authorization": f"Bearer {LLM_API_KEY}",
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -407,7 +597,7 @@ Rules:
{"role": "system", "content": system}, {"role": "system", "content": system},
{"role": "user", "content": message}, {"role": "user", "content": message},
], ],
"max_tokens": 800, "max_tokens": max_tokens,
"temperature": 0.25, "temperature": 0.25,
}, },
) )
@@ -422,15 +612,78 @@ Rules:
return content return content
if log: if log:
await log("warn", "llm", f"← Empty or invalid LLM output ({ms}ms)") await log("warn", "llm", f"← Empty or invalid LLM output ({ms}ms)")
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:200] if exc.response is not None else str(exc)
if log:
await log("err", "llm", f"✗ vLLM HTTP {exc.response.status_code}: {detail}")
# One hard retry with a minimal context if we blew the window.
if exc.response is not None and exc.response.status_code == 400 and "maximum context length" in detail:
tiny = _truncate_for_llm(context, 1200)
system2 = rules + "\n" + tiny
max2 = 128
if log:
await log("warn", "llm", f" retry with tiny context ({len(tiny)} chars, max_tokens={max2})")
try:
async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client:
r2 = await client.post(
f"{llm_url.rstrip('/')}/chat/completions",
headers={
"Authorization": f"Bearer {LLM_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": LLM_MODEL,
"messages": [
{"role": "system", "content": system2},
{"role": "user", "content": message},
],
"max_tokens": max2,
"temperature": 0.25,
},
)
r2.raise_for_status()
content2 = r2.json()["choices"][0]["message"]["content"].strip()
if content2:
if log:
await log("ok", "llm", f"← vLLM retry OK {len(content2)} chars")
return content2
except Exception as exc2:
if log:
await log("err", "llm", f"✗ vLLM retry failed: {exc2}")
except Exception as exc: except Exception as exc:
if log: if log:
await log("err", "llm", f"✗ vLLM error: {exc}") await log("err", "llm", f"✗ vLLM error: {exc}")
return None return None
def fallback_answer(agent_id: str, context: str) -> str: def fallback_answer(agent_id: str, context: str, user_message: str = "") -> str:
agent_name = next(a["name"] for a in AGENTS if a["id"] == agent_id) agent_name = next(a["name"] for a in AGENTS if a["id"] == agent_id)
return f"**{agent_name}** (offline LLM — ruwe data):\n\n{context}" if user_message and _is_pii_question(user_message):
try:
from pii_catalog import format_pii_chat_answer
return f"**{agent_name}**\n\n" + format_pii_chat_answer(user_message)
except Exception:
marker = "=== PII MASKING EVIDENCE"
if marker in context:
return (
f"**{agent_name}** — masking policy (synced with Data Flow):\n\n"
+ context[context.index(marker):].strip()
)
preview_lines: list[str] = []
for line in context.splitlines():
if line.startswith(("=== PRIMARY", "Health summary", "ATC Lab", "--- ")):
preview_lines.append(line)
if len(preview_lines) >= 14:
break
hint = "\n".join(preview_lines) if preview_lines else "Lab snapshot collected; LLM unavailable."
q = f"\n\nYour question: _{user_message[:200]}_" if user_message else ""
return (
f"**{agent_name}** — I could not get a reply from the GPU LLM "
f"(context window or vLLM error).{q}\n\n"
"Try a short, specific question "
"(e.g. *How many GPUs are online?* or *Is Kafka healthy?*).\n\n"
f"Quick snapshot:\n{hint}"
)
async def publish_event(event: dict[str, Any]) -> None: async def publish_event(event: dict[str, Any]) -> None:
@@ -480,7 +733,11 @@ def add_feed(agent_id: str, message: str, level: str = "info") -> dict:
async def dockhand_env_containers(env_id: int) -> list[dict]: async def dockhand_env_containers(env_id: int) -> list[dict]:
try: try:
async with httpx.AsyncClient(timeout=8.0) as client: async with httpx.AsyncClient(timeout=8.0) as client:
r = await client.get(f"{DOCKHAND_URL}/api/containers", params={"env": env_id}) r = await client.get(
f"{DOCKHAND_URL}/api/containers",
params={"env": env_id},
headers=_dockhand_headers(),
)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except Exception: except Exception:
@@ -497,15 +754,26 @@ async def probe_url(url: str) -> bool:
async def collect_gpu() -> dict[str, Any]: async def collect_gpu() -> dict[str, Any]:
host = GPU_URL.replace("http://", "").replace("https://", "").split("/")[0] urls = get_gpu_urls()
base = {"ok": False, "host": host, "ui_url": GPU_UI_URL} cfg = get_gpu_config()
gpu_url = urls["gpu_url"]
host = urls["host"]
base = {
"ok": False,
"host": host,
"ip": host,
"ui_url": urls["gpu_ui_url"],
"config_source": cfg.get("source", "env"),
"preset_id": cfg.get("preset_id"),
"config_label": cfg.get("label"),
}
try: try:
async with httpx.AsyncClient(timeout=6.0) as client: async with httpx.AsyncClient(timeout=6.0) as client:
metrics_r, model_r, integration_r = await asyncio.gather( metrics_r, model_r, integration_r = await asyncio.gather(
client.get(f"{GPU_URL}/api/gpu/metrics"), client.get(f"{gpu_url}/api/gpu/metrics"),
client.get(f"{GPU_URL}/api/active-model"), client.get(f"{gpu_url}/api/active-model"),
client.get(f"{GPU_URL}/api/integration"), client.get(f"{gpu_url}/api/integration"),
return_exceptions=True, return_exceptions=True,
) )
@@ -667,12 +935,12 @@ async def run_agent_task(agent_id: str, message: str, prompt_id: str) -> str:
await log("info", "fetch", f"[{prompt_id}] Collecting live lab metrics…") await log("info", "fetch", f"[{prompt_id}] Collecting live lab metrics…")
status = await collect_status() status = await collect_status()
context = await gather_agent_context(agent_id, status, log=log) context = await gather_agent_context(agent_id, status, log=log, message=message)
answer = await ask_llm(agent_id, message, context, log=log) answer = await ask_llm(agent_id, message, context, log=log)
if not answer: if not answer:
await log("warn", "llm", "LLM fallback — returning raw context") await log("warn", "llm", "LLM fallback — returning raw context")
answer = fallback_answer(agent_id, context) answer = fallback_answer(agent_id, context, message)
if not approval_created: if not approval_created:
proposed = detect_agent_proposed_action(answer, message) proposed = detect_agent_proposed_action(answer, message)
@@ -784,6 +1052,10 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Authentik OIDC session + API guard
cockpit_auth.init_auth_middleware(app)
cockpit_auth.setup_auth(app)
@app.get("/api/health") @app.get("/api/health")
async def health(): async def health():
@@ -983,6 +1255,41 @@ async def get_status():
async def get_gpu(): async def get_gpu():
return await collect_gpu() return await collect_gpu()
@app.get("/api/gpu/config")
async def get_gpu_config_endpoint():
return get_gpu_config_payload()
@app.post("/api/gpu/config")
async def post_gpu_config(body: dict[str, Any]):
try:
saved = save_gpu_config(
preset_id=body.get("preset_id"),
host=body.get("host"),
gpu_ui_port=body.get("gpu_ui_port"),
llm_port=body.get("llm_port"),
)
return {"ok": True, "active": saved, "presets": get_gpu_config_payload()["presets"]}
except ValueError as exc:
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400)
@app.post("/api/gpu/config/test")
async def post_gpu_config_test(body: dict[str, Any]):
return await test_gpu_target(
preset_id=body.get("preset_id"),
host=body.get("host"),
gpu_ui_port=body.get("gpu_ui_port"),
llm_port=body.get("llm_port"),
)
@app.delete("/api/gpu/config")
async def delete_gpu_config():
active = reset_gpu_config()
return {"ok": True, "active": active}
def agent_stats() -> dict[str, dict[str, Any]]: def agent_stats() -> dict[str, dict[str, Any]]:
stats: dict[str, dict[str, Any]] = {a["id"]: {"tasks": 0, "last_active": None, "alerts": 0} for a in AGENTS} stats: dict[str, dict[str, Any]] = {a["id"]: {"tasks": 0, "last_active": None, "alerts": 0} for a in AGENTS}
@@ -1053,11 +1360,11 @@ async def run_node_ask_task(node_id: str, message: str) -> None:
await terminal_log(node_id, f"→ Routing to agent {agent_id}", level="info", phase="ask") await terminal_log(node_id, f"→ Routing to agent {agent_id}", level="info", phase="ask")
log = make_logger(node_id) log = make_logger(node_id)
status = await collect_status() status = await collect_status()
context = await gather_agent_context(agent_id, status, log=log) context = await gather_agent_context(agent_id, status, log=log, message=message)
node_ctx = f"\n\n=== FOCUSED NODE: {meta['label']} ({meta['ip']}) ===\n{meta.get('description', '')}\n" node_ctx = f"\n\n=== FOCUSED NODE: {meta['label']} ({meta['ip']}) ===\n{meta.get('description', '')}\n"
answer = await ask_llm(agent_id, message, context + node_ctx, log=log) answer = await ask_llm(agent_id, message, context + node_ctx, log=log)
if not answer: if not answer:
answer = fallback_answer(agent_id, context) answer = fallback_answer(agent_id, context, message)
await terminal_log(node_id, f"{answer}", level="llm", phase="answer") await terminal_log(node_id, f"{answer}", level="llm", phase="answer")
await publish_event({"type": "node_ask_result", "node_id": node_id, "agent_id": agent_id, "answer": answer}) await publish_event({"type": "node_ask_result", "node_id": node_id, "agent_id": agent_id, "answer": answer})
+19
View File
@@ -153,6 +153,25 @@ def build_node_detail(node_id: str, snap: dict[str, Any], workload_node: dict |
wn = workload_node or {} wn = workload_node or {}
agent_id = NODE_AGENT.get(node_id, "infra-sentinel") agent_id = NODE_AGENT.get(node_id, "infra-sentinel")
if node_id == "gpu":
try:
from gpu_config import resolve_gpu_identity
gid = resolve_gpu_identity(snap.get("gpu") if isinstance(snap, dict) else None)
meta["ip"] = gid["ip"]
meta["vm"] = gid["vm"]
meta["vmid"] = gid["vmid"]
meta["ssh"] = f"ssh root@{gid['ip']}"
meta["links"] = [
{"label": "GPU Lab UI", "url": gid["ui_url"]},
{"label": "vLLM API", "url": gid["llm_url"]},
]
meta["endpoints"] = [
{"name": "gpu-lab", "host": gid["ip"], "port": "9000", "proto": "http"},
{"name": "vllm", "host": gid["ip"], "port": "8001", "proto": "http"},
]
except Exception:
pass
detail: dict[str, Any] = { detail: dict[str, Any] = {
"id": node_id, "id": node_id,
"agent_id": agent_id, "agent_id": agent_id,
+9 -9
View File
@@ -183,21 +183,21 @@ NODE_REGISTRY: dict[str, dict[str, Any]] = {
}, },
"gpu": { "gpu": {
"label": "GPU Lab", "label": "GPU Lab",
"vm": "atc-gpu-dev", "vm": "atc-gpu-prod",
"vmid": 303, "vmid": 306,
"pve": "atc-gpu", "pve": "atc-gpu",
"ip": "10.0.20.106", "ip": "10.0.10.106",
"ssh": "ssh root@10.0.20.106", "ssh": "ssh root@10.0.10.106",
"role": "inference", "role": "inference",
"color": "#3fb950", "color": "#3fb950",
"description": "4× V100 GPU lab. vLLM serves the active model (Llama 3 70B GPTQ) — powers agent reasoning in this Command Center.", "description": "4× V100 GPU lab (VM306). vLLM serves the active model (Llama 3 70B GPTQ) — powers agent reasoning in this Command Center.",
"links": [ "links": [
{"label": "GPU Lab UI", "url": "http://10.0.20.106:9000"}, {"label": "GPU Lab UI", "url": "http://10.0.10.106:9000"},
{"label": "vLLM API", "url": "http://10.0.20.106:8001/v1"}, {"label": "vLLM API", "url": "http://10.0.10.106:8001/v1"},
], ],
"endpoints": [ "endpoints": [
{"name": "gpu-lab", "host": "10.0.20.106", "port": "9000", "proto": "http"}, {"name": "gpu-lab", "host": "10.0.10.106", "port": "9000", "proto": "http"},
{"name": "vllm", "host": "10.0.20.106", "port": "8001", "proto": "http"}, {"name": "vllm", "host": "10.0.10.106", "port": "8001", "proto": "http"},
], ],
"commands": ["gpu metrics", "model status", "vram usage"], "commands": ["gpu metrics", "model status", "vram usage"],
}, },
+251 -4
View File
@@ -34,6 +34,7 @@ POLICY_PATH = Path(os.getenv("MASKING_POLICY_PATH", "/data/masking_policy.json")
DEFAULT_MASKED = True DEFAULT_MASKED = True
MASK_TOKEN = "🔒 MASKED (masking policy ON)" MASK_TOKEN = "🔒 MASKED (masking policy ON)"
_policy_cache: dict[str, bool] | None = None _policy_cache: dict[str, bool] | None = None
_policy_mtime: float = -1.0
# Datasets we surface in the PII overlay. node_id matches dataflow.py node ids. # Datasets we surface in the PII overlay. node_id matches dataflow.py node ids.
# om_fqn = OpenMetadata table FQN (service.database.schema.table) for tag lookup. # om_fqn = OpenMetadata table FQN (service.database.schema.table) for tag lookup.
@@ -77,21 +78,42 @@ DATASET_BY_KEY = {ds["key"]: ds for ds in DATASETS}
def _load_policy() -> dict[str, bool]: def _load_policy() -> dict[str, bool]:
global _policy_cache """Load policy from disk; re-read when the file mtime changes (Data Flow toggles)."""
if _policy_cache is None: global _policy_cache, _policy_mtime
try:
mtime = POLICY_PATH.stat().st_mtime
except Exception:
mtime = 0.0
if _policy_cache is None or mtime != _policy_mtime:
try: try:
_policy_cache = {k: bool(v) for k, v in json.loads(POLICY_PATH.read_text()).items()} _policy_cache = {k: bool(v) for k, v in json.loads(POLICY_PATH.read_text()).items()}
except Exception: except Exception:
if _policy_cache is None:
_policy_cache = {} _policy_cache = {}
_policy_mtime = mtime
return _policy_cache return _policy_cache
def _save_policy(p: dict[str, bool]) -> None: def _save_policy(p: dict[str, bool]) -> None:
global _policy_cache global _policy_cache, _policy_mtime
_policy_cache = p _policy_cache = p
try: try:
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True) POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
POLICY_PATH.write_text(json.dumps(p, indent=2)) POLICY_PATH.write_text(json.dumps(p, indent=2))
_policy_mtime = POLICY_PATH.stat().st_mtime
except Exception:
_policy_mtime = time.time()
def invalidate_pii_caches() -> None:
"""Force catalog rebuild so chat/Data Flow see the same mask flags immediately."""
_cache["data"] = None
_cache["ts"] = 0.0
try:
import trino_federated as tf
if hasattr(tf, "_dict_cache"):
tf._dict_cache["data"] = None
tf._dict_cache["at"] = 0.0
except Exception: except Exception:
pass pass
@@ -325,10 +347,235 @@ async def set_policy(body: MaskPolicyRequest) -> JSONResponse:
for col in cols: for col in cols:
p[f"{key}.{col}"] = bool(body.masked) p[f"{key}.{col}"] = bool(body.masked)
_save_policy(p) _save_policy(p)
_cache["data"] = None # force rebuild so masked flags reflect the new policy invalidate_pii_caches() # chat + Data Flow must reflect the toggle immediately
return JSONResponse({"ok": True, "key": key, "columns": cols, "masked": bool(body.masked)}) return JSONResponse({"ok": True, "key": key, "columns": cols, "masked": bool(body.masked)})
def policy_column_lists(*, use_cache: bool = False) -> dict[str, Any]:
"""Live masked vs visible PII columns — same source as the Data Flow tab."""
catalog = get_pii(use_cache=use_cache)
masked: list[dict[str, str]] = []
visible: list[dict[str, str]] = []
for d in catalog.get("datasets", []):
key = d.get("key") or ""
label = d.get("label") or key
locked = bool(d.get("masked_layer") or d.get("policy_locked"))
for c in d.get("pii_columns", []):
entry = {
"key": key,
"label": label,
"column": c.get("name") or "",
"category": c.get("category") or "PII",
"locked": locked,
}
(masked if c.get("masked") else visible).append(entry)
summ = catalog.get("summary") or {}
return {
"catalog": catalog,
"masked": masked,
"visible": visible,
"summary": summ,
"mask_token": MASK_TOKEN,
}
def _interest_categories(message: str) -> list[str]:
lower = (message or "").lower()
cat_map = [
(("email", "e-mail", "mail"), "EMAIL"),
(("phone", "telefoon", "mobile"), "PHONE"),
(("name", "naam"), "NAME"),
(("iban", "bank", "card"), "FINANCIAL"),
(("ssn", "bsn", "national", "passport"), "NATIONAL_ID"),
(("address", "adres"), "ADDRESS"),
(("birth", "dob", "geboorte"), "DOB"),
(("ip",), "IP"),
]
out: list[str] = []
for words, cat in cat_map:
if any(w in lower for w in words):
out.append(cat)
return out
def _sample_rows_for_chat(
*,
categories: list[str] | None = None,
max_datasets: int = 2,
rows_per: int = 2,
) -> list[dict[str, Any]]:
"""Fetch a few live rows; values already policy-masked."""
catalog = get_pii(use_cache=False)
prefer = ["mysql", "postgres", "mongodb", "cassandra", "neo4j", "curated"]
samples: list[dict[str, Any]] = []
for key in prefer:
dset = next((d for d in catalog.get("datasets", []) if d.get("key") == key), None)
if not dset or not dset.get("pii_columns"):
continue
ds = DATASET_BY_KEY.get(key)
if not ds:
continue
pii_cols = dset["pii_columns"]
if categories:
focus = [c for c in pii_cols if c.get("category") in categories]
# Always keep one id-like visible column for context when focusing
ids = [c for c in pii_cols if c.get("category") == "IDENTIFIER" and not c.get("masked")]
pick = (ids[:1] + focus) if focus else pii_cols
else:
pick = pii_cols
if not pick:
continue
# de-dupe preserving order
seen: set[str] = set()
select_cols: list[str] = []
masked_map: dict[str, bool] = {}
for c in pick:
name = c["name"]
if name in seen:
continue
seen.add(name)
select_cols.append(name)
masked_map[name] = bool(c.get("masked"))
if len(select_cols) >= 6:
break
name_col = next((c["name"] for c in pii_cols if c.get("category") == "NAME"), None)
try:
cols, rows = _lookup_rows(ds, select_cols, name_col, None, rows_per)
except Exception:
continue
if not rows:
continue
rendered = []
for row in rows[:rows_per]:
rendered.append({
cname: (MASK_TOKEN if masked_map.get(cname) else val)
for cname, val in zip(cols, row)
})
samples.append({
"key": key,
"label": dset.get("label", key),
"table": ds.get("table"),
"rows": rendered,
"masked_cols": [c for c, m in masked_map.items() if m],
"visible_cols": [c for c, m in masked_map.items() if not m],
})
if len(samples) >= max_datasets:
break
return samples
def build_policy_evidence(*, max_datasets: int = 2, rows_per: int = 2) -> str:
"""Compact internal evidence for LLM context (not shown raw to users)."""
snap = policy_column_lists(use_cache=False)
masked = snap["masked"]
visible = snap["visible"]
summ = snap["summary"]
lines = [
"=== PII POLICY (Data Flow synced) ===",
f"Masked {summ.get('masked_columns', len(masked))}/{summ.get('pii_columns', 0)} · "
f"visible {summ.get('unmasked_columns', len(visible))}. Token: {MASK_TOKEN}",
"Masked: " + ", ".join(f"{m['key']}.{m['column']}" for m in masked[:25]) + (
f" …(+{len(masked)-25})" if len(masked) > 25 else ""
),
"Visible: " + (", ".join(f"{v['key']}.{v['column']}" for v in visible[:25]) or "(none)"),
]
for s in _sample_rows_for_chat(max_datasets=max_datasets, rows_per=rows_per):
lines.append(f"Sample {s['label']}:")
for row in s["rows"]:
lines.append(" " + " | ".join(f"{k}={v}" for k, v in row.items()))
return "\n".join(lines)
def format_pii_chat_answer(message: str = "") -> str:
"""Short personal reply: masked → say masked; visible → show values. Never list field names."""
snap = policy_column_lists(use_cache=False)
masked = snap["masked"]
visible = snap["visible"]
lower = (message or "").lower()
interest = _interest_categories(message)
greeting = any(w in lower for w in ("hi", "hello", "hey", "hallo", "goedemorgen", "goedemiddag"))
hi = "Hi! " if greeting else ""
label = {
"EMAIL": "email",
"PHONE": "phone number",
"NAME": "name",
"FINANCIAL": "bank / IBAN details",
"NATIONAL_ID": "national ID",
"ADDRESS": "address",
"DOB": "date of birth",
"IP": "IP address",
}
# No specific PII type asked — keep it vague, never enumerate columns
if not interest:
if any(w in lower for w in ("mask", "pii", "sensitive", "privacy", "personal")):
return (
f"{hi}Personal data is protected by the masking policy. "
f"Ask for something specific (an email, a phone number, a name…) and I'll tell you "
f"whether I can share it — or only `{MASK_TOKEN}`."
)
return (
f"{hi}I can't share personal data that's masked. "
f"Ask me for an email, phone number, or name if you want to check."
)
topic = ", ".join(label.get(c, c.lower()) for c in interest)
interested_masked = [c for c in masked if c["category"] in interest]
interested_visible = [c for c in visible if c["category"] in interest]
# Collect visible sample *values* only (no column names in the reply)
values: list[str] = []
if interested_visible:
samples = _sample_rows_for_chat(categories=interest, max_datasets=2, rows_per=2)
for s in samples:
for row in s["rows"]:
for col in interested_visible:
if col["column"] in row:
val = row[col["column"]]
if val is None or val == "" or val == MASK_TOKEN:
continue
values.append(str(val))
# unique, preserve order
seen: set[str] = set()
uniq: list[str] = []
for v in values:
if v not in seen:
seen.add(v)
uniq.append(v)
values = uniq[:5]
# Fully masked for this ask
if interested_masked and not interested_visible:
return (
f"{hi}No — that {topic} is masked (`{MASK_TOKEN}`). "
"I can't share it."
)
# Fully visible
if interested_visible and not interested_masked:
if values:
listed = ", ".join(values)
return f"{hi}Sure — here's what I can share: {listed}."
return f"{hi}That {topic} isn't masked, but I don't have a sample value right now."
# Mixed: some sources masked, some visible — still don't name columns
if interested_visible and interested_masked:
if values:
listed = ", ".join(values)
return (
f"{hi}Some of that is masked (`{MASK_TOKEN}`); "
f"what I can share: {listed}."
)
return (
f"{hi}Some of that {topic} is masked (`{MASK_TOKEN}`). "
"I can't share the protected parts."
)
return f"{hi}I don't have that personal data available."
def _lookup_rows(ds: dict[str, Any], select: list[str], name_col: str | None, def _lookup_rows(ds: dict[str, Any], select: list[str], name_col: str | None,
search: str | None, limit: int) -> tuple[list[str], list[list[Any]]]: search: str | None, limit: int) -> tuple[list[str], list[list[Any]]]:
"""Fetch rows from the source. Native DB queries (fast, early LIMIT) for """Fetch rows from the source. Native DB queries (fast, early LIMIT) for
+17 -12
View File
@@ -90,15 +90,15 @@ def build_platform_section() -> str:
return "\n".join(lines) return "\n".join(lines)
def build_masking_section() -> str: def build_masking_section(fresh: bool = False) -> str:
"""Exact masking policy + strict guidance so the LLM can answer about masked """Exact masking policy + strict guidance so the LLM can answer about masked
data without ever revealing masked raw values.""" data without ever revealing masked raw values. Synced with Data Flow toggles."""
lines: list[str] = ["=== DATA MASKING POLICY (enforced) ==="] lines: list[str] = ["=== DATA MASKING POLICY (enforced — synced with Data Flow) ==="]
masked: list[str] = [] masked: list[str] = []
unmasked: list[str] = [] unmasked: list[str] = []
try: try:
from pii_catalog import get_pii # type: ignore from pii_catalog import get_pii # type: ignore
data = get_pii() data = get_pii(use_cache=not fresh)
for d in data.get("datasets", []): for d in data.get("datasets", []):
for c in d.get("pii_columns", []): for c in d.get("pii_columns", []):
tag = f"{d.get('label')}.{c.get('name')} [{c.get('category')}]" tag = f"{d.get('label')}.{c.get('name')} [{c.get('category')}]"
@@ -112,22 +112,27 @@ def build_masking_section() -> str:
lines.append(f"(masking catalog unavailable: {exc})") lines.append(f"(masking catalog unavailable: {exc})")
if masked: if masked:
lines.append("MASKED columns (raw values are withheld — token 🔒 MASKED):") lines.append("MASKED columns (raw values withheld — token 🔒 MASKED):")
for m in masked[:40]: for m in masked[:40]:
lines.append(f" - {m}") lines.append(f" - {m}")
else:
lines.append("MASKED columns: (none)")
if unmasked: if unmasked:
lines.append("Visible PII columns (operator opted out of masking):") lines.append("VISIBLE columns (operator opted out of masking in Data Flow — real values OK):")
for u in unmasked[:40]: for u in unmasked[:40]:
lines.append(f" - {u}") lines.append(f" - {u}")
else:
lines.append("VISIBLE columns: (none — all PII masked)")
lines += [ lines += [
"", "",
"How to handle masked data when answering:", "How to handle masked vs visible data when answering:",
" 1. NEVER reveal, guess, reconstruct or print the raw value of a MASKED column. If a value comes in as '🔒 MASKED', keep it masked.", " 1. MASKED: NEVER reveal, guess, or reconstruct raw values. Quote '🔒 MASKED' when present.",
" 2. DO still answer helpfully: confirm the column exists and is masked for privacy/governance, and explain why (PII protection policy).", " 2. VISIBLE: you MAY show the real sample values and state that the operator made them visible in Data Flow.",
" 3. You MAY use and report non-sensitive aggregates, counts, distributions and derived metrics over masked columns (e.g. 'there are N distinct customers') as long as no individual raw value is exposed.", " 3. DO still answer helpfully: confirm which columns are masked vs visible from the lists above.",
" 4. Tell the operator they can unmask a specific column from the Data Flow PII overlay if they have the authority, and that the curated/masked Iceberg layer is physically masked and cannot be unmasked.", " 4. You MAY use non-sensitive aggregates/counts over masked columns without exposing individuals.",
" 5. Unmasked PII columns may be shown, but flag that they are sensitive.", " 5. Curated/masked Iceberg layers are physically masked and cannot be unmasked from the UI.",
" 6. Never invent PII that is not in the live samples.",
] ]
return "\n".join(lines) return "\n".join(lines)
+14 -3
View File
@@ -276,6 +276,17 @@ def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
)) ))
gpus = gpu.get("gpus") or snap.get("gpu", {}).get("gpus") or [] gpus = gpu.get("gpus") or snap.get("gpu", {}).get("gpus") or []
try:
from gpu_config import resolve_gpu_identity
gpu_id = resolve_gpu_identity(snap.get("gpu") or gpu)
except Exception:
host = (snap.get("gpu") or gpu).get("host") or "10.0.10.106"
gpu_id = {
"vm": "atc-gpu-prod",
"vmid": 306,
"ui_url": f"http://{host}:9000",
"llm_url": f"http://{host}:8001/v1",
}
gpu_lines = [ gpu_lines = [
f"GPU{g['index']}: {g.get('util_gpu', 0):.0f}% util, " f"GPU{g['index']}: {g.get('util_gpu', 0):.0f}% util, "
f"{g.get('memory_used_mib', 0):.0f}/{g.get('memory_total_mib', 0):.0f} MiB" f"{g.get('memory_used_mib', 0):.0f}/{g.get('memory_total_mib', 0):.0f} MiB"
@@ -284,11 +295,11 @@ def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
slides.append(_slide( slides.append(_slide(
"gpu", "gpu",
"GPU Lab & GenAI", "GPU Lab & GenAI",
f"{gpu.get('model') or 'vLLM'} on atc-gpu-dev (VM 303)", f"{gpu.get('model') or 'vLLM'} on {gpu_id['vm']} (VM {gpu_id['vmid']})",
[ [
f"Inference: {'ON' if gpu.get('inference_active') else 'OFF'}", f"Inference: {'ON' if gpu.get('inference_active') else 'OFF'}",
f"API: {snap.get('gpu', {}).get('vllm_url') or 'http://10.0.20.106:8001/v1'}", f"API: {gpu_id['llm_url']}",
"GPU Lab UI: http://10.0.20.106:9000", f"GPU Lab UI: {gpu_id['ui_url']}",
"Kibana/Elastic: http://10.0.21.46:5601", "Kibana/Elastic: http://10.0.21.46:5601",
*gpu_lines, *gpu_lines,
], ],
+2 -2
View File
@@ -151,7 +151,7 @@ MODERN_DECKS: dict[str, dict[str, Any]] = {
"bullets": [ "bullets": [
"Supervisor + field operators on Command Center", "Supervisor + field operators on Command Center",
"Each agent sees live workload, GPU, databases, topology", "Each agent sees live workload, GPU, databases, topology",
"LLM: Llama 3 70B GPTQ via vLLM (10.0.20.106:8001)", "LLM: Llama 3 70B GPTQ via vLLM (10.0.10.106:8001)",
"Approval workflow for sensitive operations", "Approval workflow for sensitive operations",
], ],
}, },
@@ -161,7 +161,7 @@ MODERN_DECKS: dict[str, dict[str, Any]] = {
"subtitle": "Dell ATC cluster — key IPs", "subtitle": "Dell ATC cluster — key IPs",
"bullets": [ "bullets": [
"Command Center VM304: 10.0.21.33 (this dashboard)", "Command Center VM304: 10.0.21.33 (this dashboard)",
"GPU Lab VM303: 10.0.20.106 — 7× V100, vLLM, model manager", "GPU Lab VM306: 10.0.10.106 — 4× V100, vLLM, model manager",
"DB Vault: 10.0.21.51 · Lakehouse: 10.0.21.50 · Elastic: 10.0.21.46", "DB Vault: 10.0.21.51 · Lakehouse: 10.0.21.50 · Elastic: 10.0.21.46",
"Docling UI: http://10.0.21.33:5001/ui/", "Docling UI: http://10.0.21.33:5001/ui/",
], ],
+2
View File
@@ -17,3 +17,5 @@ boto3==1.35.99
paramiko==3.5.0 paramiko==3.5.0
aiokafka==0.12.0 aiokafka==0.12.0
pyarrow==18.1.0 pyarrow==18.1.0
authlib==1.4.1
itsdangerous==2.2.0
+14 -3
View File
@@ -537,13 +537,24 @@ def _build_architecture(
1, 1, "📓", 1, 1, "📓",
) )
gpu_ok = bool(gpu.get("ok")) gpu_ok = bool(gpu.get("ok"))
try:
from gpu_config import resolve_gpu_identity
gpu_id = resolve_gpu_identity(gpu)
except Exception:
host = gpu.get("ip") or gpu.get("host") or "10.0.10.106"
gpu_id = {"vm": "atc-gpu-prod", "ip": host}
ml = _arch_node( ml = _arch_node(
"cons-ml", "ML / GenAI", "vLLM cluster", C_CON, 70, "#bc8cff", "consumers", "cons-ml", "ML / GenAI", "vLLM cluster", C_CON, 70, "#bc8cff", "consumers",
"ok" if gpu_ok else "warn", "atc-gpu-dev", "10.0.20.106", "ok" if gpu_ok else "warn", gpu_id["vm"], gpu_id["ip"],
[gpu.get("active_model") or "offline"], [gpu.get("active_model") or "offline"],
[{"name": gpu.get("active_model") or "vllm", "state": "running" if gpu.get("inference_active") else "down", "image": "vllm", "ports": ["8001"]}], [{"name": gpu.get("active_model") or "vllm", "state": "running" if gpu.get("inference_active") else "down", "image": "vllm", "ports": ["8001", "9000"]}],
gpu.get("gpu_count", 0) or 0, max(gpu.get("gpu_count", 4) or 4, 1), "🤖", gpu.get("gpu_count", 0) or 0, max(gpu.get("gpu_count", 4) or 4, 1), "🤖",
{"model": gpu.get("active_model"), "util": round(sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1), 1)}, {"model": gpu.get("active_model"), "util": round(sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1), 1),
"ui_url": gpu_id.get("ui_url"), "vllm_url": gpu_id.get("llm_url"),
"links": [
{"label": "GPU Lab UI", "url": gpu_id.get("ui_url") or f"http://{gpu_id['ip']}:9000"},
{"label": "vLLM API", "url": gpu_id.get("llm_url") or f"http://{gpu_id['ip']}:8001/v1"},
]},
) )
nodes = [ nodes = [
+28 -3
View File
@@ -63,6 +63,18 @@ def build_workload_payload(snap: dict[str, Any]) -> dict[str, Any]:
gpu = snap.get("gpu", {}) gpu = snap.get("gpu", {})
objectscale = snap.get("objectscale", {}) objectscale = snap.get("objectscale", {})
command = snap.get("command_center", {}) command = snap.get("command_center", {})
try:
from gpu_config import resolve_gpu_identity
gpu_id = resolve_gpu_identity(gpu)
except Exception:
host = gpu.get("ip") or gpu.get("host") or "10.0.10.106"
gpu_id = {
"vm": "atc-gpu-prod",
"ip": host,
"ui_url": gpu.get("ui_url") or f"http://{host}:9000",
"llm_url": gpu.get("vllm_url") or f"http://{host}:8001/v1",
"vmid": 306,
}
docker_apps = [_app_row(c) for c in docker.get("containers", [])] docker_apps = [_app_row(c) for c in docker.get("containers", [])]
db_apps = [_app_row(c) for c in databases.get("containers", [])] db_apps = [_app_row(c) for c in databases.get("containers", [])]
@@ -221,6 +233,18 @@ def build_workload_payload(snap: dict[str, Any]) -> dict[str, Any]:
} }
if extra: if extra:
row.update(extra) row.update(extra)
if nid == "gpu":
row["vm"] = gpu_id["vm"]
row["ip"] = gpu_id["ip"]
row["vmid"] = gpu_id.get("vmid", row.get("vmid"))
row["links"] = [
{"label": "GPU Lab UI", "url": gpu_id["ui_url"]},
{"label": "vLLM API", "url": gpu_id["llm_url"]},
]
row["endpoints"] = [
{"name": "gpu-lab", "host": gpu_id["ip"], "port": "9000", "proto": "http"},
{"name": "vllm", "host": gpu_id["ip"], "port": "8001", "proto": "http"},
]
return row return row
connect_running = 1 if connect_app and connect_app.get("state") == "running" else 0 connect_running = 1 if connect_app and connect_app.get("state") == "running" else 0
@@ -247,10 +271,11 @@ def build_workload_payload(snap: dict[str, Any]) -> dict[str, Any]:
_node("hadoop", "Hadoop HDFS", "atc-hadoop-m01", "10.0.21.61", 50, 52, "#39ff14", "ok" if hdfs_ok else "warn", "parallel", _node("hadoop", "Hadoop HDFS", "atc-hadoop-m01", "10.0.21.61", 50, 52, "#39ff14", "ok" if hdfs_ok else "warn", "parallel",
zones[-1]["apps"], hadoop.get("live_datanodes", 0), zones[-1]["total"], zones[-1]["apps"], hadoop.get("live_datanodes", 0), zones[-1]["total"],
{"hdfs_used_gb": hadoop.get("capacity_used_gb"), "hdfs_total_gb": hadoop.get("capacity_total_gb")}), {"hdfs_used_gb": hadoop.get("capacity_used_gb"), "hdfs_total_gb": hadoop.get("capacity_total_gb")}),
_node("gpu", "GPU Lab", "atc-gpu-dev", "10.0.20.106", 88, 52, "#76b900", "ok" if gpu.get("ok") else "down", "inference", _node("gpu", "GPU Lab", gpu_id["vm"], gpu_id["ip"], 88, 52, "#76b900", "ok" if gpu.get("ok") else "down", "inference",
[{"name": gpu.get("active_model") or "vLLM", "state": "running" if gpu.get("inference_active") else "down", "image": "vllm", "ports": ["8001"]}], [{"name": gpu.get("active_model") or "vLLM", "state": "running" if gpu.get("inference_active") else "down", "image": "vllm", "ports": ["8001", "9000"]}],
gpu.get("gpu_count", 0), gpu.get("gpu_count", 0) or 4, gpu.get("gpu_count", 0), gpu.get("gpu_count", 0) or 4,
{"model": gpu.get("active_model"), "util": round(sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1), 1)}), {"model": gpu.get("active_model"), "util": round(sum(g.get("util_gpu", 0) for g in gpu.get("gpus", [])) / max(len(gpu.get("gpus", [])), 1), 1),
"ui_url": gpu_id["ui_url"], "vllm_url": gpu_id["llm_url"]}),
_node("command", "Command Center", "MCP · VM304", "10.0.21.33", 50, 78, "#00f0ff", _node("command", "Command Center", "MCP · VM304", "10.0.21.33", 50, 78, "#00f0ff",
_level(command.get("running", 0), command.get("total", 1) or 1), "hub", _level(command.get("running", 0), command.get("total", 1) or 1), "hub",
command.get("containers") and [_app_row(c) for c in command.get("containers", [])] or [ command.get("containers") and [_app_row(c) for c in command.get("containers", [])] or [
+3
View File
@@ -8,6 +8,9 @@
handle_path /dq/* { handle_path /dq/* {
reverse_proxy dq-api:5010 reverse_proxy dq-api:5010
} }
handle /auth/* {
reverse_proxy api:3201
}
handle /api/* { handle /api/* {
reverse_proxy api:3201 reverse_proxy api:3201
} }
+2 -2
View File
@@ -8,8 +8,8 @@ S3_REGION=us-east-1
JUPYTER_TOKEN=choose-a-strong-token JUPYTER_TOKEN=choose-a-strong-token
GPU_URL=http://10.0.20.106:9000 GPU_URL=http://10.0.10.106:9000
LLM_URL=http://10.0.20.106:8001/v1 LLM_URL=http://10.0.10.106:8001/v1
LLM_MODEL=gpt-4o LLM_MODEL=gpt-4o
LLM_API_KEY=sk-local LLM_API_KEY=sk-local
+4 -4
View File
@@ -32,9 +32,9 @@ services:
SQLITE_FALLBACK_PATH: /data/atc-agents.db SQLITE_FALLBACK_PATH: /data/atc-agents.db
DOCLING_URL: http://docling-serve:5001 DOCLING_URL: http://docling-serve:5001
PRESENTATIONS_DIR: /data/presentations PRESENTATIONS_DIR: /data/presentations
GPU_URL: http://10.0.20.106:9000 GPU_URL: http://10.0.10.106:9000
GPU_UI_URL: http://10.0.20.106:9000 GPU_UI_URL: http://10.0.10.106:9000
LLM_URL: http://10.0.20.106:8001/v1 LLM_URL: http://10.0.10.106:8001/v1
LLM_MODEL: gpt-4o LLM_MODEL: gpt-4o
LLM_API_KEY: sk-local LLM_API_KEY: sk-local
LAKEHOUSE_HOST: 10.0.21.50 LAKEHOUSE_HOST: 10.0.21.50
@@ -87,7 +87,7 @@ services:
CHROMA_HOST: chromadb CHROMA_HOST: chromadb
CHROMA_PORT: 8000 CHROMA_PORT: 8000
DOCLING_URL: http://docling-serve:5001 DOCLING_URL: http://docling-serve:5001
LLM_URL: http://10.0.20.106:8001/v1 LLM_URL: http://10.0.10.106:8001/v1
LLM_MODEL: gpt-4o LLM_MODEL: gpt-4o
LLM_API_KEY: sk-local LLM_API_KEY: sk-local
RAG_DATA_DIR: /data RAG_DATA_DIR: /data
+3 -3
View File
@@ -30,9 +30,9 @@ services:
DOCKHAND_URL: http://10.0.21.45:8082 DOCKHAND_URL: http://10.0.21.45:8082
DATABASE_URL: postgresql+psycopg2://atc:atc-agents-pg@postgres:5432/atc_agents DATABASE_URL: postgresql+psycopg2://atc:atc-agents-pg@postgres:5432/atc_agents
SQLITE_FALLBACK_PATH: /data/atc-agents.db SQLITE_FALLBACK_PATH: /data/atc-agents.db
GPU_URL: http://10.0.20.106:9000 GPU_URL: http://10.0.10.106:9000
GPU_UI_URL: http://10.0.20.106:9000 GPU_UI_URL: http://10.0.10.106:9000
LLM_URL: http://10.0.20.106:8001/v1 LLM_URL: http://10.0.10.106:8001/v1
LLM_MODEL: qwen2.5-32b-gptq LLM_MODEL: qwen2.5-32b-gptq
LLM_API_KEY: sk-local LLM_API_KEY: sk-local
LAKEHOUSE_HOST: 10.0.21.50 LAKEHOUSE_HOST: 10.0.21.50
+6 -10
View File
@@ -32,9 +32,9 @@ services:
SQLITE_FALLBACK_PATH: /data/atc-agents.db SQLITE_FALLBACK_PATH: /data/atc-agents.db
DOCLING_URL: http://docling-serve:5001 DOCLING_URL: http://docling-serve:5001
PRESENTATIONS_DIR: /data/presentations PRESENTATIONS_DIR: /data/presentations
GPU_URL: http://10.0.20.106:9000 GPU_URL: http://10.0.10.106:9000
GPU_UI_URL: http://10.0.20.106:9000 GPU_UI_URL: http://10.0.10.106:9000
LLM_URL: http://10.0.20.106:8001/v1 LLM_URL: http://10.0.10.106:8001/v1
LLM_MODEL: gpt-4o LLM_MODEL: gpt-4o
LLM_API_KEY: sk-local LLM_API_KEY: sk-local
LAKEHOUSE_HOST: 10.0.21.50 LAKEHOUSE_HOST: 10.0.21.50
@@ -44,10 +44,6 @@ services:
OPENMETADATA_URL: ${OPENMETADATA_URL:-http://10.0.21.47:8585} OPENMETADATA_URL: ${OPENMETADATA_URL:-http://10.0.21.47:8585}
KAFKA_UI_URL: http://10.0.21.36:9000 KAFKA_UI_URL: http://10.0.21.36:9000
HDFS_NN_URL: http://10.0.21.61:9870 HDFS_NN_URL: http://10.0.21.61:9870
S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-object_admin1}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
ELASTICSEARCH_URL: ${ELASTICSEARCH_URL:-https://10.0.21.46:9200} ELASTICSEARCH_URL: ${ELASTICSEARCH_URL:-https://10.0.21.46:9200}
KIBANA_URL: ${KIBANA_URL:-http://10.0.21.46:5601} KIBANA_URL: ${KIBANA_URL:-http://10.0.21.46:5601}
ELASTIC_USER: ${ELASTIC_USER:-elastic} ELASTIC_USER: ${ELASTIC_USER:-elastic}
@@ -94,7 +90,7 @@ services:
CHROMA_HOST: chromadb CHROMA_HOST: chromadb
CHROMA_PORT: 8000 CHROMA_PORT: 8000
DOCLING_URL: http://docling-serve:5001 DOCLING_URL: http://docling-serve:5001
LLM_URL: http://10.0.20.106:8001/v1 LLM_URL: http://10.0.10.106:8001/v1
LLM_MODEL: gpt-4o LLM_MODEL: gpt-4o
LLM_API_KEY: sk-local LLM_API_KEY: sk-local
RAG_DATA_DIR: /data RAG_DATA_DIR: /data
@@ -124,10 +120,10 @@ services:
jupyter: jupyter:
image: quay.io/jupyter/scipy-notebook:latest image: quay.io/jupyter/scipy-notebook:latest
restart: unless-stopped restart: unless-stopped
env_file:
- atc.env
environment: environment:
JUPYTER_TOKEN: ${JUPYTER_TOKEN:-atc-jupyter} JUPYTER_TOKEN: ${JUPYTER_TOKEN:-atc-jupyter}
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-object_admin1}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020} S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020}
AWS_DEFAULT_REGION: ${S3_REGION:-us-east-1} AWS_DEFAULT_REGION: ${S3_REGION:-us-east-1}
ELASTICSEARCH_URL: ${ELASTICSEARCH_URL:-https://10.0.21.46:9200} ELASTICSEARCH_URL: ${ELASTICSEARCH_URL:-https://10.0.21.46:9200}
+2868
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -1,4 +1,6 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { useAuth } from './hooks/useAuth'
import { LoginView } from './components/features/LoginView'
import { ChevronDown, ChevronUp } from 'lucide-react' import { ChevronDown, ChevronUp } from 'lucide-react'
import { useClock } from './hooks/useClock' import { useClock } from './hooks/useClock'
import { useCommandCenter } from './hooks/useCommandCenter' import { useCommandCenter } from './hooks/useCommandCenter'
@@ -27,6 +29,7 @@ import { resolveInfraNode } from './lib/infraCatalog'
import { cn } from './lib/utils' import { cn } from './lib/utils'
export default function App() { export default function App() {
const auth = useAuth()
const clock = useClock() const clock = useClock()
const cc = useCommandCenter() const cc = useCommandCenter()
const [gpuChatActive, setGpuChatActive] = useState(false) const [gpuChatActive, setGpuChatActive] = useState(false)
@@ -53,6 +56,19 @@ export default function App() {
return 'Lab' return 'Lab'
})() })()
if (auth.status === 'loading') {
return (
<div className="flex h-full min-h-screen items-center justify-center bg-surface text-sm text-foreground-muted">
Checking session
</div>
)
}
if (auth.status === 'anon') {
return <LoginView />
}
const userLabel = auth.user?.name || auth.user?.preferred_username || auth.user?.email || 'Signed in'
return ( return (
<div className="flex h-full flex-col overflow-hidden bg-surface"> <div className="flex h-full flex-col overflow-hidden bg-surface">
<TopBar <TopBar
@@ -62,6 +78,8 @@ export default function App() {
agents={cc.agents} agents={cc.agents}
approvals={cc.approvals} approvals={cc.approvals}
onApprovalsClick={openApprovals} onApprovalsClick={openApprovals}
userLabel={userLabel}
onLogout={() => { window.location.href = '/auth/logout' }}
/> />
<div className="flex min-h-0 flex-1 overflow-hidden"> <div className="flex min-h-0 flex-1 overflow-hidden">
+213 -5
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Activity, ChevronDown, ChevronUp, Cpu, ExternalLink, Zap } from 'lucide-react' import { Activity, ChevronDown, ChevronUp, Cpu, ExternalLink, Settings2, Zap } from 'lucide-react'
import { fetchGpu } from '../../lib/api' import { fetchGpu, fetchGpuConfig, resetGpuConfig, saveGpuConfig, testGpuConfig } from '../../lib/api'
import type { GpuDevice, GpuStatus } from '../../types' import type { GpuConfigPayload, GpuConfigTestResult, GpuDevice, GpuStatus } from '../../types'
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics' import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
@@ -53,6 +53,13 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
const [localGpu, setLocalGpu] = useState<GpuStatus | null>(gpu) const [localGpu, setLocalGpu] = useState<GpuStatus | null>(gpu)
const [lastPoll, setLastPoll] = useState<Date | null>(null) const [lastPoll, setLastPoll] = useState<Date | null>(null)
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [gpuConfig, setGpuConfig] = useState<GpuConfigPayload | null>(null)
const [selectedPreset, setSelectedPreset] = useState('gpu-prod')
const [customHost, setCustomHost] = useState('')
const [configBusy, setConfigBusy] = useState(false)
const [testResult, setTestResult] = useState<GpuConfigTestResult | null>(null)
const [configMsg, setConfigMsg] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
setLocalGpu(gpu) setLocalGpu(gpu)
@@ -62,6 +69,19 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
if (boost) setExpanded(true) if (boost) setExpanded(true)
}, [boost]) }, [boost])
useEffect(() => {
if (!showSettings) return
fetchGpuConfig().then((cfg) => {
if (!cfg) return
setGpuConfig(cfg)
const active = cfg.active
setSelectedPreset(active.preset_id === 'env' ? 'gpu-prod' : active.preset_id)
if (active.preset_id === 'custom' || active.source === 'override') {
setCustomHost(active.host)
}
})
}, [showSettings])
useEffect(() => { useEffect(() => {
const poll = async () => { const poll = async () => {
const g = await fetchGpu() const g = await fetchGpu()
@@ -76,6 +96,49 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
return () => clearInterval(iv) return () => clearInterval(iv)
}, [boost]) }, [boost])
const handleTestTarget = async () => {
setConfigBusy(true)
setTestResult(null)
setConfigMsg(null)
const body =
selectedPreset === 'custom'
? { preset_id: 'custom', host: customHost.trim() }
: { preset_id: selectedPreset }
const result = await testGpuConfig(body)
setTestResult(result)
setConfigBusy(false)
}
const handleSaveTarget = async () => {
setConfigBusy(true)
setConfigMsg(null)
const body =
selectedPreset === 'custom'
? { preset_id: 'custom', host: customHost.trim() }
: { preset_id: selectedPreset }
const res = await saveGpuConfig(body)
setConfigBusy(false)
if (res?.active) {
setConfigMsg(`Saved → ${res.active.label}`)
const g = await fetchGpu()
if (g) setLocalGpu(g)
} else {
setConfigMsg(res?.detail || 'Save failed')
}
}
const handleResetTarget = async () => {
setConfigBusy(true)
await resetGpuConfig()
setSelectedPreset('gpu-prod')
setCustomHost('')
setTestResult(null)
setConfigMsg('Reset to environment default')
const g = await fetchGpu()
if (g) setLocalGpu(g)
setConfigBusy(false)
}
const g = localGpu const g = localGpu
const devices = g?.gpus || [] const devices = g?.gpus || []
const inferenceOn = g?.ok && g.inference_active const inferenceOn = g?.ok && g.inference_active
@@ -102,7 +165,31 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
<h2 className="flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint"> <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 <Cpu className="h-3 w-3" /> GPU Matrix
</h2> </h2>
<p className="mt-1 text-[9px] text-foreground-faint">GPU Lab offline</p> <p className="mt-1 text-[9px] text-foreground-faint">
GPU Lab offline{g?.host ? ` · ${g.ip || g.host}` : ''}
</p>
<button
type="button"
onClick={() => setShowSettings((v) => !v)}
className="mt-1 flex items-center gap-1 text-[8px] text-docker hover:underline"
>
<Settings2 className="h-2.5 w-2.5" /> GPU target
</button>
{showSettings && gpuConfig && (
<GpuTargetSettings
gpuConfig={gpuConfig}
selectedPreset={selectedPreset}
customHost={customHost}
configBusy={configBusy}
testResult={testResult}
configMsg={configMsg}
onPresetChange={setSelectedPreset}
onCustomHostChange={setCustomHost}
onTest={handleTestTarget}
onSave={handleSaveTarget}
onReset={handleResetTarget}
/>
)}
</section> </section>
) )
} }
@@ -130,6 +217,17 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
)} )}
</button> </button>
<div className="flex shrink-0 items-center gap-0.5"> <div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
onClick={() => setShowSettings((v) => !v)}
className={cn(
'rounded p-1 hover:bg-surface-overlay',
showSettings ? 'text-docker' : 'text-foreground-muted hover:text-foreground',
)}
title="GPU target settings"
>
<Settings2 className="h-3 w-3" />
</button>
{g.ui_url && ( {g.ui_url && (
<a <a
href={g.ui_url} href={g.ui_url}
@@ -207,11 +305,121 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
</div> </div>
<p className="font-mono text-[7px] text-foreground-faint"> <p className="font-mono text-[7px] text-foreground-faint">
{g.gpu_count ?? devices.length}× V100 · {g.host} · poll {boost ? '1s' : '3s'} {g.gpu_count ?? devices.length}× V100 · {g.ip || g.host}
{g.config_label && g.config_source === 'override' ? ` · ${g.config_label}` : ''}
{' · poll '}{boost ? '1s' : '3s'}
{lastPoll && ` · ${lastPoll.toLocaleTimeString()}`} {lastPoll && ` · ${lastPoll.toLocaleTimeString()}`}
</p> </p>
{showSettings && gpuConfig && (
<GpuTargetSettings
gpuConfig={gpuConfig}
selectedPreset={selectedPreset}
customHost={customHost}
configBusy={configBusy}
testResult={testResult}
configMsg={configMsg}
onPresetChange={setSelectedPreset}
onCustomHostChange={setCustomHost}
onTest={handleTestTarget}
onSave={handleSaveTarget}
onReset={handleResetTarget}
/>
)}
</div> </div>
)} )}
</section> </section>
) )
} }
type GpuTargetSettingsProps = {
gpuConfig: GpuConfigPayload
selectedPreset: string
customHost: string
configBusy: boolean
testResult: GpuConfigTestResult | null
configMsg: string | null
onPresetChange: (id: string) => void
onCustomHostChange: (host: string) => void
onTest: () => void
onSave: () => void
onReset: () => void
}
function GpuTargetSettings({
gpuConfig,
selectedPreset,
customHost,
configBusy,
testResult,
configMsg,
onPresetChange,
onCustomHostChange,
onTest,
onSave,
onReset,
}: GpuTargetSettingsProps) {
return (
<div className="mt-1.5 rounded border border-border/80 bg-surface-overlay/40 p-2 space-y-1.5">
<p className="text-[8px] font-semibold uppercase tracking-wider text-foreground-faint">GPU Target</p>
<select
value={selectedPreset}
onChange={(e) => onPresetChange(e.target.value)}
className="w-full rounded border border-border bg-surface px-1.5 py-1 font-mono text-[9px] text-foreground"
>
{gpuConfig.presets.map((p) => (
<option key={p.id} value={p.id}>
{p.label} ({p.host})
</option>
))}
<option value="custom">Custom IP</option>
</select>
{selectedPreset === 'custom' && (
<input
type="text"
value={customHost}
onChange={(e) => onCustomHostChange(e.target.value)}
placeholder="10.0.x.x"
className="w-full rounded border border-border bg-surface px-1.5 py-1 font-mono text-[9px] text-foreground"
/>
)}
<div className="flex flex-wrap gap-1">
<button
type="button"
disabled={configBusy}
onClick={onTest}
className="rounded border border-border px-2 py-0.5 font-mono text-[8px] text-foreground-muted hover:bg-surface-overlay disabled:opacity-50"
>
Test
</button>
<button
type="button"
disabled={configBusy}
onClick={onSave}
className="rounded border border-docker/40 bg-docker/10 px-2 py-0.5 font-mono text-[8px] text-docker hover:bg-docker/20 disabled:opacity-50"
>
Save
</button>
<button
type="button"
disabled={configBusy}
onClick={onReset}
className="rounded border border-border px-2 py-0.5 font-mono text-[8px] text-foreground-faint hover:bg-surface-overlay disabled:opacity-50"
>
Reset
</button>
</div>
{testResult && (
<p className={cn('font-mono text-[8px]', testResult.ok ? 'text-success' : 'text-warning')}>
{testResult.ok
? `OK · ${testResult.gpu_count} GPU(s) · ${testResult.active_model || 'no model'}`
: `Failed · ${testResult.errors.join('; ') || 'unreachable'}`}
</p>
)}
{configMsg && <p className="font-mono text-[8px] text-foreground-muted">{configMsg}</p>}
<p className="font-mono text-[7px] text-foreground-faint">
Active: {gpuConfig.active.label} ({gpuConfig.active.host})
</p>
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { Box, LogIn } from 'lucide-react'
import { useMemo } from 'react'
export function LoginView() {
const error = useMemo(() => {
try {
const p = new URLSearchParams(window.location.search)
const err = p.get('error')
if (!err) return null
if (err === 'login_failed') return 'Sign-in failed — try again.'
return err
} catch {
return null
}
}, [])
return (
<div className="flex h-full min-h-screen flex-col items-center justify-center bg-surface px-4">
<div className="w-full max-w-md border border-border bg-surface-raised/90 p-8 shadow-panel backdrop-blur-sm">
<div className="mb-6 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-docker to-blue-600 shadow-docker">
<Box className="h-5 w-5 text-white" />
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-foreground-muted">ATC Lab</p>
<h1 className="text-lg font-semibold text-foreground">Data & AI Command Center</h1>
</div>
</div>
<p className="mb-6 text-sm text-foreground-muted">
Sign in with Authentik to open the ops glass. Lab environment not an official Dell product.
</p>
<a
href="/auth/login"
className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-docker px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90"
>
<LogIn className="h-4 w-4" />
Continue with Authentik
</a>
{error ? (
<p className="mt-4 text-sm text-warning" role="alert">
{error}
</p>
) : null}
</div>
</div>
)
}
+20 -2
View File
@@ -1,4 +1,4 @@
import { Activity, Bot, Box, Clock, ShieldAlert } from 'lucide-react' import { Activity, Bot, Box, Clock, LogOut, ShieldAlert } from 'lucide-react'
import type { Agent, Approval, StatusData, WorkloadData } from '../../types' import type { Agent, Approval, StatusData, WorkloadData } from '../../types'
import { Badge } from '../ui/Badge' import { Badge } from '../ui/Badge'
import { ThemeToggle } from './ThemeToggle' import { ThemeToggle } from './ThemeToggle'
@@ -11,9 +11,11 @@ type Props = {
agents: Agent[] agents: Agent[]
approvals: Approval[] approvals: Approval[]
onApprovalsClick: () => void onApprovalsClick: () => void
userLabel?: string
onLogout?: () => void
} }
export function TopBar({ clock, workload, agents, approvals, onApprovalsClick }: Props) { export function TopBar({ clock, workload, agents, approvals, onApprovalsClick, userLabel, onLogout }: Props) {
const pipelineOk = workload?.totals?.pipeline_active ?? false const pipelineOk = workload?.totals?.pipeline_active ?? false
const running = workload?.totals?.apps_running ?? 0 const running = workload?.totals?.apps_running ?? 0
const total = workload?.totals?.apps_total ?? 0 const total = workload?.totals?.apps_total ?? 0
@@ -53,6 +55,22 @@ export function TopBar({ clock, workload, agents, approvals, onApprovalsClick }:
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{userLabel ? (
<span className="hidden max-w-[10rem] truncate text-[11px] text-foreground-muted sm:inline" title={userLabel}>
{userLabel}
</span>
) : null}
{onLogout ? (
<button
type="button"
onClick={onLogout}
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[10px] text-foreground-muted hover:bg-surface hover:text-foreground"
title="Logout"
>
<LogOut className="h-3 w-3" />
Logout
</button>
) : null}
<ThemeToggle /> <ThemeToggle />
<div className="flex items-center gap-1.5 font-mono text-[10px] text-foreground-muted"> <div className="flex items-center gap-1.5 font-mono text-[10px] text-foreground-muted">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from 'react'
export type AuthUser = {
user?: string
email?: string
name?: string
preferred_username?: string
auth_enabled?: boolean
}
type AuthState =
| { status: 'loading'; user: null }
| { status: 'anon'; user: null }
| { status: 'authed'; user: AuthUser }
export function useAuth(): AuthState & { refresh: () => Promise<void> } {
const [state, setState] = useState<AuthState>({ status: 'loading', user: null })
const refresh = useCallback(async () => {
try {
const r = await fetch('/api/auth/me', { credentials: 'same-origin' })
if (r.status === 401) {
setState({ status: 'anon', user: null })
return
}
if (!r.ok) {
setState({ status: 'anon', user: null })
return
}
const me = (await r.json()) as AuthUser
setState({ status: 'authed', user: me })
} catch {
setState({ status: 'anon', user: null })
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
return { ...state, refresh }
}
+58 -8
View File
@@ -6,6 +6,8 @@ import type {
CdcStats, CdcStats,
DataflowGraph, DataflowGraph,
FeedEntry, FeedEntry,
GpuConfigPayload,
GpuConfigTestResult,
GpuStatus, GpuStatus,
Movement, Movement,
PiiDataset, PiiDataset,
@@ -21,7 +23,7 @@ async function fetchJson<T>(url: string, timeoutMs = 10000): Promise<T | null> {
const ctrl = new AbortController() const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), timeoutMs) const timer = setTimeout(() => ctrl.abort(), timeoutMs)
try { try {
const r = await fetch(url, { signal: ctrl.signal }) const r = await fetch(url, { signal: ctrl.signal, credentials: 'same-origin' })
if (!r.ok) return null if (!r.ok) return null
return (await r.json()) as T return (await r.json()) as T
} catch { } catch {
@@ -62,6 +64,54 @@ export async function fetchGpu(): Promise<GpuStatus | null> {
return fetchJson<GpuStatus>('/api/gpu', 8000) return fetchJson<GpuStatus>('/api/gpu', 8000)
} }
export async function fetchGpuConfig(): Promise<GpuConfigPayload | null> {
return fetchJson<GpuConfigPayload>('/api/gpu/config', 8000)
}
export async function saveGpuConfig(body: {
preset_id?: string
host?: string
gpu_ui_port?: number
llm_port?: number
}) {
const r = await fetch('/api/gpu/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return r.json()
}
export async function testGpuConfig(body: {
preset_id?: string
host?: string
gpu_ui_port?: number
llm_port?: number
}): Promise<GpuConfigTestResult | null> {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 12000)
try {
const r = await fetch('/api/gpu/config/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: ctrl.signal,
})
if (!r.ok) return null
return (await r.json()) as GpuConfigTestResult
} catch {
return null
} finally {
clearTimeout(timer)
}
}
export async function resetGpuConfig() {
const r = await fetch('/api/gpu/config', { method: 'DELETE' , credentials: 'same-origin' })
return r.json()
}
export async function fetchTerminals(): Promise<Record<string, TerminalLine[]>> { export async function fetchTerminals(): Promise<Record<string, TerminalLine[]>> {
const j = await fetchJson<{ terminals?: Record<string, TerminalLine[]> }>('/api/terminals', 8000) const j = await fetchJson<{ terminals?: Record<string, TerminalLine[]> }>('/api/terminals', 8000)
return j?.terminals || {} return j?.terminals || {}
@@ -77,7 +127,7 @@ export async function fetchNodeDetail(nodeId: string) {
} }
export function probeNode(nodeId: string) { export function probeNode(nodeId: string) {
return fetch(`/api/nodes/${nodeId}/probe`, { method: 'POST' }) return fetch(`/api/nodes/${nodeId}/probe`, { method: 'POST' , credentials: 'same-origin' })
} }
export function askNode(nodeId: string, message: string) { export function askNode(nodeId: string, message: string) {
@@ -207,19 +257,19 @@ export function triggerStreamingJob(jobId: string, conf?: Record<string, unknown
} }
export function restartKafkaConnector(name: string) { export function restartKafkaConnector(name: string) {
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/restart`, { method: 'POST' }) return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/restart`, { method: 'POST' , credentials: 'same-origin' })
} }
export function pauseKafkaConnector(name: string) { export function pauseKafkaConnector(name: string) {
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/pause`, { method: 'POST' }) return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/pause`, { method: 'POST' , credentials: 'same-origin' })
} }
export function resumeKafkaConnector(name: string) { export function resumeKafkaConnector(name: string) {
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/resume`, { method: 'POST' }) return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/resume`, { method: 'POST' , credentials: 'same-origin' })
} }
export function triggerStreamingPipeline(pipelineId: string) { export function triggerStreamingPipeline(pipelineId: string) {
return fetch(`/api/pipeline/streaming/pipeline/${encodeURIComponent(pipelineId)}`, { method: 'POST' }) return fetch(`/api/pipeline/streaming/pipeline/${encodeURIComponent(pipelineId)}`, { method: 'POST' , credentials: 'same-origin' })
} }
export function exportHdfsToKafka(body?: { path?: string; topic?: string; limit?: number }) { export function exportHdfsToKafka(body?: { path?: string; topic?: string; limit?: number }) {
@@ -231,7 +281,7 @@ export function exportHdfsToKafka(body?: { path?: string; topic?: string; limit?
} }
export function setStreamingFlow(action: 'pause' | 'resume' | 'stop') { export function setStreamingFlow(action: 'pause' | 'resume' | 'stop') {
return fetch(`/api/pipeline/streaming/flow/${action}`, { method: 'POST' }) return fetch(`/api/pipeline/streaming/flow/${action}`, { method: 'POST' , credentials: 'same-origin' })
} }
// ── Spark Workbench ────────────────────────────────────────────── // ── Spark Workbench ──────────────────────────────────────────────
@@ -271,7 +321,7 @@ export async function fetchSparkRun(runId: string) {
} }
export function cancelSparkRun(runId: string) { export function cancelSparkRun(runId: string) {
return fetch(`/api/spark/run/${runId}/cancel`, { method: 'POST' }) return fetch(`/api/spark/run/${runId}/cancel`, { method: 'POST' , credentials: 'same-origin' })
} }
export async function fetchSparkLive() { export async function fetchSparkLive() {
+5 -5
View File
@@ -158,17 +158,17 @@ export const INFRA_CATALOG: InfraNode[] = [
{ {
id: 'gpu', id: 'gpu',
label: 'GPU Lab', label: 'GPU Lab',
vm: 'atc-gpu-dev', vm: 'atc-gpu-prod',
ip: '10.0.20.106', ip: '10.0.10.106',
zone: 'gpu', zone: 'gpu',
agentId: 'infra-sentinel', agentId: 'infra-sentinel',
icon: Sparkles, icon: Sparkles,
accent: '#3fb950', accent: '#3fb950',
description: 'vLLM inference — Llama 3 70B on 4× V100', description: 'vLLM inference — Llama 3 70B on 4× V100',
ssh: 'ssh root@10.0.20.106', ssh: 'ssh root@10.0.10.106',
apps: [ apps: [
{ label: 'GPU Lab UI', url: 'http://10.0.20.106:9000', port: '9000' }, { label: 'GPU Lab UI', url: 'http://10.0.10.106:9000', port: '9000' },
{ label: 'vLLM API', url: 'http://10.0.20.106:8001/v1', port: '8001' }, { label: 'vLLM API', url: 'http://10.0.10.106:8001/v1', port: '8001' },
], ],
topoIds: ['llm', 'cons-ml'], topoIds: ['llm', 'cons-ml'],
}, },
+48
View File
@@ -217,15 +217,63 @@ export type GpuDevice = {
export type GpuStatus = { export type GpuStatus = {
ok: boolean ok: boolean
host: string host: string
ip?: string
ui_url: string ui_url: string
inference_active?: boolean inference_active?: boolean
active_model?: string | null active_model?: string | null
vllm_url?: string | null vllm_url?: string | null
gpu_count?: number gpu_count?: number
gpus?: GpuDevice[] gpus?: GpuDevice[]
config_source?: string
preset_id?: string
config_label?: string
error?: string error?: string
} }
export type GpuPreset = {
id: string
label: string
vm: string
vmid: number
host: string
gpu_ui_port: number
llm_port: number
description: string
}
export type GpuTargetConfig = {
source: string
preset_id: string
label: string
host: string
gpu_url: string
gpu_ui_url: string
llm_url: string
env_gpu_url?: string
env_llm_url?: string
updated_at?: string
}
export type GpuConfigPayload = {
active: GpuTargetConfig
presets: GpuPreset[]
defaults: GpuTargetConfig
}
export type GpuConfigTestResult = {
ok: boolean
host: string
gpu_url: string
llm_url: string
metrics_ok: boolean
llm_ok: boolean
gpu_count: number
inference_active: boolean
active_model: string | null
errors: string[]
}
export type AgentState = 'idle' | 'walk' | 'fetch' | 'return' export type AgentState = 'idle' | 'walk' | 'fetch' | 'return'
export type AgentAnim = { export type AgentAnim = {