From 43a9432b81ee75c516299e717bbda75a7eee1475 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Sat, 30 May 2026 12:51:11 -0700 Subject: [PATCH] feat(dashboard): GuruConnect v2 operator console (pass 1) React + Vite + TypeScript SPA: scaffold, operations-terminal design system, Bearer-token auth, and the Machines view. - Design system: OKLCH-tinted dark theme (ink-slate + signal-cyan), Hanken Grotesk + JetBrains Mono, status-color language (online/offline/granted/pending/denied/not_required), motion with prefers-reduced-motion honored. - Auth: token in sessionStorage via ref (never React state), protected routes, 401 session teardown, admin-gated per-agent-key UI. - Machines view: data table (sticky header, keyboard-activated rows, skeleton loading, actionable empty/error states), non-blocking detail drawer, delete confirm, admin key management with copy-once reveal. - UI primitives: Modal (focus trap + inert + portal + dialogStack), Drawer, Table, Badge/StatusDot, toast, states. - Typed API client normalizing the two error-envelope shapes. Passed Code Review (no blockers), impeccable critique-and-polish, and local gates (tsc/lint/build green). Dev-only Vite proxy to :3002. Co-Authored-By: Claude Opus 4.8 (1M context) --- dashboard/.env.example | 12 + dashboard/.gitignore | 5 + dashboard/README.md | 109 + dashboard/eslint.config.js | 32 + dashboard/index.html | 13 + dashboard/package-lock.json | 3331 +++++++++++++++++ dashboard/package.json | 44 +- dashboard/src/App.tsx | 41 + dashboard/src/api/auth.ts | 20 + dashboard/src/api/client.ts | 131 + dashboard/src/api/index.ts | 5 + dashboard/src/api/machines.ts | 73 + dashboard/src/api/stubs.ts | 23 + dashboard/src/api/types.ts | 115 + dashboard/src/auth/AuthContext.tsx | 21 + dashboard/src/auth/AuthProvider.tsx | 100 + dashboard/src/auth/ProtectedRoute.tsx | 27 + dashboard/src/components/RemoteViewer.tsx | 215 -- dashboard/src/components/SessionControls.tsx | 187 - dashboard/src/components/index.ts | 22 - dashboard/src/components/layout/AppShell.tsx | 17 + .../src/components/layout/PageHeader.tsx | 21 + dashboard/src/components/layout/Sidebar.tsx | 71 + dashboard/src/components/layout/Topbar.tsx | 51 + dashboard/src/components/layout/icons.tsx | 113 + dashboard/src/components/layout/layout.css | 215 ++ dashboard/src/components/ui/Badge.tsx | 25 + dashboard/src/components/ui/Button.tsx | 53 + dashboard/src/components/ui/ConfirmDialog.tsx | 55 + dashboard/src/components/ui/Drawer.tsx | 150 + dashboard/src/components/ui/Input.tsx | 36 + dashboard/src/components/ui/Modal.tsx | 160 + dashboard/src/components/ui/Panel.tsx | 27 + dashboard/src/components/ui/Spinner.tsx | 14 + dashboard/src/components/ui/States.tsx | 30 + dashboard/src/components/ui/StatusDot.tsx | 24 + dashboard/src/components/ui/Table.tsx | 95 + dashboard/src/components/ui/TableSkeleton.tsx | 54 + dashboard/src/components/ui/dialogStack.ts | 28 + dashboard/src/components/ui/status.ts | 28 + dashboard/src/components/ui/table.css | 153 + dashboard/src/components/ui/toast-context.ts | 25 + dashboard/src/components/ui/toast.tsx | 116 + dashboard/src/components/ui/ui.css | 454 +++ dashboard/src/features/auth/LoginPage.tsx | 106 + dashboard/src/features/auth/login.css | 91 + .../features/machines/DeleteMachineDialog.tsx | 126 + .../src/features/machines/KeyRevealModal.tsx | 72 + .../features/machines/MachineDetailDrawer.tsx | 153 + .../features/machines/MachineKeysModal.tsx | 197 + .../src/features/machines/MachinesPage.tsx | 245 ++ dashboard/src/features/machines/hooks.ts | 78 + dashboard/src/features/machines/machines.css | 131 + dashboard/src/hooks/useRemoteSession.ts | 239 -- dashboard/src/lib/protobuf.ts | 162 - dashboard/src/lib/time.ts | 60 + dashboard/src/lib/useClipboard.ts | 48 + dashboard/src/lib/useRelayStatus.ts | 28 + dashboard/src/main.tsx | 21 + dashboard/src/styles/tokens.css | 223 ++ dashboard/src/types/protocol.ts | 135 - dashboard/src/vite-env.d.ts | 10 + dashboard/tsconfig.app.json | 27 + dashboard/tsconfig.json | 24 +- dashboard/tsconfig.node.json | 20 + dashboard/vite.config.ts | 35 + 66 files changed, 7777 insertions(+), 995 deletions(-) create mode 100644 dashboard/.env.example create mode 100644 dashboard/.gitignore create mode 100644 dashboard/README.md create mode 100644 dashboard/eslint.config.js create mode 100644 dashboard/index.html create mode 100644 dashboard/package-lock.json create mode 100644 dashboard/src/App.tsx create mode 100644 dashboard/src/api/auth.ts create mode 100644 dashboard/src/api/client.ts create mode 100644 dashboard/src/api/index.ts create mode 100644 dashboard/src/api/machines.ts create mode 100644 dashboard/src/api/stubs.ts create mode 100644 dashboard/src/api/types.ts create mode 100644 dashboard/src/auth/AuthContext.tsx create mode 100644 dashboard/src/auth/AuthProvider.tsx create mode 100644 dashboard/src/auth/ProtectedRoute.tsx delete mode 100644 dashboard/src/components/RemoteViewer.tsx delete mode 100644 dashboard/src/components/SessionControls.tsx delete mode 100644 dashboard/src/components/index.ts create mode 100644 dashboard/src/components/layout/AppShell.tsx create mode 100644 dashboard/src/components/layout/PageHeader.tsx create mode 100644 dashboard/src/components/layout/Sidebar.tsx create mode 100644 dashboard/src/components/layout/Topbar.tsx create mode 100644 dashboard/src/components/layout/icons.tsx create mode 100644 dashboard/src/components/layout/layout.css create mode 100644 dashboard/src/components/ui/Badge.tsx create mode 100644 dashboard/src/components/ui/Button.tsx create mode 100644 dashboard/src/components/ui/ConfirmDialog.tsx create mode 100644 dashboard/src/components/ui/Drawer.tsx create mode 100644 dashboard/src/components/ui/Input.tsx create mode 100644 dashboard/src/components/ui/Modal.tsx create mode 100644 dashboard/src/components/ui/Panel.tsx create mode 100644 dashboard/src/components/ui/Spinner.tsx create mode 100644 dashboard/src/components/ui/States.tsx create mode 100644 dashboard/src/components/ui/StatusDot.tsx create mode 100644 dashboard/src/components/ui/Table.tsx create mode 100644 dashboard/src/components/ui/TableSkeleton.tsx create mode 100644 dashboard/src/components/ui/dialogStack.ts create mode 100644 dashboard/src/components/ui/status.ts create mode 100644 dashboard/src/components/ui/table.css create mode 100644 dashboard/src/components/ui/toast-context.ts create mode 100644 dashboard/src/components/ui/toast.tsx create mode 100644 dashboard/src/components/ui/ui.css create mode 100644 dashboard/src/features/auth/LoginPage.tsx create mode 100644 dashboard/src/features/auth/login.css create mode 100644 dashboard/src/features/machines/DeleteMachineDialog.tsx create mode 100644 dashboard/src/features/machines/KeyRevealModal.tsx create mode 100644 dashboard/src/features/machines/MachineDetailDrawer.tsx create mode 100644 dashboard/src/features/machines/MachineKeysModal.tsx create mode 100644 dashboard/src/features/machines/MachinesPage.tsx create mode 100644 dashboard/src/features/machines/hooks.ts create mode 100644 dashboard/src/features/machines/machines.css delete mode 100644 dashboard/src/hooks/useRemoteSession.ts delete mode 100644 dashboard/src/lib/protobuf.ts create mode 100644 dashboard/src/lib/time.ts create mode 100644 dashboard/src/lib/useClipboard.ts create mode 100644 dashboard/src/lib/useRelayStatus.ts create mode 100644 dashboard/src/main.tsx create mode 100644 dashboard/src/styles/tokens.css delete mode 100644 dashboard/src/types/protocol.ts create mode 100644 dashboard/src/vite-env.d.ts create mode 100644 dashboard/tsconfig.app.json create mode 100644 dashboard/tsconfig.node.json create mode 100644 dashboard/vite.config.ts diff --git a/dashboard/.env.example b/dashboard/.env.example new file mode 100644 index 0000000..ad3c41e --- /dev/null +++ b/dashboard/.env.example @@ -0,0 +1,12 @@ +# GuruConnect dashboard — environment. +# Copy to `.env.local` for local overrides (gitignored via `*.local`). + +# Base URL for the GuruConnect API. Leave UNSET to use same-origin (the +# production default — the dashboard is served by the GC server itself). +# +# In `npm run dev`, leave this unset too: Vite proxies `/api` and `/ws` to the +# local GC server (see vite.config.ts), so same-origin requests just work. +# +# Set it only to point the dashboard at a *different* host (e.g. a remote +# server while developing the UI locally): +# VITE_API_URL=https://connect.azcomputerguru.com diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 0000000..5b0bb60 --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +*.local +.vite +node_modules/.tmp diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 0000000..ecc94ed --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,109 @@ +# GuruConnect Operator Dashboard (v2) + +React + Vite + TypeScript SPA — the operator console for GuruConnect v2. A dark +"operations terminal" UI for managing the remote-support fleet. + +> **Pass 1 scope.** This pass ships the scaffold, design system, app shell, +> auth, the typed API client, and the **Machines** view. Sessions, Codes, and +> Users are nav stubs only (disabled in the sidebar) and arrive in later passes. + +## Stack + +- **React 18** + **React Router 6** (client-side routing) +- **Vite 5** (dev server + build) +- **TypeScript** (strict) +- **@tanstack/react-query** (server-state, polling, cache invalidation) +- **@fontsource** — Hanken Grotesk (UI) + JetBrains Mono (technical data) + +No component/icon libraries — primitives and icons are hand-built to keep the +console aesthetic and the bundle lean. + +## Scripts + +```bash +npm install +npm run dev # Vite dev server (proxies /api + /ws to the local GC server) +npm run build # tsc -b && vite build -> dist/ +npm run preview # serve the production build locally +npm run typecheck # tsc --noEmit +npm run lint # eslint +``` + +## Project layout + +``` +src/ + api/ Typed API client + response interfaces (source of truth: server/src/api/*.rs) + client.ts fetch wrapper: base URL, bearer token, dual error-envelope normalization + types.ts TS mirrors of the Rust response structs + auth.ts login / me / logout + machines.ts list / get / history / delete + admin key endpoints + stubs.ts sessions / codes / users — scaffolds for later passes + auth/ AuthProvider (token in memory + sessionStorage), context, ProtectedRoute + components/ + ui/ Reusable primitives: Button, Badge/StatusDot, Table, Panel, + Modal, ConfirmDialog, Input/Field, Spinner, States, Toast + layout/ AppShell, Sidebar, Topbar, PageHeader, inline SVG icons + features/ + auth/ LoginPage + machines/ MachinesPage + detail / delete / admin-keys modals + hooks + lib/ time formatting, clipboard, relay-status probe + styles/ tokens.css (design tokens) +``` + +## Design system — "operations terminal" + +Dark control-room console. Tokens live in `src/styles/tokens.css`; primitive +styles in `src/components/ui/*.css`. + +- **Surfaces:** `--bg #0b0f14`, `--panel #141b22`, `--panel-2 #0e1419` +- **Accent (signal cyan):** `--accent #22d3bf` — primary actions + live state +- **Status language (dot + label, used everywhere):** ok/online `--ok`, + pending `--warn` (soft pulse), denied/offline/error `--bad`, neutral + `--neutral`. Mapping centralised in `components/ui/status.ts`. +- **Type:** Hanken Grotesk for UI; **JetBrains Mono for all technical data** + (agent IDs, support codes, IPs, versions, timestamps, key fingerprints). +- **Motion (restrained):** staggered row fade-in, the consent pulse, the live + relay pip, hover transitions. All disabled under `prefers-reduced-motion`. + +## Auth + +`POST /api/auth/login` → `{ token, user }`. The token is held in an in-memory +ref and mirrored to **sessionStorage** (never localStorage), so it clears when +the tab closes. `GET /api/auth/me` restores the session on reload; +`POST /api/auth/logout` revokes it server-side. The client attaches +`Authorization: Bearer ` to every request and bounces to `/login` on any +401. Admin-only UI (per-agent key management) is gated on `role === "admin"`. + +The API uses **two** error envelopes — `{ error }` and +`{ detail, error_code, status_code }`. `api/client.ts` extracts a message from +whichever is present (and falls back to plain-text bodies that some routes +return), so callers see one normalized `ApiError`. + +## Dev proxy + +`vite.config.ts` proxies `/api` and `/ws` to the local GC server +(`http://localhost:3002`). Run the Rust server locally, then `npm run dev` — +same-origin requests reach the backend with no CORS setup. + +To develop the UI against a *remote* backend instead, set `VITE_API_URL` +(see `.env.example`). + +## Production serving — follow-up (NOT wired in this pass) + +The build uses `base: "./"` so emitted assets use relative paths. Production +serving means copying `dist/` into the GC server's static directory and adding a +catch-all route that returns `index.html` for non-API, non-asset paths (so deep +links like `/machines` survive a hard reload under the `BrowserRouter`). + +That Rust-side wiring is a **deploy concern** and is intentionally left for a +later step: + +1. Copy `dist/` → `server/static/` (or serve `dist/` directly). +2. Add an Axum fallback route serving `index.html` for unmatched GET paths, + *after* the `/api/*`, `/ws/*`, and static-asset routes. +3. If the dashboard is mounted under a sub-path rather than the server root, + switch Vite `base` to that path and pass the same `basename` to + ``. + +No server/Rust changes were made in this pass. diff --git a/dashboard/eslint.config.js b/dashboard/eslint.config.js new file mode 100644 index 0000000..05267a5 --- /dev/null +++ b/dashboard/eslint.config.js @@ -0,0 +1,32 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist", "node_modules"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2022, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + }, + }, +); diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..17651b8 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,13 @@ + + + + + + + GuruConnect — Operator Console + + +
+ + + diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..67d89ce --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,3331 @@ +{ + "name": "@guruconnect/dashboard", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@guruconnect/dashboard", + "version": "2.0.0", + "license": "Proprietary", + "dependencies": { + "@fontsource/hanken-grotesk": "^5.0.8", + "@fontsource/jetbrains-mono": "^5.0.18", + "@tanstack/react-query": "^5.59.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@eslint/js": "^9.11.1", + "@types/react": "^18.3.10", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "eslint": "^9.11.1", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.12", + "globals": "^15.9.0", + "typescript": "^5.6.2", + "typescript-eslint": "^8.7.0", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fontsource/hanken-grotesk": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/hanken-grotesk/-/hanken-grotesk-5.2.8.tgz", + "integrity": "sha512-J/e6hdfNCbyc4WK5hmZtk0zjaIsFx3pvCdPVxY25iYw2C9v1ZggGz4nfHnRjMhcz4WfaadUuwLNtvj8sQ70tkg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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/@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.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", + "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", + "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "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.29", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", + "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", + "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/@typescript-eslint/eslint-plugin": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "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.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "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/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "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/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "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/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "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/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "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/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "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/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json index 7e2beab..507527a 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1,25 +1,37 @@ { "name": "@guruconnect/dashboard", - "version": "0.2.0", - "description": "GuruConnect Remote Desktop Viewer Components", + "version": "2.0.0", + "description": "GuruConnect v2 operator dashboard", "author": "AZ Computer Guru", "license": "Proprietary", - "main": "src/components/index.ts", - "types": "src/components/index.ts", + "private": true, + "type": "module", "scripts": { - "typecheck": "tsc --noEmit", - "lint": "eslint src" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - }, - "devDependencies": { - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", - "typescript": "^5.0.0" + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint .", + "typecheck": "tsc --noEmit" }, "dependencies": { - "fzstd": "^0.1.1" + "@fontsource/hanken-grotesk": "^5.0.8", + "@fontsource/jetbrains-mono": "^5.0.18", + "@tanstack/react-query": "^5.59.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@eslint/js": "^9.11.1", + "@types/react": "^18.3.10", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "eslint": "^9.11.1", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.12", + "globals": "^15.9.0", + "typescript": "^5.6.2", + "typescript-eslint": "^8.7.0", + "vite": "^5.4.8" } } diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx new file mode 100644 index 0000000..9113598 --- /dev/null +++ b/dashboard/src/App.tsx @@ -0,0 +1,41 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Navigate, Route, BrowserRouter, Routes } from "react-router-dom"; +import { AuthProvider } from "./auth/AuthProvider"; +import { ProtectedRoute } from "./auth/ProtectedRoute"; +import { AppShell } from "./components/layout/AppShell"; +import { ToastProvider } from "./components/ui/toast"; +import { LoginPage } from "./features/auth/LoginPage"; +import { MachinesPage } from "./features/machines/MachinesPage"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +export function App() { + return ( + + + + + + } /> + }> + }> + } /> + {/* Sessions / Codes / Users land in later passes. */} + } /> + + + } /> + + + + + + ); +} diff --git a/dashboard/src/api/auth.ts b/dashboard/src/api/auth.ts new file mode 100644 index 0000000..21d4866 --- /dev/null +++ b/dashboard/src/api/auth.ts @@ -0,0 +1,20 @@ +import { http } from "./client"; +import type { LoginRequest, LoginResponse, User } from "./types"; + +/** POST /api/auth/login — exchange credentials for a JWT + user record. */ +export function login(credentials: LoginRequest): Promise { + // skipAuthRedirect: a 401 here is "bad credentials", not "session expired". + return http.post("/api/auth/login", credentials, { + skipAuthRedirect: true, + }); +} + +/** GET /api/auth/me — restore the current user from a stored token. */ +export function getMe(): Promise { + return http.get("/api/auth/me"); +} + +/** POST /api/auth/logout — revoke the current token server-side. */ +export function logout(): Promise<{ message: string }> { + return http.post<{ message: string }>("/api/auth/logout"); +} diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts new file mode 100644 index 0000000..d4b1550 --- /dev/null +++ b/dashboard/src/api/client.ts @@ -0,0 +1,131 @@ +// Typed fetch wrapper for the GuruConnect API. +// +// Responsibilities: +// - Resolve the base URL (VITE_API_URL, default same-origin). +// - Attach `Authorization: Bearer ` from a pluggable token provider. +// - Normalize the *two* inconsistent server error envelopes into one +// ApiError shape so callers/UI never have to branch on which one came back. + +const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, ""); + +/** A normalized API error. `code` is present only for the structured envelope. */ +export class ApiError extends Error { + readonly status: number; + readonly code?: string; + + constructor(message: string, status: number, code?: string) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + } +} + +// The token lives in memory in the auth layer. We read it through a provider so +// the client has no hard dependency on React state and stays testable. +let tokenProvider: () => string | null = () => null; + +export function setTokenProvider(provider: () => string | null): void { + tokenProvider = provider; +} + +// Called when any request returns 401 — lets the auth layer tear down session +// state and bounce to /login. Set by AuthProvider. +let onUnauthorized: (() => void) | null = null; + +export function setUnauthorizedHandler(handler: (() => void) | null): void { + onUnauthorized = handler; +} + +interface RequestOptions { + method?: string; + body?: unknown; + // Suppress the global 401 handler (used by the login call itself). + skipAuthRedirect?: boolean; + signal?: AbortSignal; +} + +/** The server's two error envelopes, unioned. We extract a message from either. */ +interface ErrorEnvelope { + error?: string; + detail?: string; + error_code?: string; + status_code?: number; +} + +function buildUrl(path: string): string { + if (path.startsWith("http://") || path.startsWith("https://")) return path; + return `${BASE_URL}${path.startsWith("/") ? path : `/${path}`}`; +} + +async function extractError(res: Response): Promise { + let message = `Request failed (${res.status})`; + let code: string | undefined; + + const raw = await res.text(); + if (raw) { + try { + const env = JSON.parse(raw) as ErrorEnvelope; + // Handle BOTH envelopes: `{error}` and `{detail, error_code, status_code}`. + const msg = env.detail ?? env.error; + if (typeof msg === "string" && msg.length > 0) message = msg; + if (typeof env.error_code === "string") code = env.error_code; + } catch { + // Non-JSON body (e.g. the machines routes return plain &'static str on + // error). Use the trimmed text as the message if it looks sane. + const trimmed = raw.trim(); + if (trimmed && trimmed.length < 300) message = trimmed; + } + } + + return new ApiError(message, res.status, code); +} + +async function request(path: string, opts: RequestOptions = {}): Promise { + const headers: Record = {}; + const token = tokenProvider(); + if (token) headers["Authorization"] = `Bearer ${token}`; + + let body: BodyInit | undefined; + if (opts.body !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(opts.body); + } + + let res: Response; + try { + res = await fetch(buildUrl(path), { + method: opts.method ?? "GET", + headers, + body, + signal: opts.signal, + }); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") throw err; + throw new ApiError("Network error — could not reach the server.", 0); + } + + if (res.status === 401 && !opts.skipAuthRedirect) { + onUnauthorized?.(); + } + + if (!res.ok) { + throw await extractError(res); + } + + // 204 No Content / empty body. + if (res.status === 204) return undefined as T; + const text = await res.text(); + if (!text) return undefined as T; + return JSON.parse(text) as T; +} + +export const http = { + get: (path: string, signal?: AbortSignal) => + request(path, { method: "GET", signal }), + post: (path: string, body?: unknown, opts?: Partial) => + request(path, { method: "POST", body, ...opts }), + put: (path: string, body?: unknown) => + request(path, { method: "PUT", body }), + del: (path: string) => request(path, { method: "DELETE" }), +}; diff --git a/dashboard/src/api/index.ts b/dashboard/src/api/index.ts new file mode 100644 index 0000000..ac61c66 --- /dev/null +++ b/dashboard/src/api/index.ts @@ -0,0 +1,5 @@ +export * from "./types"; +export { ApiError, http, setTokenProvider, setUnauthorizedHandler } from "./client"; +export * as authApi from "./auth"; +export * as machinesApi from "./machines"; +export * as stubsApi from "./stubs"; diff --git a/dashboard/src/api/machines.ts b/dashboard/src/api/machines.ts new file mode 100644 index 0000000..9799863 --- /dev/null +++ b/dashboard/src/api/machines.ts @@ -0,0 +1,73 @@ +import { http } from "./client"; +import type { + CreatedKey, + DeleteMachineParams, + DeleteMachineResponse, + KeyMetadata, + Machine, + MachineHistory, +} from "./types"; + +/** GET /api/machines — the real machines endpoint (NOT /api/sessions). */ +export function listMachines(signal?: AbortSignal): Promise { + return http.get("/api/machines", signal); +} + +/** GET /api/machines/:agent_id — single machine. */ +export function getMachine(agentId: string): Promise { + return http.get(`/api/machines/${encodeURIComponent(agentId)}`); +} + +/** GET /api/machines/:agent_id/history — past sessions + events. */ +export function getMachineHistory( + agentId: string, + signal?: AbortSignal, +): Promise { + return http.get( + `/api/machines/${encodeURIComponent(agentId)}/history`, + signal, + ); +} + +/** DELETE /api/machines/:agent_id — remove a machine, optionally uninstall/export. */ +export function deleteMachine( + agentId: string, + params: DeleteMachineParams = {}, +): Promise { + const qs = new URLSearchParams(); + if (params.uninstall) qs.set("uninstall", "true"); + if (params.export) qs.set("export", "true"); + const suffix = qs.toString() ? `?${qs.toString()}` : ""; + return http.del( + `/api/machines/${encodeURIComponent(agentId)}${suffix}`, + ); +} + +// --- Admin: per-agent keys -------------------------------------------------- + +/** GET /api/machines/:agent_id/keys — list key metadata (admin only). */ +export function listMachineKeys(agentId: string): Promise { + return http.get( + `/api/machines/${encodeURIComponent(agentId)}/keys`, + ); +} + +/** + * POST /api/machines/:agent_id/keys — mint a new per-agent key (admin only). + * The plaintext `key` is returned ONCE in the response — never again. + */ +export function createMachineKey(agentId: string): Promise { + return http.post( + `/api/machines/${encodeURIComponent(agentId)}/keys`, + ); +} + +/** DELETE /api/machines/:agent_id/keys/:key_id — revoke a key (admin only). */ +export function revokeMachineKey( + agentId: string, + keyId: string, +): Promise { + return http.del( + `/api/machines/${encodeURIComponent(agentId)}/keys/${encodeURIComponent(keyId)}`, + ); +} diff --git a/dashboard/src/api/stubs.ts b/dashboard/src/api/stubs.ts new file mode 100644 index 0000000..26013fe --- /dev/null +++ b/dashboard/src/api/stubs.ts @@ -0,0 +1,23 @@ +// Scaffolds for later passes. These endpoints exist on the server but their +// views (Sessions, Codes, Users) are out of scope for pass 1. Typed signatures +// are stubbed here so the API surface is discoverable and future passes can +// flesh out the response interfaces against the Rust source. +// +// Intentionally minimal: do NOT build UI against these yet. + +import { http } from "./client"; + +/** GET /api/sessions — active/historical sessions. Pass 2. */ +export function listSessions(signal?: AbortSignal): Promise { + return http.get("/api/sessions", signal); +} + +/** GET /api/codes — one-time support codes. Pass 2. */ +export function listCodes(signal?: AbortSignal): Promise { + return http.get("/api/codes", signal); +} + +/** GET /api/users — dashboard users (admin). Pass 2. */ +export function listUsers(signal?: AbortSignal): Promise { + return http.get("/api/users", signal); +} diff --git a/dashboard/src/api/types.ts b/dashboard/src/api/types.ts new file mode 100644 index 0000000..f5ac52c --- /dev/null +++ b/dashboard/src/api/types.ts @@ -0,0 +1,115 @@ +// Typed mirrors of the GuruConnect server API responses. +// Shapes match server/src/api/*.rs exactly. Keep in sync with the Rust source +// of truth — these are hand-maintained, not generated. + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +export type Role = "admin" | "operator" | "viewer"; + +export type Permission = + | "view" + | "control" + | "transfer" + | "manage_users" + | "manage_clients"; + +export interface User { + id: string; + username: string; + email: string | null; + // role/permission come from the server as plain strings; widen defensively. + role: Role | string; + permissions: (Permission | string)[]; +} + +export interface LoginResponse { + token: string; + user: User; +} + +export interface LoginRequest { + username: string; + password: string; +} + +// --------------------------------------------------------------------------- +// Machines +// --------------------------------------------------------------------------- + +export type MachineStatus = "online" | "offline"; + +export interface Machine { + id: string; + agent_id: string; + hostname: string; + os_version: string | null; + is_elevated: boolean; + is_persistent: boolean; + first_seen: string; // RFC3339 + last_seen: string; // RFC3339 + status: MachineStatus | string; +} + +export interface SessionRecord { + id: string; + started_at: string; + ended_at: string | null; + duration_secs: number | null; + is_support_session: boolean; + support_code: string | null; + status: string; +} + +export interface EventRecord { + id: number; + session_id: string; + event_type: string; + timestamp: string; + viewer_id: string | null; + viewer_name: string | null; + details: unknown | null; + ip_address: string | null; +} + +export interface MachineHistory { + machine: Machine; + sessions: SessionRecord[]; + events: EventRecord[]; + exported_at: string; +} + +export interface DeleteMachineParams { + /** Send an uninstall command to the agent if it is online. */ + uninstall?: boolean; + /** Include full history in the delete response before removal. */ + export?: boolean; +} + +export interface DeleteMachineResponse { + success: boolean; + message: string; + uninstall_sent: boolean; + history: MachineHistory | null; +} + +// --------------------------------------------------------------------------- +// Per-agent keys (admin plane) +// --------------------------------------------------------------------------- + +export interface KeyMetadata { + id: string; + machine_id: string; + created_at: string; + last_used_at: string | null; + revoked_at: string | null; +} + +/** Returned exactly once when a key is minted. `key` is plaintext `cak_...`. */ +export interface CreatedKey { + id: string; + machine_id: string; + key: string; + created_at: string; +} diff --git a/dashboard/src/auth/AuthContext.tsx b/dashboard/src/auth/AuthContext.tsx new file mode 100644 index 0000000..c186ca5 --- /dev/null +++ b/dashboard/src/auth/AuthContext.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext } from "react"; +import type { Permission, Role, User } from "../api/types"; + +export interface AuthState { + user: User | null; + /** True while restoring a session from a stored token on first load. */ + initializing: boolean; + login: (username: string, password: string) => Promise; + logout: () => Promise; + isAdmin: boolean; + hasRole: (role: Role) => boolean; + hasPermission: (perm: Permission) => boolean; +} + +export const AuthContext = createContext(null); + +export function useAuth(): AuthState { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within "); + return ctx; +} diff --git a/dashboard/src/auth/AuthProvider.tsx b/dashboard/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..bd73405 --- /dev/null +++ b/dashboard/src/auth/AuthProvider.tsx @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import * as authApi from "../api/auth"; +import { setTokenProvider, setUnauthorizedHandler } from "../api/client"; +import type { Permission, Role, User } from "../api/types"; +import { AuthContext, type AuthState } from "./AuthContext"; + +const STORAGE_KEY = "gc.token"; + +/** + * Token storage policy: the source of truth is an in-memory ref (survives + * re-renders, never serialized into React state to avoid accidental logging). + * It is mirrored into sessionStorage — NOT localStorage — so it clears when the + * tab closes and never leaks across browser sessions. + */ +export function AuthProvider({ children }: { children: React.ReactNode }) { + const tokenRef = useRef(sessionStorage.getItem(STORAGE_KEY)); + const [user, setUser] = useState(null); + const [initializing, setInitializing] = useState(true); + + const setToken = useCallback((token: string | null) => { + tokenRef.current = token; + if (token) sessionStorage.setItem(STORAGE_KEY, token); + else sessionStorage.removeItem(STORAGE_KEY); + }, []); + + // Wire the API client to read our token and to notify us on 401. + useEffect(() => { + setTokenProvider(() => tokenRef.current); + }, []); + + const clearSession = useCallback(() => { + setToken(null); + setUser(null); + }, [setToken]); + + useEffect(() => { + setUnauthorizedHandler(clearSession); + return () => setUnauthorizedHandler(null); + }, [clearSession]); + + // Restore session on first load if a token is present. + useEffect(() => { + let cancelled = false; + async function restore() { + if (!tokenRef.current) { + setInitializing(false); + return; + } + try { + const me = await authApi.getMe(); + if (!cancelled) setUser(me); + } catch { + // Invalid/expired token — clear it. The 401 handler also fires, but + // guard here for non-401 failures too. + if (!cancelled) clearSession(); + } finally { + if (!cancelled) setInitializing(false); + } + } + void restore(); + return () => { + cancelled = true; + }; + }, [clearSession]); + + const login = useCallback( + async (username: string, password: string) => { + const res = await authApi.login({ username, password }); + setToken(res.token); + setUser(res.user); + }, + [setToken], + ); + + const logout = useCallback(async () => { + try { + // Best-effort server-side revocation; clear locally regardless. + await authApi.logout(); + } catch { + // ignore — token may already be invalid + } finally { + clearSession(); + } + }, [clearSession]); + + const value = useMemo(() => { + const role = user?.role; + return { + user, + initializing, + login, + logout, + isAdmin: role === "admin", + hasRole: (r: Role) => role === r, + hasPermission: (p: Permission) => user?.permissions.includes(p) ?? false, + }; + }, [user, initializing, login, logout]); + + return {children}; +} diff --git a/dashboard/src/auth/ProtectedRoute.tsx b/dashboard/src/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..54a4c5e --- /dev/null +++ b/dashboard/src/auth/ProtectedRoute.tsx @@ -0,0 +1,27 @@ +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { Spinner } from "../components/ui/Spinner"; +import { useAuth } from "./AuthContext"; + +/** + * Gate for authenticated routes. While restoring a session from a stored token + * we show a spinner (avoids a login-flash on reload). No user -> /login, + * preserving the attempted location for post-login return. + */ +export function ProtectedRoute() { + const { user, initializing } = useAuth(); + const location = useLocation(); + + if (initializing) { + return ( +
+ +
+ ); + } + + if (!user) { + return ; + } + + return ; +} diff --git a/dashboard/src/components/RemoteViewer.tsx b/dashboard/src/components/RemoteViewer.tsx deleted file mode 100644 index eb35ad9..0000000 --- a/dashboard/src/components/RemoteViewer.tsx +++ /dev/null @@ -1,215 +0,0 @@ -/** - * RemoteViewer Component - * - * Canvas-based remote desktop viewer that connects to a GuruConnect - * agent via the relay server. Handles frame rendering and input capture. - */ - -import React, { useRef, useEffect, useCallback, useState } from 'react'; -import { useRemoteSession, createMouseEvent, createKeyEvent } from '../hooks/useRemoteSession'; -import type { VideoFrame, ConnectionStatus, MouseEventType } from '../types/protocol'; - -interface RemoteViewerProps { - serverUrl: string; - sessionId: string; - className?: string; - onStatusChange?: (status: ConnectionStatus) => void; - autoConnect?: boolean; - showStatusBar?: boolean; -} - -export const RemoteViewer: React.FC = ({ - serverUrl, - sessionId, - className = '', - onStatusChange, - autoConnect = true, - showStatusBar = true, -}) => { - const canvasRef = useRef(null); - const containerRef = useRef(null); - const ctxRef = useRef(null); - - // Display dimensions from received frames - const [displaySize, setDisplaySize] = useState({ width: 1920, height: 1080 }); - - // Frame buffer for rendering - const frameBufferRef = useRef(null); - - // Handle incoming video frames - const handleFrame = useCallback((frame: VideoFrame) => { - if (!frame.raw || !canvasRef.current) return; - - const { width, height, data, compressed, isKeyframe } = frame.raw; - - // Update display size if changed - if (width !== displaySize.width || height !== displaySize.height) { - setDisplaySize({ width, height }); - } - - // Get or create context - if (!ctxRef.current) { - ctxRef.current = canvasRef.current.getContext('2d', { - alpha: false, - desynchronized: true, - }); - } - - const ctx = ctxRef.current; - if (!ctx) return; - - // For MVP, we assume raw BGRA frames - // In production, handle compressed frames with fzstd - let frameData = data; - - // Create or reuse ImageData - if (!frameBufferRef.current || - frameBufferRef.current.width !== width || - frameBufferRef.current.height !== height) { - frameBufferRef.current = ctx.createImageData(width, height); - } - - const imageData = frameBufferRef.current; - - // Convert BGRA to RGBA for canvas - const pixels = imageData.data; - const len = Math.min(frameData.length, pixels.length); - - for (let i = 0; i < len; i += 4) { - pixels[i] = frameData[i + 2]; // R <- B - pixels[i + 1] = frameData[i + 1]; // G <- G - pixels[i + 2] = frameData[i]; // B <- R - pixels[i + 3] = 255; // A (opaque) - } - - // Draw to canvas - ctx.putImageData(imageData, 0, 0); - }, [displaySize]); - - // Set up session - const { status, connect, disconnect, sendMouseEvent, sendKeyEvent } = useRemoteSession({ - serverUrl, - sessionId, - onFrame: handleFrame, - onStatusChange, - }); - - // Auto-connect on mount - useEffect(() => { - if (autoConnect) { - connect(); - } - return () => { - disconnect(); - }; - }, [autoConnect, connect, disconnect]); - - // Update canvas size when display size changes - useEffect(() => { - if (canvasRef.current) { - canvasRef.current.width = displaySize.width; - canvasRef.current.height = displaySize.height; - // Reset context reference - ctxRef.current = null; - frameBufferRef.current = null; - } - }, [displaySize]); - - // Get canvas rect for coordinate translation - const getCanvasRect = useCallback(() => { - return canvasRef.current?.getBoundingClientRect() ?? new DOMRect(); - }, []); - - // Mouse event handlers - const handleMouseMove = useCallback((e: React.MouseEvent) => { - const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 0); - sendMouseEvent(event); - }, [getCanvasRect, displaySize, sendMouseEvent]); - - const handleMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 1); - sendMouseEvent(event); - }, [getCanvasRect, displaySize, sendMouseEvent]); - - const handleMouseUp = useCallback((e: React.MouseEvent) => { - const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 2); - sendMouseEvent(event); - }, [getCanvasRect, displaySize, sendMouseEvent]); - - const handleWheel = useCallback((e: React.WheelEvent) => { - e.preventDefault(); - const baseEvent = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 3); - sendMouseEvent({ - ...baseEvent, - wheelDeltaX: Math.round(e.deltaX), - wheelDeltaY: Math.round(e.deltaY), - }); - }, [getCanvasRect, displaySize, sendMouseEvent]); - - const handleContextMenu = useCallback((e: React.MouseEvent) => { - e.preventDefault(); // Prevent browser context menu - }, []); - - // Keyboard event handlers - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - e.preventDefault(); - const event = createKeyEvent(e, true); - sendKeyEvent(event); - }, [sendKeyEvent]); - - const handleKeyUp = useCallback((e: React.KeyboardEvent) => { - e.preventDefault(); - const event = createKeyEvent(e, false); - sendKeyEvent(event); - }, [sendKeyEvent]); - - return ( -
- - - {showStatusBar && ( -
- - {status.connected ? ( - Connected - ) : ( - Disconnected - )} - - {displaySize.width}x{displaySize.height} - {status.fps !== undefined && {status.fps} FPS} - {status.latencyMs !== undefined && {status.latencyMs}ms} -
- )} -
- ); -}; - -export default RemoteViewer; diff --git a/dashboard/src/components/SessionControls.tsx b/dashboard/src/components/SessionControls.tsx deleted file mode 100644 index 899acf8..0000000 --- a/dashboard/src/components/SessionControls.tsx +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Session Controls Component - * - * Toolbar for controlling the remote session (quality, displays, special keys) - */ - -import React, { useState } from 'react'; -import type { QualitySettings, Display } from '../types/protocol'; - -interface SessionControlsProps { - displays?: Display[]; - currentDisplay?: number; - onDisplayChange?: (displayId: number) => void; - quality?: QualitySettings; - onQualityChange?: (settings: QualitySettings) => void; - onSpecialKey?: (key: 'ctrl-alt-del' | 'lock-screen' | 'print-screen') => void; - onDisconnect?: () => void; -} - -export const SessionControls: React.FC = ({ - displays = [], - currentDisplay = 0, - onDisplayChange, - quality, - onQualityChange, - onSpecialKey, - onDisconnect, -}) => { - const [showQuality, setShowQuality] = useState(false); - - const handleQualityPreset = (preset: 'auto' | 'low' | 'balanced' | 'high') => { - onQualityChange?.({ - preset, - codec: 'auto', - }); - }; - - return ( -
- {/* Display selector */} - {displays.length > 1 && ( - - )} - - {/* Quality dropdown */} -
- - - {showQuality && ( -
- {(['auto', 'low', 'balanced', 'high'] as const).map((preset) => ( - - ))} -
- )} -
- - {/* Special keys */} - - - - - - - {/* Spacer */} -
- - {/* Disconnect */} - -
- ); -}; - -export default SessionControls; diff --git a/dashboard/src/components/index.ts b/dashboard/src/components/index.ts deleted file mode 100644 index de94f60..0000000 --- a/dashboard/src/components/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * GuruConnect Dashboard Components - * - * Export all components for use in GuruRMM dashboard - */ - -export { RemoteViewer } from './RemoteViewer'; -export { SessionControls } from './SessionControls'; - -// Re-export types -export type { - ConnectionStatus, - Display, - DisplayInfo, - QualitySettings, - VideoFrame, - MouseEvent as ProtoMouseEvent, - KeyEvent as ProtoKeyEvent, -} from '../types/protocol'; - -// Re-export hooks -export { useRemoteSession, createMouseEvent, createKeyEvent } from '../hooks/useRemoteSession'; diff --git a/dashboard/src/components/layout/AppShell.tsx b/dashboard/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..6d8e429 --- /dev/null +++ b/dashboard/src/components/layout/AppShell.tsx @@ -0,0 +1,17 @@ +import { Outlet } from "react-router-dom"; +import "./layout.css"; +import { Sidebar } from "./Sidebar"; +import { Topbar } from "./Topbar"; + +/** Persistent chrome: left sidebar + top bar around the routed page. */ +export function AppShell() { + return ( +
+ + +
+ +
+
+ ); +} diff --git a/dashboard/src/components/layout/PageHeader.tsx b/dashboard/src/components/layout/PageHeader.tsx new file mode 100644 index 0000000..b3512c5 --- /dev/null +++ b/dashboard/src/components/layout/PageHeader.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; + +interface PageHeaderProps { + title: string; + subtitle?: ReactNode; + /** Primary action slot, right-aligned. */ + actions?: ReactNode; +} + +/** Standard page title block with an action slot. */ +export function PageHeader({ title, subtitle, actions }: PageHeaderProps) { + return ( +
+
+

{title}

+ {subtitle &&
{subtitle}
} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/dashboard/src/components/layout/Sidebar.tsx b/dashboard/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..04c481d --- /dev/null +++ b/dashboard/src/components/layout/Sidebar.tsx @@ -0,0 +1,71 @@ +import { NavLink } from "react-router-dom"; +import type { ComponentType, SVGProps } from "react"; +import { + CodesIcon, + MachinesIcon, + SessionsIcon, + UsersIcon, +} from "./icons"; + +interface NavItem { + to: string; + label: string; + Icon: ComponentType>; + /** Pass-1 stubs are disabled until their views land in later passes. */ + enabled: boolean; +} + +const NAV: NavItem[] = [ + { to: "/machines", label: "Machines", Icon: MachinesIcon, enabled: true }, + { to: "/sessions", label: "Sessions", Icon: SessionsIcon, enabled: false }, + { to: "/codes", label: "Codes", Icon: CodesIcon, enabled: false }, + { to: "/users", label: "Users", Icon: UsersIcon, enabled: false }, +]; + +export function Sidebar() { + return ( + + ); +} diff --git a/dashboard/src/components/layout/Topbar.tsx b/dashboard/src/components/layout/Topbar.tsx new file mode 100644 index 0000000..9bb10aa --- /dev/null +++ b/dashboard/src/components/layout/Topbar.tsx @@ -0,0 +1,51 @@ +import { useAuth } from "../../auth/AuthContext"; +import { useRelayStatus } from "../../lib/useRelayStatus"; +import { Badge } from "../ui/Badge"; +import { Button } from "../ui/Button"; +import { LogoutIcon } from "./icons"; + +function roleTone(role: string | undefined): "accent" | "ok" | "neutral" { + if (role === "admin") return "accent"; + if (role === "operator") return "ok"; + return "neutral"; +} + +export function Topbar() { + const { user, logout } = useAuth(); + const { live, checking } = useRelayStatus(); + + const relayClass = live ? "relay relay--live" : "relay relay--down"; + const relayLabel = checking ? "probing" : live ? "live" : "offline"; + + return ( +
+
+
+ +
+ +
+
+ {user?.username} +
+ {user?.role ?? "—"} + +
+
+ ); +} diff --git a/dashboard/src/components/layout/icons.tsx b/dashboard/src/components/layout/icons.tsx new file mode 100644 index 0000000..a2ccf19 --- /dev/null +++ b/dashboard/src/components/layout/icons.tsx @@ -0,0 +1,113 @@ +// Inline stroke icons (no icon-library dependency). 18px on a 24 viewBox. +import type { SVGProps } from "react"; + +type IconProps = SVGProps; + +function base(props: IconProps) { + return { + width: 18, + height: 18, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.8, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + ...props, + }; +} + +export function MachinesIcon(props: IconProps) { + return ( + + + + + ); +} + +export function SessionsIcon(props: IconProps) { + return ( + + + + ); +} + +export function CodesIcon(props: IconProps) { + return ( + + + + ); +} + +export function UsersIcon(props: IconProps) { + return ( + + + + + + ); +} + +export function LogoutIcon(props: IconProps) { + return ( + + + + ); +} + +export function KeyIcon(props: IconProps) { + return ( + + + + + ); +} + +export function TrashIcon(props: IconProps) { + return ( + + + + ); +} + +export function InfoIcon(props: IconProps) { + return ( + + + + + ); +} + +export function SearchIcon(props: IconProps) { + return ( + + + + + ); +} + +export function RefreshIcon(props: IconProps) { + return ( + + + + ); +} + +export function CopyIcon(props: IconProps) { + return ( + + + + + ); +} diff --git a/dashboard/src/components/layout/layout.css b/dashboard/src/components/layout/layout.css new file mode 100644 index 0000000..1c9574e --- /dev/null +++ b/dashboard/src/components/layout/layout.css @@ -0,0 +1,215 @@ +/* ============================================================= App shell === */ +.shell { + display: grid; + grid-template-columns: var(--sidebar-w) 1fr; + grid-template-rows: var(--topbar-h) 1fr; + grid-template-areas: + "sidebar topbar" + "sidebar main"; + height: 100vh; + overflow: hidden; +} + +/* ================================================================ Sidebar === */ +.sidebar { + grid-area: sidebar; + background: var(--panel-2); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + min-height: 0; +} +.sidebar__brand { + display: flex; + align-items: center; + gap: 10px; + height: var(--topbar-h); + padding: 0 16px; + border-bottom: 1px solid var(--border); + flex: 0 0 auto; +} +.sidebar__logo { + width: 26px; + height: 26px; + border-radius: 6px; + background: linear-gradient(135deg, var(--accent), var(--accent-press)); + display: grid; + place-items: center; + color: var(--accent-ink); + font-weight: 800; + font-size: 14px; + flex: 0 0 auto; +} +.sidebar__name { + font-weight: 700; + font-size: 15px; + letter-spacing: 0.01em; +} +.sidebar__name small { + display: block; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-faint); +} +.sidebar__nav { + display: flex; + flex-direction: column; + gap: 2px; + padding: 12px 10px; + overflow-y: auto; +} +.sidebar__section { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-faint); + padding: 14px 10px 6px; +} +.navlink { + display: flex; + align-items: center; + gap: 11px; + height: 38px; + padding: 0 11px; + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 14px; + font-weight: 500; + transition: + background var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); + border: 1px solid transparent; +} +.navlink:hover { + background: var(--panel); + color: var(--text); +} +.navlink--active { + background: var(--accent-soft); + color: var(--accent); + border-color: var(--accent-ring); +} +.navlink--disabled { + color: var(--text-faint); + cursor: not-allowed; + pointer-events: none; +} +.navlink__icon { + flex: 0 0 auto; + width: 18px; + height: 18px; + display: grid; + place-items: center; +} +.navlink__soon { + margin-left: auto; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-faint); + border: 1px solid var(--border); + border-radius: 999px; + padding: 1px 6px; +} + +/* ================================================================= Topbar === */ +.topbar { + grid-area: topbar; + display: flex; + align-items: center; + gap: 16px; + padding: 0 20px; + background: var(--panel); + border-bottom: 1px solid var(--border); +} +.topbar__spacer { + flex: 1; +} +.relay { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--panel-2); +} +.relay__pip { + width: 7px; + height: 7px; + border-radius: 50%; +} +.relay--live .relay__pip { + background: var(--ok); + box-shadow: 0 0 8px var(--ok); + animation: gc-live 1.8s var(--ease) infinite; +} +.relay--down .relay__pip { + background: var(--bad); +} +.relay__label.mono { + font-size: 11px; +} +.topbar__user { + display: flex; + align-items: center; + gap: 10px; +} +.topbar__id { + display: flex; + flex-direction: column; + align-items: flex-end; + line-height: 1.2; +} +.topbar__username { + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +/* ================================================================== Main === */ +.main { + grid-area: main; + overflow-y: auto; + min-height: 0; +} +.page { + padding: 22px 24px 40px; + max-width: 1320px; + margin: 0 auto; +} +.page__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} +.page__titles h1 { + font-size: 22px; + font-weight: 700; + margin: 0; + letter-spacing: -0.015em; +} +.page__subtitle { + color: var(--text-muted); + font-size: 13px; + margin-top: 3px; +} +.page__actions { + display: flex; + align-items: center; + gap: 10px; +} + +.auth-gate { + display: grid; + place-items: center; + height: 100vh; +} diff --git a/dashboard/src/components/ui/Badge.tsx b/dashboard/src/components/ui/Badge.tsx new file mode 100644 index 0000000..2dc9162 --- /dev/null +++ b/dashboard/src/components/ui/Badge.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; +import type { StatusTone } from "./status"; +import { StatusDot } from "./StatusDot"; + +type BadgeTone = StatusTone | "accent"; + +interface BadgeProps { + tone?: BadgeTone; + /** Render a leading status dot inside the badge. */ + dot?: boolean; + children: ReactNode; +} + +/** + * A pill label using the status vocabulary. With `dot`, pairs the label with a + * matching StatusDot so the dot+label convention reads consistently. + */ +export function Badge({ tone = "neutral", dot = false, children }: BadgeProps) { + return ( + + {dot && tone !== "accent" && } + {children} + + ); +} diff --git a/dashboard/src/components/ui/Button.tsx b/dashboard/src/components/ui/Button.tsx new file mode 100644 index 0000000..2ddd63d --- /dev/null +++ b/dashboard/src/components/ui/Button.tsx @@ -0,0 +1,53 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +type Variant = "primary" | "ghost" | "danger"; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; + /** Compact 28px height for table-row actions and tight toolbars. */ + size?: "sm" | "md"; + /** Stretch to fill the container width (e.g. login submit). */ + block?: boolean; + /** Show a spinner and disable while an async action is in flight. */ + loading?: boolean; + children: ReactNode; +} + +/** + * The one button. Variants map to the design language: + * - primary: accent-solid, the single high-signal action per surface + * - ghost: bordered, secondary + * - danger: destructive (delete machine, revoke key) + */ +export function Button({ + variant = "ghost", + size = "md", + block = false, + loading = false, + disabled, + className, + children, + ...rest +}: ButtonProps) { + const classes = [ + "btn", + `btn--${variant}`, + size === "sm" && "btn--sm", + block && "btn--block", + className, + ] + .filter(Boolean) + .join(" "); + + return ( + + ); +} diff --git a/dashboard/src/components/ui/ConfirmDialog.tsx b/dashboard/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..e6834c8 --- /dev/null +++ b/dashboard/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from "react"; +import { Button } from "./Button"; +import { Modal } from "./Modal"; + +interface ConfirmDialogProps { + open: boolean; + title: string; + body: ReactNode; + confirmLabel?: string; + cancelLabel?: string; + /** Style the confirm button as destructive. */ + danger?: boolean; + /** Disable controls + spin the confirm button while the action runs. */ + busy?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +/** Small yes/no confirmation built on Modal. */ +export function ConfirmDialog({ + open, + title, + body, + confirmLabel = "Confirm", + cancelLabel = "Cancel", + danger = false, + busy = false, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + return ( + {} : onCancel} + dismissable={!busy} + footer={ + <> + + + + } + > + {body} + + ); +} diff --git a/dashboard/src/components/ui/Drawer.tsx b/dashboard/src/components/ui/Drawer.tsx new file mode 100644 index 0000000..d7ce391 --- /dev/null +++ b/dashboard/src/components/ui/Drawer.tsx @@ -0,0 +1,150 @@ +import { useEffect, useId, useRef } from "react"; +import { createPortal } from "react-dom"; +import type { ReactNode } from "react"; +import { + hasOpenDialog, + isTopDialog, + popDialog, + pushDialog, +} from "./dialogStack"; + +interface DrawerProps { + open: boolean; + title: ReactNode; + /** Accessible name when `title` is not plain text. */ + ariaLabel?: string; + /** Optional secondary line under the title (status, id). */ + subtitle?: ReactNode; + onClose: () => void; + /** Sticky footer slot for actions. */ + footer?: ReactNode; + children: ReactNode; +} + +const FOCUSABLE = + 'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])'; + +/** + * Right-anchored side panel for read and inspect flows (machine detail and + * history) where a modal would over-interrupt. Shares the dialog a11y contract: + * Tab focus is trapped, the rest of the page is inert, Escape closes, and focus + * returns to the trigger on close. + */ +export function Drawer({ + open, + title, + ariaLabel, + subtitle, + onClose, + footer, + children, +}: DrawerProps) { + const panelRef = useRef(null); + const lastFocused = useRef(null); + const titleId = useId(); + + useEffect(() => { + if (!open) return; + const panel = panelRef.current; + lastFocused.current = document.activeElement as HTMLElement | null; + const token = pushDialog(); + + const root = document.getElementById("root"); + root?.setAttribute("inert", ""); + + const first = panel?.querySelector(FOCUSABLE); + (first ?? panel)?.focus(); + + function onKey(e: KeyboardEvent) { + if (!isTopDialog(token)) return; + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + return; + } + if (e.key !== "Tab" || !panel) return; + const items = Array.from( + panel.querySelectorAll(FOCUSABLE), + ).filter((el) => el.offsetParent !== null || el === document.activeElement); + if (items.length === 0) { + e.preventDefault(); + panel.focus(); + return; + } + const firstEl = items[0]; + const lastEl = items[items.length - 1]; + const active = document.activeElement as HTMLElement; + if (e.shiftKey && (active === firstEl || active === panel)) { + e.preventDefault(); + lastEl.focus(); + } else if (!e.shiftKey && active === lastEl) { + e.preventDefault(); + firstEl.focus(); + } + } + document.addEventListener("keydown", onKey, true); + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + document.removeEventListener("keydown", onKey, true); + document.body.style.overflow = prevOverflow; + popDialog(token); + if (!hasOpenDialog()) root?.removeAttribute("inert"); + lastFocused.current?.focus?.(); + }; + }, [open, onClose]); + + if (!open) return null; + + return createPortal( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > + +
, + document.body, + ); +} diff --git a/dashboard/src/components/ui/Input.tsx b/dashboard/src/components/ui/Input.tsx new file mode 100644 index 0000000..bf3f051 --- /dev/null +++ b/dashboard/src/components/ui/Input.tsx @@ -0,0 +1,36 @@ +import { forwardRef } from "react"; +import type { InputHTMLAttributes, ReactNode } from "react"; + +interface InputProps extends InputHTMLAttributes { + /** Render technical/data values in JetBrains Mono. */ + mono?: boolean; +} + +/** Bare styled text input. Compose with for a labeled control. */ +export const Input = forwardRef(function Input( + { mono, className, ...rest }, + ref, +) { + const classes = ["input", mono && "input--mono", className] + .filter(Boolean) + .join(" "); + return ; +}); + +interface FieldProps { + label: string; + htmlFor: string; + children: ReactNode; +} + +/** Label + control wrapper for forms. */ +export function Field({ label, htmlFor, children }: FieldProps) { + return ( +
+ + {children} +
+ ); +} diff --git a/dashboard/src/components/ui/Modal.tsx b/dashboard/src/components/ui/Modal.tsx new file mode 100644 index 0000000..ae53015 --- /dev/null +++ b/dashboard/src/components/ui/Modal.tsx @@ -0,0 +1,160 @@ +import { useEffect, useId, useRef } from "react"; +import { createPortal } from "react-dom"; +import type { ReactNode } from "react"; +import { + hasOpenDialog, + isTopDialog, + popDialog, + pushDialog, +} from "./dialogStack"; + +interface ModalProps { + open: boolean; + title: ReactNode; + /** Accessible name for the dialog when `title` is not plain text. */ + ariaLabel?: string; + onClose: () => void; + /** Footer slot, typically the action buttons. */ + footer?: ReactNode; + /** Wider layout for content-heavy dialogs (key management). */ + wide?: boolean; + /** Disable overlay-click and Escape dismissal (e.g. during a pending action). */ + dismissable?: boolean; + children: ReactNode; +} + +const FOCUSABLE = + 'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])'; + +/** + * Accessible modal dialog. Closes on Escape and overlay click (unless + * `dismissable` is false), traps Tab focus inside, marks the rest of the page + * inert, and restores focus to the trigger on close. + */ +export function Modal({ + open, + title, + ariaLabel, + onClose, + footer, + wide, + dismissable = true, + children, +}: ModalProps) { + const panelRef = useRef(null); + const lastFocused = useRef(null); + const titleId = useId(); + + useEffect(() => { + if (!open) return; + const panel = panelRef.current; + lastFocused.current = document.activeElement as HTMLElement | null; + const token = pushDialog(); + + // Mark everything outside the dialog inert so focus and clicks can't reach + // the page behind. Dialogs are portaled to , so this targets #root. + const root = document.getElementById("root"); + root?.setAttribute("inert", ""); + + // Move focus to the first focusable control, falling back to the panel. + const first = panel?.querySelector(FOCUSABLE); + (first ?? panel)?.focus(); + + function onKey(e: KeyboardEvent) { + // Only the topmost dialog reacts (don't close a stack all at once). + if (!isTopDialog(token)) return; + if (e.key === "Escape" && dismissable) { + e.stopPropagation(); + onClose(); + return; + } + if (e.key !== "Tab" || !panel) return; + // Cycle focus within the dialog. + const items = Array.from( + panel.querySelectorAll(FOCUSABLE), + ).filter((el) => el.offsetParent !== null || el === document.activeElement); + if (items.length === 0) { + e.preventDefault(); + panel.focus(); + return; + } + const firstEl = items[0]; + const lastEl = items[items.length - 1]; + const active = document.activeElement as HTMLElement; + if (e.shiftKey && (active === firstEl || active === panel)) { + e.preventDefault(); + lastEl.focus(); + } else if (!e.shiftKey && active === lastEl) { + e.preventDefault(); + firstEl.focus(); + } + } + document.addEventListener("keydown", onKey, true); + + // Lock background scroll while the dialog is open. + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + document.removeEventListener("keydown", onKey, true); + document.body.style.overflow = prevOverflow; + popDialog(token); + // Only lift inert once the last dialog has closed. + if (!hasOpenDialog()) root?.removeAttribute("inert"); + lastFocused.current?.focus?.(); + }; + }, [open, dismissable, onClose]); + + if (!open) return null; + + const labelledBy = ariaLabel ? undefined : titleId; + + return createPortal( +
{ + if (e.target === e.currentTarget && dismissable) onClose(); + }} + > +
+
+

+ {title} +

+ {dismissable && ( + + )} +
+
{children}
+ {footer &&
{footer}
} +
+
, + document.body, + ); +} diff --git a/dashboard/src/components/ui/Panel.tsx b/dashboard/src/components/ui/Panel.tsx new file mode 100644 index 0000000..df80d8c --- /dev/null +++ b/dashboard/src/components/ui/Panel.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; + +interface PanelProps { + /** Optional header title. When omitted, no header bar is rendered. */ + title?: ReactNode; + /** Optional right-aligned header slot (actions, counts). */ + actions?: ReactNode; + /** Remove default body padding (e.g. when embedding a flush table). */ + flush?: boolean; + className?: string; + children: ReactNode; +} + +/** A bordered surface card. The base building block for content panels. */ +export function Panel({ title, actions, flush, className, children }: PanelProps) { + return ( +
+ {(title || actions) && ( +
+ {title ?

{title}

: } + {actions} +
+ )} +
{children}
+
+ ); +} diff --git a/dashboard/src/components/ui/Spinner.tsx b/dashboard/src/components/ui/Spinner.tsx new file mode 100644 index 0000000..15b35bb --- /dev/null +++ b/dashboard/src/components/ui/Spinner.tsx @@ -0,0 +1,14 @@ +interface SpinnerProps { + /** Optional caption rendered under the ring. */ + label?: string; +} + +/** Indeterminate loading ring with an optional label. */ +export function Spinner({ label }: SpinnerProps) { + return ( +
+
+ ); +} diff --git a/dashboard/src/components/ui/States.tsx b/dashboard/src/components/ui/States.tsx new file mode 100644 index 0000000..081142b --- /dev/null +++ b/dashboard/src/components/ui/States.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; + +interface StateProps { + title: string; + message?: ReactNode; + /** Optional action (e.g. a retry button). */ + action?: ReactNode; +} + +/** Neutral "nothing here" placeholder. */ +export function EmptyState({ title, message, action }: StateProps) { + return ( +
+
{title}
+ {message &&
{message}
} + {action} +
+ ); +} + +/** Error placeholder — surfaces a failure instead of silently empty. */ +export function ErrorState({ title, message, action }: StateProps) { + return ( +
+
{title}
+ {message &&
{message}
} + {action} +
+ ); +} diff --git a/dashboard/src/components/ui/StatusDot.tsx b/dashboard/src/components/ui/StatusDot.tsx new file mode 100644 index 0000000..7fb5af5 --- /dev/null +++ b/dashboard/src/components/ui/StatusDot.tsx @@ -0,0 +1,24 @@ +import type { StatusTone } from "./status"; + +interface StatusDotProps { + tone: StatusTone; + /** Accessible label describing what the dot represents. */ + label?: string; +} + +/** + * A small colored status dot. `warn` pulses (consent-pending language). When a + * `label` is given it is an accessible image; without one (e.g. paired with a + * visible label inside a Badge) it is decorative and hidden from assistive tech. + */ +export function StatusDot({ tone, label }: StatusDotProps) { + return ( + + ); +} diff --git a/dashboard/src/components/ui/Table.tsx b/dashboard/src/components/ui/Table.tsx new file mode 100644 index 0000000..555b67c --- /dev/null +++ b/dashboard/src/components/ui/Table.tsx @@ -0,0 +1,95 @@ +import type { ReactNode } from "react"; +import "./table.css"; + +export interface Column { + /** Unique column key. */ + key: string; + /** Header label. Omit for the status / actions rails. */ + header?: ReactNode; + /** Cell renderer. */ + render: (row: T) => ReactNode; + /** Extra class on the (e.g. dt__status, dt__actions). */ + cellClass?: string; +} + +interface TableProps { + columns: Column[]; + rows: T[]; + rowKey: (row: T) => string; + /** Optional per-row activation (opens detail). Bound to click, Enter, Space. */ + onRowClick?: (row: T) => void; + /** Accessible label for the row's primary activation, e.g. the hostname. */ + rowLabel?: (row: T) => string; + /** Cap the staggered fade-in so large lists don't crawl in. */ + maxStaggerRows?: number; +} + +/** + * Dense, console-style data table. Sticky header, hover highlight, hover- + * revealed row actions, and a staggered fade-in on mount (capped so big lists + * appear promptly). Column-driven so callers compose cells declaratively. + */ +export function Table({ + columns, + rows, + rowKey, + onRowClick, + rowLabel, + maxStaggerRows = 14, +}: TableProps) { + return ( +
+ + + + {columns.map((c) => ( + + ))} + + + + {rows.map((row, i) => { + const delay = i < maxStaggerRows ? `${i * 22}ms` : "0ms"; + return ( + onRowClick(row) : undefined} + tabIndex={onRowClick ? 0 : undefined} + aria-label={ + onRowClick && rowLabel + ? `Open detail for ${rowLabel(row)}` + : undefined + } + onKeyDown={ + onRowClick + ? (e) => { + // Activate on Enter or Space, the standard for a + // button-like row. Space must not scroll the page. + if (e.key === "Enter" || e.key === " ") { + if (e.target !== e.currentTarget) return; + e.preventDefault(); + onRowClick(row); + } + } + : undefined + } + > + {columns.map((c) => ( + + ))} + + ); + })} + +
+ {c.header} +
+ {c.render(row)} +
+
+ ); +} diff --git a/dashboard/src/components/ui/TableSkeleton.tsx b/dashboard/src/components/ui/TableSkeleton.tsx new file mode 100644 index 0000000..e18dbaa --- /dev/null +++ b/dashboard/src/components/ui/TableSkeleton.tsx @@ -0,0 +1,54 @@ +interface TableSkeletonProps { + /** Header labels, rendered in the sticky head so columns line up. */ + headers: string[]; + /** Number of placeholder rows. */ + rows?: number; + /** Per-column placeholder bar widths (CSS lengths). Falls back to a default. */ + widths?: string[]; +} + +/** + * Skeleton table that mirrors the real table's layout while data loads. Shows + * the column structure so the page does not jump when rows arrive, and reads as + * progress without a blocking spinner. + */ +export function TableSkeleton({ + headers, + rows = 8, + widths = [], +}: TableSkeletonProps) { + const colWidths = + widths.length === headers.length + ? widths + : headers.map((_, i) => (i === 0 ? "8px" : `${60 + ((i * 23) % 40)}%`)); + + return ( + + ); +} diff --git a/dashboard/src/components/ui/dialogStack.ts b/dashboard/src/components/ui/dialogStack.ts new file mode 100644 index 0000000..fcd2849 --- /dev/null +++ b/dashboard/src/components/ui/dialogStack.ts @@ -0,0 +1,28 @@ +// A tiny module-level stack so only the topmost open dialog (Modal or Drawer) +// reacts to Escape and owns the background `inert` toggle. This keeps stacked +// dialogs (e.g. a confirm on top of a management modal) from all closing at once. + +const stack: symbol[] = []; + +/** Push a dialog onto the stack. Returns its token. */ +export function pushDialog(): symbol { + const token = Symbol("dialog"); + stack.push(token); + return token; +} + +/** Remove a dialog from the stack by token. */ +export function popDialog(token: symbol): void { + const i = stack.lastIndexOf(token); + if (i !== -1) stack.splice(i, 1); +} + +/** True when `token` is the topmost open dialog. */ +export function isTopDialog(token: symbol): boolean { + return stack.length > 0 && stack[stack.length - 1] === token; +} + +/** True when any dialog is open. */ +export function hasOpenDialog(): boolean { + return stack.length > 0; +} diff --git a/dashboard/src/components/ui/status.ts b/dashboard/src/components/ui/status.ts new file mode 100644 index 0000000..9b74e09 --- /dev/null +++ b/dashboard/src/components/ui/status.ts @@ -0,0 +1,28 @@ +// Central status-language mapping. Every status indicator in the app resolves +// through here so the dot color + label vocabulary stays consistent: +// ok = online / granted / success -> --ok (green) +// warn = pending (gets the consent pulse) -> --warn (amber) +// bad = denied / offline / error -> --bad (red) +// neutral = not_required / unknown -> --neutral (slate) + +export type StatusTone = "ok" | "warn" | "bad" | "neutral"; + +/** Map a machine `status` string to a tone. */ +export function machineTone(status: string): StatusTone { + return status === "online" ? "ok" : "bad"; +} + +/** Map an attended-consent state to a tone. `pending` pulses. */ +export function consentTone(state: string): StatusTone { + switch (state) { + case "granted": + return "ok"; + case "pending": + return "warn"; + case "denied": + return "bad"; + case "not_required": + default: + return "neutral"; + } +} diff --git a/dashboard/src/components/ui/table.css b/dashboard/src/components/ui/table.css new file mode 100644 index 0000000..f2ab114 --- /dev/null +++ b/dashboard/src/components/ui/table.css @@ -0,0 +1,153 @@ +/* ============================================================ Data table === */ +.dt { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.dt thead th { + position: sticky; + top: 0; + z-index: 2; + background: var(--panel-2); + text-align: left; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-faint); + padding: 0 14px; + height: 36px; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} +.dt tbody td { + padding: 0 14px; + height: var(--row-h); + border-bottom: 1px solid var(--border); + color: var(--text); + vertical-align: middle; + white-space: nowrap; +} +.dt tbody tr { + transition: background var(--dur-fast) var(--ease); + animation: gc-row-in var(--dur) var(--ease) both; +} +.dt tbody tr:hover { + background: var(--panel-2); +} +.dt tbody tr:hover .dt__rowactions, +.dt tbody tr:focus-within .dt__rowactions { + opacity: 1; +} +/* Keyboard focus on the row itself reads as a clear inset ring. */ +.dt tbody tr:focus-visible { + outline: none; + background: var(--panel-2); + box-shadow: inset 0 0 0 1px var(--accent-ring); +} + +/* Status-dot column — fixed narrow left rail. */ +.dt__status { + width: 30px; + padding-left: 16px !important; + padding-right: 0 !important; +} + +/* Cell affordances. */ +.dt__mono { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-muted); +} +.dt__strong { + font-weight: 600; + color: var(--text); +} +.dt__muted { + color: var(--text-muted); +} + +/* Right-aligned row actions. Dimmed at rest, full on row hover/focus, but + always present and reachable by keyboard and touch (never pointer-events:none, + which would hide them from Tab and tap). */ +.dt__actions { + width: 1%; + text-align: right; +} +.dt__rowactions { + display: inline-flex; + gap: 6px; + justify-content: flex-end; + opacity: 0.5; + transition: opacity var(--dur-fast) var(--ease); +} +/* When any action button is keyboard-focused, surface the whole group. */ +.dt__rowactions:focus-within { + opacity: 1; +} +@media (hover: none) { + /* Touch devices have no hover: keep actions fully legible at all times. */ + .dt__rowactions { + opacity: 1; + } +} + +.dt-wrap { + max-height: calc(100vh - 230px); + overflow: auto; +} + +/* Skeleton loading rows: preview the table shape instead of a bare spinner. */ +.dt__skel { + display: inline-block; + height: 10px; + border-radius: 999px; + background: + linear-gradient( + 90deg, + var(--border) 0%, + var(--border-strong) 50%, + var(--border) 100% + ); + background-size: 200% 100%; + animation: gc-shimmer 1.4s var(--ease) infinite; + vertical-align: middle; +} +.dt__skel--dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +/* Search / toolbar above the table. */ +.toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} +.searchbox { + position: relative; + flex: 0 0 320px; + max-width: 100%; +} +.searchbox__icon { + position: absolute; + left: 11px; + top: 50%; + transform: translateY(-50%); + color: var(--text-faint); + pointer-events: none; +} +.searchbox .input { + width: 100%; + padding-left: 34px; +} +.toolbar__count { + margin-left: auto; + font-size: 12px; + color: var(--text-muted); +} +.toolbar__count .mono { + color: var(--text); +} diff --git a/dashboard/src/components/ui/toast-context.ts b/dashboard/src/components/ui/toast-context.ts new file mode 100644 index 0000000..b8731ae --- /dev/null +++ b/dashboard/src/components/ui/toast-context.ts @@ -0,0 +1,25 @@ +import { createContext, useContext } from "react"; + +export type ToastTone = "success" | "error" | "info"; + +export interface ToastItem { + id: number; + tone: ToastTone; + title: string; + message?: string; +} + +export interface ToastApi { + success: (title: string, message?: string) => void; + error: (title: string, message?: string) => void; + info: (title: string, message?: string) => void; +} + +export const ToastContext = createContext(null); + +/** Imperative toast notifications. Auto-dismiss after a few seconds. */ +export function useToast(): ToastApi { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error("useToast must be used within "); + return ctx; +} diff --git a/dashboard/src/components/ui/toast.tsx b/dashboard/src/components/ui/toast.tsx new file mode 100644 index 0000000..06292d4 --- /dev/null +++ b/dashboard/src/components/ui/toast.tsx @@ -0,0 +1,116 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { + ToastContext, + type ToastApi, + type ToastItem, + type ToastTone, +} from "./toast-context"; + +const AUTO_DISMISS_MS = 4500; + +/** Mounts the toast stack and provides the imperative toast API to descendants. */ +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + const nextId = useRef(1); + + const dismiss = useCallback((id: number) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const push = useCallback( + (tone: ToastTone, title: string, message?: string) => { + const id = nextId.current++; + setToasts((prev) => [...prev, { id, tone, title, message }]); + window.setTimeout(() => dismiss(id), AUTO_DISMISS_MS); + }, + [dismiss], + ); + + const api = useMemo( + () => ({ + success: (title, message) => push("success", title, message), + error: (title, message) => push("error", title, message), + info: (title, message) => push("info", title, message), + }), + [push], + ); + + return ( + + {children} + {/* Polite region for success/info; errors below are assertive. */} +
+ {toasts.map((t) => ( +
+ +
+
{t.title}
+ {t.message &&
{t.message}
} +
+ +
+ ))} +
+
+ ); +} + +function ToastGlyph({ tone }: { tone: ToastTone }) { + const common = { + width: 16, + height: 16, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 2, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + }; + if (tone === "success") { + return ( + + + + ); + } + if (tone === "error") { + return ( + + + + + ); + } + return ( + + + + + ); +} diff --git a/dashboard/src/components/ui/ui.css b/dashboard/src/components/ui/ui.css new file mode 100644 index 0000000..2f096c7 --- /dev/null +++ b/dashboard/src/components/ui/ui.css @@ -0,0 +1,454 @@ +/* ------------------------------------------------------------------ Button */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + height: 34px; + padding: 0 14px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.01em; + cursor: pointer; + white-space: nowrap; + transition: + background var(--dur-fast) var(--ease), + border-color var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease), + opacity var(--dur-fast) var(--ease); + user-select: none; +} +.btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring); +} +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.btn--sm { + height: 28px; + padding: 0 10px; + font-size: 12px; +} +.btn--primary { + background: var(--accent); + color: var(--accent-ink); +} +.btn--primary:hover:not(:disabled) { + background: var(--accent-press); +} +.btn--ghost { + background: transparent; + border-color: var(--border-strong); + color: var(--text); +} +.btn--ghost:hover:not(:disabled) { + background: var(--panel); + border-color: var(--accent); + color: var(--accent); +} +.btn--danger { + background: transparent; + border-color: var(--bad-line); + color: var(--bad); +} +.btn--danger:hover:not(:disabled) { + background: var(--bad-soft); + border-color: var(--bad); +} +.btn--block { + width: 100%; +} +.btn__spin { + width: 13px; + height: 13px; + border-radius: 50%; + border: 2px solid currentColor; + border-top-color: transparent; + opacity: 0.85; + animation: gc-spin 0.7s linear infinite; +} + +/* --------------------------------------------------------- Status dot/badge */ +.statusdot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + flex: 0 0 auto; +} +.statusdot--ok { + background: var(--ok); + box-shadow: 0 0 6px var(--ok-soft); +} +.statusdot--warn { + background: var(--warn); + animation: gc-pulse 1.6s var(--ease) infinite; +} +.statusdot--bad { + background: var(--bad); +} +.statusdot--neutral { + background: var(--neutral); +} + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + border: 1px solid var(--border); + color: var(--text-muted); + background: var(--panel-2); +} +.badge--ok { + color: var(--ok); + background: var(--ok-soft); + border-color: transparent; +} +.badge--warn { + color: var(--warn); + background: var(--warn-soft); + border-color: transparent; +} +.badge--bad { + color: var(--bad); + background: var(--bad-soft); + border-color: transparent; +} +.badge--neutral { + color: var(--text-muted); + background: var(--neutral-soft); + border-color: transparent; +} +.badge--accent { + color: var(--accent); + background: var(--accent-soft); + border-color: transparent; +} + +/* ------------------------------------------------------------- Card / Panel */ +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-1); +} +.panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} +.panel__title { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.005em; + color: var(--text); + margin: 0; +} +.panel__body { + padding: 16px; +} + +/* ------------------------------------------------------------------ Spinner */ +.spinner { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 10px; + color: var(--text-muted); + font-size: 13px; +} +.spinner__ring { + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + animation: gc-spin 0.8s linear infinite; +} +@keyframes gc-spin { + to { + transform: rotate(360deg); + } +} + +/* ----------------------------------------------------- Empty / Error states */ +.state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 48px 24px; + text-align: center; + color: var(--text-muted); +} +.state__title { + font-size: 15px; + font-weight: 600; + color: var(--text); +} +.state__msg { + font-size: 13px; + max-width: 380px; +} +.state--error .state__title { + color: var(--bad); +} + +/* --------------------------------------------------------------------- Modal */ +/* Shared icon button (modal close, toast dismiss). 28px square hit target. */ +.iconbtn { + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; + flex: 0 0 auto; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + transition: + color var(--dur-fast) var(--ease), + background var(--dur-fast) var(--ease); +} +.iconbtn:hover { + color: var(--text); + background: var(--panel-2); +} +.iconbtn:focus-visible { + outline: none; + color: var(--text); + box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring); +} + +.modal__overlay { + position: fixed; + inset: 0; + background: oklch(15% 0.01 var(--brand-hue) / 0.66); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: var(--z-modal); + animation: gc-fade 120ms var(--ease); +} +@keyframes gc-fade { + from { + opacity: 0; + } +} +.modal { + width: 100%; + max-width: 460px; + background: var(--panel); + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-pop); + animation: gc-pop 140ms var(--ease); +} +@keyframes gc-pop { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } +} +.modal--wide { + max-width: 720px; +} +.modal__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px; + border-bottom: 1px solid var(--border); +} +.modal__title { + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; + margin: 0; +} +.modal__body { + padding: 18px; +} +.modal__footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 18px; + border-top: 1px solid var(--border); +} + +/* --------------------------------------------------------------------- Drawer */ +.drawer__scrim { + position: fixed; + inset: 0; + background: oklch(15% 0.01 var(--brand-hue) / 0.5); + display: flex; + justify-content: flex-end; + z-index: var(--z-drawer); + animation: gc-fade 120ms var(--ease); +} +.drawer { + width: min(520px, 100%); + height: 100%; + display: flex; + flex-direction: column; + background: var(--panel); + border-left: 1px solid var(--border-strong); + box-shadow: var(--shadow-pop); + animation: gc-drawer-in var(--dur-panel) var(--ease-out); +} +.drawer__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--border); + flex: 0 0 auto; +} +.drawer__titles { + min-width: 0; +} +.drawer__title { + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; + margin: 0; + display: flex; + align-items: center; + gap: 10px; +} +.drawer__subtitle { + margin-top: 4px; + font-size: 12px; + color: var(--text-muted); +} +.drawer__body { + padding: 18px; + overflow-y: auto; + flex: 1 1 auto; + min-height: 0; +} +.drawer__footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 18px; + border-top: 1px solid var(--border); + flex: 0 0 auto; +} + +/* --------------------------------------------------------------------- Toast */ +.toast-stack { + position: fixed; + bottom: 20px; + right: 20px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: var(--z-toast); + max-width: 360px; +} +.toast { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px; + border-radius: var(--radius); + background: var(--panel); + border: 1px solid var(--border-strong); + box-shadow: var(--shadow-2); + font-size: 13px; + animation: gc-toast-in 180ms var(--ease); +} +@keyframes gc-toast-in { + from { + opacity: 0; + transform: translateX(12px); + } +} +.toast__icon { + display: grid; + place-items: center; + width: 26px; + height: 26px; + flex: 0 0 auto; + border-radius: 50%; +} +.toast__icon--success { + color: var(--ok); + background: var(--ok-soft); +} +.toast__icon--error { + color: var(--bad); + background: var(--bad-soft); +} +.toast__icon--info { + color: var(--accent); + background: var(--accent-soft); +} +.toast__body { + flex: 1; + color: var(--text); +} +.toast__title { + font-weight: 600; + margin-bottom: 2px; +} +.toast__msg { + color: var(--text-muted); + word-break: break-word; +} + +/* --------------------------------------------------------------------- Input */ +.field { + display: flex; + flex-direction: column; + gap: 6px; +} +.field__label { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + letter-spacing: 0.02em; +} +.input { + height: 36px; + padding: 0 12px; + background: var(--panel-2); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 14px; + font-family: inherit; + transition: + border-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease); +} +.input::placeholder { + color: var(--text-faint); +} +.input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.input--mono { + font-family: var(--font-mono); +} diff --git a/dashboard/src/features/auth/LoginPage.tsx b/dashboard/src/features/auth/LoginPage.tsx new file mode 100644 index 0000000..5a7ede3 --- /dev/null +++ b/dashboard/src/features/auth/LoginPage.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import { Navigate, useLocation, useNavigate } from "react-router-dom"; +import { ApiError } from "../../api/client"; +import { useAuth } from "../../auth/AuthContext"; +import { Button } from "../../components/ui/Button"; +import { Field, Input } from "../../components/ui/Input"; +import "./login.css"; + +interface LocationState { + from?: { pathname: string }; +} + +export function LoginPage() { + const { user, login } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Already authenticated — bounce to the app. + if (user) return ; + + const from = (location.state as LocationState | null)?.from?.pathname ?? "/machines"; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + await login(username, password); + navigate(from, { replace: true }); + } catch (err) { + if (err instanceof ApiError) { + setError( + err.status === 401 + ? "Invalid username or password." + : err.message, + ); + } else { + setError("Could not sign in. Please try again."); + } + } finally { + setSubmitting(false); + } + } + + return ( +
+ + ); +} diff --git a/dashboard/src/features/auth/login.css b/dashboard/src/features/auth/login.css new file mode 100644 index 0000000..1f94628 --- /dev/null +++ b/dashboard/src/features/auth/login.css @@ -0,0 +1,91 @@ +.login { + position: relative; + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: + radial-gradient( + 1100px 520px at 50% -10%, + oklch(78% 0.13 184 / 0.08), + transparent 60% + ), + var(--bg); + overflow: hidden; +} + +/* Faint console scanlines for control-room texture. */ +.login__scanlines { + position: absolute; + inset: 0; + pointer-events: none; + background-image: repeating-linear-gradient( + to bottom, + oklch(93% 0.008 var(--brand-hue) / 0.016) 0px, + oklch(93% 0.008 var(--brand-hue) / 0.016) 1px, + transparent 1px, + transparent 3px + ); + mask-image: radial-gradient(70% 60% at 50% 40%, black, transparent); +} + +.login__card { + position: relative; + z-index: 1; + width: 100%; + max-width: 380px; + display: flex; + flex-direction: column; + gap: 16px; + padding: 28px 26px 22px; + background: var(--panel); + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-2); +} + +.login__brand { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 6px; +} +.login__logo { + width: 40px; + height: 40px; + border-radius: 9px; + background: linear-gradient(135deg, var(--accent), var(--accent-press)); + display: grid; + place-items: center; + color: var(--accent-ink); + font-weight: 800; + font-size: 17px; +} +.login__title { + font-size: 19px; + font-weight: 700; + letter-spacing: -0.01em; +} +.login__sub { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-faint); +} + +.login__error { + font-size: 13px; + color: var(--bad); + background: var(--bad-soft); + border: 1px solid var(--bad-line); + border-radius: var(--radius-sm); + padding: 9px 12px; +} + +.login__foot { + text-align: center; + font-size: 11px; + color: var(--text-faint); + margin-top: 4px; +} diff --git a/dashboard/src/features/machines/DeleteMachineDialog.tsx b/dashboard/src/features/machines/DeleteMachineDialog.tsx new file mode 100644 index 0000000..d8f508f --- /dev/null +++ b/dashboard/src/features/machines/DeleteMachineDialog.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from "react"; +import { ApiError } from "../../api/client"; +import type { Machine } from "../../api/types"; +import { Button } from "../../components/ui/Button"; +import { Modal } from "../../components/ui/Modal"; +import { useToast } from "../../components/ui/toast-context"; +import { useDeleteMachine } from "./hooks"; + +interface DeleteMachineDialogProps { + machine: Machine | null; + onClose: () => void; +} + +/** + * Destructive machine removal with two options: + * - uninstall: also command the agent to uninstall (only meaningful online) + * - export: return full history in the delete response before removal + */ +export function DeleteMachineDialog({ machine, onClose }: DeleteMachineDialogProps) { + const toast = useToast(); + const del = useDeleteMachine(); + const [uninstall, setUninstall] = useState(false); + const [exportHistory, setExportHistory] = useState(false); + + // Reset options each time a new machine is targeted. + useEffect(() => { + if (machine) { + setUninstall(false); + setExportHistory(false); + } + }, [machine]); + + function handleConfirm() { + if (!machine) return; + del.mutate( + { agentId: machine.agent_id, params: { uninstall, export: exportHistory } }, + { + onSuccess: (res) => { + if (exportHistory && res.history) { + downloadHistory(machine.hostname, res.history); + } + toast.success( + "Machine deleted", + res.uninstall_sent + ? "Uninstall command sent to the agent." + : undefined, + ); + onClose(); + }, + onError: (err) => { + toast.error( + "Could not delete machine", + err instanceof ApiError + ? err.message + : "The server did not respond. The machine was not deleted.", + ); + }, + }, + ); + } + + return ( + {} : onClose} + dismissable={!del.isPending} + footer={ + <> + + + + } + > +

+ Permanently delete{" "} + {machine?.hostname} from GuruConnect, + including its registration and full history. This cannot be undone. +

+ + + + +
+ ); +} + +function downloadHistory(hostname: string, history: unknown) { + const blob = new Blob([JSON.stringify(history, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${hostname}-history-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/dashboard/src/features/machines/KeyRevealModal.tsx b/dashboard/src/features/machines/KeyRevealModal.tsx new file mode 100644 index 0000000..085cf56 --- /dev/null +++ b/dashboard/src/features/machines/KeyRevealModal.tsx @@ -0,0 +1,72 @@ +import { Button } from "../../components/ui/Button"; +import { Modal } from "../../components/ui/Modal"; +import { CopyIcon } from "../../components/layout/icons"; +import { useClipboard } from "../../lib/useClipboard"; + +interface KeyRevealModalProps { + /** The plaintext `cak_...` key, or null when closed. */ + plaintextKey: string | null; + onClose: () => void; +} + +/** + * Copy-once key reveal. The server returns the plaintext key exactly once on + * creation; this is the only place it is ever shown. The user is warned and + * given a copy button. Closing dismisses it for good. + */ +export function KeyRevealModal({ plaintextKey, onClose }: KeyRevealModalProps) { + const { copied, copy } = useClipboard(); + const open = plaintextKey != null; + + return ( + Done} + > +
+ +
+ Copy this key now. You will not see it again. + + The key is shown only at creation and cannot be recovered. If you + lose it, revoke it and create a new one. + +
+
+ +
+ {plaintextKey} + +
+ +

+ Use this key to enroll the agent as a persistent, individually revocable + identity. +

+
+ ); +} diff --git a/dashboard/src/features/machines/MachineDetailDrawer.tsx b/dashboard/src/features/machines/MachineDetailDrawer.tsx new file mode 100644 index 0000000..1f631a9 --- /dev/null +++ b/dashboard/src/features/machines/MachineDetailDrawer.tsx @@ -0,0 +1,153 @@ +import { ApiError } from "../../api/client"; +import type { Machine } from "../../api/types"; +import { Badge } from "../../components/ui/Badge"; +import { Drawer } from "../../components/ui/Drawer"; +import { Spinner } from "../../components/ui/Spinner"; +import { EmptyState, ErrorState } from "../../components/ui/States"; +import { machineTone } from "../../components/ui/status"; +import { StatusDot } from "../../components/ui/StatusDot"; +import { absoluteTime, formatDuration, relativeTime } from "../../lib/time"; +import { useMachineHistory } from "./hooks"; + +interface MachineDetailDrawerProps { + machine: Machine | null; + onClose: () => void; +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <> +
{label}
+
{children}
+ + ); +} + +/** + * Read and inspect surface for a single machine: facts plus session and event + * history. A side drawer (not a modal): inspecting a machine is a lightweight, + * non-blocking read, and the list stays visible behind it for context. + */ +export function MachineDetailDrawer({ machine, onClose }: MachineDetailDrawerProps) { + const history = useMachineHistory(machine?.agent_id ?? null); + + return ( + + {machine && ( + + )} + {machine?.hostname} + + } + subtitle={machine ? `Agent ${machine.agent_id}` : undefined} + onClose={onClose} + > + {machine && ( +
+ + + {machine.status} + + + {machine.os_version ?? "Unknown"} + + {machine.is_persistent ? ( + Persistent + ) : ( + Attended + )}{" "} + {machine.is_elevated && Elevated} + + + + {relativeTime(machine.first_seen)} + + + + + {relativeTime(machine.last_seen)} + + +
+ )} + +
+

Session history

+ {history.isLoading ? ( + + ) : history.isError ? ( + + ) : !history.data || history.data.sessions.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {history.data.sessions.map((s) => ( + + + + + + + + ))} + +
StartedDurationTypeCodeStatus
+ {relativeTime(s.started_at)} + {formatDuration(s.duration_secs)}{s.is_support_session ? "Support" : "Managed"}{s.support_code ?? "None"}{s.status}
+ )} +
+ + {history.data && history.data.events.length > 0 && ( +
+

Recent events

+ + + + + + + + + + + {history.data.events.slice(0, 25).map((e) => ( + + + + + + + ))} + +
TimeEventViewerIP
+ {relativeTime(e.timestamp)} + {e.event_type}{e.viewer_name ?? "None"}{e.ip_address ?? "None"}
+
+ )} +
+ ); +} diff --git a/dashboard/src/features/machines/MachineKeysModal.tsx b/dashboard/src/features/machines/MachineKeysModal.tsx new file mode 100644 index 0000000..bf6bc0c --- /dev/null +++ b/dashboard/src/features/machines/MachineKeysModal.tsx @@ -0,0 +1,197 @@ +import { useState } from "react"; +import { ApiError } from "../../api/client"; +import type { KeyMetadata, Machine } from "../../api/types"; +import { Button } from "../../components/ui/Button"; +import { ConfirmDialog } from "../../components/ui/ConfirmDialog"; +import { Modal } from "../../components/ui/Modal"; +import { Badge } from "../../components/ui/Badge"; +import { Spinner } from "../../components/ui/Spinner"; +import { EmptyState, ErrorState } from "../../components/ui/States"; +import { useToast } from "../../components/ui/toast-context"; +import { absoluteTime, relativeTime } from "../../lib/time"; +import { + useCreateMachineKey, + useMachineKeys, + useRevokeMachineKey, +} from "./hooks"; +import { KeyRevealModal } from "./KeyRevealModal"; + +interface MachineKeysModalProps { + machine: Machine | null; + onClose: () => void; +} + +function keyState(k: KeyMetadata): { tone: "ok" | "neutral"; label: string } { + return k.revoked_at + ? { tone: "neutral", label: "Revoked" } + : { tone: "ok", label: "Active" }; +} + +/** + * Admin-only per-agent key management. Lists key metadata (never the secret), + * mints new keys (revealed once via KeyRevealModal), and revokes existing keys. + */ +export function MachineKeysModal({ machine, onClose }: MachineKeysModalProps) { + const toast = useToast(); + const agentId = machine?.agent_id ?? ""; + + const keysQuery = useMachineKeys(machine?.agent_id ?? null, machine != null); + const createKey = useCreateMachineKey(agentId); + const revokeKey = useRevokeMachineKey(agentId); + + const [revealKey, setRevealKey] = useState(null); + const [pendingRevoke, setPendingRevoke] = useState(null); + + function handleCreate() { + createKey.mutate(undefined, { + onSuccess: (created) => { + setRevealKey(created.key); + toast.success("Key created", "Copy it now — it is shown only once."); + }, + onError: (err) => { + toast.error( + "Could not create key", + err instanceof ApiError ? err.message : "Unexpected error.", + ); + }, + }); + } + + function handleRevoke() { + if (!pendingRevoke) return; + const id = pendingRevoke.id; + revokeKey.mutate(id, { + onSuccess: () => { + toast.success("Key revoked"); + setPendingRevoke(null); + }, + onError: (err) => { + toast.error( + "Could not revoke key", + err instanceof ApiError ? err.message : "Unexpected error.", + ); + setPendingRevoke(null); + }, + }); + } + + const keys = keysQuery.data ?? []; + + return ( + <> + + Agent keys ·{" "} + + {machine?.hostname} + + + } + ariaLabel={`Agent keys for ${machine?.hostname ?? "machine"}`} + onClose={onClose} + wide + footer={ + <> + + + + } + > + {keysQuery.isLoading ? ( +
+ +
+ ) : keysQuery.isError ? ( + + ) : keys.length === 0 ? ( + + ) : ( + + + + + + + + + + + {keys.map((k) => { + const s = keyState(k); + return ( + + + + + + + + ); + })} + +
StateKey IDCreatedLast used +
+ + {s.label} + + + {k.id} + + {relativeTime(k.created_at)} + + {k.last_used_at ? relativeTime(k.last_used_at) : "never"} + + {!k.revoked_at && ( + + )} +
+ )} +
+ + setRevealKey(null)} /> + + + Revoking this key immediately blocks any agent authenticating with + it. This cannot be undone. Key{" "} + {pendingRevoke?.id}. + + } + onConfirm={handleRevoke} + onCancel={() => setPendingRevoke(null)} + /> + + ); +} diff --git a/dashboard/src/features/machines/MachinesPage.tsx b/dashboard/src/features/machines/MachinesPage.tsx new file mode 100644 index 0000000..78e196d --- /dev/null +++ b/dashboard/src/features/machines/MachinesPage.tsx @@ -0,0 +1,245 @@ +import { useMemo, useState } from "react"; +import { ApiError } from "../../api/client"; +import type { Machine } from "../../api/types"; +import { useAuth } from "../../auth/AuthContext"; +import { PageHeader } from "../../components/layout/PageHeader"; +import { + InfoIcon, + KeyIcon, + RefreshIcon, + SearchIcon, + TrashIcon, +} from "../../components/layout/icons"; +import { Badge } from "../../components/ui/Badge"; +import { Button } from "../../components/ui/Button"; +import { Input } from "../../components/ui/Input"; +import { Panel } from "../../components/ui/Panel"; +import { EmptyState, ErrorState } from "../../components/ui/States"; +import { StatusDot } from "../../components/ui/StatusDot"; +import { machineTone } from "../../components/ui/status"; +import { Table, type Column } from "../../components/ui/Table"; +import { TableSkeleton } from "../../components/ui/TableSkeleton"; +import { absoluteTime, relativeTime } from "../../lib/time"; +import "./machines.css"; +import { DeleteMachineDialog } from "./DeleteMachineDialog"; +import { MachineDetailDrawer } from "./MachineDetailDrawer"; +import { MachineKeysModal } from "./MachineKeysModal"; +import { useMachines } from "./hooks"; + +export function MachinesPage() { + const { isAdmin } = useAuth(); + const machinesQuery = useMachines(); + const [filter, setFilter] = useState(""); + + const [detailFor, setDetailFor] = useState(null); + const [keysFor, setKeysFor] = useState(null); + const [deleteFor, setDeleteFor] = useState(null); + + const { data } = machinesQuery; + const machines = useMemo(() => data ?? [], [data]); + + const filtered = useMemo(() => { + const q = filter.trim().toLowerCase(); + if (!q) return machines; + return machines.filter( + (m) => + m.hostname.toLowerCase().includes(q) || + m.agent_id.toLowerCase().includes(q), + ); + }, [machines, filter]); + + const onlineCount = useMemo( + () => machines.filter((m) => m.status === "online").length, + [machines], + ); + + const columns: Column[] = [ + { + key: "status", + header: "", + cellClass: "dt__status", + render: (m) => ( + + ), + }, + { + key: "hostname", + header: "Hostname", + render: (m) => {m.hostname}, + }, + { + key: "os", + header: "OS", + render: (m) => ( + {m.os_version ?? "Unknown"} + ), + }, + { + key: "mode", + header: "Mode", + render: (m) => + m.is_persistent ? ( + Persistent + ) : ( + Attended + ), + }, + { + key: "last_seen", + header: "Last seen", + render: (m) => ( + + {relativeTime(m.last_seen)} + + ), + }, + { + key: "agent_id", + header: "Agent ID", + render: (m) => ( + + {m.agent_id} + + ), + }, + { + key: "actions", + header: "", + cellClass: "dt__actions", + render: (m) => ( + e.stopPropagation()} + > + + {isAdmin && ( + + )} + + + ), + }, + ]; + + return ( +
+ void machinesQuery.refetch()} + loading={machinesQuery.isFetching} + > + + Refresh + + } + /> + + +
+
+
+ + + + setFilter(e.target.value)} + aria-label="Filter machines" + /> +
+
+ {onlineCount} online ·{" "} + {machines.length} total +
+
+
+ + {machinesQuery.isLoading ? ( + <> + + Loading machines + + + + ) : machinesQuery.isError ? ( + void machinesQuery.refetch()}> + Retry + + } + /> + ) : filtered.length === 0 ? ( + filter ? ( + setFilter("")}> + Clear filter + + } + /> + ) : ( + + ) + ) : ( + m.id} + onRowClick={setDetailFor} + rowLabel={(m) => m.hostname} + /> + )} + + + setDetailFor(null)} /> + {isAdmin && ( + setKeysFor(null)} /> + )} + setDeleteFor(null)} /> + + ); +} diff --git a/dashboard/src/features/machines/hooks.ts b/dashboard/src/features/machines/hooks.ts new file mode 100644 index 0000000..68384db --- /dev/null +++ b/dashboard/src/features/machines/hooks.ts @@ -0,0 +1,78 @@ +import { + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import * as machinesApi from "../../api/machines"; +import type { DeleteMachineParams } from "../../api/types"; + +const MACHINES_KEY = ["machines"] as const; + +/** List all machines. Polls so online/offline status stays fresh. */ +export function useMachines() { + return useQuery({ + queryKey: MACHINES_KEY, + queryFn: ({ signal }) => machinesApi.listMachines(signal), + refetchInterval: 20_000, + staleTime: 10_000, + }); +} + +/** Machine history (sessions + events) for the detail drawer. Lazy via `enabled`. */ +export function useMachineHistory(agentId: string | null) { + return useQuery({ + queryKey: ["machine-history", agentId], + queryFn: ({ signal }) => machinesApi.getMachineHistory(agentId!, signal), + enabled: agentId != null, + staleTime: 30_000, + }); +} + +/** Delete a machine, then invalidate the list so it disappears. */ +export function useDeleteMachine() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + agentId, + params, + }: { + agentId: string; + params: DeleteMachineParams; + }) => machinesApi.deleteMachine(agentId, params), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: MACHINES_KEY }); + }, + }); +} + +// --- Admin: per-agent keys -------------------------------------------------- + +export function useMachineKeys(agentId: string | null, enabled: boolean) { + return useQuery({ + queryKey: ["machine-keys", agentId], + queryFn: () => machinesApi.listMachineKeys(agentId!), + enabled: enabled && agentId != null, + staleTime: 15_000, + }); +} + +export function useCreateMachineKey(agentId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => machinesApi.createMachineKey(agentId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["machine-keys", agentId] }); + }, + }); +} + +export function useRevokeMachineKey(agentId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (keyId: string) => + machinesApi.revokeMachineKey(agentId, keyId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["machine-keys", agentId] }); + }, + }); +} diff --git a/dashboard/src/features/machines/machines.css b/dashboard/src/features/machines/machines.css new file mode 100644 index 0000000..ae72ad4 --- /dev/null +++ b/dashboard/src/features/machines/machines.css @@ -0,0 +1,131 @@ +/* ===================================================== Machine detail body */ +.mdetail__grid { + display: grid; + grid-template-columns: 140px 1fr; + gap: 8px 16px; + align-items: baseline; +} +.mdetail__k { + color: var(--text-muted); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; +} +.mdetail__v { + color: var(--text); + font-size: 13px; + word-break: break-word; +} +.mdetail__section { + margin-top: 22px; +} +.mdetail__section h3 { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-faint); + margin: 0 0 10px; +} + +/* Inline mini table inside the detail (sessions / events / keys). */ +.minitable { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.minitable th { + text-align: left; + font-size: 10px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-faint); + font-weight: 700; + padding: 0 10px 6px 0; +} +.minitable td { + padding: 6px 10px 6px 0; + border-top: 1px solid var(--border); + color: var(--text); + vertical-align: top; +} +.minitable .mono { + color: var(--text-muted); +} + +/* ============================================================ Key reveal === */ +.keyreveal__warn { + display: flex; + gap: 11px; + padding: 12px 14px; + border-radius: var(--radius-sm); + background: var(--warn-soft); + border: 1px solid var(--warn-soft); + color: var(--text); + font-size: 13px; + margin-bottom: 14px; +} +.keyreveal__warnicon { + flex: 0 0 auto; + margin-top: 1px; + color: var(--warn); +} +.keyreveal__warn strong { + display: block; + margin-bottom: 3px; +} +.keyreveal__warn span { + color: var(--text-muted); +} +.keyreveal__value { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border-radius: var(--radius-sm); + background: var(--panel-2); + border: 1px solid var(--border-strong); +} +.keyreveal__key { + flex: 1; + font-family: var(--font-mono); + font-size: 13px; + color: var(--accent); + word-break: break-all; + user-select: all; +} +.keyreveal__hint { + margin-top: 10px; + font-size: 12px; + color: var(--text-muted); +} + +/* ====================================================== Delete options === */ +.optline { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 0; + font-size: 13px; + color: var(--text); + cursor: pointer; +} +.optline input[type="checkbox"] { + margin-top: 2px; + width: 15px; + height: 15px; + accent-color: var(--accent); + flex: 0 0 auto; +} +.optline__note { + color: var(--warn); + font-style: normal; +} + +/* Revoked rows read dimmer. */ +.key--revoked td { + color: var(--text-faint); +} +.key--revoked .mono { + color: var(--text-faint); +} diff --git a/dashboard/src/hooks/useRemoteSession.ts b/dashboard/src/hooks/useRemoteSession.ts deleted file mode 100644 index 27e54e3..0000000 --- a/dashboard/src/hooks/useRemoteSession.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * React hook for managing remote desktop session connection - */ - -import { useState, useEffect, useCallback, useRef } from 'react'; -import type { ConnectionStatus, VideoFrame, MouseEvent as ProtoMouseEvent, KeyEvent as ProtoKeyEvent, MouseEventType, KeyEventType, Modifiers } from '../types/protocol'; -import { encodeMouseEvent, encodeKeyEvent, decodeVideoFrame } from '../lib/protobuf'; - -interface UseRemoteSessionOptions { - serverUrl: string; - sessionId: string; - onFrame?: (frame: VideoFrame) => void; - onStatusChange?: (status: ConnectionStatus) => void; -} - -interface UseRemoteSessionReturn { - status: ConnectionStatus; - connect: () => void; - disconnect: () => void; - sendMouseEvent: (event: ProtoMouseEvent) => void; - sendKeyEvent: (event: ProtoKeyEvent) => void; -} - -export function useRemoteSession(options: UseRemoteSessionOptions): UseRemoteSessionReturn { - const { serverUrl, sessionId, onFrame, onStatusChange } = options; - - const [status, setStatus] = useState({ - connected: false, - }); - - const wsRef = useRef(null); - const reconnectTimeoutRef = useRef(null); - const frameCountRef = useRef(0); - const lastFpsUpdateRef = useRef(Date.now()); - - // Update status and notify - const updateStatus = useCallback((newStatus: Partial) => { - setStatus(prev => { - const updated = { ...prev, ...newStatus }; - onStatusChange?.(updated); - return updated; - }); - }, [onStatusChange]); - - // Calculate FPS - const updateFps = useCallback(() => { - const now = Date.now(); - const elapsed = now - lastFpsUpdateRef.current; - if (elapsed >= 1000) { - const fps = Math.round((frameCountRef.current * 1000) / elapsed); - updateStatus({ fps }); - frameCountRef.current = 0; - lastFpsUpdateRef.current = now; - } - }, [updateStatus]); - - // Handle incoming WebSocket messages - const handleMessage = useCallback((event: MessageEvent) => { - if (event.data instanceof Blob) { - event.data.arrayBuffer().then(buffer => { - const data = new Uint8Array(buffer); - const frame = decodeVideoFrame(data); - if (frame) { - frameCountRef.current++; - updateFps(); - onFrame?.(frame); - } - }); - } else if (event.data instanceof ArrayBuffer) { - const data = new Uint8Array(event.data); - const frame = decodeVideoFrame(data); - if (frame) { - frameCountRef.current++; - updateFps(); - onFrame?.(frame); - } - } - }, [onFrame, updateFps]); - - // Connect to server - const connect = useCallback(() => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - return; - } - - // Clear any pending reconnect - if (reconnectTimeoutRef.current) { - window.clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - - const wsUrl = `${serverUrl}/ws/viewer?session_id=${encodeURIComponent(sessionId)}`; - const ws = new WebSocket(wsUrl); - ws.binaryType = 'arraybuffer'; - - ws.onopen = () => { - updateStatus({ - connected: true, - sessionId, - }); - }; - - ws.onmessage = handleMessage; - - ws.onclose = (event) => { - updateStatus({ - connected: false, - latencyMs: undefined, - fps: undefined, - }); - - // Auto-reconnect after 2 seconds - if (!event.wasClean) { - reconnectTimeoutRef.current = window.setTimeout(() => { - connect(); - }, 2000); - } - }; - - ws.onerror = () => { - updateStatus({ connected: false }); - }; - - wsRef.current = ws; - }, [serverUrl, sessionId, handleMessage, updateStatus]); - - // Disconnect from server - const disconnect = useCallback(() => { - if (reconnectTimeoutRef.current) { - window.clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - - if (wsRef.current) { - wsRef.current.close(1000, 'User disconnected'); - wsRef.current = null; - } - - updateStatus({ - connected: false, - sessionId: undefined, - latencyMs: undefined, - fps: undefined, - }); - }, [updateStatus]); - - // Send mouse event - const sendMouseEvent = useCallback((event: ProtoMouseEvent) => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - const data = encodeMouseEvent(event); - wsRef.current.send(data); - } - }, []); - - // Send key event - const sendKeyEvent = useCallback((event: ProtoKeyEvent) => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - const data = encodeKeyEvent(event); - wsRef.current.send(data); - } - }, []); - - // Cleanup on unmount - useEffect(() => { - return () => { - disconnect(); - }; - }, [disconnect]); - - return { - status, - connect, - disconnect, - sendMouseEvent, - sendKeyEvent, - }; -} - -/** - * Helper to create mouse event from DOM mouse event - */ -export function createMouseEvent( - domEvent: React.MouseEvent, - canvasRect: DOMRect, - displayWidth: number, - displayHeight: number, - eventType: MouseEventType -): ProtoMouseEvent { - // Calculate position relative to canvas and scale to display coordinates - const scaleX = displayWidth / canvasRect.width; - const scaleY = displayHeight / canvasRect.height; - - const x = Math.round((domEvent.clientX - canvasRect.left) * scaleX); - const y = Math.round((domEvent.clientY - canvasRect.top) * scaleY); - - return { - x, - y, - buttons: { - left: (domEvent.buttons & 1) !== 0, - right: (domEvent.buttons & 2) !== 0, - middle: (domEvent.buttons & 4) !== 0, - x1: (domEvent.buttons & 8) !== 0, - x2: (domEvent.buttons & 16) !== 0, - }, - wheelDeltaX: 0, - wheelDeltaY: 0, - eventType, - }; -} - -/** - * Helper to create key event from DOM keyboard event - */ -export function createKeyEvent( - domEvent: React.KeyboardEvent, - down: boolean -): ProtoKeyEvent { - const modifiers: Modifiers = { - ctrl: domEvent.ctrlKey, - alt: domEvent.altKey, - shift: domEvent.shiftKey, - meta: domEvent.metaKey, - capsLock: domEvent.getModifierState('CapsLock'), - numLock: domEvent.getModifierState('NumLock'), - }; - - // Use key code for special keys, unicode for regular characters - const isCharacter = domEvent.key.length === 1; - - return { - down, - keyType: isCharacter ? 2 : 0, // KEY_UNICODE or KEY_VK - vkCode: domEvent.keyCode, - scanCode: 0, // Not available in browser - unicode: isCharacter ? domEvent.key : undefined, - modifiers, - }; -} diff --git a/dashboard/src/lib/protobuf.ts b/dashboard/src/lib/protobuf.ts deleted file mode 100644 index 397ad0d..0000000 --- a/dashboard/src/lib/protobuf.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Minimal protobuf encoder/decoder for GuruConnect messages - * - * For MVP, we use a simplified binary format. In production, - * this would use a proper protobuf library like protobufjs. - */ - -import type { MouseEvent, KeyEvent, MouseEventType, KeyEventType, VideoFrame, RawFrame } from '../types/protocol'; - -// Message type identifiers (matching proto field numbers) -const MSG_VIDEO_FRAME = 10; -const MSG_MOUSE_EVENT = 20; -const MSG_KEY_EVENT = 21; - -/** - * Encode a mouse event to binary format - */ -export function encodeMouseEvent(event: MouseEvent): Uint8Array { - const buffer = new ArrayBuffer(32); - const view = new DataView(buffer); - - // Message type - view.setUint8(0, MSG_MOUSE_EVENT); - - // Event type - view.setUint8(1, event.eventType); - - // Coordinates (scaled to 16-bit for efficiency) - view.setInt16(2, event.x, true); - view.setInt16(4, event.y, true); - - // Buttons bitmask - let buttons = 0; - if (event.buttons.left) buttons |= 1; - if (event.buttons.right) buttons |= 2; - if (event.buttons.middle) buttons |= 4; - if (event.buttons.x1) buttons |= 8; - if (event.buttons.x2) buttons |= 16; - view.setUint8(6, buttons); - - // Wheel deltas - view.setInt16(7, event.wheelDeltaX, true); - view.setInt16(9, event.wheelDeltaY, true); - - return new Uint8Array(buffer, 0, 11); -} - -/** - * Encode a key event to binary format - */ -export function encodeKeyEvent(event: KeyEvent): Uint8Array { - const buffer = new ArrayBuffer(32); - const view = new DataView(buffer); - - // Message type - view.setUint8(0, MSG_KEY_EVENT); - - // Key down/up - view.setUint8(1, event.down ? 1 : 0); - - // Key type - view.setUint8(2, event.keyType); - - // Virtual key code - view.setUint16(3, event.vkCode, true); - - // Scan code - view.setUint16(5, event.scanCode, true); - - // Modifiers bitmask - let mods = 0; - if (event.modifiers.ctrl) mods |= 1; - if (event.modifiers.alt) mods |= 2; - if (event.modifiers.shift) mods |= 4; - if (event.modifiers.meta) mods |= 8; - if (event.modifiers.capsLock) mods |= 16; - if (event.modifiers.numLock) mods |= 32; - view.setUint8(7, mods); - - // Unicode character (if present) - if (event.unicode && event.unicode.length > 0) { - const charCode = event.unicode.charCodeAt(0); - view.setUint16(8, charCode, true); - return new Uint8Array(buffer, 0, 10); - } - - return new Uint8Array(buffer, 0, 8); -} - -/** - * Decode a video frame from binary format - */ -export function decodeVideoFrame(data: Uint8Array): VideoFrame | null { - if (data.length < 2) return null; - - const view = new DataView(data.buffer, data.byteOffset, data.byteLength); - const msgType = view.getUint8(0); - - if (msgType !== MSG_VIDEO_FRAME) return null; - - const encoding = view.getUint8(1); - const displayId = view.getUint8(2); - const sequence = view.getUint32(3, true); - const timestamp = Number(view.getBigInt64(7, true)); - - // Frame dimensions - const width = view.getUint16(15, true); - const height = view.getUint16(17, true); - - // Compressed flag - const compressed = view.getUint8(19) === 1; - - // Is keyframe - const isKeyframe = view.getUint8(20) === 1; - - // Frame data starts at offset 21 - const frameData = data.slice(21); - - const encodingStr = ['raw', 'vp9', 'h264', 'h265'][encoding] as 'raw' | 'vp9' | 'h264' | 'h265'; - - if (encodingStr === 'raw') { - return { - timestamp, - displayId, - sequence, - encoding: 'raw', - raw: { - width, - height, - data: frameData, - compressed, - dirtyRects: [], // TODO: Parse dirty rects - isKeyframe, - }, - }; - } - - return { - timestamp, - displayId, - sequence, - encoding: encodingStr, - encoded: { - data: frameData, - keyframe: isKeyframe, - pts: timestamp, - dts: timestamp, - }, - }; -} - -/** - * Simple zstd decompression placeholder - * In production, use a proper zstd library like fzstd - */ -export async function decompressZstd(data: Uint8Array): Promise { - // For MVP, assume uncompressed frames or use fzstd library - // This is a placeholder - actual implementation would use: - // import { decompress } from 'fzstd'; - // return decompress(data); - return data; -} diff --git a/dashboard/src/lib/time.ts b/dashboard/src/lib/time.ts new file mode 100644 index 0000000..fb370ae --- /dev/null +++ b/dashboard/src/lib/time.ts @@ -0,0 +1,60 @@ +// Time formatting helpers. Relative for at-a-glance scanning, absolute (mono) +// for the precise value on hover. + +const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [ + ["year", 60 * 60 * 24 * 365], + ["month", 60 * 60 * 24 * 30], + ["day", 60 * 60 * 24], + ["hour", 60 * 60], + ["minute", 60], + ["second", 1], +]; + +const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + +/** + * Human relative time, e.g. "3 minutes ago" / "in 2 days". Returns "—" for + * missing input and "just now" for sub-10-second deltas. + */ +export function relativeTime(iso: string | null | undefined): string { + if (!iso) return "—"; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return "—"; + + const deltaSec = Math.round((then - Date.now()) / 1000); + const abs = Math.abs(deltaSec); + if (abs < 10) return "just now"; + + for (const [unit, secs] of UNITS) { + if (abs >= secs || unit === "second") { + return rtf.format(Math.round(deltaSec / secs), unit); + } + } + return "just now"; +} + +/** Absolute local timestamp for tooltips / mono display. */ +export function absoluteTime(iso: string | null | undefined): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +/** Format a duration in seconds as a compact "1h 04m" / "47s" string. */ +export function formatDuration(secs: number | null | undefined): string { + if (secs == null || secs < 0) return "—"; + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = secs % 60; + if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`; + if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`; + return `${s}s`; +} diff --git a/dashboard/src/lib/useClipboard.ts b/dashboard/src/lib/useClipboard.ts new file mode 100644 index 0000000..56a42b9 --- /dev/null +++ b/dashboard/src/lib/useClipboard.ts @@ -0,0 +1,48 @@ +import { useCallback, useRef, useState } from "react"; + +/** + * Copy-to-clipboard with a transient "copied" flag. Falls back to a hidden + * textarea + execCommand for non-secure contexts where the async Clipboard API + * is unavailable. + */ +export function useClipboard(resetMs = 1800): { + copied: boolean; + copy: (text: string) => Promise; +} { + const [copied, setCopied] = useState(false); + const timer = useRef(undefined); + + const copy = useCallback( + async (text: string): Promise => { + let ok = false; + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + ok = true; + } else { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + ok = document.execCommand("copy"); + document.body.removeChild(ta); + } + } catch { + ok = false; + } + + if (ok) { + setCopied(true); + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), resetMs); + } + return ok; + }, + [resetMs], + ); + + return { copied, copy }; +} diff --git a/dashboard/src/lib/useRelayStatus.ts b/dashboard/src/lib/useRelayStatus.ts new file mode 100644 index 0000000..bc09142 --- /dev/null +++ b/dashboard/src/lib/useRelayStatus.ts @@ -0,0 +1,28 @@ +import { useQuery } from "@tanstack/react-query"; + +const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, ""); + +/** + * Lightweight relay-connection liveness probe for the topbar indicator. + * + * Pass 1 has no viewer WebSocket yet, so "live" here means the GC server is + * reachable. It polls the unauthenticated `/health` route every 15s. This is a + * deliberately cheap signal — the real relay/WS liveness lands with the viewer + * in a later pass. + */ +export function useRelayStatus(): { live: boolean; checking: boolean } { + const { data, isLoading, isError } = useQuery({ + queryKey: ["health"], + queryFn: async ({ signal }) => { + const res = await fetch(`${BASE_URL}/health`, { signal }); + if (!res.ok) throw new Error(`health ${res.status}`); + return true; + }, + refetchInterval: 15_000, + refetchOnWindowFocus: true, + retry: 1, + staleTime: 10_000, + }); + + return { live: data === true && !isError, checking: isLoading }; +} diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx new file mode 100644 index 0000000..dc2cd6c --- /dev/null +++ b/dashboard/src/main.tsx @@ -0,0 +1,21 @@ +import "@fontsource/hanken-grotesk/400.css"; +import "@fontsource/hanken-grotesk/500.css"; +import "@fontsource/hanken-grotesk/600.css"; +import "@fontsource/hanken-grotesk/700.css"; +import "@fontsource/jetbrains-mono/400.css"; +import "@fontsource/jetbrains-mono/500.css"; +import "./styles/tokens.css"; +import "./components/ui/ui.css"; + +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Root element #root not found"); + +createRoot(root).render( + + + , +); diff --git a/dashboard/src/styles/tokens.css b/dashboard/src/styles/tokens.css new file mode 100644 index 0000000..a00d163 --- /dev/null +++ b/dashboard/src/styles/tokens.css @@ -0,0 +1,223 @@ +/* + * GuruConnect "Operations terminal" design tokens. + * Dark control-room console. Not a generic dashboard. + */ +:root { + /* + * Palette is OKLCH. Every neutral is tinted toward the signal-cyan brand hue + * (~185deg) at a low chroma so the slate surfaces feel cohesive with the + * accent without reading as "colored". Lightness drives the surface scale; + * chroma drops near the extremes so nothing goes garish. + */ + --brand-hue: 184; + + /* Surfaces — dark control room, lighter = more elevated */ + --bg: oklch(17% 0.012 var(--brand-hue)); + --panel: oklch(22.5% 0.014 var(--brand-hue)); + --panel-2: oklch(19.5% 0.013 var(--brand-hue)); + --border: oklch(28% 0.014 var(--brand-hue)); + --border-strong: oklch(34% 0.016 var(--brand-hue)); + + /* Text — tinted toward brand, AA-verified on --panel and --panel-2 */ + --text: oklch(93% 0.008 var(--brand-hue)); + --text-muted: oklch(70% 0.014 var(--brand-hue)); + --text-faint: oklch(62% 0.016 var(--brand-hue)); + + /* Accent — signal cyan */ + --accent: oklch(78% 0.13 184); + --accent-press: oklch(70% 0.12 184); + --accent-ink: oklch(24% 0.06 184); /* text on accent fills */ + --accent-soft: oklch(78% 0.13 184 / 0.12); + --accent-ring: oklch(78% 0.13 184 / 0.4); + + /* Status color language */ + --ok: oklch(74% 0.16 150); + --warn: oklch(80% 0.14 86); + --bad: oklch(66% 0.19 27); + --neutral: oklch(62% 0.02 var(--brand-hue)); + --ok-soft: oklch(74% 0.16 150 / 0.15); + --warn-soft: oklch(80% 0.14 86 / 0.15); + --bad-soft: oklch(66% 0.19 27 / 0.16); + --bad-line: oklch(66% 0.19 27 / 0.42); + --neutral-soft: oklch(62% 0.02 var(--brand-hue) / 0.14); + + /* Typography */ + --font-ui: "Hanken Grotesk", system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", monospace; + + /* Radii */ + --radius-sm: 5px; + --radius: 8px; + --radius-lg: 12px; + + /* Elevation — shadow carries the brand-tinted near-black, never pure #000. */ + --shadow-ink: oklch(12% 0.02 var(--brand-hue)); + --shadow-1: 0 1px 2px oklch(12% 0.02 var(--brand-hue) / 0.4); + --shadow-2: 0 8px 24px oklch(12% 0.02 var(--brand-hue) / 0.45); + --shadow-pop: 0 16px 48px oklch(12% 0.02 var(--brand-hue) / 0.6); + + /* Layout */ + --sidebar-w: 224px; + --topbar-h: 56px; + --row-h: 38px; + + /* Motion — ease-out only (no bounce). --ease-out is a sharper expo curve + reserved for reveals (drawer, toast); --ease is the everyday transition. */ + --ease: cubic-bezier(0.2, 0.8, 0.2, 1); + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --dur-fast: 120ms; + --dur: 180ms; + --dur-panel: 240ms; + + /* z-index scale — semantic, no magic numbers */ + --z-sticky: 200; + --z-drawer: 300; + --z-modal: 400; + --z-toast: 500; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-ui); + font-size: 14px; + /* Light text on dark reads heavier; trim weight slightly for even color. */ + font-weight: 400; + line-height: 1.45; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +a { + color: var(--accent); + text-decoration: none; +} + +button { + font-family: inherit; +} + +::selection { + background: var(--accent-ring); + color: var(--text); +} + +/* Scrollbar — keep it console-quiet */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} +*::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.mono { + font-family: var(--font-mono); + font-feature-settings: "ss01", "zero"; +} + +/* Screen-reader-only utility (announce state that's otherwise visual). */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +/* Global keyboard focus ring for interactive elements without a bespoke one. */ +:where(a, [tabindex]):focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring); + border-radius: var(--radius-sm); +} + +input[type="checkbox"]:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Consent-pending pulse (status language) */ +@keyframes gc-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 var(--warn-soft); + opacity: 1; + } + 50% { + box-shadow: 0 0 0 4px transparent; + opacity: 0.55; + } +} + +/* Live relay indicator pulse */ +@keyframes gc-live { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +/* Staggered row fade-in for the data table */ +@keyframes gc-row-in { + from { + opacity: 0; + transform: translateY(3px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Drawer slide-in from the right edge */ +@keyframes gc-drawer-in { + from { + transform: translateX(16px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Skeleton shimmer for loading rows */ +@keyframes gc-shimmer { + to { + background-position: 180% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + } +} diff --git a/dashboard/src/types/protocol.ts b/dashboard/src/types/protocol.ts deleted file mode 100644 index 7baa07b..0000000 --- a/dashboard/src/types/protocol.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * TypeScript types matching guruconnect.proto definitions - * These are used for WebSocket message handling in the viewer - */ - -export enum SessionType { - SCREEN_CONTROL = 0, - VIEW_ONLY = 1, - BACKSTAGE = 2, - FILE_TRANSFER = 3, -} - -export interface SessionRequest { - agentId: string; - sessionToken: string; - sessionType: SessionType; - clientVersion: string; -} - -export interface SessionResponse { - success: boolean; - sessionId: string; - error?: string; - displayInfo?: DisplayInfo; -} - -export interface DisplayInfo { - displays: Display[]; - primaryDisplay: number; -} - -export interface Display { - id: number; - name: string; - x: number; - y: number; - width: number; - height: number; - isPrimary: boolean; -} - -export interface DirtyRect { - x: number; - y: number; - width: number; - height: number; -} - -export interface RawFrame { - width: number; - height: number; - data: Uint8Array; - compressed: boolean; - dirtyRects: DirtyRect[]; - isKeyframe: boolean; -} - -export interface EncodedFrame { - data: Uint8Array; - keyframe: boolean; - pts: number; - dts: number; -} - -export interface VideoFrame { - timestamp: number; - displayId: number; - sequence: number; - encoding: 'raw' | 'vp9' | 'h264' | 'h265'; - raw?: RawFrame; - encoded?: EncodedFrame; -} - -export enum MouseEventType { - MOUSE_MOVE = 0, - MOUSE_DOWN = 1, - MOUSE_UP = 2, - MOUSE_WHEEL = 3, -} - -export interface MouseButtons { - left: boolean; - right: boolean; - middle: boolean; - x1: boolean; - x2: boolean; -} - -export interface MouseEvent { - x: number; - y: number; - buttons: MouseButtons; - wheelDeltaX: number; - wheelDeltaY: number; - eventType: MouseEventType; -} - -export enum KeyEventType { - KEY_VK = 0, - KEY_SCAN = 1, - KEY_UNICODE = 2, -} - -export interface Modifiers { - ctrl: boolean; - alt: boolean; - shift: boolean; - meta: boolean; - capsLock: boolean; - numLock: boolean; -} - -export interface KeyEvent { - down: boolean; - keyType: KeyEventType; - vkCode: number; - scanCode: number; - unicode?: string; - modifiers: Modifiers; -} - -export interface QualitySettings { - preset: 'auto' | 'low' | 'balanced' | 'high'; - customFps?: number; - customBitrate?: number; - codec: 'auto' | 'raw' | 'vp9' | 'h264' | 'h265'; -} - -export interface ConnectionStatus { - connected: boolean; - sessionId?: string; - latencyMs?: number; - fps?: number; - bitrateKbps?: number; -} diff --git a/dashboard/src/vite-env.d.ts b/dashboard/src/vite-env.d.ts new file mode 100644 index 0000000..3550845 --- /dev/null +++ b/dashboard/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + /** Base URL for the GuruConnect API. Defaults to same-origin when unset. */ + readonly VITE_API_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/dashboard/tsconfig.app.json b/dashboard/tsconfig.app.json new file mode 100644 index 0000000..e14a8d7 --- /dev/null +++ b/dashboard/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json index 106610c..1ffef60 100644 --- a/dashboard/tsconfig.json +++ b/dashboard/tsconfig.json @@ -1,21 +1,7 @@ { - "compilerOptions": { - "target": "ES2020", - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"], - "exclude": ["node_modules"] + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] } diff --git a/dashboard/tsconfig.node.json b/dashboard/tsconfig.node.json new file mode 100644 index 0000000..3113651 --- /dev/null +++ b/dashboard/tsconfig.node.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts new file mode 100644 index 0000000..9340c8a --- /dev/null +++ b/dashboard/vite.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Dev proxy targets the local GuruConnect (GC) server. `/api` and `/ws` are +// forwarded to the Rust server on :3002 so `npm run dev` works against a real +// backend without CORS gymnastics. +// +// `base` is "./" so the built assets reference relative paths — production +// serving copies `dist/` into the server's static dir and a catch-all route +// serves index.html. Wiring that catch-all in the Rust server is a DEPLOY +// concern (see README), not done in this pass. +const GC_SERVER = "http://localhost:3002"; + +export default defineConfig({ + base: "./", + plugins: [react()], + server: { + port: 5273, + proxy: { + "/api": { + target: GC_SERVER, + changeOrigin: true, + }, + "/ws": { + target: GC_SERVER, + changeOrigin: true, + ws: true, + }, + }, + }, + build: { + outDir: "dist", + sourcemap: true, + }, +});