diff --git a/private-web/e2e/backups.spec.ts b/private-web/e2e/backups.spec.ts
index f484a51b..d57a12e4 100644
--- a/private-web/e2e/backups.spec.ts
+++ b/private-web/e2e/backups.spec.ts
@@ -600,6 +600,7 @@ test.describe("server backup capabilities", () => {
sql,
}) => {
const group = await seedServerGroup(sql, { name: "snap-caps-group" });
+ await seedServerGroupBackupConfig(sql, { groupId: group.id, status: "ready" });
const device = await seedDevice(sql);
const server = await seedServer(sql, {
name: "snap-caps-srv",
@@ -633,6 +634,7 @@ test.describe("server backup capabilities", () => {
sql,
}) => {
const group = await seedServerGroup(sql, { name: "no-snap-group" });
+ await seedServerGroupBackupConfig(sql, { groupId: group.id, status: "ready" });
const server = await seedServer(sql, {
name: "no-snap-srv",
groupId: group.id,
@@ -652,6 +654,7 @@ test.describe("server backup capabilities", () => {
sql,
}) => {
const group = await seedServerGroup(sql, { name: "inflight-group" });
+ await seedServerGroupBackupConfig(sql, { groupId: group.id, status: "ready" });
const device = await seedDevice(sql);
const server = await seedServer(sql, {
name: "inflight-srv",
@@ -683,11 +686,41 @@ test.describe("server backup capabilities", () => {
await expect(backups.getByText(/kfreshsnap/)).toBeVisible();
});
+ test("with no group backup config, capabilities are collapsed behind a message", async ({
+ page,
+ sql,
+ }) => {
+ const group = await seedServerGroup(sql, { name: "unconfigured-group" });
+ const server = await seedServer(sql, {
+ name: "unconfigured-srv",
+ groupId: group.id,
+ });
+ // A declared capability, but the group has NO backup config.
+ await seedServerBackupCapability(sql, {
+ serverId: server.id,
+ type: "tamanu-postgres",
+ });
+
+ await page.goto(`/servers/${server.id}`);
+ const backups = page.locator("#backups");
+ // The message explains the toggles are inert.
+ await expect(backups.getByText(/aren't set up for this group/i)).toBeVisible();
+ // The toggle is collapsed (not visible) until expanded.
+ const toggle = backups.getByRole("switch", {
+ name: /enable tamanu-postgres backups/i,
+ });
+ await expect(toggle).toBeHidden();
+ // …but still reachable: expanding reveals it.
+ await backups.getByRole("button", { name: /show backup types/i }).click();
+ await expect(toggle).toBeVisible();
+ });
+
test("toggling a capability switch flips enabled in the DB", async ({
page,
sql,
}) => {
const group = await seedServerGroup(sql, { name: "caps-group" });
+ await seedServerGroupBackupConfig(sql, { groupId: group.id, status: "ready" });
const server = await seedServer(sql, {
name: "caps-srv",
groupId: group.id,
diff --git a/private-web/src/routes/ServerDetail.tsx b/private-web/src/routes/ServerDetail.tsx
index 12f0b9ff..d5202f98 100644
--- a/private-web/src/routes/ServerDetail.tsx
+++ b/private-web/src/routes/ServerDetail.tsx
@@ -6,6 +6,7 @@ import {
Box,
Button,
Chip,
+ Collapse,
Dialog,
DialogActions,
DialogContent,
@@ -1631,10 +1632,16 @@ function SiblingServers({
);
}
+/// The all-zero group id, used to query backup config for an ungrouped server:
+/// it always resolves to "no config" rather than erroring on a missing group.
+const NIL_UUID = "00000000-0000-0000-0000-000000000000";
+
/// Per-(server, type) backup capabilities with an admin-only enable toggle.
/// Reads `backups.capabilities`; the switch calls `backups.set_capability` and
/// refetches. Capabilities are advertised by bestool, so a server with none yet
-/// renders an explicit empty state rather than disappearing.
+/// renders an explicit empty state rather than disappearing. When the group has
+/// no active backup config the toggles are greyed + collapsed behind a message,
+/// since they have no effect until backups are set up.
function BackupCapabilitiesSection({
serverId,
groupId,
@@ -1650,6 +1657,41 @@ function BackupCapabilitiesSection({
{ server_id: serverId },
[serverId],
);
+ // Whether the group has an *active* (ready) backup config. Ungrouped servers
+ // query the nil group, which always returns no config. While this is loading
+ // we optimistically treat the section as active to avoid a grey→normal flash.
+ const config = useApi(
+ "backups",
+ "get",
+ { server_group_id: groupId ?? NIL_UUID },
+ [groupId],
+ );
+ const inactive =
+ config.status === "ok" &&
+ !(config.data != null && config.data.status === "ready");
+ const inactiveMessage = !groupId
+ ? "This server isn't in a group, so backups can't be configured for it."
+ : config.status === "ok" && config.data == null
+ ? "Backups aren't set up for this group yet, so these settings have no effect."
+ : "Backups for this group are still being set up, so these settings have no effect yet.";
+
+ const [showInactive, setShowInactive] = useState(false);
+
+ const rows = (
+ }>
+ {caps.status === "ok" &&
+ caps.data.map((cap) => (
+
+ ))}
+
+ );
+
return (
)}
+
+ {inactive && (
+
+ Set up
+
+ ) : undefined
+ }
+ >
+ {inactiveMessage}
+
+ )}
+
{caps.status === "loading" || caps.status === "idle" ? (
) : caps.status === "error" ? (
@@ -1679,18 +1743,32 @@ function BackupCapabilitiesSection({
No backup types registered for this server.
+ ) : inactive ? (
+ // Collapsed + greyed: the toggles still work (they record intent for
+ // when backups are set up), but it's clear they're dormant right now.
+ <>
+
+
+ {rows}
+
+ >
) : (
- }>
- {caps.data.map((cap) => (
-
- ))}
-
+ rows
)}
);