From 9008fbd512dbf2fff53fedbbfce48cc6d0900a2f Mon Sep 17 00:00:00 2001 From: mo Date: Tue, 21 Jul 2026 23:20:24 +0000 Subject: [PATCH] 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. --- api/Dockerfile | 2 +- api/agent_activity.py | 4 +- api/agent_ops.py | 58 +- api/auth.py | 229 ++ api/etl_offload.py | 14 +- api/gpu_config.py | 304 ++ api/lab_context.py | 34 +- api/main.py | 391 ++- api/node_ops.py | 19 + api/node_registry.py | 18 +- api/pii_catalog.py | 257 +- api/platform_context.py | 29 +- api/presentation.py | 17 +- api/presentation_static.py | 4 +- api/requirements.txt | 2 + api/topology_views.py | 17 +- api/workload.py | 31 +- caddy/Caddyfile | 3 + config/command-center/atc.env.example | 4 +- config/command-center/docker-compose.yml | 8 +- docker-compose.dockhand.yml | 6 +- docker-compose.yml | 16 +- ui/package-lock.json | 2868 +++++++++++++++++ ui/src/App.tsx | 18 + ui/src/components/features/GpuMatrixPanel.tsx | 218 +- ui/src/components/features/LoginView.tsx | 47 + ui/src/components/layout/TopBar.tsx | 22 +- ui/src/hooks/useAuth.ts | 42 + ui/src/lib/api.ts | 66 +- ui/src/lib/infraCatalog.ts | 10 +- ui/src/types.ts | 48 + 31 files changed, 4667 insertions(+), 139 deletions(-) create mode 100644 api/auth.py create mode 100644 api/gpu_config.py create mode 100644 ui/package-lock.json create mode 100644 ui/src/components/features/LoginView.tsx create mode 100644 ui/src/hooks/useAuth.ts diff --git a/api/Dockerfile b/api/Dockerfile index 9a13ba6..8d831a8 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py 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 diff --git a/api/agent_activity.py b/api/agent_activity.py index 7e86e86..2a4a868 100644 --- a/api/agent_activity.py +++ b/api/agent_activity.py @@ -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() diff --git a/api/agent_ops.py b/api/agent_ops.py index b4d1f1f..07fc43c 100644 --- a/api/agent_ops.py +++ b/api/agent_ops.py @@ -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 _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) + 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") + 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")) diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..b8e926c --- /dev/null +++ b/api/auth.py @@ -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) diff --git a/api/etl_offload.py b/api/etl_offload.py index d73b7ac..bc65d44 100644 --- a/api/etl_offload.py +++ b/api/etl_offload.py @@ -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,8 +441,11 @@ def _loop() -> None: try: if _state["enabled"]: run_cycle() - except Exception: - pass + 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"]))) diff --git a/api/gpu_config.py b/api/gpu_config.py new file mode 100644 index 0000000..dad7c73 --- /dev/null +++ b/api/gpu_config.py @@ -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 diff --git a/api/lab_context.py b/api/lab_context.py index ead9f5e..0cb4980 100644 --- a/api/lab_context.py +++ b/api/lab_context.py @@ -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: + 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" + 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() diff --git a/api/main.py b/api/main.py index bdab09b..0c91a15 100644 --- a/api/main.py +++ b/api/main.py @@ -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 ==="] @@ -353,10 +519,24 @@ async def gather_agent_context( agent_lines.append(f" - {a['name']} ({a['id']}){sup}: {a['role']}") ctx = ctx + "\n".join(agent_lines) try: - from platform_context import build_llm_addendum - ctx = ctx + "\n\n" + build_llm_addendum() + 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: - pass + 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', '')}. + # 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("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. -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. +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. -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} -""" +--- 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: - await log("info", "llm", f"▸ Querying vLLM model={LLM_MODEL}") - await log("cmd", "llm", f"$ POST {LLM_URL.rstrip('/')}/chat/completions") + 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}) diff --git a/api/node_ops.py b/api/node_ops.py index 264cfbd..cd8ec28 100644 --- a/api/node_ops.py +++ b/api/node_ops.py @@ -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, diff --git a/api/node_registry.py b/api/node_registry.py index 0f057f2..b5a6901 100644 --- a/api/node_registry.py +++ b/api/node_registry.py @@ -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"], }, diff --git a/api/pii_catalog.py b/api/pii_catalog.py index d5a149e..11277a0 100644 --- a/api/pii_catalog.py +++ b/api/pii_catalog.py @@ -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: - _policy_cache = {} + 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 diff --git a/api/platform_context.py b/api/platform_context.py index e34ae4f..3b9b651 100644 --- a/api/platform_context.py +++ b/api/platform_context.py @@ -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) diff --git a/api/presentation.py b/api/presentation.py index 6b73627..e08ea84 100644 --- a/api/presentation.py +++ b/api/presentation.py @@ -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, ], diff --git a/api/presentation_static.py b/api/presentation_static.py index edc9630..fbeac0e 100644 --- a/api/presentation_static.py +++ b/api/presentation_static.py @@ -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/", ], diff --git a/api/requirements.txt b/api/requirements.txt index 545379e..21f2db5 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -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 diff --git a/api/topology_views.py b/api/topology_views.py index 7c015ba..468057b 100644 --- a/api/topology_views.py +++ b/api/topology_views.py @@ -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 = [ diff --git a/api/workload.py b/api/workload.py index 6ae2329..56a4d5d 100644 --- a/api/workload.py +++ b/api/workload.py @@ -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 [ diff --git a/caddy/Caddyfile b/caddy/Caddyfile index 519744f..5f356db 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -8,6 +8,9 @@ handle_path /dq/* { reverse_proxy dq-api:5010 } + handle /auth/* { + reverse_proxy api:3201 + } handle /api/* { reverse_proxy api:3201 } diff --git a/config/command-center/atc.env.example b/config/command-center/atc.env.example index 2b16e59..7396bd7 100644 --- a/config/command-center/atc.env.example +++ b/config/command-center/atc.env.example @@ -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 diff --git a/config/command-center/docker-compose.yml b/config/command-center/docker-compose.yml index e1cc2f7..66cbafc 100644 --- a/config/command-center/docker-compose.yml +++ b/config/command-center/docker-compose.yml @@ -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 diff --git a/docker-compose.dockhand.yml b/docker-compose.dockhand.yml index cb4e669..61120bd 100644 --- a/docker-compose.dockhand.yml +++ b/docker-compose.dockhand.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 3dc7022..b121c89 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..7a7fe36 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,2868 @@ +{ + "name": "atc-command-center", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "atc-command-center", + "version": "2.0.0", + "dependencies": { + "@tanstack/react-query": "^5.62.8", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "typescript": "^5.7.2", + "vite": "^6.0.3" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.3", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.3.tgz", + "integrity": "sha512-bJRzflk8GgE4JX+iZNEwz9f9p460NCHnU7bd+CZ9vIjIlZuTkt6F3WSl2oNO8StZBFx17nLEsiQ6H2wcZiY7nA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001805", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.392", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", + "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e105178..21c3a4c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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 ( +
+ Checking session… +
+ ) + } + if (auth.status === 'anon') { + return + } + + const userLabel = auth.user?.name || auth.user?.preferred_username || auth.user?.email || 'Signed in' + return (
{ window.location.href = '/auth/logout' }} />
diff --git a/ui/src/components/features/GpuMatrixPanel.tsx b/ui/src/components/features/GpuMatrixPanel.tsx index d64b334..eb058be 100644 --- a/ui/src/components/features/GpuMatrixPanel.tsx +++ b/ui/src/components/features/GpuMatrixPanel.tsx @@ -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(gpu) const [lastPoll, setLastPoll] = useState(null) const [expanded, setExpanded] = useState(false) + const [showSettings, setShowSettings] = useState(false) + const [gpuConfig, setGpuConfig] = useState(null) + const [selectedPreset, setSelectedPreset] = useState('gpu-prod') + const [customHost, setCustomHost] = useState('') + const [configBusy, setConfigBusy] = useState(false) + const [testResult, setTestResult] = useState(null) + const [configMsg, setConfigMsg] = useState(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)

GPU Matrix

-

GPU Lab offline

+

+ GPU Lab offline{g?.host ? ` · ${g.ip || g.host}` : ''} +

+ + {showSettings && gpuConfig && ( + + )} ) } @@ -130,6 +217,17 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props) )} )} ) } + +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 ( +
+

GPU Target

+ + {selectedPreset === 'custom' && ( + 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" + /> + )} +
+ + + +
+ {testResult && ( +

+ {testResult.ok + ? `OK · ${testResult.gpu_count} GPU(s) · ${testResult.active_model || 'no model'}` + : `Failed · ${testResult.errors.join('; ') || 'unreachable'}`} +

+ )} + {configMsg &&

{configMsg}

} +

+ Active: {gpuConfig.active.label} ({gpuConfig.active.host}) +

+
+ ) +} diff --git a/ui/src/components/features/LoginView.tsx b/ui/src/components/features/LoginView.tsx new file mode 100644 index 0000000..4551dc2 --- /dev/null +++ b/ui/src/components/features/LoginView.tsx @@ -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 ( +
+ ) +} diff --git a/ui/src/components/layout/TopBar.tsx b/ui/src/components/layout/TopBar.tsx index 99f7cb7..0337434 100644 --- a/ui/src/components/layout/TopBar.tsx +++ b/ui/src/components/layout/TopBar.tsx @@ -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 }:
+ {userLabel ? ( + + {userLabel} + + ) : null} + {onLogout ? ( + + ) : null}
diff --git a/ui/src/hooks/useAuth.ts b/ui/src/hooks/useAuth.ts new file mode 100644 index 0000000..95eb83f --- /dev/null +++ b/ui/src/hooks/useAuth.ts @@ -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 } { + const [state, setState] = useState({ 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 } +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 4d684c6..b92a2a7 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -6,6 +6,8 @@ import type { CdcStats, DataflowGraph, FeedEntry, + GpuConfigPayload, + GpuConfigTestResult, GpuStatus, Movement, PiiDataset, @@ -21,7 +23,7 @@ async function fetchJson(url: string, timeoutMs = 10000): Promise { 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 { return fetchJson('/api/gpu', 8000) } +export async function fetchGpuConfig(): Promise { + return fetchJson('/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 { + 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> { const j = await fetchJson<{ terminals?: Record }>('/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