From a81fbfb643db8fcee7462db7ef67fc89e5e57e0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Fri, 26 Jun 2026 06:10:32 +1200 Subject: [PATCH] feat(private-web): grey + collapse server backups when the group is unconfigured On server-detail, when the server's group has no active (ready) backup config, the Backups section shows an explanatory message and the per-type capability rows are greyed and collapsed behind a 'show backup types' expander. The toggles stay selectable (they record intent for when backups are set up) but it's clear they're dormant. Ungrouped servers query the nil group (always unconfigured). Co-Authored-By: Claude Opus 4.8 --- private-web/e2e/backups.spec.ts | 33 ++++++++ private-web/src/routes/ServerDetail.tsx | 102 +++++++++++++++++++++--- 2 files changed, 123 insertions(+), 12 deletions(-) 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 )} );