Skip to content

Commit ed9c494

Browse files
authored
Merge pull request #353 from beyondessential/backups-server-order
fix(ui): order the backups servers list by rank like the group page
2 parents 63042c5 + ff726da commit ed9c494

5 files changed

Lines changed: 195 additions & 142 deletions

File tree

private-web/e2e/backups.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,58 @@ test.describe("backups ready: stats + backup-now", () => {
252252
.toBe("true:2");
253253
});
254254

255+
test("servers panel groups by rank then kind, like the group page", async ({
256+
page,
257+
sql,
258+
}) => {
259+
const group = await seedServerGroup(sql, { name: "rank-order-group" });
260+
// Seed out of display order: expected order is the production bucket
261+
// (central before facility) then clone then dev, with rank subheaders —
262+
// not insertion or alphabetical order.
263+
await seedServer(sql, {
264+
groupId: group.id,
265+
name: "aaa-dev",
266+
rank: "dev",
267+
});
268+
await seedServer(sql, {
269+
groupId: group.id,
270+
name: "aaa-prod-facility",
271+
rank: "production",
272+
kind: "facility",
273+
});
274+
await seedServer(sql, {
275+
groupId: group.id,
276+
name: "ccc-clone",
277+
rank: "clone",
278+
});
279+
await seedServer(sql, {
280+
groupId: group.id,
281+
name: "zzz-prod-central",
282+
rank: "production",
283+
kind: "central",
284+
});
285+
await seedServerGroupBackupConfig(sql, {
286+
groupId: group.id,
287+
status: "ready",
288+
});
289+
290+
await page.goto(`/groups/${group.id}/backups`);
291+
292+
const panel = page
293+
.getByRole("heading", { name: "Servers", exact: true })
294+
.locator("..");
295+
await expect(panel.locator('a[href^="/servers/"]')).toHaveText([
296+
"zzz-prod-central",
297+
"aaa-prod-facility",
298+
"ccc-clone",
299+
"aaa-dev",
300+
]);
301+
// Rank subheaders bucket the rows, same as the group page.
302+
await expect(panel.getByText("production", { exact: true })).toBeVisible();
303+
await expect(panel.getByText("clone", { exact: true })).toBeVisible();
304+
await expect(panel.getByText("dev", { exact: true })).toBeVisible();
305+
});
306+
255307
test("stats render with unknown bucket bytes and recent runs", async ({
256308
page,
257309
sql,

private-web/src/routes/BackupPanel.tsx

Lines changed: 115 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
Tooltip,
2727
Typography,
2828
} from "@mui/material";
29-
import { useState } from "react";
29+
import { Fragment, useState } from "react";
3030
import BackupIcon from "@mui/icons-material/Backup";
3131
import RestoreIcon from "@mui/icons-material/SettingsBackupRestore";
3232
import DeleteIcon from "@mui/icons-material/Delete";
@@ -56,6 +56,7 @@ import {
5656
type BackupConfigStatus,
5757
type BackupConfigView,
5858
type BackupMaintenanceRun,
59+
groupServersByRank,
5960
type RecentRun,
6061
type ServerInfo,
6162
} from "../types";
@@ -1531,105 +1532,124 @@ function ServersPanel({
15311532
</TableRow>
15321533
</TableHead>
15331534
<TableBody>
1534-
{members.map((m) => {
1535-
const types = typesForServer(m.id);
1536-
if (types.length === 0) {
1537-
return (
1538-
<TableRow key={m.id}>
1539-
<TableCell>
1540-
<Stack spacing={0.5} sx={{ alignItems: "flex-start" }}>
1541-
{serverLink(m)}
1542-
{restoreControl(m.id)}
1543-
</Stack>
1544-
</TableCell>
1545-
<TableCell colSpan={3}>
1546-
<Typography variant="body2" color="text.secondary">
1547-
No backup types registered yet
1535+
{groupServersByRank(members).map(([rank, rankMembers]) => (
1536+
<Fragment key={rank ?? "_unranked"}>
1537+
{rank && (
1538+
<TableRow>
1539+
<TableCell
1540+
colSpan={5}
1541+
sx={{ borderBottom: "none", pb: 0 }}
1542+
>
1543+
<Typography
1544+
variant="overline"
1545+
color="text.secondary"
1546+
>
1547+
{rank}
15481548
</Typography>
15491549
</TableCell>
1550-
<TableCell align="right">
1551-
<Tooltip title="This server hasn't registered any backup types yet.">
1552-
{/* span so the tooltip works on the disabled button */}
1553-
<span>
1554-
<Button
1555-
size="small"
1556-
variant="outlined"
1557-
startIcon={<BackupIcon />}
1558-
disabled
1559-
>
1560-
Backup now
1561-
</Button>
1562-
</span>
1563-
</Tooltip>
1564-
</TableCell>
15651550
</TableRow>
1566-
);
1567-
}
1568-
return types.map((t, i) => {
1569-
const cap = capFor(m.id, t);
1570-
return (
1571-
<TableRow key={`${m.id}:${t}`}>
1572-
{i === 0 && (
1573-
<TableCell
1574-
rowSpan={types.length}
1575-
sx={{ verticalAlign: "top" }}
1576-
>
1577-
<Stack
1578-
spacing={0.5}
1579-
sx={{ alignItems: "flex-start" }}
1580-
>
1581-
{serverLink(m)}
1582-
{restoreControl(m.id)}
1583-
</Stack>
1584-
</TableCell>
1585-
)}
1586-
<TableCell>
1587-
<Stack
1588-
direction="row"
1589-
spacing={1}
1590-
sx={{ alignItems: "center" }}
1591-
>
1592-
<Typography
1593-
variant="body2"
1594-
sx={{ fontFamily: "monospace" }}
1595-
>
1596-
{t}
1597-
</Typography>
1598-
{cap?.enabled === false && (
1599-
<Tooltip title="Not on the backup schedule for this server (toggle it on in the server's Backups section). You can still back it up on demand.">
1600-
<Chip
1601-
size="small"
1602-
variant="outlined"
1603-
label="not scheduled"
1604-
/>
1551+
)}
1552+
{rankMembers.map((m) => {
1553+
const types = typesForServer(m.id);
1554+
if (types.length === 0) {
1555+
return (
1556+
<TableRow key={m.id}>
1557+
<TableCell>
1558+
<Stack spacing={0.5} sx={{ alignItems: "flex-start" }}>
1559+
{serverLink(m)}
1560+
{restoreControl(m.id)}
1561+
</Stack>
1562+
</TableCell>
1563+
<TableCell colSpan={3}>
1564+
<Typography variant="body2" color="text.secondary">
1565+
No backup types registered yet
1566+
</Typography>
1567+
</TableCell>
1568+
<TableCell align="right">
1569+
<Tooltip title="This server hasn't registered any backup types yet.">
1570+
{/* span so the tooltip works on the disabled button */}
1571+
<span>
1572+
<Button
1573+
size="small"
1574+
variant="outlined"
1575+
startIcon={<BackupIcon />}
1576+
disabled
1577+
>
1578+
Backup now
1579+
</Button>
1580+
</span>
16051581
</Tooltip>
1582+
</TableCell>
1583+
</TableRow>
1584+
);
1585+
}
1586+
return types.map((t, i) => {
1587+
const cap = capFor(m.id, t);
1588+
return (
1589+
<TableRow key={`${m.id}:${t}`}>
1590+
{i === 0 && (
1591+
<TableCell
1592+
rowSpan={types.length}
1593+
sx={{ verticalAlign: "top" }}
1594+
>
1595+
<Stack
1596+
spacing={0.5}
1597+
sx={{ alignItems: "flex-start" }}
1598+
>
1599+
{serverLink(m)}
1600+
{restoreControl(m.id)}
1601+
</Stack>
1602+
</TableCell>
16061603
)}
1607-
<BackupProcessingChip
1608-
since={cap?.processing_since}
1609-
/>
1610-
</Stack>
1611-
</TableCell>
1612-
<TableCell>
1613-
{cap?.next_backup_at ? (
1614-
<TimeAgo timestamp={cap.next_backup_at} />
1615-
) : (
1616-
<Typography variant="body2" color="text.secondary">
1617-
1618-
</Typography>
1619-
)}
1620-
</TableCell>
1621-
<TableCell>
1622-
<LatestSnapshot
1623-
id={cap?.latest_snapshot_id}
1624-
at={cap?.latest_snapshot_at}
1625-
bytes={cap?.latest_snapshot_bytes}
1626-
/>
1627-
</TableCell>
1628-
<TableCell align="right">{actionCell(m.id, t)}</TableCell>
1629-
</TableRow>
1630-
);
1631-
});
1632-
})}
1604+
<TableCell>
1605+
<Stack
1606+
direction="row"
1607+
spacing={1}
1608+
sx={{ alignItems: "center" }}
1609+
>
1610+
<Typography
1611+
variant="body2"
1612+
sx={{ fontFamily: "monospace" }}
1613+
>
1614+
{t}
1615+
</Typography>
1616+
{cap?.enabled === false && (
1617+
<Tooltip title="Not on the backup schedule for this server (toggle it on in the server's Backups section). You can still back it up on demand.">
1618+
<Chip
1619+
size="small"
1620+
variant="outlined"
1621+
label="not scheduled"
1622+
/>
1623+
</Tooltip>
1624+
)}
1625+
<BackupProcessingChip
1626+
since={cap?.processing_since}
1627+
/>
1628+
</Stack>
1629+
</TableCell>
1630+
<TableCell>
1631+
{cap?.next_backup_at ? (
1632+
<TimeAgo timestamp={cap.next_backup_at} />
1633+
) : (
1634+
<Typography variant="body2" color="text.secondary">
1635+
1636+
</Typography>
1637+
)}
1638+
</TableCell>
1639+
<TableCell>
1640+
<LatestSnapshot
1641+
id={cap?.latest_snapshot_id}
1642+
at={cap?.latest_snapshot_at}
1643+
bytes={cap?.latest_snapshot_bytes}
1644+
/>
1645+
</TableCell>
1646+
<TableCell align="right">{actionCell(m.id, t)}</TableCell>
1647+
</TableRow>
1648+
);
1649+
});
1650+
})}
1651+
</Fragment>
1652+
))}
16331653
</TableBody>
16341654
</Table>
16351655
</Box>

private-web/src/routes/GroupDetail.tsx

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,10 @@ import {
2626
BACKUP_STATUS_INTENT,
2727
BACKUP_STATUS_LABEL,
2828
type BackupConfigStatus,
29-
SERVER_RANK_ORDER,
3029
aggregateOperators,
31-
compareServersByRankThenKind,
30+
groupServersByRank,
3231
type AggregatedOperator,
3332
type IncidentData,
34-
type ServerInfo,
35-
type ServerRank,
3633
} from "../types";
3734

3835
export default function GroupDetail() {
@@ -419,31 +416,6 @@ function ActiveIncidentCard({ incident }: { incident: IncidentData }) {
419416
);
420417
}
421418

422-
/// Group a flat server list into rank buckets in display order, with
423-
/// each bucket internally sorted by kind (centrals first) then name.
424-
/// Servers without a rank land in a trailing `null` bucket.
425-
function groupServersByRank(
426-
servers: ServerInfo[],
427-
): Array<[ServerRank | null, ServerInfo[]]> {
428-
const buckets = new Map<ServerRank | null, ServerInfo[]>();
429-
for (const s of servers) {
430-
const rank = s.rank ?? null;
431-
const list = buckets.get(rank);
432-
if (list) list.push(s);
433-
else buckets.set(rank, [s]);
434-
}
435-
const order: Array<ServerRank | null> = [...SERVER_RANK_ORDER, null];
436-
const result: Array<[ServerRank | null, ServerInfo[]]> = [];
437-
for (const rank of order) {
438-
const list = buckets.get(rank);
439-
if (list && list.length > 0) {
440-
list.sort(compareServersByRankThenKind);
441-
result.push([rank, list]);
442-
}
443-
}
444-
return result;
445-
}
446-
447419
function ArchivedGroupBanner({
448420
groupId,
449421
isAdmin,

private-web/src/routes/ServerDetail.tsx

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ import { humanSeconds } from "../lib/humanDuration";
7272
import ServerNameWithGroup from "../components/ServerNameWithGroup";
7373
import {
7474
CHECK_RESULT_ORDER,
75-
SERVER_RANK_ORDER,
7675
checkResultOf,
7776
compareServersByRankThenKind,
77+
groupServersByRank,
7878
healthcheckPath,
7979
type CheckResult,
8080
type DeviceInfo,
@@ -86,7 +86,6 @@ import {
8686
type ServerGroupSilencedRef,
8787
type ServerInfo,
8888
type ServerLastStatusData,
89-
type ServerRank,
9089
type ServerSilencedRef,
9190
type ShortStatus,
9291
} from "../types";
@@ -1497,22 +1496,7 @@ function SiblingServers({
14971496
// reader scanning ServerDetail's sibling section sees the production
14981497
// peers up top and the dev scratch at the bottom in a predictable
14991498
// order. Unranked servers fall into a trailing `null` bucket.
1500-
const buckets = new Map<ServerRank | null, ServerDetailData["siblings"]>();
1501-
for (const sib of siblings) {
1502-
const rank = sib.rank ?? null;
1503-
const list = buckets.get(rank);
1504-
if (list) list.push(sib);
1505-
else buckets.set(rank, [sib]);
1506-
}
1507-
const order: Array<ServerRank | null> = [...SERVER_RANK_ORDER, null];
1508-
const groups: Array<[ServerRank | null, ServerDetailData["siblings"]]> = [];
1509-
for (const rank of order) {
1510-
const list = buckets.get(rank);
1511-
if (list && list.length > 0) {
1512-
list.sort(compareServersByRankThenKind);
1513-
groups.push([rank, list]);
1514-
}
1515-
}
1499+
const groups = groupServersByRank(siblings);
15161500

15171501
return (
15181502
<Box>

0 commit comments

Comments
 (0)