Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions private-web/e2e/backups.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
102 changes: 90 additions & 12 deletions private-web/src/routes/ServerDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Box,
Button,
Chip,
Collapse,
Dialog,
DialogActions,
DialogContent,
Expand Down Expand Up @@ -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,
Expand All @@ -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 = (
<Stack divider={<Divider />}>
{caps.status === "ok" &&
caps.data.map((cap) => (
<BackupCapabilityRow
key={cap.type}
serverId={serverId}
cap={cap}
isAdmin={isAdmin}
onChanged={caps.reload}
/>
))}
</Stack>
);

return (
<Paper id="backups" variant="outlined" sx={{ p: 2 }}>
<Stack
Expand All @@ -1671,6 +1713,28 @@ function BackupCapabilitiesSection({
</MuiLink>
)}
</Stack>

{inactive && (
<Alert
severity="info"
sx={{ mb: 1 }}
action={
groupId && isAdmin ? (
<Button
component={RouterLink}
to={`/groups/${groupId}/backups`}
color="inherit"
size="small"
>
Set up
</Button>
) : undefined
}
>
{inactiveMessage}
</Alert>
)}

{caps.status === "loading" || caps.status === "idle" ? (
<LinearProgress />
) : caps.status === "error" ? (
Expand All @@ -1679,18 +1743,32 @@ function BackupCapabilitiesSection({
<Typography variant="body2" color="text.secondary">
No backup types registered for this server.
</Typography>
) : 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.
<>
<Button
size="small"
onClick={() => setShowInactive((s) => !s)}
endIcon={
<ExpandMoreIcon
sx={{
transform: showInactive ? "rotate(180deg)" : "none",
transition: "transform 150ms",
}}
/>
}
>
{showInactive
? "Hide backup types"
: `Show backup types (${caps.data.length})`}
</Button>
<Collapse in={showInactive}>
<Box sx={{ opacity: 0.6, mt: 1 }}>{rows}</Box>
</Collapse>
</>
) : (
<Stack divider={<Divider />}>
{caps.data.map((cap) => (
<BackupCapabilityRow
key={cap.type}
serverId={serverId}
cap={cap}
isAdmin={isAdmin}
onChanged={caps.reload}
/>
))}
</Stack>
rows
)}
</Paper>
);
Expand Down