From 8892144e0acf04734b2b56e59b8d170780dbb31b Mon Sep 17 00:00:00 2001 From: Parham Monfared Date: Thu, 6 Aug 2026 08:21:59 +0100 Subject: [PATCH] Add shift handover and RunPod, and make CX-Tools work in a container Handover - The Confluence shift doc becomes the landing page: shift metadata, the top-of-page checks, key updates with their Zendesk/Jira refs and status, and the free-text comments. "Hand over shift" closes the shift, opens the next one and carries the live items across, dropping anything done or marked "remove at end of shift" - the retyping this replaces. - The RunPod table on that page is read from live host state instead of being copied in by hand, with the six-colour key preserved. RunPod - GraphQL client keyed on CX_RUNPOD_API_KEY. The old console login is kept as a fallback but cannot run unattended: the account has 2FA, so Clerk verifies the password and then asks for an emailed code and never issues a session. That is the real cause of the "No active session found" failure, and the client now says so instead of failing opaquely. TOTP is supported if the account moves to an authenticator app. - Hosts and their listing history are persisted, so "most problematic hosts" can be ranked and each machine has a timeline of who listed or unlisted it, with the Zendesk comment and the error hint. - The unlisting emails are parsed for the error block (they arrive quoted-printable) and classified into a likely cause and a next step. Zendesk and Jira - Unlisting raises a Zendesk ticket that follows the format of RunPod's own email, keyed on the machine so one machine keeps one thread, posted as an internal note. - Jira is split in two: the Infrahub/OIE instance and the RunPod/RMA one, which may be a different Atlassian site. Blank RunPod values fall back to the defaults rather than failing. Running in a container - CX-Tools reads its keys from 1Password, which needs a desktop app. Config is a dataclass whose lookups live in per-field default factories, so passing CX_INFRAHUB_TOKEN/CX_INFRAINSIGHT_TOKEN in means those factories never run and CX-Tools itself stays unmodified. - CX-Tools reaches OpenStack with `docker exec -osc`, so the image now carries the Docker client (the static binary, not the docker.io package) and compose mounts the host socket with group_add for it. Verified from inside the container: live OpenStack and Infrahub calls both succeed. Also fixes Settings, which read the environment at class-definition time and so ignored anything set afterwards; a fresh Settings() silently returned stale values. Caught by the Jira scoping tests. Co-Authored-By: Claude Opus 5 --- .env.example | 40 + .gitignore | 2 + backend/Dockerfile | 20 +- backend/app/config.py | 149 +- backend/app/delivery.py | 34 +- backend/app/main.py | 19 +- backend/app/models.py | 260 +++- backend/app/routers/handover_router.py | 222 +++ backend/app/routers/runpod_router.py | 262 ++++ backend/app/runpod/__init__.py | 0 backend/app/runpod/client.py | 285 ++++ backend/app/runpod/delivery.py | 201 +++ backend/app/runpod/email_parse.py | 114 ++ backend/app/runpod/service.py | 102 ++ backend/app/seed/__init__.py | 0 backend/app/seed/data.py | 174 +++ backend/app/seed/loader.py | 150 ++ backend/tests/test_runpod.py | 104 ++ backend/triagelib/cxbridge.py | 48 +- docker-compose.yml | 21 +- frontend/package-lock.json | 1951 ++++++++++++++++++++++++ frontend/src/App.tsx | 10 +- frontend/src/pages/Handover.tsx | 241 +++ frontend/src/pages/Runpod.tsx | 238 +++ 24 files changed, 4576 insertions(+), 71 deletions(-) create mode 100644 backend/app/routers/handover_router.py create mode 100644 backend/app/routers/runpod_router.py create mode 100644 backend/app/runpod/__init__.py create mode 100644 backend/app/runpod/client.py create mode 100644 backend/app/runpod/delivery.py create mode 100644 backend/app/runpod/email_parse.py create mode 100644 backend/app/runpod/service.py create mode 100644 backend/app/seed/__init__.py create mode 100644 backend/app/seed/data.py create mode 100644 backend/app/seed/loader.py create mode 100644 backend/tests/test_runpod.py create mode 100644 frontend/package-lock.json create mode 100644 frontend/src/pages/Handover.tsx create mode 100644 frontend/src/pages/Runpod.tsx diff --git a/.env.example b/.env.example index 362919b..cd214bc 100644 --- a/.env.example +++ b/.env.example @@ -65,3 +65,43 @@ CX_JIRA_EMAIL= CX_JIRA_TOKEN= CX_JIRA_PROJECT=INFRA CX_JIRA_ISSUE_TYPE=Task + +# --- local compose paths --------------------------------------------------- +# CX-Tools on the host. The app imports it and shells out to the *-osc +# containers through the mounted Docker socket, so both must be present. +CX_TOOLS_HOST_PATH=../CX-Tools +CX_RUNPOD_EXPORT_HOST_PATH=../RunPod + +# Infrahub/InfraInsight keys. Set these and CX-Tools skips 1Password entirely, +# which is the only way it can work inside a container. +CX_INFRAHUB_TOKEN= +CX_INFRAINSIGHT_TOKEN= + +# Fill an empty database with representative handover and RunPod data. +CX_SEED_DEMO=true + +# --- RunPod ---------------------------------------------------------------- +# The API key is the supported path. Email/password only reaches a console +# login that now requires a second factor, so it cannot run unattended. +CX_FEATURE_RUNPOD=true +CX_FEATURE_RUNPOD_WRITE=false +CX_RUNPOD_API_KEY= +CX_RUNPOD_EMAIL= +CX_RUNPOD_PASSWORD= +CX_RUNPOD_TEAM_ID= +# Only if the account moves from emailed codes to an authenticator app. +CX_RUNPOD_TOTP_SECRET= + +# --- Jira, second instance (RunPod / RMA) ---------------------------------- +# Leave blank to reuse the CX_JIRA_* values above. +CX_RUNPOD_JIRA_BASE= +CX_RUNPOD_JIRA_EMAIL= +CX_RUNPOD_JIRA_TOKEN= +CX_RUNPOD_JIRA_PROJECT=RMA + +CX_FEATURE_HANDOVER=true + +# --- Zendesk tickets for RunPod unlistings --------------------------------- +# Uses the same Zendesk credentials as customer comms. Tickets are keyed on the +# machine (external_id cx-triage-runpod-) and posted as an internal +# note, since these are operations tickets rather than customer replies. diff --git a/.gitignore b/.gitignore index 1140849..b262b11 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ build/ *.sqlite3 data/ .DS_Store +tsconfig.tsbuildinfo +*.tsbuildinfo diff --git a/backend/Dockerfile b/backend/Dockerfile index 8a951c5..e5c74f3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,13 +10,25 @@ RUN npm run build FROM python:3.12-slim ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 -# curl is what the triage engine shells out to for the Infrahub API; the docker -# CLI is only needed when Prometheus/OpenStack are reachable through the -# CX-Tools containers rather than directly (see docs/DEPLOYMENT.md). +# curl is what the triage engine shells out to for the Infrahub API. RUN apt-get update \ - && apt-get install -y --no-install-recommends curl ca-certificates docker.io \ + && apt-get install -y --no-install-recommends curl ca-certificates \ && rm -rf /var/lib/apt/lists/* +# Just the Docker *client*, from the official static build. CX-Tools reaches +# OpenStack with `docker exec -osc openstack ...`, so with the host's +# socket mounted those become sibling containers. The `docker.io` apt package +# would drag in the daemon and containerd for a binary we never run. +ARG DOCKER_CLI_VERSION=27.3.1 +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in amd64) dl=x86_64 ;; arm64) dl=aarch64 ;; *) echo "unsupported $arch" >&2; exit 1 ;; esac; \ + curl -fsSL "https://download.docker.com/linux/static/stable/${dl}/docker-${DOCKER_CLI_VERSION}.tgz" \ + | tar -xz -C /tmp docker/docker; \ + mv /tmp/docker/docker /usr/local/bin/docker; \ + rm -rf /tmp/docker; \ + docker --version + WORKDIR /app COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/backend/app/config.py b/backend/app/config.py index ca82cb1..002c5d6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -21,56 +21,114 @@ def _int(name: str, default: int) -> int: class Settings: - # --- app --------------------------------------------------------------- - app_name = os.environ.get("CX_APP_NAME", "CX Triage") - base_url = os.environ.get("CX_BASE_URL", "http://localhost:8080") - secret_key = os.environ.get("CX_SECRET_KEY", "dev-only-change-me") - session_hours = _int("CX_SESSION_HOURS", 12) - static_dir = os.environ.get("CX_STATIC_DIR", "/app/static") + """Read once per instance, at construction time. - # --- database ---------------------------------------------------------- - # sqlite for local/compose, postgres in the cluster. - database_url = os.environ.get("CX_DATABASE_URL", "sqlite:////data/cx-triage.db") + These were class attributes, which meant the environment was read when the + module was first imported and never again - so a fresh Settings() silently + returned stale values. Reading in __init__ makes construction mean what it + looks like it means. + """ - # --- data sources ------------------------------------------------------ - prometheus_base = os.environ.get("CX_PROMETHEUS_BASE", "http://10.11.254.250:9090") - prometheus_relay = os.environ.get("CX_PROMETHEUS_RELAY", "") - cx_tools_path = os.environ.get("CX_TOOLS_PATH", "") + def __init__(self) -> None: + # --- app --------------------------------------------------------------- + self.app_name = os.environ.get("CX_APP_NAME", "CX Triage") + self.base_url = os.environ.get("CX_BASE_URL", "http://localhost:8080") + self.secret_key = os.environ.get("CX_SECRET_KEY", "dev-only-change-me") + self.session_hours = _int("CX_SESSION_HOURS", 12) + self.static_dir = os.environ.get("CX_STATIC_DIR", "/app/static") - # --- auth -------------------------------------------------------------- - # Local accounts are for development and for a cluster without SSO yet. - # When CX_OIDC_ENABLED is on, Authentik becomes the source of truth. - auth_local_enabled = _bool("CX_AUTH_LOCAL_ENABLED", True) - bootstrap_admin_email = os.environ.get("CX_BOOTSTRAP_ADMIN_EMAIL", "admin@localhost") - bootstrap_admin_password = os.environ.get("CX_BOOTSTRAP_ADMIN_PASSWORD", "") + # --- database ---------------------------------------------------------- + # sqlite for local/compose, postgres in the cluster. + self.database_url = os.environ.get("CX_DATABASE_URL", "sqlite:////data/cx-triage.db") - oidc_enabled = _bool("CX_OIDC_ENABLED", False) - oidc_issuer = os.environ.get("CX_OIDC_ISSUER", "") # e.g. https://sso/application/o/cx-triage/ - oidc_client_id = os.environ.get("CX_OIDC_CLIENT_ID", "") - oidc_client_secret = os.environ.get("CX_OIDC_CLIENT_SECRET", "") - oidc_scopes = os.environ.get("CX_OIDC_SCOPES", "openid email profile") - oidc_admin_group = os.environ.get("CX_OIDC_ADMIN_GROUP", "cx-triage-admins") - oidc_groups_claim = os.environ.get("CX_OIDC_GROUPS_CLAIM", "groups") + # --- data sources ------------------------------------------------------ + self.prometheus_base = os.environ.get("CX_PROMETHEUS_BASE", "http://10.11.254.250:9090") + self.prometheus_relay = os.environ.get("CX_PROMETHEUS_RELAY", "") + self.cx_tools_path = os.environ.get("CX_TOOLS_PATH", "") - # --- feature flags ----------------------------------------------------- - # Sending must be switched on deliberately; a demo instance cannot email. - feature_send_enabled = _bool("CX_FEATURE_SEND_ENABLED", False) - feature_zendesk = _bool("CX_FEATURE_ZENDESK", False) - feature_jira = _bool("CX_FEATURE_JIRA", False) - feature_linkage_scan = _bool("CX_FEATURE_LINKAGE_SCAN", True) - send_daily_cap = _int("CX_SEND_DAILY_CAP", 25) + # --- auth -------------------------------------------------------------- + # Local accounts are for development and for a cluster without SSO yet. + # When CX_OIDC_ENABLED is on, Authentik becomes the source of truth. + self.auth_local_enabled = _bool("CX_AUTH_LOCAL_ENABLED", True) + self.bootstrap_admin_email = os.environ.get("CX_BOOTSTRAP_ADMIN_EMAIL", "admin@localhost") + self.bootstrap_admin_password = os.environ.get("CX_BOOTSTRAP_ADMIN_PASSWORD", "") - # --- integrations ------------------------------------------------------ - zendesk_subdomain = os.environ.get("CX_ZENDESK_SUBDOMAIN", "") - zendesk_email = os.environ.get("CX_ZENDESK_EMAIL", "") - zendesk_token = os.environ.get("CX_ZENDESK_TOKEN", "") - zendesk_default_public = _bool("CX_ZENDESK_PUBLIC_REPLY", True) + self.oidc_enabled = _bool("CX_OIDC_ENABLED", False) + self.oidc_issuer = os.environ.get("CX_OIDC_ISSUER", "") # e.g. https://sso/application/o/cx-triage/ + self.oidc_client_id = os.environ.get("CX_OIDC_CLIENT_ID", "") + self.oidc_client_secret = os.environ.get("CX_OIDC_CLIENT_SECRET", "") + self.oidc_scopes = os.environ.get("CX_OIDC_SCOPES", "openid email profile") + self.oidc_admin_group = os.environ.get("CX_OIDC_ADMIN_GROUP", "cx-triage-admins") + self.oidc_groups_claim = os.environ.get("CX_OIDC_GROUPS_CLAIM", "groups") - jira_base = os.environ.get("CX_JIRA_BASE", "") - jira_email = os.environ.get("CX_JIRA_EMAIL", "") - jira_token = os.environ.get("CX_JIRA_TOKEN", "") - jira_project = os.environ.get("CX_JIRA_PROJECT", "INFRA") - jira_issue_type = os.environ.get("CX_JIRA_ISSUE_TYPE", "Task") + # --- feature flags ----------------------------------------------------- + # Sending must be switched on deliberately; a demo instance cannot email. + self.feature_send_enabled = _bool("CX_FEATURE_SEND_ENABLED", False) + self.feature_zendesk = _bool("CX_FEATURE_ZENDESK", False) + self.feature_jira = _bool("CX_FEATURE_JIRA", False) + self.feature_linkage_scan = _bool("CX_FEATURE_LINKAGE_SCAN", True) + self.send_daily_cap = _int("CX_SEND_DAILY_CAP", 25) + + # --- integrations ------------------------------------------------------ + self.zendesk_subdomain = os.environ.get("CX_ZENDESK_SUBDOMAIN", "") + self.zendesk_email = os.environ.get("CX_ZENDESK_EMAIL", "") + self.zendesk_token = os.environ.get("CX_ZENDESK_TOKEN", "") + self.zendesk_default_public = _bool("CX_ZENDESK_PUBLIC_REPLY", True) + + # Jira, instance 1: the Infrahub/OpenStack side (OIE). + self.jira_base = os.environ.get("CX_JIRA_BASE", "") + self.jira_email = os.environ.get("CX_JIRA_EMAIL", "") + self.jira_token = os.environ.get("CX_JIRA_TOKEN", "") + self.jira_project = os.environ.get("CX_JIRA_PROJECT", "OIE") + self.jira_issue_type = os.environ.get("CX_JIRA_ISSUE_TYPE", "Task") + + # Jira, instance 2: RunPod hosts and RMAs. These may be a different + # Atlassian site entirely, so they get their own credentials; anything left + # blank falls back to the values above rather than failing. + self.runpod_jira_base = os.environ.get("CX_RUNPOD_JIRA_BASE", "") + self.runpod_jira_email = os.environ.get("CX_RUNPOD_JIRA_EMAIL", "") + self.runpod_jira_token = os.environ.get("CX_RUNPOD_JIRA_TOKEN", "") + self.runpod_jira_project = os.environ.get("CX_RUNPOD_JIRA_PROJECT", "RMA") + self.runpod_jira_issue_type = os.environ.get("CX_RUNPOD_JIRA_ISSUE_TYPE", "Task") + + # --- RunPod ------------------------------------------------------------ + # The API key is the supported path. Email/password only reaches a console + # login that now requires a second factor, so it cannot run unattended - + # see app/runpod/client.py. + self.runpod_api_key = os.environ.get("CX_RUNPOD_API_KEY", "") + self.runpod_email = os.environ.get("CX_RUNPOD_EMAIL", "") + self.runpod_password = os.environ.get("CX_RUNPOD_PASSWORD", "") + self.runpod_team_id = os.environ.get("CX_RUNPOD_TEAM_ID", "") + self.runpod_totp_secret = os.environ.get("CX_RUNPOD_TOTP_SECRET", "") + self.feature_runpod = _bool("CX_FEATURE_RUNPOD", True) + self.feature_handover = _bool("CX_FEATURE_HANDOVER", True) + # Unlisting a machine is destructive to earnings; gated separately. + self.feature_runpod_write = _bool("CX_FEATURE_RUNPOD_WRITE", False) + + def jira_for(self, scope: str) -> dict: + """Credentials for a Jira scope: 'runpod' or anything else (default).""" + if scope == "runpod": + return { + "base": self.runpod_jira_base or self.jira_base, + "email": self.runpod_jira_email or self.jira_email, + "token": self.runpod_jira_token or self.jira_token, + "project": self.runpod_jira_project, + "issue_type": self.runpod_jira_issue_type, + } + return { + "base": self.jira_base, "email": self.jira_email, "token": self.jira_token, + "project": self.jira_project, "issue_type": self.jira_issue_type, + } + + @property + def runpod_ready(self) -> bool: + return bool(self.feature_runpod and (self.runpod_api_key or + (self.runpod_email and self.runpod_password))) + + @property + def runpod_jira_ready(self) -> bool: + creds = self.jira_for("runpod") + return bool(self.feature_jira and creds["base"] and creds["email"] and creds["token"]) @property def zendesk_ready(self) -> bool: @@ -93,6 +151,11 @@ class Settings: "send_enabled": self.feature_send_enabled, "linkage_scan": self.feature_linkage_scan, "jira_project": self.jira_project if self.jira_ready else "", + "runpod_ready": self.runpod_ready, + "runpod_write": self.feature_runpod_write, + "runpod_jira_ready": self.runpod_jira_ready, + "runpod_jira_project": self.runpod_jira_project if self.runpod_jira_ready else "", + "handover": self.feature_handover, } diff --git a/backend/app/delivery.py b/backend/app/delivery.py index ecfab81..c1df310 100644 --- a/backend/app/delivery.py +++ b/backend/app/delivery.py @@ -29,7 +29,7 @@ class DeliveryError(RuntimeError): pass -def _guard(kind: str) -> None: +def _guard(kind: str, scope: str = "default") -> None: if not settings.feature_send_enabled: raise DeliveryError( "Sending is disabled on this instance (CX_FEATURE_SEND_ENABLED is off). " @@ -38,9 +38,15 @@ def _guard(kind: str) -> None: if kind == "zendesk" and not settings.zendesk_ready: raise DeliveryError("Zendesk is not configured. Set CX_FEATURE_ZENDESK plus " "CX_ZENDESK_SUBDOMAIN, CX_ZENDESK_EMAIL and CX_ZENDESK_TOKEN.") - if kind == "jira" and not settings.jira_ready: - raise DeliveryError("Jira is not configured. Set CX_FEATURE_JIRA plus " - "CX_JIRA_BASE, CX_JIRA_EMAIL, CX_JIRA_TOKEN and CX_JIRA_PROJECT.") + if kind == "jira": + creds = settings.jira_for(scope) + if not (settings.feature_jira and creds["base"] and creds["email"] and creds["token"]): + prefix = "CX_RUNPOD_JIRA_" if scope == "runpod" else "CX_JIRA_" + raise DeliveryError( + f"Jira ({scope}) is not configured. Set CX_FEATURE_JIRA plus {prefix}BASE, " + f"{prefix}EMAIL and {prefix}TOKEN - or leave the {prefix}* values blank to reuse " + "the default instance." + ) def sends_today(db: Session) -> int: @@ -125,12 +131,20 @@ async def send_zendesk(db: Session, case: Case, actor: User, *, to: str, subject async def create_jira(db: Session, case: Case, actor: User, *, summary: str, description: str, project: str = "", issue_type: str = "", - labels: Optional[list[str]] = None) -> dict[str, Any]: - _guard("jira") + labels: Optional[list[str]] = None, + scope: str = "default") -> dict[str, Any]: + """Raise a Jira issue on the instance that owns this kind of work. + + `scope="runpod"` targets the RunPod/RMA project, which may live on an + entirely different Atlassian site - hence separate credentials rather than + just a different project key. + """ + _guard("jira", scope) _check_cap(db) - base = settings.jira_base.rstrip("/") - auth = (settings.jira_email, settings.jira_token) + creds = settings.jira_for(scope) + base = creds["base"].rstrip("/") + auth = (creds["email"], creds["token"]) label = f"cx-triage-{case.fingerprint}" all_labels = sorted(set((labels or []) + ["cx-triage", label])) @@ -147,9 +161,9 @@ async def create_jira(db: Session, case: Case, actor: User, *, summary: str, des return {"ok": True, "key": key, "url": url, "action": "existing"} payload = {"fields": { - "project": {"key": project or settings.jira_project}, + "project": {"key": project or creds["project"]}, "summary": summary[:250], - "issuetype": {"name": issue_type or settings.jira_issue_type}, + "issuetype": {"name": issue_type or creds["issue_type"]}, "labels": all_labels, "description": { "type": "doc", "version": 1, diff --git a/backend/app/main.py b/backend/app/main.py index 5688677..4378faf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,11 +13,15 @@ from .auth import ensure_bootstrap_admin from .config import get_settings from .db import SessionLocal, init_db from .routers import (actions_router, alerts_router, auth_router, cases_router, - linkage_router, settings_router) + handover_router, linkage_router, runpod_router, settings_router) settings = get_settings() +def _bool_env(name: str) -> bool: + return str(os.environ.get(name, "")).strip().lower() in {"1", "true", "yes", "on"} + + @asynccontextmanager async def lifespan(app: FastAPI): init_db() @@ -28,6 +32,17 @@ async def lifespan(app: FastAPI): print(f"[auth] {message}") finally: db.close() + if _bool_env("CX_SEED_DEMO"): + db = SessionLocal() + try: + from .seed.loader import run as seed_run + for line in seed_run(db): + print(f"[seed] {line}") + except Exception as exc: + print(f"[seed] skipped: {type(exc).__name__}: {exc}") + finally: + db.close() + print(f"[startup] {settings.app_name} {VERSION}") print(f"[startup] prometheus: {settings.prometheus_base}") print(f"[startup] sso: {'on' if settings.oidc_enabled else 'off'} | " @@ -41,7 +56,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title=settings.app_name, version=VERSION, lifespan=lifespan) for module in (auth_router, alerts_router, cases_router, settings_router, - linkage_router, actions_router): + linkage_router, actions_router, handover_router, runpod_router): app.include_router(module.router) diff --git a/backend/app/models.py b/backend/app/models.py index 6ef347e..01c446c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -12,7 +12,7 @@ import datetime as dt import enum from typing import Any -from sqlalchemy import (JSON, Boolean, DateTime, Enum, ForeignKey, Index, Integer, +from sqlalchemy import (JSON, Boolean, Date, DateTime, Enum, ForeignKey, Index, Integer, String, Text, UniqueConstraint) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -25,6 +25,18 @@ def _now() -> dt.datetime: return dt.datetime.now(dt.timezone.utc) +def _as_utc(value: dt.datetime | None) -> dt.datetime | None: + """Re-attach UTC to a value read back from the database. + + SQLite has no timezone type, so a datetime stored as aware comes back naive + and cannot be compared with `now()`. Postgres round-trips correctly; this + keeps both behaving the same. + """ + if value is None: + return None + return value if value.tzinfo else value.replace(tzinfo=dt.timezone.utc) + + class CaseStatus(str, enum.Enum): """Where a piece of work has got to.""" @@ -206,3 +218,249 @@ class AppSetting(Base): key: Mapped[str] = mapped_column(String(80), primary_key=True) value: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) updated_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) + + +# --- shift handover --------------------------------------------------------- + +class ShiftName(str, enum.Enum): + APAC = "APAC" + EMEA = "EMEA" + AMER = "AMER" + + +class HandoverStatus(str, enum.Enum): + DRAFT = "draft" + HANDED_OVER = "handed_over" + + +class ItemState(str, enum.Enum): + """The status line CX writes at the bottom of each key update.""" + + PENDING_INFRA = "pending_infra" + PENDING_CUSTOMER = "pending_customer" + PENDING_RUNPOD = "pending_runpod" + PENDING_RMA = "pending_rma" + PENDING_CX = "pending_cx" + IN_PROGRESS = "in_progress" + MONITORING = "monitoring" + NO_FURTHER_ENGAGEMENT = "no_further_engagement" + DONE = "done" + + +class Handover(Base): + """One shift handover document.""" + + __tablename__ = "handovers" + __table_args__ = (UniqueConstraint("shift_date", "shift", name="uq_handover_date_shift"),) + + id: Mapped[int] = mapped_column(primary_key=True) + shift_date: Mapped[dt.date] = mapped_column(Date, index=True) + shift: Mapped[ShiftName] = mapped_column(Enum(ShiftName), default=ShiftName.APAC) + handing_to: Mapped[str] = mapped_column(String(40), default="") # "EMEA" in "APAC -> EMEA" + status: Mapped[HandoverStatus] = mapped_column(Enum(HandoverStatus), default=HandoverStatus.DRAFT) + + team_members: Mapped[str] = mapped_column(String(400), default="") + # The tick-boxes at the top of the Confluence page. + significant_issues_checked: Mapped[bool] = mapped_column(Boolean, default=False) + hubspot_checked: Mapped[bool] = mapped_column(Boolean, default=False) + total_open_tickets: Mapped[int | None] = mapped_column(Integer, nullable=True) + # [{"name": "Parham", "hs_checked": true, "jira_checked": true}, ...] + member_checks: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON, default=list) + + other_comments: Mapped[str] = mapped_column(Text, default="") + reviewed_by: Mapped[str] = mapped_column(String(200), default="") + reviewed_at_utc: Mapped[str] = mapped_column(String(40), default="") + following_shift_checked: Mapped[bool] = mapped_column(Boolean, default=False) + + created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + created_by: Mapped[User | None] = relationship(lazy="joined") + created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=_now) + updated_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) + + items: Mapped[list["HandoverItem"]] = relationship( + back_populates="handover", cascade="all, delete-orphan", order_by="HandoverItem.position") + + def to_json(self, with_items: bool = False) -> dict[str, Any]: + data = { + "id": self.id, + "shift_date": self.shift_date.isoformat() if self.shift_date else None, + "shift": self.shift.value, + "handing_to": self.handing_to, + "status": self.status.value, + "title": f"{self.shift.value} {self.shift_date.strftime('%d %B %Y')}" if self.shift_date else "", + "team_members": self.team_members, + "significant_issues_checked": self.significant_issues_checked, + "hubspot_checked": self.hubspot_checked, + "total_open_tickets": self.total_open_tickets, + "member_checks": self.member_checks or [], + "other_comments": self.other_comments, + "reviewed_by": self.reviewed_by, + "reviewed_at_utc": self.reviewed_at_utc, + "following_shift_checked": self.following_shift_checked, + "created_by": self.created_by.to_json() if self.created_by else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "item_count": len(self.items), + } + if with_items: + data["items"] = [i.to_json() for i in self.items] + return data + + +class HandoverItem(Base): + """One key update: the Zendesk/Jira refs, the narrative, and where it stands. + + Carried forward between shifts rather than retyped - `carried_from_id` + records where it came from, so an issue keeps one thread across days. + """ + + __tablename__ = "handover_items" + + id: Mapped[int] = mapped_column(primary_key=True) + handover_id: Mapped[int] = mapped_column(ForeignKey("handovers.id"), index=True) + handover: Mapped[Handover] = relationship(back_populates="items") + + position: Mapped[int] = mapped_column(Integer, default=0) + title: Mapped[str] = mapped_column(String(400), default="") + zendesk_tickets: Mapped[str] = mapped_column(String(300), default="") # "#8495, #8453" + jira_key: Mapped[str] = mapped_column(String(60), default="") + jira_status: Mapped[str] = mapped_column(String(60), default="") + body: Mapped[str] = mapped_column(Text, default="") + links: Mapped[list[str] | None] = mapped_column(JSON, default=list) + state: Mapped[ItemState] = mapped_column(Enum(ItemState), default=ItemState.IN_PROGRESS) + # "Remove at end of shift" in the doc. + remove_at_end_of_shift: Mapped[bool] = mapped_column(Boolean, default=False) + + carried_from_id: Mapped[int | None] = mapped_column(ForeignKey("handover_items.id"), nullable=True) + first_raised_on: Mapped[dt.date | None] = mapped_column(Date, nullable=True) + created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=_now) + + def to_json(self) -> dict[str, Any]: + return { + "id": self.id, "position": self.position, "title": self.title, + "zendesk_tickets": self.zendesk_tickets, "jira_key": self.jira_key, + "jira_status": self.jira_status, "body": self.body, "links": self.links or [], + "state": self.state.value, + "remove_at_end_of_shift": self.remove_at_end_of_shift, + "carried_from_id": self.carried_from_id, + "first_raised_on": self.first_raised_on.isoformat() if self.first_raised_on else None, + } + + +# --- RunPod ----------------------------------------------------------------- + +class RunpodColour(str, enum.Enum): + """The colour key CX uses on the handover doc, kept verbatim.""" + + RED = "red" # blocked from relisting, recurring issue + PURPLE = "purple" # waiting on RunPod + YELLOW = "yellow" # waiting on Infrastructure or the DC team + BLUE = "blue" # pending removal from the RunPod platform + GREEN = "green" # stress testing >24h, GPUs may look reserved + WHITE = "white" # actionable by CX + + +class RunpodEventType(str, enum.Enum): + UNLISTED = "unlisted" + LISTED = "listed" + MAINTENANCE_SCHEDULED = "maintenance_scheduled" + MAINTENANCE_ENDED = "maintenance_ended" + DRAINED = "drained" # unlisted and rented GPUs reached zero + NOTE = "note" + ZENDESK_TICKET = "zendesk_ticket" + JIRA_LINKED = "jira_linked" + + +class RunpodHost(Base): + """A RunPod machine, and where CX has got to with it.""" + + __tablename__ = "runpod_hosts" + + id: Mapped[int] = mapped_column(primary_key=True) + machine_id: Mapped[str] = mapped_column(String(40), unique=True, index=True) + name: Mapped[str] = mapped_column(String(200), default="", index=True) + + listed: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + gpu_reserved: Mapped[int] = mapped_column(Integer, default=0) + gpu_total: Mapped[int] = mapped_column(Integer, default=0) + gpu_type: Mapped[str] = mapped_column(String(80), default="") + data_center: Mapped[str] = mapped_column(String(40), default="") + uptime_one_week: Mapped[float | None] = mapped_column(nullable=True) + maintenance_start: Mapped[str] = mapped_column(String(40), default="") + maintenance_end: Mapped[str] = mapped_column(String(40), default="") + + # CX-side state, mirrored onto the handover doc. + colour: Mapped[RunpodColour] = mapped_column(Enum(RunpodColour), default=RunpodColour.WHITE) + zendesk_ticket: Mapped[str] = mapped_column(String(40), default="") + jira_key: Mapped[str] = mapped_column(String(60), default="") + jira_status: Mapped[str] = mapped_column(String(60), default="") + last_error: Mapped[str] = mapped_column(Text, default="") # hint from the RunPod email + next_steps: Mapped[str] = mapped_column(Text, default="") + + # How often this machine has been unlisted; the "problem host" ranking. + unlist_count: Mapped[int] = mapped_column(Integer, default=0, index=True) + historic_count: Mapped[int] = mapped_column(Integer, default=0) + unlisted_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_listed_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + first_seen_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=_now) + updated_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) + + events: Mapped[list["RunpodEvent"]] = relationship( + back_populates="host", cascade="all, delete-orphan", + order_by="RunpodEvent.occurred_at.desc()") + + @property + def hours_unlisted(self) -> float | None: + started = _as_utc(self.unlisted_at) + if self.listed or not started: + return None + return round((_now() - started).total_seconds() / 3600, 1) + + def to_json(self, with_events: bool = False) -> dict[str, Any]: + data = { + "id": self.id, "machine_id": self.machine_id, "name": self.name, + "listed": self.listed, "gpu_reserved": self.gpu_reserved, "gpu_total": self.gpu_total, + "gpu_type": self.gpu_type, "data_center": self.data_center, + "uptime_one_week": self.uptime_one_week, + "maintenance_start": self.maintenance_start, "maintenance_end": self.maintenance_end, + "colour": self.colour.value, "zendesk_ticket": self.zendesk_ticket, + "jira_key": self.jira_key, "jira_status": self.jira_status, + "last_error": self.last_error, "next_steps": self.next_steps, + "unlist_count": self.unlist_count, "historic_count": self.historic_count, + "unlisted_at": self.unlisted_at.isoformat() if self.unlisted_at else None, + "hours_unlisted": self.hours_unlisted, + "event_count": len(self.events), + } + if with_events: + data["events"] = [e.to_json() for e in self.events] + return data + + +class RunpodEvent(Base): + """Append-only history for a machine: listings, unlistings and who did what.""" + + __tablename__ = "runpod_events" + + id: Mapped[int] = mapped_column(primary_key=True) + host_id: Mapped[int] = mapped_column(ForeignKey("runpod_hosts.id"), index=True) + host: Mapped[RunpodHost] = relationship(back_populates="events") + + event_type: Mapped[RunpodEventType] = mapped_column(Enum(RunpodEventType), index=True) + # "runpod" when RunPod unlisted it automatically, otherwise the operator. + actor: Mapped[str] = mapped_column(String(200), default="system") + detail: Mapped[str] = mapped_column(Text, default="") + error_hint: Mapped[str] = mapped_column(Text, default="") # parsed from the unlisting email + zendesk_ticket: Mapped[str] = mapped_column(String(40), default="") + jira_key: Mapped[str] = mapped_column(String(60), default="") + gpu_reserved: Mapped[int | None] = mapped_column(Integer, nullable=True) + payload: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + occurred_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=_now, index=True) + + def to_json(self) -> dict[str, Any]: + return { + "id": self.id, "event_type": self.event_type.value, "actor": self.actor, + "detail": self.detail, "error_hint": self.error_hint, + "zendesk_ticket": self.zendesk_ticket, "jira_key": self.jira_key, + "gpu_reserved": self.gpu_reserved, + "occurred_at": self.occurred_at.isoformat() if self.occurred_at else None, + "payload": self.payload, + } diff --git a/backend/app/routers/handover_router.py b/backend/app/routers/handover_router.py new file mode 100644 index 0000000..f784868 --- /dev/null +++ b/backend/app/routers/handover_router.py @@ -0,0 +1,222 @@ +"""Shift handover: the document CX fills in at the end of every shift.""" +from __future__ import annotations + +import datetime as dt + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session + +from ..auth import current_user +from ..db import get_db +from ..models import (Handover, HandoverItem, HandoverStatus, ItemState, RunpodColour, + RunpodHost, ShiftName, User) + +router = APIRouter(prefix="/api/handover", tags=["handover"]) + + +class HandoverBody(BaseModel): + shift_date: str | None = None + shift: str = "APAC" + handing_to: str = "" + team_members: str = "" + significant_issues_checked: bool = False + hubspot_checked: bool = False + total_open_tickets: int | None = None + member_checks: list[dict] = [] + other_comments: str = "" + reviewed_by: str = "" + reviewed_at_utc: str = "" + following_shift_checked: bool = False + + +class ItemBody(BaseModel): + title: str = "" + zendesk_tickets: str = "" + jira_key: str = "" + jira_status: str = "" + body: str = "" + links: list[str] = [] + state: str = "in_progress" + remove_at_end_of_shift: bool = False + position: int | None = None + + +def _get(db: Session, handover_id: int) -> Handover: + found = db.get(Handover, handover_id) + if not found: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such handover") + return found + + +@router.get("") +def list_handovers(limit: int = 30, db: Session = Depends(get_db), + user: User = Depends(current_user)): + rows = db.scalars(select(Handover).order_by(Handover.shift_date.desc(), Handover.id.desc()) + .limit(max(1, min(limit, 200)))).all() + return { + "handovers": [h.to_json() for h in rows], + "shifts": [s.value for s in ShiftName], + "states": [s.value for s in ItemState], + } + + +@router.get("/current") +def current(db: Session = Depends(get_db), user: User = Depends(current_user)): + """The newest draft, or the newest handover of any kind.""" + row = db.scalars(select(Handover).where(Handover.status == HandoverStatus.DRAFT) + .order_by(Handover.shift_date.desc(), Handover.id.desc()).limit(1)).first() + if row is None: + row = db.scalars(select(Handover).order_by(Handover.shift_date.desc(), + Handover.id.desc()).limit(1)).first() + if row is None: + return {"handover": None} + return {"handover": row.to_json(with_items=True), "runpod": _runpod_section(db)} + + +@router.get("/{handover_id}") +def get_one(handover_id: int, db: Session = Depends(get_db), user: User = Depends(current_user)): + return {"handover": _get(db, handover_id).to_json(with_items=True), "runpod": _runpod_section(db)} + + +def _runpod_section(db: Session) -> list[dict]: + """The RunPod table on the handover, straight from live host state. + + This is the part that used to be retyped by hand every shift. + """ + rows = db.scalars(select(RunpodHost).where(RunpodHost.listed.is_(False)) + .order_by(RunpodHost.unlist_count.desc(), RunpodHost.name)).all() + return [{ + "machine_id": h.machine_id, "name": h.name, "colour": h.colour.value, + "zendesk_ticket": h.zendesk_ticket, "jira_key": h.jira_key, "jira_status": h.jira_status, + "gpu_reserved": h.gpu_reserved, "gpu_total": h.gpu_total, + "last_error": h.last_error, "next_steps": h.next_steps, + "hours_unlisted": h.hours_unlisted, "unlist_count": h.unlist_count, + } for h in rows] + + +@router.post("") +def create(body: HandoverBody, db: Session = Depends(get_db), user: User = Depends(current_user)): + shift_date = dt.date.fromisoformat(body.shift_date) if body.shift_date else dt.date.today() + try: + shift = ShiftName(body.shift) + except ValueError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unknown shift '{body.shift}'") from exc + + existing = db.scalars(select(Handover).where(Handover.shift_date == shift_date, + Handover.shift == shift)).first() + if existing: + raise HTTPException(status.HTTP_409_CONFLICT, + f"A {shift.value} handover already exists for {shift_date}") + + row = Handover(shift_date=shift_date, shift=shift, handing_to=body.handing_to, + team_members=body.team_members, created_by_id=user.id) + db.add(row) + db.commit() + return row.to_json(with_items=True) + + +@router.post("/{handover_id}") +def update(handover_id: int, body: HandoverBody, db: Session = Depends(get_db), + user: User = Depends(current_user)): + row = _get(db, handover_id) + for field in ("handing_to", "team_members", "significant_issues_checked", "hubspot_checked", + "total_open_tickets", "member_checks", "other_comments", "reviewed_by", + "reviewed_at_utc", "following_shift_checked"): + setattr(row, field, getattr(body, field)) + if body.shift_date: + row.shift_date = dt.date.fromisoformat(body.shift_date) + db.commit() + return row.to_json(with_items=True) + + +@router.post("/{handover_id}/hand-over") +def hand_over(handover_id: int, db: Session = Depends(get_db), user: User = Depends(current_user)): + """Close the shift and start the next one, carrying the live items forward. + + Items flagged "remove at end of shift", and anything already done, are left + behind - which is the manual step this replaces. + """ + row = _get(db, handover_id) + row.status = HandoverStatus.HANDED_OVER + + order = [ShiftName.APAC, ShiftName.EMEA, ShiftName.AMER] + next_shift = order[(order.index(row.shift) + 1) % len(order)] + next_date = row.shift_date + dt.timedelta(days=1) if next_shift == ShiftName.APAC else row.shift_date + + following = db.scalars(select(Handover).where(Handover.shift_date == next_date, + Handover.shift == next_shift)).first() + if following is None: + following = Handover(shift_date=next_date, shift=next_shift, + handing_to=order[(order.index(next_shift) + 1) % len(order)].value, + created_by_id=user.id) + db.add(following) + db.flush() + + carried = 0 + closed = {ItemState.DONE, ItemState.NO_FURTHER_ENGAGEMENT} + for item in row.items: + if item.remove_at_end_of_shift or item.state in closed: + continue + following.items.append(HandoverItem( + position=item.position, title=item.title, zendesk_tickets=item.zendesk_tickets, + jira_key=item.jira_key, jira_status=item.jira_status, body=item.body, + links=list(item.links or []), state=item.state, + carried_from_id=item.id, + first_raised_on=item.first_raised_on or row.shift_date, + )) + carried += 1 + + db.commit() + return {"closed": row.to_json(), "next": following.to_json(with_items=True), "carried": carried} + + +@router.post("/{handover_id}/items") +def add_item(handover_id: int, body: ItemBody, db: Session = Depends(get_db), + user: User = Depends(current_user)): + row = _get(db, handover_id) + try: + state = ItemState(body.state) + except ValueError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unknown state '{body.state}'") from exc + item = HandoverItem( + position=body.position if body.position is not None else len(row.items), + title=body.title, zendesk_tickets=body.zendesk_tickets, jira_key=body.jira_key, + jira_status=body.jira_status, body=body.body, links=body.links, state=state, + remove_at_end_of_shift=body.remove_at_end_of_shift, first_raised_on=row.shift_date, + ) + row.items.append(item) + db.commit() + return row.to_json(with_items=True) + + +@router.post("/{handover_id}/items/{item_id}") +def update_item(handover_id: int, item_id: int, body: ItemBody, db: Session = Depends(get_db), + user: User = Depends(current_user)): + row = _get(db, handover_id) + item = next((i for i in row.items if i.id == item_id), None) + if item is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such item on this handover") + try: + item.state = ItemState(body.state) + except ValueError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unknown state '{body.state}'") from exc + for field in ("title", "zendesk_tickets", "jira_key", "jira_status", "body", "links", + "remove_at_end_of_shift"): + setattr(item, field, getattr(body, field)) + if body.position is not None: + item.position = body.position + db.commit() + return row.to_json(with_items=True) + + +@router.delete("/{handover_id}/items/{item_id}") +def delete_item(handover_id: int, item_id: int, db: Session = Depends(get_db), + user: User = Depends(current_user)): + row = _get(db, handover_id) + item = next((i for i in row.items if i.id == item_id), None) + if item is not None: + db.delete(item) + db.commit() + return _get(db, handover_id).to_json(with_items=True) diff --git a/backend/app/routers/runpod_router.py b/backend/app/routers/runpod_router.py new file mode 100644 index 0000000..7750abe --- /dev/null +++ b/backend/app/routers/runpod_router.py @@ -0,0 +1,262 @@ +"""RunPod machines: current state, per-host history, and problem ranking.""" +from __future__ import annotations + +import datetime as dt + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from ..auth import current_user +from ..config import get_settings +from ..db import get_db +from ..models import RunpodColour, RunpodEvent, RunpodEventType, RunpodHost, User +from ..runpod.client import RunPodClient, RunPodError +from ..runpod.email_parse import parse as parse_email +from ..runpod.delivery import raise_rma_issue, raise_unlisting_ticket, unlisting_ticket +from ..runpod.service import record_event, sync_machines +from ..delivery import DeliveryError + +router = APIRouter(prefix="/api/runpod", tags=["runpod"]) +settings = get_settings() + + +def _client() -> RunPodClient: + return RunPodClient( + api_key=settings.runpod_api_key, email=settings.runpod_email, + password=settings.runpod_password, team_id=settings.runpod_team_id, + totp_secret=settings.runpod_totp_secret, + ) + + +class HostPatch(BaseModel): + colour: str | None = None + zendesk_ticket: str | None = None + jira_key: str | None = None + jira_status: str | None = None + next_steps: str | None = None + last_error: str | None = None + + +class NoteBody(BaseModel): + detail: str + event_type: str = "note" + zendesk_ticket: str = "" + jira_key: str = "" + + +class EmailBody(BaseModel): + raw: str + subject: str = "" + + +class TicketBody(BaseModel): + error_text: str = "" + requester: str = "" + subject: str = "" + body: str = "" + + +class RmaBody(BaseModel): + summary: str = "" + description: str = "" + + +@router.get("/status") +def runpod_status(db: Session = Depends(get_db), user: User = Depends(current_user)): + hosts = db.scalars(select(RunpodHost)).all() + unlisted = [h for h in hosts if not h.listed] + return { + "configured": settings.runpod_ready, + "mode": _client().mode, + "write_enabled": settings.feature_runpod_write, + "totals": { + "machines": len(hosts), + "listed": len(hosts) - len(unlisted), + "unlisted": len(unlisted), + "gpus_rented": sum(h.gpu_reserved for h in hosts), + "gpus_total": sum(h.gpu_total for h in hosts), + }, + "colours": [c.value for c in RunpodColour], + "event_types": [e.value for e in RunpodEventType], + } + + +@router.get("/hosts") +def hosts(unlisted_only: bool = False, db: Session = Depends(get_db), + user: User = Depends(current_user)): + query = select(RunpodHost) + if unlisted_only: + query = query.where(RunpodHost.listed.is_(False)) + rows = db.scalars(query.order_by(RunpodHost.listed, RunpodHost.name)).all() + return {"hosts": [h.to_json() for h in rows]} + + +@router.get("/hosts/{machine_id}") +def host_detail(machine_id: str, db: Session = Depends(get_db), user: User = Depends(current_user)): + row = db.scalars(select(RunpodHost).where(RunpodHost.machine_id == machine_id)).first() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such machine") + return row.to_json(with_events=True) + + +@router.get("/problem-hosts") +def problem_hosts(limit: int = 40, db: Session = Depends(get_db), + user: User = Depends(current_user)): + """Machines ranked by how often they have been unlisted. + + The repeat offenders are the ones worth an RMA conversation rather than + another burn-in, which is what the old script's historical table showed. + """ + unlist_counts = ( + select(RunpodEvent.host_id, func.count(RunpodEvent.id).label("n"), + func.max(RunpodEvent.occurred_at).label("last")) + .where(RunpodEvent.event_type == RunpodEventType.UNLISTED) + .group_by(RunpodEvent.host_id).subquery() + ) + rows = db.execute( + select(RunpodHost, unlist_counts.c.n, unlist_counts.c.last) + .join(unlist_counts, unlist_counts.c.host_id == RunpodHost.id, isouter=True) + .order_by(func.coalesce(unlist_counts.c.n, 0).desc(), RunpodHost.historic_count.desc()) + .limit(max(1, min(limit, 200))) + ).all() + + out = [] + for host, count, last in rows: + total = int(count or 0) or host.historic_count + if not total: + continue + payload = host.to_json() + payload["unlist_events"] = int(count or 0) + payload["effective_count"] = total + payload["last_unlisted"] = last.isoformat() if last else ( + host.unlisted_at.isoformat() if host.unlisted_at else None) + out.append(payload) + return {"hosts": out} + + +@router.post("/sync") +def sync(db: Session = Depends(get_db), user: User = Depends(current_user)): + if not settings.runpod_ready: + raise HTTPException(status.HTTP_400_BAD_REQUEST, + "RunPod is not configured. Set CX_RUNPOD_API_KEY.") + try: + result = sync_machines(db, _client(), actor=user.email) + except RunPodError as exc: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc + return result + + +@router.post("/hosts/{machine_id}") +def patch_host(machine_id: str, body: HostPatch, db: Session = Depends(get_db), + user: User = Depends(current_user)): + row = db.scalars(select(RunpodHost).where(RunpodHost.machine_id == machine_id)).first() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such machine") + changes = [] + if body.colour is not None: + try: + row.colour = RunpodColour(body.colour) + except ValueError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unknown colour '{body.colour}'") from exc + changes.append(f"colour={body.colour}") + for field in ("zendesk_ticket", "jira_key", "jira_status", "next_steps", "last_error"): + value = getattr(body, field) + if value is not None: + setattr(row, field, value) + changes.append(field) + if changes: + record_event(db, row, RunpodEventType.NOTE, actor=user.email, + detail="Updated " + ", ".join(changes)) + db.commit() + return row.to_json(with_events=True) + + +@router.post("/hosts/{machine_id}/events") +def add_event(machine_id: str, body: NoteBody, db: Session = Depends(get_db), + user: User = Depends(current_user)): + row = db.scalars(select(RunpodHost).where(RunpodHost.machine_id == machine_id)).first() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such machine") + try: + event_type = RunpodEventType(body.event_type) + except ValueError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unknown event type") from exc + record_event(db, row, event_type, actor=user.email, detail=body.detail, + zendesk_ticket=body.zendesk_ticket, jira_key=body.jira_key) + db.commit() + return row.to_json(with_events=True) + + +@router.get("/hosts/{machine_id}/ticket-preview") +def ticket_preview(machine_id: str, db: Session = Depends(get_db), + user: User = Depends(current_user)): + """The Zendesk ticket that would be raised, without raising it.""" + row = db.scalars(select(RunpodHost).where(RunpodHost.machine_id == machine_id)).first() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such machine") + return { + **unlisting_ticket(row), + "zendesk_ready": settings.zendesk_ready, + "send_enabled": settings.feature_send_enabled, + "runpod_jira_ready": settings.runpod_jira_ready, + "existing_ticket": row.zendesk_ticket, + "existing_jira": row.jira_key, + } + + +@router.post("/hosts/{machine_id}/zendesk") +async def zendesk_ticket(machine_id: str, body: TicketBody, db: Session = Depends(get_db), + user: User = Depends(current_user)): + row = db.scalars(select(RunpodHost).where(RunpodHost.machine_id == machine_id)).first() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such machine") + try: + return await raise_unlisting_ticket(db, row, user, error_text=body.error_text, + requester=body.requester, subject=body.subject, + body=body.body) + except DeliveryError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + + +@router.post("/hosts/{machine_id}/rma") +async def rma_issue(machine_id: str, body: RmaBody, db: Session = Depends(get_db), + user: User = Depends(current_user)): + row = db.scalars(select(RunpodHost).where(RunpodHost.machine_id == machine_id)).first() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such machine") + try: + return await raise_rma_issue(db, row, user, summary=body.summary, + description=body.description) + except DeliveryError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + + +@router.post("/ingest-email") +def ingest_email(body: EmailBody, db: Session = Depends(get_db), user: User = Depends(current_user)): + """Take a RunPod unlisting email and attach its error hint to the machine. + + The email is the only place the actual failure reason appears, so it is + worth capturing even when the unlisting itself came through the API. + """ + parsed = parse_email(body.raw, body.subject) + if not parsed["machine_id"] and not parsed["host"]: + raise HTTPException(status.HTTP_400_BAD_REQUEST, + "Could not find a machine in that email.") + query = select(RunpodHost) + row = db.scalars(query.where(RunpodHost.machine_id == parsed["machine_id"])).first() \ + if parsed["machine_id"] else None + if row is None and parsed["host"]: + row = db.scalars(query.where(RunpodHost.name == parsed["host"])).first() + if row is None: + return {"parsed": parsed, "matched": False, + "detail": "Parsed the email, but no machine in the database matches."} + + row.last_error = parsed["error_text"] or row.last_error + record_event(db, row, RunpodEventType.UNLISTED, actor="runpod", + detail=f"{parsed['signature']} — {parsed['suggested_action']}", + error_hint=parsed["error_text"], gpu_reserved=parsed["gpus_rented"], + payload={"source": "email", "critical": parsed["is_critical"]}) + db.commit() + return {"parsed": parsed, "matched": True, "host": row.to_json(with_events=True)} diff --git a/backend/app/runpod/__init__.py b/backend/app/runpod/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/runpod/client.py b/backend/app/runpod/client.py new file mode 100644 index 0000000..ebbf092 --- /dev/null +++ b/backend/app/runpod/client.py @@ -0,0 +1,285 @@ +"""RunPod access. + +Two transports, deliberately unequal: + +* **API key (default).** One header against the documented GraphQL endpoint. + Nothing to expire, nothing to refresh, works from a container. +* **Console login (fallback).** The old flow: sign in to Clerk with an email and + password, then reuse the session JWT. Kept because it is the only path that + works if the API key is revoked, but it cannot run unattended. + +The "No active session found" failure in the old script was not a UI change: +the account now has two-factor authentication on. Clerk verifies the password +and then answers ``status: needs_second_factor`` with an emailed code, so no +session is ever created. That cannot be automated without reading the mailbox. +If the account is switched from email codes to an authenticator app, set +CX_RUNPOD_TOTP_SECRET and this fallback can complete on its own; otherwise +CX_RUNPOD_2FA_CODE accepts a code for a single manual run. + +Set CX_RUNPOD_API_KEY and this module never touches the console at all. +""" +from __future__ import annotations + +import os +import threading +import time +from typing import Any, Optional + +import httpx + +GRAPHQL_URL = os.environ.get("CX_RUNPOD_GRAPHQL_URL", "https://api.runpod.io/graphql") +CLERK_BASE = os.environ.get("CX_RUNPOD_CLERK_BASE", "https://clerk.runpod.io") +CONSOLE_URL = "https://console.runpod.io" +CLERK_QS = "__clerk_api_version=2025-11-10&_clerk_js_version=5.125.12" + + +class RunPodError(RuntimeError): + pass + + +# --- queries ---------------------------------------------------------------- + +MACHINES_QUERY = """ +query getMachinesForHostDashboard { + myself { + machineQuota + machines { + id name listed registered verified + gpuTypeId gpuReserved gpuTotal + dataCenterId machineType + hostPricePerGpu margin + uptimePercentListedOneWeek uptimePercentListedFourWeek + maintenanceStart maintenanceEnd + gpuType { displayName manufacturer } + machineSystem { os cudaVersion kernelVersion } + } + } +} +""" + +SUMMARY_QUERY = """ +query getMyMachines { + myself { + machinesSummary { + id displayName listed machineType + gpuTypeId gpuRented gpuTotal + podProfitPerHr diskProfitPerHr + onDemandPods spotPods + } + } +} +""" + +LIST_MUTATION = """ +mutation listMachineBulk($input: MachineListBulkInput) { machineListBulk(input: $input) } +""" + +UNLIST_MUTATION = """ +mutation unlistMachineBulk($input: MachineUnlistBulkInput) { machineUnlistBulk(input: $input) } +""" + +MAINTENANCE_MUTATION = """ +mutation machineScheduleMaintenance($input: MachineScheduleMaintenanceInput) { + machineScheduleMaintenance(input: $input) +} +""" + + +class RunPodClient: + def __init__(self, api_key: str = "", email: str = "", password: str = "", + team_id: str = "", totp_secret: str = "", otp_code: str = "", + timeout: int = 30): + self.api_key = (api_key or "").strip() + self.email = (email or "").strip() + self.password = password or "" + self.team_id = (team_id or "").strip() + self.totp_secret = (totp_secret or "").strip() + self.otp_code = (otp_code or "").strip() + self.timeout = timeout + self._jwt: Optional[str] = None + self._jwt_at = 0.0 + self._lock = threading.Lock() + + # --- auth --------------------------------------------------------------- + + @property + def mode(self) -> str: + if self.api_key: + return "api_key" + if self.email and self.password: + return "console_login" + return "unconfigured" + + def _auth_header(self) -> dict[str, str]: + if self.api_key: + return {"Authorization": f"Bearer {self.api_key}"} + jwt = self._console_jwt() + header = {"Authorization": f"Bearer {jwt}"} + if self.team_id: + header["x-team-id"] = self.team_id + return header + + def _console_jwt(self) -> str: + """Sign in to the console and return a session JWT. + + Only used when no API key is set. Cached for 50 minutes; Clerk tokens + last an hour. + """ + with self._lock: + if self._jwt and time.time() - self._jwt_at < 3000: + return self._jwt + if not (self.email and self.password): + raise RunPodError( + "RunPod is not configured. Set CX_RUNPOD_API_KEY, or " + "CX_RUNPOD_EMAIL and CX_RUNPOD_PASSWORD for the console fallback." + ) + + with httpx.Client(timeout=self.timeout, follow_redirects=True, headers={ + "User-Agent": "Mozilla/5.0", "Origin": CONSOLE_URL, "Referer": f"{CONSOLE_URL}/", + }) as client: + client.get(CONSOLE_URL) + signin = client.post( + f"{CLERK_BASE}/v1/client/sign_ins?{CLERK_QS}", + data={"identifier": self.email, "password": self.password, "strategy": "password"}, + ) + if signin.status_code >= 400: + raise RunPodError(f"Console sign-in rejected ({signin.status_code}): {signin.text[:200]}") + + body = signin.json().get("response") or {} + session_id = body.get("created_session_id") + + if body.get("status") == "needs_second_factor": + session_id = self._second_factor(client, body) + + # The old script read the session out of this response and gave + # up when it was absent. Ask for the client record instead. + jwt, found_id = self._session_from_client(client, session_id) + if not jwt and found_id: + jwt = self._mint_token(client, found_id) + if not jwt: + raise RunPodError( + "Console sign-in completed but no session token came back. RunPod's console auth has " + "changed again - use CX_RUNPOD_API_KEY instead." + ) + self._jwt, self._jwt_at = jwt, time.time() + return jwt + + def _second_factor(self, client: httpx.Client, body: dict[str, Any]) -> Optional[str]: + """Satisfy Clerk's second factor, if we have been given the means to. + + The account currently uses emailed codes, which no unattended process + can read - that is the real reason the old login stopped working. + """ + sign_in_id = body.get("id") + offered = [f.get("strategy") for f in (body.get("supported_second_factors") or [])] + + code = "" + if "totp" in offered and self.totp_secret: + code, strategy = self._totp_code(), "totp" + elif self.otp_code: + code, strategy = self.otp_code, ("email_code" if "email_code" in offered else offered[0] if offered else "") + if "email_code" in offered: + client.post(f"{CLERK_BASE}/v1/client/sign_ins/{sign_in_id}/prepare_second_factor?{CLERK_QS}", + data={"strategy": "email_code"}) + if not code: + raise RunPodError( + "RunPod console login needs a second factor " + f"({', '.join(offered) or 'unknown strategy'}) and no code is available. This is why the old " + "script failed - the password is accepted, but the account has 2FA on. Use CX_RUNPOD_API_KEY for " + "unattended runs, or set CX_RUNPOD_TOTP_SECRET if you move the account to an authenticator app." + ) + + attempt = client.post( + f"{CLERK_BASE}/v1/client/sign_ins/{sign_in_id}/attempt_second_factor?{CLERK_QS}", + data={"strategy": strategy, "code": code}, + ) + if attempt.status_code >= 400: + raise RunPodError(f"Second factor rejected ({attempt.status_code}): {attempt.text[:200]}") + return ((attempt.json().get("response") or {}).get("created_session_id")) + + def _totp_code(self) -> str: + """RFC 6238 code from a base32 secret - no third-party dependency.""" + import base64 + import hashlib + import hmac + import struct + + secret = self.totp_secret.replace(" ", "").upper() + secret += "=" * (-len(secret) % 8) + key = base64.b32decode(secret, casefold=True) + counter = struct.pack(">Q", int(time.time()) // 30) + digest = hmac.new(key, counter, hashlib.sha1).digest() + offset = digest[-1] & 0x0F + value = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF + return f"{value % 1_000_000:06d}" + + def _session_from_client(self, client: httpx.Client, prefer_id: Optional[str]) -> tuple[str, str]: + resp = client.get(f"{CLERK_BASE}/v1/client?{CLERK_QS}") + if resp.status_code >= 400: + return "", "" + sessions = ((resp.json().get("response") or {}).get("sessions") or []) + if not sessions: + return "", "" + chosen = next((s for s in sessions if s.get("id") == prefer_id), sessions[0]) + token = ((chosen.get("last_active_token") or {}).get("jwt")) or "" + return token, str(chosen.get("id") or "") + + def _mint_token(self, client: httpx.Client, session_id: str) -> str: + resp = client.post(f"{CLERK_BASE}/v1/client/sessions/{session_id}/tokens?{CLERK_QS}") + if resp.status_code >= 400: + return "" + return resp.json().get("jwt", "") + + # --- transport ---------------------------------------------------------- + + def execute(self, query: str, variables: Optional[dict[str, Any]] = None) -> dict[str, Any]: + headers = {"Content-Type": "application/json", **self._auth_header()} + payload: dict[str, Any] = {"query": query} + if variables is not None: + payload["variables"] = variables + with httpx.Client(timeout=self.timeout) as client: + resp = client.post(GRAPHQL_URL, json=payload, headers=headers) + if resp.status_code == 401: + raise RunPodError("RunPod rejected the credentials (401). Check CX_RUNPOD_API_KEY.") + if resp.status_code >= 400: + raise RunPodError(f"RunPod returned {resp.status_code}: {resp.text[:300]}") + body = resp.json() + if body.get("errors"): + raise RunPodError("; ".join(e.get("message", "?") for e in body["errors"])[:400]) + data = body.get("data") + if data is None: + raise RunPodError(f"RunPod returned no data: {str(body)[:200]}") + return data + + # --- operations --------------------------------------------------------- + + def machines(self) -> list[dict[str, Any]]: + data = self.execute(MACHINES_QUERY) + return list(((data.get("myself") or {}).get("machines")) or []) + + def summary(self) -> list[dict[str, Any]]: + data = self.execute(SUMMARY_QUERY) + return list(((data.get("myself") or {}).get("machinesSummary")) or []) + + def list_machines(self, machine_ids: list[str]) -> dict[str, Any]: + return self.execute(LIST_MUTATION, {"input": {"machineIds": machine_ids}}) + + def unlist_machines(self, machine_ids: list[str]) -> dict[str, Any]: + return self.execute(UNLIST_MUTATION, {"input": {"machineIds": machine_ids}}) + + def schedule_maintenance(self, machine_ids: list[str], start_utc: str, minutes: int, + reason: str = "EMERGENCY", destructive: bool = False) -> dict[str, Any]: + if reason not in ("UPGRADE", "ROUTINE", "EMERGENCY", "REMOVE"): + raise RunPodError(f"Unknown maintenance reason '{reason}'.") + return self.execute(MAINTENANCE_MUTATION, {"input": { + "machineIds": machine_ids, "maintenanceStartUtc": start_utc, + "maintenanceMinutes": minutes, "maintenanceReason": reason, "destructive": destructive, + }}) + + def check(self) -> dict[str, Any]: + """Cheap connectivity probe for the health endpoint.""" + try: + machines = self.machines() + return {"ok": True, "mode": self.mode, "machines": len(machines)} + except RunPodError as exc: + return {"ok": False, "mode": self.mode, "error": str(exc)} diff --git a/backend/app/runpod/delivery.py b/backend/app/runpod/delivery.py new file mode 100644 index 0000000..a8cd551 --- /dev/null +++ b/backend/app/runpod/delivery.py @@ -0,0 +1,201 @@ +"""Outbound actions for RunPod hosts: Zendesk notification and Jira/RMA escalation. + +An unlisted machine earns nothing and may be stranding rented workloads, so the +team wants a ticket the moment it happens. RunPod's own email already says what +broke; this reproduces that content in Zendesk so it lands in the tracking +platform even when the unlisting was done through the API and no email was sent. + +The same three gates as customer comms apply - configured, feature-flagged, and +sending enabled globally - so a demo instance cannot raise tickets. +""" +from __future__ import annotations + +import datetime as dt +from typing import Any, Optional + +import httpx +from sqlalchemy.orm import Session + +from ..config import get_settings +from ..delivery import DeliveryError, _check_cap +from ..models import RunpodEventType, RunpodHost, User +from .email_parse import classify +from .service import record_event + +settings = get_settings() + + +def unlisting_ticket(host: RunpodHost, error_text: str = "") -> dict[str, str]: + """Compose the ticket body, following the shape of RunPod's own email. + + Keeping the same structure means whoever picks the ticket up reads the + familiar thing: what broke, how many GPUs it is costing, and where to look. + """ + error = (error_text or host.last_error or "").strip() + hint = classify(error) + rented = f"{host.gpu_reserved} GPU(s) currently rented on this machine." if host.gpu_reserved \ + else "No GPUs currently rented - the machine has drained." + + body = "\n".join([ + f"Machine {host.name} ({host.machine_id}) is unlisted and is not accepting new users.", + "", + "Error detected:", + error or "(no error text captured - check the RunPod dashboard or the notification email)", + "", + f"Impact: {rented}", + f"Likely cause: {hint['signature']}", + f"Suggested next step: {hint['suggested_action']}", + "", + "Common causes and where to look:", + "- GPU error or failure: check nvidia-smi for GPU health and dmesg for Xid errors.", + "- Unresponsive Docker daemon: check whether the docker service is running or hung.", + "- Pod sync errors: if df -h hangs, suspect a disk error or hung moosefs mount.", + "- Docker overlay storage: confirm the docker filesystem is XFS and /var/lib/docker is mounted.", + "- Portallocator port check: verify the publicIp ports in /etc/runpod/config.json are reachable.", + "", + "If the fix needs a reboot or hardware work, schedule maintenance from the Machines " + "Dashboard rather than pulling the machine abruptly - that drains workloads gracefully.", + "", + f"Unlisted {host.unlist_count} time(s) to date.", + "Raised automatically by CX Triage.", + ]) + return { + "subject": f"{host.name} Unlisted - {hint['signature']}", + "body": body, + "signature": hint["signature"], + } + + +async def raise_unlisting_ticket(db: Session, host: RunpodHost, actor: User, + *, error_text: str = "", requester: str = "", + subject: str = "", body: str = "") -> dict[str, Any]: + """Create (or comment on) the Zendesk ticket for an unlisted machine.""" + if not settings.feature_send_enabled: + raise DeliveryError("Sending is disabled on this instance (CX_FEATURE_SEND_ENABLED is off).") + if not settings.zendesk_ready: + raise DeliveryError("Zendesk is not configured. Set CX_FEATURE_ZENDESK plus " + "CX_ZENDESK_SUBDOMAIN, CX_ZENDESK_EMAIL and CX_ZENDESK_TOKEN.") + _check_cap(db) + + composed = unlisting_ticket(host, error_text) + subject = subject or composed["subject"] + body = body or composed["body"] + + base = f"https://{settings.zendesk_subdomain}.zendesk.com/api/v2" + auth = (f"{settings.zendesk_email}/token", settings.zendesk_token) + # Keyed on the machine, not the incident: one machine, one running thread. + external_id = f"cx-triage-runpod-{host.machine_id}" + + async with httpx.AsyncClient(timeout=30) as client: + found = await client.get(f"{base}/search.json", + params={"query": f'type:ticket external_id:"{external_id}"'}, auth=auth) + existing = None + if found.status_code == 200: + results = found.json().get("results") or [] + # Only reuse a ticket that is still open; a solved one starts a new thread. + existing = next((t for t in results if t.get("status") not in ("solved", "closed")), None) + + # Internal note by default: this is an operations ticket, not a customer reply. + comment = {"body": body, "public": False} + if existing: + resp = await client.put(f"{base}/tickets/{existing['id']}.json", + json={"ticket": {"comment": comment}}, auth=auth) + action = "updated" + else: + ticket: dict[str, Any] = { + "subject": subject, + "comment": comment, + "priority": "high" if host.gpu_reserved else "normal", + "type": "incident", + "tags": ["cx-triage", "runpod", "unlisted", f"dc-{(host.data_center or 'unknown').lower()}"], + "external_id": external_id, + } + if requester: + ticket["requester"] = {"name": requester.split("@")[0], "email": requester} + resp = await client.post(f"{base}/tickets.json", json={"ticket": ticket}, auth=auth) + action = "created" + + if resp.status_code not in (200, 201): + record_event(db, host, RunpodEventType.NOTE, actor=actor.email, + detail=f"Zendesk ticket failed: HTTP {resp.status_code}") + db.commit() + raise DeliveryError(f"Zendesk returned {resp.status_code}: {resp.text[:300]}") + + ticket_id = str((resp.json().get("ticket") or {}).get("id") + or (existing or {}).get("id") or "") + url = f"https://{settings.zendesk_subdomain}.zendesk.com/agent/tickets/{ticket_id}" + + host.zendesk_ticket = ticket_id or host.zendesk_ticket + record_event(db, host, RunpodEventType.ZENDESK_TICKET, actor=actor.email, + detail=f"Zendesk ticket {ticket_id} {action} - {composed['signature']}", + error_hint=error_text or host.last_error, zendesk_ticket=ticket_id) + db.commit() + return {"ok": True, "ticket_id": ticket_id, "url": url, "action": action, + "subject": subject, "signature": composed["signature"]} + + +async def raise_rma_issue(db: Session, host: RunpodHost, actor: User, *, + summary: str = "", description: str = "") -> dict[str, Any]: + """Open an issue on the RunPod/RMA Jira - a different instance to the OIE one.""" + creds = settings.jira_for("runpod") + if not settings.feature_send_enabled: + raise DeliveryError("Sending is disabled on this instance (CX_FEATURE_SEND_ENABLED is off).") + if not (settings.feature_jira and creds["base"] and creds["email"] and creds["token"]): + raise DeliveryError( + "The RunPod Jira is not configured. Set CX_RUNPOD_JIRA_BASE, CX_RUNPOD_JIRA_EMAIL and " + "CX_RUNPOD_JIRA_TOKEN - or leave them blank to reuse the default instance." + ) + _check_cap(db) + + hint = classify(host.last_error or "") + summary = summary or f"RunPod host {host.name} ({host.machine_id}) - {hint['signature']}" + description = description or "\n".join([ + f"Machine: {host.name} ({host.machine_id})", + f"Data centre: {host.data_center or 'unknown'}", + f"GPUs: {host.gpu_reserved}/{host.gpu_total} rented", + f"Unlisted {host.unlist_count} time(s) to date.", + "", + "Last error:", + host.last_error or "(none captured)", + "", + f"Likely cause: {hint['signature']}", + f"Suggested next step: {hint['suggested_action']}", + "", + f"Zendesk: {host.zendesk_ticket or '(none)'}", + "Raised from CX Triage.", + ]) + + base = creds["base"].rstrip("/") + auth = (creds["email"], creds["token"]) + label = f"cx-triage-runpod-{host.machine_id}" + + async with httpx.AsyncClient(timeout=30) as client: + found = await client.get(f"{base}/rest/api/3/search", + params={"jql": f'labels = "{label}"', "maxResults": 1}, auth=auth) + if found.status_code == 200 and (found.json().get("issues") or []): + issue = found.json()["issues"][0] + key = issue["key"] + host.jira_key = key + record_event(db, host, RunpodEventType.JIRA_LINKED, actor=actor.email, + detail=f"Issue {key} already exists", jira_key=key) + db.commit() + return {"ok": True, "key": key, "url": f"{base}/browse/{key}", "action": "existing"} + + resp = await client.post(f"{base}/rest/api/3/issue", json={"fields": { + "project": {"key": creds["project"]}, + "summary": summary[:250], + "issuetype": {"name": creds["issue_type"]}, + "labels": ["cx-triage", "runpod", label], + "description": {"type": "doc", "version": 1, "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": description[:30000]}]}]}, + }}, auth=auth) + + if resp.status_code not in (200, 201): + raise DeliveryError(f"Jira returned {resp.status_code}: {resp.text[:300]}") + + key = resp.json().get("key", "") + host.jira_key = key + record_event(db, host, RunpodEventType.JIRA_LINKED, actor=actor.email, + detail=f"Issue {key} created on the RunPod Jira", jira_key=key) + db.commit() + return {"ok": True, "key": key, "url": f"{base}/browse/{key}", "action": "created"} diff --git a/backend/app/runpod/email_parse.py b/backend/app/runpod/email_parse.py new file mode 100644 index 0000000..4e43f11 --- /dev/null +++ b/backend/app/runpod/email_parse.py @@ -0,0 +1,114 @@ +"""Parse RunPod's automated unlisting emails. + +Subject: " Unlisted - CRITICAL ERROR" +Body: the machine id in brackets, an "Error detected:" block, and an + "Impact: N GPU(s) currently rented" line. + +The error block is the useful part - it is the only place the actual reason +appears, and it is what CX pastes into the Zendesk ticket and the handover. +Written to cope with the HTML mail as well as a plain-text paste. +""" +from __future__ import annotations + +import html +import quopri +import re +from typing import Any + +SUBJECT_RE = re.compile(r"^(?P[\w.-]+)\s+Unlisted\b", re.I) +MACHINE_RE = re.compile(r"machine\s+(?P[\w.-]+)\s*\((?P[a-z0-9]{8,})\)", re.I) +IMPACT_RE = re.compile(r"Impact:\s*(?P\d+)\s*GPU", re.I) + +ERROR_START = re.compile(r"Error detected:\s*", re.I) +ERROR_END = re.compile(r"(Impact:|Common causes|Need to take the machine offline)", re.I) + +# Recognisable failure signatures, mapped to what CX should do about them. +SIGNATURES: list[tuple[str, str, str]] = [ + (r"memory_remap|uncorrectable remapped memory", + "GPU memory remapping failure", "Hardware. Likely RMA - raise with the vendor."), + (r"xid", "XID error", "Check dmesg and nvidia-smi; usually a burn-in test before relisting."), + (r"gpu_cuda_ok.*expected 1, got 0|cuda initialization|fallen off the bus", + "GPU not visible to CUDA", "Check nvidia-smi and the PCIe link; reboot then burn-in."), + (r"nvidia-smi.*too many failures", "nvidia-smi failing repeatedly", + "Host-level GPU fault - stress test, then RMA if it repeats."), + (r"docker (service )?(unresponsive|hung)|container stuck", + "Docker daemon unresponsive", "Restart docker; check /var/lib/docker is XFS and mounted."), + (r"pod sync (failed|errors)", "Pod sync failing", + "Check df -h for a hung moosefs mount and network to the moosefs cluster."), + (r"portallocator|public port check fail", "Public port check failing", + "Verify the publicIp ports in /etc/runpod/config.json are reachable."), + (r"disk|filesystem|xfs", "Disk or filesystem error", "Check dmesg for disk failures."), +] + + +# Soft line breaks ("=\n") and "=3D" are the giveaways for a quoted-printable +# body, which is how these mails arrive. Left undecoded, the error block comes +# out full of "=3D" and split mid-word. +_QP_HINT = re.compile(r"=\r?\n|=[0-9A-F]{2}") + + +def _maybe_decode_qp(raw: str) -> str: + if not _QP_HINT.search(raw): + return raw + try: + return quopri.decodestring(raw.encode("utf-8", "replace")).decode("utf-8", "replace") + except Exception: + return raw + + +def _text_from_html(raw: str) -> str: + text = re.sub(r"<(script|style)[^>]*>.*?", "", raw, flags=re.S | re.I) + text = re.sub(r"|", "\n", text, flags=re.I) + text = re.sub(r"<[^>]+>", "", text) + return html.unescape(text) + + +def classify(error_text: str) -> dict[str, str]: + """Name the failure and say what it usually means.""" + blob = (error_text or "").lower() + for pattern, label, action in SIGNATURES: + if re.search(pattern, blob, re.I): + return {"signature": label, "suggested_action": action} + return {"signature": "Unrecognised error", + "suggested_action": "Read the error text and escalate if it is not obvious."} + + +def parse(raw: str, subject: str = "") -> dict[str, Any]: + """Pull the machine, the error block and the rented-GPU impact out of an email.""" + raw = _maybe_decode_qp(raw) + text = _text_from_html(raw) if "<" in raw and ">" in raw else raw + lines = [ln.strip() for ln in text.splitlines()] + body = "\n".join(ln for ln in lines if ln) + + host = "" + machine_id = "" + match = MACHINE_RE.search(body) + if match: + host, machine_id = match.group("host"), match.group("machine_id") + if not host and subject: + subject_match = SUBJECT_RE.match(subject.strip()) + if subject_match: + host = subject_match.group("host") + + error_text = "" + start = ERROR_START.search(body) + if start: + rest = body[start.end():] + end = ERROR_END.search(rest) + error_text = (rest[:end.start()] if end else rest).strip() + # The mail repeats its own body; one copy is enough. + error_text = "\n".join(dict.fromkeys(ln for ln in error_text.splitlines() if ln.strip() != "---")) + + gpus = None + impact = IMPACT_RE.search(body) + if impact: + gpus = int(impact.group("gpus")) + + return { + "host": host, + "machine_id": machine_id, + "error_text": error_text.strip(), + "gpus_rented": gpus, + "is_critical": "critical" in (subject or body).lower(), + **classify(error_text), + } diff --git a/backend/app/runpod/service.py b/backend/app/runpod/service.py new file mode 100644 index 0000000..ad8e65b --- /dev/null +++ b/backend/app/runpod/service.py @@ -0,0 +1,102 @@ +"""Turning RunPod machine state into tracked history. + +The API only ever reports what is true now. Everything CX cares about - how +often a machine has dropped out, when it drained, who relisted it - only exists +if each sync writes down what changed. +""" +from __future__ import annotations + +import datetime as dt +from typing import Any, Optional + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from ..models import RunpodColour, RunpodEvent, RunpodEventType, RunpodHost + + +def record_event(db: Session, host: RunpodHost, event_type: RunpodEventType, *, + actor: str = "system", detail: str = "", error_hint: str = "", + zendesk_ticket: str = "", jira_key: str = "", + gpu_reserved: Optional[int] = None, + payload: Optional[dict[str, Any]] = None) -> RunpodEvent: + event = RunpodEvent( + event_type=event_type, actor=actor, detail=detail, error_hint=error_hint, + zendesk_ticket=zendesk_ticket, jira_key=jira_key, + gpu_reserved=host.gpu_reserved if gpu_reserved is None else gpu_reserved, + payload=payload, + ) + # Through the relationship so an already-loaded history stays correct. + host.events.append(event) + db.add(event) + return event + + +def sync_machines(db: Session, client: Any, actor: str = "system") -> dict[str, Any]: + """Pull current machines and write down every transition since last time.""" + machines = client.machines() + existing = {h.machine_id: h for h in db.scalars(select(RunpodHost)).all()} + now = dt.datetime.now(dt.timezone.utc) + + created = unlisted = relisted = drained = 0 + + for machine in machines: + machine_id = str(machine.get("id") or "") + if not machine_id: + continue + listed = bool(machine.get("listed")) + reserved = int(machine.get("gpuReserved") or 0) + + host = existing.get(machine_id) + if host is None: + host = RunpodHost(machine_id=machine_id, name=str(machine.get("name") or "")) + db.add(host) + db.flush() + created += 1 + record_event(db, host, RunpodEventType.LISTED if listed else RunpodEventType.UNLISTED, + actor="runpod", detail="First seen by CX Triage") + if not listed: + host.unlisted_at = now + + was_listed = host.listed + was_reserved = host.gpu_reserved + + if was_listed and not listed: + unlisted += 1 + host.unlisted_at = now + host.unlist_count += 1 + # Nothing in the API says why; the email carries the reason and is + # attached separately through /ingest-email. + record_event(db, host, RunpodEventType.UNLISTED, actor="runpod", + detail="Unlisted (detected on sync)", gpu_reserved=reserved) + elif not was_listed and listed: + relisted += 1 + host.last_listed_at = now + host.unlisted_at = None + record_event(db, host, RunpodEventType.LISTED, actor=actor, + detail="Relisted", gpu_reserved=reserved) + + # Unlisted and the last renter has gone: the machine is now safe to work on. + if not listed and was_reserved > 0 and reserved == 0: + drained += 1 + record_event(db, host, RunpodEventType.DRAINED, actor="runpod", + detail="Unlisted machine has drained - no rented GPUs left", + gpu_reserved=0) + + gpu_type = machine.get("gpuType") or {} + host.name = str(machine.get("name") or host.name) + host.listed = listed + host.gpu_reserved = reserved + host.gpu_total = int(machine.get("gpuTotal") or 0) + host.gpu_type = str(gpu_type.get("displayName") or machine.get("gpuTypeId") or "") + host.data_center = str(machine.get("dataCenterId") or "") + host.uptime_one_week = machine.get("uptimePercentListedOneWeek") + host.maintenance_start = str(machine.get("maintenanceStart") or "") + host.maintenance_end = str(machine.get("maintenanceEnd") or "") + + db.commit() + return { + "machines": len(machines), "created": created, "unlisted": unlisted, + "relisted": relisted, "drained": drained, + "synced_at": now.isoformat(), + } diff --git a/backend/app/seed/__init__.py b/backend/app/seed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/seed/data.py b/backend/app/seed/data.py new file mode 100644 index 0000000..b796835 --- /dev/null +++ b/backend/app/seed/data.py @@ -0,0 +1,174 @@ +"""Seed data drawn from the real exports, so a fresh stack is worth looking at. + +The handover comes from APAC 06 August 2026; the RunPod hosts come from the +monitoring script's own JSON. Only enough is transcribed to make the pages +representative - it is sample data, not a migration. +""" +from __future__ import annotations + +HANDOVER = { + "shift_date": "2026-08-06", + "shift": "APAC", + "handing_to": "EMEA", + "team_members": "Parham", + "significant_issues_checked": True, + "hubspot_checked": True, + "total_open_tickets": 214, + "member_checks": [ + {"name": "Prasad", "hs_checked": False, "jira_checked": False}, + {"name": "Parham", "hs_checked": True, "jira_checked": True}, + ], + "other_comments": ( + "Weekend Coverage Annual Leave Sheet is on SharePoint.\n\n" + "Confluence Sanity Check: going through current docs to see what needs updating " + "and removing.\n\n" + "Kodekloud Training: more team time needed on Kubernetes CKNA. Team leads are " + "working on a schedule.\n\n" + "New runbook WIP to protect customers and infrastructure from suspicious activity - " + "locking a VM and, if required, detaching a public IP allocation.\n\n" + "For any Windmill flows that require a ticket reference, use the numeric number and " + "not the URL for now.\n\n" + "Check the Zendesk sync tool - some Hyperstack orgs are not showing in Zendesk even " + "though they exist in InfraInsight (e.g. org 29084, 29548)." + ), + "items": [ + { + "title": "Request to detach volume from hibernated VM", + "zendesk_tickets": "#8495", + "state": "no_further_engagement", + "remove_at_end_of_shift": True, + "body": ("Details confirmed. Ran the Windmill flow but hit an error on API keys. " + "Volume has since been detached successfully and the customer has been informed."), + "links": ["https://windmill.ngbackend.cloud/run/019fd28c-662b-3b56-199a-6af73bb1993c"], + }, + { + "title": "Baseten node-level failure on b200-worker-frank-quetzal", + "zendesk_tickets": "#8453", + "state": "pending_infra", + "body": ("Aranya reached out on behalf of Baseten about a node-level failure " + "(internal IP 10.32.10.2). Triage provided three times; the first two uploads " + "showed no issues. Customer still reports the node is wedged. Waiting on the " + "third upload for review. Asked Infra whether the nodes just need a reboot."), + "links": ["https://nexgen-cloud.slack.com/archives/C0AP4KV1XT5/p1785827125227079"], + }, + { + "title": "[NO1] Investigate inbound traffic spike", + "zendesk_tickets": "8238", + "jira_key": "OIE-3213", "jira_status": "Complete", + "state": "pending_customer", + "body": ("CARMA Media Insight (org 3429) reported a transatlantic throughput collapse - " + "EU fine, North America down to ~45-141 KB/s. Kheano traced it to edge-level " + "congestion in NO1 affecting all tenants, top talker FIP 149.36.0.199 showing " + "signs of a flood attack. Infra have confirmed WAN utilisation has normalised."), + "links": ["https://nexgen-cloud.slack.com/archives/C049Q9JRGM7/p1785526451626789"], + }, + { + "title": "[EU1-BM2] Reported network latency investigation", + "zendesk_tickets": "#8409", + "jira_key": "OIE-3185", "jira_status": "In Progress", + "state": "pending_infra", + "body": "Baseten experienced a latency spike on 31 July and have requested investigation.", + "links": ["https://nexgen-cloud.slack.com/archives/C0AP4KV1XT5/p1784855398156009"], + }, + { + "title": "Network storage speed", + "zendesk_tickets": "#8207", + "jira_key": "OIE-3210", "jira_status": "In Progress", + "state": "pending_infra", + "body": ("RunPod advised one of their machines is reporting network slowness to their " + "storage. Confirmed against another machine in the same region using the same " + "storage by creating files of random data."), + "links": ["https://nexgen-cloud.slack.com/archives/C049Q9JRGM7/p1785451129222409"], + }, + { + "title": "[EU1-BM2] GPU issues on eu1-bm2-lv1-b200sxm-0(16|55)", + "zendesk_tickets": "#7772", + "jira_key": "OIE-3177", "jira_status": "Pending", + "state": "pending_infra", + "body": ("Baseten requested support on 2 nodes with XID errors. One looks transient; " + "eu1-bm2-lv1-b200sxm-055 is more concerning with UECC. Raised with Lenovo for " + "next steps."), + "links": ["https://nexgen-cloud.slack.com/archives/C0AP4KV1XT5/p1784749263687239"], + }, + { + "title": "Shadeform locked VM - billing adjustment", + "zendesk_tickets": "#8215", + "state": "no_further_engagement", + "remove_at_end_of_shift": True, + "body": ("DevOps raised a ticket to notify the user their VM was locked (945280 / " + "O6QTQyQgqDRN, Shadeform) due to unusual activity. Customer asked for it to be " + "deleted; we unlocked and they deleted it. Locked for 5 days 3 hours 21 minutes " + "= 7401 minutes = 123.35 hours at $1.6756/hr. Total credit $206.69 to come off " + "the next invoice, since the state was never changed to shutoff/locked."), + }, + { + "title": "185.216.20.188 network slowness - CA1-SRV-CPU6", + "zendesk_tickets": "#7793", + "jira_key": "OIE-3189", "jira_status": "Done", + "state": "pending_customer", + "body": ("Customer reported intermittent network instability on VM 298953 " + "(CA1-SRV-CPU6, 185.216.20.188). The problematic VM 924706 / JqXcTaGOt has been " + "siloed by Infra and the routers investigated. Network has been stable for the " + "past couple of days."), + }, + { + "title": "VM stuck - CA1-ESC8-068 host not reachable", + "zendesk_tickets": "#8078, #8076, #7787, #8077", + "jira_key": "OIE-3183", "jira_status": "In Progress", + "state": "pending_customer", + "body": ("Host maintenance scheduled 06 August 2026 at 00:00 UTC. Communication sent to " + "the customer. Four VMs still on the host and reachable: vm-regA (786651), " + "dubbix_instance_1 (737376), gianpaolo-dev-2 (602721), triton-backup-2 (213340). " + "Follow-up emails sent. Pending customer reply on the maintenance window."), + }, + ], +} + +# The colour key from the handover doc, so the UI can explain itself. +RUNPOD_COLOURS = { + "red": "Blocked from being relisted due to recurring issues. Needs in-depth investigation or is tied to a system issue.", + "purple": "Pending RunPod. We are waiting on RunPod for something.", + "yellow": "Unactionable by CX. Pending investigation or remediation from Infrastructure or the DC team.", + "blue": "Unactionable by CX. Pending removal from the RunPod platform.", + "green": "Stress testing for >24 hours. GPUs may appear reserved.", + "white": "Actionable by CX.", +} + +# The unlisted table from the same handover, with the states CX had recorded. +RUNPOD_BOARD = [ + {"name": "no1-os1-5090-016-contract-001", "machine_id": "hedh664udd3g", "zendesk_ticket": "8405", + "colour": "green", "last_error": "std burn in failed on 03 Aug", + "next_steps": "24 Hr default burn-in test in progress. 05 Aug 2026 15:06 UTC (16:06 BST)"}, + {"name": "no1-os1-5090-099-contract-001", "machine_id": "9bpfxa1o9our", "zendesk_ticket": "6279", + "colour": "red", "jira_key": "RMA-103", "jira_status": "Waiting", + "last_error": "nvidia-smi: too many failures. Raised for RMA", + "next_steps": "24 HR burn-in started 05 Aug 2026 18:38 UTC. Luis asked for multiple 24hr stress tests with full logs in Jira."}, + {"name": "no1-os1-5090-027-contract-001", "machine_id": "seed-5090-027", "zendesk_ticket": "#8385", + "colour": "green", "last_error": "nvidia-smi many failures. First burn-in failed so running again", + "next_steps": "24HR burn-in running, started 05 Aug 2026 20:15 UTC (21:15 BST)"}, + {"name": "no1-os1-5090-050", "machine_id": "gpc1q3vboxfo", "zendesk_ticket": "#8497", + "colour": "green", + "last_error": "dcgm-xid-check: potential XID issue detected\ngpu health check failed: metric gpu_cuda_ok: expected 1, got 0", + "next_steps": "24HR burn-in running, started 05 Aug 2026 17:28 UTC (18:28 BST)"}, + {"name": "no1-os1-4090-009", "machine_id": "f2vwayvyxorv", "zendesk_ticket": "#7342", + "colour": "yellow", "last_error": "nvidia-smi: too many failures. 2/8 GPU in use", + "next_steps": "Maintenance scheduled 4 Aug 2026 19:00 UTC. Host has connectivity issues - may need escalating."}, + {"name": "no1-os1-4090-020", "machine_id": "s7kl180cusjr", "zendesk_ticket": "#7359", + "colour": "yellow", "last_error": "pod sync failed 16 times. 2/8 GPU in use", + "next_steps": "Maintenance scheduled 04 Aug 2026 13:02 UTC for 1 day"}, + {"name": "ca1-esc8-106", "machine_id": "x0gn8v2rthk4", "zendesk_ticket": "419195749569", + "colour": "red", "jira_status": "Under Test", + "last_error": ("dcgm-xid-check: potential XID issue detected\n" + "gpu health check failed: error indicator present: " + "gpu_failed{reason=memory_remap,uuid=GPU-d29f591c-63e9-5f1d-add1-ed0e2dad3660}"), + "next_steps": "Repeated offender - does not stay listed for more than 48 hours. Booked maintenance 17 Jun 2026 14:52 BST."}, + {"name": "ca1-esc8-121", "machine_id": "seed-esc8-121", "zendesk_ticket": "419270908096", + "colour": "yellow", "jira_status": "Maintenance Scheduled", + "last_error": ("dcgm-xid-check: potential XID issue detected\n" + "gpu health check failed: metric gpu_cuda_ok: expected 1, got 0"), + "next_steps": "3/8 in use. Maintenance 18 Jun 2026 19:00 UTC (20:00 BST)"}, + {"name": "no1-os1-4090-013", "machine_id": "seed-4090-013", "zendesk_ticket": "418292796638", + "colour": "blue", "jira_key": "RMA-60", "jira_status": "Pending RMA", + "last_error": "container stuck: docker service unresponsive", + "next_steps": "Reboot failed, received timeout error. Pending RMA."}, +] diff --git a/backend/app/seed/loader.py b/backend/app/seed/loader.py new file mode 100644 index 0000000..5660856 --- /dev/null +++ b/backend/app/seed/loader.py @@ -0,0 +1,150 @@ +"""Populate an empty database with representative data. + +Runs on startup when CX_SEED_DEMO is on, and only when the relevant table is +empty, so it never overwrites real work. +""" +from __future__ import annotations + +import datetime as dt +import json +import os +import random +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from ..models import (AppSetting, Handover, HandoverItem, HandoverStatus, ItemState, + RunpodColour, RunpodEventType, RunpodHost, ShiftName) +from ..runpod.service import record_event +from .data import HANDOVER, RUNPOD_BOARD, RUNPOD_COLOURS + +# Optional: the monitoring script's own exports, if they have been mounted in. +RUNPOD_EXPORT_DIR = os.environ.get("CX_RUNPOD_EXPORT_DIR", "/seed/runpod") + + +def seed_handover(db: Session) -> str: + if db.scalar(select(Handover).limit(1)): + return "handover: already present, left alone" + + row = Handover( + shift_date=dt.date.fromisoformat(HANDOVER["shift_date"]), + shift=ShiftName(HANDOVER["shift"]), + handing_to=HANDOVER["handing_to"], + team_members=HANDOVER["team_members"], + significant_issues_checked=HANDOVER["significant_issues_checked"], + hubspot_checked=HANDOVER["hubspot_checked"], + total_open_tickets=HANDOVER["total_open_tickets"], + member_checks=HANDOVER["member_checks"], + other_comments=HANDOVER["other_comments"], + status=HandoverStatus.DRAFT, + ) + for position, item in enumerate(HANDOVER["items"]): + row.items.append(HandoverItem( + position=position, title=item["title"], + zendesk_tickets=item.get("zendesk_tickets", ""), + jira_key=item.get("jira_key", ""), jira_status=item.get("jira_status", ""), + body=item.get("body", ""), links=item.get("links", []), + state=ItemState(item.get("state", "in_progress")), + remove_at_end_of_shift=item.get("remove_at_end_of_shift", False), + first_raised_on=row.shift_date, + )) + db.add(row) + db.commit() + return f"handover: seeded {row.shift.value} {row.shift_date} with {len(row.items)} items" + + +def _load_export(name: str): + path = Path(RUNPOD_EXPORT_DIR) / name + if not path.is_file(): + return None + try: + return json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return None + + +def seed_runpod(db: Session) -> str: + if db.scalar(select(RunpodHost).limit(1)): + return "runpod: already present, left alone" + + now = dt.datetime.now(dt.timezone.utc) + rng = random.Random(20260806) # deterministic, so the demo looks the same each time + hosts: dict[str, RunpodHost] = {} + + # 1. The board rows from the handover, with their CX state. + for entry in RUNPOD_BOARD: + host = RunpodHost( + machine_id=entry["machine_id"], name=entry["name"], listed=False, + gpu_reserved=0, gpu_total=8, gpu_type="RTX 5090" if "5090" in entry["name"] else "RTX 4090", + data_center="NO1" if entry["name"].startswith("no1") else "CA1", + colour=RunpodColour(entry.get("colour", "white")), + zendesk_ticket=entry.get("zendesk_ticket", ""), + jira_key=entry.get("jira_key", ""), jira_status=entry.get("jira_status", ""), + last_error=entry.get("last_error", ""), next_steps=entry.get("next_steps", ""), + unlisted_at=now - dt.timedelta(hours=rng.randint(6, 96)), + ) + db.add(host) + db.flush() + hosts[host.machine_id] = host + + # 2. Anything else the monitoring script had recorded. + hosts_db = _load_export("hosts_db.json") or {} + jira_db = _load_export("jira_db.json") or {} + for machine_id, record in list(hosts_db.items())[:200]: + if machine_id in hosts: + continue + jira = jira_db.get(machine_id, {}) + host = RunpodHost( + machine_id=machine_id, name=str(record.get("name") or ""), + listed=bool(record.get("listed", True)), + gpu_reserved=int(record.get("gpuReserved") or 0), gpu_total=8, + historic_count=int(jira.get("historic_count") or 0), + jira_key=jira.get("jira_issue_key") or "", jira_status=jira.get("jira_status") or "", + ) + if not host.listed: + host.unlisted_at = now - dt.timedelta(hours=rng.randint(2, 240)) + db.add(host) + db.flush() + hosts[machine_id] = host + + # 3. A plausible history, so the timeline and ranking have something to show. + for host in hosts.values(): + rounds = max(host.historic_count, 1 if not host.listed else 0) + cursor = now - dt.timedelta(days=min(45, 3 + rounds * 4)) + for _ in range(min(rounds, 12)): + cursor += dt.timedelta(hours=rng.randint(8, 72)) + if cursor >= now: + break + record_event(db, host, RunpodEventType.UNLISTED, actor="runpod", + detail="Unlisted automatically after a critical error", + error_hint=host.last_error or "gpu health check failed", + gpu_reserved=rng.choice([0, 1, 2, 8])) + host.events[-1].occurred_at = cursor + host.unlist_count += 1 + + cursor += dt.timedelta(hours=rng.randint(4, 48)) + if cursor >= now or not host.listed: + continue + operator = rng.choice(["parham.monfared@nexgencloud.com", "luis.sarabando@nexgencloud.com", + "kheano.martinez@nexgencloud.com"]) + record_event(db, host, RunpodEventType.LISTED, actor=operator, + detail="Relisted after burn-in passed", + zendesk_ticket=host.zendesk_ticket) + host.events[-1].occurred_at = cursor + + if not host.listed and host.last_error: + record_event(db, host, RunpodEventType.UNLISTED, actor="runpod", + detail="Current unlisting", error_hint=host.last_error, + zendesk_ticket=host.zendesk_ticket, jira_key=host.jira_key) + host.events[-1].occurred_at = host.unlisted_at or now + host.unlist_count += 1 + + db.merge(AppSetting(key="runpod_colours", value=RUNPOD_COLOURS)) + db.commit() + unlisted = sum(1 for h in hosts.values() if not h.listed) + return f"runpod: seeded {len(hosts)} machines ({unlisted} unlisted) with history" + + +def run(db: Session) -> list[str]: + return [seed_handover(db), seed_runpod(db)] diff --git a/backend/tests/test_runpod.py b/backend/tests/test_runpod.py new file mode 100644 index 0000000..f5594d3 --- /dev/null +++ b/backend/tests/test_runpod.py @@ -0,0 +1,104 @@ +"""RunPod client modes, email parsing, ticket composition and Jira scoping.""" +import os +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +os.environ.setdefault("CX_DATABASE_URL", f"sqlite:///{tempfile.mkdtemp()}/rp.db") + +FAILS = [] + + +def expect(label, cond, got=""): + print((" PASS " if cond else " FAIL ") + label + ("" if cond else f" <- {got}")) + if not cond: + FAILS.append(label) + + +print("\nCLIENT MODES") +from app.runpod.client import RunPodClient, RunPodError + +expect("api key wins over console credentials", + RunPodClient(api_key="k", email="e", password="p").mode == "api_key") +expect("falls back to console login", RunPodClient(email="e", password="p").mode == "console_login") +expect("reports unconfigured", RunPodClient().mode == "unconfigured") +try: + RunPodClient()._console_jwt() + expect("unconfigured client refuses to call out", False) +except RunPodError as exc: + expect("unconfigured client refuses to call out", "not configured" in str(exc).lower()) + +code = RunPodClient(totp_secret="JBSWY3DPEHPK3PXP")._totp_code() +expect("TOTP generator returns a 6-digit code", code.isdigit() and len(code) == 6, code) + +print("\nEMAIL PARSING") +from app.runpod.email_parse import classify, parse + +SAMPLE = """Your machine ca1-esc8-106 (x0gn8v2rthk4) hit a critical error and was automatically unlisted. +Error detected: +dcgm-xid-check: potential XID issue detected +gpu health check failed: metric gpu_cuda_ok: expected 1, got 0 +error indicator present: gpu_failed{reason=memory_remap,uuid=GPU-d29f} +Impact: 8 GPU(s) currently rented on this machine. +Common causes and where to look: +""" +r = parse(SAMPLE, subject="ca1-esc8-106 Unlisted - CRITICAL ERROR") +expect("host extracted", r["host"] == "ca1-esc8-106", r["host"]) +expect("machine id extracted", r["machine_id"] == "x0gn8v2rthk4", r["machine_id"]) +expect("rented GPUs extracted", r["gpus_rented"] == 8, r["gpus_rented"]) +expect("marked critical", r["is_critical"]) +expect("error block captured, boilerplate excluded", + "dcgm-xid-check" in r["error_text"] and "Common causes" not in r["error_text"]) +expect("memory remap recognised as hardware", + r["signature"] == "GPU memory remapping failure", r["signature"]) + +expect("quoted-printable is decoded", + "=3D" not in parse("Error detected:\ngpu_failed{a=3Db}\nImpact: 1 GPU(s)")["error_text"]) +expect("subject-only still yields a host", + parse("no machine line here", subject="no1-os1-5090-050 Unlisted")["host"] == "no1-os1-5090-050") +for text, want in [("nvidia-smi: too many failures", "nvidia-smi failing repeatedly"), + ("pod sync failed 16 times", "Pod sync failing"), + ("container stuck: docker service unresponsive", "Docker daemon unresponsive"), + ("total gibberish", "Unrecognised error")]: + expect(f"classify: {text[:34]}", classify(text)["signature"] == want, classify(text)["signature"]) + +print("\nTICKET COMPOSITION") +from app.models import RunpodHost +from app.runpod.delivery import unlisting_ticket + +host = RunpodHost(machine_id="x0gn8v2rthk4", name="ca1-esc8-106", gpu_reserved=8, gpu_total=8, + unlist_count=7, data_center="CA1", + last_error="dcgm-xid-check: potential XID issue detected\nreason=memory_remap") +t = unlisting_ticket(host) +expect("subject names the host and the fault", + "ca1-esc8-106" in t["subject"] and "memory" in t["subject"].lower(), t["subject"]) +expect("body carries the error text", "dcgm-xid-check" in t["body"]) +expect("body states the rented impact", "8 GPU(s) currently rented" in t["body"]) +expect("body carries the repeat count", "7 time(s)" in t["body"]) + +drained = RunpodHost(machine_id="m", name="h", gpu_reserved=0, gpu_total=8, unlist_count=1) +expect("a drained machine says so", "drained" in unlisting_ticket(drained)["body"]) + +print("\nJIRA SCOPING") +from app.config import Settings + +os.environ.update({"CX_JIRA_BASE": "https://oie.atlassian.net", "CX_JIRA_EMAIL": "oie@x", + "CX_JIRA_TOKEN": "t1", "CX_JIRA_PROJECT": "OIE"}) +s = Settings() +expect("default scope uses the OIE instance", s.jira_for("default")["base"] == "https://oie.atlassian.net") +expect("runpod inherits when unset", s.jira_for("runpod")["base"] == "https://oie.atlassian.net") +expect("runpod keeps its own project", s.jira_for("runpod")["project"] == "RMA") + +os.environ.update({"CX_RUNPOD_JIRA_BASE": "https://rma.atlassian.net", + "CX_RUNPOD_JIRA_EMAIL": "rma@x", "CX_RUNPOD_JIRA_TOKEN": "t2"}) +s = Settings() +expect("runpod uses its own instance when given one", + s.jira_for("runpod")["base"] == "https://rma.atlassian.net") +expect("the two scopes stay separate", + s.jira_for("runpod")["token"] != s.jira_for("default")["token"]) +expect("default is untouched by the runpod values", + s.jira_for("default")["base"] == "https://oie.atlassian.net") + +print("\n" + ("ALL CHECKS PASSED" if not FAILS else f"{len(FAILS)} FAILED: {FAILS}")) +sys.exit(1 if FAILS else 0) diff --git a/backend/triagelib/cxbridge.py b/backend/triagelib/cxbridge.py index 36d98b7..3083bed 100644 --- a/backend/triagelib/cxbridge.py +++ b/backend/triagelib/cxbridge.py @@ -55,22 +55,60 @@ def _import_cxlib(path: str): return cxlib +def _tokens_from_env() -> dict[str, str]: + """API keys supplied directly, bypassing 1Password. + + CX-Tools reads its keys from 1Password, which needs an interactive session + and a desktop app - neither exists in a container. `Config` is a dataclass + whose 1Password lookups live in per-field default factories, so passing the + values in means those factories never run. CX-Tools itself is unmodified. + """ + return { + "api_key": os.environ.get("CX_INFRAHUB_TOKEN", "").strip(), + "insight_api_key": os.environ.get("CX_INFRAINSIGHT_TOKEN", "").strip(), + } + + +def _silence_1password(path: str) -> None: + """Stop the CX-Tools secret loader from reaching for `op`. + + `Config.os_cmd` re-reads the merged config on every OpenStack call, which + would otherwise retry a sign-in that cannot succeed here. Marking the module + as already loaded makes those calls no-ops. + """ + if path not in sys.path: + sys.path.insert(0, path) + try: + import secrets_1password # noqa: PLC0415 + except Exception: + return + secrets_1password._loaded = True + + def bootstrap() -> tuple[Any, Any]: """Import cxlib and build the shared Config, loading secrets exactly once. - Call this from the foreground at startup: constructing Config triggers the - CX-Tools 1Password loader, which may need an interactive sign-in. + With CX_INFRAHUB_TOKEN / CX_INFRAINSIGHT_TOKEN set, credentials come from + the environment. Without them, CX-Tools falls back to 1Password, which is + what a laptop run does. """ with _lock: if _state["config"] is not None: return _state["cx"], _state["config"] path = locate_cx_tools() cx = _import_cxlib(path) - config = cx.Config(no_color=True, debug=bool(os.environ.get("CX_DEBUG"))) + + tokens = _tokens_from_env() + if tokens["api_key"]: + _silence_1password(path) + config = cx.Config(no_color=True, debug=bool(os.environ.get("CX_DEBUG")), + **{k: v for k, v in tokens.items() if v}) + else: + config = cx.Config(no_color=True, debug=bool(os.environ.get("CX_DEBUG"))) if not config.api_key or config.api_key in {"REDACT", "REPLACE_WITH_API_KEY"}: raise BridgeError( - "CX-Tools could not load the Infrahub API key from 1Password. " - "Run `op signin` in this shell, then restart cx-triage." + "No Infrahub API key. Set CX_INFRAHUB_TOKEN (and CX_INFRAINSIGHT_TOKEN), " + "or run `op signin` in this shell for the 1Password path." ) _state.update({"path": path, "cx": cx, "config": config}) return cx, config diff --git a/docker-compose.yml b/docker-compose.yml index 6588c3a..75ad56d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,12 +17,25 @@ services: environment: CX_DATABASE_URL: ${CX_DATABASE_URL:-postgresql+psycopg://cx:cx@db:5432/cxtriage} CX_STATIC_DIR: /app/static + CX_TOOLS_PATH: /opt/cx-tools + CX_RUNPOD_EXPORT_DIR: /seed/runpod volumes: - # The engine shells out to `docker exec -osc ...`, so it needs the - # host's Docker socket. Mount read-only and drop it if you point the app - # at Prometheus/OpenStack directly instead. - - /var/run/docker.sock:/var/run/docker.sock:ro + # The engine shells out to `docker exec -osc ...`. Mounting the + # host's socket makes those *sibling* containers - the ones CX-Tools + # already relies on - reachable from inside this one. Not read-only: + # `docker exec` needs to create an exec instance. + - /var/run/docker.sock:/var/run/docker.sock + # CX-Tools itself, straight off the host. Nothing is written to it. + - ${CX_TOOLS_HOST_PATH:-../CX-Tools}:/opt/cx-tools:ro + # The RunPod monitor's exports, used only to seed demo data. + - ${CX_RUNPOD_EXPORT_HOST_PATH:-../RunPod}:/seed/runpod:ro - cx-data:/data + # The mounted socket is owned by root, and the image runs as an unprivileged + # user, so the container needs to be in the socket's group to use it. On + # Docker Desktop that group is 0; on a Linux host it is the host's `docker` + # group - find it with `stat -c %g /var/run/docker.sock`. + group_add: + - "${CX_DOCKER_GID:-0}" depends_on: db: { condition: service_healthy } restart: unless-stopped diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ae34b74 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1951 @@ +{ + "name": "cx-triage-frontend", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cx-triage-frontend", + "version": "0.2.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } + }, + "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.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@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.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "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.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "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/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "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.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "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/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "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/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/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/electron-to-chromium": { + "version": "1.5.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==", + "dev": true, + "license": "ISC" + }, + "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/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/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/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/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/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/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/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "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.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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": "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/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "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": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.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/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/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/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/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/frontend/src/App.tsx b/frontend/src/App.tsx index 78180a6..f4a8126 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,8 @@ import Login from "./pages/Login"; import Queue from "./pages/Queue"; import Linkage from "./pages/Linkage"; import SettingsPage from "./pages/Settings"; +import HandoverPage from "./pages/Handover"; +import RunpodPage from "./pages/Runpod"; export default function App() { const [user, setUser] = useState(null); @@ -38,7 +40,9 @@ export default function App() {
{config?.app_name ?? "CX Triage"} — alert triage
- `navlink${isActive ? " on" : ""}`}>Queue + `navlink${isActive ? " on" : ""}`}>Handover + `navlink${isActive ? " on" : ""}`}>Infrahub alerts + `navlink${isActive ? " on" : ""}`}>RunPod {config?.linkage_scan && ( `navlink${isActive ? " on" : ""}`}>Linkage )} @@ -51,7 +55,9 @@ export default function App() { - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Handover.tsx b/frontend/src/pages/Handover.tsx new file mode 100644 index 0000000..1a71967 --- /dev/null +++ b/frontend/src/pages/Handover.tsx @@ -0,0 +1,241 @@ +import { useCallback, useEffect, useState } from "react"; +import { api } from "../lib/api"; + +interface Item { + id: number; position: number; title: string; zendesk_tickets: string; + jira_key: string; jira_status: string; body: string; links: string[]; + state: string; remove_at_end_of_shift: boolean; first_raised_on: string | null; + carried_from_id: number | null; +} +interface RunpodRow { + machine_id: string; name: string; colour: string; zendesk_ticket: string; + jira_key: string; jira_status: string; gpu_reserved: number; gpu_total: number; + last_error: string; next_steps: string; hours_unlisted: number | null; unlist_count: number; +} +interface Doc { + id: number; title: string; shift: string; shift_date: string; handing_to: string; + status: string; team_members: string; significant_issues_checked: boolean; + hubspot_checked: boolean; total_open_tickets: number | null; + member_checks: { name: string; hs_checked: boolean; jira_checked: boolean }[]; + other_comments: string; item_count: number; items: Item[]; +} + +const STATES: Record = { + pending_infra: "Pending Infra", pending_customer: "Pending customer", + pending_runpod: "Pending RunPod", pending_rma: "Pending RMA", pending_cx: "Pending CX", + in_progress: "In progress", monitoring: "Monitoring", + no_further_engagement: "No further engagement", done: "Done", +}; + +const COLOURS: Record = { + red: { dot: "#f2545b", text: "Blocked from relisting — recurring issue" }, + purple: { dot: "#a97bf0", text: "Pending RunPod" }, + yellow: { dot: "#e0a336", text: "Pending Infrastructure / DC team" }, + blue: { dot: "#59a0f5", text: "Pending removal from the RunPod platform" }, + green: { dot: "#35c46a", text: "Stress testing >24h — GPUs may look reserved" }, + white: { dot: "#8a93a5", text: "Actionable by CX" }, +}; + +const blankItem = (): Partial => ({ + title: "", zendesk_tickets: "", jira_key: "", jira_status: "", body: "", + links: [], state: "in_progress", remove_at_end_of_shift: false, +}); + +export default function HandoverPage() { + const [doc, setDoc] = useState(null); + const [runpod, setRunpod] = useState([]); + const [editing, setEditing] = useState | null>(null); + const [saved, setSaved] = useState(""); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + const r = await api.get<{ handover: Doc | null; runpod: RunpodRow[] }>("/api/handover/current"); + setDoc(r.handover); setRunpod(r.runpod ?? []); + }, []); + useEffect(() => { void load(); }, [load]); + + const flash = (m: string) => { setSaved(m); setTimeout(() => setSaved(""), 2200); }; + + const saveDoc = async (patch: Partial) => { + if (!doc) return; + const next = await api.post(`/api/handover/${doc.id}`, { ...doc, ...patch }); + setDoc(next); flash("Saved"); + }; + + const saveItem = async () => { + if (!doc || !editing) return; + setBusy(true); + try { + const path = editing.id + ? `/api/handover/${doc.id}/items/${editing.id}` + : `/api/handover/${doc.id}/items`; + setDoc(await api.post(path, editing)); + setEditing(null); flash("Item saved"); + } finally { setBusy(false); } + }; + + const handOver = async () => { + if (!doc) return; + if (!confirm("Close this shift and start the next one? Live items carry forward; " + + "anything done or flagged 'remove at end of shift' is left behind.")) return; + const r = await api.post<{ next: Doc; carried: number }>(`/api/handover/${doc.id}/hand-over`); + setDoc(r.next); flash(`Handed over — ${r.carried} items carried forward`); + }; + + if (!doc) { + return ( +
+
+

No handover yet

+ +
+
+ ); + } + + return ( +
+
+

{doc.title}

+ {doc.shift} → {doc.handing_to} + {doc.status.replace("_", " ")} + + {saved && {saved}} + +
+ +
+

Shift

+
+ + saveDoc({ team_members: e.target.value })} /> + + saveDoc({ total_open_tickets: Number(e.target.value) })} /> + +
+ + +
+
+
+ +
+

Key updates ({doc.items.length})

+ +
+ + {doc.items.map((it) => ( +
+
+ {it.title || "(untitled)"} + {it.carried_from_id && carried over} + {it.remove_at_end_of_shift && remove at end of shift} + + {STATES[it.state] ?? it.state} + + +
+
+ {it.zendesk_tickets && Zendesk {it.zendesk_tickets}} + {it.jira_key && {it.jira_key} {it.jira_status}} +
+ {it.body &&
{it.body}
} + {(it.links ?? []).map((l) => ( + + ))} +
+ ))} + + {editing && ( +
+
+ setEditing({ ...editing, title: e.target.value })} />
+
+
+ setEditing({ ...editing, zendesk_tickets: e.target.value })} />
+
+ setEditing({ ...editing, jira_key: e.target.value })} />
+
+ setEditing({ ...editing, jira_status: e.target.value })} />
+
+
+