diff --git a/charts/gameplane/crd-manifests/gameplane.local_backups.yaml b/charts/gameplane/crd-manifests/gameplane.local_backups.yaml index e3db1991a..3e827695c 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_backups.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_backups.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: backups.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml b/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml index b3255041b..b2f4b4a41 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: backupschedules.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml b/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml index 35173d8b7..3cbd485ee 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: clusters.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml b/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml index 30559c941..4d27fdf06 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: gameservers.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml b/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml index fccbfdecc..db3ad51aa 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: gametemplates.gameplane.local spec: @@ -222,6 +222,15 @@ spec: description: ActionParamSpec is a single input collected before running an action. properties: + currentFrom: + description: |- + CurrentFrom names a status metric (spec.capabilities.status.metrics[].id) + whose live value pre-fills this input in the dashboard dialog, so a + "set" action opens on the current setting. Default applies when the + metric has no reading or (for Type=enum) no option matches it. + maxLength: 63 + pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ + type: string default: description: Default is the pre-filled value (as a string). diff --git a/charts/gameplane/crd-manifests/gameplane.local_modules.yaml b/charts/gameplane/crd-manifests/gameplane.local_modules.yaml index e3bdec549..d9d2e2b67 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_modules.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_modules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: modules.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml b/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml index f2b13ee05..a315bba4d 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: modulesources.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml b/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml index 28d362d97..eb4e1558f 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: networkcaptures.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_restores.yaml b/charts/gameplane/crd-manifests/gameplane.local_restores.yaml index 897ee35b9..d1e676178 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_restores.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_restores.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: restores.gameplane.local spec: diff --git a/charts/gameplane/crds/gameplane.local_backups.yaml b/charts/gameplane/crds/gameplane.local_backups.yaml index e3db1991a..3e827695c 100644 --- a/charts/gameplane/crds/gameplane.local_backups.yaml +++ b/charts/gameplane/crds/gameplane.local_backups.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: backups.gameplane.local spec: diff --git a/charts/gameplane/crds/gameplane.local_backupschedules.yaml b/charts/gameplane/crds/gameplane.local_backupschedules.yaml index b3255041b..b2f4b4a41 100644 --- a/charts/gameplane/crds/gameplane.local_backupschedules.yaml +++ b/charts/gameplane/crds/gameplane.local_backupschedules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: backupschedules.gameplane.local spec: diff --git a/charts/gameplane/crds/gameplane.local_clusters.yaml b/charts/gameplane/crds/gameplane.local_clusters.yaml index 35173d8b7..3cbd485ee 100644 --- a/charts/gameplane/crds/gameplane.local_clusters.yaml +++ b/charts/gameplane/crds/gameplane.local_clusters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: clusters.gameplane.local spec: diff --git a/charts/gameplane/crds/gameplane.local_gameservers.yaml b/charts/gameplane/crds/gameplane.local_gameservers.yaml index 30559c941..4d27fdf06 100644 --- a/charts/gameplane/crds/gameplane.local_gameservers.yaml +++ b/charts/gameplane/crds/gameplane.local_gameservers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: gameservers.gameplane.local spec: diff --git a/charts/gameplane/crds/gameplane.local_gametemplates.yaml b/charts/gameplane/crds/gameplane.local_gametemplates.yaml index fccbfdecc..db3ad51aa 100644 --- a/charts/gameplane/crds/gameplane.local_gametemplates.yaml +++ b/charts/gameplane/crds/gameplane.local_gametemplates.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: gametemplates.gameplane.local spec: @@ -222,6 +222,15 @@ spec: description: ActionParamSpec is a single input collected before running an action. properties: + currentFrom: + description: |- + CurrentFrom names a status metric (spec.capabilities.status.metrics[].id) + whose live value pre-fills this input in the dashboard dialog, so a + "set" action opens on the current setting. Default applies when the + metric has no reading or (for Type=enum) no option matches it. + maxLength: 63 + pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ + type: string default: description: Default is the pre-filled value (as a string). diff --git a/charts/gameplane/crds/gameplane.local_modules.yaml b/charts/gameplane/crds/gameplane.local_modules.yaml index e3bdec549..d9d2e2b67 100644 --- a/charts/gameplane/crds/gameplane.local_modules.yaml +++ b/charts/gameplane/crds/gameplane.local_modules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: modules.gameplane.local spec: diff --git a/charts/gameplane/crds/gameplane.local_modulesources.yaml b/charts/gameplane/crds/gameplane.local_modulesources.yaml index f2b13ee05..a315bba4d 100644 --- a/charts/gameplane/crds/gameplane.local_modulesources.yaml +++ b/charts/gameplane/crds/gameplane.local_modulesources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: modulesources.gameplane.local spec: diff --git a/charts/gameplane/crds/gameplane.local_networkcaptures.yaml b/charts/gameplane/crds/gameplane.local_networkcaptures.yaml index 28d362d97..eb4e1558f 100644 --- a/charts/gameplane/crds/gameplane.local_networkcaptures.yaml +++ b/charts/gameplane/crds/gameplane.local_networkcaptures.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: networkcaptures.gameplane.local spec: diff --git a/charts/gameplane/crds/gameplane.local_restores.yaml b/charts/gameplane/crds/gameplane.local_restores.yaml index 897ee35b9..d1e676178 100644 --- a/charts/gameplane/crds/gameplane.local_restores.yaml +++ b/charts/gameplane/crds/gameplane.local_restores.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 13361941435c18767a393a107f2a80242b0e84339630ad0db002d46d00e1e62d + gameplane.local/crd-bundle-sha256: 5fa59369664cfab2e0c8ccedfd665ca4dfbd1f3f933e778bd8e1d6bae52c6eea controller-gen.kubebuilder.io/version: v0.20.1 name: restores.gameplane.local spec: diff --git a/docs/module-authoring.md b/docs/module-authoring.md index 7febbbfaf..bd49903e4 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -1214,6 +1214,15 @@ capabilities: agent — the shared `gameaction` module — so neither is trusted blind. - A command template that fails to parse disables only that one action (logged), never the whole panel. +- **`currentFrom`** (optional, on a param) names a + `capabilities.status.metrics[].id`. When the dialog opens, the dashboard + pre-fills the param with that metric's live value, so a "set" action + starts on the current setting. An `enum` param takes the option that + matches case-insensitively (a reading of `Easy` selects `easy`), a `bool` + takes `true`/`false` and an `int` takes an integer. With no reading, or + no valid match, the param falls back to `default`; the user's own choice + always wins. Readings come from the Status metrics below, so they need + RCON. Minecraft's `set-difficulty` uses `currentFrom: difficulty`. #### Status diff --git a/operator/api/v1alpha1/gametemplate_types.go b/operator/api/v1alpha1/gametemplate_types.go index c6029d90b..e25abeab1 100644 --- a/operator/api/v1alpha1/gametemplate_types.go +++ b/operator/api/v1alpha1/gametemplate_types.go @@ -685,6 +685,15 @@ type ActionParamSpec struct { // +optional Default string `json:"default,omitempty"` + // CurrentFrom names a status metric (spec.capabilities.status.metrics[].id) + // whose live value pre-fills this input in the dashboard dialog, so a + // "set" action opens on the current setting. Default applies when the + // metric has no reading or (for Type=enum) no option matches it. + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` + // +kubebuilder:validation:MaxLength=63 + // +optional + CurrentFrom string `json:"currentFrom,omitempty"` + // Enum restricts valid values when Type=enum. // +optional Enum []string `json:"enum,omitempty"` diff --git a/operator/config/crd/gameplane.local_gametemplates.yaml b/operator/config/crd/gameplane.local_gametemplates.yaml index 3b0034f6a..89bee926d 100644 --- a/operator/config/crd/gameplane.local_gametemplates.yaml +++ b/operator/config/crd/gameplane.local_gametemplates.yaml @@ -221,6 +221,15 @@ spec: description: ActionParamSpec is a single input collected before running an action. properties: + currentFrom: + description: |- + CurrentFrom names a status metric (spec.capabilities.status.metrics[].id) + whose live value pre-fills this input in the dashboard dialog, so a + "set" action opens on the current setting. Default applies when the + metric has no reading or (for Type=enum) no option matches it. + maxLength: 63 + pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ + type: string default: description: Default is the pre-filled value (as a string). diff --git a/web/specs.md b/web/specs.md index 181faaad1..4c8b764fc 100644 --- a/web/specs.md +++ b/web/specs.md @@ -316,7 +316,7 @@ Console input/output and Logs streaming use existing bidirectional WebSocket (co - **WipeServerDialog.tsx** (123 lines) — Wipe-world confirmation from the HeroUI `AlertDialog` family plus `Checkbox` (import lines 3–14) — not ui/ConfirmDialog - **DeleteServerDialog.tsx** (52 lines) — Delete server confirmation via ui/ ConfirmDialog (line 2) - **ServerStatusCard.tsx** (73 lines) — Status summary card in Overview tab, built on HeroUI Card (line 3); displays "—" when the agent reports no game version instead of falling back to template name -- **ServerActionsCard.tsx** (487 lines) — Lifecycle action card in Overview tab; HeroUI Button/Card/CardHeader/CardContent/Modal family/Input/Label/Select/ListBox/ListBoxItem/Checkbox/Description/FieldError (import lines 20–39) +- **ServerActionsCard.tsx** (487 lines) — Lifecycle action card in Overview tab; HeroUI Button/Card/CardHeader/CardContent/Modal family/Input/Label/Select/ListBox/ListBoxItem/Checkbox/Description/FieldError (import lines 20–39). A param with `currentFrom` is pre-filled from that status metric's live reading (shared `server-status` query; enum options match case-insensitively), falling back to `default`; the user's edits always take precedence - **ServerSleepCard.tsx** (170 lines) — Server sleep/idle state summary; HeroUI Card/Alert (line 2) plus a `Chip` re-exported from ui/PhaseChip (line 6) - **EventList.tsx** (43 lines) — Kubernetes event list renderer, used from both Events.tsx (line 6) and Overview.tsx (line 12) - **PortOverridesEditor.tsx** (80 lines) — Port configuration helper; HeroUI Input/Button (line 1) (Settings tab, deferred) diff --git a/web/src/components/server/ServerActionsCard.test.tsx b/web/src/components/server/ServerActionsCard.test.tsx index 3078f4e45..5a5deb2b7 100644 --- a/web/src/components/server/ServerActionsCard.test.tsx +++ b/web/src/components/server/ServerActionsCard.test.tsx @@ -112,6 +112,92 @@ describe("ServerActionsCard", () => { ); }); + describe("currentFrom", () => { + const difficultyAction: ServerActionDecl = { + id: "set-difficulty", + displayName: "Set difficulty", + params: [ + { + name: "level", + displayName: "Difficulty", + type: "enum", + enum: ["peaceful", "easy", "normal", "hard"], + default: "normal", + required: true, + currentFrom: "difficulty", + }, + ], + }; + + // routeStatus layers a /servers/s1/status answer over routeFetch. + function routeStatus(readings: unknown) { + const base = fetchMock.getMockImplementation(); + fetchMock.mockImplementation((url: string, opts?: { method?: string; body?: string }) => + url.endsWith("/servers/s1/status") + ? Promise.resolve(jsonRes(readings)) + : base?.(url, opts), + ); + } + + it("pre-fills an enum param from its status metric, matching case-insensitively", async () => { + const runs: RunCall[] = []; + routeFetch("operator", runs); + routeStatus([{ id: "difficulty", value: "Easy" }]); + renderWithQuery(); + const open = await screen.findByRole("button", { name: /set difficulty/i }); + await waitFor(() => expect(open).not.toBeDisabled()); + fireEvent.click(open); + + // The Select trigger is a button named by its label (see the enum test above). + const trigger = screen.getByRole("button", { name: /difficulty/i }); + await waitFor(() => expect(trigger).toHaveTextContent("easy")); + fireEvent.click(screen.getByRole("button", { name: "Run" })); + await waitFor(() => + expect(runs).toEqual([{ id: "set-difficulty", params: { level: "easy" } }]), + ); + }); + + it("keeps the user's choice over the live reading", async () => { + const runs: RunCall[] = []; + routeFetch("operator", runs); + routeStatus([{ id: "difficulty", value: "Easy" }]); + renderWithQuery(); + const open = await screen.findByRole("button", { name: /set difficulty/i }); + await waitFor(() => expect(open).not.toBeDisabled()); + fireEvent.click(open); + + const trigger = screen.getByRole("button", { name: /difficulty/i }); + await waitFor(() => expect(trigger).toHaveTextContent("easy")); + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("option", { name: "hard" })); + await waitFor(() => expect(trigger).toHaveTextContent("hard")); + fireEvent.click(screen.getByRole("button", { name: "Run" })); + await waitFor(() => + expect(runs).toEqual([{ id: "set-difficulty", params: { level: "hard" } }]), + ); + }); + + it("falls back to the default when the reading matches no option", async () => { + const runs: RunCall[] = []; + routeFetch("operator", runs); + routeStatus([{ id: "difficulty", value: "Extreme" }]); + renderWithQuery(); + await waitFor(() => + expect( + fetchMock.mock.calls.some(([u]) => String(u).endsWith("/servers/s1/status")), + ).toBe(true), + ); + const open = await screen.findByRole("button", { name: /set difficulty/i }); + await waitFor(() => expect(open).not.toBeDisabled()); + fireEvent.click(open); + + fireEvent.click(await screen.findByRole("button", { name: "Run" })); + await waitFor(() => + expect(runs).toEqual([{ id: "set-difficulty", params: { level: "normal" } }]), + ); + }); + }); + it("disables actions for a viewer", async () => { routeFetch("viewer", []); renderWithQuery( diff --git a/web/src/components/server/ServerActionsCard.tsx b/web/src/components/server/ServerActionsCard.tsx index d489d478b..a761ede73 100644 --- a/web/src/components/server/ServerActionsCard.tsx +++ b/web/src/components/server/ServerActionsCard.tsx @@ -1,6 +1,6 @@ import { useResourceClient, useResourceTarget, resourceKey, useResourceAccess } from "@/lib/resourceTarget"; import { useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Backpack, Ban, @@ -56,7 +56,7 @@ import { Description, } from "@heroui/react"; -import type { ActionParamDecl, GameServer, GameTemplate, ServerActionDecl } from "@/types"; +import type { ActionParamDecl, GameServer, GameTemplate, ServerActionDecl, StatusReading } from "@/types"; import { type LifecycleVerb } from "@/lib/endpoints"; import { rconAvailable } from "@/lib/capabilities"; import { APIError } from "@/lib/api"; @@ -147,6 +147,17 @@ export function ServerActionsCard({ const actions = tmpl?.spec.capabilities?.actions ?? []; const hasRcon = rconAvailable(tmpl); + // Live readings for params that pre-fill from a status metric. Same key as + // ServerStatusCard, so both share one cache entry and one poll. + const wantsStatus = actions.some((a) => a.params?.some((p) => p.currentFrom)); + const { data: readings } = useQuery({ + queryKey: resourceKey(resourceTarget, "server-status", name, ns), + queryFn: ({ signal }) => resourceClient.withSignal(signal).Servers.status(name, ns), + enabled: wantsStatus && hasRcon, + staleTime: 10_000, + retry: false, + }); + const lifecycle = useMutation({ mutationFn: (verb: LifecycleVerb) => Servers.lifecycle(name, verb, ns), onSuccess: () => qc.invalidateQueries({ queryKey: resourceKey(resourceTarget, "server", name, ns) }), @@ -355,6 +366,7 @@ export function ServerActionsCard({ {active && ( setActive(null)} onRun={(params) => run.mutate({ action: active, params })} @@ -370,18 +382,27 @@ export function ServerActionsCard({ // trip and to render the right input per type. function ActionDialog({ action, + readings, pending, onCancel, onRun, }: { action: ServerActionDecl; + readings?: StatusReading[]; pending: boolean; onCancel: () => void; onRun: (params: Record) => void; }) { const params = action.params ?? []; - const [values, setValues] = useState>(() => - Object.fromEntries(params.map((p) => [p.name, p.default ?? defaultFor(p)])), + // Only the user's edits are state. Everything else is derived per render, + // so a reading that lands after the dialog opened still pre-fills its + // input until the user picks a value themselves. + const [edits, setEdits] = useState>({}); + const values: Record = Object.fromEntries( + params.map((p) => [ + p.name, + edits[p.name] ?? currentValue(p, readings) ?? p.default ?? defaultFor(p), + ]), ); const errors = params @@ -407,7 +428,7 @@ function ActionDialog({ key={p.name} param={p} value={values[p.name] ?? ""} - onChange={(v) => setValues((prev) => ({ ...prev, [p.name]: v }))} + onChange={(v) => setEdits((prev) => ({ ...prev, [p.name]: v }))} /> ))} {params.length === 0 && ( @@ -524,6 +545,27 @@ function defaultFor(p: ActionParamDecl): string { return ""; } +// currentValue maps the live metric named by p.currentFrom onto a value the +// input accepts, or undefined when there is no usable reading. Enum options +// match case-insensitively: Minecraft reports "Easy" for the option "easy". +function currentValue(p: ActionParamDecl, readings?: StatusReading[]): string | undefined { + if (!p.currentFrom) return undefined; + const raw = readings?.find((r) => r.id === p.currentFrom)?.value.trim(); + if (!raw) return undefined; + switch (p.type) { + case "enum": + return p.enum?.find((o) => o.toLowerCase() === raw.toLowerCase()); + case "bool": { + const b = raw.toLowerCase(); + return b === "true" || b === "false" ? b : undefined; + } + case "int": + return /^-?\d+$/.test(raw) ? raw : undefined; + default: + return raw; + } +} + // validate returns an error string or null. Mirrors the agent's // server-side checks so the UI fails fast, but the agent remains the // source of truth (it also rejects control characters etc.). diff --git a/web/src/types.ts b/web/src/types.ts index 7e37e89f1..ba6edd374 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -19,6 +19,8 @@ export interface ActionParamDecl { default?: string; enum?: string[]; required?: boolean; + // Status metric id whose live value pre-fills this param in the dialog. + currentFrom?: string; } // A module-declared operator action surfaced as a button on the server