diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d5018cbe6..6bf55774c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -80,6 +80,7 @@ jobs: go: ${{ steps.combine.outputs.go }} web: ${{ steps.combine.outputs.web }} charts: ${{ steps.combine.outputs.charts }} + standalone: ${{ steps.combine.outputs.standalone }} e2e: ${{ steps.combine.outputs.e2e }} weblive: ${{ steps.combine.outputs.weblive }} images: ${{ steps.combine.outputs.images }} @@ -149,6 +150,18 @@ jobs: # from these; an edit to either must re-run it. - 'operator/config/crd/**' - 'hack/sync-chart-crds.sh' + # Compose builds only API, its local module dependencies, and web. + # Deployment prose and unrelated Go/chart changes need no smoke VM. + standalone: + - 'api/**' + - 'netguard/**' + - 'gameaction/**' + - 'gp-module/**' + - 'telemetryschema/**' + - 'web/**' + - 'deploy/standalone/compose*.yaml' + - 'deploy/standalone/smoke.py' + - '.dockerignore' e2etree: - 'test/e2e/**' - 'deploy/kind/**' @@ -294,6 +307,7 @@ jobs: GO: ${{ steps.scope.outputs.go }} WEB: ${{ steps.filter.outputs.web }} CHARTS: ${{ steps.filter.outputs.charts }} + STANDALONE: ${{ steps.filter.outputs.standalone }} E2ETREE: ${{ steps.filter.outputs.e2etree }} GAMECOVERAGE: ${{ steps.filter.outputs.gamecoverage }} CI: ${{ steps.filter.outputs.ci == 'true' || steps.scope.outputs.all == 'true' }} @@ -308,6 +322,7 @@ jobs: gov=false; { [ "$GO" = true ] || [ "$CI" = true ]; } && gov=true webv=false; { [ "$WEB" = true ] || [ "$CI" = true ]; } && webv=true chv=false; { [ "$CHARTS" = true ] || [ "$CI" = true ]; } && chv=true + standalonev=false; { [ "$STANDALONE" = true ] || [ "$CI" = true ]; } && standalonev=true e2ev=false; { [ "$GO" = true ] || [ "$CHARTS" = true ] || [ "$E2ETREE" = true ] || [ "$GAMECOVERAGE" = true ] || [ "$CI" = true ]; } && e2ev=true wlv=false; { [ "$e2ev" = true ] || [ "$WEB" = true ] || [ "$CI" = true ]; } && wlv=true ghv=false; { [ "$GITHUB" = true ] || [ "$CI" = true ]; } && ghv=true @@ -323,6 +338,7 @@ jobs: echo "go=$gov" echo "web=$webv" echo "charts=$chv" + echo "standalone=$standalonev" echo "e2e=$e2ev" echo "weblive=$wlv" echo "images=$wlv" @@ -946,6 +962,52 @@ jobs: - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 - run: helm lint charts/gameplane + standalone-compose: + name: standalone Compose smoke + needs: [changes] + if: needs.changes.outputs.standalone == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + # Match Compose's image names for its fixed gameplane-panel project. + # Separate cache scopes prevent the API/web exports overwriting each other. + - name: build standalone API + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: api/Dockerfile + platforms: linux/amd64 + load: true + push: false + tags: gameplane-panel-gameplane-api:latest + cache-from: type=gha,scope=standalone-api + cache-to: type=gha,mode=max,scope=standalone-api + - name: build standalone dashboard + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: web/Dockerfile + platforms: linux/amd64 + load: true + push: false + tags: gameplane-panel-web:latest + cache-from: type=gha,scope=standalone-web + cache-to: type=gha,mode=max,scope=standalone-web + - name: start standalone panel + run: docker compose -f deploy/standalone/compose.yaml up -d --no-build --pull never + - name: bootstrap and verify persistent standalone panel + run: python3 deploy/standalone/smoke.py + - name: container logs + if: failure() + run: docker compose -f deploy/standalone/compose.yaml logs + - name: remove smoke containers and volume + if: always() + run: docker compose -f deploy/standalone/compose.yaml down --volumes + chart-template: name: chart render needs: [changes] @@ -960,6 +1022,8 @@ jobs: - run: helm template gameplane charts/gameplane > /tmp/rendered.yaml - name: gateway isolation and API-disabled chart renders run: python3 charts/tests/check_gateway.py + - name: standalone panel isolation and persistence chart renders + run: python3 charts/tests/check_standalone.py - name: sanity-check rendered manifests run: | grep -q 'kind: Deployment' /tmp/rendered.yaml @@ -1397,8 +1461,8 @@ jobs: # The api's PostgreSQL backend (--db-driver=postgres) is compiled only # with -tags postgres, so the `go` matrix and `lint` never build it. This - # job builds and vets api with the tag and runs api/internal/db's tests - # against a real PostgreSQL service; the db_postgres_test.go tests skip + # job builds and vets api with the tag and runs database and standalone + # management storage tests against a real PostgreSQL service; these tests skip # unless GAMEPLANE_TEST_POSTGRES_DSN is set. The server keeps the image's # default (non-C) locale on purpose: TestPostgres_TextOrderingIsByteWise # checks the migrations' COLLATE "C" columns sort byte-wise regardless. @@ -1449,11 +1513,11 @@ jobs: - name: go vet -tags postgres working-directory: api run: go vet -tags postgres ./... - - name: test api/internal/db against PostgreSQL + - name: test database and standalone management storage against PostgreSQL working-directory: api env: GAMEPLANE_TEST_POSTGRES_DSN: postgres://postgres@localhost:5432/gameplane_test?sslmode=disable - run: go test -tags postgres -count=1 ./internal/db/... + run: go test -tags postgres -count=1 ./internal/db/... ./internal/controlplane/... # --------------------------------------------------------------------- # kind e2e — one job per suite, each with its own throwaway cluster on @@ -2125,6 +2189,7 @@ jobs: web-e2e-mock, helm, chart-template, + standalone-compose, workflow-lint, go-e2e-unit, api-postgres, @@ -2231,7 +2296,7 @@ jobs: const NEEDS_ORDER = [ 'changes', 'build-images', 'build-images-arm64', 'vuln-images', 'capture-sidecar-setcap-proof', 'docs', 'codegen-drift', 'submodule-freshness', 'dependency-review', 'vuln-go', 'lint', 'go', - 'web', 'web-e2e-mock', 'helm', 'chart-template', 'workflow-lint', 'go-e2e-unit', + 'web', 'web-e2e-mock', 'helm', 'chart-template', 'standalone-compose', 'workflow-lint', 'go-e2e-unit', 'api-postgres', 'e2e-buckets', 'e2e-go', 'e2e-go-arm64', 'e2e-multicluster', 'e2e-multicluster-arm64', 'e2e-upgrade', 'e2e-upgrade-arm64', 'e2e-web-live', 'e2e-web-live-arm64', 'e2e-game-bot', 'e2e-capture-overhead', 'e2e-kind-ingress-smoke', @@ -2254,6 +2319,7 @@ jobs: 'web-e2e-mock': (n) => n === 'web e2e (mock)', helm: (n) => n === 'helm lint', 'chart-template': (n) => n === 'chart render', + 'standalone-compose': (n) => n === 'standalone Compose smoke', 'workflow-lint': (n) => n === 'workflow lint', 'go-e2e-unit': (n) => n === 'go (test/e2e)', 'api-postgres': (n) => n === 'api (postgres)', diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a5a20d1..399d0a86c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ reaches `1.0.0`. Pre-1.0 minor versions may contain breaking changes. ### Upgrade Notes +- **Standalone panel/API:** run the central panel with Docker Compose or the + standalone Helm profile, without a local game cluster or operator. Compose + needs no Kubernetes installation on the panel host. + Register remote clusters explicitly. Back up the database and matching + credential encryption key separately, with restricted access and encrypted + storage for key backups. Supplied profiles use key storage outside the database + volume. Preserve the original key when migrating legacy `/data/panel.key` + installations. See [standalone installation](docs/standalone-panel.md). +- **Breaking: selected-cluster module/template grants:** in combined and + standalone installations, catalog permissions now require a cluster-wide + grant on the selected cluster or wildcard cluster. A grant on `local` retains + local access but no longer authorizes a remote catalog. Before upgrading, + identify users who rely on remote catalog access and grant a role containing + `modules:read`, `modules:manage`, `templates:read`, and/or `templates:write` + on each intended remote cluster, selecting **All namespaces**. Grant only the + operations each user needs; namespace-only grants do not authorize catalogs. + See [grant workload access](docs/standalone-panel.md#grant-workload-access). - **Telemetry is on by default for new installs, after a first-login notice:** a new install with a telemetry destination in effect now shares basic and extended data by default; the stock destination is the project's @@ -69,6 +86,17 @@ reaches `1.0.0`. Pre-1.0 minor versions may contain breaking changes. ### Added +- **Independent central panel:** Docker Compose runs the API/dashboard without + host Kubernetes; a standalone Helm profile runs them without a central game + operator or workloads. SQL stores registrations, settings, and encrypted + credentials; explicitly selected remote clusters host games through their own + operators and agents. +- **Credential maintenance:** standalone keys have separate generated storage + or read-only provisioned custody, the offline transactional `rotate-panel-key` + command, and `PUT /clusters/{name}/kubeconfig` preserving registration identity + and grants. `PUT`/`DELETE /clusters/{name}/gateway` manage remote gateway + credentials; `api.remoteAllowedCIDRs` restricts workload API and gateway + destinations. See [standalone setup](docs/standalone-panel.md). - **Extended telemetry tier:** a second, separately consented tier with a random install ID, environment, game-usage and feature-adoption categories. Every value comes from a fixed set, a band or a number; no @@ -135,6 +163,21 @@ reaches `1.0.0`. Pre-1.0 minor versions may contain breaking changes. Team17's licensed hosting partners, so no public image can install it. Its hand-run e2e test and probe package are removed with it. +### Security + +- **Standalone credential protection:** encryption keys are kept outside + database volumes, with durable publication, fail-closed file-access checks, + read-only provisioned custody, and authenticated transactional rotation. + Database and key backups need separate access policies; recovery requires + the matching key. See [storage and recovery](docs/standalone-panel.md#storage-and-recovery). +- **Remote workload boundaries:** standalone connections require verified TLS, + reject unsafe destinations at dial time, and bound finite responses, watch + frames, and retained observations. Private networks remain reachable unless + destination CIDRs restrict them; configure `GAMEPLANE_REMOTE_ALLOWED_CIDRS` + or `api.remoteAllowedCIDRs` for the workload APIs and gateways. Module and + template authorization follows the selected cluster in every deployment mode. + See [security](docs/security.md#kubeconfig-secret-handling). + ## [0.3.0] — 2026-10-05 The first Gameplane release without a pre-release suffix, a pre-v1 release. It diff --git a/README.md b/README.md index c33345f38..cf73f0c8a 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ A Kubernetes-native game server control panel. Open-source alternative to Docker — scales from a single-node k3s homelab to multi-node production clusters without changing the operational model. +The dashboard and API can also run on a host without Kubernetes using +[standalone Docker Compose](docs/standalone-panel.md). Game servers still run +on registered Kubernetes clusters, each with its own operator and agents. + > Status: **pre-v1 release** (`v0.3.0`). The operator, API, agent, and dashboard > are feature-complete for the v1 scope and stabilized for external testing. > See [Pre-v1 status & known limitations](#pre-v1-status--limitations) before @@ -63,15 +67,15 @@ are feature-complete for the v1 scope and stabilized for external testing. | Dimension | Gameplane | Pterodactyl | CubeCoders AMP | Agones | |-----------|-----------|-------------|----------------|--------| -| Deployment/runtime model | Kubernetes-native CRDs and controller-runtime operator; scales from k3s homelab to multi-node clusters. [BETA] [G-a](docs/comparison-sources.md#gameplane-row-a) | Self-hosted Panel with PHP/MySQL/Redis dependencies; Wings backend manages Docker containers. [P-a](docs/comparison-sources.md#pterodactyl-row-a) | Web-based control panel supporting Windows (native) and Linux (Debian 10+). [C-a](docs/comparison-sources.md#cubecoders-row-a) | Kubernetes-native library extending K8s with GameServer/Fleet CRDs. [A-a](docs/comparison-sources.md#agones-row-a) | +| Deployment/runtime model | Combined Kubernetes install or standalone panel on Docker Compose; game workloads use Kubernetes CRDs and a controller-runtime operator, from k3s to multi-node clusters. [BETA] [G-a](docs/comparison-sources.md#gameplane-row-a) | Self-hosted Panel with PHP/MySQL/Redis dependencies; Wings backend manages Docker containers. [P-a](docs/comparison-sources.md#pterodactyl-row-a) | Web-based control panel supporting Windows (native) and Linux (Debian 10+). [C-a](docs/comparison-sources.md#cubecoders-row-a) | Kubernetes-native library extending K8s with GameServer/Fleet CRDs. [A-a](docs/comparison-sources.md#agones-row-a) | | Scaling & auto-sleep | Opt-in idle auto-sleep with configurable wake windows, manual wake button, or wake-on-connect. Minecraft/Terraria full protocol support; others use packet heuristics. [optional] [G-b](docs/comparison-sources.md#gameplane-row-b) | Multi-node support with cron-based power scheduling; no dedicated idle/auto-sleep feature. [P-b](docs/comparison-sources.md#pterodactyl-row-b) | Instance Automatic Sleep feature; multi-server architecture with controller managing instances. [C-b](docs/comparison-sources.md#cubecoders-row-b) | Fleet autoscaling via buffer/webhook strategies; no idle/sleep state. [A-b](docs/comparison-sources.md#agones-row-b) | | Inbound connectivity (NAT traversal, relay) | Integrated frp, Tailscale, playit relay sidecars; playit mappings user-managed via playit.gg account. [optional; disabled by default] [G-c](docs/comparison-sources.md#gameplane-row-c) | No integrated relay sidecars; manual proxy/port forwarding configuration required. [P-c](docs/comparison-sources.md#pterodactyl-row-c) | not publicly documented (checked 2026-09-02) [C-c](docs/comparison-sources.md#cubecoders-row-c) | No relay or NAT traversal features documented. [A-c](docs/comparison-sources.md#agones-row-c) | | Backup and restore | Restic snapshots to S3-compatible storage; on-demand or cron-scheduled via BackupSchedule; one-click restore. [G-d](docs/comparison-sources.md#gameplane-row-d) | Wings (local, default) or S3-compatible backup drivers; cron scheduling and on-demand backups. [P-d](docs/comparison-sources.md#pterodactyl-row-d) | not publicly documented (checked 2026-09-02) [C-d](docs/comparison-sources.md#cubecoders-row-d) | not applicable (Agones is a Kubernetes operator library) [A-d](docs/comparison-sources.md#agones-row-d) | | Access control & authentication | Local argon2id + OIDC (Keycloak/Google/GitHub); three built-in roles (admin/operator/viewer); custom roles supported. [G-e](docs/comparison-sources.md#gameplane-row-e) | 2FA configurable per account or admin-only; subuser management via Artisan CLI. [P-e](docs/comparison-sources.md#pterodactyl-row-e) | Role-based access control; OIDC single-sign-on in Advanced Edition. [C-e](docs/comparison-sources.md#cubecoders-row-e) | not applicable (Agones is a Kubernetes operator library) [A-e](docs/comparison-sources.md#agones-row-e) | | Game template distribution | OCI bundles via ModuleSource (git/http/oci/local/upload); optional cosign signature verification per source. 30 ready-to-use templates shipped. [G-f](docs/comparison-sources.md#gameplane-row-f) | Community eggs repository (eggs.pterodactyl.io) with nests and custom egg creation support. [P-f](docs/comparison-sources.md#pterodactyl-row-f) | Customizable templates framework; community-contributed templates available via external repositories. [C-f](docs/comparison-sources.md#cubecoders-row-f) | not applicable (Agones is a Kubernetes operator library) [A-f](docs/comparison-sources.md#agones-row-f) | -| Multi-tenancy & multi-cluster | Cluster CRD, remote Pod logs/PTY, and optional private gateway for supported agent operations. UID-bound remote capture downloads/cleanup; modpack and ID-list configuration on the selected cluster. Requires matching gateway, operator, agent and capture-sidecar versions. [G-g](docs/comparison-sources.md#gameplane-row-g) | Single Panel managing multiple nodes; no documented remote cluster or cross-cluster streaming. [P-g](docs/comparison-sources.md#pterodactyl-row-g) | Multi-server management via controller architecture; multi-tenancy not supported in AMP 2 (planned for AMP 3). [C-g](docs/comparison-sources.md#cubecoders-row-g) | Multi-cluster allocation via GameServerAllocationPolicy; allocator service with mTLS authentication. [A-g](docs/comparison-sources.md#agones-row-g) | +| Multi-tenancy & multi-cluster | Remote registration through Cluster CRDs in combined mode or SQL in standalone mode; central user grants, remote Pod logs/PTY, and optional private gateway for agent operations. UID-bound capture downloads/cleanup; modpack and ID-list configuration on the selected cluster. Requires matching gateway, operator, agent and capture-sidecar versions. [G-g](docs/comparison-sources.md#gameplane-row-g) | Single Panel managing multiple nodes; no documented remote cluster or cross-cluster streaming. [P-g](docs/comparison-sources.md#pterodactyl-row-g) | Multi-server management via controller architecture; multi-tenancy not supported in AMP 2 (planned for AMP 3). [C-g](docs/comparison-sources.md#cubecoders-row-g) | Multi-cluster allocation via GameServerAllocationPolicy; allocator service with mTLS authentication. [A-g](docs/comparison-sources.md#agones-row-g) | | Licensing | GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). [G-h](docs/comparison-sources.md#gameplane-row-h) | MIT License (Panel and Wings). [P-h](docs/comparison-sources.md#pterodactyl-row-h) | Proprietary; per-instance tiers (Standard/Professional/Advanced/Enterprise). [C-h](docs/comparison-sources.md#cubecoders-row-h) | Apache License 2.0. [A-h](docs/comparison-sources.md#agones-row-h) | -| Target operator scope (self-hosted vs. managed SaaS) | Self-hosted only; runs on Kubernetes (k3s, kubeadm, managed services); no managed SaaS offering. [G-i](docs/comparison-sources.md#gameplane-row-i) | Self-hosted only; requires Linux system capable of running Docker containers. [P-i](docs/comparison-sources.md#pterodactyl-row-i) | Self-installed on user hardware (Windows or Linux); no managed SaaS version. [C-i](docs/comparison-sources.md#cubecoders-row-i) | Self-hosted operator software; runs anywhere Kubernetes can run. [A-i](docs/comparison-sources.md#agones-row-i) | +| Target operator scope (self-hosted vs. managed SaaS) | Self-hosted only; panel runs on Docker Compose or Kubernetes, while games require Kubernetes (k3s, kubeadm, managed services); no managed SaaS offering. [G-i](docs/comparison-sources.md#gameplane-row-i) | Self-hosted only; requires Linux system capable of running Docker containers. [P-i](docs/comparison-sources.md#pterodactyl-row-i) | Self-installed on user hardware (Windows or Linux); no managed SaaS version. [C-i](docs/comparison-sources.md#cubecoders-row-i) | Self-hosted operator software; runs anywhere Kubernetes can run. [A-i](docs/comparison-sources.md#agones-row-i) | ## Features @@ -99,7 +103,7 @@ Gameplane supports two distinct mod installation models, depending on how the ga Gameplane integrates with **10 mod registries**: Modrinth, CurseForge, Thunderstore, Hangar, Factorio Mod Portal, Steam Workshop, SpigotMC, GitHub Releases, uMod, and Nexus Mods. - **No setup required**: Modrinth, Thunderstore, Hangar, Factorio, SpigotMC, GitHub, and uMod work out of the box. -- **API Key required**: CurseForge, Steam Workshop, and Nexus Mods require an API key configured under **Settings → Mod registries**. Keys are securely stored in Kubernetes Secrets. +- **API Key required**: CurseForge, Steam Workshop, and Nexus Mods require an API key configured under **Settings → Mod registries**. Combined installations store keys in Kubernetes Secrets; standalone panels encrypt them in the API database. - **Registry Caveats**: Nexus Mods is browse-only because download links require a premium account and direct requester IP. Factorio downloads require the user's `factorio.com` credentials in the install form. ## Architecture @@ -128,7 +132,7 @@ Central API (Go): authentication, RBAC, REST, WebSocket, aggregation └──────────────────────────────────────────────────────────────┘ ``` -The central API connects directly to agents in its local cluster. Remote agent +In a combined installation, the central API connects directly to agents in its local cluster. Remote agent operations use the optional gateway, which runs the API image's `gateway` subcommand and verifies the selected GameServer identity. Direct Kubernetes connectivity is still required for every registered cluster. Each cluster retains @@ -136,6 +140,13 @@ its own operator and storage; users and authorization stay central. See [gateway installation](docs/gateway-install.md) and [remote request routing](docs/multicluster-agent-gateway.md). +A standalone panel has no `local` cluster. It stores remote registrations and +encrypted credentials in SQL and connects to the registered workload clusters. +Back up its database and matching encryption key separately, with restricted +access and encrypted storage for the key backup. Supplied profiles keep the key +at `/keys/panel.key`, outside database storage; `/data/panel.key` remains the +legacy default for custom deployments. See [storage and recovery](docs/standalone-panel.md#storage-and-recovery). + ### Components | Path | Language | Description | @@ -197,6 +208,7 @@ Gameplane extends Kubernetes using custom resources under `gameplane.local/v1alp ├── mcp-server/ # Optional read-only Model Context Protocol server ├── charts/gameplane/ # Helm deployment chart ├── deploy/kind/ # Local dev environment bootstrap scripts +├── deploy/standalone/ # API + dashboard Docker Compose deployment ├── docs/ # Technical documentation & guides ├── specs/ # Feature specifications and proposals └── test/ # Integration & end-to-end kind test suites @@ -204,6 +216,20 @@ Gameplane extends Kubernetes using custom resources under `gameplane.local/v1alp ## Installation +Choose where the panel and game workloads run: + +| Installation | Panel host | Game workloads | Guide | +|---|---|---|---| +| Combined Helm install | Kubernetes | Local and registered remote clusters | [Combined installation](docs/install.md) | +| Standalone panel | Docker Compose, or a panel-only Helm release | Registered remote Kubernetes clusters | [Standalone setup](docs/standalone-panel.md) | +| Remote operator and optional gateway | Central panel runs elsewhere | This Kubernetes cluster | [Remote cluster setup](docs/standalone-panel.md#install-and-register-remote-game-clusters) | + +Standalone support requires a checkout or release containing this feature. The +guide builds the Compose images from that checkout and covers admin setup, +HTTPS access, remote registration, permissions, and database/key backups. + +For the default combined installation, use Helm: + The Helm chart and component images are published to the GitHub Container Registry as OCI artifacts — no `helm repo add` required: diff --git a/api/Dockerfile b/api/Dockerfile index 1613efe8a..20738fa8a 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -30,9 +30,14 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \ -ldflags="-s -w -X main.Version=${VERSION}" \ -o /out/api ./cmd +RUN mkdir -p /out/data /out/keys && chmod 0700 /out/keys && chown 65532:65532 /out/data /out/keys + FROM gcr.io/distroless/static:nonroot WORKDIR / COPY --from=build /out/api /api +# Seed named Docker volumes with a directory writable by the runtime user. +COPY --from=build --chown=65532:65532 /out/data /data +COPY --from=build --chown=65532:65532 /out/keys /keys USER 65532:65532 EXPOSE 8000 VOLUME ["/data"] diff --git a/api/cmd/main.go b/api/cmd/main.go index 56990e0a4..44d7d3a7d 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -17,7 +17,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - ctrl "sigs.k8s.io/controller-runtime" "github.com/GameplanePanel/gameplane/api/internal/audit" "github.com/GameplanePanel/gameplane/api/internal/auth" @@ -27,7 +26,6 @@ import ( "github.com/GameplanePanel/gameplane/api/internal/notify" "github.com/GameplanePanel/gameplane/api/internal/rbac" "github.com/GameplanePanel/gameplane/api/internal/registry" - "github.com/GameplanePanel/gameplane/api/internal/scope" "github.com/GameplanePanel/gameplane/api/internal/steam" "github.com/GameplanePanel/gameplane/api/internal/telemetry" "github.com/GameplanePanel/gameplane/api/internal/ws" @@ -86,6 +84,16 @@ func main() { os.Exit(1) } return + case "rotate-panel-key": + if err := rotatePanelKey(ctx, args[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + return + } + logger.Error("rotate-panel-key", "err", err) + os.Exit(1) + } + logger.Info("panel key rotation completed; restart the API with the new key file") + return default: logger.Error("unknown subcommand", "name", args[0]) os.Exit(2) @@ -180,25 +188,11 @@ func main() { os.Exit(1) } - restCfg, err := ctrl.GetConfig() - if err != nil { - logger.Error("get kubeconfig", "err", err) - os.Exit(1) - } - k8s, err := kube.New(restCfg) + k8s, reg, err := managementClients(ctx, cfg, store) if err != nil { - logger.Error("kube client", "err", err) + logger.Error("initialize management storage", "err", err) os.Exit(1) } - // reg is the pool of per-cluster clients that cluster-dispatch-aware - // handlers resolve `?cluster=` against. A single-cluster install has - // exactly one entry: the home cluster registered as scope.DefaultCluster - // ("local"). Cluster-dispatch handlers (Resources, PodEvents, Lifecycle, - // Ownership, Events, Destinations) take reg; home-cluster/control-plane - // handlers (SystemLogs, Modules, Cluster, ClusterActions, AuthProviderSecrets, ws) - // still take the bare k8s client directly. - reg := kube.NewRegistry(scope.DefaultCluster) - reg.Set(scope.DefaultCluster, k8s) sessions := auth.NewSessionStore(store) sessions.StartGC(ctx, time.Hour) @@ -283,7 +277,7 @@ func main() { // configured in Admin Settings. Best-effort mirror — never gates // reconciliation or requests. notifier := notify.New(store, k8s, cfg.namespace) - go notifier.Run(ctx) + go notifier.RunWithRegistry(ctx, reg) // Cluster registry: watch Cluster CRDs and populate the client registry // with remote cluster connections. Single-cluster installs only have the @@ -396,11 +390,12 @@ func main() { handlers.MountCluster(p, reg, store, Version, cfg.clusterOps, cfg.updateChannel) handlers.MountClusterActions(p, k8s, cfg.clusterOps, cfg.clusterExternalAddress) handlers.MountClusters(p, reg, k8s, cfg.namespace) + handlers.MountInstallation(p, cfg.standalone, Version) handlers.MountEvents(p, reg) handlers.MountDestinations(p, reg) handlers.MountTunnelCredentials(p, reg) handlers.MountSystemLogs(p, k8s, cfg.namespace) - handlers.MountModules(p, k8s, cfg.namespace) + handlers.MountModulesWithRegistry(p, reg, cfg.namespace) handlers.MountRegistrySecrets(p, k8s, cfg.namespace) // Precedence: a runtime, admin-configured key (DB row + labelled // Secret, resolved lazily per registry.DBKeyFunc) always wins over @@ -516,11 +511,15 @@ func main() { } type config struct { - addr string - metricsAddr string - dbDriver string - dbDSN string - logLevel string + standalone bool + panelKeyFile string + panelKeyProvisioned bool + remoteAllowedCIDRs string + addr string + metricsAddr string + dbDriver string + dbDSN string + logLevel string oidcIssuer string oidcClientID string @@ -657,6 +656,10 @@ func (c *config) bindFlags(fs *flag.FlagSet) { c.captureMaxRetentionSecs = envOrInt64("GAMEPLANE_CAPTURE_MAX_RETENTION", 604800) fs.IntVar(&c.captureDefaultMaxDurationS, "capture-default-max-duration", envOrInt("GAMEPLANE_CAPTURE_DEFAULT_MAX_DURATION", 300), "default max capture duration in seconds") c.captureDefaultMaxSizeBytes = envOrInt64("GAMEPLANE_CAPTURE_DEFAULT_MAX_SIZE", 5368709120) + fs.BoolVar(&c.standalone, "standalone", envOr("GAMEPLANE_STANDALONE", "") == "true", "run without a local Kubernetes cluster") + fs.StringVar(&c.panelKeyFile, "panel-key-file", envOr("GAMEPLANE_PANEL_KEY_FILE", "/data/panel.key"), "persistent standalone credential encryption key file") + fs.BoolVar(&c.panelKeyProvisioned, "panel-key-provisioned", envOr("GAMEPLANE_PANEL_KEY_PROVISIONED", "") == "true", "require an existing read-only standalone credential key; never generate it") + fs.StringVar(&c.remoteAllowedCIDRs, "remote-allowed-cidrs", envOr("GAMEPLANE_REMOTE_ALLOWED_CIDRS", ""), "optional comma-separated CIDRs allowed for standalone Kubernetes and gateway connections") fs.StringVar(&c.namespace, "namespace", envOr("GAMEPLANE_NAMESPACE", "gameplane-system"), "namespace the control plane runs in (module upload ConfigMaps are stored here)") fs.StringVar(&c.gameDataStorageClass, "game-data-storage-class", envOr("GAMEPLANE_GAME_DATA_STORAGE_CLASS", ""), diff --git a/api/cmd/management.go b/api/cmd/management.go new file mode 100644 index 000000000..d0141e5a9 --- /dev/null +++ b/api/cmd/management.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "fmt" + "strings" + + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/GameplanePanel/gameplane/api/internal/controlplane" + "github.com/GameplanePanel/gameplane/api/internal/db" + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/api/internal/scope" +) + +// managementClients separates panel storage from workload clients. Standalone +// never consults in-cluster credentials or the host's kubeconfig. +func managementClients(ctx context.Context, cfg config, store *db.Store) (*kube.Client, *kube.Registry, error) { + reg := kube.NewRegistry(scope.DefaultCluster) + if cfg.standalone { + if cfg.clusterOps { + return nil, nil, fmt.Errorf("cluster-ops requires a local Kubernetes cluster") + } + var cidrs []string + if strings.TrimSpace(cfg.remoteAllowedCIDRs) != "" { + cidrs = strings.Split(cfg.remoteAllowedCIDRs, ",") + } + policy, err := kube.NewRemoteAccessPolicy(cidrs) + if err != nil { + return nil, nil, fmt.Errorf("standalone remote policy: %w", err) + } + management, err := controlplane.NewWithOptions(ctx, store, controlplane.KeyOptions{ + File: cfg.panelKeyFile, Provisioned: cfg.panelKeyProvisioned, + }) + if err != nil { + return nil, nil, fmt.Errorf("standalone storage: %w", err) + } + management.RemoteAccess = policy + reg.SetManagement(management) + return management, reg, nil + } + restCfg, err := ctrl.GetConfig() + if err != nil { + return nil, nil, fmt.Errorf("get kubeconfig: %w", err) + } + local, err := kube.New(restCfg) + if err != nil { + return nil, nil, fmt.Errorf("kube client: %w", err) + } + reg.Set(scope.DefaultCluster, local) + reg.SetManagement(local) + return local, reg, nil +} diff --git a/api/cmd/management_test.go b/api/cmd/management_test.go new file mode 100644 index 000000000..2870448c8 --- /dev/null +++ b/api/cmd/management_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/db" +) + +func TestStandaloneProvisionedKeyMustExist(t *testing.T) { + store, err := db.Open(t.Context(), "sqlite", "file::memory:?cache=shared") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Migrate(t.Context()); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "operator.key") + _, _, err = managementClients(t.Context(), config{standalone: true, panelKeyFile: path, panelKeyProvisioned: true}, store) + if err == nil { + t.Fatal("missing provisioned key must fail startup") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("startup generated a replacement for a provisioned key") + } +} + +func TestStandaloneManagementIgnoresKubeconfig(t *testing.T) { + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "does-not-exist")) + t.Setenv("KUBERNETES_SERVICE_HOST", "") + store, err := db.Open(t.Context(), "sqlite", "file::memory:?cache=shared") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Migrate(t.Context()); err != nil { + t.Fatal(err) + } + management, reg, err := managementClients(t.Context(), config{standalone: true, panelKeyFile: filepath.Join(t.TempDir(), "panel.key")}, store) + if err != nil { + t.Fatal(err) + } + if management.Typed != nil || management.Dynamic != nil || management.Config != nil { + t.Fatal("standalone management acquired a Kubernetes client") + } + if reg.Default() != nil || len(reg.IDs()) != 0 || reg.Management() != management { + t.Fatal("standalone registered a local workload cluster") + } +} + +func TestStandaloneRejectsLocalCredentialMinting(t *testing.T) { + _, _, err := managementClients(t.Context(), config{standalone: true, clusterOps: true}, nil) + if err == nil { + t.Fatal("standalone must reject local cluster operations") + } +} + +func TestStandaloneRejectsInvalidDestinationPolicyBeforeOpeningStorage(t *testing.T) { + _, _, err := managementClients(t.Context(), config{standalone: true, remoteAllowedCIDRs: "10.0.0.0/8,invalid"}, nil) + if err == nil { + t.Fatal("standalone accepted an invalid remote destination policy") + } +} diff --git a/api/cmd/mounts_test.go b/api/cmd/mounts_test.go index 6476b895d..46ae6dcf7 100644 --- a/api/cmd/mounts_test.go +++ b/api/cmd/mounts_test.go @@ -19,7 +19,6 @@ var homeClientMounts = []string{ "handlers.MountAuthProviderSecrets", "handlers.MountClusterActions", "handlers.MountClusters", - "handlers.MountModules", "handlers.MountNotifications", "handlers.MountRegistrySecrets", "handlers.MountSystemLogs", diff --git a/api/cmd/panel_key.go b/api/cmd/panel_key.go new file mode 100644 index 000000000..f9a99ee86 --- /dev/null +++ b/api/cmd/panel_key.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/GameplanePanel/gameplane/api/internal/controlplane" + "github.com/GameplanePanel/gameplane/api/internal/db" +) + +func rotatePanelKey(ctx context.Context, args []string) error { + return rotatePanelKeyWithOutput(ctx, args, os.Stderr) +} + +func rotatePanelKeyWithOutput(ctx context.Context, args []string, output io.Writer) error { + fs := flag.NewFlagSet("rotate-panel-key", flag.ContinueOnError) + fs.SetOutput(output) + driver := fs.String("db-driver", envOr("GAMEPLANE_DB_DRIVER", "sqlite"), "sqlite or postgres") + // DSNs are environment-only: neither argv nor flag help may contain + // database credentials. + dsn := envOr("GAMEPLANE_DB_DSN", "file:/data/gameplane.db?_pragma=journal_mode(WAL)") + fs.Usage = func() { + _, _ = fmt.Fprintln(output, "Usage: api rotate-panel-key [options]\nSet GAMEPLANE_DB_DSN for the database connection; stop the API before rotation.") + fs.PrintDefaults() + } + oldFile := fs.String("old-key-file", envOr("GAMEPLANE_PANEL_KEY_FILE", ""), "existing management key file") + newFile := fs.String("new-key-file", "", "separately named replacement management key file") + provisioned := fs.Bool("new-key-provisioned", false, "require a read-only operator-provisioned replacement key") + // Reject the former flag without echoing a credential-bearing argument. + for _, arg := range args { + if arg == "--db-dsn" || arg == "-db-dsn" || strings.HasPrefix(arg, "--db-dsn=") || strings.HasPrefix(arg, "-db-dsn=") { + return errors.New("supply database DSNs through GAMEPLANE_DB_DSN; --db-dsn is not supported") + } + } + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 || *oldFile == "" || *newFile == "" { + return errors.New("rotate-panel-key requires --old-key-file and --new-key-file, with no positional arguments; stop the API before rotation") + } + store, err := db.Open(ctx, *driver, dsn) + if err != nil { + return fmt.Errorf("open management database for key rotation: %w", err) + } + defer func() { _ = store.Close() }() + if err := store.Migrate(ctx); err != nil { + return fmt.Errorf("migrate management database for key rotation: %w", err) + } + return controlplane.RotateKeyWithOptions(ctx, store, *oldFile, controlplane.KeyOptions{File: *newFile, Provisioned: *provisioned}) +} diff --git a/api/cmd/panel_key_test.go b/api/cmd/panel_key_test.go new file mode 100644 index 000000000..c430a39ea --- /dev/null +++ b/api/cmd/panel_key_test.go @@ -0,0 +1,121 @@ +//go:build unix + +package main + +import ( + "bytes" + "errors" + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/controlplane" + "github.com/GameplanePanel/gameplane/api/internal/db" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestRotatePanelKeyHelpDoesNotExposeDatabaseCredentials(t *testing.T) { + t.Setenv("GAMEPLANE_DB_DSN", "postgres://user:private-help-password@database/panel") + var out bytes.Buffer + err := rotatePanelKeyWithOutput(t.Context(), []string{"--help"}, &out) + if !errors.Is(err, flag.ErrHelp) || !strings.Contains(out.String(), "new-key-file") || strings.Contains(out.String(), "private-help-password") { + t.Fatalf("unsafe or missing help: %v %s", err, &out) + } +} + +func TestRotatePanelKeyRejectsCredentialBearingDSNArgument(t *testing.T) { + err := rotatePanelKey(t.Context(), []string{"--db-dsn=postgres://user:private-argument-password@database/panel", "--old-key-file=old.key", "--new-key-file=new.key"}) + if err == nil || !strings.Contains(err.Error(), "GAMEPLANE_DB_DSN") || strings.Contains(err.Error(), "private-argument-password") { + t.Fatalf("credential-bearing argument accepted or exposed: %v", err) + } +} + +func TestRotatePanelKeyRejectsIncompleteArgumentsBeforeDatabaseWrites(t *testing.T) { + t.Setenv("GAMEPLANE_PANEL_KEY_FILE", "") + for _, args := range [][]string{ + {}, + {"--old-key-file=old.key"}, + {"--new-key-file=new.key"}, + {"--old-key-file=old.key", "--new-key-file=new.key", "unexpected"}, + } { + path := filepath.Join(t.TempDir(), "panel.db") + t.Setenv("GAMEPLANE_DB_DRIVER", "sqlite") + t.Setenv("GAMEPLANE_DB_DSN", path) + if err := rotatePanelKey(t.Context(), args); err == nil { + t.Fatal("accepted incomplete rotation arguments") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("invalid command arguments caused a database write") + } + } +} + +func TestRotatePanelKeyCommandUsesConfiguredDatabaseAndSeparateKeyPaths(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "panel.db") + oldPath := filepath.Join(dir, "old.key") + newPath := filepath.Join(dir, "new.key") + store, err := db.Open(t.Context(), "sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Migrate(t.Context()); err != nil { + t.Fatal(err) + } + client, err := controlplane.New(t.Context(), store, oldPath) + if err != nil { + t.Fatal(err) + } + _, err = client.Secrets("panel").Create(t.Context(), &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "provider"}, StringData: map[string]string{"token": "private-test-token"}}, metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + t.Setenv("GAMEPLANE_DB_DRIVER", "sqlite") + t.Setenv("GAMEPLANE_DB_DSN", dbPath) + args := []string{"--old-key-file=" + oldPath, "--new-key-file=" + newPath} + if err := rotatePanelKey(t.Context(), args); err != nil { + t.Fatal(err) + } + rotated, err := controlplane.New(t.Context(), store, newPath) + if err != nil { + t.Fatal(err) + } + secret, err := rotated.Secrets("panel").Get(t.Context(), "provider", metav1.GetOptions{}) + if err != nil || string(secret.Data["token"]) != "private-test-token" { + t.Fatalf("command failed to preserve credential: %v", err) + } +} + +func TestRotatePanelKeyMissingProvisionedReplacementLeavesOldStateUsable(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "panel.db") + oldPath := filepath.Join(dir, "old.key") + newPath := filepath.Join(dir, "external", "new.key") + store, err := db.Open(t.Context(), "sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Migrate(t.Context()); err != nil { + t.Fatal(err) + } + if _, err := controlplane.New(t.Context(), store, oldPath); err != nil { + t.Fatal(err) + } + t.Setenv("GAMEPLANE_DB_DRIVER", "sqlite") + t.Setenv("GAMEPLANE_DB_DSN", dbPath) + args := []string{"--old-key-file=" + oldPath, "--new-key-file=" + newPath, "--new-key-provisioned"} + if err := rotatePanelKey(t.Context(), args); err == nil { + t.Fatal("generated missing provisioned replacement key") + } + if _, err := os.Stat(filepath.Dir(newPath)); !os.IsNotExist(err) { + t.Fatal("provisioned rotation created key directory") + } + if _, err := controlplane.New(t.Context(), store, oldPath); err != nil { + t.Fatalf("failed provisioned rotation retired old key: %v", err) + } +} diff --git a/api/go.mod b/api/go.mod index bda1680d8..3127a227e 100644 --- a/api/go.mod +++ b/api/go.mod @@ -46,6 +46,7 @@ require ( k8s.io/api v0.37.1 k8s.io/apimachinery v0.37.1 k8s.io/client-go v0.37.1 + k8s.io/streaming v0.37.1 modernc.org/sqlite v1.60.1 sigs.k8s.io/controller-runtime v0.25.2 sigs.k8s.io/yaml v1.6.0 @@ -120,7 +121,6 @@ require ( k8s.io/apiextensions-apiserver v0.37.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect - k8s.io/streaming v0.37.1 // indirect k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect modernc.org/libc v1.77.1 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/api/internal/auth/perms.go b/api/internal/auth/perms.go index d71ebf18b..1a6cd22dc 100644 --- a/api/internal/auth/perms.go +++ b/api/internal/auth/perms.go @@ -117,7 +117,7 @@ func PermsByClusterToJSON(perms map[string]map[string]map[string]struct{}) map[s // privilege escalation. // - The "*" permission wildcard (the built-in admin role) matches any perm // but is still subject to the same cluster gating for namespaced perms. -// - Inventory (cluster:read) always requires a cluster-wide grant on the +// - Inventory, modules and templates always require a cluster-wide grant on the // selected cluster or wildcard cluster, regardless of namespaced. An // omitted cluster selects the home cluster; namespace grants never qualify. func (u *User) Can(perm string, namespaced bool, cluster, ns string) bool { @@ -128,7 +128,7 @@ func (u *User) Can(perm string, namespaced bool, cluster, ns string) bool { cwHolds := func(ck string) bool { return permSetHas(u.Perms[ck]["*"], "*") || permSetHas(u.Perms[ck]["*"], perm) } - if perm == "cluster:read" { + if perm == "cluster:read" || perm == "modules:read" || perm == "modules:manage" || perm == "templates:read" || perm == "templates:write" { if cluster == "" { cluster = scope.DefaultCluster } @@ -168,7 +168,7 @@ func (u *User) CanDiscoverCluster(cluster string) bool { if u.Can("users:manage", false, "", "") || u.Can("cluster:manage", false, "", "") { return true } - if u.Can("cluster:read", true, cluster, "") { + if u.Can("cluster:read", true, cluster, "") || u.Can("modules:read", true, cluster, "") || u.Can("modules:manage", true, cluster, "") { return true } for _, ck := range []string{cluster, "*"} { diff --git a/api/internal/auth/perms_test.go b/api/internal/auth/perms_test.go index 00e9d52af..9eb70c39d 100644 --- a/api/internal/auth/perms_test.go +++ b/api/internal/auth/perms_test.go @@ -128,6 +128,39 @@ func TestUserCan_ClusterScopedNotGatedByCluster(t *testing.T) { } } +func TestUserCan_ModuleAndTemplatePermissionsAreTargetScoped(t *testing.T) { + for _, permission := range []string{"modules:read", "modules:manage", "templates:read", "templates:write"} { + for _, namespaced := range []bool{false, true} { + t.Run(permission+map[bool]string{false: "/cluster-scoped", true: "/explicit-target"}[namespaced], func(t *testing.T) { + remote := &User{Perms: map[string]map[string]map[string]struct{}{"remote": {"*": {permission: {}}}}} + if !remote.Can(permission, namespaced, "remote", "") { + t.Fatal("remote workload grant denied on selected cluster") + } + for _, other := range []string{"", "local", "other"} { + if remote.Can(permission, namespaced, other, "") { + t.Errorf("remote workload grant leaked to target %q", other) + } + } + namespace := &User{Perms: map[string]map[string]map[string]struct{}{"remote": {"games": {permission: {}, "*": {}}}}} + if namespace.Can(permission, namespaced, "remote", "games") { + t.Fatal("namespace grant authorized cluster-wide catalog access") + } + wildcard := &User{Perms: map[string]map[string]map[string]struct{}{"*": {"*": {permission: {}}}}} + for _, target := range []string{"", "local", "remote", "other"} { + if !wildcard.Can(permission, namespaced, target, "") { + t.Errorf("wildcard workload grant denied target %q", target) + } + } + for _, central := range []string{"users:manage", "roles:manage", "config:manage", "cluster:manage"} { + if remote.Can(central, false, "", "") { + t.Errorf("workload grant conferred central %s", central) + } + } + }) + } + } +} + func TestUserCan_InventoryAlwaysTargetScoped(t *testing.T) { for _, tc := range []struct { name, bindingCluster, bindingNamespace, permission, target, namespace string diff --git a/api/internal/auth/registry.go b/api/internal/auth/registry.go index 622f837a3..de5c69936 100644 --- a/api/internal/auth/registry.go +++ b/api/internal/auth/registry.go @@ -103,7 +103,7 @@ type SecretReader func(ctx context.Context, name string) (map[string][]byte, err // ProviderSecretLabel=true. func NewK8sSecretReader(k *kube.Client, ns string) SecretReader { return func(ctx context.Context, name string) (map[string][]byte, error) { - sec, err := k.Typed.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{}) + sec, err := k.Secrets(ns).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get provider secret %s/%s: %w", ns, name, err) } diff --git a/api/internal/controlplane/credential_error.go b/api/internal/controlplane/credential_error.go new file mode 100644 index 000000000..db7be6b10 --- /dev/null +++ b/api/internal/controlplane/credential_error.go @@ -0,0 +1,9 @@ +package controlplane + +// A persisted payload was retrieved but failed definitive authentication or +// decoding. Consumers may preserve credentials across unknown read failures, +// but must invalidate them for this error; the marker avoids a package cycle. +type credentialStorageError struct{ error } + +func (e *credentialStorageError) Unwrap() error { return e.error } +func (e *credentialStorageError) PermanentCredentialFailure() bool { return true } diff --git a/api/internal/controlplane/key_acl_linux.go b/api/internal/controlplane/key_acl_linux.go new file mode 100644 index 000000000..fd854feac --- /dev/null +++ b/api/internal/controlplane/key_acl_linux.go @@ -0,0 +1,28 @@ +//go:build linux + +package controlplane + +import ( + "errors" + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +func validateKeyACLPlatform() error { return nil } + +func validateKeyACL(f *os.File) error { + // With an extended POSIX ACL, the mode's group bits describe its mask, + // not its readers. Even 0640 with an API-owned group can expose the key + // to named unrelated users/groups. Inspect the inode we will read, without + // allocating an ACL buffer or following its path a second time. + _, err := unix.Fgetxattr(int(f.Fd()), "system.posix_acl_access", nil) + if errors.Is(err, unix.ENODATA) { + return nil + } + if err != nil { + return fmt.Errorf("management key ACL could not be verified: %w", err) + } + return errors.New("management key must not have an extended access ACL") +} diff --git a/api/internal/controlplane/key_acl_linux_test.go b/api/internal/controlplane/key_acl_linux_test.go new file mode 100644 index 000000000..be3f0393c --- /dev/null +++ b/api/internal/controlplane/key_acl_linux_test.go @@ -0,0 +1,162 @@ +//go:build linux + +package controlplane + +import ( + "bytes" + "encoding/binary" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func installNamedReadACL(t *testing.T, f *os.File, tag uint16) { + t.Helper() + installNamedACL(t, f, tag, "system.posix_acl_access") +} + +func installNamedACL(t *testing.T, f *os.File, tag uint16, attribute string) { + t.Helper() + // Linux's POSIX ACL xattr ABI: version 2 and little-endian 8-byte entries. + type aclEntry struct { + tag, permission uint16 + id uint32 + } + id := uint32(1) + if (tag == 2 && os.Geteuid() == 1) || (tag == 8 && os.Getegid() == 1) { + id = 2 + } + entries := []aclEntry{{1, 6, ^uint32(0)}} // owner rw + if tag == 2 { + entries = append(entries, aclEntry{tag, 4, id}) + } + entries = append(entries, aclEntry{4, 4, ^uint32(0)}) // owning group r + if tag == 8 { + entries = append(entries, aclEntry{tag, 4, id}) + } + entries = append(entries, + aclEntry{16, 4, ^uint32(0)}, // mask r: st_mode still reports 0640 + aclEntry{32, 0, ^uint32(0)}, // other none + ) + acl := make([]byte, 4+8*len(entries)) + binary.LittleEndian.PutUint32(acl, 2) + for i, entry := range entries { + b := acl[4+i*8:] + binary.LittleEndian.PutUint16(b, entry.tag) + binary.LittleEndian.PutUint16(b[2:], entry.permission) + binary.LittleEndian.PutUint32(b[4:], entry.id) + } + if err := unix.Fsetxattr(int(f.Fd()), attribute, acl, 0); err != nil { + if errors.Is(err, unix.EOPNOTSUPP) { + t.Skip("test filesystem cannot install POSIX access ACLs") + } + t.Fatalf("install named read ACL: %v", err) + } +} + +func TestGeneratedKeyRejectsInheritedExtendedAccessACL(t *testing.T) { + dir := t.TempDir() + f, err := os.Open(dir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + installNamedACL(t, f, 2, "system.posix_acl_default") + path := filepath.Join(dir, "panel.key") + if _, err := loadKey(path, false); err == nil || !strings.Contains(err.Error(), "ACL") { + t.Fatalf("generated key bypassed ACL validation: %v", err) + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("fixture must publish a private-mode key with inherited ACL: %v", err) + } + if _, err := readKey(path); err == nil { + t.Fatal("inherited ACL would also bypass subsequent startup validation") + } +} + +func TestExistingKeyRejectsNamedACLReadersWithSafeModeAndGroup(t *testing.T) { + for _, tag := range []uint16{2, 8} { // named user and named group + t.Run(map[uint16]string{2: "user", 8: "group"}[tag], func(t *testing.T) { + path := filepath.Join(t.TempDir(), "panel.key") + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + if _, err := f.Write(make([]byte, 32)); err != nil { + t.Fatal(err) + } + installNamedReadACL(t, f, tag) + info, err := f.Stat() + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0640 || validateKeyFile(info) != nil { + t.Fatal("ACL fixture must pass the prior mode/ownership checks") + } + if _, err := readKey(path); err == nil || !strings.Contains(err.Error(), "ACL") { + t.Fatalf("accepted a named ACL reader: %v", err) + } + }) + } +} + +func TestKeyACLValidationUsesOpenedInode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "panel.key") + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + if _, err := f.Write(make([]byte, 32)); err != nil { + t.Fatal(err) + } + installNamedReadACL(t, f, 2) + if err := os.Rename(path, filepath.Join(dir, "replaced.key")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, make([]byte, 32), 0600); err != nil { + t.Fatal(err) + } + if err := validateKeyACL(f); err == nil { + t.Fatal("ACL inspection followed the replaced path instead of its handle") + } + if _, err := readKey(path); err != nil { + t.Fatalf("safe replacement key rejected: %v", err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if err := validateKeyACL(f); err == nil { + t.Fatal("uninspectable key ACL was accepted") + } +} + +func TestProvisionedProjectedKeyWithoutExtendedACLRemainsReadable(t *testing.T) { + dir := t.TempDir() + versionDir := filepath.Join(dir, "..version") + if err := os.Mkdir(versionDir, 0700); err != nil { + t.Fatal(err) + } + key := bytes.Repeat([]byte{42}, 32) + if err := os.WriteFile(filepath.Join(versionDir, "panel.key"), key, 0440); err != nil { + t.Fatal(err) + } + if err := os.Symlink("..version", filepath.Join(dir, "..data")); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "panel.key") + if err := os.Symlink("..data/panel.key", path); err != nil { + t.Fatal(err) + } + got, err := loadConfiguredKey(KeyOptions{File: path, Provisioned: true}, true) + if err != nil || !bytes.Equal(got, key) { + t.Fatalf("projected provisioned key rejected: %v", err) + } +} diff --git a/api/internal/controlplane/key_acl_unsupported.go b/api/internal/controlplane/key_acl_unsupported.go new file mode 100644 index 000000000..d6925ada2 --- /dev/null +++ b/api/internal/controlplane/key_acl_unsupported.go @@ -0,0 +1,14 @@ +//go:build unix && !linux + +package controlplane + +import ( + "errors" + "os" +) + +func validateKeyACLPlatform() error { + return errors.New("management key ACL validation is unavailable on this platform; run the API in a Linux container") +} + +func validateKeyACL(_ *os.File) error { return validateKeyACLPlatform() } diff --git a/api/internal/controlplane/key_ciphertext.go b/api/internal/controlplane/key_ciphertext.go new file mode 100644 index 000000000..f3c75f66b --- /dev/null +++ b/api/internal/controlplane/key_ciphertext.go @@ -0,0 +1,39 @@ +package controlplane + +import ( + "encoding/base64" + "errors" + "strings" +) + +const ciphertextVersion = "gpk1" + +func (s *objects) ciphertextHeader() string { return ciphertextVersion + "." + s.keyID + "." } + +func (s *objects) ciphertextAAD(name, uid string, version int64) []byte { + // Authenticating the header also prevents stripping it to force legacy + // decoding. The full key ID is a domain-separated digest of a random key. + return append([]byte(s.ciphertextHeader()+"\x00"), s.aad(name, uid, version)...) +} + +func (s *objects) openCiphertext(name, uid string, version int64, payload string) ([]byte, error) { + aad := s.aad(name, uid, version) + encoded := payload + if strings.Contains(payload, ".") { + header := s.ciphertextHeader() + if !strings.HasPrefix(payload, header) { + return nil, errors.New("unsupported management credential format or encryption key identity") + } + encoded = strings.TrimPrefix(payload, header) + aad = s.ciphertextAAD(name, uid, version) + } + sealed, err := base64.StdEncoding.DecodeString(encoded) + if err != nil || len(sealed) < s.aead.NonceSize() { + return nil, errors.New("invalid encrypted management credential") + } + data, err := s.aead.Open(nil, sealed[:s.aead.NonceSize()], sealed[s.aead.NonceSize():], aad) + if err != nil { + return nil, errors.New("management credential authentication failed; verify the database and encryption key") + } + return data, nil +} diff --git a/api/internal/controlplane/key_directory.go b/api/internal/controlplane/key_directory.go new file mode 100644 index 000000000..78386a2cd --- /dev/null +++ b/api/internal/controlplane/key_directory.go @@ -0,0 +1,56 @@ +package controlplane + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +func syncKeyDirectory(path string) error { + root, err := os.OpenRoot(path) + if err != nil { + return fmt.Errorf("open key directory root for sync: %w", err) + } + defer func() { _ = root.Close() }() + dir, err := root.Open(".") + if err != nil { + return fmt.Errorf("open key directory for sync: %w", err) + } + defer func() { _ = dir.Close() }() + if err := dir.Sync(); err != nil { + return fmt.Errorf("persist key directory: %w", err) + } + return nil +} + +// Publish each new directory durably before creating a child beneath it. +func createKeyDirectory(path string, syncDirectory func(string) error) error { + info, err := os.Stat(path) + if err == nil { + if !info.IsDir() { + return errors.New("management key parent is not a directory") + } + // Visibility does not prove durability: an earlier attempt may have + // created this directory and failed while syncing its parent. Recheck + // publication before a retry can create encrypted database records. + return syncDirectory(filepath.Dir(path)) + } + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspect key directory: %w", err) + } + parent := filepath.Dir(path) + if err := createKeyDirectory(parent, syncDirectory); err != nil { + return err + } + if err := os.Mkdir(path, 0700); err != nil { + if !errors.Is(err, os.ErrExist) { + return fmt.Errorf("create private key directory: %w", err) + } + info, statErr := os.Stat(path) + if statErr != nil || !info.IsDir() { + return errors.New("management key directory changed during creation") + } + } + return syncDirectory(parent) +} diff --git a/api/internal/controlplane/key_durability_test.go b/api/internal/controlplane/key_durability_test.go new file mode 100644 index 000000000..f6c325152 --- /dev/null +++ b/api/internal/controlplane/key_durability_test.go @@ -0,0 +1,103 @@ +package controlplane + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestKeyPublicationWaitsForDirectorySync(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "panel.key") + wantErr := errors.New("directory sync failed") + called := false + key, err := loadKeyWithSync(path, false, func(got string) error { + if got == filepath.Dir(dir) { + return nil // persist the already-visible parent directory first + } + called = true + if got != dir { + t.Fatalf("synced %q instead of key directory", got) + } + info, statErr := os.Stat(path) + if statErr != nil || info.Size() != 32 { + t.Fatal("key was not fully published before directory sync") + } + return wantErr + }) + if !called || !errors.Is(err, wantErr) || key != nil { + t.Fatalf("unsynced key became usable: called=%v err=%v", called, err) + } + // A sync failure must never remove or replace the potentially published key. + if info, err := os.Stat(path); err != nil || info.Size() != 32 { + t.Fatal("lost published key after directory sync failure") + } +} + +func TestNestedKeyDirectoriesAreDurablyPublished(t *testing.T) { + base := t.TempDir() + path := filepath.Join(base, "private", "keys", "panel.key") + var synced []string + key, err := loadKeyWithSync(path, false, func(dir string) error { + synced = append(synced, dir) + return syncKeyDirectory(dir) + }) + if err != nil || len(key) != 32 { + t.Fatalf("create key: %v", err) + } + want := []string{filepath.Dir(base), base, filepath.Join(base, "private"), filepath.Dir(path)} + if len(synced) != len(want) { + t.Fatalf("directory sync count = %d, want %d", len(synced), len(want)) + } + for i := range want { + if synced[i] != want[i] { + t.Fatalf("directory sync %d = %q, want %q", i, synced[i], want[i]) + } + } +} + +func TestKeyDirectorySyncFailurePreventsPublication(t *testing.T) { + base := t.TempDir() + path := filepath.Join(base, "private", "panel.key") + wantErr := errors.New("parent directory sync failed") + if key, err := loadKeyWithSync(path, false, func(string) error { return wantErr }); key != nil || !errors.Is(err, wantErr) { + t.Fatalf("accepted undurable directory: %v", err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatal("published key after directory sync failed") + } +} + +func TestKeyDirectorySyncFailureRetryPersistsVisibleParent(t *testing.T) { + base := t.TempDir() + dir := filepath.Join(base, "private") + path := filepath.Join(dir, "panel.key") + wantErr := errors.New("parent directory sync failed once") + parentSyncs := 0 + syncDirectory := func(got string) error { + if got == base { + parentSyncs++ + if parentSyncs == 1 { + return wantErr + } + } + if got == dir && parentSyncs < 2 { + t.Fatal("key became usable before retry persisted the directory entry") + } + return syncKeyDirectory(got) + } + if key, err := loadKeyWithSync(path, false, syncDirectory); key != nil || !errors.Is(err, wantErr) { + t.Fatalf("first publication failure: %v", err) + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + t.Fatal("failed publication did not leave a visible directory to retry") + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatal("key was published before its directory entry was persisted") + } + key, err := loadKeyWithSync(path, false, syncDirectory) + if err != nil || len(key) != 32 || parentSyncs != 2 { + t.Fatalf("retry failed to persist the existing directory: parent syncs=%d error=%v", parentSyncs, err) + } +} diff --git a/api/internal/controlplane/key_file_unix.go b/api/internal/controlplane/key_file_unix.go new file mode 100644 index 000000000..ee5e7f318 --- /dev/null +++ b/api/internal/controlplane/key_file_unix.go @@ -0,0 +1,70 @@ +//go:build unix + +package controlplane + +import ( + "errors" + "fmt" + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +func validateKeyPlatform() error { return validateKeyACLPlatform() } + +func openKeyHandle(root *os.Root, name string) (*os.File, error) { + // FIFOs must not block startup before regular-file validation can run. + f, err := root.OpenFile(name, os.O_RDONLY|unix.O_NONBLOCK, 0) + if err != nil { + return nil, err + } + if err := validateKeyACL(f); err != nil { + _ = f.Close() + return nil, err + } + return f, nil +} + +func validateKeyFile(info os.FileInfo) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("management key ownership could not be verified") + } + groups, err := os.Getgroups() + if err != nil { + return fmt.Errorf("inspect management key process groups: %w", err) + } + groups = append(groups, os.Getegid()) + if stat.Nlink != 1 { + return errors.New("management key must have exactly one filesystem link") + } + return validateKeyAccess(stat.Uid, stat.Gid, info.Mode(), os.Geteuid(), os.Getegid(), groups) +} + +func validateKeyAccess(uid, gid uint32, mode os.FileMode, euid, egid int, groups []int) error { + // Root-owned projected Kubernetes keys are legitimate when fsGroup grants + // this process read access. Unrelated owners, world access, executable keys, + // and group-write access are never accepted. + if uid != 0 && int64(uid) != int64(euid) { + return errors.New("management key must be owned by root or the API user") + } + if mode.Perm()&0137 != 0 || mode.Perm()&0400 == 0 { + return errors.New("management key permissions must be private (0600/0400), or safely group-readable (0440/0640)") + } + if mode.Perm()&0040 != 0 { + // Only operator-owned projections may rely on a supplementary fsGroup. + // API-owned keys must use the process's primary group, not a shared + // supplementary group inherited from the host account. + if uid != 0 && int64(gid) != int64(egid) { + return errors.New("API-owned management key read group must be the effective process group") + } + for _, group := range groups { + if int64(gid) == int64(group) { + return nil + } + } + return errors.New("management key read group must be an API process group") + } + return nil +} diff --git a/api/internal/controlplane/key_file_unix_test.go b/api/internal/controlplane/key_file_unix_test.go new file mode 100644 index 000000000..d5d6a33e3 --- /dev/null +++ b/api/internal/controlplane/key_file_unix_test.go @@ -0,0 +1,114 @@ +//go:build unix + +package controlplane + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/unix" +) + +func TestExistingKeyRejectsUnsafePermissionsAndNonRegularFiles(t *testing.T) { + for _, mode := range []os.FileMode{0644, 0660, 0700, 0444} { + t.Run(mode.String(), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "panel.key") + if err := os.WriteFile(path, make([]byte, 32), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } + if _, err := readKey(path); err == nil { + t.Fatal("accepted unsafe key permissions") + } + }) + } + for _, kind := range []string{"directory", "fifo"} { + t.Run(kind, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "panel.key") + var err error + if kind == "directory" { + err = os.Mkdir(path, 0700) + } else { + err = unix.Mkfifo(path, 0600) + } + if err != nil { + t.Fatal(err) + } + if _, err := readKey(path); err == nil { + t.Fatal("accepted nonregular key") + } + }) + } +} + +func TestExistingKeyRequiresExactly32Bytes(t *testing.T) { + for _, size := range []int{0, 31, 33, 1 << 20} { + path := filepath.Join(t.TempDir(), "panel.key") + if err := os.WriteFile(path, make([]byte, size), 0600); err != nil { + t.Fatal(err) + } + if _, err := readKey(path); err == nil { + t.Fatalf("accepted key size %d", size) + } + } +} + +func TestProjectedKeySymlinksRemainConfinedAndReadable(t *testing.T) { + dir := t.TempDir() + dataDir := filepath.Join(dir, "..2026") + if err := os.Mkdir(dataDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dataDir, "panel.key"), make([]byte, 32), 0440); err != nil { + t.Fatal(err) + } + if err := os.Symlink("..2026", filepath.Join(dir, "..data")); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "panel.key") + if err := os.Symlink("..data/panel.key", path); err != nil { + t.Fatal(err) + } + if key, err := readKey(path); err != nil || len(key) != 32 { + t.Fatalf("projected key: %v", err) + } + outside := filepath.Join(t.TempDir(), "outside.key") + if err := os.WriteFile(outside, make([]byte, 32), 0600); err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, path); err != nil { + t.Fatal(err) + } + if _, err := readKey(path); err == nil { + t.Fatal("followed a key symlink outside configured directory") + } +} + +func TestKeyOwnershipAllowsProjectedRootOwnedFSGroup(t *testing.T) { + for _, tc := range []struct { + name string + uid, gid uint32 + mode os.FileMode + wantOK bool + }{ + {"private-process-key", 65532, 65532, 0600, true}, + {"projected-root-fsgroup", 0, 65532, 0440, true}, + {"foreign-owner", 1234, 65532, 0440, false}, + {"foreign-group", 0, 1234, 0440, false}, + {"world-readable", 0, 65532, 0444, false}, + {"group-writable", 0, 65532, 0460, false}, + } { + t.Run(tc.name, func(t *testing.T) { + err := validateKeyAccess(tc.uid, tc.gid, tc.mode, 65532, 65532, []int{65532}) + if (err == nil) != tc.wantOK { + t.Fatalf("key access accepted=%v want=%v", err == nil, tc.wantOK) + } + }) + } +} diff --git a/api/internal/controlplane/key_file_unsupported.go b/api/internal/controlplane/key_file_unsupported.go new file mode 100644 index 000000000..fc2ee4282 --- /dev/null +++ b/api/internal/controlplane/key_file_unsupported.go @@ -0,0 +1,20 @@ +//go:build !unix + +package controlplane + +import ( + "errors" + "os" +) + +func validateKeyPlatform() error { + return errors.New("native standalone key permission validation is unavailable on this platform; run the API in a Linux container") +} + +func openKeyHandle(_ *os.Root, _ string) (*os.File, error) { + return nil, validateKeyPlatform() +} + +func validateKeyFile(_ os.FileInfo) error { + return errors.New("management key ownership validation is unavailable on this platform") +} diff --git a/api/internal/controlplane/key_file_unsupported_test.go b/api/internal/controlplane/key_file_unsupported_test.go new file mode 100644 index 000000000..300fb138e --- /dev/null +++ b/api/internal/controlplane/key_file_unsupported_test.go @@ -0,0 +1,19 @@ +//go:build !unix + +package controlplane + +import ( + "os" + "path/filepath" + "testing" +) + +func TestKeyAccessFailsClosedWithoutPlatformPermissionValidation(t *testing.T) { + path := filepath.Join(t.TempDir(), "panel.key") + if err := os.WriteFile(path, make([]byte, 32), 0600); err != nil { + t.Fatal(err) + } + if _, err := readKey(path); err == nil { + t.Fatal("accepted key without platform access validation") + } +} diff --git a/api/internal/controlplane/key_options.go b/api/internal/controlplane/key_options.go new file mode 100644 index 000000000..8fa1d07fa --- /dev/null +++ b/api/internal/controlplane/key_options.go @@ -0,0 +1,31 @@ +package controlplane + +import ( + "errors" + "fmt" + "os" +) + +// KeyOptions selects management key custody. Provisioned files are read-only +// operator inputs: the API never creates their directory, file, or replacement. +type KeyOptions struct { + File string + Provisioned bool +} + +func loadConfiguredKey(opts KeyOptions, required bool) ([]byte, error) { + if opts.File == "" { + return nil, errors.New("management key file path is required") + } + if !opts.Provisioned { + return loadKey(opts.File, required) + } + key, err := readKey(opts.File) + if errors.Is(err, os.ErrNotExist) { + return nil, errors.New("provisioned management key is missing; mount the original operator-provisioned key") + } + if err != nil { + return nil, fmt.Errorf("read provisioned management key: %w", err) + } + return key, nil +} diff --git a/api/internal/controlplane/key_options_test.go b/api/internal/controlplane/key_options_test.go new file mode 100644 index 000000000..d59b9462a --- /dev/null +++ b/api/internal/controlplane/key_options_test.go @@ -0,0 +1,69 @@ +//go:build unix + +package controlplane + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestProvisionedKeyNeverGeneratesMissingFile(t *testing.T) { + store, _, _ := managementFixture(t) + path := filepath.Join(t.TempDir(), "operator", "panel.key") + if _, err := NewWithOptions(t.Context(), store, KeyOptions{File: path, Provisioned: true}); err == nil { + t.Fatal("started without required provisioned key") + } + if _, err := os.Stat(filepath.Dir(path)); !errors.Is(err, os.ErrNotExist) { + t.Fatal("provisioned key mode created a directory") + } +} + +func TestProvisionedKeyAcceptsPrivateReadOnlyInputWithoutChangingIt(t *testing.T) { + store, _, originalPath := managementFixture(t) + before, err := os.ReadFile(originalPath) + if err != nil { + t.Fatal(err) + } + // The operator's key mount is read-only; the separate database directory + // stays writable for startup key-state validation and SQLite journaling. + path := filepath.Join(t.TempDir(), "panel.key") + if err := os.WriteFile(path, before, 0600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0400); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Dir(path), 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(filepath.Dir(path), 0700) }) + if _, err := NewWithOptions(t.Context(), store, KeyOptions{File: path, Provisioned: true}); err != nil { + t.Fatalf("read-only provisioned key: %v", err) + } + after, err := os.ReadFile(path) + info, statErr := os.Stat(path) + if err != nil || statErr != nil || !bytes.Equal(before, after) || info.Mode().Perm() != 0400 { + t.Fatal("provisioned key mode changed its input") + } +} + +func TestLegacyKeyConstructorStillReusesExistingKey(t *testing.T) { + store, _, path := managementFixture(t) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if _, err := New(t.Context(), store, path); err != nil { + t.Fatal(err) + } + if _, err := NewWithOptions(t.Context(), store, KeyOptions{File: path}); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("constructor changed the encryption key") + } +} diff --git a/api/internal/controlplane/key_publication_unix.go b/api/internal/controlplane/key_publication_unix.go new file mode 100644 index 000000000..1178119a2 --- /dev/null +++ b/api/internal/controlplane/key_publication_unix.go @@ -0,0 +1,94 @@ +//go:build unix + +package controlplane + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +// Generated key publishers share a persistent directory lock. Never unlink +// its file: replacing the inode would permit independent overlapping locks. +func lockKeyPublication(directory string) (func(), error) { + root, err := os.OpenRoot(directory) + if err != nil { + return nil, fmt.Errorf("open key publication root: %w", err) + } + defer func() { _ = root.Close() }() + f, err := root.OpenFile(".management-key.lock", os.O_CREATE|os.O_RDWR|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0600) + if err != nil { + return nil, fmt.Errorf("open key publication lock: %w", err) + } + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() { + _ = f.Close() + return nil, errors.New("key publication lock must be a regular file") + } + if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil { + _ = f.Close() + return nil, fmt.Errorf("lock key publication: %w", err) + } + return func() { _ = f.Close() }, nil +} + +// The caller holds the publication lock. Different inodes can be active or +// interrupted publications of other filenames, and are never removed. +func recoverKeyAliases(path string) error { + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return err + } + defer func() { _ = root.Close() }() + key, err := openKeyHandle(root, filepath.Base(path)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + defer func() { _ = key.Close() }() + info, err := key.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() != 32 { + return nil + } + dir, err := root.Open(".") + if err != nil { + return err + } + defer func() { _ = dir.Close() }() + entries, err := dir.ReadDir(-1) + if err != nil { + return err + } + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), ".management-key-") || entry.Type()&os.ModeSymlink != 0 { + continue + } + alias, err := root.OpenFile(entry.Name(), os.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + aliasInfo, statErr := alias.Stat() + _ = alias.Close() + if statErr != nil { + return statErr + } + if os.SameFile(info, aliasInfo) { + if err := root.Remove(entry.Name()); err != nil { + return err + } + } + } + return nil +} diff --git a/api/internal/controlplane/key_publication_unsupported.go b/api/internal/controlplane/key_publication_unsupported.go new file mode 100644 index 000000000..3cb46884a --- /dev/null +++ b/api/internal/controlplane/key_publication_unsupported.go @@ -0,0 +1,6 @@ +//go:build !unix + +package controlplane + +func lockKeyPublication(_ string) (func(), error) { return nil, validateKeyPlatform() } +func recoverKeyAliases(_ string) error { return validateKeyPlatform() } diff --git a/api/internal/controlplane/key_review_test.go b/api/internal/controlplane/key_review_test.go new file mode 100644 index 000000000..e8836c6a7 --- /dev/null +++ b/api/internal/controlplane/key_review_test.go @@ -0,0 +1,141 @@ +//go:build unix + +package controlplane + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestGeneratedKeyRejectsSupplementaryReadGroup(t *testing.T) { + if err := validateKeyAccess(1000, 2000, 0640, 1000, 1000, []int{1000, 2000}); err == nil { + t.Fatal("accepted generated key readable by a supplementary group") + } + if err := validateKeyAccess(0, 2000, 0440, 1000, 1000, []int{1000, 2000}); err != nil { + t.Fatalf("rejected root-owned fsGroup projection: %v", err) + } +} + +func TestGeneratedKeyRecoversOnlyMatchingPublicationAlias(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "panel.key") + key := bytes.Repeat([]byte{7}, 32) + if err := os.WriteFile(path, key, 0600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(dir, ".management-key-stale") + if err := os.Link(path, alias); err != nil { + t.Fatal(err) + } + unrelated := filepath.Join(dir, ".management-key-active") + if err := os.WriteFile(unrelated, bytes.Repeat([]byte{8}, 32), 0600); err != nil { + t.Fatal(err) + } + if got, err := loadKey(path, true); err != nil || !bytes.Equal(got, key) { + t.Fatalf("recover key: %v", err) + } + if _, err := os.Stat(alias); !os.IsNotExist(err) { + t.Fatal("stale key alias survived") + } + if _, err := os.Stat(unrelated); err != nil { + t.Fatal("removed an unrelated publisher's temporary file") + } +} + +func TestGeneratedKeyConcurrentPublishersRetainOneKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "panel.key") + var workers sync.WaitGroup + keys := make(chan []byte, 8) + errors := make(chan error, 8) + for range 8 { + workers.Go(func() { key, err := loadKey(path, false); keys <- key; errors <- err }) + } + workers.Wait() + close(keys) + close(errors) + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + var first []byte + for key := range keys { + if first == nil { + first = key + } + if !bytes.Equal(first, key) { + t.Fatal("publishers loaded different keys") + } + } + if _, err := readKey(path); err != nil { + t.Fatalf("published key access: %v", err) + } +} + +func TestRotationRejectsUninitializedEmptyStoreWithoutMutation(t *testing.T) { + store, _, oldPath := managementFixture(t) + if _, err := store.DB.ExecContext(t.Context(), `DELETE FROM management_key_state`); err != nil { + t.Fatal(err) + } + newPath := filepath.Join(t.TempDir(), "new.key") + if err := RotateKey(t.Context(), store, oldPath, newPath); err == nil { + t.Fatal("adopted an uninitialized empty database") + } + var count int + if err := store.DB.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM management_key_state`).Scan(&count); err != nil || count != 0 { + t.Fatal("failed rotation changed key state") + } + if _, err := os.Stat(newPath); !os.IsNotExist(err) { + t.Fatal("failed source validation created a new key") + } +} + +func TestRotationAuthenticatesPopulatedStoreWithoutKeyState(t *testing.T) { + store, client, oldPath := managementFixture(t) + if _, err := client.Secrets("panel").Create(t.Context(), credential("legacy-state"), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + if _, err := store.DB.ExecContext(t.Context(), `DELETE FROM management_key_state`); err != nil { + t.Fatal(err) + } + if err := RotateKey(t.Context(), store, oldPath, filepath.Join(t.TempDir(), "new.key")); err != nil { + t.Fatalf("authenticated migration: %v", err) + } +} + +func TestStartupCredentialErrorIdentifiesObjectWithoutPayload(t *testing.T) { + store, client, keyPath := managementFixture(t) + if _, err := client.Secrets("panel").Create(t.Context(), credential("broken-credential"), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + if _, err := store.DB.ExecContext(t.Context(), `UPDATE management_objects SET payload = 'private-broken-payload' WHERE name = 'broken-credential'`); err != nil { + t.Fatal(err) + } + _, err := New(t.Context(), store, keyPath) + if err == nil || !strings.Contains(err.Error(), "secrets/panel/broken-credential") || strings.Contains(err.Error(), "private-broken-payload") { + t.Fatalf("unsafe or unidentified failure: %v", err) + } + var permanent interface{ PermanentCredentialFailure() bool } + if !errors.As(err, &permanent) || !permanent.PermanentCredentialFailure() { + t.Fatal("authenticated storage corruption was classified as transient") + } +} + +func TestTypedCredentialCorruptionIsPermanent(t *testing.T) { + _, err := asSecret(&unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{"name": "malformed", "namespace": "panel"}, + "data": map[string]any{"kubeconfig": 42}, + }}, nil) + var permanent interface{ PermanentCredentialFailure() bool } + if err == nil || !errors.As(err, &permanent) || !permanent.PermanentCredentialFailure() { + t.Fatalf("typed credential corruption was classified as transient: %v", err) + } +} diff --git a/api/internal/controlplane/key_rotation.go b/api/internal/controlplane/key_rotation.go new file mode 100644 index 000000000..a1b304b85 --- /dev/null +++ b/api/internal/controlplane/key_rotation.go @@ -0,0 +1,183 @@ +package controlplane + +import ( + "context" + "database/sql" + "errors" + "fmt" + "path/filepath" + + "github.com/GameplanePanel/gameplane/api/internal/db" +) + +// RotateKey rotates into a separately named generated key file. An existing +// safely permissioned destination is retained for interrupted-command retries. +// Stop the API first and retain both key files for their matching DB backups. +func RotateKey(ctx context.Context, store *db.Store, oldFile, newFile string) error { + return RotateKeyWithOptions(ctx, store, oldFile, KeyOptions{File: newFile}) +} + +// RotateKeyWithOptions also accepts an operator-provisioned, read-only new key. +// Its durable custody remains with the operator; the API never writes/syncs it. +func RotateKeyWithOptions(ctx context.Context, store *db.Store, oldFile string, newOptions KeyOptions) error { + if oldFile == "" || newOptions.File == "" { + return errors.New("old and new management key file paths are required") + } + oldPath, err := filepath.Abs(oldFile) + if err != nil { + return fmt.Errorf("resolve old management key path: %w", err) + } + newPath, err := filepath.Abs(newOptions.File) + if err != nil { + return fmt.Errorf("resolve new management key path: %w", err) + } + if oldPath == newPath { + return errors.New("rotation requires separate old and new key file paths") + } + oldKey, err := loadConfiguredKey(KeyOptions{File: oldFile, Provisioned: true}, true) + if err != nil { + return err + } + oldBackend, err := backendForKey(store.DB, oldKey) + if err != nil { + return err + } + // Authenticate the old key before creating a destination. If a previous + // commit succeeded but its result was lost, authenticate the new state + // before reporting the same old/new request as already completed. + completed, err := validateRotationSource(ctx, oldBackend, newOptions) + if err != nil || completed { + return err + } + newKey, err := loadConfiguredKey(newOptions, false) + if err != nil { + return err + } + newBackend, err := backendForKey(store.DB, newKey) + if err != nil { + return err + } + if oldBackend.keyID == newBackend.keyID { + return errors.New("rotation requires different old and new key material") + } + // Generated keys are already durably published before opening this + // transaction. No failure path removes either key or overwrites the old one. + tx, err := store.DB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin management key rotation: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := oldBackend.lockKeyState(ctx, tx); err != nil { + return err + } + if err := oldBackend.validateStorage(ctx, tx); err != nil { + return err + } + records, err := readRotationRecords(ctx, tx) + if err != nil { + return err + } + for _, record := range records { + oldObjects := &objects{backend: oldBackend, kind: "secrets", ns: record.namespace} + obj, err := oldObjects.decode(record.name, record.uid, record.version, record.payload) + if err != nil { + return err + } + newObjects := &objects{backend: newBackend, kind: "secrets", ns: record.namespace} + payload, err := newObjects.encode(obj, record.version) + if err != nil { + return err + } + result, err := tx.ExecContext(ctx, `UPDATE management_objects SET payload = ? WHERE kind = 'secrets' AND namespace = ? AND name = ? AND uid = ? AND version = ?`, payload, record.namespace, record.name, record.uid, record.version) + if err != nil { + return fmt.Errorf("re-encrypt management credential: %w", err) + } + count, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("inspect rotated credential write: %w", err) + } + if count != 1 { + return errors.New("management credential changed during key rotation") + } + } + if _, err := tx.ExecContext(ctx, `UPDATE management_key_state SET key_id = ?, previous_key_id = ? WHERE singleton = 1`, newBackend.keyID, oldBackend.keyID); err != nil { + return fmt.Errorf("activate rotated management key: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit management key rotation: %w", err) + } + return nil +} + +func validateRotationSource(ctx context.Context, old *backend, newOptions KeyOptions) (bool, error) { + tx, err := old.db.BeginTx(ctx, nil) + if err != nil { + return false, fmt.Errorf("begin rotation source validation: %w", err) + } + defer func() { _ = tx.Rollback() }() + var initialized, credentials int + if err := tx.QueryRowContext(ctx, `SELECT (SELECT COUNT(*) FROM management_key_state), (SELECT COUNT(*) FROM management_objects WHERE kind = 'secrets')`).Scan(&initialized, &credentials); err != nil { + return false, fmt.Errorf("inspect rotation source: %w", err) + } + if initialized == 0 && credentials == 0 { + return false, errors.New("rotation source is an uninitialized database; select an established panel database") + } + if err := old.initializeKeyState(ctx, tx); err != nil { + // A differing active key is safe only for an authenticated retry of + // the immediately preceding rotation with these exact key identities. + var active, previous string + if readErr := tx.QueryRowContext(ctx, `SELECT key_id, previous_key_id FROM management_key_state WHERE singleton = 1`).Scan(&active, &previous); readErr != nil { + return false, err + } + newKey, readErr := loadConfiguredKey(newOptions, true) + if readErr != nil { + return false, err + } + current, buildErr := backendForKey(old.db, newKey) + if buildErr != nil || active != current.keyID || previous != old.keyID { + return false, err + } + if lockErr := current.lockKeyState(ctx, tx); lockErr != nil { + return false, lockErr + } + if validateErr := current.validateStorage(ctx, tx); validateErr != nil { + return false, validateErr + } + if commitErr := tx.Commit(); commitErr != nil { + return false, fmt.Errorf("confirm completed key rotation: %w", commitErr) + } + return true, nil + } + if err := old.validateStorage(ctx, tx); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, fmt.Errorf("commit rotation source validation: %w", err) + } + return false, nil +} + +type rotationRecord struct { + namespace, name, uid, payload string + version int64 +} + +func readRotationRecords(ctx context.Context, tx *sql.Tx) ([]rotationRecord, error) { + rows, err := tx.QueryContext(ctx, `SELECT namespace, name, uid, version, payload FROM management_objects WHERE kind = 'secrets' ORDER BY namespace, name`) + if err != nil { + return nil, fmt.Errorf("read key rotation credentials: %w", err) + } + defer func() { _ = rows.Close() }() + var records []rotationRecord + for rows.Next() { + var record rotationRecord + if err := rows.Scan(&record.namespace, &record.name, &record.uid, &record.version, &record.payload); err != nil { + return nil, fmt.Errorf("scan key rotation credential: %w", err) + } + records = append(records, record) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read key rotation credentials: %w", err) + } + return records, nil +} diff --git a/api/internal/controlplane/key_rotation_postgres_test.go b/api/internal/controlplane/key_rotation_postgres_test.go new file mode 100644 index 000000000..18905a5a0 --- /dev/null +++ b/api/internal/controlplane/key_rotation_postgres_test.go @@ -0,0 +1,88 @@ +//go:build postgres && unix + +package controlplane + +import ( + "context" + "crypto/rand" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/db" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPostgresKeyRotationRejectsStaleWriterAcrossConnections(t *testing.T) { + dsn := os.Getenv("GAMEPLANE_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("GAMEPLANE_TEST_POSTGRES_DSN not set") + } + admin, err := db.Open(t.Context(), "postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = admin.Close() }) + var suffix [8]byte + if _, err := rand.Read(suffix[:]); err != nil { + t.Fatal(err) + } + schema := "gp_rotation_" + hex.EncodeToString(suffix[:]) + if _, err := admin.DB.ExecContext(t.Context(), `CREATE SCHEMA `+schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := admin.DB.ExecContext(context.WithoutCancel(t.Context()), `DROP SCHEMA `+schema+` CASCADE`); err != nil { + t.Error(err) + } + }) + if strings.Contains(dsn, "://") { + separator := "?" + if strings.Contains(dsn, "?") { + separator = "&" + } + dsn += separator + "search_path=" + schema + } else { + dsn += " search_path=" + schema + } + store, err := db.Open(t.Context(), "postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Migrate(t.Context()); err != nil { + t.Fatal(err) + } + oldPath := filepath.Join(t.TempDir(), "old.key") + oldClient, err := New(t.Context(), store, oldPath) + if err != nil { + t.Fatal(err) + } + if _, err := oldClient.Secrets("panel").Create(t.Context(), credential("provider"), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + rotator, err := db.Open(t.Context(), "postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = rotator.Close() }) + newPath := filepath.Join(t.TempDir(), "new.key") + if err := RotateKey(t.Context(), rotator, oldPath, newPath); err != nil { + t.Fatal(err) + } + if _, err := oldClient.Secrets("panel").Create(t.Context(), credential("stale"), metav1.CreateOptions{}); err == nil { + t.Fatal("independent stale PostgreSQL writer committed old-key credential") + } + newClient, err := New(t.Context(), rotator, newPath) + if err != nil { + t.Fatal(err) + } + if _, err := newClient.Secrets("panel").Get(t.Context(), "provider", metav1.GetOptions{}); err != nil { + t.Fatal(err) + } + if err := RotateKey(t.Context(), rotator, oldPath, newPath); err != nil { + t.Fatalf("PostgreSQL committed rotation retry: %v", err) + } +} diff --git a/api/internal/controlplane/key_rotation_test.go b/api/internal/controlplane/key_rotation_test.go new file mode 100644 index 000000000..ca6ba5bd6 --- /dev/null +++ b/api/internal/controlplane/key_rotation_test.go @@ -0,0 +1,259 @@ +//go:build unix + +package controlplane + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMasterKeyRotationPreservesCredentialsAndRejectsStaleWriter(t *testing.T) { + store, oldClient, oldPath := managementFixture(t) + created, err := oldClient.Secrets("panel").Create(t.Context(), credential("provider"), metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + newPath := filepath.Join(t.TempDir(), "keys", "rotated.key") + if err := RotateKey(t.Context(), store, oldPath, newPath); err != nil { + t.Fatal(err) + } + if err := RotateKey(t.Context(), store, oldPath, newPath); err != nil { + t.Fatalf("retry of committed rotation: %v", err) + } + if _, err := New(t.Context(), store, oldPath); err == nil { + t.Fatal("old key opened rotated store") + } + newClient, err := New(t.Context(), store, newPath) + if err != nil { + t.Fatal(err) + } + got, err := newClient.Secrets("panel").Get(t.Context(), "provider", metav1.GetOptions{}) + if err != nil || !bytes.Equal(got.Data["token"], created.Data["token"]) || got.UID != created.UID || got.ResourceVersion != created.ResourceVersion { + t.Fatalf("rotation changed credential identity/content: %v", err) + } + if _, err := oldClient.Secrets("panel").Create(t.Context(), credential("stale"), metav1.CreateOptions{}); err == nil { + t.Fatal("stale running writer committed old-key ciphertext") + } + secret, registration := registrationObjects() + if _, err := oldClient.RegisterCluster(t.Context(), "panel", secret, registration); err == nil { + t.Fatal("stale atomic registration writer committed old-key ciphertext") + } + if _, err := newClient.Secrets("panel").Create(t.Context(), credential("fresh"), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(oldPath); err != nil { + t.Fatal("rotation removed backup key") + } +} + +func TestMasterKeyRotationRollbackRetainsAllOldCiphertext(t *testing.T) { + store, client, oldPath := managementFixture(t) + before := map[string]string{} + for _, name := range []string{"a", "b"} { + if _, err := client.Secrets("panel").Create(t.Context(), credential(name), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + var payload string + if err := store.DB.QueryRowContext(t.Context(), `SELECT payload FROM management_objects WHERE name = ?`, name).Scan(&payload); err != nil { + t.Fatal(err) + } + before[name] = payload + } + if _, err := store.DB.ExecContext(t.Context(), `CREATE TRIGGER reject_rotation BEFORE UPDATE OF payload ON management_objects WHEN NEW.name = 'b' BEGIN SELECT RAISE(ABORT, 'interrupted rotation'); END`); err != nil { + t.Fatal(err) + } + newPath := filepath.Join(t.TempDir(), "rotated.key") + if err := RotateKey(t.Context(), store, oldPath, newPath); err == nil { + t.Fatal("rotation ignored database interruption") + } + for name, want := range before { + var got string + if err := store.DB.QueryRowContext(t.Context(), `SELECT payload FROM management_objects WHERE name = ?`, name).Scan(&got); err != nil || got != want { + t.Fatalf("partial rotation persisted for %s: %v", name, err) + } + } + if _, err := New(t.Context(), store, oldPath); err != nil { + t.Fatalf("rollback lost old key: %v", err) + } + if _, err := New(t.Context(), store, newPath); err == nil { + t.Fatal("rollback activated new key") + } + if _, err := store.DB.ExecContext(t.Context(), `DROP TRIGGER reject_rotation`); err != nil { + t.Fatal(err) + } + if err := RotateKey(t.Context(), store, oldPath, newPath); err != nil { + t.Fatalf("retry with preserved new key: %v", err) + } +} + +func TestMasterKeyRotationRejectsCorruptSourceBeforeCreatingNewKey(t *testing.T) { + store, client, oldPath := managementFixture(t) + if _, err := client.Secrets("panel").Create(t.Context(), credential("provider"), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + if _, err := store.DB.ExecContext(t.Context(), `UPDATE management_objects SET payload = 'corrupt'`); err != nil { + t.Fatal(err) + } + newPath := filepath.Join(t.TempDir(), "new.key") + if err := RotateKey(t.Context(), store, oldPath, newPath); err == nil { + t.Fatal("rotated corrupt source") + } + if _, err := os.Stat(newPath); !os.IsNotExist(err) { + t.Fatal("corrupt source caused new key publication") + } + var payload string + if err := store.DB.QueryRowContext(t.Context(), `SELECT payload FROM management_objects WHERE name = 'provider'`).Scan(&payload); err != nil || payload != "corrupt" { + t.Fatal("rotation changed corrupt source instead of failing closed") + } +} + +func TestMasterKeyRotationAuthenticatesLegacyCiphertext(t *testing.T) { + store, client, oldPath := managementFixture(t) + secret, err := client.Secrets("panel").Create(t.Context(), credential("legacy"), metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + key, err := os.ReadFile(oldPath) + if err != nil { + t.Fatal(err) + } + block, err := aes.NewCipher(key) + if err != nil { + t.Fatal(err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(secret) + if err != nil { + t.Fatal(err) + } + nonce := make([]byte, aead.NonceSize()) + aad := []byte("secrets\x00panel\x00legacy\x00" + string(secret.UID) + "\x001") + legacy := base64.StdEncoding.EncodeToString(aead.Seal(nonce, nonce, data, aad)) + if _, err := store.DB.ExecContext(t.Context(), `UPDATE management_objects SET payload = ? WHERE name = 'legacy'`, legacy); err != nil { + t.Fatal(err) + } + newPath := filepath.Join(t.TempDir(), "rotated.key") + if err := RotateKey(t.Context(), store, oldPath, newPath); err != nil { + t.Fatal(err) + } + var payload string + if err := store.DB.QueryRowContext(t.Context(), `SELECT payload FROM management_objects WHERE name = 'legacy'`).Scan(&payload); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(payload, "gpk1.") || payload == legacy { + t.Fatal("rotation did not migrate legacy credential format") + } + newClient, err := New(t.Context(), store, newPath) + if err != nil { + t.Fatal(err) + } + if _, err := newClient.Secrets("panel").Get(t.Context(), "legacy", metav1.GetOptions{}); err != nil { + t.Fatal(err) + } +} + +func TestMasterKeyRotationRejectsWrongAndIdenticalKeys(t *testing.T) { + store, _, oldPath := managementFixture(t) + wrongPath := filepath.Join(t.TempDir(), "wrong.key") + if err := os.WriteFile(wrongPath, make([]byte, 32), 0600); err != nil { + t.Fatal(err) + } + newPath := filepath.Join(t.TempDir(), "new.key") + if err := RotateKey(t.Context(), store, wrongPath, newPath); err == nil { + t.Fatal("wrong old key rotated empty established store") + } + if _, err := os.Stat(newPath); !os.IsNotExist(err) { + t.Fatal("invalid old key caused new key creation") + } + if err := RotateKey(t.Context(), store, oldPath, oldPath); err == nil { + t.Fatal("same key path accepted") + } + samePath := filepath.Join(t.TempDir(), "copy.key") + key, err := os.ReadFile(oldPath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(samePath, key, 0600); err != nil { + t.Fatal(err) + } + if err := RotateKey(t.Context(), store, oldPath, samePath); err == nil { + t.Fatal("identical key bytes accepted") + } +} + +func TestMasterKeyRotationSupportsEmptyStoreAndProvisionedNewKey(t *testing.T) { + store, oldClient, oldPath := managementFixture(t) + newPath := filepath.Join(t.TempDir(), "external.key") + newKey := bytes.Repeat([]byte{42}, 32) + if err := os.WriteFile(newPath, newKey, 0400); err != nil { + t.Fatal(err) + } + if err := RotateKeyWithOptions(t.Context(), store, oldPath, KeyOptions{File: newPath, Provisioned: true}); err != nil { + t.Fatal(err) + } + if _, err := oldClient.Secrets("panel").Create(t.Context(), credential("stale"), metav1.CreateOptions{}); err == nil { + t.Fatal("empty-store rotation allowed stale writer") + } + if _, err := NewWithOptions(t.Context(), store, KeyOptions{File: newPath, Provisioned: true}); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(newPath) + if err != nil || !bytes.Equal(got, newKey) { + t.Fatal("rotation changed externally provisioned key") + } +} + +func TestEstablishedEmptyStoreNeverReplacesMissingKey(t *testing.T) { + store, _, path := managementFixture(t) + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if _, err := New(t.Context(), store, path); err == nil { + t.Fatal("replaced missing key of established empty store") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("missing established key was recreated") + } +} + +func TestCiphertextHeaderCannotBeStrippedOrChangeKeyIdentity(t *testing.T) { + for _, change := range []string{"strip", "identity"} { + t.Run(change, func(t *testing.T) { + store, client, path := managementFixture(t) + if _, err := client.Secrets("panel").Create(t.Context(), credential("provider"), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + var payload string + if err := store.DB.QueryRowContext(t.Context(), `SELECT payload FROM management_objects WHERE name = 'provider'`).Scan(&payload); err != nil { + t.Fatal(err) + } + parts := strings.Split(payload, ".") + if len(parts) != 3 { + t.Fatal("credential has no versioned key identity") + } + if change == "strip" { + payload = parts[2] + } else { + payload = parts[0] + "." + strings.Repeat("0", 64) + "." + parts[2] + } + if _, err := store.DB.ExecContext(t.Context(), `UPDATE management_objects SET payload = ? WHERE name = 'provider'`, payload); err != nil { + t.Fatal(err) + } + if _, err := New(t.Context(), store, path); err == nil { + t.Fatal("ciphertext header tampering authenticated") + } + }) + } +} diff --git a/api/internal/controlplane/key_state.go b/api/internal/controlplane/key_state.go new file mode 100644 index 000000000..4484aac28 --- /dev/null +++ b/api/internal/controlplane/key_state.go @@ -0,0 +1,117 @@ +package controlplane + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" +) + +func backendForKey(database *sql.DB, key []byte) (*backend, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("create credential cipher: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create credential encryption: %w", err) + } + identity := sha256.New() + _, _ = identity.Write([]byte("Gameplane management encryption key identity\x00")) + _, _ = identity.Write(key) + return &backend{db: database, aead: aead, keyID: hex.EncodeToString(identity.Sum(nil))}, nil +} + +// lockKeyState both checks key identity and takes a database write lock held +// until tx commits. UPDATE works on SQLite and PostgreSQL without dialect-specific +// SELECT FOR UPDATE. Rotation cannot retire a key underneath a checked writer. +func (b *backend) lockKeyState(ctx context.Context, tx *sql.Tx) error { + result, err := tx.ExecContext(ctx, `UPDATE management_key_state SET key_id = key_id WHERE singleton = 1 AND key_id = ?`, b.keyID) + if err != nil { + return fmt.Errorf("lock management encryption key state: %w", err) + } + count, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("inspect management encryption key state: %w", err) + } + if count != 1 { + return errors.New("management encryption key is inactive; restart with the current key file") + } + return nil +} + +func (b *backend) initializeKeyState(ctx context.Context, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, `INSERT INTO management_key_state (singleton, key_id, previous_key_id) VALUES (1, ?, '') ON CONFLICT (singleton) DO NOTHING`, b.keyID); err != nil { + return fmt.Errorf("initialize management encryption key state: %w", err) + } + return b.lockKeyState(ctx, tx) +} + +func (b *backend) validateStorage(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, `SELECT kind, namespace, name, uid, version, payload FROM management_objects`) + if err != nil { + return fmt.Errorf("validate management storage: %w", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var kind, ns, name, uid, payload string + var version int64 + if err := rows.Scan(&kind, &ns, &name, &uid, &version, &payload); err != nil { + return fmt.Errorf("read management row: %w", err) + } + if kind != "secrets" && kind != "clusters" { + return errors.New("unknown management object kind") + } + if _, err := (&objects{b, kind, ns}).decode(name, uid, version, payload); err != nil { + return err + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("read management storage: %w", err) + } + return nil +} + +func (b *backend) validateAndInitialize(ctx context.Context) error { + tx, err := b.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin management key validation: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := b.initializeKeyState(ctx, tx); err != nil { + return err + } + if err := b.validateStorage(ctx, tx); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit management key validation: %w", err) + } + return nil +} + +func (b *backend) write(ctx context.Context, secret bool, query string, args ...any) (sql.Result, error) { + if !secret { + return b.db.ExecContext(ctx, query, args...) + } + tx, err := b.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin credential write: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := b.lockKeyState(ctx, tx); err != nil { + return nil, err + } + result, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("write credential: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit credential write: %w", err) + } + return result, nil +} diff --git a/api/internal/controlplane/registrations.go b/api/internal/controlplane/registrations.go new file mode 100644 index 000000000..ed4a758aa --- /dev/null +++ b/api/internal/controlplane/registrations.go @@ -0,0 +1,128 @@ +package controlplane + +import ( + "bytes" + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + + "github.com/GameplanePanel/gameplane/api/internal/kube" + corev1 "k8s.io/api/core/v1" + apierrors "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/types" + "k8s.io/apimachinery/pkg/util/validation" +) + +func (b *backend) registerCluster(ctx context.Context, ns string, secret *corev1.Secret, registration *unstructured.Unstructured) (*unstructured.Unstructured, error) { + if secret == nil || registration == nil { + return nil, apierrors.NewBadRequest("registration and credential are required") + } + name := registration.GetName() + ref, _, _ := unstructured.NestedString(registration.Object, "spec", "kubeconfigSecret", "name") + if secret.Name != "cluster-"+name+"-kubeconfig" || ref != secret.Name || secret.Labels[kube.ClusterKubeconfigLabel] != "true" || secret.Labels["gameplane.local/managed-by"] != "gameplane-api" { + return nil, apierrors.NewBadRequest("registration requires its API-owned kubeconfig credential") + } + credential, err := secretObject(secret) + if err != nil { + return nil, err + } + credential, err = prepareRegistrationObject(credential, ns) + if err != nil { + return nil, err + } + registration, err = prepareRegistrationObject(registration, "") + if err != nil { + return nil, err + } + tx, err := b.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin cluster registration: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := b.lockKeyState(ctx, tx); err != nil { + return nil, err + } + // The absence check and possible legacy-orphan adoption occur in the + // same transaction as both writes. No failed registration strands a key. + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM management_objects WHERE kind = 'clusters' AND namespace = '' AND name = ?`, name).Scan(&count); err != nil { + return nil, fmt.Errorf("inspect cluster registration: %w", err) + } + if count != 0 { + return nil, apierrors.NewAlreadyExists(kube.GVRCluster.GroupResource(), name) + } + credentials := &objects{backend: b, kind: "secrets", ns: ns} + var uid, payload string + var version int64 + err = tx.QueryRowContext(ctx, `SELECT uid, version, payload FROM management_objects WHERE kind = 'secrets' AND namespace = ? AND name = ?`, ns, secret.Name).Scan(&uid, &version, &payload) + switch { + case errors.Is(err, sql.ErrNoRows): + if err := insertRegistrationObject(ctx, tx, credentials, credential); err != nil { + return nil, err + } + case err != nil: + return nil, fmt.Errorf("inspect registration credential: %w", err) + default: + existing, err := asSecret(credentials.decode(secret.Name, uid, version, payload)) + if err != nil { + return nil, err + } + // Recover a legacy interrupted POST only when its fixed name, + // ownership labels and exact credential match this retry. Adopt it + // without changing its identity or contents; external secrets survive. + incoming, err := asSecret(credential, nil) + if err != nil { + return nil, err + } + if existing.Labels[kube.ClusterKubeconfigLabel] != "true" || existing.Labels["gameplane.local/managed-by"] != "gameplane-api" || len(existing.Data) != 1 || len(incoming.Data) != 1 || !bytes.Equal(existing.Data["kubeconfig"], incoming.Data["kubeconfig"]) { + return nil, apierrors.NewAlreadyExists(corev1.Resource("secrets"), secret.Name) + } + } + if err := insertRegistrationObject(ctx, tx, &objects{backend: b, kind: "clusters"}, registration); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit cluster registration: %w", err) + } + return registration, nil +} + +func prepareRegistrationObject(obj *unstructured.Unstructured, ns string) (*unstructured.Unstructured, error) { + obj = obj.DeepCopy() + if len(validation.IsDNS1123Subdomain(obj.GetName())) != 0 || (obj.GetNamespace() != "" && obj.GetNamespace() != ns) || obj.GetResourceVersion() != "" { + return nil, apierrors.NewBadRequest("invalid registration object metadata") + } + identity := make([]byte, 16) + if _, err := rand.Read(identity); err != nil { + return nil, fmt.Errorf("generate registration identity: %w", err) + } + obj.SetNamespace(ns) + obj.SetUID(types.UID(hex.EncodeToString(identity))) + obj.SetCreationTimestamp(metav1.Now()) + obj.SetResourceVersion("1") + return obj, nil +} + +func insertRegistrationObject(ctx context.Context, tx *sql.Tx, store *objects, obj *unstructured.Unstructured) error { + payload, err := store.encode(obj, 1) + if err != nil { + return err + } + result, err := tx.ExecContext(ctx, `INSERT INTO management_objects (kind, namespace, name, uid, version, payload) VALUES (?, ?, ?, ?, 1, ?) ON CONFLICT (kind, namespace, name) DO NOTHING`, store.kind, store.ns, obj.GetName(), string(obj.GetUID()), payload) + if err != nil { + return fmt.Errorf("persist cluster registration: %w", err) + } + count, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("inspect cluster registration write: %w", err) + } + if count == 0 { + return apierrors.NewAlreadyExists(store.resource(), obj.GetName()) + } + return nil +} diff --git a/api/internal/controlplane/registrations_test.go b/api/internal/controlplane/registrations_test.go new file mode 100644 index 000000000..c13b1559e --- /dev/null +++ b/api/internal/controlplane/registrations_test.go @@ -0,0 +1,121 @@ +package controlplane + +import ( + "bytes" + "context" + "database/sql/driver" + "fmt" + "testing" + "time" + + "github.com/GameplanePanel/gameplane/api/internal/kube" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "modernc.org/sqlite" +) + +func registrationObjects() (*corev1.Secret, *unstructured.Unstructured) { + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "cluster-remote-kubeconfig", Namespace: "panel", Labels: map[string]string{kube.ClusterKubeconfigLabel: "true", "gameplane.local/managed-by": "gameplane-api"}}, Data: map[string][]byte{"kubeconfig": []byte("registration-credential")}} + registration := &unstructured.Unstructured{Object: map[string]any{"apiVersion": "gameplane.local/v1alpha1", "kind": "Cluster", "metadata": map[string]any{"name": "remote"}, "spec": map[string]any{"kubeconfigSecret": map[string]any{"name": secret.Name, "key": "kubeconfig"}}}} + return secret, registration +} + +func TestRegistrationCancellationAfterCredentialWriteRollsBack(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + function := fmt.Sprintf("cancel_registration_%d", time.Now().UnixNano()) + inserted := false + if err := sqlite.RegisterScalarFunction(function, 0, func(_ *sqlite.FunctionContext, _ []driver.Value) (driver.Value, error) { + inserted = true + cancel() + return int64(0), nil + }); err != nil { + t.Fatal(err) + } + store, client, _ := managementFixture(t) + if _, err := store.DB.ExecContext(t.Context(), `CREATE TRIGGER cancel_registration AFTER INSERT ON management_objects WHEN NEW.kind = 'secrets' BEGIN SELECT `+function+`(); END`); err != nil { + t.Fatal(err) + } + secret, registration := registrationObjects() + if _, err := client.RegisterCluster(ctx, "panel", secret, registration); err == nil || !inserted { + t.Fatalf("registration must cancel after the credential insert: inserted=%v err=%v", inserted, err) + } + var count int + if err := store.DB.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM management_objects`).Scan(&count); err != nil || count != 0 { + t.Fatalf("cancelled transaction persisted objects: count=%d err=%v", count, err) + } +} + +func TestRegistrationTransactionRollsBackCredentialOnRegistrationFailure(t *testing.T) { + store, client, _ := managementFixture(t) + // Fail the second database write, after the credential has been inserted. + if _, err := store.DB.ExecContext(t.Context(), `CREATE TRIGGER reject_registration BEFORE INSERT ON management_objects WHEN NEW.kind = 'clusters' BEGIN SELECT RAISE(ABORT, 'registration rejected'); END`); err != nil { + t.Fatal(err) + } + secret, registration := registrationObjects() + if _, err := client.RegisterCluster(t.Context(), "panel", secret, registration); err == nil { + t.Fatal("registration unexpectedly succeeded") + } + if _, err := client.Secrets("panel").Get(t.Context(), secret.Name, metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("transaction left an orphan credential: %v", err) + } + if _, err := client.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("transaction left a registration: %v", err) + } +} + +func TestRegistrationCancellationAndConflictLeaveNoCredential(t *testing.T) { + _, client, _ := managementFixture(t) + secret, registration := registrationObjects() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := client.RegisterCluster(ctx, "panel", secret, registration); err == nil { + t.Fatal("cancelled registration succeeded") + } + if _, err := client.Clusters().Create(t.Context(), registration, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + if _, err := client.RegisterCluster(t.Context(), "panel", secret, registration); !apierrors.IsAlreadyExists(err) { + t.Fatalf("existing registration must conflict: %v", err) + } + if _, err := client.Secrets("panel").Get(t.Context(), secret.Name, metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("cancel/conflict left a credential: %v", err) + } +} + +func TestRegistrationRecoversOnlyMatchingLegacyOwnedOrphan(t *testing.T) { + for _, mutation := range []string{"matching", "external", "different-credential", "different-name"} { + t.Run(mutation, func(t *testing.T) { + _, client, _ := managementFixture(t) + secret, registration := registrationObjects() + orphan := secret.DeepCopy() + switch mutation { + case "external": + delete(orphan.Labels, "gameplane.local/managed-by") + case "different-credential": + orphan.Data["kubeconfig"] = []byte("unrelated-credential") + case "different-name": + secret.Name, orphan.Name = "external-kubeconfig", "external-kubeconfig" + _ = unstructured.SetNestedField(registration.Object, secret.Name, "spec", "kubeconfigSecret", "name") + } + created, err := client.Secrets("panel").Create(t.Context(), orphan, metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + registered, err := client.RegisterCluster(t.Context(), "panel", secret, registration) + if mutation == "matching" { + if err != nil || registered.GetUID() == "" { + t.Fatalf("legacy orphan was not recovered: %v", err) + } + } else if err == nil { + t.Fatal("adopted unrelated credential") + } + current, err := client.Secrets("panel").Get(t.Context(), orphan.Name, metav1.GetOptions{}) + if err != nil || current.UID != created.UID || current.ResourceVersion != created.ResourceVersion || !bytes.Equal(current.Data["kubeconfig"], created.Data["kubeconfig"]) { + t.Fatal("orphan recovery modified the existing secret") + } + }) + } +} diff --git a/api/internal/controlplane/remote_health_fixture_test.go b/api/internal/controlplane/remote_health_fixture_test.go new file mode 100644 index 000000000..209a81799 --- /dev/null +++ b/api/internal/controlplane/remote_health_fixture_test.go @@ -0,0 +1,85 @@ +package controlplane + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "math/big" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// Use a real private-network address and verified TLS so the watcher exercises +// the production destination guard without permitting loopback or external I/O. +func privateTLSServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + listener, ip := privateTestListener(t) + t.Cleanup(func() { _ = listener.Close() }) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Now() + certificate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + IPAddresses: []net.IP{ip}, + BasicConstraintsValid: true, + IsCA: true, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, certificate, certificate, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(handler) + _ = server.Listener.Close() + server.Listener = listener + server.TLS = &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{{ + Certificate: [][]byte{der}, + PrivateKey: key, + }}, + } + server.StartTLS() + t.Cleanup(server.Close) + return server +} + +func privateTestListener(t *testing.T) (net.Listener, net.IP) { + t.Helper() + interfaces, err := net.Interfaces() + if err != nil { + t.Fatal(err) + } + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + addresses, err := iface.Addrs() + if err != nil { + t.Fatalf("inspect private test interface: %v", err) + } + for _, address := range addresses { + ip, _, err := net.ParseCIDR(address.String()) + if err != nil || ip.To4() == nil || !ip.IsPrivate() || ip.IsLoopback() { + continue + } + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp4", net.JoinHostPort(ip.String(), "0")) + if err != nil { + t.Fatalf("bind private remote-health test listener: %v", err) + } + return listener, ip + } + } + t.Fatal("remote health test requires an up interface with a private IPv4 address") + return nil, nil +} diff --git a/api/internal/controlplane/resources.go b/api/internal/controlplane/resources.go new file mode 100644 index 000000000..8a476d1c7 --- /dev/null +++ b/api/internal/controlplane/resources.go @@ -0,0 +1,134 @@ +package controlplane + +import ( + "context" + "encoding/json" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +type secrets struct{ objects } +type clusters struct{ objects } + +func secretObject(secret *corev1.Secret) (*unstructured.Unstructured, error) { + secret = secret.DeepCopy() + if secret.Data == nil { + secret.Data = map[string][]byte{} + } + for key, value := range secret.StringData { + secret.Data[key] = []byte(value) + } + secret.StringData = nil + secret.APIVersion = "v1" + secret.Kind = "Secret" + data, err := json.Marshal(secret) + if err != nil { + return nil, fmt.Errorf("encode credential: %w", err) + } + obj := &unstructured.Unstructured{} + if err := json.Unmarshal(data, &obj.Object); err != nil { + return nil, fmt.Errorf("decode credential object: %w", err) + } + return obj, nil +} + +func asSecret(obj *unstructured.Unstructured, err error) (*corev1.Secret, error) { + if err != nil { + return nil, err + } + data, err := json.Marshal(obj.Object) + if err != nil { + return nil, &credentialStorageError{fmt.Errorf("management object secrets/%s/%s: encode credential object: %w", obj.GetNamespace(), obj.GetName(), err)} + } + secret := &corev1.Secret{} + if err := json.Unmarshal(data, secret); err != nil { + return nil, &credentialStorageError{fmt.Errorf("management object secrets/%s/%s: decode credential: %w", obj.GetNamespace(), obj.GetName(), err)} + } + return secret, nil +} + +func (s *secrets) Get(ctx context.Context, name string, _ metav1.GetOptions) (*corev1.Secret, error) { + return asSecret(s.get(ctx, name)) +} + +func (s *secrets) List(ctx context.Context, opts metav1.ListOptions) (*corev1.SecretList, error) { + list, err := s.list(ctx, opts) + if err != nil { + return nil, err + } + out := &corev1.SecretList{Items: []corev1.Secret{}} + out.Continue = list.GetContinue() + for i := range list.Items { + secret, err := asSecret(&list.Items[i], nil) + if err != nil { + return nil, err + } + out.Items = append(out.Items, *secret) + } + return out, nil +} + +func (s *secrets) Create(ctx context.Context, secret *corev1.Secret, opts metav1.CreateOptions) (*corev1.Secret, error) { + if len(opts.DryRun) != 0 { + return nil, apierrors.NewBadRequest("management dry run is unsupported") + } + obj, err := secretObject(secret) + if err != nil { + return nil, err + } + return asSecret(s.save(ctx, obj, true)) +} + +func (s *secrets) Update(ctx context.Context, secret *corev1.Secret, opts metav1.UpdateOptions) (*corev1.Secret, error) { + if len(opts.DryRun) != 0 { + return nil, apierrors.NewBadRequest("management dry run is unsupported") + } + obj, err := secretObject(secret) + if err != nil { + return nil, err + } + return asSecret(s.save(ctx, obj, false)) +} + +func (s *secrets) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + if len(opts.DryRun) != 0 { + return apierrors.NewBadRequest("management dry run is unsupported") + } + return s.delete(ctx, name, opts) +} + +func (s *clusters) Get(ctx context.Context, name string, _ metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) { + if len(subresources) != 0 { + return nil, apierrors.NewBadRequest("management subresources are unsupported") + } + return s.get(ctx, name) +} + +func (s *clusters) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + return s.list(ctx, opts) +} + +func (s *clusters) Create(ctx context.Context, obj *unstructured.Unstructured, opts metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) { + if len(subresources) != 0 || len(opts.DryRun) != 0 { + return nil, apierrors.NewBadRequest("management subresources and dry run are unsupported") + } + return s.save(ctx, obj, true) +} + +func (s *clusters) Update(ctx context.Context, obj *unstructured.Unstructured, opts metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) { + if len(subresources) != 0 || len(opts.DryRun) != 0 { + return nil, apierrors.NewBadRequest("management subresources and dry run are unsupported") + } + return s.save(ctx, obj, false) +} + +func (s *clusters) Delete(ctx context.Context, name string, opts metav1.DeleteOptions, subresources ...string) error { + if len(subresources) != 0 || len(opts.DryRun) != 0 { + return apierrors.NewBadRequest("management subresources and dry run are unsupported") + } + return s.delete(ctx, name, opts) +} diff --git a/api/internal/controlplane/store.go b/api/internal/controlplane/store.go new file mode 100644 index 000000000..18e94217a --- /dev/null +++ b/api/internal/controlplane/store.go @@ -0,0 +1,403 @@ +// Package controlplane stores standalone panel credentials and remote cluster +// registrations without creating a local Kubernetes cluster. +package controlplane + +import ( + "bytes" + "context" + "crypto/cipher" + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/GameplanePanel/gameplane/api/internal/db" + "github.com/GameplanePanel/gameplane/api/internal/kube" + apierrors "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/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" +) + +type backend struct { + db *sql.DB + aead cipher.AEAD + keyID string +} + +type objects struct { + *backend + kind string + ns string +} + +// New opens management storage after Store.Migrate. The encryption key must +// survive alongside the database; a missing key with existing secrets is fatal. +// Kubernetes workload clients are intentionally absent from the result. +func New(ctx context.Context, store *db.Store, keyFile string) (*kube.Client, error) { + return NewWithOptions(ctx, store, KeyOptions{File: keyFile}) +} + +// NewWithOptions opens standalone management storage with explicit key custody. +func NewWithOptions(ctx context.Context, store *db.Store, opts KeyOptions) (*kube.Client, error) { + var count int + if err := store.DB.QueryRowContext(ctx, `SELECT (SELECT COUNT(*) FROM management_objects WHERE kind = 'secrets') + (SELECT COUNT(*) FROM management_key_state)`).Scan(&count); err != nil { + return nil, fmt.Errorf("inspect management credentials: %w", err) + } + key, err := loadConfiguredKey(opts, count != 0) + if err != nil { + return nil, err + } + b, err := backendForKey(store.DB, key) + if err != nil { + return nil, err + } + // Validate every persisted row before serving requests. Wrong keys and + // corrupt ciphertext fail startup. Keep validation under the same write + // lock rotation uses, so an old-key process cannot start during retirement. + if err := b.validateAndInitialize(ctx); err != nil { + return nil, err + } + return &kube.Client{ + SecretStore: func(ns string) kube.SecretStore { return &secrets{objects{b, "secrets", ns}} }, + ClusterStore: &clusters{objects{b, "clusters", ""}}, + RegisterCluster: b.registerCluster, + }, nil +} + +func loadKey(path string, required bool) ([]byte, error) { + return loadKeyWithSync(path, required, syncKeyDirectory) +} + +func loadKeyWithSync(path string, required bool, syncDirectory func(string) error) ([]byte, error) { + if path == "" { + return nil, errors.New("management key file path is required") + } + if err := validateKeyPlatform(); err != nil { + return nil, err + } + if !required { + if err := createKeyDirectory(filepath.Dir(path), syncDirectory); err != nil { + return nil, fmt.Errorf("create key directory: %w", err) + } + } + unlock, err := lockKeyPublication(filepath.Dir(path)) + if err != nil { + return nil, err + } + defer unlock() + if err := recoverKeyAliases(path); err != nil { + return nil, fmt.Errorf("recover management key publication: %w", err) + } + key, err := readKey(path) + if err == nil { + if len(key) != 32 { + return nil, errors.New("management key file must contain exactly 32 bytes") + } + // Also persist a concurrent winner's publication, including a retry + // after its directory sync failed. Never replace its key. + if err := syncDirectory(filepath.Dir(path)); err != nil { + return nil, fmt.Errorf("sync existing management key directory: %w", err) + } + return key, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("read management key: %w", err) + } + if required { + return nil, errors.New("management encryption key is missing; restore the original key alongside the database") + } + key = make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generate management key: %w", err) + } + // Link a fully written private temporary file into place atomically. An + // existing destination is never overwritten, including concurrent startup. + f, err := os.CreateTemp(filepath.Dir(path), ".management-key-*") + if err != nil { + return nil, fmt.Errorf("create management key: %w", err) + } + tmp := f.Name() + defer func() { _ = os.Remove(tmp) }() + if _, err := f.Write(key); err != nil { + _ = f.Close() + return nil, fmt.Errorf("write management key: %w", err) + } + if err := f.Sync(); err != nil { + _ = f.Close() + return nil, fmt.Errorf("sync management key: %w", err) + } + if err := f.Close(); err != nil { + return nil, fmt.Errorf("close management key: %w", err) + } + if err := os.Link(tmp, path); err != nil { + if errors.Is(err, os.ErrExist) { + return nil, errors.New("management key appeared outside the publication lock; retry startup") + } + return nil, fmt.Errorf("publish management key: %w", err) + } + if err := os.Remove(tmp); err != nil { + return nil, fmt.Errorf("remove management key publication alias: %w", err) + } + // Syncing the inode above does not persist its published directory entry. + // Do not allow encrypted DB writes until the final name survives a crash. + if err := syncDirectory(filepath.Dir(path)); err != nil { + return nil, fmt.Errorf("sync management key directory: %w", err) + } + // Files can inherit extended ACLs from their parent even when created + // with mode 0600. Apply the same handle-based access checks before first use. + published, err := readKey(path) + if err != nil { + return nil, fmt.Errorf("validate published management key: %w", err) + } + if !bytes.Equal(published, key) { + return nil, errors.New("management key changed during publication") + } + return key, nil +} + +// readKey confines key access to its configured directory, including symlink +// resolution. The path is operator configuration, never an HTTP request value. +func readKey(path string) ([]byte, error) { + if err := validateKeyPlatform(); err != nil { + return nil, err + } + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return nil, fmt.Errorf("open management key directory: %w", err) + } + defer func() { _ = root.Close() }() + f, err := openKeyHandle(root, filepath.Base(path)) + if err != nil { + return nil, fmt.Errorf("open management key: %w", err) + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("inspect management key: %w", err) + } + if !info.Mode().IsRegular() || info.Size() != 32 { + return nil, errors.New("management key must be a regular file containing exactly 32 bytes") + } + if err := validateKeyFile(info); err != nil { + return nil, err + } + // Validate and read the same open inode; limit even if it grows after Stat. + key, err := io.ReadAll(io.LimitReader(f, 33)) + if err != nil { + return nil, fmt.Errorf("read management key bytes: %w", err) + } + if len(key) != 32 { + return nil, errors.New("management key changed size while reading") + } + return key, nil +} + +func (s *objects) resource() schema.GroupResource { + if s.kind == "clusters" { + return kube.GVRCluster.GroupResource() + } + return schema.GroupResource{Resource: "secrets"} +} + +func (s *objects) aad(name, uid string, version int64) []byte { + return []byte(strings.Join([]string{s.kind, s.ns, name, uid, strconv.FormatInt(version, 10)}, "\x00")) +} + +func (s *objects) encode(obj *unstructured.Unstructured, version int64) (string, error) { + data, err := json.Marshal(obj.Object) + if err != nil { + return "", fmt.Errorf("encode management object: %w", err) + } + if s.kind != "secrets" { + return string(data), nil + } + nonce := make([]byte, s.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("generate credential nonce: %w", err) + } + sealed := s.aead.Seal(nonce, nonce, data, s.ciphertextAAD(obj.GetName(), string(obj.GetUID()), version)) + return s.ciphertextHeader() + base64.StdEncoding.EncodeToString(sealed), nil +} + +func (s *objects) decode(name, uid string, version int64, payload string) (*unstructured.Unstructured, error) { + data := []byte(payload) + if s.kind == "secrets" { + var err error + data, err = s.openCiphertext(name, uid, version, payload) + if err != nil { + return nil, &credentialStorageError{fmt.Errorf("management object %s/%s/%s: %w", s.kind, s.ns, name, err)} + } + } + obj := &unstructured.Unstructured{} + if err := json.Unmarshal(data, &obj.Object); err != nil { + return nil, &credentialStorageError{fmt.Errorf("management object %s/%s/%s: invalid object data", s.kind, s.ns, name)} + } + if obj.GetName() != name || obj.GetNamespace() != s.ns || string(obj.GetUID()) != uid || obj.GetResourceVersion() != strconv.FormatInt(version, 10) { + return nil, &credentialStorageError{fmt.Errorf("management object %s/%s/%s: metadata does not match stored identity", s.kind, s.ns, name)} + } + return obj, nil +} + +func (s *objects) get(ctx context.Context, name string) (*unstructured.Unstructured, error) { + var uid, payload string + var version int64 + err := s.db.QueryRowContext(ctx, `SELECT uid, version, payload FROM management_objects WHERE kind = ? AND namespace = ? AND name = ?`, s.kind, s.ns, name).Scan(&uid, &version, &payload) + if errors.Is(err, sql.ErrNoRows) { + return nil, apierrors.NewNotFound(s.resource(), name) + } + if err != nil { + return nil, fmt.Errorf("read management object: %w", err) + } + return s.decode(name, uid, version, payload) +} + +func (s *objects) list(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + selector, err := labels.Parse(opts.LabelSelector) + if err != nil { + return nil, apierrors.NewBadRequest("invalid label selector") + } + if opts.FieldSelector != "" || opts.Watch || opts.Limit < 0 { + return nil, apierrors.NewBadRequest("unsupported management list options") + } + var after string + if opts.Continue != "" { + decoded, err := base64.RawURLEncoding.DecodeString(opts.Continue) + if err != nil { + return nil, apierrors.NewBadRequest("invalid continuation token") + } + after = string(decoded) + } + rows, err := s.db.QueryContext(ctx, `SELECT name, uid, version, payload FROM management_objects WHERE kind = ? AND namespace = ? AND name > ? ORDER BY name`, s.kind, s.ns, after) + if err != nil { + return nil, fmt.Errorf("list management objects: %w", err) + } + defer func() { _ = rows.Close() }() + result := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{}} + for rows.Next() { + var name, uid, payload string + var version int64 + if err := rows.Scan(&name, &uid, &version, &payload); err != nil { + return nil, fmt.Errorf("read management object: %w", err) + } + obj, err := s.decode(name, uid, version, payload) + if err != nil { + return nil, err + } + if !selector.Matches(labels.Set(obj.GetLabels())) { + continue + } + if opts.Limit > 0 && int64(len(result.Items)) == opts.Limit { + result.SetContinue(base64.RawURLEncoding.EncodeToString([]byte(result.Items[len(result.Items)-1].GetName()))) + break + } + result.Items = append(result.Items, *obj) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list management objects: %w", err) + } + return result, nil +} + +func (s *objects) save(ctx context.Context, obj *unstructured.Unstructured, create bool) (*unstructured.Unstructured, error) { + obj = obj.DeepCopy() + name := obj.GetName() + if len(validation.IsDNS1123Subdomain(name)) != 0 { + return nil, apierrors.NewBadRequest("a valid management object name is required") + } + if obj.GetNamespace() != "" && obj.GetNamespace() != s.ns { + return nil, apierrors.NewBadRequest("namespace does not match management store") + } + obj.SetNamespace(s.ns) + version := int64(1) + if create { + if obj.GetResourceVersion() != "" { + return nil, apierrors.NewBadRequest("resourceVersion must be empty on create") + } + uid := make([]byte, 16) + if _, err := rand.Read(uid); err != nil { + return nil, fmt.Errorf("generate management identity: %w", err) + } + obj.SetUID(types.UID(hex.EncodeToString(uid))) + obj.SetCreationTimestamp(metav1.Now()) + } else { + existing, err := s.get(ctx, name) + if err != nil { + return nil, err + } + if obj.GetResourceVersion() == "" || obj.GetResourceVersion() != existing.GetResourceVersion() || (obj.GetUID() != "" && obj.GetUID() != existing.GetUID()) { + return nil, apierrors.NewConflict(s.resource(), name, errors.New("management object changed; reload before updating")) + } + previous, err := strconv.ParseInt(existing.GetResourceVersion(), 10, 64) + if err != nil { + return nil, errors.New("invalid management object version") + } + version = previous + 1 + obj.SetUID(existing.GetUID()) + obj.SetCreationTimestamp(existing.GetCreationTimestamp()) + } + obj.SetResourceVersion(strconv.FormatInt(version, 10)) + payload, err := s.encode(obj, version) + if err != nil { + return nil, err + } + var result sql.Result + if create { + result, err = s.write(ctx, s.kind == "secrets", `INSERT INTO management_objects (kind, namespace, name, uid, version, payload) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (kind, namespace, name) DO NOTHING`, s.kind, s.ns, name, string(obj.GetUID()), version, payload) + } else { + result, err = s.write(ctx, s.kind == "secrets", `UPDATE management_objects SET version = ?, payload = ? WHERE kind = ? AND namespace = ? AND name = ? AND uid = ? AND version = ?`, version, payload, s.kind, s.ns, name, string(obj.GetUID()), version-1) + } + if err != nil { + return nil, fmt.Errorf("persist management object: %w", err) + } + n, err := result.RowsAffected() + if err != nil { + return nil, fmt.Errorf("inspect management write: %w", err) + } + if n == 0 { + if create { + return nil, apierrors.NewAlreadyExists(s.resource(), name) + } + return nil, apierrors.NewConflict(s.resource(), name, errors.New("management object changed during update")) + } + return obj, nil +} + +func (s *objects) delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + obj, err := s.get(ctx, name) + if err != nil { + return err + } + if p := opts.Preconditions; p != nil { + if (p.UID != nil && *p.UID != obj.GetUID()) || (p.ResourceVersion != nil && *p.ResourceVersion != obj.GetResourceVersion()) { + return apierrors.NewConflict(s.resource(), name, errors.New("management object delete precondition failed")) + } + } + version, err := strconv.ParseInt(obj.GetResourceVersion(), 10, 64) + if err != nil { + return fmt.Errorf("management object %s/%s/%s: invalid version: %w", s.kind, s.ns, name, err) + } + result, err := s.write(ctx, s.kind == "secrets", `DELETE FROM management_objects WHERE kind = ? AND namespace = ? AND name = ? AND uid = ? AND version = ?`, s.kind, s.ns, name, string(obj.GetUID()), version) + if err != nil { + return fmt.Errorf("delete management object: %w", err) + } + n, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("inspect management deletion: %w", err) + } + if n == 0 { + return apierrors.NewConflict(s.resource(), name, errors.New("management object changed during deletion")) + } + return nil +} diff --git a/api/internal/controlplane/store_postgres_test.go b/api/internal/controlplane/store_postgres_test.go new file mode 100644 index 000000000..a10d83225 --- /dev/null +++ b/api/internal/controlplane/store_postgres_test.go @@ -0,0 +1,113 @@ +//go:build postgres + +package controlplane + +import ( + "context" + "crypto/rand" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/db" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Exercise management SQL through the actual PostgreSQL driver, including +// placeholder rebinding, optimistic writes and authenticated persistence. +func TestPostgresManagementPersistence(t *testing.T) { + dsn := os.Getenv("GAMEPLANE_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("GAMEPLANE_TEST_POSTGRES_DSN not set") + } + ctx := t.Context() + admin, err := db.Open(ctx, "postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = admin.Close() }) + var suffix [8]byte + if _, err := rand.Read(suffix[:]); err != nil { + t.Fatal(err) + } + schema := "gp_management_" + hex.EncodeToString(suffix[:]) + if _, err := admin.DB.ExecContext(ctx, `CREATE SCHEMA `+schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := admin.DB.ExecContext(context.WithoutCancel(ctx), `DROP SCHEMA `+schema+` CASCADE`); err != nil { + t.Errorf("drop management schema: %v", err) + } + }) + if strings.Contains(dsn, "://") { + separator := "?" + if strings.Contains(dsn, "?") { + separator = "&" + } + dsn += separator + "search_path=" + schema + } else { + dsn += " search_path=" + schema + } + store, err := db.Open(ctx, "postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Migrate(ctx); err != nil { + t.Fatal(err) + } + keyFile := filepath.Join(t.TempDir(), "panel.key") + client, err := New(ctx, store, keyFile) + if err != nil { + t.Fatal(err) + } + first, err := client.Secrets("panel").Create(ctx, credential("provider"), metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + if _, err := client.Secrets("panel").Create(ctx, credential("provider"), metav1.CreateOptions{}); !apierrors.IsAlreadyExists(err) { + t.Fatalf("duplicate credential: %v", err) + } + changed := first.DeepCopy() + changed.Data["token"] = []byte("rotated-postgres-token") + updated, err := client.Secrets("panel").Update(ctx, changed, metav1.UpdateOptions{}) + if err != nil { + t.Fatal(err) + } + if _, err := client.Secrets("panel").Update(ctx, first, metav1.UpdateOptions{}); !apierrors.IsConflict(err) { + t.Fatalf("stale credential update: %v", err) + } + if _, err := client.Clusters().Create(ctx, registration("remote"), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + reopened, err := New(ctx, store, keyFile) + if err != nil { + t.Fatal(err) + } + list, err := reopened.Secrets("panel").List(ctx, metav1.ListOptions{LabelSelector: "managed=true"}) + if err != nil || len(list.Items) != 1 || string(list.Items[0].Data["token"]) != "rotated-postgres-token" { + t.Fatalf("reopened credentials: %v", err) + } + if _, err := reopened.Secrets("other").Get(ctx, "provider", metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("cross-namespace credential: %v", err) + } + if _, err := reopened.Clusters().Get(ctx, "remote", metav1.GetOptions{}); err != nil { + t.Fatal(err) + } + if err := reopened.Secrets("panel").Delete(ctx, "provider", metav1.DeleteOptions{Preconditions: &metav1.Preconditions{ResourceVersion: &first.ResourceVersion}}); !apierrors.IsConflict(err) { + t.Fatalf("stale credential deletion: %v", err) + } + if err := reopened.Secrets("panel").Delete(ctx, "provider", metav1.DeleteOptions{Preconditions: &metav1.Preconditions{UID: &updated.UID, ResourceVersion: &updated.ResourceVersion}}); err != nil { + t.Fatal(err) + } + if err := reopened.Clusters().Delete(ctx, "remote", metav1.DeleteOptions{}); err != nil { + t.Fatal(err) + } + registrations, err := reopened.Clusters().List(ctx, metav1.ListOptions{}) + if err != nil || len(registrations.Items) != 0 { + t.Fatalf("deleted registrations remain: %v", err) + } +} diff --git a/api/internal/controlplane/store_test.go b/api/internal/controlplane/store_test.go new file mode 100644 index 000000000..605c8bd3c --- /dev/null +++ b/api/internal/controlplane/store_test.go @@ -0,0 +1,349 @@ +package controlplane + +import ( + "context" + "encoding/base64" + "encoding/pem" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/GameplanePanel/gameplane/api/internal/db" + "github.com/GameplanePanel/gameplane/api/internal/kube" + corev1 "k8s.io/api/core/v1" + apierrors "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/types" +) + +func managementFixture(t *testing.T) (*db.Store, *kube.Client, string) { + t.Helper() + dir := t.TempDir() + store, err := db.Open(t.Context(), "sqlite", filepath.Join(dir, "panel.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Migrate(t.Context()); err != nil { + t.Fatal(err) + } + keyFile := filepath.Join(dir, "management.key") + client, err := New(t.Context(), store, keyFile) + if err != nil { + t.Fatal(err) + } + return store, client, keyFile +} + +func credential(name string) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{"managed": "true"}}, + StringData: map[string]string{"token": "very-private-test-credential"}, + } +} + +func TestManagementKeyRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { + for _, size := range []int{0, 31, 33} { + t.Run(fmt.Sprint(size), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "panel.key") + original := make([]byte, size) + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + if _, err := loadKey(path, false); err == nil { + t.Fatal("accepted an invalid existing key") + } + got, err := os.ReadFile(path) + if err != nil || string(got) != string(original) { + t.Fatal("replaced invalid key instead of failing closed") + } + }) + } +} + +func TestManagementKeyCreatesNestedDirectoryAndReusesKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "private", "keys", "panel.key") + first, err := loadKey(path, false) + if err != nil { + t.Fatal(err) + } + second, err := loadKey(path, true) + if err != nil || len(first) != 32 || string(second) != string(first) { + t.Fatalf("key did not survive reopen: %v", err) + } +} + +func TestCredentialsPersistEncryptedAndRemainNamespaceIsolated(t *testing.T) { + store, client, keyFile := managementFixture(t) + created, err := client.Secrets("panel").Create(t.Context(), credential("provider"), metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + if created.UID == "" || created.ResourceVersion != "1" || len(created.StringData) != 0 { + t.Fatalf("missing persisted identity: %#v", created.ObjectMeta) + } + var payload string + if err := store.DB.QueryRowContext(t.Context(), `SELECT payload FROM management_objects WHERE name = 'provider'`).Scan(&payload); err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"very-private-test-credential", base64.StdEncoding.EncodeToString(created.Data["token"]), `"token"`} { + if strings.Contains(payload, forbidden) { + t.Fatal("credential data stored without encryption") + } + } + keyBefore, err := os.ReadFile(keyFile) + if err != nil { + t.Fatal(err) + } + reopened, err := New(t.Context(), store, keyFile) + if err != nil { + t.Fatal(err) + } + keyAfter, err := os.ReadFile(keyFile) + if err != nil || string(keyBefore) != string(keyAfter) { + t.Fatal("startup changed the encryption key") + } + got, err := reopened.Secrets("panel").Get(t.Context(), "provider", metav1.GetOptions{}) + if err != nil || string(got.Data["token"]) != "very-private-test-credential" || got.UID != created.UID { + t.Fatalf("credential did not survive reopen: %v", err) + } + if _, err := reopened.Secrets("other").Get(t.Context(), "provider", metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("namespace leak: %v", err) + } + if _, err := reopened.Secrets("other").Create(t.Context(), credential("provider"), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + list, err := reopened.Secrets("panel").List(t.Context(), metav1.ListOptions{LabelSelector: "managed=true"}) + if err != nil || len(list.Items) != 1 { + t.Fatalf("label/namespace selection failed: %v", err) + } +} + +func TestCredentialUpdatesAndDeletesUseOptimisticConcurrency(t *testing.T) { + _, client, _ := managementFixture(t) + secrets := client.Secrets("panel") + first, err := secrets.Create(t.Context(), credential("provider"), metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + if _, err := secrets.Create(t.Context(), credential("provider"), metav1.CreateOptions{}); !apierrors.IsAlreadyExists(err) { + t.Fatalf("duplicate create: %v", err) + } + changed := first.DeepCopy() + changed.Data["token"] = []byte("rotated") + second, err := secrets.Update(t.Context(), changed, metav1.UpdateOptions{}) + if err != nil { + t.Fatal(err) + } + if second.UID != first.UID || second.ResourceVersion != "2" { + t.Fatal("update lost identity/version") + } + if _, err := secrets.Update(t.Context(), first, metav1.UpdateOptions{}); !apierrors.IsConflict(err) { + t.Fatalf("stale update accepted: %v", err) + } + if err := secrets.Delete(t.Context(), "provider", metav1.DeleteOptions{Preconditions: &metav1.Preconditions{ResourceVersion: &first.ResourceVersion}}); !apierrors.IsConflict(err) { + t.Fatalf("stale delete accepted: %v", err) + } + wrong := types.UID("wrong") + if err := secrets.Delete(t.Context(), "provider", metav1.DeleteOptions{Preconditions: &metav1.Preconditions{UID: &wrong}}); !apierrors.IsConflict(err) { + t.Fatalf("wrong identity delete accepted: %v", err) + } + if err := secrets.Delete(t.Context(), "provider", metav1.DeleteOptions{Preconditions: &metav1.Preconditions{UID: &second.UID, ResourceVersion: &second.ResourceVersion}}); err != nil { + t.Fatal(err) + } + recreated, err := secrets.Create(t.Context(), credential("provider"), metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + if recreated.UID == first.UID { + t.Fatal("recreation reused deleted identity") + } + if _, err := secrets.Update(t.Context(), first, metav1.UpdateOptions{}); !apierrors.IsConflict(err) { + t.Fatalf("old object replaced recreated credential: %v", err) + } +} + +func TestStartupFailsClosedOnMissingWrongKeyAndCorruptCiphertext(t *testing.T) { + for _, damage := range []string{"missing-key", "wrong-key", "ciphertext"} { + t.Run(damage, func(t *testing.T) { + store, client, keyFile := managementFixture(t) + if _, err := client.Secrets("panel").Create(t.Context(), credential("provider"), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + switch damage { + case "missing-key": + if err := os.Remove(keyFile); err != nil { + t.Fatal(err) + } + case "wrong-key": + if err := os.WriteFile(keyFile, make([]byte, 32), 0600); err != nil { + t.Fatal(err) + } + case "ciphertext": + if _, err := store.DB.ExecContext(t.Context(), `UPDATE management_objects SET payload = 'corrupt'`); err != nil { + t.Fatal(err) + } + } + if _, err := New(t.Context(), store, keyFile); err == nil { + t.Fatal("startup accepted inaccessible credential state") + } + if damage == "missing-key" { + if _, err := os.Stat(keyFile); !os.IsNotExist(err) { + t.Fatal("startup replaced a missing key over existing credentials") + } + } + }) + } +} + +func TestCiphertextCannotBeMovedBetweenCredentialIdentities(t *testing.T) { + store, client, _ := managementFixture(t) + for _, name := range []string{"one", "two"} { + if _, err := client.Secrets("panel").Create(t.Context(), credential(name), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + } + if _, err := store.DB.ExecContext(t.Context(), `UPDATE management_objects SET payload = (SELECT payload FROM management_objects WHERE name = 'one') WHERE name = 'two'`); err != nil { + t.Fatal(err) + } + if _, err := client.Secrets("panel").Get(t.Context(), "two", metav1.GetOptions{}); err == nil { + t.Fatal("swapped ciphertext authenticated") + } +} + +func registration(name string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "gameplane.local/v1alpha1", "kind": "Cluster", + "metadata": map[string]any{"name": name}, + "spec": map[string]any{"kubeconfigSecret": map[string]any{"name": "remote-config", "key": "kubeconfig"}}, + }} +} + +func TestStandaloneRegistryIsEmptyAndRegistrationsPersistWithPagination(t *testing.T) { + store, client, keyFile := managementFixture(t) + reg := kube.NewRegistry("") + reg.SetManagement(client) + if len(reg.IDs()) != 0 || reg.Default() != nil || reg.Management() != client || client.Typed != nil || client.Dynamic != nil || client.Config != nil { + t.Fatal("standalone panel became a workload cluster") + } + for _, name := range []string{"alpha", "beta"} { + if _, err := client.Clusters().Create(t.Context(), registration(name), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + } + reopened, err := New(t.Context(), store, keyFile) + if err != nil { + t.Fatal(err) + } + first, err := reopened.Clusters().List(t.Context(), metav1.ListOptions{Limit: 1}) + if err != nil || len(first.Items) != 1 || first.Items[0].GetName() != "alpha" || first.GetContinue() == "" { + t.Fatalf("first page: %v", err) + } + second, err := reopened.Clusters().List(t.Context(), metav1.ListOptions{Limit: 1, Continue: first.GetContinue()}) + if err != nil || len(second.Items) != 1 || second.Items[0].GetName() != "beta" || second.GetContinue() != "" { + t.Fatalf("second page: %v", err) + } + stale := first.Items[0].DeepCopy() + current := stale.DeepCopy() + current.Object["status"] = map[string]any{"phase": "Healthy"} + if _, err := reopened.Clusters().Update(t.Context(), current, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + if _, err := reopened.Clusters().Update(t.Context(), stale, metav1.UpdateOptions{}); !apierrors.IsConflict(err) { + t.Fatalf("stale registration update: %v", err) + } +} + +func TestStandaloneWatcherReloadsAndPersistsRemoteHealth(t *testing.T) { + store, client, keyFile := managementFixture(t) + server := privateTLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/version" { + t.Errorf("unexpected remote path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"gitVersion":"v1.35.0"}`)) + })) + defer server.Close() + ca := base64.StdEncoding.EncodeToString(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})) + config := fmt.Sprintf(`apiVersion: v1 +kind: Config +clusters: +- name: remote + cluster: + server: %s + certificate-authority-data: %s +contexts: +- name: remote + context: + cluster: remote + user: panel +current-context: remote +users: +- name: panel + user: + token: private-remote-token +`, server.URL, ca) + if _, err := kube.ValidateStandaloneKubeconfig([]byte(config)); err != nil { + t.Fatalf("remote health fixture violates standalone access policy: %v", err) + } + _, err := client.Secrets("panel").Create(t.Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "remote-config", Labels: map[string]string{kube.ClusterKubeconfigLabel: "true"}}, + Data: map[string][]byte{"kubeconfig": []byte(config)}, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + if _, err := client.Clusters().Create(t.Context(), registration("remote"), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + client, err = New(t.Context(), store, keyFile) + if err != nil { + t.Fatal(err) + } + reg := kube.NewRegistry("") + reg.SetManagement(client) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan struct{}) + go func() { defer close(done); kube.WatchClusters(ctx, client, reg, "panel") }() + deadline := time.After(5 * time.Second) + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-deadline: + t.Fatal("watcher did not restore registration and health") + case <-ticker.C: + obj, err := client.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + phase, _, _ := unstructured.NestedString(obj.Object, "status", "phase") + if phase != "Healthy" { + continue + } + version, _, _ := unstructured.NestedString(obj.Object, "status", "serverVersion") + if version != "v1.35.0" { + t.Fatalf("wrong remote version: %s", version) + } + if _, ok := reg.Get("remote"); !ok { + continue + } + if reg.Default() != nil { + t.Fatal("watcher installed a local workload client") + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("watcher did not stop on cancellation") + } + return + } + } +} diff --git a/api/internal/db/migrations/common/016_standalone_management.sql b/api/internal/db/migrations/common/016_standalone_management.sql new file mode 100644 index 000000000..7cd769593 --- /dev/null +++ b/api/internal/db/migrations/common/016_standalone_management.sql @@ -0,0 +1,11 @@ +-- Credentials are authenticated ciphertext, never plaintext JSON. Cluster +-- registrations contain public connection references and health metadata. +CREATE TABLE management_objects ( + kind TEXT NOT NULL, + namespace TEXT NOT NULL, + name TEXT NOT NULL, + uid TEXT NOT NULL, + version BIGINT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (kind, namespace, name) +); diff --git a/api/internal/db/migrations/common/017_standalone_key_state.sql b/api/internal/db/migrations/common/017_standalone_key_state.sql new file mode 100644 index 000000000..cea1a480d --- /dev/null +++ b/api/internal/db/migrations/common/017_standalone_key_state.sql @@ -0,0 +1,7 @@ +-- The active identity is public metadata, not encryption-key material. +-- Writers hold this singleton's write lock until their credential commit. +CREATE TABLE management_key_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + key_id TEXT NOT NULL, + previous_key_id TEXT NOT NULL DEFAULT '' +); diff --git a/api/internal/handlers/binding_permissions.go b/api/internal/handlers/binding_permissions.go index 517e4cb7e..efefa18d3 100644 --- a/api/internal/handlers/binding_permissions.go +++ b/api/internal/handlers/binding_permissions.go @@ -11,11 +11,11 @@ import ( // remoteWidePermissionsAllowed keeps supplemental remote grants away from // control-plane administration. Can always scopes cluster:read to the selected // cluster. Other Can(false) permissions accept a global grant from any cluster, -// so only inventory and catalogued namespaced permissions are safe here; +// so only target-scoped inventory, modules, templates and catalogued namespaced permissions are safe here; // '*' is never allowed. func remoteWidePermissionsAllowed(permissions []string) bool { for _, permission := range permissions { - if permission != "cluster:read" && !rbac.Namespaced(permission) { + if permission != "cluster:read" && permission != "modules:read" && permission != "modules:manage" && permission != "templates:read" && permission != "templates:write" && !rbac.Namespaced(permission) { return false } } diff --git a/api/internal/handlers/cluster.go b/api/internal/handlers/cluster.go index 5901ebbcd..7ce26fd8b 100644 --- a/api/internal/handlers/cluster.go +++ b/api/internal/handlers/cluster.go @@ -73,9 +73,9 @@ func (h *clusterHandler) forRequest(w http.ResponseWriter, req *http.Request) (* // Registration metadata lives centrally, but inventory never does. // Keep a disconnected registration distinguishable from an unknown ID. if id != h.reg.DefaultID() { - home := h.reg.Default() - if home != nil && home.Dynamic != nil { - _, err := home.Dynamic.Resource(kube.GVRCluster).Get(req.Context(), id, metav1.GetOptions{}) + home := h.reg.Management() + if home != nil && home.Clusters() != nil { + _, err := home.Clusters().Get(req.Context(), id, metav1.GetOptions{}) if apierrors.IsNotFound(err) { httperr.Write(w, req, scope.ErrForbiddenCluster) return nil, false diff --git a/api/internal/handlers/cluster_guard_test.go b/api/internal/handlers/cluster_guard_test.go index 966ad29af..2add12d18 100644 --- a/api/internal/handlers/cluster_guard_test.go +++ b/api/internal/handlers/cluster_guard_test.go @@ -274,7 +274,6 @@ func mountHomeClientRoutes(t *testing.T, r chi.Router, home *kube.Client, reg *k MountClusterActions(r, home, true, "") MountClusters(r, reg, home, controlNS) MountSystemLogs(r, home, controlNS) - MountModules(r, home, controlNS) MountRegistrySecrets(r, home, controlNS) } @@ -299,6 +298,7 @@ func TestHomeClientMounts_ServeHomeClusterOnly(t *testing.T) { r := chi.NewRouter() r.Use(rbac.Middleware(reg)) mountHomeClientRoutes(t, r, home, reg) + MountModulesWithRegistry(r, reg, "gameplane-system") MountCluster(r, reg, newTestStore(t), "test", true, "") MountRegistryWithRegistry(r, reg, fakeSet{p: &fakeProvider{}}) MountModIDsWithRegistry(r, reg) diff --git a/api/internal/handlers/clusters.go b/api/internal/handlers/clusters.go index 26896ac4c..75df58a30 100644 --- a/api/internal/handlers/clusters.go +++ b/api/internal/handlers/clusters.go @@ -13,12 +13,14 @@ import ( "log/slog" "net/http" "sort" + "time" "github.com/go-chi/chi/v5" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/util/retry" "github.com/GameplanePanel/gameplane/api/internal/auth" "github.com/GameplanePanel/gameplane/api/internal/httperr" @@ -32,6 +34,9 @@ func MountClusters(r chi.Router, reg *kube.Registry, k *kube.Client, ns string) r.Get("/", h.list) r.Post("/", h.create) r.Delete("/{name}", h.delete) + r.Put("/{name}/kubeconfig", h.replaceKubeconfig) + r.Put("/{name}/gateway", h.configureGateway) + r.Delete("/{name}/gateway", h.removeGateway) }) } @@ -41,31 +46,40 @@ func clusterKubeconfigSecretName(cluster string) string { return "cluster-" + cluster + "-kubeconfig" } -// deleteClusterKubeconfigSecret deletes a kubeconfig Secret only when it is the one -// that POST /clusters generates for this cluster (cluster--kubeconfig) and carries -// the kube.ClusterKubeconfigLabel label. Secrets created before managed-by labelling -// are cleaned up; any other Secret (different name or missing the kubeconfig label) -// is left in place and returns apierrors.NewNotFound. +// deleteClusterKubeconfigSecret deletes only the API's fixed-name or rotated +// kubeconfig credential. Legacy fixed names predate managed-by labelling; +// rotated names require both ownership labels. Other references are preserved. func deleteClusterKubeconfigSecret(ctx context.Context, k *kube.Client, ns, cluster, secretName string) error { - // Check if the secret name matches what POST /clusters generates. - expectedName := clusterKubeconfigSecretName(cluster) - if secretName != expectedName { - return apierrors.NewNotFound(corev1.Resource("secrets"), secretName) + secret, err := clusterKubeconfigSecret(ctx, k, ns, cluster, secretName) + if err != nil { + return err + } + return k.Secrets(ns).Delete(ctx, secretName, metav1.DeleteOptions{Preconditions: objectDeletePreconditions(secret)}) +} + +func clusterKubeconfigSecret(ctx context.Context, k *kube.Client, ns, cluster, secretName string) (*corev1.Secret, error) { + // Permit the legacy fixed name and API-generated immutable rotations. + if !ownedKubeconfigSecretName(cluster, secretName) { + return nil, apierrors.NewNotFound(corev1.Resource("secrets"), secretName) } // Fetch the Secret to check its labels. - secret, err := k.Typed.CoreV1().Secrets(ns).Get(ctx, secretName, metav1.GetOptions{}) + secret, err := k.Secrets(ns).Get(ctx, secretName, metav1.GetOptions{}) if err != nil { - return err + return nil, err } // Only delete if it carries the kubeconfig label. if secret.Labels[kube.ClusterKubeconfigLabel] != "true" { - return apierrors.NewNotFound(corev1.Resource("secrets"), secretName) + return nil, apierrors.NewNotFound(corev1.Resource("secrets"), secretName) + } + // Legacy API secrets preceded the managed-by label. Rotated names are new + // and must have explicit ownership before they qualify for cleanup. + if secretName != clusterKubeconfigSecretName(cluster) && secret.Labels[ManagedByLabel] != managedByValue { + return nil, apierrors.NewNotFound(corev1.Resource("secrets"), secretName) } - // Delete the Secret. - return k.Typed.CoreV1().Secrets(ns).Delete(ctx, secretName, metav1.DeleteOptions{}) + return secret, nil } type clustersHandler struct { @@ -103,13 +117,13 @@ func (h clustersHandler) list(w http.ResponseWriter, req *http.Request) { } out := clustersListResp{Items: make([]clusterRegistryView, 0)} local := h.reg.DefaultID() - if u.CanDiscoverCluster(local) { + if h.reg.Default() != nil && u.CanDiscoverCluster(local) { out.Items = append(out.Items, clusterRegistryView{Name: local, Phase: "Healthy", CanViewInventory: u.Can("cluster:read", true, local, "")}) } // Persisted registrations remain discoverable when a kubeconfig cannot // load; the client registry alone would silently drop those clusters. - registrations, err := h.k.Dynamic.Resource(kube.GVRCluster).List(req.Context(), metav1.ListOptions{}) + registrations, err := h.k.Clusters().List(req.Context(), metav1.ListOptions{}) if err != nil { // Older single-cluster installations may not have the optional CRD. // Only that absence may degrade to local discovery; permission and @@ -172,9 +186,9 @@ func (h clustersHandler) create(w http.ResponseWriter, req *http.Request) { errors.New("kubeconfig is required")) return } - _, err := kube.ConfigFromKubeconfig([]byte(in.Kubeconfig)) + err := h.validateRemoteKubeconfig([]byte(in.Kubeconfig)) if err != nil { - httperr.WriteCode(w, req, http.StatusBadRequest, err) + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("invalid or disallowed kubeconfig")) return } @@ -196,16 +210,6 @@ func (h clustersHandler) create(w http.ResponseWriter, req *http.Request) { "kubeconfig": []byte(in.Kubeconfig), }, } - _, err = h.k.Typed.CoreV1().Secrets(h.namespace).Create(req.Context(), secret, metav1.CreateOptions{}) - if err != nil { - if apierrors.IsAlreadyExists(err) { - httperr.WriteCode(w, req, http.StatusConflict, errors.New("cluster already exists")) - return - } - httperr.Write(w, req, err) - return - } - // Create the Cluster CR. clusterCR := &unstructured.Unstructured{ Object: map[string]any{ @@ -223,10 +227,14 @@ func (h clustersHandler) create(w http.ResponseWriter, req *http.Request) { }, }, } - _, err = h.k.Dynamic.Resource(kube.GVRCluster).Create(req.Context(), clusterCR, metav1.CreateOptions{}) + if h.k.RegisterCluster != nil { + _, err = h.k.RegisterCluster(req.Context(), h.namespace, secret, clusterCR) + } else if h.k.IsStandalone() { + err = errors.New("atomic registration storage is unavailable") + } else { + err = h.registerKubernetesCluster(req.Context(), secret, clusterCR) + } if err != nil { - // Clean up the Secret on CR creation failure. - _ = h.k.Typed.CoreV1().Secrets(h.namespace).Delete(req.Context(), secretName, metav1.DeleteOptions{}) if apierrors.IsAlreadyExists(err) { httperr.WriteCode(w, req, http.StatusConflict, errors.New("cluster already exists")) return @@ -242,6 +250,30 @@ func (h clustersHandler) create(w http.ResponseWriter, req *http.Request) { }) } +// Kubernetes cannot transact across a Secret and a CR. Pin cleanup to the +// credential we created and use a bounded independent context so cancellation +// does not strand it. An ambiguous CR write retains possibly-live credentials. +func (h clustersHandler) registerKubernetesCluster(ctx context.Context, secret *corev1.Secret, registration *unstructured.Unstructured) error { + created, err := h.k.Secrets(h.namespace).Create(ctx, secret, metav1.CreateOptions{}) + if err != nil { + return err + } + if _, err := h.k.Clusters().Create(ctx, registration, metav1.CreateOptions{}); err != nil { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + current, readErr := h.k.Clusters().Get(cleanupCtx, registration.GetName(), metav1.GetOptions{}) + ref := "" + if readErr == nil { + ref, _, _ = unstructured.NestedString(current.Object, "spec", "kubeconfigSecret", "name") + } + if apierrors.IsNotFound(readErr) || (readErr == nil && ref != created.Name) { + _ = h.k.Secrets(h.namespace).Delete(cleanupCtx, created.Name, metav1.DeleteOptions{Preconditions: objectDeletePreconditions(created)}) + } + return err + } + return nil +} + func (h clustersHandler) delete(w http.ResponseWriter, req *http.Request) { name := chi.URLParam(req, "name") @@ -253,7 +285,7 @@ func (h clustersHandler) delete(w http.ResponseWriter, req *http.Request) { } // Read the Cluster CR to find the Secret name. - u, err := h.k.Dynamic.Resource(kube.GVRCluster).Get(req.Context(), name, metav1.GetOptions{}) + u, err := h.k.Clusters().Get(req.Context(), name, metav1.GetOptions{}) if err != nil { if apierrors.IsNotFound(err) { httperr.WriteCode(w, req, http.StatusNotFound, err) @@ -263,33 +295,51 @@ func (h clustersHandler) delete(w http.ResponseWriter, req *http.Request) { return } - // Extract the Secret name from the Cluster CR for cleanup. - kcSpec, ok, _ := unstructured.NestedMap(u.Object, "spec", "kubeconfigSecret") - secretName := "" - if ok { - if sn, ok := kcSpec["name"].(string); ok { - secretName = sn + // Pin deletion to the registration we read. A health update may advance + // resourceVersion; a new registration with this name must survive. + originalUID := u.GetUID() + var kubeconfigSecret *corev1.Secret + ctx := req.Context() + err = retry.RetryOnConflict(retry.DefaultRetry, func() error { + current, err := h.k.Clusters().Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err } - } - - // Delete the Cluster CR. - if err := h.k.Dynamic.Resource(kube.GVRCluster).Delete(req.Context(), name, metav1.DeleteOptions{}); err != nil { - if !apierrors.IsNotFound(err) { - httperr.Write(w, req, err) - return + if current.GetUID() != originalUID { + return apierrors.NewNotFound(kube.GVRCluster.GroupResource(), name) + } + secretName, _, _ := unstructured.NestedString(current.Object, "spec", "kubeconfigSecret", "name") + kubeconfigSecret, err = clusterKubeconfigSecret(ctx, h.k, h.namespace, name, secretName) + if err != nil && !apierrors.IsNotFound(err) { + return err + } + if err := h.k.Clusters().Delete(ctx, name, metav1.DeleteOptions{Preconditions: objectDeletePreconditions(current)}); err != nil { + return err } + u = current + return nil + }) + if err != nil { + httperr.Write(w, req, err) + return } // Drop the cluster's client now instead of waiting for the cluster // watch, so no request is dispatched through a removed registration. - h.reg.Remove(name) + h.reg.RemoveIfUID(name, originalUID) + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(req.Context()), 5*time.Second) + defer cancel() + // Only remove credentials this API owns; external GitOps credentials survive. + h.cleanupGatewayCredential(cleanupCtx, name, gatewayCredentialName(u)) - // Clean up the kubeconfig Secret only if it is the one this cluster was created with - // (cluster--kubeconfig) and carries the kubeconfig label. This includes Secrets - // created before the managed-by label was added. Any other Secret — different name - // or missing the kubeconfig label — is left in place. - if secretName != "" { - if err := deleteClusterKubeconfigSecret(req.Context(), h.k, h.namespace, name, secretName); err != nil && !apierrors.IsNotFound(err) { + // Clean up the fixed-name or immutable rotated credential captured before + // deletion. Legacy fixed names predate managed-by; rotated names require it. + // UID/version preconditions preserve replacements and external references. + if kubeconfigSecret != nil { + // A blocked gateway read must not consume the kubeconfig's cleanup budget. + credentialCtx, cancelCredential := context.WithTimeout(context.WithoutCancel(req.Context()), 5*time.Second) + defer cancelCredential() + if err := h.k.Secrets(h.namespace).Delete(credentialCtx, kubeconfigSecret.Name, metav1.DeleteOptions{Preconditions: objectDeletePreconditions(kubeconfigSecret)}); err != nil && !apierrors.IsNotFound(err) { slog.Warn("cluster delete: kubeconfig secret cleanup failed", "cluster", name, "err", err) } } diff --git a/api/internal/handlers/clusters_gateway.go b/api/internal/handlers/clusters_gateway.go new file mode 100644 index 000000000..a3b7b36f7 --- /dev/null +++ b/api/internal/handlers/clusters_gateway.go @@ -0,0 +1,188 @@ +package handlers + +import ( + "context" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/url" + "reflect" + "strings" + "time" + + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/go-chi/chi/v5" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/util/retry" + + "github.com/GameplanePanel/gameplane/api/internal/httperr" +) + +const clusterGatewayLabel = "gameplane.local/agent-gateway-credentials" + +type clusterGatewayRequest struct { + URL string `json:"url"` + CACert string `json:"caCert"` + ClientCert string `json:"clientCert"` + ClientKey string `json:"clientKey"` +} + +func gatewaySecretName(name string) string { return "cluster-" + name + "-gateway" } + +func (h clustersHandler) configureGateway(w http.ResponseWriter, req *http.Request) { + name := chi.URLParam(req, "name") + if name == h.reg.DefaultID() || !dnsLabelRE.MatchString(name) { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("select a registered remote cluster")) + return + } + var in clusterGatewayRequest + if err := json.NewDecoder(req.Body).Decode(&in); err != nil { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("invalid gateway configuration")) + return + } + u, err := url.Parse(in.URL) + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") || u.Opaque != "" { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("gateway URL must be an HTTPS origin")) + return + } + if _, err := tls.X509KeyPair([]byte(in.ClientCert), []byte(in.ClientKey)); err != nil { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("invalid gateway client certificate or key")) + return + } + if err := h.k.RemoteAccess.ValidateURL(in.URL); err != nil { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("gateway destination is not allowed")) + return + } + if !x509.NewCertPool().AppendCertsFromPEM([]byte(in.CACert)) { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("invalid gateway CA certificate")) + return + } + registration, err := h.k.Clusters().Get(req.Context(), name, metav1.GetOptions{}) + if err != nil { + httperr.Write(w, req, err) + return + } + if registration.GetDeletionTimestamp() != nil { + httperr.Write(w, req, apierrors.NewNotFound(kube.GVRCluster.GroupResource(), name)) + return + } + // Publish a new immutable credential object before switching the pointer. + // Concurrent rotations can never combine one request's URL with another's key. + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + httperr.Write(w, req, err) + return + } + secretName := gatewaySecretName(name) + "-" + hex.EncodeToString(nonce) + immutable := true + _, err = h.k.Secrets(h.namespace).Create(req.Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: h.namespace, Labels: map[string]string{ + clusterGatewayLabel: "true", ManagedByLabel: managedByValue, + }}, + Immutable: &immutable, + Type: corev1.SecretTypeOpaque, + StringData: map[string]string{"ca.crt": in.CACert, "tls.crt": in.ClientCert, "tls.key": in.ClientKey}, + }, metav1.CreateOptions{}) + if err != nil { + httperr.Write(w, req, err) + return + } + err = h.updateGatewayRegistration(req.Context(), registration, map[string]any{ + "url": in.URL, "tlsSecretRef": map[string]any{"name": secretName}, + }) + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(req.Context()), 5*time.Second) + defer cancel() + if err != nil { + // An update may have committed before a transport failure. Retain the + // credential when the live pointer cannot be checked safely. + current, readErr := h.k.Clusters().Get(cleanupCtx, name, metav1.GetOptions{}) + if apierrors.IsNotFound(readErr) || (readErr == nil && gatewayCredentialName(current) != secretName) { + h.cleanupGatewayCredential(cleanupCtx, name, secretName) + } + httperr.Write(w, req, err) + return + } + h.cleanupGatewayCredential(cleanupCtx, name, gatewayCredentialName(registration)) + + w.WriteHeader(http.StatusNoContent) +} + +func (h clustersHandler) removeGateway(w http.ResponseWriter, req *http.Request) { + name := chi.URLParam(req, "name") + registration, err := h.k.Clusters().Get(req.Context(), name, metav1.GetOptions{}) + if err != nil { + httperr.Write(w, req, err) + return + } + secretName := gatewayCredentialName(registration) + if err := h.updateGatewayRegistration(req.Context(), registration, nil); err != nil { + httperr.Write(w, req, err) + return + } + + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(req.Context()), 5*time.Second) + defer cancel() + h.cleanupGatewayCredential(cleanupCtx, name, secretName) + w.WriteHeader(http.StatusNoContent) +} + +func gatewayCredentialName(registration *unstructured.Unstructured) string { + name, _, _ := unstructured.NestedString(registration.Object, "spec", "agentGateway", "tlsSecretRef", "name") + return name +} + +func (h clustersHandler) cleanupGatewayCredential(ctx context.Context, cluster, secretName string) { + // Preserve external references and support the original fixed-name API secret. + base := gatewaySecretName(cluster) + if secretName != base && (!strings.HasPrefix(secretName, base+"-") || len(secretName) != len(base)+33) { + return + } + if secretName != base { + if _, err := hex.DecodeString(strings.TrimPrefix(secretName, base+"-")); err != nil { + return + } + } + if err := deleteManagedSecret(ctx, h.k, h.namespace, secretName, clusterGatewayLabel); err != nil && !apierrors.IsNotFound(err) { + slog.Warn("cluster gateway credential cleanup failed", "cluster", cluster) + } +} + +// A health status update may race configuration. Retry against fresh metadata, +// but never carry a gateway write across deletion/recreation of a registration. +func (h clustersHandler) updateGatewayRegistration(ctx context.Context, original *unstructured.Unstructured, gateway map[string]any) error { + previous, _, err := unstructured.NestedMap(original.Object, "spec", "agentGateway") + if err != nil { + return err + } + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + current, err := h.k.Clusters().Get(ctx, original.GetName(), metav1.GetOptions{}) + if err != nil { + return err + } + if current.GetUID() != original.GetUID() || current.GetDeletionTimestamp() != nil { + return apierrors.NewNotFound(kube.GVRCluster.GroupResource(), original.GetName()) + } + live, _, err := unstructured.NestedMap(current.Object, "spec", "agentGateway") + if err != nil { + return err + } + if !reflect.DeepEqual(previous, live) { + return apierrors.NewConflict(kube.GVRCluster.GroupResource(), original.GetName(), errors.New("gateway configuration changed; reload before updating")) + } + if gateway == nil { + unstructured.RemoveNestedField(current.Object, "spec", "agentGateway") + } else if err := unstructured.SetNestedMap(current.Object, gateway, "spec", "agentGateway"); err != nil { + return err + } + _, err = h.k.Clusters().Update(ctx, current, metav1.UpdateOptions{}) + return err + }) +} diff --git a/api/internal/handlers/clusters_gateway_test.go b/api/internal/handlers/clusters_gateway_test.go new file mode 100644 index 000000000..770ef7a16 --- /dev/null +++ b/api/internal/handlers/clusters_gateway_test.go @@ -0,0 +1,390 @@ +package handlers + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "math/big" + "net/http" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/GameplanePanel/gameplane/api/internal/controlplane" + "github.com/GameplanePanel/gameplane/api/internal/kube" +) + +func gatewayCertificateRequest(t *testing.T) clusterGatewayRequest { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, IsCA: true, BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + privateKey, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + certificate := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + return clusterGatewayRequest{URL: "https://gateway.example.invalid:8443", CACert: certificate, ClientCert: certificate, ClientKey: string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privateKey}))} +} + +type gatewayInterleavingSecrets struct { + kube.SecretStore + beforeGet func(string) + beforeDelete func(string) +} + +func (s gatewayInterleavingSecrets) Get(ctx context.Context, name string, opts metav1.GetOptions) (*corev1.Secret, error) { + if s.beforeGet != nil { + s.beforeGet(name) + } + return s.SecretStore.Get(ctx, name, opts) +} + +func (s gatewayInterleavingSecrets) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + if s.beforeDelete != nil { + s.beforeDelete(name) + } + return s.SecretStore.Delete(ctx, name, opts) +} + +type gatewayInterleavingClusters struct { + kube.ClusterStore + beforeUpdate func() +} + +func (s gatewayInterleavingClusters) Update(ctx context.Context, obj *unstructured.Unstructured, opts metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) { + s.beforeUpdate() + return s.ClusterStore.Update(ctx, obj, opts, subresources...) +} + +func TestGatewayRemovalCannotDeleteConcurrentRotation(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + if _, err := home.Clusters().Create(t.Context(), newCluster("remote", map[string]any{}, nil), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + router := standaloneClustersRouter(home, reg) + original := gatewayCertificateRequest(t) + if rr := doClusters(t, router, http.MethodPut, "/clusters/remote/gateway", original); rr.Code != http.StatusNoContent { + t.Fatalf("initial configure: %d %s", rr.Code, rr.Body) + } + registration, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + oldSecret := gatewayCredentialName(registration) + rotated := gatewayCertificateRequest(t) + rotated.URL = "https://rotated.example.invalid" + secrets := home.SecretStore + interleaved := false + home.SecretStore = func(ns string) kube.SecretStore { + return gatewayInterleavingSecrets{SecretStore: secrets(ns), beforeGet: func(name string) { + if name != oldSecret || interleaved { + return + } + interleaved = true + // DELETE has detached the old pointer but has not read its secret. + if rr := doClusters(t, router, http.MethodPut, "/clusters/remote/gateway", rotated); rr.Code != http.StatusNoContent { + t.Fatalf("concurrent configure: %d %s", rr.Code, rr.Body) + } + }} + } + if rr := doClusters(t, router, http.MethodDelete, "/clusters/remote/gateway", nil); rr.Code != http.StatusNoContent { + t.Fatalf("remove: %d %s", rr.Code, rr.Body) + } + if !interleaved { + t.Fatal("removal never reached credential cleanup") + } + assertLiveGateway(t, home, rotated) + if _, err := secrets(standaloneTestNamespace).Get(t.Context(), oldSecret, metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("old credential survived removal: %v", err) + } +} + +func TestConcurrentGatewayConfigurationsKeepURLAndCredentialsTogether(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + if _, err := home.Clusters().Create(t.Context(), newCluster("remote", map[string]any{}, nil), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + router := standaloneClustersRouter(home, reg) + first, second := gatewayCertificateRequest(t), gatewayCertificateRequest(t) + second.URL = "https://second.example.invalid" + interleaved := false + home.ClusterStore = gatewayInterleavingClusters{ClusterStore: home.ClusterStore, beforeUpdate: func() { + if interleaved { + return + } + interleaved = true + // The first request has persisted its key and read the registration. + if rr := doClusters(t, router, http.MethodPut, "/clusters/remote/gateway", second); rr.Code != http.StatusNoContent { + t.Fatalf("second configure: %d %s", rr.Code, rr.Body) + } + }} + if rr := doClusters(t, router, http.MethodPut, "/clusters/remote/gateway", first); rr.Code != http.StatusConflict { + t.Fatalf("stale configure: %d %s", rr.Code, rr.Body) + } + assertLiveGateway(t, home, second) + secrets, err := home.Secrets(standaloneTestNamespace).List(t.Context(), metav1.ListOptions{LabelSelector: clusterGatewayLabel + "=true"}) + if err != nil || len(secrets.Items) != 1 { + t.Fatalf("unpublished credential was not cleaned up: %+v %v", secrets, err) + } +} + +func assertLiveGateway(t *testing.T, home *kube.Client, want clusterGatewayRequest) { + t.Helper() + registration, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + endpoint, _, _ := unstructured.NestedString(registration.Object, "spec", "agentGateway", "url") + secret, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), gatewayCredentialName(registration), metav1.GetOptions{}) + if err != nil { + t.Fatalf("live gateway has no credentials: %v", err) + } + if endpoint != want.URL || string(secret.Data["tls.key"]) != want.ClientKey || string(secret.Data["tls.crt"]) != want.ClientCert || string(secret.Data["ca.crt"]) != want.CACert { + t.Fatal("gateway URL and credentials came from different writes") + } +} + +func TestGatewayRotationAndClusterDeletionCleanOnlyDetachedCredentials(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + if _, err := home.Clusters().Create(t.Context(), newCluster("remote", map[string]any{}, nil), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + router := standaloneClustersRouter(home, reg) + previous := "" + for range 2 { + in := gatewayCertificateRequest(t) + if rr := doClusters(t, router, http.MethodPut, "/clusters/remote/gateway", in); rr.Code != http.StatusNoContent { + t.Fatalf("configure: %d %s", rr.Code, rr.Body) + } + assertLiveGateway(t, home, in) + registration, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + current := gatewayCredentialName(registration) + if current == previous { + t.Fatal("rotation reused a credential identity") + } + if previous != "" { + if _, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), previous, metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("detached credential survived rotation: %v", err) + } + } + previous = current + } + if rr := doClusters(t, router, http.MethodDelete, "/clusters/remote", nil); rr.Code != http.StatusNoContent { + t.Fatalf("delete cluster: %d %s", rr.Code, rr.Body) + } + if _, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), previous, metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("gateway credential survived cluster removal: %v", err) + } +} + +func TestClusterDeletionPreservesRecreatedKubeconfig(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + name := clusterKubeconfigSecretName("remote") + registration := newCluster("remote", map[string]any{"kubeconfigSecret": map[string]any{"name": name}}, nil) + if _, err := home.Clusters().Create(t.Context(), registration, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + secrets := home.SecretStore + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{kube.ClusterKubeconfigLabel: "true"}}, StringData: map[string]string{"kubeconfig": "old-credentials"}} + if _, err := secrets(standaloneTestNamespace).Create(t.Context(), secret, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + interleaved := false + home.SecretStore = func(ns string) kube.SecretStore { + return gatewayInterleavingSecrets{SecretStore: secrets(ns), beforeDelete: func(deleting string) { + if deleting != name || interleaved { + return + } + interleaved = true + if err := secrets(ns).Delete(t.Context(), name, metav1.DeleteOptions{}); err != nil { + t.Fatal(err) + } + secret.StringData["kubeconfig"] = "replacement-credentials" + if _, err := secrets(ns).Create(t.Context(), secret, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + if _, err := home.Clusters().Create(t.Context(), registration, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + }} + } + if rr := doClusters(t, standaloneClustersRouter(home, reg), http.MethodDelete, "/clusters/remote", nil); rr.Code != http.StatusNoContent { + t.Fatalf("delete: %d %s", rr.Code, rr.Body) + } + current, err := secrets(standaloneTestNamespace).Get(t.Context(), name, metav1.GetOptions{}) + if !interleaved || err != nil || string(current.Data["kubeconfig"]) != "replacement-credentials" { + t.Fatalf("replacement kubeconfig was removed: %v", err) + } +} + +func TestStandaloneGatewayConfigurationEncryptsCredentialsAndStoresOnlyReference(t *testing.T) { + store, home, reg, keyPath := standaloneHandlerStore(t) + if _, err := home.Clusters().Create(t.Context(), newCluster("remote", map[string]any{}, nil), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + in := gatewayCertificateRequest(t) + router := standaloneClustersRouter(home, reg) + configured := doClusters(t, router, http.MethodPut, "/clusters/remote/gateway", in) + if configured.Code != http.StatusNoContent || configured.Body.Len() != 0 { + t.Fatalf("configure: %d %s", configured.Code, configured.Body) + } + registration, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + secretName := gatewayCredentialName(registration) + if !strings.HasPrefix(secretName, gatewaySecretName("remote")+"-") { + t.Fatalf("credential does not have a unique generated name: %s", secretName) + } + assertManagementCiphertext(t, store, secretName, in.ClientKey, in.ClientCert) + home, err = controlplane.New(t.Context(), store, keyPath) + if err != nil { + t.Fatal(err) + } + stored, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), secretName, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + if string(stored.Data["tls.key"]) != in.ClientKey || string(stored.Data["tls.crt"]) != in.ClientCert || string(stored.Data["ca.crt"]) != in.CACert { + t.Fatal("gateway credentials did not survive reopen") + } + if stored.Labels[clusterGatewayLabel] != "true" || stored.Labels[ManagedByLabel] != managedByValue { + t.Fatal("gateway credential ownership labels missing") + } + if stored.Immutable == nil || !*stored.Immutable { + t.Fatal("gateway credentials must be immutable") + } + registration, err = home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + gateway, found, err := unstructured.NestedMap(registration.Object, "spec", "agentGateway") + if err != nil || !found || len(gateway) != 2 || gateway["url"] != in.URL { + t.Fatalf("unexpected gateway registration: %+v %v", gateway, err) + } + ref, _, _ := unstructured.NestedString(registration.Object, "spec", "agentGateway", "tlsSecretRef", "name") + if ref != secretName { + t.Fatalf("wrong credential reference: %s", ref) + } + var payload string + if err := store.DB.QueryRowContext(t.Context(), `SELECT payload FROM management_objects WHERE kind = 'clusters' AND name = 'remote'`).Scan(&payload); err != nil { + t.Fatal(err) + } + if strings.Contains(payload, "PRIVATE KEY") || strings.Contains(payload, "CERTIFICATE") || strings.Contains(payload, "clientKey") { + t.Fatal("registration contains credential material") + } + listed := doClusters(t, router, http.MethodGet, "/clusters/", nil) + if listed.Code != http.StatusOK || strings.Contains(listed.Body.String(), "gateway.example") || strings.Contains(listed.Body.String(), "PRIVATE KEY") { + t.Fatalf("gateway leaked in discovery: %d %s", listed.Code, listed.Body) + } +} + +func TestGatewayRejectsNonHTTPSOriginsAndInvalidPEMWithoutWritingSecrets(t *testing.T) { + valid := gatewayCertificateRequest(t) + for _, badURL := range []string{"http://gateway.example.invalid", "https://user:password@gateway.example.invalid", "https://gateway.example.invalid/path", "https://gateway.example.invalid?token=private", "https://gateway.example.invalid#fragment", "https://"} { + t.Run(badURL, func(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + if _, err := home.Clusters().Create(t.Context(), newCluster("remote", map[string]any{}, nil), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + in := valid + in.URL = badURL + rr := doClusters(t, standaloneClustersRouter(home, reg), http.MethodPut, "/clusters/remote/gateway", in) + if rr.Code != http.StatusBadRequest { + t.Fatalf("invalid URL accepted: %d %s", rr.Code, rr.Body) + } + if secrets, err := home.Secrets(standaloneTestNamespace).List(t.Context(), metav1.ListOptions{}); err != nil || len(secrets.Items) != 0 { + t.Fatalf("invalid URL wrote credentials: %+v %v", secrets, err) + } + }) + } + for _, field := range []string{"ca", "certificate", "key"} { + t.Run(field, func(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + if _, err := home.Clusters().Create(t.Context(), newCluster("remote", map[string]any{}, nil), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + in := valid + switch field { + case "ca": + in.CACert = "private-invalid-ca" + case "certificate": + in.ClientCert = "private-invalid-cert" + case "key": + in.ClientKey = "private-invalid-key" + } + rr := doClusters(t, standaloneClustersRouter(home, reg), http.MethodPut, "/clusters/remote/gateway", in) + if rr.Code != http.StatusBadRequest || strings.Contains(rr.Body.String(), "private-invalid") { + t.Fatalf("invalid PEM response: %d %s", rr.Code, rr.Body) + } + if secrets, err := home.Secrets(standaloneTestNamespace).List(t.Context(), metav1.ListOptions{}); err != nil || len(secrets.Items) != 0 { + t.Fatalf("invalid PEM wrote credentials: %+v %v", secrets, err) + } + }) + } +} + +func TestGatewayRemovalDeletesOnlyGeneratedSecretWithBothOwnershipLabels(t *testing.T) { + for _, tc := range []struct { + name, secretName string + labels map[string]string + deleted bool + }{ + {"managed", gatewaySecretName("remote"), map[string]string{clusterGatewayLabel: "true", ManagedByLabel: managedByValue}, true}, + {"missing-feature-label", gatewaySecretName("remote"), map[string]string{ManagedByLabel: managedByValue}, false}, + {"gitops-secret", gatewaySecretName("remote"), map[string]string{clusterGatewayLabel: "true"}, false}, + {"external-reference", "externally-owned-gateway", map[string]string{clusterGatewayLabel: "true", ManagedByLabel: managedByValue}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + _, err := home.Clusters().Create(t.Context(), newCluster("remote", map[string]any{"agentGateway": map[string]any{"url": "https://gateway.example.invalid", "tlsSecretRef": map[string]any{"name": tc.secretName}}}, nil), metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + _, err = home.Secrets(standaloneTestNamespace).Create(t.Context(), &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: tc.secretName, Labels: tc.labels}, StringData: map[string]string{"tls.key": "private-key-value"}}, metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + rr := doClusters(t, standaloneClustersRouter(home, reg), http.MethodDelete, "/clusters/remote/gateway", nil) + if rr.Code != http.StatusNoContent { + t.Fatalf("remove: %d %s", rr.Code, rr.Body) + } + _, err = home.Secrets(standaloneTestNamespace).Get(t.Context(), tc.secretName, metav1.GetOptions{}) + if tc.deleted && !apierrors.IsNotFound(err) { + t.Fatalf("managed secret survived: %v", err) + } + if !tc.deleted && err != nil { + t.Fatalf("unowned secret removed: %v", err) + } + registration, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + if _, found, err := unstructured.NestedMap(registration.Object, "spec", "agentGateway"); err != nil || found { + t.Fatalf("gateway reference survived: %v", err) + } + }) + } +} diff --git a/api/internal/handlers/clusters_kubeconfig.go b/api/internal/handlers/clusters_kubeconfig.go new file mode 100644 index 000000000..d702b2fcc --- /dev/null +++ b/api/internal/handlers/clusters_kubeconfig.go @@ -0,0 +1,167 @@ +package handlers + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "reflect" + "strings" + "time" + + "github.com/GameplanePanel/gameplane/api/internal/httperr" + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/go-chi/chi/v5" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/util/retry" +) + +// replaceKubeconfig publishes immutable credentials before changing their +// registration pointer. A CAS prevents overlapping rotations from mixing data +// or applying an old request to a deleted/recreated registration. +func (h clustersHandler) replaceKubeconfig(w http.ResponseWriter, req *http.Request) { + name := chi.URLParam(req, "name") + if name == h.reg.DefaultID() || !dnsLabelRE.MatchString(name) { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("select a registered remote cluster")) + return + } + var in struct { + Kubeconfig string `json:"kubeconfig"` + } + if err := json.NewDecoder(req.Body).Decode(&in); err != nil || in.Kubeconfig == "" { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("kubeconfig is required")) + return + } + if err := h.validateRemoteKubeconfig([]byte(in.Kubeconfig)); err != nil { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("invalid kubeconfig")) + return + } + registration, err := h.k.Clusters().Get(req.Context(), name, metav1.GetOptions{}) + if err != nil { + httperr.Write(w, req, err) + return + } + if registration.GetDeletionTimestamp() != nil { + httperr.Write(w, req, apierrors.NewNotFound(kube.GVRCluster.GroupResource(), name)) + return + } + oldName, _, _ := unstructured.NestedString(registration.Object, "spec", "kubeconfigSecret", "name") + // Capture the old credential identity before publishing the replacement; + // cleanup must not remove a fixed-name secret recreated by another POST. + oldSecret, err := clusterKubeconfigSecret(req.Context(), h.k, h.namespace, name, oldName) + if err != nil && !apierrors.IsNotFound(err) { + httperr.Write(w, req, err) + return + } + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + httperr.Write(w, req, err) + return + } + secretName := clusterKubeconfigSecretName(name) + "-" + hex.EncodeToString(nonce) + immutable := true + created, err := h.k.Secrets(h.namespace).Create(req.Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: h.namespace, Labels: map[string]string{kube.ClusterKubeconfigLabel: "true", ManagedByLabel: managedByValue}}, + Immutable: &immutable, Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{"kubeconfig": []byte(in.Kubeconfig)}, + }, metav1.CreateOptions{}) + if err != nil { + httperr.Write(w, req, err) + return + } + err = h.updateKubeconfigRegistration(req.Context(), registration, secretName) + // Cleanup and invalidation must finish even when the caller disconnects. + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(req.Context()), 5*time.Second) + defer cancel() + if err != nil { + current, readErr := h.k.Clusters().Get(cleanupCtx, name, metav1.GetOptions{}) + liveName := "" + if readErr == nil { + liveName, _, _ = unstructured.NestedString(current.Object, "spec", "kubeconfigSecret", "name") + } + if apierrors.IsNotFound(readErr) || (readErr == nil && liveName != secretName) { + h.cleanupKubeconfigCredential(cleanupCtx, name, created) + } else { + // A lost response may hide a committed pointer update. Retain the + // credential and conservatively invalidate the old client. + h.reg.RemoveIfUID(name, registration.GetUID()) + } + httperr.Write(w, req, err) + return + } + h.reg.RemoveIfUID(name, registration.GetUID()) + // Retire the captured credential before refreshing the workload client. + // A concurrent health probe or slow store can consume the refresh budget; + // it must not leave a fixed-name credential blocking later registration. + h.cleanupKubeconfigCredential(cleanupCtx, name, oldSecret) + // Loading uses the same bounded and validated transport as startup. + // It rechecks the live UID/version before publication; the poll/watch can + // retry if a concurrent health update or removal defeats this refresh. + _ = kube.RefreshRegisteredCluster(cleanupCtx, h.k, h.reg, h.namespace, name, registration.GetUID()) + w.WriteHeader(http.StatusNoContent) +} + +func (h clustersHandler) validateRemoteKubeconfig(data []byte) error { + if !h.k.IsStandalone() { + _, err := kube.ConfigFromKubeconfig(data) + return err + } + cfg, err := kube.ValidateStandaloneKubeconfig(data) + if err != nil { + return err + } + return h.k.RemoteAccess.ValidateURL(cfg.Host) +} + +func (h clustersHandler) updateKubeconfigRegistration(ctx context.Context, original *unstructured.Unstructured, secretName string) error { + previous, _, err := unstructured.NestedMap(original.Object, "spec", "kubeconfigSecret") + if err != nil { + return err + } + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + current, err := h.k.Clusters().Get(ctx, original.GetName(), metav1.GetOptions{}) + if err != nil { + return err + } + if current.GetUID() != original.GetUID() || current.GetDeletionTimestamp() != nil { + return apierrors.NewNotFound(kube.GVRCluster.GroupResource(), original.GetName()) + } + live, _, err := unstructured.NestedMap(current.Object, "spec", "kubeconfigSecret") + if err != nil { + return err + } + if !reflect.DeepEqual(previous, live) { + return apierrors.NewConflict(kube.GVRCluster.GroupResource(), original.GetName(), errors.New("kubeconfig changed; reload before updating")) + } + if err := unstructured.SetNestedMap(current.Object, map[string]any{"name": secretName, "key": "kubeconfig"}, "spec", "kubeconfigSecret"); err != nil { + return err + } + _, err = h.k.Clusters().Update(ctx, current, metav1.UpdateOptions{}) + return err + }) +} + +func ownedKubeconfigSecretName(cluster, name string) bool { + base := clusterKubeconfigSecretName(cluster) + if name == base { + return true + } + if !strings.HasPrefix(name, base+"-") || len(name) != len(base)+33 { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(name, base+"-")) + return err == nil +} + +func (h clustersHandler) cleanupKubeconfigCredential(ctx context.Context, cluster string, secret *corev1.Secret) { + if secret == nil || !ownedKubeconfigSecretName(cluster, secret.Name) || secret.Labels[kube.ClusterKubeconfigLabel] != "true" || (secret.Name != clusterKubeconfigSecretName(cluster) && secret.Labels[ManagedByLabel] != managedByValue) { + return + } + // UID/resourceVersion preconditions make stale cleanup harmless. + _ = h.k.Secrets(h.namespace).Delete(ctx, secret.Name, metav1.DeleteOptions{Preconditions: objectDeletePreconditions(secret)}) +} diff --git a/api/internal/handlers/clusters_kubeconfig_test.go b/api/internal/handlers/clusters_kubeconfig_test.go new file mode 100644 index 000000000..8a4267fe7 --- /dev/null +++ b/api/internal/handlers/clusters_kubeconfig_test.go @@ -0,0 +1,212 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/auth" + "github.com/GameplanePanel/gameplane/api/internal/controlplane" + "github.com/GameplanePanel/gameplane/api/internal/kube" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func registeredRotationFixture(t *testing.T) (*kube.Client, *kube.Registry, http.Handler, *unstructured.Unstructured) { + t.Helper() + _, home, reg, _ := standaloneHandlerStore(t) + router := standaloneClustersRouter(home, reg) + if rr := doClusters(t, router, http.MethodPost, "/clusters/", clusterCreateReq{Name: "remote", DisplayName: "Remote games", Kubeconfig: standaloneTestKubeconfig}); rr.Code != http.StatusCreated { + t.Fatalf("register: %d %s", rr.Code, rr.Body) + } + registration, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + return home, reg, router, registration +} + +func TestKubeconfigRotationPreservesRegistrationAndImmediatelyReplacesClient(t *testing.T) { + store, home, reg, keyPath := standaloneHandlerStore(t) + router := standaloneClustersRouter(home, reg) + if rr := doClusters(t, router, http.MethodPost, "/clusters/", clusterCreateReq{Name: "remote", DisplayName: "Remote games", Kubeconfig: standaloneTestKubeconfig}); rr.Code != http.StatusCreated { + t.Fatalf("register: %d %s", rr.Code, rr.Body) + } + registration, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + registration.Object["spec"].(map[string]any)["agentGateway"] = map[string]any{"url": "https://gateway.example.invalid", "tlsSecretRef": map[string]any{"name": "externally-managed-gateway"}} + registration, err = home.Clusters().Update(t.Context(), registration, metav1.UpdateOptions{}) + if err != nil { + t.Fatal(err) + } + oldClient := fakeKubeClient() + reg.SetWithUID("remote", registration.GetUID(), oldClient) + userID := seedUser(t, store, "rotation-reader", "viewer", "") + if _, err := store.DB.ExecContext(t.Context(), `INSERT INTO user_role_bindings(user_id,role_name,cluster,namespace) VALUES (?, 'viewer', 'remote', 'gameplane-games')`, userID); err != nil { + t.Fatal(err) + } + rotated := strings.ReplaceAll(standaloneTestKubeconfig, "secret-standalone-registration-token", "rotated-registration-token") + rr := doClusters(t, router, http.MethodPut, "/clusters/remote/kubeconfig", map[string]string{"kubeconfig": rotated}) + if rr.Code != http.StatusNoContent || rr.Body.Len() != 0 { + t.Fatalf("rotate: %d %s", rr.Code, rr.Body) + } + if client, ok := reg.Get("remote"); ok && client == oldClient { + t.Fatal("old workload client remains available after rotation") + } + current, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil || current.GetUID() != registration.GetUID() || gatewayCredentialName(current) != "externally-managed-gateway" { + t.Fatal("rotation changed registration identity or gateway") + } + var bindings int + if err := store.DB.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM user_role_bindings WHERE user_id = ? AND cluster = 'remote'`, userID).Scan(&bindings); err != nil || bindings != 1 { + t.Fatal("rotation removed the existing workload grant") + } + name, _, _ := unstructured.NestedString(current.Object, "spec", "kubeconfigSecret", "name") + assertManagementCiphertext(t, store, name, rotated) + reopened, err := controlplane.New(t.Context(), store, keyPath) + if err != nil { + t.Fatal(err) + } + secret, err := reopened.Secrets(standaloneTestNamespace).Get(t.Context(), name, metav1.GetOptions{}) + if err != nil || string(secret.Data["kubeconfig"]) != rotated || secret.Immutable == nil || !*secret.Immutable { + t.Fatal("immutable rotated credential did not survive restart") + } + if rr := doClusters(t, router, http.MethodDelete, "/clusters/remote", nil); rr.Code != http.StatusNoContent { + t.Fatalf("delete rotated cluster: %d %s", rr.Code, rr.Body) + } + if _, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), name, metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("cluster deletion stranded the versioned credential: %v", err) + } +} + +func TestKubeconfigRotationRequiresCentralClusterManagement(t *testing.T) { + _, _, router, _ := registeredRotationFixture(t) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPut, "/clusters/remote/kubeconfig", strings.NewReader(`{"kubeconfig":"private-input"}`)) + unauthenticated := httptest.NewRecorder() + router.ServeHTTP(unauthenticated, request) + if unauthenticated.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated credential rotation: %d", unauthenticated.Code) + } + for _, permission := range []string{"modules:manage", "templates:write", "servers:write", "config:manage"} { + body, err := json.Marshal(map[string]string{"kubeconfig": standaloneTestKubeconfig}) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequestWithContext(auth.WithUser(t.Context(), inventoryUser("remote", "*", permission)), http.MethodPut, "/clusters/remote/kubeconfig", bytes.NewReader(body)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, request) + if rr.Code != http.StatusForbidden { + t.Fatalf("%s granted credential rotation: %d %s", permission, rr.Code, rr.Body) + } + } + for _, tc := range []struct{ name, config string }{{"local", standaloneTestKubeconfig}, {"remote", "invalid-private-kubeconfig"}, {"remote", ""}} { + rr := doClusters(t, router, http.MethodPut, "/clusters/"+tc.name+"/kubeconfig", map[string]string{"kubeconfig": tc.config}) + if rr.Code != http.StatusBadRequest || strings.Contains(rr.Body.String(), "invalid-private-kubeconfig") { + t.Fatalf("invalid rotation leaked/accepted input: %d %s", rr.Code, rr.Body) + } + } + body, err := json.Marshal(map[string]string{"kubeconfig": standaloneTestKubeconfig}) + if err != nil { + t.Fatal(err) + } + request = httptest.NewRequestWithContext(auth.WithUser(t.Context(), inventoryUser("local", "*", "cluster:manage")), http.MethodPut, "/clusters/remote/kubeconfig", bytes.NewReader(body)) + allowed := httptest.NewRecorder() + router.ServeHTTP(allowed, request) + if allowed.Code != http.StatusNoContent { + t.Fatalf("central cluster-management grant denied rotation: %d %s", allowed.Code, allowed.Body) + } +} + +func TestKubeconfigRotationPreservesExternalCredential(t *testing.T) { + home, _, router, registration := registeredRotationFixture(t) + external := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "external-kubeconfig", Labels: map[string]string{kube.ClusterKubeconfigLabel: "true"}}, Data: map[string][]byte{"kubeconfig": []byte(standaloneTestKubeconfig)}} + if _, err := home.Secrets(standaloneTestNamespace).Create(t.Context(), external, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + _ = unstructured.SetNestedField(registration.Object, external.Name, "spec", "kubeconfigSecret", "name") + if _, err := home.Clusters().Update(t.Context(), registration, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + if rr := doClusters(t, router, http.MethodPut, "/clusters/remote/kubeconfig", map[string]string{"kubeconfig": standaloneTestKubeconfig}); rr.Code != http.StatusNoContent { + t.Fatalf("rotate external reference: %d %s", rr.Code, rr.Body) + } + if _, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), external.Name, metav1.GetOptions{}); err != nil { + t.Fatalf("removed external credential: %v", err) + } +} + +func TestConcurrentKubeconfigRotationsPublishOnlyOneCompleteCredential(t *testing.T) { + home, _, router, _ := registeredRotationFixture(t) + first := strings.ReplaceAll(standaloneTestKubeconfig, "secret-standalone-registration-token", "first-rotation-token") + second := strings.ReplaceAll(standaloneTestKubeconfig, "secret-standalone-registration-token", "second-rotation-token") + interleaved := false + home.ClusterStore = gatewayInterleavingClusters{ClusterStore: home.ClusterStore, beforeUpdate: func() { + if interleaved { + return + } + interleaved = true + if rr := doClusters(t, router, http.MethodPut, "/clusters/remote/kubeconfig", map[string]string{"kubeconfig": second}); rr.Code != http.StatusNoContent { + t.Fatalf("concurrent rotate: %d %s", rr.Code, rr.Body) + } + }} + if rr := doClusters(t, router, http.MethodPut, "/clusters/remote/kubeconfig", map[string]string{"kubeconfig": first}); rr.Code != http.StatusConflict { + t.Fatalf("stale rotate: %d %s", rr.Code, rr.Body) + } + current, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + name, _, _ := unstructured.NestedString(current.Object, "spec", "kubeconfigSecret", "name") + secret, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), name, metav1.GetOptions{}) + if err != nil || string(secret.Data["kubeconfig"]) != second { + t.Fatal("pointer and credential came from different rotations") + } + secrets, err := home.Secrets(standaloneTestNamespace).List(t.Context(), metav1.ListOptions{LabelSelector: kube.ClusterKubeconfigLabel + "=true"}) + if err != nil || len(secrets.Items) != 1 { + t.Fatalf("rotation stranded detached credentials: %v", err) + } +} + +func TestKubeconfigRotationCannotCrossRegistrationDeletionAndRecreation(t *testing.T) { + home, reg, router, original := registeredRotationFixture(t) + interleaved := false + home.ClusterStore = gatewayInterleavingClusters{ClusterStore: home.ClusterStore, beforeUpdate: func() { + if interleaved { + return + } + interleaved = true + if rr := doClusters(t, router, http.MethodDelete, "/clusters/remote", nil); rr.Code != http.StatusNoContent { + t.Fatalf("concurrent remove: %d %s", rr.Code, rr.Body) + } + if rr := doClusters(t, router, http.MethodPost, "/clusters/", clusterCreateReq{Name: "remote", Kubeconfig: standaloneTestKubeconfig}); rr.Code != http.StatusCreated { + t.Fatalf("concurrent recreate: %d %s", rr.Code, rr.Body) + } + current, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + reg.SetWithUID("remote", current.GetUID(), fakeKubeClient()) + }} + rr := doClusters(t, router, http.MethodPut, "/clusters/remote/kubeconfig", map[string]string{"kubeconfig": strings.ReplaceAll(standaloneTestKubeconfig, "secret-standalone-registration-token", "stale-private-token")}) + if rr.Code != http.StatusConflict && rr.Code != http.StatusNotFound { + t.Fatalf("rotation crossed recreation: %d %s", rr.Code, rr.Body) + } + current, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil || current.GetUID() == original.GetUID() { + t.Fatal("replacement registration missing") + } + name, _, _ := unstructured.NestedString(current.Object, "spec", "kubeconfigSecret", "name") + if name != clusterKubeconfigSecretName("remote") { + t.Fatal("rotation replaced the recreated registration's credential") + } + if _, ok := reg.Get("remote"); !ok { + t.Fatal("stale rotation removed the replacement client") + } +} diff --git a/api/internal/handlers/clusters_registration_test.go b/api/internal/handlers/clusters_registration_test.go new file mode 100644 index 000000000..fca5ddde0 --- /dev/null +++ b/api/internal/handlers/clusters_registration_test.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "context" + "net/http" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/kube" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestStandaloneRegistrationRecoversOwnedMatchingOrphanThroughRoute(t *testing.T) { + store, home, reg, _ := standaloneHandlerStore(t) + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: clusterKubeconfigSecretName("remote"), Labels: map[string]string{kube.ClusterKubeconfigLabel: "true", ManagedByLabel: managedByValue}}, Data: map[string][]byte{"kubeconfig": []byte(standaloneTestKubeconfig)}} + created, err := home.Secrets(standaloneTestNamespace).Create(t.Context(), secret, metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + rr := doClusters(t, standaloneClustersRouter(home, reg), http.MethodPost, "/clusters/", clusterCreateReq{Name: "remote", Kubeconfig: standaloneTestKubeconfig}) + if rr.Code != http.StatusCreated { + t.Fatalf("retry must recover the orphan: %d %s", rr.Code, rr.Body) + } + current, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), secret.Name, metav1.GetOptions{}) + if err != nil || current.UID != created.UID || current.ResourceVersion != created.ResourceVersion { + t.Fatal("retry changed the legacy credential identity") + } + assertManagementCiphertext(t, store, secret.Name, standaloneTestKubeconfig) +} + +type cancelledRegistrationStore struct { + kube.ClusterStore + cancel context.CancelFunc +} + +func (s cancelledRegistrationStore) Create(context.Context, *unstructured.Unstructured, metav1.CreateOptions, ...string) (*unstructured.Unstructured, error) { + s.cancel() + return nil, context.Canceled +} + +func TestKubernetesRegistrationCancellationUsesIndependentCleanupContext(t *testing.T) { + home := fakeKubeClientWithClusters() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + home.ClusterStore = cancelledRegistrationStore{ClusterStore: home.Clusters(), cancel: cancel} + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "cluster-remote-kubeconfig", Labels: map[string]string{kube.ClusterKubeconfigLabel: "true"}}, Data: map[string][]byte{"kubeconfig": []byte(standaloneTestKubeconfig)}} + h := clustersHandler{k: home, namespace: standaloneTestNamespace} + if err := h.registerKubernetesCluster(ctx, secret, newCluster("remote", nil, nil)); err == nil { + t.Fatal("cancelled registration succeeded") + } + if _, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), secret.Name, metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("cancelled Kubernetes registration stranded a credential: %v", err) + } +} diff --git a/api/internal/handlers/clusters_remote_policy_test.go b/api/internal/handlers/clusters_remote_policy_test.go new file mode 100644 index 000000000..51dc616bd --- /dev/null +++ b/api/internal/handlers/clusters_remote_policy_test.go @@ -0,0 +1,63 @@ +package handlers + +import ( + "net/http" + "strings" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/kube" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestStandaloneRegistrationAndRotationRejectUnsafeDestinations(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + router := standaloneClustersRouter(home, reg) + if rr := doClusters(t, router, http.MethodPost, "/clusters/", clusterCreateReq{Name: "remote", Kubeconfig: standaloneTestKubeconfig}); rr.Code != http.StatusCreated { + t.Fatalf("initial registration: %d %s", rr.Code, rr.Body) + } + for _, server := range []string{"http://remote.example", "https://127.0.0.1", "https://169.254.169.254", "https://metadata.google.internal"} { + t.Run(server, func(t *testing.T) { + config := strings.ReplaceAll(standaloneTestKubeconfig, "https://remote.example.invalid", server) + for _, request := range []struct { + method, path string + body any + }{ + {http.MethodPost, "/clusters/", clusterCreateReq{Name: "blocked", Kubeconfig: config}}, + {http.MethodPut, "/clusters/remote/kubeconfig", map[string]string{"kubeconfig": config}}, + } { + rr := doClusters(t, router, request.method, request.path, request.body) + if rr.Code != http.StatusBadRequest { + t.Fatalf("%s unsafe endpoint accepted: %d %s", request.method, rr.Code, rr.Body) + } + } + }) + } + secrets, err := home.Secrets(standaloneTestNamespace).List(t.Context(), metav1.ListOptions{}) + if err != nil || len(secrets.Items) != 1 { + t.Fatalf("rejected operations persisted credentials: %v", err) + } +} + +func TestStandaloneGatewayAppliesOperatorDestinationRestriction(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + policy, err := kube.NewRemoteAccessPolicy([]string{"10.20.0.0/16"}) + if err != nil { + t.Fatal(err) + } + home.RemoteAccess = policy + if _, err := home.Clusters().Create(t.Context(), newCluster("remote", map[string]any{}, nil), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + router := standaloneClustersRouter(home, reg) + in := gatewayCertificateRequest(t) + for _, destination := range []string{"https://127.0.0.1:8443", "https://10.21.0.1:8443"} { + in.URL = destination + if rr := doClusters(t, router, http.MethodPut, "/clusters/remote/gateway", in); rr.Code != http.StatusBadRequest { + t.Fatalf("unsafe gateway accepted: %d %s", rr.Code, rr.Body) + } + } + in.URL = "https://10.20.0.1:8443" + if rr := doClusters(t, router, http.MethodPut, "/clusters/remote/gateway", in); rr.Code != http.StatusNoContent { + t.Fatalf("allowed gateway rejected: %d %s", rr.Code, rr.Body) + } +} diff --git a/api/internal/handlers/credential_review_test.go b/api/internal/handlers/credential_review_test.go new file mode 100644 index 000000000..7ab3dd4c4 --- /dev/null +++ b/api/internal/handlers/credential_review_test.go @@ -0,0 +1,233 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/go-chi/chi/v5" + corev1 "k8s.io/api/core/v1" + apierrors "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" + "k8s.io/client-go/dynamic/fake" + kubefake "k8s.io/client-go/kubernetes/fake" +) + +type stalledRotationRefresh struct { + kube.ClusterStore + published bool + stalled bool +} + +func (s *stalledRotationRefresh) Update(ctx context.Context, obj *unstructured.Unstructured, opts metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) { + updated, err := s.ClusterStore.Update(ctx, obj, opts, subresources...) + if err == nil { + s.published = true + } + return updated, err +} + +func (s *stalledRotationRefresh) Get(ctx context.Context, name string, opts metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) { + if s.published && !s.stalled { + s.stalled = true + // Model a queued or blocked refresh consuming the whole cleanup budget. + <-ctx.Done() + return nil, ctx.Err() + } + return s.ClusterStore.Get(ctx, name, opts, subresources...) +} + +func TestKubeconfigCleanupSurvivesExpiredClientRefresh(t *testing.T) { + t.Parallel() + home, _, router, registration := registeredRotationFixture(t) + oldName, _, _ := unstructured.NestedString(registration.Object, "spec", "kubeconfigSecret", "name") + home.ClusterStore = &stalledRotationRefresh{ClusterStore: home.ClusterStore} + rotated := strings.ReplaceAll(standaloneTestKubeconfig, "secret-standalone-registration-token", "rotated-cleanup-token") + if rr := doClusters(t, router, http.MethodPut, "/clusters/remote/kubeconfig", map[string]string{"kubeconfig": rotated}); rr.Code != http.StatusNoContent { + t.Fatalf("rotate: %d %s", rr.Code, rr.Body) + } + if _, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), oldName, metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("expired refresh stranded old credential: %v", err) + } + if rr := doClusters(t, router, http.MethodDelete, "/clusters/remote", nil); rr.Code != http.StatusNoContent { + t.Fatalf("delete: %d %s", rr.Code, rr.Body) + } + if rr := doClusters(t, router, http.MethodPost, "/clusters/", clusterCreateReq{Name: "remote", Kubeconfig: rotated}); rr.Code != http.StatusCreated { + t.Fatalf("re-registration after timed-out refresh: %d %s", rr.Code, rr.Body) + } +} + +type stalledGatewayCredentialRead struct { + kube.SecretStore + gatewayName string +} + +func (s stalledGatewayCredentialRead) Get(ctx context.Context, name string, opts metav1.GetOptions) (*corev1.Secret, error) { + if name == s.gatewayName { + <-ctx.Done() + return nil, ctx.Err() + } + return s.SecretStore.Get(ctx, name, opts) +} + +func TestClusterDeletionSeparatesCredentialCleanupBudgets(t *testing.T) { + t.Parallel() + home, _, router, registration := registeredRotationFixture(t) + kubeconfigName, _, _ := unstructured.NestedString(registration.Object, "spec", "kubeconfigSecret", "name") + if rr := doClusters(t, router, http.MethodPut, "/clusters/remote/gateway", gatewayCertificateRequest(t)); rr.Code != http.StatusNoContent { + t.Fatalf("configure: %d", rr.Code) + } + current, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + gatewayName := gatewayCredentialName(current) + secrets := home.SecretStore + home.SecretStore = func(namespace string) kube.SecretStore { + return stalledGatewayCredentialRead{SecretStore: secrets(namespace), gatewayName: gatewayName} + } + if rr := doClusters(t, router, http.MethodDelete, "/clusters/remote", nil); rr.Code != http.StatusNoContent { + t.Fatalf("delete: %d %s", rr.Code, rr.Body) + } + if _, err := secrets(standaloneTestNamespace).Get(t.Context(), kubeconfigName, metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("gateway read consumed kubeconfig cleanup budget: %v", err) + } +} + +func TestCombinedGatewayConfigurationRejectsUnsafeDestination(t *testing.T) { + home := &kube.Client{Typed: kubefake.NewSimpleClientset(), Dynamic: fake.NewSimpleDynamicClient(runtime.NewScheme())} + reg := kube.NewRegistry("local") + reg.Set("local", home) + h := clustersHandler{k: home, reg: reg, namespace: standaloneTestNamespace} + in := gatewayCertificateRequest(t) + in.URL = "https://169.254.169.254" + rr := gatewayReviewRecorder() + h.configureGateway(rr, gatewayReviewRequest(t.Context(), t, in)) + if rr.Code != http.StatusBadRequest { + t.Fatalf("combined handler accepted metadata origin: %d %s", rr.Code, rr.Body) + } +} + +func TestLegacyKubeconfigRotationDeletesCapturedSecret(t *testing.T) { + home, _, router, registration := registeredRotationFixture(t) + name, _, _ := unstructured.NestedString(registration.Object, "spec", "kubeconfigSecret", "name") + secret, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + delete(secret.Labels, ManagedByLabel) + if _, err := home.Secrets(standaloneTestNamespace).Update(t.Context(), secret, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + if rr := doClusters(t, router, http.MethodPut, "/clusters/remote/kubeconfig", map[string]string{"kubeconfig": standaloneTestKubeconfig}); rr.Code != http.StatusNoContent { + t.Fatalf("rotate: %d %s", rr.Code, rr.Body) + } + if _, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), name, metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("legacy credential survived: %v", err) + } +} + +type cancelGatewayUpdate struct { + kube.ClusterStore + cancel context.CancelFunc +} + +type cancelClusterDelete struct { + kube.ClusterStore + cancel context.CancelFunc +} + +func (s cancelClusterDelete) Delete(ctx context.Context, name string, opts metav1.DeleteOptions, subresources ...string) error { + if err := s.ClusterStore.Delete(ctx, name, opts, subresources...); err != nil { + return err + } + s.cancel() + return nil +} + +func TestClusterRemovalCleansCredentialsAfterCallerDisconnects(t *testing.T) { + home, reg, router, registration := registeredRotationFixture(t) + kubeconfigName, _, _ := unstructured.NestedString(registration.Object, "spec", "kubeconfigSecret", "name") + if rr := doClusters(t, router, http.MethodPut, "/clusters/remote/gateway", gatewayCertificateRequest(t)); rr.Code != http.StatusNoContent { + t.Fatalf("configure: %d", rr.Code) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + home.ClusterStore = cancelClusterDelete{home.ClusterStore, cancel} + // Cancel only after persisted removal, exercising the cleanup boundary. + request := gatewayReviewRequest(ctx, t, gatewayCertificateRequest(t)) + request.Method = http.MethodDelete + h := clustersHandler{k: home, reg: reg, namespace: standaloneTestNamespace} + rr := gatewayReviewRecorder() + h.delete(rr, request) + if rr.Code != http.StatusNoContent { + t.Fatalf("delete: %d %s", rr.Code, rr.Body) + } + if _, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), kubeconfigName, metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("kubeconfig survived: %v", err) + } + if secrets, err := home.Secrets(standaloneTestNamespace).List(t.Context(), metav1.ListOptions{}); err != nil || len(secrets.Items) != 0 { + t.Fatal("gateway credential survived caller disconnect") + } +} + +func (s cancelGatewayUpdate) Update(ctx context.Context, _ *unstructured.Unstructured, _ metav1.UpdateOptions, _ ...string) (*unstructured.Unstructured, error) { + s.cancel() + return nil, ctx.Err() +} + +func TestCancelledGatewayConfigurationCleansUnusedCredential(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + if _, err := home.Clusters().Create(t.Context(), newCluster("remote", map[string]any{}, nil), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + home.ClusterStore = cancelGatewayUpdate{home.ClusterStore, cancel} + h := clustersHandler{k: home, reg: reg, namespace: standaloneTestNamespace} + // Drive the handler with a request carrying the cancelled update context. + req := gatewayReviewRequest(ctx, t, gatewayCertificateRequest(t)) + rr := gatewayReviewRecorder() + h.configureGateway(rr, req) + secrets, err := home.Secrets(standaloneTestNamespace).List(t.Context(), metav1.ListOptions{}) + if err != nil || len(secrets.Items) != 0 { + t.Fatalf("cancelled request stranded credentials: %v %+v", err, secrets) + } +} + +func TestGatewayConfigurationRejectsTerminatingRegistration(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + registration := newCluster("remote", map[string]any{}, nil) + now := metav1.Now() + registration.SetDeletionTimestamp(&now) + if _, err := home.Clusters().Create(t.Context(), registration, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + if rr := doClusters(t, standaloneClustersRouter(home, reg), http.MethodPut, "/clusters/remote/gateway", gatewayCertificateRequest(t)); rr.Code != http.StatusNotFound { + t.Fatalf("terminating registration accepted: %d", rr.Code) + } + if secrets, err := home.Secrets(standaloneTestNamespace).List(t.Context(), metav1.ListOptions{}); err != nil || len(secrets.Items) != 0 { + t.Fatal("terminating registration received a credential") + } +} + +func gatewayReviewRequest(ctx context.Context, t *testing.T, in clusterGatewayRequest) *http.Request { + t.Helper() + data, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + route := chi.NewRouteContext() + route.URLParams.Add("name", "remote") + ctx = context.WithValue(ctx, chi.RouteCtxKey, route) + return httptest.NewRequestWithContext(ctx, http.MethodPut, "/clusters/remote/gateway", bytes.NewReader(data)) +} + +func gatewayReviewRecorder() *httptest.ResponseRecorder { return httptest.NewRecorder() } diff --git a/api/internal/handlers/events_scope_test.go b/api/internal/handlers/events_scope_test.go index 0dede011c..dea2dfc01 100644 --- a/api/internal/handlers/events_scope_test.go +++ b/api/internal/handlers/events_scope_test.go @@ -117,12 +117,19 @@ func TestEvents_StreamsOnlyReadableKinds(t *testing.T) { want: []string{"servers", "templates"}, }, { - // Namespaced kinds are gated by the target cluster; the - // cluster-scoped templates:read is granted by any cluster's - // cluster-wide binding (auth.User.Can). + // Templates, like namespaced workload kinds, require permission + // on the selected cluster. name: "bindings on another cluster only", perms: permMap{"other": {"*": eventsPermSet("servers:read", "backups:read", "schedules:read", "templates:read")}}, - want: []string{"templates"}, + want: []string{}, + }, + { + name: "local server reader cannot stream another cluster's templates", + perms: permMap{ + scope.DefaultCluster: {"*": eventsPermSet("servers:read")}, + "other": {"*": eventsPermSet("templates:read")}, + }, + want: []string{"servers"}, }, { name: "admin", diff --git a/api/internal/handlers/fleet.go b/api/internal/handlers/fleet.go index ee87779b9..ee53371b4 100644 --- a/api/internal/handlers/fleet.go +++ b/api/internal/handlers/fleet.go @@ -175,11 +175,11 @@ func (h fleetHandler) candidates(ctx context.Context, filter string) ([]fleetClu k, _ := h.reg.Get(id) byID[id] = fleetCluster{id: id, name: id, k: k} } - if _, ok := byID[h.reg.DefaultID()]; !ok { + if _, ok := byID[h.reg.DefaultID()]; !ok && h.reg.Default() != nil { byID[h.reg.DefaultID()] = fleetCluster{id: h.reg.DefaultID(), name: h.reg.DefaultID()} } issues := make([]fleetIssue, 0) - home := h.reg.Default() + home := h.reg.Management() applyRegistration := func(obj *unstructured.Unstructured) { id := obj.GetName() if obj.GetDeletionTimestamp() != nil { @@ -198,7 +198,7 @@ func (h fleetHandler) candidates(ctx context.Context, filter string) ([]fleetClu } // The local registration is intrinsic; its explicit filter does not // depend on discovering unrelated remote registrations. - if filter != h.reg.DefaultID() && (home == nil || home.Dynamic == nil) { + if filter != h.reg.DefaultID() && (home == nil || home.Clusters() == nil) { issues = append(issues, fleetIssue{Code: "unavailable", Message: "Cluster discovery is incomplete"}) } else if filter != h.reg.DefaultID() { ctx, cancel := context.WithTimeout(ctx, fleetScopeTimeout) @@ -207,12 +207,12 @@ func (h fleetHandler) candidates(ctx context.Context, filter string) ([]fleetClu var err error if filter != "" { var obj *unstructured.Unstructured - obj, err = home.Dynamic.Resource(kube.GVRCluster).Get(ctx, filter, metav1.GetOptions{}) + obj, err = home.Clusters().Get(ctx, filter, metav1.GetOptions{}) if err == nil { list = &unstructured.UnstructuredList{Items: []unstructured.Unstructured{*obj}} } } else { - list, err = home.Dynamic.Resource(kube.GVRCluster).List(ctx, metav1.ListOptions{Limit: fleetMaxItems}) + list, err = home.Clusters().List(ctx, metav1.ListOptions{Limit: fleetMaxItems}) } if err != nil && !apierrors.IsNotFound(err) { issues = append(issues, fleetIssue{Code: "unavailable", Message: "Cluster discovery is incomplete"}) @@ -660,10 +660,6 @@ func (h fleetHandler) placements(w http.ResponseWriter, req *http.Request) { return } out := fleetResult[fleetPlacement]{Items: make([]fleetPlacement, 0), Issues: make([]fleetIssue, 0)} - if !u.Can("templates:read", false, "", "") { - writeJSON(w, out) - return - } ctx, cancel := context.WithTimeout(req.Context(), fleetTimeout) defer cancel() clusters, issues := h.candidates(ctx, f.cluster) @@ -674,6 +670,9 @@ func (h fleetHandler) placements(w http.ResponseWriter, req *http.Request) { } allowed := make([]eligible, 0) for _, c := range clusters { + if !u.Can("templates:read", false, c.id, "") { + continue + } namespaces := make([]string, 0) for _, ns := range scope.AllowedNamespaces { if (f.namespace == "" || ns == f.namespace) && u.Can("servers:write", true, c.id, ns) { diff --git a/api/internal/handlers/fleet_test.go b/api/internal/handlers/fleet_test.go index 816395625..7b0b2ed45 100644 --- a/api/internal/handlers/fleet_test.go +++ b/api/internal/handlers/fleet_test.go @@ -327,9 +327,9 @@ func TestFleetPlacements_RequireCreateAndTemplateRead(t *testing.T) { home := fleetTestClient(newCluster("remote", nil, nil), newCluster("offline", nil, nil), template.DeepCopy()) reg := clusterTestRegistry(home) reg.Set("remote", fleetTestClient(template)) - u := inventoryUser("local", "*", "templates:read") - u.Perms["remote"] = map[string]map[string]struct{}{scope.DefaultNamespace: {"servers:write": {}}} - u.Perms["offline"] = map[string]map[string]struct{}{scope.DefaultNamespace: {"servers:write": {}}} + u := inventoryUser("remote", "*", "templates:read") + u.Perms["remote"][scope.DefaultNamespace] = map[string]struct{}{"servers:write": {}} + u.Perms["offline"] = map[string]map[string]struct{}{"*": {"templates:read": {}}, scope.DefaultNamespace: {"servers:write": {}}} got := decodeFleet[fleetPlacement](t, fleetRouter(reg), u, "/fleet/placements") if !got.Partial || len(got.Items) != 1 || got.Items[0].Cluster != "remote" || got.Items[0].Namespace != scope.DefaultNamespace || string(got.Items[0].Templates[0].GetUID()) != "remote-template" { t.Fatalf("placements=%+v", got) @@ -347,6 +347,12 @@ func TestFleetPlacements_RequireCreateAndTemplateRead(t *testing.T) { if len(noCreate.Items) != 0 || noCreate.Partial { t.Fatalf("template reader gained create placement: %+v", noCreate) } + wrongTarget := inventoryUser("local", "*", "templates:read") + wrongTarget.Perms["remote"] = map[string]map[string]struct{}{scope.DefaultNamespace: {"servers:write": {}}} + denied := decodeFleet[fleetPlacement](t, fleetRouter(reg), wrongTarget, "/fleet/placements") + if len(denied.Items) != 0 || denied.Partial { + t.Fatalf("local template grant authorized remote placement: %+v", denied) + } } func TestFleetExactServerAccess_IsTargetScoped(t *testing.T) { diff --git a/api/internal/handlers/installation.go b/api/internal/handlers/installation.go new file mode 100644 index 000000000..bce393d3f --- /dev/null +++ b/api/internal/handlers/installation.go @@ -0,0 +1,18 @@ +package handlers + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +// MountInstallation exposes capabilities only through the authenticated router. +func MountInstallation(r chi.Router, standalone bool, version string) { + r.Get("/admin/installation", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, struct { + Standalone bool `json:"standalone"` + LocalCluster bool `json:"localCluster"` + Version string `json:"version"` + }{Standalone: standalone, LocalCluster: !standalone, Version: version}) + }) +} diff --git a/api/internal/handlers/installation_test.go b/api/internal/handlers/installation_test.go new file mode 100644 index 000000000..8890a1739 --- /dev/null +++ b/api/internal/handlers/installation_test.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func TestInstallationReportsPanelVersionWithoutWorkloadCluster(t *testing.T) { + for _, standalone := range []bool{false, true} { + t.Run(map[bool]string{false: "combined", true: "standalone"}[standalone], func(t *testing.T) { + r := chi.NewRouter() + MountInstallation(r, standalone, "v0.2.3-review") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/admin/installation", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + var got struct { + Standalone bool `json:"standalone"` + LocalCluster bool `json:"localCluster"` + Version string `json:"version"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Standalone != standalone || got.LocalCluster == standalone || got.Version != "v0.2.3-review" { + t.Fatalf("unexpected installation metadata: %+v", got) + } + }) + } +} diff --git a/api/internal/handlers/modules_cluster.go b/api/internal/handlers/modules_cluster.go new file mode 100644 index 000000000..b562ea915 --- /dev/null +++ b/api/internal/handlers/modules_cluster.go @@ -0,0 +1,59 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/GameplanePanel/gameplane/api/internal/auth" + "github.com/GameplanePanel/gameplane/api/internal/httperr" + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/go-chi/chi/v5" +) + +// MountModulesWithRegistry retains central module ownership in combined installs. +// Without a local cluster the caller must select the operator that owns modules. +func MountModulesWithRegistry(r chi.Router, reg *kube.Registry, namespace string) { + if reg.Default() != nil { + r.Group(func(local chi.Router) { + local.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if rejectRemoteCluster(w, req) { + return + } + next.ServeHTTP(w, req) + }) + }) + MountModules(local, reg.Default(), namespace) + }) + return + } + handle := func(w http.ResponseWriter, req *http.Request) { + id := strings.TrimSpace(req.URL.Query().Get("cluster")) + if id == "" || id == reg.DefaultID() || len(req.URL.Query()["cluster"]) != 1 { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("select a registered cluster for modules")) + return + } + permission := "modules:read" + if req.Method != http.MethodGet { + permission = "modules:manage" + } + u := auth.UserFromContext(req.Context()) + if u == nil || !u.Can(permission, true, id, "") { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + k, ok := resolveCluster(w, req, reg) + if !ok { + return + } + // A request owns its handler/client; never mutate shared selection. + selected := chi.NewRouter() + MountModules(selected, k, namespace) + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, chi.NewRouteContext()) + selected.ServeHTTP(w, req.WithContext(ctx)) + } + r.HandleFunc("/modules", handle) + r.HandleFunc("/modules/*", handle) +} diff --git a/api/internal/handlers/modules_cluster_test.go b/api/internal/handlers/modules_cluster_test.go new file mode 100644 index 000000000..c14c585ed --- /dev/null +++ b/api/internal/handlers/modules_cluster_test.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "net/http" + "strings" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/api/internal/rbac" + "github.com/go-chi/chi/v5" +) + +func TestStandaloneModulesUseExplicitTargetAndItsPermission(t *testing.T) { + reg := kube.NewRegistry("local") + reg.SetManagement(fakeKubeClientWithClusters()) + reg.Set("remote", fakeKubeClient(newModule("remote-only", nil))) + reg.Set("other", fakeKubeClient(newModule("other-only", nil))) + r := chi.NewRouter() + r.Use(rbac.Middleware(reg)) + MountModulesWithRegistry(r, reg, "gameplane-system") + user := inventoryUser("remote", "*", "modules:read") + rr := inventoryRequest(t, r, user, http.MethodGet, "/modules/?cluster=remote") + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "remote-only") || strings.Contains(rr.Body.String(), "other-only") { + t.Fatalf("remote list: %d %s", rr.Code, rr.Body) + } + for _, target := range []string{"/modules/", "/modules/?cluster=other", "/modules/?cluster=remote&cluster=other"} { + rr := inventoryRequest(t, r, user, http.MethodGet, target) + if rr.Code < 400 { + t.Fatalf("unscoped/unauthorized request %s: %d %s", target, rr.Code, rr.Body) + } + } +} + +func TestCombinedModulesCannotUseRemoteGrantForLocalCatalog(t *testing.T) { + reg := kube.NewRegistry("local") + reg.Set("local", fakeKubeClient(newModule("local-only", nil))) + r := chi.NewRouter() + r.Use(rbac.Middleware(reg)) + MountModulesWithRegistry(r, reg, "gameplane-system") + rr := inventoryRequest(t, r, inventoryUser("remote", "*", "modules:read"), http.MethodGet, "/modules/?cluster=remote") + if rr.Code != http.StatusNotImplemented { + t.Fatalf("remote selector: %d %s", rr.Code, rr.Body) + } +} diff --git a/api/internal/handlers/remote_bindings_test.go b/api/internal/handlers/remote_bindings_test.go index 51550f431..134511179 100644 --- a/api/internal/handlers/remote_bindings_test.go +++ b/api/internal/handlers/remote_bindings_test.go @@ -136,7 +136,7 @@ func TestRemoteBindings_GrantAndRevokePreservePrimaryRole(t *testing.T) { func TestRemoteBindings_RejectGlobalRolesAndNonRemoteTargets(t *testing.T) { r, store, sessions, id := remoteBindingFixture(t) createRemoteRole(t, r, "cluster-reader", []string{"cluster:read"}) - for _, permission := range []string{"users:manage", "roles:manage", "config:manage", "cluster:manage", "modules:read"} { + for _, permission := range []string{"users:manage", "roles:manage", "config:manage", "cluster:manage"} { resource, _, _ := strings.Cut(permission, ":") createRemoteRole(t, r, "global-"+resource, []string{permission}) } @@ -148,7 +148,6 @@ func TestRemoteBindings_RejectGlobalRolesAndNonRemoteTargets(t *testing.T) { {RoleName: "global-roles", Namespace: "*", Cluster: "remote"}, {RoleName: "global-config", Namespace: "*", Cluster: "remote"}, {RoleName: "global-cluster", Namespace: "*", Cluster: "remote"}, - {RoleName: "global-modules", Namespace: "*", Cluster: "remote"}, {RoleName: "cluster-reader", Namespace: "*", Cluster: "local"}, {RoleName: "cluster-reader", Namespace: "*", Cluster: ""}, {RoleName: "cluster-reader", Namespace: "*", Cluster: "*"}, @@ -172,6 +171,65 @@ func TestRemoteBindings_RejectGlobalRolesAndNonRemoteTargets(t *testing.T) { } } +func TestRemoteBindings_ModuleAndTemplateRoleGrantsStayOnSelectedCluster(t *testing.T) { + r, _, sessions, id := remoteBindingFixture(t) + permissions := []string{"modules:read", "modules:manage", "templates:read", "templates:write"} + before := loadedBindingUser(t, sessions, id) + createRemoteRole(t, r, "remote-catalog", permissions) + path := "/users/" + strconv.FormatInt(id, 10) + "/bindings" + granted := remoteBindingRequest(t, r, http.MethodPost, path, addBindingReq{RoleName: "remote-catalog", Namespace: "*", Cluster: "remote"}) + if granted.Code != http.StatusCreated { + t.Fatalf("remote catalog grant: %d %s", granted.Code, granted.Body) + } + u := loadedBindingUser(t, sessions, id) + for _, permission := range permissions { + if !u.Can(permission, false, "remote", "") { + t.Errorf("remote role did not grant %s", permission) + } + if u.Can(permission, false, "other", "") { + t.Errorf("remote role leaked %s to other cluster", permission) + } + if u.Can(permission, false, "local", "") != before.Can(permission, false, "local", "") { + t.Errorf("remote role changed primary local grant for %s", permission) + } + } + for _, permission := range []string{"users:manage", "roles:manage", "config:manage", "cluster:manage"} { + if u.Can(permission, false, "", "") { + t.Errorf("remote catalog role conferred central %s", permission) + } + } + // A bound role can gain another target-scoped catalog permission while + // central administration remains forbidden by the role-edit guards. + updated := remoteBindingRequest(t, r, http.MethodPatch, "/roles/remote-catalog", map[string]any{"permissions": []string{"modules:read", "templates:read"}}) + if updated.Code != http.StatusOK { + t.Fatalf("catalog role read-only edit: %d %s", updated.Code, updated.Body) + } + u = loadedBindingUser(t, sessions, id) + if u.Can("modules:manage", false, "remote", "") || u.Can("templates:write", false, "remote", "") || !u.Can("templates:read", false, "remote", "") { + t.Fatal("catalog role edit failed to revoke writes while preserving reads") + } + updated = remoteBindingRequest(t, r, http.MethodPatch, "/roles/remote-catalog", map[string]any{"permissions": permissions}) + if updated.Code != http.StatusOK { + t.Fatalf("catalog role target-scoped edit: %d %s", updated.Code, updated.Body) + } + for _, permission := range []string{"users:manage", "roles:manage", "config:manage", "cluster:manage"} { + denied := remoteBindingRequest(t, r, http.MethodPatch, "/roles/remote-catalog", map[string]any{"permissions": []string{"modules:read", permission}}) + if denied.Code != http.StatusBadRequest { + t.Errorf("catalog role widened to %s: %d %s", permission, denied.Code, denied.Body) + } + } + revoked := remoteBindingRequest(t, r, http.MethodDelete, path+"/remote-catalog/*?cluster=remote", nil) + if revoked.Code != http.StatusNoContent { + t.Fatalf("catalog role revoke: %d %s", revoked.Code, revoked.Body) + } + u = loadedBindingUser(t, sessions, id) + for _, permission := range permissions { + if u.Can(permission, false, "remote", "") { + t.Errorf("revoked remote permission survived: %s", permission) + } + } +} + func TestRemoteBindings_RoleEditsCannotWidenButCanRevoke(t *testing.T) { r, _, sessions, id := remoteBindingFixture(t) createRemoteRole(t, r, "cluster-reader", []string{"cluster:read", "servers:read"}) @@ -180,7 +238,7 @@ func TestRemoteBindings_RoleEditsCannotWidenButCanRevoke(t *testing.T) { if rr.Code != http.StatusCreated { t.Fatalf("grant status=%d body=%s", rr.Code, rr.Body) } - for _, permission := range []string{"*", "users:manage", "roles:manage", "config:manage", "cluster:manage", "modules:read"} { + for _, permission := range []string{"*", "users:manage", "roles:manage", "config:manage", "cluster:manage"} { rr = remoteBindingRequest(t, r, http.MethodPatch, "/roles/cluster-reader", map[string]any{"permissions": []string{"cluster:read", permission}}) if rr.Code != http.StatusBadRequest { t.Fatalf("widen to %s: status=%d body=%s", permission, rr.Code, rr.Body) diff --git a/api/internal/handlers/secrets_managed.go b/api/internal/handlers/secrets_managed.go index 9b03e93d0..dea686999 100644 --- a/api/internal/handlers/secrets_managed.go +++ b/api/internal/handlers/secrets_managed.go @@ -15,13 +15,11 @@ package handlers import ( "context" - "encoding/json" "errors" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" "github.com/GameplanePanel/gameplane/api/internal/kube" ) @@ -35,8 +33,8 @@ var errNotManagedSecret = errors.New("a secret with that name exists but is not // upsertLabelledSecret creates or updates ns/name with featureLabel=true // + managed-by and the supplied keys. Callers must pass the complete key -// set for their schema (empty string for unset optionals) — the update is -// a merge patch, so an omitted key would otherwise keep its old value. +// set for their schema (empty string for unset optionals) — supplied keys +// are merged into the current Secret, so omitted keys retain their value. // Updating a Secret that lacks featureLabel is refused, so this path // can't overwrite arbitrary control-plane Secrets. func upsertLabelledSecret(ctx context.Context, k *kube.Client, ns, name, featureLabel string, data map[string]string) error { @@ -50,30 +48,34 @@ func upsertLabelledSecret(ctx context.Context, k *kube.Client, ns, name, feature Type: corev1.SecretTypeOpaque, StringData: data, } - _, err := k.Typed.CoreV1().Secrets(ns).Create(ctx, desired, metav1.CreateOptions{}) + _, err := k.Secrets(ns).Create(ctx, desired, metav1.CreateOptions{}) if err == nil { return nil } if !apierrors.IsAlreadyExists(err) { return err } - existing, err := k.Typed.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{}) + existing, err := k.Secrets(ns).Get(ctx, name, metav1.GetOptions{}) if err != nil { return err } if existing.Labels[featureLabel] != "true" { return errNotManagedSecret } - patch, err := json.Marshal(map[string]any{ - // Re-assert managed-by so a pre-created labelled Secret an admin - // re-saves through the UI becomes deletable through the UI too. - "metadata": map[string]any{"labels": labels}, - "stringData": data, - }) - if err != nil { - return err + // Keep unrelated keys/labels and bind the update to the version just read. + if existing.Labels == nil { + existing.Labels = map[string]string{} + } + for key, value := range labels { + existing.Labels[key] = value + } + if existing.StringData == nil { + existing.StringData = map[string]string{} + } + for key, value := range data { + existing.StringData[key] = value } - _, err = k.Typed.CoreV1().Secrets(ns).Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{}) + _, err = k.Secrets(ns).Update(ctx, existing, metav1.UpdateOptions{}) return err } @@ -81,12 +83,12 @@ func upsertLabelledSecret(ctx context.Context, k *kube.Client, ns, name, feature // feature label and managed-by=gameplane-api. Anything else reads as // not-found so the endpoint doesn't leak which Secrets exist. func deleteManagedSecret(ctx context.Context, k *kube.Client, ns, name, featureLabel string) error { - existing, err := k.Typed.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{}) + existing, err := k.Secrets(ns).Get(ctx, name, metav1.GetOptions{}) if err != nil { return err } if existing.Labels[featureLabel] != "true" || existing.Labels[ManagedByLabel] != managedByValue { return apierrors.NewNotFound(corev1.Resource("secrets"), name) } - return k.Typed.CoreV1().Secrets(ns).Delete(ctx, name, metav1.DeleteOptions{}) + return k.Secrets(ns).Delete(ctx, name, metav1.DeleteOptions{Preconditions: objectDeletePreconditions(existing)}) } diff --git a/api/internal/handlers/secrets_managed_test.go b/api/internal/handlers/secrets_managed_test.go index 6ea930914..c9b464ab1 100644 --- a/api/internal/handlers/secrets_managed_test.go +++ b/api/internal/handlers/secrets_managed_test.go @@ -68,8 +68,9 @@ func TestUpsertLabelledSecret_UpdatesLabelledSecret(t *testing.T) { t.Parallel() existing := smtSecret("creds", - map[string]string{smtFeatureLabel: "true"}, + map[string]string{smtFeatureLabel: "true", "extra": "keep-label"}, map[string]string{"a": "old", "b": "keep"}) + existing.Data = map[string][]byte{"unrelated": []byte("keep-data")} _, k := smtClient(existing) if err := upsertLabelledSecret(t.Context(), k, smtNamespace, "creds", smtFeatureLabel, map[string]string{"a": "new"}); err != nil { @@ -86,7 +87,10 @@ func TestUpsertLabelledSecret_UpdatesLabelledSecret(t *testing.T) { t.Fatalf("a = %q, want new", got.StringData["a"]) } if got.StringData["b"] != "keep" { - t.Fatalf("b = %q, want keep (merge patch must not drop it)", got.StringData["b"]) + t.Fatalf("b = %q, want keep (credential merge must not drop it)", got.StringData["b"]) + } + if string(got.Data["unrelated"]) != "keep-data" || got.Labels["extra"] != "keep-label" { + t.Fatal("credential update dropped unrelated stored data or labels") } } @@ -142,9 +146,9 @@ func TestUpsertLabelledSecret_ErrorPropagation(t *testing.T) { injected: injected, }, { - name: "patch error is returned", + name: "update error is returned", objs: []runtime.Object{smtSecret("creds", map[string]string{smtFeatureLabel: "true"}, nil)}, - verb: "patch", + verb: "update", injected: injected, }, } diff --git a/api/internal/handlers/standalone_storage_test.go b/api/internal/handlers/standalone_storage_test.go new file mode 100644 index 000000000..38c1d1462 --- /dev/null +++ b/api/internal/handlers/standalone_storage_test.go @@ -0,0 +1,234 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/GameplanePanel/gameplane/api/internal/auth" + "github.com/GameplanePanel/gameplane/api/internal/controlplane" + "github.com/GameplanePanel/gameplane/api/internal/db" + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/api/internal/rbac" +) + +const standaloneTestNamespace = "gameplane-system" +const standaloneTestKubeconfig = `apiVersion: v1 +kind: Config +clusters: +- name: remote + cluster: + server: https://remote.example.invalid:6443 +contexts: +- name: remote + context: + cluster: remote + user: panel +current-context: remote +users: +- name: panel + user: + token: secret-standalone-registration-token +` + +func standaloneHandlerStore(t *testing.T) (*db.Store, *kube.Client, *kube.Registry, string) { + t.Helper() + dir := t.TempDir() + store, err := db.Open(t.Context(), "sqlite", filepath.Join(dir, "panel.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Migrate(t.Context()); err != nil { + t.Fatal(err) + } + keyPath := filepath.Join(dir, "credentials.key") + home, err := controlplane.New(t.Context(), store, keyPath) + if err != nil { + t.Fatal(err) + } + reg := kube.NewRegistry("local") + reg.SetManagement(home) + return store, home, reg, keyPath +} + +func standaloneClustersRouter(home *kube.Client, reg *kube.Registry) http.Handler { + r := chi.NewRouter() + r.Use(rbac.Middleware(reg)) + MountClusters(r, reg, home, standaloneTestNamespace) + return r +} + +func assertManagementCiphertext(t *testing.T, store *db.Store, name string, sensitive ...string) { + t.Helper() + var payload string + if err := store.DB.QueryRowContext(t.Context(), `SELECT payload FROM management_objects WHERE kind = 'secrets' AND namespace = ? AND name = ?`, standaloneTestNamespace, name).Scan(&payload); err != nil { + t.Fatal(err) + } + if json.Valid([]byte(payload)) { + t.Fatal("credential stored as plaintext JSON") + } + for _, value := range sensitive { + if strings.Contains(payload, value) || strings.Contains(payload, base64.StdEncoding.EncodeToString([]byte(value))) { + t.Fatal("credential content stored without encryption") + } + } +} + +func TestStandaloneClusterHTTPRegistrationPersistsEncryptedAndDeletesCleanly(t *testing.T) { + store, home, reg, keyPath := standaloneHandlerStore(t) + router := standaloneClustersRouter(home, reg) + empty := doClusters(t, router, http.MethodGet, "/clusters/", nil) + if empty.Code != http.StatusOK || strings.TrimSpace(empty.Body.String()) != `{"items":[]}` { + t.Fatalf("new standalone discovery: %d %s", empty.Code, empty.Body) + } + created := doClusters(t, router, http.MethodPost, "/clusters/", clusterCreateReq{Name: "remote", DisplayName: "Remote cluster", Kubeconfig: standaloneTestKubeconfig}) + if created.Code != http.StatusCreated { + t.Fatalf("create: %d %s", created.Code, created.Body) + } + if strings.Contains(created.Body.String(), "secret-standalone") || strings.Contains(created.Body.String(), "kubeconfig") { + t.Fatal("registration response exposed credentials") + } + assertManagementCiphertext(t, store, clusterKubeconfigSecretName("remote"), standaloneTestKubeconfig, "secret-standalone-registration-token") + first, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + home, err = controlplane.New(t.Context(), store, keyPath) + if err != nil { + t.Fatal(err) + } + reg = kube.NewRegistry("local") + reg.SetManagement(home) + router = standaloneClustersRouter(home, reg) + stored, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), clusterKubeconfigSecretName("remote"), metav1.GetOptions{}) + if err != nil || string(stored.Data["kubeconfig"]) != standaloneTestKubeconfig { + t.Fatalf("reopened credential: %v", err) + } + registration, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil || registration.GetUID() != first.GetUID() { + t.Fatalf("reopened registration identity: %v", err) + } + listed := doClusters(t, router, http.MethodGet, "/clusters/", nil) + if listed.Code != http.StatusOK { + t.Fatalf("list: %d %s", listed.Code, listed.Body) + } + var out clustersListResp + if err := json.Unmarshal(listed.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if len(out.Items) != 1 || out.Items[0].Name != "remote" || out.Items[0].DisplayName != "Remote cluster" { + t.Fatalf("unexpected discovery: %+v", out.Items) + } + for _, forbidden := range []string{"secret-standalone", "kubeconfig", "remote.example.invalid", `"name":"local"`} { + if strings.Contains(listed.Body.String(), forbidden) { + t.Fatalf("discovery leaked %q", forbidden) + } + } + if reg.Default() != nil || home.Typed != nil || home.Dynamic != nil { + t.Fatal("management backend became a workload cluster") + } + deleted := doClusters(t, router, http.MethodDelete, "/clusters/remote", nil) + if deleted.Code != http.StatusNoContent { + t.Fatalf("delete: %d %s", deleted.Code, deleted.Body) + } + if _, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("registration survived delete: %v", err) + } + if _, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), clusterKubeconfigSecretName("remote"), metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("kubeconfig survived delete: %v", err) + } +} + +func TestStandaloneRegistrationRejectsRemoteOnlyWorkloadPermissions(t *testing.T) { + for _, permission := range []string{"servers:write", "cluster:read"} { + t.Run(permission, func(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + router := standaloneClustersRouter(home, reg) + created := doClusters(t, router, http.MethodPost, "/clusters/", clusterCreateReq{Name: "remote", Kubeconfig: standaloneTestKubeconfig}) + if created.Code != http.StatusCreated { + t.Fatalf("fixture registration: %d %s", created.Code, created.Body) + } + user := &auth.User{Perms: map[string]map[string]map[string]struct{}{"remote": {"*": {permission: {}}}}} + for _, request := range []struct{ method, path string }{ + {http.MethodPost, "/clusters/?cluster=remote"}, + {http.MethodDelete, "/clusters/remote?cluster=remote"}, + {http.MethodPut, "/clusters/remote/kubeconfig?cluster=remote"}, + {http.MethodPut, "/clusters/remote/gateway?cluster=remote"}, + {http.MethodDelete, "/clusters/remote/gateway?cluster=remote"}, + } { + // Intentionally invalid mutation body: authorization must run first. + req := httptest.NewRequestWithContext(auth.WithUser(t.Context(), user), request.method, request.path, bytes.NewBufferString(`{}`)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("remote-only %s allowed %s %s: %d %s", permission, request.method, request.path, rr.Code, rr.Body) + } + } + if _, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}); err != nil { + t.Fatalf("denied mutation changed registration: %v", err) + } + }) + } +} + +type replaceCredentialBeforeDelete struct { + kube.SecretStore + replace func(context.Context, string) error +} + +func (s *replaceCredentialBeforeDelete) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + if err := s.replace(ctx, name); err != nil { + return err + } + return s.SecretStore.Delete(ctx, name, opts) +} + +func TestStandaloneCredentialCleanupDoesNotDeleteConcurrentReplacement(t *testing.T) { + for _, feature := range []string{clusterGatewayLabel, kube.ClusterKubeconfigLabel} { + t.Run(feature, func(t *testing.T) { + _, home, _, _ := standaloneHandlerStore(t) + name := gatewaySecretName("remote") + if feature == kube.ClusterKubeconfigLabel { + name = clusterKubeconfigSecretName("remote") + } + underlying := home.Secrets(standaloneTestNamespace) + _, err := underlying.Create(t.Context(), &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{feature: "true", ManagedByLabel: managedByValue}}}, metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + home.SecretStore = func(string) kube.SecretStore { + return &replaceCredentialBeforeDelete{SecretStore: underlying, replace: func(ctx context.Context, name string) error { + if err := underlying.Delete(ctx, name, metav1.DeleteOptions{}); err != nil { + return err + } + _, err := underlying.Create(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name}, StringData: map[string]string{"owner": "external-replacement"}}, metav1.CreateOptions{}) + return err + }} + } + if feature == kube.ClusterKubeconfigLabel { + err = deleteClusterKubeconfigSecret(t.Context(), home, standaloneTestNamespace, "remote", name) + } else { + err = deleteManagedSecret(t.Context(), home, standaloneTestNamespace, name, feature) + } + if !apierrors.IsConflict(err) { + t.Fatalf("cleanup did not reject replaced credential: %v", err) + } + replacement, err := underlying.Get(t.Context(), name, metav1.GetOptions{}) + if err != nil || string(replacement.Data["owner"]) != "external-replacement" { + t.Fatalf("replacement was deleted: %v", err) + } + }) + } +} diff --git a/api/internal/handlers/standalone_test.go b/api/internal/handlers/standalone_test.go new file mode 100644 index 000000000..e0b19b77b --- /dev/null +++ b/api/internal/handlers/standalone_test.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/kube" +) + +func TestStandaloneDiscoveryDoesNotInventLocalCluster(t *testing.T) { + home := fakeKubeClientWithClusters() + reg := kube.NewRegistry("local") + reg.SetManagement(home) + rr := doClusters(t, mountClustersRouter(home, reg), http.MethodGet, "/clusters/", nil) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body) + } + var out clustersListResp + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out.Items == nil || len(out.Items) != 0 { + t.Fatalf("expected empty array, got %+v", out.Items) + } +} + +func TestStandaloneFleetDiscoversOnlyManagementRegistrations(t *testing.T) { + home := fakeKubeClientWithClusters(newCluster("offline", nil, nil)) + reg := kube.NewRegistry("local") + reg.SetManagement(home) + h := fleetHandler{reg: reg} + items, issues := h.candidates(t.Context(), "") + if len(issues) != 0 || len(items) != 1 || items[0].id != "offline" { + t.Fatalf("items=%+v issues=%+v", items, issues) + } +} + +func TestStandaloneFleetEmptyIsComplete(t *testing.T) { + reg := kube.NewRegistry("local") + reg.SetManagement(fakeKubeClientWithClusters()) + h := fleetHandler{reg: reg} + items, issues := h.candidates(t.Context(), "") + if len(items) != 0 || len(issues) != 0 { + t.Fatalf("items=%+v issues=%+v", items, issues) + } +} diff --git a/api/internal/handlers/systemlogs.go b/api/internal/handlers/systemlogs.go index 46149e207..1f3cf3f72 100644 --- a/api/internal/handlers/systemlogs.go +++ b/api/internal/handlers/systemlogs.go @@ -22,6 +22,10 @@ import ( // MountSystemLogs registers the system logs API handlers. func MountSystemLogs(r chi.Router, k *kube.Client, namespace string) { r.Get("/admin/system-logs/{component}", func(w http.ResponseWriter, req *http.Request) { + if k == nil || k.Typed == nil { + httperr.WriteCode(w, req, http.StatusNotImplemented, errors.New("standalone panel logs are available from the container runtime")) + return + } component := chi.URLParam(req, "component") // Component must be exactly "api" or "operator" diff --git a/api/internal/kube/client.go b/api/internal/kube/client.go index c8dce7824..364f38fc9 100644 --- a/api/internal/kube/client.go +++ b/api/internal/kube/client.go @@ -19,6 +19,13 @@ type Client struct { Dynamic dynamic.Interface Typed kubernetes.Interface Config *rest.Config + // Management stores are set only for a standalone panel. Such a client + // deliberately has no Kubernetes transport and is never a workload target. + SecretStore func(string) SecretStore + ClusterStore ClusterStore + RegisterCluster ClusterRegistrar + RemoteAccess *RemoteAccessPolicy + kubeconfigDigest [32]byte } // GetServer fetches a GameServer by namespace and name. Returns nil if diff --git a/api/internal/kube/loader.go b/api/internal/kube/loader.go index e9af95503..dee4612a6 100644 --- a/api/internal/kube/loader.go +++ b/api/internal/kube/loader.go @@ -2,6 +2,7 @@ package kube import ( "context" + "crypto/sha256" "errors" "fmt" @@ -16,6 +17,21 @@ import ( // The label must have the value "true". const ClusterKubeconfigLabel = "gameplane.local/cluster-kubeconfig" +// A retrieval failure leaves the payload unknown; validation failures below +// establish that the stored credentials cannot be used. Only transient reads +// may retain a client with the same registration/credential reference. +type credentialReadError struct{ error } + +func (e *credentialReadError) Unwrap() error { return e.error } + +func transientCredentialRead(err error) bool { + var readErr *credentialReadError + var permanent interface{ PermanentCredentialFailure() bool } + return errors.As(err, &readErr) && + (!errors.As(err, &permanent) || !permanent.PermanentCredentialFailure()) && + !apierrors.IsNotFound(err) && !apierrors.IsForbidden(err) && !apierrors.IsUnauthorized(err) +} + // ConfigFromKubeconfig parses self-contained kubeconfig bytes and returns a rest.Config. func ConfigFromKubeconfig(data []byte) (*rest.Config, error) { cfg, err := remoteconfig.RESTConfig(data) @@ -32,9 +48,9 @@ func ClientFromSecret(ctx context.Context, home *Client, ns, name, key string) ( if key == "" { key = "kubeconfig" } - secret, err := home.Typed.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{}) + secret, err := home.Secrets(ns).Get(ctx, name, metav1.GetOptions{}) if err != nil { - return nil, fmt.Errorf("get secret: %w", err) + return nil, &credentialReadError{fmt.Errorf("get secret: %w", err)} } // Verify the secret has the required label to prevent loading arbitrary secrets. if secret.Labels == nil || secret.Labels[ClusterKubeconfigLabel] != "true" { @@ -44,7 +60,16 @@ func ClientFromSecret(ctx context.Context, home *Client, ns, name, key string) ( if !ok { return nil, fmt.Errorf("secret data missing key %q", key) } - cfg, err := ConfigFromKubeconfig(data) + var cfg *rest.Config + if home.IsStandalone() { + cfg, err = ValidateStandaloneKubeconfig(data) + if err == nil { + err = home.RemoteAccess.ValidateURL(cfg.Host) + ApplyStandaloneRemotePolicy(cfg, home.RemoteAccess) + } + } else { + cfg, err = ConfigFromKubeconfig(data) + } if err != nil { return nil, fmt.Errorf("load kubeconfig from secret: %w", err) } @@ -52,6 +77,7 @@ func ClientFromSecret(ctx context.Context, home *Client, ns, name, key string) ( if err != nil { return nil, fmt.Errorf("create client from config: %w", err) } + c.kubeconfigDigest = sha256.Sum256(data) return c, nil } diff --git a/api/internal/kube/management.go b/api/internal/kube/management.go new file mode 100644 index 000000000..a0215dcfe --- /dev/null +++ b/api/internal/kube/management.go @@ -0,0 +1,64 @@ +package kube + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// SecretStore is the management credential operations shared by Kubernetes +// and the standalone encrypted database backend. +type SecretStore interface { + Get(context.Context, string, metav1.GetOptions) (*corev1.Secret, error) + List(context.Context, metav1.ListOptions) (*corev1.SecretList, error) + Create(context.Context, *corev1.Secret, metav1.CreateOptions) (*corev1.Secret, error) + Update(context.Context, *corev1.Secret, metav1.UpdateOptions) (*corev1.Secret, error) + Delete(context.Context, string, metav1.DeleteOptions) error +} + +// ClusterStore persists remote cluster registrations independently of workload +// clients. The signatures also accept the Kubernetes dynamic resource client. +type ClusterStore interface { + Get(context.Context, string, metav1.GetOptions, ...string) (*unstructured.Unstructured, error) + List(context.Context, metav1.ListOptions) (*unstructured.UnstructuredList, error) + Create(context.Context, *unstructured.Unstructured, metav1.CreateOptions, ...string) (*unstructured.Unstructured, error) + Update(context.Context, *unstructured.Unstructured, metav1.UpdateOptions, ...string) (*unstructured.Unstructured, error) + Delete(context.Context, string, metav1.DeleteOptions, ...string) error +} + +// ClusterRegistrar commits a registration and its credential together. Only +// standalone management implements this; Kubernetes uses its native stores. +type ClusterRegistrar func(context.Context, string, *corev1.Secret, *unstructured.Unstructured) (*unstructured.Unstructured, error) + +// Secrets selects management credentials in a namespace. +func (c *Client) Secrets(ns string) SecretStore { + if c == nil { + return nil + } + if c.SecretStore != nil { + return c.SecretStore(ns) + } + if c.Typed == nil { + return nil + } + return c.Typed.CoreV1().Secrets(ns) +} + +// Clusters selects the registration store. +func (c *Client) Clusters() ClusterStore { + if c == nil { + return nil + } + if c.ClusterStore != nil { + return c.ClusterStore + } + if c.Dynamic == nil { + return nil + } + return c.Dynamic.Resource(GVRCluster) +} + +// IsStandalone reports whether management is independent of Kubernetes. +func (c *Client) IsStandalone() bool { return c != nil && c.ClusterStore != nil } diff --git a/api/internal/kube/registry.go b/api/internal/kube/registry.go index 04ac6721c..0abae57e4 100644 --- a/api/internal/kube/registry.go +++ b/api/internal/kube/registry.go @@ -16,18 +16,142 @@ import ( // is not usable; construct with NewRegistry. All methods are safe for // concurrent use. type Registry struct { - mu sync.RWMutex - clients map[string]*Client - uids map[string]types.UID - defaultID string + mu sync.RWMutex + clients map[string]*Client + uids map[string]types.UID + defaultID string + management *Client + operations map[string]*registrationSequence + identities map[string]credentialIdentity +} + +// Sequence credential loads per name, while the global lock protects only +// memory. Removal/publication invalidates active work using an epoch. Idle +// sequences are discarded so churn cannot retain one lock per historical name. +type registrationSequence struct { + ready chan struct{} + refs int + epoch uint64 +} + +type registryOperation struct { + registry *Registry + name string + sequence *registrationSequence + epoch uint64 +} + +type credentialIdentity struct { + uid types.UID + name, key string +} + +func registrationCredentials(u *unstructured.Unstructured) credentialIdentity { + name, _, _ := unstructured.NestedString(u.Object, "spec", "kubeconfigSecret", "name") + key, _, _ := unstructured.NestedString(u.Object, "spec", "kubeconfigSecret", "key") + if key == "" { + key = "kubeconfig" + } + return credentialIdentity{uid: u.GetUID(), name: name, key: key} +} + +func (r *Registry) beginOperation(ctx context.Context, name string) (*registryOperation, error) { + r.mu.Lock() + sequence := r.operations[name] + if sequence == nil { + sequence = ®istrationSequence{ready: make(chan struct{}, 1)} + sequence.ready <- struct{}{} + r.operations[name] = sequence + } + sequence.refs++ + r.mu.Unlock() + select { + case <-ctx.Done(): + r.releaseOperation(name, sequence) + return nil, ctx.Err() + case <-sequence.ready: + } + r.mu.RLock() + epoch := sequence.epoch + r.mu.RUnlock() + return ®istryOperation{registry: r, name: name, sequence: sequence, epoch: epoch}, nil +} + +func (r *Registry) releaseOperation(name string, sequence *registrationSequence) { + r.mu.Lock() + defer r.mu.Unlock() + sequence.refs-- + if sequence.refs == 0 { + delete(r.operations, name) + } +} + +func (op *registryOperation) finish() { + op.sequence.ready <- struct{}{} + op.registry.releaseOperation(op.name, op.sequence) +} + +func (r *Registry) invalidateLocked(name string) { + if sequence := r.operations[name]; sequence != nil { + sequence.epoch++ + } +} + +func (op *registryOperation) publish(identity credentialIdentity, client *Client) bool { + r := op.registry + r.mu.Lock() + defer r.mu.Unlock() + if op.sequence.epoch != op.epoch { + return false + } + if client == nil { + delete(r.clients, op.name) + delete(r.uids, op.name) + delete(r.identities, op.name) + } else { + r.clients[op.name] = client + r.uids[op.name] = identity.uid + r.identities[op.name] = identity + } + return true +} + +func (r *Registry) existingClient(name string, identity credentialIdentity) *Client { + r.mu.RLock() + defer r.mu.RUnlock() + if r.identities[name] == identity { + return r.clients[name] + } + return nil +} + +// SetManagement sets the panel's credential and registration backend. It does +// not register the panel host as a workload cluster. +func (r *Registry) SetManagement(c *Client) { + r.mu.Lock() + defer r.mu.Unlock() + r.management = c +} + +// Management returns the explicit backend, falling back to the default +// Kubernetes client for existing installations and callers. +func (r *Registry) Management() *Client { + r.mu.RLock() + defer r.mu.RUnlock() + if r.management != nil { + return r.management + } + return r.clients[r.defaultID] } // NewRegistry returns an empty Registry whose default cluster is defaultID. func NewRegistry(defaultID string) *Registry { return &Registry{ - clients: map[string]*Client{}, - uids: map[string]types.UID{}, - defaultID: defaultID, + clients: map[string]*Client{}, + uids: map[string]types.UID{}, + defaultID: defaultID, + operations: map[string]*registrationSequence{}, + identities: map[string]credentialIdentity{}, } } @@ -58,6 +182,8 @@ func (r *Registry) Set(id string, c *Client) { r.mu.Lock() defer r.mu.Unlock() r.clients[id] = c + r.invalidateLocked(id) + delete(r.identities, id) delete(r.uids, id) } @@ -67,6 +193,8 @@ func (r *Registry) Remove(id string) { defer r.mu.Unlock() delete(r.clients, id) delete(r.uids, id) + delete(r.identities, id) + r.invalidateLocked(id) } // SetWithUID registers (or replaces) the client for cluster id and stores its UID. @@ -75,6 +203,8 @@ func (r *Registry) SetWithUID(id string, uid types.UID, c *Client) { defer r.mu.Unlock() r.clients[id] = c r.uids[id] = uid + delete(r.identities, id) + r.invalidateLocked(id) } // RemoveIfUID removes the entry (and its UID) when uid is empty, when the stored @@ -86,6 +216,8 @@ func (r *Registry) RemoveIfUID(id string, uid types.UID) { // Remove if the argument uid is empty. if uid == "" { + r.invalidateLocked(id) + delete(r.identities, id) delete(r.clients, id) delete(r.uids, id) return @@ -94,6 +226,8 @@ func (r *Registry) RemoveIfUID(id string, uid types.UID) { // Remove if the stored uid is empty. storedUID, ok := r.uids[id] if !ok || storedUID == "" { + r.invalidateLocked(id) + delete(r.identities, id) delete(r.clients, id) delete(r.uids, id) return @@ -101,6 +235,8 @@ func (r *Registry) RemoveIfUID(id string, uid types.UID) { // Remove if the stored uid matches the argument. if storedUID == uid { + r.invalidateLocked(id) + delete(r.identities, id) delete(r.clients, id) delete(r.uids, id) return diff --git a/api/internal/kube/remote_access.go b/api/internal/kube/remote_access.go new file mode 100644 index 000000000..be9d9c0a2 --- /dev/null +++ b/api/internal/kube/remote_access.go @@ -0,0 +1,208 @@ +package kube + +import ( + "context" + "errors" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "time" + + "github.com/GameplanePanel/gameplane/netguard" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +// RemoteAccessPolicy restricts standalone workload connections. Operator CIDRs +// can narrow the safe destinations, but can never permit unsafe address classes. +// A nil policy applies the safe baseline and allows private workload networks. +type RemoteAccessPolicy struct { + allowed []netip.Prefix + resolve func(context.Context, string) ([]net.IPAddr, error) + dial func(context.Context, string, string) (net.Conn, error) +} + +// NewRemoteAccessPolicy parses an optional operator destination allowlist. +func NewRemoteAccessPolicy(allowedCIDRs []string) (*RemoteAccessPolicy, error) { + policy := &RemoteAccessPolicy{} + for _, raw := range allowedCIDRs { + prefix, err := netip.ParsePrefix(strings.TrimSpace(raw)) + if err != nil { + return nil, errors.New("invalid remote destination CIDR") + } + policy.allowed = append(policy.allowed, prefix.Masked()) + } + return policy, nil +} + +func normalizedRemoteIP(ip net.IP) net.IP { + if v4 := ip.To4(); v4 != nil { + return v4 + } + if len(ip) == net.IPv6len { + compatible := true + for _, b := range ip[:12] { + if b != 0 { + compatible = false + break + } + } + if compatible { + return ip[12:] + } + } + return ip +} + +func (p *RemoteAccessPolicy) allowsIP(ip net.IP) bool { + // IPv4-translated addresses are distinct from Go's IPv4-mapped form. + // Reject the whole ::ffff:0:0:0/96 translation range before IPv6 policy + // checks, including private addresses: a translator can route their payload. + if len(ip) == net.IPv6len && ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0 && ip[4] == 0 && ip[5] == 0 && ip[6] == 0 && ip[7] == 0 && ip[8] == 0xff && ip[9] == 0xff && ip[10] == 0 && ip[11] == 0 { + return false + } + if ip == nil || ip.IsLoopback() || !netguard.IsAllowed(ip) { + return false + } + ip = normalizedRemoteIP(ip) + if ip.IsLoopback() || !ip.IsGlobalUnicast() { + return false + } + if v4 := ip.To4(); v4 != nil && (v4[0] == 0 || v4[0] >= 240) { + return false + } + address, ok := netip.AddrFromSlice(ip) + if !ok { + return false + } + address = address.Unmap() + if p == nil || len(p.allowed) == 0 { + return true + } + for _, prefix := range p.allowed { + if prefix.Contains(address) { + return true + } + } + return false +} + +// DialContext resolves once, validates every returned address, and dials only a +// validated literal. There is no second DNS lookup between checking and dialing. +func (p *RemoteAccessPolicy) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, errors.New("invalid remote destination") + } + if netguard.HostIsMetadata(host) { + return nil, netguard.ErrBlockedAddr + } + var addresses []net.IPAddr + if ip := net.ParseIP(host); ip != nil { + addresses = []net.IPAddr{{IP: ip}} + } else { + resolve := net.DefaultResolver.LookupIPAddr + if p != nil && p.resolve != nil { + resolve = p.resolve + } + addresses, err = resolve(ctx, host) + if err != nil { + return nil, errors.New("remote destination lookup failed") + } + } + if len(addresses) == 0 { + return nil, netguard.ErrBlockedAddr + } + for _, address := range addresses { + if address.Zone != "" || !p.allowsIP(address.IP) { + return nil, netguard.ErrBlockedAddr + } + } + dial := (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext + if p != nil && p.dial != nil { + dial = p.dial + } + for _, address := range addresses { + conn, dialErr := dial(ctx, network, net.JoinHostPort(address.IP.String(), port)) + if dialErr == nil { + return conn, nil + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + } + return nil, errors.New("remote destination connection failed") +} + +// ValidateStandaloneKubeconfig rejects insecure or proxy-dependent entries before +// building a client. It returns a configuration with response and dial guards. +func ValidateStandaloneKubeconfig(data []byte) (*rest.Config, error) { + parsed, err := clientcmd.Load(data) + if err != nil { + return nil, errors.New("invalid remote kubeconfig") + } + for _, cluster := range parsed.Clusters { + if cluster == nil { + continue + } + if cluster.InsecureSkipTLSVerify { + return nil, errors.New("standalone kubeconfigs require verified TLS") + } + if cluster.ProxyURL != "" { + return nil, errors.New("standalone kubeconfigs cannot use a custom proxy") + } + if err := (*RemoteAccessPolicy)(nil).ValidateURL(cluster.Server); err != nil { + return nil, err + } + } + cfg, err := ConfigFromKubeconfig(data) + if err != nil { + return nil, err + } + ApplyStandaloneRemotePolicy(cfg, nil) + return cfg, nil +} + +// ValidateURL checks configured origins without a DNS preflight; DNS answers are +// checked when connecting. TLS hostname verification remains authoritative. +func (p *RemoteAccessPolicy) ValidateURL(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Opaque != "" || u.RawQuery != "" || u.Fragment != "" { + return errors.New("standalone remote endpoints require an HTTPS URL without credentials, query, or fragment") + } + if netguard.HostIsMetadata(u.Hostname()) { + return netguard.ErrBlockedAddr + } + if ip := net.ParseIP(u.Hostname()); ip != nil && !p.allowsIP(ip) { + return netguard.ErrBlockedAddr + } + return nil +} + +// ApplyStandaloneRemotePolicy installs operator restrictions on a config returned +// by ValidateStandaloneKubeconfig. It never honors environment forward proxies. +func ApplyStandaloneRemotePolicy(cfg *rest.Config, policy *RemoteAccessPolicy) { + cfg.Dial = policy.DialContext + cfg.Proxy = func(*http.Request) (*url.URL, error) { return nil, nil } + cfg.WrapTransport = standaloneRemoteTransport +} + +type standaloneTransport struct{ next http.RoundTripper } + +func standaloneRemoteTransport(next http.RoundTripper) http.RoundTripper { + return &standaloneTransport{next: boundedRemoteTransport(next)} +} + +func (t *standaloneTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.next.RoundTrip(req) + if err != nil { + return resp, err + } + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + _ = resp.Body.Close() + return nil, errors.New("remote Kubernetes redirects are unsupported") + } + return resp, nil +} diff --git a/api/internal/kube/remote_access_test.go b/api/internal/kube/remote_access_test.go new file mode 100644 index 000000000..f390a4e65 --- /dev/null +++ b/api/internal/kube/remote_access_test.go @@ -0,0 +1,162 @@ +package kube + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "testing" + + "github.com/GameplanePanel/gameplane/netguard" +) + +func standaloneConfigInput(server, fields string) []byte { + return []byte(fmt.Sprintf(`{"apiVersion":"v1","kind":"Config","clusters":[{"name":"remote","cluster":{"server":%q%s}}],"contexts":[{"name":"remote","context":{"cluster":"remote","user":"panel"}}],"current-context":"remote","users":[{"name":"panel","user":{"token":"private-token"}}]}`, server, fields)) +} + +func TestStandaloneKubeconfigRequiresVerifiedDirectHTTPS(t *testing.T) { + for _, tc := range []struct{ name, server, fields string }{ + {"plaintext", "http://10.0.0.1:6443", ""}, + {"unverified TLS", "https://10.0.0.1:6443", `,"insecure-skip-tls-verify":true`}, + {"custom proxy", "https://10.0.0.1:6443", `,"proxy-url":"http://proxy.example:8080"`}, + {"URL credentials", "https://private:token@cluster.example:6443", ""}, + {"metadata hostname", "https://metadata.google.internal", ""}, + {"loopback", "https://127.0.0.1:6443", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg, err := ValidateStandaloneKubeconfig(standaloneConfigInput(tc.server, tc.fields)) + if err == nil || cfg != nil { + t.Fatal("unsafe standalone kubeconfig was accepted") + } + if strings.Contains(err.Error(), "private-token") || strings.Contains(err.Error(), "private:token") { + t.Fatal("validation disclosed credentials") + } + }) + } + cfg, err := ValidateStandaloneKubeconfig(standaloneConfigInput("https://10.0.0.1:6443", "")) + if err != nil { + t.Fatal(err) + } + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888") + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, cfg.Host, nil) + if err != nil { + t.Fatal(err) + } + proxy, err := cfg.Proxy(req) + if err != nil || proxy != nil { + t.Fatal("standalone transport honored an environment proxy") + } +} + +func TestStandaloneKubeconfigChecksUnusedContexts(t *testing.T) { + data := standaloneConfigInput("https://10.0.0.1:6443", "") + // clientcmd preserves every named cluster; an unselected insecure entry must + // not be retained for later current-context changes. + data = []byte(strings.Replace(string(data), `"clusters":[`, `"clusters":[{"name":"unused","cluster":{"server":"https://10.0.0.2","insecure-skip-tls-verify":true}},`, 1)) + if cfg, err := ValidateStandaloneKubeconfig(data); err == nil || cfg != nil { + t.Fatal("unused insecure cluster was accepted") + } +} + +func TestRemoteAccessPolicyCannotAllowUnsafeClasses(t *testing.T) { + policy, err := NewRemoteAccessPolicy([]string{"0.0.0.0/0", "::/0"}) + if err != nil { + t.Fatal(err) + } + for _, raw := range []string{"127.0.0.1", "::1", "169.254.169.254", "fd00:ec2::254", "100.100.100.200", "0.0.0.0", "::", "224.0.0.1", "ff02::1", "64:ff9b::a9fe:a9fe", "2002:a9fe:a9fe::1", "::ffff:127.0.0.1", "::127.0.0.1", "0.1.2.3", "255.255.255.255", "::ffff:0:127.0.0.1", "::ffff:0:169.254.169.254", "::ffff:0:10.0.0.1"} { + t.Run(raw, func(t *testing.T) { + called := false + policy.dial = func(context.Context, string, string) (net.Conn, error) { + called = true + return nil, errors.New("unexpected dial") + } + _, err := policy.DialContext(t.Context(), "tcp", net.JoinHostPort(raw, "6443")) + if !errors.Is(err, netguard.ErrBlockedAddr) || called { + t.Fatalf("unsafe address reached dial: %v", err) + } + }) + } +} + +func TestRemoteAccessPolicyPermitsOnlySelectedSafeDestinations(t *testing.T) { + policy, err := NewRemoteAccessPolicy([]string{"10.0.0.0/8", "fd01::/64"}) + if err != nil { + t.Fatal(err) + } + policy.dial = func(context.Context, string, string) (net.Conn, error) { + a, b := net.Pipe() + _ = b.Close() + return a, nil + } + for _, raw := range []string{"10.2.3.4", "fd01::5"} { + conn, err := policy.DialContext(t.Context(), "tcp", net.JoinHostPort(raw, "6443")) + if err != nil { + t.Fatal(err) + } + if err := conn.Close(); err != nil { + t.Fatal(err) + } + } + if _, err := policy.DialContext(t.Context(), "tcp", "192.168.1.1:6443"); !errors.Is(err, netguard.ErrBlockedAddr) { + t.Fatal("destination outside operator CIDRs was allowed") + } + if _, err := NewRemoteAccessPolicy([]string{"typo"}); err == nil { + t.Fatal("invalid operator allowlist failed open") + } +} + +func TestRemoteAccessPolicyPinsValidatedDNSAndRejectsRebinding(t *testing.T) { + policy, err := NewRemoteAccessPolicy(nil) + if err != nil { + t.Fatal(err) + } + lookups, dials := 0, 0 + policy.resolve = func(context.Context, string) ([]net.IPAddr, error) { + lookups++ + raw := "10.0.0.8" + if lookups > 1 { + raw = "169.254.169.254" + } + return []net.IPAddr{{IP: net.ParseIP(raw)}}, nil + } + policy.dial = func(_ context.Context, _ string, address string) (net.Conn, error) { + dials++ + if address != "10.0.0.8:6443" { + t.Fatalf("dial performed another DNS resolution: %s", address) + } + a, b := net.Pipe() + _ = b.Close() + return a, nil + } + conn, err := policy.DialContext(t.Context(), "tcp", "cluster.example:6443") + if err != nil { + t.Fatal(err) + } + if err := conn.Close(); err != nil { + t.Fatal(err) + } + if _, err := policy.DialContext(t.Context(), "tcp", "cluster.example:6443"); !errors.Is(err, netguard.ErrBlockedAddr) || dials != 1 { + t.Fatal("rebound destination was dialed") + } +} + +func TestStandaloneTransportRejectsCredentialRedirects(t *testing.T) { + client := &http.Client{Transport: standaloneRemoteTransport(responseRoundTripper(func(_ *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusTemporaryRedirect, Header: http.Header{"Location": []string{"https://attacker.example/collect"}}, Body: io.NopCloser(strings.NewReader("redirect"))}, nil + }))} + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://cluster.example/version", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer private-token") + resp, err := client.Do(req) + if resp != nil { + _ = resp.Body.Close() + } + if err == nil || strings.Contains(err.Error(), "private-token") { + t.Fatal("credential redirect was followed or leaked its token") + } +} diff --git a/api/internal/kube/remote_response.go b/api/internal/kube/remote_response.go new file mode 100644 index 000000000..b5a32ea7b --- /dev/null +++ b/api/internal/kube/remote_response.go @@ -0,0 +1,203 @@ +package kube + +import ( + "compress/gzip" + "errors" + "io" + "net/http" + "strings" +) + +const ( + remoteResponseLimit = 8 << 20 + remoteVersionLimit = 64 << 10 + remoteWatchFrameLimit = 1 << 20 +) + +var errRemoteResponseTooLarge = errors.New("remote Kubernetes response exceeds the observation limit") + +type remoteResponseTransport struct{ next http.RoundTripper } + +func boundedRemoteTransport(next http.RoundTripper) http.RoundTripper { + return &remoteResponseTransport{next: next} +} + +func (t *remoteResponseTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.Query().Get("watch") == "true" { + // Select JSON framing consistently, including typed clients that otherwise + // prefer protobuf. Kubernetes supports JSON for every watched resource. + req = req.Clone(req.Context()) + req.Header.Set("Accept", "application/json") + } + resp, err := t.next.RoundTrip(req) + if err != nil || resp.Body == nil { + return resp, err + } + subresource := kubernetesPodStream(req.URL.Path) + // SPDY adds the Upgrade header inside its own cloned request. Its explicit + // stream negotiation header is visible to the surrounding REST wrappers. + requestedUpgrade := req.Header.Get("Upgrade") != "" || + (subresource != "" && subresource != "log" && req.Header.Get("X-Stream-Protocol-Version") != "") + if resp.StatusCode == http.StatusSwitchingProtocols && !requestedUpgrade { + // Client-go buffers responses before checking their status. Never let a + // remote turn an ordinary observation into an unbounded raw connection. + _ = resp.Body.Close() + return nil, errors.New("unsolicited remote protocol upgrade") + } + // User log/exec/attach streams are intentionally not finite observations. + // A watch is different: each event needs a bound, while its lifetime does not. + if resp.StatusCode == http.StatusSwitchingProtocols || + (subresource == "log" && resp.StatusCode >= 200 && resp.StatusCode < 300) { + return resp, nil + } + // These responses have no representation, regardless of encoding headers. + if req.Method == http.MethodHead || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusNotModified || resp.StatusCode < 200 { + return resp, nil + } + body := resp.Body + if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") { + body = &decodedRemoteBody{compressed: resp.Body} + resp.Header.Del("Content-Encoding") + resp.Header.Del("Content-Length") + resp.ContentLength = -1 + resp.Uncompressed = true + } + if req.URL.Query().Get("watch") == "true" && resp.StatusCode >= 200 && resp.StatusCode < 300 { + resp.Body = &remoteWatchBody{ReadCloser: body} + } else { + limit := int64(remoteResponseLimit) + if strings.HasSuffix(strings.TrimSuffix(req.URL.Path, "/"), "/version") { + limit = remoteVersionLimit + } + resp.Body = &finiteRemoteBody{ReadCloser: body, remaining: limit} + } + return resp, nil +} + +func kubernetesPodStream(path string) string { + // Match core Pod subresources, preserving any kubeconfig API-server prefix. + // A CR or other resource named "log" must remain a bounded observation. + parts := strings.Split(strings.TrimPrefix(path, "/"), "/") + if len(parts) < 7 { + return "" + } + parts = parts[len(parts)-7:] + if parts[0] != "api" || parts[1] != "v1" || parts[2] != "namespaces" || parts[3] == "" || parts[4] != "pods" || parts[5] == "" { + return "" + } + switch parts[6] { + case "log", "exec", "attach", "portforward": + return parts[6] + default: + return "" + } +} + +type decodedRemoteBody struct { + reader *gzip.Reader + compressed io.ReadCloser + failed bool +} + +func (r *decodedRemoteBody) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if r.failed { + return 0, errors.New("invalid compressed remote response") + } + if r.reader == nil { + reader, err := gzip.NewReader(r.compressed) + if err != nil { + r.failed = true + return 0, errors.New("invalid compressed remote response") + } + r.reader = reader + } + return r.reader.Read(p) +} + +func (r *decodedRemoteBody) Close() error { + if r.reader != nil { + return errors.Join(r.reader.Close(), r.compressed.Close()) + } + return r.compressed.Close() +} + +type finiteRemoteBody struct { + io.ReadCloser + remaining int64 + failed bool +} + +func (r *finiteRemoteBody) Read(p []byte) (int, error) { + if r.failed { + return 0, errRemoteResponseTooLarge + } + if len(p) == 0 { + return 0, nil + } + if int64(len(p)) > r.remaining+1 { + p = p[:r.remaining+1] + } + n, err := r.ReadCloser.Read(p) + if int64(n) > r.remaining { + n = int(r.remaining) + r.remaining = 0 + r.failed = true + return n, errRemoteResponseTooLarge + } + r.remaining -= int64(n) + return n, err +} + +// remoteWatchBody recognizes JSON object boundaries without decoding or retaining +// a frame. This runs before client-go's JSON framer, which otherwise allocates the +// entire RawMessage before applying its own size limit. +type remoteWatchBody struct { + io.ReadCloser + size, depth int + inString, escaped, failed bool +} + +func (r *remoteWatchBody) Read(p []byte) (int, error) { + if r.failed { + return 0, errRemoteResponseTooLarge + } + if len(p) == 0 { + return 0, nil + } + if remaining := remoteWatchFrameLimit - r.size + 1; len(p) > remaining { + p = p[:remaining] + } + n, err := r.ReadCloser.Read(p) + for i, b := range p[:n] { + r.size++ + if r.size > remoteWatchFrameLimit { + r.failed = true + return i, errRemoteResponseTooLarge + } + if r.inString { + if r.escaped { + r.escaped = false + } else if b == '\\' { + r.escaped = true + } else if b == '"' { + r.inString = false + } + continue + } + switch b { + case '"': + r.inString = true + case '{', '[': + r.depth++ + case '}', ']': + r.depth-- + if r.depth == 0 { + r.size = 0 + } + } + } + return n, err +} diff --git a/api/internal/kube/remote_response_test.go b/api/internal/kube/remote_response_test.go new file mode 100644 index 000000000..5e3126b51 --- /dev/null +++ b/api/internal/kube/remote_response_test.go @@ -0,0 +1,311 @@ +package kube + +import ( + "bytes" + "compress/gzip" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type responseRoundTripper func(*http.Request) (*http.Response, error) + +func (f responseRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +type countedResponse struct { + io.Reader + read int + closed bool +} + +func (r *countedResponse) Read(p []byte) (int, error) { + n, err := r.Reader.Read(p) + r.read += n + return n, err +} + +func (r *countedResponse) Close() error { r.closed = true; return nil } + +func TestRemoteResponseBoundsVersionBeforeBuffering(t *testing.T) { + for _, compressed := range []bool{false, true} { + t.Run(map[bool]string{false: "chunked", true: "gzip"}[compressed], func(t *testing.T) { + payload := []byte(strings.Repeat("x", 128*1024)) + header := http.Header{} + if compressed { + var encoded bytes.Buffer + writer := gzip.NewWriter(&encoded) + if _, err := writer.Write(payload); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + payload = encoded.Bytes() + header.Set("Content-Encoding", "gzip") + } + body := &countedResponse{Reader: bytes.NewReader(payload)} + transport := boundedRemoteTransport(responseRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: header, Body: body, ContentLength: -1}, nil + })) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://cluster.example/version", nil) + if err != nil { + t.Fatal(err) + } + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(resp.Body) + if !errors.Is(err, errRemoteResponseTooLarge) || len(data) > 64*1024 { + t.Fatalf("oversize version was buffered: bytes=%d error=%v", len(data), err) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + if !body.closed { + t.Fatal("remote response was not closed") + } + if !compressed && body.read > 64*1024+1 { + t.Fatalf("read %d bytes before rejecting", body.read) + } + }) + } +} + +func TestRemoteResponseBoundsPrefixedVersion(t *testing.T) { + transport := boundedRemoteTransport(responseRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(strings.Repeat("x", remoteVersionLimit+1)))}, nil + })) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://cluster.example/kubernetes/workload/version", nil) + if err != nil { + t.Fatal(err) + } + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if !errors.Is(err, errRemoteResponseTooLarge) || len(data) > remoteVersionLimit { + t.Fatalf("prefixed version exceeded bound: %d %v", len(data), err) + } +} + +func TestRemoteResponseAcceptsBodylessGzip(t *testing.T) { + for _, tc := range []struct { + method string + status int + }{{http.MethodDelete, 204}, {http.MethodGet, 304}, {http.MethodHead, 200}} { + body := &countedResponse{Reader: strings.NewReader("")} + transport := boundedRemoteTransport(responseRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: tc.status, Header: http.Header{"Content-Encoding": {"gzip"}}, Body: body}, nil + })) + req, err := http.NewRequestWithContext(t.Context(), tc.method, "https://cluster.example/api/v1/pods", nil) + if err != nil { + t.Fatal(err) + } + resp, err := transport.RoundTrip(req) + if err != nil || resp == nil || body.read != 0 { + t.Fatalf("bodyless response rejected/read: %v %d", err, body.read) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + } +} + +func TestRemoteResponseWatchBoundsIndividualEvents(t *testing.T) { + for _, tc := range []struct { + name, payload string + wantError bool + }{ + {"multiple bounded frames", strings.Repeat(`{"type":"ADDED","object":{"padding":"`+strings.Repeat("x", 600*1024)+`"}}`+"\n", 3), false}, + {"oversize frame", `{"type":"ADDED","object":{"padding":"` + strings.Repeat("x", 2*1024*1024) + `"}}`, true}, + {"escaped braces", `{"object":{"padding":"` + strings.Repeat(`\"{}\\`, 1024) + `"}}` + "\n", false}, + } { + t.Run(tc.name, func(t *testing.T) { + body := &countedResponse{Reader: strings.NewReader(tc.payload)} + transport := boundedRemoteTransport(responseRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: body}, nil + })) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://cluster.example/apis/gameplane.local/v1alpha1/gameservers?watch=true", nil) + if err != nil { + t.Fatal(err) + } + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + _, err = io.Copy(io.Discard, resp.Body) + if errors.Is(err, errRemoteResponseTooLarge) != tc.wantError { + t.Fatalf("watch result: %v", err) + } + if tc.wantError && body.read > 1024*1024+1 { + t.Fatalf("oversize event read %d bytes", body.read) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestRemoteResponseLeavesUserLogStreamsUnbounded(t *testing.T) { + payload := strings.Repeat("log line\n", 1024*1024) + transport := boundedRemoteTransport(responseRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(payload))}, nil + })) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://cluster.example/api/v1/namespaces/games/pods/game/log?follow=true", nil) + if err != nil { + t.Fatal(err) + } + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + n, err := io.Copy(io.Discard, resp.Body) + if err != nil || n != int64(len(payload)) { + t.Fatalf("log stream was truncated: bytes=%d error=%v", n, err) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } +} + +func TestRemoteResponseRejectsUnsolicitedUpgradeWithoutReading(t *testing.T) { + for _, path := range []string{"/version", "/api/v1/namespaces/games/pods/game/log"} { + t.Run(path, func(t *testing.T) { + body := &countedResponse{Reader: strings.NewReader(strings.Repeat("x", 128*1024))} + transport := boundedRemoteTransport(responseRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusSwitchingProtocols, Header: http.Header{"Connection": {"Upgrade"}, "Upgrade": {"h2c"}}, Body: body}, nil + })) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://cluster.example"+path, nil) + if err != nil { + t.Fatal(err) + } + resp, err := transport.RoundTrip(req) + if err == nil || resp != nil || body.read != 0 || !body.closed { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + t.Fatalf("unsolicited upgrade was not closed before buffering: response=%v error=%v read=%d closed=%v", resp, err, body.read, body.closed) + } + }) + } +} + +func TestRemoteResponsePreservesRequestedUpgrade(t *testing.T) { + body := &countedResponse{Reader: strings.NewReader("upgraded stream")} + transport := boundedRemoteTransport(responseRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusSwitchingProtocols, Header: http.Header{}, Body: body}, nil + })) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://cluster.example/api/v1/namespaces/games/pods/game/exec", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "SPDY/3.1") + resp, err := transport.RoundTrip(req) + if err != nil || resp == nil || resp.Body != body || body.closed { + t.Fatalf("requested stream upgrade was altered: response=%v error=%v closed=%v", resp, err, body.closed) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } +} + +func TestRemoteResponsePreservesSPDYNegotiatedUpgrade(t *testing.T) { + body := &countedResponse{Reader: strings.NewReader("upgraded stream")} + transport := boundedRemoteTransport(responseRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusSwitchingProtocols, Header: http.Header{}, Body: body}, nil + })) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://cluster.example/api/v1/namespaces/games/pods/game/attach", nil) + if err != nil { + t.Fatal(err) + } + // client-go adds Upgrade to a cloned request inside its SPDY round tripper; + // the outer wrappers receive only the explicit stream negotiation header. + req.Header.Add("X-Stream-Protocol-Version", "v4.channel.k8s.io") + resp, err := transport.RoundTrip(req) + if err != nil || resp == nil || resp.Body != body || body.closed { + t.Fatalf("SPDY negotiation was rejected: response=%v error=%v closed=%v", resp, err, body.closed) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } +} + +func TestRemoteResponseBoundsPreUpgradeErrors(t *testing.T) { + for _, subresource := range []string{"exec", "attach", "portforward"} { + for _, status := range []int{http.StatusOK, http.StatusBadRequest} { + t.Run(subresource+"/"+http.StatusText(status), func(t *testing.T) { + body := &countedResponse{Reader: strings.NewReader(strings.Repeat("x", remoteResponseLimit+1024))} + transport := boundedRemoteTransport(responseRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: status, Header: http.Header{}, Body: body}, nil + })) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://cluster.example/api/v1/namespaces/games/pods/game/"+subresource, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Upgrade", "SPDY/3.1") + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + _, err = io.Copy(io.Discard, resp.Body) + if !errors.Is(err, errRemoteResponseTooLarge) || body.read > remoteResponseLimit+1 { + t.Fatalf("pre-upgrade error bypassed observation bound: read=%d error=%v", body.read, err) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + }) + } + } +} + +func TestRemoteResponseBoundsResourcesNamedLikeStreams(t *testing.T) { + for _, name := range []string{"log", "exec", "attach", "portforward"} { + t.Run(name, func(t *testing.T) { + body := &countedResponse{Reader: strings.NewReader(strings.Repeat("x", remoteResponseLimit+1024))} + transport := boundedRemoteTransport(responseRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: body}, nil + })) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://cluster.example/apis/gameplane.local/v1alpha1/namespaces/games/gametemplates/"+name, nil) + if err != nil { + t.Fatal(err) + } + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(resp.Body) + if !errors.Is(err, errRemoteResponseTooLarge) || len(data) > remoteResponseLimit || body.read > remoteResponseLimit+1 { + t.Fatalf("resource bypassed observation bound: bytes=%d read=%d error=%v", len(data), body.read, err) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestRemoteResponsePreservesPrefixedPodLogStream(t *testing.T) { + body := &countedResponse{Reader: strings.NewReader("stream")} + transport := boundedRemoteTransport(responseRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: body}, nil + })) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://cluster.example/kubernetes/workload/api/v1/namespaces/games/pods/game/log?follow=true", nil) + if err != nil { + t.Fatal(err) + } + resp, err := transport.RoundTrip(req) + if err != nil || resp == nil || resp.Body != body { + t.Fatalf("prefixed pod log stream was altered: response=%v error=%v", resp, err) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/api/internal/kube/remote_spdy.go b/api/internal/kube/remote_spdy.go new file mode 100644 index 000000000..177dfce8e --- /dev/null +++ b/api/internal/kube/remote_spdy.go @@ -0,0 +1,160 @@ +package kube + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "net/url" + "sync/atomic" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" + clientspdy "k8s.io/client-go/transport/spdy" + "k8s.io/streaming/pkg/httpstream" + httpstreamspdy "k8s.io/streaming/pkg/httpstream/spdy" +) + +// NewSPDYExecutor preserves the configured dial guard for pod streams. The +// client-go default SPDY factory ignores rest.Config.Dial, so standalone clients +// need an explicit upgrade transport. Unrestricted combined clients keep their +// existing transport construction. +func (c *Client) NewSPDYExecutor(method string, target *url.URL) (remotecommand.Executor, error) { + if c.Config.Dial == nil { + return remotecommand.NewSPDYExecutor(c.Config, method, target) + } + tlsConfig, err := rest.TLSConfigFor(c.Config) + if err != nil { + return nil, fmt.Errorf("configure guarded stream TLS: %w", err) + } + if tlsConfig == nil { + // REST uses nil for system trust roots, but SPDY's custom-dial path + // interprets nil as InsecureSkipVerify. Preserve system roots and + // inferred hostname verification with an explicit verified default. + tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + // The SPDY implementation unwraps this transport for both TLS and dialing; + // supply the raw HTTP transport so no wrapper can hide its guarded dialer. + var connection atomic.Pointer[streamHandshakeConn] + upgrade, err := httpstreamspdy.NewRoundTripperWithConfig(httpstreamspdy.RoundTripperConfig{ + UpgradeTransport: &http.Transport{ + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + conn, err := c.Config.Dial(ctx, network, address) + if err != nil { + return nil, err + } + if err := conn.SetDeadline(time.Now().Add(remoteStreamHandshakeTimeout)); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("set remote stream handshake deadline: %w", err) + } + bounded := &streamHandshakeConn{Conn: conn, remaining: remoteStreamHandshakeLimit} + bounded.limited.Store(true) + // The upstream manual HTTP header reader does not cancel reads + // itself; close the connection when the operation context ends. + bounded.stopCancel = context.AfterFunc(ctx, func() { _ = conn.Close() }) + connection.Store(bounded) + return bounded, nil + }, + TLSClientConfig: tlsConfig, + Proxy: c.Config.Proxy, + }, + PingPeriod: 5 * time.Second, + }) + if err != nil { + return nil, fmt.Errorf("configure guarded stream transport: %w", err) + } + guarded := &guardedSPDYTransport{UpgradeRoundTripper: upgrade, connection: &connection} + wrapped, err := rest.HTTPWrappersForConfig(c.Config, guarded) + if err != nil { + return nil, fmt.Errorf("configure guarded stream authentication: %w", err) + } + return remotecommand.NewSPDYExecutorRejectRedirects(wrapped, clientspdy.NewUpgraderForStreaming(guarded), method, target) +} + +// Include TLS records in the initial budget: the upstream SPDY reader parses +// HTTP headers directly from its connection without a header-size limit. This +// bound is lifted once headers parse, leaving real interactive streams unlimited. +const remoteStreamHandshakeLimit = 1 << 20 +const remoteStreamHandshakeTimeout = 10 * time.Second + +type streamHandshakeConn struct { + net.Conn + remaining int64 + limited atomic.Bool + stopCancel func() bool +} + +func (c *streamHandshakeConn) Read(p []byte) (int, error) { + if !c.limited.Load() || len(p) == 0 { + return c.Conn.Read(p) + } + if int64(len(p)) > c.remaining+1 { + p = p[:c.remaining+1] + } + n, err := c.Conn.Read(p) + if int64(n) > c.remaining { + _ = c.Close() + return int(c.remaining), errRemoteResponseTooLarge + } + c.remaining -= int64(n) + return n, err +} + +func (c *streamHandshakeConn) Close() error { + if c.stopCancel != nil { + c.stopCancel() + } + return c.Conn.Close() +} + +type guardedSPDYTransport struct { + httpstream.UpgradeRoundTripper + connection *atomic.Pointer[streamHandshakeConn] +} + +func (t *guardedSPDYTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.UpgradeRoundTripper.RoundTrip(req) + if conn := t.connection.Load(); conn != nil { + if err != nil { + _ = conn.Close() + } else { + if err := conn.SetDeadline(time.Time{}); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("clear remote stream handshake deadline: %w", err) + } + conn.limited.Store(false) + } + } + return resp, err +} + +func (t *guardedSPDYTransport) NewConnection(resp *http.Response) (httpstream.Connection, error) { + connection, err := t.UpgradeRoundTripper.NewConnection(resp) + if err != nil { + return nil, err + } + return &boundedSPDYConnection{Connection: connection}, nil +} + +type boundedSPDYConnection struct{ httpstream.Connection } + +func (c *boundedSPDYConnection) CreateStream(headers http.Header) (httpstream.Stream, error) { + stream, err := c.Connection.CreateStream(headers) + if err != nil { + return nil, err + } + if headers.Get(corev1.StreamType) == corev1.StreamTypeError { + return &boundedSPDYControlStream{Stream: stream, body: finiteRemoteBody{ReadCloser: stream, remaining: remoteVersionLimit}}, nil + } + return stream, nil +} + +type boundedSPDYControlStream struct { + httpstream.Stream + body finiteRemoteBody +} + +func (s *boundedSPDYControlStream) Read(p []byte) (int, error) { return s.body.Read(p) } diff --git a/api/internal/kube/remote_spdy_test.go b/api/internal/kube/remote_spdy_test.go new file mode 100644 index 000000000..f8ca005e2 --- /dev/null +++ b/api/internal/kube/remote_spdy_test.go @@ -0,0 +1,310 @@ +package kube + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/tools/remotecommand" + "k8s.io/streaming/pkg/httpstream" + httpstreamspdy "k8s.io/streaming/pkg/httpstream/spdy" + + "github.com/GameplanePanel/gameplane/netguard" +) + +func standaloneSPDYFixture(t *testing.T, handler http.Handler) (*Client, *RemoteAccessPolicy, *url.URL) { + t.Helper() + server := httptest.NewTLSServer(handler) + t.Cleanup(server.Close) + _, port, err := net.SplitHostPort(server.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + ca := base64.StdEncoding.EncodeToString(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})) + cfg, err := ValidateStandaloneKubeconfig(standaloneConfigInput("https://cluster.example:"+port, fmt.Sprintf(`,"certificate-authority-data":%q,"tls-server-name":"127.0.0.1"`, ca))) + if err != nil { + t.Fatal(err) + } + policy, err := NewRemoteAccessPolicy([]string{"10.0.0.0/8"}) + if err != nil { + t.Fatal(err) + } + policy.resolve = func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("10.0.0.1")}}, nil + } + policy.dial = func(ctx context.Context, network, address string) (net.Conn, error) { + if address != net.JoinHostPort("10.0.0.1", port) { + return nil, errors.New("unexpected validated destination") + } + return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String()) + } + ApplyStandaloneRemotePolicy(cfg, policy) + client, err := New(cfg) + if err != nil { + t.Fatal(err) + } + target, err := url.Parse(cfg.Host + "/api/v1/namespaces/games/pods/game/attach?stdout=true") + if err != nil { + t.Fatal(err) + } + return client, policy, target +} + +func TestStandaloneSPDYExecutorRejectsDisallowedDNSBeforeDial(t *testing.T) { + for _, address := range []string{"127.0.0.1", "169.254.169.254", "192.168.1.1"} { + t.Run(address, func(t *testing.T) { + client, policy, target := standaloneSPDYFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unexpected connection", http.StatusInternalServerError) + })) + policy.resolve = func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP(address)}}, nil + } + var dials atomic.Int32 + policy.dial = func(context.Context, string, string) (net.Conn, error) { + dials.Add(1) + return nil, errors.New("unexpected dial") + } + executor, err := client.NewSPDYExecutor(http.MethodPost, target) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{Stdout: io.Discard}) + // client-go SPDY negotiation stringifies transport errors with %v. + // Verify its blocked-destination cause and that no dial occurred. + if err == nil || !strings.HasSuffix(err.Error(), netguard.ErrBlockedAddr.Error()) || dials.Load() != 0 { + t.Fatalf("SPDY bypassed destination guard: error=%v dials=%d", err, dials.Load()) + } + }) + } +} + +func TestStandaloneSPDYExecutorTrustedUpgradeUsesPolicyAndTLS(t *testing.T) { + var observed atomic.Bool + var deadlineArmed, deadlineCleared atomic.Bool + payload := strings.Repeat("trusted output", 100*1024) + client, policy, target := standaloneSPDYFixture(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Header.Get("Authorization") != "Bearer private-token" || req.Header.Get("Upgrade") != "SPDY/3.1" { + http.Error(w, "missing authenticated upgrade", http.StatusBadRequest) + return + } + observed.Store(true) + w.Header().Set(httpstream.HeaderProtocolVersion, "v4.channel.k8s.io") + connection := httpstreamspdy.NewResponseUpgrader().UpgradeResponse(w, req, func(stream httpstream.Stream, replySent <-chan struct{}) error { + go func() { + <-replySent + if stream.Headers().Get(corev1.StreamType) == corev1.StreamTypeStdout { + _, _ = io.WriteString(stream, payload) + } + _ = stream.Close() + }() + return nil + }) + if connection != nil { + defer connection.Close() + <-connection.CloseChan() + } + })) + dial := policy.dial + policy.dial = func(ctx context.Context, network, address string) (net.Conn, error) { + conn, err := dial(ctx, network, address) + if err != nil { + return nil, err + } + return &observedDeadlineConn{Conn: conn, armed: &deadlineArmed, cleared: &deadlineCleared}, nil + } + executor, err := client.NewSPDYExecutor(http.MethodPost, target) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + var output bytes.Buffer + err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{Stdout: &output}) + if err != nil || !observed.Load() || output.String() != payload { + t.Fatalf("trusted SPDY upgrade failed: error=%v observed=%v output bytes=%d", err, observed.Load(), output.Len()) + } + if !deadlineArmed.Load() || !deadlineCleared.Load() { + t.Fatal("upgrade did not arm and clear its independent handshake deadline") + } +} + +type observedDeadlineConn struct { + net.Conn + armed, cleared *atomic.Bool +} + +func (c *observedDeadlineConn) SetDeadline(deadline time.Time) error { + if deadline.IsZero() { + c.cleared.Store(true) + } else { + c.armed.Store(true) + } + return c.Conn.SetDeadline(deadline) +} + +func TestStandaloneSPDYExecutorSystemRootsRejectUntrustedTLSBeforeAuthentication(t *testing.T) { + var observed atomic.Bool + client, policy, target := standaloneSPDYFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + observed.Store(true) + http.Error(w, "unexpected authenticated request", http.StatusBadRequest) + })) + // A kubeconfig without custom TLS fields uses system trust roots. In this + // case rest.TLSConfigFor returns nil, which SPDY must not treat as insecure. + client.Config.CAData = nil + client.Config.ServerName = "" + var dials atomic.Int32 + dial := policy.dial + policy.dial = func(ctx context.Context, network, address string) (net.Conn, error) { + dials.Add(1) + return dial(ctx, network, address) + } + executor, err := client.NewSPDYExecutor(http.MethodPost, target) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{Stdout: io.Discard}) + if err == nil || !strings.Contains(err.Error(), "certificate") || dials.Load() != 1 || observed.Load() { + t.Fatalf("system-root SPDY request bypassed TLS verification: error=%v dials=%d handler reached=%v", err, dials.Load(), observed.Load()) + } +} + +func TestStandaloneSPDYExecutorRejectsDNSRebindingAfterRESTRequest(t *testing.T) { + client, policy, target := standaloneSPDYFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{}`) + })) + var resolutions atomic.Int32 + policy.resolve = func(context.Context, string) ([]net.IPAddr, error) { + address := "10.0.0.1" + if resolutions.Add(1) > 1 { + address = "127.0.0.1" + } + return []net.IPAddr{{IP: net.ParseIP(address)}}, nil + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + if _, err := client.Typed.Discovery().RESTClient().Get().AbsPath("/version").Do(ctx).Raw(); err != nil { + t.Fatal(err) + } + executor, err := client.NewSPDYExecutor(http.MethodPost, target) + if err != nil { + t.Fatal(err) + } + err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{Stdout: io.Discard}) + if err == nil || !strings.HasSuffix(err.Error(), netguard.ErrBlockedAddr.Error()) || resolutions.Load() != 2 { + t.Fatalf("SPDY reused unvalidated rebound DNS: error=%v resolutions=%d", err, resolutions.Load()) + } +} + +func TestStandaloneSPDYExecutorBoundsPreUpgradeErrors(t *testing.T) { + client, _, target := standaloneSPDYFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, strings.Repeat("x", remoteResponseLimit+1024)) + })) + executor, err := client.NewSPDYExecutor(http.MethodPost, target) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{Stdout: io.Discard}) + if err == nil || !strings.Contains(err.Error(), "unable to read error") || len(err.Error()) > 1024 { + t.Fatalf("SPDY buffered or reflected oversized error: length=%d", len(fmt.Sprint(err))) + } +} + +func TestStandaloneSPDYExecutorBoundsUpgradeHeaders(t *testing.T) { + client, _, target := standaloneSPDYFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hijacker, ok := w.(http.Hijacker) + if !ok { + return + } + connection, buffered, err := hijacker.Hijack() + if err != nil { + return + } + defer connection.Close() + _, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\nX-Padding: " + strings.Repeat("x", remoteResponseLimit) + "\r\n\r\n") + _ = buffered.Flush() + })) + executor, err := client.NewSPDYExecutor(http.MethodPost, target) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{Stdout: io.Discard}) + if err == nil || !strings.Contains(err.Error(), "observation limit") || len(err.Error()) > 1024 { + t.Fatalf("SPDY accepted or reflected oversized headers: error length=%d", len(fmt.Sprint(err))) + } +} + +func TestStandaloneSPDYExecutorTimesOutStalledHandshake(t *testing.T) { + for _, stage := range []string{"TLS", "HTTP headers"} { + t.Run(stage, func(t *testing.T) { + client, policy, target := standaloneSPDYFixture(t, http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) { <-req.Context().Done() })) + if stage == "TLS" { + policy.dial = func(context.Context, string, string) (net.Conn, error) { + a, b := net.Pipe() + go func() { defer b.Close(); _, _ = io.Copy(io.Discard, b) }() + return a, nil + } + } + executor, err := client.NewSPDYExecutor(http.MethodPost, target) + if err != nil { + t.Fatal(err) + } + started := time.Now() + err = executor.StreamWithContext(t.Context(), remotecommand.StreamOptions{Stdout: io.Discard}) + if err == nil || time.Since(started) > 12*time.Second { + t.Fatalf("stalled %s handshake was not bounded: %v", stage, err) + } + }) + } +} + +func TestStandaloneSPDYExecutorBoundsUpgradedErrorStream(t *testing.T) { + client, _, target := standaloneSPDYFixture(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set(httpstream.HeaderProtocolVersion, "v4.channel.k8s.io") + connection := httpstreamspdy.NewResponseUpgrader().UpgradeResponse(w, req, func(stream httpstream.Stream, replySent <-chan struct{}) error { + go func() { + <-replySent + if stream.Headers().Get(corev1.StreamType) == corev1.StreamTypeError { + _, _ = io.WriteString(stream, strings.Repeat("x", remoteVersionLimit+1024)) + } + _ = stream.Close() + }() + return nil + }) + if connection != nil { + defer connection.Close() + <-connection.CloseChan() + } + })) + executor, err := client.NewSPDYExecutor(http.MethodPost, target) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{Stdout: io.Discard}) + if !errors.Is(err, errRemoteResponseTooLarge) || len(err.Error()) > 1024 { + t.Fatalf("SPDY accepted or reflected oversized control stream: error length=%d", len(fmt.Sprint(err))) + } +} diff --git a/api/internal/kube/stdin.go b/api/internal/kube/stdin.go index a391dd2b0..9573be857 100644 --- a/api/internal/kube/stdin.go +++ b/api/internal/kube/stdin.go @@ -52,7 +52,7 @@ func (c *Client) WriteStdinLines(ctx context.Context, ns, pod, container string, }, scheme.ParameterCodec). URL() - exec, err := remotecommand.NewSPDYExecutor(c.Config, "POST", url) + exec, err := c.NewSPDYExecutor("POST", url) if err != nil { return fmt.Errorf("build stdin attach executor for pod %s/%s: %w", ns, pod, err) } diff --git a/api/internal/kube/watch.go b/api/internal/kube/watch.go index 96408bd81..20256c8c2 100644 --- a/api/internal/kube/watch.go +++ b/api/internal/kube/watch.go @@ -6,6 +6,7 @@ import ( "log/slog" "time" + apierrors "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/types" @@ -23,6 +24,10 @@ const resyncPeriod = 10 * time.Minute // remote kubeconfigs are logged but do not crash — the registry continues // to serve what it has. func WatchClusters(ctx context.Context, home *Client, reg *Registry, ns string) { + if home.IsStandalone() { + watchStandaloneClusters(ctx, home, reg, ns) + return + } factory := dynamicinformer.NewDynamicSharedInformerFactory(home.Dynamic, resyncPeriod) if _, err := factory.ForResource(GVRCluster).Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ @@ -105,10 +110,29 @@ func removeDeletedCluster(reg *Registry, obj any) { // loads the secret, creates a client, and registers it in the registry. // If the Cluster is being deleted, it is removed from the registry instead. func loadCluster(ctx context.Context, home *Client, reg *Registry, ns, name string) error { - u, err := home.Dynamic.Resource(GVRCluster).Get(ctx, name, metav1.GetOptions{}) + return loadClusterForUID(ctx, home, reg, ns, name, "") +} + +// RefreshRegisteredCluster reloads credentials without a remote connectivity +// probe. The expected identity prevents a rotation from carrying a refresh +// across deletion/recreation of the registration. +func RefreshRegisteredCluster(ctx context.Context, home *Client, reg *Registry, ns, name string, expectedUID types.UID) error { + return loadClusterForUID(ctx, home, reg, ns, name, expectedUID) +} + +func loadClusterForUID(ctx context.Context, home *Client, reg *Registry, ns, name string, expectedUID types.UID) error { + op, err := reg.beginOperation(ctx, name) + if err != nil { + return err + } + defer op.finish() + u, err := home.Clusters().Get(ctx, name, metav1.GetOptions{}) if err != nil { return fmt.Errorf("get cluster CRD: %w", err) } + if expectedUID != "" && u.GetUID() != expectedUID { + return apierrors.NewNotFound(GVRCluster.GroupResource(), name) + } // If the cluster is being deleted, remove it from the registry. if u.GetDeletionTimestamp() != nil { @@ -144,7 +168,23 @@ func loadCluster(ctx context.Context, home *Client, reg *Registry, ns, name stri return fmt.Errorf("load client from secret: %w", err) } - reg.SetWithUID(name, u.GetUID(), c) + // Status-only updates do not change credential identity. The per-name + // sequence orders loads; epoch publication rejects concurrent removals. + checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + current, err := home.Clusters().Get(checkCtx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("recheck cluster registration: %w", err) + } + if current.GetDeletionTimestamp() != nil || registrationCredentials(current) != registrationCredentials(u) { + return apierrors.NewConflict(GVRCluster.GroupResource(), name, fmt.Errorf("cluster registration changed during credential load")) + } + if existing := reg.existingClient(name, registrationCredentials(u)); existing != nil && existing.kubeconfigDigest == c.kubeconfigDigest { + c = existing + } + if !op.publish(registrationCredentials(u), c) { + return apierrors.NewConflict(GVRCluster.GroupResource(), name, fmt.Errorf("cluster registration invalidated during credential load")) + } slog.Debug("cluster watch: loaded cluster", "cluster", name) return nil } diff --git a/api/internal/kube/watch_rotation_test.go b/api/internal/kube/watch_rotation_test.go new file mode 100644 index 000000000..7c0c2567d --- /dev/null +++ b/api/internal/kube/watch_rotation_test.go @@ -0,0 +1,120 @@ +package kube + +import ( + "context" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "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" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + kubefake "k8s.io/client-go/kubernetes/fake" +) + +type boundedPublicationStore struct { + ClusterStore + t *testing.T + reads int +} + +func (s *boundedPublicationStore) Get(ctx context.Context, name string, opts metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) { + s.reads++ + if s.reads == 2 { + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) > 5*time.Second { + s.t.Fatal("registry publication holds its lock without a bounded management read") + } + return nil, context.DeadlineExceeded + } + return s.ClusterStore.Get(ctx, name, opts, subresources...) +} + +func TestClusterRefreshBoundsReadWhileHoldingRegistryLock(t *testing.T) { + registration := newTestCluster("remote") + registration.SetUID("original") + dynamic := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{GVRCluster: "ClusterList"}, registration) + typed := kubefake.NewClientset(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "cluster-remote-kubeconfig", Namespace: "panel", Labels: map[string]string{ClusterKubeconfigLabel: "true"}}, Data: map[string][]byte{"kubeconfig": kubeconfig()}}) + home := &Client{Dynamic: dynamic, Typed: typed} + home.ClusterStore = &boundedPublicationStore{ClusterStore: home.Clusters(), t: t} + reg := NewRegistry("local") + if err := RefreshRegisteredCluster(t.Context(), home, reg, "panel", "remote", registration.GetUID()); err == nil { + t.Fatal("publication ignored a timed-out management read") + } + if _, ok := reg.Get("remote"); ok { + t.Fatal("failed publication retained a client") + } +} + +type rotationSecretStore struct { + SecretStore + afterRead func() +} + +func (s rotationSecretStore) Get(ctx context.Context, name string, opts metav1.GetOptions) (*corev1.Secret, error) { + secret, err := s.SecretStore.Get(ctx, name, opts) + if err == nil { + s.afterRead() + } + return secret, err +} + +func TestClusterRefreshDoesNotPublishCredentialAcrossRotationOrDeletion(t *testing.T) { + for _, action := range []string{"rotate", "delete", "recreate"} { + t.Run(action, func(t *testing.T) { + registration := newTestCluster("remote") + registration.SetUID("original") + registration.SetResourceVersion("1") + dynamic := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{GVRCluster: "ClusterList"}, registration) + typed := kubefake.NewClientset(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "cluster-remote-kubeconfig", Namespace: "panel", Labels: map[string]string{ClusterKubeconfigLabel: "true"}}, Data: map[string][]byte{"kubeconfig": kubeconfig()}}) + home := &Client{Dynamic: dynamic, Typed: typed} + reg := NewRegistry("local") + home.SecretStore = func(ns string) SecretStore { + return rotationSecretStore{SecretStore: typed.CoreV1().Secrets(ns), afterRead: func() { + switch action { + case "rotate": + // Rotate the credential reference and payload. A status-only + // resourceVersion update must not invalidate a loaded client. + rotatedName := "cluster-remote-kubeconfig-rotated" + if _, err := typed.CoreV1().Secrets(ns).Create(t.Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: rotatedName, Namespace: ns, Labels: map[string]string{ClusterKubeconfigLabel: "true"}}, + Data: map[string][]byte{"kubeconfig": []byte(strings.ReplaceAll(string(kubeconfig()), "fake-token", "rotated-token"))}, + }, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + current := registration.DeepCopy() + current.SetResourceVersion("2") + if err := unstructured.SetNestedField(current.Object, rotatedName, "spec", "kubeconfigSecret", "name"); err != nil { + t.Fatal(err) + } + if _, err := home.Clusters().Update(t.Context(), current, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + default: + if err := home.Clusters().Delete(t.Context(), "remote", metav1.DeleteOptions{}); err != nil { + t.Fatal(err) + } + if action == "recreate" { + current := registration.DeepCopy() + current.SetUID("replacement") + if _, err := home.Clusters().Create(t.Context(), current, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + } + } + }} + } + err := RefreshRegisteredCluster(t.Context(), home, reg, "panel", "remote", registration.GetUID()) + if !apierrors.IsConflict(err) && !apierrors.IsNotFound(err) { + t.Fatalf("stale publication must be rejected: %v", err) + } + if _, ok := reg.Get("remote"); ok { + t.Fatal("stale refresh published an old client") + } + }) + } +} diff --git a/api/internal/kube/watch_standalone.go b/api/internal/kube/watch_standalone.go new file mode 100644 index 000000000..c32fb1240 --- /dev/null +++ b/api/internal/kube/watch_standalone.go @@ -0,0 +1,143 @@ +package kube + +import ( + "context" + "encoding/json" + "log/slog" + "regexp" + "sync" + "time" + + apierrors "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/version" +) + +const standalonePollInterval = 30 * time.Second + +var standaloneVersionRE = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$`) + +func watchStandaloneClusters(ctx context.Context, home *Client, reg *Registry, ns string) { + ticker := time.NewTicker(standalonePollInterval) + defer ticker.Stop() + for { + syncStandaloneClusters(ctx, home, reg, ns) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +// syncStandaloneClusters uses a fixed-size worker pool with cancellable remote +// probes. No network operation holds a database transaction or registry lock. +func syncStandaloneClusters(ctx context.Context, home *Client, reg *Registry, ns string) { + list, err := home.Clusters().List(ctx, metav1.ListOptions{}) + if err != nil { + if ctx.Err() == nil { + slog.Warn("standalone cluster poll: registration store unavailable") + } + return + } + jobs := make(chan *unstructured.Unstructured) + var workers sync.WaitGroup + for range 4 { + workers.Go(func() { + for registration := range jobs { + probeStandaloneCluster(ctx, home, reg, ns, registration) + } + }) + } + for i := range list.Items { + select { + case <-ctx.Done(): + close(jobs) + workers.Wait() + return + case jobs <- &list.Items[i]: + } + } + close(jobs) + workers.Wait() + // Recheck each name in its own sequence. A registration added after the + // list must not be evicted because it missed that snapshot. + for _, name := range reg.IDs() { + op, err := reg.beginOperation(ctx, name) + if err != nil { + return + } + _, err = home.Clusters().Get(ctx, name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + op.publish(credentialIdentity{}, nil) + } + op.finish() + } +} + +func probeStandaloneCluster(ctx context.Context, home *Client, reg *Registry, ns string, registration *unstructured.Unstructured) { + name := registration.GetName() + op, err := reg.beginOperation(ctx, name) + if err != nil { + return + } + defer op.finish() + probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + identity := registrationCredentials(registration) + secretName, _, _ := unstructured.NestedString(registration.Object, "spec", "kubeconfigSecret", "name") + key, _, _ := unstructured.NestedString(registration.Object, "spec", "kubeconfigSecret", "key") + phase, message, serverVersion := "Unhealthy", "Cluster credentials could not be loaded", "" + client, loadErr := ClientFromSecret(probeCtx, home, ns, secretName, key) + existing := reg.existingClient(name, identity) + if transientCredentialRead(loadErr) { + client = existing + } + if loadErr == nil { + // Keep a stable client identity while credentials are unchanged, so + // consumers can retain their informers and transport connections. + if existing != nil && existing.kubeconfigDigest == client.kubeconfigDigest { + client = existing + } + message = "Cluster connection is unavailable" + var data []byte + data, err = client.Typed.Discovery().RESTClient().Get().AbsPath("/version").Do(probeCtx).Raw() + if err == nil { + var info version.Info + if json.Unmarshal(data, &info) == nil && len(info.GitVersion) <= 128 && standaloneVersionRE.MatchString(info.GitVersion) { + phase, message, serverVersion = "Healthy", "", info.GitVersion + } + } + } + if ctx.Err() != nil { + return + } + // Never persist transport/parser error strings: they may contain credentials + // or host details. The public status is deliberately a small safe projection. + // Rebase status onto the latest metadata, retaining the store's update CAS. + // Status RV churn cannot reject unchanged credentials. Removal invalidates + // the epoch without waiting for storage or a remote probe. + latest, err := home.Clusters().Get(ctx, name, metav1.GetOptions{}) + if err != nil || registrationCredentials(latest) != identity { + return + } + if latest.GetDeletionTimestamp() != nil { + op.publish(identity, nil) + return + } + // A definitively missing/invalid credential must not keep serving through + // a separate status-store failure. Epoch checks still protect replacements. + if client == nil { + op.publish(identity, nil) + } + latest = latest.DeepCopy() + latest.Object["status"] = map[string]any{ + "phase": phase, "message": message, "serverVersion": serverVersion, + "lastCheckTime": time.Now().UTC().Format(time.RFC3339), + } + if _, err := home.Clusters().Update(ctx, latest, metav1.UpdateOptions{}); err != nil { + return + } + op.publish(identity, client) +} diff --git a/api/internal/kube/watch_standalone_test.go b/api/internal/kube/watch_standalone_test.go new file mode 100644 index 000000000..086d93445 --- /dev/null +++ b/api/internal/kube/watch_standalone_test.go @@ -0,0 +1,339 @@ +package kube + +import ( + "context" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + kubefake "k8s.io/client-go/kubernetes/fake" +) + +type interceptClusterStore struct { + ClusterStore + get func(context.Context, string, metav1.GetOptions) (*unstructured.Unstructured, error) +} + +func (s interceptClusterStore) Get(ctx context.Context, name string, opts metav1.GetOptions, _ ...string) (*unstructured.Unstructured, error) { + return s.get(ctx, name, opts) +} + +type unavailableSecretStore struct { + SecretStore + failure error +} + +func (s unavailableSecretStore) Get(context.Context, string, metav1.GetOptions) (*corev1.Secret, error) { + if s.failure != nil { + return nil, s.failure + } + return nil, errors.New("temporary database failure") +} + +func TestClusterRefreshDoesNotBlockRegistryReadsOrResurrectRemoval(t *testing.T) { + home, reg, _ := standaloneWatchFixture(t, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + store := home.ClusterStore + blocked, release := make(chan struct{}), make(chan struct{}) + var calls atomic.Int32 + home.ClusterStore = interceptClusterStore{ClusterStore: store, get: func(ctx context.Context, name string, opts metav1.GetOptions) (*unstructured.Unstructured, error) { + u, err := store.Get(ctx, name, opts) + if calls.Add(1) == 2 { + close(blocked) + <-release + } + return u, err + }} + reg.Set("other", &Client{}) + done := make(chan error, 1) + go func() { done <- RefreshRegisteredCluster(t.Context(), home, reg, "panel", "remote", "remote-identity") }() + select { + case <-blocked: + case <-time.After(3 * time.Second): + close(release) + t.Fatal("refresh did not reach recheck") + } + read := make(chan struct{}) + go func() { reg.Get("other"); reg.IDs(); reg.Remove("remote"); close(read) }() + select { + case <-read: + case <-time.After(time.Second): + close(release) + <-done + t.Fatal("store I/O blocked registry access") + } + close(release) + <-done + if _, ok := reg.Get("remote"); ok { + t.Fatal("refresh published after removal invalidated its epoch") + } +} + +func TestClusterRefreshAcceptsStatusOnlyResourceVersionChange(t *testing.T) { + home, reg, _ := standaloneWatchFixture(t, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + store := home.ClusterStore + var calls atomic.Int32 + home.ClusterStore = interceptClusterStore{ClusterStore: store, get: func(ctx context.Context, name string, opts metav1.GetOptions) (*unstructured.Unstructured, error) { + u, err := store.Get(ctx, name, opts) + if err == nil && calls.Add(1) == 2 { + u.SetResourceVersion("status-only-update") + } + return u, err + }} + if err := RefreshRegisteredCluster(t.Context(), home, reg, "panel", "remote", "remote-identity"); err != nil { + t.Fatal(err) + } + if _, ok := reg.Get("remote"); !ok { + t.Fatal("status update rejected fresh credentials") + } +} + +func TestStandaloneProbeRetainsClientAcrossTransientSecretRead(t *testing.T) { + home, reg, registration := standaloneWatchFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"gitVersion":"v1.35.0"}`)) })) + probeStandaloneCluster(t.Context(), home, reg, "panel", registration) + first, _ := reg.Get("remote") + secretStore := home.Secrets("panel") + home.SecretStore = func(string) SecretStore { return unavailableSecretStore{SecretStore: secretStore} } + current, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + probeStandaloneCluster(t.Context(), home, reg, "panel", current) + if got, _ := reg.Get("remote"); first == nil || got != first { + t.Fatal("transient credential read discarded live client") + } + if err := unstructured.SetNestedField(current.Object, "new-secret", "spec", "kubeconfigSecret", "name"); err != nil { + t.Fatal(err) + } + if _, err := home.Clusters().Update(t.Context(), current, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + probeStandaloneCluster(t.Context(), home, reg, "panel", current) + if _, ok := reg.Get("remote"); ok { + t.Fatal("changed credential identity retained old client after failed load") + } +} + +type corruptCredentialError struct{} + +func (corruptCredentialError) Error() string { return "management credential authentication failed" } +func (corruptCredentialError) PermanentCredentialFailure() bool { return true } + +func TestStandaloneProbeEvictsDefinitivelyInvalidCredentials(t *testing.T) { + for _, mode := range []string{"missing", "invalid kubeconfig", "corrupt ciphertext"} { + t.Run(mode, func(t *testing.T) { + home, reg, registration := standaloneWatchFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"gitVersion":"v1.35.0"}`)) })) + probeStandaloneCluster(t.Context(), home, reg, "panel", registration) + if _, ok := reg.Get("remote"); !ok { + t.Fatal("initial probe did not register client") + } + store := home.Secrets("panel") + switch mode { + case "missing": + if err := store.Delete(t.Context(), "cluster-remote-kubeconfig", metav1.DeleteOptions{}); err != nil { + t.Fatal(err) + } + case "invalid kubeconfig": + secret, err := store.Get(t.Context(), "cluster-remote-kubeconfig", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + secret.Data["kubeconfig"] = []byte("invalid") + if _, err := store.Update(t.Context(), secret, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + case "corrupt ciphertext": + home.SecretStore = func(string) SecretStore { + return unavailableSecretStore{SecretStore: store, failure: corruptCredentialError{}} + } + } + current, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + probeStandaloneCluster(t.Context(), home, reg, "panel", current) + if _, ok := reg.Get("remote"); ok { + t.Fatal("definitively invalid credentials retained live client") + } + }) + } +} + +func TestClusterRefreshRejectsChangedCredentialOrRegistrationIdentity(t *testing.T) { + for _, mode := range []string{"credential pointer", "registration UID", "terminating"} { + t.Run(mode, func(t *testing.T) { + home, reg, _ := standaloneWatchFixture(t, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + store := home.ClusterStore + var calls atomic.Int32 + home.ClusterStore = interceptClusterStore{ClusterStore: store, get: func(ctx context.Context, name string, opts metav1.GetOptions) (*unstructured.Unstructured, error) { + u, err := store.Get(ctx, name, opts) + if err == nil && calls.Add(1) == 2 { + switch mode { + case "credential pointer": + if err := unstructured.SetNestedField(u.Object, "rotated-secret", "spec", "kubeconfigSecret", "name"); err != nil { + return nil, err + } + case "registration UID": + u.SetUID("replacement") + case "terminating": + u.SetDeletionTimestamp(&metav1.Time{Time: time.Now()}) + } + } + return u, err + }} + if err := RefreshRegisteredCluster(t.Context(), home, reg, "panel", "remote", "remote-identity"); err == nil { + t.Fatal("changed identity published stale loaded credentials") + } + if _, ok := reg.Get("remote"); ok { + t.Fatal("changed identity was published") + } + }) + } +} + +func standaloneWatchFixture(t *testing.T, handler http.Handler) (*Client, *Registry, *unstructured.Unstructured) { + t.Helper() + server := httptest.NewTLSServer(handler) + t.Cleanup(server.Close) + ca := base64.StdEncoding.EncodeToString(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})) + _, port, err := net.SplitHostPort(server.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + config := fmt.Sprintf(`{"apiVersion":"v1","kind":"Config","clusters":[{"name":"remote","cluster":{"server":%q,"tls-server-name":"127.0.0.1","certificate-authority-data":%q}}],"contexts":[{"name":"remote","context":{"cluster":"remote","user":"panel"}}],"current-context":"remote","users":[{"name":"panel","user":{"token":"private-token"}}]}`, "https://"+net.JoinHostPort("10.0.0.1", port), ca) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster-remote-kubeconfig", Namespace: "panel", Labels: map[string]string{ClusterKubeconfigLabel: "true"}}, + Data: map[string][]byte{"kubeconfig": []byte(config)}, + } + typed := kubefake.NewClientset(secret) + registration := newTestCluster("remote") + registration.SetUID("remote-identity") + registration.SetResourceVersion("1") + dynamic := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{GVRCluster: "ClusterList"}, registration) + home := &Client{ + SecretStore: func(ns string) SecretStore { return typed.CoreV1().Secrets(ns) }, + ClusterStore: dynamic.Resource(GVRCluster), + RemoteAccess: &RemoteAccessPolicy{dial: func(ctx context.Context, network, address string) (net.Conn, error) { + if address != net.JoinHostPort("10.0.0.1", port) { + return nil, fmt.Errorf("unexpected test destination") + } + return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String()) + }}, + } + reg := NewRegistry("") + reg.SetManagement(home) + return home, reg, registration +} + +func TestStandaloneProbeDoesNotResurrectDeletedRegistration(t *testing.T) { + started, release := make(chan struct{}), make(chan struct{}) + home, reg, registration := standaloneWatchFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + close(started) + <-release + _, _ = w.Write([]byte(`{"gitVersion":"v1.35.0"}`)) + })) + done := make(chan struct{}) + go func() { defer close(done); probeStandaloneCluster(t.Context(), home, reg, "panel", registration) }() + select { + case <-started: + case <-time.After(3 * time.Second): + close(release) + t.Fatal("probe did not start") + } + if err := home.Clusters().Delete(t.Context(), "remote", metav1.DeleteOptions{}); err != nil { + close(release) + t.Fatal(err) + } + reg.Remove("remote") + close(release) + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("probe did not finish") + } + if _, ok := reg.Get("remote"); ok { + t.Fatal("stale probe resurrected deleted registration") + } +} + +func TestStandalonePollReusesClientsAndRotatesChangedCredentials(t *testing.T) { + home, reg, registration := standaloneWatchFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"gitVersion":"v1.35.0"}`)) + })) + probeStandaloneCluster(t.Context(), home, reg, "panel", registration) + first, ok := reg.Get("remote") + if !ok { + t.Fatal("first probe did not load client") + } + current, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + probeStandaloneCluster(t.Context(), home, reg, "panel", current) + second, _ := reg.Get("remote") + if first != second { + t.Fatal("unchanged credentials replaced the workload client") + } + secret, err := home.Secrets("panel").Get(t.Context(), "cluster-remote-kubeconfig", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + secret.Data["kubeconfig"] = []byte(strings.ReplaceAll(string(secret.Data["kubeconfig"]), "private-token", "rotated-token")) + if _, err := home.Secrets("panel").Update(t.Context(), secret, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + current, err = home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + probeStandaloneCluster(t.Context(), home, reg, "panel", current) + third, _ := reg.Get("remote") + if third == nil || third == second { + t.Fatal("changed credentials did not replace workload client") + } +} + +func TestStandaloneProbePersistsSafeFailureMessage(t *testing.T) { + home, reg, registration := standaloneWatchFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "private-token https://sensitive.example.invalid", http.StatusForbidden) + })) + probeStandaloneCluster(t.Context(), home, reg, "panel", registration) + current, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + phase, _, _ := unstructured.NestedString(current.Object, "status", "phase") + message, _, _ := unstructured.NestedString(current.Object, "status", "message") + if phase != "Unhealthy" || message != "Cluster connection is unavailable" { + t.Fatalf("unsafe or missing failure status: %s %s", phase, message) + } +} + +func TestStandaloneProbeRejectsOversizeVersion(t *testing.T) { + home, reg, registration := standaloneWatchFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"gitVersion":"v1.35.0","padding":"` + strings.Repeat("x", 128*1024) + `"}`)) + })) + probeStandaloneCluster(t.Context(), home, reg, "panel", registration) + current, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + phase, _, _ := unstructured.NestedString(current.Object, "status", "phase") + version, _, _ := unstructured.NestedString(current.Object, "status", "serverVersion") + if phase != "Unhealthy" || version != "" { + t.Fatal("oversize remote version was accepted") + } +} diff --git a/api/internal/notify/format.go b/api/internal/notify/format.go index c6efef9b9..1a75080be 100644 --- a/api/internal/notify/format.go +++ b/api/internal/notify/format.go @@ -39,6 +39,9 @@ func title(e Event) string { if e.Name == "" { return fmt.Sprintf("[%s] %s", inst, what) } + if e.Cluster != "" { + return fmt.Sprintf("[%s] %s: %s/%s/%s", inst, what, e.Cluster, e.Namespace, e.Name) + } return fmt.Sprintf("[%s] %s: %s/%s", inst, what, e.Namespace, e.Name) } diff --git a/api/internal/notify/format_test.go b/api/internal/notify/format_test.go index 47e14f823..f1205cd50 100644 --- a/api/internal/notify/format_test.go +++ b/api/internal/notify/format_test.go @@ -16,6 +16,8 @@ func TestTitle(t *testing.T) { "[Gameplane] backup failed: games/nightly"}, {"named instance", Event{Type: EventServerUnhealthy, Instance: "prod", Namespace: "games", Name: "mc"}, "[prod] server unhealthy: games/mc"}, + {"remote cluster", Event{Type: EventServerUnhealthy, Instance: "prod", Cluster: "remote-1", Namespace: "games", Name: "mc"}, + "[prod] server unhealthy: remote-1/games/mc"}, {"test event", Event{Type: EventTest, Test: true}, "[Gameplane] test notification"}, } diff --git a/api/internal/notify/notify.go b/api/internal/notify/notify.go index 8dc69a4b5..ee44bc9a3 100644 --- a/api/internal/notify/notify.go +++ b/api/internal/notify/notify.go @@ -47,6 +47,7 @@ const queueSize = 256 // shipped to generic webhook sinks, so field renames are a breaking change // for webhook consumers. type Event struct { + Cluster string `json:"cluster,omitempty"` Type EventType `json:"type"` TS string `json:"ts"` Kind string `json:"kind,omitempty"` // CRD kind: GameServer | Backup | Restore diff --git a/api/internal/notify/poll_standalone.go b/api/internal/notify/poll_standalone.go new file mode 100644 index 000000000..b21feb7b4 --- /dev/null +++ b/api/internal/notify/poll_standalone.go @@ -0,0 +1,161 @@ +package notify + +import ( + "context" + "errors" + "log/slog" + "strings" + "time" + + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/api/internal/scope" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/validation" +) + +const remoteNotificationObjectLimit = 1024 + +type clusterSnapshot struct { + previous map[string]map[string]*unstructured.Unstructured + alerted map[string]bool + lastWarning map[string]time.Time +} + +func newClusterSnapshot() *clusterSnapshot { + return &clusterSnapshot{previous: map[string]map[string]*unstructured.Unstructured{}, alerted: map[string]bool{}, lastWarning: map[string]time.Time{}} +} + +func (n *Notifier) runClusterPoller(ctx context.Context, k *kube.Client, cluster string) { + state := newClusterSnapshot() + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + n.pollClusterSnapshot(ctx, k, cluster, state) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +// Poll finite, bounded snapshots instead of caching remote watch inventories. +// Failed/oversized snapshots never replace a baseline or manufacture transitions. +func (n *Notifier) pollClusterSnapshot(ctx context.Context, k *kube.Client, cluster string, state *clusterSnapshot) { + for _, kind := range []string{"servers", "backups", "restores"} { + probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + current, err := notificationSnapshot(probeCtx, k, kind) + cancel() + if ctx.Err() != nil { + return + } + if err != nil { + if time.Since(state.lastWarning[kind]) >= time.Minute { + // Never log upstream errors or object data: either may carry + // credentials. The warning identifies the affected observer. + slog.Warn("standalone notification snapshot unavailable or exceeds observation limit; retaining baseline", "cluster", cluster, "resource", kind) + state.lastWarning[kind] = time.Now() + } + continue + } + previous := state.previous[kind] + for key, obj := range current { + old := previous[key] + if old == nil || old.GetUID() != obj.GetUID() { + if kind == "servers" { + delete(state.alerted, key) + } + if previous == nil { + continue + } // First successful snapshot is silent. + // An object created and completed between polls still represents + // a new terminal event. Never pair it with a replaced object's state. + old = &unstructured.Unstructured{Object: map[string]any{}} + } + var events []Event + if kind == "servers" { + var alerted bool + events, alerted = serverEvents(old, obj, state.alerted[key]) + if alerted { + state.alerted[key] = true + } else { + delete(state.alerted, key) + } + } else { + resourceKind := "Backup" + if kind == "restores" { + resourceKind = "Restore" + } + events = phaseEvents(resourceKind, old, obj) + } + for _, e := range events { + e.Cluster = cluster + n.Enqueue(stampTS(e)) + } + } + if kind == "servers" { + for key := range state.alerted { + if current[key] == nil { + delete(state.alerted, key) + } + } + } + state.previous[kind] = current + } +} + +func notificationSnapshot(ctx context.Context, k *kube.Client, kind string) (map[string]*unstructured.Unstructured, error) { + current := map[string]*unstructured.Unstructured{} + count := 0 + seen := map[string]bool{} + for _, ns := range scope.AllowedNamespaces { + if seen[ns] { + continue + } + seen[ns] = true + // Scope remotely before applying the aggregate object budget. Objects + // in namespaces the panel ignores cannot exhaust the retained baseline. + list, err := k.Dynamic.Resource(kube.GVRs[kind]).Namespace(ns).List(ctx, metav1.ListOptions{Limit: int64(remoteNotificationObjectLimit - count + 1)}) + if err != nil { + return nil, err + } + count += len(list.Items) + if count > remoteNotificationObjectLimit || list.GetContinue() != "" { + return nil, errors.New("remote notification snapshot exceeds observation limit") + } + for i := range list.Items { + obj := &list.Items[i] + if obj.GetNamespace() != ns || len(validation.IsDNS1123Subdomain(obj.GetName())) != 0 || len(obj.GetUID()) > 128 { + continue + } + current[ns+"/"+obj.GetName()] = notificationProjection(obj) + } + } + return current, nil +} + +// Retain only fields needed to detect alerts, with fixed text limits. Remote spec +// data and arbitrarily many conditions do not belong in notification baselines. +func notificationProjection(obj *unstructured.Unstructured) *unstructured.Unstructured { + out := &unstructured.Unstructured{Object: map[string]any{}} + out.SetName(boundedNotificationText(obj.GetName(), 253)) + out.SetNamespace(boundedNotificationText(obj.GetNamespace(), 63)) + out.SetUID(obj.GetUID()) + phase, _, _ := unstructured.NestedString(obj.Object, "status", "phase") + message, _, _ := unstructured.NestedString(obj.Object, "status", "message") + conditions := []any{} + for _, typ := range []string{"Healthy", "Ready"} { + status, reason, detail := condition(obj, typ) + conditions = append(conditions, map[string]any{"type": typ, "status": boundedNotificationText(status, 64), "reason": boundedNotificationText(reason, 256), "message": boundedNotificationText(detail, 4096)}) + } + out.Object["status"] = map[string]any{"phase": boundedNotificationText(phase, 64), "message": boundedNotificationText(message, 4096), "conditions": conditions} + return out +} + +func boundedNotificationText(value string, limit int) string { + if len(value) > limit { + return strings.Clone(value[:limit]) + } + return value +} diff --git a/api/internal/notify/poll_standalone_test.go b/api/internal/notify/poll_standalone_test.go new file mode 100644 index 000000000..6f3d5272e --- /dev/null +++ b/api/internal/notify/poll_standalone_test.go @@ -0,0 +1,172 @@ +package notify + +import ( + "fmt" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/api/internal/scope" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" +) + +func pollTestClient(objects ...runtime.Object) *kube.Client { + kinds := map[schema.GroupVersionResource]string{ + kube.GVRs["servers"]: "GameServerList", kube.GVRs["backups"]: "BackupList", kube.GVRs["restores"]: "RestoreList", + } + return &kube.Client{Dynamic: dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), kinds, objects...)} +} + +func TestStandaloneNotificationPollReportsNewTerminalObjectsAfterBaseline(t *testing.T) { + k := pollTestClient() + n := &Notifier{ch: make(chan Event, 10)} + state := newClusterSnapshot() + n.pollClusterSnapshot(t.Context(), k, "remote", state) + for _, kind := range []string{"Backup", "Restore", "GameServer"} { + resource, phase := "backups", "Succeeded" + if kind == "Restore" { + resource, phase = "restores", "Failed" + } + if kind == "GameServer" { + resource, phase = "servers", "Failed" + } + obj := &unstructured.Unstructured{Object: map[string]any{"apiVersion": "gameplane.local/v1alpha1", "kind": kind, "status": map[string]any{"phase": phase}}} + obj.SetName("new-object") + obj.SetNamespace(scope.DefaultNamespace) + obj.SetUID("new-identity") + if _, err := k.Dynamic.Resource(kube.GVRs[resource]).Namespace(scope.DefaultNamespace).Create(t.Context(), obj, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + } + n.pollClusterSnapshot(t.Context(), k, "remote", state) + if len(n.ch) != 3 { + t.Fatalf("missed newly observed terminal objects: %d events", len(n.ch)) + } + n.pollClusterSnapshot(t.Context(), k, "remote", state) + if len(n.ch) != 3 { + t.Fatal("stable terminal objects emitted duplicate alerts") + } +} + +func TestStandaloneNotificationPollScopesBeforeBounding(t *testing.T) { + objects := make([]runtime.Object, 0, 1101) + for i := range 1100 { + objects = append(objects, gsObj("ignored", fmt.Sprintf("server-%d", i), "Running", "True", "AgentFresh", "", "")) + } + objects = append(objects, gsObj(scope.DefaultNamespace, "visible", "Running", "True", "AgentFresh", "", "")) + n := &Notifier{ch: make(chan Event, 10)} + state := newClusterSnapshot() + n.pollClusterSnapshot(t.Context(), pollTestClient(objects...), "remote", state) + if len(state.previous["servers"]) != 1 { + t.Fatal("ignored namespace exhausted notification snapshot") + } +} + +func TestStandaloneNotificationPollPreservesTransitions(t *testing.T) { + server := gsObj(scope.DefaultNamespace, "game", "Running", "True", "AgentFresh", "", "") + server.SetUID("original") + k := pollTestClient(server) + n := &Notifier{ch: make(chan Event, 10)} + state := newClusterSnapshot() + n.pollClusterSnapshot(t.Context(), k, "remote", state) + if len(n.ch) != 0 { + t.Fatal("initial snapshot emitted alerts") + } + failed := gsObj(scope.DefaultNamespace, "game", "Failed", "False", "Failed", "ImagePullFailed", "cannot pull") + failed.SetUID("original") + if _, err := k.Dynamic.Resource(kube.GVRs["servers"]).Namespace(scope.DefaultNamespace).Update(t.Context(), failed, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + n.pollClusterSnapshot(t.Context(), k, "remote", state) + select { + case e := <-n.ch: + if e.Cluster != "remote" || e.Type != EventServerUnhealthy || e.Reason != "ImagePullFailed" { + t.Fatalf("wrong event: %+v", e) + } + default: + t.Fatal("failure transition was missed") + } + n.pollClusterSnapshot(t.Context(), k, "remote", state) + if len(n.ch) != 0 { + t.Fatal("stable failure emitted duplicate alerts") + } + if _, err := k.Dynamic.Resource(kube.GVRs["servers"]).Namespace(scope.DefaultNamespace).Update(t.Context(), server, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + n.pollClusterSnapshot(t.Context(), k, "remote", state) + select { + case e := <-n.ch: + if e.Type != EventServerRecovered { + t.Fatalf("wrong recovery: %+v", e) + } + default: + t.Fatal("recovery transition was missed") + } +} + +func TestStandaloneNotificationPollRejectsExcessObjects(t *testing.T) { + objects := make([]runtime.Object, 0, 1100) + for i := range 1100 { + objects = append(objects, gsObj(scope.DefaultNamespace, fmt.Sprintf("server-%d", i), "Running", "True", "AgentFresh", "", "")) + } + n := &Notifier{ch: make(chan Event, 10)} + state := newClusterSnapshot() + n.pollClusterSnapshot(t.Context(), pollTestClient(objects...), "remote", state) + if len(state.previous["servers"]) != 0 { + t.Fatal("oversize remote inventory retained objects") + } + if len(n.ch) != 0 { + t.Fatal("rejected inventory emitted alerts") + } + firstWarning := state.lastWarning["servers"] + if firstWarning.IsZero() { + t.Fatal("oversize snapshot was silently rejected") + } + n.pollClusterSnapshot(t.Context(), pollTestClient(objects...), "remote", state) + if state.lastWarning["servers"] != firstWarning { + t.Fatal("snapshot warnings were not rate limited") + } +} + +func TestStandaloneNotificationPollDoesNotPairRecreatedServer(t *testing.T) { + for _, tc := range []struct { + phase, healthy string + wantFailure bool + }{{"Running", "True", false}, {"Failed", "False", true}} { + t.Run(tc.phase, func(t *testing.T) { + server := gsObj(scope.DefaultNamespace, "game", "Failed", "False", "Failed", "", "") + server.SetUID("old") + k := pollTestClient(server) + n := &Notifier{ch: make(chan Event, 10)} + state := newClusterSnapshot() + n.pollClusterSnapshot(t.Context(), k, "remote", state) + state.alerted[scope.DefaultNamespace+"/game"] = true + replacement := gsObj(scope.DefaultNamespace, "game", tc.phase, tc.healthy, "AgentFresh", "ReplacementFailed", "new failure") + replacement.SetUID("new") + if _, err := k.Dynamic.Resource(kube.GVRs["servers"]).Namespace(scope.DefaultNamespace).Update(t.Context(), replacement, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + n.pollClusterSnapshot(t.Context(), k, "remote", state) + if tc.wantFailure { + select { + case event := <-n.ch: + if event.Type != EventServerUnhealthy || event.Reason != "ReplacementFailed" { + t.Fatalf("replacement did not report its own failure: %+v", event) + } + default: + t.Fatal("replacement failure was suppressed by the old outage") + } + } + if len(n.ch) != 0 { + t.Fatal("replacement inherited an old recovery or emitted extra events") + } + n.pollClusterSnapshot(t.Context(), k, "remote", state) + if len(n.ch) != 0 { + t.Fatal("stable replacement emitted duplicate events") + } + }) + } +} diff --git a/api/internal/notify/registry.go b/api/internal/notify/registry.go new file mode 100644 index 000000000..49c0a0efc --- /dev/null +++ b/api/internal/notify/registry.go @@ -0,0 +1,93 @@ +package notify + +import ( + "context" + "log/slog" + "time" + + "github.com/GameplanePanel/gameplane/api/internal/kube" +) + +// RunWithRegistry keeps credentials central while watching remote workloads in a +// standalone panel. Removing or replacing a client cancels its old poller. +func (n *Notifier) RunWithRegistry(ctx context.Context, reg *kube.Registry) { + if !n.k.IsStandalone() { + n.Run(ctx) + return + } + go n.watchRegistry(ctx, reg) + for { + select { + case <-ctx.Done(): + return + case e := <-n.ch: + n.fanOut(ctx, e) + } + } +} + +func (n *Notifier) watchRegistry(ctx context.Context, reg *kube.Registry) { + type observer struct { + client *kube.Client + cancel context.CancelFunc + } + watchers := map[string]observer{} + defer func() { + for _, w := range watchers { + w.cancel() + } + }() + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + observe := false + var lastWarning time.Time + for { + clients := map[string]*kube.Client{} + loadCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + sinks, err := n.loadSinks(loadCtx) + cancel() + observe = notificationObservationEnabled(observe, sinks, err) + if err != nil && ctx.Err() == nil && time.Since(lastWarning) >= time.Minute { + slog.Warn("standalone notification sink configuration unavailable; retaining last observation decision") + lastWarning = time.Now() + } + if observe { + for _, id := range reg.IDs() { + if k, ok := reg.Get(id); ok && k != nil { + clients[id] = k + } + } + } + for id, w := range watchers { + if clients[id] != w.client { + w.cancel() + delete(watchers, id) + } + } + for id, k := range clients { + if _, ok := watchers[id]; ok || k.Dynamic == nil { + continue + } + watchCtx, cancel := context.WithCancel(ctx) + watchers[id] = observer{client: k, cancel: cancel} + go n.runClusterPoller(watchCtx, k, id) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func notificationObservationEnabled(previous bool, sinks []Sink, err error) bool { + if err != nil { + return previous + } + for _, sink := range sinks { + if sink.Enabled && sink.ConfigRef != "" { + return true + } + } + return false +} diff --git a/api/internal/notify/registry_test.go b/api/internal/notify/registry_test.go new file mode 100644 index 000000000..d5f1fc257 --- /dev/null +++ b/api/internal/notify/registry_test.go @@ -0,0 +1,25 @@ +package notify + +import ( + "errors" + "testing" +) + +func TestNotificationObservationRetainsLastKnownSinkDecision(t *testing.T) { + enabled := []Sink{{Enabled: true, ConfigRef: "sink-credential"}} + observe := notificationObservationEnabled(false, enabled, nil) + if !observe { + t.Fatal("configured sink did not enable observers") + } + observe = notificationObservationEnabled(observe, nil, errors.New("store temporarily unavailable")) + if !observe { + t.Fatal("transient config read cancelled established observers") + } + observe = notificationObservationEnabled(observe, nil, nil) + if observe { + t.Fatal("successfully removed sinks did not cancel observers") + } + if notificationObservationEnabled(false, nil, errors.New("unavailable")) { + t.Fatal("unknown initial config started polling") + } +} diff --git a/api/internal/notify/sinks.go b/api/internal/notify/sinks.go index b4a88b157..ea72b290e 100644 --- a/api/internal/notify/sinks.go +++ b/api/internal/notify/sinks.go @@ -65,7 +65,7 @@ func sinkMatches(s Sink, t EventType) bool { // sinkSecret fetches the sink's credential Secret from the control-plane // namespace, refusing any Secret not labelled SinkSecretLabel=true. func (n *Notifier) sinkSecret(ctx context.Context, name string) (map[string][]byte, error) { - sec, err := n.k.Typed.CoreV1().Secrets(n.controlNS).Get(ctx, name, metav1.GetOptions{}) + sec, err := n.k.Secrets(n.controlNS).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get sink secret %s/%s: %w", n.controlNS, name, err) } diff --git a/api/internal/notify/watch.go b/api/internal/notify/watch.go index d1c30bdfc..9062d8e51 100644 --- a/api/internal/notify/watch.go +++ b/api/internal/notify/watch.go @@ -25,7 +25,14 @@ const resyncPeriod = 10 * time.Minute // which is also the whole restart story (a failure that completes while // the API is down is missed here but still visible in the dashboard). func (n *Notifier) runWatchers(ctx context.Context) { - factory := dynamicinformer.NewDynamicSharedInformerFactory(n.k.Dynamic, resyncPeriod) + n.runClusterWatchers(ctx, n.k, "") +} + +func (n *Notifier) runClusterWatchers(ctx context.Context, k *kube.Client, cluster string) { + if k == nil || k.Dynamic == nil { + return + } + factory := dynamicinformer.NewDynamicSharedInformerFactory(k.Dynamic, resyncPeriod) // alerted tracks GameServers with an un-recovered unhealthy alert, so // recovery events pair with an outage instead of firing on every start. @@ -51,6 +58,7 @@ func (n *Notifier) runWatchers(ctx context.Context) { } mu.Unlock() for _, e := range events { + e.Cluster = cluster n.Enqueue(stampTS(e)) } }, @@ -80,6 +88,7 @@ func (n *Notifier) runWatchers(ctx context.Context) { return } for _, e := range phaseEvents(kind, oldU, newU) { + e.Cluster = cluster n.Enqueue(stampTS(e)) } }, diff --git a/api/internal/rbac/rbac.go b/api/internal/rbac/rbac.go index ff0cbc645..e8c14b903 100644 --- a/api/internal/rbac/rbac.go +++ b/api/internal/rbac/rbac.go @@ -92,7 +92,7 @@ func Middleware(fetch ServerFetcher) func(http.Handler) http.Handler { } ns := "" cl := scope.DefaultCluster - if clusterInventoryRead(req.Method, req.URL.Path) { + if clusterInventoryRead(req.Method, req.URL.Path) || strings.HasPrefix(req.URL.Path, "/modules") || strings.HasPrefix(req.URL.Path, "/templates") { // Authorize before discovery, including registered clusters whose // credentials could not load. The handler validates registration. cl = scope.RequestedCluster(req) @@ -214,6 +214,7 @@ var rules = []rule{ // Admin area. Audit is a read; config splits read vs manage; anything // else under /admin defaults to the admin wildcard (fail-closed-ish). + {method: "GET", segment: "admin", suffix: "/admin/installation", perm: ""}, {method: "GET", segment: "admin", prefix: "/admin/audit", perm: "audit:read"}, {method: "GET", segment: "admin", prefix: "/admin/config", perm: "config:read"}, {segment: "admin", prefix: "/admin/config", perm: "config:manage"}, diff --git a/api/internal/registry/keys.go b/api/internal/registry/keys.go index 83b4e7ea9..526539599 100644 --- a/api/internal/registry/keys.go +++ b/api/internal/registry/keys.go @@ -54,7 +54,7 @@ type SecretReader func(ctx context.Context, name string) (map[string][]byte, err // RegistryKeySecretLabel=true. func NewK8sSecretReader(k *kube.Client, ns string) SecretReader { return func(ctx context.Context, name string) (map[string][]byte, error) { - sec, err := k.Typed.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{}) + sec, err := k.Secrets(ns).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get registry-key secret %s/%s: %w", ns, name, err) } diff --git a/api/internal/telemetry/collect.go b/api/internal/telemetry/collect.go index 6cd6552d3..1d0a55007 100644 --- a/api/internal/telemetry/collect.go +++ b/api/internal/telemetry/collect.go @@ -251,7 +251,7 @@ func collectFeatures(ctx context.Context, deps Deps, servers []unstructured.Unst Backups: len(listItems(ctx, deps, "schedules")) > 0, SSO: deps.Flags.OIDCConfigured || hasSSOProvider(ctx, deps.Store), AuditForwarding: deps.Flags.AuditWebhook || deps.Flags.AuditS3, - Clusters: telemetryschema.ClusterBand(1 + countClusters(ctx, deps)), + Clusters: telemetryschema.ClusterBand(localClusterCount(deps) + countClusters(ctx, deps)), DB: telemetryschema.SanitizeEnum(telemetryschema.DBs, deps.Flags.DBDriver), Language: "en", } @@ -259,10 +259,10 @@ func collectFeatures(ctx context.Context, deps Deps, servers []unstructured.Unst // countClusters counts registered Cluster CRs, 0 when they can't be listed. func countClusters(ctx context.Context, deps Deps) int { - if deps.Kube == nil || deps.Kube.Dynamic == nil { + if deps.Kube == nil || deps.Kube.Clusters() == nil { return 0 } - list, err := deps.Kube.Dynamic.Resource(kube.GVRCluster).List(ctx, metav1.ListOptions{}) + list, err := deps.Kube.Clusters().List(ctx, metav1.ListOptions{}) if err != nil { return 0 } @@ -296,3 +296,10 @@ func hasSSOProvider(ctx context.Context, store *db.Store) bool { } return false } + +func localClusterCount(deps Deps) int { + if deps.Kube == nil || deps.Kube.IsStandalone() { + return 0 + } + return 1 +} diff --git a/api/internal/ws/attach.go b/api/internal/ws/attach.go index 86705c55e..844bf83ed 100644 --- a/api/internal/ws/attach.go +++ b/api/internal/ws/attach.go @@ -112,7 +112,7 @@ func (a *attachProxy) handle(w http.ResponseWriter, req *http.Request) { }, scheme.ParameterCodec). URL() - exec, err := remotecommand.NewSPDYExecutor(a.k.Config, "POST", url) + exec, err := a.k.NewSPDYExecutor("POST", url) if err != nil { slog.Error("build attach executor", "name", name, "ns", ns, "err", err) writeEnvErr(ctx, wsConn, "attach unavailable") diff --git a/api/internal/ws/gateway_capture_test.go b/api/internal/ws/gateway_capture_test.go index bb422ae6f..eec1cf670 100644 --- a/api/internal/ws/gateway_capture_test.go +++ b/api/internal/ws/gateway_capture_test.go @@ -43,7 +43,7 @@ func TestCaptureCapabilitiesAreVersionedBoundedAndCancelable(t *testing.T) { {"malformed", "{", 200, false, true}, } { t.Run(tc.name, func(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := newGatewayTLSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/capabilities" || r.Header.Get("Cookie") != "" { t.Error("wrong capability request") } @@ -67,7 +67,7 @@ func TestCaptureCapabilitiesAreVersionedBoundedAndCancelable(t *testing.T) { func TestCaptureGatewayUsesFreshIdentityAndCredentials(t *testing.T) { calls := 0 - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := newGatewayTLSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ target, err := gatewayprotocol.ParseCapturePath(r.URL.Path) if err != nil || target.UID != "remote-uid" || target.CaptureUID != "capture-uid" { diff --git a/api/internal/ws/gateway_client.go b/api/internal/ws/gateway_client.go index df1dc20dd..b5c217f42 100644 --- a/api/internal/ws/gateway_client.go +++ b/api/internal/ws/gateway_client.go @@ -6,7 +6,6 @@ import ( "crypto/x509" "errors" "fmt" - "net" "net/http" "net/url" "strings" @@ -47,11 +46,11 @@ func (r *agentGatewayResolver) resolve(ctx context.Context, cluster string, targ if !ok || k == nil { return nil, nil, scope.ErrForbiddenCluster } - home := r.registry.Default() - if home == nil || home.Dynamic == nil || home.Typed == nil { + home := r.registry.Management() + if home == nil || home.Clusters() == nil { return nil, nil, errGatewayUnavailable } - registration, err := home.Dynamic.Resource(kube.GVRCluster).Get(ctx, cluster, metav1.GetOptions{}) + registration, err := home.Clusters().Get(ctx, cluster, metav1.GetOptions{}) if err != nil { return nil, nil, fmt.Errorf("get gateway registration: %w", err) } @@ -73,7 +72,11 @@ func (r *agentGatewayResolver) resolve(ctx context.Context, cluster string, targ if err != nil { return nil, nil, err } - secret, err := home.Typed.CoreV1().Secrets(r.namespace).Get(ctx, secretName, metav1.GetOptions{}) + if err := home.RemoteAccess.ValidateURL(endpoint); err != nil { + return nil, nil, err + } + dial := home.RemoteAccess.DialContext + secret, err := home.Secrets(r.namespace).Get(ctx, secretName, metav1.GetOptions{}) if err != nil { return nil, nil, fmt.Errorf("read gateway credentials: %w", err) } @@ -103,7 +106,7 @@ func (r *agentGatewayResolver) resolve(ctx context.Context, cluster string, targ target: gatewayprotocol.Target{Cluster: cluster, Namespace: target.namespace, Name: target.name, UID: string(server.GetUID())}, http: &http.Client{ Transport: &http.Transport{ - DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + DialContext: dial, TLSHandshakeTimeout: 10 * time.Second, TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{cert}, RootCAs: roots, MinVersion: tls.VersionTLS12}, // One resolved transport belongs to one operation. Do not retain diff --git a/api/internal/ws/gateway_client_test.go b/api/internal/ws/gateway_client_test.go index 10e2a1870..c3e1baa6e 100644 --- a/api/internal/ws/gateway_client_test.go +++ b/api/internal/ws/gateway_client_test.go @@ -78,6 +78,37 @@ func TestGatewayURLRejectsNonOrigins(t *testing.T) { } } +func TestStandaloneGatewayRejectsUnsafeDestinations(t *testing.T) { + for _, endpoint := range []string{"https://127.0.0.1:8443", "https://169.254.169.254", "https://metadata.google.internal", "https://10.1.2.3:8443"} { + t.Run(endpoint, func(t *testing.T) { + resolver, home, _ := gatewayFixture(t, endpoint) + // Select the existing fake Kubernetes registration/secret clients as + // standalone management storage, without introducing a network bypass. + home.ClusterStore = home.Clusters() + home.RemoteAccess, _ = kube.NewRemoteAccessPolicy([]string{"192.168.0.0/16"}) + if _, _, err := resolver.resolve(t.Context(), "remote", agentTarget{name: "alpha", namespace: "gameplane-games"}); err == nil { + t.Fatal("unsafe gateway endpoint was accepted") + } + }) + } +} + +func TestCombinedGatewayUsesDestinationGuard(t *testing.T) { + for _, endpoint := range []string{"https://127.0.0.1:8443", "https://169.254.169.254", "https://metadata.google.internal"} { + resolver, _, _ := gatewayFixture(t, endpoint) + if _, _, err := resolver.resolve(t.Context(), "remote", agentTarget{name: "alpha", namespace: "gameplane-games"}); err == nil { + t.Fatalf("combined gateway accepted unsafe destination %s", endpoint) + } + } + resolver, _, _ := gatewayFixture(t, "https://10.1.2.3:8443") + if _, transport, err := resolver.resolve(t.Context(), "remote", agentTarget{name: "alpha", namespace: "gameplane-games"}); err != nil { + t.Fatalf("private workload destination rejected: %v", err) + } else if conn, err := transport.http.Transport.(*http.Transport).DialContext(t.Context(), "tcp", "169.254.169.254:443"); err == nil { + _ = conn.Close() + t.Fatal("combined gateway dial bypassed metadata protection") + } +} + func TestGatewayResolverBindsRemoteUIDAndReadsCredentialsFresh(t *testing.T) { resolver, home, remote := gatewayFixture(t, "https://gateway.test:8443") target := agentTarget{name: "alpha", namespace: "gameplane-games"} @@ -111,7 +142,7 @@ func TestGatewayResolverBindsRemoteUIDAndReadsCredentialsFresh(t *testing.T) { } func TestGatewayTransportPreservesBoundTargetAndFiltersCredentials(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + srv := newGatewayTLSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { target, path, err := gatewayprotocol.ParsePath(req.URL.Path) if err != nil || target.Cluster != "remote" || target.UID != "remote-uid" || path != "/files/write" { t.Errorf("target = %+v, path = %s, error = %v", target, path, err) diff --git a/api/internal/ws/gateway_duration_test.go b/api/internal/ws/gateway_duration_test.go index f54fea515..9f7ec40d8 100644 --- a/api/internal/ws/gateway_duration_test.go +++ b/api/internal/ws/gateway_duration_test.go @@ -7,7 +7,7 @@ import ( "io" "net" "net/http" - "net/http/httptest" + "strings" "testing" "time" @@ -17,7 +17,7 @@ import ( func durationGatewayTransport(t *testing.T, handler http.Handler) *gatewayAgentTransport { t.Helper() - srv := httptest.NewTLSServer(handler) + srv := newGatewayTLSTestServer(t, handler) t.Cleanup(srv.Close) resolver, home, _ := gatewayFixture(t, srv.URL) secret, err := home.Typed.CoreV1().Secrets("gameplane-system").Get(t.Context(), "gateway-client", metav1.GetOptions{}) diff --git a/api/internal/ws/gateway_private_fixture_test.go b/api/internal/ws/gateway_private_fixture_test.go new file mode 100644 index 000000000..06e64f168 --- /dev/null +++ b/api/internal/ws/gateway_private_fixture_test.go @@ -0,0 +1,85 @@ +package ws + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "math/big" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// Use a real private-network address and verified TLS so the watcher exercises +// the production destination guard without permitting loopback or external I/O. +func newGatewayTLSTestServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + listener, ip := gatewayPrivateTestListener(t) + t.Cleanup(func() { _ = listener.Close() }) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Now() + certificate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + IPAddresses: []net.IP{ip}, + BasicConstraintsValid: true, + IsCA: true, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, certificate, certificate, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(handler) + _ = server.Listener.Close() + server.Listener = listener + server.TLS = &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{{ + Certificate: [][]byte{der}, + PrivateKey: key, + }}, + } + server.StartTLS() + t.Cleanup(server.Close) + return server +} + +func gatewayPrivateTestListener(t *testing.T) (net.Listener, net.IP) { + t.Helper() + interfaces, err := net.Interfaces() + if err != nil { + t.Fatal(err) + } + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + addresses, err := iface.Addrs() + if err != nil { + t.Fatalf("inspect private test interface: %v", err) + } + for _, address := range addresses { + ip, _, err := net.ParseCIDR(address.String()) + if err != nil || ip.To4() == nil || !ip.IsPrivate() || ip.IsLoopback() { + continue + } + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp4", net.JoinHostPort(ip.String(), "0")) + if err != nil { + t.Fatalf("bind private gateway test listener: %v", err) + } + return listener, ip + } + } + t.Fatal("gateway test requires an up interface with a private IPv4 address") + return nil, nil +} diff --git a/api/specs.md b/api/specs.md index b3ce7ca86..66edfe09e 100644 --- a/api/specs.md +++ b/api/specs.md @@ -850,3 +850,25 @@ Kubernetes clients. Gateway loss does not inherently disable those clients. The protocol does not provide storage replication, workload migration, or central database availability. Deployment guidance is in [`docs/gateway-install.md`](../docs/gateway-install.md). + +## Standalone management mode + +`--standalone` / `GAMEPLANE_STANDALONE=true` skips Kubernetes credential discovery and does not register `local`. Management secrets and remote registrations persist in the API database through narrow `kube.Client.Secrets`/`Clusters` interfaces; workloads still use explicit Kubernetes registry clients. Credential payloads use AES-GCM with a durable 32-byte key selected by `--panel-key-file` / `GAMEPLANE_PANEL_KEY_FILE` (legacy default `/data/panel.key`; supplied deployment profiles use separate storage at `/keys/panel.key`). `--panel-key-provisioned` / `GAMEPLANE_PANEL_KEY_PROVISIONED=true` requires an existing protected key and performs no key-file writes. Startup validates ownership, permissions, file type, size and existing ciphertext; unsafe/missing/wrong keys fail closed. Database and key backups need separate protection. Existing Kubernetes management objects are not imported automatically. + +Offline `rotate-panel-key --old-key-file PATH --new-key-file PATH` durably creates or reuses a separate key and re-encrypts management secrets atomically, retaining both files. `--new-key-provisioned` requires an existing operator-durable read-only replacement. Versioned `gpk1` ciphertext authenticates its key identity and object identity/version; legacy ciphertext remains readable and is upgraded by rotation. A singleton database key state serializes rotation with all credential mutations, including atomic registration, rejecting stale writers. Exact retries of the immediately preceding rotation authenticate the new state and succeed. Stop every API process first and retain each historical key with separately protected matching backups. + +`GET /admin/installation` is authenticated and reports `{standalone,localCluster,version}`; `version` identifies the panel build without requiring a workload cluster. Empty standalone `/clusters` and `/fleet/*` responses contain no synthesized local cluster. A bounded API poller loads registrations and maintains safe connectivity status, independent of an operator on the panel host. Notifications observe registered remote workloads while credentials remain central. + +Registry loads serialize per registration; storage and remote probes never hold the global registry lock. Publication checks registration UID and credential reference and is invalidated by removal/rotation. Status-only resource-version changes do not reject an otherwise unchanged registration. A transient credential read preserves the last client only while that registration identity/reference still matches; missing, denied or invalid credentials remove it. An unavailable read cannot prove that an external writer has not changed the Secret; the next successful read revalidates its contents. + +Standalone remote clients require verified HTTPS and reject kubeconfig proxies and redirects. Direct dial-time resolution rejects every unsafe address before connecting to a pinned literal IP; optional `--remote-allowed-cidrs` / `GAMEPLANE_REMOTE_ALLOWED_CIDRS` further restricts destinations for both Kubernetes and gateway connections. Private workload networks remain supported. Finite decompressed responses are bounded to 8 MiB (`/version`: 64 KiB), JSON watch frames to 1 MiB, and notification snapshots to 1,024 objects per resource. Notification polling every five seconds is enabled only with a configured enabled sink; failed/oversized snapshots do not manufacture transitions. User log and interactive streams are not subject to the finite-response cap. + +The version cap also applies behind API path prefixes. SPDY TLS/header negotiation has a ten-second deadline, removed after successful upgrade. Gateway dial guards apply in combined installations too. Notification snapshots collect allowed namespaces before applying the aggregate resource limit, retain their baseline on failures, and emit rate-limited warnings. After the first successful baseline, newly observed terminal objects produce events. Transient sink-configuration failures preserve existing observers. + +`POST /clusters` accepts a raw, self-contained kubeconfig string; existing kubeconfig validation rejects executable/file-backed credential references. `PUT /clusters/{name}/gateway` accepts `{url,caCert,clientCert,clientKey}` with an HTTPS origin and PEM TLS material. It persists only labelled management credentials and returns 204 without exposing secrets. `DELETE /clusters/{name}/gateway` clears the reference and only removes API-managed credentials. Existing central `cluster:manage` authorization applies to registration and gateway mutations. + +`PUT /clusters/{name}/kubeconfig` accepts `{kubeconfig:string}` and requires central `cluster:manage`. It publishes an immutable labelled credential, updates the existing registration with UID and previous-pointer conflict checks, invalidates the previous workload client and attempts immediate refresh. Registration UID, gateway settings and grants are preserved. The 204 response contains no credentials and does not imply remote connectivity; conflicting rotations return 409. Cleanup uses secret identity/version preconditions and preserves externally managed credentials. Standalone initial registration commits the credential and registration atomically; an exact matching API-owned legacy orphan can be adopted without changing it. + +Standalone `/modules/*` requests require one explicit registered remote `cluster` selector and cluster-wide module permission on that target. The selected remote operator owns module reconciliation; upload ConfigMaps use the configured operator namespace. Combined installs retain local module ownership and reject remote selectors on this surface. Local node credential minting is incompatible with standalone startup; Kubernetes system-log endpoints return 501 and the dashboard directs administrators to container logs. + +See [standalone installation](../docs/standalone-panel.md) and [design](../specs/023-standalone-panel/spec.md). diff --git a/assets/design-export/MANIFEST.md b/assets/design-export/MANIFEST.md index e57018383..bc60de7e9 100644 --- a/assets/design-export/MANIFEST.md +++ b/assets/design-export/MANIFEST.md @@ -2532,13 +2532,62 @@ T041 and T052 of `specs/022-default-telemetry-dashboard/`. New frames sit in an `q31B6w` is unchanged. Both listed frames have MCP `Get` JSON exports (depth 30, path geometry included) and 2× PNG exports. The cooldown card was visually checked with `TakeScreenshot`; no clipping was reported. Browser capture for `FBiMN` uses a mocked 429 Start response with Retry-After; visual comparison remains pending CI. +## Standalone panel (PR #630, 2026-10-08) + +Added standalone variants beside the existing combined-installation designs. Copy and available actions were checked against PR #630 at `794ee79da08d5381af875b15d80b5b17593b172c`. + +| Object ID | State | +| --- | --- | +| `w34N9` | Dashboard with no workload clusters: zero fleet, unavailable inventory, central audit, and no cluster-dependent create action | +| `n2qdi` | Empty registered-cluster list | +| `WrmU5` | Registered remote clusters with API connectivity status | +| `Z81TW` | Reusable registration dialog with empty kubeconfig input | +| `RpHBg` | Registration dialog in the Clusters screen | +| `HVhmb` | Remove-registration confirmation; workloads remain on the cluster | +| `Be7ZW` | Cluster inventory requiring an explicit workload selection | +| `RS0KZ` | Selector, installation/registry failures, loading, and Retry reference states | +| `Z0oyh` | Modules without a selected workload cluster | +| `ZlLf0` | Modules permission denied on the selected cluster | +| `EWrn0` | Remote module catalog with Deploy to east actions | +| `p8QSPq` | Remote module catalog with inline source management | +| `D9QYZ` | Backup destinations requiring workload selection | +| `l64bd` | Module source settings requiring workload selection | +| `Uzzje` | Standalone container update guidance | +| `FOVMf` | Standalone About without a local Kubernetes version | +| `r139UP` | Container-runtime system-log guidance | +| `aMU7O` | User grants without a selected workload target | +| `C9bR6` | User grants targeting a named remote cluster | + +Interaction context records permission gating, retained registration drafts on failure, pending dismissal restrictions, and clearing cluster-bound module drafts when the target changes. Primary user roles manage panel access; additional grants name the workload cluster and namespace. The reference board documents separate request states, not a single application screen. Master-key storage/rotation and in-place kubeconfig rotation are backend/operator workflows in this PR and do not add dashboard controls. + +All 19 roots have MCP `Get` JSON exports (depth 30, path geometry included) and 2× PNG exports. Structural checks found no remaining placeholders or unintended clipping. The two catalog category strips intentionally scroll horizontally. Screens and dialogs were visually inspected with `TakeScreenshot`; JSON copy/actions received an independent source review. No live-browser comparison was performed for this design-only pass. + +## PR #630 maintainer follow-up — October 14, 2026 + +Updated existing roots before dashboard implementation and refreshed their MCP +Get JSON exports (depth 30, path geometry) plus 2x PNG exports: + +| Root | Updated behavior | +| --- | --- | +| `RS0KZ` | Identity-fetch failure has Retry, distinct from denied permission; background refreshes preserve loaded data and drafts | +| `HVhmb` | Removal error stays inside confirmation; dismissal/reopening resets it; successful removal clears matching selection without fallback | +| `EWrn0` | Catalog background failure displays inline Retry; Deploy labels use the registration display name while routing uses its ID | +| `p8QSPq` | Source background failure displays inline Retry and retains source-edit drafts | +| `FOVMf` | About shows the central API build version without requesting local Kubernetes metadata | + +The request-state board, removal confirmation, About, catalog, and source view +were visually inspected with MCP TakeScreenshot. New retry banners are visible +and aligned. The existing horizontally scrolling category strips retain their +intentional clipping. Deploy action rows use vertical layout to fit display-name +labels. UI regression checks are prepared for CI execution; local TypeScript +compilation passed. The user saved design.pen after the design updates. ## Players tab Nuclear Option state (spec 002, 2026-10-11) | Object ID | Name | Change | | --- | --- | --- | -| `EkcbF` | Screen/Server Detail — Players (Nuclear Option) | New copy of `dPP50` with three player rows: display name plus faction chip, raw Steam ID (muted monospace) plus faction chip, and a named row without a faction. Kick/ban actions unchanged. Games without entries keep the `dPP50` layout. | +| `U9xSg` | Screen/Server Detail — Players (Nuclear Option) | New copy of `dPP50` with three player rows: display name plus faction chip, raw Steam ID (muted monospace) plus faction chip, and a named row without a faction. Kick/ban actions unchanged. Games without entries keep the `dPP50` layout. | -JSON: `Print(JSON.stringify(Get("EkcbF", {depth: 12})))`, `python3 -m json.tool` passes, no `"..."` elisions. PNG: `Export(["EkcbF"], "png", …, {scale: 2})` (2880x1800). Body-text check: `Pilot_Vance` appears only in `EkcbF.json`. +JSON: `Print(JSON.stringify(Get("U9xSg", {depth: 12})))`, `python3 -m json.tool` passes, no `"..."` elisions. PNG: `Export(["U9xSg"], "png", …, {scale: 2})` (2880x1800). Body-text check: `Pilot_Vance` appears only in `U9xSg.json`. ## Incremental export 2026-10-11 — Game config optional password clear state (spec 021 OD-2) @@ -2546,16 +2595,36 @@ JSON: `Print(JSON.stringify(Get("EkcbF", {depth: 12})))`, `python3 -m json.tool` | --- | --- | --- | | `OC804` | Screen/Server Detail — Settings · Game configuration | Stored optional password (untouched): the input sits in an "Input row" beside a ghost "Remove password" button. | | `QFEg9` | … (Invalid value) | Same "Remove password" button added to the stored password row. | -| `h5clan` | … (Password will be removed) | New copy of `OC804`. Danger-outlined input reads "Password will be removed", the button reads "Undo", helper "Removed when you save. Undo to keep the stored password." | -| `ffmEd` | … (New password typed) | New copy of `OC804`. Masked typed value, no remove button, helper "Write-only". | +| `BguST` | … (Password will be removed) | New copy of `OC804`. Danger-outlined input reads "Password will be removed", the button reads "Undo", helper "Removed when you save. Undo to keep the stored password." | +| `YN8cE` | … (New password typed) | New copy of `OC804`. Masked typed value, no remove button, helper "Write-only". | -`Ne5TA` is unaffected. All four have MCP `Get` JSON exports (depth 20, zero elisions; `OC804`/`QFEg9` patched from the prior export with the `Get` result for the changed field subtree) and 2× PNG exports via `Export`. Validated by grepping `Password will be removed` (only `h5clan`) and `Remove password` (`OC804`, `QFEg9`, `h5clan`). +`Ne5TA` is unaffected. All four have MCP `Get` JSON exports (depth 20, zero elisions; `OC804`/`QFEg9` patched from the prior export with the `Get` result for the changed field subtree) and 2× PNG exports via `Export`. Validated by grepping `Password will be removed` (only `BguST`) and `Remove password` (`OC804`, `QFEg9`, `BguST`). ## Incremental export 2026-10-14 — Central management banner on Modules Catalog and Admin Authentication (PR #685) | Node | Name | Change | | --- | --- | --- | -| `kK8Ji` | Screen/Modules Catalog | Re-exported (1440×1320, was 1440×1150). A copy of the Central management notice from `Wj0V4` (`QOOnO`) now sits in Main between the Top Bar and the Body (`I2bQk`). The App Sidebar instance (`DlcuA`, replaces `h2sWTB`) carries the same overrides as `Wj0V4`'s sidebar (`EXGen`): brand subtitle "Central management" and both "Clusters" and "Cluster" under ADMIN, with Modules as the active item. The frame grew 45px for the notice and a further 125px so Row 3 is no longer clipped. | -| `uMiwd` | Screen/Admin Settings — Authentication | Re-exported (1440×1636, was 1440×1591). Same notice copy (`UosI6`) and the same sidebar overrides as `EXGen` (`bbiZQ`, replaces `WrwBN`), with Settings as the active item. The frame grew 45px for the notice. | +| `kK8Ji` | Screen/Modules Catalog | Re-exported (1440×1320, was 1440×1150). A copy of the Central management notice from `Wj0V4` (`QOOnO`) now sits in Main between the Top Bar and the Body (`m7kmp`). The App Sidebar instance (`h2sWTB`, updated in place) carries the same overrides as `Wj0V4`'s sidebar (`EXGen`): brand subtitle "Central management" and both "Clusters" and "Cluster" under ADMIN, with Modules as the active item. The frame grew 45px for the notice and a further 125px so Row 3 is no longer clipped. | +| `uMiwd` | Screen/Admin Settings — Authentication | Re-exported (1440×1636, was 1440×1591). Same notice copy (`svJe1`) and the same sidebar overrides as `EXGen` (`WrwBN`, updated in place), with Settings as the active item. The frame grew 45px for the notice. | The app already renders the notice on `/modules` and `/admin`; the maintainer decided on 2026-10-12 that the design follows the app. The Top Bar and Body subtrees of both frames are unchanged, and no reusable component was touched. JSON: the committed export patched with `Get` results (depth 30, path geometry included) for the sidebar instance and the notice, then checked by length and FNV-1a hash against `JSON.stringify(Get(id, …))` of the whole frame on the canvas; `python3 -m json.tool` passes, zero `"..."` elisions. PNG: `Export(ids, "png", …, {scale: 2})` (2880×2640 and 2880×3272). Body-text check: `"Central management."` appears only in `kK8Ji.json`, `uMiwd.json` and `Wj0V4.json`. + + + +## Merge with upstream master 86cc48c9 — October 14, 2026 + +Preserved all 19 standalone roots and the maintainer follow-up states. The other +upstream exports moved unchanged into assets/design-export. Applied upstream +changes to OC804/QFEg9 (optional password removal), kK8Ji (central management +notice and sidebar), and uMiwd (the same notice/sidebar), retaining their root +IDs. Recreated the three new upstream screens through Pencil insertion, which +assigns fresh IDs; their current canonical IDs are U9xSg (Players, Nuclear +Option), BguST (password will be removed), and YN8cE (new password typed). +Their screenshot-test/spec/manifest references use those IDs. Superseded export +filenames were removed rather than leaving snapshots that imply missing roots. + +All seven touched roots were visually inspected through MCP TakeScreenshot and +re-exported with full Get JSON plus 2x PNGs to assets/design-export. No reusable +component was changed. Modules keeps upstream spacing, filter width, grid +alignment and outline Create action alongside explicit target permissions and +retained-draft error handling. diff --git a/assets/design-export/json/Be7ZW.json b/assets/design-export/json/Be7ZW.json new file mode 100644 index 000000000..d113f232a --- /dev/null +++ b/assets/design-export/json/Be7ZW.json @@ -0,0 +1,178 @@ +{ + "type": "frame", + "id": "Be7ZW", + "x": -12557, + "y": 30632, + "name": "Screen/Standalone — Cluster inventory (No selection)", + "context": "PR #630. Selection is explicit and only registered remotes are listed. No implicit local target; no workload requests until installation and selected target are known.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "Y9a83", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "itf8i", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "C7VwdL", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Cluster" + } + } + }, + { + "type": "frame", + "id": "esSlA", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "EIpc6", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "iLv7K", + "name": "API status", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "yFXL6", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "x5uaL", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "Select cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "c7JcJ5", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "ZYbBc", + "name": "Select cluster guidance", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "Q4HiV", + "name": "Selection title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Select a workload cluster", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "n4V8Q", + "name": "Selection description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Choose a registered cluster to view its node inventory. Register workload clusters on the Clusters page.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] +} + diff --git a/assets/design-export/json/h5clan.json b/assets/design-export/json/BguST.json similarity index 91% rename from assets/design-export/json/h5clan.json rename to assets/design-export/json/BguST.json index 04d681f63..de594a37c 100644 --- a/assets/design-export/json/h5clan.json +++ b/assets/design-export/json/BguST.json @@ -1,6 +1,6 @@ { "type": "frame", - "id": "h5clan", + "id": "BguST", "x": 43120, "y": 11355, "name": "Screen/Server Detail — Settings · Game configuration (Password will be removed)", @@ -13,10 +13,11 @@ "fill": "$background/background", "children": [ { - "id": "W2XAo", + "id": "LuUN8", "type": "ref", "ref": "kKFX9", "height": "fill_container", + "name": "Restored ref", "descendants": { "iTbd8": { "fill": "#00000000" @@ -40,7 +41,7 @@ }, { "type": "frame", - "id": "z9d3P", + "id": "aoYjR", "name": "Main", "width": "fill_container", "height": "fill_container", @@ -48,10 +49,11 @@ "layout": "vertical", "children": [ { - "id": "l19Id", + "id": "qVEH5", "type": "ref", "ref": "gu5WY", "width": "fill_container", + "name": "Restored ref", "descendants": { "Gl7AY": { "content": "mc-survival" @@ -60,7 +62,7 @@ }, { "type": "frame", - "id": "fDTIu", + "id": "j6OM5", "name": "Body", "width": "fill_container", "height": "fill_container", @@ -70,14 +72,14 @@ "padding": 24, "children": [ { - "id": "F25Cp", + "id": "B5Wr9y", "type": "ref", "ref": "S4k0x", "name": "Detail Header", "width": "fill_container" }, { - "id": "EPBOZ", + "id": "pMkLK", "type": "ref", "ref": "I9kvlZ", "name": "Tabs Bar", @@ -105,13 +107,13 @@ }, { "type": "frame", - "id": "rgg36", + "id": "EKV1P", "name": "Settings Content", "width": "fill_container", "height": "fill_container", "children": [ { - "id": "NhLDd", + "id": "NVuLp", "type": "ref", "ref": "EV8mp", "name": "Settings Sub Nav", @@ -236,7 +238,7 @@ }, { "type": "frame", - "id": "eVucv", + "id": "aBxfM", "name": "Settings Panel Wrap", "width": "fill_container", "height": "fill_container", @@ -244,7 +246,7 @@ "children": [ { "type": "frame", - "id": "J2VnM", + "id": "WR2kZ", "name": "Settings Form", "width": "fill_container", "height": "fill_container", @@ -252,7 +254,7 @@ "children": [ { "type": "frame", - "id": "QuP2F", + "id": "EGZKP", "name": "Form Body", "width": "fill_container", "height": "fill_container", @@ -262,7 +264,7 @@ "children": [ { "type": "frame", - "id": "S5mGZ", + "id": "NruaW", "name": "Section Header", "width": "fill_container", "layout": "vertical", @@ -270,7 +272,7 @@ "children": [ { "type": "text", - "id": "z5KwZN", + "id": "Y52c9", "name": "Section Title", "fill": "$foreground/foreground", "content": "Game configuration", @@ -280,7 +282,7 @@ }, { "type": "text", - "id": "SICLk", + "id": "D4JKs", "name": "Section Description", "fill": "$muted", "content": "Settings from this server's game template. Saving restarts the server.", @@ -291,7 +293,7 @@ ] }, { - "id": "i5kSI", + "id": "c0F4nN", "type": "ref", "ref": "uJ3KZ", "name": "Restart Alert", @@ -307,14 +309,14 @@ }, { "type": "frame", - "id": "D2XSw", + "id": "AiAbk", "name": "Field World name", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "aLFDZ", + "id": "wXEkG", "name": "lbl", "width": 200, "layout": "vertical", @@ -322,7 +324,7 @@ "children": [ { "type": "text", - "id": "wa9uX", + "id": "agibC", "name": "Label", "fill": "$foreground/foreground", "content": "World name", @@ -334,14 +336,14 @@ }, { "type": "frame", - "id": "pHuja", + "id": "Bv61i", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "Etrig", + "id": "VXtHn", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -359,14 +361,14 @@ }, { "type": "frame", - "id": "OwRSn", + "id": "V0JLk", "name": "Field Difficulty", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "vmud3", + "id": "Nx9st", "name": "lbl", "width": 200, "layout": "vertical", @@ -374,7 +376,7 @@ "children": [ { "type": "text", - "id": "vToUF", + "id": "n2J6yD", "name": "Label", "fill": "$foreground/foreground", "content": "Difficulty", @@ -386,14 +388,14 @@ }, { "type": "frame", - "id": "M54st4", + "id": "jYBxI", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "Dt1fG", + "id": "RuWUk", "type": "ref", "ref": "AT7ya", "name": "Select", @@ -414,14 +416,14 @@ }, { "type": "frame", - "id": "SzlMN", + "id": "g4qdfR", "name": "Field Max players", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "e50P75", + "id": "mxs97", "name": "lbl", "width": 200, "layout": "vertical", @@ -429,7 +431,7 @@ "children": [ { "type": "text", - "id": "r8ZQWt", + "id": "f3RaF", "name": "Label", "fill": "$foreground/foreground", "content": "Max players", @@ -441,14 +443,14 @@ }, { "type": "frame", - "id": "X9Pvl", + "id": "bDhXp", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "s43pHd", + "id": "o8uLNg", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -466,14 +468,14 @@ }, { "type": "frame", - "id": "X0E8H", + "id": "OYF4O", "name": "Field PvP", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "kSnF3", + "id": "rh0jC", "name": "lbl", "width": 200, "layout": "vertical", @@ -481,7 +483,7 @@ "children": [ { "type": "text", - "id": "j4lfR", + "id": "mmwLw", "name": "Label", "fill": "$foreground/foreground", "content": "PvP", @@ -493,14 +495,14 @@ }, { "type": "frame", - "id": "AwdJJ", + "id": "RrUyM", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "W0Foz", + "id": "KdXb9", "type": "ref", "ref": "AT7ya", "name": "Select", @@ -521,14 +523,14 @@ }, { "type": "frame", - "id": "sKu9v", + "id": "KhtIr", "name": "Field Server password", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "Kz4wH", + "id": "g0AGoR", "name": "lbl", "width": 200, "layout": "vertical", @@ -536,7 +538,7 @@ "children": [ { "type": "text", - "id": "TxWfa", + "id": "o2MKya", "name": "Label", "fill": "$foreground/foreground", "content": "Server password", @@ -548,7 +550,7 @@ }, { "type": "frame", - "id": "xC231", + "id": "LtCR3", "name": "ctrl", "width": "fill_container", "layout": "vertical", @@ -556,14 +558,14 @@ "children": [ { "type": "frame", - "id": "pkqQ8", + "id": "swOk7", "name": "Input row", "width": "fill_container", "gap": 8, "alignItems": "center", "children": [ { - "id": "L9xAIS", + "id": "kxEL9", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -577,7 +579,7 @@ } }, { - "id": "FUHzm", + "id": "JkJXW", "type": "ref", "ref": "J09iP", "name": "Remove password button", @@ -591,7 +593,7 @@ }, { "type": "text", - "id": "Eb0j0", + "id": "JWDSU", "name": "Helper", "fill": "$danger/danger", "content": "Removed when you save. Undo to keep the stored password.", @@ -605,14 +607,14 @@ }, { "type": "frame", - "id": "ITufX", + "id": "feyyB", "name": "Field MOTD", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "j4C93", + "id": "LPPW6", "name": "lbl", "width": 200, "layout": "vertical", @@ -620,7 +622,7 @@ "children": [ { "type": "text", - "id": "J5PPz", + "id": "oFik0", "name": "Label", "fill": "$foreground/foreground", "content": "MOTD", @@ -632,14 +634,14 @@ }, { "type": "frame", - "id": "K3aqU", + "id": "z7eB6e", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "Gotg9", + "id": "QMXDP", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -653,7 +655,7 @@ }, { "type": "text", - "id": "GPjVy", + "id": "m0bl6", "name": "Helper", "fill": "$muted", "content": "Written to serverconfig.txt", @@ -669,7 +671,7 @@ }, { "type": "frame", - "id": "TY24A", + "id": "n007q", "name": "Form Footer", "width": "fill_container", "stroke": "$border/border", @@ -687,7 +689,7 @@ "alignItems": "center", "children": [ { - "id": "e35wn", + "id": "I0wipM", "type": "ref", "ref": "J09iP", "name": "Btn Discard", @@ -698,7 +700,7 @@ } }, { - "id": "S04hZr", + "id": "bAJYA", "type": "ref", "ref": "z9ShNE", "name": "Btn Save Changes", diff --git a/assets/design-export/json/C9bR6.json b/assets/design-export/json/C9bR6.json new file mode 100644 index 000000000..73cc125ff --- /dev/null +++ b/assets/design-export/json/C9bR6.json @@ -0,0 +1,298 @@ +{ + "id": "C9bR6", + "type": "ref", + "ref": "x3beP", + "name": "Gameplane/Dialog/Edit user — Standalone (Remote grant)", + "x": -14157, + "y": 34832, + "theme": { + "semantic": "dark" + }, + "width": 480, + "context": "PR #630. Only registered remotes appear as workload grant targets. Primary binding is panel access. workload-viewer is a sample role containing workload permissions only. All namespaces must reject roles containing central administration permissions; Add is disabled for unknown or missing targets.", + "descendants": { + "E7Whx": { + "content": "Edit user" + }, + "qzcst": { + "content": "operator-01" + }, + "Nht52": { + "type": "frame", + "id": "VTnn4", + "name": "User fields and remote grants", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "frame", + "id": "cvRRo", + "name": "Display name field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "daXHP", + "name": "Display name label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Display name", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "bnmSQ", + "type": "ref", + "ref": "D0cDM", + "name": "Display name value", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "Server Operator", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "GQKvB", + "name": "Email field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "E68vT", + "name": "Email label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Email", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "dlcki", + "type": "ref", + "ref": "D0cDM", + "name": "Email value", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "operator@example.test", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "YGebt", + "name": "Primary role (cluster-wide) field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "fKaDX", + "name": "Primary role (cluster-wide) label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Primary role (cluster-wide)", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "NDN3M", + "type": "ref", + "ref": "AT7ya", + "name": "Primary role (cluster-wide) value", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "operator", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "WKq0E", + "name": "Grant separator", + "width": "fill_container", + "height": 1, + "fill": "$border/border" + }, + { + "type": "text", + "id": "z3zvqX", + "name": "Grant heading", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "CLUSTER & NAMESPACE GRANTS", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ib7po", + "name": "Grant scope explanation", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The primary role above manages panel access. Additional grants apply only to their named cluster and namespace.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "TMJ5E", + "name": "Primary panel binding", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "operator in all namespaces on panel\nPrimary role (managed above)", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "nEG0s", + "name": "Remote target and role", + "width": "fill_container", + "gap": 8, + "children": [ + { + "id": "g3poUP", + "type": "ref", + "ref": "AT7ya", + "name": "Grant cluster", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "East production (east)" + } + } + }, + { + "id": "Qnadg", + "type": "ref", + "ref": "AT7ya", + "name": "Grant role", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "workload-viewer" + } + } + } + ] + }, + { + "type": "frame", + "id": "JnTPd", + "name": "Namespace grant actions", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "zj1aN", + "type": "ref", + "ref": "D0cDM", + "name": "Grant namespace", + "width": "fill_container", + "height": 32, + "descendants": { + "PJERm": { + "content": "*" + } + } + }, + { + "id": "k0ziBt", + "type": "ref", + "ref": "i6gfu", + "name": "All namespaces", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "All namespaces" + } + } + }, + { + "id": "i6XYjB", + "type": "ref", + "ref": "i6gfu", + "name": "Add", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Add" + } + } + } + ] + }, + { + "type": "text", + "id": "Z3OG8d", + "name": "Inventory grant guidance", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "For remote node inventory, create a role in the Roles tab with “View nodes, version, and storage”, then grant it to all namespaces on that cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + "V7HYp": { + "opacity": 1 + }, + "TZ7Ef": { + "content": "Save changes" + } + } +} + diff --git a/assets/design-export/json/D9QYZ.json b/assets/design-export/json/D9QYZ.json new file mode 100644 index 000000000..f5927ffc1 --- /dev/null +++ b/assets/design-export/json/D9QYZ.json @@ -0,0 +1,272 @@ +{ + "type": "frame", + "id": "D9QYZ", + "x": -17357, + "y": 33432, + "name": "Screen/Standalone — Settings · Backup destinations", + "context": "PR #630 standalone installation. No local Kubernetes metadata or local workload fallback. Master-key storage and offline rotation have no dashboard controls.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "amVFQ", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Central management" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "u9vMl", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "LXGCU", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "s6Mzg", + "name": "Central management notice", + "width": "fill_container", + "fill": "$surface/surface", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "layout": "vertical", + "padding": [ + 12, + 24 + ], + "children": [ + { + "type": "text", + "id": "QQ1lm", + "name": "Central management scope", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "LBC1p", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "Z9H5G0", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "L9E1S", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "NK9SS", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "fOAMd": { + "fill": "$surface/secondary" + } + } + }, + { + "type": "frame", + "id": "v5zYl", + "name": "Workload target", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "Z8ppQk", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "sEwer", + "name": "API status", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "Y9yBT", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "kJRiP", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "Select cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "j47im", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "ZRrXg", + "name": "No workload target", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "hOirG", + "name": "Selection title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Select a workload cluster", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "YXyzH", + "name": "Selection subtitle", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Backup destinations are stored on the workload cluster that runs your servers.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "IwTZ0", + "name": "Selection description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Choose a registered cluster above, or register one on the Clusters page.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} + diff --git a/assets/design-export/json/EWrn0.json b/assets/design-export/json/EWrn0.json new file mode 100644 index 000000000..1995342df --- /dev/null +++ b/assets/design-export/json/EWrn0.json @@ -0,0 +1,3293 @@ +{ + "type": "frame", + "id": "EWrn0", + "x": -10957, + "y": 34832, + "name": "Screen/Standalone — Modules (East selected)", + "context": "Background refetch failures display an inline retry banner and retain existing data plus open install/upload/builder/source drafts. Only an initial failure replaces loading content. Explicit target changes discard drafts.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1900, + "fill": "$background/background", + "children": [ + { + "id": "zaoyJ", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "sTSGR": { + "fill": "$accent/soft" + }, + "sTSGR/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "sTSGR/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "VcdIX", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "t2qdy", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Modules" + } + } + }, + { + "type": "frame", + "id": "SgTSF", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "P4F1bF", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "hbyla", + "name": "API status", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "E2XJ2", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "nTkwE", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "East production", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "MNnA6", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "id": "q0nYXF", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Modules" + }, + "VbHwU": { + "content": "Pre-packaged game-server templates pulled from your configured module sources." + }, + "TgdLz": { + "type": "frame", + "id": "h8BsNj", + "name": "phActions", + "height": 36, + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "UEw9g", + "type": "ref", + "ref": "i6gfu", + "name": "btnCreate", + "descendants": { + "FCYQ9": { + "icon": "plus" + }, + "GqVun": { + "content": "Create module" + } + } + }, + { + "id": "TXKLW", + "type": "ref", + "ref": "i6gfu", + "name": "btnUpload", + "descendants": { + "FCYQ9": { + "icon": "upload" + }, + "GqVun": { + "content": "Upload module" + } + } + }, + { + "id": "SCjB3", + "type": "ref", + "ref": "i6gfu", + "name": "btnSources", + "descendants": { + "FCYQ9": { + "icon": "settings-2" + }, + "GqVun": { + "content": "Manage sources" + } + } + } + ] + } + } + }, + { + "type": "frame", + "id": "ySjMT", + "name": "Background refresh error with retained draft", + "width": "fill_container", + "height": 64, + "fill": "$surface/surface", + "gap": 16, + "padding": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Q2MWK", + "name": "Refresh error", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Failed to load module catalog.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "jrRAt", + "type": "ref", + "ref": "FIB65", + "name": "Retry request", + "height": 32, + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Retry" + } + } + } + ] + }, + { + "type": "frame", + "id": "lRXRg", + "name": "Filters", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Pi5Bp", + "name": "srcGroup", + "fill": "$surface/surface", + "cornerRadius": 6, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 4, + "padding": 4, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "s6nQs", + "name": "srcAll", + "fill": "$accent/soft", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "nKoFm", + "fill": "$accent/accent", + "content": "All sources", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "GS0Vn", + "name": "srcDefault", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "nSMft", + "fill": "$muted", + "content": "default", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "G7SLsf", + "name": "srcCommunity", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Azuhv", + "fill": "$muted", + "content": "community", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "dPEXn", + "name": "catGroup", + "clip": true, + "width": 440, + "height": 36, + "fill": "$surface/surface", + "cornerRadius": 6, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 4, + "padding": 4, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "TX7BU", + "name": "catAllcategories", + "fill": "#00000000", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "MQMLR", + "name": "Label", + "fill": "$muted", + "content": "All categories", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "hPCOz", + "name": "catAdventure", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dxKOt", + "name": "Label", + "fill": "$muted", + "content": "Adventure", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "lkwX5", + "name": "catBuilding", + "fill": "$accent/soft", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WDbWy", + "name": "Label", + "fill": "$accent/accent", + "content": "Building", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "R1nKb", + "name": "catCoop", + "fill": "$accent/soft", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lM1p6", + "name": "Label", + "fill": "$accent/accent", + "content": "Co-op", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "bV13f", + "name": "catCreative", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "g0Sdk", + "name": "Label", + "fill": "$muted", + "content": "Creative", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "QMcFw", + "name": "catHorror", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "n1Wnd", + "name": "Label", + "fill": "$muted", + "content": "Horror", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "dE5WN", + "name": "catModded", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "PwJ7b", + "name": "Label", + "fill": "$muted", + "content": "Modded", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "HzSXR", + "name": "catPvP", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "h6SIMc", + "name": "Label", + "fill": "$muted", + "content": "PvP", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "a6OiDZ", + "name": "catSandbox", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "TDLxy", + "name": "Label", + "fill": "$muted", + "content": "Sandbox", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "uAqBo", + "name": "catShooter", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ODmPM", + "name": "Label", + "fill": "$muted", + "content": "Shooter", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "sgYsv", + "name": "catSimulation", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "PNKVY", + "name": "Label", + "fill": "$muted", + "content": "Simulation", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "gXI40", + "name": "catSurvival", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "UnCsA", + "name": "Label", + "fill": "$muted", + "content": "Survival", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "rectangle", + "cornerRadius": [ + 0, + 6, + 6, + 0 + ], + "id": "wsANd", + "layoutPosition": "absolute", + "x": 384, + "y": 0, + "name": "Fade Right", + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 270, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A1A00", + "position": 0 + }, + { + "color": "$surface/surface", + "position": 1 + } + ] + }, + "width": 56, + "height": 36 + } + ] + }, + { + "type": "frame", + "id": "FnTh4", + "name": "spacer", + "width": "fill_container", + "height": 1 + }, + { + "id": "RG2PH", + "type": "ref", + "ref": "Lmaf1", + "name": "Search", + "width": 256, + "descendants": { + "EJMtX": { + "content": "Search modules…" + } + } + } + ] + }, + { + "type": "frame", + "id": "hzKUQ", + "name": "Row 1", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "BCrCb", + "name": "mc1", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "Rw7i9", + "name": "mc1h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "haZF9", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$success/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "bsT96", + "fill": "$success/soft-foreground", + "content": "MI", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Pe1CO", + "name": "mc1Badges", + "gap": 6, + "alignItems": "center", + "children": [ + { + "id": "i8tkn", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "type": "frame", + "id": "RNsOW", + "name": "IconLabel", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "vIqo1", + "width": 12, + "height": 12, + "icon": "shield-check", + "library": "lucide", + "fill": "$success/soft-foreground" + }, + { + "type": "text", + "id": "a9LP0N", + "name": "Label", + "fill": "$success/soft-foreground", + "content": "VERIFIED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + } + }, + { + "id": "FHgzd", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "vMPO6", + "name": "mc1Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "kkWNN", + "fill": "$foreground/foreground", + "content": "Minecraft (Java Edition)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "glS31", + "fill": "$muted", + "content": "v1.21.4 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "djI4H", + "name": "mc1Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Z7pgt", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "H9sXzt", + "fill": "$muted", + "content": "sha256:a1b2c3d4…9e2f", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "oSprH", + "name": "mc1Rollback", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "f1y07t", + "width": 12, + "height": 12, + "icon": "history", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "X6ujHo", + "fill": "$muted", + "content": "rollback target · v1.21.3", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "RmOZQ", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "GaEqQ", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + }, + { + "id": "XGbIN", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "t5biUC", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + } + ] + }, + { + "type": "text", + "id": "fodxs", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Vanilla / Paper / Spigot / Bukkit / Purpur / Forge / NeoForge / Fabric / Quilt servers with a version+loader picker and in-app Modrinth browse.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "j0YHk", + "name": "mc1f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "XLqau", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "pcgth", + "name": "mc1Btns", + "layout": "vertical", + "gap": 4, + "alignItems": "end", + "children": [ + { + "id": "TE5Bx", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to East production" + } + } + }, + { + "id": "W6xCJ5", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "hERwe", + "name": "mc2", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "kXi6M", + "name": "mc2h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "a1h6Ig", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$success/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "JornN", + "fill": "$success/soft-foreground", + "content": "MI", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "oxcwz", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "GHHz0", + "name": "mc2Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "cooKT", + "fill": "$foreground/foreground", + "content": "Minecraft (Bedrock)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "vCh84", + "fill": "$muted", + "content": "v1.21.5 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "TX2G5", + "name": "mc2Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "iB5L3", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "ya51K", + "fill": "$muted", + "content": "sha256:5f0ab913…c47d", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "b6QHhA", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "NuYWm", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + }, + { + "id": "UWyS3", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "C3o0y", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Creative", + "descendants": { + "r4GQ9": { + "content": "Creative" + } + } + } + ] + }, + { + "type": "text", + "id": "EMvTt", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Cross-platform edition for mobile, console and Windows clients. Allow-list and permissions managed from the wizard.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "elylQ", + "name": "mc2f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "iMHIZ", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "P4r07", + "name": "mc2Btns", + "layout": "vertical", + "gap": 4, + "alignItems": "end", + "children": [ + { + "id": "zljEL", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to East production" + } + } + }, + { + "id": "If2a1", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "f2WWnE", + "name": "mc3", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "L0X72", + "name": "mc3h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "dVuQ0", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$warning/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cApsW", + "fill": "$warning/soft-foreground", + "content": "VA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "k5zck", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "Iycls", + "name": "mc3Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "sHUcT", + "fill": "$foreground/foreground", + "content": "Valheim (Dedicated)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "YC8CL", + "fill": "$muted", + "content": "v2.3.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "z3yEy", + "name": "mc3Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Egf82", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "Pxd1z", + "fill": "$muted", + "content": "sha256:9c14e07b…3ba8", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "oVpos", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "rQ9mZ", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "yI4Ws", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Co-op", + "descendants": { + "r4GQ9": { + "content": "Co-op" + } + } + }, + { + "id": "MAr14", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + } + ] + }, + { + "type": "text", + "id": "xhJgt", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Valheim dedicated server (lloesche/valheim-server) with a stable/public-test channel picker, a BepInEx mod volume, and in-app Thunderstore browse. Crossplay-friendly.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "NwuXn", + "name": "mc3f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "DJCt9", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "KtH2v", + "name": "mc3Btns", + "layout": "vertical", + "gap": 4, + "alignItems": "end", + "children": [ + { + "id": "P9kUxK", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to East production" + } + } + }, + { + "id": "bcH2d", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "NA6mR", + "name": "mc4", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "JTrti", + "name": "mc4h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "C5wlgq", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$accent/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pdQJL", + "fill": "$accent/accent", + "content": "FA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "wvOjg", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "BpAMw", + "name": "mc4Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "WPiYJ", + "fill": "$foreground/foreground", + "content": "Factorio (Headless)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "rxhFg", + "fill": "$muted", + "content": "v1.1.0", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "z7XqJ2", + "name": "mc4Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "rVSoL", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "rWVTa", + "fill": "$muted", + "content": "sha256:7d32e46a…1161", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "j1l8I6", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "eRrkk", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Simulation", + "descendants": { + "r4GQ9": { + "content": "Simulation" + } + } + }, + { + "id": "jkIqr", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + }, + { + "id": "KXKvS", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + } + ] + }, + { + "type": "text", + "id": "P69knx", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Factorio headless server (factoriotools/factorio) with a stable/latest/pinned channel picker, server-settings managed from the wizard, and a mods volume with in-app Factorio mod portal browse.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "HeLCi", + "name": "mc4f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Y3r3AB", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "uM7Xe", + "name": "mc4Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "bTm7i", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "vDJLX", + "name": "Row 2", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "NeXJU", + "name": "mc5", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "gGhsi", + "name": "mc5h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rzWEK", + "name": "tile", + "width": 36, + "height": 36, + "fill": "#895AF633", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HgYDr", + "fill": "#895AF6", + "content": "PA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "s3J8Z", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "HJMo3", + "name": "mc5Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "j2mmb", + "fill": "$foreground/foreground", + "content": "Palworld (Dedicated)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "pVjgV", + "fill": "$muted", + "content": "v1.1.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "QO5Jt", + "name": "mc5Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "YSOHi", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "j13hF", + "fill": "$muted", + "content": "sha256:2e91fa04…8d17", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "N7Snz", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "RfE1q", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "L4VM73", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Co-op", + "descendants": { + "r4GQ9": { + "content": "Co-op" + } + } + }, + { + "id": "gNcBT", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + } + ] + }, + { + "type": "text", + "id": "C7yvMg", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Palworld dedicated server (thijsvanloef/palworld-server-docker) with RCON console, save-aware graceful stop, and a .pak mod volume with archive install.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "I51r9E", + "name": "mc5f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "d6EPRJ", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Cmaj5", + "name": "mc5Btns", + "layout": "vertical", + "gap": 4, + "alignItems": "end", + "children": [ + { + "id": "j2yeju", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to East production" + } + } + }, + { + "id": "hDZKa", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "R7HxH", + "name": "mc6", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "xluPy", + "name": "mc6h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "HZ0sa", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$danger/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zxevY", + "fill": "$danger/soft-foreground", + "content": "AR", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "f6EW7", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "yh0Be", + "name": "mc6Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "RxHCA", + "fill": "$foreground/foreground", + "content": "ARK: Survival Evolved", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "PZQ8j", + "fill": "$muted", + "content": "v0.9.2", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "nGrkk", + "name": "mc6Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "QhUUd", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "rECBN", + "fill": "$muted", + "content": "sha256:b7440ce1…05af", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "JxxJf", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "NCdnR", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "q6Ln7F", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + }, + { + "id": "tu6Hu", + "type": "ref", + "ref": "Y43SFn", + "name": "cat PvP", + "descendants": { + "r4GQ9": { + "content": "PvP" + } + } + } + ] + }, + { + "type": "text", + "id": "NNpph", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Dinosaur survival on the Unreal engine. Mod install via the Steam workshop; schedule onto a GPU node for acceptable tick rates.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Kex9B", + "name": "mc6f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "u3CZ77", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "q9Og6", + "name": "mc6Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "Bv55w", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "uMr7p", + "name": "mc7", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "C9vJKc", + "name": "mc7h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "E3dmRR", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$success/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "S7QRzt", + "fill": "$success/soft-foreground", + "content": "TE", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "a0KqNB", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "d1BQAV", + "name": "mc7Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "vJjJb", + "fill": "$foreground/foreground", + "content": "Terraria", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "Hnfya", + "fill": "$muted", + "content": "v2.1.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "P1MQ9", + "name": "mc7Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "omb9D", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "E0xDN", + "fill": "$muted", + "content": "sha256:c805d7f2…46e9", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "rCIdU", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "zXQZf", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + }, + { + "id": "jcTbA", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "d7MLe", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Adventure", + "descendants": { + "r4GQ9": { + "content": "Adventure" + } + } + } + ] + }, + { + "type": "text", + "id": "nIdd4", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Terraria + tModLoader dedicated server (passivelemon/terraria-docker) with a vanilla/tModLoader version picker and a tModLoader ModPacks volume. Console via container stdin.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "KRDvM", + "name": "mc7f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "t4Zan", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "IkPSv", + "name": "mc7Btns", + "layout": "vertical", + "gap": 4, + "alignItems": "end", + "children": [ + { + "id": "o1D6u", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to East production" + } + } + }, + { + "id": "nihHT", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "eGBhX", + "name": "mc8", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "NCgBk", + "name": "mc8h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "uYuR0", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$warning/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "UVI8H", + "fill": "$warning/soft-foreground", + "content": "CO", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "W2kYRR", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "wMiwK", + "name": "mc8Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "m1FoHp", + "fill": "$foreground/foreground", + "content": "Counter-Strike 2", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "Ylyst", + "fill": "$muted", + "content": "v0.4.1", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "uOeXZ", + "name": "mc8Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "J8kMOv", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "FtORK", + "fill": "$muted", + "content": "sha256:1a6be390…f72c", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "aZNhK", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "DLxem", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Shooter", + "descendants": { + "r4GQ9": { + "content": "Shooter" + } + } + }, + { + "id": "LW3Rz", + "type": "ref", + "ref": "Y43SFn", + "name": "cat PvP", + "descendants": { + "r4GQ9": { + "content": "PvP" + } + } + } + ] + }, + { + "type": "text", + "id": "cBceH", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Competitive shooter with MatchZy and CounterStrikeSharp plugin support. Game-server login token supplied as a secret.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "I8umeK", + "name": "mc8f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "NaGSd", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "v6v7X", + "name": "mc8Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "nbbBo", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zZFc5", + "name": "Row 3", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "M68n5", + "name": "mc9", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "rsmuw", + "name": "mc9h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "vXsFx", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$danger/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "oCZ18", + "fill": "$danger/soft-foreground", + "content": "RU", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "wAZnl", + "name": "mc9Badges", + "gap": 6, + "alignItems": "center", + "children": [ + { + "id": "P3NTh", + "type": "ref", + "ref": "a3AjAZ", + "name": "Badge", + "descendants": { + "F9x0p": { + "type": "frame", + "id": "CwAPj", + "name": "IconLabel", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "ymFze", + "width": 12, + "height": 12, + "icon": "shield-question", + "library": "lucide", + "fill": "$success/success" + }, + { + "type": "text", + "id": "AylTm", + "name": "Label", + "fill": "$success/success", + "content": "POLICY", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + } + }, + { + "id": "N0bvy", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "N0dqF", + "name": "mc9Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "g0ZcSO", + "fill": "$foreground/foreground", + "content": "Rust", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "TEB97", + "fill": "$muted", + "content": "v0.7.3", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "YjXtM", + "name": "mc9Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "LCrSR", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "PZwFZ", + "fill": "$muted", + "content": "sha256:3fd0817c…ba24", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ViBDp", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "K5XFp", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "VyNne", + "type": "ref", + "ref": "Y43SFn", + "name": "cat PvP", + "descendants": { + "r4GQ9": { + "content": "PvP" + } + } + }, + { + "id": "W133T", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + } + ] + }, + { + "type": "text", + "id": "PhGiP", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Hardcore multiplayer survival with Oxide and Carbon mod loaders. Monthly forced wipe; schedule a backup before each one.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "o98v54", + "name": "mc9f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "B4Esb", + "fill": "$muted", + "content": "community (oci)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "OzUxH", + "name": "mc9Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "cgHbS", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "AUaNF", + "name": "mc10", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "AKk8A", + "name": "mc10h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "OFa1R", + "name": "tile", + "width": 36, + "height": 36, + "fill": "#9494944D", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mpTKp", + "fill": "$muted", + "content": "7-", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "WVhdW", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "B2JMAN", + "name": "mc10Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "q6LCmJ", + "fill": "$foreground/foreground", + "content": "7 Days to Die", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "RcwL2", + "fill": "$muted", + "content": "v0.5.0", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "XMJ92", + "name": "mc10Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "dXoJj", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "g3xhEU", + "fill": "$muted", + "content": "sha256:e2c93b58…7a0d", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "s13ktJ", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "mF7rL", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "m0DemM", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Horror", + "descendants": { + "r4GQ9": { + "content": "Horror" + } + } + }, + { + "id": "u7uJa", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + } + ] + }, + { + "type": "text", + "id": "QbRP4", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Zombie survival crafting in a destructible voxel world. Blood-moon horde nights are CPU-spiky — give it headroom.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "rKsSc", + "name": "mc10f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "IcZFB", + "fill": "$muted", + "content": "community (oci)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "g2efJH", + "name": "mc10Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "iTW6E", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "W8Xax", + "name": "mc11", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "w8J7PW", + "name": "mc11h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "wa89S", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$accent/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ztZ6y", + "fill": "$accent/accent", + "content": "SA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "Fa8wc", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "P8xhy", + "name": "mc11Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "RyheW", + "fill": "$foreground/foreground", + "content": "Satisfactory (Dedicated)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "cDx5s", + "fill": "$muted", + "content": "v1.0.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "p9pK9", + "name": "mc11Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "u46f4B", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "ORATC", + "fill": "$muted", + "content": "sha256:4b7ecf19…d3e6", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "uzh9z", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "h5YHb", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Simulation", + "descendants": { + "r4GQ9": { + "content": "Simulation" + } + } + }, + { + "id": "U3QXti", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + }, + { + "id": "CsLAC", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + } + ] + }, + { + "type": "text", + "id": "fBpsd", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Satisfactory dedicated server (wolveix/satisfactory-server) with a stable/experimental branch picker. Env-configured; installs via steamcmd on first boot.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "G0nj3G", + "name": "mc11f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "NprMT", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "V7UXSy", + "name": "mc11Btns", + "layout": "vertical", + "gap": 4, + "alignItems": "end", + "children": [ + { + "id": "cquRY", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to East production" + } + } + }, + { + "id": "p2JMEL", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "bWvsv", + "name": "gridFiller", + "width": "fill_container", + "height": 1 + } + ] + } + ] + } + ] + } + ] +} diff --git a/assets/design-export/json/EkcbF.json b/assets/design-export/json/EkcbF.json deleted file mode 100644 index 03b526583..000000000 --- a/assets/design-export/json/EkcbF.json +++ /dev/null @@ -1 +0,0 @@ -{"type":"frame","id":"EkcbF","x":24240,"y":9175,"name":"Screen/Server Detail — Players (Nuclear Option)","theme":{"semantic":"dark"},"clip":true,"width":1440,"height":900,"fill":"$background/background","children":[{"id":"OgNOa","type":"ref","ref":"kKFX9","name":"App Sidebar","height":"fill_container","descendants":{"iTbd8":{"fill":"#00000000"},"iTbd8/ghUsw":{"fill":"$foreground/foreground"},"iTbd8/bpSQ7":{"fill":"$foreground/foreground"},"i8Z1B":{"fill":"$accent/soft"},"i8Z1B/Sr2Q5":{"fill":"$accent/soft-foreground"},"i8Z1B/Ra9NK":{"fill":"$accent/soft-foreground"}}},{"type":"frame","id":"Rp6DU","name":"Main","width":"fill_container","height":"fill_container","fill":"$background/background","layout":"vertical","children":[{"id":"YvEX0","type":"ref","ref":"gu5WY","name":"Top Bar","width":"fill_container","descendants":{"Gl7AY":{"content":"mc-survival"}}},{"id":"ziiI3","type":"ref","ref":"S4k0x","name":"Detail Header","width":"fill_container"},{"id":"CZGgf","type":"ref","ref":"I9kvlZ","name":"Tabs Bar","width":"fill_container","padding":[0,24],"descendants":{"Wp6q1":{"fill":"#00000000"},"Wp6q1/YUR8N":{"fill":"$muted"},"hNitQ":{"fill":"$segment"},"hNitQ/VcxMI":{"fill":"$foreground/segment"}}},{"type":"frame","id":"kSV4O","name":"Body","width":"fill_container","height":"fill_container","fill":"$background/background","layout":"vertical","gap":24,"padding":24,"children":[{"type":"frame","id":"qaOgu","name":"Stat Cards Row","width":"fill_container","gap":12,"children":[{"id":"WzVvI","type":"ref","ref":"ZWcwn","name":"Stat Online","width":"fill_container","descendants":{"Nd4Wf":{"content":"ONLINE"},"QdcEm":{"icon":"users","fill":"$success/success","library":"lucide","enabled":false},"vj2Ru":{"content":"3 / 16"},"h0n7zo":{"enabled":false}}},{"id":"e6vjE","type":"ref","ref":"ZWcwn","name":"Stat Whitelisted","width":"fill_container","descendants":{"Nd4Wf":{"content":"WHITELISTED"},"QdcEm":{"icon":"list-checks","fill":"$accent/accent","library":"lucide","enabled":false},"vj2Ru":{"content":"1"},"h0n7zo":{"enabled":false}}},{"id":"t6W6Ha","type":"ref","ref":"ZWcwn","name":"Stat Banned","width":"fill_container","descendants":{"Nd4Wf":{"content":"BANNED"},"QdcEm":{"icon":"ban","fill":"#DC2626","library":"lucide","enabled":false},"vj2Ru":{"content":"0"},"h0n7zo":{"enabled":false}}}]},{"type":"frame","id":"Fd8Uh","name":"Online Header","width":"fill_container","justifyContent":"space_between","alignItems":"center","children":[{"type":"text","id":"hq3TE","name":"onlineCount","fill":"$muted","content":"3 / 16 online","fontFamily":"$typography/font-sans","fontSize":14,"fontWeight":"normal"},{"id":"uZt1s","type":"ref","ref":"J09iP","name":"Btn Refresh","descendants":{"eWkIT/WtPjS":{"icon":"refresh-cw"},"eWkIT/r4VbAi":{"content":"Refresh"}}}]},{"type":"frame","id":"FFLq9","name":"Player Rows","width":"fill_container","layout":"vertical","gap":4,"children":[{"type":"frame","id":"CU5YB","name":"Player Row — Pilot_Vance","width":"fill_container","fill":"$surface/surface","cornerRadius":8,"stroke":"$border/border","strokeWidth":1,"strokeAlignment":"inner","gap":8,"padding":[8,12],"justifyContent":"space_between","alignItems":"center","children":[{"type":"frame","id":"U4JCYZ","name":"Identity","gap":12,"alignItems":"center","children":[{"type":"text","id":"jazOT","name":"Player Name","fill":"$foreground/foreground","content":"Pilot_Vance","fontFamily":"$typography/font-mono","fontSize":14,"fontWeight":"normal"},{"type":"frame","id":"o4KOO","name":"Faction Chip","fill":"$accent/soft","cornerRadius":999,"padding":[2,8],"children":[{"type":"text","id":"g29s1","name":"Faction","fill":"$accent/soft-foreground","content":"Boscali","fontFamily":"$typography/font-sans","fontSize":12,"fontWeight":"normal"}]}]},{"type":"frame","id":"lcV26","name":"Actions","gap":4,"children":[{"type":"icon","id":"QHLxS","name":"Kick","width":16,"height":16,"icon":"user-minus","library":"lucide","fill":"$muted"},{"type":"icon","id":"Yg6O6","name":"Ban","width":16,"height":16,"icon":"ban","library":"lucide","fill":"$muted"}]}]},{"type":"frame","id":"n4v2g","name":"Player Row — 76561198012345678","width":"fill_container","fill":"$surface/surface","cornerRadius":8,"stroke":"$border/border","strokeWidth":1,"strokeAlignment":"inner","gap":8,"padding":[8,12],"justifyContent":"space_between","alignItems":"center","children":[{"type":"frame","id":"A5Zm3","name":"Identity","gap":12,"alignItems":"center","children":[{"type":"text","id":"y5jU7","name":"Player Name","fill":"$muted","content":"76561198012345678","fontFamily":"$typography/font-mono","fontSize":14,"fontWeight":"normal"},{"type":"frame","id":"XA9al","name":"Faction Chip","fill":"$warning/soft","cornerRadius":999,"padding":[2,8],"children":[{"type":"text","id":"c4KnH","name":"Faction","fill":"$warning/soft-foreground","content":"Primeva","fontFamily":"$typography/font-sans","fontSize":12,"fontWeight":"normal"}]}]},{"type":"frame","id":"FcXFL","name":"Actions","gap":4,"children":[{"type":"icon","id":"bNGuv","name":"Kick","width":16,"height":16,"icon":"user-minus","library":"lucide","fill":"$muted"},{"type":"icon","id":"rW6uh","name":"Ban","width":16,"height":16,"icon":"ban","library":"lucide","fill":"$muted"}]}]},{"type":"frame","id":"BZT9r","name":"Player Row — Corvus","width":"fill_container","fill":"$surface/surface","cornerRadius":8,"stroke":"$border/border","strokeWidth":1,"strokeAlignment":"inner","gap":8,"padding":[8,12],"justifyContent":"space_between","alignItems":"center","children":[{"type":"frame","id":"fdtbM","name":"Identity","gap":12,"alignItems":"center","children":[{"type":"text","id":"WWbxK","name":"Player Name","fill":"$foreground/foreground","content":"Corvus","fontFamily":"$typography/font-mono","fontSize":14,"fontWeight":"normal"}]},{"type":"frame","id":"NKkGP","name":"Actions","gap":4,"children":[{"type":"icon","id":"eRiYf","name":"Kick","width":16,"height":16,"icon":"user-minus","library":"lucide","fill":"$muted"},{"type":"icon","id":"r67gbD","name":"Ban","width":16,"height":16,"icon":"ban","library":"lucide","fill":"$muted"}]}]}]},{"type":"frame","id":"VJXco","name":"Whitelist Section","width":"fill_container","layout":"vertical","children":[{"type":"frame","id":"g6KG4P","name":"Whitelist Header","width":"fill_container","stroke":"$border/border","strokeAlignment":"inner","gap":4,"alignItems":"center","children":[{"type":"icon","id":"M7drTR","name":"Whitelist Chevron","width":12,"height":12,"icon":"chevron-right","library":"lucide","fill":"$muted"},{"type":"icon","id":"Vlwun","name":"Whitelist Icon","width":12,"height":12,"icon":"list-checks","library":"lucide","fill":"$muted"},{"type":"text","id":"Ldhz8","name":"Whitelist Label","fill":"$muted","content":"Whitelist (1)","fontFamily":"$typography/font-sans","fontSize":14,"fontWeight":"normal"}]}]},{"type":"frame","id":"c7d0b","name":"Banned Section","width":"fill_container","layout":"vertical","children":[{"type":"frame","id":"k0OT6l","name":"Banned Header","width":"fill_container","stroke":"$border/border","strokeAlignment":"inner","gap":4,"alignItems":"center","children":[{"type":"icon","id":"aNE27","name":"Banned Chevron","width":12,"height":12,"icon":"chevron-right","library":"lucide","fill":"$muted"},{"type":"text","id":"TjQ06","name":"Banned Label","fill":"$muted","content":"Banned (0)","fontFamily":"$typography/font-sans","fontSize":14,"fontWeight":"normal"}]}]}]}]}]} diff --git a/assets/design-export/json/FOVMf.json b/assets/design-export/json/FOVMf.json new file mode 100644 index 000000000..82f60176d --- /dev/null +++ b/assets/design-export/json/FOVMf.json @@ -0,0 +1,262 @@ +{ + "type": "frame", + "id": "FOVMf", + "x": -12557, + "y": 33432, + "name": "Screen/Standalone — Settings · About", + "context": "Panel build version comes from authenticated installation metadata; standalone About does not fetch local Kubernetes metadata.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "CjTjh", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Central management" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "UN7yo", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "Pucwu", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "i1oDk0", + "name": "Central management notice", + "width": "fill_container", + "fill": "$surface/surface", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "layout": "vertical", + "padding": [ + 12, + 24 + ], + "children": [ + { + "type": "text", + "id": "wVBRj", + "name": "Central management scope", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "GFScj", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "I7XR8", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "euy61", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "p5foG0", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "WlxKs": { + "fill": "$surface/secondary" + } + } + }, + { + "type": "frame", + "id": "X1EyDE", + "name": "About", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "D6N4hh", + "name": "Section title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "About", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "osqdf", + "name": "About description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "This Gameplane build.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "CSzHs", + "name": "Gameplane row", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "text", + "id": "OlU4p", + "name": "Gameplane label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": 100, + "content": "Gameplane", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "AORiq", + "name": "Gameplane value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "0.3.0-dev", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IMEAc", + "name": "License row", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "text", + "id": "lgbba", + "name": "License label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": 100, + "content": "License", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Slm3S", + "name": "License value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "AGPL-3.0", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/assets/design-export/json/HVhmb.json b/assets/design-export/json/HVhmb.json new file mode 100644 index 000000000..d93472dfc --- /dev/null +++ b/assets/design-export/json/HVhmb.json @@ -0,0 +1,504 @@ +{ + "type": "frame", + "id": "HVhmb", + "x": -14157, + "y": 32032, + "name": "Screen/Standalone — Remove cluster", + "context": "Removal errors stay inside the confirmation and reset when dismissed or reopened. Successful removal clears a matching current selection to empty without selecting local or another remote.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "zuZiD", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "aXpXv", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "XV2NC", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters" + } + } + }, + { + "type": "frame", + "id": "inByJ", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "tw1jI", + "type": "ref", + "ref": "xCDF7", + "name": "Clusters header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Clusters" + }, + "VbHwU": { + "content": "Administration of registered locations, connectivity and node inventory." + }, + "TgdLz": { + "type": "frame", + "id": "A0T5b4", + "name": "Page actions", + "height": 36, + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "D8RFW", + "type": "ref", + "ref": "cb4rt", + "name": "Register cluster", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "Register cluster" + } + } + } + ] + } + } + }, + { + "type": "text", + "id": "F8tLDA", + "name": "API status scope", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "2 clusters available · Status reports Kubernetes API connectivity, not game or gateway health.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "dIWjV", + "name": "Registered remote clusters", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "obBhO", + "name": "east cluster", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "omivO", + "name": "Cluster identity", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "C2L0n", + "name": "Name and registration", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "aj0qW", + "name": "Cluster display name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "East production", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "o3tYqe", + "name": "Registration name", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "east", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "K1HqW6", + "name": "Selected cluster", + "fill": "$accent/accent", + "content": "Selected", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "i3CZtk", + "name": "API connectivity", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "API: Healthy", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "L7W6Gr", + "name": "Last checked", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Last checked just now", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "p48i2a", + "name": "Cluster actions", + "gap": 8, + "children": [ + { + "id": "BO9AY", + "type": "ref", + "ref": "cb4rt", + "name": "View servers", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "View servers" + } + } + }, + { + "id": "stC1B", + "type": "ref", + "ref": "i6gfu", + "name": "View nodes", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "View nodes" + } + } + }, + { + "id": "G6OopD", + "type": "ref", + "ref": "i6gfu", + "name": "Remove", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Remove" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "ztTtW", + "name": "west cluster", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "kP0G0", + "name": "Cluster identity", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "Y7DpB", + "name": "Name and registration", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Bw0Ud", + "name": "Cluster display name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "West backup", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "OnCwQ", + "name": "Registration name", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "west", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "tbzYp", + "name": "API connectivity", + "fill": "$danger/danger", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "API: Unhealthy", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "tyi4k", + "name": "Last checked", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Last checked just now", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "GHLWv", + "name": "Cluster actions", + "gap": 8, + "children": [ + { + "id": "e8AF5P", + "type": "ref", + "ref": "cb4rt", + "name": "View servers", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "View servers" + } + } + }, + { + "id": "W1CM5U", + "type": "ref", + "ref": "i6gfu", + "name": "View nodes", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "View nodes" + } + } + }, + { + "id": "K8EeTB", + "type": "ref", + "ref": "i6gfu", + "name": "Remove", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Remove" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "BjUmJ", + "layoutPosition": "absolute", + "x": 0, + "y": 0, + "name": "Modal backdrop", + "width": 1440, + "height": 900, + "fill": "#00000066", + "layout": "vertical", + "padding": 40, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "id": "iqyS7", + "type": "ref", + "ref": "WwNlX", + "name": "Remove cluster confirmation", + "width": 440, + "descendants": { + "QARhG": { + "content": "Remove east?" + }, + "aZP2h": { + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "KC7CR", + "name": "Removal description and error", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "DcBlI", + "name": "Removal description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Removes this panel’s connection to the cluster. Running workloads remain on the cluster.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "BOOHh", + "name": "Removal request error", + "fill": "$danger/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Couldn’t remove the cluster registration. Try again.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + "Wa0YZ": { + "enabled": false, + "width": 400, + "height": 0 + }, + "tvH6d": { + "content": "Remove cluster" + } + } + } + ] + } + ] +} diff --git a/assets/design-export/json/OC804.json b/assets/design-export/json/OC804.json index ded1245d5..f813d4740 100644 --- a/assets/design-export/json/OC804.json +++ b/assets/design-export/json/OC804.json @@ -554,9 +554,19 @@ "layout": "vertical", "gap": 6, "children": [ + { + "type": "text", + "id": "faUlK", + "name": "Helper", + "fill": "$muted", + "content": "Write-only", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, { "type": "frame", - "id": "YZzBD", + "id": "oS9Ry", "name": "Input row", "width": "fill_container", "gap": 8, @@ -576,7 +586,7 @@ } }, { - "id": "rZ2ZW", + "id": "B3qhL", "type": "ref", "ref": "J09iP", "name": "Remove password button", @@ -587,16 +597,6 @@ } } ] - }, - { - "type": "text", - "id": "faUlK", - "name": "Helper", - "fill": "$muted", - "content": "Write-only", - "fontFamily": "$typography/font-sans", - "fontSize": 12, - "fontWeight": "normal" } ] } diff --git a/assets/design-export/json/QFEg9.json b/assets/design-export/json/QFEg9.json index e1c711ef9..9bdf401a8 100644 --- a/assets/design-export/json/QFEg9.json +++ b/assets/design-export/json/QFEg9.json @@ -565,9 +565,19 @@ "layout": "vertical", "gap": 6, "children": [ + { + "type": "text", + "id": "GJfAx", + "name": "Helper", + "fill": "$muted", + "content": "Write-only", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, { "type": "frame", - "id": "v2RYLO", + "id": "t5LkHa", "name": "Input row", "width": "fill_container", "gap": 8, @@ -587,7 +597,7 @@ } }, { - "id": "yCtMa", + "id": "KAuGZ", "type": "ref", "ref": "J09iP", "name": "Remove password button", @@ -598,16 +608,6 @@ } } ] - }, - { - "type": "text", - "id": "GJfAx", - "name": "Helper", - "fill": "$muted", - "content": "Write-only", - "fontFamily": "$typography/font-sans", - "fontSize": 12, - "fontWeight": "normal" } ] } diff --git a/assets/design-export/json/RS0KZ.json b/assets/design-export/json/RS0KZ.json new file mode 100644 index 000000000..c270a7602 --- /dev/null +++ b/assets/design-export/json/RS0KZ.json @@ -0,0 +1,490 @@ +{ + "type": "frame", + "id": "RS0KZ", + "x": -12557, + "y": 32032, + "name": "State/Standalone — Cluster selection and request states", + "context": "Interaction reference, not a single dashboard screen. No workload queries before capabilities and selection resolve. Retry retains the selected remote.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 32, + "children": [ + { + "type": "text", + "id": "MKxQW", + "name": "Reference title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Standalone workload selection", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 24, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "ebNpV", + "name": "Selection states", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "cGqBU", + "name": "Empty dropdown", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "bm0hY", + "name": "State title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "No registered clusters", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "oV6ND", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "kRBdc", + "name": "API status", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "MNf1m", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ocDjw", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "Select cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "ahab6", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "Hikui", + "name": "Cluster menu", + "width": "fill_container", + "fill": "$overlay", + "cornerRadius": 6, + "padding": 12, + "children": [ + { + "type": "text", + "id": "OMwrM", + "name": "Empty cluster menu action", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "No clusters available · Manage clusters", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "yuYzW", + "name": "Registered dropdown", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "VDdRD", + "name": "State title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Explicit remote selection", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "aW3rE", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "z0qmuR", + "name": "API status", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "G4TPp", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "WKcwf", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "East production", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "eEmef", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "EQ4VY", + "name": "Cluster choices", + "width": "fill_container", + "fill": "$overlay", + "cornerRadius": 6, + "layout": "vertical", + "gap": 16, + "padding": 12, + "children": [ + { + "type": "text", + "id": "l32Yb", + "name": "East option", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "East production ✓", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "GV2tB", + "name": "West option", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "West backup", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "PT3xk", + "name": "Administration action", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "View all clusters", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "FW9eC", + "name": "Loading and failure states", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "ln61E", + "name": "Loading", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "YO7gr", + "name": "State title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Loading", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "MHckh", + "name": "Status copy", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Loading installation…\nLoading clusters…\nLoading permissions…", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "JNCrO", + "name": "Installation unavailable", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "wCHfF", + "name": "State title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Installation unavailable", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "R72SqV", + "name": "Status copy", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Couldn't load installation capabilities.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "qJC3T", + "type": "ref", + "ref": "i6gfu", + "name": "Retry", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Retry" + } + } + } + ] + }, + { + "type": "frame", + "id": "KHCdz", + "name": "Registry unavailable", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "BHDVF", + "name": "State title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Registry unavailable", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "ouszc", + "name": "Status copy", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Couldn't load registered clusters.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "bDQy5", + "type": "ref", + "ref": "i6gfu", + "name": "Retry", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Retry" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "rmmyP", + "name": "Identity request failure", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "vytzd", + "name": "Identity failure message", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Couldn’t load your permissions.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "lNVmH", + "type": "ref", + "ref": "FIB65", + "name": "Retry request", + "height": 32, + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Retry" + } + } + } + ] + }, + { + "type": "text", + "id": "ZwDM2", + "name": "Scope note", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Initial failures show a retry state. Background refresh errors keep the last loaded catalog, sources, and open drafts mounted. Permission fetch failures show Retry, separately from denied permission. Successful removal clears the selected registration without choosing another cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] +} diff --git a/assets/design-export/json/RpHBg.json b/assets/design-export/json/RpHBg.json new file mode 100644 index 000000000..92a0e1ad0 --- /dev/null +++ b/assets/design-export/json/RpHBg.json @@ -0,0 +1,186 @@ +{ + "type": "frame", + "id": "RpHBg", + "x": -15757, + "y": 32032, + "name": "Screen/Standalone — Register cluster", + "context": "PR #630. Modal actions use central cluster-management permission. Dismissing is disabled during submission.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "m32aX", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "r5jGM", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "onCRW", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters" + } + } + }, + { + "type": "frame", + "id": "S1gmm", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "gtqox", + "type": "ref", + "ref": "xCDF7", + "name": "Clusters header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Clusters" + }, + "VbHwU": { + "content": "Administration of registered locations, connectivity and node inventory." + }, + "TgdLz": { + "type": "frame", + "id": "PlKp6", + "name": "Page actions", + "height": 36, + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "Z2lia", + "type": "ref", + "ref": "cb4rt", + "name": "Register cluster", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "Register cluster" + } + } + } + ] + } + } + }, + { + "type": "frame", + "id": "qdNhP", + "name": "No registered clusters", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "u4sFfS", + "name": "Empty title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "No clusters available", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Y8Ygo", + "name": "Empty description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Register a workload cluster to deploy and manage game servers. This panel can run without a local cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "DdHRg", + "layoutPosition": "absolute", + "x": 0, + "y": 0, + "name": "Modal backdrop", + "width": 1440, + "height": 900, + "fill": "#00000066", + "layout": "vertical", + "padding": 40, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "id": "cb9rq", + "type": "ref", + "ref": "Z81TW", + "name": "Register cluster dialog", + "width": 480 + } + ] + } + ] +} + diff --git a/assets/design-export/json/U9xSg.json b/assets/design-export/json/U9xSg.json new file mode 100644 index 000000000..f97d6e7b4 --- /dev/null +++ b/assets/design-export/json/U9xSg.json @@ -0,0 +1,575 @@ +{ + "type": "frame", + "id": "U9xSg", + "x": 24240, + "y": 9175, + "name": "Screen/Server Detail — Players (Nuclear Option)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "kb3SP", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "i8Z1B": { + "fill": "$accent/soft" + }, + "i8Z1B/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "i8Z1B/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "ORz62", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "qCr4C", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "mc-survival" + } + } + }, + { + "id": "y982W", + "type": "ref", + "ref": "S4k0x", + "name": "Detail Header", + "width": "fill_container" + }, + { + "id": "bgz6L", + "type": "ref", + "ref": "I9kvlZ", + "name": "Tabs Bar", + "width": "fill_container", + "padding": [ + 0, + 24 + ], + "descendants": { + "Wp6q1": { + "fill": "#00000000" + }, + "Wp6q1/YUR8N": { + "fill": "$muted" + }, + "hNitQ": { + "fill": "$segment" + }, + "hNitQ/VcxMI": { + "fill": "$foreground/segment" + } + } + }, + { + "type": "frame", + "id": "lbWu7", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "RA5cq", + "name": "Stat Cards Row", + "width": "fill_container", + "gap": 12, + "children": [ + { + "id": "avKiV", + "type": "ref", + "ref": "ZWcwn", + "name": "Stat Online", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "ONLINE" + }, + "QdcEm": { + "icon": "users", + "fill": "$success/success", + "library": "lucide", + "enabled": false + }, + "vj2Ru": { + "content": "3 / 16" + }, + "h0n7zo": { + "enabled": false + } + } + }, + { + "id": "vEoPm", + "type": "ref", + "ref": "ZWcwn", + "name": "Stat Whitelisted", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "WHITELISTED" + }, + "QdcEm": { + "icon": "list-checks", + "fill": "$accent/accent", + "library": "lucide", + "enabled": false + }, + "vj2Ru": { + "content": "1" + }, + "h0n7zo": { + "enabled": false + } + } + }, + { + "id": "K8TBF", + "type": "ref", + "ref": "ZWcwn", + "name": "Stat Banned", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "BANNED" + }, + "QdcEm": { + "icon": "ban", + "fill": "#DC2626", + "library": "lucide", + "enabled": false + }, + "vj2Ru": { + "content": "0" + }, + "h0n7zo": { + "enabled": false + } + } + } + ] + }, + { + "type": "frame", + "id": "a8WBd", + "name": "Online Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "uKEOF", + "name": "onlineCount", + "fill": "$muted", + "content": "3 / 16 online", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "tskEG", + "type": "ref", + "ref": "J09iP", + "name": "Btn Refresh", + "descendants": { + "eWkIT/WtPjS": { + "icon": "refresh-cw" + }, + "eWkIT/r4VbAi": { + "content": "Refresh" + } + } + } + ] + }, + { + "type": "frame", + "id": "Y1qaR", + "name": "Player Rows", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "zs5Pr", + "name": "Player Row — Pilot_Vance", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 8, + "padding": [ + 8, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "SfNlc", + "name": "Identity", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "b3MDFM", + "name": "Player Name", + "fill": "$foreground/foreground", + "content": "Pilot_Vance", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "r19BYn", + "name": "Faction Chip", + "fill": "$accent/soft", + "cornerRadius": 999, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "ul0k3", + "name": "Faction", + "fill": "$accent/soft-foreground", + "content": "Boscali", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "g7WUX", + "name": "Actions", + "gap": 4, + "children": [ + { + "type": "icon", + "id": "voATz", + "name": "Kick", + "width": 16, + "height": 16, + "icon": "user-minus", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "icon", + "id": "tbcjp", + "name": "Ban", + "width": 16, + "height": 16, + "icon": "ban", + "library": "lucide", + "fill": "$muted" + } + ] + } + ] + }, + { + "type": "frame", + "id": "xqdRG", + "name": "Player Row — 76561198012345678", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 8, + "padding": [ + 8, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "O3r5Kq", + "name": "Identity", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "LDVpd", + "name": "Player Name", + "fill": "$muted", + "content": "76561198012345678", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "MekUX", + "name": "Faction Chip", + "fill": "$warning/soft", + "cornerRadius": 999, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "cHWED", + "name": "Faction", + "fill": "$warning/soft-foreground", + "content": "Primeva", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "IjDVg", + "name": "Actions", + "gap": 4, + "children": [ + { + "type": "icon", + "id": "gGdB5", + "name": "Kick", + "width": 16, + "height": 16, + "icon": "user-minus", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "icon", + "id": "o0R69j", + "name": "Ban", + "width": 16, + "height": 16, + "icon": "ban", + "library": "lucide", + "fill": "$muted" + } + ] + } + ] + }, + { + "type": "frame", + "id": "EDOQ8", + "name": "Player Row — Corvus", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 8, + "padding": [ + 8, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "wb55l", + "name": "Identity", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "NwVkL", + "name": "Player Name", + "fill": "$foreground/foreground", + "content": "Corvus", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "cjUkD", + "name": "Actions", + "gap": 4, + "children": [ + { + "type": "icon", + "id": "uf3OA", + "name": "Kick", + "width": 16, + "height": 16, + "icon": "user-minus", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "icon", + "id": "P53mLy", + "name": "Ban", + "width": 16, + "height": 16, + "icon": "ban", + "library": "lucide", + "fill": "$muted" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "OJpx1", + "name": "Whitelist Section", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "MG8Zl", + "name": "Whitelist Header", + "width": "fill_container", + "stroke": "$border/border", + "strokeAlignment": "inner", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "C5nK6", + "name": "Whitelist Chevron", + "width": 12, + "height": 12, + "icon": "chevron-right", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "icon", + "id": "Y694nU", + "name": "Whitelist Icon", + "width": 12, + "height": 12, + "icon": "list-checks", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "ICjzI", + "name": "Whitelist Label", + "fill": "$muted", + "content": "Whitelist (1)", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "whI8w", + "name": "Banned Section", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "khA99", + "name": "Banned Header", + "width": "fill_container", + "stroke": "$border/border", + "strokeAlignment": "inner", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "xWJbE", + "name": "Banned Chevron", + "width": 12, + "height": 12, + "icon": "chevron-right", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "aFc8A", + "name": "Banned Label", + "fill": "$muted", + "content": "Banned (0)", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/assets/design-export/json/Uzzje.json b/assets/design-export/json/Uzzje.json new file mode 100644 index 000000000..180ae5bcc --- /dev/null +++ b/assets/design-export/json/Uzzje.json @@ -0,0 +1,249 @@ +{ + "type": "frame", + "id": "Uzzje", + "x": -14157, + "y": 33432, + "name": "Screen/Standalone — Settings · Updates", + "context": "PR #630 standalone installation. No local Kubernetes metadata or local workload fallback. Master-key storage and offline rotation have no dashboard controls.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "WRQtS", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Central management" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "JC6dK", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "CCla0", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "BocHR", + "name": "Central management notice", + "width": "fill_container", + "fill": "$surface/surface", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "layout": "vertical", + "padding": [ + 12, + 24 + ], + "children": [ + { + "type": "text", + "id": "rSe5T", + "name": "Central management scope", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "MBSfV", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "MsH9w", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "qxuaB", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "qW9R7", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "bwo3a": { + "fill": "$surface/secondary" + } + } + }, + { + "type": "frame", + "id": "zyhz1", + "name": "Updates", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "uyYAr", + "name": "Section title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Updates", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "tveul", + "name": "Container upgrade guidance", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Update the panel and API container images to upgrade this installation.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "LOUx7", + "name": "Channel", + "width": "fill_container", + "gap": 24, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "SmuVJ", + "name": "Channel label and hint", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "BhUQD", + "name": "Channel label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Channel", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "W3hgaz", + "name": "Channel hint", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Container image tags select the release for this panel.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "BtKwG", + "name": "Unavailable channel", + "fill": "$foreground/foreground", + "content": "—", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} + diff --git a/assets/design-export/json/WrmU5.json b/assets/design-export/json/WrmU5.json new file mode 100644 index 000000000..492c30495 --- /dev/null +++ b/assets/design-export/json/WrmU5.json @@ -0,0 +1,429 @@ +{ + "type": "frame", + "id": "WrmU5", + "x": -14157, + "y": 30632, + "name": "Screen/Standalone — Clusters (Registered)", + "context": "PR #630 standalone deployment; matches current dashboard behavior.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "Q8Gy6o", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "cZ1pA", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "Dd917", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters" + } + } + }, + { + "type": "frame", + "id": "quYQ7", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "mnPfw", + "type": "ref", + "ref": "xCDF7", + "name": "Clusters header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Clusters" + }, + "VbHwU": { + "content": "Administration of registered locations, connectivity and node inventory." + }, + "TgdLz": { + "type": "frame", + "id": "irpdH", + "name": "Page actions", + "height": 36, + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "I414g1", + "type": "ref", + "ref": "cb4rt", + "name": "Register cluster", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "Register cluster" + } + } + } + ] + } + } + }, + { + "type": "text", + "id": "a7s07j", + "name": "API status scope", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "2 clusters available · Status reports Kubernetes API connectivity, not game or gateway health.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "jiXKV", + "name": "Registered remote clusters", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "K1kXX", + "name": "east cluster", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "ApcQf", + "name": "Cluster identity", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "FQWWf", + "name": "Name and registration", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "owEMr", + "name": "Cluster display name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "East production", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "jyqB7", + "name": "Registration name", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "east", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "D5B31", + "name": "Selected cluster", + "fill": "$accent/accent", + "content": "Selected", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "zlSQA", + "name": "API connectivity", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "API: Healthy", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "IcfRP", + "name": "Last checked", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Last checked just now", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "LGzV8", + "name": "Cluster actions", + "gap": 8, + "children": [ + { + "id": "i0uSmF", + "type": "ref", + "ref": "cb4rt", + "name": "View servers", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "View servers" + } + } + }, + { + "id": "vuyS6", + "type": "ref", + "ref": "i6gfu", + "name": "View nodes", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "View nodes" + } + } + }, + { + "id": "W1073i", + "type": "ref", + "ref": "i6gfu", + "name": "Remove", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Remove" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "q8PXwO", + "name": "west cluster", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "kbDHQ", + "name": "Cluster identity", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "AvgGx", + "name": "Name and registration", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "dr8J4", + "name": "Cluster display name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "West backup", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "cBPBX", + "name": "Registration name", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "west", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "j82uX", + "name": "API connectivity", + "fill": "$danger/danger", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "API: Unhealthy", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "bmjhu", + "name": "Last checked", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Last checked just now", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "QcHLE", + "name": "Cluster actions", + "gap": 8, + "children": [ + { + "id": "U7jY7p", + "type": "ref", + "ref": "cb4rt", + "name": "View servers", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "View servers" + } + } + }, + { + "id": "IoRuy", + "type": "ref", + "ref": "i6gfu", + "name": "View nodes", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "View nodes" + } + } + }, + { + "id": "Bd9ic", + "type": "ref", + "ref": "i6gfu", + "name": "Remove", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Remove" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} + diff --git a/assets/design-export/json/ffmEd.json b/assets/design-export/json/YN8cE.json similarity index 91% rename from assets/design-export/json/ffmEd.json rename to assets/design-export/json/YN8cE.json index 15cec5709..958766452 100644 --- a/assets/design-export/json/ffmEd.json +++ b/assets/design-export/json/YN8cE.json @@ -1,6 +1,6 @@ { "type": "frame", - "id": "ffmEd", + "id": "YN8cE", "x": 44640, "y": 11355, "name": "Screen/Server Detail — Settings · Game configuration (New password typed)", @@ -13,10 +13,11 @@ "fill": "$background/background", "children": [ { - "id": "R4f1Bd", + "id": "QC4QI", "type": "ref", "ref": "kKFX9", "height": "fill_container", + "name": "Restored ref", "descendants": { "iTbd8": { "fill": "#00000000" @@ -40,7 +41,7 @@ }, { "type": "frame", - "id": "gvBWy", + "id": "AwAAZ", "name": "Main", "width": "fill_container", "height": "fill_container", @@ -48,10 +49,11 @@ "layout": "vertical", "children": [ { - "id": "dwyWE", + "id": "z1zcAw", "type": "ref", "ref": "gu5WY", "width": "fill_container", + "name": "Restored ref", "descendants": { "Gl7AY": { "content": "mc-survival" @@ -60,7 +62,7 @@ }, { "type": "frame", - "id": "qtMLk", + "id": "JsrrP", "name": "Body", "width": "fill_container", "height": "fill_container", @@ -70,14 +72,14 @@ "padding": 24, "children": [ { - "id": "kaJiG", + "id": "dBiIp", "type": "ref", "ref": "S4k0x", "name": "Detail Header", "width": "fill_container" }, { - "id": "DVcnL", + "id": "yHNON", "type": "ref", "ref": "I9kvlZ", "name": "Tabs Bar", @@ -105,13 +107,13 @@ }, { "type": "frame", - "id": "x3y7b", + "id": "y9X6p", "name": "Settings Content", "width": "fill_container", "height": "fill_container", "children": [ { - "id": "OeKpX", + "id": "IxbPy", "type": "ref", "ref": "EV8mp", "name": "Settings Sub Nav", @@ -236,7 +238,7 @@ }, { "type": "frame", - "id": "UciMy", + "id": "B1JyQ", "name": "Settings Panel Wrap", "width": "fill_container", "height": "fill_container", @@ -244,7 +246,7 @@ "children": [ { "type": "frame", - "id": "y50jVK", + "id": "c9zM4k", "name": "Settings Form", "width": "fill_container", "height": "fill_container", @@ -252,7 +254,7 @@ "children": [ { "type": "frame", - "id": "KLlDA", + "id": "KGHPV", "name": "Form Body", "width": "fill_container", "height": "fill_container", @@ -262,7 +264,7 @@ "children": [ { "type": "frame", - "id": "zheQw", + "id": "AFwnw", "name": "Section Header", "width": "fill_container", "layout": "vertical", @@ -270,7 +272,7 @@ "children": [ { "type": "text", - "id": "WKlYQ", + "id": "bqAlw", "name": "Section Title", "fill": "$foreground/foreground", "content": "Game configuration", @@ -280,7 +282,7 @@ }, { "type": "text", - "id": "bNeAn", + "id": "n3asvo", "name": "Section Description", "fill": "$muted", "content": "Settings from this server's game template. Saving restarts the server.", @@ -291,7 +293,7 @@ ] }, { - "id": "frc5U", + "id": "dIZPs", "type": "ref", "ref": "uJ3KZ", "name": "Restart Alert", @@ -307,14 +309,14 @@ }, { "type": "frame", - "id": "sQG0D", + "id": "EDxrk", "name": "Field World name", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "PASr1", + "id": "E21gp0", "name": "lbl", "width": 200, "layout": "vertical", @@ -322,7 +324,7 @@ "children": [ { "type": "text", - "id": "hQD9M", + "id": "mxrxW", "name": "Label", "fill": "$foreground/foreground", "content": "World name", @@ -334,14 +336,14 @@ }, { "type": "frame", - "id": "TCJEz", + "id": "blKmQ", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "om1Ce", + "id": "S4Ryz", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -359,14 +361,14 @@ }, { "type": "frame", - "id": "ZG2Rl", + "id": "YrnLI", "name": "Field Difficulty", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "TvVIP", + "id": "ebygk", "name": "lbl", "width": 200, "layout": "vertical", @@ -374,7 +376,7 @@ "children": [ { "type": "text", - "id": "SSSuv", + "id": "ihoZt", "name": "Label", "fill": "$foreground/foreground", "content": "Difficulty", @@ -386,14 +388,14 @@ }, { "type": "frame", - "id": "Q6Hnqy", + "id": "O4oaS", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "q7ogrE", + "id": "qWlRT", "type": "ref", "ref": "AT7ya", "name": "Select", @@ -414,14 +416,14 @@ }, { "type": "frame", - "id": "T1v3SB", + "id": "BfNvQ", "name": "Field Max players", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "pTb09", + "id": "D9yRI", "name": "lbl", "width": 200, "layout": "vertical", @@ -429,7 +431,7 @@ "children": [ { "type": "text", - "id": "RdYMY", + "id": "r0dvK", "name": "Label", "fill": "$foreground/foreground", "content": "Max players", @@ -441,14 +443,14 @@ }, { "type": "frame", - "id": "jJMtS", + "id": "fGN7X", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "fU82D", + "id": "JSnMN", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -466,14 +468,14 @@ }, { "type": "frame", - "id": "ApFXd", + "id": "M7WyzF", "name": "Field PvP", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "L7UtB", + "id": "S7efIl", "name": "lbl", "width": 200, "layout": "vertical", @@ -481,7 +483,7 @@ "children": [ { "type": "text", - "id": "gbE51", + "id": "UCrrs", "name": "Label", "fill": "$foreground/foreground", "content": "PvP", @@ -493,14 +495,14 @@ }, { "type": "frame", - "id": "L68HA", + "id": "M3aHv", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "bDced", + "id": "wO3zr", "type": "ref", "ref": "AT7ya", "name": "Select", @@ -521,14 +523,14 @@ }, { "type": "frame", - "id": "w2nZcC", + "id": "xQ9Nz", "name": "Field Server password", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "JrKAC", + "id": "uD85J", "name": "lbl", "width": 200, "layout": "vertical", @@ -536,7 +538,7 @@ "children": [ { "type": "text", - "id": "qxcPJ", + "id": "vovRG", "name": "Label", "fill": "$foreground/foreground", "content": "Server password", @@ -548,7 +550,7 @@ }, { "type": "frame", - "id": "hmZjo", + "id": "mX0ac", "name": "ctrl", "width": "fill_container", "layout": "vertical", @@ -556,14 +558,14 @@ "children": [ { "type": "frame", - "id": "XbscA", + "id": "sAYIe", "name": "Input row", "width": "fill_container", "gap": 8, "alignItems": "center", "children": [ { - "id": "vmqyd", + "id": "FrryF", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -579,7 +581,7 @@ }, { "type": "text", - "id": "TNT4W", + "id": "q473dg", "name": "Helper", "fill": "$muted", "content": "Write-only", @@ -593,14 +595,14 @@ }, { "type": "frame", - "id": "j2XdIM", + "id": "NBrKm", "name": "Field MOTD", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "Cwah9", + "id": "aFMOo", "name": "lbl", "width": 200, "layout": "vertical", @@ -608,7 +610,7 @@ "children": [ { "type": "text", - "id": "vHFsM", + "id": "TRziH", "name": "Label", "fill": "$foreground/foreground", "content": "MOTD", @@ -620,14 +622,14 @@ }, { "type": "frame", - "id": "w7aQ3", + "id": "oUikf", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "WzcYU", + "id": "z36WJ", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -641,7 +643,7 @@ }, { "type": "text", - "id": "XbSxU", + "id": "L1jWH", "name": "Helper", "fill": "$muted", "content": "Written to serverconfig.txt", @@ -657,7 +659,7 @@ }, { "type": "frame", - "id": "uUZDu", + "id": "gkY8j", "name": "Form Footer", "width": "fill_container", "stroke": "$border/border", @@ -675,7 +677,7 @@ "alignItems": "center", "children": [ { - "id": "hveZg", + "id": "jZQLp", "type": "ref", "ref": "J09iP", "name": "Btn Discard", @@ -686,7 +688,7 @@ } }, { - "id": "X7QEbN", + "id": "IOHMd", "type": "ref", "ref": "z9ShNE", "name": "Btn Save Changes", diff --git a/assets/design-export/json/Z0oyh.json b/assets/design-export/json/Z0oyh.json new file mode 100644 index 000000000..05c155d76 --- /dev/null +++ b/assets/design-export/json/Z0oyh.json @@ -0,0 +1,191 @@ +{ + "type": "frame", + "id": "Z0oyh", + "x": -10957, + "y": 30632, + "name": "Screen/Standalone — Modules (No selection)", + "context": "PR #630. Selection is explicit and only registered remotes are listed. No implicit local target; no workload requests until installation and selected target are known.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "BQhzL", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "sTSGR": { + "fill": "$accent/soft" + }, + "sTSGR/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "sTSGR/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "ZCtkb", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "DRroL", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Modules" + } + } + }, + { + "type": "frame", + "id": "pFGvI", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "s0m1C", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "abqFF", + "name": "API status", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "h8qD6u", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Udlbe", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "Select cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "EdLxM", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "B0Utzk", + "name": "Select cluster guidance", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "k8WlA", + "name": "Selection title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Select a workload cluster", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "W8Meb", + "name": "Selection description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Modules and their sources are managed by the operator on a registered workload cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "AIuUK", + "name": "Manage clusters link", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Manage clusters", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] +} + diff --git a/assets/design-export/json/Z81TW.json b/assets/design-export/json/Z81TW.json new file mode 100644 index 000000000..8a4b33f61 --- /dev/null +++ b/assets/design-export/json/Z81TW.json @@ -0,0 +1,165 @@ +{ + "id": "Z81TW", + "type": "ref", + "reusable": true, + "ref": "x3beP", + "x": -15757, + "y": 30632, + "name": "Gameplane/Dialog/Register workload cluster", + "theme": { + "semantic": "dark" + }, + "width": 480, + "context": "PR #630. Requires central cluster:manage. Register stays disabled until Name and Kubeconfig have non-whitespace text. During submission both buttons and dismissal are disabled. On error preserve the draft. Clear the kubeconfig on success. No existing credential is ever displayed.", + "descendants": { + "E7Whx": { + "content": "Register cluster" + }, + "qzcst": { + "content": "Connect an existing workload cluster with the Gameplane operator and CRDs installed. The panel host does not become a workload node." + }, + "Nht52": { + "type": "frame", + "id": "cvNaq", + "name": "Registration fields", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "frame", + "id": "E1kLQw", + "name": "Name field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "WY2Eh", + "name": "Name label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Name", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "Y9McT", + "type": "ref", + "ref": "D0cDM", + "name": "Name input", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "production", + "fill": "$field/placeholder" + } + } + } + ] + }, + { + "type": "frame", + "id": "ReaIB", + "name": "Display name field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "d8DaPW", + "name": "Display name label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Display name", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "nZk5b", + "type": "ref", + "ref": "D0cDM", + "name": "Display name input", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "Rfps1", + "name": "Kubeconfig field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "Aht2x", + "name": "Kubeconfig label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Kubeconfig", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Bi1Nn", + "name": "Kubeconfig textarea (eight rows)", + "width": "fill_container", + "height": 172, + "fill": "$field/background", + "cornerRadius": "$radius/xl", + "stroke": "$field/border", + "strokeWidth": 1, + "padding": 12 + } + ] + }, + { + "type": "text", + "id": "zJG5E", + "name": "Connection helper", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Use a self-contained kubeconfig with credentials and an API address reachable from this panel. Agent console, files and logs also require remote gateway configuration.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + "V7HYp": { + "opacity": 0.5 + }, + "TZ7Ef": { + "content": "Register" + } + } +} + diff --git a/assets/design-export/json/ZlLf0.json b/assets/design-export/json/ZlLf0.json new file mode 100644 index 000000000..87032f9a5 --- /dev/null +++ b/assets/design-export/json/ZlLf0.json @@ -0,0 +1,165 @@ +{ + "type": "frame", + "id": "ZlLf0", + "x": -17357, + "y": 32032, + "name": "Screen/Standalone — Modules (Permission denied)", + "context": "PR #630. Selection is explicit and only registered remotes are listed. No implicit local target; no workload requests until installation and selected target are known.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "CRRpO", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "sTSGR": { + "fill": "$accent/soft" + }, + "sTSGR/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "sTSGR/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "s8SIBZ", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "kk86p", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Modules" + } + } + }, + { + "type": "frame", + "id": "k2PQ8", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "f4IRRz", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "J1bAjI", + "name": "API status", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "mA8Ze", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "RRBRI", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "East production", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "r0ll2C", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "l9iRNt", + "name": "No module access", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "BYqDc", + "name": "Permission error", + "fill": "$danger/danger", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "You don't have permission to view modules on this cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] +} + diff --git a/assets/design-export/json/aMU7O.json b/assets/design-export/json/aMU7O.json new file mode 100644 index 000000000..c05afdc70 --- /dev/null +++ b/assets/design-export/json/aMU7O.json @@ -0,0 +1,298 @@ +{ + "id": "aMU7O", + "type": "ref", + "ref": "x3beP", + "name": "Gameplane/Dialog/Edit user — Standalone (No target)", + "x": -15757, + "y": 34832, + "theme": { + "semantic": "dark" + }, + "width": 480, + "context": "PR #630. Only registered remotes appear as workload grant targets. Primary binding is panel access. workload-viewer is a sample role containing workload permissions only. All namespaces must reject roles containing central administration permissions; Add is disabled for unknown or missing targets.", + "descendants": { + "E7Whx": { + "content": "Edit user" + }, + "qzcst": { + "content": "operator-01" + }, + "Nht52": { + "type": "frame", + "id": "yHFNV", + "name": "User fields and remote grants", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "frame", + "id": "dHAV6", + "name": "Display name field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "u7sEw", + "name": "Display name label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Display name", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "pPOJS", + "type": "ref", + "ref": "D0cDM", + "name": "Display name value", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "Server Operator", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "xUDS9", + "name": "Email field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "k4bn3", + "name": "Email label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Email", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "qCJUr", + "type": "ref", + "ref": "D0cDM", + "name": "Email value", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "operator@example.test", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "jkYvt", + "name": "Primary role (cluster-wide) field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "PioTM", + "name": "Primary role (cluster-wide) label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Primary role (cluster-wide)", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "tWpbc", + "type": "ref", + "ref": "AT7ya", + "name": "Primary role (cluster-wide) value", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "operator", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "KesHu", + "name": "Grant separator", + "width": "fill_container", + "height": 1, + "fill": "$border/border" + }, + { + "type": "text", + "id": "lByLy", + "name": "Grant heading", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "CLUSTER & NAMESPACE GRANTS", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "cMoFK", + "name": "Grant scope explanation", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The primary role above manages panel access. Additional grants apply only to their named cluster and namespace.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "kCgJB", + "name": "Primary panel binding", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "operator in all namespaces on panel\nPrimary role (managed above)", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "iiSDM", + "name": "Remote target and role", + "width": "fill_container", + "gap": 8, + "children": [ + { + "id": "dqqi3", + "type": "ref", + "ref": "AT7ya", + "name": "Grant cluster", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "Select a workload cluster" + } + } + }, + { + "id": "tInht", + "type": "ref", + "ref": "AT7ya", + "name": "Grant role", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "workload-viewer" + } + } + } + ] + }, + { + "type": "frame", + "id": "lmxYz", + "name": "Namespace grant actions", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "hJb3f", + "type": "ref", + "ref": "D0cDM", + "name": "Grant namespace", + "width": "fill_container", + "height": 32, + "descendants": { + "PJERm": { + "content": "namespace" + } + } + }, + { + "id": "F0krw", + "type": "ref", + "ref": "i6gfu", + "name": "All namespaces", + "opacity": 0.5, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "All namespaces" + } + } + }, + { + "id": "g1QLQ", + "type": "ref", + "ref": "i6gfu", + "name": "Add", + "opacity": 0.5, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Add" + } + } + } + ] + }, + { + "type": "text", + "id": "wT2ls", + "name": "Inventory grant guidance", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "For remote node inventory, create a role in the Roles tab with “View nodes, version, and storage”, then grant it to all namespaces on that cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + "V7HYp": { + "opacity": 1 + }, + "TZ7Ef": { + "content": "Save changes" + } + } +} + diff --git a/assets/design-export/json/kK8Ji.json b/assets/design-export/json/kK8Ji.json index ef788a3d9..aca89daaa 100644 --- a/assets/design-export/json/kK8Ji.json +++ b/assets/design-export/json/kK8Ji.json @@ -13,12 +13,12 @@ "fill": "$background/background", "children": [ { - "id": "DlcuA", + "id": "h2sWTB", "type": "ref", "ref": "kKFX9", + "name": "App Sidebar", "height": "fill_container", "layout": "vertical", - "name": "App Sidebar", "descendants": { "Sci9k": { "height": 70, @@ -39,7 +39,7 @@ }, "ijsQu": { "type": "frame", - "id": "wVY15", + "id": "yuZHJ", "name": "Nav Content", "width": "fill_container", "height": "fill_container", @@ -51,7 +51,7 @@ ], "children": [ { - "id": "A8CqgC", + "id": "jekdC", "type": "ref", "ref": "uFDbF", "name": "Section General", @@ -63,7 +63,7 @@ } }, { - "id": "t8hOE", + "id": "FTFLn", "type": "ref", "ref": "B5sYje", "name": "navDashboard", @@ -89,7 +89,7 @@ } }, { - "id": "jV8uw", + "id": "l2BPqJ", "type": "ref", "ref": "W8XiN", "name": "navServers", @@ -113,7 +113,7 @@ } }, { - "id": "raFQV", + "id": "RvmiI", "type": "ref", "ref": "W8XiN", "name": "navModules", @@ -139,7 +139,7 @@ } }, { - "id": "O0HLrA", + "id": "BMZAy", "type": "ref", "ref": "W8XiN", "name": "navBackups", @@ -163,7 +163,7 @@ } }, { - "id": "k0QQxc", + "id": "i0M86T", "type": "ref", "ref": "uFDbF", "name": "Section Admin", @@ -175,7 +175,7 @@ } }, { - "id": "ubagu", + "id": "RxY1j", "type": "ref", "ref": "W8XiN", "name": "navCluster", @@ -199,7 +199,7 @@ } }, { - "id": "Ka2Xf", + "id": "l8tdzK", "type": "ref", "ref": "W8XiN", "name": "Cluster inventory navigation", @@ -223,7 +223,7 @@ } }, { - "id": "g7FxN", + "id": "AM6yG", "type": "ref", "ref": "W8XiN", "name": "navUsers", @@ -247,7 +247,7 @@ } }, { - "id": "GbLXA", + "id": "OPf4U", "type": "ref", "ref": "W8XiN", "name": "navAudit", @@ -271,7 +271,7 @@ } }, { - "id": "Pd9d1", + "id": "rX4JW", "type": "ref", "ref": "W8XiN", "name": "navLogs", @@ -295,7 +295,7 @@ } }, { - "id": "n2Gdq", + "id": "uzLV2", "type": "ref", "ref": "W8XiN", "name": "navSettings", @@ -325,7 +325,7 @@ }, "oByLP": { "type": "frame", - "id": "gVXWY", + "id": "DIHKx", "name": "Toggle Row", "width": "fill_container", "gap": 8, @@ -333,14 +333,14 @@ "alignItems": "center", "children": [ { - "id": "f5oXbp", + "id": "yVbl6", "type": "ref", "ref": "iA2C8", "name": "Appearance Toggle", "fill": "$surface/secondary" }, { - "id": "NJR0y", + "id": "T7leKW", "type": "ref", "ref": "owgrI", "name": "Customize Theme", @@ -377,7 +377,7 @@ }, { "type": "frame", - "id": "I2bQk", + "id": "m7kmp", "name": "Central management notice", "width": "fill_container", "height": 45, @@ -396,7 +396,7 @@ "children": [ { "type": "text", - "id": "aBBe4", + "id": "rX0zZ", "name": "Central management label", "fill": "$foreground/foreground", "content": "Central management.", @@ -407,7 +407,7 @@ }, { "type": "text", - "id": "HWUr3", + "id": "vudRw", "name": "Central management explanation", "fill": "$muted", "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", diff --git a/assets/design-export/json/l64bd.json b/assets/design-export/json/l64bd.json new file mode 100644 index 000000000..04cd98e93 --- /dev/null +++ b/assets/design-export/json/l64bd.json @@ -0,0 +1,272 @@ +{ + "type": "frame", + "id": "l64bd", + "x": -15757, + "y": 33432, + "name": "Screen/Standalone — Settings · Module sources", + "context": "PR #630 standalone installation. No local Kubernetes metadata or local workload fallback. Master-key storage and offline rotation have no dashboard controls.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "FcbBC", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Central management" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "D6zRW", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "nnRoX", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "mZGwz", + "name": "Central management notice", + "width": "fill_container", + "fill": "$surface/surface", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "layout": "vertical", + "padding": [ + 12, + 24 + ], + "children": [ + { + "type": "text", + "id": "hvXxM", + "name": "Central management scope", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "WKQYl", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "PH5hw", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "u43cEm", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "WqU5M", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "OUh8J": { + "fill": "$surface/secondary" + } + } + }, + { + "type": "frame", + "id": "s166r", + "name": "Workload target", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "x1QWec", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "sD5mQ", + "name": "API status", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "qohKg", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "CP1f4", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "Select cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "K9APyK", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "DYERt", + "name": "No workload target", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "G0Xvr", + "name": "Selection title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Select a workload cluster", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "YekgK", + "name": "Selection subtitle", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Modules and their sources are managed by the operator on a registered workload cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "sVMpB", + "name": "Manage clusters link", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Manage clusters", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} + diff --git a/assets/design-export/json/n2qdi.json b/assets/design-export/json/n2qdi.json new file mode 100644 index 000000000..3a73be1bf --- /dev/null +++ b/assets/design-export/json/n2qdi.json @@ -0,0 +1,162 @@ +{ + "type": "frame", + "id": "n2qdi", + "x": -17357, + "y": 30632, + "name": "Screen/Standalone — Clusters (Empty)", + "context": "PR #630 standalone deployment; matches current dashboard behavior.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "peZu0", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "p4p9Y2", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "G9ZToI", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters" + } + } + }, + { + "type": "frame", + "id": "f9tEzp", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "lL09w", + "type": "ref", + "ref": "xCDF7", + "name": "Clusters header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Clusters" + }, + "VbHwU": { + "content": "Administration of registered locations, connectivity and node inventory." + }, + "TgdLz": { + "type": "frame", + "id": "uUIw5", + "name": "Page actions", + "height": 36, + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "zl6X7", + "type": "ref", + "ref": "cb4rt", + "name": "Register cluster", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "Register cluster" + } + } + } + ] + } + } + }, + { + "type": "frame", + "id": "MMxRr", + "name": "No registered clusters", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "A1ikQ", + "name": "Empty title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "No clusters available", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ITq33", + "name": "Empty description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Register a workload cluster to deploy and manage game servers. This panel can run without a local cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] +} + diff --git a/assets/design-export/json/p8QSPq.json b/assets/design-export/json/p8QSPq.json new file mode 100644 index 000000000..d3f59452a --- /dev/null +++ b/assets/design-export/json/p8QSPq.json @@ -0,0 +1,3763 @@ +{ + "type": "frame", + "id": "p8QSPq", + "x": -17357, + "y": 34832, + "name": "Screen/Standalone — Modules (Manage sources)", + "context": "Background refetch failures display an inline retry banner and retain existing data plus open install/upload/builder/source drafts. Only an initial failure replaces loading content. Explicit target changes discard drafts.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 2050, + "fill": "$background/background", + "children": [ + { + "id": "a0hci", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "sTSGR": { + "fill": "$accent/soft" + }, + "sTSGR/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "sTSGR/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "g0Gcc", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "wnNlp", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Modules" + } + } + }, + { + "type": "frame", + "id": "JlGgj", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "K58mkD", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "a3CoXF", + "name": "API status", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "XZcMg", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "BHaZy", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "East production", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "h2ttG", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "id": "Q5xfK", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Modules" + }, + "VbHwU": { + "content": "Pre-packaged game-server templates pulled from your configured module sources." + }, + "TgdLz": { + "type": "frame", + "id": "lGRJX", + "name": "phActions", + "height": 36, + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "M6aca", + "type": "ref", + "ref": "i6gfu", + "name": "btnCreate", + "descendants": { + "FCYQ9": { + "icon": "plus" + }, + "GqVun": { + "content": "Create module" + } + } + }, + { + "id": "MihiS", + "type": "ref", + "ref": "i6gfu", + "name": "btnUpload", + "descendants": { + "FCYQ9": { + "icon": "upload" + }, + "GqVun": { + "content": "Upload module" + } + } + }, + { + "id": "d6jwl", + "type": "ref", + "ref": "i6gfu", + "name": "btnSources", + "descendants": { + "FCYQ9": { + "icon": "settings-2" + }, + "GqVun": { + "content": "Manage sources" + } + } + } + ] + } + } + }, + { + "type": "frame", + "id": "b5EIv4", + "name": "Background refresh error with retained draft", + "width": "fill_container", + "height": 64, + "fill": "$surface/surface", + "gap": 16, + "padding": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "hLpJp", + "name": "Refresh error", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Failed to load module sources.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "TjNdy", + "type": "ref", + "ref": "FIB65", + "name": "Retry request", + "height": 32, + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Retry" + } + } + } + ] + }, + { + "type": "frame", + "id": "J8jUzJ", + "name": "Sources managed on east", + "clip": true, + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "MI0WR", + "name": "Card Header", + "width": "fill_container", + "gap": 12, + "padding": [ + 20, + 20, + 12, + 20 + ], + "justifyContent": "space_between", + "children": [ + { + "type": "frame", + "id": "hkBdS", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "Y75u1i", + "fill": "$foreground/foreground", + "content": "Module sources", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "MTpye", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Registries, git repositories, archives, and uploads the operator pulls module bundles from. Equivalent to applying ModuleSource resources directly.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "RxHiA", + "name": "btnAddSource", + "height": 32, + "fill": "$accent/accent", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "tuDoD", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$accent/foreground" + }, + { + "type": "text", + "id": "T1mVdr", + "fill": "$accent/foreground", + "content": "Add source", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "VWcp7", + "name": "Card Content", + "width": "fill_container", + "layout": "vertical", + "padding": [ + 0, + 20, + 20, + 20 + ], + "children": [ + { + "type": "frame", + "id": "yxJqR", + "name": "srcDefault", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "strokeAlignment": "inner", + "gap": 12, + "padding": [ + 12, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "uZnIz", + "width": 16, + "height": 16, + "icon": "circle-check", + "library": "lucide", + "fill": "$success/success" + }, + { + "type": "frame", + "id": "P0ZcU", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "frame", + "id": "gd05S", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pJ5au", + "fill": "$foreground/foreground", + "content": "default", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "D8xlhC", + "name": "typeBadge", + "fill": "$surface/surface", + "cornerRadius": 4, + "padding": [ + 2, + 6 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "sEpu8", + "fill": "$muted", + "content": "GIT", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "shxpn", + "fill": "$muted", + "content": "https://github.com/ValgulNecron/gameplane-module.git@main", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "zwFvI", + "layout": "vertical", + "gap": 2, + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "S7mzjE", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "BIcZC", + "width": 12, + "height": 12, + "icon": "refresh-ccw", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "veFpU", + "fill": "$muted", + "content": "1h", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "WwnzL", + "fill": "$muted", + "content": "6 modules · synced 5m ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "AFCUJ", + "name": "srcActions", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "PU4yE", + "name": "btnEdit", + "width": 32, + "height": 32, + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "WAw4z", + "width": 14, + "height": 14, + "icon": "pencil", + "library": "lucide", + "fill": "$foreground/foreground" + } + ] + }, + { + "type": "frame", + "id": "d5PVe", + "name": "btnDelete", + "width": 32, + "height": 32, + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "EF7tb", + "width": 14, + "height": 14, + "icon": "trash-2", + "library": "lucide", + "fill": "#DC2626" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "l1orWs", + "name": "srcUploads", + "width": "fill_container", + "gap": 12, + "padding": [ + 12, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "gbSh7", + "width": 16, + "height": 16, + "icon": "circle-check", + "library": "lucide", + "fill": "$success/success" + }, + { + "type": "frame", + "id": "Z3GdpB", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "frame", + "id": "s2hoU", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "vEfk1", + "fill": "$foreground/foreground", + "content": "uploads", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "S8r64q", + "name": "typeBadge", + "fill": "$surface/surface", + "cornerRadius": 4, + "padding": [ + 2, + 6 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f6DG2j", + "fill": "$muted", + "content": "UPLOAD", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "LgAwr", + "fill": "$muted", + "content": "uploaded bundles", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "FPJNy", + "layout": "vertical", + "gap": 2, + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "TWnYn", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "RMrWy", + "width": 12, + "height": 12, + "icon": "refresh-ccw", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "BSGzG", + "fill": "$muted", + "content": "1h", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "U3iqN", + "fill": "$muted", + "content": "0 modules · synced 6m ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "OmFqB", + "name": "srcActions", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "A6PjcO", + "name": "btnEdit", + "width": 32, + "height": 32, + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "CZ85P", + "width": 14, + "height": 14, + "icon": "pencil", + "library": "lucide", + "fill": "$foreground/foreground" + } + ] + }, + { + "type": "frame", + "id": "n1dMB", + "name": "btnDelete", + "width": 32, + "height": 32, + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "N0SV7", + "width": 14, + "height": 14, + "icon": "trash-2", + "library": "lucide", + "fill": "#DC2626" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "u8Ae4", + "name": "Filters", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "soXvQ", + "name": "srcGroup", + "fill": "$surface/surface", + "cornerRadius": 6, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 4, + "padding": 4, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "kilzZ", + "name": "srcAll", + "fill": "$accent/soft", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "a6vN1", + "fill": "$accent/accent", + "content": "All sources", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "QGtwU", + "name": "srcDefault", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f4olUt", + "fill": "$muted", + "content": "default", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "mGEYc", + "name": "srcCommunity", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "aSClZ", + "fill": "$muted", + "content": "community", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "zto8x", + "name": "catGroup", + "clip": true, + "width": 440, + "height": 36, + "fill": "$surface/surface", + "cornerRadius": 6, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 4, + "padding": 4, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "CjVw6", + "name": "catAllcategories", + "fill": "#00000000", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "V2Hgz", + "name": "Label", + "fill": "$muted", + "content": "All categories", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "UPWC1", + "name": "catAdventure", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "PTgAB", + "name": "Label", + "fill": "$muted", + "content": "Adventure", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "TxAhs", + "name": "catBuilding", + "fill": "$accent/soft", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "j40Io", + "name": "Label", + "fill": "$accent/accent", + "content": "Building", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "cQ08T", + "name": "catCoop", + "fill": "$accent/soft", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WfIdC", + "name": "Label", + "fill": "$accent/accent", + "content": "Co-op", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "iJoF9", + "name": "catCreative", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "g0ypA", + "name": "Label", + "fill": "$muted", + "content": "Creative", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "mVLhg", + "name": "catHorror", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "x9ePWy", + "name": "Label", + "fill": "$muted", + "content": "Horror", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Mxl9i", + "name": "catModded", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cZhAZ", + "name": "Label", + "fill": "$muted", + "content": "Modded", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "rROw4", + "name": "catPvP", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Hz4BX", + "name": "Label", + "fill": "$muted", + "content": "PvP", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "P2WuYs", + "name": "catSandbox", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zHCrO", + "name": "Label", + "fill": "$muted", + "content": "Sandbox", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "eF4q6", + "name": "catShooter", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qj6a4", + "name": "Label", + "fill": "$muted", + "content": "Shooter", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "topRr", + "name": "catSimulation", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "au1oN", + "name": "Label", + "fill": "$muted", + "content": "Simulation", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "TzW0o", + "name": "catSurvival", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Nwe92", + "name": "Label", + "fill": "$muted", + "content": "Survival", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "rectangle", + "cornerRadius": [ + 0, + 6, + 6, + 0 + ], + "id": "KCgvL", + "layoutPosition": "absolute", + "x": 384, + "y": 0, + "name": "Fade Right", + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 270, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A1A00", + "position": 0 + }, + { + "color": "$surface/surface", + "position": 1 + } + ] + }, + "width": 56, + "height": 36 + } + ] + }, + { + "type": "frame", + "id": "tSaLJ", + "name": "spacer", + "width": "fill_container", + "height": 1 + }, + { + "id": "DMhCM", + "type": "ref", + "ref": "Lmaf1", + "name": "Search", + "width": 256, + "descendants": { + "EJMtX": { + "content": "Search modules…" + } + } + } + ] + }, + { + "type": "frame", + "id": "Z7GgT", + "name": "Row 1", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "Zd3Wj", + "name": "mc1", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "pkGvC", + "name": "mc1h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "xO038", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$success/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dQ6tC", + "fill": "$success/soft-foreground", + "content": "MI", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "qqcoX", + "name": "mc1Badges", + "gap": 6, + "alignItems": "center", + "children": [ + { + "id": "viCwz", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "type": "frame", + "id": "SwTdp", + "name": "IconLabel", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "s2gRw", + "width": 12, + "height": 12, + "icon": "shield-check", + "library": "lucide", + "fill": "$success/soft-foreground" + }, + { + "type": "text", + "id": "Nh46h", + "name": "Label", + "fill": "$success/soft-foreground", + "content": "VERIFIED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + } + }, + { + "id": "Dgl18", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "UtBQE", + "name": "mc1Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "yismd", + "fill": "$foreground/foreground", + "content": "Minecraft (Java Edition)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "eN247", + "fill": "$muted", + "content": "v1.21.4 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "AIC3u", + "name": "mc1Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "sZt4l", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "N9pQuB", + "fill": "$muted", + "content": "sha256:a1b2c3d4…9e2f", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "jB6tM", + "name": "mc1Rollback", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "VZyxm", + "width": 12, + "height": 12, + "icon": "history", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "t8TEG", + "fill": "$muted", + "content": "rollback target · v1.21.3", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "yKw65", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "t9g85", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + }, + { + "id": "cAjOH", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "BCceU", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + } + ] + }, + { + "type": "text", + "id": "zkONJ", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Vanilla / Paper / Spigot / Bukkit / Purpur / Forge / NeoForge / Fabric / Quilt servers with a version+loader picker and in-app Modrinth browse.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "P2MKOI", + "name": "mc1f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "O3JPo", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "xemn4", + "name": "mc1Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "THRCL", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to east" + } + } + }, + { + "id": "LLUiX", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "bKZtT", + "name": "mc2", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "LQZCB", + "name": "mc2h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "hCC9R", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$success/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "NkmDB", + "fill": "$success/soft-foreground", + "content": "MI", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "KEk3N", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "svhbh", + "name": "mc2Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "zB1vf", + "fill": "$foreground/foreground", + "content": "Minecraft (Bedrock)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "xx6Te", + "fill": "$muted", + "content": "v1.21.5 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "s8JV5D", + "name": "mc2Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "mELJs", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "lKLfS", + "fill": "$muted", + "content": "sha256:5f0ab913…c47d", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "bA5ER", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "HhSiK", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + }, + { + "id": "x3nz4N", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "lTCyy", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Creative", + "descendants": { + "r4GQ9": { + "content": "Creative" + } + } + } + ] + }, + { + "type": "text", + "id": "XCnNa", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Cross-platform edition for mobile, console and Windows clients. Allow-list and permissions managed from the wizard.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "tLq9Z", + "name": "mc2f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "R9pBOh", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "xc52L", + "name": "mc2Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "iQoM4", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to east" + } + } + }, + { + "id": "ZOIiU", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "tYqjs", + "name": "mc3", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "CleEB", + "name": "mc3h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Q8ONbV", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$warning/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "AA2fN", + "fill": "$warning/soft-foreground", + "content": "VA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "RSvQp", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "lVWIo", + "name": "mc3Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "zQ8nz", + "fill": "$foreground/foreground", + "content": "Valheim (Dedicated)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "lnC9o", + "fill": "$muted", + "content": "v2.3.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Aeiig", + "name": "mc3Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "YTxBw", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "G5qbs", + "fill": "$muted", + "content": "sha256:9c14e07b…3ba8", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "j91LWH", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "vQck4", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "TsaRJ", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Co-op", + "descendants": { + "r4GQ9": { + "content": "Co-op" + } + } + }, + { + "id": "LkpEw", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + } + ] + }, + { + "type": "text", + "id": "vXbJa", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Valheim dedicated server (lloesche/valheim-server) with a stable/public-test channel picker, a BepInEx mod volume, and in-app Thunderstore browse. Crossplay-friendly.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "k8ytP", + "name": "mc3f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "cNCQT", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "mPtJ5", + "name": "mc3Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "AernV", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to east" + } + } + }, + { + "id": "RxFCR", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ebRfJ", + "name": "mc4", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "JedWd", + "name": "mc4h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ZLHOt", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$accent/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "bwjDn", + "fill": "$accent/accent", + "content": "FA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "yMufO", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "Z8AjI", + "name": "mc4Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "dUUYa", + "fill": "$foreground/foreground", + "content": "Factorio (Headless)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "l5q9e", + "fill": "$muted", + "content": "v1.1.0", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "fbuay", + "name": "mc4Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "mWApO", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "xSozj", + "fill": "$muted", + "content": "sha256:7d32e46a…1161", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "VTf1l", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "leIyw", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Simulation", + "descendants": { + "r4GQ9": { + "content": "Simulation" + } + } + }, + { + "id": "E64arz", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + }, + { + "id": "IaSJ2", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + } + ] + }, + { + "type": "text", + "id": "uRWC2", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Factorio headless server (factoriotools/factorio) with a stable/latest/pinned channel picker, server-settings managed from the wizard, and a mods volume with in-app Factorio mod portal browse.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "IxGHt", + "name": "mc4f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dvai6", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "sKCUO", + "name": "mc4Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "MEgRI", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "OMvxz", + "x": 24, + "y": 511, + "name": "Row 2", + "enabled": false, + "width": 1132, + "height": 1, + "gap": 16, + "children": [ + { + "type": "frame", + "id": "fygEK", + "name": "mc5", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "NeZoF", + "name": "mc5h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "lKnxf", + "name": "tile", + "width": 36, + "height": 36, + "fill": "#895AF633", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Pk8TQ", + "fill": "#895AF6", + "content": "PA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "jPeZ7", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "R82R9r", + "name": "mc5Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "f0U3O", + "fill": "$foreground/foreground", + "content": "Palworld (Dedicated)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "cH13y", + "fill": "$muted", + "content": "v1.1.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "wuFv6", + "name": "mc5Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "h6TAP", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "bmVsk", + "fill": "$muted", + "content": "sha256:2e91fa04…8d17", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Vhyjs", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "z0TyDF", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "oiRRk", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Co-op", + "descendants": { + "r4GQ9": { + "content": "Co-op" + } + } + }, + { + "id": "LCdcS", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + } + ] + }, + { + "type": "text", + "id": "r9S2W", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Palworld dedicated server (thijsvanloef/palworld-server-docker) with RCON console, save-aware graceful stop, and a .pak mod volume with archive install.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "j2upP", + "name": "mc5f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "pMiIe", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "jQ0AO", + "name": "mc5Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "mOdgq", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to east" + } + } + }, + { + "id": "iUWAR", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ZXGJc", + "name": "mc6", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "n7t59", + "name": "mc6h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "DiDY0", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$danger/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pS0jk", + "fill": "$danger/soft-foreground", + "content": "AR", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "Z6oyo", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "XwZbc", + "name": "mc6Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "cCYEd", + "fill": "$foreground/foreground", + "content": "ARK: Survival Evolved", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "dfWh4", + "fill": "$muted", + "content": "v0.9.2", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "nGV9x", + "name": "mc6Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "eAwzD", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "JuN3U", + "fill": "$muted", + "content": "sha256:b7440ce1…05af", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "jis3O", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "YXrSG", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "Fjn4y", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + }, + { + "id": "Pz6cy", + "type": "ref", + "ref": "Y43SFn", + "name": "cat PvP", + "descendants": { + "r4GQ9": { + "content": "PvP" + } + } + } + ] + }, + { + "type": "text", + "id": "XHmii", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Dinosaur survival on the Unreal engine. Mod install via the Steam workshop; schedule onto a GPU node for acceptable tick rates.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "ndlqp", + "name": "mc6f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "x8peT", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "MJCDK", + "name": "mc6Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "MVzKP", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "WamPF", + "name": "mc7", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "tJKeT", + "name": "mc7h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "qOirq", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$success/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "F0hOT", + "fill": "$success/soft-foreground", + "content": "TE", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "QyR5D", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "A3sspS", + "name": "mc7Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "KXJuU", + "fill": "$foreground/foreground", + "content": "Terraria", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "QKgoR", + "fill": "$muted", + "content": "v2.1.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "asFjn", + "name": "mc7Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "mdmwx", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "Krpj9", + "fill": "$muted", + "content": "sha256:c805d7f2…46e9", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Bb4Tn", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "ZRMPP", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + }, + { + "id": "slB2u", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "Ry6I9", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Adventure", + "descendants": { + "r4GQ9": { + "content": "Adventure" + } + } + } + ] + }, + { + "type": "text", + "id": "RRWKb", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Terraria + tModLoader dedicated server (passivelemon/terraria-docker) with a vanilla/tModLoader version picker and a tModLoader ModPacks volume. Console via container stdin.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "XdW5u", + "name": "mc7f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "BrXvJ", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "GbpER", + "name": "mc7Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "CxB0Y", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to east" + } + } + }, + { + "id": "zkOJa", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "g8SEAP", + "name": "mc8", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "qY5oM", + "name": "mc8h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ejhb3", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$warning/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cMKUu", + "fill": "$warning/soft-foreground", + "content": "CO", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "HDUbZ", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "Ftwt2", + "name": "mc8Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "pPpkZ", + "fill": "$foreground/foreground", + "content": "Counter-Strike 2", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "pT7nq", + "fill": "$muted", + "content": "v0.4.1", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "OhBMN", + "name": "mc8Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "fJTiv", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "uPjgV", + "fill": "$muted", + "content": "sha256:1a6be390…f72c", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "RfdEt", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "ys0Z7", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Shooter", + "descendants": { + "r4GQ9": { + "content": "Shooter" + } + } + }, + { + "id": "apdY4", + "type": "ref", + "ref": "Y43SFn", + "name": "cat PvP", + "descendants": { + "r4GQ9": { + "content": "PvP" + } + } + } + ] + }, + { + "type": "text", + "id": "OvL8k", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Competitive shooter with MatchZy and CounterStrikeSharp plugin support. Game-server login token supplied as a secret.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Lyfuy", + "name": "mc8f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pnnS5", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "a726o", + "name": "mc8Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "uzcks", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "WLFZc", + "x": 24, + "y": 861, + "name": "Row 3", + "enabled": false, + "width": 1132, + "height": 1, + "gap": 16, + "children": [ + { + "type": "frame", + "id": "jthhr", + "name": "mc9", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "saPTN", + "name": "mc9h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "d4sg4a", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$danger/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f4C5ms", + "fill": "$danger/soft-foreground", + "content": "RU", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "zdIe4", + "name": "mc9Badges", + "gap": 6, + "alignItems": "center", + "children": [ + { + "id": "g6Qmwx", + "type": "ref", + "ref": "a3AjAZ", + "name": "Badge", + "descendants": { + "F9x0p": { + "type": "frame", + "id": "KTSSG", + "name": "IconLabel", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "DZlp8", + "width": 12, + "height": 12, + "icon": "shield-question", + "library": "lucide", + "fill": "$success/success" + }, + { + "type": "text", + "id": "oJrm9", + "name": "Label", + "fill": "$success/success", + "content": "POLICY", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + } + }, + { + "id": "CrFwU", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "g1RN2G", + "name": "mc9Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "U17Ikh", + "fill": "$foreground/foreground", + "content": "Rust", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "r6uNr3", + "fill": "$muted", + "content": "v0.7.3", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "D2mhK", + "name": "mc9Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "AGbwo", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "ls9wW", + "fill": "$muted", + "content": "sha256:3fd0817c…ba24", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "wQgao", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "t2qSb", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "S7U6hK", + "type": "ref", + "ref": "Y43SFn", + "name": "cat PvP", + "descendants": { + "r4GQ9": { + "content": "PvP" + } + } + }, + { + "id": "Y4jom", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + } + ] + }, + { + "type": "text", + "id": "xbXZf", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Hardcore multiplayer survival with Oxide and Carbon mod loaders. Monthly forced wipe; schedule a backup before each one.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "YU647", + "name": "mc9f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "l3RRM", + "fill": "$muted", + "content": "community (oci)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "niThi", + "name": "mc9Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "NyJPf", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "V1ZI1I", + "name": "mc10", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "R0vvsr", + "name": "mc10h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "CpEVl", + "name": "tile", + "width": 36, + "height": 36, + "fill": "#9494944D", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "J2XKZ2", + "fill": "$muted", + "content": "7-", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "EbCy6", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "nV7q0", + "name": "mc10Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "SzaPV", + "fill": "$foreground/foreground", + "content": "7 Days to Die", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "lGiWo", + "fill": "$muted", + "content": "v0.5.0", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "o6F6Q", + "name": "mc10Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "NTdlH", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "nEZCx", + "fill": "$muted", + "content": "sha256:e2c93b58…7a0d", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "H4BCKr", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "tJ6b2", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "tPBQT", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Horror", + "descendants": { + "r4GQ9": { + "content": "Horror" + } + } + }, + { + "id": "LPhof", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + } + ] + }, + { + "type": "text", + "id": "r7qr7", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Zombie survival crafting in a destructible voxel world. Blood-moon horde nights are CPU-spiky — give it headroom.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "h5V6fW", + "name": "mc10f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Od5rd", + "fill": "$muted", + "content": "community (oci)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "D6YZj", + "name": "mc10Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "z7Ywcw", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "XuDAZ", + "name": "mc11", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "n5ZozJ", + "name": "mc11h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "r8s2R4", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$accent/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mDQZ9", + "fill": "$accent/accent", + "content": "SA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "rHKzb", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "Bmq8H", + "name": "mc11Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "SERSW", + "fill": "$foreground/foreground", + "content": "Satisfactory (Dedicated)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "nNQib", + "fill": "$muted", + "content": "v1.0.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "T9fUSf", + "name": "mc11Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "F435la", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "dDRIP", + "fill": "$muted", + "content": "sha256:4b7ecf19…d3e6", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "N9m6e", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "s6rHyX", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Simulation", + "descendants": { + "r4GQ9": { + "content": "Simulation" + } + } + }, + { + "id": "PHNkq", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + }, + { + "id": "kWbw2", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + } + ] + }, + { + "type": "text", + "id": "C9af8t", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Satisfactory dedicated server (wolveix/satisfactory-server) with a stable/experimental branch picker. Env-configured; installs via steamcmd on first boot.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "M4IPNC", + "name": "mc11f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "Hk6v6", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "mXitr", + "name": "mc11Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "Ez1K6", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to east" + } + } + }, + { + "id": "OcY4I", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "tOwOm", + "name": "gridFiller", + "width": "fill_container", + "height": 1 + } + ] + } + ] + } + ] + } + ] +} diff --git a/assets/design-export/json/r139UP.json b/assets/design-export/json/r139UP.json new file mode 100644 index 000000000..9f8d794a4 --- /dev/null +++ b/assets/design-export/json/r139UP.json @@ -0,0 +1,234 @@ +{ + "type": "frame", + "id": "r139UP", + "x": -10957, + "y": 33432, + "name": "Screen/Standalone — System logs", + "context": "PR #630 standalone installation. No local Kubernetes metadata or local workload fallback. Master-key storage and offline rotation have no dashboard controls.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "WWoii", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Central management" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "zV6Z5": { + "fill": "$accent/soft" + }, + "zV6Z5/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "zV6Z5/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "bR7V9", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "GFROW", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "VzGni": { + "type": "frame", + "id": "i0bjE", + "name": "Breadcrumbs", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "AFgeH", + "name": "crumbRoot", + "fill": "$muted", + "content": "gameplane", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "hxYAU", + "name": "crumbSep", + "width": 14, + "height": 14, + "icon": "chevron-right", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "u2AU5", + "name": "crumbMid", + "fill": "$muted", + "content": "Settings", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "trGtl", + "name": "crumbSep", + "width": 14, + "height": 14, + "icon": "chevron-right", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "SXXoW", + "name": "crumbPage", + "fill": "$foreground/foreground", + "content": "System logs", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "500" + } + ] + } + } + }, + { + "type": "frame", + "id": "cI7TE", + "name": "Central management notice", + "width": "fill_container", + "fill": "$surface/surface", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "layout": "vertical", + "padding": [ + 12, + 24 + ], + "children": [ + { + "type": "text", + "id": "MILE7", + "name": "Central management scope", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "HdzoC", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "SC3by", + "type": "ref", + "ref": "xCDF7", + "name": "System logs header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "System logs" + }, + "VbHwU": { + "content": "Logs for this standalone panel." + }, + "TgdLz": { + "type": "frame", + "id": "bVoTh", + "x": 1132, + "y": 8.5, + "name": "Page actions", + "enabled": false, + "width": 1, + "height": 1, + "gap": 8, + "alignItems": "center" + } + } + }, + { + "type": "frame", + "id": "s6N0gl", + "name": "Container logs", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "yPx4G", + "name": "Container logs title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "View logs through your container runtime", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "TT48A", + "name": "Container logs guidance", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "For Docker Compose, run docker compose logs --follow from your panel installation directory. Operator logs are available on each workload cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] +} + diff --git a/assets/design-export/json/uMiwd.json b/assets/design-export/json/uMiwd.json index 06da29d69..27851fc33 100644 --- a/assets/design-export/json/uMiwd.json +++ b/assets/design-export/json/uMiwd.json @@ -13,11 +13,12 @@ "fill": "$background/background", "children": [ { - "id": "bbiZQ", + "id": "WrwBN", "type": "ref", "ref": "kKFX9", "height": "fill_container", "layout": "vertical", + "name": "Restored ref", "descendants": { "Sci9k": { "height": 70, @@ -38,7 +39,7 @@ }, "ijsQu": { "type": "frame", - "id": "UfWg3", + "id": "YmdpQ", "name": "Nav Content", "width": "fill_container", "height": "fill_container", @@ -50,7 +51,7 @@ ], "children": [ { - "id": "AuPaR", + "id": "ZxAJ8", "type": "ref", "ref": "uFDbF", "name": "Section General", @@ -62,7 +63,7 @@ } }, { - "id": "M83pNt", + "id": "HdjOv", "type": "ref", "ref": "B5sYje", "name": "navDashboard", @@ -88,7 +89,7 @@ } }, { - "id": "p4SLRN", + "id": "untri", "type": "ref", "ref": "W8XiN", "name": "navServers", @@ -112,7 +113,7 @@ } }, { - "id": "YxlR5", + "id": "E3ZmBl", "type": "ref", "ref": "W8XiN", "name": "navModules", @@ -136,7 +137,7 @@ } }, { - "id": "Jb5er", + "id": "WFFAr", "type": "ref", "ref": "W8XiN", "name": "navBackups", @@ -160,7 +161,7 @@ } }, { - "id": "n2RpJu", + "id": "HaMLJ", "type": "ref", "ref": "uFDbF", "name": "Section Admin", @@ -172,7 +173,7 @@ } }, { - "id": "BKefb", + "id": "Pqt7v", "type": "ref", "ref": "W8XiN", "name": "navCluster", @@ -196,7 +197,7 @@ } }, { - "id": "HtRdQ", + "id": "r7g6W", "type": "ref", "ref": "W8XiN", "name": "Cluster inventory navigation", @@ -220,7 +221,7 @@ } }, { - "id": "E85j60", + "id": "AjxXL", "type": "ref", "ref": "W8XiN", "name": "navUsers", @@ -244,7 +245,7 @@ } }, { - "id": "yuoW5", + "id": "Q7mRuA", "type": "ref", "ref": "W8XiN", "name": "navAudit", @@ -268,7 +269,7 @@ } }, { - "id": "C1XTGP", + "id": "sA0m2", "type": "ref", "ref": "W8XiN", "name": "navLogs", @@ -292,7 +293,7 @@ } }, { - "id": "ec5Zd", + "id": "XoxKL", "type": "ref", "ref": "W8XiN", "name": "navSettings", @@ -324,7 +325,7 @@ }, "oByLP": { "type": "frame", - "id": "qAhr6", + "id": "UFIkp", "name": "Toggle Row", "width": "fill_container", "gap": 8, @@ -332,14 +333,14 @@ "alignItems": "center", "children": [ { - "id": "d1Whiq", + "id": "HzR8S", "type": "ref", "ref": "iA2C8", "name": "Appearance Toggle", "fill": "$surface/secondary" }, { - "id": "ugAHI", + "id": "T21hit", "type": "ref", "ref": "owgrI", "name": "Customize Theme", @@ -375,7 +376,7 @@ }, { "type": "frame", - "id": "UosI6", + "id": "svJe1", "name": "Central management notice", "width": "fill_container", "height": 45, @@ -394,7 +395,7 @@ "children": [ { "type": "text", - "id": "TpnjI", + "id": "LnYBB", "name": "Central management label", "fill": "$foreground/foreground", "content": "Central management.", @@ -405,7 +406,7 @@ }, { "type": "text", - "id": "zpf0T", + "id": "LLHgj", "name": "Central management explanation", "fill": "$muted", "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", diff --git a/assets/design-export/json/w34N9.json b/assets/design-export/json/w34N9.json new file mode 100644 index 000000000..c05605afc --- /dev/null +++ b/assets/design-export/json/w34N9.json @@ -0,0 +1,802 @@ +{ + "type": "frame", + "id": "w34N9", + "x": -17357, + "y": 36232, + "name": "Screen/Standalone — Dashboard (No workload clusters)", + "context": "PR #630; successful empty fleet responses. Central audit is still authorized independently of workload clusters. No local inventory or cluster-dependent create action is available.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1150, + "fill": "$background/background", + "children": [ + { + "id": "v5wvl4", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + } + } + }, + { + "type": "frame", + "id": "q6N7y", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "Y2kiaV", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gb27k": { + "fontFamily": "$typography/font-sans" + } + } + }, + { + "type": "frame", + "id": "UuBhM", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 32, + "children": [ + { + "id": "A1SP8", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "VbHwU": { + "content": "Your game servers and resources across authorized locations." + }, + "TgdLz": { + "enabled": false, + "width": 1, + "height": 1, + "children": [ + { + "id": "eYdj6", + "type": "ref", + "ref": "tpKRk", + "name": "Create Server Button", + "descendants": { + "q58In/Xpn7K": { + "library": "lucide", + "icon": "plus" + }, + "q58In/U6q13B": { + "content": "Create server" + } + } + } + ] + } + } + }, + { + "id": "HXGfS", + "type": "ref", + "ref": "AT7ya", + "name": "Dashboard location filter", + "width": 240, + "context": "Optional location filter; All locations combines authorized inventory.", + "descendants": { + "cFsFk": { + "content": "All locations", + "fill": "$foreground/foreground" + } + } + }, + { + "id": "rlQYA", + "type": "ref", + "ref": "YF85x", + "name": "Stats Row", + "width": "fill_container", + "descendants": { + "m20CHt/QdcEm": { + "fill": "#000000" + }, + "m20CHt/vj2Ru": { + "content": "0" + }, + "m20CHt/h0n7zo": { + "content": "of 0 total" + }, + "T0vNbN/vj2Ru": { + "content": "0" + }, + "T0vNbN/h0n7zo": { + "content": "peak 0" + }, + "pkvT2/QdcEm": { + "fill": "$chart/stat" + }, + "pkvT2/vj2Ru": { + "content": "—" + }, + "pkvT2/h0n7zo": { + "content": "authorized inventory cores" + }, + "f77w2/vj2Ru": { + "content": "—" + }, + "f77w2/h0n7zo": { + "content": "inventory unavailable" + }, + "FlXsH/QdcEm": { + "fill": "$chart/stat" + }, + "FlXsH/vj2Ru": { + "content": "—" + }, + "FlXsH/h0n7zo": { + "content": "no node data" + } + } + }, + { + "type": "frame", + "id": "f4fqgS", + "name": "Row2", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "v5Iz4", + "name": "Fleet Status Card", + "width": "fill_container", + "height": 372, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "vGIhs", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "K0OUb", + "name": "title", + "fill": "$foreground/foreground", + "content": "Fleet status", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "juhsa", + "name": "link", + "fill": "$muted", + "content": "0 servers", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "g74KcN", + "name": "Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "p2hqGo", + "name": "Health Bar", + "clip": true, + "width": "fill_container", + "height": 8, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "id": "hk791", + "x": 0, + "y": 0, + "name": "Running Segment", + "enabled": false, + "fill": "$success/success", + "width": 1, + "height": 8 + } + ] + }, + { + "type": "frame", + "id": "dmVSG", + "name": "Legend", + "gap": 20, + "children": [ + { + "type": "frame", + "id": "SgRIo", + "name": "legend-Running 1", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "swU3B", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "Lb2vq", + "name": "label", + "fill": "$muted", + "content": "Running 0", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "MjbJw", + "name": "legend-Stopped 0", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "y2RUy", + "name": "dot", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "l2BXOh", + "name": "label", + "fill": "$muted", + "content": "Stopped 0", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "s7xpZc", + "name": "legend-Failed 0", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "BVMtu", + "name": "dot", + "fill": "$danger/danger", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "UQrlo", + "name": "label", + "fill": "$muted", + "content": "Failed 0", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "W3zVRn", + "name": "Attention Section", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "text", + "id": "YiJ4T", + "name": "attnLabel", + "fill": "$muted", + "content": "NEEDS ATTENTION", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "500", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "aifto", + "name": "healthyRow", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "DNys1", + "name": "check", + "width": 16, + "height": 16, + "icon": "circle-check", + "library": "lucide", + "fill": "$success/success" + }, + { + "type": "text", + "id": "cZoEg", + "name": "healthyLabel", + "fill": "$muted", + "content": "Everything looks healthy.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "czOM1", + "name": "Cluster resources", + "width": "fill_container", + "height": 372, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "wboXa", + "name": "Cluster resources header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "KS89o", + "name": "Card title", + "fill": "$foreground/foreground", + "content": "Cluster resources", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "UcF7u", + "name": "No registered workload location", + "fill": "$muted", + "content": "—", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "mueaU", + "name": "CPU meter", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "frame", + "id": "MqVSU", + "name": "CPU values", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "LgrxO", + "name": "Resource name", + "fill": "$muted", + "content": "CPU", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ftItm", + "name": "Unavailable reading", + "fill": "$foreground/foreground", + "content": "—", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "RomqQ", + "name": "CPU empty track", + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999 + } + ] + }, + { + "type": "frame", + "id": "WVquz", + "name": "Memory meter", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "frame", + "id": "KN0ld", + "name": "Memory values", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "zMf52", + "name": "Resource name", + "fill": "$muted", + "content": "Memory", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "wBK9q", + "name": "Unavailable reading", + "fill": "$foreground/foreground", + "content": "—", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "DdUUw", + "name": "Memory empty track", + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999 + } + ] + }, + { + "type": "frame", + "id": "D5BQN8", + "name": "Storage meter", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "frame", + "id": "xVsFF", + "name": "Storage values", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "wwX0F", + "name": "Resource name", + "fill": "$muted", + "content": "Storage", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "fA9Kp", + "name": "Unavailable reading", + "fill": "$foreground/foreground", + "content": "—", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "FFws4", + "name": "Storage empty track", + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999 + }, + { + "type": "text", + "id": "y97ogA", + "name": "Storage availability", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "inventory unavailable", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "DEkoJ", + "name": "Row3", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "mTosb", + "name": "Central audit activity", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "CP6C3", + "name": "Central audit scope", + "fill": "$muted", + "content": "Central Gameplane audit activity", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "vaVan", + "name": "Recent Activity Card", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "RS9aM", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pXAT5", + "name": "title", + "fill": "$foreground/foreground", + "content": "Recent activity", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "NfMjL", + "name": "link", + "fill": "$accent/accent", + "content": "View all", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "I1LzJ", + "name": "Empty audit content", + "width": "fill_container", + "height": 290, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "MRJ7O", + "name": "No activity", + "fill": "$muted", + "content": "No recent activity.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "d0DCd", + "name": "Recent Backups Card", + "width": "fill_container", + "height": 353, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "dzlc5", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "LaUmt", + "name": "title", + "fill": "$foreground/foreground", + "content": "Recent backups", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "VNlOb", + "name": "link", + "fill": "$accent/accent", + "content": "View all", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "utnZA", + "name": "Content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [ + 40, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "XmWeX", + "name": "empty", + "fill": "$muted", + "content": "No backups yet.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} + diff --git a/assets/design-export/screenshots/Be7ZW.png b/assets/design-export/screenshots/Be7ZW.png new file mode 100644 index 000000000..09e382bdd Binary files /dev/null and b/assets/design-export/screenshots/Be7ZW.png differ diff --git a/assets/design-export/screenshots/BguST.png b/assets/design-export/screenshots/BguST.png new file mode 100644 index 000000000..94b80c82d Binary files /dev/null and b/assets/design-export/screenshots/BguST.png differ diff --git a/assets/design-export/screenshots/C9bR6.png b/assets/design-export/screenshots/C9bR6.png new file mode 100644 index 000000000..e3e3aeafc Binary files /dev/null and b/assets/design-export/screenshots/C9bR6.png differ diff --git a/assets/design-export/screenshots/D9QYZ.png b/assets/design-export/screenshots/D9QYZ.png new file mode 100644 index 000000000..b93740588 Binary files /dev/null and b/assets/design-export/screenshots/D9QYZ.png differ diff --git a/assets/design-export/screenshots/EWrn0.png b/assets/design-export/screenshots/EWrn0.png new file mode 100644 index 000000000..c837c8e49 Binary files /dev/null and b/assets/design-export/screenshots/EWrn0.png differ diff --git a/assets/design-export/screenshots/EkcbF.png b/assets/design-export/screenshots/EkcbF.png deleted file mode 100644 index 4fccbb52f..000000000 Binary files a/assets/design-export/screenshots/EkcbF.png and /dev/null differ diff --git a/assets/design-export/screenshots/FOVMf.png b/assets/design-export/screenshots/FOVMf.png new file mode 100644 index 000000000..001aa2b54 Binary files /dev/null and b/assets/design-export/screenshots/FOVMf.png differ diff --git a/assets/design-export/screenshots/HVhmb.png b/assets/design-export/screenshots/HVhmb.png new file mode 100644 index 000000000..72b0609f8 Binary files /dev/null and b/assets/design-export/screenshots/HVhmb.png differ diff --git a/assets/design-export/screenshots/OC804.png b/assets/design-export/screenshots/OC804.png index 77dc3ca72..bedeba2e6 100644 Binary files a/assets/design-export/screenshots/OC804.png and b/assets/design-export/screenshots/OC804.png differ diff --git a/assets/design-export/screenshots/QFEg9.png b/assets/design-export/screenshots/QFEg9.png index cca04013e..df25d93d0 100644 Binary files a/assets/design-export/screenshots/QFEg9.png and b/assets/design-export/screenshots/QFEg9.png differ diff --git a/assets/design-export/screenshots/RS0KZ.png b/assets/design-export/screenshots/RS0KZ.png new file mode 100644 index 000000000..92c7c1c60 Binary files /dev/null and b/assets/design-export/screenshots/RS0KZ.png differ diff --git a/assets/design-export/screenshots/RpHBg.png b/assets/design-export/screenshots/RpHBg.png new file mode 100644 index 000000000..655303e3c Binary files /dev/null and b/assets/design-export/screenshots/RpHBg.png differ diff --git a/assets/design-export/screenshots/U9xSg.png b/assets/design-export/screenshots/U9xSg.png new file mode 100644 index 000000000..1624cbece Binary files /dev/null and b/assets/design-export/screenshots/U9xSg.png differ diff --git a/assets/design-export/screenshots/Uzzje.png b/assets/design-export/screenshots/Uzzje.png new file mode 100644 index 000000000..8751d2804 Binary files /dev/null and b/assets/design-export/screenshots/Uzzje.png differ diff --git a/assets/design-export/screenshots/WrmU5.png b/assets/design-export/screenshots/WrmU5.png new file mode 100644 index 000000000..854b04c09 Binary files /dev/null and b/assets/design-export/screenshots/WrmU5.png differ diff --git a/assets/design-export/screenshots/YN8cE.png b/assets/design-export/screenshots/YN8cE.png new file mode 100644 index 000000000..655a9ddc4 Binary files /dev/null and b/assets/design-export/screenshots/YN8cE.png differ diff --git a/assets/design-export/screenshots/Z0oyh.png b/assets/design-export/screenshots/Z0oyh.png new file mode 100644 index 000000000..50109bbd7 Binary files /dev/null and b/assets/design-export/screenshots/Z0oyh.png differ diff --git a/assets/design-export/screenshots/Z81TW.png b/assets/design-export/screenshots/Z81TW.png new file mode 100644 index 000000000..67bba0a31 Binary files /dev/null and b/assets/design-export/screenshots/Z81TW.png differ diff --git a/assets/design-export/screenshots/ZlLf0.png b/assets/design-export/screenshots/ZlLf0.png new file mode 100644 index 000000000..514ef9815 Binary files /dev/null and b/assets/design-export/screenshots/ZlLf0.png differ diff --git a/assets/design-export/screenshots/aMU7O.png b/assets/design-export/screenshots/aMU7O.png new file mode 100644 index 000000000..08a443c56 Binary files /dev/null and b/assets/design-export/screenshots/aMU7O.png differ diff --git a/assets/design-export/screenshots/ffmEd.png b/assets/design-export/screenshots/ffmEd.png deleted file mode 100644 index 0bbde67b1..000000000 Binary files a/assets/design-export/screenshots/ffmEd.png and /dev/null differ diff --git a/assets/design-export/screenshots/h5clan.png b/assets/design-export/screenshots/h5clan.png deleted file mode 100644 index abf130bd7..000000000 Binary files a/assets/design-export/screenshots/h5clan.png and /dev/null differ diff --git a/assets/design-export/screenshots/kK8Ji.png b/assets/design-export/screenshots/kK8Ji.png index cd9f9e691..c1106621b 100644 Binary files a/assets/design-export/screenshots/kK8Ji.png and b/assets/design-export/screenshots/kK8Ji.png differ diff --git a/assets/design-export/screenshots/l64bd.png b/assets/design-export/screenshots/l64bd.png new file mode 100644 index 000000000..826c86a8d Binary files /dev/null and b/assets/design-export/screenshots/l64bd.png differ diff --git a/assets/design-export/screenshots/n2qdi.png b/assets/design-export/screenshots/n2qdi.png new file mode 100644 index 000000000..0dcabbf56 Binary files /dev/null and b/assets/design-export/screenshots/n2qdi.png differ diff --git a/assets/design-export/screenshots/p8QSPq.png b/assets/design-export/screenshots/p8QSPq.png new file mode 100644 index 000000000..8819cef24 Binary files /dev/null and b/assets/design-export/screenshots/p8QSPq.png differ diff --git a/assets/design-export/screenshots/r139UP.png b/assets/design-export/screenshots/r139UP.png new file mode 100644 index 000000000..ea2c73dc3 Binary files /dev/null and b/assets/design-export/screenshots/r139UP.png differ diff --git a/assets/design-export/screenshots/uMiwd.png b/assets/design-export/screenshots/uMiwd.png index 6aa7abe74..40e6fe7b8 100644 Binary files a/assets/design-export/screenshots/uMiwd.png and b/assets/design-export/screenshots/uMiwd.png differ diff --git a/assets/design-export/screenshots/w34N9.png b/assets/design-export/screenshots/w34N9.png new file mode 100644 index 000000000..36b9e8b7e Binary files /dev/null and b/assets/design-export/screenshots/w34N9.png differ diff --git a/charts/gameplane/examples/standalone-panel-values.yaml b/charts/gameplane/examples/standalone-panel-values.yaml new file mode 100644 index 000000000..12472e1be --- /dev/null +++ b/charts/gameplane/examples/standalone-panel-values.yaml @@ -0,0 +1,12 @@ +# Fresh central panel install; use helm install/upgrade --skip-crds. +# Helm processes crds/ before values/templates, so values alone cannot skip CRDs. +operator: + enabled: false +api: + enabled: true + standalone: true +crds: + autoApply: + enabled: false +ingress: + enabled: false diff --git a/charts/gameplane/templates/NOTES.txt b/charts/gameplane/templates/NOTES.txt index 05742eea5..5d86d3263 100644 --- a/charts/gameplane/templates/NOTES.txt +++ b/charts/gameplane/templates/NOTES.txt @@ -4,6 +4,17 @@ persistence and dashboard blocks are absent. */ -}} {{- $r := .Values.api.telemetry.receiver }} +{{- if .Values.api.standalone }} + +Standalone central panel: no local game cluster or Kubernetes API credentials. +Register remote clusters through the panel API after bootstrapping an admin. +Back up the database and matching encryption key separately. SQLite uses the +API data PVC; generated keys use the separate API key PVC mounted at /keys. +If using api.panelKey.existingSecret, protect and back up that Secret instead. +Restrict access to key backups; do not include them in database backup jobs. +Always pass --skip-crds when installing this profile; Helm's native crds/ +installation happens before chart values can disable resources. +{{- end }} {{- $receiverDeployed := and (eq (include "gameplane.apiEnabled" .) "true") $r.enabled (not .Values.api.telemetry.endpoint) }} {{- $persistence := $r.persistence | default dict }} {{- $persistenceEnabled := ternary $persistence.enabled false (hasKey $persistence "enabled") }} diff --git a/charts/gameplane/templates/_helpers.tpl b/charts/gameplane/templates/_helpers.tpl index 3ea948fdb..bec3e1ffb 100644 --- a/charts/gameplane/templates/_helpers.tpl +++ b/charts/gameplane/templates/_helpers.tpl @@ -126,3 +126,12 @@ without them rotating a custom Secret would not roll the API and operator. true {{- end -}} {{- end -}} + +{{- /* Preserve the combined default when upgrading with --reuse-values. */}} +{{- define "gameplane.operatorEnabled" -}} +{{- if hasKey .Values.operator "enabled" -}} +{{- .Values.operator.enabled -}} +{{- else -}} +true +{{- end -}} +{{- end -}} diff --git a/charts/gameplane/templates/api.yaml b/charts/gameplane/templates/api.yaml index 75924c742..f1155ed05 100644 --- a/charts/gameplane/templates/api.yaml +++ b/charts/gameplane/templates/api.yaml @@ -11,6 +11,7 @@ metadata: name: gameplane-api namespace: {{ .Release.Namespace }} labels: {{- include "gameplane.labels" . | nindent 4 }} +{{- if not .Values.api.standalone }} --- # Cluster-wide READ access: templates (cluster-scoped), nodes, storage # classes, and read-only watches on Gameplane CRDs for the /events stream. @@ -36,7 +37,7 @@ rules: verbs: [create, update, patch, delete] # source management (add/edit/remove sources) from the dashboard - apiGroups: ["gameplane.local"] resources: [clusters] - verbs: [get, list, watch, create, delete] # cluster registration and management from the dashboard + verbs: [get, list, watch, create, update, delete] # registration and gateway configuration from the dashboard - apiGroups: [""] resources: [nodes, events] verbs: [get, list, watch] @@ -179,6 +180,7 @@ subjects: - kind: ServiceAccount name: gameplane-api namespace: {{ .Release.Namespace }} +{{- end }} {{- /* Safety guard: If switching an existing install to existingClaim, ensure the live gameplane-api-data PVC won't be pruned and destroyed by Helm. @@ -221,7 +223,9 @@ spec: replicas: {{ .Values.api.replicas }} selector: matchLabels: { app.kubernetes.io/name: gameplane-api } - {{- if eq .Values.api.db.driver "sqlite" }} + {{- if or .Values.api.standalone (eq .Values.api.db.driver "sqlite") }} + # Standalone also avoids overlapping API processes with PostgreSQL: generated + # keys use a ReadWriteOnce PVC, and audit/user-management locks are per-process. # Recreate strategy for SQLite: the database is a single-writer store # on a ReadWriteOnce PVC. RollingUpdate would start a new pod before # terminating the old one, allowing two processes to compete for the @@ -249,20 +253,39 @@ spec: labels: app.kubernetes.io/name: gameplane-api {{- include "gameplane.labels" . | nindent 8 }} + {{- if not .Values.api.standalone }} annotations: checksum/mtls: {{ include "gameplane.mtlsChecksum" . }} + {{- end }} spec: serviceAccountName: gameplane-api + {{- if .Values.api.standalone }} + automountServiceAccountToken: false + {{- end }} securityContext: runAsNonRoot: true runAsUser: 65532 fsGroup: 65532 + {{- if .Values.api.standalone }} + # Avoid widening a generated 0600 key to 0660 on each PVC remount. + fsGroupChangePolicy: OnRootMismatch + {{- end }} seccompProfile: { type: RuntimeDefault } containers: - name: api image: {{ include "gameplane.apiImage" . }} imagePullPolicy: {{ .Values.image.pullPolicy }} args: + {{- if .Values.api.standalone }} + - --standalone + - {{ printf "--panel-key-file=/keys/%s" ((.Values.api.panelKey | default dict).fileName | default "panel.key") | quote }} + {{- with .Values.api.remoteAllowedCIDRs }} + - {{ printf "--remote-allowed-cidrs=%s" (join "," .) | quote }} + {{- end }} + {{- if (.Values.api.panelKey | default dict).existingSecret }} + - --panel-key-provisioned + {{- end }} + {{- end }} - --db-driver={{ .Values.api.db.driver }} - --db-dsn={{ .Values.api.db.dsn }} {{- if .Values.api.oidc.enabled }} @@ -288,12 +311,14 @@ spec: - {{ printf "--oidc-role-mapping-viewer=%s" (join "," .Values.api.oidc.roleMappings.viewer) | quote }} {{- end }} {{- end }} + {{- if not .Values.api.standalone }} {{- if and .Values.operator.gameDataStorage .Values.operator.gameDataStorage.storageClassName }} - --game-data-storage-class={{ .Values.operator.gameDataStorage.storageClassName }} {{- end }} - --agent-ca-bundle=/etc/gameplane/agent-ca/ca.crt - --agent-client-cert=/etc/gameplane/agent-client/tls.crt - --agent-client-key=/etc/gameplane/agent-client/tls.key + {{- end }} {{- if gt (int .Values.api.audit.retentionDays) 0 }} - --audit-retention-days={{ .Values.api.audit.retentionDays }} {{- end }} @@ -336,7 +361,7 @@ spec: {{- if not (ternary $telemetry.enabled true (hasKey $telemetry "enabled")) }} - --telemetry-disabled {{- end }} - {{- if .Values.defaultModuleSource.enabled }} + {{- if and (not .Values.api.standalone) .Values.defaultModuleSource.enabled }} - --official-module-source={{ .Values.defaultModuleSource.name | default "default" }} {{- end }} {{- /* @@ -359,14 +384,14 @@ spec: - --update-channel={{ $channel }} - --trusted-proxies={{ .Values.api.trustedProxies }} - --metrics-addr=:{{ .Values.api.metricsPort | default 9090 }} - {{- if .Values.clusterOps.externalAddress }} + {{- if and (not .Values.api.standalone) .Values.clusterOps.externalAddress }} - --cluster-external-address={{ .Values.clusterOps.externalAddress }} {{- end }} env: - name: GAMEPLANE_NAMESPACE valueFrom: fieldRef: { fieldPath: metadata.namespace } - {{- if .Values.clusterOps.enabled }} + {{- if and (not .Values.api.standalone) .Values.clusterOps.enabled }} - name: GAMEPLANE_CLUSTER_OPS value: "true" {{- end }} @@ -473,20 +498,49 @@ spec: readOnlyRootFilesystem: true capabilities: { drop: [ALL] } volumeMounts: + {{- if or (not .Values.api.standalone) (eq .Values.api.db.driver "sqlite") }} - { name: data, mountPath: /data } + {{- end }} + {{- if .Values.api.standalone }} + - name: panel-key + mountPath: /keys + {{- if (.Values.api.panelKey | default dict).existingSecret }} + readOnly: true + {{- end }} + {{- end }} + {{- if not .Values.api.standalone }} - { name: agent-ca, mountPath: /etc/gameplane/agent-ca, readOnly: true } - { name: agent-client, mountPath: /etc/gameplane/agent-client, readOnly: true } + {{- end }} volumes: + {{- if or (not .Values.api.standalone) (eq .Values.api.db.driver "sqlite") }} - name: data {{- if eq .Values.api.db.driver "sqlite" }} persistentVolumeClaim: { claimName: {{ .Values.api.storage.existingClaim | default "gameplane-api-data" }} } {{- else }} emptyDir: {} {{- end }} + {{- end }} + {{- if .Values.api.standalone }} + {{- $panelKey := .Values.api.panelKey | default dict }} + - name: panel-key + {{- if $panelKey.existingSecret }} + secret: + secretName: {{ $panelKey.existingSecret | quote }} + defaultMode: 0440 + items: + - key: {{ $panelKey.secretKey | default "panel.key" | quote }} + path: {{ $panelKey.fileName | default "panel.key" | quote }} + {{- else }} + persistentVolumeClaim: + claimName: {{ ($panelKey.storage | default dict).existingClaim | default "gameplane-api-key" | quote }} + {{- end }} + {{- else }} - name: agent-ca secret: { secretName: {{ .Values.api.agentMTLS.caSecretRef.name }} } - name: agent-client secret: { secretName: {{ .Values.api.agentMTLS.clientCertRef.name }} } + {{- end }} {{- with .Values.image.pullSecrets }} imagePullSecrets: {{ toYaml . | nindent 8 }} {{- end }} diff --git a/charts/gameplane/templates/clusterops-rbac.yaml b/charts/gameplane/templates/clusterops-rbac.yaml index 1ed613719..0a4ca9cbc 100644 --- a/charts/gameplane/templates/clusterops-rbac.yaml +++ b/charts/gameplane/templates/clusterops-rbac.yaml @@ -1,4 +1,4 @@ -{{- if and (eq (include "gameplane.apiEnabled" .) "true") .Values.clusterOps.enabled }} +{{- if and (not .Values.api.standalone) (eq (include "gameplane.apiEnabled" .) "true") .Values.clusterOps.enabled }} # RBAC for the credential-minting cluster operations (Add node, Download # kubeconfig). Granted ONLY when clusterOps.enabled — these are powerful # (create bootstrap tokens; approve client-cert CSRs), so they are opt-in diff --git a/charts/gameplane/templates/crd-apply-hook.yaml b/charts/gameplane/templates/crd-apply-hook.yaml index f0f9d6bcb..b767b4e28 100644 --- a/charts/gameplane/templates/crd-apply-hook.yaml +++ b/charts/gameplane/templates/crd-apply-hook.yaml @@ -1,3 +1,4 @@ +{{- if not .Values.api.standalone }} {{- if .Values.crds.autoApply.enabled }} {{- /* Helm installs the files under crds/ only on the FIRST install and never @@ -278,3 +279,5 @@ spec: imagePullSecrets: {{ toYaml . | nindent 8 }} {{- end }} {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/grafana-dashboard.yaml b/charts/gameplane/templates/grafana-dashboard.yaml index e8b3b7387..de1052252 100644 --- a/charts/gameplane/templates/grafana-dashboard.yaml +++ b/charts/gameplane/templates/grafana-dashboard.yaml @@ -1,3 +1,4 @@ +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} {{- if .Values.grafanaDashboards.enabled }} # Grafana dashboard for the operator, shipped as a ConfigMap that the Grafana # sidecar (kube-prometheus-stack / grafana helm chart) auto-imports by label. @@ -17,3 +18,5 @@ data: gameplane-operator.json: |- {{- .Files.Get "dashboards/gameplane-operator.json" | nindent 4 }} {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/mcp-server.yaml b/charts/gameplane/templates/mcp-server.yaml index aae636650..3858878a3 100644 --- a/charts/gameplane/templates/mcp-server.yaml +++ b/charts/gameplane/templates/mcp-server.yaml @@ -1,3 +1,4 @@ +{{- if not .Values.api.standalone }} {{- if .Values.mcpServer.enabled }} # Optional, strictly read-only MCP server. It speaks MCP (JSON-RPC 2.0) over # stdio only, never a network port, so there is no Service here — reach a @@ -87,3 +88,5 @@ spec: imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/module-cosign-key.yaml b/charts/gameplane/templates/module-cosign-key.yaml index de4b51940..20eb373fe 100644 --- a/charts/gameplane/templates/module-cosign-key.yaml +++ b/charts/gameplane/templates/module-cosign-key.yaml @@ -1,3 +1,4 @@ +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} {{- if and .Values.defaultModuleSource.enabled (eq (.Values.defaultModuleSource.type | default "oci") "oci") .Values.defaultModuleSource.oci.verify.enabled }} {{- if not .Values.defaultModuleSource.oci.verify.cosignPublicKey }} {{- fail "defaultModuleSource.oci.verify.enabled is true but defaultModuleSource.oci.verify.cosignPublicKey is empty — provide the signing public key (PEM)" }} @@ -17,3 +18,5 @@ type: Opaque data: cosign.pub: {{ .Values.defaultModuleSource.oci.verify.cosignPublicKey | b64enc }} {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/modulesource.yaml b/charts/gameplane/templates/modulesource.yaml index ad94ae188..ad43139ae 100644 --- a/charts/gameplane/templates/modulesource.yaml +++ b/charts/gameplane/templates/modulesource.yaml @@ -1,3 +1,4 @@ +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} {{- if .Values.defaultModuleSource.enabled }} {{- $src := .Values.defaultModuleSource }} {{- $type := $src.type | default "oci" }} @@ -50,3 +51,5 @@ spec: type: upload refreshInterval: {{ .Values.uploadModuleSource.refreshInterval | default "1h" }} {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/mtls.yaml b/charts/gameplane/templates/mtls.yaml index 116c363da..3eda0bbcd 100644 --- a/charts/gameplane/templates/mtls.yaml +++ b/charts/gameplane/templates/mtls.yaml @@ -1,3 +1,4 @@ +{{- if or (eq (include "gameplane.operatorEnabled" .) "true") (and (eq (include "gameplane.apiEnabled" .) "true") (not .Values.api.standalone)) }} {{- /* Agent mTLS material. @@ -109,3 +110,5 @@ data: {{- fail (printf "api.agentMTLS.clientCertRef.name=%q: Secret not found or missing required keys (tls.crt, tls.key)" $clientSecretName) }} {{- end }} {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/namespaces.yaml b/charts/gameplane/templates/namespaces.yaml index 0d62fbc31..8642b33dd 100644 --- a/charts/gameplane/templates/namespaces.yaml +++ b/charts/gameplane/templates/namespaces.yaml @@ -1,3 +1,4 @@ +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} apiVersion: v1 kind: Namespace metadata: @@ -14,3 +15,5 @@ metadata: # StatefulSet, PVC and per-server Secret Kubernetes garbage-collects # underneath it). Same pattern as the API PVC in api.yaml. helm.sh/resource-policy: keep + +{{- end }} diff --git a/charts/gameplane/templates/networkpolicies.yaml b/charts/gameplane/templates/networkpolicies.yaml index 6e93edb10..81f963f4d 100644 --- a/charts/gameplane/templates/networkpolicies.yaml +++ b/charts/gameplane/templates/networkpolicies.yaml @@ -1,3 +1,4 @@ +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} {{- if .Values.networkPolicies.enabled }} # Default-deny ingress in the games namespace. Individual GameServers # open only the ports declared in their template (and whatever the @@ -383,3 +384,5 @@ spec: # and the per-server ingress policy cover which ports are actually # advertised for each server. {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/operator.yaml b/charts/gameplane/templates/operator.yaml index c81f91084..772e0b10e 100644 --- a/charts/gameplane/templates/operator.yaml +++ b/charts/gameplane/templates/operator.yaml @@ -1,3 +1,4 @@ +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} apiVersion: v1 kind: ServiceAccount metadata: @@ -409,3 +410,5 @@ spec: {{- with .Values.image.pullSecrets }} imagePullSecrets: {{ toYaml . | nindent 8 }} {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/panel-key.yaml b/charts/gameplane/templates/panel-key.yaml new file mode 100644 index 000000000..bf90ebe12 --- /dev/null +++ b/charts/gameplane/templates/panel-key.yaml @@ -0,0 +1,21 @@ +{{- if and (eq (include "gameplane.apiEnabled" .) "true") .Values.api.standalone }} +{{- $panelKey := .Values.api.panelKey | default dict }} +{{- $storage := $panelKey.storage | default dict }} +{{- if and (not $panelKey.existingSecret) (not $storage.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: gameplane-api-key + namespace: {{ .Release.Namespace }} + labels: {{- include "gameplane.labels" . | nindent 4 }} + annotations: + helm.sh/resource-policy: keep +spec: + accessModes: [ReadWriteOnce] + resources: + requests: { storage: {{ $storage.size | default "1Mi" }} } + {{- with $storage.storageClassName }} + storageClassName: {{ . | quote }} + {{- end }} +{{- end }} +{{- end }} diff --git a/charts/gameplane/templates/prometheusrule.yaml b/charts/gameplane/templates/prometheusrule.yaml index 6754a6c13..e7b039e94 100644 --- a/charts/gameplane/templates/prometheusrule.yaml +++ b/charts/gameplane/templates/prometheusrule.yaml @@ -1,3 +1,4 @@ +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} {{- if .Values.prometheusRules.enabled }} # Alerts on the operator's standard controller-runtime metrics (scraped by the # operator ServiceMonitor). Deliberately uses only the operator's own metric @@ -87,3 +88,5 @@ spec: failed and has not been superseded; check the Backup status and the operator logs before relying on restore. {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/servicemonitors.yaml b/charts/gameplane/templates/servicemonitors.yaml index 03bde412e..4521db18e 100644 --- a/charts/gameplane/templates/servicemonitors.yaml +++ b/charts/gameplane/templates/servicemonitors.yaml @@ -1,4 +1,5 @@ {{- if .Values.serviceMonitors.enabled }} +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} apiVersion: v1 kind: Service metadata: @@ -28,6 +29,7 @@ spec: endpoints: - { port: metrics, interval: 30s } --- +{{- end }} {{- if (eq (include "gameplane.apiEnabled" .) "true") }} apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor @@ -87,6 +89,7 @@ spec: rationale (rejected client-cert approach, containerPort naming) lives in agent/specs.md's /metrics section — this is a pointer, not a copy. */}} +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} apiVersion: monitoring.coreos.com/v1 kind: PodMonitor metadata: @@ -105,3 +108,5 @@ spec: interval: 30s path: /metrics {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/validate.yaml b/charts/gameplane/templates/validate.yaml new file mode 100644 index 000000000..79b5e17b6 --- /dev/null +++ b/charts/gameplane/templates/validate.yaml @@ -0,0 +1,22 @@ +{{- if .Values.api.standalone }} +{{- if ne (include "gameplane.apiEnabled" .) "true" }} +{{- fail "api.standalone requires api.enabled=true" }} +{{- end }} +{{- $panelKey := .Values.api.panelKey | default dict }} +{{- $keyFileName := $panelKey.fileName | default "panel.key" }} +{{- if or (gt (len $keyFileName) 128) (not (regexMatch "^[A-Za-z0-9][A-Za-z0-9._-]*$" $keyFileName)) }} +{{- fail "api.panelKey.fileName must be a simple filename of at most 128 characters" }} +{{- end }} +{{- if and $panelKey.existingSecret ($panelKey.storage | default dict).existingClaim }} +{{- fail "choose either api.panelKey.existingSecret or api.panelKey.storage.existingClaim" }} +{{- end }} +{{- if eq (include "gameplane.operatorEnabled" .) "true" }} +{{- fail "api.standalone requires operator.enabled=false; install operators in the remote game clusters" }} +{{- end }} +{{- if ne (int .Values.api.replicas) 1 }} +{{- fail "api.standalone requires api.replicas=1 for the persistent panel key and database" }} +{{- end }} +{{- if (.Values.gateway | default dict).enabled }} +{{- fail "api.standalone cannot host gateway.enabled; install the gateway in the remote game cluster" }} +{{- end }} +{{- end }} diff --git a/charts/gameplane/templates/web.yaml b/charts/gameplane/templates/web.yaml index 872cc415a..4429fc5e4 100644 --- a/charts/gameplane/templates/web.yaml +++ b/charts/gameplane/templates/web.yaml @@ -25,6 +25,7 @@ spec: app.kubernetes.io/name: gameplane-web {{- include "gameplane.labels" . | nindent 8 }} spec: + automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 101 # the nginx user in nginx-unprivileged diff --git a/charts/gameplane/values.yaml b/charts/gameplane/values.yaml index 27215bd72..2b02286df 100644 --- a/charts/gameplane/values.yaml +++ b/charts/gameplane/values.yaml @@ -47,6 +47,8 @@ crds: pullPolicy: IfNotPresent operator: + # Disable all operator resources for a standalone central panel. + enabled: true replicas: 1 logLevel: info # zap log level: debug|info|error (warn not supported by zap's --zap-log-level) agentLogLevel: "" # injected into every agent sidecar as GAMEPLANE_LOG_LEVEL (debug|info|warn|error); empty injects nothing, avoiding StatefulSet rollouts on upgrade @@ -123,6 +125,9 @@ api: # Disable the central API, its database/RBAC, dashboard, ingress and API-only # integrations on remote operator+gateway installs. Agent mTLS is preserved. enabled: true + # Central panel only: SQL-backed settings and credentials, no local cluster. + # Requires operator.enabled=false and helm install --skip-crds. + standalone: false replicas: 1 # Keep at 1. SQLite is single-writer on a RWO volume; with db.driver=postgres the user-management lock and audit hash chain are still per-process logLevel: info # API log verbosity (debug|info|warn|error), passed as GAMEPLANE_LOG_LEVEL # Port of the API's Prometheus metrics listener. It is separate from the @@ -154,7 +159,24 @@ api: # image is built without it). Example: # dsn: "postgres://gameplane:***@postgres/gameplane?sslmode=require" - # PVC attached to the API for SQLite storage. Only used when db.driver=sqlite. + # Standalone encryption keys use separate storage configured under panelKey. + panelKey: + # Select a new filename after offline master-key rotation on the generated PVC. + fileName: panel.key + # Mount a pre-provisioned 32-byte key read-only. No key is generated in this mode. + # Use a separate Secret and backup/access policy from the database. + existingSecret: "" + secretKey: panel.key + storage: + size: 1Mi + storageClassName: "" + existingClaim: "" + + # Optional destination CIDRs for standalone Kubernetes APIs and agent gateways. + # Empty permits safe public/private addresses; unsafe address classes stay blocked. + remoteAllowedCIDRs: [] + + # Database PVC. Back up its contents separately from the encryption key. storage: size: 2Gi storageClassName: "" diff --git a/charts/tests/check_standalone.py b/charts/tests/check_standalone.py new file mode 100644 index 000000000..21fed688b --- /dev/null +++ b/charts/tests/check_standalone.py @@ -0,0 +1,106 @@ +"""CI-only checks for combined, standalone central, and remote deployment roles.""" +from pathlib import Path + +import yaml + +from check_gateway import one, render + + +def main(): + defaults = render() + one(defaults, "Deployment", "gameplane-operator") + one(defaults, "Deployment", "gameplane-api") + one(defaults, "ModuleSource", "default") + api_rules = one(defaults, "ClusterRole", "gameplane-api-read")["rules"] + assert any(rule.get("resources") == ["clusters"] and "update" in rule["verbs"] for rule in api_rules) + assert "--standalone" not in one(defaults, "Deployment", "gameplane-api")["spec"]["template"]["spec"]["containers"][0]["args"] + + values = { + "operator": {"enabled": False}, "api": {"standalone": True}, + "serviceMonitors": {"enabled": True}, "prometheusRules": {"enabled": True}, + "grafanaDashboards": {"enabled": True}, "clusterOps": {"enabled": True}, + "mcpServer": {"enabled": True}, "capture": {"enabled": True}, + } + for upgrade in (False, True): + objects = render(values, upgrade=upgrade) + for obj in objects: + assert obj["kind"] not in {"ClusterRole", "ClusterRoleBinding", "Role", "RoleBinding", "Namespace", "ModuleSource", "NetworkPolicy", "PodMonitor", "PrometheusRule", "Job"}, obj + assert "operator" not in obj["metadata"]["name"], obj + assert not obj["metadata"]["name"].startswith("gameplane-agent"), obj + one(objects, "Deployment", "gameplane-web") + one(objects, "ServiceMonitor", "gameplane-api") + one(objects, "PersistentVolumeClaim", "gameplane-api-data") + deployment = one(objects, "Deployment", "gameplane-api") + pod = deployment["spec"]["template"]["spec"] + assert pod["automountServiceAccountToken"] is False + assert pod["securityContext"]["fsGroupChangePolicy"] == "OnRootMismatch" + assert "annotations" not in deployment["spec"]["template"]["metadata"] + container = pod["containers"][0] + assert "--standalone" in container["args"] + assert "--panel-key-file=/keys/panel.key" in container["args"] + assert not any(arg.startswith("--agent-") for arg in container["args"]) + assert container["volumeMounts"] == [{"name": "data", "mountPath": "/data"}, {"name": "panel-key", "mountPath": "/keys"}] + assert pod["volumes"] == [ + {"name": "data", "persistentVolumeClaim": {"claimName": "gameplane-api-data"}}, + {"name": "panel-key", "persistentVolumeClaim": {"claimName": "gameplane-api-key"}}, + ] + one(objects, "PersistentVolumeClaim", "gameplane-api-key") + + # PostgreSQL needs persistent key custody, but no local database volume. + # Retain Recreate to avoid overlapping API writers, including Secret custody. + for custody in ({}, {"existingSecret": "protected-panel-key"}): + objects = render({"operator": {"enabled": False}, "api": {"standalone": True, "db": {"driver": "postgres"}, "panelKey": custody}}) + assert not any(obj["kind"] == "PersistentVolumeClaim" and obj["metadata"]["name"] == "gameplane-api-data" for obj in objects) + deployment = one(objects, "Deployment", "gameplane-api") + assert deployment["spec"]["strategy"] == {"type": "Recreate", "rollingUpdate": None} + pod = deployment["spec"]["template"]["spec"] + assert not any(volume["name"] == "data" for volume in pod["volumes"]) + assert not any(mount["name"] == "data" for mount in pod["containers"][0]["volumeMounts"]) + assert any(mount["name"] == "panel-key" and mount["mountPath"] == "/keys" for mount in pod["containers"][0]["volumeMounts"]) + if not custody: + one(objects, "PersistentVolumeClaim", "gameplane-api-key") + objects = render({"operator": {"enabled": False}, "api": {"standalone": True, "storage": {"existingClaim": "panel-data"}, "panelKey": {"storage": {"existingClaim": "panel-key"}}}}) + assert not any(obj["kind"] == "PersistentVolumeClaim" for obj in objects) + assert one(objects, "Deployment", "gameplane-api")["spec"]["template"]["spec"]["volumes"][0]["persistentVolumeClaim"]["claimName"] == "panel-data" + + # Externally provisioned keys are mounted read-only, without generating a key PVC. + objects = render({"operator": {"enabled": False}, "api": {"standalone": True, "panelKey": {"existingSecret": "protected-panel-key", "secretKey": "master"}}}) + pod = one(objects, "Deployment", "gameplane-api")["spec"]["template"]["spec"] + assert "--panel-key-provisioned" in pod["containers"][0]["args"] + assert {"name": "panel-key", "mountPath": "/keys", "readOnly": True} in pod["containers"][0]["volumeMounts"] + key_volume = next(volume for volume in pod["volumes"] if volume["name"] == "panel-key") + assert key_volume["secret"] == {"secretName": "protected-panel-key", "defaultMode": 0o440, "items": [{"key": "master", "path": "panel.key"}]} + assert not any(obj["kind"] == "PersistentVolumeClaim" and obj["metadata"]["name"] == "gameplane-api-key" for obj in objects) + + for custody in ({}, {"existingSecret": "protected-panel-key", "secretKey": "next"}): + objects = render({"operator": {"enabled": False}, "api": {"standalone": True, "panelKey": {**custody, "fileName": "panel-next.key"}}}) + pod = one(objects, "Deployment", "gameplane-api")["spec"]["template"]["spec"] + assert "--panel-key-file=/keys/panel-next.key" in pod["containers"][0]["args"] + if custody: + volume = next(volume for volume in pod["volumes"] if volume["name"] == "panel-key") + assert volume["secret"]["items"] == [{"key": "next", "path": "panel-next.key"}] + render({"operator": {"enabled": False}, "api": {"standalone": True, "panelKey": {"fileName": "../outside.key"}}}, failure="api.panelKey.fileName must be a simple filename") + + render({"operator": {"enabled": False}, "api": {"standalone": True, "panelKey": {"existingSecret": "key", "storage": {"existingClaim": "key-pvc"}}}}, failure="choose either api.panelKey.existingSecret or api.panelKey.storage.existingClaim") + + objects = render({"operator": {"enabled": False}, "api": {"standalone": True, "remoteAllowedCIDRs": ["10.20.0.0/16", "fd12:3456::/48"]}}) + args = one(objects, "Deployment", "gameplane-api")["spec"]["template"]["spec"]["containers"][0]["args"] + assert "--remote-allowed-cidrs=10.20.0.0/16,fd12:3456::/48" in args + + remote = render({"api": {"enabled": False}}) + one(remote, "Deployment", "gameplane-operator") + one(remote, "Secret", "gameplane-agent-ca") + assert not any(obj["metadata"]["name"] in {"gameplane-api", "gameplane-web"} for obj in remote) + disabled = render({"operator": {"enabled": False}}) + assert not any("operator" in obj["metadata"]["name"] for obj in disabled) + one(disabled, "Deployment", "gameplane-api") + + render({"api": {"standalone": True}}, failure="api.standalone requires operator.enabled=false") + render({"operator": {"enabled": False}, "api": {"enabled": False, "standalone": True}}, failure="api.standalone requires api.enabled=true") + render({"operator": {"enabled": False}, "api": {"standalone": True, "replicas": 2}}, failure="api.standalone requires api.replicas=1") + example = yaml.safe_load((Path(__file__).parents[1] / "gameplane/examples/standalone-panel-values.yaml").read_text()) + one(render(example), "Deployment", "gameplane-api") + + +if __name__ == "__main__": + main() diff --git a/deploy/standalone/README.md b/deploy/standalone/README.md new file mode 100644 index 000000000..1afefc920 --- /dev/null +++ b/deploy/standalone/README.md @@ -0,0 +1,14 @@ +# Standalone panel deployment + +This Compose file runs the Gameplane API and dashboard without Kubernetes on the +panel host. Registered game clusters still need Kubernetes, an operator, and +agents. + +Follow the [standalone setup guide](../../docs/standalone-panel.md) for the +matching checkout, build commands, first administrator, HTTPS access, remote +registration, permissions, upgrades, and backups. Run its Compose commands from +the repository root. Preserve `panel-data` for the database and `panel-keys` for +the generated `/keys/panel.key`. Back them up separately, with restricted access +and encrypted storage for the key backup. If using a provisioned key, protect +and back up its source file instead. Recovery requires the matching database +and key. Run exactly one API container, including custom PostgreSQL deployments. diff --git a/deploy/standalone/compose.provisioned-key.yaml b/deploy/standalone/compose.provisioned-key.yaml new file mode 100644 index 000000000..5c2eae18f --- /dev/null +++ b/deploy/standalone/compose.provisioned-key.yaml @@ -0,0 +1,13 @@ +# Use with compose.yaml. Provision a private 32-byte key file on the host first. +# Docker Compose file-backed secrets preserve host file permissions/ownership. +services: + gameplane-api: + environment: + GAMEPLANE_PANEL_KEY_FILE: /run/secrets/panel-key + GAMEPLANE_PANEL_KEY_PROVISIONED: "true" + secrets: + - panel-key + +secrets: + panel-key: + file: ${GAMEPLANE_PANEL_KEY_SOURCE_FILE:?Set the absolute path of the protected 32-byte panel key} diff --git a/deploy/standalone/compose.yaml b/deploy/standalone/compose.yaml new file mode 100644 index 000000000..9ba68b61e --- /dev/null +++ b/deploy/standalone/compose.yaml @@ -0,0 +1,51 @@ +name: gameplane-panel + +services: + gameplane-api: + build: + context: ../.. + dockerfile: api/Dockerfile + restart: unless-stopped + environment: + GAMEPLANE_STANDALONE: "true" + GAMEPLANE_PANEL_KEY_FILE: ${GAMEPLANE_PANEL_KEY_FILE:-/keys/panel.key} + GAMEPLANE_REMOTE_ALLOWED_CIDRS: ${GAMEPLANE_REMOTE_ALLOWED_CIDRS:-} + GAMEPLANE_DB_DRIVER: sqlite + GAMEPLANE_DB_DSN: "file:/data/gameplane.db?_pragma=journal_mode(WAL)" + volumes: + - panel-data:/data + - panel-keys:/keys + read_only: true + tmpfs: + - /tmp:uid=65532,gid=65532,mode=1777 + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + + web: + build: + context: ../.. + dockerfile: web/Dockerfile + restart: unless-stopped + depends_on: + - gameplane-api + environment: + API_UPSTREAM: http://gameplane-api:8000 + ports: + - "127.0.0.1:${GAMEPLANE_PORT:-8080}:8080" + read_only: true + tmpfs: + - /etc/nginx/conf.d:uid=101,gid=101,mode=0755 + - /tmp:uid=101,gid=101,mode=1777 + - /var/cache/nginx:uid=101,gid=101,mode=0755 + - /var/run:uid=101,gid=101,mode=0755 + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + healthcheck: + test: [CMD, wget, -q, -O, /dev/null, "http://127.0.0.1:8080/nginx-health"] + interval: 30s + timeout: 5s + retries: 3 + +volumes: + panel-data: + panel-keys: diff --git a/deploy/standalone/smoke.py b/deploy/standalone/smoke.py new file mode 100644 index 000000000..772147345 --- /dev/null +++ b/deploy/standalone/smoke.py @@ -0,0 +1,207 @@ +"""CI-only smoke test against the real Compose images, without Kubernetes.""" +import base64 +import hashlib +from http.cookies import SimpleCookie +import json +import os +from pathlib import Path +import secrets +import sqlite3 +import subprocess +import tempfile +import time +import urllib.error +import urllib.request + + +ROOT = Path(__file__).resolve().parents[2] +COMPOSE = ["docker", "compose", "-f", str(ROOT / "deploy/standalone/compose.yaml")] +ORIGIN = "http://127.0.0.1:" + os.environ.get("GAMEPLANE_PORT", "8080") + + +def compose(*args, **kwargs): + return subprocess.run(COMPOSE + list(args), check=True, text=True, **kwargs) + + +def request(path, data=None, cookie="", method=None): + headers = {"Accept": "application/json", "Cookie": cookie} + if data is not None: + headers["Content-Type"] = "application/json" + if cookie and (data is not None or method not in (None, "GET")): + cookies = SimpleCookie(cookie) + headers["X-Gameplane-CSRF"] = cookies["gameplane_csrf"].value + req = urllib.request.Request(ORIGIN + path, headers=headers, + data=json.dumps(data).encode() if data is not None else None, + method=method) + with urllib.request.urlopen(req, timeout=10) as response: + return response.read(), response.headers + + +def wait_ready(): + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + try: + request("/healthz") + return + except (OSError, urllib.error.URLError): + time.sleep(1) + raise AssertionError("standalone API did not become healthy") + + +def login(password): + body, headers = request("/auth/login", {"username": "standalone-admin", "password": password}) + assert json.loads(body)["user"]["role"] == "admin" + cookies = SimpleCookie() + for value in headers.get_all("Set-Cookie", []): + cookies.load(value) + assert "gameplane_session" in cookies + # This isolated CI endpoint is loopback HTTP; send Secure cookies explicitly. + # Production deployments retain HTTPS and normal browser cookie enforcement. + return "; ".join(f"{key}={value.value}" for key, value in cookies.items()) + + +def assert_empty(cookie): + clusters = json.loads(request("/clusters", cookie=cookie)[0]) + assert clusters["items"] == [], clusters + for kind in ("inventory", "servers", "backups", "placements"): + fleet = json.loads(request("/fleet/" + kind, cookie=cookie)[0]) + assert fleet["items"] == [], fleet + assert not fleet["partial"], fleet + assert not fleet.get("issues"), fleet + user = json.loads(request("/users/me", cookie=cookie)[0]) + assert user["username"] == "standalone-admin" + + +def key_digest(container, path="/keys/panel.key"): + with tempfile.TemporaryDirectory() as directory: + key = Path(directory) / "panel.key" + subprocess.run(["docker", "cp", container + ":" + path, str(key)], check=True) + return hashlib.sha256(key.read_bytes()).digest() + + +def registration(token): + # No Kubernetes dependency: the registration is valid but its target is + # deliberately unreachable inside the API container. + kubeconfig = json.dumps({ + "apiVersion": "v1", "kind": "Config", "current-context": "smoke", + "clusters": [{"name": "smoke", "cluster": {"server": "https://192.0.2.1:65534"}}], + "users": [{"name": "smoke", "user": {"token": token}}], + "contexts": [{"name": "smoke", "context": {"cluster": "smoke", "user": "smoke"}}], + }) + return {"name": "smoke-remote", "displayName": "Persistent smoke remote", "kubeconfig": kubeconfig} + + +def assert_registered(cookie, token): + body = request("/clusters", cookie=cookie)[0] + items = json.loads(body)["items"] + assert len(items) == 1, items + assert items[0]["name"] == "smoke-remote", items + assert items[0]["displayName"] == "Persistent smoke remote", items + assert token.encode() not in body, "registration response exposed credentials" + + +def assert_encrypted_registration(container, token): + # Caller stops the API first so the database and any WAL form one stable + # snapshot. Read only the copy, never the live volume. + with tempfile.TemporaryDirectory() as directory: + subprocess.run(["docker", "cp", container + ":/data/.", directory], check=True) + with sqlite3.connect(str(Path(directory) / "gameplane.db")) as database: + registration_payload = database.execute( + "SELECT payload FROM management_objects WHERE kind = 'clusters' AND name = ?", + ("smoke-remote",), + ).fetchone()[0] + credential_name = json.loads(registration_payload)["spec"]["kubeconfigSecret"]["name"] + rows = database.execute( + "SELECT kind, name, payload FROM management_objects WHERE name IN (?, ?)", + ("smoke-remote", credential_name), + ).fetchall() + assert {(kind, name) for kind, name, _ in rows} == { + ("clusters", "smoke-remote"), ("secrets", credential_name), + }, "registration and credential must both persist" + payload = next(payload for kind, _, payload in rows if kind == "secrets") + envelope, key_id, encoded = payload.split(".", 2) + assert envelope == "gpk1" and len(key_id) == 64 + assert len(bytes.fromhex(key_id)) == 32, "ciphertext must identify its encryption key" + sealed = base64.b64decode(encoded, validate=True) + assert len(sealed) > 28, "credential ciphertext must include nonce and authentication tag" + try: + json.loads(sealed) + except (ValueError, UnicodeDecodeError): + pass + else: + raise AssertionError("credential payload is unencrypted JSON") + for path in Path(directory).glob("gameplane.db*"): + assert token.encode() not in path.read_bytes(), "database snapshot exposed credentials" + + +def main(): + config = json.loads(compose("config", "--format", "json", capture_output=True).stdout) + assert set(config["services"]) == {"gameplane-api", "web"} + api = config["services"]["gameplane-api"] + assert "KUBECONFIG" not in api.get("environment", {}) + assert {volume["target"] for volume in api["volumes"]} == {"/data", "/keys"} + assert not api.get("ports") + assert api["read_only"] is True + assert api["environment"]["GAMEPLANE_STANDALONE"] == "true" + wait_ready() + container = compose("ps", "-q", "gameplane-api", capture_output=True).stdout.strip() + inspection = json.loads(subprocess.run(["docker", "inspect", container], check=True, text=True, capture_output=True).stdout)[0] + assert inspection["Config"]["User"] == "65532:65532" + assert not any("serviceaccount" in mount["Destination"] or "docker.sock" in mount["Destination"] for mount in inspection["Mounts"]) + original_key = key_digest(container) + password = secrets.token_urlsafe(24) + compose("exec", "-T", "gameplane-api", "/api", "bootstrap-admin", "--username", "standalone-admin", "--password-stdin", input=password + "\n") + cookie = login(password) + assert_empty(cookie) + token = secrets.token_urlsafe(32) + remote = registration(token) + request("/clusters", remote, cookie=cookie) + assert_registered(cookie, token) + compose("stop", "gameplane-api") + assert_encrypted_registration(container, token) + compose("up", "-d", "--no-build", "--force-recreate", "gameplane-api") + wait_ready() + container = compose("ps", "-q", "gameplane-api", capture_output=True).stdout.strip() + assert key_digest(container) == original_key, "panel key changed on container replacement" + # Startup validates all persisted credentials using the original key. + # Metadata and the existing session must survive alongside those credentials. + assert_registered(cookie, token) + cookie = login(password) + assert_registered(cookie, token) + token = secrets.token_urlsafe(32) + remote = registration(token) + request("/clusters/smoke-remote/kubeconfig", {"kubeconfig": remote["kubeconfig"]}, cookie=cookie, method="PUT") + compose("stop", "gameplane-api") + assert_encrypted_registration(container, token) + rotation = ("run", "--rm", "--no-deps", "--entrypoint", "/api", "gameplane-api", + "rotate-panel-key", "--old-key-file", "/keys/panel.key", "--new-key-file", "/keys/panel-next.key") + compose(*rotation) + compose(*rotation) # The exact retry is safe after an unknown commit outcome. + os.environ["GAMEPLANE_PANEL_KEY_FILE"] = "/keys/panel-next.key" + compose("up", "-d", "--no-build", "--force-recreate", "gameplane-api") + wait_ready() + container = compose("ps", "-q", "gameplane-api", capture_output=True).stdout.strip() + assert key_digest(container) == original_key, "rotation modified the historical backup key" + assert key_digest(container, "/keys/panel-next.key") != original_key + assert_registered(cookie, token) + # Exercise provisioned mode against a genuinely read-only key volume. + with tempfile.TemporaryDirectory() as directory: + override = Path(directory) / "provisioned.json" + override.write_text(json.dumps({"services": {"gameplane-api": { + "environment": {"GAMEPLANE_PANEL_KEY_PROVISIONED": "true"}, + "volumes": [{"type": "volume", "source": "panel-keys", "target": "/keys", "read_only": True}], + }}})) + subprocess.run(COMPOSE + ["-f", str(override), "up", "-d", "--no-build", "--force-recreate", "gameplane-api"], check=True) + wait_ready() + assert_registered(cookie, token) + request("/clusters/smoke-remote", cookie=cookie, method="DELETE") + assert_empty(cookie) + # An orphaned credential would make this second registration return 409. + request("/clusters", remote, cookie=cookie) + request("/clusters/smoke-remote", cookie=cookie, method="DELETE") + assert_empty(cookie) + print("Standalone Compose: bootstrap, encrypted registration, kubeconfig/master-key rotation, read-only provisioned key, restart and cleanup passed") + + +if __name__ == "__main__": + main() diff --git a/design.pen b/design.pen index cfdbcf720..3196d8c00 100644 --- a/design.pen +++ b/design.pen @@ -1,5 +1,5 @@ { - "version": "2.20", + "version": "2.21", "children": [ { "type": "frame", @@ -22866,12 +22866,12 @@ "fill": "$background/background", "children": [ { - "id": "DlcuA", + "id": "h2sWTB", "type": "ref", "ref": "kKFX9", + "name": "App Sidebar", "height": "fill_container", "layout": "vertical", - "name": "App Sidebar", "descendants": { "Sci9k": { "height": 70, @@ -22892,7 +22892,7 @@ }, "ijsQu": { "type": "frame", - "id": "wVY15", + "id": "yuZHJ", "name": "Nav Content", "width": "fill_container", "height": "fill_container", @@ -22904,7 +22904,7 @@ ], "children": [ { - "id": "A8CqgC", + "id": "jekdC", "type": "ref", "ref": "uFDbF", "name": "Section General", @@ -22916,7 +22916,7 @@ } }, { - "id": "t8hOE", + "id": "FTFLn", "type": "ref", "ref": "B5sYje", "name": "navDashboard", @@ -22942,7 +22942,7 @@ } }, { - "id": "jV8uw", + "id": "l2BPqJ", "type": "ref", "ref": "W8XiN", "name": "navServers", @@ -22966,7 +22966,7 @@ } }, { - "id": "raFQV", + "id": "RvmiI", "type": "ref", "ref": "W8XiN", "name": "navModules", @@ -22992,7 +22992,7 @@ } }, { - "id": "O0HLrA", + "id": "BMZAy", "type": "ref", "ref": "W8XiN", "name": "navBackups", @@ -23016,7 +23016,7 @@ } }, { - "id": "k0QQxc", + "id": "i0M86T", "type": "ref", "ref": "uFDbF", "name": "Section Admin", @@ -23028,7 +23028,7 @@ } }, { - "id": "ubagu", + "id": "RxY1j", "type": "ref", "ref": "W8XiN", "name": "navCluster", @@ -23052,7 +23052,7 @@ } }, { - "id": "Ka2Xf", + "id": "l8tdzK", "type": "ref", "ref": "W8XiN", "name": "Cluster inventory navigation", @@ -23076,7 +23076,7 @@ } }, { - "id": "g7FxN", + "id": "AM6yG", "type": "ref", "ref": "W8XiN", "name": "navUsers", @@ -23100,7 +23100,7 @@ } }, { - "id": "GbLXA", + "id": "OPf4U", "type": "ref", "ref": "W8XiN", "name": "navAudit", @@ -23124,7 +23124,7 @@ } }, { - "id": "Pd9d1", + "id": "rX4JW", "type": "ref", "ref": "W8XiN", "name": "navLogs", @@ -23148,7 +23148,7 @@ } }, { - "id": "n2Gdq", + "id": "uzLV2", "type": "ref", "ref": "W8XiN", "name": "navSettings", @@ -23178,7 +23178,7 @@ }, "oByLP": { "type": "frame", - "id": "gVXWY", + "id": "DIHKx", "name": "Toggle Row", "width": "fill_container", "gap": 8, @@ -23186,14 +23186,14 @@ "alignItems": "center", "children": [ { - "id": "f5oXbp", + "id": "yVbl6", "type": "ref", "ref": "iA2C8", "name": "Appearance Toggle", "fill": "$surface/secondary" }, { - "id": "NJR0y", + "id": "T7leKW", "type": "ref", "ref": "owgrI", "name": "Customize Theme", @@ -23230,7 +23230,7 @@ }, { "type": "frame", - "id": "I2bQk", + "id": "m7kmp", "name": "Central management notice", "width": "fill_container", "height": 45, @@ -23249,7 +23249,7 @@ "children": [ { "type": "text", - "id": "aBBe4", + "id": "rX0zZ", "name": "Central management label", "fill": "$foreground/foreground", "content": "Central management.", @@ -23260,7 +23260,7 @@ }, { "type": "text", - "id": "HWUr3", + "id": "vudRw", "name": "Central management explanation", "fill": "$muted", "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", @@ -32800,11 +32800,12 @@ "fill": "$background/background", "children": [ { - "id": "bbiZQ", + "id": "WrwBN", "type": "ref", "ref": "kKFX9", "height": "fill_container", "layout": "vertical", + "name": "Restored ref", "descendants": { "Sci9k": { "height": 70, @@ -32825,7 +32826,7 @@ }, "ijsQu": { "type": "frame", - "id": "UfWg3", + "id": "YmdpQ", "name": "Nav Content", "width": "fill_container", "height": "fill_container", @@ -32837,7 +32838,7 @@ ], "children": [ { - "id": "AuPaR", + "id": "ZxAJ8", "type": "ref", "ref": "uFDbF", "name": "Section General", @@ -32849,7 +32850,7 @@ } }, { - "id": "M83pNt", + "id": "HdjOv", "type": "ref", "ref": "B5sYje", "name": "navDashboard", @@ -32875,7 +32876,7 @@ } }, { - "id": "p4SLRN", + "id": "untri", "type": "ref", "ref": "W8XiN", "name": "navServers", @@ -32899,7 +32900,7 @@ } }, { - "id": "YxlR5", + "id": "E3ZmBl", "type": "ref", "ref": "W8XiN", "name": "navModules", @@ -32923,7 +32924,7 @@ } }, { - "id": "Jb5er", + "id": "WFFAr", "type": "ref", "ref": "W8XiN", "name": "navBackups", @@ -32947,7 +32948,7 @@ } }, { - "id": "n2RpJu", + "id": "HaMLJ", "type": "ref", "ref": "uFDbF", "name": "Section Admin", @@ -32959,7 +32960,7 @@ } }, { - "id": "BKefb", + "id": "Pqt7v", "type": "ref", "ref": "W8XiN", "name": "navCluster", @@ -32983,7 +32984,7 @@ } }, { - "id": "HtRdQ", + "id": "r7g6W", "type": "ref", "ref": "W8XiN", "name": "Cluster inventory navigation", @@ -33007,7 +33008,7 @@ } }, { - "id": "E85j60", + "id": "AjxXL", "type": "ref", "ref": "W8XiN", "name": "navUsers", @@ -33031,7 +33032,7 @@ } }, { - "id": "yuoW5", + "id": "Q7mRuA", "type": "ref", "ref": "W8XiN", "name": "navAudit", @@ -33055,7 +33056,7 @@ } }, { - "id": "C1XTGP", + "id": "sA0m2", "type": "ref", "ref": "W8XiN", "name": "navLogs", @@ -33079,7 +33080,7 @@ } }, { - "id": "ec5Zd", + "id": "XoxKL", "type": "ref", "ref": "W8XiN", "name": "navSettings", @@ -33111,7 +33112,7 @@ }, "oByLP": { "type": "frame", - "id": "qAhr6", + "id": "UFIkp", "name": "Toggle Row", "width": "fill_container", "gap": 8, @@ -33119,14 +33120,14 @@ "alignItems": "center", "children": [ { - "id": "d1Whiq", + "id": "HzR8S", "type": "ref", "ref": "iA2C8", "name": "Appearance Toggle", "fill": "$surface/secondary" }, { - "id": "ugAHI", + "id": "T21hit", "type": "ref", "ref": "owgrI", "name": "Customize Theme", @@ -33162,7 +33163,7 @@ }, { "type": "frame", - "id": "UosI6", + "id": "svJe1", "name": "Central management notice", "width": "fill_container", "height": 45, @@ -33181,7 +33182,7 @@ "children": [ { "type": "text", - "id": "TpnjI", + "id": "LnYBB", "name": "Central management label", "fill": "$foreground/foreground", "content": "Central management.", @@ -33192,7 +33193,7 @@ }, { "type": "text", - "id": "zpf0T", + "id": "LLHgj", "name": "Central management explanation", "fill": "$muted", "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", @@ -121010,40 +121011,6 @@ "layout": "vertical", "gap": 6, "children": [ - { - "type": "frame", - "id": "YZzBD", - "name": "Input row", - "width": "fill_container", - "gap": 8, - "alignItems": "center", - "children": [ - { - "id": "X5gmY", - "type": "ref", - "ref": "D0cDM", - "name": "Input", - "width": "fill_container", - "descendants": { - "PJERm": { - "content": "Unchanged — type to replace", - "fill": "$field/placeholder" - } - } - }, - { - "id": "rZ2ZW", - "type": "ref", - "ref": "J09iP", - "name": "Remove password button", - "descendants": { - "eWkIT/r4VbAi": { - "content": "Remove password" - } - } - } - ] - }, { "type": "text", "id": "faUlK", @@ -121053,710 +121020,17 @@ "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" - } - ] - } - ] - }, - { - "type": "frame", - "id": "C8qC3X", - "name": "Field MOTD", - "width": "fill_container", - "gap": 16, - "children": [ - { - "type": "frame", - "id": "mpfFx", - "name": "lbl", - "width": 200, - "layout": "vertical", - "gap": 4, - "children": [ - { - "type": "text", - "id": "gGkg4", - "name": "Label", - "fill": "$foreground/foreground", - "content": "MOTD", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "f2oJf", - "name": "ctrl", - "width": "fill_container", - "layout": "vertical", - "gap": 6, - "children": [ - { - "id": "f8dFYS", - "type": "ref", - "ref": "D0cDM", - "name": "Input", - "width": "fill_container", - "descendants": { - "PJERm": { - "content": "Welcome to Gameplane", - "fill": "$foreground/foreground" - } - } }, - { - "type": "text", - "id": "lEQjX", - "name": "Helper", - "fill": "$muted", - "content": "Written to serverconfig.txt", - "fontFamily": "$typography/font-sans", - "fontSize": 12, - "fontWeight": "normal" - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "X0swm", - "name": "Form Footer", - "width": "fill_container", - "stroke": "$border/border", - "strokeWidth": { - "top": 1 - }, - "strokeAlignment": "inner", - "strokeLinejoin": "round", - "gap": 12, - "padding": [ - 12, - 24 - ], - "justifyContent": "end", - "alignItems": "center", - "children": [ - { - "id": "tuuEJ", - "type": "ref", - "ref": "J09iP", - "name": "Btn Discard", - "descendants": { - "eWkIT/WtPjS": { - "icon": "pencil" - } - } - }, - { - "id": "m42SL", - "type": "ref", - "ref": "z9ShNE", - "name": "Btn Save Changes", - "descendants": { - "nWTJb": { - "enabled": false - }, - "IVgQ8/U1xvSo": { - "content": "Save changes" - } - } - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "QFEg9", - "x": 43120, - "y": 10375, - "name": "Screen/Server Detail — Settings · Game configuration (Invalid value)", - "theme": { - "semantic": "dark" - }, - "clip": true, - "width": 1440, - "height": 900, - "fill": "$background/background", - "children": [ - { - "id": "cuPev", - "type": "ref", - "ref": "kKFX9", - "height": "fill_container", - "descendants": { - "iTbd8": { - "fill": "#00000000" - }, - "iTbd8/ghUsw": { - "fill": "$foreground/foreground" - }, - "iTbd8/bpSQ7": { - "fill": "$foreground/foreground" - }, - "i8Z1B": { - "fill": "$accent/soft" - }, - "i8Z1B/Sr2Q5": { - "fill": "$accent/soft-foreground" - }, - "i8Z1B/Ra9NK": { - "fill": "$accent/soft-foreground" - } - } - }, - { - "type": "frame", - "id": "gXNrE", - "name": "Main", - "width": "fill_container", - "height": "fill_container", - "fill": "$background/background", - "layout": "vertical", - "children": [ - { - "id": "dGFZT", - "type": "ref", - "ref": "gu5WY", - "width": "fill_container", - "descendants": { - "Gl7AY": { - "content": "mc-survival" - } - } - }, - { - "type": "frame", - "id": "UI5Sw", - "name": "Body", - "width": "fill_container", - "height": "fill_container", - "fill": "$background/background", - "layout": "vertical", - "gap": 24, - "padding": 24, - "children": [ - { - "id": "seorE", - "type": "ref", - "ref": "S4k0x", - "name": "Detail Header", - "width": "fill_container" - }, - { - "id": "tsi45", - "type": "ref", - "ref": "I9kvlZ", - "name": "Tabs Bar", - "width": "fill_container", - "padding": [ - 0, - 0, - 0, - 24 - ], - "descendants": { - "Wp6q1": { - "fill": "#00000000" - }, - "Wp6q1/YUR8N": { - "fill": "$muted" - }, - "Y4QES": { - "fill": "$segment" - }, - "Y4QES/VcxMI": { - "fill": "$foreground/segment" - } - } - }, - { - "type": "frame", - "id": "dkze7", - "name": "Settings Content", - "width": "fill_container", - "height": "fill_container", - "children": [ - { - "id": "M9raFo", - "type": "ref", - "ref": "EV8mp", - "name": "Settings Sub Nav", - "width": 224, - "height": "fill_container", - "descendants": { - "oXEaP": { - "fill": "#00000000" - }, - "K2QiS9": { - "fill": "#000000" - }, - "KfnMt": { - "fill": "$muted" - }, - "axMXL": { - "fill": "#00000000" - }, - "P8u2Kj": { - "fill": "$muted" - }, - "VQPTx": { - "fill": "$muted" - }, - "p3HCVj": { - "fill": "$surface/secondary" - }, - "nbQNd": { - "fill": "$foreground/foreground" - }, - "nnoXy": { - "fill": "#00000000" - }, - "F1IWTf": { - "fill": "$muted" - }, - "bHNH0": { - "fill": "$muted" - }, - "QrhqW": { - "fill": "#00000000" - }, - "b0WSG": { - "fill": "$muted" - }, - "J4128": { - "fill": "$muted" - }, - "DnXAN": { - "fill": "#00000000" - }, - "RK6jY": { - "fill": "$muted" - }, - "rWM9D": { - "fill": "$muted" - }, - "tv3iR": { - "fill": "#00000000" - }, - "nfFt4": { - "fill": "$muted" - }, - "Y2PGH": { - "fill": "$muted" - }, - "bLFiu": { - "fill": "#00000000" - }, - "S7IwYB": { - "fill": "$muted" - }, - "IUddK": { - "fill": "$muted" - }, - "hNpsj": { - "fill": "#00000000" - }, - "lU1pD": { - "fill": "$muted" - }, - "Fkfhz": { - "fill": "$muted" - }, - "faW0l": { - "fill": "#00000000" - }, - "dDJ3f": { - "fill": "$muted" - }, - "v3XCDT": { - "fill": "$muted" - }, - "LktID": { - "fill": "#00000000" - }, - "OdmLx": { - "fill": "$muted" - }, - "paOZA": { - "fill": "$muted" - }, - "JlFDJ": { - "fill": "#00000000" - }, - "Hw7vh": { - "fill": "$muted" - }, - "Qbxnz": { - "fill": "$muted" - }, - "WLobW": { - "fill": "#00000000" - }, - "zpxa4": { - "fill": "$danger/danger" - }, - "xlVdh": { - "fill": "$danger/danger" - } - } - }, - { - "type": "frame", - "id": "kzTX4", - "name": "Settings Panel Wrap", - "width": "fill_container", - "height": "fill_container", - "layout": "vertical", - "children": [ - { - "type": "frame", - "id": "Pzfwm", - "name": "Settings Form", - "width": "fill_container", - "height": "fill_container", - "layout": "vertical", - "children": [ - { - "type": "frame", - "id": "eP1oQ", - "name": "Form Body", - "width": "fill_container", - "height": "fill_container", - "layout": "vertical", - "gap": 20, - "padding": 24, - "children": [ - { - "type": "frame", - "id": "aNQBZ", - "name": "Section Header", - "width": "fill_container", - "layout": "vertical", - "gap": 4, - "children": [ - { - "type": "text", - "id": "g9m0Gd", - "name": "Section Title", - "fill": "$foreground/foreground", - "content": "Game configuration", - "fontFamily": "$typography/font-sans", - "fontSize": 18, - "fontWeight": "600" - }, - { - "type": "text", - "id": "zw8X6", - "name": "Section Description", - "fill": "$muted", - "content": "Settings from this server's game template. Saving restarts the server.", - "fontFamily": "$typography/font-sans", - "fontSize": 13, - "fontWeight": "normal" - } - ] - }, - { - "id": "E1f4f5", - "type": "ref", - "ref": "uJ3KZ", - "name": "Restart Alert", - "width": "fill_container", - "descendants": { - "xzGxP": { - "content": "Saving changes restarts the server so they apply." - }, - "yLGJ9": { - "enabled": false - } - } - }, - { - "type": "frame", - "id": "arkqL", - "name": "Field World name", - "width": "fill_container", - "gap": 16, - "children": [ - { - "type": "frame", - "id": "BCZlm", - "name": "lbl", - "width": 200, - "layout": "vertical", - "gap": 4, - "children": [ - { - "type": "text", - "id": "bNDSU", - "name": "Label", - "fill": "$foreground/foreground", - "content": "World name", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "yJpKO", - "name": "ctrl", - "width": "fill_container", - "layout": "vertical", - "gap": 6, - "children": [ - { - "id": "cObWv", - "type": "ref", - "ref": "D0cDM", - "name": "Input", - "width": "fill_container", - "descendants": { - "PJERm": { - "content": "Gameplane", - "fill": "$foreground/foreground" - } - } - } - ] - } - ] - }, - { - "type": "frame", - "id": "vgwa9", - "name": "Field Difficulty", - "width": "fill_container", - "gap": 16, - "children": [ - { - "type": "frame", - "id": "uSbbx", - "name": "lbl", - "width": 200, - "layout": "vertical", - "gap": 4, - "children": [ - { - "type": "text", - "id": "aeTX8", - "name": "Label", - "fill": "$foreground/foreground", - "content": "Difficulty", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "j6jkkw", - "name": "ctrl", - "width": "fill_container", - "layout": "vertical", - "gap": 6, - "children": [ - { - "id": "jWCp2", - "type": "ref", - "ref": "AT7ya", - "name": "Select", - "width": "fill_container", - "descendants": { - "cFsFk": { - "content": "Expert", - "fill": "$foreground/foreground" - }, - "mULN8": { - "fill": "$muted" - } - } - } - ] - } - ] - }, - { - "type": "frame", - "id": "tE3dK", - "name": "Field Max players", - "width": "fill_container", - "gap": 16, - "children": [ - { - "type": "frame", - "id": "S73zC", - "name": "lbl", - "width": 200, - "layout": "vertical", - "gap": 4, - "children": [ - { - "type": "text", - "id": "XClZT", - "name": "Label", - "fill": "$foreground/foreground", - "content": "Max players", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "W1tdS", - "name": "ctrl", - "width": "fill_container", - "layout": "vertical", - "gap": 6, - "children": [ - { - "id": "KgXrB", - "type": "ref", - "ref": "D0cDM", - "name": "Input", - "width": "fill_container", - "stroke": "$danger/danger", - "descendants": { - "PJERm": { - "content": "900", - "fill": "$foreground/foreground" - } - } - }, - { - "type": "text", - "id": "FBU3t", - "name": "Error", - "fill": "$danger/danger", - "content": "Must be between 1 and 255.", - "fontFamily": "$typography/font-sans", - "fontSize": 12, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "type": "frame", - "id": "FUxGH", - "name": "Field PvP", - "width": "fill_container", - "gap": 16, - "children": [ - { - "type": "frame", - "id": "pMX10", - "name": "lbl", - "width": 200, - "layout": "vertical", - "gap": 4, - "children": [ - { - "type": "text", - "id": "n3rlw", - "name": "Label", - "fill": "$foreground/foreground", - "content": "PvP", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "rkpqw", - "name": "ctrl", - "width": "fill_container", - "layout": "vertical", - "gap": 6, - "children": [ - { - "id": "UNRL8", - "type": "ref", - "ref": "AT7ya", - "name": "Select", - "width": "fill_container", - "descendants": { - "cFsFk": { - "content": "false", - "fill": "$foreground/foreground" - }, - "mULN8": { - "fill": "$muted" - } - } - } - ] - } - ] - }, - { - "type": "frame", - "id": "jU78c", - "name": "Field Server password", - "width": "fill_container", - "gap": 16, - "children": [ - { - "type": "frame", - "id": "nThB1", - "name": "lbl", - "width": 200, - "layout": "vertical", - "gap": 4, - "children": [ - { - "type": "text", - "id": "bwLAS", - "name": "Label", - "fill": "$foreground/foreground", - "content": "Server password", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "j1xLO", - "name": "ctrl", - "width": "fill_container", - "layout": "vertical", - "gap": 6, - "children": [ { "type": "frame", - "id": "v2RYLO", + "id": "oS9Ry", "name": "Input row", "width": "fill_container", "gap": 8, "alignItems": "center", "children": [ { - "id": "S8dLsw", + "id": "X5gmY", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -121769,7 +121043,7 @@ } }, { - "id": "yCtMa", + "id": "B3qhL", "type": "ref", "ref": "J09iP", "name": "Remove password button", @@ -121780,16 +121054,6 @@ } } ] - }, - { - "type": "text", - "id": "GJfAx", - "name": "Helper", - "fill": "$muted", - "content": "Write-only", - "fontFamily": "$typography/font-sans", - "fontSize": 12, - "fontWeight": "normal" } ] } @@ -121797,14 +121061,14 @@ }, { "type": "frame", - "id": "fwtst", + "id": "C8qC3X", "name": "Field MOTD", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "q91RCt", + "id": "mpfFx", "name": "lbl", "width": 200, "layout": "vertical", @@ -121812,7 +121076,7 @@ "children": [ { "type": "text", - "id": "IfgbZ", + "id": "gGkg4", "name": "Label", "fill": "$foreground/foreground", "content": "MOTD", @@ -121824,14 +121088,14 @@ }, { "type": "frame", - "id": "E38b42", + "id": "f2oJf", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "OsKM1", + "id": "f8dFYS", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -121845,7 +121109,7 @@ }, { "type": "text", - "id": "dDTq7", + "id": "lEQjX", "name": "Helper", "fill": "$muted", "content": "Written to serverconfig.txt", @@ -121861,7 +121125,7 @@ }, { "type": "frame", - "id": "q5c4Gb", + "id": "X0swm", "name": "Form Footer", "width": "fill_container", "stroke": "$border/border", @@ -121879,7 +121143,7 @@ "alignItems": "center", "children": [ { - "id": "Qiela", + "id": "tuuEJ", "type": "ref", "ref": "J09iP", "name": "Btn Discard", @@ -121890,11 +121154,10 @@ } }, { - "id": "uejqu", + "id": "m42SL", "type": "ref", "ref": "z9ShNE", "name": "Btn Save Changes", - "opacity": 0.5, "descendants": { "nWTJb": { "enabled": false @@ -121920,10 +121183,10 @@ }, { "type": "frame", - "id": "wxINm", - "x": 44640, + "id": "QFEg9", + "x": 43120, "y": 10375, - "name": "Screen/Server Detail — Settings · Game configuration (Read-only)", + "name": "Screen/Server Detail — Settings · Game configuration (Invalid value)", "theme": { "semantic": "dark" }, @@ -121933,7 +121196,7 @@ "fill": "$background/background", "children": [ { - "id": "ownZs", + "id": "cuPev", "type": "ref", "ref": "kKFX9", "height": "fill_container", @@ -121960,7 +121223,7 @@ }, { "type": "frame", - "id": "AMZxe", + "id": "gXNrE", "name": "Main", "width": "fill_container", "height": "fill_container", @@ -121968,7 +121231,7 @@ "layout": "vertical", "children": [ { - "id": "f34GRg", + "id": "dGFZT", "type": "ref", "ref": "gu5WY", "width": "fill_container", @@ -121980,7 +121243,7 @@ }, { "type": "frame", - "id": "vxKU0", + "id": "UI5Sw", "name": "Body", "width": "fill_container", "height": "fill_container", @@ -121990,14 +121253,14 @@ "padding": 24, "children": [ { - "id": "iOLkC", + "id": "seorE", "type": "ref", "ref": "S4k0x", "name": "Detail Header", "width": "fill_container" }, { - "id": "W6iuqg", + "id": "tsi45", "type": "ref", "ref": "I9kvlZ", "name": "Tabs Bar", @@ -122025,13 +121288,13 @@ }, { "type": "frame", - "id": "qoj0w", + "id": "dkze7", "name": "Settings Content", "width": "fill_container", "height": "fill_container", "children": [ { - "id": "T1PXq", + "id": "M9raFo", "type": "ref", "ref": "EV8mp", "name": "Settings Sub Nav", @@ -122156,7 +121419,7 @@ }, { "type": "frame", - "id": "naDVX", + "id": "kzTX4", "name": "Settings Panel Wrap", "width": "fill_container", "height": "fill_container", @@ -122164,7 +121427,7 @@ "children": [ { "type": "frame", - "id": "z3Sr5x", + "id": "Pzfwm", "name": "Settings Form", "width": "fill_container", "height": "fill_container", @@ -122172,7 +121435,7 @@ "children": [ { "type": "frame", - "id": "ZOM1B", + "id": "eP1oQ", "name": "Form Body", "width": "fill_container", "height": "fill_container", @@ -122182,7 +121445,7 @@ "children": [ { "type": "frame", - "id": "rujeX", + "id": "aNQBZ", "name": "Section Header", "width": "fill_container", "layout": "vertical", @@ -122190,7 +121453,7 @@ "children": [ { "type": "text", - "id": "dx0eJ", + "id": "g9m0Gd", "name": "Section Title", "fill": "$foreground/foreground", "content": "Game configuration", @@ -122200,7 +121463,7 @@ }, { "type": "text", - "id": "LDGJw", + "id": "zw8X6", "name": "Section Description", "fill": "$muted", "content": "Settings from this server's game template. Saving restarts the server.", @@ -122210,16 +121473,31 @@ } ] }, + { + "id": "E1f4f5", + "type": "ref", + "ref": "uJ3KZ", + "name": "Restart Alert", + "width": "fill_container", + "descendants": { + "xzGxP": { + "content": "Saving changes restarts the server so they apply." + }, + "yLGJ9": { + "enabled": false + } + } + }, { "type": "frame", - "id": "rbPPE", + "id": "arkqL", "name": "Field World name", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "qYecu", + "id": "BCZlm", "name": "lbl", "width": 200, "layout": "vertical", @@ -122227,7 +121505,7 @@ "children": [ { "type": "text", - "id": "O0Vqo", + "id": "bNDSU", "name": "Label", "fill": "$foreground/foreground", "content": "World name", @@ -122239,19 +121517,18 @@ }, { "type": "frame", - "id": "h84Bg", + "id": "yJpKO", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "R2iIfL", + "id": "cObWv", "type": "ref", "ref": "D0cDM", "name": "Input", "width": "fill_container", - "opacity": 0.5, "descendants": { "PJERm": { "content": "Gameplane", @@ -122265,14 +121542,14 @@ }, { "type": "frame", - "id": "Ss7Qt", + "id": "vgwa9", "name": "Field Difficulty", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "M2kWGv", + "id": "uSbbx", "name": "lbl", "width": 200, "layout": "vertical", @@ -122280,7 +121557,7 @@ "children": [ { "type": "text", - "id": "xR2q2", + "id": "aeTX8", "name": "Label", "fill": "$foreground/foreground", "content": "Difficulty", @@ -122292,19 +121569,18 @@ }, { "type": "frame", - "id": "eKzaV", + "id": "j6jkkw", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "eR3C5", + "id": "jWCp2", "type": "ref", "ref": "AT7ya", "name": "Select", "width": "fill_container", - "opacity": 0.5, "descendants": { "cFsFk": { "content": "Expert", @@ -122321,14 +121597,14 @@ }, { "type": "frame", - "id": "f0OCC", + "id": "tE3dK", "name": "Field Max players", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "DQTki", + "id": "S73zC", "name": "lbl", "width": 200, "layout": "vertical", @@ -122336,7 +121612,7 @@ "children": [ { "type": "text", - "id": "W8e4Xz", + "id": "XClZT", "name": "Label", "fill": "$foreground/foreground", "content": "Max players", @@ -122348,25 +121624,35 @@ }, { "type": "frame", - "id": "FwF8o", + "id": "W1tdS", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "Hj3Df", + "id": "KgXrB", "type": "ref", "ref": "D0cDM", "name": "Input", "width": "fill_container", - "opacity": 0.5, + "stroke": "$danger/danger", "descendants": { "PJERm": { - "content": "8", + "content": "900", "fill": "$foreground/foreground" } } + }, + { + "type": "text", + "id": "FBU3t", + "name": "Error", + "fill": "$danger/danger", + "content": "Must be between 1 and 255.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" } ] } @@ -122374,14 +121660,14 @@ }, { "type": "frame", - "id": "qDuEU", + "id": "FUxGH", "name": "Field PvP", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "H00WT", + "id": "pMX10", "name": "lbl", "width": 200, "layout": "vertical", @@ -122389,7 +121675,7 @@ "children": [ { "type": "text", - "id": "OlsyL", + "id": "n3rlw", "name": "Label", "fill": "$foreground/foreground", "content": "PvP", @@ -122401,19 +121687,18 @@ }, { "type": "frame", - "id": "jrBDc", + "id": "rkpqw", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "LgZWL", + "id": "UNRL8", "type": "ref", "ref": "AT7ya", "name": "Select", "width": "fill_container", - "opacity": 0.5, "descendants": { "cFsFk": { "content": "false", @@ -122430,14 +121715,14 @@ }, { "type": "frame", - "id": "mAzyA", + "id": "jU78c", "name": "Field Server password", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "Bot7W", + "id": "nThB1", "name": "lbl", "width": 200, "layout": "vertical", @@ -122445,7 +121730,7 @@ "children": [ { "type": "text", - "id": "CWDcq", + "id": "bwLAS", "name": "Label", "fill": "$foreground/foreground", "content": "Server password", @@ -122457,35 +121742,55 @@ }, { "type": "frame", - "id": "Gfu5L", + "id": "j1xLO", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ - { - "id": "PMezE", - "type": "ref", - "ref": "D0cDM", - "name": "Input", - "width": "fill_container", - "opacity": 0.5, - "descendants": { - "PJERm": { - "content": "Unchanged — type to replace", - "fill": "$field/placeholder" - } - } - }, { "type": "text", - "id": "Q1VQUm", + "id": "GJfAx", "name": "Helper", "fill": "$muted", "content": "Write-only", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" + }, + { + "type": "frame", + "id": "t5LkHa", + "name": "Input row", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "S8dLsw", + "type": "ref", + "ref": "D0cDM", + "name": "Input", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "Unchanged — type to replace", + "fill": "$field/placeholder" + } + } + }, + { + "id": "KAuGZ", + "type": "ref", + "ref": "J09iP", + "name": "Remove password button", + "descendants": { + "eWkIT/r4VbAi": { + "content": "Remove password" + } + } + } + ] } ] } @@ -122493,14 +121798,14 @@ }, { "type": "frame", - "id": "hnhvX", + "id": "fwtst", "name": "Field MOTD", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "B71aY", + "id": "q91RCt", "name": "lbl", "width": 200, "layout": "vertical", @@ -122508,7 +121813,7 @@ "children": [ { "type": "text", - "id": "qtuLZ", + "id": "IfgbZ", "name": "Label", "fill": "$foreground/foreground", "content": "MOTD", @@ -122520,19 +121825,18 @@ }, { "type": "frame", - "id": "lZBKq", + "id": "E38b42", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "x6Qhp7", + "id": "OsKM1", "type": "ref", "ref": "D0cDM", "name": "Input", "width": "fill_container", - "opacity": 0.5, "descendants": { "PJERm": { "content": "Welcome to Gameplane", @@ -122542,7 +121846,7 @@ }, { "type": "text", - "id": "r95oyK", + "id": "dDTq7", "name": "Helper", "fill": "$muted", "content": "Written to serverconfig.txt", @@ -122558,7 +121862,7 @@ }, { "type": "frame", - "id": "ltZtY", + "id": "q5c4Gb", "name": "Form Footer", "width": "fill_container", "stroke": "$border/border", @@ -122572,17 +121876,34 @@ 12, 24 ], + "justifyContent": "end", "alignItems": "center", "children": [ { - "type": "text", - "id": "W9ncg", - "name": "Permission Note", - "fill": "$muted", - "content": "You need permission to change this server's settings.", - "fontFamily": "$typography/font-sans", - "fontSize": 13, - "fontWeight": "normal" + "id": "Qiela", + "type": "ref", + "ref": "J09iP", + "name": "Btn Discard", + "descendants": { + "eWkIT/WtPjS": { + "icon": "pencil" + } + } + }, + { + "id": "uejqu", + "type": "ref", + "ref": "z9ShNE", + "name": "Btn Save Changes", + "opacity": 0.5, + "descendants": { + "nWTJb": { + "enabled": false + }, + "IVgQ8/U1xvSo": { + "content": "Save changes" + } + } } ] } @@ -122600,20 +121921,20 @@ }, { "type": "frame", - "id": "e9FnC", - "x": 46160, + "id": "wxINm", + "x": 44640, "y": 10375, - "name": "Screen/Server Detail — Settings · Game configuration (Orphaned key)", + "name": "Screen/Server Detail — Settings · Game configuration (Read-only)", "theme": { "semantic": "dark" }, "clip": true, "width": 1440, - "height": 960, + "height": 900, "fill": "$background/background", "children": [ { - "id": "SmgsC", + "id": "ownZs", "type": "ref", "ref": "kKFX9", "height": "fill_container", @@ -122640,7 +121961,7 @@ }, { "type": "frame", - "id": "h33O1x", + "id": "AMZxe", "name": "Main", "width": "fill_container", "height": "fill_container", @@ -122648,7 +121969,7 @@ "layout": "vertical", "children": [ { - "id": "nuxk1", + "id": "f34GRg", "type": "ref", "ref": "gu5WY", "width": "fill_container", @@ -122660,7 +121981,7 @@ }, { "type": "frame", - "id": "NEtcG", + "id": "vxKU0", "name": "Body", "width": "fill_container", "height": "fill_container", @@ -122670,14 +121991,694 @@ "padding": 24, "children": [ { - "id": "r0jyTg", + "id": "iOLkC", "type": "ref", "ref": "S4k0x", "name": "Detail Header", "width": "fill_container" }, { - "id": "lb6Cf", + "id": "W6iuqg", + "type": "ref", + "ref": "I9kvlZ", + "name": "Tabs Bar", + "width": "fill_container", + "padding": [ + 0, + 0, + 0, + 24 + ], + "descendants": { + "Wp6q1": { + "fill": "#00000000" + }, + "Wp6q1/YUR8N": { + "fill": "$muted" + }, + "Y4QES": { + "fill": "$segment" + }, + "Y4QES/VcxMI": { + "fill": "$foreground/segment" + } + } + }, + { + "type": "frame", + "id": "qoj0w", + "name": "Settings Content", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "id": "T1PXq", + "type": "ref", + "ref": "EV8mp", + "name": "Settings Sub Nav", + "width": 224, + "height": "fill_container", + "descendants": { + "oXEaP": { + "fill": "#00000000" + }, + "K2QiS9": { + "fill": "#000000" + }, + "KfnMt": { + "fill": "$muted" + }, + "axMXL": { + "fill": "#00000000" + }, + "P8u2Kj": { + "fill": "$muted" + }, + "VQPTx": { + "fill": "$muted" + }, + "p3HCVj": { + "fill": "$surface/secondary" + }, + "nbQNd": { + "fill": "$foreground/foreground" + }, + "nnoXy": { + "fill": "#00000000" + }, + "F1IWTf": { + "fill": "$muted" + }, + "bHNH0": { + "fill": "$muted" + }, + "QrhqW": { + "fill": "#00000000" + }, + "b0WSG": { + "fill": "$muted" + }, + "J4128": { + "fill": "$muted" + }, + "DnXAN": { + "fill": "#00000000" + }, + "RK6jY": { + "fill": "$muted" + }, + "rWM9D": { + "fill": "$muted" + }, + "tv3iR": { + "fill": "#00000000" + }, + "nfFt4": { + "fill": "$muted" + }, + "Y2PGH": { + "fill": "$muted" + }, + "bLFiu": { + "fill": "#00000000" + }, + "S7IwYB": { + "fill": "$muted" + }, + "IUddK": { + "fill": "$muted" + }, + "hNpsj": { + "fill": "#00000000" + }, + "lU1pD": { + "fill": "$muted" + }, + "Fkfhz": { + "fill": "$muted" + }, + "faW0l": { + "fill": "#00000000" + }, + "dDJ3f": { + "fill": "$muted" + }, + "v3XCDT": { + "fill": "$muted" + }, + "LktID": { + "fill": "#00000000" + }, + "OdmLx": { + "fill": "$muted" + }, + "paOZA": { + "fill": "$muted" + }, + "JlFDJ": { + "fill": "#00000000" + }, + "Hw7vh": { + "fill": "$muted" + }, + "Qbxnz": { + "fill": "$muted" + }, + "WLobW": { + "fill": "#00000000" + }, + "zpxa4": { + "fill": "$danger/danger" + }, + "xlVdh": { + "fill": "$danger/danger" + } + } + }, + { + "type": "frame", + "id": "naDVX", + "name": "Settings Panel Wrap", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "z3Sr5x", + "name": "Settings Form", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "ZOM1B", + "name": "Form Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 20, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "rujeX", + "name": "Section Header", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "dx0eJ", + "name": "Section Title", + "fill": "$foreground/foreground", + "content": "Game configuration", + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "LDGJw", + "name": "Section Description", + "fill": "$muted", + "content": "Settings from this server's game template. Saving restarts the server.", + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "rbPPE", + "name": "Field World name", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "qYecu", + "name": "lbl", + "width": 200, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "O0Vqo", + "name": "Label", + "fill": "$foreground/foreground", + "content": "World name", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "h84Bg", + "name": "ctrl", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "id": "R2iIfL", + "type": "ref", + "ref": "D0cDM", + "name": "Input", + "width": "fill_container", + "opacity": 0.5, + "descendants": { + "PJERm": { + "content": "Gameplane", + "fill": "$foreground/foreground" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "Ss7Qt", + "name": "Field Difficulty", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "M2kWGv", + "name": "lbl", + "width": 200, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "xR2q2", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Difficulty", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "eKzaV", + "name": "ctrl", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "id": "eR3C5", + "type": "ref", + "ref": "AT7ya", + "name": "Select", + "width": "fill_container", + "opacity": 0.5, + "descendants": { + "cFsFk": { + "content": "Expert", + "fill": "$foreground/foreground" + }, + "mULN8": { + "fill": "$muted" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "f0OCC", + "name": "Field Max players", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "DQTki", + "name": "lbl", + "width": 200, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "W8e4Xz", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Max players", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "FwF8o", + "name": "ctrl", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "id": "Hj3Df", + "type": "ref", + "ref": "D0cDM", + "name": "Input", + "width": "fill_container", + "opacity": 0.5, + "descendants": { + "PJERm": { + "content": "8", + "fill": "$foreground/foreground" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "qDuEU", + "name": "Field PvP", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "H00WT", + "name": "lbl", + "width": 200, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "OlsyL", + "name": "Label", + "fill": "$foreground/foreground", + "content": "PvP", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "jrBDc", + "name": "ctrl", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "id": "LgZWL", + "type": "ref", + "ref": "AT7ya", + "name": "Select", + "width": "fill_container", + "opacity": 0.5, + "descendants": { + "cFsFk": { + "content": "false", + "fill": "$foreground/foreground" + }, + "mULN8": { + "fill": "$muted" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "mAzyA", + "name": "Field Server password", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "Bot7W", + "name": "lbl", + "width": 200, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "CWDcq", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Server password", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Gfu5L", + "name": "ctrl", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "id": "PMezE", + "type": "ref", + "ref": "D0cDM", + "name": "Input", + "width": "fill_container", + "opacity": 0.5, + "descendants": { + "PJERm": { + "content": "Unchanged — type to replace", + "fill": "$field/placeholder" + } + } + }, + { + "type": "text", + "id": "Q1VQUm", + "name": "Helper", + "fill": "$muted", + "content": "Write-only", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "hnhvX", + "name": "Field MOTD", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "B71aY", + "name": "lbl", + "width": 200, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "qtuLZ", + "name": "Label", + "fill": "$foreground/foreground", + "content": "MOTD", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "lZBKq", + "name": "ctrl", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "id": "x6Qhp7", + "type": "ref", + "ref": "D0cDM", + "name": "Input", + "width": "fill_container", + "opacity": 0.5, + "descendants": { + "PJERm": { + "content": "Welcome to Gameplane", + "fill": "$foreground/foreground" + } + } + }, + { + "type": "text", + "id": "r95oyK", + "name": "Helper", + "fill": "$muted", + "content": "Written to serverconfig.txt", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ltZtY", + "name": "Form Footer", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "strokeLinejoin": "round", + "gap": 12, + "padding": [ + 12, + 24 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "W9ncg", + "name": "Permission Note", + "fill": "$muted", + "content": "You need permission to change this server's settings.", + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "e9FnC", + "x": 46160, + "y": 10375, + "name": "Screen/Server Detail — Settings · Game configuration (Orphaned key)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 960, + "fill": "$background/background", + "children": [ + { + "id": "SmgsC", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "i8Z1B": { + "fill": "$accent/soft" + }, + "i8Z1B/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "i8Z1B/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "h33O1x", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "nuxk1", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "mc-survival" + } + } + }, + { + "type": "frame", + "id": "NEtcG", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "r0jyTg", + "type": "ref", + "ref": "S4k0x", + "name": "Detail Header", + "width": "fill_container" + }, + { + "id": "lb6Cf", "type": "ref", "ref": "I9kvlZ", "name": "Tabs Bar", @@ -129831,7 +129832,12206 @@ }, { "type": "frame", - "id": "EkcbF", + "id": "n2qdi", + "x": -17357, + "y": 30632, + "name": "Screen/Standalone — Clusters (Empty)", + "context": "PR #630 standalone deployment; matches current dashboard behavior.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "peZu0", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "p4p9Y2", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "G9ZToI", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters" + } + } + }, + { + "type": "frame", + "id": "f9tEzp", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "lL09w", + "type": "ref", + "ref": "xCDF7", + "name": "Clusters header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Clusters" + }, + "VbHwU": { + "content": "Administration of registered locations, connectivity and node inventory." + }, + "TgdLz": { + "type": "frame", + "id": "uUIw5", + "name": "Page actions", + "height": 36, + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "zl6X7", + "type": "ref", + "ref": "cb4rt", + "name": "Register cluster", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "Register cluster" + } + } + } + ] + } + } + }, + { + "type": "frame", + "id": "MMxRr", + "name": "No registered clusters", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "A1ikQ", + "name": "Empty title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "No clusters available", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ITq33", + "name": "Empty description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Register a workload cluster to deploy and manage game servers. This panel can run without a local cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "Z81TW", + "type": "ref", + "reusable": true, + "ref": "x3beP", + "x": -15757, + "y": 30632, + "name": "Gameplane/Dialog/Register workload cluster", + "theme": { + "semantic": "dark" + }, + "width": 480, + "context": "PR #630. Requires central cluster:manage. Register stays disabled until Name and Kubeconfig have non-whitespace text. During submission both buttons and dismissal are disabled. On error preserve the draft. Clear the kubeconfig on success. No existing credential is ever displayed.", + "descendants": { + "E7Whx": { + "content": "Register cluster" + }, + "qzcst": { + "content": "Connect an existing workload cluster with the Gameplane operator and CRDs installed. The panel host does not become a workload node." + }, + "Nht52": { + "type": "frame", + "id": "cvNaq", + "name": "Registration fields", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "frame", + "id": "E1kLQw", + "name": "Name field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "WY2Eh", + "name": "Name label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Name", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "Y9McT", + "type": "ref", + "ref": "D0cDM", + "name": "Name input", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "production", + "fill": "$field/placeholder" + } + } + } + ] + }, + { + "type": "frame", + "id": "ReaIB", + "name": "Display name field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "d8DaPW", + "name": "Display name label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Display name", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "nZk5b", + "type": "ref", + "ref": "D0cDM", + "name": "Display name input", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "Rfps1", + "name": "Kubeconfig field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "Aht2x", + "name": "Kubeconfig label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Kubeconfig", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Bi1Nn", + "name": "Kubeconfig textarea (eight rows)", + "width": "fill_container", + "height": 172, + "fill": "$field/background", + "cornerRadius": "$radius/xl", + "stroke": "$field/border", + "strokeWidth": 1, + "padding": 12 + } + ] + }, + { + "type": "text", + "id": "zJG5E", + "name": "Connection helper", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Use a self-contained kubeconfig with credentials and an API address reachable from this panel. Agent console, files and logs also require remote gateway configuration.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + "V7HYp": { + "opacity": 0.5 + }, + "TZ7Ef": { + "content": "Register" + } + } + }, + { + "type": "frame", + "id": "WrmU5", + "x": -14157, + "y": 30632, + "name": "Screen/Standalone — Clusters (Registered)", + "context": "PR #630 standalone deployment; matches current dashboard behavior.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "Q8Gy6o", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "cZ1pA", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "Dd917", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters" + } + } + }, + { + "type": "frame", + "id": "quYQ7", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "mnPfw", + "type": "ref", + "ref": "xCDF7", + "name": "Clusters header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Clusters" + }, + "VbHwU": { + "content": "Administration of registered locations, connectivity and node inventory." + }, + "TgdLz": { + "type": "frame", + "id": "irpdH", + "name": "Page actions", + "height": 36, + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "I414g1", + "type": "ref", + "ref": "cb4rt", + "name": "Register cluster", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "Register cluster" + } + } + } + ] + } + } + }, + { + "type": "text", + "id": "a7s07j", + "name": "API status scope", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "2 clusters available · Status reports Kubernetes API connectivity, not game or gateway health.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "jiXKV", + "name": "Registered remote clusters", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "K1kXX", + "name": "east cluster", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "ApcQf", + "name": "Cluster identity", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "FQWWf", + "name": "Name and registration", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "owEMr", + "name": "Cluster display name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "East production", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "jyqB7", + "name": "Registration name", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "east", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "D5B31", + "name": "Selected cluster", + "fill": "$accent/accent", + "content": "Selected", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "zlSQA", + "name": "API connectivity", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "API: Healthy", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "IcfRP", + "name": "Last checked", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Last checked just now", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "LGzV8", + "name": "Cluster actions", + "gap": 8, + "children": [ + { + "id": "i0uSmF", + "type": "ref", + "ref": "cb4rt", + "name": "View servers", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "View servers" + } + } + }, + { + "id": "vuyS6", + "type": "ref", + "ref": "i6gfu", + "name": "View nodes", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "View nodes" + } + } + }, + { + "id": "W1073i", + "type": "ref", + "ref": "i6gfu", + "name": "Remove", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Remove" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "q8PXwO", + "name": "west cluster", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "kbDHQ", + "name": "Cluster identity", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "AvgGx", + "name": "Name and registration", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "dr8J4", + "name": "Cluster display name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "West backup", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "cBPBX", + "name": "Registration name", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "west", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "j82uX", + "name": "API connectivity", + "fill": "$danger/danger", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "API: Unhealthy", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "bmjhu", + "name": "Last checked", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Last checked just now", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "QcHLE", + "name": "Cluster actions", + "gap": 8, + "children": [ + { + "id": "U7jY7p", + "type": "ref", + "ref": "cb4rt", + "name": "View servers", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "View servers" + } + } + }, + { + "id": "IoRuy", + "type": "ref", + "ref": "i6gfu", + "name": "View nodes", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "View nodes" + } + } + }, + { + "id": "Bd9ic", + "type": "ref", + "ref": "i6gfu", + "name": "Remove", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Remove" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Be7ZW", + "x": -12557, + "y": 30632, + "name": "Screen/Standalone — Cluster inventory (No selection)", + "context": "PR #630. Selection is explicit and only registered remotes are listed. No implicit local target; no workload requests until installation and selected target are known.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "Y9a83", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "itf8i", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "C7VwdL", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Cluster" + } + } + }, + { + "type": "frame", + "id": "esSlA", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "EIpc6", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "iLv7K", + "name": "API status", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "yFXL6", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "x5uaL", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "Select cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "c7JcJ5", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "ZYbBc", + "name": "Select cluster guidance", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "Q4HiV", + "name": "Selection title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Select a workload cluster", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "n4V8Q", + "name": "Selection description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Choose a registered cluster to view its node inventory. Register workload clusters on the Clusters page.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Z0oyh", + "x": -10957, + "y": 30632, + "name": "Screen/Standalone — Modules (No selection)", + "context": "PR #630. Selection is explicit and only registered remotes are listed. No implicit local target; no workload requests until installation and selected target are known.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "BQhzL", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "sTSGR": { + "fill": "$accent/soft" + }, + "sTSGR/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "sTSGR/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "ZCtkb", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "DRroL", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Modules" + } + } + }, + { + "type": "frame", + "id": "pFGvI", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "s0m1C", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "abqFF", + "name": "API status", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "h8qD6u", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Udlbe", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "Select cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "EdLxM", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "B0Utzk", + "name": "Select cluster guidance", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "k8WlA", + "name": "Selection title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Select a workload cluster", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "W8Meb", + "name": "Selection description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Modules and their sources are managed by the operator on a registered workload cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "AIuUK", + "name": "Manage clusters link", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Manage clusters", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ZlLf0", + "x": -17357, + "y": 32032, + "name": "Screen/Standalone — Modules (Permission denied)", + "context": "PR #630. Selection is explicit and only registered remotes are listed. No implicit local target; no workload requests until installation and selected target are known.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "CRRpO", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "sTSGR": { + "fill": "$accent/soft" + }, + "sTSGR/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "sTSGR/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "s8SIBZ", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "kk86p", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Modules" + } + } + }, + { + "type": "frame", + "id": "k2PQ8", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "f4IRRz", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "J1bAjI", + "name": "API status", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "mA8Ze", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "RRBRI", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "East production", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "r0ll2C", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "l9iRNt", + "name": "No module access", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "BYqDc", + "name": "Permission error", + "fill": "$danger/danger", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "You don't have permission to view modules on this cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "RpHBg", + "x": -15757, + "y": 32032, + "name": "Screen/Standalone — Register cluster", + "context": "PR #630. Modal actions use central cluster-management permission. Dismissing is disabled during submission.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "m32aX", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "r5jGM", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "onCRW", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters" + } + } + }, + { + "type": "frame", + "id": "S1gmm", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "gtqox", + "type": "ref", + "ref": "xCDF7", + "name": "Clusters header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Clusters" + }, + "VbHwU": { + "content": "Administration of registered locations, connectivity and node inventory." + }, + "TgdLz": { + "type": "frame", + "id": "PlKp6", + "name": "Page actions", + "height": 36, + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "Z2lia", + "type": "ref", + "ref": "cb4rt", + "name": "Register cluster", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "Register cluster" + } + } + } + ] + } + } + }, + { + "type": "frame", + "id": "qdNhP", + "name": "No registered clusters", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "u4sFfS", + "name": "Empty title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "No clusters available", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Y8Ygo", + "name": "Empty description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Register a workload cluster to deploy and manage game servers. This panel can run without a local cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "DdHRg", + "layoutPosition": "absolute", + "x": 0, + "y": 0, + "name": "Modal backdrop", + "width": 1440, + "height": 900, + "fill": "#00000066", + "layout": "vertical", + "padding": 40, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "id": "cb9rq", + "type": "ref", + "ref": "Z81TW", + "name": "Register cluster dialog", + "width": 480 + } + ] + } + ] + }, + { + "type": "frame", + "id": "HVhmb", + "x": -14157, + "y": 32032, + "name": "Screen/Standalone — Remove cluster", + "context": "Removal errors stay inside the confirmation and reset when dismissed or reopened. Successful removal clears a matching current selection to empty without selecting local or another remote.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "zuZiD", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "aXpXv", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "XV2NC", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters" + } + } + }, + { + "type": "frame", + "id": "inByJ", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "tw1jI", + "type": "ref", + "ref": "xCDF7", + "name": "Clusters header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Clusters" + }, + "VbHwU": { + "content": "Administration of registered locations, connectivity and node inventory." + }, + "TgdLz": { + "type": "frame", + "id": "A0T5b4", + "name": "Page actions", + "height": 36, + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "D8RFW", + "type": "ref", + "ref": "cb4rt", + "name": "Register cluster", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "Register cluster" + } + } + } + ] + } + } + }, + { + "type": "text", + "id": "F8tLDA", + "name": "API status scope", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "2 clusters available · Status reports Kubernetes API connectivity, not game or gateway health.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "dIWjV", + "name": "Registered remote clusters", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "obBhO", + "name": "east cluster", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "omivO", + "name": "Cluster identity", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "C2L0n", + "name": "Name and registration", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "aj0qW", + "name": "Cluster display name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "East production", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "o3tYqe", + "name": "Registration name", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "east", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "K1HqW6", + "name": "Selected cluster", + "fill": "$accent/accent", + "content": "Selected", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "i3CZtk", + "name": "API connectivity", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "API: Healthy", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "L7W6Gr", + "name": "Last checked", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Last checked just now", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "p48i2a", + "name": "Cluster actions", + "gap": 8, + "children": [ + { + "id": "BO9AY", + "type": "ref", + "ref": "cb4rt", + "name": "View servers", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "View servers" + } + } + }, + { + "id": "stC1B", + "type": "ref", + "ref": "i6gfu", + "name": "View nodes", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "View nodes" + } + } + }, + { + "id": "G6OopD", + "type": "ref", + "ref": "i6gfu", + "name": "Remove", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Remove" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "ztTtW", + "name": "west cluster", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "kP0G0", + "name": "Cluster identity", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "Y7DpB", + "name": "Name and registration", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Bw0Ud", + "name": "Cluster display name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "West backup", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "OnCwQ", + "name": "Registration name", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "west", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "tbzYp", + "name": "API connectivity", + "fill": "$danger/danger", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "API: Unhealthy", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "tyi4k", + "name": "Last checked", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Last checked just now", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "GHLWv", + "name": "Cluster actions", + "gap": 8, + "children": [ + { + "id": "e8AF5P", + "type": "ref", + "ref": "cb4rt", + "name": "View servers", + "opacity": 1, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "View servers" + } + } + }, + { + "id": "W1CM5U", + "type": "ref", + "ref": "i6gfu", + "name": "View nodes", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "View nodes" + } + } + }, + { + "id": "K8EeTB", + "type": "ref", + "ref": "i6gfu", + "name": "Remove", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Remove" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "BjUmJ", + "layoutPosition": "absolute", + "x": 0, + "y": 0, + "name": "Modal backdrop", + "width": 1440, + "height": 900, + "fill": "#00000066", + "layout": "vertical", + "padding": 40, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "id": "iqyS7", + "type": "ref", + "ref": "WwNlX", + "name": "Remove cluster confirmation", + "width": 440, + "descendants": { + "QARhG": { + "content": "Remove east?" + }, + "aZP2h": { + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "KC7CR", + "name": "Removal description and error", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "DcBlI", + "name": "Removal description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Removes this panel’s connection to the cluster. Running workloads remain on the cluster.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "BOOHh", + "name": "Removal request error", + "fill": "$danger/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Couldn’t remove the cluster registration. Try again.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + "Wa0YZ": { + "enabled": false, + "width": 400, + "height": 0 + }, + "tvH6d": { + "content": "Remove cluster" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "RS0KZ", + "x": -12557, + "y": 32032, + "name": "State/Standalone — Cluster selection and request states", + "context": "Interaction reference, not a single dashboard screen. No workload queries before capabilities and selection resolve. Retry retains the selected remote.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 32, + "children": [ + { + "type": "text", + "id": "MKxQW", + "name": "Reference title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Standalone workload selection", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 24, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "ebNpV", + "name": "Selection states", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "cGqBU", + "name": "Empty dropdown", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "bm0hY", + "name": "State title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "No registered clusters", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "oV6ND", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "kRBdc", + "name": "API status", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "MNf1m", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ocDjw", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "Select cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "ahab6", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "Hikui", + "name": "Cluster menu", + "width": "fill_container", + "fill": "$overlay", + "cornerRadius": 6, + "padding": 12, + "children": [ + { + "type": "text", + "id": "OMwrM", + "name": "Empty cluster menu action", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "No clusters available · Manage clusters", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "yuYzW", + "name": "Registered dropdown", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "VDdRD", + "name": "State title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Explicit remote selection", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "aW3rE", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "z0qmuR", + "name": "API status", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "G4TPp", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "WKcwf", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "East production", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "eEmef", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "EQ4VY", + "name": "Cluster choices", + "width": "fill_container", + "fill": "$overlay", + "cornerRadius": 6, + "layout": "vertical", + "gap": 16, + "padding": 12, + "children": [ + { + "type": "text", + "id": "l32Yb", + "name": "East option", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "East production ✓", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "GV2tB", + "name": "West option", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "West backup", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "PT3xk", + "name": "Administration action", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "View all clusters", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "FW9eC", + "name": "Loading and failure states", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "ln61E", + "name": "Loading", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "YO7gr", + "name": "State title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Loading", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "MHckh", + "name": "Status copy", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Loading installation…\nLoading clusters…\nLoading permissions…", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "JNCrO", + "name": "Installation unavailable", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "wCHfF", + "name": "State title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Installation unavailable", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "R72SqV", + "name": "Status copy", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Couldn't load installation capabilities.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "qJC3T", + "type": "ref", + "ref": "i6gfu", + "name": "Retry", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Retry" + } + } + } + ] + }, + { + "type": "frame", + "id": "KHCdz", + "name": "Registry unavailable", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "BHDVF", + "name": "State title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Registry unavailable", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "ouszc", + "name": "Status copy", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Couldn't load registered clusters.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "bDQy5", + "type": "ref", + "ref": "i6gfu", + "name": "Retry", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Retry" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "rmmyP", + "name": "Identity request failure", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "vytzd", + "name": "Identity failure message", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Couldn’t load your permissions.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "lNVmH", + "type": "ref", + "ref": "FIB65", + "name": "Retry request", + "height": 32, + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Retry" + } + } + } + ] + }, + { + "type": "text", + "id": "ZwDM2", + "name": "Scope note", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Initial failures show a retry state. Background refresh errors keep the last loaded catalog, sources, and open drafts mounted. Permission fetch failures show Retry, separately from denied permission. Successful removal clears the selected registration without choosing another cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "D9QYZ", + "x": -17357, + "y": 33432, + "name": "Screen/Standalone — Settings · Backup destinations", + "context": "PR #630 standalone installation. No local Kubernetes metadata or local workload fallback. Master-key storage and offline rotation have no dashboard controls.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "amVFQ", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Central management" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "u9vMl", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "LXGCU", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "s6Mzg", + "name": "Central management notice", + "width": "fill_container", + "fill": "$surface/surface", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "layout": "vertical", + "padding": [ + 12, + 24 + ], + "children": [ + { + "type": "text", + "id": "QQ1lm", + "name": "Central management scope", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "LBC1p", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "Z9H5G0", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "L9E1S", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "NK9SS", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "fOAMd": { + "fill": "$surface/secondary" + } + } + }, + { + "type": "frame", + "id": "v5zYl", + "name": "Workload target", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "Z8ppQk", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "sEwer", + "name": "API status", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "Y9yBT", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "kJRiP", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "Select cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "j47im", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "ZRrXg", + "name": "No workload target", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "hOirG", + "name": "Selection title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Select a workload cluster", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "YXyzH", + "name": "Selection subtitle", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Backup destinations are stored on the workload cluster that runs your servers.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "IwTZ0", + "name": "Selection description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Choose a registered cluster above, or register one on the Clusters page.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "l64bd", + "x": -15757, + "y": 33432, + "name": "Screen/Standalone — Settings · Module sources", + "context": "PR #630 standalone installation. No local Kubernetes metadata or local workload fallback. Master-key storage and offline rotation have no dashboard controls.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "FcbBC", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Central management" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "D6zRW", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "nnRoX", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "mZGwz", + "name": "Central management notice", + "width": "fill_container", + "fill": "$surface/surface", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "layout": "vertical", + "padding": [ + 12, + 24 + ], + "children": [ + { + "type": "text", + "id": "hvXxM", + "name": "Central management scope", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "WKQYl", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "PH5hw", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "u43cEm", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "WqU5M", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "OUh8J": { + "fill": "$surface/secondary" + } + } + }, + { + "type": "frame", + "id": "s166r", + "name": "Workload target", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "x1QWec", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "sD5mQ", + "name": "API status", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "qohKg", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "CP1f4", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "Select cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "K9APyK", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "DYERt", + "name": "No workload target", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "G0Xvr", + "name": "Selection title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Select a workload cluster", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "YekgK", + "name": "Selection subtitle", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Modules and their sources are managed by the operator on a registered workload cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "sVMpB", + "name": "Manage clusters link", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Manage clusters", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Uzzje", + "x": -14157, + "y": 33432, + "name": "Screen/Standalone — Settings · Updates", + "context": "PR #630 standalone installation. No local Kubernetes metadata or local workload fallback. Master-key storage and offline rotation have no dashboard controls.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "WRQtS", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Central management" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "JC6dK", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "CCla0", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "BocHR", + "name": "Central management notice", + "width": "fill_container", + "fill": "$surface/surface", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "layout": "vertical", + "padding": [ + 12, + 24 + ], + "children": [ + { + "type": "text", + "id": "rSe5T", + "name": "Central management scope", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "MBSfV", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "MsH9w", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "qxuaB", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "qW9R7", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "bwo3a": { + "fill": "$surface/secondary" + } + } + }, + { + "type": "frame", + "id": "zyhz1", + "name": "Updates", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "uyYAr", + "name": "Section title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Updates", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "tveul", + "name": "Container upgrade guidance", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Update the panel and API container images to upgrade this installation.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "LOUx7", + "name": "Channel", + "width": "fill_container", + "gap": 24, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "SmuVJ", + "name": "Channel label and hint", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "BhUQD", + "name": "Channel label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Channel", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "W3hgaz", + "name": "Channel hint", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Container image tags select the release for this panel.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "BtKwG", + "name": "Unavailable channel", + "fill": "$foreground/foreground", + "content": "—", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "FOVMf", + "x": -12557, + "y": 33432, + "name": "Screen/Standalone — Settings · About", + "context": "Panel build version comes from authenticated installation metadata; standalone About does not fetch local Kubernetes metadata.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "CjTjh", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Central management" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "UN7yo", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "Pucwu", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "i1oDk0", + "name": "Central management notice", + "width": "fill_container", + "fill": "$surface/surface", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "layout": "vertical", + "padding": [ + 12, + 24 + ], + "children": [ + { + "type": "text", + "id": "wVBRj", + "name": "Central management scope", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "GFScj", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "I7XR8", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "euy61", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "p5foG0", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "WlxKs": { + "fill": "$surface/secondary" + } + } + }, + { + "type": "frame", + "id": "X1EyDE", + "name": "About", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "D6N4hh", + "name": "Section title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "About", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "osqdf", + "name": "About description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "This Gameplane build.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "CSzHs", + "name": "Gameplane row", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "text", + "id": "OlU4p", + "name": "Gameplane label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": 100, + "content": "Gameplane", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "AORiq", + "name": "Gameplane value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "0.3.0-dev", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IMEAc", + "name": "License row", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "text", + "id": "lgbba", + "name": "License label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": 100, + "content": "License", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Slm3S", + "name": "License value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "AGPL-3.0", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "r139UP", + "x": -10957, + "y": 33432, + "name": "Screen/Standalone — System logs", + "context": "PR #630 standalone installation. No local Kubernetes metadata or local workload fallback. Master-key storage and offline rotation have no dashboard controls.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "WWoii", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Central management" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "zV6Z5": { + "fill": "$accent/soft" + }, + "zV6Z5/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "zV6Z5/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "bR7V9", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "GFROW", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "VzGni": { + "type": "frame", + "id": "i0bjE", + "name": "Breadcrumbs", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "AFgeH", + "name": "crumbRoot", + "fill": "$muted", + "content": "gameplane", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "hxYAU", + "name": "crumbSep", + "width": 14, + "height": 14, + "icon": "chevron-right", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "u2AU5", + "name": "crumbMid", + "fill": "$muted", + "content": "Settings", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "trGtl", + "name": "crumbSep", + "width": 14, + "height": 14, + "icon": "chevron-right", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "SXXoW", + "name": "crumbPage", + "fill": "$foreground/foreground", + "content": "System logs", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "500" + } + ] + } + } + }, + { + "type": "frame", + "id": "cI7TE", + "name": "Central management notice", + "width": "fill_container", + "fill": "$surface/surface", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "layout": "vertical", + "padding": [ + 12, + 24 + ], + "children": [ + { + "type": "text", + "id": "MILE7", + "name": "Central management scope", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "HdzoC", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "SC3by", + "type": "ref", + "ref": "xCDF7", + "name": "System logs header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "System logs" + }, + "VbHwU": { + "content": "Logs for this standalone panel." + }, + "TgdLz": { + "type": "frame", + "id": "bVoTh", + "x": 1132, + "y": 8.5, + "name": "Page actions", + "enabled": false, + "width": 1, + "height": 1, + "gap": 8, + "alignItems": "center" + } + } + }, + { + "type": "frame", + "id": "s6N0gl", + "name": "Container logs", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 24, + "children": [ + { + "type": "text", + "id": "yPx4G", + "name": "Container logs title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "View logs through your container runtime", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "TT48A", + "name": "Container logs guidance", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "For Docker Compose, run docker compose logs --follow from your panel installation directory. Operator logs are available on each workload cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "EWrn0", + "x": -10957, + "y": 34832, + "name": "Screen/Standalone — Modules (East selected)", + "context": "Background refetch failures display an inline retry banner and retain existing data plus open install/upload/builder/source drafts. Only an initial failure replaces loading content. Explicit target changes discard drafts.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1900, + "fill": "$background/background", + "children": [ + { + "id": "zaoyJ", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "sTSGR": { + "fill": "$accent/soft" + }, + "sTSGR/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "sTSGR/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "VcdIX", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "t2qdy", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Modules" + } + } + }, + { + "type": "frame", + "id": "SgTSF", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "P4F1bF", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "hbyla", + "name": "API status", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "E2XJ2", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "nTkwE", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "East production", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "MNnA6", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "id": "q0nYXF", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Modules" + }, + "VbHwU": { + "content": "Pre-packaged game-server templates pulled from your configured module sources." + }, + "TgdLz": { + "type": "frame", + "id": "h8BsNj", + "name": "phActions", + "height": 36, + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "UEw9g", + "type": "ref", + "ref": "i6gfu", + "name": "btnCreate", + "descendants": { + "FCYQ9": { + "icon": "plus" + }, + "GqVun": { + "content": "Create module" + } + } + }, + { + "id": "TXKLW", + "type": "ref", + "ref": "i6gfu", + "name": "btnUpload", + "descendants": { + "FCYQ9": { + "icon": "upload" + }, + "GqVun": { + "content": "Upload module" + } + } + }, + { + "id": "SCjB3", + "type": "ref", + "ref": "i6gfu", + "name": "btnSources", + "descendants": { + "FCYQ9": { + "icon": "settings-2" + }, + "GqVun": { + "content": "Manage sources" + } + } + } + ] + } + } + }, + { + "type": "frame", + "id": "ySjMT", + "name": "Background refresh error with retained draft", + "width": "fill_container", + "height": 64, + "fill": "$surface/surface", + "gap": 16, + "padding": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Q2MWK", + "name": "Refresh error", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Failed to load module catalog.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "jrRAt", + "type": "ref", + "ref": "FIB65", + "name": "Retry request", + "height": 32, + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Retry" + } + } + } + ] + }, + { + "type": "frame", + "id": "lRXRg", + "name": "Filters", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Pi5Bp", + "name": "srcGroup", + "fill": "$surface/surface", + "cornerRadius": 6, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 4, + "padding": 4, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "s6nQs", + "name": "srcAll", + "fill": "$accent/soft", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "nKoFm", + "fill": "$accent/accent", + "content": "All sources", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "GS0Vn", + "name": "srcDefault", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "nSMft", + "fill": "$muted", + "content": "default", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "G7SLsf", + "name": "srcCommunity", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Azuhv", + "fill": "$muted", + "content": "community", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "dPEXn", + "name": "catGroup", + "clip": true, + "width": 440, + "height": 36, + "fill": "$surface/surface", + "cornerRadius": 6, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 4, + "padding": 4, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "TX7BU", + "name": "catAllcategories", + "fill": "#00000000", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "MQMLR", + "name": "Label", + "fill": "$muted", + "content": "All categories", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "hPCOz", + "name": "catAdventure", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dxKOt", + "name": "Label", + "fill": "$muted", + "content": "Adventure", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "lkwX5", + "name": "catBuilding", + "fill": "$accent/soft", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WDbWy", + "name": "Label", + "fill": "$accent/accent", + "content": "Building", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "R1nKb", + "name": "catCoop", + "fill": "$accent/soft", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lM1p6", + "name": "Label", + "fill": "$accent/accent", + "content": "Co-op", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "bV13f", + "name": "catCreative", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "g0Sdk", + "name": "Label", + "fill": "$muted", + "content": "Creative", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "QMcFw", + "name": "catHorror", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "n1Wnd", + "name": "Label", + "fill": "$muted", + "content": "Horror", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "dE5WN", + "name": "catModded", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "PwJ7b", + "name": "Label", + "fill": "$muted", + "content": "Modded", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "HzSXR", + "name": "catPvP", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "h6SIMc", + "name": "Label", + "fill": "$muted", + "content": "PvP", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "a6OiDZ", + "name": "catSandbox", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "TDLxy", + "name": "Label", + "fill": "$muted", + "content": "Sandbox", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "uAqBo", + "name": "catShooter", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ODmPM", + "name": "Label", + "fill": "$muted", + "content": "Shooter", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "sgYsv", + "name": "catSimulation", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "PNKVY", + "name": "Label", + "fill": "$muted", + "content": "Simulation", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "gXI40", + "name": "catSurvival", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "UnCsA", + "name": "Label", + "fill": "$muted", + "content": "Survival", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "rectangle", + "cornerRadius": [ + 0, + 6, + 6, + 0 + ], + "id": "wsANd", + "layoutPosition": "absolute", + "x": 384, + "y": 0, + "name": "Fade Right", + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 270, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A1A00", + "position": 0 + }, + { + "color": "$surface/surface", + "position": 1 + } + ] + }, + "width": 56, + "height": 36 + } + ] + }, + { + "type": "frame", + "id": "FnTh4", + "name": "spacer", + "width": "fill_container", + "height": 1 + }, + { + "id": "RG2PH", + "type": "ref", + "ref": "Lmaf1", + "name": "Search", + "width": 256, + "descendants": { + "EJMtX": { + "content": "Search modules…" + } + } + } + ] + }, + { + "type": "frame", + "id": "hzKUQ", + "name": "Row 1", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "BCrCb", + "name": "mc1", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "Rw7i9", + "name": "mc1h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "haZF9", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$success/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "bsT96", + "fill": "$success/soft-foreground", + "content": "MI", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Pe1CO", + "name": "mc1Badges", + "gap": 6, + "alignItems": "center", + "children": [ + { + "id": "i8tkn", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "type": "frame", + "id": "RNsOW", + "name": "IconLabel", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "vIqo1", + "width": 12, + "height": 12, + "icon": "shield-check", + "library": "lucide", + "fill": "$success/soft-foreground" + }, + { + "type": "text", + "id": "a9LP0N", + "name": "Label", + "fill": "$success/soft-foreground", + "content": "VERIFIED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + } + }, + { + "id": "FHgzd", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "vMPO6", + "name": "mc1Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "kkWNN", + "fill": "$foreground/foreground", + "content": "Minecraft (Java Edition)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "glS31", + "fill": "$muted", + "content": "v1.21.4 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "djI4H", + "name": "mc1Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Z7pgt", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "H9sXzt", + "fill": "$muted", + "content": "sha256:a1b2c3d4…9e2f", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "oSprH", + "name": "mc1Rollback", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "f1y07t", + "width": 12, + "height": 12, + "icon": "history", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "X6ujHo", + "fill": "$muted", + "content": "rollback target · v1.21.3", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "RmOZQ", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "GaEqQ", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + }, + { + "id": "XGbIN", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "t5biUC", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + } + ] + }, + { + "type": "text", + "id": "fodxs", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Vanilla / Paper / Spigot / Bukkit / Purpur / Forge / NeoForge / Fabric / Quilt servers with a version+loader picker and in-app Modrinth browse.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "j0YHk", + "name": "mc1f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "XLqau", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "pcgth", + "name": "mc1Btns", + "layout": "vertical", + "gap": 4, + "alignItems": "end", + "children": [ + { + "id": "TE5Bx", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to East production" + } + } + }, + { + "id": "W6xCJ5", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "hERwe", + "name": "mc2", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "kXi6M", + "name": "mc2h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "a1h6Ig", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$success/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "JornN", + "fill": "$success/soft-foreground", + "content": "MI", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "oxcwz", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "GHHz0", + "name": "mc2Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "cooKT", + "fill": "$foreground/foreground", + "content": "Minecraft (Bedrock)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "vCh84", + "fill": "$muted", + "content": "v1.21.5 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "TX2G5", + "name": "mc2Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "iB5L3", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "ya51K", + "fill": "$muted", + "content": "sha256:5f0ab913…c47d", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "b6QHhA", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "NuYWm", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + }, + { + "id": "UWyS3", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "C3o0y", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Creative", + "descendants": { + "r4GQ9": { + "content": "Creative" + } + } + } + ] + }, + { + "type": "text", + "id": "EMvTt", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Cross-platform edition for mobile, console and Windows clients. Allow-list and permissions managed from the wizard.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "elylQ", + "name": "mc2f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "iMHIZ", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "P4r07", + "name": "mc2Btns", + "layout": "vertical", + "gap": 4, + "alignItems": "end", + "children": [ + { + "id": "zljEL", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to East production" + } + } + }, + { + "id": "If2a1", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "f2WWnE", + "name": "mc3", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "L0X72", + "name": "mc3h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "dVuQ0", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$warning/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cApsW", + "fill": "$warning/soft-foreground", + "content": "VA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "k5zck", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "Iycls", + "name": "mc3Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "sHUcT", + "fill": "$foreground/foreground", + "content": "Valheim (Dedicated)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "YC8CL", + "fill": "$muted", + "content": "v2.3.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "z3yEy", + "name": "mc3Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Egf82", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "Pxd1z", + "fill": "$muted", + "content": "sha256:9c14e07b…3ba8", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "oVpos", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "rQ9mZ", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "yI4Ws", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Co-op", + "descendants": { + "r4GQ9": { + "content": "Co-op" + } + } + }, + { + "id": "MAr14", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + } + ] + }, + { + "type": "text", + "id": "xhJgt", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Valheim dedicated server (lloesche/valheim-server) with a stable/public-test channel picker, a BepInEx mod volume, and in-app Thunderstore browse. Crossplay-friendly.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "NwuXn", + "name": "mc3f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "DJCt9", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "KtH2v", + "name": "mc3Btns", + "layout": "vertical", + "gap": 4, + "alignItems": "end", + "children": [ + { + "id": "P9kUxK", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to East production" + } + } + }, + { + "id": "bcH2d", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "NA6mR", + "name": "mc4", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "JTrti", + "name": "mc4h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "C5wlgq", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$accent/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pdQJL", + "fill": "$accent/accent", + "content": "FA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "wvOjg", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "BpAMw", + "name": "mc4Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "WPiYJ", + "fill": "$foreground/foreground", + "content": "Factorio (Headless)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "rxhFg", + "fill": "$muted", + "content": "v1.1.0", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "z7XqJ2", + "name": "mc4Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "rVSoL", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "rWVTa", + "fill": "$muted", + "content": "sha256:7d32e46a…1161", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "j1l8I6", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "eRrkk", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Simulation", + "descendants": { + "r4GQ9": { + "content": "Simulation" + } + } + }, + { + "id": "jkIqr", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + }, + { + "id": "KXKvS", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + } + ] + }, + { + "type": "text", + "id": "P69knx", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Factorio headless server (factoriotools/factorio) with a stable/latest/pinned channel picker, server-settings managed from the wizard, and a mods volume with in-app Factorio mod portal browse.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "HeLCi", + "name": "mc4f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Y3r3AB", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "uM7Xe", + "name": "mc4Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "bTm7i", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "vDJLX", + "name": "Row 2", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "NeXJU", + "name": "mc5", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "gGhsi", + "name": "mc5h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rzWEK", + "name": "tile", + "width": 36, + "height": 36, + "fill": "#895AF633", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HgYDr", + "fill": "#895AF6", + "content": "PA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "s3J8Z", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "HJMo3", + "name": "mc5Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "j2mmb", + "fill": "$foreground/foreground", + "content": "Palworld (Dedicated)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "pVjgV", + "fill": "$muted", + "content": "v1.1.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "QO5Jt", + "name": "mc5Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "YSOHi", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "j13hF", + "fill": "$muted", + "content": "sha256:2e91fa04…8d17", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "N7Snz", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "RfE1q", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "L4VM73", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Co-op", + "descendants": { + "r4GQ9": { + "content": "Co-op" + } + } + }, + { + "id": "gNcBT", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + } + ] + }, + { + "type": "text", + "id": "C7yvMg", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Palworld dedicated server (thijsvanloef/palworld-server-docker) with RCON console, save-aware graceful stop, and a .pak mod volume with archive install.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "I51r9E", + "name": "mc5f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "d6EPRJ", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Cmaj5", + "name": "mc5Btns", + "layout": "vertical", + "gap": 4, + "alignItems": "end", + "children": [ + { + "id": "j2yeju", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to East production" + } + } + }, + { + "id": "hDZKa", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "R7HxH", + "name": "mc6", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "xluPy", + "name": "mc6h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "HZ0sa", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$danger/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zxevY", + "fill": "$danger/soft-foreground", + "content": "AR", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "f6EW7", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "yh0Be", + "name": "mc6Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "RxHCA", + "fill": "$foreground/foreground", + "content": "ARK: Survival Evolved", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "PZQ8j", + "fill": "$muted", + "content": "v0.9.2", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "nGrkk", + "name": "mc6Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "QhUUd", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "rECBN", + "fill": "$muted", + "content": "sha256:b7440ce1…05af", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "JxxJf", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "NCdnR", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "q6Ln7F", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + }, + { + "id": "tu6Hu", + "type": "ref", + "ref": "Y43SFn", + "name": "cat PvP", + "descendants": { + "r4GQ9": { + "content": "PvP" + } + } + } + ] + }, + { + "type": "text", + "id": "NNpph", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Dinosaur survival on the Unreal engine. Mod install via the Steam workshop; schedule onto a GPU node for acceptable tick rates.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Kex9B", + "name": "mc6f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "u3CZ77", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "q9Og6", + "name": "mc6Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "Bv55w", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "uMr7p", + "name": "mc7", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "C9vJKc", + "name": "mc7h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "E3dmRR", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$success/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "S7QRzt", + "fill": "$success/soft-foreground", + "content": "TE", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "a0KqNB", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "d1BQAV", + "name": "mc7Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "vJjJb", + "fill": "$foreground/foreground", + "content": "Terraria", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "Hnfya", + "fill": "$muted", + "content": "v2.1.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "P1MQ9", + "name": "mc7Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "omb9D", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "E0xDN", + "fill": "$muted", + "content": "sha256:c805d7f2…46e9", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "rCIdU", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "zXQZf", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + }, + { + "id": "jcTbA", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "d7MLe", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Adventure", + "descendants": { + "r4GQ9": { + "content": "Adventure" + } + } + } + ] + }, + { + "type": "text", + "id": "nIdd4", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Terraria + tModLoader dedicated server (passivelemon/terraria-docker) with a vanilla/tModLoader version picker and a tModLoader ModPacks volume. Console via container stdin.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "KRDvM", + "name": "mc7f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "t4Zan", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "IkPSv", + "name": "mc7Btns", + "layout": "vertical", + "gap": 4, + "alignItems": "end", + "children": [ + { + "id": "o1D6u", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to East production" + } + } + }, + { + "id": "nihHT", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "eGBhX", + "name": "mc8", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "NCgBk", + "name": "mc8h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "uYuR0", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$warning/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "UVI8H", + "fill": "$warning/soft-foreground", + "content": "CO", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "W2kYRR", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "wMiwK", + "name": "mc8Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "m1FoHp", + "fill": "$foreground/foreground", + "content": "Counter-Strike 2", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "Ylyst", + "fill": "$muted", + "content": "v0.4.1", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "uOeXZ", + "name": "mc8Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "J8kMOv", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "FtORK", + "fill": "$muted", + "content": "sha256:1a6be390…f72c", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "aZNhK", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "DLxem", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Shooter", + "descendants": { + "r4GQ9": { + "content": "Shooter" + } + } + }, + { + "id": "LW3Rz", + "type": "ref", + "ref": "Y43SFn", + "name": "cat PvP", + "descendants": { + "r4GQ9": { + "content": "PvP" + } + } + } + ] + }, + { + "type": "text", + "id": "cBceH", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Competitive shooter with MatchZy and CounterStrikeSharp plugin support. Game-server login token supplied as a secret.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "I8umeK", + "name": "mc8f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "NaGSd", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "v6v7X", + "name": "mc8Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "nbbBo", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zZFc5", + "name": "Row 3", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "M68n5", + "name": "mc9", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "rsmuw", + "name": "mc9h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "vXsFx", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$danger/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "oCZ18", + "fill": "$danger/soft-foreground", + "content": "RU", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "wAZnl", + "name": "mc9Badges", + "gap": 6, + "alignItems": "center", + "children": [ + { + "id": "P3NTh", + "type": "ref", + "ref": "a3AjAZ", + "name": "Badge", + "descendants": { + "F9x0p": { + "type": "frame", + "id": "CwAPj", + "name": "IconLabel", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "ymFze", + "width": 12, + "height": 12, + "icon": "shield-question", + "library": "lucide", + "fill": "$success/success" + }, + { + "type": "text", + "id": "AylTm", + "name": "Label", + "fill": "$success/success", + "content": "POLICY", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + } + }, + { + "id": "N0bvy", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "N0dqF", + "name": "mc9Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "g0ZcSO", + "fill": "$foreground/foreground", + "content": "Rust", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "TEB97", + "fill": "$muted", + "content": "v0.7.3", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "YjXtM", + "name": "mc9Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "LCrSR", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "PZwFZ", + "fill": "$muted", + "content": "sha256:3fd0817c…ba24", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ViBDp", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "K5XFp", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "VyNne", + "type": "ref", + "ref": "Y43SFn", + "name": "cat PvP", + "descendants": { + "r4GQ9": { + "content": "PvP" + } + } + }, + { + "id": "W133T", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + } + ] + }, + { + "type": "text", + "id": "PhGiP", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Hardcore multiplayer survival with Oxide and Carbon mod loaders. Monthly forced wipe; schedule a backup before each one.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "o98v54", + "name": "mc9f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "B4Esb", + "fill": "$muted", + "content": "community (oci)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "OzUxH", + "name": "mc9Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "cgHbS", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "AUaNF", + "name": "mc10", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "AKk8A", + "name": "mc10h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "OFa1R", + "name": "tile", + "width": 36, + "height": 36, + "fill": "#9494944D", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mpTKp", + "fill": "$muted", + "content": "7-", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "WVhdW", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "B2JMAN", + "name": "mc10Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "q6LCmJ", + "fill": "$foreground/foreground", + "content": "7 Days to Die", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "RcwL2", + "fill": "$muted", + "content": "v0.5.0", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "XMJ92", + "name": "mc10Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "dXoJj", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "g3xhEU", + "fill": "$muted", + "content": "sha256:e2c93b58…7a0d", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "s13ktJ", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "mF7rL", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "m0DemM", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Horror", + "descendants": { + "r4GQ9": { + "content": "Horror" + } + } + }, + { + "id": "u7uJa", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + } + ] + }, + { + "type": "text", + "id": "QbRP4", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Zombie survival crafting in a destructible voxel world. Blood-moon horde nights are CPU-spiky — give it headroom.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "rKsSc", + "name": "mc10f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "IcZFB", + "fill": "$muted", + "content": "community (oci)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "g2efJH", + "name": "mc10Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "iTW6E", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "W8Xax", + "name": "mc11", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "w8J7PW", + "name": "mc11h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "wa89S", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$accent/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ztZ6y", + "fill": "$accent/accent", + "content": "SA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "Fa8wc", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "P8xhy", + "name": "mc11Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "RyheW", + "fill": "$foreground/foreground", + "content": "Satisfactory (Dedicated)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "cDx5s", + "fill": "$muted", + "content": "v1.0.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "p9pK9", + "name": "mc11Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "u46f4B", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "ORATC", + "fill": "$muted", + "content": "sha256:4b7ecf19…d3e6", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "uzh9z", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "h5YHb", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Simulation", + "descendants": { + "r4GQ9": { + "content": "Simulation" + } + } + }, + { + "id": "U3QXti", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + }, + { + "id": "CsLAC", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + } + ] + }, + { + "type": "text", + "id": "fBpsd", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Satisfactory dedicated server (wolveix/satisfactory-server) with a stable/experimental branch picker. Env-configured; installs via steamcmd on first boot.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "G0nj3G", + "name": "mc11f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "NprMT", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "V7UXSy", + "name": "mc11Btns", + "layout": "vertical", + "gap": 4, + "alignItems": "end", + "children": [ + { + "id": "cquRY", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to East production" + } + } + }, + { + "id": "p2JMEL", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "bWvsv", + "name": "gridFiller", + "width": "fill_container", + "height": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "p8QSPq", + "x": -17357, + "y": 34832, + "name": "Screen/Standalone — Modules (Manage sources)", + "context": "Background refetch failures display an inline retry banner and retain existing data plus open install/upload/builder/source drafts. Only an initial failure replaces loading content. Explicit target changes discard drafts.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 2050, + "fill": "$background/background", + "children": [ + { + "id": "a0hci", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + }, + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "sTSGR": { + "fill": "$accent/soft" + }, + "sTSGR/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "sTSGR/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "g0Gcc", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "wnNlp", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Modules" + } + } + }, + { + "type": "frame", + "id": "JlGgj", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "K58mkD", + "name": "Workload cluster selector", + "height": 36, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 6, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "a3CoXF", + "name": "API status", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "XZcMg", + "name": "Selector label", + "fill": "$muted", + "content": "Cluster:", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "BHaZy", + "name": "Selected workload cluster", + "fill": "$foreground/foreground", + "content": "East production", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "h2ttG", + "name": "Open cluster choices", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$muted" + } + ] + }, + { + "id": "Q5xfK", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Modules" + }, + "VbHwU": { + "content": "Pre-packaged game-server templates pulled from your configured module sources." + }, + "TgdLz": { + "type": "frame", + "id": "lGRJX", + "name": "phActions", + "height": 36, + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "M6aca", + "type": "ref", + "ref": "i6gfu", + "name": "btnCreate", + "descendants": { + "FCYQ9": { + "icon": "plus" + }, + "GqVun": { + "content": "Create module" + } + } + }, + { + "id": "MihiS", + "type": "ref", + "ref": "i6gfu", + "name": "btnUpload", + "descendants": { + "FCYQ9": { + "icon": "upload" + }, + "GqVun": { + "content": "Upload module" + } + } + }, + { + "id": "d6jwl", + "type": "ref", + "ref": "i6gfu", + "name": "btnSources", + "descendants": { + "FCYQ9": { + "icon": "settings-2" + }, + "GqVun": { + "content": "Manage sources" + } + } + } + ] + } + } + }, + { + "type": "frame", + "id": "b5EIv4", + "name": "Background refresh error with retained draft", + "width": "fill_container", + "height": 64, + "fill": "$surface/surface", + "gap": 16, + "padding": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "hLpJp", + "name": "Refresh error", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Failed to load module sources.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "TjNdy", + "type": "ref", + "ref": "FIB65", + "name": "Retry request", + "height": 32, + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Retry" + } + } + } + ] + }, + { + "type": "frame", + "id": "J8jUzJ", + "name": "Sources managed on east", + "clip": true, + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "MI0WR", + "name": "Card Header", + "width": "fill_container", + "gap": 12, + "padding": [ + 20, + 20, + 12, + 20 + ], + "justifyContent": "space_between", + "children": [ + { + "type": "frame", + "id": "hkBdS", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "Y75u1i", + "fill": "$foreground/foreground", + "content": "Module sources", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "MTpye", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Registries, git repositories, archives, and uploads the operator pulls module bundles from. Equivalent to applying ModuleSource resources directly.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "RxHiA", + "name": "btnAddSource", + "height": 32, + "fill": "$accent/accent", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "tuDoD", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$accent/foreground" + }, + { + "type": "text", + "id": "T1mVdr", + "fill": "$accent/foreground", + "content": "Add source", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "VWcp7", + "name": "Card Content", + "width": "fill_container", + "layout": "vertical", + "padding": [ + 0, + 20, + 20, + 20 + ], + "children": [ + { + "type": "frame", + "id": "yxJqR", + "name": "srcDefault", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "strokeAlignment": "inner", + "gap": 12, + "padding": [ + 12, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "uZnIz", + "width": 16, + "height": 16, + "icon": "circle-check", + "library": "lucide", + "fill": "$success/success" + }, + { + "type": "frame", + "id": "P0ZcU", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "frame", + "id": "gd05S", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pJ5au", + "fill": "$foreground/foreground", + "content": "default", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "D8xlhC", + "name": "typeBadge", + "fill": "$surface/surface", + "cornerRadius": 4, + "padding": [ + 2, + 6 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "sEpu8", + "fill": "$muted", + "content": "GIT", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "shxpn", + "fill": "$muted", + "content": "https://github.com/ValgulNecron/gameplane-module.git@main", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "zwFvI", + "layout": "vertical", + "gap": 2, + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "S7mzjE", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "BIcZC", + "width": 12, + "height": 12, + "icon": "refresh-ccw", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "veFpU", + "fill": "$muted", + "content": "1h", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "WwnzL", + "fill": "$muted", + "content": "6 modules · synced 5m ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "AFCUJ", + "name": "srcActions", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "PU4yE", + "name": "btnEdit", + "width": 32, + "height": 32, + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "WAw4z", + "width": 14, + "height": 14, + "icon": "pencil", + "library": "lucide", + "fill": "$foreground/foreground" + } + ] + }, + { + "type": "frame", + "id": "d5PVe", + "name": "btnDelete", + "width": 32, + "height": 32, + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "EF7tb", + "width": 14, + "height": 14, + "icon": "trash-2", + "library": "lucide", + "fill": "#DC2626" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "l1orWs", + "name": "srcUploads", + "width": "fill_container", + "gap": 12, + "padding": [ + 12, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "gbSh7", + "width": 16, + "height": 16, + "icon": "circle-check", + "library": "lucide", + "fill": "$success/success" + }, + { + "type": "frame", + "id": "Z3GdpB", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "frame", + "id": "s2hoU", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "vEfk1", + "fill": "$foreground/foreground", + "content": "uploads", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "S8r64q", + "name": "typeBadge", + "fill": "$surface/surface", + "cornerRadius": 4, + "padding": [ + 2, + 6 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f6DG2j", + "fill": "$muted", + "content": "UPLOAD", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "LgAwr", + "fill": "$muted", + "content": "uploaded bundles", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "FPJNy", + "layout": "vertical", + "gap": 2, + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "TWnYn", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "RMrWy", + "width": 12, + "height": 12, + "icon": "refresh-ccw", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "BSGzG", + "fill": "$muted", + "content": "1h", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "U3iqN", + "fill": "$muted", + "content": "0 modules · synced 6m ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "OmFqB", + "name": "srcActions", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "A6PjcO", + "name": "btnEdit", + "width": 32, + "height": 32, + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "CZ85P", + "width": 14, + "height": 14, + "icon": "pencil", + "library": "lucide", + "fill": "$foreground/foreground" + } + ] + }, + { + "type": "frame", + "id": "n1dMB", + "name": "btnDelete", + "width": 32, + "height": 32, + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "N0SV7", + "width": 14, + "height": 14, + "icon": "trash-2", + "library": "lucide", + "fill": "#DC2626" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "u8Ae4", + "name": "Filters", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "soXvQ", + "name": "srcGroup", + "fill": "$surface/surface", + "cornerRadius": 6, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 4, + "padding": 4, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "kilzZ", + "name": "srcAll", + "fill": "$accent/soft", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "a6vN1", + "fill": "$accent/accent", + "content": "All sources", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "QGtwU", + "name": "srcDefault", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f4olUt", + "fill": "$muted", + "content": "default", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "mGEYc", + "name": "srcCommunity", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "aSClZ", + "fill": "$muted", + "content": "community", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "zto8x", + "name": "catGroup", + "clip": true, + "width": 440, + "height": 36, + "fill": "$surface/surface", + "cornerRadius": 6, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 4, + "padding": 4, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "CjVw6", + "name": "catAllcategories", + "fill": "#00000000", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "V2Hgz", + "name": "Label", + "fill": "$muted", + "content": "All categories", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "UPWC1", + "name": "catAdventure", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "PTgAB", + "name": "Label", + "fill": "$muted", + "content": "Adventure", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "TxAhs", + "name": "catBuilding", + "fill": "$accent/soft", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "j40Io", + "name": "Label", + "fill": "$accent/accent", + "content": "Building", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "cQ08T", + "name": "catCoop", + "fill": "$accent/soft", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WfIdC", + "name": "Label", + "fill": "$accent/accent", + "content": "Co-op", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "iJoF9", + "name": "catCreative", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "g0ypA", + "name": "Label", + "fill": "$muted", + "content": "Creative", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "mVLhg", + "name": "catHorror", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "x9ePWy", + "name": "Label", + "fill": "$muted", + "content": "Horror", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Mxl9i", + "name": "catModded", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cZhAZ", + "name": "Label", + "fill": "$muted", + "content": "Modded", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "rROw4", + "name": "catPvP", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Hz4BX", + "name": "Label", + "fill": "$muted", + "content": "PvP", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "P2WuYs", + "name": "catSandbox", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zHCrO", + "name": "Label", + "fill": "$muted", + "content": "Sandbox", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "eF4q6", + "name": "catShooter", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qj6a4", + "name": "Label", + "fill": "$muted", + "content": "Shooter", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "topRr", + "name": "catSimulation", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "au1oN", + "name": "Label", + "fill": "$muted", + "content": "Simulation", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "TzW0o", + "name": "catSurvival", + "cornerRadius": 4, + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Nwe92", + "name": "Label", + "fill": "$muted", + "content": "Survival", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "rectangle", + "cornerRadius": [ + 0, + 6, + 6, + 0 + ], + "id": "KCgvL", + "layoutPosition": "absolute", + "x": 384, + "y": 0, + "name": "Fade Right", + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 270, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A1A00", + "position": 0 + }, + { + "color": "$surface/surface", + "position": 1 + } + ] + }, + "width": 56, + "height": 36 + } + ] + }, + { + "type": "frame", + "id": "tSaLJ", + "name": "spacer", + "width": "fill_container", + "height": 1 + }, + { + "id": "DMhCM", + "type": "ref", + "ref": "Lmaf1", + "name": "Search", + "width": 256, + "descendants": { + "EJMtX": { + "content": "Search modules…" + } + } + } + ] + }, + { + "type": "frame", + "id": "Z7GgT", + "name": "Row 1", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "Zd3Wj", + "name": "mc1", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "pkGvC", + "name": "mc1h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "xO038", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$success/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dQ6tC", + "fill": "$success/soft-foreground", + "content": "MI", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "qqcoX", + "name": "mc1Badges", + "gap": 6, + "alignItems": "center", + "children": [ + { + "id": "viCwz", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "type": "frame", + "id": "SwTdp", + "name": "IconLabel", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "s2gRw", + "width": 12, + "height": 12, + "icon": "shield-check", + "library": "lucide", + "fill": "$success/soft-foreground" + }, + { + "type": "text", + "id": "Nh46h", + "name": "Label", + "fill": "$success/soft-foreground", + "content": "VERIFIED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + } + }, + { + "id": "Dgl18", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "UtBQE", + "name": "mc1Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "yismd", + "fill": "$foreground/foreground", + "content": "Minecraft (Java Edition)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "eN247", + "fill": "$muted", + "content": "v1.21.4 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "AIC3u", + "name": "mc1Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "sZt4l", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "N9pQuB", + "fill": "$muted", + "content": "sha256:a1b2c3d4…9e2f", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "jB6tM", + "name": "mc1Rollback", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "VZyxm", + "width": 12, + "height": 12, + "icon": "history", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "t8TEG", + "fill": "$muted", + "content": "rollback target · v1.21.3", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "yKw65", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "t9g85", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + }, + { + "id": "cAjOH", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "BCceU", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + } + ] + }, + { + "type": "text", + "id": "zkONJ", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Vanilla / Paper / Spigot / Bukkit / Purpur / Forge / NeoForge / Fabric / Quilt servers with a version+loader picker and in-app Modrinth browse.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "P2MKOI", + "name": "mc1f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "O3JPo", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "xemn4", + "name": "mc1Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "THRCL", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to east" + } + } + }, + { + "id": "LLUiX", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "bKZtT", + "name": "mc2", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "LQZCB", + "name": "mc2h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "hCC9R", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$success/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "NkmDB", + "fill": "$success/soft-foreground", + "content": "MI", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "KEk3N", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "svhbh", + "name": "mc2Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "zB1vf", + "fill": "$foreground/foreground", + "content": "Minecraft (Bedrock)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "xx6Te", + "fill": "$muted", + "content": "v1.21.5 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "s8JV5D", + "name": "mc2Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "mELJs", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "lKLfS", + "fill": "$muted", + "content": "sha256:5f0ab913…c47d", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "bA5ER", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "HhSiK", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + }, + { + "id": "x3nz4N", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "lTCyy", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Creative", + "descendants": { + "r4GQ9": { + "content": "Creative" + } + } + } + ] + }, + { + "type": "text", + "id": "XCnNa", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Cross-platform edition for mobile, console and Windows clients. Allow-list and permissions managed from the wizard.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "tLq9Z", + "name": "mc2f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "R9pBOh", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "xc52L", + "name": "mc2Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "iQoM4", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to east" + } + } + }, + { + "id": "ZOIiU", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "tYqjs", + "name": "mc3", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "CleEB", + "name": "mc3h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Q8ONbV", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$warning/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "AA2fN", + "fill": "$warning/soft-foreground", + "content": "VA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "RSvQp", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "lVWIo", + "name": "mc3Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "zQ8nz", + "fill": "$foreground/foreground", + "content": "Valheim (Dedicated)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "lnC9o", + "fill": "$muted", + "content": "v2.3.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Aeiig", + "name": "mc3Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "YTxBw", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "G5qbs", + "fill": "$muted", + "content": "sha256:9c14e07b…3ba8", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "j91LWH", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "vQck4", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "TsaRJ", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Co-op", + "descendants": { + "r4GQ9": { + "content": "Co-op" + } + } + }, + { + "id": "LkpEw", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + } + ] + }, + { + "type": "text", + "id": "vXbJa", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Valheim dedicated server (lloesche/valheim-server) with a stable/public-test channel picker, a BepInEx mod volume, and in-app Thunderstore browse. Crossplay-friendly.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "k8ytP", + "name": "mc3f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "cNCQT", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "mPtJ5", + "name": "mc3Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "AernV", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to east" + } + } + }, + { + "id": "RxFCR", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ebRfJ", + "name": "mc4", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "JedWd", + "name": "mc4h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ZLHOt", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$accent/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "bwjDn", + "fill": "$accent/accent", + "content": "FA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "yMufO", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "Z8AjI", + "name": "mc4Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "dUUYa", + "fill": "$foreground/foreground", + "content": "Factorio (Headless)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "l5q9e", + "fill": "$muted", + "content": "v1.1.0", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "fbuay", + "name": "mc4Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "mWApO", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "xSozj", + "fill": "$muted", + "content": "sha256:7d32e46a…1161", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "VTf1l", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "leIyw", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Simulation", + "descendants": { + "r4GQ9": { + "content": "Simulation" + } + } + }, + { + "id": "E64arz", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + }, + { + "id": "IaSJ2", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + } + ] + }, + { + "type": "text", + "id": "uRWC2", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Factorio headless server (factoriotools/factorio) with a stable/latest/pinned channel picker, server-settings managed from the wizard, and a mods volume with in-app Factorio mod portal browse.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "IxGHt", + "name": "mc4f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dvai6", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "sKCUO", + "name": "mc4Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "MEgRI", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "OMvxz", + "x": 24, + "y": 511, + "name": "Row 2", + "enabled": false, + "width": 1132, + "height": 1, + "gap": 16, + "children": [ + { + "type": "frame", + "id": "fygEK", + "name": "mc5", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "NeZoF", + "name": "mc5h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "lKnxf", + "name": "tile", + "width": 36, + "height": 36, + "fill": "#895AF633", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Pk8TQ", + "fill": "#895AF6", + "content": "PA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "jPeZ7", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "R82R9r", + "name": "mc5Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "f0U3O", + "fill": "$foreground/foreground", + "content": "Palworld (Dedicated)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "cH13y", + "fill": "$muted", + "content": "v1.1.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "wuFv6", + "name": "mc5Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "h6TAP", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "bmVsk", + "fill": "$muted", + "content": "sha256:2e91fa04…8d17", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Vhyjs", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "z0TyDF", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "oiRRk", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Co-op", + "descendants": { + "r4GQ9": { + "content": "Co-op" + } + } + }, + { + "id": "LCdcS", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + } + ] + }, + { + "type": "text", + "id": "r9S2W", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Palworld dedicated server (thijsvanloef/palworld-server-docker) with RCON console, save-aware graceful stop, and a .pak mod volume with archive install.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "j2upP", + "name": "mc5f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "pMiIe", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "jQ0AO", + "name": "mc5Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "mOdgq", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to east" + } + } + }, + { + "id": "iUWAR", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ZXGJc", + "name": "mc6", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "n7t59", + "name": "mc6h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "DiDY0", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$danger/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pS0jk", + "fill": "$danger/soft-foreground", + "content": "AR", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "Z6oyo", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "XwZbc", + "name": "mc6Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "cCYEd", + "fill": "$foreground/foreground", + "content": "ARK: Survival Evolved", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "dfWh4", + "fill": "$muted", + "content": "v0.9.2", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "nGV9x", + "name": "mc6Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "eAwzD", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "JuN3U", + "fill": "$muted", + "content": "sha256:b7440ce1…05af", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "jis3O", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "YXrSG", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "Fjn4y", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + }, + { + "id": "Pz6cy", + "type": "ref", + "ref": "Y43SFn", + "name": "cat PvP", + "descendants": { + "r4GQ9": { + "content": "PvP" + } + } + } + ] + }, + { + "type": "text", + "id": "XHmii", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Dinosaur survival on the Unreal engine. Mod install via the Steam workshop; schedule onto a GPU node for acceptable tick rates.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "ndlqp", + "name": "mc6f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "x8peT", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "MJCDK", + "name": "mc6Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "MVzKP", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "WamPF", + "name": "mc7", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "tJKeT", + "name": "mc7h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "qOirq", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$success/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "F0hOT", + "fill": "$success/soft-foreground", + "content": "TE", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "QyR5D", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "A3sspS", + "name": "mc7Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "KXJuU", + "fill": "$foreground/foreground", + "content": "Terraria", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "QKgoR", + "fill": "$muted", + "content": "v2.1.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "asFjn", + "name": "mc7Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "mdmwx", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "Krpj9", + "fill": "$muted", + "content": "sha256:c805d7f2…46e9", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Bb4Tn", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "ZRMPP", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + }, + { + "id": "slB2u", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "Ry6I9", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Adventure", + "descendants": { + "r4GQ9": { + "content": "Adventure" + } + } + } + ] + }, + { + "type": "text", + "id": "RRWKb", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Terraria + tModLoader dedicated server (passivelemon/terraria-docker) with a vanilla/tModLoader version picker and a tModLoader ModPacks volume. Console via container stdin.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "XdW5u", + "name": "mc7f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "BrXvJ", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "GbpER", + "name": "mc7Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "CxB0Y", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to east" + } + } + }, + { + "id": "zkOJa", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "g8SEAP", + "name": "mc8", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "qY5oM", + "name": "mc8h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ejhb3", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$warning/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cMKUu", + "fill": "$warning/soft-foreground", + "content": "CO", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "HDUbZ", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "Ftwt2", + "name": "mc8Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "pPpkZ", + "fill": "$foreground/foreground", + "content": "Counter-Strike 2", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "pT7nq", + "fill": "$muted", + "content": "v0.4.1", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "OhBMN", + "name": "mc8Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "fJTiv", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "uPjgV", + "fill": "$muted", + "content": "sha256:1a6be390…f72c", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "RfdEt", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "ys0Z7", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Shooter", + "descendants": { + "r4GQ9": { + "content": "Shooter" + } + } + }, + { + "id": "apdY4", + "type": "ref", + "ref": "Y43SFn", + "name": "cat PvP", + "descendants": { + "r4GQ9": { + "content": "PvP" + } + } + } + ] + }, + { + "type": "text", + "id": "OvL8k", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Competitive shooter with MatchZy and CounterStrikeSharp plugin support. Game-server login token supplied as a secret.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Lyfuy", + "name": "mc8f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pnnS5", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "a726o", + "name": "mc8Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "uzcks", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "WLFZc", + "x": 24, + "y": 861, + "name": "Row 3", + "enabled": false, + "width": 1132, + "height": 1, + "gap": 16, + "children": [ + { + "type": "frame", + "id": "jthhr", + "name": "mc9", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "saPTN", + "name": "mc9h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "d4sg4a", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$danger/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f4C5ms", + "fill": "$danger/soft-foreground", + "content": "RU", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "zdIe4", + "name": "mc9Badges", + "gap": 6, + "alignItems": "center", + "children": [ + { + "id": "g6Qmwx", + "type": "ref", + "ref": "a3AjAZ", + "name": "Badge", + "descendants": { + "F9x0p": { + "type": "frame", + "id": "KTSSG", + "name": "IconLabel", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "DZlp8", + "width": 12, + "height": 12, + "icon": "shield-question", + "library": "lucide", + "fill": "$success/success" + }, + { + "type": "text", + "id": "oJrm9", + "name": "Label", + "fill": "$success/success", + "content": "POLICY", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + } + }, + { + "id": "CrFwU", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "g1RN2G", + "name": "mc9Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "U17Ikh", + "fill": "$foreground/foreground", + "content": "Rust", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "r6uNr3", + "fill": "$muted", + "content": "v0.7.3", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "D2mhK", + "name": "mc9Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "AGbwo", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "ls9wW", + "fill": "$muted", + "content": "sha256:3fd0817c…ba24", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "wQgao", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "t2qSb", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "S7U6hK", + "type": "ref", + "ref": "Y43SFn", + "name": "cat PvP", + "descendants": { + "r4GQ9": { + "content": "PvP" + } + } + }, + { + "id": "Y4jom", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Modded", + "descendants": { + "r4GQ9": { + "content": "Modded" + } + } + } + ] + }, + { + "type": "text", + "id": "xbXZf", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Hardcore multiplayer survival with Oxide and Carbon mod loaders. Monthly forced wipe; schedule a backup before each one.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "YU647", + "name": "mc9f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "l3RRM", + "fill": "$muted", + "content": "community (oci)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "niThi", + "name": "mc9Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "NyJPf", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "V1ZI1I", + "name": "mc10", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "R0vvsr", + "name": "mc10h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "CpEVl", + "name": "tile", + "width": 36, + "height": 36, + "fill": "#9494944D", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "J2XKZ2", + "fill": "$muted", + "content": "7-", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "EbCy6", + "type": "ref", + "ref": "KmZnF", + "name": "Badge", + "descendants": { + "xZ9ah": { + "content": "AVAILABLE", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$muted" + } + } + } + ] + }, + { + "type": "frame", + "id": "nV7q0", + "name": "mc10Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "SzaPV", + "fill": "$foreground/foreground", + "content": "7 Days to Die", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "lGiWo", + "fill": "$muted", + "content": "v0.5.0", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "o6F6Q", + "name": "mc10Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "NTdlH", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "nEZCx", + "fill": "$muted", + "content": "sha256:e2c93b58…7a0d", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "H4BCKr", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "tJ6b2", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Survival", + "descendants": { + "r4GQ9": { + "content": "Survival" + } + } + }, + { + "id": "tPBQT", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Horror", + "descendants": { + "r4GQ9": { + "content": "Horror" + } + } + }, + { + "id": "LPhof", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + } + ] + }, + { + "type": "text", + "id": "r7qr7", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Zombie survival crafting in a destructible voxel world. Blood-moon horde nights are CPU-spiky — give it headroom.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "h5V6fW", + "name": "mc10f", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Od5rd", + "fill": "$muted", + "content": "community (oci)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "D6YZj", + "name": "mc10Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "z7Ywcw", + "type": "ref", + "ref": "j9c5W", + "name": "btnInstall", + "descendants": { + "n8Hmwk": { + "icon": "download" + }, + "U1xvSo": { + "content": "Install" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "XuDAZ", + "name": "mc11", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 10, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "n5ZozJ", + "name": "mc11h", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "r8s2R4", + "name": "tile", + "width": 36, + "height": 36, + "fill": "$accent/soft", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mDQZ9", + "fill": "$accent/accent", + "content": "SA", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "rHKzb", + "type": "ref", + "ref": "UOoQW", + "name": "Badge", + "descendants": { + "YXoPG": { + "content": "INSTALLED", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$success/soft-foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "Bmq8H", + "name": "mc11Info", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "SERSW", + "fill": "$foreground/foreground", + "content": "Satisfactory (Dedicated)", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "nNQib", + "fill": "$muted", + "content": "v1.0.0 installed", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "T9fUSf", + "name": "mc11Digest", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "F435la", + "width": 12, + "height": 12, + "icon": "fingerprint", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "dDRIP", + "fill": "$muted", + "content": "sha256:4b7ecf19…d3e6", + "fontFamily": "$typography/font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "N9m6e", + "name": "Categories", + "width": "fill_container", + "gap": 6, + "children": [ + { + "id": "s6rHyX", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Simulation", + "descendants": { + "r4GQ9": { + "content": "Simulation" + } + } + }, + { + "id": "PHNkq", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Building", + "descendants": { + "r4GQ9": { + "content": "Building" + } + } + }, + { + "id": "kWbw2", + "type": "ref", + "ref": "Y43SFn", + "name": "cat Sandbox", + "descendants": { + "r4GQ9": { + "content": "Sandbox" + } + } + } + ] + }, + { + "type": "text", + "id": "C9af8t", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Satisfactory dedicated server (wolveix/satisfactory-server) with a stable/experimental branch picker. Env-configured; installs via steamcmd on first boot.", + "lineHeight": 1.4, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "M4IPNC", + "name": "mc11f", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "Hk6v6", + "fill": "$muted", + "content": "default (git)", + "fontFamily": "$typography/font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "mXitr", + "name": "mc11Btns", + "gap": 4, + "alignItems": "center", + "children": [ + { + "id": "Ez1K6", + "type": "ref", + "ref": "FIB65", + "name": "btnDeploy", + "descendants": { + "K8d1zg": { + "icon": "external-link" + }, + "zMNkR": { + "content": "Deploy to east" + } + } + }, + { + "id": "OcY4I", + "type": "ref", + "ref": "FIB65", + "name": "btnUninstall", + "descendants": { + "K8d1zg": { + "enabled": false + }, + "zMNkR": { + "content": "Uninstall" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "tOwOm", + "name": "gridFiller", + "width": "fill_container", + "height": 1 + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "aMU7O", + "type": "ref", + "ref": "x3beP", + "name": "Gameplane/Dialog/Edit user — Standalone (No target)", + "x": -15757, + "y": 34832, + "theme": { + "semantic": "dark" + }, + "width": 480, + "context": "PR #630. Only registered remotes appear as workload grant targets. Primary binding is panel access. workload-viewer is a sample role containing workload permissions only. All namespaces must reject roles containing central administration permissions; Add is disabled for unknown or missing targets.", + "descendants": { + "E7Whx": { + "content": "Edit user" + }, + "qzcst": { + "content": "operator-01" + }, + "Nht52": { + "type": "frame", + "id": "yHFNV", + "name": "User fields and remote grants", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "frame", + "id": "dHAV6", + "name": "Display name field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "u7sEw", + "name": "Display name label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Display name", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "pPOJS", + "type": "ref", + "ref": "D0cDM", + "name": "Display name value", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "Server Operator", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "xUDS9", + "name": "Email field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "k4bn3", + "name": "Email label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Email", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "qCJUr", + "type": "ref", + "ref": "D0cDM", + "name": "Email value", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "operator@example.test", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "jkYvt", + "name": "Primary role (cluster-wide) field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "PioTM", + "name": "Primary role (cluster-wide) label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Primary role (cluster-wide)", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "tWpbc", + "type": "ref", + "ref": "AT7ya", + "name": "Primary role (cluster-wide) value", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "operator", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "KesHu", + "name": "Grant separator", + "width": "fill_container", + "height": 1, + "fill": "$border/border" + }, + { + "type": "text", + "id": "lByLy", + "name": "Grant heading", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "CLUSTER & NAMESPACE GRANTS", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "cMoFK", + "name": "Grant scope explanation", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The primary role above manages panel access. Additional grants apply only to their named cluster and namespace.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "kCgJB", + "name": "Primary panel binding", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "operator in all namespaces on panel\nPrimary role (managed above)", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "iiSDM", + "name": "Remote target and role", + "width": "fill_container", + "gap": 8, + "children": [ + { + "id": "dqqi3", + "type": "ref", + "ref": "AT7ya", + "name": "Grant cluster", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "Select a workload cluster" + } + } + }, + { + "id": "tInht", + "type": "ref", + "ref": "AT7ya", + "name": "Grant role", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "workload-viewer" + } + } + } + ] + }, + { + "type": "frame", + "id": "lmxYz", + "name": "Namespace grant actions", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "hJb3f", + "type": "ref", + "ref": "D0cDM", + "name": "Grant namespace", + "width": "fill_container", + "height": 32, + "descendants": { + "PJERm": { + "content": "namespace" + } + } + }, + { + "id": "F0krw", + "type": "ref", + "ref": "i6gfu", + "name": "All namespaces", + "opacity": 0.5, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "All namespaces" + } + } + }, + { + "id": "g1QLQ", + "type": "ref", + "ref": "i6gfu", + "name": "Add", + "opacity": 0.5, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Add" + } + } + } + ] + }, + { + "type": "text", + "id": "wT2ls", + "name": "Inventory grant guidance", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "For remote node inventory, create a role in the Roles tab with “View nodes, version, and storage”, then grant it to all namespaces on that cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + "V7HYp": { + "opacity": 1 + }, + "TZ7Ef": { + "content": "Save changes" + } + } + }, + { + "id": "C9bR6", + "type": "ref", + "ref": "x3beP", + "name": "Gameplane/Dialog/Edit user — Standalone (Remote grant)", + "x": -14157, + "y": 34832, + "theme": { + "semantic": "dark" + }, + "width": 480, + "context": "PR #630. Only registered remotes appear as workload grant targets. Primary binding is panel access. workload-viewer is a sample role containing workload permissions only. All namespaces must reject roles containing central administration permissions; Add is disabled for unknown or missing targets.", + "descendants": { + "E7Whx": { + "content": "Edit user" + }, + "qzcst": { + "content": "operator-01" + }, + "Nht52": { + "type": "frame", + "id": "VTnn4", + "name": "User fields and remote grants", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "frame", + "id": "cvRRo", + "name": "Display name field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "daXHP", + "name": "Display name label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Display name", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "bnmSQ", + "type": "ref", + "ref": "D0cDM", + "name": "Display name value", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "Server Operator", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "GQKvB", + "name": "Email field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "E68vT", + "name": "Email label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Email", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "dlcki", + "type": "ref", + "ref": "D0cDM", + "name": "Email value", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "operator@example.test", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "YGebt", + "name": "Primary role (cluster-wide) field", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "fKaDX", + "name": "Primary role (cluster-wide) label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Primary role (cluster-wide)", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "NDN3M", + "type": "ref", + "ref": "AT7ya", + "name": "Primary role (cluster-wide) value", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "operator", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "WKq0E", + "name": "Grant separator", + "width": "fill_container", + "height": 1, + "fill": "$border/border" + }, + { + "type": "text", + "id": "z3zvqX", + "name": "Grant heading", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "CLUSTER & NAMESPACE GRANTS", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ib7po", + "name": "Grant scope explanation", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The primary role above manages panel access. Additional grants apply only to their named cluster and namespace.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "TMJ5E", + "name": "Primary panel binding", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "operator in all namespaces on panel\nPrimary role (managed above)", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "nEG0s", + "name": "Remote target and role", + "width": "fill_container", + "gap": 8, + "children": [ + { + "id": "g3poUP", + "type": "ref", + "ref": "AT7ya", + "name": "Grant cluster", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "East production (east)" + } + } + }, + { + "id": "Qnadg", + "type": "ref", + "ref": "AT7ya", + "name": "Grant role", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "workload-viewer" + } + } + } + ] + }, + { + "type": "frame", + "id": "JnTPd", + "name": "Namespace grant actions", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "zj1aN", + "type": "ref", + "ref": "D0cDM", + "name": "Grant namespace", + "width": "fill_container", + "height": 32, + "descendants": { + "PJERm": { + "content": "*" + } + } + }, + { + "id": "k0ziBt", + "type": "ref", + "ref": "i6gfu", + "name": "All namespaces", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "All namespaces" + } + } + }, + { + "id": "i6XYjB", + "type": "ref", + "ref": "i6gfu", + "name": "Add", + "opacity": 1, + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Add" + } + } + } + ] + }, + { + "type": "text", + "id": "Z3OG8d", + "name": "Inventory grant guidance", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "For remote node inventory, create a role in the Roles tab with “View nodes, version, and storage”, then grant it to all namespaces on that cluster.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + "V7HYp": { + "opacity": 1 + }, + "TZ7Ef": { + "content": "Save changes" + } + } + }, + { + "type": "frame", + "id": "w34N9", + "x": -17357, + "y": 36232, + "name": "Screen/Standalone — Dashboard (No workload clusters)", + "context": "PR #630; successful empty fleet responses. Central audit is still authorized independently of workload clusters. No local inventory or cluster-dependent create action is available.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1150, + "fill": "$background/background", + "children": [ + { + "id": "v5wvl4", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "BuhAS": { + "content": "Gameplane" + } + } + }, + { + "type": "frame", + "id": "q6N7y", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "Y2kiaV", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gb27k": { + "fontFamily": "$typography/font-sans" + } + } + }, + { + "type": "frame", + "id": "UuBhM", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 32, + "children": [ + { + "id": "A1SP8", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "VbHwU": { + "content": "Your game servers and resources across authorized locations." + }, + "TgdLz": { + "enabled": false, + "width": 1, + "height": 1, + "children": [ + { + "id": "eYdj6", + "type": "ref", + "ref": "tpKRk", + "name": "Create Server Button", + "descendants": { + "q58In/Xpn7K": { + "library": "lucide", + "icon": "plus" + }, + "q58In/U6q13B": { + "content": "Create server" + } + } + } + ] + } + } + }, + { + "id": "HXGfS", + "type": "ref", + "ref": "AT7ya", + "name": "Dashboard location filter", + "width": 240, + "context": "Optional location filter; All locations combines authorized inventory.", + "descendants": { + "cFsFk": { + "content": "All locations", + "fill": "$foreground/foreground" + } + } + }, + { + "id": "rlQYA", + "type": "ref", + "ref": "YF85x", + "name": "Stats Row", + "width": "fill_container", + "descendants": { + "m20CHt/QdcEm": { + "fill": "#000000" + }, + "m20CHt/vj2Ru": { + "content": "0" + }, + "m20CHt/h0n7zo": { + "content": "of 0 total" + }, + "T0vNbN/vj2Ru": { + "content": "0" + }, + "T0vNbN/h0n7zo": { + "content": "peak 0" + }, + "pkvT2/QdcEm": { + "fill": "$chart/stat" + }, + "pkvT2/vj2Ru": { + "content": "—" + }, + "pkvT2/h0n7zo": { + "content": "authorized inventory cores" + }, + "f77w2/vj2Ru": { + "content": "—" + }, + "f77w2/h0n7zo": { + "content": "inventory unavailable" + }, + "FlXsH/QdcEm": { + "fill": "$chart/stat" + }, + "FlXsH/vj2Ru": { + "content": "—" + }, + "FlXsH/h0n7zo": { + "content": "no node data" + } + } + }, + { + "type": "frame", + "id": "f4fqgS", + "name": "Row2", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "v5Iz4", + "name": "Fleet Status Card", + "width": "fill_container", + "height": 372, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "vGIhs", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "K0OUb", + "name": "title", + "fill": "$foreground/foreground", + "content": "Fleet status", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "juhsa", + "name": "link", + "fill": "$muted", + "content": "0 servers", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "g74KcN", + "name": "Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "p2hqGo", + "name": "Health Bar", + "clip": true, + "width": "fill_container", + "height": 8, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "id": "hk791", + "x": 0, + "y": 0, + "name": "Running Segment", + "enabled": false, + "fill": "$success/success", + "width": 1, + "height": 8 + } + ] + }, + { + "type": "frame", + "id": "dmVSG", + "name": "Legend", + "gap": 20, + "children": [ + { + "type": "frame", + "id": "SgRIo", + "name": "legend-Running 1", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "swU3B", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "Lb2vq", + "name": "label", + "fill": "$muted", + "content": "Running 0", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "MjbJw", + "name": "legend-Stopped 0", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "y2RUy", + "name": "dot", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "l2BXOh", + "name": "label", + "fill": "$muted", + "content": "Stopped 0", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "s7xpZc", + "name": "legend-Failed 0", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "BVMtu", + "name": "dot", + "fill": "$danger/danger", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "UQrlo", + "name": "label", + "fill": "$muted", + "content": "Failed 0", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "W3zVRn", + "name": "Attention Section", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "text", + "id": "YiJ4T", + "name": "attnLabel", + "fill": "$muted", + "content": "NEEDS ATTENTION", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "500", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "aifto", + "name": "healthyRow", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "DNys1", + "name": "check", + "width": 16, + "height": 16, + "icon": "circle-check", + "library": "lucide", + "fill": "$success/success" + }, + { + "type": "text", + "id": "cZoEg", + "name": "healthyLabel", + "fill": "$muted", + "content": "Everything looks healthy.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "czOM1", + "name": "Cluster resources", + "width": "fill_container", + "height": 372, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "wboXa", + "name": "Cluster resources header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "KS89o", + "name": "Card title", + "fill": "$foreground/foreground", + "content": "Cluster resources", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "UcF7u", + "name": "No registered workload location", + "fill": "$muted", + "content": "—", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "mueaU", + "name": "CPU meter", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "frame", + "id": "MqVSU", + "name": "CPU values", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "LgrxO", + "name": "Resource name", + "fill": "$muted", + "content": "CPU", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ftItm", + "name": "Unavailable reading", + "fill": "$foreground/foreground", + "content": "—", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "RomqQ", + "name": "CPU empty track", + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999 + } + ] + }, + { + "type": "frame", + "id": "WVquz", + "name": "Memory meter", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "frame", + "id": "KN0ld", + "name": "Memory values", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "zMf52", + "name": "Resource name", + "fill": "$muted", + "content": "Memory", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "wBK9q", + "name": "Unavailable reading", + "fill": "$foreground/foreground", + "content": "—", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "DdUUw", + "name": "Memory empty track", + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999 + } + ] + }, + { + "type": "frame", + "id": "D5BQN8", + "name": "Storage meter", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "frame", + "id": "xVsFF", + "name": "Storage values", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "wwX0F", + "name": "Resource name", + "fill": "$muted", + "content": "Storage", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "fA9Kp", + "name": "Unavailable reading", + "fill": "$foreground/foreground", + "content": "—", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "FFws4", + "name": "Storage empty track", + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999 + }, + { + "type": "text", + "id": "y97ogA", + "name": "Storage availability", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "inventory unavailable", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "DEkoJ", + "name": "Row3", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "mTosb", + "name": "Central audit activity", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "CP6C3", + "name": "Central audit scope", + "fill": "$muted", + "content": "Central Gameplane audit activity", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "vaVan", + "name": "Recent Activity Card", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "RS9aM", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pXAT5", + "name": "title", + "fill": "$foreground/foreground", + "content": "Recent activity", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "NfMjL", + "name": "link", + "fill": "$accent/accent", + "content": "View all", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "I1LzJ", + "name": "Empty audit content", + "width": "fill_container", + "height": 290, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "MRJ7O", + "name": "No activity", + "fill": "$muted", + "content": "No recent activity.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "d0DCd", + "name": "Recent Backups Card", + "width": "fill_container", + "height": 353, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "dzlc5", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "LaUmt", + "name": "title", + "fill": "$foreground/foreground", + "content": "Recent backups", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "VNlOb", + "name": "link", + "fill": "$accent/accent", + "content": "View all", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "utnZA", + "name": "Content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [ + 40, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "XmWeX", + "name": "empty", + "fill": "$muted", + "content": "No backups yet.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "U9xSg", "x": 24240, "y": 9175, "name": "Screen/Server Detail — Players (Nuclear Option)", @@ -129844,7 +142044,7 @@ "fill": "$background/background", "children": [ { - "id": "OgNOa", + "id": "kb3SP", "type": "ref", "ref": "kKFX9", "name": "App Sidebar", @@ -129872,7 +142072,7 @@ }, { "type": "frame", - "id": "Rp6DU", + "id": "ORz62", "name": "Main", "width": "fill_container", "height": "fill_container", @@ -129880,7 +142080,7 @@ "layout": "vertical", "children": [ { - "id": "YvEX0", + "id": "qCr4C", "type": "ref", "ref": "gu5WY", "name": "Top Bar", @@ -129892,14 +142092,14 @@ } }, { - "id": "ziiI3", + "id": "y982W", "type": "ref", "ref": "S4k0x", "name": "Detail Header", "width": "fill_container" }, { - "id": "CZGgf", + "id": "bgz6L", "type": "ref", "ref": "I9kvlZ", "name": "Tabs Bar", @@ -129925,7 +142125,7 @@ }, { "type": "frame", - "id": "kSV4O", + "id": "lbWu7", "name": "Body", "width": "fill_container", "height": "fill_container", @@ -129936,13 +142136,13 @@ "children": [ { "type": "frame", - "id": "qaOgu", + "id": "RA5cq", "name": "Stat Cards Row", "width": "fill_container", "gap": 12, "children": [ { - "id": "WzVvI", + "id": "avKiV", "type": "ref", "ref": "ZWcwn", "name": "Stat Online", @@ -129966,7 +142166,7 @@ } }, { - "id": "e6vjE", + "id": "vEoPm", "type": "ref", "ref": "ZWcwn", "name": "Stat Whitelisted", @@ -129990,7 +142190,7 @@ } }, { - "id": "t6W6Ha", + "id": "K8TBF", "type": "ref", "ref": "ZWcwn", "name": "Stat Banned", @@ -130017,7 +142217,7 @@ }, { "type": "frame", - "id": "Fd8Uh", + "id": "a8WBd", "name": "Online Header", "width": "fill_container", "justifyContent": "space_between", @@ -130025,7 +142225,7 @@ "children": [ { "type": "text", - "id": "hq3TE", + "id": "uKEOF", "name": "onlineCount", "fill": "$muted", "content": "3 / 16 online", @@ -130034,7 +142234,7 @@ "fontWeight": "normal" }, { - "id": "uZt1s", + "id": "tskEG", "type": "ref", "ref": "J09iP", "name": "Btn Refresh", @@ -130051,7 +142251,7 @@ }, { "type": "frame", - "id": "FFLq9", + "id": "Y1qaR", "name": "Player Rows", "width": "fill_container", "layout": "vertical", @@ -130059,7 +142259,7 @@ "children": [ { "type": "frame", - "id": "CU5YB", + "id": "zs5Pr", "name": "Player Row — Pilot_Vance", "width": "fill_container", "fill": "$surface/surface", @@ -130077,14 +142277,14 @@ "children": [ { "type": "frame", - "id": "U4JCYZ", + "id": "SfNlc", "name": "Identity", "gap": 12, "alignItems": "center", "children": [ { "type": "text", - "id": "jazOT", + "id": "b3MDFM", "name": "Player Name", "fill": "$foreground/foreground", "content": "Pilot_Vance", @@ -130094,7 +142294,7 @@ }, { "type": "frame", - "id": "o4KOO", + "id": "r19BYn", "name": "Faction Chip", "fill": "$accent/soft", "cornerRadius": 999, @@ -130105,7 +142305,7 @@ "children": [ { "type": "text", - "id": "g29s1", + "id": "ul0k3", "name": "Faction", "fill": "$accent/soft-foreground", "content": "Boscali", @@ -130119,13 +142319,13 @@ }, { "type": "frame", - "id": "lcV26", + "id": "g7WUX", "name": "Actions", "gap": 4, "children": [ { "type": "icon", - "id": "QHLxS", + "id": "voATz", "name": "Kick", "width": 16, "height": 16, @@ -130135,7 +142335,7 @@ }, { "type": "icon", - "id": "Yg6O6", + "id": "tbcjp", "name": "Ban", "width": 16, "height": 16, @@ -130149,7 +142349,7 @@ }, { "type": "frame", - "id": "n4v2g", + "id": "xqdRG", "name": "Player Row — 76561198012345678", "width": "fill_container", "fill": "$surface/surface", @@ -130167,14 +142367,14 @@ "children": [ { "type": "frame", - "id": "A5Zm3", + "id": "O3r5Kq", "name": "Identity", "gap": 12, "alignItems": "center", "children": [ { "type": "text", - "id": "y5jU7", + "id": "LDVpd", "name": "Player Name", "fill": "$muted", "content": "76561198012345678", @@ -130184,7 +142384,7 @@ }, { "type": "frame", - "id": "XA9al", + "id": "MekUX", "name": "Faction Chip", "fill": "$warning/soft", "cornerRadius": 999, @@ -130195,7 +142395,7 @@ "children": [ { "type": "text", - "id": "c4KnH", + "id": "cHWED", "name": "Faction", "fill": "$warning/soft-foreground", "content": "Primeva", @@ -130209,13 +142409,13 @@ }, { "type": "frame", - "id": "FcXFL", + "id": "IjDVg", "name": "Actions", "gap": 4, "children": [ { "type": "icon", - "id": "bNGuv", + "id": "gGdB5", "name": "Kick", "width": 16, "height": 16, @@ -130225,7 +142425,7 @@ }, { "type": "icon", - "id": "rW6uh", + "id": "o0R69j", "name": "Ban", "width": 16, "height": 16, @@ -130239,7 +142439,7 @@ }, { "type": "frame", - "id": "BZT9r", + "id": "EDOQ8", "name": "Player Row — Corvus", "width": "fill_container", "fill": "$surface/surface", @@ -130257,14 +142457,14 @@ "children": [ { "type": "frame", - "id": "fdtbM", + "id": "wb55l", "name": "Identity", "gap": 12, "alignItems": "center", "children": [ { "type": "text", - "id": "WWbxK", + "id": "NwVkL", "name": "Player Name", "fill": "$foreground/foreground", "content": "Corvus", @@ -130276,13 +142476,13 @@ }, { "type": "frame", - "id": "NKkGP", + "id": "cjUkD", "name": "Actions", "gap": 4, "children": [ { "type": "icon", - "id": "eRiYf", + "id": "uf3OA", "name": "Kick", "width": 16, "height": 16, @@ -130292,7 +142492,7 @@ }, { "type": "icon", - "id": "r67gbD", + "id": "P53mLy", "name": "Ban", "width": 16, "height": 16, @@ -130308,14 +142508,14 @@ }, { "type": "frame", - "id": "VJXco", + "id": "OJpx1", "name": "Whitelist Section", "width": "fill_container", "layout": "vertical", "children": [ { "type": "frame", - "id": "g6KG4P", + "id": "MG8Zl", "name": "Whitelist Header", "width": "fill_container", "stroke": "$border/border", @@ -130325,7 +142525,7 @@ "children": [ { "type": "icon", - "id": "M7drTR", + "id": "C5nK6", "name": "Whitelist Chevron", "width": 12, "height": 12, @@ -130335,7 +142535,7 @@ }, { "type": "icon", - "id": "Vlwun", + "id": "Y694nU", "name": "Whitelist Icon", "width": 12, "height": 12, @@ -130345,7 +142545,7 @@ }, { "type": "text", - "id": "Ldhz8", + "id": "ICjzI", "name": "Whitelist Label", "fill": "$muted", "content": "Whitelist (1)", @@ -130359,14 +142559,14 @@ }, { "type": "frame", - "id": "c7d0b", + "id": "whI8w", "name": "Banned Section", "width": "fill_container", "layout": "vertical", "children": [ { "type": "frame", - "id": "k0OT6l", + "id": "khA99", "name": "Banned Header", "width": "fill_container", "stroke": "$border/border", @@ -130376,7 +142576,7 @@ "children": [ { "type": "icon", - "id": "aNE27", + "id": "xWJbE", "name": "Banned Chevron", "width": 12, "height": 12, @@ -130386,7 +142586,7 @@ }, { "type": "text", - "id": "TjQ06", + "id": "aFc8A", "name": "Banned Label", "fill": "$muted", "content": "Banned (0)", @@ -130406,7 +142606,7 @@ }, { "type": "frame", - "id": "h5clan", + "id": "BguST", "x": 43120, "y": 11355, "name": "Screen/Server Detail — Settings · Game configuration (Password will be removed)", @@ -130419,10 +142619,11 @@ "fill": "$background/background", "children": [ { - "id": "W2XAo", + "id": "LuUN8", "type": "ref", "ref": "kKFX9", "height": "fill_container", + "name": "Restored ref", "descendants": { "iTbd8": { "fill": "#00000000" @@ -130446,7 +142647,7 @@ }, { "type": "frame", - "id": "z9d3P", + "id": "aoYjR", "name": "Main", "width": "fill_container", "height": "fill_container", @@ -130454,10 +142655,11 @@ "layout": "vertical", "children": [ { - "id": "l19Id", + "id": "qVEH5", "type": "ref", "ref": "gu5WY", "width": "fill_container", + "name": "Restored ref", "descendants": { "Gl7AY": { "content": "mc-survival" @@ -130466,7 +142668,7 @@ }, { "type": "frame", - "id": "fDTIu", + "id": "j6OM5", "name": "Body", "width": "fill_container", "height": "fill_container", @@ -130476,14 +142678,14 @@ "padding": 24, "children": [ { - "id": "F25Cp", + "id": "B5Wr9y", "type": "ref", "ref": "S4k0x", "name": "Detail Header", "width": "fill_container" }, { - "id": "EPBOZ", + "id": "pMkLK", "type": "ref", "ref": "I9kvlZ", "name": "Tabs Bar", @@ -130511,13 +142713,13 @@ }, { "type": "frame", - "id": "rgg36", + "id": "EKV1P", "name": "Settings Content", "width": "fill_container", "height": "fill_container", "children": [ { - "id": "NhLDd", + "id": "NVuLp", "type": "ref", "ref": "EV8mp", "name": "Settings Sub Nav", @@ -130642,7 +142844,7 @@ }, { "type": "frame", - "id": "eVucv", + "id": "aBxfM", "name": "Settings Panel Wrap", "width": "fill_container", "height": "fill_container", @@ -130650,7 +142852,7 @@ "children": [ { "type": "frame", - "id": "J2VnM", + "id": "WR2kZ", "name": "Settings Form", "width": "fill_container", "height": "fill_container", @@ -130658,7 +142860,7 @@ "children": [ { "type": "frame", - "id": "QuP2F", + "id": "EGZKP", "name": "Form Body", "width": "fill_container", "height": "fill_container", @@ -130668,7 +142870,7 @@ "children": [ { "type": "frame", - "id": "S5mGZ", + "id": "NruaW", "name": "Section Header", "width": "fill_container", "layout": "vertical", @@ -130676,7 +142878,7 @@ "children": [ { "type": "text", - "id": "z5KwZN", + "id": "Y52c9", "name": "Section Title", "fill": "$foreground/foreground", "content": "Game configuration", @@ -130686,7 +142888,7 @@ }, { "type": "text", - "id": "SICLk", + "id": "D4JKs", "name": "Section Description", "fill": "$muted", "content": "Settings from this server's game template. Saving restarts the server.", @@ -130697,7 +142899,7 @@ ] }, { - "id": "i5kSI", + "id": "c0F4nN", "type": "ref", "ref": "uJ3KZ", "name": "Restart Alert", @@ -130713,14 +142915,14 @@ }, { "type": "frame", - "id": "D2XSw", + "id": "AiAbk", "name": "Field World name", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "aLFDZ", + "id": "wXEkG", "name": "lbl", "width": 200, "layout": "vertical", @@ -130728,7 +142930,7 @@ "children": [ { "type": "text", - "id": "wa9uX", + "id": "agibC", "name": "Label", "fill": "$foreground/foreground", "content": "World name", @@ -130740,14 +142942,14 @@ }, { "type": "frame", - "id": "pHuja", + "id": "Bv61i", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "Etrig", + "id": "VXtHn", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -130765,14 +142967,14 @@ }, { "type": "frame", - "id": "OwRSn", + "id": "V0JLk", "name": "Field Difficulty", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "vmud3", + "id": "Nx9st", "name": "lbl", "width": 200, "layout": "vertical", @@ -130780,7 +142982,7 @@ "children": [ { "type": "text", - "id": "vToUF", + "id": "n2J6yD", "name": "Label", "fill": "$foreground/foreground", "content": "Difficulty", @@ -130792,14 +142994,14 @@ }, { "type": "frame", - "id": "M54st4", + "id": "jYBxI", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "Dt1fG", + "id": "RuWUk", "type": "ref", "ref": "AT7ya", "name": "Select", @@ -130820,14 +143022,14 @@ }, { "type": "frame", - "id": "SzlMN", + "id": "g4qdfR", "name": "Field Max players", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "e50P75", + "id": "mxs97", "name": "lbl", "width": 200, "layout": "vertical", @@ -130835,7 +143037,7 @@ "children": [ { "type": "text", - "id": "r8ZQWt", + "id": "f3RaF", "name": "Label", "fill": "$foreground/foreground", "content": "Max players", @@ -130847,14 +143049,14 @@ }, { "type": "frame", - "id": "X9Pvl", + "id": "bDhXp", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "s43pHd", + "id": "o8uLNg", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -130872,14 +143074,14 @@ }, { "type": "frame", - "id": "X0E8H", + "id": "OYF4O", "name": "Field PvP", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "kSnF3", + "id": "rh0jC", "name": "lbl", "width": 200, "layout": "vertical", @@ -130887,7 +143089,7 @@ "children": [ { "type": "text", - "id": "j4lfR", + "id": "mmwLw", "name": "Label", "fill": "$foreground/foreground", "content": "PvP", @@ -130899,14 +143101,14 @@ }, { "type": "frame", - "id": "AwdJJ", + "id": "RrUyM", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "W0Foz", + "id": "KdXb9", "type": "ref", "ref": "AT7ya", "name": "Select", @@ -130927,14 +143129,14 @@ }, { "type": "frame", - "id": "sKu9v", + "id": "KhtIr", "name": "Field Server password", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "Kz4wH", + "id": "g0AGoR", "name": "lbl", "width": 200, "layout": "vertical", @@ -130942,7 +143144,7 @@ "children": [ { "type": "text", - "id": "TxWfa", + "id": "o2MKya", "name": "Label", "fill": "$foreground/foreground", "content": "Server password", @@ -130954,7 +143156,7 @@ }, { "type": "frame", - "id": "xC231", + "id": "LtCR3", "name": "ctrl", "width": "fill_container", "layout": "vertical", @@ -130962,14 +143164,14 @@ "children": [ { "type": "frame", - "id": "pkqQ8", + "id": "swOk7", "name": "Input row", "width": "fill_container", "gap": 8, "alignItems": "center", "children": [ { - "id": "L9xAIS", + "id": "kxEL9", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -130983,7 +143185,7 @@ } }, { - "id": "FUHzm", + "id": "JkJXW", "type": "ref", "ref": "J09iP", "name": "Remove password button", @@ -130997,7 +143199,7 @@ }, { "type": "text", - "id": "Eb0j0", + "id": "JWDSU", "name": "Helper", "fill": "$danger/danger", "content": "Removed when you save. Undo to keep the stored password.", @@ -131011,14 +143213,14 @@ }, { "type": "frame", - "id": "ITufX", + "id": "feyyB", "name": "Field MOTD", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "j4C93", + "id": "LPPW6", "name": "lbl", "width": 200, "layout": "vertical", @@ -131026,7 +143228,7 @@ "children": [ { "type": "text", - "id": "J5PPz", + "id": "oFik0", "name": "Label", "fill": "$foreground/foreground", "content": "MOTD", @@ -131038,14 +143240,14 @@ }, { "type": "frame", - "id": "K3aqU", + "id": "z7eB6e", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "Gotg9", + "id": "QMXDP", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -131059,7 +143261,7 @@ }, { "type": "text", - "id": "GPjVy", + "id": "m0bl6", "name": "Helper", "fill": "$muted", "content": "Written to serverconfig.txt", @@ -131075,7 +143277,7 @@ }, { "type": "frame", - "id": "TY24A", + "id": "n007q", "name": "Form Footer", "width": "fill_container", "stroke": "$border/border", @@ -131093,7 +143295,7 @@ "alignItems": "center", "children": [ { - "id": "e35wn", + "id": "I0wipM", "type": "ref", "ref": "J09iP", "name": "Btn Discard", @@ -131104,7 +143306,7 @@ } }, { - "id": "S04hZr", + "id": "bAJYA", "type": "ref", "ref": "z9ShNE", "name": "Btn Save Changes", @@ -131133,7 +143335,7 @@ }, { "type": "frame", - "id": "ffmEd", + "id": "YN8cE", "x": 44640, "y": 11355, "name": "Screen/Server Detail — Settings · Game configuration (New password typed)", @@ -131146,10 +143348,11 @@ "fill": "$background/background", "children": [ { - "id": "R4f1Bd", + "id": "QC4QI", "type": "ref", "ref": "kKFX9", "height": "fill_container", + "name": "Restored ref", "descendants": { "iTbd8": { "fill": "#00000000" @@ -131173,7 +143376,7 @@ }, { "type": "frame", - "id": "gvBWy", + "id": "AwAAZ", "name": "Main", "width": "fill_container", "height": "fill_container", @@ -131181,10 +143384,11 @@ "layout": "vertical", "children": [ { - "id": "dwyWE", + "id": "z1zcAw", "type": "ref", "ref": "gu5WY", "width": "fill_container", + "name": "Restored ref", "descendants": { "Gl7AY": { "content": "mc-survival" @@ -131193,7 +143397,7 @@ }, { "type": "frame", - "id": "qtMLk", + "id": "JsrrP", "name": "Body", "width": "fill_container", "height": "fill_container", @@ -131203,14 +143407,14 @@ "padding": 24, "children": [ { - "id": "kaJiG", + "id": "dBiIp", "type": "ref", "ref": "S4k0x", "name": "Detail Header", "width": "fill_container" }, { - "id": "DVcnL", + "id": "yHNON", "type": "ref", "ref": "I9kvlZ", "name": "Tabs Bar", @@ -131238,13 +143442,13 @@ }, { "type": "frame", - "id": "x3y7b", + "id": "y9X6p", "name": "Settings Content", "width": "fill_container", "height": "fill_container", "children": [ { - "id": "OeKpX", + "id": "IxbPy", "type": "ref", "ref": "EV8mp", "name": "Settings Sub Nav", @@ -131369,7 +143573,7 @@ }, { "type": "frame", - "id": "UciMy", + "id": "B1JyQ", "name": "Settings Panel Wrap", "width": "fill_container", "height": "fill_container", @@ -131377,7 +143581,7 @@ "children": [ { "type": "frame", - "id": "y50jVK", + "id": "c9zM4k", "name": "Settings Form", "width": "fill_container", "height": "fill_container", @@ -131385,7 +143589,7 @@ "children": [ { "type": "frame", - "id": "KLlDA", + "id": "KGHPV", "name": "Form Body", "width": "fill_container", "height": "fill_container", @@ -131395,7 +143599,7 @@ "children": [ { "type": "frame", - "id": "zheQw", + "id": "AFwnw", "name": "Section Header", "width": "fill_container", "layout": "vertical", @@ -131403,7 +143607,7 @@ "children": [ { "type": "text", - "id": "WKlYQ", + "id": "bqAlw", "name": "Section Title", "fill": "$foreground/foreground", "content": "Game configuration", @@ -131413,7 +143617,7 @@ }, { "type": "text", - "id": "bNeAn", + "id": "n3asvo", "name": "Section Description", "fill": "$muted", "content": "Settings from this server's game template. Saving restarts the server.", @@ -131424,7 +143628,7 @@ ] }, { - "id": "frc5U", + "id": "dIZPs", "type": "ref", "ref": "uJ3KZ", "name": "Restart Alert", @@ -131440,14 +143644,14 @@ }, { "type": "frame", - "id": "sQG0D", + "id": "EDxrk", "name": "Field World name", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "PASr1", + "id": "E21gp0", "name": "lbl", "width": 200, "layout": "vertical", @@ -131455,7 +143659,7 @@ "children": [ { "type": "text", - "id": "hQD9M", + "id": "mxrxW", "name": "Label", "fill": "$foreground/foreground", "content": "World name", @@ -131467,14 +143671,14 @@ }, { "type": "frame", - "id": "TCJEz", + "id": "blKmQ", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "om1Ce", + "id": "S4Ryz", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -131492,14 +143696,14 @@ }, { "type": "frame", - "id": "ZG2Rl", + "id": "YrnLI", "name": "Field Difficulty", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "TvVIP", + "id": "ebygk", "name": "lbl", "width": 200, "layout": "vertical", @@ -131507,7 +143711,7 @@ "children": [ { "type": "text", - "id": "SSSuv", + "id": "ihoZt", "name": "Label", "fill": "$foreground/foreground", "content": "Difficulty", @@ -131519,14 +143723,14 @@ }, { "type": "frame", - "id": "Q6Hnqy", + "id": "O4oaS", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "q7ogrE", + "id": "qWlRT", "type": "ref", "ref": "AT7ya", "name": "Select", @@ -131547,14 +143751,14 @@ }, { "type": "frame", - "id": "T1v3SB", + "id": "BfNvQ", "name": "Field Max players", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "pTb09", + "id": "D9yRI", "name": "lbl", "width": 200, "layout": "vertical", @@ -131562,7 +143766,7 @@ "children": [ { "type": "text", - "id": "RdYMY", + "id": "r0dvK", "name": "Label", "fill": "$foreground/foreground", "content": "Max players", @@ -131574,14 +143778,14 @@ }, { "type": "frame", - "id": "jJMtS", + "id": "fGN7X", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "fU82D", + "id": "JSnMN", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -131599,14 +143803,14 @@ }, { "type": "frame", - "id": "ApFXd", + "id": "M7WyzF", "name": "Field PvP", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "L7UtB", + "id": "S7efIl", "name": "lbl", "width": 200, "layout": "vertical", @@ -131614,7 +143818,7 @@ "children": [ { "type": "text", - "id": "gbE51", + "id": "UCrrs", "name": "Label", "fill": "$foreground/foreground", "content": "PvP", @@ -131626,14 +143830,14 @@ }, { "type": "frame", - "id": "L68HA", + "id": "M3aHv", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "bDced", + "id": "wO3zr", "type": "ref", "ref": "AT7ya", "name": "Select", @@ -131654,14 +143858,14 @@ }, { "type": "frame", - "id": "w2nZcC", + "id": "xQ9Nz", "name": "Field Server password", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "JrKAC", + "id": "uD85J", "name": "lbl", "width": 200, "layout": "vertical", @@ -131669,7 +143873,7 @@ "children": [ { "type": "text", - "id": "qxcPJ", + "id": "vovRG", "name": "Label", "fill": "$foreground/foreground", "content": "Server password", @@ -131681,7 +143885,7 @@ }, { "type": "frame", - "id": "hmZjo", + "id": "mX0ac", "name": "ctrl", "width": "fill_container", "layout": "vertical", @@ -131689,14 +143893,14 @@ "children": [ { "type": "frame", - "id": "XbscA", + "id": "sAYIe", "name": "Input row", "width": "fill_container", "gap": 8, "alignItems": "center", "children": [ { - "id": "vmqyd", + "id": "FrryF", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -131712,7 +143916,7 @@ }, { "type": "text", - "id": "TNT4W", + "id": "q473dg", "name": "Helper", "fill": "$muted", "content": "Write-only", @@ -131726,14 +143930,14 @@ }, { "type": "frame", - "id": "j2XdIM", + "id": "NBrKm", "name": "Field MOTD", "width": "fill_container", "gap": 16, "children": [ { "type": "frame", - "id": "Cwah9", + "id": "aFMOo", "name": "lbl", "width": 200, "layout": "vertical", @@ -131741,7 +143945,7 @@ "children": [ { "type": "text", - "id": "vHFsM", + "id": "TRziH", "name": "Label", "fill": "$foreground/foreground", "content": "MOTD", @@ -131753,14 +143957,14 @@ }, { "type": "frame", - "id": "w7aQ3", + "id": "oUikf", "name": "ctrl", "width": "fill_container", "layout": "vertical", "gap": 6, "children": [ { - "id": "WzcYU", + "id": "z36WJ", "type": "ref", "ref": "D0cDM", "name": "Input", @@ -131774,7 +143978,7 @@ }, { "type": "text", - "id": "XbSxU", + "id": "L1jWH", "name": "Helper", "fill": "$muted", "content": "Written to serverconfig.txt", @@ -131790,7 +143994,7 @@ }, { "type": "frame", - "id": "uUZDu", + "id": "gkY8j", "name": "Form Footer", "width": "fill_container", "stroke": "$border/border", @@ -131808,7 +144012,7 @@ "alignItems": "center", "children": [ { - "id": "hveZg", + "id": "jZQLp", "type": "ref", "ref": "J09iP", "name": "Btn Discard", @@ -131819,7 +144023,7 @@ } }, { - "id": "X7QEbN", + "id": "IOHMd", "type": "ref", "ref": "z9ShNE", "name": "Btn Save Changes", diff --git a/docs/architecture.md b/docs/architecture.md index cc9ccb2fe..8701ccb5f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,6 +4,11 @@ Gameplane is split across long-lived components — dashboard, API, operator, and [optional] audit-syslog-bridge / telemetry-receiver satellites — plus a short-lived per-pod agent sidecar. +The default Helm installation runs the panel and operator together. A +[standalone panel](standalone-panel.md) runs the dashboard and API without local +Kubernetes; registered remote clusters run the operators and game workloads. +The diagram and direct-agent examples below describe the combined installation. + > **For AI Agents & Developers:** This document provides a high-level human-readable overview. For deep technical details, specifications, and boundaries of individual components, read the respective `specs.md` files located in each component directory (e.g., `api/specs.md`, `operator/specs.md`). AI Agents should consult [`agent-architecture.md`](agent-architecture.md) as their primary index. ``` @@ -80,7 +85,7 @@ manage Gameplane with `kubectl apply` — the operator is authoritative. 4. StatefulSet starts the game pod (game container + agent sidecar) 5. Agent starts heartbeating → sets `status.agent.lastHeartbeat` 6. Operator observes heartbeat → sets `status.phase=Running` -7. Dashboard SSE stream receives the status update, re-renders +7. Dashboard receives the status update through local SSE or resource polling and re-renders > **What `Running` means.** The phase flips to `Running` once the pod is > Ready *and* the agent heartbeat is fresh. That signals the pod and @@ -289,19 +294,23 @@ bundle upload) only reads and writes these CRs/ConfigMaps; the operator owns all reconciliation, so the dashboard and kubectl always converge on the same outcome. Format spec: `docs/module-authoring.md`. +Combined panels manage the local module catalog. Standalone panels require an +explicit registered cluster for module and source operations; that cluster's +operator reconciles the changes. + ## Multi-cluster (federation) Gameplane scales across multiple Kubernetes clusters through a federation model: each target cluster runs its own operator and agents, -while the API server (on the control-plane cluster) holds a pool of +while the central API server holds a pool of Kubernetes clients keyed by cluster ID and dispatches requests via a -`?cluster=` URL parameter. The built-in "local" cluster targets the -same cluster as the API; any additional cluster is registered through -a cluster-scoped CRD. +`?cluster=` URL parameter. Combined installations have a built-in `local` +cluster targeting the same cluster as the API. Standalone panels have no local +workload client and start with an empty registry. **Cluster registration and health:** -- A `Cluster` CRD (group `gameplane.local/v1alpha1`) is the source of +- In combined mode, a `Cluster` CRD (group `gameplane.local/v1alpha1`) is the source of truth for additional clusters. Each `Cluster` references a `kubeconfig` Secret via a selector, and the API watches `Cluster` updates to populate its client pool live. @@ -309,9 +318,13 @@ a cluster-scoped CRD. `gameplane.local/cluster-kubeconfig=true` in the control-plane namespace — the label guard prevents pointing at arbitrary Secrets (see "Kubeconfig Secret handling" in `docs/security.md`). -- The operator health-checks each registered `Cluster` and reconciles +- In combined mode, the operator health-checks each registered `Cluster` and reconciles `status.phase` (Unknown/Healthy/Unhealthy), so Kubernetes connectivity is visible in the dashboard. This status does not probe an optional gateway. +- Standalone panels persist registrations and labelled credentials in SQL. + Credential payloads use AES-GCM with a persistent key outside the database. + The API polls registrations and remote Kubernetes health every 30 seconds; + it needs no central operator. See [storage and recovery](standalone-panel.md#storage-and-recovery). **Deployment model:** @@ -320,11 +333,13 @@ a cluster-scoped CRD. Optional remote agent access also runs a private gateway from the API image, with no user database or browser/admin routes. Upgrade the operator and agents for the UID-bound agent protocol before enabling this path. -- The **control-plane cluster** hosts the API and dashboard; it may or - may not have game pods itself (if `cluster=local`). +- The **central panel** hosts the API and dashboard. A combined installation + can also host local game pods. A standalone panel manages registered remote + workloads and can run on a host without Kubernetes. - A **resource request** made to the API with `?cluster=` is dispatched to the named cluster's Kubernetes client. Omitting the selector targets - the built-in "local" cluster. Collection reads under `/fleet/*` instead combine + the built-in `local` cluster in combined mode and fails in standalone mode. + Collection reads under `/fleet/*` combine authorized scopes by default, with optional cluster and namespace filters. Each returned resource retains its target identity and permissions; partial results report unavailable or truncated scopes. See the @@ -362,13 +377,12 @@ a cluster-scoped CRD. **RBAC and permissions:** -Registering an additional cluster grants **no implicit RBAC** on it. -Each cluster's role bindings are independent; existing bindings -created before federation was enabled are pinned to "local". To grant -a user access to resources on a newly registered cluster, create -matching role bindings on that target cluster or add cluster-scoped -permissions through the API. See `docs/install.md#registering-an-additional-cluster` -for the registration flow. +Registering a cluster grants no implicit workload access. The central API stores +user roles and grants by cluster and namespace in its database. Existing primary +roles remain pinned to `local`; in standalone mode they grant panel administration +without remote workload access. Add remote grants through **Users & RBAC** or the +user bindings API. The registered kubeconfig separately needs Kubernetes RBAC on +the target. See [registration and permissions](install.md#rbac-and-permissions). ## Security boundaries diff --git a/docs/comparison-sources.md b/docs/comparison-sources.md index 12db33e79..7e6f7418e 100644 --- a/docs/comparison-sources.md +++ b/docs/comparison-sources.md @@ -5,7 +5,7 @@ README comparison table. Each product has a section below. Entries are organized by row (dimension a–i) and include the source URL, date checked, and what was verified. -**Last Updated**: 2026-10-02 +**Last Updated**: 2026-10-08 --- @@ -19,9 +19,12 @@ verified. ### Row (a): Deployment/runtime model **Source ID**: G-a -**Evidence**: README.md:8; operator/api/v1alpha1/gameserver_types.go:1–50 -**Checked on**: 2026-09-02 -**What was verified**: README status line confirms project is a pre-v1 release (`v0.3.0`; status line re-checked 2026-10-05); GameServer CRD types and controller-runtime operator documented in operator module with support for k3s and multi-node clusters. +**Evidence**: docs/standalone-panel.md; deploy/standalone/compose.yaml; api/cmd/management.go; operator/api/v1alpha1/gameserver_types.go + +**Checked on**: 2026-10-08 (source review) + +**What was verified**: The default install combines the panel and operator on Kubernetes. The standalone Compose deployment runs the API and dashboard without a local Kubernetes cluster; games still use GameServer CRDs and an operator on registered Kubernetes clusters. The README identifies Gameplane as a pre-v1 release. Standalone setup requires a checkout or release containing this feature. + **Last-known URL**: https://github.com/GameplanePanel/Gameplane @@ -74,11 +77,11 @@ verified. **Source ID**: G-g -**Evidence**: operator/api/v1alpha1/cluster_types.go; api/internal/gateway/server.go; api/internal/handlers/capture_remote.go; api/internal/handlers/mod_target.go; docs/architecture.md +**Evidence**: operator/api/v1alpha1/cluster_types.go; api/internal/controlplane/store.go; api/internal/gateway/server.go; api/internal/handlers/capture_remote.go; api/internal/handlers/mod_target.go; docs/standalone-panel.md; docs/architecture.md -**Checked on**: 2026-10-02 (source review) +**Checked on**: 2026-10-08 (source review) -**What was verified**: Cluster CRD for remote registration/monitoring; Pod logs and PTY attach use registered Kubernetes clients. An optional private gateway routes agent operations to UID-aware agents and provides capture downloads/cleanup bound to server and capture identity. Modpack and ID-list configuration use the selected Kubernetes client and template. Direct Kubernetes connectivity and matching component versions remain required. Cluster health reports Kubernetes connectivity, not gateway health. +**What was verified**: Combined mode stores remote registrations in Cluster CRDs; standalone mode stores them in SQL and encrypts their credentials. User grants remain central and identify the remote cluster and namespace. Pod logs and PTY attach use registered Kubernetes clients. An optional private gateway routes agent operations to UID-aware agents and provides capture downloads/cleanup bound to server and capture identity. Modpack and ID-list configuration use the selected Kubernetes client and template. Direct Kubernetes connectivity and matching component versions remain required. Cluster health reports Kubernetes connectivity, not gateway health. **Last-known URL**: https://github.com/GameplanePanel/Gameplane/tree/master/operator/api/v1alpha1 @@ -95,9 +98,12 @@ verified. ### Row (i): Target operator scope (self-hosted vs. managed SaaS) **Source ID**: G-i -**Evidence**: README.md § Why Gameplane? (line 50 onward); CLAUDE.md § Repository Map -**Checked on**: 2026-09-26 (re-verified) -**What was verified**: README "Why Gameplane?" section states self-hosted Kubernetes deployments (k3s, kubeadm, managed services); no managed SaaS offering documented. +**Evidence**: docs/standalone-panel.md; deploy/standalone/compose.yaml; api/cmd/management.go; README.md § Installation + +**Checked on**: 2026-10-08 (source review) + +**What was verified**: The panel can run on a Docker host or Kubernetes. Game workloads require Kubernetes, including k3s, kubeadm, or managed services. These are self-hosted deployments; no managed SaaS offering is documented. + **Last-known URL**: https://github.com/GameplanePanel/Gameplane --- diff --git a/docs/gateway-install.md b/docs/gateway-install.md index a8ba76897..0763dbfbe 100644 --- a/docs/gateway-install.md +++ b/docs/gateway-install.md @@ -7,6 +7,10 @@ management, Pod logs and PTY attach; a gateway does not replace those credential See [central registration and request routing](multicluster-agent-gateway.md) for the matching central API configuration. +The central panel can run in Kubernetes or on a separate Docker host. Use the +[standalone setup guide](standalone-panel.md) when the panel has no local game +cluster. The remote operator/gateway installation below is the same in both cases. + ## Prepare the remote cluster Use matching API, operator and agent versions. The gateway uses the API image's @@ -52,6 +56,9 @@ Adapt the [example values](../charts/gameplane/examples/gateway-values.yaml): ```yaml api: enabled: false + standalone: false +operator: + enabled: true gateway: enabled: true clusterID: remote-1 @@ -64,7 +71,7 @@ gateway: apiServerCIDRs: [10.96.0.1/32, 10.0.0.10/32] ``` -`clusterID` must equal the `Cluster` resource name in the central installation. +`clusterID` must equal the registered cluster name in the central installation. An empty namespace list selects only `gamesNamespace`; additional namespaces must already exist. The chart creates a Role/RoleBinding in each configured namespace. When `networkPolicies.enabled` is true, it also creates matching agent ingress rules. @@ -178,8 +185,9 @@ inventory access only to that cluster; the user's local primary role and existin game-namespace grants remain separate. Changing a user's grants revokes their sessions, so that user must sign in again. -Remote all-namespace roles may contain only `cluster:read` and namespaced -permissions. Wildcard or central-administration permissions are rejected, and a +Remote all-namespace roles may contain `cluster:read`, `modules:read`, +`modules:manage`, `templates:read`, `templates:write`, and namespaced permissions. +Wildcard or central-administration permissions are rejected, and a role cannot gain them later while it has remote all-namespace bindings. ## Private networking diff --git a/docs/install.md b/docs/install.md index d3d64a70f..835cc2e7b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,5 +1,10 @@ # Install +For a central dashboard/API on a host without Kubernetes, use the +[standalone panel guide](standalone-panel.md), including Docker Compose and a +panel-only Helm profile. The instructions below describe the default combined +installation, with an operator and a local game cluster. + ## Prerequisites - Kubernetes 1.28+ @@ -106,6 +111,9 @@ Open `https://` and log in. Top-level knobs (see `values.yaml` for the full list): - `image.registry` / `image.tag` — container image pinning +- `operator.enabled` — enable the workload operator (default `true`). Set to `false` for a standalone central panel. +- `api.enabled` — enable the central API and dashboard (default `true`). Set to `false` on a remote operator/gateway installation. +- `api.standalone` — store management records and encrypted credentials in SQL without a local cluster (default `false`). Requires `operator.enabled=false`, one API replica, and `--skip-crds` when installing the panel. See the [standalone guide](standalone-panel.md). - `operator.replicas` — leader-elected, safe at 2+ - `operator.configInitImage` / `operator.resticImage` — the two images the operator injects into workloads it creates: the config-init container on game pods @@ -127,7 +135,9 @@ Top-level knobs (see `values.yaml` for the full list): `--set operator.gameDataStorage.storageClassName=fast-nvme` - `api.db.driver` — `sqlite` (default, production-tested) or `postgres` [experimental] (requires an api image built with `-tags postgres`; not yet covered by e2e or upgrade tests) - `api.db.dsn` — connection string; SQLite default persists to a PVC -- `api.storage.existingClaim` — pre-existing PVC to mount for the API's SQLite database instead of letting Helm create `gameplane-api-data` (default `""`). The chart annotates `gameplane-api-data` with `helm.sh/resource-policy: keep` so switching to an existing claim preserves the previous PVC. +- `api.storage.existingClaim` — pre-existing PVC for the API's SQLite database (default `""`). PostgreSQL standalone deployments create no local database PVC or mount. The chart annotates `gameplane-api-data` with `helm.sh/resource-policy: keep` so switching to an existing claim preserves the previous PVC. +- `api.panelKey.storage.existingClaim` — pre-existing key-only PVC for generated standalone encryption keys; independent of database storage. `api.panelKey.storage.size` and `storageClassName` configure a new key PVC when no existing claim is selected. +- `api.panelKey.existingSecret` — mount an externally provisioned standalone key read-only instead of a key PVC. `api.panelKey.secretKey` selects the Secret entry (default `panel.key`); `api.panelKey.fileName` selects its mounted filename. Back up the database and matching key separately under different access policies. See [key custody](standalone-panel.md#central-panel-on-kubernetes). - `api.oidc.enabled` + the following settings — wire OIDC login from Helm (shows up as the read-only `helm` provider). Providers can also be added at runtime under **Admin Settings → Authentication** — no Helm values or restart needed; @@ -604,14 +614,14 @@ official registry) and `uploads` (dashboard bundle uploads). The default uses `type: git` is available to track an unreleased branch directly from the `GameplanePanel/module` repository. Install games from the dashboard's **Modules** page, or add more sources — git repositories, http archives, a local directory — under -**Modules → Manage sources** (admin) or by applying `ModuleSource` CRs. See +**Modules → Manage sources** (requires `modules:manage` on that cluster) or by applying `ModuleSource` CRs. Standalone panels require a selected remote cluster for these operations. See `docs/module-authoring.md` for the source types and the bundle format. ## Registering an additional cluster Gameplane can manage game servers across multiple Kubernetes clusters through a federation model. Each target cluster runs its own operator -instance; the control-plane cluster's API dispatches requests to the +instance; the central API dispatches requests to the target cluster via a `?cluster=` parameter. See [architecture.md](architecture.md#multi-cluster-federation) for the design details. @@ -664,6 +674,10 @@ readiness or complete interactive feature coverage. ### Path 1: kubectl apply +This path applies to a combined installation with a central Kubernetes cluster. +A standalone panel uses the dashboard or API in Path 2 and stores registrations +in its database. + 1. Create a `kubeconfig` Secret in the control-plane's `gameplane-system` namespace. The Secret **must** be labelled `gameplane.local/cluster-kubeconfig=true`: @@ -730,25 +744,34 @@ in place. Delete the Secret with kubectl when you no longer need it. ### Path 2: Dashboard API -POST to `/clusters` with permission `cluster:manage` (admin-only): +In either installation mode, use **Clusters → Register cluster**, or POST to +`/clusters` with `cluster:manage`. Supply a self-contained kubeconfig as a raw +YAML string in JSON. The following example uses `jq` to encode it correctly. +`PANEL_URL` is the HTTPS dashboard origin, `cookies.txt` holds your authenticated +`gameplane_session` and `gameplane_csrf` cookies, and `CSRF_TOKEN` is the latter +cookie's value: ```sh -curl -X POST https:///api/clusters \ - -H "Content-Type: application/json" \ - -H "X-Gameplane-CSRF: " \ - --cookie "session=" \ - -d '{ - "name": "my-cluster", - "kubeconfig": "" - }' +jq -n --rawfile kubeconfig remote-kubeconfig.yaml \ + '{name:"my-cluster",kubeconfig:$kubeconfig}' | + curl --fail-with-body --cookie cookies.txt \ + --header "Content-Type: application/json" \ + --header "X-Gameplane-CSRF: $CSRF_TOKEN" \ + --data-binary @- "$PANEL_URL/clusters" ``` -The API stores the kubeconfig as a labelled Secret and creates the -`Cluster` CRD. The kubeconfig is never returned by the API and never -logged. Removing the cluster deletes both the `Cluster` and that Secret. +Combined installations store the kubeconfig in a labelled Secret and create a +`Cluster` resource. Standalone panels store the registration and encrypted +credential in SQL. The API never returns or logs the kubeconfig. Removing an +API-created registration also removes its managed credentials; it leaves the +remote workloads running. Keep cookie jars and kubeconfig files private. ### Helm CRD caveat +This section applies to combined and remote workload installations. A standalone +central Helm release skips CRDs and their apply hook; follow its +[panel profile](standalone-panel.md#central-panel-on-kubernetes). + **`helm upgrade` never updates CRDs.** That is a documented Helm limitation, not a Gameplane one: files under a chart's `crds/` directory are installed on first install and ignored on every upgrade thereafter. @@ -798,16 +821,27 @@ helm install gameplane charts/gameplane -n gameplane-system --create-namespace - ### RBAC and permissions -Registering a cluster grants **no implicit RBAC** on it — a user who -can start servers on the "local" cluster will not automatically be able -to do so on a newly registered cluster. Each cluster maintains its own -role bindings. To grant a user access to resources on the target -cluster, create matching role bindings there, or use the dashboard to -add cluster-scoped permissions if the target cluster's API also -supports the same RBAC model. +Registering a cluster grants no dashboard user access to its workloads. The +central API stores user roles and cluster/namespace grants in its database. +In **Users & RBAC**, create a custom workload role, then add a grant for the +intended user and remote cluster. Use **All namespaces** for inventory, module, +and template permissions, or a specific game namespace for namespaced access. +Changing a user's grants ends their sessions; they must sign in again. + +Remote roles cannot contain central administration permissions or the built-in +admin wildcard. Keep the user's primary panel role when adding remote grants. +The [standalone guide](standalone-panel.md#grant-workload-access) lists the +permissions and dashboard steps. + +Separately, the registered kubeconfig needs Kubernetes RBAC for the operations +the API performs on that target. Kubernetes RoleBindings grant access to that +credential's identity; they do not create Gameplane user grants. ## Upgrading +These Helm commands upgrade a combined installation. For Compose and panel-only +Helm releases, use [standalone upgrades](standalone-panel.md#upgrading). + ```sh helm upgrade gameplane oci://ghcr.io/gameplanepanel/charts/gameplane \ --version \ @@ -862,7 +896,9 @@ upgrade strategy: the old pod is fully terminated before the new one starts. This ensures no two API processes try to write the same SQLite database file (which is a single-writer store on a ReadWriteOnce PVC). As a result, SQLite-backed installs experience a few seconds of dashboard downtime during -an upgrade — this is expected and deliberate. Postgres-backed installs (experimental) -would use rolling updates with no downtime, since the database is external and -shared, but Postgres remains experimental and the API should still run as a -single replica (see `api.replicas` in `values.yaml`). +an upgrade. Combined PostgreSQL installs use rolling updates, but the driver +remains experimental and the API should still run as a single replica (see +`api.replicas` in `values.yaml`). Standalone installs use `Recreate` with either +database driver: generated keys use a ReadWriteOnce volume, and audit-chain and +user-management locks remain per-process. A shared provisioned key does not make +multiple API replicas supported. diff --git a/docs/multicluster-agent-gateway.md b/docs/multicluster-agent-gateway.md index 75613e466..a0e911fb3 100644 --- a/docs/multicluster-agent-gateway.md +++ b/docs/multicluster-agent-gateway.md @@ -8,7 +8,16 @@ central API still connects directly to each Kubernetes API for resource changes, pod logs, PTY attach, and stdin module actions. The gateway does not provide a Kubernetes tunnel, replicate storage, or run a second user database. -## Register the gateway in the central cluster +## Register the gateway with the central API + +In either installation mode, register the workload cluster through +**Clusters → Register cluster** or `POST /clusters`. Set its gateway URL and mTLS +credentials with `PUT /clusters/{name}/gateway`; the +[standalone guide](standalone-panel.md#configure-remote-agent-access) shows the +request. Standalone panels encrypt those credentials in SQL and have no central +Kubernetes Secret or `Cluster` resource to apply. + +The Kubernetes manifests below are an alternative for combined installations. Install the updated operator, UID-aware agents and [optional gateway](gateway-install.md) in each game cluster. The gateway must have an HTTPS certificate valid for its configured URL @@ -55,10 +64,10 @@ spec: The registration name must match the gateway's configured cluster ID. The URL is an HTTPS origin with no userinfo, path, query or fragment. Credentials are read only from the central API namespace and must carry the label above. Register the -cluster through the central registration API or its `Cluster` resource, and -configure the optional gateway field through Kubernetes. The dashboard's -**Clusters** overview selects existing registrations; enrollment remains an -operator-managed step. +cluster through the dashboard, central registration API, or its `Cluster` +resource in combined mode. Configure gateway credentials through the API above +or these manifests. The dashboard's registration form accepts the kubeconfig; +gateway credentials use the API or the combined installation's Kubernetes tools. ## Routing and identity @@ -89,8 +98,8 @@ attach does not support atomic UID preconditions, so this is a preflight check. ## Failure and rotation behavior -The central API reads the Cluster and credential Secret for every new gateway -operation. Missing, deleted, unlabeled or malformed credentials fail closed; +The central API reads the registration and credential from its management store +for every new gateway operation. Missing, deleted, unlabeled or malformed credentials fail closed; there is no fallback to a local namesake. Removing the optional gateway reference removes new interactive access without removing Kubernetes management. Redirects are not followed and writes are not automatically retried after an ambiguous diff --git a/docs/multicluster-ui.md b/docs/multicluster-ui.md index c29f587cf..2d1c93713 100644 --- a/docs/multicluster-ui.md +++ b/docs/multicluster-ui.md @@ -10,8 +10,10 @@ for filtering and server navigation. The **Clusters** page (`/clusters`) lists authorized registrations with their display name, stable ID, Kubernetes API health, version and last check when -available. **Local** identifies the central cluster. The administration navigation -is available to users with cluster-management or inventory access. +available. **Local** identifies the central cluster in combined installations; +standalone panels have no Local entry and can start with no registrations. The +administration navigation is available to users with cluster-management or +inventory access. - **View servers** opens the server list filtered to that location. - **View nodes** opens the selected cluster's inventory when the user has its @@ -19,8 +21,9 @@ is available to users with cluster-management or inventory access. - Connection health reports Kubernetes API connectivity. It does not establish gateway readiness or game health. -Registration is an administrator-managed prerequisite and grants no user access -by itself. See [installation](install.md) for Kubernetes registration and +Users with `cluster:manage` can register a target through **Register cluster**. +Registration grants no workload access by itself. See +[standalone setup](standalone-panel.md) or [combined installation](install.md) and [remote agent access](multicluster-agent-gateway.md) for gateway credentials. ## Node inventory @@ -51,10 +54,13 @@ To grant remote access in **Users & RBAC**: `cluster:read` with **All namespaces**. 4. Add the grant. The user's local primary role remains separate. -Remote all-namespace roles may contain `cluster:read` and namespaced permissions. +Remote all-namespace roles may contain `cluster:read`, `modules:read`, +`modules:manage`, `templates:read`, `templates:write`, and namespaced permissions. Wildcard and central-administration permissions are rejected, including when an existing role is edited. Namespace-only server grants do not grant node inventory access. Changing grants revokes the user's sessions, so they must sign in again. -Accounts, roles, module catalog, audit and installation settings remain centrally -managed. Their requests do not inherit the inventory selection. +Accounts, roles, audit and installation settings remain centrally managed. Their +requests do not inherit the inventory selection. Combined panels manage the local +module catalog; standalone panels require an explicit remote target for modules +and sources. diff --git a/docs/security.md b/docs/security.md index 66e70b944..d4de1c94a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -954,6 +954,32 @@ cluster, don't enable `mcpServer` there. ## Secrets +Combined installations store management credentials in Kubernetes Secrets. +Standalone panels store their labelled management credentials, including remote +kubeconfigs, gateway keys, OIDC client secrets, notification credentials, and mod +registry keys, as AES-GCM ciphertext in SQL. They use a persistent 32-byte key. +The supplied standalone profiles mount it separately at `/keys/panel.key`; +custom deployments retain the legacy `/data/panel.key` default. Keep protected +key backups separate from database backups. Provisioned-key mode requires an +existing read-only key file or Kubernetes Secret and never creates a replacement. +Startup validates file ownership, permissions, type and size, and rejects extended +access ACLs that could grant additional readers. Encryption does not protect credentials from someone who can +read both files or control the running API. A missing key with existing +credentials, a wrong key, or corrupted credential data prevents API startup. +See [standalone storage and recovery](standalone-panel.md#storage-and-recovery). + +Master-key rotation is an offline transaction through `rotate-panel-key`. +Versioned authenticated ciphertext identifies its key; the database key-state +lock rejects writes by processes still holding a retired key. Old key files are +retained for historical backups. See the +[rotation procedure](standalone-panel.md#rotate-the-master-encryption-key) before +changing a mounted key. + +The feature labels and managed-secret deletion guards apply to both stores. +Game credentials and backup repository Secrets remain on the workload cluster. +The Kubernetes Secret and Helm environment examples below apply to Kubernetes +deployments. + Secrets Gameplane reads or creates, by convention: - `gameplane--rcon` — per-game RCON password, created by operator @@ -984,9 +1010,9 @@ generates a fresh password on the next pod restart. ## Kubeconfig Secret handling -In a multi-cluster setup, each target cluster is referenced by a Secret -containing its kubeconfig. Access to cluster credentials is protected -by several layers: +Each registered cluster references a labelled kubeconfig credential, stored in +a Kubernetes Secret in combined mode or encrypted SQL in standalone mode. +Access to cluster credentials is protected by several layers: - **Embedded credentials only.** The API and operator reject token files, client certificate/key files, CA files, `exec` authentication, and @@ -994,29 +1020,52 @@ by several layers: entry, including unused contexts. Remote kubeconfigs must carry tokens or certificate/key/CA data directly; they cannot read control-plane files or run local authentication commands. +- **Standalone destination policy.** Standalone clients require verified HTTPS, + reject redirects and forward proxies, and validate all DNS answers before + dialing a pinned address. Loopback, metadata, link-local and other unsafe + address classes are blocked; optional operator CIDRs narrow access to the + intended workload networks. The same policy protects gateway connections. + With no destination CIDRs, permitted private addresses remain reachable from + the panel host. An administrator able to register endpoints can direct + requests to unrelated private services that satisfy the connection policy; + TLS verification and unsafe-address blocks do not isolate those networks. + Set `GAMEPLANE_REMOTE_ALLOWED_CIDRS` for Compose/custom deployments or + `api.remoteAllowedCIDRs` for Helm to the workload API and gateway networks, + and use firewall rules for additional service or port restrictions. + Remote response, watch-frame and notification-cache limits bound memory used + by an untrusted workload endpoint. See the + [standalone connection policy](standalone-panel.md#install-and-register-remote-game-clusters). - **Label guard.** The API only reads Secrets labelled `gameplane.local/cluster-kubeconfig=true` when registering a cluster via the dashboard or API. This prevents a user from pointing at an arbitrary control-plane Secret (e.g., the OIDC client secret or backup credentials) and using it as a kubeconfig. - **Delete guard.** `DELETE /clusters/{name}` drops the cluster's client at - once and deletes the referenced Secret only when it is the one POST generates - for that cluster (cluster--kubeconfig) and carries - `gameplane.local/cluster-kubeconfig=true` (Secrets created before the - managed-by label was added are also cleaned up). Any other Secret, including + once and deletes the referenced Secret only when it uses the API's fixed + registration name or a nonce-suffixed rotation name for that cluster, and carries + `gameplane.local/cluster-kubeconfig=true`. Rotation names additionally require + the API's managed-by label (legacy fixed-name Secrets predate that label). + UID/resourceVersion preconditions prevent cleanup from deleting a replacement. + Any other Secret, including one named for a different cluster or one without the kubeconfig label, is left in place. A kubeconfig Secret you create with kubectl or GitOps under another name is never deleted over HTTP. +- **In-place rotation.** `PUT /clusters/{name}/kubeconfig` requires central + `cluster:manage`, publishes an immutable replacement, and switches the + registration reference only if its UID and previous reference still match. + It preserves gateway configuration and user grants and reloads the client. + Revoke the old credential on the target after verifying the replacement; + requests already in flight may still use it. - **Never logged or returned.** The kubeconfig is never logged by the API, never echoed in responses, never visible in audit trails. It exists only to bootstrap the Kubernetes client for that cluster. -- **Permission gating.** Only users holding the `cluster:manage` - permission (admin-only) can register, list, or delete clusters via - the API. Dashboard access to `/clusters` is similarly gated. +- **Permission gating.** Registering or deleting a cluster requires + `cluster:manage`. Discovery returns only registrations the caller may see; + workload grants and central cluster-management permission determine visibility. - **No implicit RBAC.** Registering a cluster does not grant any user - access to resources on that cluster. Access is determined by role - bindings created independently on the target cluster, not by - federation. See [install.md](install.md#rbac-and-permissions). + access to resources on that cluster. The central API stores dashboard user + grants by target cluster and namespace. The registered kubeconfig's Kubernetes + permissions are a separate requirement. See [install.md](install.md#rbac-and-permissions). ## Install-Time OIDC Role Mappings diff --git a/docs/standalone-panel.md b/docs/standalone-panel.md new file mode 100644 index 000000000..b6884b4bf --- /dev/null +++ b/docs/standalone-panel.md @@ -0,0 +1,436 @@ +# Standalone panel + +Run the dashboard and API on a host that has no Kubernetes installation. Remote +game clusters run their own operator and game server agents. The central host is +never registered as a `local` cluster and cannot host games itself. + +The default Helm install remains a combined panel and operator installation. +Standalone mode is an explicit choice for a fresh central panel; this guide +does not migrate an existing installation's Kubernetes Secrets or Cluster CRs +into SQL storage. + +## Docker Compose + +Install Git and Docker with the Compose plugin. Use a release or commit that +contains standalone support; replace `` below with that tag or commit. +The Compose file builds the API and web images from the same checkout. + +Before starting the panel, set `GAMEPLANE_REMOTE_ALLOWED_CIDRS` to the destination +networks for your Kubernetes APIs and agent gateways, for example +`export GAMEPLANE_REMOTE_ALLOWED_CIDRS=10.20.0.0/16,10.30.0.0/16`. Without this +restriction, registration can reach permitted public and private addresses +accessible from the panel host. A registration administrator can therefore +direct requests into private networks. Verified TLS and blocked unsafe address +classes do not isolate unrelated private services; use narrow destination CIDRs +and host/network firewall rules. See [connection policy](#install-and-register-remote-game-clusters). + +Run the commands from the repository root: + +```sh +git clone https://github.com/GameplanePanel/Gameplane.git +cd Gameplane +git checkout +docker compose -f deploy/standalone/compose.yaml up -d --build +docker compose -f deploy/standalone/compose.yaml logs gameplane-api +``` + +Only two services run: `gameplane-api` and `web`. They need no host Kubernetes +installation or Docker socket mount. The API is reachable only through +the Compose network; nginx forwards requests to `http://gameplane-api:8000`. +The dashboard is bound to `127.0.0.1:8080` on the host. Set `GAMEPLANE_PORT` to +change that port. Use `http://localhost:8080` locally; for remote access, put an +HTTPS reverse proxy in front of the loopback port and forward WebSocket upgrades +and streaming responses. Login cookies require a secure browser context; serving +the dashboard on an ordinary remote HTTP origin will not work. + +Run exactly one `gameplane-api` container. Do not scale it through Compose or +start another API process against the same database, including PostgreSQL. + +Create the first administrator using the same database volume. This Bash example +reads the password without including it in shell history or process arguments: + +```sh +read -r -s -p 'Admin password: ' PANEL_PASSWORD +printf '\n' +printf '%s\n' "$PANEL_PASSWORD" | docker compose -f deploy/standalone/compose.yaml \ + exec -T gameplane-api /api bootstrap-admin --username admin --password-stdin +unset PANEL_PASSWORD +``` + +Use a password of at least 12 characters. Sign in at the dashboard. A new panel +has no clusters; register a remote cluster before using game-server operations. +Authentication, users, roles, settings, and registration remain available before +any remote cluster exists. Administrators can use **Register cluster** on the +Clusters page or the registration API below. The cluster picker selects already +registered clusters. Gateway credentials are configured through the API. + +## Storage and recovery + +The Compose volume `panel-data` contains `/data/gameplane.db`; a separate +`panel-keys` volume contains `/keys/panel.key`. Standalone settings and remote +credentials are stored in SQL; credential values are authenticated-encrypted +using the persistent key. Back up the database and its WAL files after stopping +the services. Back up the key separately, with restricted access and encrypted +backup storage. Both are needed for recovery, but a database backup should not +automatically include the decryption key. Restore generated volumes with +ownership `65532:65532`. Normal `docker compose down` retains both volumes; +`down --volumes` deletes them. + +With the supplied Compose project name, Docker names these volumes +`gameplane-panel_panel-data` and `gameplane-panel_panel-keys`. Inspect both before +backing them up, especially if you changed the Compose project name. + +The API image seeds a new named volume with a directory writable by its non-root +runtime user. If you replace it with a host bind mount, create that directory with +ownership `65532:65532` yourself. Generated key files use mode `0600`. Restored +keys must be regular files containing exactly 32 raw bytes, owned by the API user +or root, with no access for other users and no group write/execute permissions. +For API-owned keys, group read is accepted only for the API's effective group. +Root-owned provisioned keys may use a supplementary group the API belongs to, +which permits read-only Kubernetes Secret projections. Use a dedicated key-read +group: membership checks cannot prove that no unrelated account shares it. +Extended file access ACLs are rejected +because they can grant extra readers despite safe-looking mode bits. On Linux, +inspect restored keys with `getfacl` and remove extra file grants with +`setfacl -b` before applying the required ownership/mode; key directories should +not have inherited default ACL grants. Key permissions are validated at +startup; creating a replacement key cannot decrypt existing credentials. + +For custom deployments, pass `--standalone` or `GAMEPLANE_STANDALONE=true` to the +API. `--panel-key-file` / `GAMEPLANE_PANEL_KEY_FILE` selects the key path (default +`/data/panel.key` for compatibility with earlier custom deployments; the supplied +Compose/Helm profiles use `/keys/panel.key`). A persistent key file is required +even when using the experimental PostgreSQL build. Run one API replica in +Compose and custom deployments as well as Helm. Independently generated keys +cannot serve multiple API processes against a shared database. A shared key +with `--panel-key-provisioned` prevents independent generation but does not make +multiple replicas supported: audit-chain and user-management locks remain +per-process. Run standalone key storage in Linux containers on a filesystem supporting POSIX ACL +inspection. Native non-Linux key access and uninspectable ACLs fail closed. + +### Provision a key outside the database storage + +Set `--panel-key-provisioned` / `GAMEPLANE_PANEL_KEY_PROVISIONED=true` to require +an existing key at startup. This mode never generates a missing key. Provision +32 cryptographically random bytes through your secret manager or a protected +file, then mount it read-only. For a Linux Docker host: + +```sh +sudo install -d -m 0700 /etc/gameplane/keys +sudo sh -c 'set -C; umask 077; openssl rand 32 > /etc/gameplane/keys/panel.key' +sudo chown 65532:65532 /etc/gameplane/keys/panel.key +export GAMEPLANE_PANEL_KEY_SOURCE_FILE=/etc/gameplane/keys/panel.key +docker compose -f deploy/standalone/compose.yaml \ + -f deploy/standalone/compose.provisioned-key.yaml up -d --build +``` + +Use the same two Compose files for subsequent commands. File-backed Compose +secrets preserve the host file's ownership and permissions; configure those +before starting the API. Keep this file outside database backup jobs. The API +does not upload keys to a service or put their values in environment variables. +A separately protected key helps with database/backup disclosure; compromise of +the running API or a host that can read both files still exposes credentials. +See [OWASP's key-storage guidance](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html#key-storage). + +### Move an existing key to separate storage + +For installations created before separate key volumes, stop the API and retain +a protected backup of the original `/data/panel.key`. Copy that exact file into +the new key volume as `/keys/panel.key`, preserving ownership and mode `0600`, or +provision it through the read-only file option above. Start using the new path +and verify existing registrations load before removing the old copy from the +database volume and excluding it from future database backups. Do not generate +a new key as a migration step; it cannot decrypt existing records. + +### Rotate the master encryption key + +Stop every API process that uses this database, then take a database backup and +a separately protected backup of its current key. The offline command accepts +file paths and never prints key material. Database connections come from +`GAMEPLANE_DB_DSN`; the rotation command does not accept `--db-dsn`, so database +passwords need not appear in shell history or process arguments. Supply the old +key path explicitly with `--old-key-file` or `GAMEPLANE_PANEL_KEY_FILE`. +`rotate-panel-key --help` describes the available options without connecting to +the database. For generated keys in the Compose volume, use a new filename: + +```sh +docker compose -f deploy/standalone/compose.yaml stop gameplane-api +docker compose -f deploy/standalone/compose.yaml run --rm --no-deps \ + --entrypoint /api gameplane-api rotate-panel-key \ + --old-key-file /keys/panel.key --new-key-file /keys/panel-2026-10.key +export GAMEPLANE_PANEL_KEY_FILE=/keys/panel-2026-10.key +docker compose -f deploy/standalone/compose.yaml up -d --no-build gameplane-api +``` + +Persist the selected path in your Compose environment configuration before +future restarts. Check login, cluster health and credential-backed operations. +Back up the new key separately. The command retains the old file because older +database backups still require it; retire that key only after its matching +backups have expired under your retention policy. + +The new generated key is durably published before one database transaction +re-encrypts all management credentials and activates its identity. A failure +before commit leaves the old database state usable. Keep both files if the +command is interrupted or its result is uncertain, and retry the same old/new +pair; a completed immediately preceding rotation is authenticated and accepted. +Do not create another new key or restore only one half of a database/key backup. +Stale API processes cannot write credentials under the retired key, but stopping +them first is still required for a clean maintenance window. + +For a secret manager or read-only Kubernetes Secret, durably provision a new +32-byte key first and mount the old and new keys at separate paths in the offline +maintenance container. Add `--new-key-provisioned` to the command; a missing new +key fails without creating a replacement. Use the same database configuration +as the API (`GAMEPLANE_DB_DRIVER` and `GAMEPLANE_DB_DSN`). After success, update the +API's mounted key/path and restart it. For Helm Secret projections, switch +`api.panelKey.existingSecret` or its `secretKey` entry to the new key while keeping +the old Secret protected for retained backups. Changing the mounted key without +running the rotation command cannot decrypt existing records. + +For a generated Helm key PVC, scale the API Deployment to zero before running a +maintenance Job with the same database and key volumes. Rotate into a new file +under `/keys`, then set `api.panelKey.fileName` to that filename in your Helm +values and upgrade the release to restart its single API replica. The chart +keeps the selected filename across future upgrades. Preserve the old key file +for retained backups. + +## Central panel on Kubernetes + +The panel can also run in a Kubernetes cluster without using that cluster for +games or granting the API Kubernetes access. From this checkout: + +```sh +helm upgrade --install gameplane charts/gameplane \ + --namespace gameplane-system --create-namespace --skip-crds \ + --values charts/gameplane/examples/standalone-panel-values.yaml +``` + +Use images built from the matching version (set `image.registry` and `image.tag` +for your build). The example enables `api.standalone`, disables `operator.enabled` +and the CRD upgrade hook, and disables ingress until you configure your hostname +and TLS. Expose the web Service through your HTTPS ingress or use a local port +forward. Bootstrap the admin with `/api bootstrap-admin` in the API pod as in the +[combined installation guide](install.md#first-time-setup). + +Keep `--skip-crds` on installation commands. Helm installs files in `crds/` +before evaluating templates; setting values alone cannot suppress those CRDs. +Standalone mode skips the CRD apply hook automatically. It creates no operator, +agent certificates, game namespace, module source, game network policies, +operator monitors, or API Kubernetes RBAC. Its API pod disables service account +token mounting. SQLite uses a database PVC; PostgreSQL creates no local database +volume. Generated keys use a separate key PVC with either driver. Do not switch an +existing combined release to these values as a migration procedure. + +For an externally managed key, set `api.panelKey.existingSecret` to a Secret in +the release namespace and `api.panelKey.secretKey` to its 32-byte data entry +(default `panel.key`). The chart mounts it read-only at `/keys/panel.key` with +mode `0440` and enables provisioned-key mode. No generated key PVC is created. +For generated keys, `api.panelKey.storage.existingClaim` selects an existing +key-only PVC; `size` and `storageClassName` configure a newly created one. +Apply separate RBAC, encryption-at-rest, and backup policies to the key Secret +or PVC. Do not put key bytes in Helm values, command arguments, or source control. +The standalone pod uses `fsGroupChangePolicy: OnRootMismatch` so normal generated +key PVC remounts do not widen mode `0600` to group-writable `0660`. Start generated +key storage with an empty PVC. When restoring or migrating an existing PVC, first +prepare its mount root with group `65532`, mode `2770` (including setgid), and restore the key as `65532:65532`, +mode `0600`. Storage drivers that independently rewrite file permissions must +preserve this protection; otherwise use the read-only Secret option. The API +rejects a group-writable key instead of silently changing its permissions. + +## Install and register remote game clusters + +Each target still needs Kubernetes, Gameplane CRDs, an operator, and storage for +games. From the same checkout, install the chart using credentials for the remote +cluster. Use matching component images accessible to that cluster; replace +`` and `` with the registry and tag for your build: + +```sh +helm --kubeconfig remote-admin.yaml upgrade --install gameplane charts/gameplane \ + --namespace gameplane-system --create-namespace \ + --set api.enabled=false --set api.standalone=false --set operator.enabled=true \ + --set image.registry= --set image.tag= +``` + +The remote install needs its CRDs, so keep `--skip-crds` off this command. The +operator creates agents alongside game servers. Enable the optional +[private gateway](gateway-install.md) for agent operations such as console, +files, and player management. Without a gateway, Kubernetes resource operations +remain available, but agent-backed operations are unavailable. + +The central API must reach each target's Kubernetes API directly. The gateway +does not tunnel Kubernetes traffic. Use a kubeconfig containing credentials and +CA data that are usable from the central host. Grant the credential's identity +Kubernetes access to the required Gameplane resources, plus the +[streaming](install.md#pod-logs-and-pty-console-permissions) and +[inventory](gateway-install.md#remote-inventory-permissions) permissions for +features you use. Module management needs access to the cluster-scoped +`modules`, `modulesources`, and `gametemplates` resources; bundle uploads also +need ConfigMap access in the operator namespace. Do not use kubeconfigs +whose credentials require local files or interactive credential plugins. Use +the same operator namespace on the targets as the API's `--namespace` setting +(default `gameplane-system`). + +Standalone kubeconfigs must use HTTPS with certificate verification. HTTP, +`insecure-skip-tls-verify`, custom kubeconfig proxies, and redirects are rejected; +environment forward proxies are not used for workload connections. Use embedded +CA data for a private CA. Kubernetes API and gateway connections validate every +resolved address at dial time and reject loopback, link-local, cloud metadata, +unspecified, multicast, and address-translation bypasses. Private workload +addresses remain supported. + +For tighter network isolation, set `GAMEPLANE_REMOTE_ALLOWED_CIDRS` (or +`--remote-allowed-cidrs`) to comma-separated destination networks, such as +`10.20.0.0/16,10.30.0.0/16`. The Helm equivalent is +`api.remoteAllowedCIDRs: ["10.20.0.0/16", "10.30.0.0/16"]`. This applies to +both Kubernetes API and gateway destinations and cannot override blocked address +classes. DNS names must resolve entirely within the allowed ranges. Restart the +API after changing the policy. Add host or network firewall rules where port-level +or additional service isolation is needed. + +Register the target with an authenticated administrator session. In the examples +below, `cookies.txt` is a private cookie jar for that session and `CSRF_TOKEN` +is its `gameplane_csrf` cookie value. `PANEL_URL` is the HTTPS dashboard origin. +The API expects the kubeconfig's YAML text as the JSON string: + +```sh +jq -n --rawfile kubeconfig remote-kubeconfig.yaml \ + '{name:"remote-1",displayName:"Remote games",kubeconfig:$kubeconfig}' | + curl --fail-with-body --cookie cookies.txt \ + --header "X-Gameplane-CSRF: $CSRF_TOKEN" \ + --header 'Content-Type: application/json' \ + --data-binary @- "$PANEL_URL/clusters" +``` + +The standalone panel saves the registration and credentials in its database; +there is no central `Cluster` CR or Kubernetes Secret to create. It monitors +remote Kubernetes connectivity itself. Selecting that registration routes game +operations to the target, and never falls back to the central host. + +Automatic remote observations have bounded memory: version responses are capped +at 64 KiB, other finite Kubernetes responses at 8 MiB after decompression, and +individual JSON watch frames at 1 MiB. User log and interactive streams remain +streaming. Interactive stdin/PTY uses the same destination policy, bounds its +TLS/upgrade handshake to 1 MiB, and caps its error-control stream at 64 KiB. +Standalone notifications use snapshots every five seconds with at +most 1,024 objects per resource type and only run when a notification sink is +enabled. Oversized or incomplete snapshots are ignored; transitions shorter than +the polling interval may be missed. + +### Rotate a registered kubeconfig + +Create a replacement credential on the remote cluster, then update its existing +registration. This requires central `cluster:manage` permission: + +```sh +jq -n --rawfile kubeconfig replacement-kubeconfig.yaml '{kubeconfig:$kubeconfig}' | + curl --fail-with-body --request PUT --cookie cookies.txt \ + --header "X-Gameplane-CSRF: $CSRF_TOKEN" \ + --header 'Content-Type: application/json' \ + --data-binary @- "$PANEL_URL/clusters/remote-1/kubeconfig" +``` + +A successful request returns 204, preserves the registration identity, gateway +settings and user grants, and reloads the workload client. The API checks the +configuration before storing it; success does not confirm connectivity or remote +RBAC. Verify the cluster is healthy and required operations work before revoking +the old remote credential. Requests already in flight can finish with the old +client. Overlapping rotations return a conflict; reload before retrying. Avoid +deleting and re-registering a cluster just to replace its kubeconfig. + +## Grant workload access + +Registration does not grant workload access, including to the bootstrap admin. +Its initial role administers the central panel. In **Users**, use the **Roles** +tab to create a custom role containing the target permissions you need: + +- `cluster:read` for node inventory. +- `modules:read` and `modules:manage` for the catalog, sources, and module installation. +- `templates:read` and `templates:write` for game templates. +- The server, backup, and other namespace permissions needed for your workloads. + +Open your user in the Users tab, then under **Cluster & +namespace grants**, select the remote cluster and that role. Choose a game +namespace for a namespace grant, or **All namespaces** for an allowed role that +should apply across that remote cluster, and click **Add**. Inventory, module, +and template permissions need an **All namespaces** grant on that target; a grant +limited to one game namespace does not grant those cluster-wide operations. +Adding a grant ends that user's sessions; sign in again to use it. Grant other +users access the same way. Do not remove the bootstrap user's primary role to +add remote access. + +Remote grants cannot include central administration permissions or the built-in +admin wildcard. Use a custom workload role rather than assigning the built-in +admin role to all namespaces. Wait for the registration to connect before adding +a target grant; registration metadata can appear before its client is loaded. + +On **Modules**, select the remote cluster before installing a module or opening +**Manage sources**. Module managers can edit sources there without central +`config:manage` permission. Under **Admin settings → Backup destinations**, select +the workload cluster before adding a repository. Those credentials remain in +that cluster's game namespace. Creating a destination also requires +`destinations:manage` on the target namespace; the panel's primary admin role +alone does not grant it. Switching clusters discards an open destination form. + +## Configure remote agent access + +For a configured remote gateway, store the central client's dedicated mTLS +credentials through the standalone registration API: + +```sh +jq -n --rawfile caCert gateway-ca.crt \ + --rawfile clientCert central-client.crt --rawfile clientKey central-client.key \ + '{url:"https://remote-1-gateway.internal:8443",caCert:$caCert,clientCert:$clientCert,clientKey:$clientKey}' | + curl --fail-with-body --request PUT --cookie cookies.txt \ + --header "X-Gameplane-CSRF: $CSRF_TOKEN" \ + --header 'Content-Type: application/json' \ + --data-binary @- "$PANEL_URL/clusters/remote-1/gateway" +``` + +The gateway's `clusterID` must be `remote-1`, and its `peerURI` must match the +client certificate URI SAN. Its server certificate must match the endpoint +hostname. Use a separate trust root from the target's local agent CA. Repeat PUT +to rotate credentials; DELETE on the same endpoint removes gateway access while +retaining the cluster registration. Credential responses do not return private +keys. See [gateway routing and trust](multicluster-agent-gateway.md) for the +network and certificate requirements; that document's central Kubernetes Secret +instructions apply to combined mode, while standalone uses the API above. + +## Upgrading + +Stop the Compose services and back up the database and key separately before upgrading. +From the repository root, check out the new release or commit, then rebuild both +panel images: + +```sh +docker compose -f deploy/standalone/compose.yaml stop +# Back up gameplane-panel_panel-data and protect a separate panel-key backup. +git checkout +docker compose -f deploy/standalone/compose.yaml up -d --build +docker compose -f deploy/standalone/compose.yaml logs gameplane-api +``` + +Keep the same Compose project name, data volume, and selected key. The API applies database +migrations at startup; retain the pre-upgrade backup if you need to restore an +older version. Upgrade remote operators, gateways, and agents to matching +versions using their Helm values. Existing games continue running during a panel +restart; a remote operator upgrade may roll game pods. + +For a panel-only Helm release, repeat the installation command with the updated +chart, matching images, `--skip-crds`, and your standalone values file. Keep +`api.replicas=1` and the existing key storage, including when using PostgreSQL. + +## Troubleshooting + +| Symptom | Check | +|---|---| +| Login succeeds but returns to the login page | Use HTTPS for remote access, or `localhost` locally. The session cookie is `Secure`. | +| API cannot write `/data` | Restore volume ownership to `65532:65532`. A host bind mount needs those permissions too. | +| API reports a missing key or credential authentication failure | Restore the original `panel.key` with its database. A new key cannot decrypt existing credentials. | +| Registered cluster is unhealthy | Check the Kubernetes API address from the panel's network, embedded credentials, CA data, and target RBAC. Registration polling runs every 30 seconds. | +| Cluster appears, but workload requests return 403 | Add the user's remote cluster/namespace grant, then sign in again. Also check the registered kubeconfig's Kubernetes permissions. | +| Inventory works, but files or RCON do not | Check gateway routing, its cluster ID, certificate hostname, and central client URI SAN. Kubernetes health does not test the gateway. | +| Module uploads fail | Check ConfigMap permissions and the matching operator namespace on the panel and target. | + +Standalone system logs come from the container runtime. Use +`docker compose -f deploy/standalone/compose.yaml logs gameplane-api web` for +panel logs and the remote cluster's Kubernetes tools for operator and game logs. diff --git a/docs/unified-dashboard.md b/docs/unified-dashboard.md index c14b4292f..1f9127f6a 100644 --- a/docs/unified-dashboard.md +++ b/docs/unified-dashboard.md @@ -13,7 +13,7 @@ One central login/API presents the resources each user can access. Clusters rema - **Server detail:** canonical links carry the server's cluster and namespace. Console, files, players, settings, backups and related actions inherit that immutable resource target. Refreshing or opening a link in another tab does not depend on a previous localStorage selection. Legacy links without a cluster mean local; errors never select another site automatically. - **Backups:** snapshots, schedules and restores combine authorized scopes with optional filters. Detail, restore, create, edit and delete operations retain the originating resource's cluster and namespace. A server picker identifies duplicate names by full target. - **Search:** one authorized server search across sites. Results and navigation retain cluster, namespace and name; identical names cannot overwrite one another. -- **Infrastructure:** cluster registrations and node inventory belong to administration. A local selector can filter that administrative view. The ordinary shell no longer requires a global cluster switcher. Accounts, roles, module catalog, audit and installation settings retain their central ownership. +- **Infrastructure:** cluster registrations and node inventory belong to administration. A selector can filter that administrative view. The ordinary shell no longer requires a global cluster switcher. Accounts, roles, audit and installation settings retain their central ownership. Combined panels manage the local module catalog; [standalone panels](standalone-panel.md) select a registered remote cluster for modules and sources. - **Creation:** deployment targeting is a property of a new server, captured before creation and retained for any credential follow-up and success link. The API supplies eligible cluster/namespace placements and their templates using existing target-scoped permissions. A single eligible placement is automatic; several placements expose a location choice, initially preferring an eligible local placement and otherwise the first stable eligible choice. Changing placement revalidates the selected template and form. This UI does not introduce an automatic cross-cluster scheduler or retry creation on another site. Use existing page headers, cards, tables, menus, filters, alerts and responsive layouts. The single-site experience should remain familiar. A cluster filter changes the list being viewed, not an already-open server's identity or an outstanding operation. diff --git a/specs/023-standalone-panel/spec.md b/specs/023-standalone-panel/spec.md new file mode 100644 index 000000000..29e172be5 --- /dev/null +++ b/specs/023-standalone-panel/spec.md @@ -0,0 +1,31 @@ +# Standalone panel and API + +## Intent + +Run the panel and API independently of the operator and agents, including with Docker Compose on a host that is not a Kubernetes cluster. Start with no registered clusters and add remote clusters explicitly. Preserve the default combined Kubernetes installation. + +## Design + +`--standalone` (`GAMEPLANE_STANDALONE=true`) selects database-backed management storage and skips Kubernetes credential discovery. The API does not register a synthetic `local` cluster. Cluster registration metadata and labelled management credentials use a narrow storage interface; the existing installation delegates that interface to Kubernetes. Standalone secrets are authenticated-encrypted with a persistent key outside the database, selected by `--panel-key-file` (`GAMEPLANE_PANEL_KEY_FILE`, legacy default `/data/panel.key`; deployment profiles use separate storage at `/keys/panel.key`). Back up the database and key separately under different access policies. `--panel-key-provisioned` requires a protected existing key mounted read-only and never generates a missing key. File ownership, permissions, type and size are checked before use; generated key publication is crash durable. Losing or replacing a key must fail closed. + +Remote workloads remain Kubernetes resources reconciled by the remote operator. The API maintains remote connectivity status without requiring a central operator. Agent operations use each remote cluster's existing private gateway protocol. Registration and gateway credentials remain central and must not appear in response bodies, errors or logs. Modules are routed to an explicit remote cluster in standalone mode; management authentication, users, roles and installation settings remain central. + +Master-key rotation is an offline transaction with versioned authenticated ciphertext and a singleton key-state lock shared by credential writers. Old and new key files remain separate; a stale process cannot write under a retired key. Kubeconfig rotation changes the existing registration's credential reference with UID/concurrency checks and preserves gateway settings and grants. Standalone initial registration commits metadata and its credential atomically. + +Remote clients require verified HTTPS, no forward proxy or redirects, and dial-time destination validation. Optional operator CIDRs narrow safe public/private workload access; loopback, metadata and other unsafe address classes remain blocked. Bound decompressed responses and watch frames before decoding, and use bounded notification snapshots so a compromised endpoint cannot grow a central informer cache indefinitely. + +The dashboard derives cluster choices from the API and supports zero registered clusters. It must neither fabricate a local entry nor send background inventory requests to the panel host. Authenticated installation capabilities distinguish the modes without leaking infrastructure information before login. + +Helm exposes independent operator enablement and standalone API configuration. A documented panel-only profile suppresses workload resources and local credentials. Docker Compose contains only API and web services with persistent database and key storage. Existing combined and remote operator/gateway profiles retain their behavior. + +## Acceptance and validation + +- Start without kubeconfig, Kubernetes service credentials, operator, or agent. +- Login, manage panel settings, register/remove remote clusters and survive restart. +- Empty cluster and fleet lists are successful and contain no implicit local cluster. +- Workload, module, inventory and agent requests target only explicitly selected registered clusters and preserve RBAC. +- Secret writes preserve label isolation, optimistic concurrency and encryption; malformed input fails safely. +- Chart matrix covers combined, remote-only and standalone profiles; Compose documentation includes bootstrap and persistence. +- Repository policy requires tests/lint in CI; compile locally, add regression coverage and report CI status accurately. + +Existing Kubernetes-managed registrations are not silently imported into standalone storage. Switching an existing deployment requires deliberate configuration transfer and backups. diff --git a/test/e2e/multicluster_e2e_test.go b/test/e2e/multicluster_e2e_test.go index 181e1169c..a6dc34e8d 100644 --- a/test/e2e/multicluster_e2e_test.go +++ b/test/e2e/multicluster_e2e_test.go @@ -266,57 +266,29 @@ func TestMultiCluster_ClusterDispatchAndScopedRBAC(t *testing.T) { return false, "cluster not yet healthy: " + string(body) }) - // --- Create a GameTemplate + GameServer directly on cluster B ----------- - tmplName := fmt.Sprintf("e2e-mc-tmpl-%d", time.Now().UnixNano()) - resp, body, err = admin.Post("/templates?cluster="+clusterID, map[string]any{ - "apiVersion": "gameplane.local/v1alpha1", - "kind": "GameTemplate", - "metadata": map[string]any{"name": tmplName}, - "spec": map[string]any{ - "displayName": "E2E busybox (cluster B)", - "game": "busybox", - "version": "1", - "image": "busybox:1.36", - "command": []any{"sh", "-c", "echo remote-stream-start; export STREAM_SITE=remote; exec sh"}, - "consoleMode": "pty", - "ports": []any{ - map[string]any{"name": "noop", "containerPort": int64(12345), "advertise": true, "protocol": "TCP"}, - }, - }, + const ns = "gameplane-games" + + // Give the dedicated user explicit target permissions. Template permissions + // require a cluster-wide grant; the admin's local wildcard and the user's + // namespace grant must not authorize remote templates. + templateRole := fmt.Sprintf("e2e-mc-templates-%d", time.Now().UnixNano()) + resp, body, err = admin.Post("/roles", map[string]any{ + "name": templateRole, + "permissions": []string{"templates:read", "templates:write"}, }) if err != nil { - t.Fatalf("POST /templates?cluster=%s: %v", clusterID, err) + t.Fatalf("create remote template role: %v", err) } + resp.Body.Close() if resp.StatusCode != http.StatusCreated { - t.Fatalf("POST /templates?cluster=%s: status=%d body=%s", clusterID, resp.StatusCode, string(body)) + t.Fatalf("create remote template role: status=%d body=%s", resp.StatusCode, body) } - resp.Body.Close() t.Cleanup(func() { - r, _, _ := admin.Delete("/templates/" + tmplName + "?cluster=" + clusterID) + r, _, _ := admin.Delete("/roles/" + templateRole) if r != nil { r.Body.Close() } }) - - const ns = "gameplane-games" - - // --- A dedicated operator user for cluster-B's namespaced writes -------- - // servers:read/servers:write are NAMESPACED perms: auth.User.Can gates - // them on the request's target cluster (or an explicit cluster="*" - // binding) — a binding on some OTHER cluster never confers them, by - // design (see the cluster-gating comment on Can). The bootstrap admin's - // only binding is (cluster=local, namespace=*, role=admin), so - // admin.Post("/servers?cluster="+clusterID, ...) would 403, not 201. - // (POST /templates above is unaffected: templates:* is cluster-scoped, - // not namespaced, so it's granted by admin's cluster-wide binding on ANY - // cluster.) - // - // Grant a dedicated user an explicit (cluster=B, namespace=gameplane- - // games) binding instead of widening the admin's own grants, so this - // test doesn't depend on (or risk masking a regression in) cross-cluster - // admin scoping. It's an "operator" primary role, not another admin, to - // keep the elevated grant scoped to exactly the namespace/cluster this - // test needs. opUsername, opPassword, opID := envInstance.CreateUser(t, admin, "operator", "e2e-mc-operator") t.Cleanup(func() { r, _, _ := admin.Delete("/users/" + opID) @@ -336,6 +308,18 @@ func TestMultiCluster_ClusterDispatchAndScopedRBAC(t *testing.T) { t.Fatalf("POST /users/%s/bindings: status=%d body=%s", opID, resp.StatusCode, string(body)) } resp.Body.Close() + resp, body, err = admin.Post("/users/"+opID+"/bindings", map[string]any{ + "roleName": templateRole, + "cluster": clusterID, + "namespace": "*", + }) + if err != nil { + t.Fatalf("grant remote template role: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("grant remote template role: status=%d body=%s", resp.StatusCode, body) + } // addBinding invalidates the target user's existing sessions (see // userHandler.invalidateSessions) so a bound-in-flight session wouldn't // see the new grant anyway — log in AFTER granting the binding, not @@ -344,6 +328,45 @@ func TestMultiCluster_ClusterDispatchAndScopedRBAC(t *testing.T) { operatorClient := envInstance.APIClient(t, opUsername, opPassword) defer operatorClient.Close() + resp, body, err = admin.Post("/templates?cluster="+clusterID, map[string]any{}) + if err != nil { + t.Fatalf("local admin remote template write: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("local admin remote template write: status=%d want=403 body=%s", resp.StatusCode, body) + } + // --- Create a GameTemplate + GameServer directly on cluster B ----------- + tmplName := fmt.Sprintf("e2e-mc-tmpl-%d", time.Now().UnixNano()) + resp, body, err = operatorClient.Post("/templates?cluster="+clusterID, map[string]any{ + "apiVersion": "gameplane.local/v1alpha1", + "kind": "GameTemplate", + "metadata": map[string]any{"name": tmplName}, + "spec": map[string]any{ + "displayName": "E2E busybox (cluster B)", + "game": "busybox", + "version": "1", + "image": "busybox:1.36", + "command": []any{"sh", "-c", "echo remote-stream-start; export STREAM_SITE=remote; exec sh"}, + "consoleMode": "pty", + "ports": []any{ + map[string]any{"name": "noop", "containerPort": int64(12345), "advertise": true, "protocol": "TCP"}, + }, + }, + }) + if err != nil { + t.Fatalf("POST /templates?cluster=%s: %v", clusterID, err) + } + if resp.StatusCode != http.StatusCreated { + t.Fatalf("POST /templates?cluster=%s: status=%d body=%s", clusterID, resp.StatusCode, string(body)) + } + resp.Body.Close() + t.Cleanup(func() { + r, _, _ := operatorClient.Delete("/templates/" + tmplName + "?cluster=" + clusterID) + if r != nil { + r.Body.Close() + } + }) gsName := fmt.Sprintf("e2e-mc-gs-%d", time.Now().UnixNano()) resp, body, err = operatorClient.Post("/servers?cluster="+clusterID, map[string]any{ "apiVersion": "gameplane.local/v1alpha1", @@ -666,7 +689,7 @@ func checkClusterRemoval(t *testing.T, admin, operatorClient *APIClient, cluster if resp.StatusCode >= 300 { t.Fatalf("DELETE /servers/%s?cluster=%s: status=%d body=%s", gsName, clusterID, resp.StatusCode, string(body)) } - resp, body, err = admin.Delete("/templates/" + tmplName + "?cluster=" + clusterID) + resp, body, err = operatorClient.Delete("/templates/" + tmplName + "?cluster=" + clusterID) if err != nil { t.Fatalf("DELETE /templates/%s?cluster=%s: %v", tmplName, clusterID, err) } diff --git a/web/e2e/screenshots/slice-gameconfig.spec.ts b/web/e2e/screenshots/slice-gameconfig.spec.ts index 6d9534a38..25175348c 100644 --- a/web/e2e/screenshots/slice-gameconfig.spec.ts +++ b/web/e2e/screenshots/slice-gameconfig.spec.ts @@ -2,8 +2,8 @@ import { test, expect, type Page } from "@playwright/test"; import path from "path"; import { fileURLToPath } from "node:url"; -// Settings > Game configuration (design frames OC804, QFEg9, h5clan password -// will be removed, ffmEd new password typed) and the 404 page (Ne5TA), captured at 1440x900 @2x = 2880x1800, same as the frames. +// Settings > Game configuration (design frames OC804, QFEg9, BguST password +// will be removed, YN8cE new password typed) and the 404 page (Ne5TA), captured at 1440x900 @2x = 2880x1800, same as the frames. // Frames wxINm (read-only) and e9FnC (orphan key) are intentionally not // captured: they need per-state server fixtures. @@ -63,7 +63,7 @@ test.describe("Game configuration + 404 (Desktop — 1440x900) @screenshots", () await capture(page, "QFEg9"); }); - test("h5clan: Server Detail — Settings · Game configuration (password will be removed)", async ({ page }) => { + test("BguST: Server Detail — Settings · Game configuration (password will be removed)", async ({ page }) => { await page.goto("/servers/mc-survival"); await clickTab(page, "Settings"); await clickTab(page, "Game configuration"); @@ -72,10 +72,10 @@ test.describe("Game configuration + 404 (Desktop — 1440x900) @screenshots", () await page.mouse.move(0, 0); await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); await page.waitForTimeout(200); - await capture(page, "h5clan"); + await capture(page, "BguST"); }); - test("ffmEd: Server Detail — Settings · Game configuration (new password typed)", async ({ page }) => { + test("YN8cE: Server Detail — Settings · Game configuration (new password typed)", async ({ page }) => { await page.goto("/servers/mc-survival"); await clickTab(page, "Settings"); await clickTab(page, "Game configuration"); @@ -84,7 +84,7 @@ test.describe("Game configuration + 404 (Desktop — 1440x900) @screenshots", () await page.mouse.move(0, 0); await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); await page.waitForTimeout(200); - await capture(page, "ffmEd"); + await capture(page, "YN8cE"); }); test("Ne5TA: Page not found", async ({ page }) => { diff --git a/web/e2e/specs/live/share-links.spec.ts b/web/e2e/specs/live/share-links.spec.ts index 899126adf..77411101c 100644 --- a/web/e2e/specs/live/share-links.spec.ts +++ b/web/e2e/specs/live/share-links.spec.ts @@ -166,7 +166,9 @@ test.describe("live: share links", () => { await expect(page.getByRole("heading", { name: serverName })).toBeVisible({ timeout: 20_000 }); await detail.clickTab("Settings"); await page.getByRole("tab", { name: /^share links$/i }).click(); - await page.getByRole("main").getByRole("button", { name: "Create link" }).click(); + // The persistent header opener comes first; an empty list also renders + // the same action in its empty state (including on a fresh retry worker). + await page.getByRole("main").getByRole("button", { name: "Create link", exact: true }).first().click(); const dialog = page.getByRole("dialog", { name: new RegExp(`^create share link for ${serverName}$`, "i"), }); diff --git a/web/specs.md b/web/specs.md index b840a44d6..1c27d253e 100644 --- a/web/specs.md +++ b/web/specs.md @@ -20,6 +20,22 @@ The Gameplane dashboard is a React SPA providing a UI layer over the Gameplane A - Validate form inputs against declared server/template schema before submit - Display real-time resource status (CPU, memory, uptime) and live action metrics from RCON/agent +### Standalone panel installations + +- Authenticated `GET /admin/installation` reports `standalone`, `localCluster`, and the central API build `version`. About displays that panel version even without local Kubernetes metadata. The dashboard waits for this capability response before making local infrastructure or module requests; a failed capability request does not imply a local cluster. +- Module catalog/source background refresh failures retain the last loaded data and open drafts, with an inline Retry error. Initial load failures use the full error state. Failed identity loading has its own Retry state and is distinct from permission denial. +- Successful removal clears the currently selected registration to an explicit empty selection, preserved across reload, without selecting another workload. Removal errors stay inside the confirmation dialog and reset on dismissal/reopening. Remote Deploy labels use the cluster display name while navigation retains its registration ID. +- Standalone `/clusters` may be empty. The UI must not synthesize a local registration or fetch local inventory, templates, or module catalogs in that state. Existing fleet pages continue to render their empty states. +- Cluster managers can register an existing workload cluster from the Clusters page using its name, display name, and self-contained kubeconfig. Registration does not install an operator or make the panel host a workload node. Removing a registration requires confirmation and leaves running workloads intact. Agent features additionally require remote gateway configuration through the documented API. +- On standalone installations, Modules and Admin → Module sources require an explicitly selected registered cluster. Catalogs, source mutations, module installation, uploads, builder installation, and deployment use that same captured target. Switching the target remounts dialogs so pending form state cannot be submitted to another cluster. +- The Modules page exposes its source editor directly, so a workload module manager does not need central configuration permission to manage sources. Source controls use the catalog's target and permissions. +- Standalone backup destination settings also require an explicitly selected registered workload cluster. Destination reads and mutations retain that target; switching clusters discards open destination forms. +- Combined installations retain the local module catalog and local module deployment behavior. Account, authentication, configuration, registry, fleet, and sharing endpoints remain management APIs independent of workload selection. +- Standalone About and Updates sections do not query local Kubernetes information; upgrades use panel/API container images. +- Standalone System logs shows container-runtime guidance without opening a local Kubernetes log stream. Module reads and management controls check permissions against the selected workload cluster (local on combined installations); permissions granted on another cluster do not enable these controls. +- The local-only notifications SSE subscription starts only after installation capabilities confirm a local cluster. Standalone dashboards retain polling and do not retry an unscoped `/events` stream against the panel host. +- Standalone user grants require explicit remote selection, omit the local workload option, and keep the primary panel role separate. Remote cluster-wide roles may contain module read/manage and template read/write permissions as well as inventory and namespaced permissions. + ## Non-goals / Boundaries **Design-first rule:** Any change to the dashboard's visual surface (new page, new form field, layout shift, color/icon change) originates in `design.pen` (Pencil MCP server), not in React code. The Pencil file is the source of truth for all UI design; code-led redesigns are reverted. See docs/architecture.md and CLAUDE.md rule 1. diff --git a/web/src/components/AppLayout.test.tsx b/web/src/components/AppLayout.test.tsx index 8d41d24c5..f780bb6f1 100644 --- a/web/src/components/AppLayout.test.tsx +++ b/web/src/components/AppLayout.test.tsx @@ -65,6 +65,50 @@ async function sidebarRoot() { } describe("AppLayout", () => { + it.each([true, false])("labels module ownership for the installation mode (standalone=%s)", async (standalone) => { + const originalLocation = routerMocks.useLocation; + routerMocks.useLocation = () => ({ pathname: "/modules" } as ReturnType); + server.use(http.get("/admin/installation", () => HttpResponse.json({ standalone, localCluster: !standalone }))); + const view = renderWithQuery(); + try { + await waitFor(() => expect(view.client.getQueryData(["installation"])).toEqual({ standalone, localCluster: !standalone })); + await screen.findByTestId("outlet"); + if (standalone) { + await waitFor(() => expect(screen.queryByText("Central management.")).not.toBeInTheDocument()); + } else { + expect(await screen.findByText("Central management.")).toBeInTheDocument(); + } + } finally { + view.unmount(); + routerMocks.useLocation = originalLocation; + } + }); + + it.each([true, false])("opens local events only when installation capabilities report a local cluster (standalone=%s)", async (standalone) => { + const connect = vi.fn(); + class EventStream { + constructor(url: string) { connect(url); } + close() {} + } + vi.stubGlobal("EventSource", EventStream); + server.use(http.get("/admin/installation", () => HttpResponse.json({ standalone, localCluster: !standalone }))); + const view = renderWithQuery(); + try { + expect(connect).not.toHaveBeenCalled(); + await waitFor(() => expect(view.client.getQueryData(["installation"])).toEqual({ standalone, localCluster: !standalone })); + if (standalone) { + await userEvent.click(await screen.findByRole("button", { name: /notifications/i })); + expect(await screen.findByText("No recent activity.")).toBeInTheDocument(); + expect(connect).not.toHaveBeenCalled(); + } else { + await waitFor(() => expect(connect).toHaveBeenCalledWith("/events")); + } + } finally { + view.unmount(); + vi.unstubAllGlobals(); + } + }); + it("renders the sidebar nav items shared by all roles", async () => { server.use( http.get("/users/me", () => HttpResponse.json(makeUser({ role: "viewer" }))), diff --git a/web/src/components/AppLayout.tsx b/web/src/components/AppLayout.tsx index 622ec406c..fc51bb2ee 100644 --- a/web/src/components/AppLayout.tsx +++ b/web/src/components/AppLayout.tsx @@ -14,6 +14,7 @@ import { import { APIError } from "@/lib/api"; import { Clusters, Auth } from "@/lib/endpoints"; import { useMe, can } from "@/lib/auth"; +import { useInstallation } from "@/lib/useInstallation"; import type { User } from "@/types"; import { useEffect, useState } from "react"; import { AppShell } from "@/components/ui/AppShell"; @@ -91,6 +92,7 @@ function useAppearance( export function AppLayout() { const { data: me, error, isLoading } = useMe(); + const installation = useInstallation(); const { data: registry } = useQuery({ queryKey: ["clusters"], queryFn: () => Clusters.list() }); const canViewInventory = registry?.items.some((item) => item.canViewInventory === true) === true; const canManageInfrastructure = can(me, "cluster:manage") || canViewInventory; @@ -206,7 +208,7 @@ export function AppLayout() { : buildCrumbs(pathname); // Extract the last breadcrumb label as the mobile title const mobileTitle = crumbs.length > 0 ? crumbs[crumbs.length - 1].label : ""; - const centralManagement = ["/modules", "/users", "/admin", "/settings"].some( + const centralManagement = ["/users", "/admin", "/settings", ...(installation.data?.standalone === false ? ["/modules"] : [])].some( (prefix) => pathname === prefix || pathname.startsWith(prefix + "/"), ); @@ -242,7 +244,7 @@ export function AppLayout() { breadcrumbs={} clusterSelector={null} search={} - notifications={} + notifications={} mobileTitle={mobileTitle} user={me} onMenuClick={() => setDrawerOpen(true)} diff --git a/web/src/components/ClusterSelector.test.tsx b/web/src/components/ClusterSelector.test.tsx index d107c227c..58c385a3b 100644 --- a/web/src/components/ClusterSelector.test.tsx +++ b/web/src/components/ClusterSelector.test.tsx @@ -155,6 +155,14 @@ describe("ClusterSelector", () => { expect(getCurrentCluster()).toBe("removed-site"); }); + it("prompts for a cluster when the selection was explicitly cleared", async () => { + setCurrentCluster(""); + server.use(http.get("/clusters", () => HttpResponse.json({ items: [{ name: "east", displayName: "East", phase: "Healthy" }] }))); + renderWithQuery(); + expect(await screen.findByRole("button", { name: "Select cluster" })).toHaveTextContent("Select cluster"); + expect(getCurrentCluster()).toBe(""); + }); + it("includes an 'View all clusters' option at the bottom of the dropdown", async () => { const clusters: ClusterRegistry[] = [ { name: "local", displayName: "Local", phase: "Healthy" }, @@ -181,17 +189,18 @@ describe("ClusterSelector", () => { renderWithQuery(); - // Should render "local" as fallback + // An empty registry must not advertise a nonexistent local cluster. await waitFor(() => { - expect(screen.getByText("local")).toBeInTheDocument(); + expect(screen.getByText("Select cluster")).toBeInTheDocument(); }); + expect(screen.queryByText("local")).not.toBeInTheDocument(); const trigger = screen.getByRole("button", { name: /select cluster/i }); await userEvent.click(trigger); // Should show a helpful message await waitFor(() => { - expect(screen.getByText("No clusters available")).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: /Manage clusters/ })).toBeEnabled(); }); }); diff --git a/web/src/components/ClusterSelector.tsx b/web/src/components/ClusterSelector.tsx index 5161a6581..045dfca68 100644 --- a/web/src/components/ClusterSelector.tsx +++ b/web/src/components/ClusterSelector.tsx @@ -28,7 +28,7 @@ function getPhaseColor(phase: ClusterRegistry["phase"]): string { } function getDisplayName(cluster: ClusterRegistry | undefined | null, selectedId: string): string { - if (!cluster) return selectedId; + if (!cluster) return selectedId || "Select cluster"; return cluster.displayName || cluster.name || "local"; } @@ -44,7 +44,8 @@ export function ClusterSelector() { const clusters = data?.items ?? []; const currentCluster = clusters.find((c) => c.name === currentClusterId); - const displayName = getDisplayName(currentCluster, currentClusterId); + const displayName = data && !currentCluster && currentClusterId === "local" + ? "Select cluster" : getDisplayName(currentCluster, currentClusterId); const phase = currentCluster?.phase ?? "Unknown"; const phaseColor = getPhaseColor(phase); @@ -83,8 +84,8 @@ export function ClusterSelector() { ) : clusters.length === 0 ? ( - - No clusters available + + No clusters available · Manage clusters ) : ( <> diff --git a/web/src/components/ModuleTarget.tsx b/web/src/components/ModuleTarget.tsx new file mode 100644 index 000000000..71d701696 --- /dev/null +++ b/web/src/components/ModuleTarget.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { Card, CardContent } from "@heroui/react"; +import { Clusters } from "@/lib/endpoints"; +import { useCurrentCluster } from "@/lib/cluster"; +import { useInstallation } from "@/lib/useInstallation"; +import { ClusterSelector } from "@/components/ClusterSelector"; +import { ErrorCard } from "@/components/ui/ErrorCard"; +import { LoadingCard } from "@/components/ui/LoadingCard"; +import { useMe, can } from "@/lib/auth"; +import { APIError } from "@/lib/api"; + +const unauthorized = (error: unknown) => error instanceof APIError && error.status === 401; + +// Do not mount workload queries until the installation and target are known. +// Combined installations retain their existing local module catalog. +export function ModuleTarget({ children, loadingMessage = "Loading installation…" }: { children: (cluster: string, canManage: boolean, clusterDisplayName: string) => ReactNode; loadingMessage?: string }) { + const installation = useInstallation(); + const identity = useMe(); + const selected = useCurrentCluster(); + const registry = useQuery({ + queryKey: ["clusters"], queryFn: () => Clusters.list(), + enabled: installation.data?.standalone === true, + }); + if (installation.isPending) return ; + if (installation.isError && (!installation.data || unauthorized(installation.error))) return void installation.refetch()} />; + const renderTarget = (cluster: string, clusterDisplayName = cluster) => { + if (identity.isPending) return ; + if (identity.isError && (!identity.data || unauthorized(identity.error))) return void identity.refetch()} />; + return <> + {identity.isError && void identity.refetch()} />} + {can(identity.data, "modules:read", "*", cluster) + ? children(cluster, can(identity.data, "modules:manage", "*", cluster), clusterDisplayName) + : } + ; + }; + const installationError = installation.isError && void installation.refetch()} />; + if (!installation.data.standalone) return <>{installationError}{renderTarget("local")}; + const target = registry.data?.items.find((cluster) => cluster.name === selected); + return
+ + {installationError} + {registry.isError && registry.data && !unauthorized(registry.error) && void registry.refetch()} />} + {registry.isPending ? + : registry.isError && (!registry.data || unauthorized(registry.error)) ? void registry.refetch()} /> + : target ? renderTarget(target.name, target.displayName || target.name) + : +

Select a workload cluster

+

Modules and their sources are managed by the operator on a registered workload cluster.

+ Manage clusters +
} +
; +} diff --git a/web/src/components/RegisterClusterDialog.tsx b/web/src/components/RegisterClusterDialog.tsx new file mode 100644 index 000000000..917e0b6b9 --- /dev/null +++ b/web/src/components/RegisterClusterDialog.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button, Input, Label, Modal, ModalBackdrop, ModalContainer, ModalDialog, ModalHeader, ModalHeading, ModalBody, ModalFooter, TextArea } from "@heroui/react"; +import { Clusters } from "@/lib/endpoints"; +import { APIError } from "@/lib/api"; + +export function RegisterClusterDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const client = useQueryClient(); + const [name, setName] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [kubeconfig, setKubeconfig] = useState(""); + const register = useMutation({ + mutationFn: () => Clusters.register({ name: name.trim(), displayName: displayName.trim(), kubeconfig }), + onSuccess: async () => { + setKubeconfig(""); + onOpenChange(false); + await client.invalidateQueries({ queryKey: ["clusters"] }); + await client.invalidateQueries({ queryKey: ["fleet"] }); + }, + }); + return + + + Register cluster + +

Connect an existing workload cluster with the Gameplane operator and CRDs installed. The panel host does not become a workload node.

+
setName(event.target.value)} placeholder="production" />
+
setDisplayName(event.target.value)} />
+