diff --git a/.gitignore b/.gitignore
index a18123c4..348f0f55 100644
--- a/.gitignore
+++ b/.gitignore
@@ -68,3 +68,8 @@ env/
### Node.js
node_modules/
+web/.next/
+web/out/
+web/.turbo/
+*.tsbuildinfo
+.pnpm-store/
diff --git a/Makefile b/Makefile
index e03d18d7..bb0afff7 100644
--- a/Makefile
+++ b/Makefile
@@ -13,6 +13,7 @@ K8S_NAMESPACE ?= agentenv-system
K8S_RUNTIME_IMAGE ?= agentenv-runtime:latest
K8S_GATEWAY_IMAGE ?= agentenv-gateway:latest
K8S_SCHEDULER_IMAGE ?= agentenv-scheduler:latest
+K8S_WEB_IMAGE ?= agentenv-web:latest
K3S_CTR ?= sudo k3s ctr
# aenv home path.
@@ -237,19 +238,23 @@ k8s-build:
$(DOCKER) build $(if $(APT_MIRROR_BASE),--build-arg APT_MIRROR_BASE="$(APT_MIRROR_BASE)",) -f deploy/docker/Dockerfile.agentenv -t $(K8S_RUNTIME_IMAGE) .
$(DOCKER) build -f deploy/docker/Dockerfile.gateway -t $(K8S_GATEWAY_IMAGE) .
$(DOCKER) build -f deploy/docker/Dockerfile.scheduler -t $(K8S_SCHEDULER_IMAGE) .
+ $(DOCKER) build -f deploy/docker/Dockerfile.web -t $(K8S_WEB_IMAGE) web
k8s-redeploy:
$(KUBECTL) rollout restart deploy/agentenv-gateway -n $(K8S_NAMESPACE)
$(KUBECTL) rollout restart deploy/agentenv-scheduler -n $(K8S_NAMESPACE)
+ $(KUBECTL) rollout restart deploy/agentenv-web -n $(K8S_NAMESPACE)
$(KUBECTL) rollout restart ds/agentenv-node -n $(K8S_NAMESPACE)
$(KUBECTL) rollout status deploy/agentenv-gateway -n $(K8S_NAMESPACE)
$(KUBECTL) rollout status deploy/agentenv-scheduler -n $(K8S_NAMESPACE)
+ $(KUBECTL) rollout status deploy/agentenv-web -n $(K8S_NAMESPACE)
$(KUBECTL) rollout status ds/agentenv-node -n $(K8S_NAMESPACE)
k8s-load-dev:
$(DOCKER) save $(K8S_RUNTIME_IMAGE) | $(K3S_CTR) images import -
$(DOCKER) save $(K8S_GATEWAY_IMAGE) | $(K3S_CTR) images import -
$(DOCKER) save $(K8S_SCHEDULER_IMAGE) | $(K3S_CTR) images import -
+ $(DOCKER) save $(K8S_WEB_IMAGE) | $(K3S_CTR) images import -
k8s-refresh-dev: k8s-build k8s-load-dev k8s-redeploy
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index f87621ef..24850369 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -77,6 +77,22 @@ services:
- "8080:8080"
restart: unless-stopped
+ web:
+ build:
+ context: ../web
+ dockerfile: ../deploy/docker/Dockerfile.web
+ image: agentenv-web:latest
+ container_name: agentenv-web
+ depends_on:
+ - gateway
+ ports:
+ - "3000:3000"
+ environment:
+ NODE_ENV: production
+ PORT: "3000"
+ AENV_DEFAULT_GATEWAY_URL: ${AENV_DEFAULT_GATEWAY_URL:-http://127.0.0.1:8080}
+ restart: unless-stopped
+
agentenv-a:
<<: *agentenv-base
build:
diff --git a/deploy/docker/Dockerfile.web b/deploy/docker/Dockerfile.web
new file mode 100644
index 00000000..dd69034e
--- /dev/null
+++ b/deploy/docker/Dockerfile.web
@@ -0,0 +1,32 @@
+FROM node:22-bookworm-slim AS deps
+WORKDIR /app
+RUN corepack enable && corepack prepare pnpm@11.0.8 --activate
+COPY package.json pnpm-lock.yaml ./
+COPY pnpm-workspace.yaml* ./
+RUN pnpm install --frozen-lockfile
+
+FROM node:22-bookworm-slim AS builder
+WORKDIR /app
+RUN corepack enable && corepack prepare pnpm@11.0.8 --activate
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+ENV NEXT_TELEMETRY_DISABLED=1
+RUN pnpm build
+
+FROM node:22-bookworm-slim AS runner
+WORKDIR /app
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+ENV PORT=3000
+ENV HOSTNAME=0.0.0.0
+
+RUN groupadd --system --gid 1001 nodejs \
+ && useradd --system --uid 1001 --gid nodejs nextjs
+
+COPY --from=builder /app/public ./public
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+USER nextjs
+EXPOSE 3000
+CMD ["node", "server.js"]
diff --git a/deploy/k8s/base/kustomization.yaml b/deploy/k8s/base/kustomization.yaml
index 797a3bb7..3c7857cc 100644
--- a/deploy/k8s/base/kustomization.yaml
+++ b/deploy/k8s/base/kustomization.yaml
@@ -12,6 +12,8 @@ resources:
- scheduler-deployment.yaml
- gateway-service.yaml
- gateway-deployment.yaml
+ - web-service.yaml
+ - web-deployment.yaml
- agentenv-headless-service.yaml
- agentenv-ublk-daemon-metrics-service.yaml
- agentenv-daemonset.yaml
@@ -40,6 +42,9 @@ images:
- name: agentenv-runtime
newName: agentenv-runtime
newTag: latest
+ - name: agentenv-web
+ newName: agentenv-web
+ newTag: latest
generatorOptions:
disableNameSuffixHash: true
diff --git a/deploy/k8s/base/web-deployment.yaml b/deploy/k8s/base/web-deployment.yaml
new file mode 100644
index 00000000..e390418b
--- /dev/null
+++ b/deploy/k8s/base/web-deployment.yaml
@@ -0,0 +1,50 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: agentenv-web
+ labels:
+ app.kubernetes.io/name: agentenv-web
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: agentenv-web
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: agentenv-web
+ spec:
+ nodeSelector:
+ kubernetes.io/os: linux
+ containers:
+ - name: web
+ image: agentenv-web:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - name: http
+ containerPort: 3000
+ env:
+ - name: NODE_ENV
+ value: production
+ - name: PORT
+ value: "3000"
+ - name: AENV_DEFAULT_GATEWAY_URL
+ value: http://agentenv-gateway:8080
+ readinessProbe:
+ httpGet:
+ path: /
+ port: http
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ livenessProbe:
+ httpGet:
+ path: /
+ port: http
+ initialDelaySeconds: 15
+ periodSeconds: 20
+ resources:
+ requests:
+ cpu: 50m
+ memory: 128Mi
+ limits:
+ memory: 512Mi
diff --git a/deploy/k8s/base/web-service.yaml b/deploy/k8s/base/web-service.yaml
new file mode 100644
index 00000000..5da437f6
--- /dev/null
+++ b/deploy/k8s/base/web-service.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: agentenv-web
+ labels:
+ app.kubernetes.io/name: agentenv-web
+spec:
+ selector:
+ app.kubernetes.io/name: agentenv-web
+ ports:
+ - name: http
+ port: 3000
+ targetPort: http
diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md
index 76c4d02e..481fcbc5 100644
--- a/docs/src/SUMMARY.md
+++ b/docs/src/SUMMARY.md
@@ -6,6 +6,7 @@
- [Quick Start](./getting-started/quickstart.md)
- [On-Demand Loading](./getting-started/on-demand-loading.md)
- [aenv CLI Reference](./getting-started/aenv-cli.md)
+- [Web UI](./getting-started/web-ui.md)
# Deployment
diff --git a/docs/src/deployment/docker-compose.md b/docs/src/deployment/docker-compose.md
index 30fa7a2d..816265f8 100644
--- a/docs/src/deployment/docker-compose.md
+++ b/docs/src/deployment/docker-compose.md
@@ -8,6 +8,7 @@ Run a full multi-node stack on a single host using Docker Compose. This simulate
|---------|------|-------------|
| Gateway | `:8080` | HTTP/WebSocket reverse proxy |
| Scheduler | `:9090` | gRPC node selection and sandbox binding |
+| Web UI | `:3000` | Control-plane console (Next.js) |
| agentenv-a | `:8001` | AgentENV runtime node A |
| agentenv-b | `:8002` | AgentENV runtime node B |
diff --git a/docs/src/deployment/kubernetes.md b/docs/src/deployment/kubernetes.md
index 9f70fbe1..fccf883f 100644
--- a/docs/src/deployment/kubernetes.md
+++ b/docs/src/deployment/kubernetes.md
@@ -8,6 +8,7 @@ Deploy AgentENV across a Kubernetes cluster with a gateway, scheduler, and runti
|----------|------|-------------|
| `agentenv-gateway` | Deployment + ClusterIP Service | HTTP reverse proxy for client traffic |
| `agentenv-scheduler` | Deployment (single replica) + ClusterIP Service | gRPC node selection and sandbox binding |
+| `agentenv-web` | Deployment + ClusterIP Service | Control-plane Web UI (Next.js) |
| `agentenv-node` | DaemonSet (privileged) | One runtime Pod per Kubernetes node |
| `agentenv-nodes` | Headless Service | Used by the scheduler for EndpointSlice discovery |
@@ -37,7 +38,7 @@ cd AgentENV
make k8s-build
```
-This builds three images: `agentenv-runtime:latest`, `agentenv-gateway:latest`, and `agentenv-scheduler:latest`.
+This builds four images: `agentenv-runtime:latest`, `agentenv-gateway:latest`, `agentenv-scheduler:latest`, and `agentenv-web:latest`.
## Deploy
@@ -66,6 +67,14 @@ wildcard DNS for `*.sandbox.example.com`.
The default overlay is `deploy/k8s/overlays/default`, targeting the `agentenv-system` namespace. The gateway is exposed as ClusterIP by default. Add your own Ingress or LoadBalancer for external access.
+Access the Web UI with:
+
+```bash
+kubectl -n agentenv-system port-forward svc/agentenv-web 3000:3000
+```
+
+Then open [http://127.0.0.1:3000](http://127.0.0.1:3000). See [Web UI](../getting-started/web-ui.md).
+
The make targets build a temporary Kustomize context so runtime Pods mount the repository's `config/default.toml` rather than a separate checked-in copy.
The runtime DaemonSet injects scheduler-report wiring for each node Pod:
diff --git a/docs/src/getting-started/web-ui.md b/docs/src/getting-started/web-ui.md
new file mode 100644
index 00000000..43eda74f
--- /dev/null
+++ b/docs/src/getting-started/web-ui.md
@@ -0,0 +1,50 @@
+# Web UI (control plane)
+
+The AgentENV Web UI is a Next.js console for operators and developers to manage sandboxes, snapshots, templates, and nodes through the **Gateway HTTP API**.
+
+## Prerequisites
+
+- A running AgentENV Gateway (single-node `:8000` or multi-node Gateway `:8080`)
+- An API key header value (`X-API-Key`) accepted by the deployment
+- Optional admin token (`X-Admin-Token`) for `/nodes` APIs
+
+## Local development
+
+```bash
+cd web
+pnpm install
+pnpm dev
+```
+
+Open [http://localhost:3000](http://localhost:3000). In **Settings**, set:
+
+| Field | Example (Compose Gateway) | Example (single node) |
+|---|---|---|
+| Gateway URL | `http://127.0.0.1:8080` | `http://127.0.0.1:8000` |
+| API key | any non-empty key | same |
+| Admin token | optional | optional |
+
+Credentials are stored in httpOnly session cookies. They are cleared on logout and, because the cookies carry no expiry, when the browser session ends.
+
+## Docker Compose
+
+The `web` service is defined in `deploy/docker-compose.yml` and published on host port **3000**.
+
+```bash
+docker compose -f deploy/docker-compose.yml up -d --build web
+```
+
+Then open [http://127.0.0.1:3000](http://127.0.0.1:3000) and point Settings at `http://127.0.0.1:8080` (Gateway published on the host).
+
+Build context for the image is `web/` using `deploy/docker/Dockerfile.web`.
+
+## Kubernetes
+
+Kustomize base includes `agentenv-web` Deployment + Service (`deploy/k8s/base/`).
+
+```bash
+make k8s-apply
+kubectl -n agentenv-system port-forward svc/agentenv-web 3000:3000
+```
+
+Default in-cluster Gateway URL env: `http://agentenv-gateway:8080`. When using the UI from a browser on your laptop via port-forward, set Settings to the Gateway URL **reachable from the Next.js server** (in-cluster service name if server-side fetches run in-pod) or use host-accessible URLs consistently.
diff --git a/web/.dockerignore b/web/.dockerignore
new file mode 100644
index 00000000..a9d01e33
--- /dev/null
+++ b/web/.dockerignore
@@ -0,0 +1,13 @@
+node_modules
+.next
+.git
+.gitignore
+*.md
+.env*
+.env*.local
+eslint.config.*
+.next/
+out/
+coverage/
+.turbo
+.DS_Store
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 00000000..5ef6a520
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,41 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 00000000..870740eb
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,31 @@
+# AgentENV Web UI (`web/`)
+
+Control-plane console for [Issue #6](https://github.com/kvcache-ai/AgentENV/issues/6).
+
+## Stack
+
+- Next.js App Router + TypeScript
+- Tailwind CSS v4 + shadcn/ui
+- pnpm
+
+## Develop
+
+```bash
+cd web
+pnpm install
+pnpm dev
+```
+
+Open http://localhost:3000 — configure Gateway (default Compose `:8080`) under **Settings**.
+
+## Conventions
+
+- Talk to **Gateway HTTP only** (never Scheduler gRPC).
+- Credentials live in **httpOnly cookies** (`src/lib/session.ts`); never log full secrets.
+- Upstream calls: `src/lib/api/client.ts` (`gatewayFetch`).
+- Feature routes live under `src/app/(console)/`.
+- Use existing shadcn components in `src/components/ui/`.
+
+## Non-goals (v1)
+
+No browser terminal, filesystem browser, or in-sandbox process execution.
diff --git a/web/components.json b/web/components.json
new file mode 100644
index 00000000..8d886db5
--- /dev/null
+++ b/web/components.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "base-nova",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "menuColor": "default",
+ "menuAccent": "subtle",
+ "registries": {}
+}
diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs
new file mode 100644
index 00000000..05e726d1
--- /dev/null
+++ b/web/eslint.config.mjs
@@ -0,0 +1,18 @@
+import { defineConfig, globalIgnores } from "eslint/config";
+import nextVitals from "eslint-config-next/core-web-vitals";
+import nextTs from "eslint-config-next/typescript";
+
+const eslintConfig = defineConfig([
+ ...nextVitals,
+ ...nextTs,
+ // Override default ignores of eslint-config-next.
+ globalIgnores([
+ // Default ignores of eslint-config-next:
+ ".next/**",
+ "out/**",
+ "build/**",
+ "next-env.d.ts",
+ ]),
+]);
+
+export default eslintConfig;
diff --git a/web/next.config.ts b/web/next.config.ts
new file mode 100644
index 00000000..90a10fd2
--- /dev/null
+++ b/web/next.config.ts
@@ -0,0 +1,13 @@
+import type { NextConfig } from "next";
+
+const extraDevOrigins = (process.env.AENV_WEB_DEV_ORIGINS ?? "")
+ .split(",")
+ .map((origin) => origin.trim())
+ .filter(Boolean);
+
+const nextConfig: NextConfig = {
+ output: "standalone",
+ allowedDevOrigins: ["127.0.0.1", "[::1]", ...extraDevOrigins],
+};
+
+export default nextConfig;
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 00000000..ba91b46d
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "web",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "eslint"
+ },
+ "dependencies": {
+ "@base-ui/react": "^1.6.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^1.27.0",
+ "next": "16.2.12",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
+ "shadcn": "^4.15.0",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.6.0",
+ "tw-animate-css": "^1.4.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "eslint": "^9",
+ "eslint-config-next": "16.2.12",
+ "tailwindcss": "^4",
+ "typescript": "^5"
+ }
+}
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
new file mode 100644
index 00000000..18a2b196
--- /dev/null
+++ b/web/pnpm-lock.yaml
@@ -0,0 +1,6008 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@base-ui/react':
+ specifier: ^1.6.0
+ version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ class-variance-authority:
+ specifier: ^0.7.1
+ version: 0.7.1
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ lucide-react:
+ specifier: ^1.27.0
+ version: 1.27.0(react@19.2.4)
+ next:
+ specifier: 16.2.12
+ version: 16.2.12(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react:
+ specifier: 19.2.4
+ version: 19.2.4
+ react-dom:
+ specifier: 19.2.4
+ version: 19.2.4(react@19.2.4)
+ shadcn:
+ specifier: ^4.15.0
+ version: 4.15.0(supports-color@7.2.0)(typescript@5.9.3)
+ sonner:
+ specifier: ^2.0.7
+ version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ tailwind-merge:
+ specifier: ^3.6.0
+ version: 3.6.0
+ tw-animate-css:
+ specifier: ^1.4.0
+ version: 1.4.0
+ devDependencies:
+ '@tailwindcss/postcss':
+ specifier: ^4
+ version: 4.3.3
+ '@types/node':
+ specifier: ^20
+ version: 20.19.43
+ '@types/react':
+ specifier: ^19
+ version: 19.2.17
+ '@types/react-dom':
+ specifier: ^19
+ version: 19.2.3(@types/react@19.2.17)
+ eslint:
+ specifier: ^9
+ version: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
+ eslint-config-next:
+ specifier: 16.2.12
+ version: 16.2.12(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ tailwindcss:
+ specifier: ^4
+ version: 4.3.3
+ typescript:
+ specifier: ^5
+ version: 5.9.3
+
+packages:
+
+ '@alloc/quick-lru@5.2.0':
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.29.7':
+ resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.7':
+ resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.7':
+ resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-annotate-as-pure@7.29.7':
+ resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.29.7':
+ resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-create-class-features-plugin@7.29.7':
+ resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-globals@7.29.7':
+ resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-member-expression-to-functions@7.29.7':
+ resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.29.7':
+ resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.29.7':
+ resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-optimise-call-expression@7.29.7':
+ resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-plugin-utils@7.29.7':
+ resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-replace-supers@7.29.7':
+ resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-skip-transparent-expression-wrappers@7.29.7':
+ resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.29.7':
+ resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.7':
+ resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.7':
+ resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/plugin-syntax-jsx@7.29.7':
+ resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-syntax-typescript@7.29.7':
+ resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-modules-commonjs@7.29.7':
+ resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-typescript@7.29.7':
+ resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/preset-typescript@7.29.7':
+ resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.7':
+ resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.7':
+ resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
+ engines: {node: '>=6.9.0'}
+
+ '@base-ui/react@1.6.0':
+ resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ '@date-fns/tz': ^1.2.0
+ '@types/react': ^17 || ^18 || ^19
+ date-fns: ^4.0.0
+ react: ^17 || ^18 || ^19
+ react-dom: ^17 || ^18 || ^19
+ peerDependenciesMeta:
+ '@date-fns/tz':
+ optional: true
+ '@types/react':
+ optional: true
+ date-fns:
+ optional: true
+
+ '@base-ui/utils@0.3.1':
+ resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==}
+ peerDependencies:
+ '@types/react': ^17 || ^18 || ^19
+ react: ^17 || ^18 || ^19
+ react-dom: ^17 || ^18 || ^19
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@dotenvx/dotenvx@1.75.1':
+ resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==}
+ hasBin: true
+
+ '@dotenvx/primitives@0.8.0':
+ resolution: {integrity: sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==}
+
+ '@emnapi/core@1.10.0':
+ resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
+
+ '@emnapi/runtime@1.10.0':
+ resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+
+ '@emnapi/wasi-threads@1.2.1':
+ resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+
+ '@eslint-community/eslint-utils@4.10.1':
+ resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+ '@eslint-community/regexpp@4.12.2':
+ resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
+ engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+ '@eslint/config-array@0.21.2':
+ resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/config-helpers@0.4.2':
+ resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/core@0.17.0':
+ resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/eslintrc@3.3.6':
+ resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/js@9.39.5':
+ resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/object-schema@2.1.7':
+ resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/plugin-kit@0.4.1':
+ resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@floating-ui/core@1.8.0':
+ resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
+
+ '@floating-ui/dom@1.8.0':
+ resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
+
+ '@floating-ui/react-dom@2.1.9':
+ resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@floating-ui/utils@0.2.12':
+ resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
+
+ '@hono/node-server@1.19.15':
+ resolution: {integrity: sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==}
+ engines: {node: '>=18.14.1'}
+ peerDependencies:
+ hono: ^4
+
+ '@humanfs/core@0.19.2':
+ resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/node@0.16.8':
+ resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/types@0.15.0':
+ resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanwhocodes/module-importer@1.0.1':
+ resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+ engines: {node: '>=12.22'}
+
+ '@humanwhocodes/retry@0.4.3':
+ resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
+ engines: {node: '>=18.18'}
+
+ '@img/colour@1.1.0':
+ resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
+ engines: {node: '>=18'}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-darwin-x64@0.34.5':
+ resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@img/sharp-linux-arm64@0.34.5':
+ resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-linux-arm@0.34.5':
+ resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-linux-s390x@0.34.5':
+ resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-linux-x64@0.34.5':
+ resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@img/sharp-wasm32@0.34.5':
+ resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [wasm32]
+
+ '@img/sharp-win32-arm64@0.34.5':
+ resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@img/sharp-win32-ia32@0.34.5':
+ resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@img/sharp-win32-x64@0.34.5':
+ resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@modelcontextprotocol/sdk@1.29.0':
+ resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@cfworker/json-schema': ^4.1.1
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ '@cfworker/json-schema':
+ optional: true
+
+ '@napi-rs/wasm-runtime@1.1.6':
+ resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1
+ '@emnapi/runtime': ^1.7.1
+
+ '@next/env@16.2.12':
+ resolution: {integrity: sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==}
+
+ '@next/eslint-plugin-next@16.2.12':
+ resolution: {integrity: sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==}
+
+ '@next/swc-darwin-arm64@16.2.12':
+ resolution: {integrity: sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@next/swc-darwin-x64@16.2.12':
+ resolution: {integrity: sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@next/swc-linux-arm64-gnu@16.2.12':
+ resolution: {integrity: sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@next/swc-linux-arm64-musl@16.2.12':
+ resolution: {integrity: sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@next/swc-linux-x64-gnu@16.2.12':
+ resolution: {integrity: sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@next/swc-linux-x64-musl@16.2.12':
+ resolution: {integrity: sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@next/swc-win32-arm64-msvc@16.2.12':
+ resolution: {integrity: sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@next/swc-win32-x64-msvc@16.2.12':
+ resolution: {integrity: sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@nodelib/fs.scandir@2.1.5':
+ resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.stat@2.0.5':
+ resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.walk@1.2.8':
+ resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+ engines: {node: '>= 8'}
+
+ '@nolyfill/is-core-module@1.0.39':
+ resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
+ engines: {node: '>=12.4.0'}
+
+ '@rtsao/scc@1.1.0':
+ resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
+
+ '@sec-ant/readable-stream@0.4.1':
+ resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
+
+ '@sindresorhus/merge-streams@4.0.0':
+ resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
+ engines: {node: '>=18'}
+
+ '@swc/helpers@0.5.15':
+ resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
+
+ '@tailwindcss/node@4.3.3':
+ resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
+
+ '@tailwindcss/oxide-android-arm64@4.3.3':
+ resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.3.3':
+ resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.3.3':
+ resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.3.3':
+ resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
+ resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
+ resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
+ resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
+ resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.3.3':
+ resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.3.3':
+ resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
+ resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
+ resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.3.3':
+ resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
+ engines: {node: '>= 20'}
+
+ '@tailwindcss/postcss@4.3.3':
+ resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
+
+ '@ts-morph/common@0.27.0':
+ resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
+
+ '@tybys/wasm-util@0.10.3':
+ resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/json-schema@7.0.15':
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
+ '@types/json5@0.0.29':
+ resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+
+ '@types/node@20.19.43':
+ resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
+
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.17':
+ resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
+
+ '@types/validate-npm-package-name@4.0.2':
+ resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
+
+ '@typescript-eslint/eslint-plugin@8.65.0':
+ resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.65.0
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/parser@8.65.0':
+ resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/project-service@8.65.0':
+ resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/scope-manager@8.65.0':
+ resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.65.0':
+ resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/type-utils@8.65.0':
+ resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/types@8.65.0':
+ resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.65.0':
+ resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/utils@8.65.0':
+ resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/visitor-keys@8.65.0':
+ resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@unrs/resolver-binding-android-arm-eabi@1.12.2':
+ resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==}
+ cpu: [arm]
+ os: [android]
+
+ '@unrs/resolver-binding-android-arm64@1.12.2':
+ resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==}
+ cpu: [arm64]
+ os: [android]
+
+ '@unrs/resolver-binding-darwin-arm64@1.12.2':
+ resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@unrs/resolver-binding-darwin-x64@1.12.2':
+ resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@unrs/resolver-binding-freebsd-x64@1.12.2':
+ resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
+ resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==}
+ cpu: [arm]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
+ resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==}
+ cpu: [arm]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
+ resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
+ resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
+ resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
+ resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
+ resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
+ resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
+ resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
+ resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
+ resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@unrs/resolver-binding-linux-x64-musl@1.12.2':
+ resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@unrs/resolver-binding-openharmony-arm64@1.12.2':
+ resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@unrs/resolver-binding-wasm32-wasi@1.12.2':
+ resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+
+ '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
+ resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
+ resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
+ resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==}
+ cpu: [x64]
+ os: [win32]
+
+ accepts@2.0.0:
+ resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
+ engines: {node: '>= 0.6'}
+
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.17.0:
+ resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ ajv-formats@2.1.1:
+ resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
+ ajv-formats@3.0.1:
+ resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
+ ajv@6.15.0:
+ resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
+
+ ajv@8.20.0:
+ resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
+
+ ansi-colors@4.1.3:
+ resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
+ engines: {node: '>=6'}
+
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
+ ansi-regex@6.2.2:
+ resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
+ engines: {node: '>=12'}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+ aria-query@5.3.2:
+ resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
+ engines: {node: '>= 0.4'}
+
+ array-buffer-byte-length@1.0.2:
+ resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
+ engines: {node: '>= 0.4'}
+
+ array-includes@3.1.9:
+ resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.findlast@1.2.5:
+ resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.findlastindex@1.2.6:
+ resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flat@1.3.3:
+ resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flatmap@1.3.3:
+ resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.tosorted@1.1.4:
+ resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==}
+ engines: {node: '>= 0.4'}
+
+ arraybuffer.prototype.slice@1.0.4:
+ resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
+ engines: {node: '>= 0.4'}
+
+ ast-types-flow@0.0.8:
+ resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
+
+ ast-types@0.16.1:
+ resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
+ engines: {node: '>=4'}
+
+ async-function@1.0.0:
+ resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
+ engines: {node: '>= 0.4'}
+
+ atomically@1.7.0:
+ resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==}
+ engines: {node: '>=10.12.0'}
+
+ available-typed-arrays@1.0.7:
+ resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
+ engines: {node: '>= 0.4'}
+
+ axe-core@4.12.1:
+ resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==}
+ engines: {node: '>=4'}
+
+ axobject-query@4.1.0:
+ resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
+ engines: {node: '>= 0.4'}
+
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+ balanced-match@4.0.4:
+ resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+ engines: {node: 18 || 20 || >=22}
+
+ baseline-browser-mapping@2.11.4:
+ resolution: {integrity: sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ body-parser@2.3.0:
+ resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
+ engines: {node: '>=18'}
+
+ brace-expansion@1.1.16:
+ resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
+
+ brace-expansion@5.0.8:
+ resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
+ engines: {node: 20 || >=22}
+
+ braces@3.0.3:
+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+ engines: {node: '>=8'}
+
+ browserslist@4.28.7:
+ resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ bundle-name@4.1.0:
+ resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
+ engines: {node: '>=18'}
+
+ bytes@3.1.2:
+ resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+ engines: {node: '>= 0.8'}
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bind@1.0.9:
+ resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
+
+ callsites@3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+
+ caniuse-lite@1.0.30001806:
+ resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
+
+ chalk@4.1.2:
+ resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+ engines: {node: '>=10'}
+
+ chalk@5.6.2:
+ resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
+ engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+
+ class-variance-authority@0.7.1:
+ resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+
+ cli-cursor@5.0.0:
+ resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
+ engines: {node: '>=18'}
+
+ cli-spinners@2.9.2:
+ resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
+ engines: {node: '>=6'}
+
+ client-only@0.0.1:
+ resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
+
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
+ code-block-writer@13.0.3:
+ resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ commander@11.1.0:
+ resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
+ engines: {node: '>=16'}
+
+ commander@14.0.3:
+ resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
+ engines: {node: '>=20'}
+
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+ conf@10.2.0:
+ resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==}
+ engines: {node: '>=12'}
+
+ content-disposition@1.1.0:
+ resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
+ engines: {node: '>=18'}
+
+ content-type@1.0.5:
+ resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+ engines: {node: '>= 0.6'}
+
+ content-type@2.0.0:
+ resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
+ engines: {node: '>=18'}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cookie-signature@1.2.2:
+ resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
+ engines: {node: '>=6.6.0'}
+
+ cookie@0.7.2:
+ resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+ engines: {node: '>= 0.6'}
+
+ cors@2.8.6:
+ resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
+ engines: {node: '>= 0.10'}
+
+ cosmiconfig@9.0.2:
+ resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==}
+ engines: {node: '>=14'}
+ peerDependencies:
+ typescript: '>=4.9.5'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ cssesc@3.0.0:
+ resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+ engines: {node: '>=4'}
+ hasBin: true
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ damerau-levenshtein@1.0.8:
+ resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
+
+ data-view-buffer@1.0.2:
+ resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
+ engines: {node: '>= 0.4'}
+
+ data-view-byte-length@1.0.2:
+ resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
+ engines: {node: '>= 0.4'}
+
+ data-view-byte-offset@1.0.1:
+ resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
+ engines: {node: '>= 0.4'}
+
+ debounce-fn@4.0.0:
+ resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==}
+ engines: {node: '>=10'}
+
+ debug@3.2.7:
+ resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ dedent@1.7.2:
+ resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
+ peerDependencies:
+ babel-plugin-macros: ^3.1.0
+ peerDependenciesMeta:
+ babel-plugin-macros:
+ optional: true
+
+ deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+ deepmerge@4.3.1:
+ resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
+ engines: {node: '>=0.10.0'}
+
+ default-browser-id@5.0.1:
+ resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
+ engines: {node: '>=18'}
+
+ default-browser@5.5.0:
+ resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
+ engines: {node: '>=18'}
+
+ define-data-property@1.1.4:
+ resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
+ engines: {node: '>= 0.4'}
+
+ define-lazy-prop@2.0.0:
+ resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
+ engines: {node: '>=8'}
+
+ define-lazy-prop@3.0.0:
+ resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
+ engines: {node: '>=12'}
+
+ define-properties@1.2.1:
+ resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
+ engines: {node: '>= 0.4'}
+
+ depd@2.0.0:
+ resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+ engines: {node: '>= 0.8'}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ diff@8.0.4:
+ resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
+ engines: {node: '>=0.3.1'}
+
+ doctrine@2.1.0:
+ resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
+ engines: {node: '>=0.10.0'}
+
+ dot-prop@6.0.1:
+ resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
+ engines: {node: '>=10'}
+
+ dotenv@17.4.2:
+ resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
+ engines: {node: '>=12'}
+
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ ee-first@1.1.1:
+ resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
+ electron-to-chromium@1.5.396:
+ resolution: {integrity: sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==}
+
+ emoji-regex@10.6.0:
+ resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
+
+ emoji-regex@9.2.2:
+ resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+
+ encodeurl@2.0.0:
+ resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
+ engines: {node: '>= 0.8'}
+
+ enhanced-resolve@5.24.3:
+ resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==}
+ engines: {node: '>=10.13.0'}
+
+ enquirer@2.4.1:
+ resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
+ engines: {node: '>=8.6'}
+
+ env-paths@2.2.1:
+ resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
+ engines: {node: '>=6'}
+
+ error-ex@1.3.4:
+ resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
+
+ es-abstract-get@1.0.0:
+ resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
+ engines: {node: '>= 0.4'}
+
+ es-abstract@1.24.2:
+ resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
+ engines: {node: '>= 0.4'}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-iterator-helpers@1.4.0:
+ resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==}
+ engines: {node: '>= 0.4'}
+
+ es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+ engines: {node: '>= 0.4'}
+
+ es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+
+ es-shim-unscopables@1.1.0:
+ resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
+ engines: {node: '>= 0.4'}
+
+ es-to-primitive@1.3.4:
+ resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
+ engines: {node: '>= 0.4'}
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ escape-html@1.0.3:
+ resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ eslint-config-next@16.2.12:
+ resolution: {integrity: sha512-iaaf4vvKo5h2LBdGt0JuRv7t0Ysqr9FMCiFxbptDg8LqOE//mIKR80DdpOnSVM7qjLH3jT8P0aFiwXxBEGZRXw==}
+ peerDependencies:
+ eslint: '>=9.0.0'
+ typescript: '>=3.3.1'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ eslint-import-resolver-node@0.3.10:
+ resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==}
+
+ eslint-import-resolver-typescript@3.10.1:
+ resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ eslint: '*'
+ eslint-plugin-import: '*'
+ eslint-plugin-import-x: '*'
+ peerDependenciesMeta:
+ eslint-plugin-import:
+ optional: true
+ eslint-plugin-import-x:
+ optional: true
+
+ eslint-module-utils@2.14.0:
+ resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ '@typescript-eslint/parser': '*'
+ eslint: '*'
+ eslint-import-resolver-node: '*'
+ eslint-import-resolver-typescript: '*'
+ eslint-import-resolver-webpack: '*'
+ peerDependenciesMeta:
+ '@typescript-eslint/parser':
+ optional: true
+ eslint:
+ optional: true
+ eslint-import-resolver-node:
+ optional: true
+ eslint-import-resolver-typescript:
+ optional: true
+ eslint-import-resolver-webpack:
+ optional: true
+
+ eslint-plugin-import@2.32.0:
+ resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ '@typescript-eslint/parser': '*'
+ eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9
+ peerDependenciesMeta:
+ '@typescript-eslint/parser':
+ optional: true
+
+ eslint-plugin-jsx-a11y@6.10.2:
+ resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
+
+ eslint-plugin-react-hooks@7.1.1:
+ resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
+
+ eslint-plugin-react@7.37.5:
+ resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
+
+ eslint-scope@8.4.0:
+ resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ eslint-visitor-keys@4.2.1:
+ resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@5.0.1:
+ resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ eslint@9.39.5:
+ resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ espree@10.4.0:
+ resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ esprima@4.0.1:
+ resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
+ engines: {node: '>=4'}
+ hasBin: true
+
+ esquery@1.7.0:
+ resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
+ engines: {node: '>=0.10'}
+
+ esrecurse@4.3.0:
+ resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+ engines: {node: '>=4.0'}
+
+ estraverse@5.3.0:
+ resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+ engines: {node: '>=4.0'}
+
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
+ etag@1.8.1:
+ resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+ engines: {node: '>= 0.6'}
+
+ eventsource-parser@3.1.0:
+ resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
+ engines: {node: '>=18.0.0'}
+
+ eventsource@3.0.7:
+ resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
+ engines: {node: '>=18.0.0'}
+
+ execa@5.1.1:
+ resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
+ engines: {node: '>=10'}
+
+ execa@9.6.1:
+ resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
+ engines: {node: ^18.19.0 || >=20.5.0}
+
+ express-rate-limit@8.6.0:
+ resolution: {integrity: sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ express: '>= 4.11'
+
+ express@5.2.1:
+ resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
+ engines: {node: '>= 18'}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-glob@3.3.1:
+ resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
+ engines: {node: '>=8.6.0'}
+
+ fast-glob@3.3.3:
+ resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+ engines: {node: '>=8.6.0'}
+
+ fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+ fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+ fast-uri@3.1.4:
+ resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
+
+ fastq@1.20.1:
+ resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ figures@6.1.0:
+ resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
+ engines: {node: '>=18'}
+
+ file-entry-cache@8.0.0:
+ resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+ engines: {node: '>=16.0.0'}
+
+ fill-range@7.1.1:
+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+ engines: {node: '>=8'}
+
+ finalhandler@2.1.1:
+ resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
+ engines: {node: '>= 18.0.0'}
+
+ find-up@3.0.0:
+ resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
+ engines: {node: '>=6'}
+
+ find-up@5.0.0:
+ resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+ engines: {node: '>=10'}
+
+ flat-cache@4.0.1:
+ resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+ engines: {node: '>=16'}
+
+ flatted@3.4.3:
+ resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==}
+
+ for-each@0.3.5:
+ resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
+ engines: {node: '>= 0.4'}
+
+ forwarded@0.2.0:
+ resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+ engines: {node: '>= 0.6'}
+
+ fresh@2.0.0:
+ resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+ engines: {node: '>= 0.8'}
+
+ fs-extra@11.4.0:
+ resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==}
+ engines: {node: '>=14.14'}
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ function.prototype.name@1.2.0:
+ resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==}
+ engines: {node: '>= 0.4'}
+
+ functions-have-names@1.2.3:
+ resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
+
+ fuzzysort@3.1.0:
+ resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==}
+
+ generator-function@2.0.1:
+ resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
+ engines: {node: '>= 0.4'}
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ get-east-asian-width@1.6.0:
+ resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
+ engines: {node: '>=18'}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-own-enumerable-keys@1.0.0:
+ resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==}
+ engines: {node: '>=14.16'}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ get-stream@6.0.1:
+ resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
+ engines: {node: '>=10'}
+
+ get-stream@9.0.1:
+ resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
+ engines: {node: '>=18'}
+
+ get-symbol-description@1.1.0:
+ resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
+ engines: {node: '>= 0.4'}
+
+ get-tsconfig@4.14.0:
+ resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
+
+ glob-parent@5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ globals@14.0.0:
+ resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+ engines: {node: '>=18'}
+
+ globals@16.4.0:
+ resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==}
+ engines: {node: '>=18'}
+
+ globalthis@1.0.4:
+ resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
+ engines: {node: '>= 0.4'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ has-bigints@1.1.0:
+ resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
+ engines: {node: '>= 0.4'}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ has-property-descriptors@1.0.2:
+ resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
+
+ has-proto@1.2.0:
+ resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
+ engines: {node: '>= 0.4'}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
+ hermes-estree@0.25.1:
+ resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
+
+ hermes-parser@0.25.1:
+ resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+
+ hono@4.12.32:
+ resolution: {integrity: sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==}
+ engines: {node: '>=16.9.0'}
+
+ http-errors@2.0.1:
+ resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+ engines: {node: '>= 0.8'}
+
+ human-signals@2.1.0:
+ resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
+ engines: {node: '>=10.17.0'}
+
+ human-signals@8.0.1:
+ resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
+ engines: {node: '>=18.18.0'}
+
+ iconv-lite@0.7.3:
+ resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
+ engines: {node: '>=0.10.0'}
+
+ ignore@5.3.2:
+ resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+ engines: {node: '>= 4'}
+
+ ignore@7.0.6:
+ resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
+ engines: {node: '>= 4'}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
+ imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+ engines: {node: '>=0.8.19'}
+
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+ internal-slot@1.1.0:
+ resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
+ engines: {node: '>= 0.4'}
+
+ ip-address@10.3.1:
+ resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==}
+ engines: {node: '>= 12'}
+
+ ipaddr.js@1.9.1:
+ resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+ engines: {node: '>= 0.10'}
+
+ is-array-buffer@3.0.5:
+ resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
+ engines: {node: '>= 0.4'}
+
+ is-arrayish@0.2.1:
+ resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
+
+ is-async-function@2.1.1:
+ resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
+ engines: {node: '>= 0.4'}
+
+ is-bigint@1.1.0:
+ resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
+ engines: {node: '>= 0.4'}
+
+ is-boolean-object@1.2.2:
+ resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
+ engines: {node: '>= 0.4'}
+
+ is-bun-module@2.0.0:
+ resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
+
+ is-callable@1.2.7:
+ resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
+ engines: {node: '>= 0.4'}
+
+ is-core-module@2.16.2:
+ resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
+ engines: {node: '>= 0.4'}
+
+ is-data-view@1.0.2:
+ resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
+ engines: {node: '>= 0.4'}
+
+ is-date-object@1.1.0:
+ resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
+ engines: {node: '>= 0.4'}
+
+ is-docker@2.2.1:
+ resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
+ engines: {node: '>=8'}
+ hasBin: true
+
+ is-docker@3.0.0:
+ resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ hasBin: true
+
+ is-document.all@1.0.0:
+ resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
+ engines: {node: '>= 0.4'}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-finalizationregistry@1.1.1:
+ resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
+ engines: {node: '>= 0.4'}
+
+ is-generator-function@1.1.2:
+ resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
+ engines: {node: '>= 0.4'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ is-in-ssh@1.0.0:
+ resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
+ engines: {node: '>=20'}
+
+ is-inside-container@1.0.0:
+ resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
+ engines: {node: '>=14.16'}
+ hasBin: true
+
+ is-interactive@2.0.0:
+ resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
+ engines: {node: '>=12'}
+
+ is-map@2.0.3:
+ resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
+ engines: {node: '>= 0.4'}
+
+ is-negative-zero@2.0.3:
+ resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
+ engines: {node: '>= 0.4'}
+
+ is-number-object@1.1.1:
+ resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
+ engines: {node: '>= 0.4'}
+
+ is-number@7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+
+ is-obj@2.0.0:
+ resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==}
+ engines: {node: '>=8'}
+
+ is-obj@3.0.0:
+ resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==}
+ engines: {node: '>=12'}
+
+ is-plain-obj@4.1.0:
+ resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
+ engines: {node: '>=12'}
+
+ is-promise@4.0.0:
+ resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+
+ is-regex@1.2.1:
+ resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
+ engines: {node: '>= 0.4'}
+
+ is-regexp@3.1.0:
+ resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==}
+ engines: {node: '>=12'}
+
+ is-set@2.0.3:
+ resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
+ engines: {node: '>= 0.4'}
+
+ is-shared-array-buffer@1.0.4:
+ resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
+ engines: {node: '>= 0.4'}
+
+ is-stream@2.0.1:
+ resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
+ engines: {node: '>=8'}
+
+ is-stream@4.0.1:
+ resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
+ engines: {node: '>=18'}
+
+ is-string@1.1.1:
+ resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
+ engines: {node: '>= 0.4'}
+
+ is-symbol@1.1.1:
+ resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
+ engines: {node: '>= 0.4'}
+
+ is-typed-array@1.1.15:
+ resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
+ engines: {node: '>= 0.4'}
+
+ is-unicode-supported@1.3.0:
+ resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==}
+ engines: {node: '>=12'}
+
+ is-unicode-supported@2.1.0:
+ resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
+ engines: {node: '>=18'}
+
+ is-weakmap@2.0.2:
+ resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
+ engines: {node: '>= 0.4'}
+
+ is-weakref@1.1.1:
+ resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
+ engines: {node: '>= 0.4'}
+
+ is-weakset@2.0.4:
+ resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
+ engines: {node: '>= 0.4'}
+
+ is-wsl@2.2.0:
+ resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
+ engines: {node: '>=8'}
+
+ is-wsl@3.1.1:
+ resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
+ engines: {node: '>=16'}
+
+ isarray@2.0.5:
+ resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ isexe@3.1.5:
+ resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
+ engines: {node: '>=18'}
+
+ iterator.prototype@1.1.5:
+ resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
+ engines: {node: '>= 0.4'}
+
+ jiti@2.7.0:
+ resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
+ hasBin: true
+
+ jose@6.2.4:
+ resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==}
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ js-yaml@4.3.0:
+ resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
+ hasBin: true
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+ json-parse-even-better-errors@2.3.1:
+ resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
+
+ json-schema-traverse@0.4.1:
+ resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+ json-schema-traverse@1.0.0:
+ resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+
+ json-schema-typed@7.0.3:
+ resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==}
+
+ json-schema-typed@8.0.2:
+ resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
+
+ json-stable-stringify-without-jsonify@1.0.1:
+ resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+ json5@1.0.2:
+ resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
+ hasBin: true
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ jsonfile@6.2.1:
+ resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
+
+ jsx-ast-utils@3.3.5:
+ resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
+ engines: {node: '>=4.0'}
+
+ keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+ kleur@3.0.3:
+ resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
+ engines: {node: '>=6'}
+
+ kleur@4.1.5:
+ resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
+ engines: {node: '>=6'}
+
+ language-subtag-registry@0.3.23:
+ resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
+
+ language-tags@1.0.9:
+ resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
+ engines: {node: '>=0.10'}
+
+ levn@0.4.1:
+ resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+ engines: {node: '>= 0.8.0'}
+
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ engines: {node: '>= 12.0.0'}
+
+ lines-and-columns@1.2.4:
+ resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
+ locate-path@3.0.0:
+ resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==}
+ engines: {node: '>=6'}
+
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
+ lodash.merge@4.6.2:
+ resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
+ log-symbols@6.0.0:
+ resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
+ engines: {node: '>=18'}
+
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ lucide-react@1.27.0:
+ resolution: {integrity: sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
+ media-typer@1.1.1:
+ resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
+ engines: {node: '>= 0.8'}
+
+ merge-descriptors@2.0.0:
+ resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
+ engines: {node: '>=18'}
+
+ merge-stream@2.0.0:
+ resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
+
+ merge2@1.4.1:
+ resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+ engines: {node: '>= 8'}
+
+ micromatch@4.0.8:
+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+ engines: {node: '>=8.6'}
+
+ mime-db@1.54.0:
+ resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@3.0.2:
+ resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
+ engines: {node: '>=18'}
+
+ mimic-fn@2.1.0:
+ resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
+ engines: {node: '>=6'}
+
+ mimic-fn@3.1.0:
+ resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==}
+ engines: {node: '>=8'}
+
+ mimic-function@5.0.1:
+ resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
+ engines: {node: '>=18'}
+
+ minimatch@10.2.5:
+ resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
+ engines: {node: 18 || 20 || >=22}
+
+ minimatch@3.1.5:
+ resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+
+ minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.16:
+ resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ napi-postinstall@0.3.4:
+ resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
+ engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
+ hasBin: true
+
+ natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+ negotiator@1.0.0:
+ resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
+ engines: {node: '>= 0.6'}
+
+ next@16.2.12:
+ resolution: {integrity: sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==}
+ engines: {node: '>=20.9.0'}
+ hasBin: true
+ peerDependencies:
+ '@opentelemetry/api': ^1.1.0
+ '@playwright/test': ^1.51.1
+ babel-plugin-react-compiler: '*'
+ react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
+ react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
+ sass: ^1.3.0
+ peerDependenciesMeta:
+ '@opentelemetry/api':
+ optional: true
+ '@playwright/test':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+ sass:
+ optional: true
+
+ node-exports-info@1.6.2:
+ resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==}
+ engines: {node: '>= 0.4'}
+
+ node-releases@2.0.51:
+ resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
+ engines: {node: '>=18'}
+
+ npm-run-path@4.0.1:
+ resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
+ engines: {node: '>=8'}
+
+ npm-run-path@6.0.0:
+ resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
+ engines: {node: '>=18'}
+
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ object-inspect@1.13.4:
+ resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+ engines: {node: '>= 0.4'}
+
+ object-keys@1.1.1:
+ resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
+ engines: {node: '>= 0.4'}
+
+ object-treeify@1.1.33:
+ resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==}
+ engines: {node: '>= 10'}
+
+ object.assign@4.1.7:
+ resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
+ engines: {node: '>= 0.4'}
+
+ object.entries@1.1.9:
+ resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==}
+ engines: {node: '>= 0.4'}
+
+ object.fromentries@2.0.8:
+ resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
+ engines: {node: '>= 0.4'}
+
+ object.groupby@1.0.3:
+ resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==}
+ engines: {node: '>= 0.4'}
+
+ object.values@1.2.1:
+ resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
+ engines: {node: '>= 0.4'}
+
+ on-finished@2.4.1:
+ resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+ engines: {node: '>= 0.8'}
+
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+ onetime@5.1.2:
+ resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
+ engines: {node: '>=6'}
+
+ onetime@7.0.0:
+ resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
+ engines: {node: '>=18'}
+
+ open@11.0.0:
+ resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
+ engines: {node: '>=20'}
+
+ open@8.4.2:
+ resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
+ engines: {node: '>=12'}
+
+ optionator@0.9.4:
+ resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+ engines: {node: '>= 0.8.0'}
+
+ ora@8.2.0:
+ resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
+ engines: {node: '>=18'}
+
+ own-keys@1.0.2:
+ resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
+ engines: {node: '>= 0.4'}
+
+ p-limit@2.3.0:
+ resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+ engines: {node: '>=6'}
+
+ p-limit@3.1.0:
+ resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+ engines: {node: '>=10'}
+
+ p-locate@3.0.0:
+ resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==}
+ engines: {node: '>=6'}
+
+ p-locate@5.0.0:
+ resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+ engines: {node: '>=10'}
+
+ p-try@2.2.0:
+ resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
+ engines: {node: '>=6'}
+
+ parent-module@1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+
+ parse-json@5.2.0:
+ resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
+ engines: {node: '>=8'}
+
+ parse-ms@4.0.0:
+ resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
+ engines: {node: '>=18'}
+
+ parseurl@1.3.3:
+ resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+ engines: {node: '>= 0.8'}
+
+ path-browserify@1.0.1:
+ resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
+
+ path-exists@3.0.0:
+ resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==}
+ engines: {node: '>=4'}
+
+ path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ path-key@4.0.0:
+ resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
+ engines: {node: '>=12'}
+
+ path-parse@1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+ path-to-regexp@8.4.2:
+ resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
+ engines: {node: '>=8.6'}
+
+ picomatch@4.0.5:
+ resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
+ engines: {node: '>=12'}
+
+ pkce-challenge@5.0.1:
+ resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
+ engines: {node: '>=16.20.0'}
+
+ pkg-up@3.1.0:
+ resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==}
+ engines: {node: '>=8'}
+
+ possible-typed-array-names@1.1.0:
+ resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
+ engines: {node: '>= 0.4'}
+
+ postcss-selector-parser@7.1.4:
+ resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==}
+ engines: {node: '>=4'}
+
+ postcss@8.4.31:
+ resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ postcss@8.5.23:
+ resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ powershell-utils@0.1.0:
+ resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
+ engines: {node: '>=20'}
+
+ prelude-ls@1.2.1:
+ resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+ engines: {node: '>= 0.8.0'}
+
+ pretty-ms@9.3.0:
+ resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
+ engines: {node: '>=18'}
+
+ prompts@2.4.2:
+ resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
+ engines: {node: '>= 6'}
+
+ prop-types@15.8.1:
+ resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+
+ proxy-addr@2.0.7:
+ resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
+ engines: {node: '>= 0.10'}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
+ qs@6.15.3:
+ resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
+ engines: {node: '>=0.6'}
+
+ queue-microtask@1.2.3:
+ resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
+ range-parser@1.3.0:
+ resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
+ engines: {node: '>= 0.6'}
+
+ raw-body@3.0.2:
+ resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
+ engines: {node: '>= 0.10'}
+
+ react-dom@19.2.4:
+ resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
+ peerDependencies:
+ react: ^19.2.4
+
+ react-is@16.13.1:
+ resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+
+ react@19.2.4:
+ resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
+ engines: {node: '>=0.10.0'}
+
+ recast@0.23.12:
+ resolution: {integrity: sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==}
+ engines: {node: '>= 4'}
+
+ reflect.getprototypeof@1.0.10:
+ resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
+ engines: {node: '>= 0.4'}
+
+ regexp.prototype.flags@1.5.4:
+ resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
+ engines: {node: '>= 0.4'}
+
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
+ reselect@5.2.0:
+ resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
+
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
+ resolve-pkg-maps@1.0.0:
+ resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+
+ resolve@2.0.0-next.7:
+ resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
+ restore-cursor@5.1.0:
+ resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
+ engines: {node: '>=18'}
+
+ reusify@1.1.0:
+ resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+ engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+ router@2.2.0:
+ resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
+ engines: {node: '>= 18'}
+
+ run-applescript@7.1.0:
+ resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
+ engines: {node: '>=18'}
+
+ run-parallel@1.2.0:
+ resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
+ safe-array-concat@1.1.4:
+ resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
+ engines: {node: '>=0.4'}
+
+ safe-push-apply@1.0.0:
+ resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
+ engines: {node: '>= 0.4'}
+
+ safe-regex-test@1.1.0:
+ resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
+ engines: {node: '>= 0.4'}
+
+ safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ send@1.2.1:
+ resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
+ engines: {node: '>= 18'}
+
+ serve-static@2.2.1:
+ resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
+ engines: {node: '>= 18'}
+
+ set-function-length@1.2.2:
+ resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
+ engines: {node: '>= 0.4'}
+
+ set-function-name@2.0.2:
+ resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
+ engines: {node: '>= 0.4'}
+
+ set-proto@1.0.0:
+ resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
+ engines: {node: '>= 0.4'}
+
+ setprototypeof@1.2.0:
+ resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
+ shadcn@4.15.0:
+ resolution: {integrity: sha512-fFTpfOuRwqjpXGp/sKpAxJkjdgv1jf8bDrW1xi0cVn2k7WJ5ijV/gjkAlkAidGDjhEkgcUuxVdm4oRB9r8BivA==}
+ engines: {node: '>=20.18.1'}
+ hasBin: true
+
+ sharp@0.34.5:
+ resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ side-channel-list@1.0.1:
+ resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-map@1.0.1:
+ resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-weakmap@1.0.2:
+ resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+ engines: {node: '>= 0.4'}
+
+ side-channel@1.1.1:
+ resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
+ engines: {node: '>= 0.4'}
+
+ signal-exit@3.0.7:
+ resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+
+ signal-exit@4.1.0:
+ resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
+ engines: {node: '>=14'}
+
+ sisteransi@1.0.5:
+ resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
+
+ sonner@2.0.7:
+ resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ source-map@0.6.1:
+ resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+ engines: {node: '>=0.10.0'}
+
+ stable-hash@0.0.5:
+ resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
+
+ statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+
+ stdin-discarder@0.2.2:
+ resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
+ engines: {node: '>=18'}
+
+ stop-iteration-iterator@1.1.0:
+ resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
+ engines: {node: '>= 0.4'}
+
+ string-width@7.2.0:
+ resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
+ engines: {node: '>=18'}
+
+ string.prototype.includes@2.0.1:
+ resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.matchall@4.0.12:
+ resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.repeat@1.0.0:
+ resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==}
+
+ string.prototype.trim@1.2.11:
+ resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trimend@1.0.10:
+ resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trimstart@1.0.8:
+ resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
+ engines: {node: '>= 0.4'}
+
+ stringify-object@5.0.0:
+ resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==}
+ engines: {node: '>=14.16'}
+
+ strip-ansi@6.0.1:
+ resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+ engines: {node: '>=8'}
+
+ strip-ansi@7.2.0:
+ resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
+ engines: {node: '>=12'}
+
+ strip-bom@3.0.0:
+ resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
+ engines: {node: '>=4'}
+
+ strip-final-newline@2.0.0:
+ resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
+ engines: {node: '>=6'}
+
+ strip-final-newline@4.0.0:
+ resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
+ engines: {node: '>=18'}
+
+ strip-json-comments@3.1.1:
+ resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+ engines: {node: '>=8'}
+
+ styled-jsx@5.1.6:
+ resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
+ engines: {node: '>= 12.0.0'}
+ peerDependencies:
+ '@babel/core': '*'
+ babel-plugin-macros: '*'
+ react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
+ peerDependenciesMeta:
+ '@babel/core':
+ optional: true
+ babel-plugin-macros:
+ optional: true
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ supports-preserve-symlinks-flag@1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+
+ systeminformation@5.33.1:
+ resolution: {integrity: sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==}
+ engines: {node: '>=10.0.0'}
+ os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android]
+ hasBin: true
+
+ tailwind-merge@3.6.0:
+ resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
+
+ tailwindcss@4.3.3:
+ resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
+
+ tapable@2.3.3:
+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
+ engines: {node: '>=6'}
+
+ tiny-invariant@1.3.3:
+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ to-regex-range@5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+
+ toidentifier@1.0.1:
+ resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+ engines: {node: '>=0.6'}
+
+ ts-api-utils@2.5.0:
+ resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
+ engines: {node: '>=18.12'}
+ peerDependencies:
+ typescript: '>=4.8.4'
+
+ ts-morph@26.0.0:
+ resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==}
+
+ tsconfig-paths@3.15.0:
+ resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
+
+ tsconfig-paths@4.2.0:
+ resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
+ engines: {node: '>=6'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ tw-animate-css@1.4.0:
+ resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
+
+ type-check@0.4.0:
+ resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+ engines: {node: '>= 0.8.0'}
+
+ type-is@2.1.0:
+ resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
+ engines: {node: '>= 18'}
+
+ typed-array-buffer@1.0.3:
+ resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-byte-length@1.0.3:
+ resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-byte-offset@1.0.4:
+ resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-length@1.0.8:
+ resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
+ engines: {node: '>= 0.4'}
+
+ typescript-eslint@8.65.0:
+ resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ unbox-primitive@1.1.0:
+ resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
+ engines: {node: '>= 0.4'}
+
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+ undici@7.29.0:
+ resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
+ engines: {node: '>=20.18.1'}
+
+ unicorn-magic@0.3.0:
+ resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
+ engines: {node: '>=18'}
+
+ universalify@2.0.1:
+ resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
+ engines: {node: '>= 10.0.0'}
+
+ unpipe@1.0.0:
+ resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+ engines: {node: '>= 0.8'}
+
+ unrs-resolver@1.12.2:
+ resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
+
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ uri-js@4.4.1:
+ resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+ use-sync-external-store@1.6.0:
+ resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ validate-npm-package-name@7.0.2:
+ resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+
+ vary@1.1.2:
+ resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+ engines: {node: '>= 0.8'}
+
+ which-boxed-primitive@1.1.1:
+ resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
+ engines: {node: '>= 0.4'}
+
+ which-builtin-type@1.2.1:
+ resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
+ engines: {node: '>= 0.4'}
+
+ which-collection@1.0.2:
+ resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
+ engines: {node: '>= 0.4'}
+
+ which-typed-array@1.1.22:
+ resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
+ engines: {node: '>= 0.4'}
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ which@4.0.0:
+ resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
+ engines: {node: ^16.13.0 || >=18.0.0}
+ hasBin: true
+
+ word-wrap@1.2.5:
+ resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+ engines: {node: '>=0.10.0'}
+
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+ wsl-utils@0.3.1:
+ resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
+ engines: {node: '>=20'}
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+ yocto-queue@0.1.0:
+ resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+ engines: {node: '>=10'}
+
+ yocto-spinner@1.2.2:
+ resolution: {integrity: sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==}
+ engines: {node: '>=18.19'}
+
+ yoctocolors@2.2.0:
+ resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==}
+ engines: {node: '>=18'}
+
+ zod-to-json-schema@3.25.2:
+ resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
+ peerDependencies:
+ zod: ^3.25.28 || ^4
+
+ zod-validation-error@4.0.2:
+ resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+
+ zod@3.25.76:
+ resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+
+ zod@4.4.3:
+ resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
+
+snapshots:
+
+ '@alloc/quick-lru@5.2.0': {}
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.29.7': {}
+
+ '@babel/core@7.29.7(supports-color@7.2.0)':
+ 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/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/helpers': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/template': 7.29.7
+ '@babel/traverse': 7.29.7(supports-color@7.2.0)
+ '@babel/types': 7.29.7
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3(supports-color@7.2.0)
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.7':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-annotate-as-pure@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@babel/helper-compilation-targets@7.29.7':
+ dependencies:
+ '@babel/compat-data': 7.29.7
+ '@babel/helper-validator-option': 7.29.7
+ browserslist: 4.28.7
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)':
+ dependencies:
+ '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/helper-annotate-as-pure': 7.29.7
+ '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0)
+ '@babel/helper-optimise-call-expression': 7.29.7
+ '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0)
+ '@babel/traverse': 7.29.7(supports-color@7.2.0)
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-globals@7.29.7': {}
+
+ '@babel/helper-member-expression-to-functions@7.29.7(supports-color@7.2.0)':
+ dependencies:
+ '@babel/traverse': 7.29.7(supports-color@7.2.0)
+ '@babel/types': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)':
+ dependencies:
+ '@babel/traverse': 7.29.7(supports-color@7.2.0)
+ '@babel/types': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)':
+ dependencies:
+ '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0)
+ '@babel/helper-validator-identifier': 7.29.7
+ '@babel/traverse': 7.29.7(supports-color@7.2.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-optimise-call-expression@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@babel/helper-plugin-utils@7.29.7': {}
+
+ '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)':
+ dependencies:
+ '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0)
+ '@babel/helper-optimise-call-expression': 7.29.7
+ '@babel/traverse': 7.29.7(supports-color@7.2.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@7.2.0)':
+ dependencies:
+ '@babel/traverse': 7.29.7(supports-color@7.2.0)
+ '@babel/types': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/helper-validator-option@7.29.7': {}
+
+ '@babel/helpers@7.29.7':
+ dependencies:
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@babel/parser@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))':
+ dependencies:
+ '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))':
+ dependencies:
+ '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)':
+ dependencies:
+ '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/helper-plugin-utils': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)':
+ dependencies:
+ '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/helper-annotate-as-pure': 7.29.7
+ '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/helper-plugin-utils': 7.29.7
+ '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0)
+ '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/preset-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)':
+ dependencies:
+ '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/helper-plugin-utils': 7.29.7
+ '@babel/helper-validator-option': 7.29.7
+ '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))
+ '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/runtime@7.29.7': {}
+
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@babel/traverse@7.29.7(supports-color@7.2.0)':
+ 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.4.3(supports-color@7.2.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.7':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@base-ui/react@1.6.0(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@base-ui/utils': 0.3.1(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@floating-ui/react-dom': 2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@floating-ui/utils': 0.2.12
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ use-sync-external-store: 1.6.0(react@19.2.4)
+ optionalDependencies:
+ '@types/react': 19.2.17
+
+ '@base-ui/utils@0.3.1(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@floating-ui/utils': 0.2.12
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ reselect: 5.2.0
+ use-sync-external-store: 1.6.0(react@19.2.4)
+ optionalDependencies:
+ '@types/react': 19.2.17
+
+ '@dotenvx/dotenvx@1.75.1':
+ dependencies:
+ '@dotenvx/primitives': 0.8.0
+ commander: 11.1.0
+ conf: 10.2.0
+ dotenv: 17.4.2
+ enquirer: 2.4.1
+ env-paths: 2.2.1
+ execa: 5.1.1
+ fdir: 6.5.0(picomatch@4.0.5)
+ ignore: 5.3.2
+ object-treeify: 1.1.33
+ open: 8.4.2
+ picomatch: 4.0.5
+ systeminformation: 5.33.1
+ undici: 7.29.0
+ which: 4.0.0
+ yocto-spinner: 1.2.2
+
+ '@dotenvx/primitives@0.8.0': {}
+
+ '@emnapi/core@1.10.0':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.1
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.10.0':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.11.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))':
+ dependencies:
+ eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.2': {}
+
+ '@eslint/config-array@0.21.2(supports-color@7.2.0)':
+ dependencies:
+ '@eslint/object-schema': 2.1.7
+ debug: 4.4.3(supports-color@7.2.0)
+ minimatch: 3.1.5
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/config-helpers@0.4.2':
+ dependencies:
+ '@eslint/core': 0.17.0
+
+ '@eslint/core@0.17.0':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/eslintrc@3.3.6(supports-color@7.2.0)':
+ dependencies:
+ ajv: 6.15.0
+ debug: 4.4.3(supports-color@7.2.0)
+ espree: 10.4.0
+ globals: 14.0.0
+ ignore: 5.3.2
+ import-fresh: 3.3.1
+ js-yaml: 4.3.0
+ minimatch: 3.1.5
+ strip-json-comments: 3.1.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/js@9.39.5': {}
+
+ '@eslint/object-schema@2.1.7': {}
+
+ '@eslint/plugin-kit@0.4.1':
+ dependencies:
+ '@eslint/core': 0.17.0
+ levn: 0.4.1
+
+ '@floating-ui/core@1.8.0':
+ dependencies:
+ '@floating-ui/utils': 0.2.12
+
+ '@floating-ui/dom@1.8.0':
+ dependencies:
+ '@floating-ui/core': 1.8.0
+ '@floating-ui/utils': 0.2.12
+
+ '@floating-ui/react-dom@2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@floating-ui/dom': 1.8.0
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
+ '@floating-ui/utils@0.2.12': {}
+
+ '@hono/node-server@1.19.15(hono@4.12.32)':
+ dependencies:
+ hono: 4.12.32
+
+ '@humanfs/core@0.19.2':
+ dependencies:
+ '@humanfs/types': 0.15.0
+
+ '@humanfs/node@0.16.8':
+ dependencies:
+ '@humanfs/core': 0.19.2
+ '@humanfs/types': 0.15.0
+ '@humanwhocodes/retry': 0.4.3
+
+ '@humanfs/types@0.15.0': {}
+
+ '@humanwhocodes/module-importer@1.0.1': {}
+
+ '@humanwhocodes/retry@0.4.3': {}
+
+ '@img/colour@1.1.0':
+ optional: true
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-darwin-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-arm@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-s390x@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-wasm32@0.34.5':
+ dependencies:
+ '@emnapi/runtime': 1.11.3
+ optional: true
+
+ '@img/sharp-win32-arm64@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-ia32@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-x64@0.34.5':
+ optional: true
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76)':
+ dependencies:
+ '@hono/node-server': 1.19.15(hono@4.12.32)
+ ajv: 8.20.0
+ ajv-formats: 3.0.1(ajv@8.20.0)
+ content-type: 1.0.5
+ cors: 2.8.6
+ cross-spawn: 7.0.6
+ eventsource: 3.0.7
+ eventsource-parser: 3.1.0
+ express: 5.2.1(supports-color@7.2.0)
+ express-rate-limit: 8.6.0(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0)
+ hono: 4.12.32
+ jose: 6.2.4
+ json-schema-typed: 8.0.2
+ pkce-challenge: 5.0.1
+ raw-body: 3.0.2
+ zod: 3.25.76
+ zod-to-json-schema: 3.25.2(zod@3.25.76)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@tybys/wasm-util': 0.10.3
+ optional: true
+
+ '@next/env@16.2.12': {}
+
+ '@next/eslint-plugin-next@16.2.12':
+ dependencies:
+ fast-glob: 3.3.1
+
+ '@next/swc-darwin-arm64@16.2.12':
+ optional: true
+
+ '@next/swc-darwin-x64@16.2.12':
+ optional: true
+
+ '@next/swc-linux-arm64-gnu@16.2.12':
+ optional: true
+
+ '@next/swc-linux-arm64-musl@16.2.12':
+ optional: true
+
+ '@next/swc-linux-x64-gnu@16.2.12':
+ optional: true
+
+ '@next/swc-linux-x64-musl@16.2.12':
+ optional: true
+
+ '@next/swc-win32-arm64-msvc@16.2.12':
+ optional: true
+
+ '@next/swc-win32-x64-msvc@16.2.12':
+ optional: true
+
+ '@nodelib/fs.scandir@2.1.5':
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ run-parallel: 1.2.0
+
+ '@nodelib/fs.stat@2.0.5': {}
+
+ '@nodelib/fs.walk@1.2.8':
+ dependencies:
+ '@nodelib/fs.scandir': 2.1.5
+ fastq: 1.20.1
+
+ '@nolyfill/is-core-module@1.0.39': {}
+
+ '@rtsao/scc@1.1.0': {}
+
+ '@sec-ant/readable-stream@0.4.1': {}
+
+ '@sindresorhus/merge-streams@4.0.0': {}
+
+ '@swc/helpers@0.5.15':
+ dependencies:
+ tslib: 2.8.1
+
+ '@tailwindcss/node@4.3.3':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.24.3
+ jiti: 2.7.0
+ lightningcss: 1.32.0
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.3.3
+
+ '@tailwindcss/oxide-android-arm64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide@4.3.3':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.3.3
+ '@tailwindcss/oxide-darwin-arm64': 4.3.3
+ '@tailwindcss/oxide-darwin-x64': 4.3.3
+ '@tailwindcss/oxide-freebsd-x64': 4.3.3
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
+ '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
+ '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
+ '@tailwindcss/oxide-linux-x64-musl': 4.3.3
+ '@tailwindcss/oxide-wasm32-wasi': 4.3.3
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
+ '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
+
+ '@tailwindcss/postcss@4.3.3':
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ '@tailwindcss/node': 4.3.3
+ '@tailwindcss/oxide': 4.3.3
+ postcss: 8.5.23
+ tailwindcss: 4.3.3
+
+ '@ts-morph/common@0.27.0':
+ dependencies:
+ fast-glob: 3.3.3
+ minimatch: 10.2.5
+ path-browserify: 1.0.1
+
+ '@tybys/wasm-util@0.10.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@types/estree@1.0.9': {}
+
+ '@types/json-schema@7.0.15': {}
+
+ '@types/json5@0.0.29': {}
+
+ '@types/node@20.19.43':
+ dependencies:
+ undici-types: 6.21.0
+
+ '@types/react-dom@19.2.3(@types/react@19.2.17)':
+ dependencies:
+ '@types/react': 19.2.17
+
+ '@types/react@19.2.17':
+ dependencies:
+ csstype: 3.2.3
+
+ '@types/validate-npm-package-name@4.0.2': {}
+
+ '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.65.0
+ '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.65.0
+ eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
+ ignore: 7.0.6
+ natural-compare: 1.4.0
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.65.0
+ '@typescript-eslint/types': 8.65.0
+ '@typescript-eslint/typescript-estree': 8.65.0(supports-color@7.2.0)(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.65.0
+ debug: 4.4.3(supports-color@7.2.0)
+ eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.65.0(supports-color@7.2.0)(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.65.0
+ debug: 4.4.3(supports-color@7.2.0)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.65.0':
+ dependencies:
+ '@typescript-eslint/types': 8.65.0
+ '@typescript-eslint/visitor-keys': 8.65.0
+
+ '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/type-utils@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.65.0
+ '@typescript-eslint/typescript-estree': 8.65.0(supports-color@7.2.0)(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ debug: 4.4.3(supports-color@7.2.0)
+ eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.65.0': {}
+
+ '@typescript-eslint/typescript-estree@8.65.0(supports-color@7.2.0)(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.65.0(supports-color@7.2.0)(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.65.0
+ '@typescript-eslint/visitor-keys': 8.65.0
+ debug: 4.4.3(supports-color@7.2.0)
+ minimatch: 10.2.5
+ semver: 7.8.5
+ tinyglobby: 0.2.17
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
+ '@typescript-eslint/scope-manager': 8.65.0
+ '@typescript-eslint/types': 8.65.0
+ '@typescript-eslint/typescript-estree': 8.65.0(supports-color@7.2.0)(typescript@5.9.3)
+ eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.65.0':
+ dependencies:
+ '@typescript-eslint/types': 8.65.0
+ eslint-visitor-keys: 5.0.1
+
+ '@unrs/resolver-binding-android-arm-eabi@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-android-arm64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-darwin-arm64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-darwin-x64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-freebsd-x64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-x64-musl@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-openharmony-arm64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-wasm32-wasi@1.12.2':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ optional: true
+
+ '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
+ optional: true
+
+ accepts@2.0.0:
+ dependencies:
+ mime-types: 3.0.2
+ negotiator: 1.0.0
+
+ acorn-jsx@5.3.2(acorn@8.17.0):
+ dependencies:
+ acorn: 8.17.0
+
+ acorn@8.17.0: {}
+
+ ajv-formats@2.1.1(ajv@8.20.0):
+ optionalDependencies:
+ ajv: 8.20.0
+
+ ajv-formats@3.0.1(ajv@8.20.0):
+ optionalDependencies:
+ ajv: 8.20.0
+
+ ajv@6.15.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-json-stable-stringify: 2.1.0
+ json-schema-traverse: 0.4.1
+ uri-js: 4.4.1
+
+ ajv@8.20.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-uri: 3.1.4
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+
+ ansi-colors@4.1.3: {}
+
+ ansi-regex@5.0.1: {}
+
+ ansi-regex@6.2.2: {}
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
+ argparse@2.0.1: {}
+
+ aria-query@5.3.2: {}
+
+ array-buffer-byte-length@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ is-array-buffer: 3.0.5
+
+ array-includes@3.1.9:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ is-string: 1.1.1
+ math-intrinsics: 1.1.0
+
+ array.prototype.findlast@1.2.5:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.findlastindex@1.2.6:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.flat@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.flatmap@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.tosorted@1.1.4:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-shim-unscopables: 1.1.0
+
+ arraybuffer.prototype.slice@1.0.4:
+ dependencies:
+ array-buffer-byte-length: 1.0.2
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ is-array-buffer: 3.0.5
+
+ ast-types-flow@0.0.8: {}
+
+ ast-types@0.16.1:
+ dependencies:
+ tslib: 2.8.1
+
+ async-function@1.0.0: {}
+
+ atomically@1.7.0: {}
+
+ available-typed-arrays@1.0.7:
+ dependencies:
+ possible-typed-array-names: 1.1.0
+
+ axe-core@4.12.1: {}
+
+ axobject-query@4.1.0: {}
+
+ balanced-match@1.0.2: {}
+
+ balanced-match@4.0.4: {}
+
+ baseline-browser-mapping@2.11.4: {}
+
+ body-parser@2.3.0(supports-color@7.2.0):
+ dependencies:
+ bytes: 3.1.2
+ content-type: 2.0.0
+ debug: 4.4.3(supports-color@7.2.0)
+ http-errors: 2.0.1
+ iconv-lite: 0.7.3
+ on-finished: 2.4.1
+ qs: 6.15.3
+ raw-body: 3.0.2
+ type-is: 2.1.0
+ transitivePeerDependencies:
+ - supports-color
+
+ brace-expansion@1.1.16:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ brace-expansion@5.0.8:
+ dependencies:
+ balanced-match: 4.0.4
+
+ braces@3.0.3:
+ dependencies:
+ fill-range: 7.1.1
+
+ browserslist@4.28.7:
+ dependencies:
+ baseline-browser-mapping: 2.11.4
+ caniuse-lite: 1.0.30001806
+ electron-to-chromium: 1.5.396
+ node-releases: 2.0.51
+ update-browserslist-db: 1.2.3(browserslist@4.28.7)
+
+ bundle-name@4.1.0:
+ dependencies:
+ run-applescript: 7.1.0
+
+ bytes@3.1.2: {}
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ call-bind@1.0.9:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ get-intrinsic: 1.3.0
+ set-function-length: 1.2.2
+
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+
+ callsites@3.1.0: {}
+
+ caniuse-lite@1.0.30001806: {}
+
+ chalk@4.1.2:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
+ chalk@5.6.2: {}
+
+ class-variance-authority@0.7.1:
+ dependencies:
+ clsx: 2.1.1
+
+ cli-cursor@5.0.0:
+ dependencies:
+ restore-cursor: 5.1.0
+
+ cli-spinners@2.9.2: {}
+
+ client-only@0.0.1: {}
+
+ clsx@2.1.1: {}
+
+ code-block-writer@13.0.3: {}
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ commander@11.1.0: {}
+
+ commander@14.0.3: {}
+
+ concat-map@0.0.1: {}
+
+ conf@10.2.0:
+ dependencies:
+ ajv: 8.20.0
+ ajv-formats: 2.1.1(ajv@8.20.0)
+ atomically: 1.7.0
+ debounce-fn: 4.0.0
+ dot-prop: 6.0.1
+ env-paths: 2.2.1
+ json-schema-typed: 7.0.3
+ onetime: 5.1.2
+ pkg-up: 3.1.0
+ semver: 7.8.5
+
+ content-disposition@1.1.0: {}
+
+ content-type@1.0.5: {}
+
+ content-type@2.0.0: {}
+
+ convert-source-map@2.0.0: {}
+
+ cookie-signature@1.2.2: {}
+
+ cookie@0.7.2: {}
+
+ cors@2.8.6:
+ dependencies:
+ object-assign: 4.1.1
+ vary: 1.1.2
+
+ cosmiconfig@9.0.2(typescript@5.9.3):
+ dependencies:
+ env-paths: 2.2.1
+ import-fresh: 3.3.1
+ js-yaml: 4.3.0
+ parse-json: 5.2.0
+ optionalDependencies:
+ typescript: 5.9.3
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ cssesc@3.0.0: {}
+
+ csstype@3.2.3: {}
+
+ damerau-levenshtein@1.0.8: {}
+
+ data-view-buffer@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ data-view-byte-length@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ data-view-byte-offset@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ debounce-fn@4.0.0:
+ dependencies:
+ mimic-fn: 3.1.0
+
+ debug@3.2.7(supports-color@7.2.0):
+ dependencies:
+ ms: 2.1.3
+ optionalDependencies:
+ supports-color: 7.2.0
+
+ debug@4.4.3(supports-color@7.2.0):
+ dependencies:
+ ms: 2.1.3
+ optionalDependencies:
+ supports-color: 7.2.0
+
+ dedent@1.7.2: {}
+
+ deep-is@0.1.4: {}
+
+ deepmerge@4.3.1: {}
+
+ default-browser-id@5.0.1: {}
+
+ default-browser@5.5.0:
+ dependencies:
+ bundle-name: 4.1.0
+ default-browser-id: 5.0.1
+
+ define-data-property@1.1.4:
+ dependencies:
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ define-lazy-prop@2.0.0: {}
+
+ define-lazy-prop@3.0.0: {}
+
+ define-properties@1.2.1:
+ dependencies:
+ define-data-property: 1.1.4
+ has-property-descriptors: 1.0.2
+ object-keys: 1.1.1
+
+ depd@2.0.0: {}
+
+ detect-libc@2.1.2: {}
+
+ diff@8.0.4: {}
+
+ doctrine@2.1.0:
+ dependencies:
+ esutils: 2.0.3
+
+ dot-prop@6.0.1:
+ dependencies:
+ is-obj: 2.0.0
+
+ dotenv@17.4.2: {}
+
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ ee-first@1.1.1: {}
+
+ electron-to-chromium@1.5.396: {}
+
+ emoji-regex@10.6.0: {}
+
+ emoji-regex@9.2.2: {}
+
+ encodeurl@2.0.0: {}
+
+ enhanced-resolve@5.24.3:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.3
+
+ enquirer@2.4.1:
+ dependencies:
+ ansi-colors: 4.1.3
+ strip-ansi: 6.0.1
+
+ env-paths@2.2.1: {}
+
+ error-ex@1.3.4:
+ dependencies:
+ is-arrayish: 0.2.1
+
+ es-abstract-get@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ is-callable: 1.2.7
+ object-inspect: 1.13.4
+
+ es-abstract@1.24.2:
+ dependencies:
+ array-buffer-byte-length: 1.0.2
+ arraybuffer.prototype.slice: 1.0.4
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ data-view-buffer: 1.0.2
+ data-view-byte-length: 1.0.2
+ data-view-byte-offset: 1.0.1
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ es-set-tostringtag: 2.1.0
+ es-to-primitive: 1.3.4
+ function.prototype.name: 1.2.0
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ get-symbol-description: 1.1.0
+ globalthis: 1.0.4
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+ has-proto: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.4
+ internal-slot: 1.1.0
+ is-array-buffer: 3.0.5
+ is-callable: 1.2.7
+ is-data-view: 1.0.2
+ is-negative-zero: 2.0.3
+ is-regex: 1.2.1
+ is-set: 2.0.3
+ is-shared-array-buffer: 1.0.4
+ is-string: 1.1.1
+ is-typed-array: 1.1.15
+ is-weakref: 1.1.1
+ math-intrinsics: 1.1.0
+ object-inspect: 1.13.4
+ object-keys: 1.1.1
+ object.assign: 4.1.7
+ own-keys: 1.0.2
+ regexp.prototype.flags: 1.5.4
+ safe-array-concat: 1.1.4
+ safe-push-apply: 1.0.0
+ safe-regex-test: 1.1.0
+ set-proto: 1.0.0
+ stop-iteration-iterator: 1.1.0
+ string.prototype.trim: 1.2.11
+ string.prototype.trimend: 1.0.10
+ string.prototype.trimstart: 1.0.8
+ typed-array-buffer: 1.0.3
+ typed-array-byte-length: 1.0.3
+ typed-array-byte-offset: 1.0.4
+ typed-array-length: 1.0.8
+ unbox-primitive: 1.1.0
+ which-typed-array: 1.1.22
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-iterator-helpers@1.4.0:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-set-tostringtag: 2.1.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ globalthis: 1.0.4
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+ has-proto: 1.2.0
+ has-symbols: 1.1.0
+ internal-slot: 1.1.0
+ iterator.prototype: 1.1.5
+ math-intrinsics: 1.1.0
+
+ es-object-atoms@1.1.2:
+ dependencies:
+ es-errors: 1.3.0
+
+ es-set-tostringtag@2.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.4
+
+ es-shim-unscopables@1.1.0:
+ dependencies:
+ hasown: 2.0.4
+
+ es-to-primitive@1.3.4:
+ dependencies:
+ es-abstract-get: 1.0.0
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ is-callable: 1.2.7
+ is-date-object: 1.1.0
+ is-symbol: 1.1.1
+
+ escalade@3.2.0: {}
+
+ escape-html@1.0.3: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ eslint-config-next@16.2.12(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3):
+ dependencies:
+ '@next/eslint-plugin-next': 16.2.12
+ eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
+ eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
+ eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
+ eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
+ eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
+ globals: 16.4.0
+ typescript-eslint: 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - '@typescript-eslint/parser'
+ - eslint-import-resolver-webpack
+ - eslint-plugin-import-x
+ - supports-color
+
+ eslint-import-resolver-node@0.3.10(supports-color@7.2.0):
+ dependencies:
+ debug: 3.2.7(supports-color@7.2.0)
+ is-core-module: 2.16.2
+ resolve: 2.0.0-next.7
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
+ dependencies:
+ '@nolyfill/is-core-module': 1.0.39
+ debug: 4.4.3(supports-color@7.2.0)
+ eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
+ get-tsconfig: 4.14.0
+ is-bun-module: 2.0.0
+ stable-hash: 0.0.5
+ tinyglobby: 0.2.17
+ unrs-resolver: 1.12.2
+ optionalDependencies:
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-module-utils@2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
+ dependencies:
+ debug: 3.2.7(supports-color@7.2.0)
+ optionalDependencies:
+ '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
+ eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
+ dependencies:
+ '@rtsao/scc': 1.1.0
+ array-includes: 3.1.9
+ array.prototype.findlastindex: 1.2.6
+ array.prototype.flat: 1.3.3
+ array.prototype.flatmap: 1.3.3
+ debug: 3.2.7(supports-color@7.2.0)
+ doctrine: 2.1.0
+ eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
+ eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
+ eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
+ hasown: 2.0.4
+ is-core-module: 2.16.2
+ is-glob: 4.0.3
+ minimatch: 3.1.5
+ object.fromentries: 2.0.8
+ object.groupby: 1.0.3
+ object.values: 1.2.1
+ semver: 6.3.1
+ string.prototype.trimend: 1.0.10
+ tsconfig-paths: 3.15.0
+ optionalDependencies:
+ '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ transitivePeerDependencies:
+ - eslint-import-resolver-typescript
+ - eslint-import-resolver-webpack
+ - supports-color
+
+ eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)):
+ dependencies:
+ aria-query: 5.3.2
+ array-includes: 3.1.9
+ array.prototype.flatmap: 1.3.3
+ ast-types-flow: 0.0.8
+ axe-core: 4.12.1
+ axobject-query: 4.1.0
+ damerau-levenshtein: 1.0.8
+ emoji-regex: 9.2.2
+ eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
+ hasown: 2.0.4
+ jsx-ast-utils: 3.3.5
+ language-tags: 1.0.9
+ minimatch: 3.1.5
+ object.fromentries: 2.0.8
+ safe-regex-test: 1.1.0
+ string.prototype.includes: 2.0.1
+
+ eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
+ dependencies:
+ '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/parser': 7.29.7
+ eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
+ hermes-parser: 0.25.1
+ zod: 4.4.3
+ zod-validation-error: 4.0.2(zod@4.4.3)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)):
+ dependencies:
+ array-includes: 3.1.9
+ array.prototype.findlast: 1.2.5
+ array.prototype.flatmap: 1.3.3
+ array.prototype.tosorted: 1.1.4
+ doctrine: 2.1.0
+ es-iterator-helpers: 1.4.0
+ eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
+ estraverse: 5.3.0
+ hasown: 2.0.4
+ jsx-ast-utils: 3.3.5
+ minimatch: 3.1.5
+ object.entries: 1.1.9
+ object.fromentries: 2.0.8
+ object.values: 1.2.1
+ prop-types: 15.8.1
+ resolve: 2.0.0-next.7
+ semver: 6.3.1
+ string.prototype.matchall: 4.0.12
+ string.prototype.repeat: 1.0.0
+
+ eslint-scope@8.4.0:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@4.2.1: {}
+
+ eslint-visitor-keys@5.0.1: {}
+
+ eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.21.2(supports-color@7.2.0)
+ '@eslint/config-helpers': 0.4.2
+ '@eslint/core': 0.17.0
+ '@eslint/eslintrc': 3.3.6(supports-color@7.2.0)
+ '@eslint/js': 9.39.5
+ '@eslint/plugin-kit': 0.4.1
+ '@humanfs/node': 0.16.8
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.9
+ ajv: 6.15.0
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3(supports-color@7.2.0)
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.0
+ esquery: 1.7.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ 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.4
+ optionalDependencies:
+ jiti: 2.7.0
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@10.4.0:
+ dependencies:
+ acorn: 8.17.0
+ acorn-jsx: 5.3.2(acorn@8.17.0)
+ eslint-visitor-keys: 4.2.1
+
+ esprima@4.0.1: {}
+
+ esquery@1.7.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ esrecurse@4.3.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ estraverse@5.3.0: {}
+
+ esutils@2.0.3: {}
+
+ etag@1.8.1: {}
+
+ eventsource-parser@3.1.0: {}
+
+ eventsource@3.0.7:
+ dependencies:
+ eventsource-parser: 3.1.0
+
+ execa@5.1.1:
+ dependencies:
+ cross-spawn: 7.0.6
+ get-stream: 6.0.1
+ human-signals: 2.1.0
+ is-stream: 2.0.1
+ merge-stream: 2.0.0
+ npm-run-path: 4.0.1
+ onetime: 5.1.2
+ signal-exit: 3.0.7
+ strip-final-newline: 2.0.0
+
+ execa@9.6.1:
+ dependencies:
+ '@sindresorhus/merge-streams': 4.0.0
+ cross-spawn: 7.0.6
+ figures: 6.1.0
+ get-stream: 9.0.1
+ human-signals: 8.0.1
+ is-plain-obj: 4.1.0
+ is-stream: 4.0.1
+ npm-run-path: 6.0.0
+ pretty-ms: 9.3.0
+ signal-exit: 4.1.0
+ strip-final-newline: 4.0.0
+ yoctocolors: 2.2.0
+
+ express-rate-limit@8.6.0(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0):
+ dependencies:
+ debug: 4.4.3(supports-color@7.2.0)
+ express: 5.2.1(supports-color@7.2.0)
+ ip-address: 10.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ express@5.2.1(supports-color@7.2.0):
+ dependencies:
+ accepts: 2.0.0
+ body-parser: 2.3.0(supports-color@7.2.0)
+ content-disposition: 1.1.0
+ content-type: 1.0.5
+ cookie: 0.7.2
+ cookie-signature: 1.2.2
+ debug: 4.4.3(supports-color@7.2.0)
+ depd: 2.0.0
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ finalhandler: 2.1.1(supports-color@7.2.0)
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ merge-descriptors: 2.0.0
+ mime-types: 3.0.2
+ on-finished: 2.4.1
+ once: 1.4.0
+ parseurl: 1.3.3
+ proxy-addr: 2.0.7
+ qs: 6.15.3
+ range-parser: 1.3.0
+ router: 2.2.0(supports-color@7.2.0)
+ send: 1.2.1(supports-color@7.2.0)
+ serve-static: 2.2.1(supports-color@7.2.0)
+ statuses: 2.0.2
+ type-is: 2.1.0
+ vary: 1.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-glob@3.3.1:
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ '@nodelib/fs.walk': 1.2.8
+ glob-parent: 5.1.2
+ merge2: 1.4.1
+ micromatch: 4.0.8
+
+ fast-glob@3.3.3:
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ '@nodelib/fs.walk': 1.2.8
+ glob-parent: 5.1.2
+ merge2: 1.4.1
+ micromatch: 4.0.8
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
+ fast-uri@3.1.4: {}
+
+ fastq@1.20.1:
+ dependencies:
+ reusify: 1.1.0
+
+ fdir@6.5.0(picomatch@4.0.5):
+ optionalDependencies:
+ picomatch: 4.0.5
+
+ figures@6.1.0:
+ dependencies:
+ is-unicode-supported: 2.1.0
+
+ file-entry-cache@8.0.0:
+ dependencies:
+ flat-cache: 4.0.1
+
+ fill-range@7.1.1:
+ dependencies:
+ to-regex-range: 5.0.1
+
+ finalhandler@2.1.1(supports-color@7.2.0):
+ dependencies:
+ debug: 4.4.3(supports-color@7.2.0)
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ on-finished: 2.4.1
+ parseurl: 1.3.3
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ find-up@3.0.0:
+ dependencies:
+ locate-path: 3.0.0
+
+ find-up@5.0.0:
+ dependencies:
+ locate-path: 6.0.0
+ path-exists: 4.0.0
+
+ flat-cache@4.0.1:
+ dependencies:
+ flatted: 3.4.3
+ keyv: 4.5.4
+
+ flatted@3.4.3: {}
+
+ for-each@0.3.5:
+ dependencies:
+ is-callable: 1.2.7
+
+ forwarded@0.2.0: {}
+
+ fresh@2.0.0: {}
+
+ fs-extra@11.4.0:
+ dependencies:
+ graceful-fs: 4.2.11
+ jsonfile: 6.2.1
+ universalify: 2.0.1
+
+ function-bind@1.1.2: {}
+
+ function.prototype.name@1.2.0:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.2
+ hasown: 2.0.4
+ is-callable: 1.2.7
+ is-document.all: 1.0.0
+
+ functions-have-names@1.2.3: {}
+
+ fuzzysort@3.1.0: {}
+
+ generator-function@2.0.1: {}
+
+ gensync@1.0.0-beta.2: {}
+
+ get-east-asian-width@1.6.0: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.4
+ math-intrinsics: 1.1.0
+
+ get-own-enumerable-keys@1.0.0: {}
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.2
+
+ get-stream@6.0.1: {}
+
+ get-stream@9.0.1:
+ dependencies:
+ '@sec-ant/readable-stream': 0.4.1
+ is-stream: 4.0.1
+
+ get-symbol-description@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+
+ get-tsconfig@4.14.0:
+ dependencies:
+ resolve-pkg-maps: 1.0.0
+
+ glob-parent@5.1.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ globals@14.0.0: {}
+
+ globals@16.4.0: {}
+
+ globalthis@1.0.4:
+ dependencies:
+ define-properties: 1.2.1
+ gopd: 1.2.0
+
+ gopd@1.2.0: {}
+
+ graceful-fs@4.2.11: {}
+
+ has-bigints@1.1.0: {}
+
+ has-flag@4.0.0: {}
+
+ has-property-descriptors@1.0.2:
+ dependencies:
+ es-define-property: 1.0.1
+
+ has-proto@1.2.0:
+ dependencies:
+ dunder-proto: 1.0.1
+
+ has-symbols@1.1.0: {}
+
+ has-tostringtag@1.0.2:
+ dependencies:
+ has-symbols: 1.1.0
+
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
+ hermes-estree@0.25.1: {}
+
+ hermes-parser@0.25.1:
+ dependencies:
+ hermes-estree: 0.25.1
+
+ hono@4.12.32: {}
+
+ http-errors@2.0.1:
+ dependencies:
+ depd: 2.0.0
+ inherits: 2.0.4
+ setprototypeof: 1.2.0
+ statuses: 2.0.2
+ toidentifier: 1.0.1
+
+ human-signals@2.1.0: {}
+
+ human-signals@8.0.1: {}
+
+ iconv-lite@0.7.3:
+ dependencies:
+ safer-buffer: 2.1.2
+
+ ignore@5.3.2: {}
+
+ ignore@7.0.6: {}
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ imurmurhash@0.1.4: {}
+
+ inherits@2.0.4: {}
+
+ internal-slot@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ hasown: 2.0.4
+ side-channel: 1.1.1
+
+ ip-address@10.3.1: {}
+
+ ipaddr.js@1.9.1: {}
+
+ is-array-buffer@3.0.5:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+ is-arrayish@0.2.1: {}
+
+ is-async-function@2.1.1:
+ dependencies:
+ async-function: 1.0.0
+ call-bound: 1.0.4
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-bigint@1.1.0:
+ dependencies:
+ has-bigints: 1.1.0
+
+ is-boolean-object@1.2.2:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-bun-module@2.0.0:
+ dependencies:
+ semver: 7.8.5
+
+ is-callable@1.2.7: {}
+
+ is-core-module@2.16.2:
+ dependencies:
+ hasown: 2.0.4
+
+ is-data-view@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ is-typed-array: 1.1.15
+
+ is-date-object@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-docker@2.2.1: {}
+
+ is-docker@3.0.0: {}
+
+ is-document.all@1.0.0:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-extglob@2.1.1: {}
+
+ is-finalizationregistry@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-generator-function@1.1.2:
+ dependencies:
+ call-bound: 1.0.4
+ generator-function: 2.0.1
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ is-in-ssh@1.0.0: {}
+
+ is-inside-container@1.0.0:
+ dependencies:
+ is-docker: 3.0.0
+
+ is-interactive@2.0.0: {}
+
+ is-map@2.0.3: {}
+
+ is-negative-zero@2.0.3: {}
+
+ is-number-object@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-number@7.0.0: {}
+
+ is-obj@2.0.0: {}
+
+ is-obj@3.0.0: {}
+
+ is-plain-obj@4.1.0: {}
+
+ is-promise@4.0.0: {}
+
+ is-regex@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.4
+
+ is-regexp@3.1.0: {}
+
+ is-set@2.0.3: {}
+
+ is-shared-array-buffer@1.0.4:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-stream@2.0.1: {}
+
+ is-stream@4.0.1: {}
+
+ is-string@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-symbol@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-symbols: 1.1.0
+ safe-regex-test: 1.1.0
+
+ is-typed-array@1.1.15:
+ dependencies:
+ which-typed-array: 1.1.22
+
+ is-unicode-supported@1.3.0: {}
+
+ is-unicode-supported@2.1.0: {}
+
+ is-weakmap@2.0.2: {}
+
+ is-weakref@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-weakset@2.0.4:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+ is-wsl@2.2.0:
+ dependencies:
+ is-docker: 2.2.1
+
+ is-wsl@3.1.1:
+ dependencies:
+ is-inside-container: 1.0.0
+
+ isarray@2.0.5: {}
+
+ isexe@2.0.0: {}
+
+ isexe@3.1.5: {}
+
+ iterator.prototype@1.1.5:
+ dependencies:
+ define-data-property: 1.1.4
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ has-symbols: 1.1.0
+ set-function-name: 2.0.2
+
+ jiti@2.7.0: {}
+
+ jose@6.2.4: {}
+
+ js-tokens@4.0.0: {}
+
+ js-yaml@4.3.0:
+ dependencies:
+ argparse: 2.0.1
+
+ jsesc@3.1.0: {}
+
+ json-buffer@3.0.1: {}
+
+ json-parse-even-better-errors@2.3.1: {}
+
+ json-schema-traverse@0.4.1: {}
+
+ json-schema-traverse@1.0.0: {}
+
+ json-schema-typed@7.0.3: {}
+
+ json-schema-typed@8.0.2: {}
+
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
+ json5@1.0.2:
+ dependencies:
+ minimist: 1.2.8
+
+ json5@2.2.3: {}
+
+ jsonfile@6.2.1:
+ dependencies:
+ universalify: 2.0.1
+ optionalDependencies:
+ graceful-fs: 4.2.11
+
+ jsx-ast-utils@3.3.5:
+ dependencies:
+ array-includes: 3.1.9
+ array.prototype.flat: 1.3.3
+ object.assign: 4.1.7
+ object.values: 1.2.1
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
+ kleur@3.0.3: {}
+
+ kleur@4.1.5: {}
+
+ language-subtag-registry@0.3.23: {}
+
+ language-tags@1.0.9:
+ dependencies:
+ language-subtag-registry: 0.3.23
+
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
+ lightningcss-android-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.32.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.32.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ optional: true
+
+ lightningcss@1.32.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
+
+ lines-and-columns@1.2.4: {}
+
+ locate-path@3.0.0:
+ dependencies:
+ p-locate: 3.0.0
+ path-exists: 3.0.0
+
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lodash.merge@4.6.2: {}
+
+ log-symbols@6.0.0:
+ dependencies:
+ chalk: 5.6.2
+ is-unicode-supported: 1.3.0
+
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ lucide-react@1.27.0(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ math-intrinsics@1.1.0: {}
+
+ media-typer@1.1.1: {}
+
+ merge-descriptors@2.0.0: {}
+
+ merge-stream@2.0.0: {}
+
+ merge2@1.4.1: {}
+
+ micromatch@4.0.8:
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.2
+
+ mime-db@1.54.0: {}
+
+ mime-types@3.0.2:
+ dependencies:
+ mime-db: 1.54.0
+
+ mimic-fn@2.1.0: {}
+
+ mimic-fn@3.1.0: {}
+
+ mimic-function@5.0.1: {}
+
+ minimatch@10.2.5:
+ dependencies:
+ brace-expansion: 5.0.8
+
+ minimatch@3.1.5:
+ dependencies:
+ brace-expansion: 1.1.16
+
+ minimist@1.2.8: {}
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.16: {}
+
+ napi-postinstall@0.3.4: {}
+
+ natural-compare@1.4.0: {}
+
+ negotiator@1.0.0: {}
+
+ next@16.2.12(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ dependencies:
+ '@next/env': 16.2.12
+ '@swc/helpers': 0.5.15
+ baseline-browser-mapping: 2.11.4
+ caniuse-lite: 1.0.30001806
+ postcss: 8.4.31
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.4)
+ optionalDependencies:
+ '@next/swc-darwin-arm64': 16.2.12
+ '@next/swc-darwin-x64': 16.2.12
+ '@next/swc-linux-arm64-gnu': 16.2.12
+ '@next/swc-linux-arm64-musl': 16.2.12
+ '@next/swc-linux-x64-gnu': 16.2.12
+ '@next/swc-linux-x64-musl': 16.2.12
+ '@next/swc-win32-arm64-msvc': 16.2.12
+ '@next/swc-win32-x64-msvc': 16.2.12
+ sharp: 0.34.5
+ transitivePeerDependencies:
+ - '@babel/core'
+ - babel-plugin-macros
+
+ node-exports-info@1.6.2:
+ dependencies:
+ array.prototype.flatmap: 1.3.3
+ es-errors: 1.3.0
+ object.entries: 1.1.9
+ semver: 6.3.1
+
+ node-releases@2.0.51: {}
+
+ npm-run-path@4.0.1:
+ dependencies:
+ path-key: 3.1.1
+
+ npm-run-path@6.0.0:
+ dependencies:
+ path-key: 4.0.0
+ unicorn-magic: 0.3.0
+
+ object-assign@4.1.1: {}
+
+ object-inspect@1.13.4: {}
+
+ object-keys@1.1.1: {}
+
+ object-treeify@1.1.33: {}
+
+ object.assign@4.1.7:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+ has-symbols: 1.1.0
+ object-keys: 1.1.1
+
+ object.entries@1.1.9:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ object.fromentries@2.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+
+ object.groupby@1.0.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+
+ object.values@1.2.1:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ on-finished@2.4.1:
+ dependencies:
+ ee-first: 1.1.1
+
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
+ onetime@5.1.2:
+ dependencies:
+ mimic-fn: 2.1.0
+
+ onetime@7.0.0:
+ dependencies:
+ mimic-function: 5.0.1
+
+ open@11.0.0:
+ dependencies:
+ default-browser: 5.5.0
+ define-lazy-prop: 3.0.0
+ is-in-ssh: 1.0.0
+ is-inside-container: 1.0.0
+ powershell-utils: 0.1.0
+ wsl-utils: 0.3.1
+
+ open@8.4.2:
+ dependencies:
+ define-lazy-prop: 2.0.0
+ is-docker: 2.2.1
+ is-wsl: 2.2.0
+
+ optionator@0.9.4:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ word-wrap: 1.2.5
+
+ ora@8.2.0:
+ dependencies:
+ chalk: 5.6.2
+ cli-cursor: 5.0.0
+ cli-spinners: 2.9.2
+ is-interactive: 2.0.0
+ is-unicode-supported: 2.1.0
+ log-symbols: 6.0.0
+ stdin-discarder: 0.2.2
+ string-width: 7.2.0
+ strip-ansi: 7.2.0
+
+ own-keys@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ object-keys: 1.1.1
+ safe-push-apply: 1.0.0
+
+ p-limit@2.3.0:
+ dependencies:
+ p-try: 2.2.0
+
+ p-limit@3.1.0:
+ dependencies:
+ yocto-queue: 0.1.0
+
+ p-locate@3.0.0:
+ dependencies:
+ p-limit: 2.3.0
+
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
+ p-try@2.2.0: {}
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ parse-json@5.2.0:
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ error-ex: 1.3.4
+ json-parse-even-better-errors: 2.3.1
+ lines-and-columns: 1.2.4
+
+ parse-ms@4.0.0: {}
+
+ parseurl@1.3.3: {}
+
+ path-browserify@1.0.1: {}
+
+ path-exists@3.0.0: {}
+
+ path-exists@4.0.0: {}
+
+ path-key@3.1.1: {}
+
+ path-key@4.0.0: {}
+
+ path-parse@1.0.7: {}
+
+ path-to-regexp@8.4.2: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@2.3.2: {}
+
+ picomatch@4.0.5: {}
+
+ pkce-challenge@5.0.1: {}
+
+ pkg-up@3.1.0:
+ dependencies:
+ find-up: 3.0.0
+
+ possible-typed-array-names@1.1.0: {}
+
+ postcss-selector-parser@7.1.4:
+ dependencies:
+ cssesc: 3.0.0
+ util-deprecate: 1.0.2
+
+ postcss@8.4.31:
+ dependencies:
+ nanoid: 3.3.16
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ postcss@8.5.23:
+ dependencies:
+ nanoid: 3.3.16
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ powershell-utils@0.1.0: {}
+
+ prelude-ls@1.2.1: {}
+
+ pretty-ms@9.3.0:
+ dependencies:
+ parse-ms: 4.0.0
+
+ prompts@2.4.2:
+ dependencies:
+ kleur: 3.0.3
+ sisteransi: 1.0.5
+
+ prop-types@15.8.1:
+ dependencies:
+ loose-envify: 1.4.0
+ object-assign: 4.1.1
+ react-is: 16.13.1
+
+ proxy-addr@2.0.7:
+ dependencies:
+ forwarded: 0.2.0
+ ipaddr.js: 1.9.1
+
+ punycode@2.3.1: {}
+
+ qs@6.15.3:
+ dependencies:
+ es-define-property: 1.0.1
+ side-channel: 1.1.1
+
+ queue-microtask@1.2.3: {}
+
+ range-parser@1.3.0: {}
+
+ raw-body@3.0.2:
+ dependencies:
+ bytes: 3.1.2
+ http-errors: 2.0.1
+ iconv-lite: 0.7.3
+ unpipe: 1.0.0
+
+ react-dom@19.2.4(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ scheduler: 0.27.0
+
+ react-is@16.13.1: {}
+
+ react@19.2.4: {}
+
+ recast@0.23.12:
+ dependencies:
+ ast-types: 0.16.1
+ esprima: 4.0.1
+ source-map: 0.6.1
+ tiny-invariant: 1.3.3
+ tslib: 2.8.1
+
+ reflect.getprototypeof@1.0.10:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ which-builtin-type: 1.2.1
+
+ regexp.prototype.flags@1.5.4:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-errors: 1.3.0
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ set-function-name: 2.0.2
+
+ require-from-string@2.0.2: {}
+
+ reselect@5.2.0: {}
+
+ resolve-from@4.0.0: {}
+
+ resolve-pkg-maps@1.0.0: {}
+
+ resolve@2.0.0-next.7:
+ dependencies:
+ es-errors: 1.3.0
+ is-core-module: 2.16.2
+ node-exports-info: 1.6.2
+ object-keys: 1.1.1
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
+ restore-cursor@5.1.0:
+ dependencies:
+ onetime: 7.0.0
+ signal-exit: 4.1.0
+
+ reusify@1.1.0: {}
+
+ router@2.2.0(supports-color@7.2.0):
+ dependencies:
+ debug: 4.4.3(supports-color@7.2.0)
+ depd: 2.0.0
+ is-promise: 4.0.0
+ parseurl: 1.3.3
+ path-to-regexp: 8.4.2
+ transitivePeerDependencies:
+ - supports-color
+
+ run-applescript@7.1.0: {}
+
+ run-parallel@1.2.0:
+ dependencies:
+ queue-microtask: 1.2.3
+
+ safe-array-concat@1.1.4:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ has-symbols: 1.1.0
+ isarray: 2.0.5
+
+ safe-push-apply@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ isarray: 2.0.5
+
+ safe-regex-test@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-regex: 1.2.1
+
+ safer-buffer@2.1.2: {}
+
+ scheduler@0.27.0: {}
+
+ semver@6.3.1: {}
+
+ semver@7.8.5: {}
+
+ send@1.2.1(supports-color@7.2.0):
+ dependencies:
+ debug: 4.4.3(supports-color@7.2.0)
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ mime-types: 3.0.2
+ ms: 2.1.3
+ on-finished: 2.4.1
+ range-parser: 1.3.0
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ serve-static@2.2.1(supports-color@7.2.0):
+ dependencies:
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ parseurl: 1.3.3
+ send: 1.2.1(supports-color@7.2.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ set-function-length@1.2.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+
+ set-function-name@2.0.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.2
+
+ set-proto@1.0.0:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+
+ setprototypeof@1.2.0: {}
+
+ shadcn@4.15.0(supports-color@7.2.0)(typescript@5.9.3):
+ dependencies:
+ '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/parser': 7.29.7
+ '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
+ '@dotenvx/dotenvx': 1.75.1
+ '@modelcontextprotocol/sdk': 1.29.0(supports-color@7.2.0)(zod@3.25.76)
+ '@types/validate-npm-package-name': 4.0.2
+ browserslist: 4.28.7
+ commander: 14.0.3
+ cosmiconfig: 9.0.2(typescript@5.9.3)
+ dedent: 1.7.2
+ deepmerge: 4.3.1
+ diff: 8.0.4
+ execa: 9.6.1
+ fast-glob: 3.3.3
+ fs-extra: 11.4.0
+ fuzzysort: 3.1.0
+ kleur: 4.1.5
+ open: 11.0.0
+ ora: 8.2.0
+ postcss: 8.5.23
+ postcss-selector-parser: 7.1.4
+ prompts: 2.4.2
+ recast: 0.23.12
+ stringify-object: 5.0.0
+ tailwind-merge: 3.6.0
+ ts-morph: 26.0.0
+ tsconfig-paths: 4.2.0
+ undici: 7.29.0
+ validate-npm-package-name: 7.0.2
+ zod: 3.25.76
+ zod-to-json-schema: 3.25.2(zod@3.25.76)
+ transitivePeerDependencies:
+ - '@cfworker/json-schema'
+ - babel-plugin-macros
+ - supports-color
+ - typescript
+
+ sharp@0.34.5:
+ dependencies:
+ '@img/colour': 1.1.0
+ detect-libc: 2.1.2
+ semver: 7.8.5
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.34.5
+ '@img/sharp-darwin-x64': 0.34.5
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-linux-arm': 0.34.5
+ '@img/sharp-linux-arm64': 0.34.5
+ '@img/sharp-linux-ppc64': 0.34.5
+ '@img/sharp-linux-riscv64': 0.34.5
+ '@img/sharp-linux-s390x': 0.34.5
+ '@img/sharp-linux-x64': 0.34.5
+ '@img/sharp-linuxmusl-arm64': 0.34.5
+ '@img/sharp-linuxmusl-x64': 0.34.5
+ '@img/sharp-wasm32': 0.34.5
+ '@img/sharp-win32-arm64': 0.34.5
+ '@img/sharp-win32-ia32': 0.34.5
+ '@img/sharp-win32-x64': 0.34.5
+ optional: true
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ side-channel-list@1.0.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-map@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-weakmap@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-map: 1.0.1
+
+ side-channel@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.1
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
+ signal-exit@3.0.7: {}
+
+ signal-exit@4.1.0: {}
+
+ sisteransi@1.0.5: {}
+
+ sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
+ source-map-js@1.2.1: {}
+
+ source-map@0.6.1: {}
+
+ stable-hash@0.0.5: {}
+
+ statuses@2.0.2: {}
+
+ stdin-discarder@0.2.2: {}
+
+ stop-iteration-iterator@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ internal-slot: 1.1.0
+
+ string-width@7.2.0:
+ dependencies:
+ emoji-regex: 10.6.0
+ get-east-asian-width: 1.6.0
+ strip-ansi: 7.2.0
+
+ string.prototype.includes@2.0.1:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+
+ string.prototype.matchall@4.0.12:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ internal-slot: 1.1.0
+ regexp.prototype.flags: 1.5.4
+ set-function-name: 2.0.2
+ side-channel: 1.1.1
+
+ string.prototype.repeat@1.0.0:
+ dependencies:
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+
+ string.prototype.trim@1.2.11:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-data-property: 1.1.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+ has-property-descriptors: 1.0.2
+ safe-regex-test: 1.1.0
+
+ string.prototype.trimend@1.0.10:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ string.prototype.trimstart@1.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ stringify-object@5.0.0:
+ dependencies:
+ get-own-enumerable-keys: 1.0.0
+ is-obj: 3.0.0
+ is-regexp: 3.1.0
+
+ strip-ansi@6.0.1:
+ dependencies:
+ ansi-regex: 5.0.1
+
+ strip-ansi@7.2.0:
+ dependencies:
+ ansi-regex: 6.2.2
+
+ strip-bom@3.0.0: {}
+
+ strip-final-newline@2.0.0: {}
+
+ strip-final-newline@4.0.0: {}
+
+ strip-json-comments@3.1.1: {}
+
+ styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.4):
+ dependencies:
+ client-only: 0.0.1
+ react: 19.2.4
+ optionalDependencies:
+ '@babel/core': 7.29.7(supports-color@7.2.0)
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ supports-preserve-symlinks-flag@1.0.0: {}
+
+ systeminformation@5.33.1: {}
+
+ tailwind-merge@3.6.0: {}
+
+ tailwindcss@4.3.3: {}
+
+ tapable@2.3.3: {}
+
+ tiny-invariant@1.3.3: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.5)
+ picomatch: 4.0.5
+
+ to-regex-range@5.0.1:
+ dependencies:
+ is-number: 7.0.0
+
+ toidentifier@1.0.1: {}
+
+ ts-api-utils@2.5.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
+ ts-morph@26.0.0:
+ dependencies:
+ '@ts-morph/common': 0.27.0
+ code-block-writer: 13.0.3
+
+ tsconfig-paths@3.15.0:
+ dependencies:
+ '@types/json5': 0.0.29
+ json5: 1.0.2
+ minimist: 1.2.8
+ strip-bom: 3.0.0
+
+ tsconfig-paths@4.2.0:
+ dependencies:
+ json5: 2.2.3
+ minimist: 1.2.8
+ strip-bom: 3.0.0
+
+ tslib@2.8.1: {}
+
+ tw-animate-css@1.4.0: {}
+
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
+ type-is@2.1.0:
+ dependencies:
+ content-type: 2.0.0
+ media-typer: 1.1.1
+ mime-types: 3.0.2
+
+ typed-array-buffer@1.0.3:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-typed-array: 1.1.15
+
+ typed-array-byte-length@1.0.3:
+ dependencies:
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ has-proto: 1.2.0
+ is-typed-array: 1.1.15
+
+ typed-array-byte-offset@1.0.4:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ has-proto: 1.2.0
+ is-typed-array: 1.1.15
+ reflect.getprototypeof: 1.0.10
+
+ typed-array-length@1.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ is-typed-array: 1.1.15
+ possible-typed-array-names: 1.1.0
+ reflect.getprototypeof: 1.0.10
+
+ typescript-eslint@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3):
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.65.0(supports-color@7.2.0)(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ typescript@5.9.3: {}
+
+ unbox-primitive@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-bigints: 1.1.0
+ has-symbols: 1.1.0
+ which-boxed-primitive: 1.1.1
+
+ undici-types@6.21.0: {}
+
+ undici@7.29.0: {}
+
+ unicorn-magic@0.3.0: {}
+
+ universalify@2.0.1: {}
+
+ unpipe@1.0.0: {}
+
+ unrs-resolver@1.12.2:
+ dependencies:
+ napi-postinstall: 0.3.4
+ optionalDependencies:
+ '@unrs/resolver-binding-android-arm-eabi': 1.12.2
+ '@unrs/resolver-binding-android-arm64': 1.12.2
+ '@unrs/resolver-binding-darwin-arm64': 1.12.2
+ '@unrs/resolver-binding-darwin-x64': 1.12.2
+ '@unrs/resolver-binding-freebsd-x64': 1.12.2
+ '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2
+ '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2
+ '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-arm64-musl': 1.12.2
+ '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-loong64-musl': 1.12.2
+ '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2
+ '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-x64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-x64-musl': 1.12.2
+ '@unrs/resolver-binding-openharmony-arm64': 1.12.2
+ '@unrs/resolver-binding-wasm32-wasi': 1.12.2
+ '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2
+ '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2
+ '@unrs/resolver-binding-win32-x64-msvc': 1.12.2
+
+ update-browserslist-db@1.2.3(browserslist@4.28.7):
+ dependencies:
+ browserslist: 4.28.7
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
+ use-sync-external-store@1.6.0(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+
+ util-deprecate@1.0.2: {}
+
+ validate-npm-package-name@7.0.2: {}
+
+ vary@1.1.2: {}
+
+ which-boxed-primitive@1.1.1:
+ dependencies:
+ is-bigint: 1.1.0
+ is-boolean-object: 1.2.2
+ is-number-object: 1.1.1
+ is-string: 1.1.1
+ is-symbol: 1.1.1
+
+ which-builtin-type@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ function.prototype.name: 1.2.0
+ has-tostringtag: 1.0.2
+ is-async-function: 2.1.1
+ is-date-object: 1.1.0
+ is-finalizationregistry: 1.1.1
+ is-generator-function: 1.1.2
+ is-regex: 1.2.1
+ is-weakref: 1.1.1
+ isarray: 2.0.5
+ which-boxed-primitive: 1.1.1
+ which-collection: 1.0.2
+ which-typed-array: 1.1.22
+
+ which-collection@1.0.2:
+ dependencies:
+ is-map: 2.0.3
+ is-set: 2.0.3
+ is-weakmap: 2.0.2
+ is-weakset: 2.0.4
+
+ which-typed-array@1.1.22:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ for-each: 0.3.5
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ which@4.0.0:
+ dependencies:
+ isexe: 3.1.5
+
+ word-wrap@1.2.5: {}
+
+ wrappy@1.0.2: {}
+
+ wsl-utils@0.3.1:
+ dependencies:
+ is-wsl: 3.1.1
+ powershell-utils: 0.1.0
+
+ yallist@3.1.1: {}
+
+ yocto-queue@0.1.0: {}
+
+ yocto-spinner@1.2.2:
+ dependencies:
+ yoctocolors: 2.2.0
+
+ yoctocolors@2.2.0: {}
+
+ zod-to-json-schema@3.25.2(zod@3.25.76):
+ dependencies:
+ zod: 3.25.76
+
+ zod-validation-error@4.0.2(zod@4.4.3):
+ dependencies:
+ zod: 4.4.3
+
+ zod@3.25.76: {}
+
+ zod@4.4.3: {}
diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml
new file mode 100644
index 00000000..dd1b4fd5
--- /dev/null
+++ b/web/pnpm-workspace.yaml
@@ -0,0 +1,3 @@
+allowBuilds:
+ sharp: true
+ unrs-resolver: true
diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs
new file mode 100644
index 00000000..61e36849
--- /dev/null
+++ b/web/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/web/public/file.svg b/web/public/file.svg
new file mode 100644
index 00000000..004145cd
--- /dev/null
+++ b/web/public/file.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/public/globe.svg b/web/public/globe.svg
new file mode 100644
index 00000000..567f17b0
--- /dev/null
+++ b/web/public/globe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/public/next.svg b/web/public/next.svg
new file mode 100644
index 00000000..5174b28c
--- /dev/null
+++ b/web/public/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/public/vercel.svg b/web/public/vercel.svg
new file mode 100644
index 00000000..77053960
--- /dev/null
+++ b/web/public/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/public/window.svg b/web/public/window.svg
new file mode 100644
index 00000000..b2b2a44f
--- /dev/null
+++ b/web/public/window.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/src/app/(console)/dashboard/page.tsx b/web/src/app/(console)/dashboard/page.tsx
new file mode 100644
index 00000000..9abcae65
--- /dev/null
+++ b/web/src/app/(console)/dashboard/page.tsx
@@ -0,0 +1,74 @@
+import type { Metadata } from "next";
+
+import { loadDashboard } from "@/lib/api/dashboard";
+import { RefreshControls } from "@/components/dashboard/refresh-controls";
+import { NotConnected } from "@/components/dashboard/not-connected";
+import { ConnectionPanel } from "@/components/dashboard/connection-panel";
+import { ClusterPanel } from "@/components/dashboard/cluster-panel";
+import { CapacityPanel } from "@/components/dashboard/capacity-panel";
+import { CatalogPanel } from "@/components/dashboard/catalog-panel";
+import { AttentionPanel } from "@/components/dashboard/attention-panel";
+import {
+ SandboxActivityPanels,
+ SandboxPanel,
+} from "@/components/dashboard/sandbox-panel";
+
+export const metadata: Metadata = {
+ title: "Dashboard · AgentENV",
+};
+
+export const dynamic = "force-dynamic";
+
+export default async function DashboardPage() {
+ const data = await loadDashboard();
+
+ if (!data.connected) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function PageHeading() {
+ return (
+
+
Dashboard
+
+ Live view of Gateway health, node capacity, and sandbox activity.
+
+
+ );
+}
diff --git a/web/src/app/(console)/layout.tsx b/web/src/app/(console)/layout.tsx
new file mode 100644
index 00000000..ec24419f
--- /dev/null
+++ b/web/src/app/(console)/layout.tsx
@@ -0,0 +1,9 @@
+import { ConsoleShell } from "@/components/console-shell";
+
+export default function ConsoleLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return {children} ;
+}
diff --git a/web/src/app/(console)/nodes/[nodeId]/page.tsx b/web/src/app/(console)/nodes/[nodeId]/page.tsx
new file mode 100644
index 00000000..dc7d19cb
--- /dev/null
+++ b/web/src/app/(console)/nodes/[nodeId]/page.tsx
@@ -0,0 +1,416 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import {
+ ArrowLeftIcon,
+ CpuIcon,
+ HardDriveIcon,
+ ServerIcon,
+ TriangleAlertIcon,
+} from "lucide-react";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { ApiError, userFacingApiMessage } from "@/lib/api/errors";
+import {
+ getNodeDetail,
+ nodePressure,
+ nodeStatus,
+ type ClusterNodeDetail,
+} from "@/lib/api/nodes";
+import { getConnectionSession } from "@/lib/session";
+import {
+ formatBytes,
+ formatCount,
+ formatPercent,
+ percentOf,
+} from "@/components/dashboard/format";
+import { NotConnected } from "@/components/dashboard/not-connected";
+import {
+ DefinitionRow,
+ EmptyState,
+ PanelNotice,
+ StatTile,
+ UsageBar,
+} from "@/components/dashboard/primitives";
+import { RefreshControls } from "@/components/dashboard/refresh-controls";
+import {
+ NodeStatusBadge,
+ PressureBadge,
+} from "@/components/nodes/node-status-badge";
+
+export const metadata: Metadata = {
+ title: "Node · AgentENV",
+};
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ params: Promise<{ nodeId: string }>;
+ searchParams: Promise<{ cluster?: string }>;
+};
+
+export default async function NodeDetailPage({
+ params,
+ searchParams,
+}: PageProps) {
+ const { nodeId } = await params;
+ const { cluster } = await searchParams;
+ const session = await getConnectionSession();
+ const fetchedAt = new Date().toISOString();
+
+ if (!session) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (!session.adminToken) {
+ return (
+
+ );
+ }
+
+ let node: ClusterNodeDetail;
+ try {
+ node = await getNodeDetail(nodeId, cluster);
+ } catch (caught) {
+ if (caught instanceof ApiError && caught.status === 404) {
+ notFound();
+ }
+ return (
+
+ );
+ }
+
+ const metrics = node.metrics;
+ const pressure = nodePressure(node);
+ const cpuPercent =
+ typeof metrics?.cpuPercent === "number" ? metrics.cpuPercent : null;
+ const cpuBusyCores =
+ cpuPercent === null
+ ? undefined
+ : (cpuPercent / 100) * (metrics?.cpuCount ?? 0);
+ const createTotal = (node.createSuccesses ?? 0) + (node.createFails ?? 0);
+
+ return (
+
+
+
+
+
+
+
+ {node.version ? `orchestrator ${node.version}` : "Node detail"}
+ {node.clusterID ? ` · cluster ${node.clusterID}` : ""}
+
+
+
+
+
+ {pressure.reasons.length > 0 ? (
+
+
+
+ {pressure.level === "critical"
+ ? "This node needs attention"
+ : "This node is degraded"}
+
+ {pressure.reasons.join(" · ")}
+
+ ) : null}
+
+
+
+
+
+
+ Identity
+
+ Reported by the node heartbeat.
+
+
+
+
+ {node.id}
+
+
+ {node.clusterID || "—"}
+
+
+ {node.serviceInstanceID || "—"}
+
+
+ {node.version || "—"}
+
+
+ {node.commit || "—"}
+
+
+ {node.machineInfo?.cpuArchitecture || "—"}
+
+
+ {node.machineInfo?.cpuModelName || "—"}
+
+
+ {node.machineInfo?.cpuFamily || "—"} /{" "}
+ {node.machineInfo?.cpuModel || "—"}
+
+
+
+
+
+
+
+
+
+ Host metrics
+
+
+ Live host usage. Markers show CPU and memory reserved by running
+ sandboxes.
+
+
+
+ {!metrics ? (
+ This node has not reported metrics yet.
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ Sandboxes
+
+ Counts and lifetime create outcomes for this node.
+
+
+
+ 0 ? "positive" : "neutral"}
+ />
+
+
+ 0 ? "critical" : "neutral"}
+ hint={
+ createTotal > 0
+ ? formatPercent(
+ ((node.createFails ?? 0) / createTotal) * 100,
+ 1,
+ )
+ : undefined
+ }
+ />
+
+
+
+
+
+
+
+ Disks
+
+
+ Mount points reported by the host.
+
+
+
+ {!metrics?.disks || metrics.disks.length === 0 ? (
+ No disk metrics reported.
+ ) : (
+
+
+
+ Mount
+ Device
+ Type
+ Used
+
+
+
+ {metrics.disks.map((disk) => {
+ const percent = percentOf(disk.usedBytes, disk.totalBytes);
+ return (
+
+
+ {disk.mountPoint}
+
+
+ {disk.device}
+
+
+ {disk.filesystemType}
+
+
+ {formatBytes(disk.usedBytes)} /{" "}
+ {formatBytes(disk.totalBytes)}
+
+ ({percent === null ? "—" : formatPercent(percent)})
+
+
+
+ );
+ })}
+
+
+ )}
+
+
+
+
+ {node.cachedBuilds && node.cachedBuilds.length > 0 ? (
+
+
+ Cached builds
+
+ Build artifacts already present on this node.
+
+
+
+ {node.cachedBuilds.map((build) => (
+
+ {build}
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+function BackLink() {
+ return (
+ }
+ >
+
+ All nodes
+
+ );
+}
diff --git a/web/src/app/(console)/nodes/page.tsx b/web/src/app/(console)/nodes/page.tsx
new file mode 100644
index 00000000..a092a3c4
--- /dev/null
+++ b/web/src/app/(console)/nodes/page.tsx
@@ -0,0 +1,235 @@
+import type { Metadata } from "next";
+import { ServerIcon } from "lucide-react";
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { ApiError, userFacingApiMessage } from "@/lib/api/errors";
+import {
+ aggregateCapacity,
+ countNodesByStatus,
+ listNodes,
+ nodeStatus,
+ type ClusterNode,
+} from "@/lib/api/nodes";
+import { getConnectionSession } from "@/lib/session";
+import { formatBytes, formatCount } from "@/components/dashboard/format";
+import { NotConnected } from "@/components/dashboard/not-connected";
+import {
+ EmptyState,
+ PanelNotice,
+ StatTile,
+} from "@/components/dashboard/primitives";
+import { RefreshControls } from "@/components/dashboard/refresh-controls";
+import {
+ NodeFilters,
+ type NodeStatusFilter,
+} from "@/components/nodes/node-filters";
+import { NodesTable } from "@/components/nodes/nodes-table";
+import { type DisplayNodeStatus } from "@/components/nodes/node-status-badge";
+
+export const metadata: Metadata = {
+ title: "Nodes · AgentENV",
+};
+
+export const dynamic = "force-dynamic";
+
+const STATUS_FILTERS: readonly NodeStatusFilter[] = [
+ "all",
+ "ready",
+ "draining",
+ "connecting",
+ "unhealthy",
+ "unknown",
+];
+
+function parseStatus(raw?: string): NodeStatusFilter {
+ const candidate = (raw ?? "all").toLowerCase();
+ return STATUS_FILTERS.includes(candidate as NodeStatusFilter)
+ ? (candidate as NodeStatusFilter)
+ : "all";
+}
+
+function matchesQuery(node: ClusterNode, query: string): boolean {
+ if (!query) {
+ return true;
+ }
+ const needle = query.toLowerCase();
+ const haystack = [
+ node.id,
+ node.clusterID,
+ node.serviceInstanceID,
+ node.version,
+ node.commit,
+ node.machineInfo?.cpuModelName,
+ node.machineInfo?.cpuArchitecture,
+ ];
+ return haystack.some((value) => value?.toLowerCase().includes(needle));
+}
+
+function matchesStatus(node: ClusterNode, status: NodeStatusFilter): boolean {
+ if (status === "all") {
+ return true;
+ }
+ const current: DisplayNodeStatus = nodeStatus(node);
+ return current === status;
+}
+
+export default async function NodesPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ q?: string; status?: string }>;
+}) {
+ const { q, status: rawStatus } = await searchParams;
+ const query = (q ?? "").trim();
+ const status = parseStatus(rawStatus);
+ const session = await getConnectionSession();
+ const fetchedAt = new Date().toISOString();
+
+ if (!session) {
+ return (
+
+ );
+ }
+
+ if (!session.adminToken) {
+ return (
+
+
+
+
+
+
+ Node inventory
+
+
+ GET /nodes requires an
+ admin token.
+
+
+
+
+
+
+
+ );
+ }
+
+ let nodes: ClusterNode[] = [];
+ let error: { status?: number; message: string } | null = null;
+ try {
+ nodes = await listNodes();
+ } catch (caught) {
+ error = {
+ status: caught instanceof ApiError ? caught.status : undefined,
+ message: userFacingApiMessage(caught),
+ };
+ }
+
+ const counts = countNodesByStatus(nodes);
+ const capacity = aggregateCapacity(nodes);
+ const visible = nodes.filter(
+ (node) => matchesStatus(node, status) && matchesQuery(node, query),
+ );
+
+ return (
+
+
+
+ {error ? (
+
+ ) : (
+ <>
+
+
+ 0 ? "critical" : "positive"}
+ hint={`${formatCount(counts.draining)} draining · ${formatCount(
+ counts.connecting,
+ )} connecting`}
+ />
+
+
+
+
+
+
+ Nodes
+
+ Read-only inventory from the Gateway admin API. Select a node
+ for host details and metrics.
+
+
+
+
+ {visible.length === 0 ? (
+
+ {nodes.length === 0
+ ? "No nodes are registered with this Gateway."
+ : "No nodes match the current filters."}
+
+ ) : (
+
+ )}
+
+
+ >
+ )}
+
+ );
+}
+
+function PageHeading() {
+ return (
+
+
Nodes
+
+ Cluster nodes reporting to the Gateway, with host and sandbox capacity.
+
+
+ );
+}
diff --git a/web/src/app/(console)/sandboxes/[sandboxId]/page.tsx b/web/src/app/(console)/sandboxes/[sandboxId]/page.tsx
new file mode 100644
index 00000000..07f4d687
--- /dev/null
+++ b/web/src/app/(console)/sandboxes/[sandboxId]/page.tsx
@@ -0,0 +1,66 @@
+import Link from "next/link";
+import { ArrowLeftIcon, TriangleAlertIcon } from "lucide-react";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { userFacingApiMessage } from "@/lib/api/errors";
+import {
+ getCustomExtensionParams,
+ getSandbox,
+ type CustomExtensionParams,
+ type SandboxDetail,
+} from "@/lib/api/sandboxes";
+import { SandboxDetailView } from "@/components/sandboxes/sandbox-detail-view";
+
+export default async function SandboxDetailPage({
+ params,
+}: {
+ params: Promise<{ sandboxId: string }>;
+}) {
+ const { sandboxId } = await params;
+
+ let sandbox: SandboxDetail;
+ try {
+ sandbox = await getSandbox(sandboxId);
+ } catch (error) {
+ return (
+
+
}
+ >
+
+ Sandboxes
+
+
+
+ Could not load sandbox
+
+ {sandboxId}
+ {userFacingApiMessage(error)}
+
+
+
+ );
+ }
+
+ let extensionParams: CustomExtensionParams | null = null;
+ let extensionError: string | undefined;
+ try {
+ extensionParams = await getCustomExtensionParams(sandboxId);
+ } catch (error) {
+ extensionError = userFacingApiMessage(error);
+ }
+
+ return (
+
+ );
+}
diff --git a/web/src/app/(console)/sandboxes/actions.ts b/web/src/app/(console)/sandboxes/actions.ts
new file mode 100644
index 00000000..bd8082c2
--- /dev/null
+++ b/web/src/app/(console)/sandboxes/actions.ts
@@ -0,0 +1,173 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+
+import { userFacingApiMessage } from "@/lib/api/errors";
+import {
+ connectSandbox,
+ createColdSandbox,
+ createSandbox,
+ createSandboxSnapshot,
+ forkSandbox,
+ getCustomExtensionParams,
+ killSandbox,
+ patchCustomExtensionParams,
+ pauseSandbox,
+ setSandboxTimeout,
+ updateSandboxNetwork,
+ type CustomExtensionParams,
+ type NewColdSandboxRequest,
+ type NewSandboxRequest,
+ type SandboxNetworkUpdate,
+} from "@/lib/api/sandboxes";
+
+export type ActionResult =
+ | { ok: true; data: T }
+ | { ok: false; error: string };
+
+async function run(
+ operation: () => Promise,
+ revalidate: string[],
+): Promise> {
+ try {
+ const data = await operation();
+ for (const path of revalidate) {
+ revalidatePath(path);
+ }
+ return { ok: true, data };
+ } catch (error) {
+ return { ok: false, error: userFacingApiMessage(error) };
+ }
+}
+
+function sandboxPaths(sandboxID: string): string[] {
+ return ["/sandboxes", `/sandboxes/${sandboxID}`];
+}
+
+export async function pauseSandboxAction(
+ sandboxID: string,
+): Promise {
+ return run(async () => {
+ await pauseSandbox(sandboxID);
+ return undefined;
+ }, sandboxPaths(sandboxID));
+}
+
+export async function resumeSandboxAction(
+ sandboxID: string,
+ timeoutSeconds: number,
+): Promise {
+ return run(async () => {
+ await connectSandbox(sandboxID, timeoutSeconds);
+ return undefined;
+ }, sandboxPaths(sandboxID));
+}
+
+export async function setSandboxTimeoutAction(
+ sandboxID: string,
+ timeoutSeconds: number,
+): Promise {
+ return run(async () => {
+ await setSandboxTimeout(sandboxID, timeoutSeconds);
+ return undefined;
+ }, sandboxPaths(sandboxID));
+}
+
+export async function killSandboxAction(
+ sandboxID: string,
+): Promise {
+ return run(async () => {
+ await killSandbox(sandboxID);
+ return undefined;
+ }, sandboxPaths(sandboxID));
+}
+
+export type ForkSummary = {
+ createdSandboxIDs: string[];
+ failures: string[];
+};
+
+export async function forkSandboxAction(
+ sandboxID: string,
+ count: number,
+ timeoutSeconds?: number,
+): Promise> {
+ return run(async () => {
+ const results = await forkSandbox(sandboxID, {
+ count,
+ timeout: timeoutSeconds,
+ });
+ const summary: ForkSummary = { createdSandboxIDs: [], failures: [] };
+ for (const result of results ?? []) {
+ if (result.sandbox?.sandboxID) {
+ summary.createdSandboxIDs.push(result.sandbox.sandboxID);
+ } else {
+ summary.failures.push(result.error?.message ?? "Fork failed.");
+ }
+ }
+ return summary;
+ }, sandboxPaths(sandboxID));
+}
+
+export async function createSandboxSnapshotAction(
+ sandboxID: string,
+ name?: string,
+): Promise> {
+ return run(
+ async () => {
+ const snapshot = await createSandboxSnapshot(sandboxID, { name });
+ return { snapshotID: snapshot.snapshotID };
+ },
+ [...sandboxPaths(sandboxID), "/snapshots"],
+ );
+}
+
+export async function updateSandboxNetworkAction(
+ sandboxID: string,
+ update: SandboxNetworkUpdate,
+): Promise {
+ return run(async () => {
+ await updateSandboxNetwork(sandboxID, update);
+ return undefined;
+ }, sandboxPaths(sandboxID));
+}
+
+export async function loadCustomExtensionParamsAction(
+ sandboxID: string,
+): Promise> {
+ return run(() => getCustomExtensionParams(sandboxID), []);
+}
+
+export async function patchCustomExtensionParamsAction(
+ sandboxID: string,
+ patch: Record,
+): Promise> {
+ return run(
+ () => patchCustomExtensionParams(sandboxID, patch),
+ sandboxPaths(sandboxID),
+ );
+}
+
+export async function createSandboxAction(
+ body: NewSandboxRequest,
+): Promise> {
+ return run(
+ async () => {
+ const sandbox = await createSandbox(body);
+ return { sandboxID: sandbox.sandboxID };
+ },
+ ["/sandboxes"],
+ );
+}
+
+export async function createColdSandboxAction(
+ body: NewColdSandboxRequest,
+): Promise> {
+ return run(
+ async () => {
+ const sandbox = await createColdSandbox(body);
+ return { sandboxID: sandbox.sandboxID };
+ },
+ ["/sandboxes"],
+ );
+}
diff --git a/web/src/app/(console)/sandboxes/new/page.tsx b/web/src/app/(console)/sandboxes/new/page.tsx
new file mode 100644
index 00000000..e8397648
--- /dev/null
+++ b/web/src/app/(console)/sandboxes/new/page.tsx
@@ -0,0 +1,35 @@
+import {
+ CreateSandboxForm,
+ type CreateMode,
+ type SourceKind,
+} from "@/components/sandboxes/create-sandbox-form";
+
+type SearchParams = Record;
+
+function firstValue(value: string | string[] | undefined): string {
+ return (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
+}
+
+export default async function NewSandboxPage({
+ searchParams,
+}: {
+ searchParams: Promise;
+}) {
+ const resolved = await searchParams;
+ const fromSnapshot = firstValue(resolved.fromSnapshot);
+ const fromTemplate = firstValue(resolved.fromTemplate);
+ const fromImage = firstValue(resolved.fromImage);
+
+ const sourceKind: SourceKind = fromSnapshot ? "snapshot" : "template";
+ const sourceId = fromSnapshot || fromTemplate;
+ const mode: CreateMode = !sourceId && fromImage ? "cold" : "template";
+
+ return (
+
+ );
+}
diff --git a/web/src/app/(console)/sandboxes/page.tsx b/web/src/app/(console)/sandboxes/page.tsx
new file mode 100644
index 00000000..43772e2e
--- /dev/null
+++ b/web/src/app/(console)/sandboxes/page.tsx
@@ -0,0 +1,85 @@
+import { userFacingApiMessage } from "@/lib/api/errors";
+import {
+ listSandboxes,
+ type ListedSandbox,
+ type SandboxLifecycleState,
+} from "@/lib/api/sandboxes";
+import {
+ SandboxList,
+ type SandboxStateFilter,
+} from "@/components/sandboxes/sandbox-list";
+
+const DEFAULT_LIMIT = 50;
+const ALLOWED_LIMITS = [25, 50, 100, 200];
+
+type SearchParams = Record;
+
+function firstValue(value: string | string[] | undefined): string | undefined {
+ return Array.isArray(value) ? value[0] : value;
+}
+
+function parseStateFilter(value: string | undefined): SandboxStateFilter {
+ return value === "running" || value === "paused" ? value : "all";
+}
+
+function parseLimit(value: string | undefined): number {
+ const parsed = Number(value);
+ return ALLOWED_LIMITS.includes(parsed) ? parsed : DEFAULT_LIMIT;
+}
+
+/** Accepts the raw `user=abc&app=prod` form used by the Gateway metadata filter. */
+function parseMetadataQuery(value: string | undefined): Record {
+ if (!value) {
+ return {};
+ }
+ const metadata: Record = {};
+ for (const [key, entry] of new URLSearchParams(value).entries()) {
+ if (key.trim() !== "") {
+ metadata[key] = entry;
+ }
+ }
+ return metadata;
+}
+
+export default async function SandboxesPage({
+ searchParams,
+}: {
+ searchParams: Promise;
+}) {
+ const resolved = await searchParams;
+ const stateFilter = parseStateFilter(firstValue(resolved.state));
+ const limit = parseLimit(firstValue(resolved.limit));
+ const metadataQuery = firstValue(resolved.metadata)?.trim() ?? "";
+ const metadata = parseMetadataQuery(metadataQuery);
+
+ const states: SandboxLifecycleState[] =
+ stateFilter === "all" ? ["running", "paused"] : [stateFilter];
+
+ let sandboxes: ListedSandbox[] = [];
+ let hasMore = false;
+ let error: string | undefined;
+
+ try {
+ const result = await listSandboxes({
+ states,
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
+ limit,
+ });
+ sandboxes = result.items;
+ hasMore = Boolean(result.nextToken);
+ } catch (loadError) {
+ error = userFacingApiMessage(loadError);
+ }
+
+ return (
+
+ );
+}
diff --git a/web/src/app/(console)/settings/page.tsx b/web/src/app/(console)/settings/page.tsx
new file mode 100644
index 00000000..b44e7e8f
--- /dev/null
+++ b/web/src/app/(console)/settings/page.tsx
@@ -0,0 +1,28 @@
+import { ConnectionForm } from "@/components/settings/connection-form";
+import { getConnectionSessionSummary } from "@/lib/session";
+
+export const dynamic = "force-dynamic";
+
+export const metadata = {
+ title: "Settings — AgentENV",
+};
+
+export default async function SettingsPage() {
+ const session = await getConnectionSessionSummary();
+
+ return (
+
+
+
Settings
+
+ Point the console at an AgentENV Gateway and store the credentials it
+ should use for this browser session.
+
+
+
+
+ );
+}
diff --git a/web/src/app/(console)/snapshots/[snapshotId]/page.tsx b/web/src/app/(console)/snapshots/[snapshotId]/page.tsx
new file mode 100644
index 00000000..eb9b47e8
--- /dev/null
+++ b/web/src/app/(console)/snapshots/[snapshotId]/page.tsx
@@ -0,0 +1,241 @@
+import Link from "next/link";
+import { ArrowLeftIcon, InfoIcon, PlayIcon } from "lucide-react";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { CopyButton } from "@/components/sandboxes/copy-button";
+import { userFacingApiMessage } from "@/lib/api/errors";
+import {
+ getSnapshot,
+ snapshotAliases,
+ snapshotId,
+ snapshotSourceSandboxId,
+} from "@/lib/api/snapshots";
+import { formatCpu, formatMiB } from "@/lib/format";
+import { LocalTime } from "@/components/local-time";
+import type { SnapshotInfo } from "@/lib/api/types";
+
+type SnapshotDetailParams = { snapshotId: string };
+type SnapshotDetailSearch = { sandboxID?: string };
+
+function Field({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+export default async function SnapshotDetailPage({
+ params,
+ searchParams,
+}: {
+ params: Promise;
+ searchParams: Promise;
+}) {
+ const { snapshotId: requestedId } = await params;
+ const { sandboxID: filterContext } = await searchParams;
+
+ let snapshot: SnapshotInfo | null = null;
+ let error: string | null = null;
+
+ try {
+ snapshot = await getSnapshot(requestedId);
+ } catch (caught) {
+ error = userFacingApiMessage(caught);
+ }
+
+ if (!snapshot) {
+ return (
+
+
}
+ >
+
+ Snapshots
+
+
+ Could not load snapshot
+
+ {error ?? "The Gateway returned no snapshot for this identifier."}
+
+
+
+ );
+ }
+
+ const id = snapshotId(snapshot) || requestedId;
+ const aliases = snapshotAliases(snapshot);
+ const title = aliases[0] ?? id;
+ const reportedSource = snapshotSourceSandboxId(snapshot);
+ const sourceSandbox = reportedSource ?? filterContext?.trim();
+
+ return (
+
+
}
+ >
+
+ Snapshots
+
+
+
+
+
+ }
+ >
+
+ Create sandbox
+
+
+
+
+
+
+ Identity
+
+ The snapshot ID is stable; aliases are optional labels assigned at
+ capture time.
+
+
+
+
+
+ {id}
+
+
+ {aliases.length === 0 ? (
+
+ No alias was assigned.
+
+ ) : (
+
+ {aliases.map((alias) => (
+
+ {alias}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ Resources
+
+ Sandboxes restored from this snapshot inherit these resources.
+
+
+
+
+ {formatCpu(snapshot.cpuCount)}
+ {formatMiB(snapshot.memoryMB)}
+ {formatMiB(snapshot.diskSizeMB)}
+
+
+
+
+
+
+ Timeline
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Provenance
+
+ Which sandbox this snapshot was captured from.
+
+
+
+ {sourceSandbox ? (
+
+
+
+
+ {sourceSandbox}
+
+
+
+
+ {reportedSource ? null : (
+
+ Carried over from the sandbox filter you browsed with, not
+ from the snapshot payload.
+
+ )}
+
+ ) : (
+
+ The snapshot payload does not carry a source sandbox. Filter the
+ list by a sandbox ID to see the snapshots it produced.
+
+ )}
+
+
+
+
+
+
+ Snapshots cannot be deleted from the console
+
+ The control-plane API exposes no delete endpoint for snapshots, so
+ they are read-only here.
+
+
+
+ );
+}
diff --git a/web/src/app/(console)/snapshots/page.tsx b/web/src/app/(console)/snapshots/page.tsx
new file mode 100644
index 00000000..87389547
--- /dev/null
+++ b/web/src/app/(console)/snapshots/page.tsx
@@ -0,0 +1,152 @@
+import Link from "next/link";
+import { CameraIcon, ChevronRightIcon, RotateCcwIcon } from "lucide-react";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { SnapshotFilters } from "@/components/snapshots/snapshot-filters";
+import { SnapshotTable } from "@/components/snapshots/snapshot-table";
+import { userFacingApiMessage } from "@/lib/api/errors";
+import { listSnapshots } from "@/lib/api/snapshots";
+import { DEFAULT_PAGE_LIMIT } from "@/lib/api/paging";
+import type { SnapshotInfo } from "@/lib/api/types";
+
+type SnapshotsSearchParams = {
+ name?: string;
+ sandboxID?: string;
+ nextToken?: string;
+};
+
+export const metadata = {
+ title: "Snapshots · AgentENV",
+};
+
+export default async function SnapshotsPage({
+ searchParams,
+}: {
+ searchParams: Promise;
+}) {
+ const params = await searchParams;
+ const name = params.name?.trim() ?? "";
+ const sandboxID = params.sandboxID?.trim() ?? "";
+ const nextToken = params.nextToken?.trim() || undefined;
+
+ let snapshots: SnapshotInfo[] = [];
+ let followingToken: string | undefined;
+ let error: string | null = null;
+
+ try {
+ const page = await listSnapshots({
+ name: name || undefined,
+ sandboxID: sandboxID || undefined,
+ nextToken,
+ limit: DEFAULT_PAGE_LIMIT,
+ });
+ snapshots = page.items;
+ followingToken = page.nextToken;
+ } catch (caught) {
+ error = userFacingApiMessage(caught);
+ }
+
+ const filtered = name !== "" || sandboxID !== "";
+ const nextPageQuery = new URLSearchParams();
+ if (name) {
+ nextPageQuery.set("name", name);
+ }
+ if (sandboxID) {
+ nextPageQuery.set("sandboxID", sandboxID);
+ }
+ const firstPageHref = nextPageQuery.toString()
+ ? `/snapshots?${nextPageQuery.toString()}`
+ : "/snapshots";
+ if (followingToken) {
+ nextPageQuery.set("nextToken", followingToken);
+ }
+
+ return (
+
+
+
Snapshots
+
+ Paused sandbox images captured through the snapshot API. Use one as
+ the starting point for a new sandbox.
+
+
+
+
+
+
+
+
+
+ {error ? (
+
+ Could not load snapshots
+ {error}
+
+ ) : snapshots.length === 0 ? (
+
+
+
+
+ {filtered ? "No matching snapshots" : "No snapshots yet"}
+
+
+ {filtered
+ ? "No snapshot matches the current filters on this page."
+ : "Snapshots appear here once a running sandbox is captured through the snapshot API."}
+
+ {filtered ? (
+ }
+ >
+
+ Clear filters
+
+ ) : null}
+
+
+ ) : (
+
+
+
+ )}
+
+ {(nextToken || followingToken) && !error ? (
+
+
+ Showing {snapshots.length} snapshot
+ {snapshots.length === 1 ? "" : "s"}
+ {nextToken ? " on a following page" : ""}.
+
+
+ {nextToken ? (
+ }
+ >
+ First page
+
+ ) : null}
+ {followingToken ? (
+ }
+ >
+ Next page
+
+
+ ) : null}
+
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/app/(console)/templates/[templateId]/page.tsx b/web/src/app/(console)/templates/[templateId]/page.tsx
new file mode 100644
index 00000000..e507f9aa
--- /dev/null
+++ b/web/src/app/(console)/templates/[templateId]/page.tsx
@@ -0,0 +1,304 @@
+import Link from "next/link";
+import { ArrowLeftIcon, ScrollTextIcon } from "lucide-react";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { BuildLogViewer } from "@/components/templates/build-log-viewer";
+import { BuildStatusBadge } from "@/components/templates/build-status-badge";
+import { TemplateActions } from "@/components/templates/template-actions";
+import { CopyButton } from "@/components/sandboxes/copy-button";
+import { userFacingApiMessage } from "@/lib/api/errors";
+import { getTemplate } from "@/lib/api/templates-server";
+import {
+ latestBuild,
+ sortBuildsByRecency,
+ templateNames,
+ type TemplateWithBuilds,
+} from "@/lib/api/templates";
+import { formatCpu, formatMiB, formatNumber } from "@/lib/format";
+import { LocalTime } from "@/components/local-time";
+
+type TemplateDetailParams = { templateId: string };
+type TemplateDetailSearch = { build?: string };
+
+function Field({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+export default async function TemplateDetailPage({
+ params,
+ searchParams,
+}: {
+ params: Promise;
+ searchParams: Promise;
+}) {
+ const { templateId: requestedId } = await params;
+ const { build: requestedBuild } = await searchParams;
+
+ let template: TemplateWithBuilds | null = null;
+ let error: string | null = null;
+
+ try {
+ template = await getTemplate(requestedId);
+ } catch (caught) {
+ error = userFacingApiMessage(caught);
+ }
+
+ if (!template) {
+ return (
+
+
}
+ >
+
+ Templates
+
+
+ Could not load template
+
+ {error ?? "The Gateway returned no template for this identifier."}
+
+
+
+ );
+ }
+
+ const templateID = template.templateID || requestedId;
+ const names = templateNames(template);
+ const title = names[0] ?? templateID;
+ const builds = sortBuildsByRecency(template.builds ?? []);
+ const newest = latestBuild(template);
+ const selectedBuildID = requestedBuild?.trim() || newest?.buildID;
+ const selectedBuild = builds.find(
+ (build) => build.buildID === selectedBuildID,
+ );
+
+ return (
+
+
}
+ >
+
+ Templates
+
+
+
+
+
+
{title}
+
+
+
+ {templateID}
+
+
+
+
+
+
+
+
+
+ Identity
+
+
+
+
+ {names.length === 0 ? (
+ —
+ ) : (
+
+ {names.map((name) => (
+
+ {name}
+
+ ))}
+
+ )}
+
+
+ {template.public ? "Public" : "Team only"}
+
+
+
+
+
+
+
+ Resources
+
+ Reported per build; these come from the most recent one.
+
+
+
+
+ {formatCpu(newest?.cpuCount)}
+ {formatMiB(newest?.memoryMB)}
+ {formatMiB(newest?.diskSizeMB)}
+
+
+
+
+
+
+ Usage
+
+
+
+ {formatNumber(builds.length)}
+ {formatNumber(template.spawnCount)}
+
+
+
+
+
+
+
+
+
+ Timeline
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Build history
+
+ AgentENV publishes one build per template, so a rebuild produces a
+ new template rather than another row here.
+
+
+ {builds.length === 0 ? (
+
+
+ No builds reported for this template.
+
+
+ ) : (
+
+
+
+ Build
+ Status
+ Resources
+ envd
+ Created
+ Finished
+ Logs
+
+
+
+ {builds.map((build) => (
+
+
+ {build.buildID}
+
+
+
+
+
+ {formatCpu(build.cpuCount)} · {formatMiB(build.memoryMB)}
+
+
+ {build.envdVersion ?? "—"}
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+ View
+
+
+
+ ))}
+
+
+ )}
+
+
+ {selectedBuildID ? (
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/app/(console)/templates/actions.ts b/web/src/app/(console)/templates/actions.ts
new file mode 100644
index 00000000..3bb821fd
--- /dev/null
+++ b/web/src/app/(console)/templates/actions.ts
@@ -0,0 +1,97 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+
+import { userFacingApiMessage } from "@/lib/api/errors";
+import {
+ createTemplate,
+ deleteTemplate,
+ getBuildStatus,
+ resolveTemplateAlias,
+ startTemplateBuild,
+} from "@/lib/api/templates-server";
+import type {
+ CreateTemplateRequest,
+ StartBuildRequest,
+ TemplateBuildInfo,
+} from "@/lib/api/templates";
+
+export type ActionResult =
+ | { ok: true; data: T }
+ | { ok: false; error: string };
+
+async function run(
+ operation: () => Promise,
+ revalidate: string[] = [],
+): Promise> {
+ try {
+ const data = await operation();
+ for (const path of revalidate) {
+ revalidatePath(path);
+ }
+ return { ok: true, data };
+ } catch (error) {
+ return { ok: false, error: userFacingApiMessage(error) };
+ }
+}
+
+export type BuildSubmission = {
+ template: CreateTemplateRequest;
+ build: StartBuildRequest;
+};
+
+/**
+ * Creating a template and starting its build are two calls, and the second one
+ * only queues the work — the server builds in the background, so callers poll
+ * the returned build for its status.
+ */
+export async function createAndBuildTemplateAction(
+ submission: BuildSubmission,
+): Promise> {
+ return run(async () => {
+ const created = await createTemplate(submission.template);
+ const templateID = created.templateID;
+ const buildID = created.buildID ?? created.templateID;
+ await startTemplateBuild(templateID, buildID, submission.build);
+ return { templateID, buildID };
+ }, ["/templates"]);
+}
+
+export async function deleteTemplateAction(
+ templateID: string,
+): Promise {
+ return run(
+ async () => {
+ await deleteTemplate(templateID);
+ return undefined;
+ },
+ ["/templates", `/templates/${templateID}`],
+ );
+}
+
+export async function fetchBuildStatusAction(
+ templateID: string,
+ buildID: string,
+): Promise> {
+ return run(() => getBuildStatus(templateID, buildID));
+}
+
+export async function resolveTemplateAliasAction(
+ alias: string,
+): Promise> {
+ return run(async () => {
+ const resolved = await resolveTemplateAlias(alias);
+ return {
+ templateID: resolved.templateID,
+ public: resolved.public ?? false,
+ };
+ });
+}
+
+/** Refreshes the server-rendered detail view once a polled build settles. */
+export async function revalidateTemplateAction(
+ templateID: string,
+): Promise {
+ revalidatePath("/templates");
+ revalidatePath(`/templates/${templateID}`);
+}
diff --git a/web/src/app/(console)/templates/new/page.tsx b/web/src/app/(console)/templates/new/page.tsx
new file mode 100644
index 00000000..6b850a17
--- /dev/null
+++ b/web/src/app/(console)/templates/new/page.tsx
@@ -0,0 +1,68 @@
+import Link from "next/link";
+import { ArrowLeftIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ TemplateBuildForm,
+ type BuildFormDefaults,
+} from "@/components/templates/template-build-form";
+
+type NewTemplateSearchParams = {
+ name?: string;
+ tags?: string;
+ cpuCount?: string;
+ memoryMB?: string;
+ fromImage?: string;
+ fromTemplate?: string;
+ rebuildFrom?: string;
+};
+
+export const metadata = {
+ title: "Build template · AgentENV",
+};
+
+export default async function NewTemplatePage({
+ searchParams,
+}: {
+ searchParams: Promise;
+}) {
+ const params = await searchParams;
+
+ const defaults: BuildFormDefaults = {
+ name: params.name,
+ tags: params.tags,
+ cpuCount: params.cpuCount,
+ memoryMB: params.memoryMB,
+ fromImage: params.fromImage,
+ fromTemplate: params.fromTemplate,
+ rebuildFrom: params.rebuildFrom,
+ baseKind: params.fromTemplate ? "template" : "image",
+ };
+
+ return (
+
+
}
+ >
+
+ Templates
+
+
+
+
+ Build a template
+
+
+ Describe the environment once; sandboxes then start from the published
+ snapshot instead of rebuilding it.
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/(console)/templates/page.tsx b/web/src/app/(console)/templates/page.tsx
new file mode 100644
index 00000000..3307d57d
--- /dev/null
+++ b/web/src/app/(console)/templates/page.tsx
@@ -0,0 +1,116 @@
+import Link from "next/link";
+import { ChevronRightIcon, LayersIcon, PlusIcon } from "lucide-react";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { TemplateTable } from "@/components/templates/template-table";
+import { userFacingApiMessage } from "@/lib/api/errors";
+import { MAX_PAGE_LIMIT } from "@/lib/api/paging";
+import { listTemplates } from "@/lib/api/templates-server";
+import type { ListedTemplate } from "@/lib/api/templates";
+
+type TemplatesSearchParams = {
+ nextToken?: string;
+};
+
+export const metadata = {
+ title: "Templates · AgentENV",
+};
+
+export default async function TemplatesPage({
+ searchParams,
+}: {
+ searchParams: Promise;
+}) {
+ const params = await searchParams;
+ const nextToken = params.nextToken?.trim() || undefined;
+
+ let templates: ListedTemplate[] = [];
+ let followingToken: string | undefined;
+ let error: string | null = null;
+
+ try {
+ const page = await listTemplates({ nextToken, limit: MAX_PAGE_LIMIT });
+ templates = page.items;
+ followingToken = page.nextToken;
+ } catch (caught) {
+ error = userFacingApiMessage(caught);
+ }
+
+ return (
+
+
+
+
Templates
+
+ Declaratively built base images that sandboxes start from.
+
+
+
}>
+
+ Build template
+
+
+
+ {error ? (
+
+ Could not load templates
+ {error}
+
+ ) : templates.length === 0 ? (
+
+
+
+ No templates yet
+
+ Build one from an OCI image or an existing template to give
+ sandboxes a pre-warmed environment.
+
+ }
+ >
+
+ Build template
+
+
+
+ ) : (
+
+ )}
+
+ {(nextToken || followingToken) && !error ? (
+
+ {nextToken ? (
+ }
+ >
+ First page
+
+ ) : null}
+ {followingToken ? (
+
+ }
+ >
+ Next page
+
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/app/api/connection/route.ts b/web/src/app/api/connection/route.ts
new file mode 100644
index 00000000..57f98ac4
--- /dev/null
+++ b/web/src/app/api/connection/route.ts
@@ -0,0 +1,92 @@
+import { NextResponse } from "next/server";
+
+import {
+ EMPTY_SESSION_SUMMARY,
+ type ConnectionApiResponse,
+} from "@/lib/api/connection";
+import {
+ probeConnection,
+ resolveConnectionInput,
+} from "@/lib/api/connection-server";
+import {
+ clearConnectionSession,
+ getConnectionSessionSummary,
+ setConnectionSession,
+ summarizeConnectionSession,
+} from "@/lib/session";
+
+async function errorResponse(
+ status: number,
+ message: string,
+): Promise> {
+ const payload: ConnectionApiResponse = {
+ session: await getConnectionSessionSummary(),
+ probe: null,
+ error: message,
+ };
+ return NextResponse.json(payload, { status });
+}
+
+export async function GET() {
+ try {
+ const payload: ConnectionApiResponse = {
+ session: await getConnectionSessionSummary(),
+ probe: null,
+ };
+ return NextResponse.json(payload);
+ } catch (error) {
+ console.error("connection GET failed", error);
+ return errorResponse(500, "Could not read the connection session.");
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const resolved = await resolveConnectionInput(request);
+ if (!resolved.ok) {
+ const payload: ConnectionApiResponse = {
+ session: await getConnectionSessionSummary(),
+ probe: null,
+ error: resolved.error,
+ };
+ return NextResponse.json(payload, { status: 400 });
+ }
+
+ const probe = await probeConnection(resolved.credentials);
+
+ if (probe.status === "disconnected" && !resolved.force) {
+ const payload: ConnectionApiResponse = {
+ session: await getConnectionSessionSummary(),
+ probe,
+ error: probe.summary,
+ };
+ return NextResponse.json(payload, { status: 400 });
+ }
+
+ await setConnectionSession(resolved.credentials);
+
+ const payload: ConnectionApiResponse = {
+ session: summarizeConnectionSession(resolved.credentials),
+ probe,
+ };
+ return NextResponse.json(payload);
+ } catch (error) {
+ console.error("connection POST failed", error);
+ return errorResponse(500, "Could not save the connection session.");
+ }
+}
+
+export async function DELETE() {
+ try {
+ await clearConnectionSession();
+
+ const payload: ConnectionApiResponse = {
+ session: EMPTY_SESSION_SUMMARY,
+ probe: null,
+ };
+ return NextResponse.json(payload);
+ } catch (error) {
+ console.error("connection DELETE failed", error);
+ return errorResponse(500, "Could not clear the connection session.");
+ }
+}
diff --git a/web/src/app/api/connection/validate/route.ts b/web/src/app/api/connection/validate/route.ts
new file mode 100644
index 00000000..a159610c
--- /dev/null
+++ b/web/src/app/api/connection/validate/route.ts
@@ -0,0 +1,43 @@
+/** Dry-run connectivity check. Probes without writing cookies. */
+
+import { NextResponse } from "next/server";
+
+import {
+ EMPTY_SESSION_SUMMARY,
+ type ConnectionApiResponse,
+} from "@/lib/api/connection";
+import {
+ probeConnection,
+ resolveConnectionInput,
+} from "@/lib/api/connection-server";
+import { getConnectionSessionSummary } from "@/lib/session";
+
+export async function POST(request: Request) {
+ try {
+ const resolved = await resolveConnectionInput(request);
+ const session = await getConnectionSessionSummary();
+
+ if (!resolved.ok) {
+ const payload: ConnectionApiResponse = {
+ session,
+ probe: null,
+ error: resolved.error,
+ };
+ return NextResponse.json(payload, { status: 400 });
+ }
+
+ const payload: ConnectionApiResponse = {
+ session,
+ probe: await probeConnection(resolved.credentials),
+ };
+ return NextResponse.json(payload);
+ } catch (error) {
+ console.error("connection validate failed", error);
+ const payload: ConnectionApiResponse = {
+ session: EMPTY_SESSION_SUMMARY,
+ probe: null,
+ error: "Could not validate the connection.",
+ };
+ return NextResponse.json(payload, { status: 500 });
+ }
+}
diff --git a/web/src/app/favicon.ico b/web/src/app/favicon.ico
new file mode 100644
index 00000000..718d6fea
Binary files /dev/null and b/web/src/app/favicon.ico differ
diff --git a/web/src/app/globals.css b/web/src/app/globals.css
new file mode 100644
index 00000000..b165cd8c
--- /dev/null
+++ b/web/src/app/globals.css
@@ -0,0 +1,183 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ /* `*-src` vars are injected by next/font in layout.tsx. Never point these at
+ their own name — a self-referential var() is invalid and silently falls
+ back to the browser default serif. */
+ --font-sans: var(--font-sans-src), ui-sans-serif, system-ui, sans-serif;
+ --font-mono: var(--font-mono-src), ui-monospace, "SF Mono", monospace;
+ --font-heading: var(--font-sans);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --radius-sm: calc(var(--radius) * 0.6);
+ --radius-md: calc(var(--radius) * 0.8);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) * 1.4);
+ --radius-2xl: calc(var(--radius) * 1.8);
+ --radius-3xl: calc(var(--radius) * 2.2);
+ --radius-4xl: calc(var(--radius) * 2.6);
+}
+
+/* Neutrals carry a slight cool cast (hue ~264) rather than being pure achromatic
+ gray — it keeps large dark surfaces from reading flat and muddy. */
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.17 0.012 264);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.17 0.012 264);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.17 0.012 264);
+ --primary: oklch(0.22 0.014 264);
+ --primary-foreground: oklch(0.985 0.002 264);
+ --secondary: oklch(0.968 0.004 264);
+ --secondary-foreground: oklch(0.22 0.014 264);
+ --muted: oklch(0.968 0.004 264);
+ --muted-foreground: oklch(0.545 0.016 264);
+ --accent: oklch(0.968 0.004 264);
+ --accent-foreground: oklch(0.22 0.014 264);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.918 0.005 264);
+ --input: oklch(0.918 0.005 264);
+ --ring: oklch(0.62 0.13 235);
+ --chart-1: oklch(0.62 0.13 235);
+ --chart-2: oklch(0.7 0.15 155);
+ --chart-3: oklch(0.75 0.14 85);
+ --chart-4: oklch(0.6 0.19 25);
+ --chart-5: oklch(0.55 0.02 264);
+ --radius: 0.5rem;
+ --sidebar: oklch(0.978 0.004 264);
+ --sidebar-foreground: oklch(0.17 0.012 264);
+ --sidebar-primary: oklch(0.62 0.13 235);
+ --sidebar-primary-foreground: oklch(0.985 0.002 264);
+ --sidebar-accent: oklch(0.945 0.006 264);
+ --sidebar-accent-foreground: oklch(0.17 0.012 264);
+ --sidebar-border: oklch(0.918 0.005 264);
+ --sidebar-ring: oklch(0.62 0.13 235);
+}
+
+.dark {
+ --background: oklch(0.178 0.009 264);
+ --foreground: oklch(0.965 0.003 264);
+ --card: oklch(0.212 0.01 264);
+ --card-foreground: oklch(0.965 0.003 264);
+ --popover: oklch(0.212 0.01 264);
+ --popover-foreground: oklch(0.965 0.003 264);
+ --primary: oklch(0.93 0.004 264);
+ --primary-foreground: oklch(0.19 0.011 264);
+ --secondary: oklch(0.272 0.012 264);
+ --secondary-foreground: oklch(0.965 0.003 264);
+ --muted: oklch(0.272 0.012 264);
+ --muted-foreground: oklch(0.705 0.016 264);
+ --accent: oklch(0.278 0.014 264);
+ --accent-foreground: oklch(0.98 0.003 264);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 9%);
+ --input: oklch(1 0 0 / 13%);
+ --ring: oklch(0.68 0.13 232);
+ --chart-1: oklch(0.68 0.13 232);
+ --chart-2: oklch(0.74 0.15 155);
+ --chart-3: oklch(0.8 0.14 85);
+ --chart-4: oklch(0.68 0.18 25);
+ --chart-5: oklch(0.62 0.02 264);
+ /* Sidebar recedes behind the content surface instead of floating above it. */
+ --sidebar: oklch(0.148 0.009 264);
+ --sidebar-foreground: oklch(0.9 0.005 264);
+ --sidebar-primary: oklch(0.68 0.13 232);
+ --sidebar-primary-foreground: oklch(0.985 0.002 264);
+ --sidebar-accent: oklch(0.268 0.014 264);
+ --sidebar-accent-foreground: oklch(0.985 0.002 264);
+ --sidebar-border: oklch(1 0 0 / 8%);
+ --sidebar-ring: oklch(0.68 0.13 232);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground font-sans;
+ /* Instrument Sans ships proportional figures by default; a console is
+ full of counters and timers that must not jitter as they tick. */
+ font-variant-numeric: tabular-nums;
+ font-feature-settings: "cv11", "ss01";
+ letter-spacing: -0.006em;
+ }
+ html {
+ @apply font-sans;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ }
+ /* Optical tracking: display sizes need to tighten, small text needs to open
+ up. A single `tracking-tight` on every heading does neither well. */
+ h1 {
+ letter-spacing: -0.025em;
+ }
+ h2,
+ h3 {
+ letter-spacing: -0.018em;
+ }
+ code,
+ pre,
+ kbd,
+ samp,
+ .font-mono {
+ font-feature-settings: "zero", "ss01";
+ letter-spacing: 0;
+ }
+ ::selection {
+ background: color-mix(in oklch, var(--ring) 32%, transparent);
+ }
+}
+
+/* Micro-label used for stat captions and section eyebrows. Mono + wide tracking
+ reads as instrumentation rather than as shrunken body copy. */
+@utility label-micro {
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+ line-height: 1.1;
+ font-weight: 500;
+ letter-spacing: 0.11em;
+ text-transform: uppercase;
+ font-feature-settings: "zero";
+}
+
+/* Large readouts: tabular, tight, and slightly optically enlarged. */
+@utility numeric-readout {
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.02em;
+ font-feature-settings: "zero", "ss01";
+}
\ No newline at end of file
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx
new file mode 100644
index 00000000..84312856
--- /dev/null
+++ b/web/src/app/layout.tsx
@@ -0,0 +1,42 @@
+import type { Metadata } from "next";
+import { Instrument_Sans, Spline_Sans_Mono } from "next/font/google";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import "./globals.css";
+
+const sans = Instrument_Sans({
+ variable: "--font-sans-src",
+ subsets: ["latin"],
+ display: "swap",
+});
+
+const mono = Spline_Sans_Mono({
+ variable: "--font-mono-src",
+ subsets: ["latin"],
+ display: "swap",
+});
+
+export const metadata: Metadata = {
+ title: "AgentENV Control Plane",
+ description:
+ "Web UI for managing AgentENV sandboxes, snapshots, templates, and nodes via the Gateway.",
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx
new file mode 100644
index 00000000..9ef12352
--- /dev/null
+++ b/web/src/app/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function HomePage() {
+ redirect("/dashboard");
+}
diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx
new file mode 100644
index 00000000..2010ff07
--- /dev/null
+++ b/web/src/components/app-sidebar.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import {
+ BoxIcon,
+ CameraIcon,
+ LayoutDashboardIcon,
+ ServerIcon,
+ SettingsIcon,
+ LayersIcon,
+} from "lucide-react";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarGroup,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarRail,
+ SidebarTrigger,
+} from "@/components/ui/sidebar";
+
+const NAV = [
+ { href: "/dashboard", label: "Dashboard", icon: LayoutDashboardIcon },
+ { href: "/nodes", label: "Nodes", icon: ServerIcon },
+ { href: "/sandboxes", label: "Sandboxes", icon: BoxIcon },
+ { href: "/snapshots", label: "Snapshots", icon: CameraIcon },
+ { href: "/templates", label: "Templates", icon: LayersIcon },
+ { href: "/settings", label: "Settings", icon: SettingsIcon },
+] as const;
+
+export function AppSidebar() {
+ const pathname = usePathname();
+
+ return (
+
+
+
+
+
+ AgentENV
+
+
+ Control Plane
+
+
+ {/* Desktop trigger lives inside the sidebar; the mobile sidebar is a
+ sheet, so its trigger has to stay in the top bar. */}
+
+
+
+
+
+
+ Manage
+
+
+ {NAV.map((item) => {
+ const active =
+ pathname === item.href || pathname.startsWith(`${item.href}/`);
+ return (
+
+ }
+ tooltip={item.label}
+ className="relative h-9 text-[0.8125rem] font-medium tracking-[-0.005em] text-sidebar-foreground/70 transition-colors data-active:text-sidebar-accent-foreground before:absolute before:top-1/2 before:left-0 before:h-4 before:w-0.5 before:-translate-y-1/2 before:rounded-r-full before:bg-sidebar-primary before:opacity-0 before:transition-opacity data-active:before:opacity-100"
+ >
+
+ {item.label}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/console-shell.tsx b/web/src/components/console-shell.tsx
new file mode 100644
index 00000000..70687eb1
--- /dev/null
+++ b/web/src/components/console-shell.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
+import { AppSidebar } from "@/components/app-sidebar";
+import { Toaster } from "@/components/ui/sonner";
+
+export function ConsoleShell({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+ {/* Desktop trigger is inside the sidebar; on mobile the sidebar is an
+ off-canvas sheet, so it needs a trigger out here to be reachable. */}
+
+
+ Gateway-backed management console
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/web/src/components/dashboard/attention-panel.tsx b/web/src/components/dashboard/attention-panel.tsx
new file mode 100644
index 00000000..db4fdfc8
--- /dev/null
+++ b/web/src/components/dashboard/attention-panel.tsx
@@ -0,0 +1,143 @@
+import type { ReactNode } from "react";
+import Link from "next/link";
+import { CircleCheckIcon, TriangleAlertIcon } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import type { LoadResult } from "@/lib/api/dashboard";
+import { summarizeTemplates, templateLabel } from "@/lib/api/dashboard";
+import { nodePressure, nodeStatus, type ClusterNode } from "@/lib/api/nodes";
+import type { TemplateInfo } from "@/lib/api/types";
+import { truncateId } from "@/components/dashboard/format";
+import {
+ NodeStatusBadge,
+ PressureBadge,
+} from "@/components/nodes/node-status-badge";
+
+type Problem = {
+ key: string;
+ title: ReactNode;
+ detail: string;
+ badge: ReactNode;
+};
+
+const PRESSURE_RANK = { critical: 0, warn: 1, ok: 2 } as const;
+
+function nodeProblems(nodes: LoadResult): Problem[] {
+ if (!nodes.ok) {
+ return [];
+ }
+
+ return nodes.data
+ .map((node) => ({ node, pressure: nodePressure(node) }))
+ .filter(({ pressure }) => pressure.level !== "ok")
+ .sort(
+ (a, b) =>
+ PRESSURE_RANK[a.pressure.level] - PRESSURE_RANK[b.pressure.level],
+ )
+ .slice(0, 6)
+ .map(({ node, pressure }) => ({
+ key: `node:${node.id}`,
+ title: (
+
+ {truncateId(node.id, 18)}
+
+ ),
+ detail: pressure.reasons.join(" · "),
+ badge:
+ pressure.level === "critical" ? (
+
+ ) : (
+
+ ),
+ }));
+}
+
+function templateProblems(templates: LoadResult): Problem[] {
+ if (!templates.ok) {
+ return [];
+ }
+
+ return summarizeTemplates(templates.data)
+ .failed.slice(0, 6)
+ .map((template) => ({
+ key: `template:${template.templateID ?? templateLabel(template)}`,
+ title: {templateLabel(template)} ,
+ detail: "Last build failed",
+ badge: (
+
+ Build error
+
+ ),
+ }));
+}
+
+/** Aggregates the things an operator should look at first. */
+export function AttentionPanel({
+ nodes,
+ templates,
+}: {
+ nodes: LoadResult;
+ templates: LoadResult;
+}) {
+ const problems = [...nodeProblems(nodes), ...templateProblems(templates)];
+ const unavailable = !nodes.ok || !templates.ok;
+
+ return (
+
+
+
+
+ Needs attention
+
+
+ Unhealthy or saturated nodes and failed template builds.
+
+
+
+ {problems.length === 0 ? (
+
+
+ Nothing needs attention right now.
+
+ ) : (
+
+ {problems.map((problem) => (
+
+
+ {problem.title}
+
+ {problem.detail}
+
+
+ {problem.badge}
+
+ ))}
+
+ )}
+ {unavailable ? (
+
+ {!nodes.ok ? "Node checks unavailable. " : ""}
+ {!templates.ok ? "Template build checks unavailable." : ""}
+
+ ) : null}
+
+
+ );
+}
diff --git a/web/src/components/dashboard/capacity-panel.tsx b/web/src/components/dashboard/capacity-panel.tsx
new file mode 100644
index 00000000..447ce7fa
--- /dev/null
+++ b/web/src/components/dashboard/capacity-panel.tsx
@@ -0,0 +1,90 @@
+import { GaugeIcon } from "lucide-react";
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import type { LoadResult } from "@/lib/api/dashboard";
+import { aggregateCapacity, type ClusterNode } from "@/lib/api/nodes";
+import { formatBytes, formatCount } from "@/components/dashboard/format";
+import {
+ EmptyState,
+ PanelNotice,
+ UsageBar,
+} from "@/components/dashboard/primitives";
+
+/** Host utilisation and sandbox reservations summed across every node. */
+export function CapacityPanel({ nodes }: { nodes: LoadResult }) {
+ return (
+
+
+
+
+ Cluster capacity
+
+
+ Host usage across all nodes. The marker shows CPU/memory reserved by
+ running sandboxes.
+
+
+
+ {!nodes.ok ? (
+
+ ) : nodes.data.length === 0 ? (
+ No nodes are reporting to this Gateway.
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function CapacityBars({ nodes }: { nodes: ClusterNode[] }) {
+ const capacity = aggregateCapacity(nodes);
+ const cpuUsed =
+ capacity.cpuPercent === null
+ ? undefined
+ : (capacity.cpuPercent / 100) * capacity.cpuCount;
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/web/src/components/dashboard/catalog-panel.tsx b/web/src/components/dashboard/catalog-panel.tsx
new file mode 100644
index 00000000..5f4cdd92
--- /dev/null
+++ b/web/src/components/dashboard/catalog-panel.tsx
@@ -0,0 +1,99 @@
+import Link from "next/link";
+import { CameraIcon, LayersIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import type { LoadResult } from "@/lib/api/dashboard";
+import { summarizeTemplates } from "@/lib/api/dashboard";
+import type { SnapshotInfo, TemplateInfo } from "@/lib/api/types";
+import { formatCount } from "@/components/dashboard/format";
+import { PanelNotice, StatTile } from "@/components/dashboard/primitives";
+
+function TemplateTiles({ templates }: { templates: TemplateInfo[] }) {
+ const summary = summarizeTemplates(templates);
+
+ return (
+
+
+ 0 ? "positive" : "neutral"}
+ />
+ 0 ? "warning" : "neutral"}
+ />
+
+ );
+}
+
+/** Template build health and snapshot inventory, both best-effort. */
+export function CatalogPanel({
+ templates,
+ snapshots,
+}: {
+ templates: LoadResult;
+ snapshots: LoadResult;
+}) {
+ return (
+
+
+
+
+ Templates & snapshots
+
+ Build state and stored images.
+
+ }
+ >
+ Templates
+
+
+
+
+ {templates.ok ? (
+
+ ) : (
+
+ )}
+
+ {snapshots.ok ? (
+
+
+
+ Snapshots
+
+
+
+ {formatCount(snapshots.data.length)}
+
+ }
+ >
+ View
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/dashboard/cluster-panel.tsx b/web/src/components/dashboard/cluster-panel.tsx
new file mode 100644
index 00000000..208e4602
--- /dev/null
+++ b/web/src/components/dashboard/cluster-panel.tsx
@@ -0,0 +1,115 @@
+import Link from "next/link";
+import { ServerIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import type { LoadResult } from "@/lib/api/dashboard";
+import {
+ aggregateCapacity,
+ countNodesByStatus,
+ NODE_STATUSES,
+ type ClusterNode,
+} from "@/lib/api/nodes";
+import { formatCount, formatPercent } from "@/components/dashboard/format";
+import { PanelNotice, StatTile } from "@/components/dashboard/primitives";
+import { NodeStatusBadge } from "@/components/nodes/node-status-badge";
+
+export function ClusterPanel({ nodes }: { nodes: LoadResult }) {
+ return (
+
+
+
+
+ Nodes
+
+ Cluster inventory by reported status.
+
+ }
+ >
+ View all
+
+
+
+
+ {!nodes.ok ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function NodeCounts({ nodes }: { nodes: ClusterNode[] }) {
+ const counts = countNodesByStatus(nodes);
+ const capacity = aggregateCapacity(nodes);
+ const createTotal = capacity.createSuccesses + capacity.createFails;
+ const failureRate =
+ createTotal > 0 ? (capacity.createFails / createTotal) * 100 : null;
+
+ return (
+
+
+
+ {formatCount(counts.total)}
+
+
+ node{counts.total === 1 ? "" : "s"} observed
+
+
+
+ {NODE_STATUSES.map((status) => (
+
+
+
+ {formatCount(counts[status])}
+
+
+ ))}
+ {counts.unknown > 0 ? (
+
+
+
+ {formatCount(counts.unknown)}
+
+
+ ) : null}
+
+
+
+ 0 ? "critical" : "neutral"}
+ />
+ = 5 ? "warning" : "neutral"
+ }
+ />
+
+
+ );
+}
diff --git a/web/src/components/dashboard/connection-panel.tsx b/web/src/components/dashboard/connection-panel.tsx
new file mode 100644
index 00000000..91936a2d
--- /dev/null
+++ b/web/src/components/dashboard/connection-panel.tsx
@@ -0,0 +1,66 @@
+import { ActivityIcon, CircleCheckIcon, CircleXIcon } from "lucide-react";
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+import type { DashboardData } from "@/lib/api/dashboard";
+import { formatCount } from "@/components/dashboard/format";
+
+export function ConnectionPanel({ data }: { data: DashboardData }) {
+ const healthy = data.health.ok;
+
+ return (
+
+
+
+
+ Gateway
+
+
+ {data.gatewayUrl ?? "—"}
+
+
+
+
+ {healthy ? (
+
+ ) : (
+
+ )}
+
+ {healthy ? "Healthy" : "Unreachable"}
+
+ {data.health.ok ? (
+
+ {formatCount(data.health.data.latencyMs)} ms
+
+ ) : null}
+
+ {!data.health.ok ? (
+ {data.health.message}
+ ) : null}
+
+
+ {data.adminTokenPresent ? "Admin token set" : "No admin token"}
+
+
+ {data.adminTokenPresent
+ ? "Node and capacity panels enabled."
+ : "Node and capacity panels are unavailable."}
+
+
+
+
+ );
+}
diff --git a/web/src/components/dashboard/format.ts b/web/src/components/dashboard/format.ts
new file mode 100644
index 00000000..405e8374
--- /dev/null
+++ b/web/src/components/dashboard/format.ts
@@ -0,0 +1,89 @@
+/** Formatting helpers shared by the dashboard and nodes views. */
+
+const BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] as const;
+
+export function formatBytes(bytes?: number, fractionDigits = 1): string {
+ if (typeof bytes !== "number" || !Number.isFinite(bytes)) {
+ return "—";
+ }
+ if (bytes < 1024) {
+ return `${Math.round(bytes)} B`;
+ }
+ let value = bytes;
+ let unitIndex = 0;
+ while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
+ value /= 1024;
+ unitIndex += 1;
+ }
+ const digits = value >= 100 ? 0 : fractionDigits;
+ return `${value.toFixed(digits)} ${BYTE_UNITS[unitIndex]}`;
+}
+
+export function formatMegabytes(megabytes?: number): string {
+ if (typeof megabytes !== "number" || !Number.isFinite(megabytes)) {
+ return "—";
+ }
+ return formatBytes(megabytes * 1024 * 1024);
+}
+
+export function formatCount(value?: number): string {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ return "—";
+ }
+ return new Intl.NumberFormat("en-US").format(value);
+}
+
+export function formatPercent(value?: number | null, digits = 0): string {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ return "—";
+ }
+ return `${value.toFixed(digits)}%`;
+}
+
+export function percentOf(used?: number, total?: number): number | null {
+ if (
+ typeof used !== "number" ||
+ typeof total !== "number" ||
+ !Number.isFinite(used) ||
+ !Number.isFinite(total) ||
+ total <= 0
+ ) {
+ return null;
+ }
+ return (used / total) * 100;
+}
+
+export function formatDuration(ms: number): string {
+ const abs = Math.abs(ms);
+ const seconds = Math.round(abs / 1000);
+ if (seconds < 60) {
+ return `${seconds}s`;
+ }
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) {
+ const rest = seconds % 60;
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
+ }
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) {
+ const rest = minutes % 60;
+ return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
+ }
+ const days = Math.floor(hours / 24);
+ const rest = hours % 24;
+ return rest === 0 ? `${days}d` : `${days}d ${rest}h`;
+}
+
+export function truncateId(id?: string, head = 12): string {
+ if (!id) {
+ return "—";
+ }
+ return id.length <= head + 3 ? id : `${id.slice(0, head)}…`;
+}
+
+export function shortCommit(commit?: string): string {
+ if (!commit) {
+ return "—";
+ }
+ return commit.length > 7 ? commit.slice(0, 7) : commit;
+}
diff --git a/web/src/components/dashboard/not-connected.tsx b/web/src/components/dashboard/not-connected.tsx
new file mode 100644
index 00000000..d1c7b644
--- /dev/null
+++ b/web/src/components/dashboard/not-connected.tsx
@@ -0,0 +1,39 @@
+import Link from "next/link";
+import { PlugZapIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+
+export function NotConnected({
+ title = "Not connected to a Gateway",
+ description = "Configure the Gateway URL and API key in Settings to load cluster health, node inventory, and sandbox activity.",
+}: {
+ title?: string;
+ description?: string;
+}) {
+ return (
+
+
+
+
+ {title}
+
+ {description}
+
+
+ }>
+ Open Settings
+
+
+ An admin token is optional, but required for node and capacity data.
+
+
+
+ );
+}
diff --git a/web/src/components/dashboard/primitives.tsx b/web/src/components/dashboard/primitives.tsx
new file mode 100644
index 00000000..676ac939
--- /dev/null
+++ b/web/src/components/dashboard/primitives.tsx
@@ -0,0 +1,195 @@
+import type { ReactNode } from "react";
+import Link from "next/link";
+import { LockIcon, TriangleAlertIcon } from "lucide-react";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { formatPercent, percentOf } from "@/components/dashboard/format";
+import type { LoadResult } from "@/lib/api/dashboard";
+
+export type Tone = "neutral" | "positive" | "warning" | "critical";
+
+const TONE_TEXT: Record = {
+ neutral: "text-foreground",
+ positive: "text-emerald-400",
+ warning: "text-amber-400",
+ critical: "text-destructive",
+};
+
+const TONE_BAR: Record = {
+ neutral: "bg-foreground/60",
+ positive: "bg-emerald-500",
+ warning: "bg-amber-500",
+ critical: "bg-destructive",
+};
+
+export function toneForPercent(percent: number | null): Tone {
+ if (percent === null) {
+ return "neutral";
+ }
+ if (percent >= 90) {
+ return "critical";
+ }
+ if (percent >= 75) {
+ return "warning";
+ }
+ return "positive";
+}
+
+export function StatTile({
+ label,
+ value,
+ hint,
+ tone = "neutral",
+ className,
+}: {
+ label: string;
+ value: ReactNode;
+ hint?: ReactNode;
+ tone?: Tone;
+ className?: string;
+}) {
+ return (
+
+
{label}
+
+ {value}
+
+ {hint ? (
+
{hint}
+ ) : null}
+
+ );
+}
+
+export function UsageBar({
+ label,
+ used,
+ total,
+ usedLabel,
+ totalLabel,
+ secondary,
+ secondaryLabel,
+ className,
+}: {
+ label: string;
+ used?: number;
+ total?: number;
+ usedLabel: string;
+ totalLabel: string;
+ secondary?: number;
+ secondaryLabel?: string;
+ className?: string;
+}) {
+ const percent = percentOf(used, total);
+ const secondaryPercent = percentOf(secondary, total);
+ const tone = toneForPercent(percent);
+
+ return (
+
+
+ {label}
+
+ {usedLabel}
+ / {totalLabel}
+
+
+
+
+ {secondaryPercent !== null ? (
+
+ ) : null}
+
+
+ {percent === null ? "no data" : formatPercent(percent)}
+ {secondaryLabel ? {secondaryLabel} : null}
+
+
+ );
+}
+
+export function DefinitionRow({
+ label,
+ children,
+ mono = false,
+}: {
+ label: string;
+ children: ReactNode;
+ mono?: boolean;
+}) {
+ return (
+
+
{label}
+
+ {children}
+
+
+ );
+}
+
+export function EmptyState({ children }: { children: ReactNode }) {
+ return (
+ {children}
+ );
+}
+
+export function PanelNotice({
+ result,
+ resourceLabel,
+}: {
+ result: { ok: false; status?: number; message: string };
+ resourceLabel: string;
+}) {
+ const needsAdminToken = result.status === 403 || result.status === 401;
+
+ return (
+
+ {needsAdminToken ? : }
+
+ {needsAdminToken
+ ? `Admin token required for ${resourceLabel}`
+ : `Could not load ${resourceLabel}`}
+
+
+ {result.message}
+ {needsAdminToken ? (
+ }
+ >
+ Add admin token
+
+ ) : null}
+
+
+ );
+}
+
+export function unwrap(result: LoadResult): T | null {
+ return result.ok ? result.data : null;
+}
diff --git a/web/src/components/dashboard/refresh-controls.tsx b/web/src/components/dashboard/refresh-controls.tsx
new file mode 100644
index 00000000..77ad7572
--- /dev/null
+++ b/web/src/components/dashboard/refresh-controls.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { useCallback, useEffect, useSyncExternalStore, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { RefreshCwIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { cn } from "@/lib/utils";
+import { LocalTime } from "@/components/local-time";
+
+const AUTO_REFRESH_MS = 15_000;
+
+/**
+ * `localStorage` is an external store, so the preference is read through
+ * `useSyncExternalStore` instead of an effect. Writes notify local subscribers
+ * because the `storage` event only fires in other tabs.
+ */
+const toggleListeners = new Set<() => void>();
+
+function subscribeToToggle(onChange: () => void) {
+ toggleListeners.add(onChange);
+ window.addEventListener("storage", onChange);
+ return () => {
+ toggleListeners.delete(onChange);
+ window.removeEventListener("storage", onChange);
+ };
+}
+
+function useAutoRefreshPreference(
+ storageKey: string,
+): [boolean, (next: boolean) => void] {
+ const enabled = useSyncExternalStore(
+ subscribeToToggle,
+ () => window.localStorage.getItem(storageKey) === "on",
+ () => false,
+ );
+
+ const setEnabled = useCallback(
+ (next: boolean) => {
+ window.localStorage.setItem(storageKey, next ? "on" : "off");
+ for (const listener of toggleListeners) {
+ listener();
+ }
+ },
+ [storageKey],
+ );
+
+ return [enabled, setEnabled];
+}
+
+/**
+ * Manual refresh plus an opt-in 15s auto refresh. Both re-run the server
+ * components for the current route, so every panel refetches from the Gateway.
+ */
+export function RefreshControls({
+ fetchedAt,
+ storageKey,
+ className,
+}: {
+ fetchedAt: string;
+ storageKey: string;
+ className?: string;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [autoRefresh, setAutoRefresh] = useAutoRefreshPreference(storageKey);
+ const switchId = `${storageKey}-auto-refresh`;
+
+ useEffect(() => {
+ if (!autoRefresh) {
+ return;
+ }
+ const timer = setInterval(() => {
+ startTransition(() => router.refresh());
+ }, AUTO_REFRESH_MS);
+ return () => clearInterval(timer);
+ }, [autoRefresh, router]);
+
+ return (
+
+
+ Updated
+
+
+
+
+ Auto {AUTO_REFRESH_MS / 1000}s
+
+
+
startTransition(() => router.refresh())}
+ >
+
+ Refresh
+
+
+ );
+}
diff --git a/web/src/components/dashboard/sandbox-panel.tsx b/web/src/components/dashboard/sandbox-panel.tsx
new file mode 100644
index 00000000..b2642bf5
--- /dev/null
+++ b/web/src/components/dashboard/sandbox-panel.tsx
@@ -0,0 +1,251 @@
+import type { ReactNode } from "react";
+import Link from "next/link";
+import { BoxIcon, ClockIcon, SparklesIcon } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { cn } from "@/lib/utils";
+import type { LoadResult } from "@/lib/api/dashboard";
+import { summarizeSandboxes } from "@/lib/api/dashboard";
+import type { SandboxInfo } from "@/lib/api/types";
+import {
+ formatCount,
+ formatMegabytes,
+ truncateId,
+} from "@/components/dashboard/format";
+import { LocalTime, RelativeTime } from "@/components/local-time";
+import {
+ EmptyState,
+ PanelNotice,
+ StatTile,
+} from "@/components/dashboard/primitives";
+
+/** `/v2/sandboxes` is paged; the dashboard only reads the first page. */
+const PAGE_LIMIT = 100;
+
+function templateLabelOf(sandbox: SandboxInfo): string {
+ if (typeof sandbox.alias === "string" && sandbox.alias) {
+ return sandbox.alias;
+ }
+ return sandbox.templateID ?? "—";
+}
+
+function SandboxStateBadge({ state }: { state?: string }) {
+ const normalized = (state ?? "").toLowerCase();
+ const className =
+ normalized === "running"
+ ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-500"
+ : normalized === "paused"
+ ? "border-sky-500/30 bg-sky-500/10 text-sky-500"
+ : "border-border bg-muted/40 text-muted-foreground";
+
+ return (
+
+ {normalized || "unknown"}
+
+ );
+}
+
+export function SandboxPanel({
+ sandboxes,
+}: {
+ sandboxes: LoadResult;
+}) {
+ return (
+
+
+
+
+ Sandboxes
+
+
+ Running and paused sandboxes reported by the Gateway.
+
+
+ }
+ >
+ View all
+
+
+
+
+ {!sandboxes.ok ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function SandboxTiles({ sandboxes }: { sandboxes: SandboxInfo[] }) {
+ const summary = summarizeSandboxes(sandboxes);
+
+ return (
+
+
+ 0 ? "positive" : "neutral"}
+ />
+
+
+
+
+ {summary.total >= PAGE_LIMIT ? (
+
+ Showing the first {PAGE_LIMIT} sandboxes — totals may be higher.
+
+ ) : null}
+
+ );
+}
+
+function SandboxTable({
+ sandboxes,
+ timeColumn,
+ timeValue,
+ emptyMessage,
+}: {
+ sandboxes: SandboxInfo[];
+ timeColumn: string;
+ timeValue: (sandbox: SandboxInfo) => string | undefined;
+ emptyMessage: ReactNode;
+}) {
+ if (sandboxes.length === 0) {
+ return {emptyMessage} ;
+ }
+
+ return (
+
+
+
+ Sandbox
+ Template
+ State
+ {timeColumn}
+
+
+
+ {sandboxes.map((sandbox) => (
+
+
+ {truncateId(sandbox.sandboxID, 10)}
+
+
+ {templateLabelOf(sandbox)}
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+export function SandboxActivityPanels({
+ sandboxes,
+}: {
+ sandboxes: LoadResult;
+}) {
+ const summary = sandboxes.ok
+ ? summarizeSandboxes(sandboxes.data)
+ : { recent: [], expiringSoon: [] };
+
+ return (
+ <>
+
+
+
+
+ Recently created
+
+ The five newest sandbox starts.
+
+
+ {!sandboxes.ok ? (
+
+ ) : (
+ sandbox.startedAt}
+ emptyMessage="No sandboxes have been created yet."
+ />
+ )}
+
+
+
+
+
+
+
+ Expiring soon
+
+
+ Sandboxes closest to their auto-eviction deadline.
+
+
+
+ {!sandboxes.ok ? (
+
+ ) : (
+ sandbox.endAt}
+ emptyMessage="No sandboxes have an upcoming expiry."
+ />
+ )}
+
+
+ >
+ );
+}
diff --git a/web/src/components/local-time.tsx b/web/src/components/local-time.tsx
new file mode 100644
index 00000000..6c146f19
--- /dev/null
+++ b/web/src/components/local-time.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { useEffect, useState, useSyncExternalStore } from "react";
+
+import { cn } from "@/lib/utils";
+import { formatDuration } from "@/components/dashboard/format";
+
+function parse(value?: string): Date | null {
+ if (!value) {
+ return null;
+ }
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
+const noopSubscribe = () => () => {};
+
+function useIsHydrated(): boolean {
+ return useSyncExternalStore(
+ noopSubscribe,
+ () => true,
+ () => false,
+ );
+}
+
+export function LocalTime({
+ value,
+ className,
+ dateStyle,
+ timeStyle,
+}: {
+ value?: string;
+ className?: string;
+ dateStyle?: Intl.DateTimeFormatOptions["dateStyle"];
+ timeStyle?: Intl.DateTimeFormatOptions["timeStyle"];
+}) {
+ const hydrated = useIsHydrated();
+ const date = parse(value);
+
+ if (!date) {
+ return — ;
+ }
+
+ const options: Intl.DateTimeFormatOptions =
+ dateStyle === undefined && timeStyle === undefined
+ ? { dateStyle: "medium", timeStyle: "medium" }
+ : { dateStyle, timeStyle };
+
+ const text = hydrated
+ ? new Intl.DateTimeFormat(undefined, options).format(date)
+ : (value ?? "—");
+
+ return (
+
+ {text}
+
+ );
+}
+
+export function RelativeTime({
+ value,
+ className,
+ refreshMs = 15_000,
+}: {
+ value?: string;
+ className?: string;
+ refreshMs?: number;
+}) {
+ const [now, setNow] = useState(null);
+
+ useEffect(() => {
+ const tick = () => setNow(Date.now());
+ const initial = setTimeout(tick, 0);
+ const timer = setInterval(tick, refreshMs);
+ return () => {
+ clearTimeout(initial);
+ clearInterval(timer);
+ };
+ }, [refreshMs]);
+
+ const date = parse(value);
+ if (!date) {
+ return — ;
+ }
+
+ const delta = now === null ? null : date.getTime() - now;
+ const label =
+ delta === null
+ ? "—"
+ : delta >= 0
+ ? `in ${formatDuration(delta)}`
+ : `${formatDuration(delta)} ago`;
+
+ return (
+
+ {label}
+
+ );
+}
diff --git a/web/src/components/nodes/node-filters.tsx b/web/src/components/nodes/node-filters.tsx
new file mode 100644
index 00000000..55530e4e
--- /dev/null
+++ b/web/src/components/nodes/node-filters.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { useEffect, useRef, useState, useTransition } from "react";
+import { usePathname, useRouter } from "next/navigation";
+import { SearchIcon } from "lucide-react";
+
+import { Input } from "@/components/ui/input";
+import { cn } from "@/lib/utils";
+import type { NodeStatusCounts } from "@/lib/api/nodes";
+import {
+ DISPLAY_NODE_STATUSES,
+ nodeStatusLabel,
+ type DisplayNodeStatus,
+} from "@/components/nodes/node-status-badge";
+
+export type NodeStatusFilter = DisplayNodeStatus | "all";
+
+const SEARCH_DEBOUNCE_MS = 250;
+
+export function NodeFilters({
+ query,
+ status,
+ counts,
+}: {
+ query: string;
+ status: NodeStatusFilter;
+ counts: NodeStatusCounts;
+}) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const [, startTransition] = useTransition();
+ const [draft, setDraft] = useState(query);
+ const [appliedQuery, setAppliedQuery] = useState(query);
+ const debounce = useRef | null>(null);
+
+ if (query !== appliedQuery) {
+ setAppliedQuery(query);
+ setDraft(query);
+ }
+
+ useEffect(
+ () => () => {
+ if (debounce.current) {
+ clearTimeout(debounce.current);
+ }
+ },
+ [],
+ );
+
+ const apply = (next: { query?: string; status?: NodeStatusFilter }) => {
+ const params = new URLSearchParams();
+ const nextQuery = next.query ?? query;
+ const nextStatus = next.status ?? status;
+ if (nextQuery.trim()) {
+ params.set("q", nextQuery.trim());
+ }
+ if (nextStatus !== "all") {
+ params.set("status", nextStatus);
+ }
+ const search = params.toString();
+ startTransition(() => {
+ router.replace(search ? `${pathname}?${search}` : pathname, {
+ scroll: false,
+ });
+ });
+ };
+
+ const onSearchChange = (value: string) => {
+ setDraft(value);
+ if (debounce.current) {
+ clearTimeout(debounce.current);
+ }
+ debounce.current = setTimeout(
+ () => apply({ query: value }),
+ SEARCH_DEBOUNCE_MS,
+ );
+ };
+
+ const chips: Array<{
+ value: NodeStatusFilter;
+ label: string;
+ count: number;
+ }> = [
+ { value: "all", label: "All", count: counts.total },
+ ...DISPLAY_NODE_STATUSES.map((value) => ({
+ value,
+ label: nodeStatusLabel(value),
+ count: counts[value],
+ })),
+ ];
+ if (counts.unknown > 0) {
+ chips.push({
+ value: "unknown",
+ label: nodeStatusLabel("unknown"),
+ count: counts.unknown,
+ });
+ }
+
+ return (
+
+
+
+ onSearchChange(event.target.value)}
+ placeholder="Filter by node, cluster, version…"
+ className="h-8 w-64 pl-8"
+ aria-label="Filter nodes"
+ />
+
+
+ {chips.map((chip) => (
+ apply({ status: chip.value })}
+ aria-pressed={chip.value === status}
+ className={cn(
+ "flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-xs transition-colors",
+ chip.value === status
+ ? "border-foreground/20 bg-muted text-foreground"
+ : "border-transparent text-muted-foreground hover:bg-muted/50",
+ )}
+ >
+ {chip.label}
+ {chip.count}
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/components/nodes/node-status-badge.tsx b/web/src/components/nodes/node-status-badge.tsx
new file mode 100644
index 00000000..f32b92f1
--- /dev/null
+++ b/web/src/components/nodes/node-status-badge.tsx
@@ -0,0 +1,95 @@
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+import type { NodeStatus } from "@/lib/api/types";
+import type { PressureLevel } from "@/lib/api/nodes";
+
+export type DisplayNodeStatus = NodeStatus | "unknown";
+
+/**
+ * Display order for status filters. Kept here rather than imported from
+ * `@/lib/api/nodes` so client components never pull the server-only Gateway
+ * client (and therefore `next/headers`) into their bundle.
+ */
+export const DISPLAY_NODE_STATUSES = [
+ "ready",
+ "draining",
+ "connecting",
+ "unhealthy",
+] as const satisfies readonly NodeStatus[];
+
+const STATUS_STYLE: Record<
+ DisplayNodeStatus,
+ { label: string; dot: string; className: string }
+> = {
+ ready: {
+ label: "Ready",
+ dot: "bg-emerald-500",
+ className: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500",
+ },
+ connecting: {
+ label: "Connecting",
+ dot: "bg-sky-500",
+ className: "border-sky-500/30 bg-sky-500/10 text-sky-500",
+ },
+ draining: {
+ label: "Draining",
+ dot: "bg-amber-500",
+ className: "border-amber-500/30 bg-amber-500/10 text-amber-500",
+ },
+ unhealthy: {
+ label: "Unhealthy",
+ dot: "bg-red-500",
+ className: "border-red-500/30 bg-red-500/10 text-red-500",
+ },
+ unknown: {
+ label: "Unknown",
+ dot: "bg-muted-foreground",
+ className: "border-border bg-muted/40 text-muted-foreground",
+ },
+};
+
+export function nodeStatusLabel(status: DisplayNodeStatus): string {
+ return STATUS_STYLE[status].label;
+}
+
+export function NodeStatusBadge({
+ status,
+ className,
+}: {
+ status: DisplayNodeStatus;
+ className?: string;
+}) {
+ const style = STATUS_STYLE[status];
+ return (
+
+
+ {style.label}
+
+ );
+}
+
+const PRESSURE_STYLE: Record = {
+ ok: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500",
+ warn: "border-amber-500/30 bg-amber-500/10 text-amber-500",
+ critical: "border-red-500/30 bg-red-500/10 text-red-500",
+};
+
+const PRESSURE_LABEL: Record = {
+ ok: "Healthy",
+ warn: "Degraded",
+ critical: "At risk",
+};
+
+export function PressureBadge({
+ level,
+ className,
+}: {
+ level: PressureLevel;
+ className?: string;
+}) {
+ return (
+
+ {PRESSURE_LABEL[level]}
+
+ );
+}
diff --git a/web/src/components/nodes/nodes-table.tsx b/web/src/components/nodes/nodes-table.tsx
new file mode 100644
index 00000000..0d29a65a
--- /dev/null
+++ b/web/src/components/nodes/nodes-table.tsx
@@ -0,0 +1,190 @@
+import Link from "next/link";
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { cn } from "@/lib/utils";
+import {
+ nodePressure,
+ nodeStatus,
+ type ClusterNode,
+ type ClusterNodeMetrics,
+} from "@/lib/api/nodes";
+import {
+ formatBytes,
+ formatCount,
+ formatPercent,
+ percentOf,
+ shortCommit,
+ truncateId,
+} from "@/components/dashboard/format";
+import { toneForPercent } from "@/components/dashboard/primitives";
+import { NodeStatusBadge } from "@/components/nodes/node-status-badge";
+
+const TONE_TEXT = {
+ neutral: "text-foreground",
+ positive: "text-foreground",
+ warning: "text-amber-400",
+ critical: "text-destructive",
+} as const;
+
+function nodeHref(node: ClusterNode): string {
+ const path = `/nodes/${encodeURIComponent(node.id)}`;
+ return node.clusterID
+ ? `${path}?cluster=${encodeURIComponent(node.clusterID)}`
+ : path;
+}
+
+function MeterCell({
+ percent,
+ primary,
+ secondary,
+}: {
+ percent: number | null;
+ primary: string;
+ secondary: string;
+}) {
+ const tone = toneForPercent(percent);
+
+ return (
+
+
+ {primary}
+
+ {percent === null ? "—" : formatPercent(percent)}
+
+
+
+
{secondary}
+
+ );
+}
+
+function cpuCell(metrics?: ClusterNodeMetrics) {
+ const cpuCount = metrics?.cpuCount ?? 0;
+ const percent =
+ typeof metrics?.cpuPercent === "number" ? metrics.cpuPercent : null;
+ return (
+
+ );
+}
+
+function memoryCell(metrics?: ClusterNodeMetrics) {
+ const percent = percentOf(
+ metrics?.memoryUsedBytes,
+ metrics?.memoryTotalBytes,
+ );
+ return (
+
+ );
+}
+
+export function NodesTable({ nodes }: { nodes: ClusterNode[] }) {
+ return (
+
+
+
+ Node
+ Status
+ Version
+ Sandboxes
+ CPU
+ Memory
+
+
+
+ {nodes.map((node) => {
+ const status = nodeStatus(node);
+ const pressure = nodePressure(node);
+ const unhealthy = pressure.level === "critical";
+
+ return (
+
+
+
+ {truncateId(node.id, 20)}
+
+
+ cluster {truncateId(node.clusterID, 12)}
+
+
+
+
+ {pressure.level !== "ok" && pressure.reasons.length > 0 ? (
+
+ {pressure.reasons.join(" · ")}
+
+ ) : null}
+
+
+ {node.version || "—"}
+
+ {shortCommit(node.commit)}
+
+
+
+ {formatCount(node.sandboxCount ?? 0)} running
+
+ {formatCount(node.sandboxStartingCount ?? 0)} starting ·{" "}
+ {formatCount(node.sandboxPausedCount ?? 0)} paused
+
+
+
+ {cpuCell(node.metrics)}
+
+
+ {memoryCell(node.metrics)}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/components/sandboxes/copy-button.tsx b/web/src/components/sandboxes/copy-button.tsx
new file mode 100644
index 00000000..49aae4f2
--- /dev/null
+++ b/web/src/components/sandboxes/copy-button.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { useState } from "react";
+import { CheckIcon, CopyIcon } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+type CopyButtonProps = {
+ value: string;
+ label?: string;
+ className?: string;
+ size?: "icon-xs" | "icon-sm" | "icon";
+};
+
+export function CopyButton({
+ value,
+ label = "identifier",
+ className,
+ size = "icon-xs",
+}: CopyButtonProps) {
+ const [copied, setCopied] = useState(false);
+
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ toast.error(`Could not copy ${label} to the clipboard.`);
+ }
+ }
+
+ return (
+ {
+ event.stopPropagation();
+ void copy();
+ }}
+ >
+ {copied ? : }
+
+ );
+}
diff --git a/web/src/components/sandboxes/create-sandbox-form.tsx b/web/src/components/sandboxes/create-sandbox-form.tsx
new file mode 100644
index 00000000..1a57b602
--- /dev/null
+++ b/web/src/components/sandboxes/create-sandbox-form.tsx
@@ -0,0 +1,1009 @@
+"use client";
+
+import { useMemo, useState, useTransition } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import {
+ ArrowLeftIcon,
+ ClockIcon,
+ HardDriveIcon,
+ PlusIcon,
+ RocketIcon,
+ TrashIcon,
+ TriangleAlertIcon,
+} from "lucide-react";
+import { toast } from "sonner";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ createColdSandboxAction,
+ createSandboxAction,
+} from "@/app/(console)/sandboxes/actions";
+import {
+ parseCommaSeparated,
+ parseJsonObject,
+ parseKeyValueLines,
+} from "@/components/sandboxes/format";
+import type {
+ AttachedDriveInput,
+ NewColdSandboxRequest,
+ NewSandboxRequest,
+ SandboxNetworkConfig,
+} from "@/lib/api/sandboxes";
+
+export type CreateMode = "template" | "cold";
+export type SourceKind = "template" | "snapshot";
+
+type InternetAccess = "default" | "allow" | "block";
+type OnTimeout = "pause" | "kill";
+
+const SOURCE_KIND_LABELS: Record = {
+ template: "Template",
+ snapshot: "Snapshot",
+};
+
+const ON_TIMEOUT_LABELS: Record = {
+ pause: "Pause (resumable)",
+ kill: "Kill (discard)",
+};
+
+const INTERNET_LABELS: Record = {
+ default: "Server default",
+ allow: "Allow internet",
+ block: "Block internet",
+};
+
+type DriveDraft = {
+ key: string;
+ driveID: string;
+ image: string;
+ mountPath: string;
+ subPath: string;
+ readOnly: boolean;
+ diskSizeMB: string;
+};
+
+type Errors = Record;
+
+function newDrive(): DriveDraft {
+ return {
+ key: Math.random().toString(36).slice(2),
+ driveID: "",
+ image: "",
+ mountPath: "",
+ subPath: "",
+ readOnly: true,
+ diskSizeMB: "",
+ };
+}
+
+type NumberField = { value?: number; error?: string };
+
+function parseNumber(
+ raw: string,
+ options: { min?: number; required?: boolean; label: string },
+): NumberField {
+ const trimmed = raw.trim();
+ if (trimmed === "") {
+ return options.required
+ ? { error: `${options.label} is required.` }
+ : {};
+ }
+ const value = Number(trimmed);
+ if (!Number.isInteger(value)) {
+ return { error: `${options.label} must be a whole number.` };
+ }
+ if (options.min !== undefined && value < options.min) {
+ return { error: `${options.label} must be at least ${options.min}.` };
+ }
+ return { value };
+}
+
+function Field({
+ label,
+ htmlFor,
+ hint,
+ error,
+ children,
+}: {
+ label: string;
+ htmlFor?: string;
+ hint?: string;
+ error?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
{label}
+ {children}
+ {error ? (
+
{error}
+ ) : hint ? (
+
{hint}
+ ) : null}
+
+ );
+}
+
+function ToggleRow({
+ label,
+ hint,
+ checked,
+ onCheckedChange,
+}: {
+ label: string;
+ hint?: string;
+ checked: boolean;
+ onCheckedChange: (checked: boolean) => void;
+}) {
+ return (
+
+
+ {label}
+ {hint ? (
+ {hint}
+ ) : null}
+
+
+
+ );
+}
+
+export type CreateSandboxFormProps = {
+ initialMode: CreateMode;
+ initialSourceKind: SourceKind;
+ initialSourceId: string;
+ initialImage: string;
+};
+
+export function CreateSandboxForm({
+ initialMode,
+ initialSourceKind,
+ initialSourceId,
+ initialImage,
+}: CreateSandboxFormProps) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [attempted, setAttempted] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+
+ const [mode, setMode] = useState(initialMode);
+
+ const [sourceKind, setSourceKind] = useState(initialSourceKind);
+ const [sourceId, setSourceId] = useState(initialSourceId);
+ const [secure, setSecure] = useState(false);
+
+ const [image, setImage] = useState(initialImage);
+ const [cpuCount, setCpuCount] = useState("2");
+ const [memoryMB, setMemoryMB] = useState("2048");
+ const [diskSizeMB, setDiskSizeMB] = useState("");
+ const [extraBootArgs, setExtraBootArgs] = useState("");
+ const [drives, setDrives] = useState([]);
+
+ const [timeoutSeconds, setTimeoutSeconds] = useState("300");
+ const [onTimeout, setOnTimeout] = useState("pause");
+ const [autoResume, setAutoResume] = useState(false);
+
+ const [envVarsText, setEnvVarsText] = useState("");
+ const [metadataText, setMetadataText] = useState("");
+ const [customParamsText, setCustomParamsText] = useState("");
+
+ const [internetAccess, setInternetAccess] = useState("default");
+ const [allowPublicTraffic, setAllowPublicTraffic] = useState(true);
+ const [allowOutText, setAllowOutText] = useState("");
+ const [denyOutText, setDenyOutText] = useState("");
+ const [maskRequestHost, setMaskRequestHost] = useState("");
+
+ const built = useMemo(() => {
+ const errors: Errors = {};
+
+ const timeout = parseNumber(timeoutSeconds, {
+ min: 0,
+ required: true,
+ label: "Timeout",
+ });
+ if (timeout.error) {
+ errors.timeout = timeout.error;
+ }
+
+ const envVars = parseKeyValueLines(envVarsText);
+ if (!envVars.ok) {
+ errors.envVars = envVars.error;
+ }
+ const metadata = parseKeyValueLines(metadataText);
+ if (!metadata.ok) {
+ errors.metadata = metadata.error;
+ }
+ const customParams = parseJsonObject(customParamsText);
+ if (!customParams.ok) {
+ errors.customParams = customParams.error;
+ }
+
+ const allowOut = parseCommaSeparated(allowOutText);
+ const denyOut = parseCommaSeparated(denyOutText);
+ const maskHost = maskRequestHost.trim();
+
+ const network: SandboxNetworkConfig = {};
+ if (!allowPublicTraffic) {
+ network.allowPublicTraffic = false;
+ }
+ if (allowOut.length > 0) {
+ network.allowOut = allowOut;
+ }
+ if (denyOut.length > 0) {
+ network.denyOut = denyOut;
+ }
+ if (maskHost !== "") {
+ network.maskRequestHost = maskHost;
+ }
+ const hasNetwork = Object.keys(network).length > 0;
+
+ const shared = {
+ timeout: timeout.value,
+ autoPause: onTimeout === "pause",
+ autoResume: autoResume ? { enabled: true } : undefined,
+ network: hasNetwork ? network : undefined,
+ metadata:
+ metadata.ok && Object.keys(metadata.value).length > 0
+ ? metadata.value
+ : undefined,
+ envVars:
+ envVars.ok && Object.keys(envVars.value).length > 0
+ ? envVars.value
+ : undefined,
+ customExtensionParams:
+ customParams.ok && Object.keys(customParams.value).length > 0
+ ? customParams.value
+ : undefined,
+ };
+
+ const internet =
+ internetAccess === "default" ? undefined : internetAccess === "allow";
+
+ if (mode === "template") {
+ const trimmedSource = sourceId.trim();
+ if (trimmedSource === "") {
+ errors.sourceId = `${SOURCE_KIND_LABELS[sourceKind]} ID or alias is required.`;
+ }
+
+ const body: NewSandboxRequest = {
+ templateID: trimmedSource,
+ ...shared,
+ secure: secure ? true : undefined,
+ allow_internet_access: internet,
+ };
+ return { errors, endpoint: "POST /sandboxes", body };
+ }
+
+ const trimmedImage = image.trim();
+ if (trimmedImage === "") {
+ errors.image = "OCI image reference is required.";
+ }
+
+ const cpu = parseNumber(cpuCount, { min: 1, label: "CPU count" });
+ if (cpu.error) {
+ errors.cpuCount = cpu.error;
+ }
+ const memory = parseNumber(memoryMB, { min: 128, label: "Memory" });
+ if (memory.error) {
+ errors.memoryMB = memory.error;
+ }
+ const disk = parseNumber(diskSizeMB, { min: 0, label: "Disk size" });
+ if (disk.error) {
+ errors.diskSizeMB = disk.error;
+ }
+
+ const attachedDrives: AttachedDriveInput[] = [];
+ const seenDriveIds = new Set();
+ for (const [index, drive] of drives.entries()) {
+ const driveID = drive.driveID.trim();
+ const driveImage = drive.image.trim();
+ if (driveID === "") {
+ errors[`drive-${index}`] = "Drive ID is required.";
+ } else if (driveID.includes("/")) {
+ errors[`drive-${index}`] = "Drive ID must not contain '/'.";
+ } else if (seenDriveIds.has(driveID)) {
+ errors[`drive-${index}`] = "Drive IDs must be unique.";
+ } else if (driveImage === "") {
+ errors[`drive-${index}`] = "Drive image is required.";
+ } else if (drive.mountPath.trim() !== "" && !drive.mountPath.trim().startsWith("/")) {
+ errors[`drive-${index}`] = "Mount path must be absolute.";
+ } else if (drive.subPath.trim().startsWith("/")) {
+ errors[`drive-${index}`] = "Sub path must be relative.";
+ }
+ seenDriveIds.add(driveID);
+
+ const driveDisk = parseNumber(drive.diskSizeMB, {
+ min: 0,
+ label: "Drive disk size",
+ });
+ if (driveDisk.error) {
+ errors[`drive-${index}`] = driveDisk.error;
+ }
+
+ if (driveID !== "" && driveImage !== "") {
+ attachedDrives.push({
+ driveID,
+ source: { image: driveImage },
+ readOnly: drive.readOnly,
+ mountPath: drive.mountPath.trim() || undefined,
+ subPath: drive.subPath.trim() || undefined,
+ diskSizeMB: driveDisk.value,
+ });
+ }
+ }
+
+ const body: NewColdSandboxRequest = {
+ image: trimmedImage,
+ ...shared,
+ allowInternetAccess: internet,
+ cpuCount: cpu.value,
+ memoryMB: memory.value,
+ diskSizeMB: disk.value,
+ attachedDrives: attachedDrives.length > 0 ? attachedDrives : undefined,
+ extraBootArgs: extraBootArgs.trim() || undefined,
+ };
+ return { errors, endpoint: "POST /sandboxes-cold", body };
+ }, [
+ mode,
+ sourceId,
+ sourceKind,
+ secure,
+ image,
+ cpuCount,
+ memoryMB,
+ diskSizeMB,
+ drives,
+ extraBootArgs,
+ timeoutSeconds,
+ onTimeout,
+ autoResume,
+ envVarsText,
+ metadataText,
+ customParamsText,
+ internetAccess,
+ allowPublicTraffic,
+ allowOutText,
+ denyOutText,
+ maskRequestHost,
+ ]);
+
+ const errorList = Object.values(built.errors);
+ const showError = (key: string) =>
+ attempted ? built.errors[key] : undefined;
+
+ function submit() {
+ setAttempted(true);
+ setSubmitError(null);
+
+ if (errorList.length > 0) {
+ toast.error(
+ `Resolve ${errorList.length} issue${errorList.length === 1 ? "" : "s"} before creating the sandbox.`,
+ );
+ return;
+ }
+
+ startTransition(async () => {
+ const result =
+ mode === "template"
+ ? await createSandboxAction(built.body as NewSandboxRequest)
+ : await createColdSandboxAction(built.body as NewColdSandboxRequest);
+
+ if (!result.ok) {
+ setSubmitError(result.error);
+ toast.error(result.error);
+ return;
+ }
+
+ toast.success(`Sandbox ${result.data.sandboxID} created.`);
+ router.push(`/sandboxes/${encodeURIComponent(result.data.sandboxID)}`);
+ });
+ }
+
+ return (
+
+
+
+
}
+ >
+
+ Sandboxes
+
+
+ Create sandbox
+
+
+ Start from an existing template or snapshot, or cold-start straight
+ from an OCI image.
+
+
+
+
+
setMode(value as CreateMode)}
+ className="gap-4"
+ >
+
+ Template / snapshot
+ Cold start (OCI image)
+
+
+
+
+
+ Source
+
+ The Gateway resolves templates and snapshots through the same
+ identifier, so an ID or alias both work here.
+
+
+
+
+ {
+ if (value) {
+ setSourceKind(value);
+ }
+ }}
+ >
+
+
+ {(value) => SOURCE_KIND_LABELS[value as SourceKind]}
+
+
+
+ {Object.entries(SOURCE_KIND_LABELS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ setSourceId(event.target.value)}
+ placeholder={
+ sourceKind === "snapshot" ? "my-snapshot:v1" : "base"
+ }
+ className="font-mono text-xs"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ Cold starts can be slow
+
+ On a cache miss the node pulls and converts the OCI layers before
+ boot, which can take tens of seconds. Keep this tab open until the
+ request returns.
+
+
+
+
+
+ Image and resources
+
+ Resources apply to the cold-started VM. Leave disk size empty to
+ use the server default.
+
+
+
+
+ setImage(event.target.value)}
+ placeholder="ghcr.io/org/image:tag"
+ className="font-mono text-xs"
+ />
+
+
+
+ setCpuCount(event.target.value)}
+ />
+
+
+ setMemoryMB(event.target.value)}
+ />
+
+
+ setDiskSizeMB(event.target.value)}
+ placeholder="server default"
+ />
+
+
+
+
+
+
+
+
+
+ Attached drives
+
+
+ Extra block drives resolved from OCI images and mounted in the
+ guest. Their state is captured if the sandbox is snapshotted.
+
+
+
+ {drives.length === 0 ? (
+
+ No attached drives.
+
+ ) : (
+ drives.map((drive, index) => (
+
+
+
+ Drive {index + 1}
+
+
+ setDrives((current) =>
+ current.filter((item) => item.key !== drive.key),
+ )
+ }
+ >
+
+
+
+
+
+
+ setDrives((current) =>
+ current.map((item) =>
+ item.key === drive.key
+ ? { ...item, driveID: event.target.value }
+ : item,
+ ),
+ )
+ }
+ placeholder="data"
+ className="font-mono text-xs"
+ />
+
+
+
+ setDrives((current) =>
+ current.map((item) =>
+ item.key === drive.key
+ ? { ...item, image: event.target.value }
+ : item,
+ ),
+ )
+ }
+ placeholder="ghcr.io/org/data:latest"
+ className="font-mono text-xs"
+ />
+
+
+
+ setDrives((current) =>
+ current.map((item) =>
+ item.key === drive.key
+ ? { ...item, mountPath: event.target.value }
+ : item,
+ ),
+ )
+ }
+ placeholder="/mnt/data"
+ className="font-mono text-xs"
+ />
+
+
+
+ setDrives((current) =>
+ current.map((item) =>
+ item.key === drive.key
+ ? { ...item, subPath: event.target.value }
+ : item,
+ ),
+ )
+ }
+ placeholder="subdir"
+ className="font-mono text-xs"
+ />
+
+
+
+ setDrives((current) =>
+ current.map((item) =>
+ item.key === drive.key
+ ? { ...item, diskSizeMB: event.target.value }
+ : item,
+ ),
+ )
+ }
+ />
+
+
+ setDrives((current) =>
+ current.map((item) =>
+ item.key === drive.key
+ ? { ...item, readOnly: checked }
+ : item,
+ ),
+ )
+ }
+ />
+
+ {showError(`drive-${index}`) ? (
+
+ {showError(`drive-${index}`)}
+
+ ) : null}
+
+ ))
+ )}
+
+
setDrives((current) => [...current, newDrive()])}
+ >
+
+ Add drive
+
+
+
+
+
+
+
+ Advanced boot
+
+ Extra kernel command-line arguments. The server applies its own
+ allowlist and silently drops non-matching arguments.
+
+
+
+
+ setExtraBootArgs(event.target.value)}
+ placeholder="quiet loglevel=3"
+ className="font-mono text-xs"
+ />
+
+
+
+
+
+
+
+
+
+
+ Lifecycle
+
+
+ How long the sandbox lives and what happens when the timeout fires.
+
+
+
+
+ setTimeoutSeconds(event.target.value)}
+ />
+
+
+ {
+ if (value) {
+ setOnTimeout(value);
+ }
+ }}
+ >
+
+
+ {(value) => ON_TIMEOUT_LABELS[value as OnTimeout]}
+
+
+
+ {Object.entries(ON_TIMEOUT_LABELS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ Network
+
+ Egress rules and proxy exposure. Allow entries take precedence over
+ deny entries.
+
+
+
+
+
+ {
+ if (value) {
+ setInternetAccess(value);
+ }
+ }}
+ >
+
+
+ {(value) => INTERNET_LABELS[value as InternetAccess]}
+
+
+
+ {Object.entries(INTERNET_LABELS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ setMaskRequestHost(event.target.value)}
+ placeholder="example.com"
+ className="font-mono text-xs"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Environment and metadata
+
+ Env vars and metadata use KEY=value lines. Custom extension params
+ are opaque JSON interpreted only by the configured extension.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Request summary
+
+ Exactly what the console will send to{" "}
+ {built.endpoint} .
+
+
+
+
+ {JSON.stringify(built.body, null, 2)}
+
+ {attempted && errorList.length > 0 ? (
+
+
+
+ {errorList.length} issue{errorList.length === 1 ? "" : "s"} to fix
+
+
+
+ {errorList.map((message) => (
+ {message}
+ ))}
+
+
+
+ ) : null}
+ {submitError ? (
+
+
+ Create failed
+ {submitError}
+
+ ) : null}
+
+
+
+
+ }>
+ Cancel
+
+
+
+ {pending ? "Creating…" : "Create sandbox"}
+
+
+
+ );
+}
diff --git a/web/src/components/sandboxes/format.ts b/web/src/components/sandboxes/format.ts
new file mode 100644
index 00000000..4a5bd914
--- /dev/null
+++ b/web/src/components/sandboxes/format.ts
@@ -0,0 +1,137 @@
+/** Presentation helpers shared by the sandbox list, detail, and create views. */
+
+/** Sandboxes within this window of their expiration are surfaced as at-risk. */
+export const EXPIRING_SOON_MS = 5 * 60 * 1000;
+
+export function formatDuration(ms: number): string {
+ const totalSeconds = Math.floor(Math.abs(ms) / 1000);
+ const days = Math.floor(totalSeconds / 86_400);
+ const hours = Math.floor((totalSeconds % 86_400) / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ if (days > 0) {
+ return `${days}d ${hours}h`;
+ }
+ if (hours > 0) {
+ return `${hours}h ${minutes}m`;
+ }
+ if (minutes > 0) {
+ return `${minutes}m ${seconds}s`;
+ }
+ return `${seconds}s`;
+}
+
+export function formatRelative(iso: string | undefined, now: number): string {
+ if (!iso) {
+ return "—";
+ }
+ const time = new Date(iso).getTime();
+ if (Number.isNaN(time)) {
+ return iso;
+ }
+ const delta = time - now;
+ return delta >= 0
+ ? `in ${formatDuration(delta)}`
+ : `${formatDuration(delta)} ago`;
+}
+
+export function millisUntil(iso: string | undefined, now: number): number | null {
+ if (!iso) {
+ return null;
+ }
+ const time = new Date(iso).getTime();
+ if (Number.isNaN(time)) {
+ return null;
+ }
+ return time - now;
+}
+
+export function formatMemoryMB(memoryMB: number | undefined): string {
+ if (memoryMB === undefined || memoryMB === null) {
+ return "—";
+ }
+ if (memoryMB >= 1024) {
+ const gib = memoryMB / 1024;
+ return `${Number.isInteger(gib) ? gib : gib.toFixed(1)} GiB`;
+ }
+ return `${memoryMB} MiB`;
+}
+
+export function formatCpu(cpuCount: number | undefined): string {
+ if (cpuCount === undefined || cpuCount === null) {
+ return "—";
+ }
+ return `${cpuCount} vCPU`;
+}
+
+/** Truncates long identifiers for table cells while keeping both ends legible. */
+export function shortId(id: string, head = 10, tail = 4): string {
+ if (id.length <= head + tail + 1) {
+ return id;
+ }
+ return `${id.slice(0, head)}…${id.slice(-tail)}`;
+}
+
+export type ParsedPairs =
+ | { ok: true; value: Record }
+ | { ok: false; error: string };
+
+/** Parses `KEY=value` lines used by the env var and metadata editors. */
+export function parseKeyValueLines(raw: string): ParsedPairs {
+ const value: Record = {};
+ const lines = raw.split("\n");
+
+ for (const [index, line] of lines.entries()) {
+ const trimmed = line.trim();
+ if (trimmed === "" || trimmed.startsWith("#")) {
+ continue;
+ }
+ const separator = trimmed.indexOf("=");
+ if (separator <= 0) {
+ return {
+ ok: false,
+ error: `Line ${index + 1} must use KEY=value format.`,
+ };
+ }
+ const key = trimmed.slice(0, separator).trim();
+ if (key === "") {
+ return { ok: false, error: `Line ${index + 1} is missing a key.` };
+ }
+ value[key] = trimmed.slice(separator + 1).trim();
+ }
+
+ return { ok: true, value };
+}
+
+export function parseCommaSeparated(raw: string): string[] {
+ return raw
+ .split(/[\n,]/)
+ .map((entry) => entry.trim())
+ .filter((entry) => entry !== "");
+}
+
+export type ParsedJsonObject =
+ | { ok: true; value: Record }
+ | { ok: false; error: string };
+
+export function parseJsonObject(raw: string): ParsedJsonObject {
+ const trimmed = raw.trim();
+ if (trimmed === "") {
+ return { ok: true, value: {} };
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch {
+ return { ok: false, error: "Not valid JSON." };
+ }
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ return { ok: false, error: "Must be a JSON object." };
+ }
+ return { ok: true, value: parsed as Record };
+}
+
+export function isEmptyObject(value: Record): boolean {
+ return Object.keys(value).length === 0;
+}
diff --git a/web/src/components/sandboxes/sandbox-actions.tsx b/web/src/components/sandboxes/sandbox-actions.tsx
new file mode 100644
index 00000000..20d3eb95
--- /dev/null
+++ b/web/src/components/sandboxes/sandbox-actions.tsx
@@ -0,0 +1,641 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import {
+ CameraIcon,
+ CopyPlusIcon,
+ NetworkIcon,
+ PauseIcon,
+ PlayIcon,
+ RefreshCwIcon,
+ TimerIcon,
+ Trash2Icon,
+ TriangleAlertIcon,
+} from "lucide-react";
+import { toast } from "sonner";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ createSandboxSnapshotAction,
+ forkSandboxAction,
+ killSandboxAction,
+ pauseSandboxAction,
+ resumeSandboxAction,
+ setSandboxTimeoutAction,
+ updateSandboxNetworkAction,
+} from "@/app/(console)/sandboxes/actions";
+import { parseCommaSeparated } from "@/components/sandboxes/format";
+import type { SandboxDetail, SandboxNetworkUpdate } from "@/lib/api/sandboxes";
+
+/** The fork endpoint accepts up to 100; the console caps it to a safer batch. */
+const MAX_FORKS = 16;
+
+type InternetAccess = "unchanged" | "allow" | "block";
+
+const INTERNET_LABELS: Record = {
+ unchanged: "Leave unset",
+ allow: "Allow internet",
+ block: "Block internet",
+};
+
+function positiveInteger(raw: string): number | null {
+ const value = Number(raw.trim());
+ return Number.isInteger(value) && value >= 0 ? value : null;
+}
+
+export function SandboxActions({ sandbox }: { sandbox: SandboxDetail }) {
+ const router = useRouter();
+ const [busy, setBusy] = useState(null);
+
+ const [timeoutOpen, setTimeoutOpen] = useState(false);
+ const [timeoutValue, setTimeoutValue] = useState("300");
+
+ const [resumeOpen, setResumeOpen] = useState(false);
+ const [resumeTimeout, setResumeTimeout] = useState("300");
+
+ const [forkOpen, setForkOpen] = useState(false);
+ const [forkCount, setForkCount] = useState("1");
+ const [forkTimeout, setForkTimeout] = useState("");
+ const [forkConfirmed, setForkConfirmed] = useState(false);
+
+ const [snapshotOpen, setSnapshotOpen] = useState(false);
+ const [snapshotName, setSnapshotName] = useState("");
+
+ const [networkOpen, setNetworkOpen] = useState(false);
+ const [allowOut, setAllowOut] = useState(
+ (sandbox.network?.allowOut ?? []).join("\n"),
+ );
+ const [denyOut, setDenyOut] = useState(
+ (sandbox.network?.denyOut ?? []).join("\n"),
+ );
+ const [internetAccess, setInternetAccess] = useState(
+ sandbox.allowInternetAccess === true
+ ? "allow"
+ : sandbox.allowInternetAccess === false
+ ? "block"
+ : "unchanged",
+ );
+ const [networkConfirmed, setNetworkConfirmed] = useState(false);
+
+ const [killOpen, setKillOpen] = useState(false);
+
+ const running = sandbox.state === "running";
+ const paused = sandbox.state === "paused";
+ const plannedForks = positiveInteger(forkCount);
+
+ async function withBusy(key: string, work: () => Promise) {
+ if (busy) {
+ return;
+ }
+ setBusy(key);
+ try {
+ await work();
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ function handlePause() {
+ void withBusy("pause", async () => {
+ const result = await pauseSandboxAction(sandbox.sandboxID);
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ toast.success("Sandbox paused.");
+ router.refresh();
+ });
+ }
+
+ function handleResume() {
+ const timeout = positiveInteger(resumeTimeout);
+ if (timeout === null) {
+ toast.error("Timeout must be a whole number of seconds.");
+ return;
+ }
+ void withBusy("resume", async () => {
+ const result = await resumeSandboxAction(sandbox.sandboxID, timeout);
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ setResumeOpen(false);
+ toast.success("Sandbox resumed.");
+ router.refresh();
+ });
+ }
+
+ function handleTimeout() {
+ const timeout = positiveInteger(timeoutValue);
+ if (timeout === null) {
+ toast.error("Timeout must be a whole number of seconds.");
+ return;
+ }
+ void withBusy("timeout", async () => {
+ const result = await setSandboxTimeoutAction(sandbox.sandboxID, timeout);
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ setTimeoutOpen(false);
+ toast.success(`Expiration set to ${timeout}s from now.`);
+ router.refresh();
+ });
+ }
+
+ function handleFork() {
+ const count = positiveInteger(forkCount);
+ if (count === null || count < 1 || count > MAX_FORKS) {
+ toast.error(`Fork count must be between 1 and ${MAX_FORKS}.`);
+ return;
+ }
+ if (count > 1 && !forkConfirmed) {
+ toast.error("Confirm that multiple sandboxes will be created.");
+ return;
+ }
+ const timeout =
+ forkTimeout.trim() === "" ? undefined : positiveInteger(forkTimeout);
+ if (timeout === null) {
+ toast.error("Fork timeout must be a whole number of seconds.");
+ return;
+ }
+
+ void withBusy("fork", async () => {
+ const result = await forkSandboxAction(sandbox.sandboxID, count, timeout);
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ const { createdSandboxIDs, failures } = result.data;
+ setForkOpen(false);
+ setForkConfirmed(false);
+ if (createdSandboxIDs.length === 0) {
+ toast.error(`All ${failures.length} forks failed to start.`, {
+ description: failures[0],
+ });
+ } else if (failures.length > 0) {
+ toast.warning(
+ `${createdSandboxIDs.length} of ${count} forks started.`,
+ { description: failures[0] },
+ );
+ } else {
+ toast.success(
+ `${createdSandboxIDs.length} fork${createdSandboxIDs.length === 1 ? "" : "s"} started.`,
+ );
+ }
+ router.refresh();
+ });
+ }
+
+ function handleSnapshot() {
+ void withBusy("snapshot", async () => {
+ const result = await createSandboxSnapshotAction(
+ sandbox.sandboxID,
+ snapshotName.trim() || undefined,
+ );
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ setSnapshotOpen(false);
+ setSnapshotName("");
+ toast.success("Snapshot created.", {
+ description: result.data.snapshotID,
+ });
+ router.refresh();
+ });
+ }
+
+ function handleNetwork() {
+ if (!networkConfirmed) {
+ toast.error("Confirm that the current egress rules will be replaced.");
+ return;
+ }
+ const update: SandboxNetworkUpdate = {};
+ const allow = parseCommaSeparated(allowOut);
+ const deny = parseCommaSeparated(denyOut);
+ if (allow.length > 0) {
+ update.allowOut = allow;
+ }
+ if (deny.length > 0) {
+ update.denyOut = deny;
+ }
+ if (internetAccess !== "unchanged") {
+ update.allow_internet_access = internetAccess === "allow";
+ }
+
+ void withBusy("network", async () => {
+ const result = await updateSandboxNetworkAction(
+ sandbox.sandboxID,
+ update,
+ );
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ setNetworkOpen(false);
+ setNetworkConfirmed(false);
+ toast.success("Network configuration replaced.");
+ router.refresh();
+ });
+ }
+
+ function handleKill() {
+ void withBusy("kill", async () => {
+ const result = await killSandboxAction(sandbox.sandboxID);
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ setKillOpen(false);
+ toast.success("Sandbox killed.");
+ router.push("/sandboxes");
+ });
+ }
+
+ return (
+
+
{
+ router.refresh();
+ toast.info("Refreshed.");
+ }}
+ >
+
+ Refresh
+
+
+ {running ? (
+ <>
+
+
+ {busy === "pause" ? "Pausing…" : "Pause"}
+
+
setTimeoutOpen(true)}
+ >
+
+ Set timeout
+
+
setForkOpen(true)}
+ >
+
+ Fork
+
+
setSnapshotOpen(true)}
+ >
+
+ Snapshot
+
+
setNetworkOpen(true)}
+ >
+
+ Network
+
+ >
+ ) : null}
+
+ {paused ? (
+
setResumeOpen(true)}
+ >
+
+ Resume
+
+ ) : null}
+
+
setKillOpen(true)}
+ >
+
+ Kill
+
+
+
+
+
+ Set timeout
+
+ The sandbox expires this many seconds from now. Each call resets
+ the countdown.
+
+
+
+ Timeout (seconds)
+ setTimeoutValue(event.target.value)}
+ />
+
+
+ setTimeoutOpen(false)}
+ disabled={busy !== null}
+ >
+ Cancel
+
+
+ {busy === "timeout" ? "Saving…" : "Set timeout"}
+
+
+
+
+
+
+
+
+ Resume sandbox
+
+ Resumes through the connect endpoint, which restores the paused VM
+ and extends its time to live.
+
+
+
+ Timeout (seconds)
+ setResumeTimeout(event.target.value)}
+ />
+
+
+ setResumeOpen(false)}
+ disabled={busy !== null}
+ >
+ Cancel
+
+
+ {busy === "resume" ? "Resuming…" : "Resume"}
+
+
+
+
+
+
+
+
+ Fork sandbox
+
+ This sandbox is briefly paused and snapshotted in place, then the
+ forks boot from that snapshot. Its own ID and expiration are
+ unchanged.
+
+
+
+ {plannedForks !== null && plannedForks > 1 ? (
+
+ setForkConfirmed(checked === true)}
+ className="mt-0.5"
+ />
+
+ Create {plannedForks} new sandboxes. Each one consumes the same
+ CPU and memory as this sandbox.
+
+
+ ) : null}
+
+ setForkOpen(false)}
+ disabled={busy !== null}
+ >
+ Cancel
+
+
+ {busy === "fork" ? "Forking…" : "Fork"}
+
+
+
+
+
+
+
+
+ Create snapshot
+
+ Persists the current sandbox state as a snapshot that outlives
+ this sandbox. Reusing a name adds a build to that snapshot.
+
+
+
+ Name (optional)
+ setSnapshotName(event.target.value)}
+ placeholder="my-snapshot"
+ className="font-mono text-xs"
+ />
+
+
+ setSnapshotOpen(false)}
+ disabled={busy !== null}
+ >
+ Cancel
+
+
+ {busy === "snapshot" ? "Snapshotting…" : "Create snapshot"}
+
+
+
+
+
+
+
+
+ Replace network configuration
+
+ The current egress rules are replaced wholesale — anything left
+ blank here is cleared on the sandbox.
+
+
+
+
+ Allow egress to
+
+
+ Deny egress to
+
+
+ Internet access
+ {
+ if (value) {
+ setInternetAccess(value);
+ }
+ }}
+ >
+
+
+ {(value) => INTERNET_LABELS[value as InternetAccess]}
+
+
+
+ {Object.entries(INTERNET_LABELS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+ setNetworkConfirmed(checked === true)
+ }
+ className="mt-0.5"
+ />
+ Replace the sandbox's current egress rules.
+
+
+
+ setNetworkOpen(false)}
+ disabled={busy !== null}
+ >
+ Cancel
+
+
+ {busy === "network" ? "Applying…" : "Replace rules"}
+
+
+
+
+
+
+
+
+
+
+
+ Kill this sandbox?
+
+ The VM is destroyed immediately and its unsnapshotted state is
+ lost. This cannot be undone.
+
+
+
+ Cancel
+
+ {busy === "kill" ? "Killing…" : "Kill sandbox"}
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/sandboxes/sandbox-badges.tsx b/web/src/components/sandboxes/sandbox-badges.tsx
new file mode 100644
index 00000000..799a5107
--- /dev/null
+++ b/web/src/components/sandboxes/sandbox-badges.tsx
@@ -0,0 +1,130 @@
+import Link from "next/link";
+
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+import { EXPIRING_SOON_MS, formatRelative, millisUntil } from "@/components/sandboxes/format";
+import type { SandboxLifecycleState } from "@/lib/api/sandboxes";
+
+export function SandboxStateBadge({
+ state,
+ className,
+}: {
+ state: SandboxLifecycleState | string;
+ className?: string;
+}) {
+ const normalized = state?.toLowerCase();
+
+ const tone =
+ normalized === "running"
+ ? {
+ badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-300",
+ dot: "bg-emerald-400",
+ }
+ : normalized === "paused"
+ ? {
+ badge: "border-amber-500/30 bg-amber-500/10 text-amber-300",
+ dot: "bg-amber-400",
+ }
+ : {
+ badge: "border-border bg-muted/40 text-muted-foreground",
+ dot: "bg-muted-foreground",
+ };
+
+ return (
+
+
+ {state || "unknown"}
+
+ );
+}
+
+export function ExpiryBadge({ endAt, now }: { endAt?: string; now: number | null }) {
+ if (!endAt) {
+ return — ;
+ }
+ if (now === null) {
+ return … ;
+ }
+
+ const remaining = millisUntil(endAt, now);
+ if (remaining === null) {
+ return — ;
+ }
+ if (remaining <= 0) {
+ return expired ;
+ }
+
+ return (
+
+ {formatRelative(endAt, now)}
+
+ );
+}
+
+export function SandboxSourceLink({
+ templateID,
+ alias,
+}: {
+ templateID?: string;
+ alias?: string;
+}) {
+ if (!templateID) {
+ return — ;
+ }
+
+ return (
+
+
+ {templateID}
+
+ {alias ? (
+
+ {alias}
+
+ ) : null}
+
+ );
+}
+
+export function MetadataChips({
+ metadata,
+ limit = 2,
+}: {
+ metadata?: Record;
+ limit?: number;
+}) {
+ const entries = Object.entries(metadata ?? {});
+ if (entries.length === 0) {
+ return — ;
+ }
+
+ const shown = entries.slice(0, limit);
+ const hidden = entries.length - shown.length;
+
+ return (
+
+ {shown.map(([key, value]) => (
+
+
+ {key}={value}
+
+
+ ))}
+ {hidden > 0 ? (
+ +{hidden}
+ ) : null}
+
+ );
+}
diff --git a/web/src/components/sandboxes/sandbox-detail-view.tsx b/web/src/components/sandboxes/sandbox-detail-view.tsx
new file mode 100644
index 00000000..ebd31017
--- /dev/null
+++ b/web/src/components/sandboxes/sandbox-detail-view.tsx
@@ -0,0 +1,466 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import {
+ ArrowLeftIcon,
+ EyeIcon,
+ EyeOffIcon,
+ PencilIcon,
+ TriangleAlertIcon,
+} from "lucide-react";
+import { toast } from "sonner";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { patchCustomExtensionParamsAction } from "@/app/(console)/sandboxes/actions";
+import { CopyButton } from "@/components/sandboxes/copy-button";
+import {
+ ExpiryBadge,
+ SandboxStateBadge,
+} from "@/components/sandboxes/sandbox-badges";
+import { SandboxActions } from "@/components/sandboxes/sandbox-actions";
+import {
+ formatCpu,
+ formatMemoryMB,
+ formatRelative,
+ parseJsonObject,
+} from "@/components/sandboxes/format";
+import { LocalTime } from "@/components/local-time";
+import { useNow } from "@/components/sandboxes/use-now";
+import type { CustomExtensionParams, SandboxDetail } from "@/lib/api/sandboxes";
+
+function DetailRow({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+function Mono({ value }: { value?: string | null }) {
+ if (!value) {
+ return — ;
+ }
+ return (
+
+ {value}
+
+
+ );
+}
+
+function SecretRow({ label, value }: { label: string; value?: string | null }) {
+ const [revealed, setRevealed] = useState(false);
+
+ return (
+
+ {value ? (
+
+
+ {revealed ? value : "•".repeat(Math.min(value.length, 24))}
+
+ setRevealed((current) => !current)}
+ >
+ {revealed ? : }
+
+
+
+ ) : (
+ —
+ )}
+
+ );
+}
+
+function BoolBadge({ value }: { value?: boolean | null }) {
+ if (value === undefined || value === null) {
+ return not set ;
+ }
+ return (
+
+ {value ? "enabled" : "disabled"}
+
+ );
+}
+
+function RuleList({ rules }: { rules?: string[] }) {
+ if (!rules || rules.length === 0) {
+ return — ;
+ }
+ return (
+
+ {rules.map((rule) => (
+
+ {rule}
+
+ ))}
+
+ );
+}
+
+function ExtensionParamsCard({
+ sandboxID,
+ initialParams,
+ loadError,
+ canPatch,
+}: {
+ sandboxID: string;
+ initialParams: CustomExtensionParams | null;
+ loadError?: string;
+ canPatch: boolean;
+}) {
+ const router = useRouter();
+ const [params, setParams] = useState(initialParams);
+ const [open, setOpen] = useState(false);
+ const [patchText, setPatchText] = useState("{}");
+ const [pending, setPending] = useState(false);
+
+ function submitPatch() {
+ const parsed = parseJsonObject(patchText);
+ if (!parsed.ok) {
+ toast.error(parsed.error);
+ return;
+ }
+ if (pending) {
+ return;
+ }
+
+ setPending(true);
+ void (async () => {
+ try {
+ const result = await patchCustomExtensionParamsAction(
+ sandboxID,
+ parsed.value,
+ );
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ setParams(result.data);
+ setOpen(false);
+ toast.success("Custom extension params updated.");
+ router.refresh();
+ } finally {
+ setPending(false);
+ }
+ })();
+ }
+
+ return (
+
+
+ Custom extension params
+
+ Opaque JSON interpreted only by the configured custom extension.
+
+
+
+ {loadError ? (
+ {loadError}
+ ) : (
+
+ {JSON.stringify(params ?? {}, null, 2)}
+
+ )}
+ {canPatch && !loadError ? (
+ setOpen(true)}>
+
+ Patch params
+
+ ) : null}
+
+
+
+
+ Patch custom extension params
+
+ The document is passed to the extension verbatim; the extension
+ decides how to merge it and returns the full updated params.
+
+
+
+ Patch document (JSON)
+
+
+ setOpen(false)}
+ disabled={pending}
+ >
+ Cancel
+
+
+ {pending ? "Applying…" : "Apply patch"}
+
+
+
+
+
+
+ );
+}
+
+export type SandboxDetailViewProps = {
+ sandbox: SandboxDetail;
+ extensionParams: CustomExtensionParams | null;
+ extensionError?: string;
+ fetchedAt: string;
+};
+
+export function SandboxDetailView({
+ sandbox,
+ extensionParams,
+ extensionError,
+ fetchedAt,
+}: SandboxDetailViewProps) {
+ const now = useNow();
+ const metadataEntries = Object.entries(sandbox.metadata ?? {});
+ const expired =
+ now !== null && sandbox.endAt
+ ? new Date(sandbox.endAt).getTime() <= now
+ : false;
+
+ return (
+
+
+
}
+ >
+
+ Sandboxes
+
+
+
+
+
+ {sandbox.sandboxID}
+
+
+
+
+
+ {sandbox.alias ? `${sandbox.alias} · ` : ""}
+ {sandbox.templateID} · loaded{" "}
+ {now === null ? "just now" : formatRelative(fetchedAt, now)}
+
+
+
+
+
+
+ {expired ? (
+
+
+ Past its expiration
+
+ This sandbox reached its end time. Depending on its timeout policy
+ it may already be paused or killed — refresh to re-read state.
+
+
+ ) : null}
+
+
+
+
+ Identity and source
+
+
+
+
+
+
+
+
+
+ {sandbox.alias ? (
+ {sandbox.alias}
+ ) : (
+ —
+ )}
+
+
+ {sandbox.envdVersion ?? (
+ —
+ )}
+
+
+ {sandbox.clientID ?? (
+ —
+ )}
+
+
+
+
+
+
+ Resources
+
+
+ {formatCpu(sandbox.cpuCount)}
+
+ {formatMemoryMB(sandbox.memoryMB)}
+
+
+ {formatMemoryMB(sandbox.diskSizeMB)}
+
+
+
+
+
+
+ Lifecycle
+
+ Timeout policy and the current expiration window.
+
+
+
+
+
+
+
+ {sandbox.lifecycle?.onTimeout ? (
+
+ {sandbox.lifecycle.onTimeout}
+
+ ) : (
+ —
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Network
+
+ Egress policy currently applied to the sandbox.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {sandbox.network?.maskRequestHost ? (
+
+ {sandbox.network.maskRequestHost}
+
+ ) : (
+ —
+ )}
+
+
+
+
+
+
+ Connection
+
+ Proxy domain and tokens are hidden until you reveal them.
+
+
+
+
+
+
+
+
+
+
+ Metadata
+
+ {metadataEntries.length} key
+ {metadataEntries.length === 1 ? "" : "s"} attached at creation.
+
+
+
+ {metadataEntries.length === 0 ? (
+ No metadata.
+ ) : (
+ metadataEntries.map(([key, value]) => (
+
+ {value}
+
+ ))
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/sandboxes/sandbox-list.tsx b/web/src/components/sandboxes/sandbox-list.tsx
new file mode 100644
index 00000000..8f4f4d6e
--- /dev/null
+++ b/web/src/components/sandboxes/sandbox-list.tsx
@@ -0,0 +1,431 @@
+"use client";
+
+import { useMemo, useState, useTransition } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import {
+ ArrowRightIcon,
+ PlusIcon,
+ RefreshCwIcon,
+ SearchIcon,
+ XIcon,
+} from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { CopyButton } from "@/components/sandboxes/copy-button";
+import {
+ ExpiryBadge,
+ MetadataChips,
+ SandboxSourceLink,
+ SandboxStateBadge,
+} from "@/components/sandboxes/sandbox-badges";
+import {
+ formatCpu,
+ formatMemoryMB,
+ formatRelative,
+ shortId,
+} from "@/components/sandboxes/format";
+import { useNow } from "@/components/sandboxes/use-now";
+import type { ListedSandbox } from "@/lib/api/sandboxes";
+
+export type SandboxStateFilter = "all" | "running" | "paused";
+
+const STATE_LABELS: Record = {
+ all: "All states",
+ running: "Running",
+ paused: "Paused",
+};
+
+const SORT_OPTIONS = {
+ "started-desc": "Newest first",
+ "started-asc": "Oldest first",
+ "expires-asc": "Expiring soonest",
+ "cpu-desc": "Most vCPU",
+} as const;
+
+type SortKey = keyof typeof SORT_OPTIONS;
+
+const LIMIT_OPTIONS = ["25", "50", "100", "200"] as const;
+
+export type SandboxListProps = {
+ sandboxes: ListedSandbox[];
+ state: SandboxStateFilter;
+ metadataQuery: string;
+ limit: number;
+ hasMore: boolean;
+ fetchedAt: string;
+ error?: string;
+};
+
+function timeValue(iso?: string): number {
+ if (!iso) {
+ return 0;
+ }
+ const parsed = Date.parse(iso);
+ return Number.isNaN(parsed) ? 0 : parsed;
+}
+
+function matchesQuery(sandbox: ListedSandbox, query: string): boolean {
+ const haystack = [
+ sandbox.sandboxID,
+ sandbox.templateID,
+ sandbox.alias,
+ sandbox.state,
+ ...Object.entries(sandbox.metadata ?? {}).map(
+ ([key, value]) => `${key}=${value}`,
+ ),
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+
+ return haystack.includes(query);
+}
+
+export function SandboxList({
+ sandboxes,
+ state,
+ metadataQuery,
+ limit,
+ hasMore,
+ fetchedAt,
+ error,
+}: SandboxListProps) {
+ const router = useRouter();
+ const now = useNow();
+ const [pending, startTransition] = useTransition();
+ const [query, setQuery] = useState("");
+ const [sort, setSort] = useState("started-desc");
+ const [metadataDraft, setMetadataDraft] = useState(metadataQuery);
+
+ function applyParams(next: Record) {
+ const params = new URLSearchParams();
+ const merged: Record = {
+ state: state === "all" ? undefined : state,
+ metadata: metadataQuery || undefined,
+ limit: limit === 50 ? undefined : String(limit),
+ ...next,
+ };
+ for (const [key, value] of Object.entries(merged)) {
+ if (value) {
+ params.set(key, value);
+ }
+ }
+ const search = params.toString();
+ startTransition(() => {
+ router.push(search ? `/sandboxes?${search}` : "/sandboxes");
+ });
+ }
+
+ const visible = useMemo(() => {
+ const normalizedQuery = query.trim().toLowerCase();
+ const filtered = normalizedQuery
+ ? sandboxes.filter((sandbox) => matchesQuery(sandbox, normalizedQuery))
+ : sandboxes;
+
+ return [...filtered].sort((a, b) => {
+ switch (sort) {
+ case "started-asc":
+ return timeValue(a.startedAt) - timeValue(b.startedAt);
+ case "expires-asc":
+ return timeValue(a.endAt) - timeValue(b.endAt);
+ case "cpu-desc":
+ return (b.cpuCount ?? 0) - (a.cpuCount ?? 0);
+ case "started-desc":
+ return timeValue(b.startedAt) - timeValue(a.startedAt);
+ default: {
+ const exhaustive: never = sort;
+ return exhaustive;
+ }
+ }
+ });
+ }, [sandboxes, query, sort]);
+
+ const counts = useMemo(() => {
+ let running = 0;
+ let paused = 0;
+ for (const sandbox of sandboxes) {
+ if (sandbox.state === "running") {
+ running += 1;
+ } else if (sandbox.state === "paused") {
+ paused += 1;
+ }
+ }
+ return { running, paused, total: sandboxes.length };
+ }, [sandboxes]);
+
+ return (
+
+
+
+
Sandboxes
+
+ {counts.total} sandbox{counts.total === 1 ? "" : "es"} · {counts.running}{" "}
+ running · {counts.paused} paused · updated{" "}
+ {now === null ? "just now" : formatRelative(fetchedAt, now)}
+
+
+
+
startTransition(() => router.refresh())}
+ >
+
+ Refresh
+
+
}>
+
+ Create sandbox
+
+
+
+
+ {error ? (
+
+ Could not load sandboxes
+ {error}
+
+ ) : null}
+
+
+
+
Search
+
+
+ setQuery(event.target.value)}
+ placeholder="ID, template, alias, metadata…"
+ className="pl-8"
+ />
+
+
+
+
+ State
+ {
+ if (value) {
+ applyParams({ state: value === "all" ? undefined : value });
+ }
+ }}
+ >
+
+ {(value) => STATE_LABELS[value as SandboxStateFilter]}
+
+
+ {Object.entries(STATE_LABELS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+ Sort
+ {
+ if (value) {
+ setSort(value);
+ }
+ }}
+ >
+
+ {(value) => SORT_OPTIONS[value as SortKey]}
+
+
+ {Object.entries(SORT_OPTIONS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+ Page size
+ {
+ if (value) {
+ applyParams({ limit: value });
+ }
+ }}
+ >
+
+ {(value) => String(value)}
+
+
+ {LIMIT_OPTIONS.map((value) => (
+
+ {value}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ Sandbox
+ State
+ Source
+ Resources
+ Started
+ Expires
+ Metadata
+
+
+
+
+ {visible.length === 0 ? (
+
+
+
+ {sandboxes.length === 0
+ ? "No sandboxes match the current filters."
+ : "No sandboxes match your search."}
+
+
+
+ ) : (
+ visible.map((sandbox) => (
+
+
+
+
+ {shortId(sandbox.sandboxID, 14, 6)}
+
+
+
+
+
+
+
+
+
+
+
+ {formatCpu(sandbox.cpuCount)} · {formatMemoryMB(sandbox.memoryMB)}
+ {sandbox.diskSizeMB
+ ? ` · ${formatMemoryMB(sandbox.diskSizeMB)} disk`
+ : ""}
+
+
+ {now === null ? "…" : formatRelative(sandbox.startedAt, now)}
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+ ))
+ )}
+
+
+
+
+ {hasMore ? (
+
+ More results available
+ Showing the first {limit}. Increase the page size or narrow the filters.
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/components/sandboxes/use-now.ts b/web/src/components/sandboxes/use-now.ts
new file mode 100644
index 00000000..4f66cad2
--- /dev/null
+++ b/web/src/components/sandboxes/use-now.ts
@@ -0,0 +1,31 @@
+"use client";
+
+import { useCallback, useRef, useSyncExternalStore } from "react";
+
+/**
+ * Ticking clock for countdowns. Stays `null` through the server render and the
+ * hydrating client render so both produce identical markup, then starts
+ * reporting wall-clock time once subscribed.
+ */
+export function useNow(intervalMs = 10_000): number | null {
+ const snapshot = useRef(null);
+
+ const subscribe = useCallback(
+ (onStoreChange: () => void) => {
+ const tick = () => {
+ snapshot.current = Date.now();
+ onStoreChange();
+ };
+ tick();
+ const timer = setInterval(tick, intervalMs);
+ return () => clearInterval(timer);
+ },
+ [intervalMs],
+ );
+
+ return useSyncExternalStore(
+ subscribe,
+ () => snapshot.current,
+ () => null,
+ );
+}
diff --git a/web/src/components/settings/connection-form.tsx b/web/src/components/settings/connection-form.tsx
new file mode 100644
index 00000000..ab85b0dc
--- /dev/null
+++ b/web/src/components/settings/connection-form.tsx
@@ -0,0 +1,516 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import {
+ EyeIcon,
+ EyeOffIcon,
+ Link2Icon,
+ LoaderCircleIcon,
+ PlugZapIcon,
+ RefreshCwIcon,
+ TriangleAlertIcon,
+ UnplugIcon,
+} from "lucide-react";
+import { toast } from "sonner";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ ConnectionStatusBadge,
+ ProbeCheckRow,
+} from "@/components/settings/connection-status";
+import {
+ parseGatewayUrl,
+ type ConnectionApiResponse,
+ type ConnectionProbe,
+ type ConnectionSessionSummary,
+ type ConnectionUpdateRequest,
+} from "@/lib/api/connection";
+import { cn } from "@/lib/utils";
+
+const FALLBACK_GATEWAY_URL = "http://127.0.0.1:8080";
+
+const TRANSPORT_ERROR =
+ "Could not reach the console server. Is the Next.js app still running?";
+
+type PendingAction = "check" | "save" | "disconnect";
+
+type ApiResult = {
+ ok: boolean;
+ data: ConnectionApiResponse | null;
+};
+
+async function callConnectionApi(
+ path: string,
+ init: RequestInit,
+): Promise {
+ try {
+ const response = await fetch(path, {
+ ...init,
+ headers: { "Content-Type": "application/json", ...init.headers },
+ });
+ const text = await response.text();
+ return {
+ ok: response.ok,
+ data: text ? (JSON.parse(text) as ConnectionApiResponse) : null,
+ };
+ } catch {
+ return { ok: false, data: null };
+ }
+}
+
+function formatCheckedAt(iso: string): string {
+ const parsed = new Date(iso);
+ return Number.isNaN(parsed.getTime())
+ ? "just now"
+ : parsed.toLocaleTimeString();
+}
+
+export function ConnectionForm({
+ initialSession,
+ defaultGatewayUrl,
+}: {
+ initialSession: ConnectionSessionSummary;
+ defaultGatewayUrl?: string;
+}) {
+ const DEFAULT_GATEWAY_URL = defaultGatewayUrl || FALLBACK_GATEWAY_URL;
+ const router = useRouter();
+
+ const [session, setSession] = useState(initialSession);
+ const [probe, setProbe] = useState(null);
+ const [gatewayUrl, setGatewayUrl] = useState(
+ initialSession.gatewayUrl ?? defaultGatewayUrl ?? "",
+ );
+ const [apiKey, setApiKey] = useState("");
+ const [adminToken, setAdminToken] = useState("");
+ const [revealApiKey, setRevealApiKey] = useState(false);
+ const [revealAdminToken, setRevealAdminToken] = useState(false);
+ const [removeAdminToken, setRemoveAdminToken] = useState(false);
+ const [pending, setPending] = useState(null);
+ const [error, setError] = useState(null);
+ const [saveBlocked, setSaveBlocked] = useState(false);
+
+ const busy = pending !== null;
+
+ /** Blank secrets keep stored values only when the Gateway URL is unchanged. */
+ const buildUpdate = useCallback(
+ (overrides?: Partial): ConnectionUpdateRequest => ({
+ gatewayUrl: gatewayUrl.trim() || undefined,
+ apiKey: apiKey.trim() || undefined,
+ adminToken: adminToken.trim() || undefined,
+ clearAdminToken: removeAdminToken || undefined,
+ ...overrides,
+ }),
+ [gatewayUrl, apiKey, adminToken, removeAdminToken],
+ );
+
+ const runCheck = useCallback(async (body: ConnectionUpdateRequest) => {
+ setPending("check");
+ setError(null);
+
+ const { ok, data } = await callConnectionApi("/api/connection/validate", {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+
+ setPending(null);
+ if (!data) {
+ setError(TRANSPORT_ERROR);
+ return;
+ }
+
+ setSession(data.session);
+ setProbe(data.probe);
+ if (!ok || data.error) {
+ setError(data.error ?? "Connection check failed.");
+ }
+ }, []);
+
+ const checkedOnMount = useRef(false);
+ useEffect(() => {
+ if (checkedOnMount.current || !initialSession.configured) {
+ return;
+ }
+ checkedOnMount.current = true;
+ void runCheck({});
+ }, [initialSession.configured, runCheck]);
+
+ async function save(force: boolean) {
+ setPending("save");
+ setError(null);
+
+ const { ok, data } = await callConnectionApi("/api/connection", {
+ method: "POST",
+ body: JSON.stringify(buildUpdate(force ? { force: true } : undefined)),
+ });
+
+ setPending(null);
+ if (!data) {
+ setError(TRANSPORT_ERROR);
+ return;
+ }
+
+ setSession(data.session);
+ setProbe(data.probe);
+
+ if (!ok) {
+ setError(data.error ?? "Could not save the connection.");
+ setSaveBlocked(data.probe?.status === "disconnected");
+ return;
+ }
+
+ setSaveBlocked(false);
+ setApiKey("");
+ setAdminToken("");
+ setRevealApiKey(false);
+ setRevealAdminToken(false);
+ setRemoveAdminToken(false);
+ setGatewayUrl(data.session.gatewayUrl ?? gatewayUrl);
+
+ toast.success("Connection saved", { description: data.probe?.summary });
+ router.refresh();
+ }
+
+ async function disconnect() {
+ setPending("disconnect");
+ setError(null);
+
+ const { ok, data } = await callConnectionApi("/api/connection", {
+ method: "DELETE",
+ });
+
+ setPending(null);
+ if (!ok || !data) {
+ setError(TRANSPORT_ERROR);
+ return;
+ }
+
+ // Keep the URL in the field: it is not a secret and is usually reused.
+ setSession(data.session);
+ setProbe(null);
+ setApiKey("");
+ setAdminToken("");
+ setRevealApiKey(false);
+ setRevealAdminToken(false);
+ setRemoveAdminToken(false);
+ setSaveBlocked(false);
+ checkedOnMount.current = true;
+
+ toast.success("Session cleared", {
+ description: "Gateway credentials were removed from this browser.",
+ });
+ router.refresh();
+ }
+
+ const parsedGateway = parseGatewayUrl(gatewayUrl);
+ const destinationChanged =
+ session.configured &&
+ (!parsedGateway.ok || parsedGateway.url !== session.gatewayUrl);
+ const canCheckEntered = Boolean(
+ gatewayUrl.trim() &&
+ (apiKey.trim() || (session.configured && !destinationChanged)),
+ );
+
+ return (
+
+
+
+
+
+ Connection status
+
+
+ {session.configured ? (
+
+ {session.gatewayUrl}
+
+ ) : (
+ "No Gateway is configured for this browser session."
+ )}
+
+
+ {probe ? (
+
+ ) : (
+
+ {session.configured ? "Not checked" : "Not configured"}
+
+ )}
+
+
+
+
+ {pending === "check" ? (
+
+
+ Probing the Gateway…
+
+ ) : null}
+
+ {probe ? (
+ <>
+ {probe.summary}
+
+ {probe.checks.map((check) => (
+
+ ))}
+
+ >
+ ) : pending !== "check" ? (
+
+ {session.configured
+ ? "Run a check to probe /health, /v2/sandboxes and /nodes."
+ : "Save a Gateway URL and API key below to connect."}
+
+ ) : null}
+
+
+
+ void runCheck({})}
+ >
+
+ Re-check saved session
+
+
+ {probe ? (
+
+ Checked {formatCheckedAt(probe.checkedAt)}
+
+ ) : null}
+
+ {session.configured ? (
+ void disconnect()}
+ >
+ {pending === "disconnect" ? (
+
+ ) : (
+
+ )}
+ Disconnect
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/settings/connection-status.tsx b/web/src/components/settings/connection-status.tsx
new file mode 100644
index 00000000..c11a34a0
--- /dev/null
+++ b/web/src/components/settings/connection-status.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import {
+ CircleCheckIcon,
+ CircleMinusIcon,
+ CircleXIcon,
+ TriangleAlertIcon,
+} from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+import {
+ CONNECTION_STATUS_LABEL,
+ type ConnectionStatus,
+ type ProbeCheck,
+ type ProbeOutcome,
+} from "@/lib/api/connection";
+
+const STATUS_BADGE_CLASS: Record = {
+ connected: "border-emerald-500/40 bg-emerald-500/10 text-emerald-400",
+ partial: "border-amber-500/40 bg-amber-500/10 text-amber-400",
+ disconnected: "border-destructive/40 bg-destructive/10 text-destructive",
+};
+
+const STATUS_DOT_CLASS: Record = {
+ connected: "bg-emerald-400",
+ partial: "bg-amber-400",
+ disconnected: "bg-destructive",
+};
+
+export function ConnectionStatusBadge({
+ status,
+ className,
+}: {
+ status: ConnectionStatus;
+ className?: string;
+}) {
+ return (
+
+
+ {CONNECTION_STATUS_LABEL[status]}
+
+ );
+}
+
+function OutcomeIcon({ outcome }: { outcome: ProbeOutcome }) {
+ switch (outcome) {
+ case "ok":
+ return ;
+ case "unauthorized":
+ return ;
+ case "failed":
+ return ;
+ case "skipped":
+ return (
+
+ );
+ default: {
+ const exhaustive: never = outcome;
+ return exhaustive;
+ }
+ }
+}
+
+export function ProbeCheckRow({ check }: { check: ProbeCheck }) {
+ return (
+
+
+
+
+
+
+ {check.label}
+
+ GET {check.path}
+
+ {check.durationMs !== undefined ? (
+
+ {check.durationMs} ms
+
+ ) : null}
+
+
{check.detail}
+
+
+ );
+}
diff --git a/web/src/components/snapshots/snapshot-filters.tsx b/web/src/components/snapshots/snapshot-filters.tsx
new file mode 100644
index 00000000..f271d08a
--- /dev/null
+++ b/web/src/components/snapshots/snapshot-filters.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { SearchIcon, XIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+export type SnapshotFilterValues = {
+ name: string;
+ sandboxID: string;
+};
+
+/**
+ * `GET /snapshots` filters server-side, so the values live in the URL and every
+ * change re-runs the page's data fetch.
+ */
+export function SnapshotFilters({ initial }: { initial: SnapshotFilterValues }) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [values, setValues] = useState(initial);
+
+ const dirty = values.name !== "" || values.sandboxID !== "";
+
+ function apply(next: SnapshotFilterValues) {
+ const query = new URLSearchParams();
+ if (next.name.trim()) {
+ query.set("name", next.name.trim());
+ }
+ if (next.sandboxID.trim()) {
+ query.set("sandboxID", next.sandboxID.trim());
+ }
+ const search = query.toString();
+ startTransition(() => {
+ router.push(search ? `/snapshots?${search}` : "/snapshots");
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/web/src/components/snapshots/snapshot-table.tsx b/web/src/components/snapshots/snapshot-table.tsx
new file mode 100644
index 00000000..38e9f5be
--- /dev/null
+++ b/web/src/components/snapshots/snapshot-table.tsx
@@ -0,0 +1,126 @@
+import Link from "next/link";
+import { ArrowUpRightIcon, PlayIcon } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { CopyButton } from "@/components/sandboxes/copy-button";
+import { snapshotAliases, snapshotId } from "@/lib/api/snapshots";
+import { formatCpu, formatMiB } from "@/lib/format";
+import { LocalTime } from "@/components/local-time";
+import type { SnapshotInfo } from "@/lib/api/types";
+
+export function SnapshotTable({
+ snapshots,
+ sourceSandboxID,
+}: {
+ snapshots: SnapshotInfo[];
+ /** Carried into detail links so provenance survives a filtered browse. */
+ sourceSandboxID?: string;
+}) {
+ return (
+
+
+
+ Snapshot
+ Aliases
+ Resources
+ Created
+ Updated
+ Actions
+
+
+
+ {snapshots.map((snapshot) => {
+ const id = snapshotId(snapshot);
+ const aliases = snapshotAliases(snapshot);
+ const detailHref = sourceSandboxID
+ ? `/snapshots/${encodeURIComponent(id)}?sandboxID=${encodeURIComponent(sourceSandboxID)}`
+ : `/snapshots/${encodeURIComponent(id)}`;
+
+ return (
+
+
+
+
+ {id}
+
+
+
+
+
+ {aliases.length === 0 ? (
+ —
+ ) : (
+
+ {aliases.map((alias) => (
+
+ {alias}
+
+ ))}
+
+ )}
+
+
+ {formatCpu(snapshot.cpuCount)} · {formatMiB(snapshot.memoryMB)}
+ {typeof snapshot.diskSizeMB === "number"
+ ? ` · ${formatMiB(snapshot.diskSizeMB)} disk`
+ : ""}
+
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+ Create sandbox
+
+
}
+ >
+ Details
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/components/templates/build-log-viewer.tsx b/web/src/components/templates/build-log-viewer.tsx
new file mode 100644
index 00000000..6a5658d1
--- /dev/null
+++ b/web/src/components/templates/build-log-viewer.tsx
@@ -0,0 +1,231 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { BuildStatusBadge } from "@/components/templates/build-status-badge";
+import {
+ fetchBuildStatusAction,
+ revalidateTemplateAction,
+} from "@/app/(console)/templates/actions";
+import {
+ isBuildInFlight,
+ logLevel,
+ type BuildLogEntry,
+ type TemplateBuildInfo,
+} from "@/lib/api/templates";
+import { LocalTime } from "@/components/local-time";
+
+const POLL_INTERVAL_MS = 3_000;
+
+function levelClass(level?: string | null): string {
+ const value = logLevel(level);
+ switch (value) {
+ case "error":
+ return "text-destructive";
+ case "warn":
+ return "text-amber-300";
+ case "debug":
+ return "text-muted-foreground";
+ case "info":
+ return "text-sky-300";
+ case "unknown":
+ return "text-muted-foreground";
+ default: {
+ const exhaustive: never = value;
+ return exhaustive;
+ }
+ }
+}
+
+function LogLines({ entries }: { entries: BuildLogEntry[] }) {
+ return (
+
+ {entries.map((entry, index) => (
+
+
+
+ {entry.level ?? ""}
+
+ {entry.step ? (
+
+ [{entry.step}]
+
+ ) : null}
+ {entry.message}
+
+ ))}
+
+ );
+}
+
+/**
+ * Builds run in the background after `POST /v2/templates/{id}/builds/{id}`
+ * returns, so an in-flight build is polled until it settles and the
+ * server-rendered detail around it is then revalidated.
+ */
+export function BuildLogViewer({
+ templateID,
+ buildID,
+ initialStatus,
+}: {
+ templateID: string;
+ buildID: string;
+ initialStatus?: string;
+}) {
+ const router = useRouter();
+ const [info, setInfo] = useState(null);
+ const [error, setError] = useState(null);
+ const [reloadKey, setReloadKey] = useState(0);
+ const wasInFlight = useRef(isBuildInFlight(initialStatus));
+
+ const status = info?.status ?? initialStatus;
+ const live = isBuildInFlight(status) && error === null;
+
+ useEffect(() => {
+ let cancelled = false;
+ let timer: ReturnType | undefined;
+
+ async function poll() {
+ const result = await fetchBuildStatusAction(templateID, buildID);
+ if (cancelled) {
+ return;
+ }
+ if (!result.ok) {
+ setError(result.error);
+ return;
+ }
+
+ setError(null);
+ setInfo(result.data);
+
+ if (isBuildInFlight(result.data.status)) {
+ wasInFlight.current = true;
+ timer = setTimeout(poll, POLL_INTERVAL_MS);
+ return;
+ }
+
+ if (wasInFlight.current) {
+ wasInFlight.current = false;
+ await revalidateTemplateAction(templateID);
+ router.refresh();
+ }
+ }
+
+ void poll();
+
+ return () => {
+ cancelled = true;
+ if (timer) {
+ clearTimeout(timer);
+ }
+ };
+ }, [templateID, buildID, reloadKey, router]);
+
+ const refresh = useCallback(() => {
+ setError(null);
+ setReloadKey((key) => key + 1);
+ }, []);
+
+ const logEntries = info?.logEntries ?? [];
+ const logs = info?.logs ?? [];
+ const reason = info?.reason;
+
+ return (
+
+
+
+ Build
+
+ {buildID}
+
+
+ {live ? (
+ Auto-refreshing every 3s
+ ) : null}
+
+
+ Logs are streamed by the Gateway; a build that reports none simply did
+ not publish any.
+
+
+
+ {error ? (
+
+ Could not read the build status
+ {error}
+
+ ) : null}
+
+ {reason ? (
+
+
+
+ Build failed{reason.step ? ` at ${reason.step}` : ""}
+
+
+ {reason.message}
+ {reason.logEntries?.length ? (
+
+ ) : null}
+
+
+ ) : null}
+
+
+
+
+
+ Structured ({logEntries.length})
+
+ Raw ({logs.length})
+
+
+
+ Refresh
+
+
+
+
+ {logEntries.length === 0 ? (
+
+ No structured log entries reported for this build.
+
+ ) : (
+
+ )}
+
+
+
+ {logs.length === 0 ? (
+
+ No plain log lines reported for this build.
+
+ ) : (
+
+ {logs.join("\n")}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/web/src/components/templates/build-status-badge.tsx b/web/src/components/templates/build-status-badge.tsx
new file mode 100644
index 00000000..4273d255
--- /dev/null
+++ b/web/src/components/templates/build-status-badge.tsx
@@ -0,0 +1,81 @@
+import {
+ CheckCircle2Icon,
+ CircleDashedIcon,
+ CircleHelpIcon,
+ LoaderIcon,
+ TriangleAlertIcon,
+} from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { buildStatus } from "@/lib/api/templates";
+import { cn } from "@/lib/utils";
+
+export function BuildStatusBadge({
+ status,
+ className,
+}: {
+ status?: string | null;
+ className?: string;
+}) {
+ const value = buildStatus(status);
+
+ switch (value) {
+ case "ready":
+ return (
+
+
+ Ready
+
+ );
+ case "building":
+ return (
+
+
+ Building
+
+ );
+ case "waiting":
+ return (
+
+
+ Waiting
+
+ );
+ case "error":
+ return (
+
+
+ Failed
+
+ );
+ case "unknown":
+ return (
+
+
+ {status?.trim() ? status : "Unknown"}
+
+ );
+ default: {
+ const exhaustive: never = value;
+ return exhaustive;
+ }
+ }
+}
diff --git a/web/src/components/templates/step-editor.tsx b/web/src/components/templates/step-editor.tsx
new file mode 100644
index 00000000..b9c08502
--- /dev/null
+++ b/web/src/components/templates/step-editor.tsx
@@ -0,0 +1,266 @@
+"use client";
+
+import {
+ ArrowDownIcon,
+ ArrowUpIcon,
+ PlusIcon,
+ Trash2Icon,
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ TEMPLATE_STEP_TYPES,
+ type TemplateStep,
+ type TemplateStepType,
+} from "@/lib/api/templates";
+
+export type StepRow = {
+ id: string;
+ type: TemplateStepType;
+ /** RUN: `[command]`. ENV: `[key, value]`. WORKDIR: `[path]`. */
+ values: string[];
+ force: boolean;
+};
+
+let nextStepId = 0;
+
+export function createStep(type: TemplateStepType = "RUN"): StepRow {
+ nextStepId += 1;
+ return {
+ id: `step-${nextStepId}`,
+ type,
+ values: type === "ENV" ? ["", ""] : [""],
+ force: false,
+ };
+}
+
+/** ENV keeps its (possibly empty) value, which the other step types do not have. */
+export function stepToRequest(step: StepRow): TemplateStep {
+ const args =
+ step.type === "ENV"
+ ? [step.values[0]?.trim() ?? "", step.values[1] ?? ""]
+ : [step.values[0]?.trim() ?? ""];
+
+ return { type: step.type, args, force: step.force };
+}
+
+export function describeStep(step: StepRow): string {
+ switch (step.type) {
+ case "RUN":
+ return `RUN ${step.values[0] ?? ""}`.trim();
+ case "ENV":
+ return `ENV ${step.values[0] ?? ""}=${step.values[1] ?? ""}`.trim();
+ case "WORKDIR":
+ return `WORKDIR ${step.values[0] ?? ""}`.trim();
+ default: {
+ const exhaustive: never = step.type;
+ return exhaustive;
+ }
+ }
+}
+
+/** Mirrors the server-side step validation so the review pane can block early. */
+export function stepIssue(step: StepRow): string | null {
+ switch (step.type) {
+ case "RUN":
+ return step.values[0]?.trim() ? null : "RUN needs a command.";
+ case "ENV":
+ return step.values[0]?.trim() ? null : "ENV needs a variable name.";
+ case "WORKDIR":
+ return step.values[0]?.trim() ? null : "WORKDIR needs a path.";
+ default: {
+ const exhaustive: never = step.type;
+ return exhaustive;
+ }
+ }
+}
+
+export function StepEditor({
+ steps,
+ onChange,
+ disabled,
+}: {
+ steps: StepRow[];
+ onChange: (steps: StepRow[]) => void;
+ disabled?: boolean;
+}) {
+ function update(id: string, patch: Partial) {
+ onChange(
+ steps.map((step) => (step.id === id ? { ...step, ...patch } : step)),
+ );
+ }
+
+ function move(index: number, delta: number) {
+ const target = index + delta;
+ if (target < 0 || target >= steps.length) {
+ return;
+ }
+ const reordered = [...steps];
+ const [moved] = reordered.splice(index, 1);
+ reordered.splice(target, 0, moved);
+ onChange(reordered);
+ }
+
+ return (
+
+ {steps.length === 0 ? (
+
+ No steps. The template will be the base image as-is.
+
+ ) : null}
+
+ {steps.map((step, index) => {
+ const issue = stepIssue(step);
+ return (
+
+
+
+ Step {index + 1}
+
+
+ {TEMPLATE_STEP_TYPES.map((type) => (
+
+ update(step.id, {
+ type,
+ values: type === "ENV" ? ["", ""] : [""],
+ })
+ }
+ >
+ {type}
+
+ ))}
+
+
+
+
+ {step.type === "RUN" ? (
+
+
+
+
move(index, -1)}
+ >
+
+
+
move(index, 1)}
+ >
+
+
+
+ onChange(steps.filter((candidate) => candidate.id !== step.id))
+ }
+ >
+
+
+
+
+ );
+ })}
+
+
onChange([...steps, createStep()])}
+ >
+
+ Add step
+
+
+ );
+}
diff --git a/web/src/components/templates/template-actions.tsx b/web/src/components/templates/template-actions.tsx
new file mode 100644
index 00000000..f003908f
--- /dev/null
+++ b/web/src/components/templates/template-actions.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { HammerIcon, PlayIcon, Trash2Icon } from "lucide-react";
+import { toast } from "sonner";
+
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { deleteTemplateAction } from "@/app/(console)/templates/actions";
+
+export type RebuildDefaults = {
+ name?: string;
+ cpuCount?: number;
+ memoryMB?: number;
+};
+
+/**
+ * The base is pinned to the template ID rather than its name: a rebuild keeps
+ * the same name, so an alias would resolve ambiguously once the new template
+ * claims it.
+ */
+function rebuildHref(templateID: string, defaults: RebuildDefaults): string {
+ const query = new URLSearchParams({
+ rebuildFrom: templateID,
+ fromTemplate: templateID,
+ });
+ if (defaults.name) {
+ query.set("name", defaults.name);
+ }
+ if (typeof defaults.cpuCount === "number") {
+ query.set("cpuCount", String(defaults.cpuCount));
+ }
+ if (typeof defaults.memoryMB === "number") {
+ query.set("memoryMB", String(defaults.memoryMB));
+ }
+ return `/templates/new?${query.toString()}`;
+}
+
+export function TemplateActions({
+ templateID,
+ label,
+ rebuildDefaults,
+}: {
+ templateID: string;
+ /** Human-readable name used in the delete confirmation. */
+ label: string;
+ rebuildDefaults: RebuildDefaults;
+}) {
+ const router = useRouter();
+ const [confirming, setConfirming] = useState(false);
+ const [deleting, startDelete] = useTransition();
+
+ function remove() {
+ startDelete(async () => {
+ const result = await deleteTemplateAction(templateID);
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ setConfirming(false);
+ toast.success(`Deleted ${label}.`);
+ router.push("/templates");
+ });
+ }
+
+ return (
+
+
+ }
+ >
+
+ Create sandbox
+
+
+
}
+ >
+
+ Rebuild
+
+
+
+ }>
+
+ Delete
+
+
+
+
+
+
+ Delete {label}?
+
+ This removes the template and its published snapshot. Sandboxes
+ already running from it keep running, but new ones can no longer
+ be created. This cannot be undone.
+
+
+
+ Cancel
+
+ {deleting ? "Deleting…" : "Delete template"}
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/templates/template-build-form.tsx b/web/src/components/templates/template-build-form.tsx
new file mode 100644
index 00000000..fba6a28a
--- /dev/null
+++ b/web/src/components/templates/template-build-form.tsx
@@ -0,0 +1,482 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import {
+ ArrowLeftIcon,
+ CheckIcon,
+ HammerIcon,
+ InfoIcon,
+ SearchCheckIcon,
+} from "lucide-react";
+import { toast } from "sonner";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ describeStep,
+ StepEditor,
+ stepIssue,
+ stepToRequest,
+ type StepRow,
+} from "@/components/templates/step-editor";
+import {
+ createAndBuildTemplateAction,
+ resolveTemplateAliasAction,
+ type BuildSubmission,
+} from "@/app/(console)/templates/actions";
+import { parseTags } from "@/lib/api/templates";
+
+type BaseKind = "image" | "template";
+
+export type BuildFormDefaults = {
+ name?: string;
+ tags?: string;
+ cpuCount?: string;
+ memoryMB?: string;
+ baseKind?: BaseKind;
+ fromImage?: string;
+ fromTemplate?: string;
+ /** Set when the form was opened from an existing template's Rebuild action. */
+ rebuildFrom?: string;
+};
+
+function positiveInteger(raw: string): number | undefined {
+ const parsed = Number(raw);
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) {
+ return undefined;
+ }
+ return parsed;
+}
+
+export function TemplateBuildForm({
+ defaults = {},
+}: {
+ defaults?: BuildFormDefaults;
+}) {
+ const router = useRouter();
+ const [submitting, startSubmit] = useTransition();
+ const [checkingAlias, startAliasCheck] = useTransition();
+
+ const [reviewing, setReviewing] = useState(false);
+ const [name, setName] = useState(defaults.name ?? "");
+ const [tags, setTags] = useState(defaults.tags ?? "");
+ const [cpuCount, setCpuCount] = useState(defaults.cpuCount ?? "2");
+ const [memoryMB, setMemoryMB] = useState(defaults.memoryMB ?? "1024");
+ const [baseKind, setBaseKind] = useState(
+ defaults.baseKind ?? (defaults.fromTemplate ? "template" : "image"),
+ );
+ const [fromImage, setFromImage] = useState(defaults.fromImage ?? "");
+ const [fromTemplate, setFromTemplate] = useState(defaults.fromTemplate ?? "");
+ const [steps, setSteps] = useState([]);
+ const [startCmd, setStartCmd] = useState("");
+ const [readyCmd, setReadyCmd] = useState("");
+ const [force, setForce] = useState(false);
+
+ const trimmedName = name.trim();
+ const cpu = positiveInteger(cpuCount);
+ const memory = positiveInteger(memoryMB);
+
+ const problems: string[] = [];
+ if (!trimmedName) {
+ problems.push("A template name is required.");
+ } else if (trimmedName.length > 128) {
+ problems.push("The template name must be 128 characters or fewer.");
+ }
+ if (cpu === undefined) {
+ problems.push("CPU count must be a whole number of at least 1.");
+ }
+ if (memory === undefined || memory < 128) {
+ problems.push("Memory must be a whole number of at least 128 MiB.");
+ }
+ if (baseKind === "image" && !fromImage.trim()) {
+ problems.push("Provide the base OCI image reference.");
+ }
+ if (baseKind === "template" && !fromTemplate.trim()) {
+ problems.push("Provide the base template name or ID.");
+ }
+ for (const [index, step] of steps.entries()) {
+ const issue = stepIssue(step);
+ if (issue) {
+ problems.push(`Step ${index + 1}: ${issue}`);
+ }
+ }
+
+ const submission: BuildSubmission = {
+ template: {
+ name: trimmedName,
+ tags: parseTags(tags),
+ cpuCount: cpu,
+ memoryMB: memory,
+ },
+ build: {
+ ...(baseKind === "image"
+ ? { fromImage: fromImage.trim() }
+ : { fromTemplate: fromTemplate.trim() }),
+ force,
+ steps: steps.map(stepToRequest),
+ ...(startCmd.trim() ? { startCmd: startCmd.trim() } : {}),
+ ...(readyCmd.trim() ? { readyCmd: readyCmd.trim() } : {}),
+ },
+ };
+
+ function checkAlias() {
+ const alias = fromTemplate.trim();
+ if (!alias) {
+ return;
+ }
+ startAliasCheck(async () => {
+ const result = await resolveTemplateAliasAction(alias);
+ if (result.ok) {
+ toast.success(`Resolved to template ${result.data.templateID}`);
+ } else {
+ toast.error(result.error);
+ }
+ });
+ }
+
+ function submit() {
+ startSubmit(async () => {
+ const result = await createAndBuildTemplateAction(submission);
+ if (!result.ok) {
+ toast.error(result.error);
+ setReviewing(false);
+ return;
+ }
+ toast.success("Build queued.");
+ router.push(
+ `/templates/${encodeURIComponent(result.data.templateID)}?build=${encodeURIComponent(result.data.buildID)}`,
+ );
+ });
+ }
+
+ if (reviewing) {
+ return (
+
+
+
+ Review the build
+
+ Creating the template and starting the build are two API calls.
+ The build itself then runs in the background.
+
+
+
+
+
+
+ Name
+
+ {trimmedName}
+
+
+
+ Resources
+
+
+ {cpu} vCPU · {memory} MiB
+
+
+
+
+ Base
+
+
+ {baseKind === "image"
+ ? fromImage.trim()
+ : fromTemplate.trim()}
+
+
+
+
+
+
+ Steps
+
+ {steps.length === 0 ? (
+
+ None — the base image is published as-is.
+
+ ) : (
+
+ {steps.map((step, index) => (
+
+
+ {index + 1}.
+
+ {describeStep(step)}
+ {step.force ? (
+ (forced)
+ ) : null}
+
+ ))}
+
+ )}
+
+
+
+
+ {`POST /v3/templates\n${JSON.stringify(submission.template, null, 2)}`}
+
+
+ {`POST /v2/templates/{id}/builds/{id}\n${JSON.stringify(submission.build, null, 2)}`}
+
+
+
+
+
+
+
setReviewing(false)}
+ >
+
+ Back to edit
+
+
+
+ {submitting ? "Starting build…" : "Create and build"}
+
+
+
+ );
+ }
+
+ return (
+
+ {defaults.rebuildFrom ? (
+
+
+ Rebuilding from an existing template
+
+ The API does not return a template's original build steps, so
+ re-enter any steps you need. The rebuild is published as a new
+ template with its own ID; the original stays untouched. Base is
+ prefilled with{" "}
+
+ {defaults.rebuildFrom}
+
+ .
+
+
+ ) : null}
+
+
+
+ Identity and resources
+
+ A tag can be appended to the name with a colon, for example
+ my-template:v1.
+
+
+
+
+ Name
+ setName(event.target.value)}
+ />
+
+
+
Tags
+
setTags(event.target.value)}
+ />
+
Comma separated.
+
+
+ CPU count
+ setCpuCount(event.target.value)}
+ />
+
+
+ Memory (MiB)
+ setMemoryMB(event.target.value)}
+ />
+
+
+
+
+
+
+ Base
+
+ Start from an OCI image or layer on top of an existing template.
+
+
+
+
+ setBaseKind("image")}
+ >
+ OCI image
+
+ setBaseKind("template")}
+ >
+ Existing template
+
+
+
+ {baseKind === "image" ? (
+
+
Image reference
+
setFromImage(event.target.value)}
+ />
+
+ Private registries need a{" "}
+ docker login on the node
+ before the build starts.
+
+
+ ) : (
+
+
Template name or ID
+
+ setFromTemplate(event.target.value)}
+ />
+
+
+ Resolve
+
+
+
+ )}
+
+
+
+
+
+ Steps
+
+ Applied in order on top of the base, the same way image layers
+ stack.
+
+
+
+
+
+
+
+
+
+ Runtime commands
+
+ Optional commands the sandbox runs after the template boots.
+
+
+
+
+
+
+ setForce(checked === true)}
+ />
+ Force the whole build, ignoring cached layers
+
+
+
+
+ {problems.length > 0 ? (
+
+ Fix these before building
+
+
+ {problems.map((problem) => (
+ {problem}
+ ))}
+
+
+
+ ) : null}
+
+
+ }>
+ Cancel
+
+ 0}
+ onClick={() => setReviewing(true)}
+ >
+
+ Review
+
+
+
+ );
+}
diff --git a/web/src/components/templates/template-table.tsx b/web/src/components/templates/template-table.tsx
new file mode 100644
index 00000000..1fff38fa
--- /dev/null
+++ b/web/src/components/templates/template-table.tsx
@@ -0,0 +1,293 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import Link from "next/link";
+import {
+ ArrowUpRightIcon,
+ LayersIcon,
+ PlayIcon,
+ SearchIcon,
+} from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { BuildStatusBadge } from "@/components/templates/build-status-badge";
+import { CopyButton } from "@/components/sandboxes/copy-button";
+import {
+ buildStatus,
+ templateNames,
+ type ListedTemplate,
+} from "@/lib/api/templates";
+import { formatCpu, formatMiB, formatNumber } from "@/lib/format";
+import { LocalTime } from "@/components/local-time";
+
+const STATUS_FILTERS: Record = {
+ all: "All statuses",
+ ready: "Ready",
+ building: "Building",
+ waiting: "Waiting",
+ error: "Failed",
+};
+
+const SORT_OPTIONS: Record = {
+ updated: "Recently updated",
+ created: "Recently created",
+ name: "Name",
+ spawns: "Most spawned",
+};
+
+function timeValue(value?: string | null): number {
+ const parsed = Date.parse(value ?? "");
+ return Number.isNaN(parsed) ? 0 : parsed;
+}
+
+/**
+ * `GET /v2/templates` takes no filter parameters, so search and status live in
+ * component state and narrow the page that was already fetched.
+ */
+export function TemplateTable({ templates }: { templates: ListedTemplate[] }) {
+ const [search, setSearch] = useState("");
+ const [status, setStatus] = useState("all");
+ const [sort, setSort] = useState("updated");
+
+ const visible = useMemo(() => {
+ const needle = search.trim().toLowerCase();
+
+ const matched = templates.filter((template) => {
+ if (status !== "all" && buildStatus(template.buildStatus) !== status) {
+ return false;
+ }
+ if (!needle) {
+ return true;
+ }
+ const haystack = [template.templateID, ...templateNames(template)];
+ return haystack.some((value) => value.toLowerCase().includes(needle));
+ });
+
+ return matched.sort((a, b) => {
+ switch (sort) {
+ case "created":
+ return timeValue(b.createdAt) - timeValue(a.createdAt);
+ case "name":
+ return (templateNames(a)[0] ?? a.templateID).localeCompare(
+ templateNames(b)[0] ?? b.templateID,
+ );
+ case "spawns":
+ return (b.spawnCount ?? 0) - (a.spawnCount ?? 0);
+ default:
+ return timeValue(b.updatedAt) - timeValue(a.updatedAt);
+ }
+ });
+ }, [templates, search, status, sort]);
+
+ return (
+
+
+
+
+
+ Search this page
+
+
+
+ setSearch(event.target.value)}
+ />
+
+
+
+
+
+ Build status
+
+ setStatus(value ?? "all")}
+ >
+
+
+
+
+ {Object.entries(STATUS_FILTERS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+ Sort by
+ setSort(value ?? "updated")}
+ >
+
+
+
+
+ {Object.entries(SORT_OPTIONS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+ {visible.length} of {templates.length} shown
+
+
+
+
+ {visible.length === 0 ? (
+
+
+
+ No matching templates
+
+ Nothing on this page matches the current search and status filter.
+
+
+
+ ) : (
+
+
+
+
+ Template
+ Aliases
+ Status
+ Resources
+ Builds
+ Spawns
+ Last spawned
+ Updated
+ Actions
+
+
+
+ {visible.map((template) => {
+ const names = templateNames(template);
+ const href = `/templates/${encodeURIComponent(template.templateID)}`;
+ return (
+
+
+
+
+ {template.templateID}
+
+
+
+
+
+ {names.length === 0 ? (
+ —
+ ) : (
+
+ {names.map((name) => (
+
+ {name}
+
+ ))}
+
+ )}
+
+
+
+
+
+ {formatCpu(template.cpuCount)} ·{" "}
+ {formatMiB(template.memoryMB)}
+
+
+ {formatNumber(template.buildCount)}
+
+
+ {formatNumber(template.spawnCount)}
+
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+ Create sandbox
+
+
}
+ >
+ Details
+
+
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+}
diff --git a/web/src/components/ui/alert-dialog.tsx b/web/src/components/ui/alert-dialog.tsx
new file mode 100644
index 00000000..05dce24f
--- /dev/null
+++ b/web/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,187 @@
+"use client"
+
+import * as React from "react"
+import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
+ return
+}
+
+function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: AlertDialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Popup.Props & {
+ size?: "default" | "sm"
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogMedia({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Close.Props &
+ Pick, "variant" | "size">) {
+ return (
+ }
+ {...props}
+ />
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+}
diff --git a/web/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx
new file mode 100644
index 00000000..1fe3176d
--- /dev/null
+++ b/web/src/components/ui/alert.tsx
@@ -0,0 +1,76 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription, AlertAction }
diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx
new file mode 100644
index 00000000..b20959dd
--- /dev/null
+++ b/web/src/components/ui/badge.tsx
@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary:
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+ outline:
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost:
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"span"> & VariantProps
) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ })
+}
+
+export { Badge, badgeVariants }
diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx
new file mode 100644
index 00000000..b0336017
--- /dev/null
+++ b/web/src/components/ui/button.tsx
@@ -0,0 +1,58 @@
+import { Button as ButtonPrimitive } from "@base-ui/react/button"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
+ outline:
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ destructive:
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ icon: "size-8",
+ "icon-xs":
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm":
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+ "icon-lg": "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: ButtonPrimitive.Props & VariantProps) {
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx
new file mode 100644
index 00000000..5d76ebce
--- /dev/null
+++ b/web/src/components/ui/card.tsx
@@ -0,0 +1,103 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+ return (
+ img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/web/src/components/ui/checkbox.tsx b/web/src/components/ui/checkbox.tsx
new file mode 100644
index 00000000..4fcd8478
--- /dev/null
+++ b/web/src/components/ui/checkbox.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
+
+import { cn } from "@/lib/utils"
+import { CheckIcon } from "lucide-react"
+
+function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx
new file mode 100644
index 00000000..2fbe7022
--- /dev/null
+++ b/web/src/components/ui/dialog.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/web/src/components/ui/dropdown-menu.tsx b/web/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..9d5ebbd2
--- /dev/null
+++ b/web/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,268 @@
+"use client"
+
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+
+import { cn } from "@/lib/utils"
+import { ChevronRightIcon, CheckIcon } from "lucide-react"
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx
new file mode 100644
index 00000000..7d21babb
--- /dev/null
+++ b/web/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/web/src/components/ui/label.tsx b/web/src/components/ui/label.tsx
new file mode 100644
index 00000000..74da65c3
--- /dev/null
+++ b/web/src/components/ui/label.tsx
@@ -0,0 +1,20 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx
new file mode 100644
index 00000000..e8021f5f
--- /dev/null
+++ b/web/src/components/ui/select.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+
+ )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+ }
+ />
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+
+ }
+ >
+
+
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/web/src/components/ui/separator.tsx b/web/src/components/ui/separator.tsx
new file mode 100644
index 00000000..6e1369e4
--- /dev/null
+++ b/web/src/components/ui/separator.tsx
@@ -0,0 +1,25 @@
+"use client"
+
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/web/src/components/ui/sheet.tsx b/web/src/components/ui/sheet.tsx
new file mode 100644
index 00000000..61ccb116
--- /dev/null
+++ b/web/src/components/ui/sheet.tsx
@@ -0,0 +1,138 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Sheet({ ...props }: SheetPrimitive.Root.Props) {
+ return
+}
+
+function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
+ return
+}
+
+function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
+ return
+}
+
+function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
+ return
+}
+
+function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: SheetPrimitive.Popup.Props & {
+ side?: "top" | "right" | "bottom" | "left"
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: SheetPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/web/src/components/ui/sidebar.tsx b/web/src/components/ui/sidebar.tsx
new file mode 100644
index 00000000..6c90d991
--- /dev/null
+++ b/web/src/components/ui/sidebar.tsx
@@ -0,0 +1,723 @@
+"use client"
+
+import * as React from "react"
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { useIsMobile } from "@/hooks/use-mobile"
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Separator } from "@/components/ui/separator"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Skeleton } from "@/components/ui/skeleton"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { PanelLeftIcon } from "lucide-react"
+
+const SIDEBAR_COOKIE_NAME = "sidebar_state"
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
+const SIDEBAR_WIDTH = "16rem"
+const SIDEBAR_WIDTH_MOBILE = "18rem"
+const SIDEBAR_WIDTH_ICON = "3rem"
+const SIDEBAR_KEYBOARD_SHORTCUT = "b"
+
+type SidebarContextProps = {
+ state: "expanded" | "collapsed"
+ open: boolean
+ setOpen: (open: boolean) => void
+ openMobile: boolean
+ setOpenMobile: (open: boolean) => void
+ isMobile: boolean
+ toggleSidebar: () => void
+}
+
+const SidebarContext = React.createContext(null)
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext)
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider.")
+ }
+
+ return context
+}
+
+function SidebarProvider({
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ className,
+ style,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ defaultOpen?: boolean
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+}) {
+ const isMobile = useIsMobile()
+ const [openMobile, setOpenMobile] = React.useState(false)
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = React.useState(defaultOpen)
+ const open = openProp ?? _open
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(open) : value
+ if (setOpenProp) {
+ setOpenProp(openState)
+ } else {
+ _setOpen(openState)
+ }
+
+ // This sets the cookie to keep the sidebar state.
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
+ },
+ [setOpenProp, open]
+ )
+
+ // Helper to toggle the sidebar.
+ const toggleSidebar = React.useCallback(() => {
+ return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
+ }, [isMobile, setOpen, setOpenMobile])
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault()
+ toggleSidebar()
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [toggleSidebar])
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? "expanded" : "collapsed"
+
+ const contextValue = React.useMemo(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function Sidebar({
+ side = "left",
+ variant = "sidebar",
+ collapsible = "offcanvas",
+ className,
+ children,
+ dir,
+ ...props
+}: React.ComponentProps<"div"> & {
+ side?: "left" | "right"
+ variant?: "sidebar" | "floating" | "inset"
+ collapsible?: "offcanvas" | "icon" | "none"
+}) {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
+
+ if (collapsible === "none") {
+ return (
+
+ {children}
+
+ )
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+ {children}
+
+
+ )
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ )
+}
+
+function SidebarTrigger({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+ {
+ onClick?.(event)
+ toggleSidebar()
+ }}
+ {...props}
+ >
+
+ Toggle Sidebar
+
+ )
+}
+
+function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+
+ )
+}
+
+function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
+ return (
+
+ )
+}
+
+function SidebarInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarGroupLabel({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(
+ {
+ className: cn(
+ "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-group-label",
+ sidebar: "group-label",
+ },
+ })
+}
+
+function SidebarGroupAction({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(
+ "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-group-action",
+ sidebar: "group-action",
+ },
+ })
+}
+
+function SidebarGroupContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+const sidebarMenuButtonVariants = cva(
+ "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
+ {
+ variants: {
+ variant: {
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline:
+ "bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
+ },
+ size: {
+ default: "h-8 text-sm",
+ sm: "h-7 text-xs",
+ lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function SidebarMenuButton({
+ render,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+}: useRender.ComponentProps<"button"> &
+ React.ComponentProps<"button"> & {
+ isActive?: boolean
+ tooltip?: string | React.ComponentProps
+ } & VariantProps) {
+ const { isMobile, state } = useSidebar()
+ const comp = useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(sidebarMenuButtonVariants({ variant, size }), className),
+ },
+ props
+ ),
+ render: !tooltip ? render : ,
+ state: {
+ slot: "sidebar-menu-button",
+ sidebar: "menu-button",
+ size,
+ active: isActive,
+ },
+ })
+
+ if (!tooltip) {
+ return comp
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ }
+ }
+
+ return (
+
+ {comp}
+
+
+ )
+}
+
+function SidebarMenuAction({
+ className,
+ render,
+ showOnHover = false,
+ ...props
+}: useRender.ComponentProps<"button"> &
+ React.ComponentProps<"button"> & {
+ showOnHover?: boolean
+ }) {
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(
+ "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
+ showOnHover &&
+ "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-menu-action",
+ sidebar: "menu-action",
+ },
+ })
+}
+
+function SidebarMenuBadge({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSkeleton({
+ className,
+ showIcon = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showIcon?: boolean
+}) {
+ // Random width between 50 to 90%.
+ const [width] = React.useState(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`
+ })
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ )
+}
+
+function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSubItem({
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSubButton({
+ render,
+ size = "md",
+ isActive = false,
+ className,
+ ...props
+}: useRender.ComponentProps<"a"> &
+ React.ComponentProps<"a"> & {
+ size?: "sm" | "md"
+ isActive?: boolean
+ }) {
+ return useRender({
+ defaultTagName: "a",
+ props: mergeProps<"a">(
+ {
+ className: cn(
+ "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-menu-sub-button",
+ sidebar: "menu-sub-button",
+ size,
+ active: isActive,
+ },
+ })
+}
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+}
diff --git a/web/src/components/ui/skeleton.tsx b/web/src/components/ui/skeleton.tsx
new file mode 100644
index 00000000..0118624f
--- /dev/null
+++ b/web/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx
new file mode 100644
index 00000000..d3f848cd
--- /dev/null
+++ b/web/src/components/ui/sonner.tsx
@@ -0,0 +1,46 @@
+"use client"
+
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ return (
+
+ ),
+ info: (
+
+ ),
+ warning: (
+
+ ),
+ error: (
+
+ ),
+ loading: (
+
+ ),
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/web/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx
new file mode 100644
index 00000000..9b8b44b1
--- /dev/null
+++ b/web/src/components/ui/switch.tsx
@@ -0,0 +1,32 @@
+"use client"
+
+import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
+
+import { cn } from "@/lib/utils"
+
+function Switch({
+ className,
+ size = "default",
+ ...props
+}: SwitchPrimitive.Root.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+
+
+ )
+}
+
+export { Switch }
diff --git a/web/src/components/ui/table.tsx b/web/src/components/ui/table.tsx
new file mode 100644
index 00000000..abeaced4
--- /dev/null
+++ b/web/src/components/ui/table.tsx
@@ -0,0 +1,116 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/web/src/components/ui/tabs.tsx b/web/src/components/ui/tabs.tsx
new file mode 100644
index 00000000..8ee8054f
--- /dev/null
+++ b/web/src/components/ui/tabs.tsx
@@ -0,0 +1,82 @@
+"use client"
+
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: TabsPrimitive.Root.Props) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: TabsPrimitive.List.Props & VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+
+ )
+}
+
+function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/web/src/components/ui/textarea.tsx b/web/src/components/ui/textarea.tsx
new file mode 100644
index 00000000..04d27f7d
--- /dev/null
+++ b/web/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/web/src/components/ui/tooltip.tsx b/web/src/components/ui/tooltip.tsx
new file mode 100644
index 00000000..69e8a822
--- /dev/null
+++ b/web/src/components/ui/tooltip.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delay = 0,
+ ...props
+}: TooltipPrimitive.Provider.Props) {
+ return (
+
+ )
+}
+
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+ return
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+ return
+}
+
+function TooltipContent({
+ className,
+ side = "top",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ children,
+ ...props
+}: TooltipPrimitive.Popup.Props &
+ Pick<
+ TooltipPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/web/src/hooks/use-mobile.ts b/web/src/hooks/use-mobile.ts
new file mode 100644
index 00000000..385ce063
--- /dev/null
+++ b/web/src/hooks/use-mobile.ts
@@ -0,0 +1,22 @@
+import * as React from "react"
+
+const MOBILE_BREAKPOINT = 768
+const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`
+
+function subscribe(onStoreChange: () => void) {
+ const mql = window.matchMedia(MOBILE_QUERY)
+ mql.addEventListener("change", onStoreChange)
+ return () => mql.removeEventListener("change", onStoreChange)
+}
+
+function getSnapshot() {
+ return window.matchMedia(MOBILE_QUERY).matches
+}
+
+function getServerSnapshot() {
+ return false
+}
+
+export function useIsMobile() {
+ return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
+}
diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts
new file mode 100644
index 00000000..f1c2f0bd
--- /dev/null
+++ b/web/src/lib/api/client.ts
@@ -0,0 +1,93 @@
+import { getConnectionSession } from "@/lib/session";
+import { ApiError } from "@/lib/api/errors";
+
+export type GatewayRequestInit = RequestInit & {
+ admin?: boolean;
+ gatewayUrl?: string;
+ apiKey?: string;
+ adminToken?: string;
+ timeoutMs?: number;
+ onResponse?: (response: Response) => void;
+};
+
+export async function gatewayFetch(
+ path: string,
+ init: GatewayRequestInit = {},
+): Promise {
+ const session = await getConnectionSession();
+ const gatewayUrl = init.gatewayUrl ?? session?.gatewayUrl;
+ const apiKey = init.apiKey ?? session?.apiKey;
+ const adminToken = init.adminToken ?? session?.adminToken;
+
+ if (!gatewayUrl || !apiKey) {
+ throw new ApiError(
+ 401,
+ "Not connected. Configure Gateway URL and API key in Settings.",
+ );
+ }
+
+ const url = `${gatewayUrl}${path.startsWith("/") ? path : `/${path}`}`;
+ const headers = new Headers(init.headers);
+ headers.set("X-API-Key", apiKey);
+ if (init.admin && adminToken) {
+ headers.set("X-Admin-Token", adminToken);
+ }
+ if (init.body && !headers.has("Content-Type")) {
+ headers.set("Content-Type", "application/json");
+ }
+
+ const timeoutMs = init.timeoutMs ?? 30_000;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ const res = await fetch(url, {
+ ...init,
+ headers,
+ signal: init.signal ?? controller.signal,
+ cache: "no-store",
+ // Avoid leaking auth headers across redirect hops.
+ redirect: "manual",
+ });
+
+ init.onResponse?.(res);
+
+ if (!res.ok) {
+ let body: unknown = undefined;
+ const text = await res.text();
+ try {
+ body = text ? JSON.parse(text) : undefined;
+ } catch {
+ body = text;
+ }
+ const message =
+ typeof body === "object" &&
+ body &&
+ "message" in body &&
+ typeof (body as { message: unknown }).message === "string"
+ ? (body as { message: string }).message
+ : res.statusText || `HTTP ${res.status}`;
+ throw new ApiError(res.status, message, 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;
+ } catch (error) {
+ if (error instanceof ApiError) {
+ throw error;
+ }
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new ApiError(408, "Request timed out talking to the Gateway.");
+ }
+ throw error;
+ } finally {
+ clearTimeout(timer);
+ }
+}
diff --git a/web/src/lib/api/connection-server.ts b/web/src/lib/api/connection-server.ts
new file mode 100644
index 00000000..48bffcce
--- /dev/null
+++ b/web/src/lib/api/connection-server.ts
@@ -0,0 +1,263 @@
+import {
+ deriveConnectionStatus,
+ parseGatewayUrl,
+ redactSecrets,
+ summarizeConnection,
+ type ConnectionProbe,
+ type ConnectionUpdateRequest,
+ type ProbeCheck,
+ type ProbeCheckId,
+} from "@/lib/api/connection";
+import { getStoredConnectionFields, type ConnectionSession } from "@/lib/session";
+
+const PROBE_TIMEOUT_MS = 8_000;
+
+const CONNECT_ERROR_HINT: Record = {
+ ECONNREFUSED:
+ "Connection refused — nothing is listening on that host and port.",
+ ENOTFOUND: "Host not found — check the hostname.",
+ EAI_AGAIN: "DNS lookup failed — check the hostname and your network.",
+ ECONNRESET: "Connection reset by the Gateway.",
+ EHOSTUNREACH: "Host unreachable from this machine.",
+ ETIMEDOUT: "Connection timed out.",
+ CERT_HAS_EXPIRED: "The Gateway's TLS certificate has expired.",
+ DEPTH_ZERO_SELF_SIGNED_CERT:
+ "The Gateway uses a self-signed TLS certificate.",
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE:
+ "The Gateway's TLS certificate could not be verified.",
+};
+
+function findErrorCode(error: unknown, depth = 0): string | undefined {
+ if (!error || typeof error !== "object" || depth > 3) {
+ return undefined;
+ }
+ const code = (error as { code?: unknown }).code;
+ if (typeof code === "string") {
+ return code;
+ }
+ const nested = (error as { errors?: unknown }).errors;
+ if (Array.isArray(nested)) {
+ for (const entry of nested) {
+ const found = findErrorCode(entry, depth + 1);
+ if (found) {
+ return found;
+ }
+ }
+ }
+ return findErrorCode((error as { cause?: unknown }).cause, depth + 1);
+}
+
+function describeFetchError(error: unknown): string {
+ const code = findErrorCode(error);
+ if (code) {
+ return CONNECT_ERROR_HINT[code] ?? `Request failed (${code}).`;
+ }
+ if (error instanceof Error && error.message !== "fetch failed") {
+ return error.message;
+ }
+ return "Could not reach the Gateway — check the URL, port, and that the service is running.";
+}
+
+async function runCheck(
+ id: ProbeCheckId,
+ label: string,
+ gatewayUrl: string,
+ path: string,
+ headers: Record,
+ secrets: Array,
+): Promise {
+ const startedAt = Date.now();
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
+
+ try {
+ const response = await fetch(`${gatewayUrl}${path}`, {
+ method: "GET",
+ headers,
+ cache: "no-store",
+ redirect: "manual",
+ signal: controller.signal,
+ });
+ const durationMs = Date.now() - startedAt;
+
+ if (response.ok) {
+ return {
+ id,
+ label,
+ path,
+ outcome: "ok",
+ httpStatus: response.status,
+ durationMs,
+ detail: `HTTP ${response.status}`,
+ };
+ }
+
+ if (response.status === 401 || response.status === 403) {
+ return {
+ id,
+ label,
+ path,
+ outcome: "unauthorized",
+ httpStatus: response.status,
+ durationMs,
+ detail:
+ response.status === 401
+ ? "Rejected (401) — credentials were not accepted."
+ : "Forbidden (403) — the supplied token lacks permission.",
+ };
+ }
+
+ return {
+ id,
+ label,
+ path,
+ outcome: "failed",
+ httpStatus: response.status,
+ durationMs,
+ detail: `HTTP ${response.status} ${response.statusText}`.trim(),
+ };
+ } catch (error) {
+ const durationMs = Date.now() - startedAt;
+ const aborted = error instanceof Error && error.name === "AbortError";
+ const detail = aborted
+ ? `No response within ${PROBE_TIMEOUT_MS / 1000}s.`
+ : describeFetchError(error);
+
+ return {
+ id,
+ label,
+ path,
+ outcome: "failed",
+ durationMs,
+ detail: redactSecrets(detail, secrets),
+ };
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+export async function probeConnection(
+ credentials: ConnectionSession,
+): Promise {
+ const secrets = [credentials.apiKey, credentials.adminToken];
+ const apiHeaders: Record = {
+ "X-API-Key": credentials.apiKey,
+ Accept: "application/json",
+ };
+ const adminHeaders: Record = credentials.adminToken
+ ? { ...apiHeaders, "X-Admin-Token": credentials.adminToken }
+ : apiHeaders;
+
+ const nodesCheck: Promise = credentials.adminToken
+ ? runCheck(
+ "nodes",
+ "Nodes API",
+ credentials.gatewayUrl,
+ "/nodes",
+ adminHeaders,
+ secrets,
+ )
+ : Promise.resolve({
+ id: "nodes",
+ label: "Nodes API",
+ path: "/nodes",
+ outcome: "skipped",
+ detail:
+ "Unavailable due to permissions — add an admin token to view nodes.",
+ });
+
+ const checks = await Promise.all([
+ runCheck(
+ "health",
+ "Gateway health",
+ credentials.gatewayUrl,
+ "/health",
+ apiHeaders,
+ secrets,
+ ),
+ runCheck(
+ "sandboxes",
+ "Sandboxes API",
+ credentials.gatewayUrl,
+ "/v2/sandboxes?limit=1",
+ apiHeaders,
+ secrets,
+ ),
+ nodesCheck,
+ ]);
+
+ const status = deriveConnectionStatus(checks);
+ return {
+ status,
+ summary: summarizeConnection(status, checks),
+ checks,
+ checkedAt: new Date().toISOString(),
+ };
+}
+
+export type ResolvedConnectionInput =
+ | { ok: true; credentials: ConnectionSession; force: boolean }
+ | { ok: false; error: string };
+
+function optionalString(value: unknown): string | undefined {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+ const trimmed = value.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+export async function resolveConnectionInput(
+ request: Request,
+): Promise {
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ body = {};
+ }
+
+ if (body !== null && typeof body !== "object") {
+ return { ok: false, error: "Expected a JSON object body." };
+ }
+
+ const input = (body ?? {}) as ConnectionUpdateRequest;
+ const stored = await getStoredConnectionFields();
+
+ const gatewayUrlInput = optionalString(input.gatewayUrl) ?? stored.gatewayUrl;
+ if (!gatewayUrlInput) {
+ return { ok: false, error: "Gateway URL is required." };
+ }
+ const parsedUrl = parseGatewayUrl(gatewayUrlInput);
+ if (!parsedUrl.ok) {
+ return { ok: false, error: parsedUrl.error };
+ }
+
+ // Blank secrets keep stored values only when the destination is unchanged.
+ const destinationChanged =
+ !stored.gatewayUrl || stored.gatewayUrl !== parsedUrl.url;
+
+ const apiKey =
+ optionalString(input.apiKey) ??
+ (destinationChanged ? undefined : stored.apiKey);
+ if (!apiKey) {
+ return {
+ ok: false,
+ error: destinationChanged
+ ? "API key is required when changing the Gateway URL."
+ : "API key is required.",
+ };
+ }
+
+ const adminToken =
+ input.clearAdminToken === true
+ ? undefined
+ : (optionalString(input.adminToken) ??
+ (destinationChanged ? undefined : stored.adminToken));
+
+ return {
+ ok: true,
+ credentials: { gatewayUrl: parsedUrl.url, apiKey, adminToken },
+ force: input.force === true,
+ };
+}
diff --git a/web/src/lib/api/connection.ts b/web/src/lib/api/connection.ts
new file mode 100644
index 00000000..de0f7b17
--- /dev/null
+++ b/web/src/lib/api/connection.ts
@@ -0,0 +1,176 @@
+export type ConnectionStatus = "connected" | "partial" | "disconnected";
+
+export type ProbeOutcome = "ok" | "unauthorized" | "failed" | "skipped";
+
+export type ProbeCheckId = "health" | "sandboxes" | "nodes";
+
+export type ProbeCheck = {
+ id: ProbeCheckId;
+ label: string;
+ path: string;
+ outcome: ProbeOutcome;
+ httpStatus?: number;
+ durationMs?: number;
+ detail: string;
+};
+
+export type ConnectionProbe = {
+ status: ConnectionStatus;
+ summary: string;
+ checks: ProbeCheck[];
+ checkedAt: string;
+};
+
+export type ConnectionSessionSummary = {
+ configured: boolean;
+ gatewayUrl: string | null;
+ apiKeyMasked: string | null;
+ adminTokenMasked: string | null;
+ hasAdminToken: boolean;
+};
+
+export const EMPTY_SESSION_SUMMARY: ConnectionSessionSummary = {
+ configured: false,
+ gatewayUrl: null,
+ apiKeyMasked: null,
+ adminTokenMasked: null,
+ hasAdminToken: false,
+};
+
+export type ConnectionUpdateRequest = {
+ gatewayUrl?: string;
+ apiKey?: string;
+ adminToken?: string;
+ clearAdminToken?: boolean;
+ force?: boolean;
+};
+
+export type ConnectionApiResponse = {
+ session: ConnectionSessionSummary;
+ probe: ConnectionProbe | null;
+ error?: string;
+};
+
+export function maskSecret(secret: string): string {
+ const trimmed = secret.trim();
+ if (!trimmed) {
+ return "";
+ }
+ if (trimmed.length <= 4) {
+ return "•".repeat(trimmed.length);
+ }
+ return `${"•".repeat(Math.min(8, trimmed.length - 4))}${trimmed.slice(-4)}`;
+}
+
+export function redactSecrets(
+ text: string,
+ secrets: Array,
+): string {
+ let out = text;
+ for (const secret of secrets) {
+ if (!secret || secret.length < 4) {
+ continue;
+ }
+ out = out.split(secret).join("[redacted]");
+ }
+ return out;
+}
+
+export type GatewayUrlParse =
+ | { ok: true; url: string }
+ | { ok: false; error: string };
+
+export function parseGatewayUrl(raw: string): GatewayUrlParse {
+ const trimmed = raw.trim();
+ if (!trimmed) {
+ return { ok: false, error: "Gateway URL is required." };
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(trimmed);
+ } catch {
+ return {
+ ok: false,
+ error: "Enter a full URL, for example http://127.0.0.1:8080",
+ };
+ }
+
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ return { ok: false, error: "Gateway URL must use http:// or https://" };
+ }
+ if (!parsed.hostname) {
+ return { ok: false, error: "Gateway URL is missing a hostname." };
+ }
+
+ const basePath = parsed.pathname.replace(/\/+$/, "");
+ return { ok: true, url: `${parsed.protocol}//${parsed.host}${basePath}` };
+}
+
+function findCheck(
+ checks: ProbeCheck[],
+ id: ProbeCheckId,
+): ProbeCheck | undefined {
+ return checks.find((check) => check.id === id);
+}
+
+export function deriveConnectionStatus(checks: ProbeCheck[]): ConnectionStatus {
+ const health = findCheck(checks, "health");
+ const sandboxes = findCheck(checks, "sandboxes");
+ const nodes = findCheck(checks, "nodes");
+
+ if (!sandboxes) {
+ return "disconnected";
+ }
+
+ switch (sandboxes.outcome) {
+ case "ok":
+ break;
+ case "unauthorized":
+ return "disconnected";
+ case "failed":
+ case "skipped":
+ return health?.outcome === "ok" ? "partial" : "disconnected";
+ default: {
+ const exhaustive: never = sandboxes.outcome;
+ return exhaustive;
+ }
+ }
+
+ if (health?.outcome !== "ok") {
+ return "partial";
+ }
+ return nodes?.outcome === "ok" ? "connected" : "partial";
+}
+
+export function summarizeConnection(
+ status: ConnectionStatus,
+ checks: ProbeCheck[],
+): string {
+ switch (status) {
+ case "connected":
+ return "Gateway reachable. API key and admin token accepted.";
+ case "partial": {
+ const nodes = findCheck(checks, "nodes");
+ if (nodes && nodes.outcome === "skipped") {
+ return "Gateway reachable. Node views are unavailable without an admin token.";
+ }
+ if (nodes && nodes.outcome !== "ok") {
+ return "Gateway reachable. Node views are unavailable with the current admin token.";
+ }
+ return "Gateway partially reachable — review the individual checks.";
+ }
+ case "disconnected":
+ return "Gateway is unreachable or the API key was rejected.";
+ default: {
+ const exhaustive: never = status;
+ return exhaustive;
+ }
+ }
+}
+
+export const CONNECTION_STATUS_LABEL: Record = {
+ connected: "Connected",
+ partial: "Partial",
+ disconnected: "Disconnected",
+};
diff --git a/web/src/lib/api/dashboard.ts b/web/src/lib/api/dashboard.ts
new file mode 100644
index 00000000..998a9804
--- /dev/null
+++ b/web/src/lib/api/dashboard.ts
@@ -0,0 +1,225 @@
+import { gatewayFetch } from "@/lib/api/client";
+import { userFacingApiMessage, ApiError } from "@/lib/api/errors";
+import { listNodes, type ClusterNode } from "@/lib/api/nodes";
+import type {
+ SandboxInfo,
+ SnapshotInfo,
+ TemplateInfo,
+} from "@/lib/api/types";
+import { getConnectionSession } from "@/lib/session";
+
+export type LoadResult =
+ | { ok: true; data: T }
+ | { ok: false; status?: number; message: string };
+
+async function settle(load: () => Promise): Promise> {
+ try {
+ return { ok: true, data: await load() };
+ } catch (error) {
+ return {
+ ok: false,
+ status: error instanceof ApiError ? error.status : undefined,
+ message: userFacingApiMessage(error),
+ };
+ }
+}
+
+export type GatewayHealth = {
+ latencyMs: number;
+};
+
+export type DashboardData = {
+ connected: boolean;
+ adminTokenPresent: boolean;
+ gatewayUrl: string | null;
+ fetchedAt: string;
+ health: LoadResult;
+ nodes: LoadResult;
+ sandboxes: LoadResult;
+ templates: LoadResult;
+ snapshots: LoadResult;
+};
+
+const ADMIN_TOKEN_MISSING: LoadResult = {
+ ok: false,
+ status: 403,
+ message: "Admin token required.",
+};
+
+const LIST_LIMIT = 100;
+
+async function checkHealth(): Promise {
+ const startedAt = Date.now();
+ await gatewayFetch("/health", { timeoutMs: 10_000 });
+ return { latencyMs: Date.now() - startedAt };
+}
+
+async function listSandboxes(): Promise {
+ const sandboxes = await gatewayFetch(
+ `/v2/sandboxes?state=running&state=paused&limit=${LIST_LIMIT}`,
+ );
+ return Array.isArray(sandboxes) ? sandboxes : [];
+}
+
+async function listTemplates(): Promise {
+ const templates = await gatewayFetch(
+ `/v2/templates?limit=${LIST_LIMIT}`,
+ );
+ return Array.isArray(templates) ? templates : [];
+}
+
+async function listSnapshots(): Promise {
+ const snapshots = await gatewayFetch(
+ `/snapshots?limit=${LIST_LIMIT}`,
+ );
+ return Array.isArray(snapshots) ? snapshots : [];
+}
+
+/**
+ * Loads every dashboard panel independently so one failing (or unauthorized)
+ * endpoint degrades a single panel instead of the whole page.
+ */
+export async function loadDashboard(): Promise {
+ const session = await getConnectionSession();
+
+ if (!session) {
+ return {
+ connected: false,
+ adminTokenPresent: false,
+ gatewayUrl: null,
+ fetchedAt: new Date().toISOString(),
+ health: { ok: false, message: "Not connected." },
+ nodes: { ok: false, message: "Not connected." },
+ sandboxes: { ok: false, message: "Not connected." },
+ templates: { ok: false, message: "Not connected." },
+ snapshots: { ok: false, message: "Not connected." },
+ };
+ }
+
+ const adminTokenPresent = Boolean(session.adminToken);
+
+ const [health, nodes, sandboxes, templates, snapshots] = await Promise.all([
+ settle(checkHealth),
+ adminTokenPresent
+ ? settle(() => listNodes())
+ : Promise.resolve>(ADMIN_TOKEN_MISSING),
+ settle(listSandboxes),
+ settle(listTemplates),
+ settle(listSnapshots),
+ ]);
+
+ return {
+ connected: true,
+ adminTokenPresent,
+ gatewayUrl: session.gatewayUrl,
+ fetchedAt: new Date().toISOString(),
+ health,
+ nodes,
+ sandboxes,
+ templates,
+ snapshots,
+ };
+}
+
+export type SandboxSummary = {
+ total: number;
+ running: number;
+ paused: number;
+ other: number;
+ cpuAllocated: number;
+ memoryAllocatedMB: number;
+ recent: SandboxInfo[];
+ expiringSoon: SandboxInfo[];
+};
+
+function timestamp(value?: string): number | null {
+ if (!value) {
+ return null;
+ }
+ const parsed = Date.parse(value);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function summarizeSandboxes(
+ sandboxes: SandboxInfo[],
+ now = Date.now(),
+): SandboxSummary {
+ const summary: SandboxSummary = {
+ total: sandboxes.length,
+ running: 0,
+ paused: 0,
+ other: 0,
+ cpuAllocated: 0,
+ memoryAllocatedMB: 0,
+ recent: [],
+ expiringSoon: [],
+ };
+
+ for (const sandbox of sandboxes) {
+ const state = (sandbox.state ?? "").toLowerCase();
+ if (state === "running") {
+ summary.running += 1;
+ } else if (state === "paused") {
+ summary.paused += 1;
+ } else {
+ summary.other += 1;
+ }
+ summary.cpuAllocated += sandbox.cpuCount ?? 0;
+ summary.memoryAllocatedMB += sandbox.memoryMB ?? 0;
+ }
+
+ summary.recent = [...sandboxes]
+ .filter((sandbox) => timestamp(sandbox.startedAt) !== null)
+ .sort(
+ (a, b) => (timestamp(b.startedAt) ?? 0) - (timestamp(a.startedAt) ?? 0),
+ )
+ .slice(0, 5);
+
+ summary.expiringSoon = [...sandboxes]
+ .filter((sandbox) => {
+ const endAt = timestamp(sandbox.endAt);
+ return endAt !== null && endAt >= now;
+ })
+ .sort((a, b) => (timestamp(a.endAt) ?? 0) - (timestamp(b.endAt) ?? 0))
+ .slice(0, 5);
+
+ return summary;
+}
+
+export type TemplateSummary = {
+ total: number;
+ ready: number;
+ building: number;
+ failed: TemplateInfo[];
+};
+
+export function summarizeTemplates(templates: TemplateInfo[]): TemplateSummary {
+ const summary: TemplateSummary = {
+ total: templates.length,
+ ready: 0,
+ building: 0,
+ failed: [],
+ };
+
+ for (const template of templates) {
+ const status = (template.buildStatus ?? "").toLowerCase();
+ if (status === "ready") {
+ summary.ready += 1;
+ } else if (status === "building" || status === "waiting") {
+ summary.building += 1;
+ } else if (status === "error" || status === "failed") {
+ summary.failed.push(template);
+ }
+ }
+
+ return summary;
+}
+
+export function templateLabel(template: TemplateInfo): string {
+ const names = template.names;
+ const first = Array.isArray(names) ? names[0] : template.aliases?.[0];
+ if (typeof first === "string" && first.length > 0) {
+ return first;
+ }
+ return template.templateID ?? "unknown template";
+}
diff --git a/web/src/lib/api/errors.ts b/web/src/lib/api/errors.ts
new file mode 100644
index 00000000..8821ed2b
--- /dev/null
+++ b/web/src/lib/api/errors.ts
@@ -0,0 +1,35 @@
+export class ApiError extends Error {
+ readonly status: number;
+ readonly body: unknown;
+
+ constructor(status: number, message: string, body?: unknown) {
+ super(message);
+ this.name = "ApiError";
+ this.status = status;
+ this.body = body;
+ }
+}
+
+export function userFacingApiMessage(error: unknown): string {
+ if (error instanceof ApiError) {
+ switch (error.status) {
+ case 401:
+ return "Unauthorized — check your API key.";
+ case 403:
+ return "Forbidden — missing or invalid admin token for this action.";
+ case 404:
+ return "Resource not found.";
+ case 409:
+ return "Conflict — the resource is in an incompatible state.";
+ default:
+ if (error.status >= 500) {
+ return "Upstream server error. Retry in a moment.";
+ }
+ return error.message || `Request failed (${error.status}).`;
+ }
+ }
+ if (error instanceof Error) {
+ return error.message;
+ }
+ return "Unexpected error.";
+}
diff --git a/web/src/lib/api/nodes.ts b/web/src/lib/api/nodes.ts
new file mode 100644
index 00000000..54a67ccf
--- /dev/null
+++ b/web/src/lib/api/nodes.ts
@@ -0,0 +1,276 @@
+import { gatewayFetch } from "@/lib/api/client";
+import type { DiskMetrics, NodeStatus } from "@/lib/api/types";
+
+/**
+ * Node payloads returned by `GET /nodes` and `GET /nodes/{nodeID}`.
+ * Field names follow `src/api/openapi.yml` (`Node` / `NodeDetail`), which uses
+ * `id` rather than the `nodeID` path parameter name.
+ */
+
+export const NODE_STATUSES = [
+ "ready",
+ "draining",
+ "connecting",
+ "unhealthy",
+] as const satisfies readonly NodeStatus[];
+
+export type MachineInfo = {
+ cpuFamily?: string;
+ cpuModel?: string;
+ cpuModelName?: string;
+ cpuArchitecture?: string;
+};
+
+export type ClusterNodeMetrics = {
+ allocatedCPU?: number;
+ allocatedMemoryBytes?: number;
+ cpuPercent?: number;
+ cpuCount?: number;
+ memoryUsedBytes?: number;
+ memoryTotalBytes?: number;
+ disks?: DiskMetrics[];
+ pausedAllocatedCPU?: number;
+ pausedAllocatedMemoryBytes?: number;
+};
+
+export type ClusterNode = {
+ id: string;
+ clusterID?: string;
+ serviceInstanceID?: string;
+ /** `NodeStatus` in practice; the gateway also emits `unspecified`. */
+ status?: string;
+ version?: string;
+ commit?: string;
+ machineInfo?: MachineInfo;
+ metrics?: ClusterNodeMetrics;
+ sandboxCount?: number;
+ sandboxStartingCount?: number;
+ sandboxPausedCount?: number;
+ createSuccesses?: number;
+ createFails?: number;
+};
+
+export type ClusterNodeDetail = ClusterNode & {
+ cachedBuilds?: string[];
+};
+
+export async function listNodes(clusterID?: string): Promise {
+ const query = clusterID ? `?clusterID=${encodeURIComponent(clusterID)}` : "";
+ const nodes = await gatewayFetch(`/nodes${query}`, {
+ admin: true,
+ });
+ return Array.isArray(nodes) ? nodes : [];
+}
+
+export async function getNodeDetail(
+ nodeID: string,
+ clusterID?: string,
+): Promise {
+ const query = clusterID ? `?clusterID=${encodeURIComponent(clusterID)}` : "";
+ return gatewayFetch(
+ `/nodes/${encodeURIComponent(nodeID)}${query}`,
+ { admin: true },
+ );
+}
+
+export function isKnownNodeStatus(status: string): status is NodeStatus {
+ return (NODE_STATUSES as readonly string[]).includes(status);
+}
+
+export function nodeStatus(node: ClusterNode): NodeStatus | "unknown" {
+ const status = (node.status ?? "").toLowerCase();
+ return isKnownNodeStatus(status) ? status : "unknown";
+}
+
+export type NodeStatusCounts = Record & {
+ total: number;
+};
+
+export function countNodesByStatus(nodes: ClusterNode[]): NodeStatusCounts {
+ const counts: NodeStatusCounts = {
+ ready: 0,
+ draining: 0,
+ connecting: 0,
+ unhealthy: 0,
+ unknown: 0,
+ total: nodes.length,
+ };
+ for (const node of nodes) {
+ counts[nodeStatus(node)] += 1;
+ }
+ return counts;
+}
+
+export type ClusterCapacity = {
+ nodeCount: number;
+ cpuCount: number;
+ cpuAllocated: number;
+ cpuAllocatedPaused: number;
+ /** Node CPU utilisation averaged across nodes, weighted by core count. */
+ cpuPercent: number | null;
+ memoryTotalBytes: number;
+ memoryUsedBytes: number;
+ memoryAllocatedBytes: number;
+ memoryAllocatedPausedBytes: number;
+ diskTotalBytes: number;
+ diskUsedBytes: number;
+ sandboxesRunning: number;
+ sandboxesPaused: number;
+ sandboxesStarting: number;
+ createSuccesses: number;
+ createFails: number;
+};
+
+export function aggregateCapacity(nodes: ClusterNode[]): ClusterCapacity {
+ const capacity: ClusterCapacity = {
+ nodeCount: nodes.length,
+ cpuCount: 0,
+ cpuAllocated: 0,
+ cpuAllocatedPaused: 0,
+ cpuPercent: null,
+ memoryTotalBytes: 0,
+ memoryUsedBytes: 0,
+ memoryAllocatedBytes: 0,
+ memoryAllocatedPausedBytes: 0,
+ diskTotalBytes: 0,
+ diskUsedBytes: 0,
+ sandboxesRunning: 0,
+ sandboxesPaused: 0,
+ sandboxesStarting: 0,
+ createSuccesses: 0,
+ createFails: 0,
+ };
+
+ let weightedCpuPercent = 0;
+ let cpuPercentWeight = 0;
+
+ for (const node of nodes) {
+ const metrics = node.metrics;
+ capacity.sandboxesRunning += node.sandboxCount ?? 0;
+ capacity.sandboxesPaused += node.sandboxPausedCount ?? 0;
+ capacity.sandboxesStarting += node.sandboxStartingCount ?? 0;
+ capacity.createSuccesses += node.createSuccesses ?? 0;
+ capacity.createFails += node.createFails ?? 0;
+
+ if (!metrics) {
+ continue;
+ }
+
+ const cpuCount = metrics.cpuCount ?? 0;
+ capacity.cpuCount += cpuCount;
+ capacity.cpuAllocated += metrics.allocatedCPU ?? 0;
+ capacity.cpuAllocatedPaused += metrics.pausedAllocatedCPU ?? 0;
+ capacity.memoryTotalBytes += metrics.memoryTotalBytes ?? 0;
+ capacity.memoryUsedBytes += metrics.memoryUsedBytes ?? 0;
+ capacity.memoryAllocatedBytes += metrics.allocatedMemoryBytes ?? 0;
+ capacity.memoryAllocatedPausedBytes += metrics.pausedAllocatedMemoryBytes ?? 0;
+
+ if (typeof metrics.cpuPercent === "number") {
+ const weight = cpuCount > 0 ? cpuCount : 1;
+ weightedCpuPercent += metrics.cpuPercent * weight;
+ cpuPercentWeight += weight;
+ }
+
+ for (const disk of metrics.disks ?? []) {
+ capacity.diskTotalBytes += disk.totalBytes ?? 0;
+ capacity.diskUsedBytes += disk.usedBytes ?? 0;
+ }
+ }
+
+ if (cpuPercentWeight > 0) {
+ capacity.cpuPercent = weightedCpuPercent / cpuPercentWeight;
+ }
+
+ return capacity;
+}
+
+export type PressureLevel = "ok" | "warn" | "critical";
+
+export type NodePressure = {
+ level: PressureLevel;
+ reasons: string[];
+};
+
+function ratioPercent(used?: number, total?: number): number | null {
+ if (!total || total <= 0 || typeof used !== "number") {
+ return null;
+ }
+ return (used / total) * 100;
+}
+
+/** Flags nodes that are unhealthy or close to exhausting a resource. */
+export function nodePressure(node: ClusterNode): NodePressure {
+ const reasons: string[] = [];
+ let level: PressureLevel = "ok";
+
+ const severity: Record = {
+ ok: 0,
+ warn: 1,
+ critical: 2,
+ };
+ const escalate = (next: PressureLevel, reason: string) => {
+ reasons.push(reason);
+ if (severity[next] > severity[level]) {
+ level = next;
+ }
+ };
+
+ const status = nodeStatus(node);
+ if (status === "unhealthy") {
+ escalate("critical", "Node reported unhealthy");
+ } else if (status === "draining") {
+ escalate("warn", "Node is draining");
+ } else if (status === "connecting" || status === "unknown") {
+ escalate("warn", "Node has not reported ready");
+ }
+
+ const metrics = node.metrics;
+ if (metrics) {
+ const cpuPercent = metrics.cpuPercent;
+ if (typeof cpuPercent === "number") {
+ if (cpuPercent >= 90) {
+ escalate("critical", `Host CPU at ${Math.round(cpuPercent)}%`);
+ } else if (cpuPercent >= 75) {
+ escalate("warn", `Host CPU at ${Math.round(cpuPercent)}%`);
+ }
+ }
+
+ const memPercent = ratioPercent(
+ metrics.memoryUsedBytes,
+ metrics.memoryTotalBytes,
+ );
+ if (memPercent !== null) {
+ if (memPercent >= 90) {
+ escalate("critical", `Host memory at ${Math.round(memPercent)}%`);
+ } else if (memPercent >= 75) {
+ escalate("warn", `Host memory at ${Math.round(memPercent)}%`);
+ }
+ }
+
+ const cpuCount = metrics.cpuCount ?? 0;
+ const allocatedCPU = metrics.allocatedCPU ?? 0;
+ if (cpuCount > 0 && allocatedCPU > cpuCount) {
+ escalate(
+ "warn",
+ `CPU oversubscribed (${allocatedCPU} allocated / ${cpuCount} cores)`,
+ );
+ }
+
+ for (const disk of metrics.disks ?? []) {
+ const diskPercent = ratioPercent(disk.usedBytes, disk.totalBytes);
+ if (diskPercent === null) {
+ continue;
+ }
+ if (diskPercent >= 90) {
+ escalate(
+ "critical",
+ `${disk.mountPoint} at ${Math.round(diskPercent)}%`,
+ );
+ } else if (diskPercent >= 80) {
+ escalate("warn", `${disk.mountPoint} at ${Math.round(diskPercent)}%`);
+ }
+ }
+ }
+
+ return { level, reasons };
+}
diff --git a/web/src/lib/api/paging.ts b/web/src/lib/api/paging.ts
new file mode 100644
index 00000000..a93c2593
--- /dev/null
+++ b/web/src/lib/api/paging.ts
@@ -0,0 +1,57 @@
+import { gatewayFetch } from "@/lib/api/client";
+
+/**
+ * Cursor pagination helper. The list endpoints return the cursor for the next
+ * page in the `x-next-token` response header rather than in the body.
+ */
+export type Page = {
+ items: T[];
+ nextToken?: string;
+};
+
+export const DEFAULT_PAGE_LIMIT = 25;
+
+/** Maximum accepted by the `limit` parameter in `src/api/openapi.yml`. */
+export const MAX_PAGE_LIMIT = 100;
+
+export function pageQuery(
+ params: Record,
+): string {
+ const search = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (value === undefined || value === "") {
+ continue;
+ }
+ search.set(key, String(value));
+ }
+ const query = search.toString();
+ return query ? `?${query}` : "";
+}
+
+export function clampLimit(
+ value: number | undefined,
+ fallback = DEFAULT_PAGE_LIMIT,
+): number {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ return fallback;
+ }
+ return Math.min(MAX_PAGE_LIMIT, Math.max(1, Math.trunc(value)));
+}
+
+export async function gatewayFetchPage(
+ path: string,
+ timeoutMs = 30_000,
+): Promise> {
+ let nextToken: string | undefined;
+ const items = await gatewayFetch(path, {
+ timeoutMs,
+ onResponse: (response) => {
+ nextToken = response.headers.get("x-next-token") ?? undefined;
+ },
+ });
+
+ return {
+ items: Array.isArray(items) ? items : [],
+ nextToken: nextToken || undefined,
+ };
+}
diff --git a/web/src/lib/api/sandboxes.ts b/web/src/lib/api/sandboxes.ts
new file mode 100644
index 00000000..27fcaef4
--- /dev/null
+++ b/web/src/lib/api/sandboxes.ts
@@ -0,0 +1,312 @@
+/**
+ * Typed wrappers around the sandbox endpoints of the AgentENV control-plane API
+ * (see `src/api/openapi.yml`). Every function runs on the server because
+ * `gatewayFetch` reads the connection session from cookies.
+ */
+
+import { gatewayFetch } from "@/lib/api/client";
+
+export const SANDBOX_STATES = ["running", "paused"] as const;
+
+export type SandboxLifecycleState = (typeof SANDBOX_STATES)[number];
+
+export type SandboxOnTimeout = "kill" | "pause";
+
+export type SandboxNetworkConfig = {
+ allowPublicTraffic?: boolean;
+ allowOut?: string[];
+ denyOut?: string[];
+ maskRequestHost?: string;
+};
+
+export type SandboxNetworkUpdate = {
+ allowOut?: string[];
+ denyOut?: string[];
+ allow_internet_access?: boolean;
+};
+
+export type SandboxLifecyclePolicy = {
+ autoResume: boolean;
+ onTimeout: SandboxOnTimeout;
+};
+
+export type ListedSandbox = {
+ sandboxID: string;
+ templateID: string;
+ alias?: string;
+ clientID?: string;
+ startedAt: string;
+ endAt: string;
+ cpuCount: number;
+ memoryMB: number;
+ diskSizeMB: number;
+ metadata?: Record;
+ state: SandboxLifecycleState;
+ envdVersion?: string;
+};
+
+export type SandboxDetail = ListedSandbox & {
+ envdAccessToken?: string;
+ allowInternetAccess?: boolean | null;
+ domain?: string | null;
+ network?: SandboxNetworkConfig;
+ lifecycle?: SandboxLifecyclePolicy;
+};
+
+/** Shape returned by create / connect / fork — identity and connection info only. */
+export type CreatedSandbox = {
+ sandboxID: string;
+ templateID: string;
+ alias?: string;
+ clientID?: string;
+ envdVersion?: string;
+ envdAccessToken?: string;
+ trafficAccessToken?: string | null;
+ domain?: string | null;
+};
+
+export type AttachedDriveInput = {
+ driveID: string;
+ source: { image: string };
+ readOnly?: boolean;
+ mountPath?: string;
+ subPath?: string;
+ diskSizeMB?: number;
+};
+
+export type NewSandboxRequest = {
+ templateID: string;
+ timeout?: number;
+ autoPause?: boolean;
+ autoResume?: { enabled: boolean };
+ secure?: boolean;
+ allow_internet_access?: boolean;
+ network?: SandboxNetworkConfig;
+ metadata?: Record;
+ envVars?: Record;
+ customExtensionParams?: Record;
+};
+
+export type NewColdSandboxRequest = {
+ image: string;
+ timeout?: number;
+ autoPause?: boolean;
+ autoResume?: { enabled: boolean };
+ allowInternetAccess?: boolean;
+ network?: SandboxNetworkConfig;
+ metadata?: Record;
+ envVars?: Record;
+ customExtensionParams?: Record;
+ cpuCount?: number;
+ memoryMB?: number;
+ diskSizeMB?: number;
+ attachedDrives?: AttachedDriveInput[];
+ extraBootArgs?: string;
+};
+
+export type SandboxForkResult = {
+ sandbox?: CreatedSandbox;
+ error?: { message?: string; code?: number };
+};
+
+export type SnapshotInfo = {
+ snapshotID: string;
+ names?: string[];
+ cpuCount?: number;
+ memoryMB?: number;
+ diskSizeMB?: number;
+ createdAt?: string;
+ updatedAt?: string;
+};
+
+export type CustomExtensionParams = Record;
+
+/**
+ * Cold starts pull and convert OCI layers on a cache miss, and template creates
+ * may have to warm a snapshot, so both need far more headroom than the 30s
+ * default in `gatewayFetch`.
+ */
+export const CREATE_TIMEOUT_MS = 180_000;
+const FORK_TIMEOUT_MS = 120_000;
+const SNAPSHOT_TIMEOUT_MS = 120_000;
+
+export type ListSandboxesParams = {
+ states?: SandboxLifecycleState[];
+ /** Metadata equality filters, applied as an AND across all pairs. */
+ metadata?: Record;
+ limit?: number;
+ nextToken?: string;
+};
+
+export type ListSandboxesResult = {
+ items: ListedSandbox[];
+ nextToken?: string;
+};
+
+export function encodeMetadataFilter(
+ metadata: Record,
+): string | undefined {
+ const pairs = Object.entries(metadata).filter(
+ ([key, value]) => key.trim() !== "" && value.trim() !== "",
+ );
+ if (pairs.length === 0) {
+ return undefined;
+ }
+ return new URLSearchParams(pairs).toString();
+}
+
+export async function listSandboxes(
+ params: ListSandboxesParams = {},
+): Promise {
+ const query = new URLSearchParams();
+ // Repeated state= params; comma-joined form 400s (openapi explode:false is wrong).
+ for (const state of params.states ?? []) {
+ query.append("state", state);
+ }
+ if (params.metadata) {
+ const encoded = encodeMetadataFilter(params.metadata);
+ if (encoded) {
+ query.set("metadata", encoded);
+ }
+ }
+ query.set("limit", String(params.limit ?? 50));
+ if (params.nextToken) {
+ query.set("nextToken", params.nextToken);
+ }
+
+ let nextToken: string | undefined;
+ const items = await gatewayFetch(
+ `/v2/sandboxes?${query.toString()}`,
+ {
+ onResponse: (res) => {
+ nextToken = res.headers.get("x-next-token") ?? undefined;
+ },
+ },
+ );
+
+ return { items: items ?? [], nextToken: nextToken || undefined };
+}
+
+export function getSandbox(sandboxID: string): Promise {
+ return gatewayFetch(
+ `/sandboxes/${encodeURIComponent(sandboxID)}`,
+ );
+}
+
+export function createSandbox(
+ body: NewSandboxRequest,
+): Promise {
+ return gatewayFetch("/sandboxes", {
+ method: "POST",
+ body: JSON.stringify(body),
+ timeoutMs: CREATE_TIMEOUT_MS,
+ });
+}
+
+export function createColdSandbox(
+ body: NewColdSandboxRequest,
+): Promise {
+ return gatewayFetch("/sandboxes-cold", {
+ method: "POST",
+ body: JSON.stringify(body),
+ timeoutMs: CREATE_TIMEOUT_MS,
+ });
+}
+
+export function pauseSandbox(sandboxID: string): Promise {
+ return gatewayFetch(
+ `/sandboxes/${encodeURIComponent(sandboxID)}/pause`,
+ { method: "POST", timeoutMs: 120_000 },
+ );
+}
+
+/**
+ * Resume is exposed through `connect`: it resumes a paused sandbox, returns a
+ * running one untouched, and only ever extends the TTL.
+ */
+export function connectSandbox(
+ sandboxID: string,
+ timeoutSeconds: number,
+): Promise {
+ return gatewayFetch(
+ `/sandboxes/${encodeURIComponent(sandboxID)}/connect`,
+ {
+ method: "POST",
+ body: JSON.stringify({ timeout: timeoutSeconds }),
+ timeoutMs: 120_000,
+ },
+ );
+}
+
+export function setSandboxTimeout(
+ sandboxID: string,
+ timeoutSeconds: number,
+): Promise {
+ return gatewayFetch(
+ `/sandboxes/${encodeURIComponent(sandboxID)}/timeout`,
+ { method: "POST", body: JSON.stringify({ timeout: timeoutSeconds }) },
+ );
+}
+
+export function killSandbox(sandboxID: string): Promise {
+ return gatewayFetch(`/sandboxes/${encodeURIComponent(sandboxID)}`, {
+ method: "DELETE",
+ timeoutMs: 60_000,
+ });
+}
+
+export function forkSandbox(
+ sandboxID: string,
+ body: { count: number; timeout?: number },
+): Promise {
+ return gatewayFetch(
+ `/sandboxes/${encodeURIComponent(sandboxID)}/fork`,
+ {
+ method: "POST",
+ body: JSON.stringify(body),
+ timeoutMs: FORK_TIMEOUT_MS,
+ },
+ );
+}
+
+export function createSandboxSnapshot(
+ sandboxID: string,
+ body: { name?: string },
+): Promise {
+ return gatewayFetch(
+ `/sandboxes/${encodeURIComponent(sandboxID)}/snapshots`,
+ {
+ method: "POST",
+ body: JSON.stringify(body),
+ timeoutMs: SNAPSHOT_TIMEOUT_MS,
+ },
+ );
+}
+
+export function updateSandboxNetwork(
+ sandboxID: string,
+ body: SandboxNetworkUpdate,
+): Promise {
+ return gatewayFetch(
+ `/sandboxes/${encodeURIComponent(sandboxID)}/network`,
+ { method: "PUT", body: JSON.stringify(body) },
+ );
+}
+
+export function getCustomExtensionParams(
+ sandboxID: string,
+): Promise {
+ return gatewayFetch(
+ `/sandboxes/${encodeURIComponent(sandboxID)}/custom-extension-params`,
+ );
+}
+
+export function patchCustomExtensionParams(
+ sandboxID: string,
+ patch: Record,
+): Promise {
+ return gatewayFetch(
+ `/sandboxes/${encodeURIComponent(sandboxID)}/custom-extension-params`,
+ { method: "PATCH", body: JSON.stringify(patch) },
+ );
+}
diff --git a/web/src/lib/api/snapshots.ts b/web/src/lib/api/snapshots.ts
new file mode 100644
index 00000000..dd6aadfb
--- /dev/null
+++ b/web/src/lib/api/snapshots.ts
@@ -0,0 +1,59 @@
+import { gatewayFetch } from "@/lib/api/client";
+import {
+ DEFAULT_PAGE_LIMIT,
+ gatewayFetchPage,
+ pageQuery,
+ type Page,
+} from "@/lib/api/paging";
+import type { SnapshotInfo } from "@/lib/api/types";
+
+export type SnapshotListParams = {
+ /** Filter by the sandbox the snapshot was captured from. */
+ sandboxID?: string;
+ /** Filter by snapshot name/alias or ID, optionally tag-qualified. */
+ name?: string;
+ limit?: number;
+ nextToken?: string;
+};
+
+export async function listSnapshots(
+ params: SnapshotListParams = {},
+): Promise> {
+ const query = pageQuery({
+ sandboxID: params.sandboxID,
+ name: params.name,
+ limit: params.limit ?? DEFAULT_PAGE_LIMIT,
+ nextToken: params.nextToken,
+ });
+ return gatewayFetchPage(`/snapshots${query}`);
+}
+
+export async function getSnapshot(snapshotID: string): Promise {
+ return gatewayFetch(
+ `/snapshots/${encodeURIComponent(snapshotID)}`,
+ );
+}
+
+export function snapshotId(snapshot: SnapshotInfo): string {
+ return snapshot.snapshotID ?? snapshot.id ?? "";
+}
+
+export function snapshotAliases(snapshot: SnapshotInfo): string[] {
+ return snapshot.names ?? snapshot.aliases ?? [];
+}
+
+/**
+ * Provenance is not part of the documented SnapshotInfo schema, so read it
+ * defensively — gateways that do return it should still surface it in the UI.
+ */
+export function snapshotSourceSandboxId(
+ snapshot: SnapshotInfo,
+): string | undefined {
+ for (const key of ["sourceSandboxID", "sandboxID", "sourceSandboxId"]) {
+ const value = snapshot[key];
+ if (typeof value === "string" && value.trim()) {
+ return value;
+ }
+ }
+ return undefined;
+}
diff --git a/web/src/lib/api/templates-server.ts b/web/src/lib/api/templates-server.ts
new file mode 100644
index 00000000..51f8f55b
--- /dev/null
+++ b/web/src/lib/api/templates-server.ts
@@ -0,0 +1,101 @@
+/**
+ * Typed wrappers around the template endpoints of the AgentENV control-plane
+ * API (see `src/api/openapi.yml`). Every function runs on the server because
+ * `gatewayFetch` reads the connection session from cookies; the types and pure
+ * helpers they share with client components live in `templates.ts`.
+ */
+
+import { gatewayFetch } from "@/lib/api/client";
+import {
+ clampLimit,
+ gatewayFetchPage,
+ pageQuery,
+ type Page,
+} from "@/lib/api/paging";
+import type {
+ CreateTemplateRequest,
+ CreateTemplateResponse,
+ ListedTemplate,
+ StartBuildRequest,
+ TemplateAliasResponse,
+ TemplateBuildInfo,
+ TemplateWithBuilds,
+} from "@/lib/api/templates";
+
+/**
+ * Builds pull and convert OCI layers before the first step runs, so both the
+ * create and the start call need far more headroom than the 30s default.
+ */
+export const BUILD_TIMEOUT_MS = 300_000;
+
+export type ListTemplatesParams = {
+ teamID?: string;
+ limit?: number;
+ nextToken?: string;
+};
+
+export function listTemplates(
+ params: ListTemplatesParams = {},
+): Promise> {
+ const query = pageQuery({
+ teamID: params.teamID,
+ limit: clampLimit(params.limit),
+ nextToken: params.nextToken,
+ });
+ return gatewayFetchPage(`/v2/templates${query}`);
+}
+
+export function getTemplate(templateID: string): Promise {
+ return gatewayFetch(
+ `/templates/${encodeURIComponent(templateID)}`,
+ );
+}
+
+export function createTemplate(
+ body: CreateTemplateRequest,
+): Promise {
+ return gatewayFetch("/v3/templates", {
+ method: "POST",
+ body: JSON.stringify(body),
+ timeoutMs: BUILD_TIMEOUT_MS,
+ });
+}
+
+export function startTemplateBuild(
+ templateID: string,
+ buildID: string,
+ body: StartBuildRequest,
+): Promise {
+ return gatewayFetch(
+ `/v2/templates/${encodeURIComponent(templateID)}/builds/${encodeURIComponent(buildID)}`,
+ {
+ method: "POST",
+ body: JSON.stringify(body),
+ timeoutMs: BUILD_TIMEOUT_MS,
+ },
+ );
+}
+
+export function getBuildStatus(
+ templateID: string,
+ buildID: string,
+): Promise {
+ return gatewayFetch(
+ `/templates/${encodeURIComponent(templateID)}/builds/${encodeURIComponent(buildID)}/status`,
+ );
+}
+
+export function deleteTemplate(templateID: string): Promise {
+ return gatewayFetch(`/templates/${encodeURIComponent(templateID)}`, {
+ method: "DELETE",
+ timeoutMs: 60_000,
+ });
+}
+
+export function resolveTemplateAlias(
+ alias: string,
+): Promise {
+ return gatewayFetch(
+ `/templates/aliases/${encodeURIComponent(alias)}`,
+ );
+}
diff --git a/web/src/lib/api/templates.ts b/web/src/lib/api/templates.ts
new file mode 100644
index 00000000..00f1dc66
--- /dev/null
+++ b/web/src/lib/api/templates.ts
@@ -0,0 +1,196 @@
+/**
+ * Isomorphic template types and helpers.
+ *
+ * Nothing here touches cookies or the network, so it is safe to import from
+ * both client components and server code. The request functions live in
+ * `templates-server.ts`.
+ *
+ * AgentENV runs the E2B template API in a compatibility mode where a template
+ * and its build share one identifier, so `buildID` always equals `templateID`
+ * and each `POST /v3/templates` mints a brand new template rather than adding a
+ * build to an existing one.
+ */
+
+export const TEMPLATE_BUILD_STATUSES = [
+ "building",
+ "waiting",
+ "ready",
+ "error",
+] as const;
+
+export type TemplateBuildStatus = (typeof TEMPLATE_BUILD_STATUSES)[number];
+
+export const LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
+
+export type LogLevel = (typeof LOG_LEVELS)[number];
+
+export type ListedTemplate = {
+ templateID: string;
+ buildID: string;
+ cpuCount?: number;
+ memoryMB?: number;
+ diskSizeMB?: number;
+ public?: boolean;
+ names?: string[];
+ /** Superseded by `names`, still emitted by some gateways. */
+ aliases?: string[];
+ createdAt?: string;
+ updatedAt?: string;
+ lastSpawnedAt?: string | null;
+ spawnCount?: number;
+ buildCount?: number;
+ envdVersion?: string;
+ buildStatus?: string;
+};
+
+export type TemplateBuild = {
+ buildID: string;
+ status?: string;
+ createdAt?: string;
+ updatedAt?: string;
+ finishedAt?: string;
+ cpuCount?: number;
+ memoryMB?: number;
+ diskSizeMB?: number;
+ envdVersion?: string;
+};
+
+export type TemplateWithBuilds = {
+ templateID: string;
+ public?: boolean;
+ names?: string[];
+ aliases?: string[];
+ createdAt?: string;
+ updatedAt?: string;
+ lastSpawnedAt?: string | null;
+ spawnCount?: number;
+ builds?: TemplateBuild[];
+};
+
+export type BuildLogEntry = {
+ timestamp?: string;
+ message: string;
+ level?: string;
+ step?: string;
+};
+
+export type BuildStatusReason = {
+ message: string;
+ step?: string;
+ logEntries?: BuildLogEntry[];
+};
+
+export type TemplateBuildInfo = {
+ templateID: string;
+ buildID: string;
+ status?: string;
+ logs?: string[];
+ logEntries?: BuildLogEntry[];
+ reason?: BuildStatusReason;
+};
+
+export type TemplateAliasResponse = {
+ templateID: string;
+ public?: boolean;
+};
+
+export const TEMPLATE_STEP_TYPES = ["RUN", "ENV", "WORKDIR"] as const;
+
+export type TemplateStepType = (typeof TEMPLATE_STEP_TYPES)[number];
+
+export type TemplateStep = {
+ type: string;
+ args?: string[];
+ filesHash?: string;
+ force?: boolean;
+};
+
+export type CreateTemplateRequest = {
+ name: string;
+ tags?: string[];
+ cpuCount?: number;
+ memoryMB?: number;
+};
+
+export type CreateTemplateResponse = {
+ templateID: string;
+ buildID: string;
+ public?: boolean;
+ names?: string[];
+ tags?: string[];
+ aliases?: string[];
+};
+
+export type StartBuildRequest = {
+ fromImage?: string;
+ fromTemplate?: string;
+ force?: boolean;
+ steps?: TemplateStep[];
+ startCmd?: string;
+ readyCmd?: string;
+};
+
+export function isTemplateBuildStatus(
+ value: string,
+): value is TemplateBuildStatus {
+ return (TEMPLATE_BUILD_STATUSES as readonly string[]).includes(value);
+}
+
+export function buildStatus(
+ value?: string | null,
+): TemplateBuildStatus | "unknown" {
+ const normalized = (value ?? "").toLowerCase();
+ return isTemplateBuildStatus(normalized) ? normalized : "unknown";
+}
+
+/** `building` and `waiting` builds keep changing, so the UI polls them. */
+export function isBuildInFlight(value?: string | null): boolean {
+ const status = buildStatus(value);
+ return status === "building" || status === "waiting";
+}
+
+export function isLogLevel(value: string): value is LogLevel {
+ return (LOG_LEVELS as readonly string[]).includes(value);
+}
+
+export function logLevel(value?: string | null): LogLevel | "unknown" {
+ const normalized = (value ?? "").toLowerCase();
+ return isLogLevel(normalized) ? normalized : "unknown";
+}
+
+export function templateNames(
+ template: Pick,
+): string[] {
+ const names = template.names?.length ? template.names : template.aliases;
+ return names ?? [];
+}
+
+function buildTime(build: TemplateBuild): number {
+ const parsed = Date.parse(build.updatedAt ?? build.createdAt ?? "");
+ return Number.isNaN(parsed) ? 0 : parsed;
+}
+
+/**
+ * `GET /templates/{id}` reports resources per build rather than on the
+ * template, so detail views read them from the newest build.
+ */
+export function latestBuild(
+ template: TemplateWithBuilds,
+): TemplateBuild | undefined {
+ const builds = template.builds ?? [];
+ if (builds.length <= 1) {
+ return builds[0];
+ }
+ return [...builds].sort((a, b) => buildTime(b) - buildTime(a))[0];
+}
+
+export function sortBuildsByRecency(builds: TemplateBuild[]): TemplateBuild[] {
+ return [...builds].sort((a, b) => buildTime(b) - buildTime(a));
+}
+
+export function parseTags(raw: string): string[] {
+ return raw
+ .split(",")
+ .map((tag) => tag.trim())
+ .filter((tag) => tag !== "");
+}
diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts
new file mode 100644
index 00000000..d04805ca
--- /dev/null
+++ b/web/src/lib/api/types.ts
@@ -0,0 +1,88 @@
+/** Minimal shared types for the control-plane UI. Expand per feature as needed. */
+
+export type NodeStatus = "ready" | "draining" | "connecting" | "unhealthy";
+
+export type SandboxState = "running" | "paused" | string;
+
+export type TemplateBuildStatus =
+ | "building"
+ | "waiting"
+ | "ready"
+ | "error"
+ | string;
+
+export type DiskMetrics = {
+ mountPoint: string;
+ device: string;
+ filesystemType: string;
+ usedBytes: number;
+ totalBytes: number;
+};
+
+export type NodeMetrics = {
+ allocatedCPU: number;
+ allocatedMemoryBytes: number;
+ cpuPercent: number;
+ memoryUsedBytes: number;
+ cpuCount: number;
+ memoryTotalBytes: number;
+ disks: DiskMetrics[];
+ createSuccesses?: number;
+ createFails?: number;
+ sandboxesRunning?: number;
+ sandboxesPaused?: number;
+ sandboxesStarting?: number;
+};
+
+export type NodeDetail = {
+ nodeID: string;
+ clusterID?: string;
+ status: NodeStatus;
+ serviceInstanceID?: string;
+ version?: string;
+ commit?: string;
+ architecture?: string;
+ metrics?: NodeMetrics;
+ [key: string]: unknown;
+};
+
+export type SandboxInfo = {
+ sandboxID: string;
+ templateID?: string;
+ name?: string;
+ state?: SandboxState;
+ cpuCount?: number;
+ memoryMB?: number;
+ diskSizeMB?: number;
+ startedAt?: string;
+ endAt?: string;
+ metadata?: Record;
+ [key: string]: unknown;
+};
+
+export type SnapshotInfo = {
+ snapshotID?: string;
+ id?: string;
+ aliases?: string[];
+ names?: string[];
+ cpuCount?: number;
+ memoryMB?: number;
+ diskSizeMB?: number;
+ createdAt?: string;
+ updatedAt?: string;
+ [key: string]: unknown;
+};
+
+export type TemplateInfo = {
+ templateID?: string;
+ aliases?: string[];
+ cpuCount?: number;
+ memoryMB?: number;
+ buildStatus?: TemplateBuildStatus;
+ buildCount?: number;
+ spawnCount?: number;
+ createdAt?: string;
+ updatedAt?: string;
+ lastSpawnedAt?: string;
+ [key: string]: unknown;
+};
diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts
new file mode 100644
index 00000000..c0244f28
--- /dev/null
+++ b/web/src/lib/format.ts
@@ -0,0 +1,26 @@
+/** Formatting helpers shared by the console tables and detail views. */
+
+export function formatMiB(value?: number): string {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ return "—";
+ }
+ if (value >= 1024) {
+ const gib = value / 1024;
+ return `${Number.isInteger(gib) ? gib : gib.toFixed(1)} GiB`;
+ }
+ return `${value} MiB`;
+}
+
+export function formatCpu(value?: number): string {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ return "—";
+ }
+ return `${value} vCPU`;
+}
+
+export function formatNumber(value?: number): string {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ return "—";
+ }
+ return value.toLocaleString("en-US");
+}
diff --git a/web/src/lib/session.ts b/web/src/lib/session.ts
new file mode 100644
index 00000000..a1e1b5d9
--- /dev/null
+++ b/web/src/lib/session.ts
@@ -0,0 +1,113 @@
+import { cookies } from "next/headers";
+import {
+ EMPTY_SESSION_SUMMARY,
+ maskSecret,
+ type ConnectionSessionSummary,
+} from "@/lib/api/connection";
+
+export const SESSION_COOKIE = {
+ gatewayUrl: "aenv_gateway_url",
+ apiKey: "aenv_api_key",
+ adminToken: "aenv_admin_token",
+} as const;
+
+export type ConnectionSession = {
+ gatewayUrl: string;
+ apiKey: string;
+ adminToken?: string;
+};
+
+function normalizeGatewayUrl(url: string): string {
+ return url.trim().replace(/\/+$/, "");
+}
+
+export async function getConnectionSession(): Promise {
+ const jar = await cookies();
+ const gatewayUrl = jar.get(SESSION_COOKIE.gatewayUrl)?.value;
+ const apiKey = jar.get(SESSION_COOKIE.apiKey)?.value;
+ const adminToken = jar.get(SESSION_COOKIE.adminToken)?.value;
+
+ if (!gatewayUrl || !apiKey) {
+ return null;
+ }
+
+ return {
+ gatewayUrl: normalizeGatewayUrl(gatewayUrl),
+ apiKey,
+ adminToken: adminToken || undefined,
+ };
+}
+
+export function sessionCookieOptions() {
+ return {
+ httpOnly: true,
+ sameSite: "lax" as const,
+ secure: process.env.NODE_ENV === "production",
+ path: "/",
+ };
+}
+
+/**
+ * Cookie values as stored, without requiring a complete session. Used when
+ * merging a partial settings update over what is already saved.
+ */
+export async function getStoredConnectionFields(): Promise<
+ Partial
+> {
+ const jar = await cookies();
+ const gatewayUrl = jar.get(SESSION_COOKIE.gatewayUrl)?.value;
+ const apiKey = jar.get(SESSION_COOKIE.apiKey)?.value;
+ const adminToken = jar.get(SESSION_COOKIE.adminToken)?.value;
+
+ return {
+ gatewayUrl: gatewayUrl ? normalizeGatewayUrl(gatewayUrl) : undefined,
+ apiKey: apiKey || undefined,
+ adminToken: adminToken || undefined,
+ };
+}
+
+export async function setConnectionSession(
+ session: ConnectionSession,
+): Promise {
+ const jar = await cookies();
+ const options = sessionCookieOptions();
+
+ jar.set(
+ SESSION_COOKIE.gatewayUrl,
+ normalizeGatewayUrl(session.gatewayUrl),
+ options,
+ );
+ jar.set(SESSION_COOKIE.apiKey, session.apiKey, options);
+ if (session.adminToken) {
+ jar.set(SESSION_COOKIE.adminToken, session.adminToken, options);
+ } else {
+ jar.delete(SESSION_COOKIE.adminToken);
+ }
+}
+
+export async function clearConnectionSession(): Promise {
+ const jar = await cookies();
+ for (const name of Object.values(SESSION_COOKIE)) {
+ jar.delete(name);
+ }
+}
+
+/** Client-safe projection of a session: URL in the clear, secrets masked. */
+export function summarizeConnectionSession(
+ session: ConnectionSession,
+): ConnectionSessionSummary {
+ return {
+ configured: true,
+ gatewayUrl: normalizeGatewayUrl(session.gatewayUrl),
+ apiKeyMasked: maskSecret(session.apiKey),
+ adminTokenMasked: session.adminToken
+ ? maskSecret(session.adminToken)
+ : null,
+ hasAdminToken: Boolean(session.adminToken),
+ };
+}
+
+export async function getConnectionSessionSummary(): Promise {
+ const session = await getConnectionSession();
+ return session ? summarizeConnectionSession(session) : EMPTY_SESSION_SUMMARY;
+}
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts
new file mode 100644
index 00000000..bd0c391d
--- /dev/null
+++ b/web/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 00000000..cf9c65d3
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}