diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e96528e..5089aed 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,9 @@ name: CI
on:
push:
- branches: [main]
+ branches:
+ - main
+ - "v*"
pull_request:
permissions:
@@ -10,33 +12,49 @@ permissions:
jobs:
go:
- name: Go (lint + test)
+ name: Go (vet + lint + test)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
- go-version: '1.23'
+ # Pull the Go version from go.mod so CI can't drift from the
+ # toolchain the binary actually requires.
+ go-version-file: go.mod
cache: true
- - name: Ensure web/dist exists for embed
+ - name: Ensure embedded SPA stub exists
+ # internal/web/web.go does //go:embed all:dist from its own
+ # package directory, so the dir internal/web/dist/ must exist
+ # with an index.html for the Go side to compile. CI doesn't
+ # run the Vite build in this job — a placeholder is enough.
run: |
- mkdir -p web/dist
- if [ ! -f web/dist/index.html ]; then
- echo '
stub' > web/dist/index.html
+ mkdir -p internal/web/dist
+ if [ ! -f internal/web/dist/index.html ]; then
+ echo 'stub' > internal/web/dist/index.html
fi
- name: Download modules
run: go mod download
+ - name: Vet
+ run: go vet ./...
+
- name: Lint
- uses: golangci/golangci-lint-action@v6
+ uses: golangci/golangci-lint-action@v8
with:
- version: latest
- args: --timeout=5m
+ # The lint binary itself must be built with Go ≥ go.mod's go
+ # directive — otherwise it refuses with "language version
+ # used to build golangci-lint is lower than the targeted
+ # Go version". v2.5.0+ is built with Go 1.25.
+ version: v2.5.0
+ args: --timeout=10m
- name: Test
- run: go test -race ./...
+ # Scoped to our own packages — `./...` would also descend into
+ # web/node_modules/flatted/golang/pkg/flatted (an npm dep that
+ # ships an embedded .go file in its tree).
+ run: go test -race ./internal/... ./cmd/...
web:
name: Web (typecheck + build)
@@ -49,6 +67,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
+ cache: npm
+ cache-dependency-path: web/package-lock.json
- name: Install
run: |
if [ -f package-lock.json ]; then npm ci; else npm install; fi
@@ -76,4 +96,8 @@ jobs:
VERSION=ci-${{ github.sha }}
COMMIT=${{ github.sha }}
- name: Inspect image size
- run: docker image inspect controlroom:ci --format '{{ .Size }}' | awk '{ printf "image size: %.1f MB\n", $1/1024/1024 }'
+ # Block scalar — the awk format string contains `: ` (colon
+ # space) which would be parsed as an inline mapping value if
+ # this were a plain `run:` scalar.
+ run: |
+ docker image inspect controlroom:ci --format '{{ .Size }}' | awk '{ printf "image size: %.1f MB\n", $1/1024/1024 }'
diff --git a/.gitignore b/.gitignore
index dab5b87..ce585c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,7 @@ web/dist/*
# Stray credential material
*.pem
*.key
+*.crt
+
+# Local Claude Code config / agent definitions — do not publish
+.claude/
diff --git a/.golangci.yml b/.golangci.yml
index 4b75748..65daf32 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,40 +1,42 @@
-run:
- timeout: 3m
- tests: true
+# golangci-lint v2 config.
+#
+# v2 requires an explicit config; v1 ran the standard linter set by
+# default. We declare the same set here so behavior matches what CI
+# used to do before the v1 → v2 jump.
+version: "2"
linters:
- disable-all: true
+ default: none
enable:
- - errcheck
- - gosimple
- - govet
- - ineffassign
- - staticcheck
- - unused
- - gofmt
- - goimports
- - revive
- - gosec
- - misspell
- - bodyclose
- - errorlint
- - nolintlint
+ - errcheck # unhandled error returns
+ - govet # standard go vet (also run separately)
+ - ineffassign # ineffectual assignments
+ - staticcheck # broad correctness/style checks
+ - unused # unused functions / vars / types
-linters-settings:
- goimports:
- local-prefixes: github.com/tm4rtin17/controlroom
- revive:
+ exclusions:
+ # Generated client-go and embedded SPA assets shouldn't fail lint.
+ paths:
+ - internal/web/dist
+ - web/node_modules
rules:
- - name: exported
- arguments: ["disableStutteringCheck"]
-
-issues:
- exclude-dirs:
- - web
- - deploy
- - docs
- exclude-rules:
- - path: _test\.go
- linters:
- - gosec
- - errcheck
+ # Tests routinely ignore errors on cleanup paths.
+ - path: _test\.go
+ linters:
+ - errcheck
+ # Audit writes are intentionally best-effort; the existing code
+ # uses the `_ = d.DB.WriteAudit(...)` pattern throughout.
+ - source: 'WriteAudit'
+ linters:
+ - errcheck
+ # `defer x.Close()` is idiomatic Go; the only failure that can
+ # happen on close-on-defer is one we couldn't act on anyway.
+ - source: '^\s*defer .*\.Close\(\)\s*$'
+ linters:
+ - errcheck
+ # Same for SetReadDeadline / SetWriteDeadline on a websocket —
+ # if the deadline can't be set the next read/write will fail
+ # and we'll exit cleanly.
+ - source: '\.Set(Read|Write)Deadline\('
+ linters:
+ - errcheck
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..54d4efc
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,222 @@
+# Changelog
+
+All notable changes to ControlRoom are documented here. The format is loosely
+based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
+project follows [Semantic Versioning](https://semver.org/) — minor releases
+(0.x.0) deliver feature work and may include backward-incompatible changes
+to deploy artifacts before 1.0.
+
+## [Unreleased]
+
+Tracks ongoing work on `main` after the v0.2 release.
+
+## [0.2.0] — 2026-05-09
+
+The Kubernetes release. Adds a full-feature Kubernetes management tab,
+turns the Docker container into an option that drives every host
+integration, fixes CI from end to end, and tightens up several rough
+edges discovered along the way.
+
+### Added — Kubernetes tab
+
+- **Phase A — read-only inventory**: list views for Nodes, Namespaces,
+ Workloads (Deployment / StatefulSet / DaemonSet), Pods, Services. Backed
+ by `client-go`. New `internal/k8s/` package with auto-detection of
+ in-cluster auth, `$KUBECONFIG`, `~/.kube/config`, and `/etc/rancher/k3s/k3s.yaml`
+ in that order. Capability-gated: `/api/system/capabilities` reports
+ `kubernetes: bool` so the SPA hides the tab when no cluster is reachable.
+ ([#9e2d3fe])
+- **Phase A — in-cluster deployment manifests** at `deploy/k8s/`:
+ Namespace + ServiceAccount + ClusterRole + ClusterRoleBinding +
+ Deployment + Service + Ingress + PVC. Pod runs as nonroot uid 65532,
+ drop ALL caps, readOnlyRootFilesystem. ([#9e2d3fe])
+- **Phase B — detail drawers + pod log streaming**: per-resource Sheet
+ drawers showing conditions, events, container statuses, etc. Live pod
+ log streaming via WebSocket, multi-container picker, pause/resume.
+ ([#dff99cc])
+- **Phase C — lifecycle actions**: restart workload (annotation patch),
+ scale Deployment / StatefulSet (UpdateScale subresource preserves
+ resourceVersion for optimistic concurrency), delete pod (controller-
+ recreate), cordon / uncordon node. Every action writes an audit row
+ with target + intent. ([#7755b9a])
+- **Phase D1 — pod exec**: SPDY → WebSocket bridge so the existing xterm
+ in the SPA can attach to any container. Frame protocol mirrors
+ `/ws/terminal` so the frontend cloned `Terminal.tsx` with minimal
+ changes. Container picker, command picker (`/bin/sh`/`/bin/bash`/
+ custom), audit on session start + end. ([#4ffa357])
+- **Phase D2 — ConfigMap viewer + editor**: list view; structured
+ key/value editor (validates DNS-1123 keys, enforces 1 MiB cap). Update
+ via PUT (not Patch) so resourceVersion conflicts surface as 409 with a
+ dedicated Reload UX instead of silent overwrite. ([#33d3b0c])
+- **Phase D3 — Secret viewer (read-only)**: list + masked detail viewer.
+ Eight fixed bullets regardless of value length so the DOM never leaks
+ size. Per-row reveal-on-click + copy-to-clipboard with insecure-context
+ fallback. JSON pretty-print and PEM detection. Every detail GET writes
+ a `k8s.secret.read` audit row carrying `key_count` only — never names
+ or values. RBAC widening is `secrets: get,list`; `watch` is
+ intentionally excluded so we don't keep a long-lived stream of all
+ cluster secret values open. ([#8bb8564])
+- **Phase D4 — Monaco YAML editor**: edit any of Deployment / StatefulSet /
+ DaemonSet / Service / ConfigMap as YAML. `metadata.{managedFields,
+ creationTimestamp,uid,resourceVersion,generation}` and the entire
+ `status` are stripped from the editable buffer; resourceVersion is sent
+ separately so optimistic concurrency works. Dry-run via
+ `metav1.DryRunAll` validates with admission webhooks before commit.
+ Apply requires AlertDialog confirmation. Monaco lazy-loaded via
+ `React.lazy` so the main bundle stays the same size. URL/body
+ cross-check (`kind` / `metadata.namespace` / `metadata.name` in YAML
+ must match URL params) prevents cross-resource attacks. ([#72b454d])
+
+### Added — non-Kubernetes
+
+- **Capability-gated navigation** (`/api/system/capabilities`): the SPA
+ hides Services, Containers, Kubernetes (and others) when their backend
+ isn't reachable instead of showing dead-end 503 pages. ([#395b640])
+- **Logs page falls back to Docker container logs** when journald isn't
+ available, with a Source toggle so the operator can view either.
+ ([#638fbdb])
+- **Tailscale CGNAT awareness** in the public-bind warning banner —
+ 100.64.0.0/10 (RFC 6598) is treated as private. ([#638fbdb])
+- **Fat-privileged Docker deployment**: optional. The `deploy/docker-compose.yml`
+ flavor now runs as root with `network_mode: host`, `pid: host`,
+ `cap_add: SYS_ADMIN/SYS_PTRACE/NET_ADMIN`, and host bind mounts so
+ every host integration (Services, Updates, Network, Logs/journal,
+ Terminal, Kubernetes) works from a container. Image switches from
+ distroless to `debian:bookworm-slim` and grows from ~25 MB to ~230 MB.
+ Documented as the higher-blast-radius option; bare-metal install
+ remains the unprivileged path. ([#f52c126])
+- **Terminal — PAM-authenticated host login**: when `CR_HOST_SHELL=true`
+ and `CR_TERMINAL_LOGIN=true` (set by the fat-privileged compose), the
+ terminal spawns a host shell through `nsenter -t 1`. Operator types
+ username, then the spawned bash drops to nobody via `setpriv` and
+ `exec su -l ` runs full PAM authentication (lockouts and
+ `/var/log/auth.log` entries flow through normally). Without the
+ privilege drop, root invoking `su` would skip authentication. ([#2d353e7],
+ [#f0ca1f7], [#aca5187])
+- **`docs/CONFIG.md`**: new reference covering every `CR_*` env var, a
+ per-tab capability matrix across the three deployment shapes, per-shape
+ data paths, and capability-detection debugging. ([#f58ccf3])
+
+### Changed
+
+- **README, INSTALL, SECURITY** rewritten for the three deployment shapes
+ (bare-metal / fat-privileged container / in-cluster Pod) with explicit
+ compromise-impact statements per shape. ([#f58ccf3])
+- **`deploy/docker-compose.yml`** now declares `controlroom-data` as
+ `external: true` so `docker compose up` and raw `docker run -v
+ controlroom-data:/data` always land on the same persistent volume.
+ Without this, switching between the two silently created
+ `deploy_controlroom-data` and the operator's admin account vanished
+ on the next boot. First-time install now requires
+ `docker volume create controlroom-data`. ([#a4187c8])
+
+### Fixed
+
+- **Container-deployment Docker tab returned `null` for ports/labels**,
+ blanking the SPA. Initialize Docker `Container` slice/map fields to
+ non-nil empty values before JSON encoding. ([#74dd538])
+- **Network tab blank-screen** with the same nil-slice JSON
+ serialization bug. `Interface.IPs`, `Interface.Flags`, and
+ `UFWStatus.Rules` now default to `[]` not `null`. ([#83deeaa])
+- **Logs page returned 500** when running in the distroless container
+ (no `journalctl`). Now returns a clean 503 with a "switch to
+ Containers source or run on bare metal" message; the SPA shows the
+ real error instead of "Could not fetch logs." ([#638fbdb])
+- **Services / Containers tabs were dead-ends** when the host integration
+ was missing. Capability-gated nav now hides them. ([#395b640])
+
+### CI
+
+- The CI pipeline had been failing at workflow startup since v0.1 (the
+ `m9 polish` push to main also failed at 0s). Fixed in five steps:
+ - Pin Go version from `go.mod` instead of a hard-coded `1.23` that
+ drifted out of sync. ([#f7917ac])
+ - Block-scalar the awk format string in the image-size step. The
+ string `"image size: %.1f MB\n"` made YAML's plain-scalar parser
+ treat `image size:` as a key. ([#8ad0356])
+ - Track `web/package-lock.json` so `npm ci` works and the CI cache
+ step resolves; bump golangci-lint action `@v6` → `@v8` and pin to
+ v2.5.0 (built with Go 1.25). ([#675dbb9])
+ - Add a minimal `.golangci.yml` (v2 doesn't run anything without one);
+ fix the two real findings (redundant assignment in `internal/docker/fake.go`,
+ unused stub type in `internal/api/auth/auth.go`). ([#0673ce8])
+ - Align Dockerfile COPY path with vite's `outDir` —
+ `/src/web/dist` not `/src/internal/web/dist`. ([#c8fb01c])
+- Added `go vet ./...` as a separate step (faster feedback than running
+ the full test suite). Scoped `go test` to `./internal/... ./cmd/...`
+ so it doesn't descend into `web/node_modules/flatted/golang/pkg/flatted`
+ (an npm dep that ships an embedded `.go` file). ([#f7917ac])
+
+### Security
+
+- **Per-Secret-read audit** (`k8s.secret.read`): every detail GET, success
+ or failure, writes a row carrying `key_count` only. Recoverable
+ read-history without leaking what was read.
+- **Per-manifest-edit audit** (`k8s.manifest.apply` / `…dry_run`):
+ records `kind`, `namespace`, `name`, `bytes` of the YAML payload —
+ never the body itself.
+- **Secret value mass-leakage**: 8 fixed-length bullets in the SPA so
+ the DOM doesn't expose value length even when masked.
+- **Cross-resource attack on manifest edit**: server enforces that the
+ YAML's `kind` / `metadata.namespace` / `metadata.name` match the URL
+ params. ([#72b454d])
+
+### Migration notes
+
+- **Container deployment**: run `docker volume create controlroom-data`
+ once before `docker compose up`. The compose volume is now `external`.
+ If you upgraded mid-session and your data appears empty, the data is
+ most likely on `deploy_controlroom-data` (project-namespaced volume
+ created by the prior compose; the original is still on
+ `controlroom-data`).
+- **In-cluster deployment**: re-apply `deploy/k8s/rbac.yaml` to pick up
+ the widened verbs (`pods/exec: create`, `pods: delete`,
+ `nodes: patch`, `apps/*: patch,update`, `apps/*/scale: update`,
+ `services/configmaps: update`, `secrets: get,list`).
+
+[#9e2d3fe]: https://github.com/tm4rtin17/ControlRoom/commit/9e2d3fe
+[#dff99cc]: https://github.com/tm4rtin17/ControlRoom/commit/dff99cc
+[#7755b9a]: https://github.com/tm4rtin17/ControlRoom/commit/7755b9a
+[#4ffa357]: https://github.com/tm4rtin17/ControlRoom/commit/4ffa357
+[#33d3b0c]: https://github.com/tm4rtin17/ControlRoom/commit/33d3b0c
+[#8bb8564]: https://github.com/tm4rtin17/ControlRoom/commit/8bb8564
+[#72b454d]: https://github.com/tm4rtin17/ControlRoom/commit/72b454d
+[#395b640]: https://github.com/tm4rtin17/ControlRoom/commit/395b640
+[#638fbdb]: https://github.com/tm4rtin17/ControlRoom/commit/638fbdb
+[#f52c126]: https://github.com/tm4rtin17/ControlRoom/commit/f52c126
+[#2d353e7]: https://github.com/tm4rtin17/ControlRoom/commit/2d353e7
+[#f0ca1f7]: https://github.com/tm4rtin17/ControlRoom/commit/f0ca1f7
+[#aca5187]: https://github.com/tm4rtin17/ControlRoom/commit/aca5187
+[#f58ccf3]: https://github.com/tm4rtin17/ControlRoom/commit/f58ccf3
+[#a4187c8]: https://github.com/tm4rtin17/ControlRoom/commit/a4187c8
+[#74dd538]: https://github.com/tm4rtin17/ControlRoom/commit/74dd538
+[#83deeaa]: https://github.com/tm4rtin17/ControlRoom/commit/83deeaa
+[#f7917ac]: https://github.com/tm4rtin17/ControlRoom/commit/f7917ac
+[#8ad0356]: https://github.com/tm4rtin17/ControlRoom/commit/8ad0356
+[#675dbb9]: https://github.com/tm4rtin17/ControlRoom/commit/675dbb9
+[#0673ce8]: https://github.com/tm4rtin17/ControlRoom/commit/0673ce8
+[#c8fb01c]: https://github.com/tm4rtin17/ControlRoom/commit/c8fb01c
+
+## [0.1.0] — 2026-05-08
+
+Initial release. See [`MILESTONES.md`](./MILESTONES.md) for the full M1–M9
+breakdown.
+
+Highlights:
+
+- Single Go binary serving an embedded Vite/React SPA over HTTPS.
+- Tabs: Dashboard, Updates, Services, Containers, Terminal, Network,
+ Logs, Settings.
+- Auth: bcrypt(cost 12) + optional TOTP, HS256 access JWT (15 min) +
+ rotating opaque refresh tokens (7 days) with reuse detection,
+ per-IP rate limit + per-user backoff.
+- TLS modes: self-signed, Let's Encrypt (HTTP-01 via autocert), and
+ proxy.
+- Bare-metal installer (`deploy/install.sh`): hardened systemd unit,
+ scoped sudoers fragment, dedicated `controlroom` system user.
+- Docker Compose deployment as a (then-distroless) alternative.
+- Audit log for every privileged action; keystrokes never recorded.
+
+[Unreleased]: https://github.com/tm4rtin17/ControlRoom/compare/v0.2.0...HEAD
+[0.2.0]: https://github.com/tm4rtin17/ControlRoom/compare/v0.1.0...v0.2.0
+[0.1.0]: https://github.com/tm4rtin17/ControlRoom/releases/tag/v0.1.0
diff --git a/README.md b/README.md
index e1f3573..64d8545 100644
--- a/README.md
+++ b/README.md
@@ -1,61 +1,67 @@
# ControlRoom
-Lightweight, modern web UI for managing headless Linux homelab servers.
+A modern, single-binary admin UI for headless Linux homelab servers — designed
+to replace routine SSH for the things you do over and over.
-> **Status:** v0.1 in development. The full feature set from the
-> [`SPEC.md`](./SPEC.md) is implemented through milestone M9; see
-> [`MILESTONES.md`](./MILESTONES.md) for what's done and what's deferred.
+> **Status:** v0.2 in development on the [`v0.2`](https://github.com/tm4rtin17/ControlRoom/tree/v0.2) branch.
+> v0.1 (tag `v0.1.0`) is the last fully-released cut. See [`MILESTONES.md`](./MILESTONES.md)
+> for what's done and the roadmap.
-## What is it?
+## Features
-A single Go binary that serves a Vite/React SPA over HTTPS and replaces
-routine SSH for:
+| Tab | What it does | Backed by |
+|---|---|---|
+| **Dashboard** | Live CPU / memory / disks / temperatures / network rates / uptime / load (1 Hz over WebSocket). | `/proc`, `/sys` |
+| **Updates** | `apt list --upgradable`, one-click check/apply with live job streaming, reboot-required banner. | `apt`, `sudo`, `systemctl` |
+| **Services** | List / start / stop / restart / enable / disable systemd units; live `journalctl -fu` log tail. | dbus |
+| **Containers** | Docker (and Podman socket-compatible) list with Compose-project grouping; per-container live logs + CPU/MEM stats; lifecycle actions. | `/var/run/docker.sock` |
+| **Kubernetes** | Cluster management — list / detail / events for nodes, namespaces, workloads (Deployment/StatefulSet/DaemonSet), pods, services, configmaps, secrets. Pod log streaming. Pod **exec** in the browser (xterm.js + SPDY). Lifecycle actions: restart workload, scale, delete pod, cordon/uncordon node. ConfigMap structured key/value editor. Secret read-only viewer (masked-by-default, audited). Monaco-based **YAML editor** with server-side dry-run + conflict detection. | client-go |
+| **Terminal** | Full PTY in the browser via xterm.js. Either a local shell or — in container deployments — a real host shell after PAM login. | `/dev/pty`, `nsenter`, `su` |
+| **Network** | Read-only interfaces + UFW rules editor (add / delete / enable / disable). | `ip -j`, `ufw`, `sudo` |
+| **Logs** | journald browser with unit / priority / since / search filters and live tail. Falls back to streaming docker container logs when journald isn't reachable. | `journalctl`, `/var/run/docker.sock` |
+| **Settings** | Change password, manage 2FA, view server config + audit log. | SQLite |
-- **Dashboard** — live CPU, memory, disks, temperatures, network rates,
- uptime, load (1 Hz over WebSocket).
-- **Updates** — `apt list --upgradable`, one-click check + apply with live
- job streaming and a reboot-required banner.
-- **Services** — list / start / stop / restart / enable / disable systemd
- units; live `journalctl -fu` log tail.
-- **Containers** — Docker / Podman list with Compose-project grouping; per
- container live logs + CPU/MEM stats; start / stop / restart / delete.
-- **Terminal** — full PTY in the browser via xterm.js.
-- **Network** — read-only interfaces + UFW rules editor.
-- **Logs** — journald browser with filters and live tail.
-- **Settings** — change password, manage 2FA, view server config.
+## Three deployment shapes
-## Footprint targets
+| Shape | What runs where | Privilege model | When to pick |
+|---|---|---|---|
+| **Bare-metal** (`deploy/install.sh`) | Single Go binary as a hardened systemd unit; dedicated `controlroom` user; tight sudoers fragment for the privileged commands. | Unprivileged user + scoped sudoers. Compromise = whatever the sudoers fragment allows. | Recommended default. Smallest blast radius for the full feature set. |
+| **Docker container** (`deploy/docker-compose.yml`) | Single container with `network_mode: host`, `pid: host`, `cap_add: SYS_ADMIN/SYS_PTRACE/NET_ADMIN`, host bind mounts for dbus / journal / apt / kubeconfig. Runs as root inside. | Effectively root on the host. | Single-user homelab where the operator already has root and wants one image to deploy. |
+| **In-cluster Kubernetes Pod** (`deploy/k8s/`) | Manifests for namespace + ServiceAccount + ClusterRole (`get,list,watch` + `pods/log get`) + Deployment + Service + Ingress + PVC. Runs as nonroot uid 65532, drop ALL caps, readOnlyRootFilesystem. | Scoped RBAC. Cluster-only feature surface — **no host integrations** (no Services / Updates / Network / host Terminal). | Cluster-only ControlRoom. Pair with bare-metal or container ControlRoom on the host if you also want host management. |
-| Metric | Goal |
-|-----------------|----------|
-| Idle RSS | ≤ 50 MB |
-| Image size | ≤ 25 MB |
-| First paint | < 1 s |
+Detailed install instructions for each: [`docs/INSTALL.md`](./docs/INSTALL.md).
+Full env-var reference + per-tab capability matrix: [`docs/CONFIG.md`](./docs/CONFIG.md).
+Per-shape threat model: [`docs/SECURITY.md`](./docs/SECURITY.md).
-## Install
+## Quick start
-### Bare-metal (Debian / Ubuntu)
+### Bare-metal (Debian / Ubuntu / Raspberry Pi OS)
```bash
sudo deploy/install.sh
sudo journalctl -u controlroom -n 200 | grep setup_token
```
-Visit `https://:8443`, paste the setup token, create your admin
-account, optionally enable 2FA.
+### Docker container (host-privileged)
-Full guide: [`docs/INSTALL.md`](./docs/INSTALL.md).
+```bash
+docker compose -f deploy/docker-compose.yml up -d
+docker compose -f deploy/docker-compose.yml logs --tail 200 | grep setup_token
+```
-### Docker
+### In-cluster Kubernetes Pod
```bash
-docker compose -f deploy/docker-compose.yml up -d
-docker compose logs --tail 200 | grep setup_token
+kubectl apply -f deploy/k8s/namespace.yaml \
+ -f deploy/k8s/rbac.yaml \
+ -f deploy/k8s/pvc.yaml \
+ -f deploy/k8s/deployment.yaml \
+ -f deploy/k8s/service.yaml
+kubectl -n controlroom logs deployment/controlroom | grep setup_token
```
-Note: the container can't reach the host's systemd dbus or journald, so the
-**Services** and **Logs** pages will return 503 inside Docker. Bare-metal
-install is required for those.
+In all three shapes: open `https://:8443`, paste the token from the
+log, create your admin account, optionally enable 2FA.
## Develop
@@ -67,52 +73,54 @@ make dev-web # Vite dev server
```
Open . Vite proxies `/api` and `/ws` to the Go backend
-on `:8443` (CR_DEV mode → plain HTTP, non-Secure cookies).
+on `:8443` (`CR_DEV` mode → plain HTTP, non-Secure cookies).
-Prerequisites: Go 1.23+, Node 20+, [`air`](https://github.com/air-verse/air)
+Prerequisites: **Go 1.25+**, **Node 20+**, [`air`](https://github.com/air-verse/air)
for backend hot reload.
## Project layout
```
cmd/controlroom/ # entrypoint
-internal/ # backend packages
+internal/
api/ # HTTP routes + middleware
- {auth,containers,logs,network,services,settings,setup,system,terminal,updates}/
+ {auth,containers,k8s,logs,network,services,settings,setup,system,terminal,updates}/
auth/ # password, TOTP, JWT, sessions, ratelimit
cert/ # self-signed + ACME (autocert)
collectors/ # /proc and /sys readers
config/ # env-based config
docker/ # Docker client wrapper
jobs/ # generic job runner with ring buffer
+ k8s/ # client-go wrapper (read-only)
logs/ # journalctl wrapper
network/ # ip + ufw wrappers
- pty/ # PTY session manager
+ pty/ # PTY session manager (incl. host-shell + login flow)
store/ # SQLite migrations + accessors
systemd/ # dbus client wrapper
web/ # embed.FS for the SPA
web/ # Vite + React + TS + Tailwind + shadcn/ui
-deploy/ # Dockerfile, install.sh, systemd unit, sudoers, compose
-docs/ # SECURITY.md, INSTALL.md
+deploy/
+ Dockerfile # fat-privileged container image
+ docker-compose.yml # docker deployment
+ install.sh # bare-metal installer
+ controlroom.service # hardened systemd unit
+ controlroom.sudoers # tight sudoers fragment
+ k8s/ # in-cluster manifests
+ scripts/ # host-side helpers (e.g. setup-host-kubeconfig.sh)
+docs/
+ INSTALL.md # per-shape install + first-run
+ CONFIG.md # env vars + per-tab requirements
+ SECURITY.md # threat model per deployment shape
```
-## Security
-
-Single trusted operator, LAN-first. Read [`docs/SECURITY.md`](./docs/SECURITY.md)
-for the threat model. Highlights:
-
-- bcrypt(cost 12) + optional TOTP.
-- HS256 access JWTs (15 min) + opaque rotating refresh tokens (7 days) with
- reuse detection that revokes the entire token family.
-- HttpOnly + Secure + SameSite=Strict cookies; CSRF double-submit token.
-- Per-IP rate limit + per-user exponential backoff on login.
-- Hardened systemd unit (`NoNewPrivileges`, `ProtectSystem=strict`, system
- call filtering); tight `/etc/sudoers.d/controlroom` fragment validated
- before install.
-- Full audit log of every privileged action. **Keystrokes are never recorded.**
+## Documentation
-Always behind a VPN, SSH tunnel, or reverse proxy with strict firewall rules
-when accessing remotely.
+- [`docs/INSTALL.md`](./docs/INSTALL.md) — detailed install for all three shapes.
+- [`docs/CONFIG.md`](./docs/CONFIG.md) — every environment variable + which tabs need what.
+- [`docs/SECURITY.md`](./docs/SECURITY.md) — threat model and what's actually enforced.
+- [`CHANGELOG.md`](./CHANGELOG.md) — release-by-release feature + fix log.
+- [`SPEC.md`](./SPEC.md) — design spec.
+- [`MILESTONES.md`](./MILESTONES.md) — what shipped when.
## License
diff --git a/cmd/controlroom/main.go b/cmd/controlroom/main.go
index 070f885..7687d96 100644
--- a/cmd/controlroom/main.go
+++ b/cmd/controlroom/main.go
@@ -33,6 +33,8 @@ import (
"github.com/tm4rtin17/controlroom/internal/config"
"github.com/tm4rtin17/controlroom/internal/docker"
"github.com/tm4rtin17/controlroom/internal/jobs"
+ "github.com/tm4rtin17/controlroom/internal/k8s"
+ "github.com/tm4rtin17/controlroom/internal/logs"
"github.com/tm4rtin17/controlroom/internal/store"
"github.com/tm4rtin17/controlroom/internal/systemd"
)
@@ -63,6 +65,8 @@ func run() error {
Str("tls_mode", string(cfg.TLSMode)).
Bool("trust_proxy", cfg.TrustProxy).
Bool("dev_mode", cfg.DevMode).
+ Bool("host_shell", cfg.HostShell).
+ Bool("terminal_login", cfg.TerminalLogin).
Msg("controlroom starting")
if err := os.MkdirAll(cfg.DataDir, 0o750); err != nil {
@@ -90,6 +94,9 @@ func run() error {
if sysdErr != nil {
logger.Warn().Err(sysdErr).Msg("systemd unavailable; /api/services disabled")
}
+ if !logs.Available() {
+ logger.Warn().Msg("journalctl not in PATH; /api/logs/journal will return 503")
+ }
if sysd != nil {
defer func() { _ = sysd.Close() }()
}
@@ -109,6 +116,14 @@ func run() error {
defer func() { _ = dock.Close() }()
}
+ // Kubernetes is best-effort: in-cluster auth first, kubeconfig fallback.
+ // When ControlRoom is not deployed inside a cluster and no kubeconfig is
+ // reachable, /api/k8s returns 503 and the SPA hides the Kubernetes tab.
+ k8sClient, k8sErr := k8s.New(context.Background())
+ if k8sErr != nil {
+ logger.Warn().Err(k8sErr).Msg("kubernetes unavailable; /api/k8s disabled")
+ }
+
deps := api.Deps{
Cfg: cfg,
Logger: logger,
@@ -121,6 +136,7 @@ func run() error {
Aggregator: collectors.NewAggregator(),
SystemD: systemdOrNil(sysd),
Docker: dockerOrNil(dock),
+ K8s: k8sClient,
Jobs: jobs.NewRunner(),
}
diff --git a/deploy/Dockerfile b/deploy/Dockerfile
index a3eddaf..f446633 100644
--- a/deploy/Dockerfile
+++ b/deploy/Dockerfile
@@ -3,7 +3,30 @@
# Multi-stage build for ControlRoom:
# 1. node-builder — `vite build` → web/dist/
# 2. go-builder — `go build` with embed.FS picking up web/dist
-# 3. runtime — distroless static, nonroot user, ~20 MB final image
+# 3. runtime — debian:bookworm-slim, fat-privileged, root user, ~150-200 MB
+#
+# FAT-PRIVILEGED SHAPE
+# ====================
+# This image is designed to run with:
+# pid: host — so nsenter -t 1 can reach systemd (PID 1 on the host)
+# cap_add: [SYS_ADMIN, SYS_PTRACE, NET_ADMIN]
+# security_opt: [apparmor:unconfined, seccomp:unconfined]
+# network_mode: host — so `ip addr` and ufw see host interfaces
+# user: root (uid 0) — required; Linux drops caps on uid transitions
+# mounts: see docker-compose.yml for the full list
+#
+# The distroless/nonroot shape has been intentionally dropped to enable
+# Terminal (nsenter), Journal Logs (journalctl), Services (dbus),
+# Updates (apt/systemctl), and Network (ip/ufw) integrations.
+#
+# Image size: ~150-200 MB (up from ~20 MB distroless). Expected and acceptable.
+#
+# UNPRIVILEGED PATH
+# =================
+# deploy/install.sh installs ControlRoom as a bare-metal systemd service with a
+# scoped sudoers file. Use that if the privilege model of this container concerns
+# you. The in-cluster K8s manifest (deploy/k8s/) also runs nonroot with a
+# read-only ClusterRole.
# ---------- 1. frontend ----------
FROM node:20-alpine AS web-builder
@@ -15,7 +38,7 @@ COPY web/ ./
RUN npm run build
# ---------- 2. backend ----------
-FROM golang:1.23-alpine AS go-builder
+FROM golang:1.25-alpine AS go-builder
ARG VERSION=dev
ARG COMMIT=none
@@ -30,10 +53,9 @@ COPY go.mod go.sum* ./
RUN go mod download
COPY . .
-COPY --from=web-builder /src/web/dist ./web/dist
+COPY --from=web-builder /src/web/dist ./internal/web/dist
-# Pre-create the data dir so we can COPY it into the runtime with the right
-# ownership (distroless has no shell to run mkdir/chown at runtime).
+# Pre-create the data dir so we can COPY it into the runtime stage.
RUN mkdir -p /out/data
RUN CGO_ENABLED=0 GOOS=linux go build \
@@ -45,16 +67,47 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
-o /out/controlroom ./cmd/controlroom
# ---------- 3. runtime ----------
-FROM gcr.io/distroless/static-debian12:nonroot
+FROM debian:bookworm-slim
+
+# Install host-integration dependencies in a single layer; purge apt lists to
+# keep the layer as small as possible.
+#
+# bash — host shell via nsenter + script use
+# ca-certificates — TLS roots for outbound HTTPS
+# sudo — wraps existing `sudo -n` calls in apt/ufw code (root → no-op,
+# but the binary must be present)
+# util-linux — provides nsenter for host-shell mode (Terminal tab)
+# procps — ps/top inside the host PID namespace
+# iproute2 — `ip -j addr show` for the Network tab
+# iptables — required by ufw
+# ufw — host firewall control (Network tab)
+# systemd — provides journalctl + systemctl + libsystemd0 (dbus client)
+# apt-utils — apt helpers used by Updates tab
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ bash \
+ ca-certificates \
+ sudo \
+ util-linux \
+ procps \
+ iproute2 \
+ iptables \
+ ufw \
+ systemd \
+ apt-utils \
+ && rm -rf /var/lib/apt/lists/*
COPY --from=go-builder /out/controlroom /app/controlroom
-COPY --from=go-builder --chown=65532:65532 /out/data /data
+COPY --from=go-builder /out/data /data
ENV CR_DATA_DIR=/data \
CR_ADDR=:8443
VOLUME ["/data"]
EXPOSE 8443
-USER 65532:65532
+
+# Run as root. SYS_ADMIN and other broad caps are dropped by the kernel on any
+# uid transition away from root, so we must stay uid 0.
+# Security boundary is the host itself — see the comment at the top of this file.
ENTRYPOINT ["/app/controlroom"]
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 02eaaa4..57e6714 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -1,27 +1,105 @@
-# ControlRoom — minimal compose file.
+# ControlRoom — fat-privileged docker-compose deployment.
#
-# Notes for container deployment:
-# - systemd dbus and journalctl are not available inside the container,
-# so /api/services and /api/logs return 503. Use the bare-metal install
-# (deploy/install.sh) if you need those.
-# - Mount the Docker socket read-only to manage other containers.
-# - For Let's Encrypt: set CR_TLS_MODE=acme + CR_ACME_HOST + CR_ACME_EMAIL,
-# and expose port 80 below for the HTTP-01 challenge.
+# SECURITY MODEL
+# ==============
+# This deployment runs as root with host network/PID namespaces and broad
+# Linux capabilities. A compromise of the ControlRoom process is effectively
+# root on the host. This is intentional for a single-user homelab where the
+# operator already has root, and is the only way to drive all integrations
+# from a container without a sidecar or privileged DaemonSet.
+#
+# If the privilege model concerns you, use deploy/install.sh instead — it
+# installs ControlRoom as a bare-metal systemd service with a scoped sudoers
+# file and no container involved.
+#
+# INTEGRATION REQUIREMENTS
+# ========================
+# Integration | Mount / Cap needed
+# ---------------|-----------------------------------------------------------
+# Docker | /var/run/docker.sock:ro (root has access without group_add)
+# Kubernetes | /etc/rancher/k3s/k3s.yaml:ro (127.0.0.1:6443 reachable via
+# | network_mode:host; no kubeconfig rewrite needed)
+# Terminal | pid:host + SYS_ADMIN (nsenter -t 1 reaches systemd/PID 1)
+# Journal Logs | /run/log/journal:ro + /var/log/journal:ro + /etc/machine-id:ro
+# Services | /var/run/dbus/system_bus_socket:rw (dbus client in image)
+# Updates (apt) | /var/cache/apt:rw + /etc/apt:ro + /var/lib/dpkg:ro
+# | root in container → sudo apt-get is a no-op passthrough
+# Network (ip) | network_mode:host (ip addr shows host interfaces)
+# Network (ufw) | network_mode:host + NET_ADMIN (ufw needs iptables access)
+#
+# PORTS
+# =====
+# network_mode:host binds directly to the host's :8443. No ports: mapping
+# needed (and it would be ignored anyway). Access via https://:8443.
services:
controlroom:
- image: ghcr.io/tm4rtin17/controlroom:latest
+ image: controlroom:dev
container_name: controlroom
restart: unless-stopped
- ports:
- - "8443:8443"
- # Uncomment when CR_TLS_MODE=acme to serve HTTP-01 challenges:
- # - "80:80"
+
+ # Share host namespaces so nsenter and ip/ufw see the real host.
+ network_mode: host
+ pid: host
+
+ # Broad caps required for nsenter (SYS_ADMIN), ps/top in host PID ns
+ # (SYS_PTRACE), and ufw/iptables (NET_ADMIN).
+ cap_add:
+ - SYS_ADMIN
+ - SYS_PTRACE
+ - NET_ADMIN
+
+ # Lift AppArmor and seccomp confinement so nsenter and host commands are
+ # not blocked by the Docker daemon's default profiles.
+ security_opt:
+ - apparmor:unconfined
+ - seccomp:unconfined
+
+ # Run as root (uid 0). Linux drops capabilities on uid transitions, so
+ # we must stay root for SYS_ADMIN to remain in effect.
+ user: "0:0"
+
volumes:
+ # Persistent app data (JWT key, SQLite DB, TLS material).
- controlroom-data:/data
+
+ # Docker socket — root has access without group_add.
- /var/run/docker.sock:/var/run/docker.sock:ro
+
+ # dbus system bus — for the Services tab (systemctl start/stop via dbus).
+ - /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:rw
+
+ # Journal logs — /run/log/journal is the volatile (current boot) location;
+ # /var/log/journal is the persistent location (if configured).
+ - /run/log/journal:/run/log/journal:ro
+ - /var/log/journal:/var/log/journal:ro
+
+ # journalctl requires the host machine-id to identify log entries.
+ - /etc/machine-id:/etc/machine-id:ro
+
+ # K3s kubeconfig — mount directly; network_mode:host makes 127.0.0.1:6443
+ # reachable without any server-URL rewrite (cert SAN includes 127.0.0.1).
+ # deploy/scripts/setup-host-kubeconfig.sh is no longer needed.
+ - /etc/rancher/k3s/k3s.yaml:/etc/k3s/kubeconfig:ro
+
+ # apt integration — rw cache so `apt-get update` can refresh package lists;
+ # dpkg state and sources are read-only.
+ - /var/cache/apt:/var/cache/apt:rw
+ - /etc/apt:/etc/apt:ro
+ - /var/lib/dpkg:/var/lib/dpkg:ro
+
environment:
CR_TLS_MODE: selfsigned
+ # client-go reads $KUBECONFIG; matches the mount above.
+ KUBECONFIG: /etc/k3s/kubeconfig
+ # Signals pty.go to wrap interactive shells with nsenter -t 1.
+ CR_HOST_SHELL: "true"
+ # When true, the terminal spawns /bin/login (PAM auth via the host's
+ # /etc/pam.d/login policy) instead of dropping straight into root's
+ # shell. Mirrors SSH UX: the operator types host credentials in the
+ # xterm and login(1) execs the user's shell at the user's uid only on
+ # success. Strongly recommended whenever CR_HOST_SHELL is true.
+ CR_TERMINAL_LOGIN: "true"
# CR_TLS_MODE: acme
# CR_ACME_HOST: ctrl.example.com
# CR_ACME_EMAIL: admin@example.com
@@ -29,4 +107,11 @@ services:
# CR_TRUST_PROXY: "true" # set when behind a reverse proxy
volumes:
+ # External so compose reuses the existing host-level volume instead of
+ # creating a project-namespaced one (`deploy_controlroom-data`). Without
+ # this, switching between `docker run -v controlroom-data:/data` and
+ # `docker compose up` silently lands on different volumes — operator's
+ # admin account, JWT key, and TLS material are stranded on the other
+ # volume and the SPA shows the first-run setup wizard.
controlroom-data:
+ external: true
diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md
new file mode 100644
index 0000000..d9477e6
--- /dev/null
+++ b/deploy/k8s/README.md
@@ -0,0 +1,68 @@
+# ControlRoom — in-cluster K8s deployment (Phase A)
+
+Runs the ControlRoom admin UI as a Pod inside the K3s cluster it manages,
+using in-cluster RBAC (read-only, Phase A) instead of a mounted kubeconfig.
+
+## Image availability
+
+**Option A — import local build into k3s containerd (default):**
+```
+docker save controlroom:dev | sudo k3s ctr images import -
+```
+The manifests default to `image: controlroom:dev` with `imagePullPolicy: IfNotPresent`.
+
+**Option B — pull from GHCR:**
+Edit `deployment.yaml` and set:
+```yaml
+image: ghcr.io/tm4rtin17/controlroom:v0.2
+imagePullPolicy: IfNotPresent
+```
+
+## Apply order
+
+```
+kubectl apply -f deploy/k8s/namespace.yaml \
+ -f deploy/k8s/rbac.yaml \
+ -f deploy/k8s/pvc.yaml \
+ -f deploy/k8s/deployment.yaml \
+ -f deploy/k8s/service.yaml \
+ -f deploy/k8s/ingress.yaml
+```
+
+Namespace and RBAC must exist before the Deployment so the SA binding resolves
+on first pod schedule.
+
+## Verify
+
+```
+kubectl -n controlroom get all
+kubectl -n controlroom logs deployment/controlroom -f
+kubectl -n controlroom describe pod -l app.kubernetes.io/name=controlroom
+```
+
+## RBAC scope
+
+Phase A grants `get/list/watch` on nodes, namespaces, pods, services,
+configmaps, events, deployments, statefulsets, daemonsets, and replicasets.
+No write verbs. No access to secrets or persistent volumes.
+Phase B/C will widen the ClusterRole to cover metrics-server, CRDs, and
+targeted write operations (scale, restart). Phase D adds Helm/Kustomize.
+
+## Notes
+
+- Replicas is hard-coded to 1. ControlRoom stores its JWT key, SQLite DB, and
+ TLS material on the PVC. Scaling to >1 requires an external DB (Phase C).
+- TLS is currently self-signed (CR_TLS_MODE=selfsigned). To switch to ACME,
+ set CR_TLS_MODE=acme + CR_ACME_HOST + CR_ACME_EMAIL in deployment.yaml and
+ add a cert-manager ClusterIssuer annotation in ingress.yaml.
+- The pod does not mount docker.sock or the host kubeconfig. All cluster access
+ flows through the projected ServiceAccount token at the standard in-cluster
+ path (/var/run/secrets/kubernetes.io/serviceaccount/).
+- **Privilege model**: This in-cluster Pod deployment is the unprivileged path.
+ It runs as nonroot uid 65532, drops ALL Linux capabilities, and uses a
+ scoped controlroom-reader ClusterRole with get/list/watch verbs only. This
+ contrasts with the docker-compose deployment (deploy/docker-compose.yml),
+ which is fat-privileged: root uid, pid:host + network_mode:host, and
+ cap_add SYS_ADMIN/SYS_PTRACE/NET_ADMIN to enable the Terminal, Journal,
+ Services, Updates, and Network integrations. Choose the in-cluster path if
+ the docker-compose privilege model is not acceptable for your environment.
diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml
new file mode 100644
index 0000000..6cf7b86
--- /dev/null
+++ b/deploy/k8s/deployment.yaml
@@ -0,0 +1,100 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: controlroom
+ namespace: controlroom
+ labels:
+ app.kubernetes.io/name: controlroom
+ app.kubernetes.io/component: server
+spec:
+ # Single replica by design: JWT signing key, SQLite DB, and TLS material
+ # all live on the PVC. Running >1 replica requires migrating to an external
+ # DB and shared-nothing session storage — defer to Phase C.
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: controlroom
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: controlroom
+ app.kubernetes.io/component: server
+ spec:
+ serviceAccountName: controlroom
+
+ # projected SA token is automatically mounted by Kubernetes;
+ # client-go inside the binary finds it at the standard in-cluster path.
+ # Do NOT mount host docker.sock or /etc/rancher/k3s/k3s.yaml here.
+
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 65532 # distroless "nonroot" uid
+ runAsGroup: 65532
+ fsGroup: 65532 # ensures PVC mount is writable by uid 65532
+ seccompProfile:
+ type: RuntimeDefault
+
+ containers:
+ - name: controlroom
+ # Default: local image imported via `docker save | k3s ctr images import`.
+ # For production, switch to: ghcr.io/tm4rtin17/controlroom:v0.2
+ image: controlroom:dev
+ imagePullPolicy: IfNotPresent
+
+ env:
+ # selfsigned: binary generates a self-signed cert and stores it in
+ # CR_DATA_DIR on first boot. Flip to CR_TLS_MODE=acme when this Pod
+ # is fronted by an Ingress with cert-manager — in that case set
+ # CR_ACME_HOST and CR_ACME_EMAIL and expose port 80 for HTTP-01.
+ - name: CR_TLS_MODE
+ value: selfsigned
+ - name: CR_DATA_DIR
+ value: /data
+ - name: CR_ADDR
+ value: :8443
+
+ ports:
+ - name: https
+ containerPort: 8443
+ protocol: TCP
+
+ securityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true # binary writes nothing to /; all state goes to /data
+ capabilities:
+ drop: [ALL]
+
+ # Kubelet skips TLS verification on HTTPS probes by design, so self-signed
+ # certs work here without any additional configuration.
+ livenessProbe:
+ httpGet:
+ path: /api/healthz
+ port: 8443
+ scheme: HTTPS
+ initialDelaySeconds: 10
+ periodSeconds: 30
+
+ readinessProbe:
+ httpGet:
+ path: /api/healthz
+ port: 8443
+ scheme: HTTPS
+ initialDelaySeconds: 3
+ periodSeconds: 10
+
+ resources:
+ requests:
+ cpu: 50m
+ memory: 64Mi
+ limits:
+ cpu: 500m # generous ceiling; Pi4 cores can burst but we cap at 0.5
+ memory: 256Mi
+
+ volumeMounts:
+ - name: controlroom-data
+ mountPath: /data
+
+ volumes:
+ - name: controlroom-data
+ persistentVolumeClaim:
+ claimName: controlroom-data
diff --git a/deploy/k8s/ingress.yaml b/deploy/k8s/ingress.yaml
new file mode 100644
index 0000000..9634b06
--- /dev/null
+++ b/deploy/k8s/ingress.yaml
@@ -0,0 +1,34 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: controlroom
+ namespace: controlroom
+ annotations:
+ # Traefik: passthrough HTTPS to the backend (pod handles TLS itself).
+ # If you switch to cert-manager + Ingress-terminated TLS, remove this
+ # annotation and configure the tls block below with a real secretName.
+ traefik.ingress.kubernetes.io/router.entrypoints: websecure
+ traefik.ingress.kubernetes.io/router.tls: "true"
+ # TODO: add cert-manager annotation when cert is issued, e.g.:
+ # cert-manager.io/cluster-issuer: letsencrypt-prod
+ # (matches roninlab-ingress-specialist ClusterIssuer naming)
+spec:
+ ingressClassName: traefik
+ rules:
+ - host: controlroom.roninlab.dev
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: controlroom
+ port:
+ number: 443
+ tls:
+ - hosts:
+ - controlroom.roninlab.dev
+ # TODO: replace with a cert-manager-issued Secret once the ClusterIssuer
+ # is wired up. Until then, Traefik serves its default self-signed cert
+ # for this host. Example:
+ # secretName: controlroom-tls
diff --git a/deploy/k8s/namespace.yaml b/deploy/k8s/namespace.yaml
new file mode 100644
index 0000000..b7f2f92
--- /dev/null
+++ b/deploy/k8s/namespace.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: controlroom
+ labels:
+ # Enforce Pod Security Standards at "restricted" level.
+ # This requires the Deployment to use a non-root, non-privileged security
+ # context — which it does (runAsUser 65532, drop ALL caps, etc.).
+ pod-security.kubernetes.io/enforce: restricted
+ pod-security.kubernetes.io/enforce-version: latest
+ pod-security.kubernetes.io/warn: restricted
+ pod-security.kubernetes.io/warn-version: latest
diff --git a/deploy/k8s/pvc.yaml b/deploy/k8s/pvc.yaml
new file mode 100644
index 0000000..19ffd0e
--- /dev/null
+++ b/deploy/k8s/pvc.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: controlroom-data
+ namespace: controlroom
+spec:
+ accessModes:
+ - ReadWriteOnce
+ storageClassName: local-path # K3s default local-path-provisioner
+ resources:
+ requests:
+ storage: 1Gi
diff --git a/deploy/k8s/rbac.yaml b/deploy/k8s/rbac.yaml
new file mode 100644
index 0000000..c704eb7
--- /dev/null
+++ b/deploy/k8s/rbac.yaml
@@ -0,0 +1,94 @@
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: controlroom
+ namespace: controlroom
+
+---
+# Phase A: read-only access to core cluster resources.
+# Phase B widens with pods/log so the SPA can stream container output.
+# Phase C widens with the verbs needed for lifecycle actions:
+# - patch on apps/{deployments,statefulsets,daemonsets} for `rollout restart`
+# (annotation patch on .spec.template.metadata).
+# - update on apps/{deployments,statefulsets}/scale subresource.
+# - delete on pods (controller-driven recreate).
+# - patch on nodes for cordon/uncordon (.spec.unschedulable).
+# We still do NOT grant access to secrets, persistentvolumes, or destructive
+# verbs on workloads (delete on apps/* would let the operator wipe a workload
+# from the SPA without going through GitOps).
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: controlroom-reader
+rules:
+ - apiGroups: [""]
+ resources:
+ - nodes
+ - namespaces
+ - pods
+ - services
+ - configmaps
+ - events
+ - endpoints
+ verbs: [get, list, watch]
+ - apiGroups: [""]
+ resources:
+ - pods/log
+ verbs: [get]
+ - apiGroups: [""]
+ resources:
+ - pods/exec
+ verbs: [create]
+ - apiGroups: [""]
+ resources:
+ - pods
+ verbs: [delete]
+ - apiGroups: [""]
+ resources:
+ - nodes
+ verbs: [patch]
+ - apiGroups: [""]
+ resources:
+ - configmaps
+ - services
+ verbs: [update]
+ # Secrets read access. Deliberately get/list only — NOT watch — so we
+ # don't keep a long-lived stream of all cluster secret values open.
+ # Every detail view writes an audit row (k8s.secret.read) so the
+ # operator's read history is recoverable.
+ - apiGroups: [""]
+ resources:
+ - secrets
+ verbs: [get, list]
+ - apiGroups: ["apps"]
+ resources:
+ - deployments
+ - statefulsets
+ - daemonsets
+ - replicasets
+ verbs: [get, list, watch]
+ - apiGroups: ["apps"]
+ resources:
+ - deployments
+ - statefulsets
+ - daemonsets
+ verbs: [patch, update]
+ - apiGroups: ["apps"]
+ resources:
+ - deployments/scale
+ - statefulsets/scale
+ verbs: [update]
+
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: controlroom-reader
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: controlroom-reader
+subjects:
+ - kind: ServiceAccount
+ name: controlroom
+ namespace: controlroom
diff --git a/deploy/k8s/service.yaml b/deploy/k8s/service.yaml
new file mode 100644
index 0000000..39d2aec
--- /dev/null
+++ b/deploy/k8s/service.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: controlroom
+ namespace: controlroom
+ labels:
+ app.kubernetes.io/name: controlroom
+ app.kubernetes.io/component: server
+spec:
+ type: ClusterIP
+ selector:
+ app.kubernetes.io/name: controlroom
+ ports:
+ - name: https
+ port: 443 # Ingress → Service on 443
+ targetPort: 8443 # Pod listens on 8443
+ protocol: TCP
diff --git a/deploy/scripts/setup-host-kubeconfig.sh b/deploy/scripts/setup-host-kubeconfig.sh
new file mode 100755
index 0000000..9d8d7ee
--- /dev/null
+++ b/deploy/scripts/setup-host-kubeconfig.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+#
+# NOTE: This script is NO LONGER NEEDED for the standard docker-compose
+# deployment. Since docker-compose.yml now uses network_mode:host, the
+# container shares the host network namespace and can reach the K3s API
+# at 127.0.0.1:6443 directly. The original /etc/rancher/k3s/k3s.yaml is
+# mounted read-only as /etc/k3s/kubeconfig inside the container — no
+# server-URL rewrite is required.
+#
+# This script is retained for non-host-network deployments (e.g. a custom
+# bridge network or a remote ControlRoom instance pointing at a separate
+# K3s host). In those cases the container cannot reach 127.0.0.1:6443, so
+# the server URL must be rewritten to the host's real IP that is in the
+# K3s TLS SAN list.
+#
+# Usage (non-host-network deployments only):
+#
+# sudo deploy/scripts/setup-host-kubeconfig.sh
+#
+# Override the host IP if the auto-detected one isn't in the cert SAN:
+#
+# sudo HOST_IP=192.168.1.50 deploy/scripts/setup-host-kubeconfig.sh
+#
+# Note: the K3s admin kubeconfig contains cluster-admin credentials; the
+# resulting file under /var/lib/controlroom/kubeconfig is mode 0600 and
+# owned by uid 65532 (the controlroom container's nonroot user). Anyone
+# with read access to that file has root on the cluster.
+
+set -euo pipefail
+
+K3S_CONFIG=${K3S_CONFIG:-/etc/rancher/k3s/k3s.yaml}
+OUT_DIR=${OUT_DIR:-/var/lib/controlroom}
+OUT=${OUT:-${OUT_DIR}/kubeconfig}
+CR_UID=65532
+CR_GID=65532
+
+if [ ! -r "$K3S_CONFIG" ]; then
+ echo "error: $K3S_CONFIG not readable — run as root or use sudo" >&2
+ exit 1
+fi
+
+# Pick a host IP that's in K3s' cert SAN.
+# Priority: $HOST_IP override → node-ip from /etc/rancher/k3s/config.yaml
+# → first non-loopback IPv4 from `hostname -I`.
+HOST_IP=${HOST_IP:-}
+if [ -z "$HOST_IP" ] && [ -r /etc/rancher/k3s/config.yaml ]; then
+ HOST_IP=$(awk '/^node-ip:/ {gsub(/[",]/,"",$2); print $2; exit}' /etc/rancher/k3s/config.yaml || true)
+fi
+if [ -z "$HOST_IP" ]; then
+ HOST_IP=$(hostname -I | awk '{print $1}')
+fi
+if [ -z "$HOST_IP" ]; then
+ echo "error: could not determine host IP — set HOST_IP=… and re-run" >&2
+ exit 1
+fi
+
+# Verify the IP is in the cert SAN before we hand the container a config that
+# will fail validation at runtime.
+CERT=/var/lib/rancher/k3s/server/tls/serving-kube-apiserver.crt
+if [ -r "$CERT" ]; then
+ if ! openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null \
+ | grep -q "IP Address:$HOST_IP\b"; then
+ echo "warning: $HOST_IP not in cert SAN of $CERT" >&2
+ echo " the container will fail TLS verification — re-run with HOST_IP=… set to a SAN-correct address" >&2
+ fi
+fi
+
+mkdir -p "$OUT_DIR"
+sed "s|server: https://127.0.0.1:6443|server: https://${HOST_IP}:6443|" "$K3S_CONFIG" > "$OUT"
+chown "$CR_UID:$CR_GID" "$OUT"
+chmod 0600 "$OUT"
+
+echo "wrote $OUT"
+echo " server: https://${HOST_IP}:6443"
+echo " owner: ${CR_UID}:${CR_GID}"
+echo " mode: 0600"
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
new file mode 100644
index 0000000..c02bee0
--- /dev/null
+++ b/docs/CONFIG.md
@@ -0,0 +1,178 @@
+# Configuration reference
+
+Everything ControlRoom can be configured with, plus the per-tab
+"what does this need to actually work" matrix.
+
+## Environment variables
+
+All settings are environment variables. There is no on-disk config file —
+in bare-metal install they live in `/etc/controlroom/controlroom.env`
+(read by the systemd unit), in Docker they go in the `environment:` block
+of `docker-compose.yml`, and in the K8s Pod they go in the Deployment's
+`spec.template.spec.containers[0].env`.
+
+### Core
+
+| Var | Default | Purpose |
+|---|---|---|
+| `CR_ADDR` | `:8443` | TCP bind address for the HTTPS listener (or plain HTTP when `CR_DEV=true`). Use `127.0.0.1:8080` when fronting with a TLS-terminating reverse proxy. |
+| `CR_DATA_DIR` | `/var/lib/controlroom` | Where TLS material, the JWT signing key, the SQLite DB, and the audit log live. Must be absolute. The directory is created with mode 0750 if missing. |
+| `CR_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error`. Controls zerolog filter. |
+| `CR_HOST_NAME` | (kernel hostname) | Display name in the SPA title bar. Cosmetic only. |
+| `CR_SESSION_HOURS` | `168` (7 days) | Refresh-token lifetime. Access tokens are always 15 min. Reducing this forces more frequent re-logins. |
+| `CR_VERSION_CHECK` | `false` | When `true`, ControlRoom does a daily release-check against GitHub releases and surfaces an "update available" badge. Off by default to avoid outbound calls. |
+| `CR_DEV` | `false` | Plain HTTP + non-Secure cookies. **Development only** — never set in production. |
+
+### TLS
+
+| Var | Default | Purpose |
+|---|---|---|
+| `CR_TLS_MODE` | `selfsigned` | `selfsigned` / `acme` / `proxy`. See [`INSTALL.md` → TLS modes](./INSTALL.md#tls-modes). |
+| `CR_ACME_HOST` | — | Required when `CR_TLS_MODE=acme`. The hostname Let's Encrypt issues against. DNS must already point at the host. |
+| `CR_ACME_EMAIL` | — | Required when `CR_TLS_MODE=acme`. Account email for the ACME directory. |
+| `CR_TRUST_PROXY` | `false` | When `true`, honor `X-Forwarded-For` and `X-Forwarded-Proto` for client IP and scheme detection. Set this only when ControlRoom is *actually* behind a trusted reverse proxy — setting it on an exposed server lets clients lie about their IP. |
+
+### Integrations
+
+| Var | Default | Purpose |
+|---|---|---|
+| `CR_DOCKER_SOCK` | `/var/run/docker.sock` | Unix socket path for the Docker daemon. Set to empty (`""`) to disable the Docker integration entirely (Containers tab gets hidden via the capability flag). |
+| `KUBECONFIG` | (none) | Standard kubeconfig path. ControlRoom's K8s client checks: (1) in-cluster SA, (2) `$KUBECONFIG`, (3) `~/.kube/config`, (4) `/etc/rancher/k3s/k3s.yaml`. First match wins. |
+
+### Container-only
+
+These only matter for the Docker deployment. The bare-metal systemd unit
+spawns shells directly as the controlroom user; the in-cluster Pod has no
+host shell to spawn at all.
+
+| Var | Default | Purpose |
+|---|---|---|
+| `CR_HOST_SHELL` | `false` | When `true`, the Terminal tab spawns its shell via `nsenter -t 1 -m -u -i -n -p` to enter the host's namespaces. Requires the container to run with `pid: host` and `cap_add: SYS_ADMIN`. The fat-privileged compose sets this `true`. |
+| `CR_TERMINAL_LOGIN` | `false` | When `true` (and `CR_HOST_SHELL=true`), the Terminal prompts for username, then `setpriv --reuid=nobody -- su -l `. PAM handles the password prompt; on success drops to that user's shell. **Strongly recommended** whenever `CR_HOST_SHELL=true` — without it you land directly as root inside the host namespaces. |
+
+### Compose helpers
+
+These aren't read by the Go binary — they're consumed by `docker-compose.yml`
+expansion only.
+
+| Var | Default | Purpose |
+|---|---|---|
+| `DOCKER_GID` | `999` | The host's `docker` group GID. Used by `group_add` so the controlroom container can read `/var/run/docker.sock` even when running as a non-root user. Get it with `getent group docker \| cut -d: -f3`. (The default fat-privileged compose runs as root and doesn't strictly need `group_add`, but the var is preserved for users who run a less-privileged variant.) |
+
+---
+
+## Per-tab capability matrix
+
+What each tab needs to function, by deployment shape.
+
+| Tab | Backend integration | Bare-metal | Docker (fat-privileged) | In-cluster Pod |
+|---|---|:-:|:-:|:-:|
+| **Dashboard** | `/proc`, `/sys` reads | ✅ | ✅ | ✅ host metrics not the cluster |
+| **Updates** | `apt`, `apt-get`, `sudo`, `systemctl reboot` | ✅ via sudoers | ✅ binaries in image, host `/etc/apt` + `/var/cache/apt` mounted | ❌ (no apt in cluster) |
+| **Services** | dbus + `systemctl` | ✅ via dbus session | ✅ `/var/run/dbus/system_bus_socket` mounted | ❌ |
+| **Containers** | `/var/run/docker.sock` | ✅ if controlroom user is in `docker` group | ✅ socket mounted ro | ❌ |
+| **Kubernetes** | client-go with kubeconfig or in-cluster SA | ✅ if `kubectl` works as the controlroom user | ✅ `/etc/rancher/k3s/k3s.yaml` mounted | ✅ in-cluster ServiceAccount |
+| ↳ Pod **exec** | client-go remotecommand (SPDY) | ✅ | ✅ | ✅ requires `pods/exec: create` in ClusterRole |
+| ↳ Lifecycle actions | dynamic-client patch / scale / delete / node-patch | ✅ | ✅ | ✅ requires `patch`/`update`/`delete` per resource |
+| ↳ ConfigMap edit | dynamic-client update | ✅ | ✅ | ✅ requires `configmaps: update` |
+| ↳ Secret view (read-only) | typed-client get/list, base64 decoded server-side | ✅ | ✅ | ✅ requires `secrets: get,list` |
+| ↳ Manifest YAML edit | dynamic-client update with DryRunAll option | ✅ | ✅ | ✅ requires `update` on the editable kind |
+| **Terminal** | PTY + (optional) `nsenter`+`su` | ✅ shell as controlroom user | ✅ host shell via nsenter+PAM login | ❌ |
+| **Network** | `ip -j addr show`, `sudo ufw …` | ✅ via sudoers | ✅ via `network_mode: host` + NET_ADMIN | ❌ shows Pod's network ns only |
+| **Logs** | `journalctl` + (fallback) docker logs | ✅ via `adm` group | ✅ `/run/log/journal` + `/etc/machine-id` mounted | ❌ host journal not reachable |
+| **Settings** | SQLite + JWT key | ✅ | ✅ | ✅ |
+
+ControlRoom auto-detects which integrations are working at boot and
+exposes the result at `GET /api/system/capabilities`:
+
+```json
+{
+ "systemd": true,
+ "docker": true,
+ "journal": true,
+ "kubernetes": true
+}
+```
+
+The SPA reads this on every page load and **hides the nav entries** for
+features that aren't reachable. Failed integrations log a warning at
+startup but never crash the binary — every integration is best-effort.
+
+---
+
+## Per-shape file paths
+
+Where things live on disk in each shape.
+
+| Path | Bare-metal | Docker | In-cluster Pod |
+|---|---|---|---|
+| Binary | `/opt/controlroom/controlroom` | `/app/controlroom` (in image) | `/app/controlroom` (in image) |
+| Data dir | `/var/lib/controlroom/` | Docker volume `controlroom-data` (mountpoint `/var/lib/docker/volumes/controlroom-data/_data` on host) | PVC `controlroom-data` (1Gi, RWO, `local-path`) |
+| SQLite DB | `$DATA_DIR/controlroom.db` | same | same |
+| JWT signing key | `$DATA_DIR/jwt.key` (mode 0600) | same | same |
+| TLS cert+key | `$DATA_DIR/tls/{server.crt,server.key}` | same | same |
+| ACME cache | `$DATA_DIR/acme/` (when `CR_TLS_MODE=acme`) | same | same |
+| Systemd unit | `/etc/systemd/system/controlroom.service` | n/a | n/a |
+| Sudoers fragment | `/etc/sudoers.d/controlroom` | n/a (root in container) | n/a (Pod, no sudoers) |
+| Env file | `/etc/controlroom/controlroom.env` | inline in compose `environment:` | inline in Deployment env |
+| Logs | `journalctl -u controlroom` | `docker logs controlroom` | `kubectl logs -n controlroom deployment/controlroom` |
+
+---
+
+## Capability detection — debugging
+
+If a tab is hidden in the SPA and you expected it to work:
+
+1. Look at the boot log. ControlRoom logs one warning per failed
+ integration at startup:
+ - `systemd unavailable; /api/services disabled`
+ - `journalctl not in PATH; /api/logs/journal will return 503`
+ - `docker unavailable; /api/containers disabled`
+ - `kubernetes unavailable; /api/k8s disabled`
+
+2. Hit the capabilities endpoint as a logged-in user (you can grab the
+ cookie from devtools and curl):
+ ```
+ curl -sk -b "cr_access=" https://:8443/api/system/capabilities
+ ```
+ The booleans tell you what the SPA is using to gate the nav.
+
+3. For Docker-shape deployments specifically, double-check the bind mount
+ actually exists inside the container:
+ ```
+ docker exec controlroom ls -la /var/run/docker.sock
+ docker exec controlroom ls -la /var/run/dbus/system_bus_socket
+ docker exec controlroom ls -la /etc/k3s/kubeconfig
+ docker exec controlroom which journalctl systemctl ip ufw apt
+ ```
+
+4. For the Kubernetes tab specifically, run a probe from inside the
+ container:
+ ```
+ docker exec controlroom sh -c '
+ KUBECONFIG=/etc/k3s/kubeconfig kubectl version --client
+ KUBECONFIG=/etc/k3s/kubeconfig kubectl get nodes 2>&1 | head
+ '
+ ```
+
+---
+
+## Compose env-file pattern
+
+Rather than putting all `CR_*` vars in `docker-compose.yml`, you can write
+an `.env` file next to it:
+
+```ini
+# .env
+CR_TLS_MODE=acme
+CR_ACME_HOST=ctrl.example.com
+CR_ACME_EMAIL=admin@example.com
+DOCKER_GID=999
+```
+
+Compose auto-loads it. The `.env` file is `.gitignore`d by default in
+this repo.
+
+For bare-metal, the equivalent is `/etc/controlroom/controlroom.env` —
+the systemd unit reads it via `EnvironmentFile=`. Same syntax (`KEY=value`,
+no quotes needed).
diff --git a/docs/INSTALL.md b/docs/INSTALL.md
index 7c1a2d1..839f2f8 100644
--- a/docs/INSTALL.md
+++ b/docs/INSTALL.md
@@ -1,43 +1,103 @@
# Installing ControlRoom
-ControlRoom v0.1 supports Debian and Ubuntu (LTS releases). Two install paths
-are supported: a one-shot bare-metal installer and a Docker Compose deployment.
+ControlRoom ships in three deployment shapes. Pick the one that matches your
+constraints — they're not mutually exclusive (you can run, e.g., a bare-metal
+ControlRoom on the host *and* an in-cluster ControlRoom in K3s for the
+cluster-management UI).
-## Prerequisites
+## Choosing a shape
-- Debian 12+ or Ubuntu 22.04+ (older releases haven't been tested).
-- A user with `sudo` (only for installation; ControlRoom itself runs as a
- dedicated `controlroom` system user afterwards).
-- Optional but recommended: `ufw`, `journalctl` (always present), and Docker
- if you want to manage containers.
+| | Bare-metal | Docker container | In-cluster K8s Pod |
+|---|---|---|---|
+| **Where it runs** | Host, as a systemd unit | Host, as a Docker container | A Pod in your K3s/k8s cluster |
+| **Privilege** | Dedicated `controlroom` user + scoped sudoers | Root inside container, host PID/network namespaces, broad capabilities | Nonroot (uid 65532), `drop: [ALL]`, scoped ClusterRole |
+| **Image size** | ~20 MB binary + ~1 MB SPA | ~230 MB | ~230 MB |
+| **Idle RSS** | ~40 MB | ~50 MB | ~50 MB |
+| **Tabs that work** | All (Dashboard, Updates, Services, Containers, Kubernetes\*, Terminal, Network, Logs, Settings) | All | Kubernetes + Dashboard + Settings only — no host integrations |
+| **Best for** | Single-host homelab, smallest blast radius, the canonical path. | Single-user homelabs that want to manage the whole host *and* keep ControlRoom in a container. | Cluster operations only; pair with bare-metal/container ControlRoom for host management. |
-## Option A — bare-metal (recommended for full feature set)
+\* Kubernetes tab on bare-metal works if `kubectl` finds a kubeconfig — see
+the [Kubernetes integration](#kubernetes-integration) section.
-The installer creates a `controlroom` system user, installs the binary under
-`/opt/controlroom`, persists data in `/var/lib/controlroom`, drops a hardened
-systemd unit, and writes a tight sudoers fragment after validating it with
-`visudo -c`.
+> **Security note.** The Docker container shape is *fat-privileged*: a
+> compromise of the ControlRoom binary inside the container is effectively
+> root on the host. That's the deliberate tradeoff to make every host
+> integration work from a container. If your threat model doesn't accept
+> that, use bare-metal. See [`SECURITY.md`](./SECURITY.md).
+
+---
+
+## Path A — Bare-metal (Debian / Ubuntu / Raspberry Pi OS)
+
+The most polished path, smallest blast radius. The installer:
+
+- Creates a dedicated `controlroom` system user and group.
+- Installs the binary to `/opt/controlroom/controlroom`.
+- Creates the data directory `/var/lib/controlroom/` (mode 0750, owned by
+ the controlroom user). TLS material, the JWT signing key, the SQLite DB
+ and the audit log all live here.
+- Drops `/etc/systemd/system/controlroom.service` — a hardened unit with
+ `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, system-call
+ filtering, and supplementary groups (`adm` for journal, `docker` if
+ present).
+- Drops `/etc/sudoers.d/controlroom` — a tight allowlist for the few
+ commands ControlRoom needs to escalate (`apt-get update/install`,
+ `systemctl reboot`, the specific `ufw` verbs the SPA exposes). Validated
+ with `visudo -c` before install — the script aborts on parse failure.
+
+### Prerequisites
+
+- Debian 12+ / Ubuntu 22.04+ / Raspberry Pi OS Bookworm. Older releases
+ haven't been tested.
+- A user with `sudo` (only used for the install — ControlRoom doesn't run
+ as root).
+- Optional but expected for full feature parity: `ufw`, Docker (if you want
+ the Containers tab), `kubectl` configured against your cluster (if you
+ want the Kubernetes tab).
+
+### Install
+
+From a clone of the repo:
```bash
sudo deploy/install.sh
```
-Or, if you cloned the repo and want to build from source first:
+If you want to build from the cloned source rather than fetching a
+pre-built binary, add `--from-source`:
```bash
sudo deploy/install.sh --from-source
```
-When the service starts it logs a one-time **setup token** — the installer
-tails it for you, but you can also run:
+The installer prints a one-time **setup token** at the end of its run and
+also tails it from the journal. If you missed it, retrieve it with:
```bash
sudo journalctl -u controlroom -n 200 | grep setup_token
```
-Visit `https://:8443` (the self-signed cert will be flagged by your
-browser the first time), paste the token into the wizard, create your admin
-account, optionally enable 2FA, and you're in.
+### First-run setup
+
+1. Open `https://:8443` in a browser. The first time, your browser
+ will warn about the self-signed certificate — accept it for now, or
+ switch to ACME / a reverse proxy later (see [TLS modes](#tls-modes)).
+2. Paste the setup token in the wizard.
+3. Pick an admin username + a strong password.
+4. Optionally scan the QR code with Authy / 1Password / Aegis to enable
+ TOTP. Verify the 6-digit code.
+5. Done — you're logged in.
+
+### Updating
+
+```bash
+cd /path/to/repo
+git pull
+sudo deploy/install.sh --from-source
+```
+
+The script preserves `/var/lib/controlroom/` (your data), restarts the
+unit, and re-validates the sudoers fragment.
### Uninstall
@@ -45,61 +105,355 @@ account, optionally enable 2FA, and you're in.
sudo deploy/install.sh --uninstall
```
-The data directory at `/var/lib/controlroom` is preserved by default — delete
-it manually if you want a truly fresh install.
+The data directory at `/var/lib/controlroom/` is *preserved* by default —
+delete it manually for a truly fresh slate (you'll lose your account, JWT
+key, TLS material, and audit log).
+
+### Common pitfalls
+
+- **`apt update` fails inside the apt job log**: the sudoers fragment
+ whitelists `apt-get` not `apt`. The Go code calls `apt-get` for
+ privileged ops; a bare `apt list --upgradable` runs unprivileged. If you
+ see "sudo: a password is required", the fragment didn't install — check
+ `sudo visudo -c -f /etc/sudoers.d/controlroom`.
+- **Services tab empty**: the `controlroom` user needs to be in the `adm`
+ group (for journal access) and able to talk to dbus. The installer does
+ this automatically — verify with `id controlroom`.
+- **Containers tab 503**: Docker daemon not present, or `controlroom` user
+ not in the `docker` group. Add them: `sudo usermod -aG docker controlroom`,
+ then `sudo systemctl restart controlroom`.
+
+---
+
+## Path B — Docker container (fat-privileged)
-## Option B — Docker Compose
+Single container that does everything. Drops distroless for `debian:bookworm-slim`
+plus `bash`, `sudo`, `util-linux` (nsenter), `procps`, `iproute2`, `iptables`,
+`ufw`, `systemd` (journalctl + systemctl + libsystemd0), `apt-utils`. Runs as
+root with broad capabilities and host namespaces.
-Container deployment is simpler but loses access to the host's systemd dbus
-and journald. The `Services` and `Logs` pages will return 503; everything
-else works.
+### Prerequisites
+
+- Docker Engine 20.10+ (or compatible — Podman with the docker-compose plugin
+ works too).
+- The host's `docker` group GID (run `getent group docker | cut -d: -f3`).
+
+If you want the Kubernetes tab to work in this shape, you also need:
+- A reachable kubeconfig (`/etc/rancher/k3s/k3s.yaml` for K3s on the same
+ host is the easiest case).
+- Either `network_mode: host` (the default in our compose, so K3s'
+ `127.0.0.1:6443` becomes reachable directly), **or** a host IP that's in
+ your cluster's TLS SAN — see [Kubernetes integration](#kubernetes-integration).
+
+### Install
```bash
+git clone https://github.com/tm4rtin17/ControlRoom.git
+cd ControlRoom
+
+# One-time: create the persistent data volume. The compose file declares
+# `controlroom-data` as `external: true` so docker compose reuses this
+# named volume instead of creating a project-namespaced one
+# (`deploy_controlroom-data`) — admin account, JWT key, and TLS material
+# would be stranded if the names diverged.
+docker volume create controlroom-data
+
+# Set DOCKER_GID for the compose file (only needed if you don't use
+# network_mode:host or run as root — the default compose runs as root and
+# doesn't need group_add, but it's still pulled from this var).
+export DOCKER_GID=$(getent group docker | cut -d: -f3)
+
docker compose -f deploy/docker-compose.yml up -d
docker compose -f deploy/docker-compose.yml logs --tail 200 | grep setup_token
```
-To manage other containers, mount the Docker socket read-only (the default
-compose file already does this).
+### What the container can reach (and how)
+
+The default `deploy/docker-compose.yml` mounts:
+
+| Path | Mode | Used for |
+|---|---|---|
+| `/var/run/docker.sock` | ro | Containers tab |
+| `/var/run/dbus/system_bus_socket` | rw | Services tab (systemctl via dbus) |
+| `/run/log/journal` | ro | Logs tab (volatile journal) |
+| `/var/log/journal` | ro | Logs tab (persistent journal, if enabled) |
+| `/etc/machine-id` | ro | journalctl needs the host's machine-id to identify entries |
+| `/etc/rancher/k3s/k3s.yaml` | ro | Kubernetes tab (mounted at `/etc/k3s/kubeconfig`) |
+| `/var/cache/apt` | rw | Updates tab (apt cache; rw so `apt-get update` can refresh) |
+| `/etc/apt` | ro | Updates tab (sources, preferences) |
+| `/var/lib/dpkg` | ro | Updates tab (dpkg state) |
+| `controlroom-data` | rw | App data (JWT key, SQLite, TLS) — Docker named volume |
+
+Plus:
+- `network_mode: host` so `ip`, `ufw`, and the K3s API at `127.0.0.1:6443`
+ all see the host directly.
+- `pid: host` so `nsenter -t 1 ...` finds the host's PID 1 (systemd).
+- `cap_add: [SYS_ADMIN, SYS_PTRACE, NET_ADMIN]` for nsenter, host `ps`/`top`,
+ and ufw/iptables.
+- `security_opt: [apparmor:unconfined, seccomp:unconfined]` so the default
+ Docker AppArmor and seccomp profiles don't block the host commands.
+
+### Terminal: host login flow
+
+The container sets `CR_HOST_SHELL=true` and `CR_TERMINAL_LOGIN=true` by
+default. When you click the Terminal tab:
+
+1. ControlRoom spawns `nsenter -t 1 -m -u -i -n -p -- /bin/bash -c `.
+ This enters the host's mount, UTS, IPC, network, and PID namespaces.
+2. The login script prints `ControlRoom Terminal — host login at `
+ and prompts for `username:`.
+3. You type your host username, hit enter.
+4. The script `exec`s `setpriv --reuid=nobody --regid=nogroup --clear-groups
+ -- su -l `. Dropping privileges first is critical: root
+ invoking `su` skips PAM authentication ("root can become anyone").
+5. `su` runs full PAM auth via `/etc/pam.d/su`, prompts for `Password:`,
+ authenticates against `/etc/shadow`, logs the attempt to
+ `/var/log/auth.log`, and on success drops to the user's shell at the
+ user's uid.
+
+Set `CR_TERMINAL_LOGIN=false` to skip the login flow (you land directly as
+root inside the host namespaces — convenient but not recommended).
+
+### Updating
+
+```bash
+git pull
+docker compose -f deploy/docker-compose.yml up -d --build
+```
+
+For a published release tag, swap the `image:` in the compose file from
+`controlroom:dev` (local build) to `ghcr.io/tm4rtin17/controlroom:v0.2.x`
+and `docker compose pull && docker compose up -d`.
+
+### Uninstall
+
+```bash
+docker compose -f deploy/docker-compose.yml down
+docker volume rm controlroom-data # nukes admin account, JWT key, TLS, audit log
+```
+
+### Common pitfalls
+
+- **All tabs say "unavailable" on first load**: the boot log will show
+ exactly which integration is failing. `docker compose logs controlroom`
+ and look for `systemd unavailable`, `journalctl not in PATH`, etc. Each
+ warning maps to a specific mount that didn't apply.
+- **Terminal: "no usable shell on host"**: the image isn't the
+ fat-privileged one (still distroless). Rebuild: `docker compose build`.
+- **Terminal: drops you to root without password**: confirm
+ `CR_TERMINAL_LOGIN=true` is set. `docker exec controlroom env | grep
+ TERMINAL`. If it's set and you still skip the password prompt, check
+ that `/usr/bin/setpriv` exists in the image: `docker exec controlroom
+ which setpriv`.
+- **Kubernetes tab hidden**: the host kubeconfig isn't mounted, or the
+ network can't reach the API server. Verify the bind mount:
+ `docker exec controlroom ls -la /etc/k3s/kubeconfig`. From the host:
+ `curl -sk https://127.0.0.1:6443/livez` should return `ok`.
+- **Logs tab works but Services doesn't**: the dbus socket isn't writable
+ in the container, or the host doesn't run systemd (e.g. Alpine). Check
+ `ls -la /var/run/dbus/system_bus_socket` on the host and `docker exec
+ controlroom ls -la /var/run/dbus/system_bus_socket` in the container.
+
+---
+
+## Path C — In-cluster Kubernetes Pod
+
+Run ControlRoom *inside* the cluster it manages. Useful when you want a
+ControlRoom that's only for the K8s tab — the host integrations (Services,
+Updates, Network, host Terminal, Journal Logs) are intentionally not wired
+in this shape.
+
+### Prerequisites
+
+- A K3s / k8s cluster you can `kubectl apply` to.
+- The `controlroom` image available to the cluster's runtime. Two options:
+ 1. Pull from a registry: edit `deploy/k8s/deployment.yaml` to point at
+ `ghcr.io/tm4rtin17/controlroom:v0.2.x` and ensure your nodes can pull
+ from GHCR.
+ 2. Side-load a locally-built image: `make image && docker save controlroom:dev
+ | sudo k3s ctr images import -` (run on **every** node in a multi-node
+ cluster — k3s' embedded containerd doesn't share images cluster-wide).
+- Storage class. The PVC requests `local-path` (the K3s default). If your
+ cluster uses a different storage class, edit `deploy/k8s/pvc.yaml`.
+- An ingress controller if you want hostname-based access. The default
+ `deploy/k8s/ingress.yaml` is a `networking.k8s.io/v1` Ingress with
+ `ingressClassName: traefik` (K3s default).
+
+### Install
+
+```bash
+kubectl apply -f deploy/k8s/namespace.yaml
+kubectl apply -f deploy/k8s/rbac.yaml
+kubectl apply -f deploy/k8s/pvc.yaml
+kubectl apply -f deploy/k8s/deployment.yaml
+kubectl apply -f deploy/k8s/service.yaml
+# Optional:
+kubectl apply -f deploy/k8s/ingress.yaml
+
+kubectl -n controlroom rollout status deployment/controlroom --timeout=90s
+kubectl -n controlroom logs deployment/controlroom | grep setup_token
+```
+
+The pod uses the projected ServiceAccount token to talk to the API server.
+No kubeconfig is needed.
+
+### What the ClusterRole grants
+
+The `controlroom-reader` ClusterRole was widened across Phases A → D as
+features landed. The current canonical version lives in
+`deploy/k8s/rbac.yaml`. Summary:
+
+| Resource | Verbs | Phase | Used for |
+|---|---|---|---|
+| `nodes` / `namespaces` / `pods` / `services` / `configmaps` / `events` / `endpoints` | `get,list,watch` | A | List + detail views |
+| `apps/deployments` / `statefulsets` / `daemonsets` / `replicasets` | `get,list,watch` | A | Workload lists + detail |
+| `pods/log` | `get` | B | Pod log streaming |
+| `pods/exec` | `create` | D1 | Pod exec / shell-in-browser |
+| `pods` | `delete` | C | Delete pod (controller-recreate) |
+| `nodes` | `patch` | C | Cordon / uncordon |
+| `apps/deployments` / `statefulsets` / `daemonsets` | `patch,update` | C + D4 | Restart annotation + full YAML edit |
+| `apps/deployments/scale` / `statefulsets/scale` | `update` | C | Scale |
+| `services` / `configmaps` | `update` | D2 + D4 | ConfigMap edit + manifest YAML edit |
+| `secrets` | `get,list` (NOT `watch`) | D3 | Read-only viewer; intentionally narrow |
+
+**Explicitly excluded**: `secrets/watch` (would establish a long-lived
+stream of all cluster secrets — point-in-time reads + audit are
+preferred); `delete` on workloads (would let the SPA wipe a Deployment
+outside GitOps); `persistentvolumes` (cluster-scoped, dangerous);
+`create` on most resources (Phase D4 is edit-only — no create-from-
+nothing path).
+
+Every secret detail GET writes a `k8s.secret.read` audit row; every
+manifest apply writes a `k8s.manifest.apply` (or `…dry_run`) row. The
+audit table records *that* a secret was read, not what was in it.
+
+### Accessing the SPA
+
+Three options, in order of typical preference:
+
+1. **Ingress** (`deploy/k8s/ingress.yaml`): edit the host (defaults to
+ `controlroom.roninlab.dev`), apply, point DNS at the ingress controller.
+ For TLS, wire cert-manager separately — the manifest leaves it as a TODO
+ comment.
+2. **Port-forward** (works without ingress): `kubectl -n controlroom
+ port-forward svc/controlroom 8443:443 --address 0.0.0.0` then visit
+ `https://:8443`.
+3. **NodePort** (edit the Service `spec.type` to `NodePort`): cluster-wide
+ `:30443` or whatever NodePort gets assigned.
+
+### First-run setup
+
+Same wizard as the other shapes — paste the token, create your admin, etc.
+
+### Updating
+
+```bash
+git pull
+make image
+docker save controlroom:dev | sudo k3s ctr images import - # repeat on every node
+kubectl -n controlroom rollout restart deployment/controlroom
+kubectl -n controlroom rollout status deployment/controlroom --timeout=90s
+```
+
+### Uninstall
+
+```bash
+kubectl delete namespace controlroom # nukes everything including the PVC
+```
+
+### Common pitfalls
+
+- **`ImagePullBackOff` after install**: the image isn't on the node.
+ `make image` only writes to your local Docker daemon, not k3s'
+ containerd. Run the `docker save | k3s ctr images import` dance on every
+ node.
+- **`/api/k8s` returns 503 once you log in**: the projected SA token wasn't
+ picked up. `kubectl -n controlroom describe pod -l app.kubernetes.io/name=controlroom`
+ and check that `serviceAccountName: controlroom` is set on the spec.
+- **Pod logs streaming fails with 403**: the ClusterRole is missing
+ `pods/log`. Make sure you applied `rbac.yaml` from this branch (Phase A's
+ rbac didn't include it; Phase B does).
+
+---
## TLS modes
-| `CR_TLS_MODE` | Behaviour |
-|---------------|----------------------------------------------------------------|
-| `selfsigned` | (default) Generates a 10-year ECDSA self-signed cert at first boot under `$DATA_DIR/tls/`. |
-| `acme` | Uses Let's Encrypt via HTTP-01. Requires `CR_ACME_HOST` and `CR_ACME_EMAIL`. The host must be reachable on **port 80** from the public internet during issuance and renewal. |
-| `proxy` | Stub for "TLS terminated by an upstream reverse proxy." Bind plain HTTP on the loopback (e.g. `CR_ADDR=127.0.0.1:8080`) and let the proxy speak HTTPS. |
-
-Pair `acme` with `--ports 80:80 8443:443` (or your reverse proxy) and ensure
-DNS for `CR_ACME_HOST` already resolves to the host before starting.
-
-## Configuration reference
-
-All settings are environment variables. Persist them in
-`/etc/controlroom/controlroom.env` (read by the systemd unit) for bare-metal,
-or pass them to `docker compose` via `environment:`.
-
-| Var | Default | Purpose |
-|----------------------|-------------------------------|-------------------------------------------|
-| `CR_ADDR` | `:8443` | TLS bind address. |
-| `CR_DATA_DIR` | `/var/lib/controlroom` | TLS material, SQLite, audit log. |
-| `CR_TLS_MODE` | `selfsigned` | `selfsigned` \| `acme` \| `proxy`. |
-| `CR_ACME_HOST` | — | Required when `acme`. |
-| `CR_ACME_EMAIL` | — | Required when `acme`. |
-| `CR_TRUST_PROXY` | `false` | Honour `X-Forwarded-*` (proxy mode). |
-| `CR_LOG_LEVEL` | `info` | `debug` \| `info` \| `warn` \| `error`. |
-| `CR_DOCKER_SOCK` | `/var/run/docker.sock` | Empty disables container management. |
-| `CR_SESSION_HOURS` | `168` (7d) | Refresh-token lifetime. |
-| `CR_HOST_NAME` | (kernel hostname) | Display name in the SPA title bar. |
-| `CR_VERSION_CHECK` | `false` | Opt-in: daily release check on GitHub. |
-| `CR_DEV` | `false` | Plain HTTP + non-Secure cookies. Dev only.|
-
-## After install
-
-1. Read [`SECURITY.md`](./SECURITY.md) for the threat model and what's
- actually enforced today.
-2. Lock down inbound access. ControlRoom is not designed to be exposed
- directly to the public internet — put it behind a VPN, SSH tunnel, or
- reverse proxy with strict firewall rules.
-3. Enable two-factor authentication (Settings → Two-factor authentication)
- on every admin account.
+All three deployment shapes share the same TLS configuration. Set with
+`CR_TLS_MODE`.
+
+| Mode | Behaviour |
+|---|---|
+| `selfsigned` (default) | Generates an ECDSA P-256 self-signed cert at first boot under `$CR_DATA_DIR/tls/`. 10-year validity. SAN entries: `localhost`, the kernel hostname, every non-loopback IPv4 on the host. |
+| `acme` | Let's Encrypt via HTTP-01 (`golang.org/x/crypto/acme/autocert`). Requires `CR_ACME_HOST` and `CR_ACME_EMAIL`. The host must be reachable on **port 80** from the public internet during issuance and renewal. Cache lives in `$CR_DATA_DIR/acme/`. |
+| `proxy` | Bind plain HTTP and front with a TLS-terminating reverse proxy. Set `CR_ADDR=127.0.0.1:8080` (or similar) so only the proxy can reach the backend, and `CR_TRUST_PROXY=true` so `X-Forwarded-*` is honored for client IP / scheme. |
+
+For container deployments using `acme`, you'll also need to add `- "80:80"`
+to the `ports:` section (or remove `network_mode: host` and use port mapping).
+
+For the in-cluster Pod, `acme` is not the right model — terminate TLS at
+the ingress controller via cert-manager and run ControlRoom with
+`CR_TLS_MODE=proxy` (or keep `selfsigned` and let the ingress proxy to
+HTTPS).
+
+---
+
+## Kubernetes integration
+
+ControlRoom's K8s client (`internal/k8s/client.go`) tries connection
+sources in this order:
+
+1. **In-cluster** (`rest.InClusterConfig()`) — uses the projected
+ ServiceAccount token at `/var/run/secrets/kubernetes.io/serviceaccount/`.
+ Used automatically when ControlRoom runs as a Pod with a SA mounted.
+2. **`$KUBECONFIG`** — explicit override. Set this to point at any
+ kubeconfig file readable by the controlroom process.
+3. **`~/.kube/config`** — the standard local kubeconfig path.
+4. **`/etc/rancher/k3s/k3s.yaml`** — K3s default location.
+
+If none works, `/api/system/capabilities` reports `kubernetes: false` and
+the SPA hides the Kubernetes tab.
+
+### From a Docker container with a non-host network
+
+If you run the container with a custom bridge instead of `network_mode: host`,
+the kubeconfig's `server: https://127.0.0.1:6443` won't resolve to the host's
+loopback. Use `deploy/scripts/setup-host-kubeconfig.sh` to generate a copy
+with the server URL rewritten to a host IP that's in the cert SAN:
+
+```bash
+sudo deploy/scripts/setup-host-kubeconfig.sh
+```
+
+The script auto-detects the IP from `/etc/rancher/k3s/config.yaml` `node-ip`,
+falling back to `hostname -I`. Override with `HOST_IP=`.
+
+It writes `/var/lib/controlroom/kubeconfig` mode 0600 owned by 65532. Mount
+that into the container at `/etc/k3s/kubeconfig` and set `KUBECONFIG=/etc/k3s/kubeconfig`.
+
+---
+
+## After install — checklist
+
+1. **Read [`SECURITY.md`](./SECURITY.md)** for the threat model that matches
+ your deployment shape.
+2. **Lock down inbound access.** ControlRoom is not designed to be exposed
+ directly to the public internet. Put it behind a VPN (Tailscale,
+ WireGuard), an SSH tunnel, or a reverse proxy with strict firewall
+ rules.
+3. **Enable two-factor authentication** in Settings → Two-factor
+ authentication. Required on every admin account in shared
+ environments.
+4. **Set strong host passwords** for any user the Terminal tab will let
+ operators authenticate as. PAM is doing the work; weak host passwords
+ = weak terminal access.
+5. **Verify the audit log is writing**. Settings → Audit log. You should
+ see your own login event from a moment ago.
+
+## Next steps / further reading
+
+- [`docs/CONFIG.md`](./CONFIG.md) — every environment variable and which
+ tabs depend on each.
+- [`docs/SECURITY.md`](./SECURITY.md) — full threat model, per-shape
+ privilege scoping, what's enforced and what's a known gap.
+- [`SPEC.md`](../SPEC.md) — design spec.
+- [`MILESTONES.md`](../MILESTONES.md) — milestone tracker.
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index ef57f2d..6f53ada 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -1,14 +1,15 @@
-# Security model — v0.1
+# Security model
This document describes ControlRoom's threat model and the implementation
-choices made through v0.1. It tracks what's *actually shipped*; for the
+choices made through v0.2. It tracks what's *actually shipped*; for the
forward-looking design, see [`../SPEC.md`](../SPEC.md) §5.
## Threat model
ControlRoom is designed for a single trusted operator (or a small team) on
-a LAN, with optional remote access via a reverse proxy or VPN. **It is not
-designed to be exposed raw to the public internet.**
+a LAN, with optional remote access via a VPN (Tailscale / WireGuard / SSH
+tunnel) or a TLS-terminating reverse proxy with strict firewall rules.
+**It is not designed to be exposed raw to the public internet.**
In scope:
- Network attacker who can read TLS-protected requests but can't break
@@ -29,9 +30,9 @@ Out of scope:
| Surface | Implementation |
|---|---|
| Password hashing | bcrypt cost 12 (`internal/auth/password.go`). |
-| TOTP | RFC 6238, 6 digits / 30 s / SHA-1, ±1-step verify (`internal/auth/totp.go`). Optional. |
-| Access token | HS256 JWT, 15 min TTL. Key in `$DATA_DIR/jwt.key` (mode 0600). |
-| Refresh token | Opaque `.<32-byte-secret>`; only sha256 stored in `sessions.refresh_hash`. 7 day TTL. |
+| TOTP | RFC 6238, 6 digits / 30 s / SHA-1, ±1-step verify (`internal/auth/totp.go`). Optional but recommended. |
+| Access token | HS256 JWT, 15 min TTL. Key in `$CR_DATA_DIR/jwt.key` (mode 0600). |
+| Refresh token | Opaque `.<32-byte-secret>`; only sha256 stored in `sessions.refresh_hash`. 7 day TTL by default (`CR_SESSION_HOURS`). |
| Rotation | Every refresh creates a new session in the same `family_id` and revokes the parent. |
| Reuse detection | Presenting a revoked session that already has a child → entire family revoked (`internal/auth/sessions.go`). Logged-out childless sessions are simply invalid (no family burn). |
| Brute force | Per-IP token bucket (5/min) on login + per-username exponential backoff (30 s → 1 h cap). |
@@ -54,7 +55,14 @@ group only — login/refresh/setup are exempt because they have no prior session
to mirror from. `SameSite=Strict` on every cookie blocks cross-origin POSTs in
modern browsers.
-## Privilege scoping (bare-metal install)
+## Privilege scoping by deployment shape
+
+The privilege model differs significantly between the three deployment
+shapes — pick the one whose blast radius matches your threat model.
+
+### Shape A — Bare-metal (`deploy/install.sh`)
+
+The default path. Lowest privilege.
The systemd unit (`deploy/controlroom.service`) runs as a dedicated
`controlroom` system user with:
@@ -67,17 +75,118 @@ The systemd unit (`deploy/controlroom.service`) runs as a dedicated
Docker socket when present.
A tight `/etc/sudoers.d/controlroom` fragment NOPASSWDs only the exact
-commands ControlRoom needs (apt update/upgrade, systemctl reboot, the UFW
-verbs used by `/api/network/firewall/*`). Everything else still requires the
-operator to authenticate.
+commands ControlRoom needs:
+- `apt-get update`, `apt-get install -y --only-upgrade …`
+- `systemctl reboot`
+- The specific `ufw` verbs used by `/api/network/firewall/*`
+
+Everything else still requires the operator to authenticate. The fragment
+is validated with `visudo -c` before installation — `install.sh` aborts
+on parse failure.
+
+**Compromise impact**: an attacker who pwns the controlroom binary can:
+- Read the journal (group `adm`).
+- Talk to the Docker daemon (group `docker`).
+- Run the exact sudoers-allowlisted commands as root.
+- Read `$CR_DATA_DIR` (the JWT key, the audit log, the SQLite DB).
+
+They **cannot** spawn arbitrary root shells, edit `/etc/sudoers`, install
+packages outside the allowlisted apt verbs, or read other users' homes.
+
+### Shape B — Docker container (`deploy/docker-compose.yml`)
+
+The fat-privileged path. Highest blast radius. Read this section.
+
+The container runs as **root** (uid 0) with:
+- `network_mode: host` — shares the host's network namespace.
+- `pid: host` — shares the host's PID namespace; `nsenter -t 1` reaches
+ systemd as PID 1.
+- `cap_add: [SYS_ADMIN, SYS_PTRACE, NET_ADMIN]`.
+- `security_opt: [apparmor:unconfined, seccomp:unconfined]`.
+
+And bind-mounts:
+- `/var/run/docker.sock:ro` (the Docker daemon)
+- `/var/run/dbus/system_bus_socket:rw` (host systemd dbus)
+- `/run/log/journal`, `/var/log/journal`, `/etc/machine-id` (host journal)
+- `/etc/rancher/k3s/k3s.yaml:ro` (kubeconfig with cluster-admin creds)
+- `/var/cache/apt:rw`, `/etc/apt:ro`, `/var/lib/dpkg:ro` (host apt state)
+
+**Compromise impact**: an attacker who pwns the controlroom binary in this
+shape has **effective root on the host**. They can `nsenter -t 1` to PID 1
+and execute arbitrary commands as root, read the K3s admin kubeconfig and
+take over the cluster, write to `/var/cache/apt` and arrange for any apt
+operation to install attacker-controlled packages, talk to the Docker
+daemon and create privileged containers.
+
+This is the deliberate tradeoff for getting all host integrations working
+from a container without sidecars. **Use only on single-user homelabs
+where the operator already has root** and where the convenience is
+explicitly worth the loss of container isolation.
+
+If your threat model doesn't accept this, use Shape A.
-The fragment is validated with `visudo -c` before installation —
-`install.sh` aborts on parse failure.
+### Shape C — In-cluster Kubernetes Pod (`deploy/k8s/`)
+
+Cluster-only path. Constrained privilege within the cluster, no host
+reach.
+
+The Pod runs as:
+- `runAsNonRoot: true`, `runAsUser: 65532`, `runAsGroup: 65532`,
+ `fsGroup: 65532` (so the PVC is writable by the nonroot user).
+- `seccompProfile: RuntimeDefault`.
+- `allowPrivilegeEscalation: false`.
+- `readOnlyRootFilesystem: true` (binary doesn't need a writable root;
+ `/data` is the only writable mount, via the PVC).
+- `capabilities: { drop: [ALL] }`.
+
+Cluster permissions are scoped via the `controlroom-reader` `ClusterRole`.
+The role grew in tightly-scoped steps as Phases A → D landed; the current
+shape is in `deploy/k8s/rbac.yaml`. Headline:
+
+| Resource | Verbs | Why |
+|---|---|---|
+| `nodes`/`namespaces`/`pods`/`services`/`configmaps`/`events`/`endpoints` | `get,list,watch` | Read views (Phase A) |
+| `apps/deployments`/`statefulsets`/`daemonsets`/`replicasets` | `get,list,watch` | Workload reads (A) |
+| `pods/log` | `get` | Pod log streaming (B) |
+| `pods/exec` | `create` | Browser exec (D1) |
+| `pods` | `delete` | Pod rotate (C) |
+| `nodes` | `patch` | Cordon/uncordon (C) |
+| `apps/deployments`/`statefulsets`/`daemonsets` | `patch,update` | Restart annotation + YAML edit (C + D4) |
+| `apps/deployments/scale`/`statefulsets/scale` | `update` | Scale (C) |
+| `services`/`configmaps` | `update` | Edit (D2 + D4) |
+| `secrets` | `get,list` only | Read-only viewer (D3) |
+
+**Explicitly excluded** at every phase boundary:
+- `secrets/watch` — would keep a long-lived stream of every cluster
+ secret value open. Point-in-time reads + per-read audit (see below)
+ are the trade.
+- `delete` on `apps/*` — would let the SPA wipe a Deployment / STS / DS
+ outside whatever GitOps tooling owns it.
+- `persistentvolumes` (cluster-scoped) and `persistentvolumeclaims`.
+- `create` on most resources — Phase D4's manifest editor is
+ edit-existing-only; there's no create-from-nothing path.
+
+**Per-read audit** for sensitive resources:
+- `k8s.secret.read` on every Secret detail GET (success or failure),
+ detail = `{"key_count": N}` only — never key names or values.
+- `k8s.manifest.apply` / `k8s.manifest.dry_run` on every YAML edit, with
+ `bytes` of the edited YAML but never the body itself.
+
+**Compromise impact**: an attacker who pwns the controlroom binary in
+this shape can read everything the ClusterRole grants (cluster inventory,
+pod logs, configmaps, secret values), and can mutate the limited write
+surface (rollout restart, scale, delete pod, cordon, configmap update,
+manifest YAML update on the 5 editable kinds). They **cannot** create
+new resources, delete workloads, watch secrets, escape the Pod, or
+reach the host. The audit log records who did what to which target.
+
+This is still the lowest-risk shape vs. the fat-privileged container,
+just no longer "read-only" after Phase D.
## Audit
-Every privileged action writes a row to `audit_log` (best-effort; never fails
-the parent request):
+Every privileged action writes a row to `audit_log` (best-effort; never
+fails the parent request):
- Auth: login success/failure (with reason), logout, refresh, refresh-reuse,
TOTP enable/disable, password change.
@@ -85,40 +194,77 @@ the parent request):
- Services / containers: each lifecycle action with target + outcome.
- Updates: check / apply / reboot job starts.
- Firewall: rule add/delete + enable/disable with the rule spec.
-- Terminal: `session_start` and `session_end` (duration, bytes_in, bytes_out,
- exit code). Keystrokes are **never** recorded.
+- Terminal: `session_start` and `session_end` (duration, bytes_in,
+ bytes_out, exit code). **Keystrokes are never recorded.**
+
+For Shape B specifically, host-level auth events flow through PAM rather
+than ControlRoom's audit table:
+- The Terminal's `su -l ` invocation writes to `/var/log/auth.log`
+ via `pam_unix`. Lockouts (`pam_faillock` if configured) apply.
+- `apt-get` operations are logged in `/var/log/apt/history.log` and
+ `/var/log/apt/term.log`.
+
+This is intentional — the host's audit trail survives ControlRoom
+restarts and re-creates.
Retention is unbounded today — see "Known gaps" below.
## TLS
-Three modes:
+Three modes, set with `CR_TLS_MODE`. See [`INSTALL.md` → TLS modes](./INSTALL.md#tls-modes)
+for operator instructions.
-- `selfsigned` — generated at first boot under `$DATA_DIR/tls/`, ECDSA P-256,
- 10-year validity, SAN entries for `localhost`, the kernel hostname, and
- every non-loopback IPv4 on the host.
-- `acme` — Let's Encrypt via HTTP-01 (`golang.org/x/crypto/acme/autocert`),
- cache in `$DATA_DIR/acme/`. Port 80 must be reachable.
-- `proxy` — bind plain HTTP on loopback and front with a TLS-terminating
- reverse proxy.
+In every mode:
+- TLS 1.2+ only. Older protocols disabled.
+- Cipher suites exclude CBC / RC4 / 3DES; TLS 1.3 uses the std-lib's
+ fixed list.
+- ALPN advertises `http/1.1` only (no h2 in this version — h2 had a
+ goroutine leak interaction with the WS upgrader that's still being
+ triaged).
+- ECDSA P-256 keys preferred over RSA where we generate.
-In every mode, the cipher suite list excludes anything below TLS 1.2 and the
-deprecated CBC/RC4/3DES suites. TLS 1.3 uses the std-lib's fixed list.
+## Public-bind detection
-## Known gaps (post-v0.1)
+The SPA shows a destructive banner ("Public-looking address") when reached
+from an IP that doesn't look like:
+- RFC 1918 (10/8, 172.16/12, 192.168/16)
+- Link-local (169.254/16, fe80::/10)
+- IPv6 ULA (fc00::/7)
+- Loopback (127/8, ::1)
+- **RFC 6598 / Tailscale CGNAT (100.64.0.0/10)** — added so admins reaching
+ ControlRoom over Tailscale don't get a misleading warning.
+
+This is a heuristic on `window.location.hostname` — it's about the URL the
+operator dialed, not what the server is bound to. False negatives are
+possible (e.g. a bare hostname like `homelab.local`).
+
+## Known gaps (post-v0.2)
These are documented and tracked, not silently missing:
- **Settings persistence**: host display name and the version-check toggle
- are read from env only — no UI write yet. Comes with v0.2 alongside RBAC.
-- **API integration tests** for the auth/setup flows. Unit tests cover the
- security-critical pieces (rotation, reuse, password, TOTP, ratelimit
- validation).
-- **Audit log retention/rotation**. Grows unbounded today.
-- **HSTS / CSP headers**. Planned for the next polish pass.
-- **Netplan editing**. Read-only interfaces today.
-- **Public-bind detection** in the SPA is best-effort (RFC 1918 heuristic on
- the URL bar).
+ are read from env only — no UI write yet. Promotion to a settings table
+ comes with the v0.3 RBAC work.
+- **Audit log retention/rotation**: grows unbounded today.
+- **HSTS / CSP headers**: planned for the next polish pass.
+- **Netplan editing**: read-only interfaces today.
+- **TLS-via-cert-manager** in the in-cluster shape: the default
+ `deploy/k8s/ingress.yaml` leaves TLS as a TODO comment for the
+ operator's chosen ClusterIssuer.
+- **RBAC user roles**: `users.role` is stored but not enforced — every
+ authenticated user has admin authority across all tabs. Real
+ multi-user RBAC is a v0.3 item.
+- **Multi-cluster Kubernetes**: the K8s tab targets exactly one cluster
+ (whatever the in-cluster SA reaches or the kubeconfig's first context
+ points at). No context switcher.
+
+Closed in v0.2 (these were gaps in v0.1):
+- **K8s tab**: Phases A–D shipped — read-only inventory, detail drawers,
+ pod log streaming, pod exec, lifecycle actions (restart / scale / delete /
+ cordon), ConfigMap and Secret viewers, Monaco YAML editor.
+- **Public-bind detection**: now treats Tailscale CGNAT (100.64.0.0/10)
+ as private so admins reaching ControlRoom over Tailscale don't see a
+ misleading warning.
## Reporting
diff --git a/go.mod b/go.mod
index 378b4d1..74ff62f 100644
--- a/go.mod
+++ b/go.mod
@@ -1,18 +1,110 @@
module github.com/tm4rtin17/controlroom
-go 1.23
+go 1.25.0
require (
github.com/coreos/go-systemd/v22 v22.5.0
github.com/creack/pty v1.1.23
- github.com/docker/docker v27.3.1+incompatible
- github.com/go-playground/validator/v10 v10.22.1
- github.com/godbus/dbus/v5 v5.1.0
+ github.com/docker/docker v28.5.2+incompatible
github.com/gofiber/fiber/v2 v2.52.6
github.com/gofiber/websocket/v2 v2.2.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/pquerna/otp v1.4.0
github.com/rs/zerolog v1.33.0
- golang.org/x/crypto v0.28.0
+ golang.org/x/crypto v0.49.0
+ k8s.io/api v0.31.0
+ k8s.io/apimachinery v0.31.0
+ k8s.io/client-go v0.31.0
modernc.org/sqlite v1.33.1
)
+
+require (
+ github.com/Microsoft/go-winio v0.6.2 // indirect
+ github.com/andybalholm/brotli v1.1.0 // indirect
+ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/containerd/errdefs v1.0.0 // indirect
+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
+ github.com/containerd/log v0.1.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/distribution/reference v0.6.0 // indirect
+ github.com/docker/go-connections v0.7.0 // indirect
+ github.com/docker/go-units v0.5.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/emicklei/go-restful/v3 v3.11.0 // indirect
+ github.com/fasthttp/websocket v1.5.3 // indirect
+ github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/fxamacker/cbor/v2 v2.7.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/go-openapi/jsonpointer v0.19.6 // indirect
+ github.com/go-openapi/jsonreference v0.20.2 // indirect
+ github.com/go-openapi/swag v0.22.4 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/golang/protobuf v1.5.4 // indirect
+ github.com/google/gnostic-models v0.6.8 // indirect
+ github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/gofuzz v1.2.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/websocket v1.5.0 // indirect
+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+ github.com/imdario/mergo v0.3.6 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/compress v1.17.9 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/moby/docker-image-spec v1.3.1 // indirect
+ github.com/moby/spdystream v0.4.0 // indirect
+ github.com/moby/sys/atomicwriter v0.1.0 // indirect
+ github.com/moby/term v0.5.2 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/morikuni/aec v1.1.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/opencontainers/go-digest v1.0.0 // indirect
+ github.com/opencontainers/image-spec v1.1.1 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasthttp v1.51.0 // indirect
+ github.com/valyala/tcplisten v1.0.0 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
+ go.opentelemetry.io/otel v1.43.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
+ go.opentelemetry.io/otel/metric v1.43.0 // indirect
+ go.opentelemetry.io/otel/trace v1.43.0 // indirect
+ golang.org/x/net v0.52.0 // indirect
+ golang.org/x/oauth2 v0.21.0 // indirect
+ golang.org/x/sys v0.42.0 // indirect
+ golang.org/x/term v0.41.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ gotest.tools/v3 v3.5.2 // indirect
+ k8s.io/klog/v2 v2.130.1 // indirect
+ k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
+ k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
+ modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
+ modernc.org/libc v1.55.3 // indirect
+ modernc.org/mathutil v1.6.0 // indirect
+ modernc.org/memory v1.8.0 // indirect
+ modernc.org/strutil v1.2.0 // indirect
+ modernc.org/token v1.1.0 // indirect
+ sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
+ sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
+ sigs.k8s.io/yaml v1.4.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..3ecf877
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,324 @@
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
+github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
+github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
+github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
+github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
+github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
+github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
+github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
+github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
+github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
+github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
+github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
+github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
+github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
+github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
+github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
+github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
+github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
+github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
+github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
+github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
+github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
+github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM=
+github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
+github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/spdystream v0.4.0 h1:Vy79D6mHeJJjiPdFEL2yku1kl0chZpJfZcPpb16BRl8=
+github.com/moby/spdystream v0.4.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
+github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
+github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
+github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
+github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
+github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
+github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
+github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
+github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
+github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
+github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
+github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
+github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
+github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
+github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
+github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
+github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
+github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
+github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
+go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
+go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
+go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
+go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
+go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
+go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
+go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
+go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
+go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
+golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
+golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
+google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
+google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
+k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo=
+k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE=
+k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc=
+k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
+k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8=
+k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU=
+k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
+k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
+k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=
+k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
+k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
+k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
+modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
+modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
+modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
+modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
+modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
+modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
+modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
+modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
+modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
+modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
+modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
+sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
+sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
+sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
+sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
+sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
diff --git a/internal/api/auth/auth.go b/internal/api/auth/auth.go
index 90a798b..303f99a 100644
--- a/internal/api/auth/auth.go
+++ b/internal/api/auth/auth.go
@@ -185,15 +185,6 @@ func (d Deps) meHandler(c *fiber.Ctx) error {
// ---- 2FA management (post-login) ----
-// pendingEnrollments holds in-progress TOTP enrollments keyed by user id.
-// Stored only in memory so a restart cancels enrollment in flight; the user
-// just enrolls again. Keeps unverified secrets out of the DB.
-type pendingEnrollments struct {
- // (TODO: M9 polish — promote to a struct map with TTL eviction. For the
- // homelab single-user surface, the user id key + verify-then-store flow
- // is sufficient.)
-}
-
type totpEnrollResp struct {
Secret string `json:"secret"`
URI string `json:"uri"`
diff --git a/internal/api/k8s/k8s.go b/internal/api/k8s/k8s.go
new file mode 100644
index 0000000..39c1c40
--- /dev/null
+++ b/internal/api/k8s/k8s.go
@@ -0,0 +1,1216 @@
+// Package k8s serves /api/k8s/* — read-only cluster inspection plus write actions.
+// If Client is nil the module is disabled and every handler returns 503.
+package k8s
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/websocket/v2"
+ "github.com/rs/zerolog"
+ k8serrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/client-go/tools/remotecommand"
+ sigsyaml "sigs.k8s.io/yaml"
+
+ "github.com/tm4rtin17/controlroom/internal/api/middleware"
+ "github.com/tm4rtin17/controlroom/internal/k8s"
+ "github.com/tm4rtin17/controlroom/internal/store"
+)
+
+// Deps mirrors the pattern used by api/services and api/containers.
+type Deps struct {
+ Client *k8s.Client // nil → module disabled
+ DB *store.DB
+ Logger zerolog.Logger
+}
+
+const (
+ wsLogWriteWait = 5 * time.Second
+ wsLogReadWait = 70 * time.Second
+ wsLogPing = 30 * time.Second
+
+ tailDefault = 200
+ tailMax = 5000
+
+ wsExecWriteWait = 5 * time.Second
+ wsExecReadWait = 70 * time.Second
+ wsExecPingInterval = 30 * time.Second
+ wsExecIdleTimeout = 30 * time.Minute
+ wsExecIdleCheck = 15 * time.Second
+
+ execCmdMaxLen = 256
+ execSizeQueueCh = 8
+)
+
+// dns1123RE matches a DNS-1123 subdomain (k8s name and namespace format).
+var dns1123RE = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
+
+func validK8sName(s string) bool {
+ return len(s) > 0 && len(s) <= 253 && dns1123RE.MatchString(s)
+}
+
+// MountHTTP registers REST endpoints on the authenticated router group.
+func MountHTTP(authed fiber.Router, d Deps) {
+ g := authed.Group("/k8s")
+ g.Get("/nodes", d.nodesHandler)
+ g.Get("/nodes/:name", d.nodeDetailHandler)
+ g.Post("/nodes/:name/cordon", d.cordonNodeHandler)
+ g.Get("/namespaces", d.namespacesHandler)
+ g.Get("/workloads", d.workloadsHandler)
+ g.Get("/workloads/:namespace/:kind/:name", d.workloadDetailHandler)
+ g.Post("/workloads/:namespace/:kind/:name/restart", d.restartWorkloadHandler)
+ g.Post("/workloads/:namespace/:kind/:name/scale", d.scaleWorkloadHandler)
+ g.Get("/pods", d.podsHandler)
+ g.Get("/pods/:namespace/:name", d.podDetailHandler)
+ g.Delete("/pods/:namespace/:name", d.deletePodHandler)
+ g.Get("/services", d.servicesHandler)
+ g.Get("/services/:namespace/:name", d.serviceDetailHandler)
+ g.Get("/configmaps", d.listConfigMapsHandler)
+ g.Get("/configmaps/:namespace/:name", d.configMapDetailHandler)
+ g.Put("/configmaps/:namespace/:name", d.updateConfigMapHandler)
+ g.Get("/secrets", d.listSecretsHandler)
+ g.Get("/secrets/:namespace/:name", d.secretDetailHandler)
+ g.Get("/manifest", d.getManifestHandler)
+ g.Post("/manifest", d.applyManifestHandler)
+}
+
+// MountWS registers WebSocket routes on the ws group.
+func MountWS(wsGroup fiber.Router, d Deps) {
+ g := wsGroup.Group("/k8s")
+ g.Get("/pods/:namespace/:name/logs", websocket.New(d.podLogsWS, websocket.Config{HandshakeTimeout: 5 * time.Second}))
+ g.Get("/pods/:namespace/:name/exec", websocket.New(d.podExecWS, websocket.Config{HandshakeTimeout: 5 * time.Second}))
+}
+
+func (d Deps) requireClient() error {
+ if d.Client == nil {
+ return fiber.NewError(http.StatusServiceUnavailable, "kubernetes not available on this host")
+ }
+ return nil
+}
+
+type nodesResp struct {
+ Nodes []k8s.Node `json:"nodes"`
+}
+
+func (d Deps) nodesHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ nodes, err := d.Client.ListNodes(c.Context())
+ if err != nil {
+ return fiber.NewError(http.StatusInternalServerError, "list nodes: "+err.Error())
+ }
+ return c.JSON(nodesResp{Nodes: nodes})
+}
+
+func (d Deps) nodeDetailHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ name := c.Params("name")
+ if !validK8sName(name) {
+ return fiber.NewError(http.StatusBadRequest, "invalid node name")
+ }
+ detail, err := d.Client.GetNode(c.Context(), name)
+ if err != nil {
+ return fiber.NewError(http.StatusInternalServerError, "get node: "+err.Error())
+ }
+ return c.JSON(detail)
+}
+
+type namespacesResp struct {
+ Namespaces []k8s.Namespace `json:"namespaces"`
+}
+
+func (d Deps) namespacesHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ nss, err := d.Client.ListNamespaces(c.Context())
+ if err != nil {
+ return fiber.NewError(http.StatusInternalServerError, "list namespaces: "+err.Error())
+ }
+ return c.JSON(namespacesResp{Namespaces: nss})
+}
+
+type workloadsResp struct {
+ Workloads []k8s.Workload `json:"workloads"`
+}
+
+func (d Deps) workloadsHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ wls, err := d.Client.ListWorkloads(c.Context(), c.Query("namespace"))
+ if err != nil {
+ return fiber.NewError(http.StatusInternalServerError, "list workloads: "+err.Error())
+ }
+ return c.JSON(workloadsResp{Workloads: wls})
+}
+
+// validWorkloadKind accepts deployment, statefulset, daemonset (case-insensitive).
+func validWorkloadKind(kind string) bool {
+ switch strings.ToLower(kind) {
+ case "deployment", "statefulset", "daemonset":
+ return true
+ }
+ return false
+}
+
+func (d Deps) workloadDetailHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ namespace := c.Params("namespace")
+ kind := c.Params("kind")
+ name := c.Params("name")
+ if !validK8sName(namespace) {
+ return fiber.NewError(http.StatusBadRequest, "invalid namespace")
+ }
+ if !validWorkloadKind(kind) {
+ return fiber.NewError(http.StatusBadRequest, fmt.Sprintf("invalid workload kind %q: must be deployment, statefulset, or daemonset", kind))
+ }
+ if !validK8sName(name) {
+ return fiber.NewError(http.StatusBadRequest, "invalid workload name")
+ }
+ detail, err := d.Client.GetWorkload(c.Context(), namespace, kind, name)
+ if err != nil {
+ return fiber.NewError(http.StatusInternalServerError, "get workload: "+err.Error())
+ }
+ return c.JSON(detail)
+}
+
+type podsResp struct {
+ Pods []k8s.Pod `json:"pods"`
+}
+
+func (d Deps) podsHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ pods, err := d.Client.ListPods(c.Context(), c.Query("namespace"))
+ if err != nil {
+ return fiber.NewError(http.StatusInternalServerError, "list pods: "+err.Error())
+ }
+ return c.JSON(podsResp{Pods: pods})
+}
+
+func (d Deps) podDetailHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ namespace := c.Params("namespace")
+ name := c.Params("name")
+ if !validK8sName(namespace) {
+ return fiber.NewError(http.StatusBadRequest, "invalid namespace")
+ }
+ if !validK8sName(name) {
+ return fiber.NewError(http.StatusBadRequest, "invalid pod name")
+ }
+ detail, err := d.Client.GetPod(c.Context(), namespace, name)
+ if err != nil {
+ return fiber.NewError(http.StatusInternalServerError, "get pod: "+err.Error())
+ }
+ return c.JSON(detail)
+}
+
+type servicesResp struct {
+ Services []k8s.Service `json:"services"`
+}
+
+func (d Deps) servicesHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ svcs, err := d.Client.ListServices(c.Context(), c.Query("namespace"))
+ if err != nil {
+ return fiber.NewError(http.StatusInternalServerError, "list services: "+err.Error())
+ }
+ return c.JSON(servicesResp{Services: svcs})
+}
+
+func (d Deps) serviceDetailHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ namespace := c.Params("namespace")
+ name := c.Params("name")
+ if !validK8sName(namespace) {
+ return fiber.NewError(http.StatusBadRequest, "invalid namespace")
+ }
+ if !validK8sName(name) {
+ return fiber.NewError(http.StatusBadRequest, "invalid service name")
+ }
+ detail, err := d.Client.GetService(c.Context(), namespace, name)
+ if err != nil {
+ return fiber.NewError(http.StatusInternalServerError, "get service: "+err.Error())
+ }
+ return c.JSON(detail)
+}
+
+// ---- configmap handlers ----
+
+const cmSizeLimit = 1 << 20 // 1 MiB
+
+type configMapsResp struct {
+ ConfigMaps []k8s.ConfigMap `json:"configmaps"`
+}
+
+func (d Deps) listConfigMapsHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ cms, err := d.Client.ListConfigMaps(c.Context(), c.Query("namespace"))
+ if err != nil {
+ return fiber.NewError(http.StatusInternalServerError, "list configmaps: "+err.Error())
+ }
+ return c.JSON(configMapsResp{ConfigMaps: cms})
+}
+
+func (d Deps) configMapDetailHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ namespace := c.Params("namespace")
+ name := c.Params("name")
+ if !validK8sName(namespace) {
+ return fiber.NewError(http.StatusBadRequest, "invalid namespace")
+ }
+ if !validK8sName(name) {
+ return fiber.NewError(http.StatusBadRequest, "invalid configmap name")
+ }
+ detail, err := d.Client.GetConfigMap(c.Context(), namespace, name)
+ if err != nil {
+ if k8serrors.IsNotFound(err) {
+ return fiber.NewError(http.StatusNotFound, "configmap not found")
+ }
+ if k8serrors.IsForbidden(err) {
+ return fiber.NewError(http.StatusForbidden, "forbidden")
+ }
+ return fiber.NewError(http.StatusInternalServerError, "get configmap: "+err.Error())
+ }
+ return c.JSON(detail)
+}
+
+type updateConfigMapReq struct {
+ Data map[string]string `json:"data"`
+}
+
+func (d Deps) updateConfigMapHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ namespace := c.Params("namespace")
+ name := c.Params("name")
+ if !validK8sName(namespace) {
+ return fiber.NewError(http.StatusBadRequest, "invalid namespace")
+ }
+ if !validK8sName(name) {
+ return fiber.NewError(http.StatusBadRequest, "invalid configmap name")
+ }
+
+ var body updateConfigMapReq
+ if err := c.BodyParser(&body); err != nil {
+ return fiber.NewError(http.StatusBadRequest, "invalid request body")
+ }
+ if body.Data == nil {
+ return fiber.NewError(http.StatusBadRequest, "data is required")
+ }
+
+ // Validate each key and compute total size.
+ var totalSize int
+ for k, v := range body.Data {
+ if !k8s.ValidConfigMapKey(k) {
+ return fiber.NewError(http.StatusBadRequest, fmt.Sprintf("invalid configmap key %q: must match ^[a-zA-Z0-9._-]+$", k))
+ }
+ totalSize += len(k) + len(v)
+ }
+ if totalSize > cmSizeLimit {
+ return fiber.NewError(http.StatusRequestEntityTooLarge, fmt.Sprintf("data size %d bytes exceeds 1 MiB limit", totalSize))
+ }
+
+ err := d.Client.UpdateConfigMap(c.Context(), namespace, name, body.Data)
+ entry := store.AuditEntry{
+ IP: c.IP(),
+ Action: "k8s.configmap.update",
+ Target: namespace + "/" + name,
+ Outcome: "success",
+ Detail: map[string]any{"key_count": len(body.Data), "size_bytes": totalSize},
+ }
+ if u := middleware.CurrentUser(c); u != nil {
+ entry.UserID = u.ID
+ }
+ if err != nil {
+ entry.Outcome = "failure"
+ entry.Detail = map[string]any{"key_count": len(body.Data), "size_bytes": totalSize, "error": err.Error()}
+ _ = d.DB.WriteAudit(c.Context(), entry)
+ if k8serrors.IsNotFound(err) {
+ return fiber.NewError(http.StatusNotFound, "configmap not found")
+ }
+ if k8serrors.IsForbidden(err) {
+ return fiber.NewError(http.StatusForbidden, "forbidden")
+ }
+ if k8serrors.IsConflict(err) {
+ return fiber.NewError(http.StatusConflict, "configmap was modified by another process; please reload and retry")
+ }
+ return fiber.NewError(http.StatusInternalServerError, "update configmap: "+err.Error())
+ }
+ _ = d.DB.WriteAudit(c.Context(), entry)
+ return c.JSON(fiber.Map{"ok": true})
+}
+
+// ---- secret handlers ----
+
+type secretsResp struct {
+ Secrets []k8s.Secret `json:"secrets"`
+}
+
+func (d Deps) listSecretsHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ ns := c.Query("namespace")
+ if ns != "" && !validK8sName(ns) {
+ return fiber.NewError(http.StatusBadRequest, "invalid namespace")
+ }
+ secrets, err := d.Client.ListSecrets(c.Context(), ns)
+ if err != nil {
+ return fiber.NewError(http.StatusInternalServerError, "list secrets: "+err.Error())
+ }
+ return c.JSON(secretsResp{Secrets: secrets})
+}
+
+func (d Deps) secretDetailHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ namespace := c.Params("namespace")
+ name := c.Params("name")
+ if !validK8sName(namespace) {
+ return fiber.NewError(http.StatusBadRequest, "invalid namespace")
+ }
+ if !validK8sName(name) {
+ return fiber.NewError(http.StatusBadRequest, "invalid secret name")
+ }
+
+ detail, err := d.Client.GetSecret(c.Context(), namespace, name)
+ entry := store.AuditEntry{
+ IP: c.IP(),
+ Action: "k8s.secret.read",
+ Target: namespace + "/" + name,
+ }
+ if u := middleware.CurrentUser(c); u != nil {
+ entry.UserID = u.ID
+ }
+ if err != nil {
+ entry.Outcome = "failure"
+ entry.Detail = map[string]any{"error": err.Error()}
+ _ = d.DB.WriteAudit(c.Context(), entry)
+ if k8serrors.IsNotFound(err) {
+ return fiber.NewError(http.StatusNotFound, "secret not found")
+ }
+ if k8serrors.IsForbidden(err) {
+ return fiber.NewError(http.StatusForbidden, "forbidden")
+ }
+ return fiber.NewError(http.StatusInternalServerError, "get secret: "+err.Error())
+ }
+ entry.Outcome = "success"
+ entry.Detail = map[string]any{"key_count": len(detail.Secret.Keys)}
+ _ = d.DB.WriteAudit(c.Context(), entry)
+ return c.JSON(detail)
+}
+
+// ---- write action handlers ----
+
+func (d Deps) restartWorkloadHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ namespace := c.Params("namespace")
+ kind := c.Params("kind")
+ name := c.Params("name")
+ if !validK8sName(namespace) {
+ return fiber.NewError(http.StatusBadRequest, "invalid namespace")
+ }
+ if !validWorkloadKind(kind) {
+ return fiber.NewError(http.StatusBadRequest, fmt.Sprintf("invalid workload kind %q: must be deployment, statefulset, or daemonset", kind))
+ }
+ if !validK8sName(name) {
+ return fiber.NewError(http.StatusBadRequest, "invalid workload name")
+ }
+
+ normalKind := strings.ToLower(kind)
+ err := d.Client.RestartWorkload(c.Context(), namespace, normalKind, name)
+ entry := store.AuditEntry{
+ IP: c.IP(),
+ Action: "k8s." + normalKind + ".restart",
+ Target: namespace + "/" + name,
+ Outcome: "success",
+ }
+ if u := middleware.CurrentUser(c); u != nil {
+ entry.UserID = u.ID
+ }
+ if err != nil {
+ entry.Outcome = "failure"
+ entry.Detail = map[string]any{"error": err.Error()}
+ _ = d.DB.WriteAudit(c.Context(), entry)
+ return fiber.NewError(http.StatusInternalServerError, "restart failed: "+err.Error())
+ }
+ _ = d.DB.WriteAudit(c.Context(), entry)
+ return c.JSON(fiber.Map{"ok": true, "message": "rollout restarted"})
+}
+
+func (d Deps) scaleWorkloadHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ namespace := c.Params("namespace")
+ kind := c.Params("kind")
+ name := c.Params("name")
+ if !validK8sName(namespace) {
+ return fiber.NewError(http.StatusBadRequest, "invalid namespace")
+ }
+ normalKind := strings.ToLower(kind)
+ if normalKind == "daemonset" {
+ return fiber.NewError(http.StatusBadRequest, "daemonsets cannot be scaled")
+ }
+ if normalKind != "deployment" && normalKind != "statefulset" {
+ return fiber.NewError(http.StatusBadRequest, fmt.Sprintf("invalid workload kind %q: must be deployment or statefulset", kind))
+ }
+ if !validK8sName(name) {
+ return fiber.NewError(http.StatusBadRequest, "invalid workload name")
+ }
+
+ var body struct {
+ Replicas *int32 `json:"replicas"`
+ }
+ if err := c.BodyParser(&body); err != nil {
+ return fiber.NewError(http.StatusBadRequest, "invalid request body")
+ }
+ if body.Replicas == nil {
+ return fiber.NewError(http.StatusBadRequest, "replicas is required")
+ }
+ if *body.Replicas < 0 || *body.Replicas > 1000 {
+ return fiber.NewError(http.StatusBadRequest, "replicas must be between 0 and 1000")
+ }
+
+ newCount, err := d.Client.ScaleWorkload(c.Context(), namespace, normalKind, name, *body.Replicas)
+ entry := store.AuditEntry{
+ IP: c.IP(),
+ Action: "k8s." + normalKind + ".scale",
+ Target: namespace + "/" + name,
+ Outcome: "success",
+ Detail: map[string]any{"replicas": *body.Replicas},
+ }
+ if u := middleware.CurrentUser(c); u != nil {
+ entry.UserID = u.ID
+ }
+ if err != nil {
+ entry.Outcome = "failure"
+ entry.Detail = map[string]any{"replicas": *body.Replicas, "error": err.Error()}
+ _ = d.DB.WriteAudit(c.Context(), entry)
+ return fiber.NewError(http.StatusInternalServerError, "scale failed: "+err.Error())
+ }
+ _ = d.DB.WriteAudit(c.Context(), entry)
+ return c.JSON(fiber.Map{"ok": true, "replicas": newCount})
+}
+
+func (d Deps) deletePodHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ namespace := c.Params("namespace")
+ name := c.Params("name")
+ if !validK8sName(namespace) {
+ return fiber.NewError(http.StatusBadRequest, "invalid namespace")
+ }
+ if !validK8sName(name) {
+ return fiber.NewError(http.StatusBadRequest, "invalid pod name")
+ }
+
+ force := c.Query("force") == "true"
+ var gracePeriod *int64
+ if g := c.Query("grace"); g != "" {
+ var n int64
+ if _, err := fmt.Sscanf(g, "%d", &n); err != nil || n < 0 || n > 600 {
+ return fiber.NewError(http.StatusBadRequest, "grace must be an integer between 0 and 600")
+ }
+ gracePeriod = &n
+ }
+
+ detail := map[string]any{"force": force}
+ if gracePeriod != nil {
+ detail["grace"] = *gracePeriod
+ }
+
+ err := d.Client.DeletePod(c.Context(), namespace, name, gracePeriod, force)
+ entry := store.AuditEntry{
+ IP: c.IP(),
+ Action: "k8s.pod.delete",
+ Target: namespace + "/" + name,
+ Outcome: "success",
+ Detail: detail,
+ }
+ if u := middleware.CurrentUser(c); u != nil {
+ entry.UserID = u.ID
+ }
+ if err != nil {
+ entry.Outcome = "failure"
+ entry.Detail = map[string]any{"force": force, "error": err.Error()}
+ _ = d.DB.WriteAudit(c.Context(), entry)
+ return fiber.NewError(http.StatusInternalServerError, "delete pod failed: "+err.Error())
+ }
+ _ = d.DB.WriteAudit(c.Context(), entry)
+ return c.JSON(fiber.Map{"ok": true})
+}
+
+func (d Deps) cordonNodeHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ name := c.Params("name")
+ if !validK8sName(name) {
+ return fiber.NewError(http.StatusBadRequest, "invalid node name")
+ }
+
+ var body struct {
+ Cordoned *bool `json:"cordoned"`
+ }
+ if err := c.BodyParser(&body); err != nil {
+ return fiber.NewError(http.StatusBadRequest, "invalid request body")
+ }
+ if body.Cordoned == nil {
+ return fiber.NewError(http.StatusBadRequest, "cordoned is required")
+ }
+
+ action := "k8s.node.cordon"
+ if !*body.Cordoned {
+ action = "k8s.node.uncordon"
+ }
+
+ err := d.Client.CordonNode(c.Context(), name, *body.Cordoned)
+ entry := store.AuditEntry{
+ IP: c.IP(),
+ Action: action,
+ Target: name,
+ Outcome: "success",
+ Detail: map[string]any{"cordoned": *body.Cordoned},
+ }
+ if u := middleware.CurrentUser(c); u != nil {
+ entry.UserID = u.ID
+ }
+ if err != nil {
+ entry.Outcome = "failure"
+ entry.Detail = map[string]any{"cordoned": *body.Cordoned, "error": err.Error()}
+ _ = d.DB.WriteAudit(c.Context(), entry)
+ return fiber.NewError(http.StatusInternalServerError, action+" failed: "+err.Error())
+ }
+ _ = d.DB.WriteAudit(c.Context(), entry)
+ return c.JSON(fiber.Map{"ok": true, "cordoned": *body.Cordoned})
+}
+
+// ---- WebSocket ----
+
+type wsLogFrame struct {
+ Type string `json:"type"` // "line" | "error"
+ Stream string `json:"stream,omitempty"` // always "stdout"
+ Line string `json:"line,omitempty"`
+ Err string `json:"err,omitempty"`
+}
+
+func (d Deps) podLogsWS(c *websocket.Conn) {
+ defer func() { _ = c.Close() }()
+
+ if d.Client == nil {
+ _ = c.WriteJSON(wsLogFrame{Type: "error", Err: "kubernetes not available"})
+ return
+ }
+
+ namespace := c.Params("namespace")
+ name := c.Params("name")
+ if !validK8sName(namespace) {
+ _ = c.WriteJSON(wsLogFrame{Type: "error", Err: "invalid namespace"})
+ return
+ }
+ if !validK8sName(name) {
+ _ = c.WriteJSON(wsLogFrame{Type: "error", Err: "invalid pod name"})
+ return
+ }
+
+ container := c.Query("container")
+ // container is validated by the API server if provided; empty is fine (k8s picks the only one).
+
+ tail := int64(tailDefault)
+ if v := c.Query("tail"); v != "" {
+ var n int64
+ if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 {
+ if n > tailMax {
+ n = tailMax
+ }
+ tail = n
+ }
+ }
+
+ var sinceSeconds *int64
+ if v := c.Query("since"); v != "" {
+ if dur, err := time.ParseDuration(v); err == nil && dur > 0 {
+ secs := int64(dur.Seconds())
+ sinceSeconds = &secs
+ }
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ _ = c.SetReadDeadline(time.Now().Add(wsLogReadWait))
+ c.SetPongHandler(func(string) error { return c.SetReadDeadline(time.Now().Add(wsLogReadWait)) })
+
+ closed := make(chan struct{})
+ go func() {
+ defer close(closed)
+ for {
+ if _, _, err := c.ReadMessage(); err != nil {
+ return
+ }
+ }
+ }()
+
+ rc, err := d.Client.PodLogs(ctx, namespace, name, container, tail, sinceSeconds)
+ if err != nil {
+ _ = c.WriteJSON(wsLogFrame{Type: "error", Err: err.Error()})
+ return
+ }
+ defer rc.Close()
+
+ lines := make(chan string, 64)
+ go func() {
+ defer close(lines)
+ scanner := bufio.NewScanner(rc)
+ for scanner.Scan() {
+ select {
+ case lines <- scanner.Text():
+ case <-ctx.Done():
+ return
+ }
+ }
+ }()
+
+ ping := time.NewTicker(wsLogPing)
+ defer ping.Stop()
+
+ for {
+ select {
+ case <-closed:
+ return
+ case <-ping.C:
+ _ = c.SetWriteDeadline(time.Now().Add(wsLogWriteWait))
+ if err := c.WriteMessage(websocket.PingMessage, nil); err != nil {
+ return
+ }
+ case line, ok := <-lines:
+ if !ok {
+ return
+ }
+ _ = c.SetWriteDeadline(time.Now().Add(wsLogWriteWait))
+ if err := c.WriteJSON(wsLogFrame{Type: "line", Stream: "stdout", Line: line}); err != nil {
+ return
+ }
+ }
+ }
+}
+
+// ---- pod exec WebSocket ----
+
+// execInitMsg is the first text frame the client must send.
+type execInitMsg struct {
+ Rows int `json:"rows"`
+ Cols int `json:"cols"`
+ Container string `json:"container"`
+ Command []string `json:"command,omitempty"`
+}
+
+// execControlMsg is a subsequent text frame from the client.
+type execControlMsg struct {
+ Type string `json:"type"`
+ Rows int `json:"rows,omitempty"`
+ Cols int `json:"cols,omitempty"`
+}
+
+// execErrorFrame is a fatal text frame sent server → client before close.
+type execErrorFrame struct {
+ Type string `json:"type"`
+ Err string `json:"err"`
+}
+
+// wsSizeQueue implements remotecommand.TerminalSizeQueue via a buffered channel.
+type wsSizeQueue struct {
+ ch chan remotecommand.TerminalSize
+}
+
+func (q *wsSizeQueue) Next() *remotecommand.TerminalSize {
+ s, ok := <-q.ch
+ if !ok {
+ return nil
+ }
+ return &s
+}
+
+// wsWriter serialises binary writes to the WebSocket with a per-write deadline.
+// It is used for both stdout and stderr (merged).
+type wsExecWriter struct {
+ conn *websocket.Conn
+ bytesOut *atomic.Int64
+}
+
+func (w *wsExecWriter) Write(p []byte) (int, error) {
+ _ = w.conn.SetWriteDeadline(time.Now().Add(wsExecWriteWait))
+ if err := w.conn.WriteMessage(websocket.BinaryMessage, p); err != nil {
+ return 0, err
+ }
+ w.bytesOut.Add(int64(len(p)))
+ return len(p), nil
+}
+
+func clampExecDim(n int) int {
+ if n < 1 {
+ return 1
+ }
+ if n > 500 {
+ return 500
+ }
+ return n
+}
+
+func (d Deps) podExecWS(c *websocket.Conn) {
+ defer func() { _ = c.Close() }()
+
+ if d.Client == nil {
+ _ = c.WriteJSON(execErrorFrame{Type: "error", Err: "kubernetes not available"})
+ return
+ }
+
+ namespace := c.Params("namespace")
+ name := c.Params("name")
+ if !validK8sName(namespace) {
+ _ = c.WriteJSON(execErrorFrame{Type: "error", Err: "invalid namespace"})
+ return
+ }
+ if !validK8sName(name) {
+ _ = c.WriteJSON(execErrorFrame{Type: "error", Err: "invalid pod name"})
+ return
+ }
+
+ _ = c.SetReadDeadline(time.Now().Add(wsExecReadWait))
+ c.SetPongHandler(func(string) error { return c.SetReadDeadline(time.Now().Add(wsExecReadWait)) })
+
+ // Expect text init frame first.
+ mt, raw, err := c.ReadMessage()
+ if err != nil {
+ return
+ }
+ if mt != websocket.TextMessage {
+ _ = c.WriteJSON(execErrorFrame{Type: "error", Err: "expected JSON init frame first"})
+ return
+ }
+
+ var init execInitMsg
+ if err := json.Unmarshal(raw, &init); err != nil {
+ _ = c.WriteJSON(execErrorFrame{Type: "error", Err: "invalid init frame: " + err.Error()})
+ return
+ }
+
+ // Validate container (required, DNS-1123 label).
+ if init.Container == "" {
+ _ = c.WriteJSON(execErrorFrame{Type: "error", Err: "container is required"})
+ return
+ }
+ if !validK8sName(init.Container) {
+ _ = c.WriteJSON(execErrorFrame{Type: "error", Err: "invalid container name"})
+ return
+ }
+
+ // Validate / default command.
+ command := init.Command
+ if len(command) == 0 {
+ command = []string{"/bin/sh"}
+ } else {
+ for i, part := range command {
+ if part == "" {
+ _ = c.WriteJSON(execErrorFrame{Type: "error", Err: fmt.Sprintf("command[%d] is empty", i)})
+ return
+ }
+ if len(part) > execCmdMaxLen {
+ _ = c.WriteJSON(execErrorFrame{Type: "error", Err: fmt.Sprintf("command[%d] exceeds max length", i)})
+ return
+ }
+ }
+ }
+
+ rows := clampExecDim(init.Rows)
+ cols := clampExecDim(init.Cols)
+
+ // Audit user.
+ userID := int64(0)
+ if u, ok := c.Locals(middleware.CtxUser).(*store.User); ok && u != nil {
+ userID = u.ID
+ }
+ startedAt := time.Now()
+
+ _ = d.DB.WriteAudit(context.Background(), store.AuditEntry{
+ UserID: userID,
+ IP: c.RemoteAddr().String(),
+ Action: "k8s.pod.exec.start",
+ Target: namespace + "/" + name,
+ Outcome: "success",
+ Detail: map[string]any{"container": init.Container, "command": command},
+ })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ // Byte counters.
+ var bytesIn, bytesOut atomic.Int64
+
+ // Stdin pipe: WS read loop → stdinW → SPDY.
+ stdinR, stdinW := io.Pipe()
+
+ // Size queue.
+ sq := &wsSizeQueue{ch: make(chan remotecommand.TerminalSize, execSizeQueueCh)}
+ // Seed with initial size.
+ sq.ch <- remotecommand.TerminalSize{Width: uint16(cols), Height: uint16(rows)}
+
+ // stdout/stderr writer.
+ writer := &wsExecWriter{conn: c, bytesOut: &bytesOut}
+
+ // Launch SPDY exec in background.
+ execDone := make(chan error, 1)
+ go func() {
+ execDone <- d.Client.PodExec(ctx, namespace, name, init.Container, command, stdinR, writer, writer, sq)
+ _ = stdinR.Close()
+ }()
+
+ // Activity tracker for idle timeout.
+ lastActivity := time.Now()
+ activityCh := make(chan struct{}, 1)
+ bumpActivity := func() {
+ lastActivity = time.Now()
+ select {
+ case activityCh <- struct{}{}:
+ default:
+ }
+ }
+
+ // Idle / ping goroutine.
+ stopIdle := make(chan struct{})
+ go func() {
+ defer close(stopIdle)
+ ticker := time.NewTicker(wsExecIdleCheck)
+ ping := time.NewTicker(wsExecPingInterval)
+ defer ticker.Stop()
+ defer ping.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-activityCh:
+ continue
+ case <-ping.C:
+ _ = c.SetWriteDeadline(time.Now().Add(wsExecWriteWait))
+ if err := c.WriteMessage(websocket.PingMessage, nil); err != nil {
+ cancel()
+ return
+ }
+ case <-ticker.C:
+ if time.Since(lastActivity) > wsExecIdleTimeout {
+ _ = c.SetWriteDeadline(time.Now().Add(wsExecWriteWait))
+ _ = c.WriteJSON(execErrorFrame{Type: "error", Err: "idle timeout"})
+ cancel()
+ return
+ }
+ }
+ }
+ }()
+
+ // WS read loop: binary → stdin, text → resize.
+ var readLoopErr error
+ for {
+ mt, data, err := c.ReadMessage()
+ if err != nil {
+ readLoopErr = err
+ break
+ }
+ bumpActivity()
+ switch mt {
+ case websocket.BinaryMessage:
+ bytesIn.Add(int64(len(data)))
+ if _, err := stdinW.Write(data); err != nil {
+ goto done
+ }
+ case websocket.TextMessage:
+ var ctrl execControlMsg
+ if jsonErr := json.Unmarshal(data, &ctrl); jsonErr != nil {
+ continue
+ }
+ if ctrl.Type == "resize" {
+ r := clampExecDim(ctrl.Rows)
+ col := clampExecDim(ctrl.Cols)
+ select {
+ case sq.ch <- remotecommand.TerminalSize{Width: uint16(col), Height: uint16(r)}:
+ default:
+ }
+ }
+ }
+ }
+done:
+ // Cancel context and close stdin pipe so SPDY and size queue both unblock.
+ cancel()
+ _ = stdinW.Close()
+ close(sq.ch)
+
+ // Wait for exec to finish; capture any error to decide if we send an error frame.
+ execErr := <-execDone
+
+ <-stopIdle
+
+ // Only send an error frame if exec failed (not a normal shell exit) and the
+ // read loop didn't already detect a closed WS.
+ if execErr != nil && readLoopErr == nil {
+ _ = c.SetWriteDeadline(time.Now().Add(wsExecWriteWait))
+ _ = c.WriteJSON(execErrorFrame{Type: "error", Err: execErr.Error()})
+ }
+
+ _ = d.DB.WriteAudit(context.Background(), store.AuditEntry{
+ UserID: userID,
+ IP: c.RemoteAddr().String(),
+ Action: "k8s.pod.exec.end",
+ Target: namespace + "/" + name,
+ Outcome: "success",
+ Detail: map[string]any{
+ "container": init.Container,
+ "command": command,
+ "duration_ms": time.Since(startedAt).Milliseconds(),
+ "bytes_in": bytesIn.Load(),
+ "bytes_out": bytesOut.Load(),
+ },
+ })
+}
+
+// ---- manifest handlers ----
+
+// validManifestKind checks the kind query param is in the editable allowlist.
+func validManifestKind(kind string) bool {
+ switch strings.ToLower(kind) {
+ case "deployment", "statefulset", "daemonset", "service", "configmap":
+ return true
+ }
+ return false
+}
+
+type manifestResp struct {
+ YAML string `json:"yaml"`
+ ResourceVersion string `json:"resource_version"`
+ GVK struct {
+ Group string `json:"group"`
+ Version string `json:"version"`
+ Kind string `json:"kind"`
+ } `json:"gvk"`
+}
+
+func (d Deps) getManifestHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+ kind := c.Query("kind")
+ namespace := c.Query("namespace")
+ name := c.Query("name")
+
+ if !validManifestKind(kind) {
+ return fiber.NewError(http.StatusBadRequest, fmt.Sprintf("invalid kind %q: must be deployment, statefulset, daemonset, service, or configmap", kind))
+ }
+ if !validK8sName(namespace) {
+ return fiber.NewError(http.StatusBadRequest, "invalid namespace")
+ }
+ if !validK8sName(name) {
+ return fiber.NewError(http.StatusBadRequest, "invalid name")
+ }
+
+ yamlOut, rv, gvk, err := d.Client.GetManifest(c.Context(), kind, namespace, name)
+ if err != nil {
+ var kindErr k8s.ErrManifestKind
+ if errors.As(err, &kindErr) {
+ return fiber.NewError(http.StatusBadRequest, kindErr.Error())
+ }
+ if errors.Is(err, k8s.ErrManifestNotFound) {
+ return fiber.NewError(http.StatusNotFound, "resource not found")
+ }
+ if errors.Is(err, k8s.ErrManifestForbidden) {
+ return fiber.NewError(http.StatusForbidden, "forbidden")
+ }
+ return fiber.NewError(http.StatusInternalServerError, "get manifest: "+err.Error())
+ }
+
+ resp := manifestResp{
+ YAML: yamlOut,
+ ResourceVersion: rv,
+ }
+ resp.GVK.Group = gvk.Group
+ resp.GVK.Version = gvk.Version
+ resp.GVK.Kind = gvk.Kind
+ return c.JSON(resp)
+}
+
+type applyReq struct {
+ YAML string `json:"yaml"`
+ ResourceVersion string `json:"resource_version"`
+ DryRun bool `json:"dry_run"`
+}
+
+type applyResp struct {
+ OK bool `json:"ok"`
+ ResourceVersion string `json:"resource_version"`
+ Warnings []string `json:"warnings"`
+ DryRun bool `json:"dry_run"`
+}
+
+func (d Deps) applyManifestHandler(c *fiber.Ctx) error {
+ if err := d.requireClient(); err != nil {
+ return err
+ }
+
+ var body applyReq
+ if err := c.BodyParser(&body); err != nil {
+ return fiber.NewError(http.StatusBadRequest, "invalid request body")
+ }
+ if body.YAML == "" {
+ return fiber.NewError(http.StatusBadRequest, "yaml is required")
+ }
+ if body.ResourceVersion == "" {
+ return fiber.NewError(http.StatusBadRequest, "resource_version is required")
+ }
+
+ action := "k8s.manifest.apply"
+ if body.DryRun {
+ action = "k8s.manifest.dry_run"
+ }
+
+ // Extract kind/ns/name from YAML for the handler.
+ parsedKind, parsedNS, parsedName, parseErr := extractManifestMeta(body.YAML)
+ if parseErr != nil {
+ return fiber.NewError(http.StatusBadRequest, "invalid YAML: "+parseErr.Error())
+ }
+ if !validManifestKind(parsedKind) {
+ return fiber.NewError(http.StatusBadRequest, fmt.Sprintf("kind %q is not in the editable allowlist", parsedKind))
+ }
+ if !validK8sName(parsedNS) {
+ return fiber.NewError(http.StatusBadRequest, "invalid namespace in YAML metadata")
+ }
+ if !validK8sName(parsedName) {
+ return fiber.NewError(http.StatusBadRequest, "invalid name in YAML metadata")
+ }
+
+ target := parsedKind + "/" + parsedNS + "/" + parsedName
+
+ newRV, warnings, err := d.Client.ApplyManifest(
+ c.Context(),
+ parsedKind, parsedNS, parsedName,
+ body.YAML, body.ResourceVersion, body.DryRun,
+ )
+
+ entry := store.AuditEntry{
+ IP: c.IP(),
+ Action: action,
+ Target: target,
+ Outcome: "success",
+ Detail: map[string]any{
+ "kind": parsedKind,
+ "namespace": parsedNS,
+ "name": parsedName,
+ "bytes": len(body.YAML),
+ "dry_run": body.DryRun,
+ },
+ }
+ if u := middleware.CurrentUser(c); u != nil {
+ entry.UserID = u.ID
+ }
+
+ if err != nil {
+ entry.Outcome = "failure"
+ entry.Detail = map[string]any{
+ "kind": parsedKind,
+ "namespace": parsedNS,
+ "name": parsedName,
+ "bytes": len(body.YAML),
+ "dry_run": body.DryRun,
+ "error": err.Error(),
+ }
+ _ = d.DB.WriteAudit(c.Context(), entry)
+
+ var kindErr k8s.ErrManifestKind
+ var mismatchErr k8s.ErrManifestMismatch
+ var invalidErr k8s.ErrManifestInvalid
+ switch {
+ case errors.As(err, &kindErr):
+ return fiber.NewError(http.StatusBadRequest, kindErr.Error())
+ case errors.As(err, &mismatchErr):
+ return fiber.NewError(http.StatusBadRequest, mismatchErr.Error())
+ case errors.As(err, &invalidErr):
+ return fiber.NewError(http.StatusUnprocessableEntity, invalidErr.Message)
+ case errors.Is(err, k8s.ErrManifestConflict):
+ return fiber.NewError(http.StatusConflict, "conflict — resource was modified by another process; reload and retry")
+ case errors.Is(err, k8s.ErrManifestNotFound):
+ return fiber.NewError(http.StatusNotFound, "resource not found")
+ case errors.Is(err, k8s.ErrManifestForbidden):
+ return fiber.NewError(http.StatusForbidden, "forbidden")
+ }
+ return fiber.NewError(http.StatusInternalServerError, "apply manifest: "+err.Error())
+ }
+
+ _ = d.DB.WriteAudit(c.Context(), entry)
+
+ rv := newRV
+ if body.DryRun {
+ rv = body.ResourceVersion
+ }
+
+ return c.JSON(applyResp{
+ OK: true,
+ ResourceVersion: rv,
+ Warnings: warnings,
+ DryRun: body.DryRun,
+ })
+}
+
+// extractManifestMeta parses the YAML document minimally to obtain kind,
+// metadata.namespace, and metadata.name without the full unstructured decode.
+func extractManifestMeta(yamlText string) (kind, namespace, name string, err error) {
+ var doc struct {
+ Kind string `json:"kind"`
+ Metadata struct {
+ Name string `json:"name"`
+ Namespace string `json:"namespace"`
+ } `json:"metadata"`
+ }
+ if unmarshalErr := sigsyaml.Unmarshal([]byte(yamlText), &doc); unmarshalErr != nil {
+ return "", "", "", unmarshalErr
+ }
+ if doc.Kind == "" {
+ return "", "", "", fmt.Errorf("kind is missing")
+ }
+ if doc.Metadata.Name == "" {
+ return "", "", "", fmt.Errorf("metadata.name is missing")
+ }
+ if doc.Metadata.Namespace == "" {
+ return "", "", "", fmt.Errorf("metadata.namespace is missing")
+ }
+ return strings.ToLower(doc.Kind), doc.Metadata.Namespace, doc.Metadata.Name, nil
+}
diff --git a/internal/api/logs/logs.go b/internal/api/logs/logs.go
index 116ccf3..9789127 100644
--- a/internal/api/logs/logs.go
+++ b/internal/api/logs/logs.go
@@ -62,6 +62,9 @@ type queryResp struct {
}
func (d Deps) queryHandler(c *fiber.Ctx) error {
+ if !logs.Available() {
+ return fiber.NewError(http.StatusServiceUnavailable, journalUnavailableMsg)
+ }
entries, err := logs.Query(c.Context(), filterFromQuery(c))
if err != nil {
return fiber.NewError(http.StatusInternalServerError, "journal query: "+err.Error())
@@ -69,6 +72,10 @@ func (d Deps) queryHandler(c *fiber.Ctx) error {
return c.JSON(queryResp{Entries: entries})
}
+// journalUnavailableMsg is shown to the operator when journalctl can't be
+// invoked (e.g. distroless container). The SPA surfaces it verbatim.
+const journalUnavailableMsg = "journal logs unavailable on this host (journalctl not found) — switch to Containers or run ControlRoom on bare metal"
+
// ---- live tail ----
// queryFrom is a tiny shim because we can't reuse fiber.Ctx here; we read
@@ -90,6 +97,11 @@ func queryFromConn(c *websocket.Conn) logs.Filter {
func (d Deps) tailWS(c *websocket.Conn) {
defer func() { _ = c.Close() }()
+ if !logs.Available() {
+ _ = c.WriteJSON(fiber.Map{"type": "error", "err": journalUnavailableMsg})
+ return
+ }
+
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
diff --git a/internal/api/router.go b/internal/api/router.go
index 626e3fb..9b9fbe5 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -12,6 +12,7 @@ import (
authapi "github.com/tm4rtin17/controlroom/internal/api/auth"
containersapi "github.com/tm4rtin17/controlroom/internal/api/containers"
+ k8sapi "github.com/tm4rtin17/controlroom/internal/api/k8s"
logsapi "github.com/tm4rtin17/controlroom/internal/api/logs"
"github.com/tm4rtin17/controlroom/internal/api/middleware"
networkapi "github.com/tm4rtin17/controlroom/internal/api/network"
@@ -27,6 +28,7 @@ import (
"github.com/tm4rtin17/controlroom/internal/config"
"github.com/tm4rtin17/controlroom/internal/docker"
"github.com/tm4rtin17/controlroom/internal/jobs"
+ "github.com/tm4rtin17/controlroom/internal/k8s"
"github.com/tm4rtin17/controlroom/internal/store"
"github.com/tm4rtin17/controlroom/internal/systemd"
"github.com/tm4rtin17/controlroom/internal/web"
@@ -45,6 +47,7 @@ type Deps struct {
Aggregator *collectors.Aggregator
SystemD systemd.Client // nil → /api/services returns 503
Docker docker.Client // nil → /api/containers returns 503
+ K8s *k8s.Client // nil → /api/k8s returns 503
Jobs *jobs.Runner
}
@@ -98,7 +101,13 @@ func NewRouter(d Deps) *fiber.App {
)
authapi.MountAuthenticated(guarded, authDeps)
- systemDeps := systemapi.Deps{Aggregator: d.Aggregator, Logger: d.Logger}
+ systemDeps := systemapi.Deps{
+ Aggregator: d.Aggregator,
+ Logger: d.Logger,
+ SystemD: d.SystemD,
+ Docker: d.Docker,
+ K8s: d.K8s,
+ }
systemapi.MountHTTP(guarded, systemDeps)
servicesDeps := servicesapi.Deps{Client: d.SystemD, DB: d.DB, Logger: d.Logger}
@@ -113,6 +122,7 @@ func NewRouter(d Deps) *fiber.App {
networkapi.MountHTTP(guarded, networkapi.Deps{DB: d.DB, Logger: d.Logger})
logsapi.MountHTTP(guarded, logsapi.Deps{Logger: d.Logger})
+ k8sapi.MountHTTP(guarded, k8sapi.Deps{Client: d.K8s, DB: d.DB, Logger: d.Logger})
settingsapi.MountHTTP(guarded, settingsapi.Deps{Cfg: d.Cfg, DB: d.DB})
// Catch-all 404 for unknown /api paths.
@@ -127,9 +137,15 @@ func NewRouter(d Deps) *fiber.App {
systemapi.MountWS(wsGroup, systemDeps)
servicesapi.MountWS(wsGroup, servicesDeps)
containersapi.MountWS(wsGroup, containersDeps)
- terminalapi.MountWS(wsGroup, terminalapi.Deps{DB: d.DB, Logger: d.Logger})
+ terminalapi.MountWS(wsGroup, terminalapi.Deps{
+ DB: d.DB,
+ Logger: d.Logger,
+ HostShell: d.Cfg.HostShell,
+ TerminalLogin: d.Cfg.TerminalLogin,
+ })
updatesapi.MountWS(wsGroup, updatesDeps)
logsapi.MountWS(wsGroup, logsapi.Deps{Logger: d.Logger})
+ k8sapi.MountWS(wsGroup, k8sapi.Deps{Client: d.K8s, Logger: d.Logger})
wsGroup.All("/*", notFound)
diff --git a/internal/api/system/system.go b/internal/api/system/system.go
index 8bd8a56..b8336f8 100644
--- a/internal/api/system/system.go
+++ b/internal/api/system/system.go
@@ -14,11 +14,21 @@ import (
"github.com/rs/zerolog"
"github.com/tm4rtin17/controlroom/internal/collectors"
+ "github.com/tm4rtin17/controlroom/internal/docker"
+ "github.com/tm4rtin17/controlroom/internal/k8s"
+ "github.com/tm4rtin17/controlroom/internal/logs"
+ "github.com/tm4rtin17/controlroom/internal/systemd"
)
type Deps struct {
Aggregator *collectors.Aggregator
Logger zerolog.Logger
+ // SystemD, Docker, and K8s are nil-or-set; mirror the same wiring used by
+ // the services / containers / k8s handlers so /api/system/capabilities can
+ // report which features the SPA should expose.
+ SystemD systemd.Client
+ Docker docker.Client
+ K8s *k8s.Client
}
// MountHTTP registers the REST endpoint. Caller is responsible for applying
@@ -26,6 +36,26 @@ type Deps struct {
func MountHTTP(authed fiber.Router, d Deps) {
g := authed.Group("/system")
g.Get("/overview", d.overviewHandler)
+ g.Get("/capabilities", d.capabilitiesHandler)
+}
+
+// capabilitiesResp tells the SPA which feature areas have a working backend
+// on this host. The frontend uses it to hide nav entries that would otherwise
+// dead-end with a 503 (e.g. the Services tab in container deployments).
+type capabilitiesResp struct {
+ Systemd bool `json:"systemd"`
+ Docker bool `json:"docker"`
+ Journal bool `json:"journal"`
+ Kubernetes bool `json:"kubernetes"`
+}
+
+func (d Deps) capabilitiesHandler(c *fiber.Ctx) error {
+ return c.JSON(capabilitiesResp{
+ Systemd: d.SystemD != nil,
+ Docker: d.Docker != nil,
+ Journal: logs.Available(),
+ Kubernetes: d.K8s != nil,
+ })
}
// MountWS registers the WebSocket route. Caller applies auth (cookie-based)
diff --git a/internal/api/terminal/terminal.go b/internal/api/terminal/terminal.go
index 08a6dd4..3e7a188 100644
--- a/internal/api/terminal/terminal.go
+++ b/internal/api/terminal/terminal.go
@@ -34,7 +34,9 @@ type Deps struct {
Logger zerolog.Logger
// IdleTimeout closes a session after this much inactivity (no read or
// write). Zero falls back to defaultIdleTimeout.
- IdleTimeout time.Duration
+ IdleTimeout time.Duration
+ HostShell bool // passed through from cfg.HostShell; wraps shell in nsenter
+ TerminalLogin bool // passed through from cfg.TerminalLogin; spawn login(1) for PAM auth
}
const (
@@ -96,9 +98,11 @@ func (d Deps) handler(c *websocket.Conn) {
sessionID := mintSessionID()
sess, err := pty.New(sessionID, pty.Options{
- Shell: init.Shell,
- Rows: init.Rows,
- Cols: init.Cols,
+ Shell: init.Shell,
+ Rows: init.Rows,
+ Cols: init.Cols,
+ HostShell: d.HostShell,
+ LoginMode: d.TerminalLogin,
})
if err != nil {
_ = c.WriteJSON(errorFrame{Type: "error", Err: err.Error()})
diff --git a/internal/config/config.go b/internal/config/config.go
index 895e9d7..550489b 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -34,6 +34,8 @@ type Config struct {
HostName string
VersionCheck bool
DevMode bool
+ HostShell bool // CR_HOST_SHELL — wrap terminal in nsenter -t 1 to enter host namespaces
+ TerminalLogin bool // CR_TERMINAL_LOGIN — spawn /bin/login (PAM auth) instead of a bare shell. Requires HostShell.
}
func Load() (*Config, error) {
@@ -50,6 +52,8 @@ func Load() (*Config, error) {
HostName: os.Getenv("CR_HOST_NAME"),
VersionCheck: envBool("CR_VERSION_CHECK", false),
DevMode: envBool("CR_DEV", false),
+ HostShell: envBool("CR_HOST_SHELL", false),
+ TerminalLogin: envBool("CR_TERMINAL_LOGIN", false),
}
if err := c.Validate(); err != nil {
return nil, err
diff --git a/internal/docker/client.go b/internal/docker/client.go
index 05f1b20..281fd93 100644
--- a/internal/docker/client.go
+++ b/internal/docker/client.go
@@ -53,6 +53,10 @@ func (c *DockerClient) List(ctx context.Context, opts ListOptions) ([]Container,
}
out := make([]Container, 0, len(summaries))
for _, s := range summaries {
+ labels := s.Labels
+ if labels == nil {
+ labels = map[string]string{}
+ }
c := Container{
ID: shortID(s.ID),
Name: primaryName(s.Names),
@@ -60,10 +64,11 @@ func (c *DockerClient) List(ctx context.Context, opts ListOptions) ([]Container,
State: s.State,
Status: s.Status,
CreatedAt: time.Unix(s.Created, 0),
- Labels: s.Labels,
+ Labels: labels,
+ Ports: []Port{},
}
- c.ComposeProject = s.Labels["com.docker.compose.project"]
- c.ComposeService = s.Labels["com.docker.compose.service"]
+ c.ComposeProject = labels["com.docker.compose.project"]
+ c.ComposeService = labels["com.docker.compose.service"]
for _, p := range s.Ports {
c.Ports = append(c.Ports, Port{
IP: p.IP,
@@ -86,6 +91,21 @@ func (c *DockerClient) Inspect(ctx context.Context, id string) (*ContainerDetail
startedAt, _ := time.Parse(time.RFC3339Nano, j.State.StartedAt)
finishedAt, _ := time.Parse(time.RFC3339Nano, j.State.FinishedAt)
+ cfgLabels := j.Config.Labels
+ if cfgLabels == nil {
+ cfgLabels = map[string]string{}
+ }
+ cmd := j.Config.Entrypoint
+ if cmd == nil {
+ cmd = []string{}
+ }
+ if j.Config.Cmd != nil {
+ cmd = append(cmd, j.Config.Cmd...)
+ }
+ env := j.Config.Env
+ if env == nil {
+ env = []string{}
+ }
d := &ContainerDetail{
Container: Container{
ID: shortID(j.ID),
@@ -94,19 +114,19 @@ func (c *DockerClient) Inspect(ctx context.Context, id string) (*ContainerDetail
State: j.State.Status,
Status: j.State.Status,
CreatedAt: createdAt,
- Labels: j.Config.Labels,
+ Labels: cfgLabels,
+ Ports: []Port{},
},
- Command: j.Config.Entrypoint,
- Env: j.Config.Env,
+ Command: cmd,
+ Env: env,
+ Mounts: []Mount{},
+ Networks: []NetworkAttach{},
StartedAt: startedAt,
FinishedAt: finishedAt,
ExitCode: j.State.ExitCode,
}
- if j.Config.Cmd != nil {
- d.Command = append(d.Command, j.Config.Cmd...)
- }
- d.ComposeProject = j.Config.Labels["com.docker.compose.project"]
- d.ComposeService = j.Config.Labels["com.docker.compose.service"]
+ d.ComposeProject = cfgLabels["com.docker.compose.project"]
+ d.ComposeService = cfgLabels["com.docker.compose.service"]
if j.HostConfig != nil {
d.RestartPolicy = string(j.HostConfig.RestartPolicy.Name)
}
diff --git a/internal/docker/fake.go b/internal/docker/fake.go
index d0bff0f..9c668d8 100644
--- a/internal/docker/fake.go
+++ b/internal/docker/fake.go
@@ -95,7 +95,6 @@ func (f *Fake) transition(id, state string, started, finished time.Time) error {
return errors.New("not found")
}
c.State = state
- c.Container.State = state
if !started.IsZero() {
c.StartedAt = started
c.FinishedAt = time.Time{}
diff --git a/internal/k8s/actions.go b/internal/k8s/actions.go
new file mode 100644
index 0000000..3aca37f
--- /dev/null
+++ b/internal/k8s/actions.go
@@ -0,0 +1,110 @@
+package k8s
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+)
+
+// RestartWorkload triggers a controller-driven rollout by patching the pod
+// template annotation — the same mechanism as `kubectl rollout restart`.
+// kind is case-insensitive: deployment | statefulset | daemonset.
+func (c *Client) RestartWorkload(ctx context.Context, namespace, kind, name string) error {
+ restartedAt := time.Now().UTC().Format(time.RFC3339)
+ patch := map[string]any{
+ "spec": map[string]any{
+ "template": map[string]any{
+ "metadata": map[string]any{
+ "annotations": map[string]any{
+ "kubectl.kubernetes.io/restartedAt": restartedAt,
+ },
+ },
+ },
+ },
+ }
+ raw, err := json.Marshal(patch)
+ if err != nil {
+ return fmt.Errorf("marshal patch: %w", err)
+ }
+
+ switch strings.ToLower(kind) {
+ case "deployment":
+ _, err = c.cs.AppsV1().Deployments(namespace).Patch(ctx, name, types.MergePatchType, raw, metav1.PatchOptions{})
+ case "statefulset":
+ _, err = c.cs.AppsV1().StatefulSets(namespace).Patch(ctx, name, types.MergePatchType, raw, metav1.PatchOptions{})
+ case "daemonset":
+ _, err = c.cs.AppsV1().DaemonSets(namespace).Patch(ctx, name, types.MergePatchType, raw, metav1.PatchOptions{})
+ default:
+ return fmt.Errorf("unknown workload kind: %s", kind)
+ }
+ return err
+}
+
+// ScaleWorkload updates the scale subresource for a Deployment or StatefulSet.
+// DaemonSets cannot be scaled and callers should reject that kind before calling.
+// Returns the post-update replica count.
+func (c *Client) ScaleWorkload(ctx context.Context, namespace, kind, name string, replicas int32) (int32, error) {
+ switch strings.ToLower(kind) {
+ case "deployment":
+ s, err := c.cs.AppsV1().Deployments(namespace).GetScale(ctx, name, metav1.GetOptions{})
+ if err != nil {
+ return 0, err
+ }
+ s.Spec.Replicas = replicas
+ updated, err := c.cs.AppsV1().Deployments(namespace).UpdateScale(ctx, name, s, metav1.UpdateOptions{})
+ if err != nil {
+ return 0, err
+ }
+ return updated.Spec.Replicas, nil
+ case "statefulset":
+ s, err := c.cs.AppsV1().StatefulSets(namespace).GetScale(ctx, name, metav1.GetOptions{})
+ if err != nil {
+ return 0, err
+ }
+ s.Spec.Replicas = replicas
+ updated, err := c.cs.AppsV1().StatefulSets(namespace).UpdateScale(ctx, name, s, metav1.UpdateOptions{})
+ if err != nil {
+ return 0, err
+ }
+ return updated.Spec.Replicas, nil
+ default:
+ return 0, fmt.Errorf("unknown or unscalable workload kind: %s", kind)
+ }
+}
+
+// DeletePod deletes a single pod. The owning controller (Deployment/STS/DS/Job)
+// will recreate it. gracePeriod nil means use the pod's own terminationGracePeriodSeconds.
+// force=true sets gracePeriodSeconds=0 and propagationPolicy=Background.
+func (c *Client) DeletePod(ctx context.Context, namespace, name string, gracePeriod *int64, force bool) error {
+ opts := metav1.DeleteOptions{}
+ if force {
+ zero := int64(0)
+ opts.GracePeriodSeconds = &zero
+ bg := metav1.DeletePropagationBackground
+ opts.PropagationPolicy = &bg
+ } else if gracePeriod != nil {
+ opts.GracePeriodSeconds = gracePeriod
+ }
+ return c.cs.CoreV1().Pods(namespace).Delete(ctx, name, opts)
+}
+
+// CordonNode patches spec.unschedulable on the named node.
+// cordoned=true cordons; cordoned=false uncordons.
+func (c *Client) CordonNode(ctx context.Context, name string, cordoned bool) error {
+ patch := map[string]any{
+ "spec": map[string]any{
+ "unschedulable": cordoned,
+ },
+ }
+ raw, err := json.Marshal(patch)
+ if err != nil {
+ return fmt.Errorf("marshal patch: %w", err)
+ }
+ _, err = c.cs.CoreV1().Nodes().Patch(ctx, name, types.MergePatchType, raw, metav1.PatchOptions{})
+ return err
+}
diff --git a/internal/k8s/client.go b/internal/k8s/client.go
new file mode 100644
index 0000000..667fea9
--- /dev/null
+++ b/internal/k8s/client.go
@@ -0,0 +1,92 @@
+// Package k8s wraps client-go for read-only cluster inspection.
+//
+// New() tries in-cluster config first, then kubeconfig paths in order:
+// $KUBECONFIG, $HOME/.kube/config, /etc/rancher/k3s/k3s.yaml.
+// Returns ErrUnavailable if all attempts fail.
+package k8s
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "k8s.io/client-go/dynamic"
+ "k8s.io/client-go/kubernetes"
+ "k8s.io/client-go/rest"
+ "k8s.io/client-go/tools/clientcmd"
+)
+
+// ErrUnavailable mirrors the docker package sentinel; handlers return 503.
+var ErrUnavailable = errors.New("kubernetes is not available")
+
+// Client holds a typed clientset, a dynamic client, and the REST config
+// needed by remotecommand.NewSPDYExecutor for pod exec streams.
+type Client struct {
+ cs *kubernetes.Clientset
+ dyn dynamic.Interface
+ cfg *rest.Config
+}
+
+// New constructs a Client. It never returns a non-nil Client alongside a
+// non-nil error.
+func New(ctx context.Context) (*Client, error) {
+ cfg, err := buildConfig()
+ if err != nil {
+ return nil, fmt.Errorf("%w: %s", ErrUnavailable, err.Error())
+ }
+ cs, err := kubernetes.NewForConfig(cfg)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %s", ErrUnavailable, err.Error())
+ }
+ dyn, err := dynamic.NewForConfig(cfg)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %s", ErrUnavailable, err.Error())
+ }
+ c := &Client{cs: cs, dyn: dyn, cfg: cfg}
+ if err := c.Ping(ctx); err != nil {
+ return nil, fmt.Errorf("%w: %s", ErrUnavailable, err.Error())
+ }
+ return c, nil
+}
+
+// Ping verifies the API server is reachable within 3 seconds.
+func (c *Client) Ping(ctx context.Context) error {
+ pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
+ defer cancel()
+ _, err := c.cs.Discovery().ServerVersion()
+ _ = pingCtx
+ return err
+}
+
+// buildConfig tries config sources in priority order.
+func buildConfig() (*rest.Config, error) {
+ // 1. In-cluster (pod service account).
+ if cfg, err := rest.InClusterConfig(); err == nil {
+ return cfg, nil
+ }
+
+ // 2. Explicit $KUBECONFIG.
+ if kc := os.Getenv("KUBECONFIG"); kc != "" {
+ if cfg, err := clientcmd.BuildConfigFromFlags("", kc); err == nil {
+ return cfg, nil
+ }
+ }
+
+ // 3. ~/.kube/config
+ if home, err := os.UserHomeDir(); err == nil {
+ p := filepath.Join(home, ".kube", "config")
+ if cfg, err := clientcmd.BuildConfigFromFlags("", p); err == nil {
+ return cfg, nil
+ }
+ }
+
+ // 4. K3s bare-metal default.
+ if cfg, err := clientcmd.BuildConfigFromFlags("", "/etc/rancher/k3s/k3s.yaml"); err == nil {
+ return cfg, nil
+ }
+
+ return nil, errors.New("no valid kubeconfig found")
+}
diff --git a/internal/k8s/configmaps.go b/internal/k8s/configmaps.go
new file mode 100644
index 0000000..d548c81
--- /dev/null
+++ b/internal/k8s/configmaps.go
@@ -0,0 +1,131 @@
+package k8s
+
+import (
+ "context"
+ "regexp"
+ "sort"
+ "time"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// configmap key rule: ^[a-zA-Z0-9._-]+$
+var cmKeyRE = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`)
+
+// ValidConfigMapKey reports whether s is a valid configmap data key.
+func ValidConfigMapKey(s string) bool {
+ return len(s) > 0 && cmKeyRE.MatchString(s)
+}
+
+// ConfigMap is the list-level representation (no data values).
+type ConfigMap struct {
+ Name string `json:"name"`
+ Namespace string `json:"namespace"`
+ Keys []string `json:"keys"` // never nil; lex-sorted
+ Age string `json:"age"` // RFC3339
+}
+
+// ConfigMapDetail is the full detail view.
+type ConfigMapDetail struct {
+ ConfigMap ConfigMap `json:"configmap"`
+ Labels map[string]string `json:"labels"` // never nil
+ Annotations map[string]string `json:"annotations"` // never nil
+ Data map[string]string `json:"data"` // never nil
+ BinaryKeys []string `json:"binary_keys"` // never nil
+ Events []Event `json:"events"` // never nil
+}
+
+// sortedKeys returns the sorted keys of a map[string]string.
+func sortedKeys(m map[string]string) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// ListConfigMaps lists configmaps. namespace="" means all namespaces.
+func (c *Client) ListConfigMaps(ctx context.Context, namespace string) ([]ConfigMap, error) {
+ list, err := c.cs.CoreV1().ConfigMaps(namespace).List(ctx, metav1.ListOptions{})
+ if err != nil {
+ return nil, err
+ }
+ out := make([]ConfigMap, 0, len(list.Items))
+ for _, cm := range list.Items {
+ keys := sortedKeys(cm.Data)
+ if keys == nil {
+ keys = []string{}
+ }
+ out = append(out, ConfigMap{
+ Name: cm.Name,
+ Namespace: cm.Namespace,
+ Keys: keys,
+ Age: cm.CreationTimestamp.UTC().Format(time.RFC3339),
+ })
+ }
+ return out, nil
+}
+
+// GetConfigMap returns full detail for a configmap including events.
+func (c *Client) GetConfigMap(ctx context.Context, namespace, name string) (*ConfigMapDetail, error) {
+ cm, err := c.cs.CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{})
+ if err != nil {
+ return nil, err
+ }
+
+ labels := cm.Labels
+ if labels == nil {
+ labels = map[string]string{}
+ }
+ annotations := cm.Annotations
+ if annotations == nil {
+ annotations = map[string]string{}
+ }
+ data := cm.Data
+ if data == nil {
+ data = map[string]string{}
+ }
+
+ binaryKeys := make([]string, 0, len(cm.BinaryData))
+ for k := range cm.BinaryData {
+ binaryKeys = append(binaryKeys, k)
+ }
+ sort.Strings(binaryKeys)
+
+ keys := sortedKeys(cm.Data)
+ if keys == nil {
+ keys = []string{}
+ }
+
+ events, err := c.ListEvents(ctx, namespace, "ConfigMap", name)
+ if err != nil {
+ events = []Event{}
+ }
+
+ return &ConfigMapDetail{
+ ConfigMap: ConfigMap{
+ Name: cm.Name,
+ Namespace: cm.Namespace,
+ Keys: keys,
+ Age: cm.CreationTimestamp.UTC().Format(time.RFC3339),
+ },
+ Labels: labels,
+ Annotations: annotations,
+ Data: data,
+ BinaryKeys: binaryKeys,
+ Events: events,
+ }, nil
+}
+
+// UpdateConfigMap replaces the data field of a configmap preserving binaryData.
+// Uses Get+Update for resourceVersion conflict detection (409 on conflict).
+func (c *Client) UpdateConfigMap(ctx context.Context, namespace, name string, newData map[string]string) error {
+ cm, err := c.cs.CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{})
+ if err != nil {
+ return err
+ }
+ cm.Data = newData
+ _, err = c.cs.CoreV1().ConfigMaps(namespace).Update(ctx, cm, metav1.UpdateOptions{})
+ return err
+}
diff --git a/internal/k8s/detail.go b/internal/k8s/detail.go
new file mode 100644
index 0000000..492988b
--- /dev/null
+++ b/internal/k8s/detail.go
@@ -0,0 +1,489 @@
+package k8s
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// ---- detail domain types ----
+
+type Event struct {
+ Type string `json:"type"` // "Normal" | "Warning"
+ Reason string `json:"reason"`
+ Message string `json:"message"`
+ Source string `json:"source"` // "kubelet" | controller name
+ Count int32 `json:"count"`
+ First string `json:"first"` // RFC3339
+ Last string `json:"last"` // RFC3339
+}
+
+type Condition struct {
+ Type string `json:"type"`
+ Status string `json:"status"` // "True" | "False" | "Unknown"
+ Reason string `json:"reason"`
+ Message string `json:"message"`
+ LastTransitionTime string `json:"last_transition_time"` // RFC3339
+}
+
+type Taint struct {
+ Key string `json:"key"`
+ Value string `json:"value"`
+ Effect string `json:"effect"`
+}
+
+type NodeDetail struct {
+ Node Node `json:"node"`
+ Conditions []Condition `json:"conditions"` // never nil
+ Allocatable NodeCapacity `json:"allocatable"`
+ Taints []Taint `json:"taints"` // never nil
+ Events []Event `json:"events"` // never nil
+}
+
+type WorkloadDetail struct {
+ Workload Workload `json:"workload"`
+ Conditions []Condition `json:"conditions"` // never nil
+ Selector map[string]string `json:"selector"` // never nil
+ Strategy string `json:"strategy"`
+ Events []Event `json:"events"` // never nil
+}
+
+type ContainerStatus struct {
+ Name string `json:"name"`
+ Image string `json:"image"`
+ Ready bool `json:"ready"`
+ RestartCount int32 `json:"restart_count"`
+ State string `json:"state"` // "running" | "waiting" | "terminated"
+ Reason string `json:"reason"` // for waiting/terminated
+ Started string `json:"started"` // RFC3339; "" when not running
+}
+
+type PodDetail struct {
+ Pod Pod `json:"pod"`
+ Conditions []Condition `json:"conditions"` // never nil
+ Containers []ContainerStatus `json:"containers"` // never nil
+ NodeName string `json:"node_name"`
+ QoSClass string `json:"qos_class"`
+ Events []Event `json:"events"` // never nil
+}
+
+type EndpointAddr struct {
+ IP string `json:"ip"`
+ NodeName string `json:"node_name"`
+ Ready bool `json:"ready"`
+}
+
+type ServiceDetail struct {
+ Service Service `json:"service"`
+ Endpoints []EndpointAddr `json:"endpoints"` // never nil
+ Selector map[string]string `json:"selector"` // never nil
+ Events []Event `json:"events"` // never nil
+}
+
+// ---- Get* methods ----
+
+func (c *Client) GetNode(ctx context.Context, name string) (*NodeDetail, error) {
+ n, err := c.cs.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{})
+ if err != nil {
+ return nil, err
+ }
+
+ conditions := make([]Condition, 0, len(n.Status.Conditions))
+ for _, cond := range n.Status.Conditions {
+ conditions = append(conditions, Condition{
+ Type: string(cond.Type),
+ Status: string(cond.Status),
+ Reason: cond.Reason,
+ Message: cond.Message,
+ LastTransitionTime: cond.LastTransitionTime.UTC().Format(time.RFC3339),
+ })
+ }
+
+ taints := make([]Taint, 0, len(n.Spec.Taints))
+ for _, t := range n.Spec.Taints {
+ taints = append(taints, Taint{
+ Key: t.Key,
+ Value: t.Value,
+ Effect: string(t.Effect),
+ })
+ }
+
+ alloc := n.Status.Allocatable
+ events, err := c.ListEvents(ctx, "", "Node", name)
+ if err != nil {
+ events = []Event{}
+ }
+
+ return &NodeDetail{
+ Node: nodeFrom(*n),
+ Conditions: conditions,
+ Allocatable: NodeCapacity{
+ CPU: alloc.Cpu().String(),
+ Memory: alloc.Memory().String(),
+ Pods: alloc.Pods().String(),
+ },
+ Taints: taints,
+ Events: events,
+ }, nil
+}
+
+func (c *Client) GetWorkload(ctx context.Context, namespace, kind, name string) (*WorkloadDetail, error) {
+ switch strings.ToLower(kind) {
+ case "deployment":
+ return c.getDeploymentDetail(ctx, namespace, name)
+ case "statefulset":
+ return c.getStatefulSetDetail(ctx, namespace, name)
+ case "daemonset":
+ return c.getDaemonSetDetail(ctx, namespace, name)
+ default:
+ return nil, fmt.Errorf("unknown workload kind: %s", kind)
+ }
+}
+
+func (c *Client) getDeploymentDetail(ctx context.Context, namespace, name string) (*WorkloadDetail, error) {
+ d, err := c.cs.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
+ if err != nil {
+ return nil, err
+ }
+
+ conditions := make([]Condition, 0, len(d.Status.Conditions))
+ for _, cond := range d.Status.Conditions {
+ conditions = append(conditions, Condition{
+ Type: string(cond.Type),
+ Status: string(cond.Status),
+ Reason: cond.Reason,
+ Message: cond.Message,
+ LastTransitionTime: cond.LastTransitionTime.UTC().Format(time.RFC3339),
+ })
+ }
+
+ selector := selectorLabels(d.Spec.Selector)
+ strategy := string(d.Spec.Strategy.Type)
+
+ labels := d.Labels
+ if labels == nil {
+ labels = map[string]string{}
+ }
+ images := containerImages(d.Spec.Template.Spec.Containers)
+
+ events, err := c.ListEvents(ctx, namespace, "Deployment", name)
+ if err != nil {
+ events = []Event{}
+ }
+
+ return &WorkloadDetail{
+ Workload: Workload{
+ Kind: "Deployment",
+ Name: d.Name,
+ Namespace: d.Namespace,
+ Ready: WorkloadReady{Current: d.Status.ReadyReplicas, Desired: derefInt32(d.Spec.Replicas)},
+ Images: images,
+ Age: d.CreationTimestamp.UTC().Format(time.RFC3339),
+ Labels: labels,
+ },
+ Conditions: conditions,
+ Selector: selector,
+ Strategy: strategy,
+ Events: events,
+ }, nil
+}
+
+func (c *Client) getStatefulSetDetail(ctx context.Context, namespace, name string) (*WorkloadDetail, error) {
+ s, err := c.cs.AppsV1().StatefulSets(namespace).Get(ctx, name, metav1.GetOptions{})
+ if err != nil {
+ return nil, err
+ }
+
+ conditions := make([]Condition, 0, len(s.Status.Conditions))
+ for _, cond := range s.Status.Conditions {
+ conditions = append(conditions, Condition{
+ Type: string(cond.Type),
+ Status: string(cond.Status),
+ Reason: cond.Reason,
+ Message: cond.Message,
+ LastTransitionTime: cond.LastTransitionTime.UTC().Format(time.RFC3339),
+ })
+ }
+
+ selector := selectorLabels(s.Spec.Selector)
+ strategy := string(s.Spec.UpdateStrategy.Type)
+
+ labels := s.Labels
+ if labels == nil {
+ labels = map[string]string{}
+ }
+ images := containerImages(s.Spec.Template.Spec.Containers)
+
+ events, err := c.ListEvents(ctx, namespace, "StatefulSet", name)
+ if err != nil {
+ events = []Event{}
+ }
+
+ return &WorkloadDetail{
+ Workload: Workload{
+ Kind: "StatefulSet",
+ Name: s.Name,
+ Namespace: s.Namespace,
+ Ready: WorkloadReady{Current: s.Status.ReadyReplicas, Desired: derefInt32(s.Spec.Replicas)},
+ Images: images,
+ Age: s.CreationTimestamp.UTC().Format(time.RFC3339),
+ Labels: labels,
+ },
+ Conditions: conditions,
+ Selector: selector,
+ Strategy: strategy,
+ Events: events,
+ }, nil
+}
+
+func (c *Client) getDaemonSetDetail(ctx context.Context, namespace, name string) (*WorkloadDetail, error) {
+ ds, err := c.cs.AppsV1().DaemonSets(namespace).Get(ctx, name, metav1.GetOptions{})
+ if err != nil {
+ return nil, err
+ }
+
+ // DaemonSets don't have conditions in status; return empty slice.
+ conditions := []Condition{}
+
+ selector := selectorLabels(ds.Spec.Selector)
+ strategy := string(ds.Spec.UpdateStrategy.Type)
+
+ labels := ds.Labels
+ if labels == nil {
+ labels = map[string]string{}
+ }
+ images := containerImages(ds.Spec.Template.Spec.Containers)
+
+ events, err := c.ListEvents(ctx, namespace, "DaemonSet", name)
+ if err != nil {
+ events = []Event{}
+ }
+
+ return &WorkloadDetail{
+ Workload: Workload{
+ Kind: "DaemonSet",
+ Name: ds.Name,
+ Namespace: ds.Namespace,
+ Ready: WorkloadReady{Current: ds.Status.NumberReady, Desired: ds.Status.DesiredNumberScheduled},
+ Images: images,
+ Age: ds.CreationTimestamp.UTC().Format(time.RFC3339),
+ Labels: labels,
+ },
+ Conditions: conditions,
+ Selector: selector,
+ Strategy: strategy,
+ Events: events,
+ }, nil
+}
+
+func (c *Client) GetPod(ctx context.Context, namespace, name string) (*PodDetail, error) {
+ p, err := c.cs.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
+ if err != nil {
+ return nil, err
+ }
+
+ conditions := make([]Condition, 0, len(p.Status.Conditions))
+ for _, cond := range p.Status.Conditions {
+ conditions = append(conditions, Condition{
+ Type: string(cond.Type),
+ Status: string(cond.Status),
+ Reason: cond.Reason,
+ Message: cond.Message,
+ LastTransitionTime: cond.LastTransitionTime.UTC().Format(time.RFC3339),
+ })
+ }
+
+ // Build a map of container status by name for O(1) lookup.
+ statusByName := make(map[string]corev1.ContainerStatus, len(p.Status.ContainerStatuses))
+ for _, cs := range p.Status.ContainerStatuses {
+ statusByName[cs.Name] = cs
+ }
+
+ containers := make([]ContainerStatus, 0, len(p.Spec.Containers))
+ for _, spec := range p.Spec.Containers {
+ cs, ok := statusByName[spec.Name]
+ state := "waiting"
+ reason := ""
+ started := ""
+ if ok {
+ state, reason, started = containerState(cs.State)
+ }
+ ready := ok && cs.Ready
+ var restarts int32
+ if ok {
+ restarts = cs.RestartCount
+ }
+ containers = append(containers, ContainerStatus{
+ Name: spec.Name,
+ Image: spec.Image,
+ Ready: ready,
+ RestartCount: restarts,
+ State: state,
+ Reason: reason,
+ Started: started,
+ })
+ }
+
+ events, err := c.ListEvents(ctx, namespace, "Pod", name)
+ if err != nil {
+ events = []Event{}
+ }
+
+ return &PodDetail{
+ Pod: podFrom(*p),
+ Conditions: conditions,
+ Containers: containers,
+ NodeName: p.Spec.NodeName,
+ QoSClass: string(p.Status.QOSClass),
+ Events: events,
+ }, nil
+}
+
+func (c *Client) GetService(ctx context.Context, namespace, name string) (*ServiceDetail, error) {
+ s, err := c.cs.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{})
+ if err != nil {
+ return nil, err
+ }
+
+ selector := s.Spec.Selector
+ if selector == nil {
+ selector = map[string]string{}
+ }
+
+ // Fetch endpoints for this service.
+ ep, err := c.cs.CoreV1().Endpoints(namespace).Get(ctx, name, metav1.GetOptions{})
+ endpoints := []EndpointAddr{}
+ if err == nil {
+ for _, subset := range ep.Subsets {
+ for _, addr := range subset.Addresses {
+ nodeName := ""
+ if addr.NodeName != nil {
+ nodeName = *addr.NodeName
+ }
+ endpoints = append(endpoints, EndpointAddr{IP: addr.IP, NodeName: nodeName, Ready: true})
+ }
+ for _, addr := range subset.NotReadyAddresses {
+ nodeName := ""
+ if addr.NodeName != nil {
+ nodeName = *addr.NodeName
+ }
+ endpoints = append(endpoints, EndpointAddr{IP: addr.IP, NodeName: nodeName, Ready: false})
+ }
+ }
+ }
+
+ events, err := c.ListEvents(ctx, namespace, "Service", name)
+ if err != nil {
+ events = []Event{}
+ }
+
+ return &ServiceDetail{
+ Service: serviceFrom(*s),
+ Endpoints: endpoints,
+ Selector: selector,
+ Events: events,
+ }, nil
+}
+
+// ListEvents lists events whose involvedObject matches (namespace, kind, name).
+// For cluster-scoped objects (e.g. Node), pass empty namespace.
+func (c *Client) ListEvents(ctx context.Context, namespace, kind, name string) ([]Event, error) {
+ // Field selector targets the specific object to avoid fetching all events.
+ fieldSelector := fmt.Sprintf("involvedObject.kind=%s,involvedObject.name=%s", kind, name)
+ if namespace != "" {
+ fieldSelector += fmt.Sprintf(",involvedObject.namespace=%s", namespace)
+ }
+
+ list, err := c.cs.CoreV1().Events(namespace).List(ctx, metav1.ListOptions{
+ FieldSelector: fieldSelector,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ out := make([]Event, 0, len(list.Items))
+ for _, ev := range list.Items {
+ out = append(out, eventFrom(ev))
+ }
+ return out, nil
+}
+
+// ---- conversion helpers ----
+
+func eventFrom(ev corev1.Event) Event {
+ source := ev.Source.Component
+ if source == "" {
+ source = ev.ReportingController
+ }
+
+ // Prefer eventTime over firstTimestamp for newer events.
+ first := ev.FirstTimestamp.UTC().Format(time.RFC3339)
+ if ev.FirstTimestamp.IsZero() && !ev.EventTime.IsZero() {
+ first = ev.EventTime.UTC().Format(time.RFC3339)
+ }
+ last := ev.LastTimestamp.UTC().Format(time.RFC3339)
+ if ev.LastTimestamp.IsZero() && !ev.EventTime.IsZero() {
+ last = ev.EventTime.UTC().Format(time.RFC3339)
+ }
+
+ count := ev.Count
+ if count == 0 {
+ count = 1
+ }
+
+ return Event{
+ Type: ev.Type,
+ Reason: ev.Reason,
+ Message: ev.Message,
+ Source: source,
+ Count: count,
+ First: first,
+ Last: last,
+ }
+}
+
+func containerState(s corev1.ContainerState) (state, reason, started string) {
+ if s.Running != nil {
+ started = s.Running.StartedAt.UTC().Format(time.RFC3339)
+ return "running", "", started
+ }
+ if s.Waiting != nil {
+ return "waiting", s.Waiting.Reason, ""
+ }
+ if s.Terminated != nil {
+ return "terminated", s.Terminated.Reason, ""
+ }
+ return "waiting", "", ""
+}
+
+func selectorLabels(sel *metav1.LabelSelector) map[string]string {
+ if sel == nil || sel.MatchLabels == nil {
+ return map[string]string{}
+ }
+ out := make(map[string]string, len(sel.MatchLabels))
+ for k, v := range sel.MatchLabels {
+ out[k] = v
+ }
+ return out
+}
+
+// PodLogs opens a streaming log reader for the given pod/container.
+// container may be empty if the pod has exactly one container.
+// sinceSeconds may be nil (no time floor).
+func (c *Client) PodLogs(ctx context.Context, namespace, name, container string, tail int64, sinceSeconds *int64) (io.ReadCloser, error) {
+ opts := &corev1.PodLogOptions{
+ Follow: true,
+ TailLines: &tail,
+ SinceSeconds: sinceSeconds,
+ }
+ if container != "" {
+ opts.Container = container
+ }
+ req := c.cs.CoreV1().Pods(namespace).GetLogs(name, opts)
+ return req.Stream(ctx)
+}
diff --git a/internal/k8s/exec.go b/internal/k8s/exec.go
new file mode 100644
index 0000000..59208e8
--- /dev/null
+++ b/internal/k8s/exec.go
@@ -0,0 +1,48 @@
+package k8s
+
+import (
+ "context"
+ "io"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/client-go/kubernetes/scheme"
+ "k8s.io/client-go/tools/remotecommand"
+)
+
+// PodExec opens a SPDY exec stream to the named container and pipes stdin to
+// the container, multiplexing stdout+stderr back to the provided writers.
+// The provided context cancels the stream when done. sizeQueue may be nil to
+// skip TTY resize tracking.
+func (c *Client) PodExec(
+ ctx context.Context,
+ namespace, pod, container string,
+ command []string,
+ stdin io.Reader,
+ stdout, stderr io.Writer,
+ sizeQueue remotecommand.TerminalSizeQueue,
+) error {
+ req := c.cs.CoreV1().RESTClient().Post().
+ Resource("pods").Name(pod).Namespace(namespace).
+ SubResource("exec").
+ VersionedParams(&corev1.PodExecOptions{
+ Container: container,
+ Command: command,
+ Stdin: true,
+ Stdout: true,
+ Stderr: true,
+ TTY: true,
+ }, scheme.ParameterCodec)
+
+ exec, err := remotecommand.NewSPDYExecutor(c.cfg, "POST", req.URL())
+ if err != nil {
+ return err
+ }
+
+ return exec.StreamWithContext(ctx, remotecommand.StreamOptions{
+ Stdin: stdin,
+ Stdout: stdout,
+ Stderr: stderr,
+ Tty: true,
+ TerminalSizeQueue: sizeQueue,
+ })
+}
diff --git a/internal/k8s/k8s.go b/internal/k8s/k8s.go
new file mode 100644
index 0000000..ccd294e
--- /dev/null
+++ b/internal/k8s/k8s.go
@@ -0,0 +1,338 @@
+package k8s
+
+import (
+ "context"
+ "strings"
+
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// ---- domain types (JSON shape is contract with the frontend) ----
+
+type Node struct {
+ Name string `json:"name"`
+ Status string `json:"status"` // "Ready" | "NotReady" | "Unknown"
+ Roles []string `json:"roles"` // never nil
+ Version string `json:"version"`
+ OS string `json:"os"`
+ Arch string `json:"arch"`
+ Addresses []NodeAddress `json:"addresses"` // never nil
+ Capacity NodeCapacity `json:"capacity"`
+ Age string `json:"age"`
+}
+
+type NodeAddress struct {
+ Type string `json:"type"`
+ Address string `json:"address"`
+}
+
+type NodeCapacity struct {
+ CPU string `json:"cpu"`
+ Memory string `json:"memory"`
+ Pods string `json:"pods"`
+}
+
+type Namespace struct {
+ Name string `json:"name"`
+ Status string `json:"status"` // "Active" | "Terminating"
+ Age string `json:"age"`
+}
+
+type Workload struct {
+ Kind string `json:"kind"` // "Deployment" | "StatefulSet" | "DaemonSet"
+ Name string `json:"name"`
+ Namespace string `json:"namespace"`
+ Ready WorkloadReady `json:"ready"`
+ Images []string `json:"images"` // never nil
+ Age string `json:"age"`
+ Labels map[string]string `json:"labels"` // never nil
+}
+
+type WorkloadReady struct {
+ Current int32 `json:"current"`
+ Desired int32 `json:"desired"`
+}
+
+type Pod struct {
+ Name string `json:"name"`
+ Namespace string `json:"namespace"`
+ Status string `json:"status"`
+ Ready PodReady `json:"ready"`
+ Restarts int32 `json:"restarts"` // sum across containers
+ Node string `json:"node"`
+ PodIP string `json:"pod_ip"`
+ Age string `json:"age"`
+ Images []string `json:"images"` // never nil
+}
+
+type PodReady struct {
+ Current int32 `json:"current"`
+ Total int32 `json:"total"`
+}
+
+type Service struct {
+ Name string `json:"name"`
+ Namespace string `json:"namespace"`
+ Type string `json:"type"` // ClusterIP | NodePort | LoadBalancer
+ ClusterIP string `json:"cluster_ip"`
+ ExternalIP string `json:"external_ip"` // empty if none
+ Ports []ServicePort `json:"ports"` // never nil
+ Age string `json:"age"`
+}
+
+type ServicePort struct {
+ Name string `json:"name"`
+ Port int32 `json:"port"`
+ TargetPort string `json:"target_port"` // intstr → string
+ Protocol string `json:"protocol"`
+ NodePort int32 `json:"node_port,omitempty"`
+}
+
+// ---- list methods ----
+
+func (c *Client) ListNodes(ctx context.Context) ([]Node, error) {
+ list, err := c.cs.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
+ if err != nil {
+ return nil, err
+ }
+ out := make([]Node, 0, len(list.Items))
+ for _, n := range list.Items {
+ out = append(out, nodeFrom(n))
+ }
+ return out, nil
+}
+
+func (c *Client) ListNamespaces(ctx context.Context) ([]Namespace, error) {
+ list, err := c.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
+ if err != nil {
+ return nil, err
+ }
+ out := make([]Namespace, 0, len(list.Items))
+ for _, ns := range list.Items {
+ out = append(out, Namespace{
+ Name: ns.Name,
+ Status: string(ns.Status.Phase),
+ Age: ns.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z07:00"),
+ })
+ }
+ return out, nil
+}
+
+// ListWorkloads returns Deployments, StatefulSets, and DaemonSets.
+// namespace="" means all namespaces.
+func (c *Client) ListWorkloads(ctx context.Context, namespace string) ([]Workload, error) {
+ var out []Workload
+
+ deps, err := c.cs.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{})
+ if err != nil {
+ return nil, err
+ }
+ for _, d := range deps.Items {
+ labels := d.Labels
+ if labels == nil {
+ labels = map[string]string{}
+ }
+ images := containerImages(d.Spec.Template.Spec.Containers)
+ out = append(out, Workload{
+ Kind: "Deployment",
+ Name: d.Name,
+ Namespace: d.Namespace,
+ Ready: WorkloadReady{Current: d.Status.ReadyReplicas, Desired: derefInt32(d.Spec.Replicas)},
+ Images: images,
+ Age: d.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z07:00"),
+ Labels: labels,
+ })
+ }
+
+ ssets, err := c.cs.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{})
+ if err != nil {
+ return nil, err
+ }
+ for _, s := range ssets.Items {
+ labels := s.Labels
+ if labels == nil {
+ labels = map[string]string{}
+ }
+ images := containerImages(s.Spec.Template.Spec.Containers)
+ out = append(out, Workload{
+ Kind: "StatefulSet",
+ Name: s.Name,
+ Namespace: s.Namespace,
+ Ready: WorkloadReady{Current: s.Status.ReadyReplicas, Desired: derefInt32(s.Spec.Replicas)},
+ Images: images,
+ Age: s.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z07:00"),
+ Labels: labels,
+ })
+ }
+
+ dsets, err := c.cs.AppsV1().DaemonSets(namespace).List(ctx, metav1.ListOptions{})
+ if err != nil {
+ return nil, err
+ }
+ for _, ds := range dsets.Items {
+ labels := ds.Labels
+ if labels == nil {
+ labels = map[string]string{}
+ }
+ images := containerImages(ds.Spec.Template.Spec.Containers)
+ out = append(out, Workload{
+ Kind: "DaemonSet",
+ Name: ds.Name,
+ Namespace: ds.Namespace,
+ Ready: WorkloadReady{Current: ds.Status.NumberReady, Desired: ds.Status.DesiredNumberScheduled},
+ Images: images,
+ Age: ds.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z07:00"),
+ Labels: labels,
+ })
+ }
+
+ if out == nil {
+ out = []Workload{}
+ }
+ return out, nil
+}
+
+func (c *Client) ListPods(ctx context.Context, namespace string) ([]Pod, error) {
+ list, err := c.cs.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{})
+ if err != nil {
+ return nil, err
+ }
+ out := make([]Pod, 0, len(list.Items))
+ for _, p := range list.Items {
+ out = append(out, podFrom(p))
+ }
+ return out, nil
+}
+
+func (c *Client) ListServices(ctx context.Context, namespace string) ([]Service, error) {
+ list, err := c.cs.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{})
+ if err != nil {
+ return nil, err
+ }
+ out := make([]Service, 0, len(list.Items))
+ for _, s := range list.Items {
+ out = append(out, serviceFrom(s))
+ }
+ return out, nil
+}
+
+// ---- conversion helpers ----
+
+const rolePrefix = "node-role.kubernetes.io/"
+
+func nodeFrom(n corev1.Node) Node {
+ roles := []string{}
+ for k := range n.Labels {
+ if strings.HasPrefix(k, rolePrefix) {
+ roles = append(roles, strings.TrimPrefix(k, rolePrefix))
+ }
+ }
+
+ addrs := make([]NodeAddress, 0, len(n.Status.Addresses))
+ for _, a := range n.Status.Addresses {
+ addrs = append(addrs, NodeAddress{Type: string(a.Type), Address: a.Address})
+ }
+
+ status := "Unknown"
+ for _, cond := range n.Status.Conditions {
+ if cond.Type == corev1.NodeReady {
+ switch cond.Status {
+ case corev1.ConditionTrue:
+ status = "Ready"
+ case corev1.ConditionFalse:
+ status = "NotReady"
+ }
+ break
+ }
+ }
+
+ cap := n.Status.Capacity
+ return Node{
+ Name: n.Name,
+ Status: status,
+ Roles: roles,
+ Version: n.Status.NodeInfo.KubeletVersion,
+ OS: n.Status.NodeInfo.OperatingSystem,
+ Arch: n.Status.NodeInfo.Architecture,
+ Addresses: addrs,
+ Capacity: NodeCapacity{
+ CPU: cap.Cpu().String(),
+ Memory: cap.Memory().String(),
+ Pods: cap.Pods().String(),
+ },
+ Age: n.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z07:00"),
+ }
+}
+
+func podFrom(p corev1.Pod) Pod {
+ var restarts int32
+ var readyCurrent int32
+ for _, cs := range p.Status.ContainerStatuses {
+ restarts += cs.RestartCount
+ if cs.Ready {
+ readyCurrent++
+ }
+ }
+ images := make([]string, 0, len(p.Spec.Containers))
+ for _, c := range p.Spec.Containers {
+ images = append(images, c.Image)
+ }
+ return Pod{
+ Name: p.Name,
+ Namespace: p.Namespace,
+ Status: string(p.Status.Phase),
+ Ready: PodReady{Current: readyCurrent, Total: int32(len(p.Spec.Containers))},
+ Restarts: restarts,
+ Node: p.Spec.NodeName,
+ PodIP: p.Status.PodIP,
+ Age: p.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z07:00"),
+ Images: images,
+ }
+}
+
+func serviceFrom(s corev1.Service) Service {
+ externalIP := ""
+ if len(s.Status.LoadBalancer.Ingress) > 0 {
+ ing := s.Status.LoadBalancer.Ingress[0]
+ if ing.IP != "" {
+ externalIP = ing.IP
+ } else {
+ externalIP = ing.Hostname
+ }
+ }
+ ports := make([]ServicePort, 0, len(s.Spec.Ports))
+ for _, p := range s.Spec.Ports {
+ ports = append(ports, ServicePort{
+ Name: p.Name,
+ Port: p.Port,
+ TargetPort: p.TargetPort.String(),
+ Protocol: string(p.Protocol),
+ NodePort: p.NodePort,
+ })
+ }
+ return Service{
+ Name: s.Name,
+ Namespace: s.Namespace,
+ Type: string(s.Spec.Type),
+ ClusterIP: s.Spec.ClusterIP,
+ ExternalIP: externalIP,
+ Ports: ports,
+ Age: s.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z07:00"),
+ }
+}
+
+func containerImages(containers []corev1.Container) []string {
+ images := make([]string, 0, len(containers))
+ for _, c := range containers {
+ images = append(images, c.Image)
+ }
+ return images
+}
+
+func derefInt32(p *int32) int32 {
+ if p == nil {
+ return 0
+ }
+ return *p
+}
diff --git a/internal/k8s/manifest.go b/internal/k8s/manifest.go
new file mode 100644
index 0000000..ad085f2
--- /dev/null
+++ b/internal/k8s/manifest.go
@@ -0,0 +1,169 @@
+package k8s
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ k8serrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ sigsyaml "sigs.k8s.io/yaml"
+)
+
+// editableKinds lists the resource types that may be fetched and patched through
+// the manifest API. Pods, Nodes, and Secrets are intentionally excluded.
+var editableKinds = map[string]schema.GroupVersionResource{
+ "deployment": {Group: "apps", Version: "v1", Resource: "deployments"},
+ "statefulset": {Group: "apps", Version: "v1", Resource: "statefulsets"},
+ "daemonset": {Group: "apps", Version: "v1", Resource: "daemonsets"},
+ "service": {Group: "", Version: "v1", Resource: "services"},
+ "configmap": {Group: "", Version: "v1", Resource: "configmaps"},
+}
+
+// editableGVK maps the same keys to the canonical GVK for the YAML apiVersion/kind header.
+var editableGVK = map[string]schema.GroupVersionKind{
+ "deployment": {Group: "apps", Version: "v1", Kind: "Deployment"},
+ "statefulset": {Group: "apps", Version: "v1", Kind: "StatefulSet"},
+ "daemonset": {Group: "apps", Version: "v1", Kind: "DaemonSet"},
+ "service": {Group: "", Version: "v1", Kind: "Service"},
+ "configmap": {Group: "", Version: "v1", Kind: "ConfigMap"},
+}
+
+// ErrManifestKind is returned when a kind is not in the editable allowlist.
+type ErrManifestKind struct{ Kind string }
+
+func (e ErrManifestKind) Error() string {
+ return fmt.Sprintf("kind %q is not in the editable allowlist (deployment|statefulset|daemonset|service|configmap)", e.Kind)
+}
+
+// ErrManifestConflict is returned when the API server returns a 409 conflict.
+var ErrManifestConflict = fmt.Errorf("conflict — resource was modified by another process; reload and retry")
+
+// ErrManifestNotFound is returned when the resource does not exist.
+var ErrManifestNotFound = fmt.Errorf("resource not found")
+
+// ErrManifestInvalid is returned when the API server rejects with 422.
+type ErrManifestInvalid struct{ Message string }
+
+func (e ErrManifestInvalid) Error() string { return e.Message }
+
+// ErrManifestForbidden is returned on 403 from the API server.
+var ErrManifestForbidden = fmt.Errorf("forbidden")
+
+// ErrManifestMismatch is returned when YAML metadata doesn't match URL params.
+type ErrManifestMismatch struct{ Detail string }
+
+func (e ErrManifestMismatch) Error() string { return e.Detail }
+
+// stripMeta removes server-managed and conflict-prone fields before serialising.
+// Keeps labels, annotations, spec. Removes status entirely.
+func stripMeta(obj *unstructured.Unstructured) {
+ meta, ok := obj.Object["metadata"].(map[string]interface{})
+ if !ok {
+ return
+ }
+ for _, field := range []string{
+ "managedFields",
+ "creationTimestamp",
+ "uid",
+ "resourceVersion",
+ "generation",
+ } {
+ delete(meta, field)
+ }
+ obj.Object["metadata"] = meta
+ delete(obj.Object, "status")
+}
+
+// GetManifest fetches a resource as cleaned YAML plus its resourceVersion and GVK.
+func (c *Client) GetManifest(ctx context.Context, kind, namespace, name string) (yamlOut string, rv string, gvk schema.GroupVersionKind, err error) {
+ normKind := strings.ToLower(kind)
+ gvr, ok := editableKinds[normKind]
+ if !ok {
+ return "", "", schema.GroupVersionKind{}, ErrManifestKind{Kind: kind}
+ }
+ gvk = editableGVK[normKind]
+
+ obj, err := c.dyn.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
+ if err != nil {
+ return "", "", gvk, mapManifestErr(err)
+ }
+
+ rv = obj.GetResourceVersion()
+
+ stripMeta(obj)
+
+ raw, err := sigsyaml.Marshal(obj.Object)
+ if err != nil {
+ return "", "", gvk, fmt.Errorf("marshal manifest: %w", err)
+ }
+ return string(raw), rv, gvk, nil
+}
+
+// ApplyManifest parses the YAML, cross-checks metadata against URL params,
+// injects resourceVersion, and calls Update (PUT) on the dynamic client.
+// Returns the post-update resourceVersion and any server warnings.
+func (c *Client) ApplyManifest(ctx context.Context, kind, namespace, name, yamlText, resourceVersion string, dryRun bool) (newRV string, warnings []string, err error) {
+ normKind := strings.ToLower(kind)
+ gvr, ok := editableKinds[normKind]
+ if !ok {
+ return "", nil, ErrManifestKind{Kind: kind}
+ }
+
+ // Parse YAML → unstructured.
+ var raw map[string]interface{}
+ if unmarshalErr := sigsyaml.Unmarshal([]byte(yamlText), &raw); unmarshalErr != nil {
+ return "", nil, fmt.Errorf("invalid YAML: %w", unmarshalErr)
+ }
+ if raw == nil {
+ return "", nil, fmt.Errorf("invalid YAML: empty document")
+ }
+
+ obj := &unstructured.Unstructured{Object: raw}
+
+ // Cross-check kind in YAML matches URL kind.
+ yamlKind := strings.ToLower(obj.GetKind())
+ if yamlKind != normKind {
+ return "", nil, ErrManifestMismatch{Detail: fmt.Sprintf("YAML kind %q does not match URL kind %q", obj.GetKind(), kind)}
+ }
+
+ // Cross-check name and namespace in YAML match URL params.
+ if obj.GetName() != name {
+ return "", nil, ErrManifestMismatch{Detail: fmt.Sprintf("YAML metadata.name %q does not match URL name %q", obj.GetName(), name)}
+ }
+ if obj.GetNamespace() != namespace {
+ return "", nil, ErrManifestMismatch{Detail: fmt.Sprintf("YAML metadata.namespace %q does not match URL namespace %q", obj.GetNamespace(), namespace)}
+ }
+
+ // Inject resourceVersion for conflict detection.
+ obj.SetResourceVersion(resourceVersion)
+
+ var dryRunOpts []string
+ if dryRun {
+ dryRunOpts = []string{metav1.DryRunAll}
+ }
+
+ result, err := c.dyn.Resource(gvr).Namespace(namespace).Update(ctx, obj, metav1.UpdateOptions{DryRun: dryRunOpts})
+ if err != nil {
+ return "", nil, mapManifestErr(err)
+ }
+
+ return result.GetResourceVersion(), []string{}, nil
+}
+
+// mapManifestErr translates typed k8s API errors to the sentinel errors above.
+func mapManifestErr(err error) error {
+ switch {
+ case k8serrors.IsConflict(err):
+ return ErrManifestConflict
+ case k8serrors.IsNotFound(err):
+ return ErrManifestNotFound
+ case k8serrors.IsInvalid(err):
+ return ErrManifestInvalid{Message: err.Error()}
+ case k8serrors.IsForbidden(err):
+ return ErrManifestForbidden
+ }
+ return err
+}
diff --git a/internal/k8s/secrets.go b/internal/k8s/secrets.go
new file mode 100644
index 0000000..7febf04
--- /dev/null
+++ b/internal/k8s/secrets.go
@@ -0,0 +1,107 @@
+package k8s
+
+import (
+ "context"
+ "sort"
+ "time"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// Secret is the list-level representation (no data values).
+type Secret struct {
+ Name string `json:"name"`
+ Namespace string `json:"namespace"`
+ Type string `json:"type"` // K8s SecretType e.g. "Opaque", "kubernetes.io/tls"
+ Keys []string `json:"keys"` // never nil; lex-sorted
+ Age string `json:"age"` // RFC3339
+}
+
+// SecretDetail is the full detail view.
+type SecretDetail struct {
+ Secret Secret `json:"secret"`
+ Labels map[string]string `json:"labels"` // never nil
+ Annotations map[string]string `json:"annotations"` // never nil
+ Data map[string]string `json:"data"` // never nil; values are base64-decoded plaintext
+ Events []Event `json:"events"` // never nil
+}
+
+// sortedSecretKeys returns alphabetically sorted keys from a map[string][]byte.
+func sortedSecretKeys(m map[string][]byte) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// ListSecrets lists secrets. namespace="" means all namespaces.
+func (c *Client) ListSecrets(ctx context.Context, namespace string) ([]Secret, error) {
+ list, err := c.cs.CoreV1().Secrets(namespace).List(ctx, metav1.ListOptions{})
+ if err != nil {
+ return nil, err
+ }
+ out := make([]Secret, 0, len(list.Items))
+ for _, s := range list.Items {
+ keys := sortedSecretKeys(s.Data)
+ if keys == nil {
+ keys = []string{}
+ }
+ out = append(out, Secret{
+ Name: s.Name,
+ Namespace: s.Namespace,
+ Type: string(s.Type),
+ Keys: keys,
+ Age: s.CreationTimestamp.UTC().Format(time.RFC3339),
+ })
+ }
+ return out, nil
+}
+
+// GetSecret returns full detail for a secret including events.
+// client-go already base64-decodes s.Data values; we cast []byte to string directly.
+func (c *Client) GetSecret(ctx context.Context, namespace, name string) (*SecretDetail, error) {
+ s, err := c.cs.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{})
+ if err != nil {
+ return nil, err
+ }
+
+ labels := s.Labels
+ if labels == nil {
+ labels = map[string]string{}
+ }
+ annotations := s.Annotations
+ if annotations == nil {
+ annotations = map[string]string{}
+ }
+
+ data := make(map[string]string, len(s.Data))
+ for k, v := range s.Data {
+ data[k] = string(v)
+ }
+
+ keys := sortedSecretKeys(s.Data)
+ if keys == nil {
+ keys = []string{}
+ }
+
+ events, err := c.ListEvents(ctx, namespace, "Secret", name)
+ if err != nil {
+ events = []Event{}
+ }
+
+ return &SecretDetail{
+ Secret: Secret{
+ Name: s.Name,
+ Namespace: s.Namespace,
+ Type: string(s.Type),
+ Keys: keys,
+ Age: s.CreationTimestamp.UTC().Format(time.RFC3339),
+ },
+ Labels: labels,
+ Annotations: annotations,
+ Data: data,
+ Events: events,
+ }, nil
+}
diff --git a/internal/logs/journal.go b/internal/logs/journal.go
index 0dc8101..d3a3130 100644
--- a/internal/logs/journal.go
+++ b/internal/logs/journal.go
@@ -15,6 +15,7 @@ import (
"os/exec"
"regexp"
"strconv"
+ "sync"
"syscall"
"time"
)
@@ -40,6 +41,19 @@ type Filter struct {
N int // last-N entries (0 → no limit on tail mode; 200 default for static)
}
+// Available reports whether `journalctl` is present in PATH. The result is
+// cached for the lifetime of the process; we don't expect journalctl to come
+// or go after boot.
+//
+// In container deployments (distroless image) this is false, and the API
+// surface should return 503 rather than 500 with a confusing exec error.
+func Available() bool { return availableOnce() }
+
+var availableOnce = sync.OnceValue(func() bool {
+ _, err := exec.LookPath("journalctl")
+ return err == nil
+})
+
// validUnit blocks shell injection through the unit name without locking us
// down to one filename pattern.
var validUnit = regexp.MustCompile(`^[a-zA-Z0-9@_.\-:\\]+\.(service|socket|target|timer|path|mount|slice|scope)$`)
diff --git a/internal/network/iface.go b/internal/network/iface.go
index 24f7705..e7e57b7 100644
--- a/internal/network/iface.go
+++ b/internal/network/iface.go
@@ -67,13 +67,18 @@ func List(ctx context.Context) ([]Interface, error) {
out2 := make([]Interface, 0, len(raws))
for _, r := range raws {
+ flags := r.Flags
+ if flags == nil {
+ flags = []string{}
+ }
ifc := Interface{
Name: r.IfName,
MAC: r.Address,
State: r.Operstate,
MTU: r.MTU,
Type: r.LinkType,
- Flags: r.Flags,
+ Flags: flags,
+ IPs: []string{},
}
for _, a := range r.AddrInfo {
if a.Local == "" {
diff --git a/internal/network/ufw.go b/internal/network/ufw.go
index 409f128..d1355a9 100644
--- a/internal/network/ufw.go
+++ b/internal/network/ufw.go
@@ -45,7 +45,7 @@ func Status(ctx context.Context) (*UFWStatus, error) {
var numberedRE = regexp.MustCompile(`^\[\s*(\d+)\]\s+(.+?)\s{2,}([A-Z][A-Z ]+?)\s{2,}(.+?)\s*$`)
func parseUFWStatus(out []byte) *UFWStatus {
- st := &UFWStatus{}
+ st := &UFWStatus{Rules: []UFWRule{}}
sc := bufio.NewScanner(bytes.NewReader(out))
for sc.Scan() {
line := sc.Text()
diff --git a/internal/pty/pty.go b/internal/pty/pty.go
index 8a419a7..6a7d7c5 100644
--- a/internal/pty/pty.go
+++ b/internal/pty/pty.go
@@ -39,6 +39,9 @@ var AllowedShells = []string{
// PreferredShells is the fallback order if the client doesn't specify one.
var PreferredShells = []string{"/bin/bash", "/bin/sh"}
+// NsenterPath is the expected location of nsenter inside the container image.
+const NsenterPath = "/usr/bin/nsenter"
+
// MaxRows / MaxCols clamp window-size requests to sane bounds.
const (
MaxRows = 500
@@ -67,12 +70,41 @@ type Session struct {
// Options for New. Fields left zero/empty fall back to safe defaults.
type Options struct {
- Shell string // "" → first existing entry of PreferredShells
- Rows int
- Cols int
- Env []string // appended to the sanitized base env
+ Shell string // "" → first existing entry of PreferredShells
+ Rows int
+ Cols int
+ Env []string // appended to the sanitized base env
+ HostShell bool // wrap spawned process in nsenter -t 1 -m -u -i -n -p
+ // LoginMode runs /bin/login inside the host namespaces instead of jumping
+ // straight to a shell. The user types their host credentials into the same
+ // xterm; PAM authenticates via the host's policies and only then execs the
+ // user's shell at the user's uid. Requires HostShell.
+ LoginMode bool
}
+// loginScript is what we hand bash via -c when LoginMode is enabled.
+//
+// Two non-obvious things going on:
+//
+// 1. We don't use util-linux login(1). Its modern incarnation refuses to
+// run interactively when execv'd from a regular process (it expects to
+// be slave to getty/sshd, with specific TTY ioctls, and exits silently
+// otherwise). su(1) is a regular interactive program — it works.
+//
+// 2. We `setpriv --reuid=nobody` before invoking su. Root invoking su(1)
+// is special-cased to skip PAM authentication ("root can become
+// anyone") — without the privilege drop, the operator would get a
+// shell as the target user without ever proving the password. After
+// dropping to nobody:nogroup, su(1) runs full PAM auth, /etc/pam.d/su
+// enforces lockouts and audit, and on success setuid root via the
+// binary's setuid bit and then drops to the requested user.
+const loginScript = `
+printf '\nControlRoom Terminal — host login at %s\n' "$(hostname)"
+read -rp 'username: ' CR_USER
+[ -z "$CR_USER" ] && { echo 'no username — disconnecting'; exit 1; }
+exec setpriv --reuid=nobody --regid=nogroup --clear-groups -- su -l "$CR_USER"
+`
+
// New starts a shell under a fresh PTY. The returned Session is hot — the
// caller must Close() it eventually or the child leaks.
func New(id string, opts Options) (*Session, error) {
@@ -81,9 +113,31 @@ func New(id string, opts Options) (*Session, error) {
return nil, err
}
+ if opts.HostShell {
+ if _, err := os.Stat(NsenterPath); err != nil {
+ return nil, errors.New("CR_HOST_SHELL=true but /usr/bin/nsenter not found in image")
+ }
+ }
+ if opts.LoginMode && !opts.HostShell {
+ return nil, errors.New("LoginMode requires HostShell")
+ }
+
rows, cols := clampSize(opts.Rows, opts.Cols)
- cmd := exec.Command(shell)
+ var cmd *exec.Cmd
+ switch {
+ case opts.HostShell && opts.LoginMode:
+ // Enter all host namespaces, then prompt for username + run su -l.
+ // PAM authenticates via the host's /etc/pam.d/su; on success su execs
+ // the user's shell at the user's uid. The password never touches Go.
+ cmd = exec.Command(NsenterPath, "-t", "1", "-m", "-u", "-i", "-n", "-p", "--", "/bin/bash", "-c", loginScript)
+ case opts.HostShell:
+ // Enter all host namespaces (mount, uts, ipc, net, pid) via PID 1 so
+ // the operator gets a full host shell, not the container's view.
+ cmd = exec.Command(NsenterPath, "-t", "1", "-m", "-u", "-i", "-n", "-p", "--", shell, "--login")
+ default:
+ cmd = exec.Command(shell)
+ }
cmd.Env = baseEnv(opts.Env, shell)
// Run in its own session so signals stay scoped.
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
@@ -198,6 +252,7 @@ func isAllowed(path string) bool {
return false
}
+
func clampSize(rows, cols int) (int, int) {
if rows < 1 {
rows = 24
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..68ecdd9
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,5370 @@
+{
+ "name": "controlroom-web",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "controlroom-web",
+ "version": "0.0.1",
+ "dependencies": {
+ "@hookform/resolvers": "^3.9.0",
+ "@monaco-editor/react": "^4.6.0",
+ "@radix-ui/react-alert-dialog": "^1.1.2",
+ "@radix-ui/react-dialog": "^1.1.2",
+ "@radix-ui/react-dropdown-menu": "^2.1.2",
+ "@radix-ui/react-label": "^2.1.0",
+ "@radix-ui/react-separator": "^1.1.0",
+ "@radix-ui/react-slot": "^1.1.0",
+ "@tanstack/react-query": "^5.59.0",
+ "@xterm/addon-fit": "^0.10.0",
+ "@xterm/addon-web-links": "^0.11.0",
+ "@xterm/xterm": "^5.5.0",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.1",
+ "input-otp": "^1.4.1",
+ "lucide-react": "^0.453.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-hook-form": "^7.53.0",
+ "react-router-dom": "^6.27.0",
+ "tailwind-merge": "^2.5.4",
+ "tailwindcss-animate": "^1.0.7",
+ "zod": "^3.23.8"
+ },
+ "devDependencies": {
+ "@types/node": "^22.7.5",
+ "@types/react": "^18.3.11",
+ "@types/react-dom": "^18.3.0",
+ "@typescript-eslint/eslint-plugin": "^8.8.1",
+ "@typescript-eslint/parser": "^8.8.1",
+ "@vitejs/plugin-react": "^4.3.2",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^8.57.1",
+ "eslint-plugin-react-hooks": "^5.0.0",
+ "eslint-plugin-react-refresh": "^0.4.12",
+ "postcss": "^8.4.47",
+ "tailwindcss": "^3.4.13",
+ "typescript": "^5.6.2",
+ "vite": "^5.4.8"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
+ "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/core/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
+ "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.6.0",
+ "globals": "^13.19.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
+ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
+ "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.6",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
+ "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.5",
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
+ "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.6"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
+ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+ "license": "MIT"
+ },
+ "node_modules/@hookform/resolvers": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
+ "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react-hook-form": "^7.0.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array": {
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
+ "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
+ "deprecated": "Use @eslint/config-array instead",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanwhocodes/object-schema": "^2.0.3",
+ "debug": "^4.3.1",
+ "minimatch": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=10.10.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/object-schema": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
+ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
+ "deprecated": "Use @eslint/object-schema instead",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@monaco-editor/loader": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
+ "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
+ "license": "MIT",
+ "dependencies": {
+ "state-local": "^1.0.6"
+ }
+ },
+ "node_modules/@monaco-editor/react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
+ "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@monaco-editor/loader": "^1.5.0"
+ },
+ "peerDependencies": {
+ "monaco-editor": ">= 0.25.0 < 1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-alert-dialog": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
+ "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dialog": "1.1.15",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
+ "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
+ "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
+ "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
+ "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
+ "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
+ "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-menu": "2.1.16",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
+ "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
+ "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-label": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
+ "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
+ "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
+ "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popper": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
+ "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-rect": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1",
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
+ "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+ "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
+ "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-separator": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
+ "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
+ "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
+ "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
+ "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
+ "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-rect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
+ "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
+ "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/rect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
+ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
+ "license": "MIT"
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.2",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
+ "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
+ "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz",
+ "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
+ "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
+ "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
+ "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
+ "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
+ "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
+ "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
+ "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
+ "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz",
+ "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
+ "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz",
+ "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
+ "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
+ "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
+ "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
+ "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
+ "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz",
+ "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
+ "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
+ "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
+ "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
+ "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
+ "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.100.9",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz",
+ "integrity": "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.100.9",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.9.tgz",
+ "integrity": "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.100.9"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.18",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz",
+ "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.28",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz",
+ "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.59.2",
+ "@typescript-eslint/type-utils": "8.59.2",
+ "@typescript-eslint/utils": "8.59.2",
+ "@typescript-eslint/visitor-keys": "8.59.2",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.59.2",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz",
+ "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.59.2",
+ "@typescript-eslint/types": "8.59.2",
+ "@typescript-eslint/typescript-estree": "8.59.2",
+ "@typescript-eslint/visitor-keys": "8.59.2",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz",
+ "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.59.2",
+ "@typescript-eslint/types": "^8.59.2",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz",
+ "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.59.2",
+ "@typescript-eslint/visitor-keys": "8.59.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz",
+ "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz",
+ "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.59.2",
+ "@typescript-eslint/typescript-estree": "8.59.2",
+ "@typescript-eslint/utils": "8.59.2",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz",
+ "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz",
+ "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.59.2",
+ "@typescript-eslint/tsconfig-utils": "8.59.2",
+ "@typescript-eslint/types": "8.59.2",
+ "@typescript-eslint/visitor-keys": "8.59.2",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz",
+ "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.59.2",
+ "@typescript-eslint/types": "8.59.2",
+ "@typescript-eslint/typescript-estree": "8.59.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.59.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz",
+ "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.59.2",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz",
+ "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@xterm/addon-fit": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
+ "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@xterm/xterm": "^5.0.0"
+ }
+ },
+ "node_modules/@xterm/addon-web-links": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
+ "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@xterm/xterm": "^5.0.0"
+ }
+ },
+ "node_modules/@xterm/xterm": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
+ "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
+ "license": "MIT"
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "license": "MIT"
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
+ "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.2",
+ "caniuse-lite": "^1.0.30001787",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.29",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
+ "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001792",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
+ "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+ "license": "MIT"
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "license": "MIT"
+ },
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/dompurify": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
+ "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "peer": true,
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.353",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz",
+ "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
+ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
+ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.6.1",
+ "@eslint/eslintrc": "^2.1.4",
+ "@eslint/js": "8.57.1",
+ "@humanwhocodes/config-array": "^0.13.0",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@nodelib/fs.walk": "^1.2.8",
+ "@ungap/structured-clone": "^1.2.0",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^7.2.2",
+ "eslint-visitor-keys": "^3.4.3",
+ "espree": "^9.6.1",
+ "esquery": "^1.4.2",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "globals": "^13.19.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "is-path-inside": "^3.0.3",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3",
+ "strip-ansi": "^6.0.1",
+ "text-table": "^0.2.0"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
+ "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.4.26",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
+ "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": ">=8.40"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/eslint/node_modules/brace-expansion": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/eslint/node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/eslint/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/espree": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.9.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.4.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^3.0.4"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
+ "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.3",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/glob/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/globals": {
+ "version": "13.24.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.20.2"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/input-otp": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
+ "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.2",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-path-inside": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "license": "MIT"
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.453.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.453.0.tgz",
+ "integrity": "sha512-kL+RGZCcJi9BvJtzg2kshO192Ddy9hv3ij+cPrVPWSRzgCWCVazoQJxOjAwgK53NomL07HB7GPHW120FimjNhQ==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/marked": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
+ "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/monaco-editor": {
+ "version": "0.55.1",
+ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
+ "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "dompurify": "3.2.7",
+ "marked": "14.0.0"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.38",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
+ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.14",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "license": "MIT"
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-hook-form": {
+ "version": "7.75.0",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.75.0.tgz",
+ "integrity": "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+ "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
+ "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
+ "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2",
+ "react-router": "6.30.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
+ "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.3",
+ "@rollup/rollup-android-arm64": "4.60.3",
+ "@rollup/rollup-darwin-arm64": "4.60.3",
+ "@rollup/rollup-darwin-x64": "4.60.3",
+ "@rollup/rollup-freebsd-arm64": "4.60.3",
+ "@rollup/rollup-freebsd-x64": "4.60.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.3",
+ "@rollup/rollup-linux-arm64-musl": "4.60.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.3",
+ "@rollup/rollup-linux-loong64-musl": "4.60.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.3",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.3",
+ "@rollup/rollup-linux-x64-gnu": "4.60.3",
+ "@rollup/rollup-linux-x64-musl": "4.60.3",
+ "@rollup/rollup-openbsd-x64": "4.60.3",
+ "@rollup/rollup-openharmony-arm64": "4.60.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.3",
+ "@rollup/rollup-win32-x64-gnu": "4.60.3",
+ "@rollup/rollup-win32-x64-msvc": "4.60.3",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
+ "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/state-local": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
+ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
+ "license": "MIT"
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
+ "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tailwindcss-animate": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
+ "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "tailwindcss": ">=3.0.0 || insiders"
+ }
+ },
+ "node_modules/text-table": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
index 7153f4f..fb8dc32 100644
--- a/web/package.json
+++ b/web/package.json
@@ -12,6 +12,7 @@
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
+ "@monaco-editor/react": "^4.6.0",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
diff --git a/web/src/components/AppLayout.tsx b/web/src/components/AppLayout.tsx
index 928f25f..0d6192b 100644
--- a/web/src/components/AppLayout.tsx
+++ b/web/src/components/AppLayout.tsx
@@ -8,6 +8,7 @@ import {
Download,
LogOut,
Menu,
+ Package,
ScrollText,
ServerCog,
Settings,
@@ -20,15 +21,26 @@ import { Button } from '@/components/ui/button'
import { ThemeToggle } from '@/components/ThemeToggle'
import { PublicBindBanner } from '@/components/PublicBindBanner'
import { useLogout, useMe } from '@/lib/auth'
-import { useSystemOverview } from '@/lib/system'
+import { type SystemCapabilities, useCapabilities, useSystemOverview } from '@/lib/system'
import { cn } from '@/lib/utils'
import { useNavigate } from 'react-router-dom'
-const NAV: { to: string; label: string; Icon: typeof Activity; ready?: boolean }[] = [
+// `requires` names a backend capability that must be true (per
+// /api/system/capabilities) for the entry to render. Unset = always show.
+type NavEntry = {
+ to: string
+ label: string
+ Icon: typeof Activity
+ ready?: boolean
+ requires?: keyof SystemCapabilities
+}
+
+const NAV: NavEntry[] = [
{ to: '/', label: 'Dashboard', Icon: Activity, ready: true },
{ to: '/updates', label: 'Updates', Icon: Download, ready: true },
- { to: '/services', label: 'Services', Icon: Cog, ready: true },
- { to: '/containers', label: 'Containers', Icon: Container, ready: true },
+ { to: '/services', label: 'Services', Icon: Cog, ready: true, requires: 'systemd' },
+ { to: '/containers', label: 'Containers', Icon: Container, ready: true, requires: 'docker' },
+ { to: '/kubernetes', label: 'Kubernetes', Icon: Package, ready: true, requires: 'kubernetes' },
{ to: '/terminal', label: 'Terminal', Icon: TerminalIcon, ready: true },
{ to: '/network', label: 'Network', Icon: Wifi, ready: true },
{ to: '/logs', label: 'Logs', Icon: ScrollText, ready: true },
@@ -50,6 +62,16 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
}
function Sidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
+ const caps = useCapabilities()
+ // While capabilities are loading we show every entry; if the request fails
+ // we also fall back to showing them (better to surface a 503 once than to
+ // hide tabs that should be available).
+ const visible = NAV.filter((entry) => {
+ if (!entry.requires) return true
+ if (!caps.data) return true
+ return caps.data[entry.requires]
+ })
+
return (
<>
{/* Mobile drawer overlay */}
@@ -78,7 +100,7 @@ function Sidebar({ open, onClose }: { open: boolean; onClose: () => void }) {