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:
+1
-1
@@ -4,7 +4,7 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py 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
|
||||
ENV DATABASE_URL=sqlite:////data/atc-agents.db
|
||||
EXPOSE 3201
|
||||
|
||||
@@ -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("/")
|
||||
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_API_TOKEN = os.getenv("DOCKHAND_API_TOKEN", "")
|
||||
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"))
|
||||
@@ -142,7 +143,8 @@ async def _infra_sentinel() -> None:
|
||||
async with httpx.AsyncClient(timeout=4.0) as client:
|
||||
async def _one(name: str, eid: int):
|
||||
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:
|
||||
return None
|
||||
d = r.json()
|
||||
|
||||
+47
-5
@@ -364,10 +364,36 @@ _CUST_INTERVAL = float(os.getenv("CUSTODIAN_OFFLOAD_INTERVAL_SECONDS", "120"))
|
||||
_CUST_BATCH = int(os.getenv("CUSTODIAN_OFFLOAD_BATCH", "200"))
|
||||
_CUST_TARGETS = [
|
||||
{"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",
|
||||
"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] = {
|
||||
"enabled": os.getenv("CUSTODIAN_OFFLOAD_ENABLED", "1") not in ("0", "false", "False", ""),
|
||||
"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
|
||||
tgt = _CUST_TARGETS[i % len(_CUST_TARGETS)]
|
||||
_custodian_state["idx"] = i + 1
|
||||
ddl = f"CREATE TABLE IF NOT EXISTS {tgt['target']} AS SELECT * FROM {tgt['src']} WHERE 1=0"
|
||||
dml = f"INSERT INTO {tgt['target']} SELECT * FROM {tgt['src']} LIMIT {_CUST_BATCH}"
|
||||
await _trino_collect("CREATE SCHEMA IF NOT EXISTS iceberg.hadoop", 1)
|
||||
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']}",
|
||||
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")
|
||||
ins = await _trino_collect(dml, 1)
|
||||
ok = bool(ins.get("ok"))
|
||||
|
||||
+229
@@ -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
@@ -24,6 +24,7 @@ Endpoints:
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import json
|
||||
import random
|
||||
import threading
|
||||
@@ -46,9 +47,9 @@ DATASETS = [
|
||||
|
||||
_lock = threading.Lock()
|
||||
_state: dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"interval_s": 60.0,
|
||||
"chunk": 5000,
|
||||
"enabled": os.getenv("ETL_OFFLOAD_ENABLED", "1") not in ("0", "false", "False"),
|
||||
"interval_s": float(os.getenv("ETL_OFFLOAD_INTERVAL_SECONDS", "30")),
|
||||
"chunk": int(os.getenv("ETL_OFFLOAD_CHUNK", "20000")),
|
||||
"running_cycle": False,
|
||||
"started_at": None,
|
||||
"last_cycle_ts": 0.0,
|
||||
@@ -440,6 +441,9 @@ def _loop() -> None:
|
||||
try:
|
||||
if _state["enabled"]:
|
||||
run_cycle()
|
||||
except Exception as exc:
|
||||
try:
|
||||
_feed(f"cycle error: {str(exc)[:160]}", level="err")
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(max(10.0, float(_state["interval_s"])))
|
||||
|
||||
@@ -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
@@ -17,6 +17,14 @@ from database_inventory import collect_database_inventory
|
||||
from node_registry import NODE_REGISTRY
|
||||
|
||||
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
|
||||
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")
|
||||
LAKEHOUSE_HOST = os.getenv("LAKEHOUSE_HOST", "10.0.21.50")
|
||||
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")
|
||||
TRINO_URL = os.getenv("TRINO_URL", f"http://{LAKEHOUSE_HOST}:8089")
|
||||
SPARK_UI_URL = os.getenv("SPARK_UI_URL", f"http://{LAKEHOUSE_HOST}:8080")
|
||||
GPU_URL = os.getenv("GPU_URL", "http://10.0.20.106:9000")
|
||||
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")
|
||||
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}")
|
||||
t0 = time.monotonic()
|
||||
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)
|
||||
r.raise_for_status()
|
||||
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]:
|
||||
await _log(log, "info", "fetch", "▸ GPU Lab metrics")
|
||||
base = {"ok": False, "host": GPU_URL, "ui_url": GPU_URL}
|
||||
gpu_url = GPU_URL
|
||||
host = GPU_URL
|
||||
if _get_gpu_urls is not None:
|
||||
try:
|
||||
metrics_url = f"{GPU_URL}/api/gpu/metrics"
|
||||
model_url = f"{GPU_URL}/api/active-model"
|
||||
u = _get_gpu_urls()
|
||||
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 {model_url}")
|
||||
t0 = time.monotonic()
|
||||
|
||||
+348
-41
@@ -15,6 +15,7 @@ import httpx
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import Body, FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import auth as cockpit_auth
|
||||
from agent_terminal import (
|
||||
get_all_terminals,
|
||||
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 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}
|
||||
_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")
|
||||
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)
|
||||
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_API_KEY = os.getenv("LLM_API_KEY", "sk-local")
|
||||
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.2–2.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 = [
|
||||
{
|
||||
@@ -247,7 +374,11 @@ ZONES = [
|
||||
]
|
||||
|
||||
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"],
|
||||
"hadoop-ranger": [
|
||||
"hadoop", "hdfs", "yarn", "datanode", "namenode", "replicatie", "replication",
|
||||
@@ -327,10 +458,24 @@ class ApprovalDecision(BaseModel):
|
||||
|
||||
def route_agent(message: str) -> str:
|
||||
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
|
||||
if any(w in lower for w in ("data", "opslag", "gb", "replicatie", "replication", "hdfs", "hadoop")):
|
||||
if not any(w in lower for w in ("postgres", "mysql", "mongo", "database", "sql", "neo4j", "cassandra")):
|
||||
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", "pii", "email")):
|
||||
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()}
|
||||
best = max(scores, key=scores.get)
|
||||
if scores[best] == 0:
|
||||
@@ -338,13 +483,34 @@ def route_agent(message: str) -> str:
|
||||
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(
|
||||
agent_id: str,
|
||||
status: dict[str, Any],
|
||||
log: Any | None = None,
|
||||
message: str | None = None,
|
||||
) -> str:
|
||||
"""Full lab snapshot for vLLM — all domains, agent's primary domain highlighted."""
|
||||
snapshot = await collect_full_lab_context(gpu_data=status.get("gpu"), log=log)
|
||||
snapshot = await collect_full_lab_context(gpu_data=status.get("gpu"), log=log, include_inventory=False)
|
||||
snapshot["domains_summary"] = status.get("domains", {})
|
||||
ctx = format_context_for_agent(agent_id, snapshot)
|
||||
agent_lines = ["", "=== AGENTS & SUPERVISORS ==="]
|
||||
@@ -352,11 +518,25 @@ async def gather_agent_context(
|
||||
sup = " [supervisor]" if a.get("supervisor") else ""
|
||||
agent_lines.append(f" - {a['name']} ({a['id']}){sup}: {a['role']}")
|
||||
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:
|
||||
from platform_context import build_llm_addendum
|
||||
ctx = ctx + "\n\n" + build_llm_addendum()
|
||||
except Exception:
|
||||
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:
|
||||
await log("info", "fetch", f"▸ Context assembled: {len(ctx)} chars for LLM")
|
||||
return ctx
|
||||
@@ -369,34 +549,44 @@ async def ask_llm(
|
||||
log: Any | None = None,
|
||||
) -> str | None:
|
||||
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.
|
||||
Specialization: {agent['role']}.
|
||||
Motto: {agent.get('motto', '')}.
|
||||
|
||||
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.
|
||||
|
||||
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}
|
||||
"""
|
||||
# Deterministic PII path: always mirror Data Flow masked vs visible toggles.
|
||||
if _is_pii_question(message):
|
||||
try:
|
||||
from pii_catalog import format_pii_chat_answer
|
||||
answer = format_pii_chat_answer(message)
|
||||
if log:
|
||||
await log("info", "llm", f"▸ Querying vLLM model={LLM_MODEL}")
|
||||
await log("cmd", "llm", f"$ POST {LLM_URL.rstrip('/')}/chat/completions")
|
||||
await log("ok", "pii", "▸ Returning Data Flow–synced masking answer (masked + visible)")
|
||||
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 ''}")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client:
|
||||
t0 = time.monotonic()
|
||||
r = await client.post(
|
||||
f"{LLM_URL.rstrip('/')}/chat/completions",
|
||||
f"{llm_url.rstrip('/')}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {LLM_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
@@ -407,7 +597,7 @@ Rules:
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": message},
|
||||
],
|
||||
"max_tokens": 800,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": 0.25,
|
||||
},
|
||||
)
|
||||
@@ -422,15 +612,78 @@ Rules:
|
||||
return content
|
||||
if log:
|
||||
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:
|
||||
if log:
|
||||
await log("err", "llm", f"✗ vLLM error: {exc}")
|
||||
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)
|
||||
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:
|
||||
@@ -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]:
|
||||
try:
|
||||
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()
|
||||
return r.json()
|
||||
except Exception:
|
||||
@@ -497,15 +754,26 @@ async def probe_url(url: str) -> bool:
|
||||
|
||||
|
||||
async def collect_gpu() -> dict[str, Any]:
|
||||
host = GPU_URL.replace("http://", "").replace("https://", "").split("/")[0]
|
||||
base = {"ok": False, "host": host, "ui_url": GPU_UI_URL}
|
||||
urls = get_gpu_urls()
|
||||
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:
|
||||
async with httpx.AsyncClient(timeout=6.0) as client:
|
||||
metrics_r, model_r, integration_r = await asyncio.gather(
|
||||
client.get(f"{GPU_URL}/api/gpu/metrics"),
|
||||
client.get(f"{GPU_URL}/api/active-model"),
|
||||
client.get(f"{GPU_URL}/api/integration"),
|
||||
client.get(f"{gpu_url}/api/gpu/metrics"),
|
||||
client.get(f"{gpu_url}/api/active-model"),
|
||||
client.get(f"{gpu_url}/api/integration"),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
@@ -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…")
|
||||
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)
|
||||
if not answer:
|
||||
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:
|
||||
proposed = detect_agent_proposed_action(answer, message)
|
||||
@@ -784,6 +1052,10 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Authentik OIDC session + API guard
|
||||
cockpit_auth.init_auth_middleware(app)
|
||||
cockpit_auth.setup_auth(app)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
@@ -983,6 +1255,41 @@ async def get_status():
|
||||
async def get_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]]:
|
||||
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")
|
||||
log = make_logger(node_id)
|
||||
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"
|
||||
answer = await ask_llm(agent_id, message, context + node_ctx, log=log)
|
||||
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 publish_event({"type": "node_ask_result", "node_id": node_id, "agent_id": agent_id, "answer": answer})
|
||||
|
||||
|
||||
@@ -153,6 +153,25 @@ def build_node_detail(node_id: str, snap: dict[str, Any], workload_node: dict |
|
||||
wn = workload_node or {}
|
||||
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] = {
|
||||
"id": node_id,
|
||||
"agent_id": agent_id,
|
||||
|
||||
@@ -183,21 +183,21 @@ NODE_REGISTRY: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"gpu": {
|
||||
"label": "GPU Lab",
|
||||
"vm": "atc-gpu-dev",
|
||||
"vmid": 303,
|
||||
"vm": "atc-gpu-prod",
|
||||
"vmid": 306,
|
||||
"pve": "atc-gpu",
|
||||
"ip": "10.0.20.106",
|
||||
"ssh": "ssh root@10.0.20.106",
|
||||
"ip": "10.0.10.106",
|
||||
"ssh": "ssh root@10.0.10.106",
|
||||
"role": "inference",
|
||||
"color": "#3fb950",
|
||||
"description": "4× V100 GPU lab. vLLM serves the active model (Llama 3 70B GPTQ) — powers agent reasoning in this Command Center.",
|
||||
"description": "4× V100 GPU lab (VM306). vLLM serves the active model (Llama 3 70B GPTQ) — powers agent reasoning in this Command Center.",
|
||||
"links": [
|
||||
{"label": "GPU Lab UI", "url": "http://10.0.20.106:9000"},
|
||||
{"label": "vLLM API", "url": "http://10.0.20.106:8001/v1"},
|
||||
{"label": "GPU Lab UI", "url": "http://10.0.10.106:9000"},
|
||||
{"label": "vLLM API", "url": "http://10.0.10.106:8001/v1"},
|
||||
],
|
||||
"endpoints": [
|
||||
{"name": "gpu-lab", "host": "10.0.20.106", "port": "9000", "proto": "http"},
|
||||
{"name": "vllm", "host": "10.0.20.106", "port": "8001", "proto": "http"},
|
||||
{"name": "gpu-lab", "host": "10.0.10.106", "port": "9000", "proto": "http"},
|
||||
{"name": "vllm", "host": "10.0.10.106", "port": "8001", "proto": "http"},
|
||||
],
|
||||
"commands": ["gpu metrics", "model status", "vram usage"],
|
||||
},
|
||||
|
||||
+251
-4
@@ -34,6 +34,7 @@ POLICY_PATH = Path(os.getenv("MASKING_POLICY_PATH", "/data/masking_policy.json")
|
||||
DEFAULT_MASKED = True
|
||||
MASK_TOKEN = "🔒 MASKED (masking policy ON)"
|
||||
_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.
|
||||
# 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]:
|
||||
global _policy_cache
|
||||
if _policy_cache is None:
|
||||
"""Load policy from disk; re-read when the file mtime changes (Data Flow toggles)."""
|
||||
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:
|
||||
_policy_cache = {k: bool(v) for k, v in json.loads(POLICY_PATH.read_text()).items()}
|
||||
except Exception:
|
||||
if _policy_cache is None:
|
||||
_policy_cache = {}
|
||||
_policy_mtime = mtime
|
||||
return _policy_cache
|
||||
|
||||
|
||||
def _save_policy(p: dict[str, bool]) -> None:
|
||||
global _policy_cache
|
||||
global _policy_cache, _policy_mtime
|
||||
_policy_cache = p
|
||||
try:
|
||||
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
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:
|
||||
pass
|
||||
|
||||
@@ -325,10 +347,235 @@ async def set_policy(body: MaskPolicyRequest) -> JSONResponse:
|
||||
for col in cols:
|
||||
p[f"{key}.{col}"] = bool(body.masked)
|
||||
_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)})
|
||||
|
||||
|
||||
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,
|
||||
search: str | None, limit: int) -> tuple[list[str], list[list[Any]]]:
|
||||
"""Fetch rows from the source. Native DB queries (fast, early LIMIT) for
|
||||
|
||||
+17
-12
@@ -90,15 +90,15 @@ def build_platform_section() -> str:
|
||||
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
|
||||
data without ever revealing masked raw values."""
|
||||
lines: list[str] = ["=== DATA MASKING POLICY (enforced) ==="]
|
||||
data without ever revealing masked raw values. Synced with Data Flow toggles."""
|
||||
lines: list[str] = ["=== DATA MASKING POLICY (enforced — synced with Data Flow) ==="]
|
||||
masked: list[str] = []
|
||||
unmasked: list[str] = []
|
||||
try:
|
||||
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 c in d.get("pii_columns", []):
|
||||
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})")
|
||||
|
||||
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]:
|
||||
lines.append(f" - {m}")
|
||||
else:
|
||||
lines.append("MASKED columns: (none)")
|
||||
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]:
|
||||
lines.append(f" - {u}")
|
||||
else:
|
||||
lines.append("VISIBLE columns: (none — all PII masked)")
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"How to handle masked 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.",
|
||||
" 2. DO still answer helpfully: confirm the column exists and is masked for privacy/governance, and explain why (PII protection policy).",
|
||||
" 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.",
|
||||
" 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.",
|
||||
" 5. Unmasked PII columns may be shown, but flag that they are sensitive.",
|
||||
"How to handle masked vs visible data when answering:",
|
||||
" 1. MASKED: NEVER reveal, guess, or reconstruct raw values. Quote '🔒 MASKED' when present.",
|
||||
" 2. VISIBLE: you MAY show the real sample values and state that the operator made them visible in Data Flow.",
|
||||
" 3. DO still answer helpfully: confirm which columns are masked vs visible from the lists above.",
|
||||
" 4. You MAY use non-sensitive aggregates/counts over masked columns without exposing individuals.",
|
||||
" 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)
|
||||
|
||||
|
||||
+14
-3
@@ -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 []
|
||||
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 = [
|
||||
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"
|
||||
@@ -284,11 +295,11 @@ def build_presentation_payload(snap: dict[str, Any]) -> dict[str, Any]:
|
||||
slides.append(_slide(
|
||||
"gpu",
|
||||
"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"API: {snap.get('gpu', {}).get('vllm_url') or 'http://10.0.20.106:8001/v1'}",
|
||||
"GPU Lab UI: http://10.0.20.106:9000",
|
||||
f"API: {gpu_id['llm_url']}",
|
||||
f"GPU Lab UI: {gpu_id['ui_url']}",
|
||||
"Kibana/Elastic: http://10.0.21.46:5601",
|
||||
*gpu_lines,
|
||||
],
|
||||
|
||||
@@ -151,7 +151,7 @@ MODERN_DECKS: dict[str, dict[str, Any]] = {
|
||||
"bullets": [
|
||||
"Supervisor + field operators on Command Center",
|
||||
"Each agent sees live workload, GPU, databases, topology",
|
||||
"LLM: Llama 3 70B GPTQ via vLLM (10.0.20.106:8001)",
|
||||
"LLM: Llama 3 70B GPTQ via vLLM (10.0.10.106:8001)",
|
||||
"Approval workflow for sensitive operations",
|
||||
],
|
||||
},
|
||||
@@ -161,7 +161,7 @@ MODERN_DECKS: dict[str, dict[str, Any]] = {
|
||||
"subtitle": "Dell ATC cluster — key IPs",
|
||||
"bullets": [
|
||||
"Command Center VM304: 10.0.21.33 (this dashboard)",
|
||||
"GPU Lab VM303: 10.0.20.106 — 7× V100, vLLM, model manager",
|
||||
"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",
|
||||
"Docling UI: http://10.0.21.33:5001/ui/",
|
||||
],
|
||||
|
||||
@@ -17,3 +17,5 @@ boto3==1.35.99
|
||||
paramiko==3.5.0
|
||||
aiokafka==0.12.0
|
||||
pyarrow==18.1.0
|
||||
authlib==1.4.1
|
||||
itsdangerous==2.2.0
|
||||
|
||||
+14
-3
@@ -537,13 +537,24 @@ def _build_architecture(
|
||||
1, 1, "📓",
|
||||
)
|
||||
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(
|
||||
"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"],
|
||||
[{"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), "🤖",
|
||||
{"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 = [
|
||||
|
||||
+28
-3
@@ -63,6 +63,18 @@ def build_workload_payload(snap: dict[str, Any]) -> dict[str, Any]:
|
||||
gpu = snap.get("gpu", {})
|
||||
objectscale = snap.get("objectscale", {})
|
||||
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", [])]
|
||||
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:
|
||||
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
|
||||
|
||||
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",
|
||||
zones[-1]["apps"], hadoop.get("live_datanodes", 0), zones[-1]["total"],
|
||||
{"hdfs_used_gb": hadoop.get("capacity_used_gb"), "hdfs_total_gb": hadoop.get("capacity_total_gb")}),
|
||||
_node("gpu", "GPU Lab", "atc-gpu-dev", "10.0.20.106", 88, 52, "#76b900", "ok" if gpu.get("ok") else "down", "inference",
|
||||
[{"name": gpu.get("active_model") or "vLLM", "state": "running" if gpu.get("inference_active") else "down", "image": "vllm", "ports": ["8001"]}],
|
||||
_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", "9000"]}],
|
||||
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",
|
||||
_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 [
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
handle_path /dq/* {
|
||||
reverse_proxy dq-api:5010
|
||||
}
|
||||
handle /auth/* {
|
||||
reverse_proxy api:3201
|
||||
}
|
||||
handle /api/* {
|
||||
reverse_proxy api:3201
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ S3_REGION=us-east-1
|
||||
|
||||
JUPYTER_TOKEN=choose-a-strong-token
|
||||
|
||||
GPU_URL=http://10.0.20.106:9000
|
||||
LLM_URL=http://10.0.20.106:8001/v1
|
||||
GPU_URL=http://10.0.10.106:9000
|
||||
LLM_URL=http://10.0.10.106:8001/v1
|
||||
LLM_MODEL=gpt-4o
|
||||
LLM_API_KEY=sk-local
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@ services:
|
||||
SQLITE_FALLBACK_PATH: /data/atc-agents.db
|
||||
DOCLING_URL: http://docling-serve:5001
|
||||
PRESENTATIONS_DIR: /data/presentations
|
||||
GPU_URL: http://10.0.20.106:9000
|
||||
GPU_UI_URL: http://10.0.20.106:9000
|
||||
LLM_URL: http://10.0.20.106:8001/v1
|
||||
GPU_URL: http://10.0.10.106:9000
|
||||
GPU_UI_URL: http://10.0.10.106:9000
|
||||
LLM_URL: http://10.0.10.106:8001/v1
|
||||
LLM_MODEL: gpt-4o
|
||||
LLM_API_KEY: sk-local
|
||||
LAKEHOUSE_HOST: 10.0.21.50
|
||||
@@ -87,7 +87,7 @@ services:
|
||||
CHROMA_HOST: chromadb
|
||||
CHROMA_PORT: 8000
|
||||
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_API_KEY: sk-local
|
||||
RAG_DATA_DIR: /data
|
||||
|
||||
@@ -30,9 +30,9 @@ services:
|
||||
DOCKHAND_URL: http://10.0.21.45:8082
|
||||
DATABASE_URL: postgresql+psycopg2://atc:atc-agents-pg@postgres:5432/atc_agents
|
||||
SQLITE_FALLBACK_PATH: /data/atc-agents.db
|
||||
GPU_URL: http://10.0.20.106:9000
|
||||
GPU_UI_URL: http://10.0.20.106:9000
|
||||
LLM_URL: http://10.0.20.106:8001/v1
|
||||
GPU_URL: http://10.0.10.106:9000
|
||||
GPU_UI_URL: http://10.0.10.106:9000
|
||||
LLM_URL: http://10.0.10.106:8001/v1
|
||||
LLM_MODEL: qwen2.5-32b-gptq
|
||||
LLM_API_KEY: sk-local
|
||||
LAKEHOUSE_HOST: 10.0.21.50
|
||||
|
||||
+6
-10
@@ -32,9 +32,9 @@ services:
|
||||
SQLITE_FALLBACK_PATH: /data/atc-agents.db
|
||||
DOCLING_URL: http://docling-serve:5001
|
||||
PRESENTATIONS_DIR: /data/presentations
|
||||
GPU_URL: http://10.0.20.106:9000
|
||||
GPU_UI_URL: http://10.0.20.106:9000
|
||||
LLM_URL: http://10.0.20.106:8001/v1
|
||||
GPU_URL: http://10.0.10.106:9000
|
||||
GPU_UI_URL: http://10.0.10.106:9000
|
||||
LLM_URL: http://10.0.10.106:8001/v1
|
||||
LLM_MODEL: gpt-4o
|
||||
LLM_API_KEY: sk-local
|
||||
LAKEHOUSE_HOST: 10.0.21.50
|
||||
@@ -44,10 +44,6 @@ services:
|
||||
OPENMETADATA_URL: ${OPENMETADATA_URL:-http://10.0.21.47:8585}
|
||||
KAFKA_UI_URL: http://10.0.21.36:9000
|
||||
HDFS_NN_URL: http://10.0.21.61:9870
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020}
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-object_admin1}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
ELASTICSEARCH_URL: ${ELASTICSEARCH_URL:-https://10.0.21.46:9200}
|
||||
KIBANA_URL: ${KIBANA_URL:-http://10.0.21.46:5601}
|
||||
ELASTIC_USER: ${ELASTIC_USER:-elastic}
|
||||
@@ -94,7 +90,7 @@ services:
|
||||
CHROMA_HOST: chromadb
|
||||
CHROMA_PORT: 8000
|
||||
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_API_KEY: sk-local
|
||||
RAG_DATA_DIR: /data
|
||||
@@ -124,10 +120,10 @@ services:
|
||||
jupyter:
|
||||
image: quay.io/jupyter/scipy-notebook:latest
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- atc.env
|
||||
environment:
|
||||
JUPYTER_TOKEN: ${JUPYTER_TOKEN:-atc-jupyter}
|
||||
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-object_admin1}
|
||||
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-http://10.0.20.111:9020}
|
||||
AWS_DEFAULT_REGION: ${S3_REGION:-us-east-1}
|
||||
ELASTICSEARCH_URL: ${ELASTICSEARCH_URL:-https://10.0.21.46:9200}
|
||||
|
||||
Generated
+2868
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useAuth } from './hooks/useAuth'
|
||||
import { LoginView } from './components/features/LoginView'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { useClock } from './hooks/useClock'
|
||||
import { useCommandCenter } from './hooks/useCommandCenter'
|
||||
@@ -27,6 +29,7 @@ import { resolveInfraNode } from './lib/infraCatalog'
|
||||
import { cn } from './lib/utils'
|
||||
|
||||
export default function App() {
|
||||
const auth = useAuth()
|
||||
const clock = useClock()
|
||||
const cc = useCommandCenter()
|
||||
const [gpuChatActive, setGpuChatActive] = useState(false)
|
||||
@@ -53,6 +56,19 @@ export default function App() {
|
||||
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 (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-surface">
|
||||
<TopBar
|
||||
@@ -62,6 +78,8 @@ export default function App() {
|
||||
agents={cc.agents}
|
||||
approvals={cc.approvals}
|
||||
onApprovalsClick={openApprovals}
|
||||
userLabel={userLabel}
|
||||
onLogout={() => { window.location.href = '/auth/logout' }}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Activity, ChevronDown, ChevronUp, Cpu, ExternalLink, Zap } from 'lucide-react'
|
||||
import { fetchGpu } from '../../lib/api'
|
||||
import type { GpuDevice, GpuStatus } from '../../types'
|
||||
import { Activity, ChevronDown, ChevronUp, Cpu, ExternalLink, Settings2, Zap } from 'lucide-react'
|
||||
import { fetchGpu, fetchGpuConfig, resetGpuConfig, saveGpuConfig, testGpuConfig } from '../../lib/api'
|
||||
import type { GpuConfigPayload, GpuConfigTestResult, GpuDevice, GpuStatus } from '../../types'
|
||||
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
|
||||
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 [lastPoll, setLastPoll] = useState<Date | null>(null)
|
||||
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(() => {
|
||||
setLocalGpu(gpu)
|
||||
@@ -62,6 +69,19 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
|
||||
if (boost) setExpanded(true)
|
||||
}, [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(() => {
|
||||
const poll = async () => {
|
||||
const g = await fetchGpu()
|
||||
@@ -76,6 +96,49 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
|
||||
return () => clearInterval(iv)
|
||||
}, [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 devices = g?.gpus || []
|
||||
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">
|
||||
<Cpu className="h-3 w-3" /> GPU Matrix
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -130,6 +217,17 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
|
||||
)}
|
||||
</button>
|
||||
<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 && (
|
||||
<a
|
||||
href={g.ui_url}
|
||||
@@ -207,11 +305,121 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
|
||||
</div>
|
||||
|
||||
<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()}`}
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 { Badge } from '../ui/Badge'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
@@ -11,9 +11,11 @@ type Props = {
|
||||
agents: Agent[]
|
||||
approvals: Approval[]
|
||||
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 running = workload?.totals?.apps_running ?? 0
|
||||
const total = workload?.totals?.apps_total ?? 0
|
||||
@@ -53,6 +55,22 @@ export function TopBar({ clock, workload, agents, approvals, onApprovalsClick }:
|
||||
</div>
|
||||
|
||||
<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 />
|
||||
<div className="flex items-center gap-1.5 font-mono text-[10px] text-foreground-muted">
|
||||
<Clock className="h-3 w-3" />
|
||||
|
||||
@@ -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
@@ -6,6 +6,8 @@ import type {
|
||||
CdcStats,
|
||||
DataflowGraph,
|
||||
FeedEntry,
|
||||
GpuConfigPayload,
|
||||
GpuConfigTestResult,
|
||||
GpuStatus,
|
||||
Movement,
|
||||
PiiDataset,
|
||||
@@ -21,7 +23,7 @@ async function fetchJson<T>(url: string, timeoutMs = 10000): Promise<T | null> {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
||||
try {
|
||||
const r = await fetch(url, { signal: ctrl.signal })
|
||||
const r = await fetch(url, { signal: ctrl.signal, credentials: 'same-origin' })
|
||||
if (!r.ok) return null
|
||||
return (await r.json()) as T
|
||||
} catch {
|
||||
@@ -62,6 +64,54 @@ export async function fetchGpu(): Promise<GpuStatus | null> {
|
||||
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[]>> {
|
||||
const j = await fetchJson<{ terminals?: Record<string, TerminalLine[]> }>('/api/terminals', 8000)
|
||||
return j?.terminals || {}
|
||||
@@ -77,7 +127,7 @@ export async function fetchNodeDetail(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) {
|
||||
@@ -207,19 +257,19 @@ export function triggerStreamingJob(jobId: string, conf?: Record<string, unknown
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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 }) {
|
||||
@@ -231,7 +281,7 @@ export function exportHdfsToKafka(body?: { path?: string; topic?: string; limit?
|
||||
}
|
||||
|
||||
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 ──────────────────────────────────────────────
|
||||
@@ -271,7 +321,7 @@ export async function fetchSparkRun(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() {
|
||||
|
||||
@@ -158,17 +158,17 @@ export const INFRA_CATALOG: InfraNode[] = [
|
||||
{
|
||||
id: 'gpu',
|
||||
label: 'GPU Lab',
|
||||
vm: 'atc-gpu-dev',
|
||||
ip: '10.0.20.106',
|
||||
vm: 'atc-gpu-prod',
|
||||
ip: '10.0.10.106',
|
||||
zone: 'gpu',
|
||||
agentId: 'infra-sentinel',
|
||||
icon: Sparkles,
|
||||
accent: '#3fb950',
|
||||
description: 'vLLM inference — Llama 3 70B on 4× V100',
|
||||
ssh: 'ssh root@10.0.20.106',
|
||||
ssh: 'ssh root@10.0.10.106',
|
||||
apps: [
|
||||
{ label: 'GPU Lab UI', url: 'http://10.0.20.106:9000', port: '9000' },
|
||||
{ label: 'vLLM API', url: 'http://10.0.20.106:8001/v1', port: '8001' },
|
||||
{ label: 'GPU Lab UI', url: 'http://10.0.10.106:9000', port: '9000' },
|
||||
{ label: 'vLLM API', url: 'http://10.0.10.106:8001/v1', port: '8001' },
|
||||
],
|
||||
topoIds: ['llm', 'cons-ml'],
|
||||
},
|
||||
|
||||
@@ -217,15 +217,63 @@ export type GpuDevice = {
|
||||
export type GpuStatus = {
|
||||
ok: boolean
|
||||
host: string
|
||||
ip?: string
|
||||
ui_url: string
|
||||
inference_active?: boolean
|
||||
active_model?: string | null
|
||||
vllm_url?: string | null
|
||||
gpu_count?: number
|
||||
gpus?: GpuDevice[]
|
||||
config_source?: string
|
||||
preset_id?: string
|
||||
config_label?: 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 AgentAnim = {
|
||||
|
||||
Reference in New Issue
Block a user