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