From e4718b814f7ba0c6e22a89e5ff70c6f49cae84a7 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Sat, 29 Aug 2026 10:03:59 -0400 Subject: [PATCH] refactor(client): uniform card widths, shared primitives, clearer titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four widths (4/6/8/12) put four different card sizes on one page, and the 4-vs-6 difference read as inconsistency rather than intent. There are now two: every card is half-width and pairs down the page, and only Monitor status is full-width because it lays six counts across a single row. Currently down is half-width too. Seven columns would crush the monitor name there, so its rows are now two lines — name and downtime lead, with type, status code and the sparkline in a quieter line beneath. No information is lost. This collapses the layout logic: two halves fill a row exactly and a full card fills its own, so nothing is ever stretched to close a gap. The only remaining decision is a lone trailing half, promoted to full so the page does not end ragged. Verified across the catalogue, the non-admin subset, the defaults and single-card cases: no mid-page gaps anywhere. Componentization — there was real duplication: - StatLine was defined twice and MetricLine was a third copy of the same thing. All three are now CardStatLine. - A label + value + bar row appeared five times across the breakdown and ranking cards. It is now CardMeterRow. - Monitors by type and by group were the same card over different keys, so they share a BreakdownCard shell. Between them they drop from 232 lines to 83. Titles, checked against what each card actually renders: - Checks on schedule -> Check queue. It reports queue jobs and workers, not the checks themselves. - Maintenance -> Maintenance windows, Team -> Team members. Bare nouns read as navigation rather than as what the card reports. - The picker no longer borrows the sidebar's group labels. The Logs page hosts logs, queue and diagnostics tabs, so "Logs" misdescribed the three cards under it; that group is now "System". Checkmate server keeps its name — it does report on the server process. Picker buttons: Reset to defaults moves to the left, away from Done, since a destructive action should not sit next to the one people reach for by reflex. Done becomes the primary contained button. Dialog gains an optional cancelSx to allow that placement. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/inputs/Dialog.tsx | 5 + .../Pages/Dashboard/Components/CardPicker.tsx | 37 +++--- .../Dashboard/Components/CardPrimitives.tsx | 95 +++++++++++++++ .../Dashboard/Components/Cards/AdminCards.tsx | 45 +------ .../Components/Cards/BreakdownCard.tsx | 106 +++++++++++++++++ .../Components/Cards/CheckmateServerCard.tsx | 45 +------ .../Components/Cards/CurrentlyDownCard.tsx | 111 ++++++++++-------- .../Components/Cards/MonitorsByGroupCard.tsx | 99 ++-------------- .../Components/Cards/MonitorsByTypeCard.tsx | 104 ++-------------- .../Components/Cards/RankingCards.tsx | 54 +++------ .../Components/Cards/SummaryCards.tsx | 36 +----- client/src/Pages/Dashboard/cards.ts | 14 ++- client/src/Pages/Dashboard/layout.ts | 63 ++++------ client/src/Pages/Dashboard/registry.ts | 32 +++-- client/src/locales/en.json | 19 ++- 15 files changed, 405 insertions(+), 460 deletions(-) create mode 100644 client/src/Pages/Dashboard/Components/Cards/BreakdownCard.tsx diff --git a/client/src/Components/inputs/Dialog.tsx b/client/src/Components/inputs/Dialog.tsx index 02d164abd..80d260a55 100644 --- a/client/src/Components/inputs/Dialog.tsx +++ b/client/src/Components/inputs/Dialog.tsx @@ -9,6 +9,7 @@ import { Button } from "@/Components/inputs"; import { LAYOUT } from "@/Utils/Theme/constants"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; +import type { SxProps } from "@mui/material/styles"; import type { ReactNode } from "react"; export const DialogInput = ({ @@ -25,6 +26,7 @@ export const DialogInput = ({ maxWidth, fullWidth = false, additionalButtons, + cancelSx, }: { open: boolean; title?: string; @@ -39,6 +41,8 @@ export const DialogInput = ({ maxWidth?: DialogProps["maxWidth"]; fullWidth?: boolean; additionalButtons?: ReactNode; + /** Styles the cancel button — e.g. an auto margin to push it left. */ + cancelSx?: SxProps; }) => { const { t } = useTranslation(); const theme = useTheme(); @@ -94,6 +98,7 @@ export const DialogInput = ({ loading={loading} variant="outlined" onClick={onCancel} + sx={cancelSx} > {cancelText ?? t("common.buttons.cancel")} diff --git a/client/src/Pages/Dashboard/Components/CardPicker.tsx b/client/src/Pages/Dashboard/Components/CardPicker.tsx index e60653cac..2ff927142 100644 --- a/client/src/Pages/Dashboard/Components/CardPicker.tsx +++ b/client/src/Pages/Dashboard/Components/CardPicker.tsx @@ -5,7 +5,7 @@ import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; import { useMemo } from "react"; -import { Dialog, Checkbox, Button } from "@/Components/inputs"; +import { Dialog, Checkbox } from "@/Components/inputs"; import { LAYOUT } from "@/Utils/Theme/constants"; import { typographyLevels } from "@/Utils/Theme/Palette"; import { CardGroups, type CardDefinition, type CardGroup, type CardId } from "../cards"; @@ -52,21 +52,17 @@ export const CardPicker = ({ maxWidth="sm" fullWidth title={t("pages.dashboard.picker.title")} - // Every change applies immediately, so there is nothing to confirm and - // no `onConfirm` — which also keeps the prominent contained button out - // of the dialog entirely. Reset is destructive and stays quiet beside - // the close action rather than being the loudest thing on screen. - onCancel={onClose} - cancelText={t("pages.dashboard.picker.done")} - additionalButtons={ - - } + // The dialog lays its actions out as cancel → additional → confirm, all + // right-aligned. Reset takes the cancel slot with an auto right margin + // so it sits alone on the left, away from Done — a destructive action + // should not be adjacent to the one people reach for by reflex. + onCancel={onReset} + cancelText={t("pages.dashboard.picker.reset")} + cancelSx={{ marginRight: "auto" }} + // Changes apply immediately, so Done only dismisses; it is the primary + // action here and carries the usual contained styling. + onConfirm={onClose} + confirmText={t("pages.dashboard.picker.done")} > - {t(`components.sidebar.menu.${group}`, { defaultValue: group })} + {/* + * The picker has its own group labels rather than reusing the + * sidebar's: the Logs page hosts logs, queue and diagnostics + * tabs, so "Logs" would misdescribe the three cards under it. + */} + {t(`pages.dashboard.picker.groups.${group}`, { + defaultValue: group, + })} {cards.map((card) => ( ); }; + +/** + * A label with its value on the right — the shape almost every stat card + * repeats. `color` highlights the value alone, leaving the label quiet. + */ +export const CardStatLine = ({ + label, + value, + color, +}: { + label: string; + value: string; + color?: string; +}) => { + const theme = useTheme(); + + return ( + + + {label} + + + {value} + + + ); +}; + +/** + * A CardStatLine with a proportional bar beneath it — the ranking and + * breakdown row. `max` defaults to a percentage scale; pass the largest value + * in the set to make bars relative to each other instead. + * + * `label` may be a node so callers can put a "N down" flag beside the name. + */ +export const CardMeterRow = ({ + label, + value, + max, + color, + trailing, +}: { + label: ReactNode; + value: number; + max?: number; + color: string; + trailing?: ReactNode; +}) => { + const theme = useTheme(); + + return ( + + + {typeof label === "string" ? ( + + {label} + + ) : ( + label + )} + {trailing} + + + + ); +}; diff --git a/client/src/Pages/Dashboard/Components/Cards/AdminCards.tsx b/client/src/Pages/Dashboard/Components/Cards/AdminCards.tsx index da00bad05..3c319fed5 100644 --- a/client/src/Pages/Dashboard/Components/Cards/AdminCards.tsx +++ b/client/src/Pages/Dashboard/Components/Cards/AdminCards.tsx @@ -9,7 +9,7 @@ import { LAYOUT } from "@/Utils/Theme/constants"; import { typographyLevels } from "@/Utils/Theme/Palette"; import { formatTimestamp } from "@/Utils/TimeUtils"; import { DashboardCard, CardMessage } from "../DashboardCard"; -import { CardFigure, CardRow, CardRowLabel } from "../CardPrimitives"; +import { CardFigure, CardRow, CardRowLabel, CardStatLine } from "../CardPrimitives"; import { REFRESH_INTERVAL_MS, SLOW_REFRESH_INTERVAL_MS } from "../../cards"; @@ -19,39 +19,6 @@ import type { User } from "@/Types/User"; const MAX_ERROR_ROWS = 5; -const StatLine = ({ - label, - value, - color, -}: { - label: string; - value: string; - color?: string; -}) => { - const theme = useTheme(); - return ( - - - {label} - - - {value} - - - ); -}; - /** Reads as a single reassurance line while nothing is failing. */ export const ChecksOnScheduleCard = () => { const theme = useTheme(); @@ -107,11 +74,11 @@ export const ChecksOnScheduleCard = () => { )} - - @@ -171,7 +138,7 @@ export const RecentErrorsCard = () => { ) : ( - @@ -238,14 +205,14 @@ export const TeamCard = () => { caption={t("pages.dashboard.cards.team.members", { count: total })} /> {byRole.map(([role, count]) => ( - ))} {unverified > 0 && ( - { + const theme = useTheme(); + const { t } = useTranslation(); + + // Bars are relative to the largest bucket, not to the total, so a fleet of + // one dominant type still shows readable proportions. + const max = buckets[0]?.total ?? 0; + + return ( + + {buckets.length === 0 ? ( + + ) : ( + + {buckets.map((bucket) => ( + + 0 ? theme.palette.error.main : theme.palette.primary.main + } + trailing={ + + {bucket.down > 0 && ( + + {t("pages.dashboard.cards.monitorsByType.down", { + count: bucket.down, + })} + + )} + + {bucket.total} + + + } + /> + + ))} + + )} + + ); +}; diff --git a/client/src/Pages/Dashboard/Components/Cards/CheckmateServerCard.tsx b/client/src/Pages/Dashboard/Components/Cards/CheckmateServerCard.tsx index cdc7b8b2d..9219ebbc5 100644 --- a/client/src/Pages/Dashboard/Components/Cards/CheckmateServerCard.tsx +++ b/client/src/Pages/Dashboard/Components/Cards/CheckmateServerCard.tsx @@ -1,14 +1,12 @@ import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; import { useGet } from "@/Hooks/UseApi"; import { LAYOUT } from "@/Utils/Theme/constants"; -import { typographyLevels } from "@/Utils/Theme/Palette"; import { formatDuration } from "@/Utils/TimeUtils"; import { DashboardCard } from "../DashboardCard"; -import { CardBar } from "../CardPrimitives"; +import { CardBar, CardStatLine } from "../CardPrimitives"; import { REFRESH_INTERVAL_MS } from "../../cards"; @@ -22,39 +20,6 @@ const EVENT_LOOP_WARM_MS = 50; const EVENT_LOOP_HOT_MS = 200; const HEAP_WARM_RATIO = 0.8; -const MetricLine = ({ - label, - value, - color, -}: { - label: string; - value: string; - color?: string; -}) => { - const theme = useTheme(); - return ( - - - {label} - - - {value} - - - ); -}; - /** * The one card that is never empty — it reports on Checkmate itself, so it * works on a fresh install with zero monitors. Admin only. @@ -93,7 +58,7 @@ export const CheckmateServerCard = () => { {data && ( - { color={heapColor} /> - - - > = { @@ -115,53 +111,70 @@ export const CurrentlyDownCard = () => { color={getStatusColor(monitor.status, theme)} size="md" /> - - - {monitor.type} - - {since && ( - - {formatDuration(Date.now() - new Date(since).getTime())} - - )} - - - {/* - * Plain text rather than StatusCodeLabel: that component wraps - * its body in a Tooltip, and a focusable element nested inside - * the row's link is unreachable by keyboard — tabbing lands on - * the link and activating it navigates away. + * Two lines rather than one wide row: at half width seven + * columns would crush the monitor name, so the name and how long + * it has been down lead, and the supporting detail sits beneath + * in a quieter line. */} - - {formatStatusCode(latestCheck(monitor)?.statusCode, t)} - + + + {monitor.name} + + {since && ( + + {formatDuration(Date.now() - new Date(since).getTime())} + + )} + + + {/* + * Plain text rather than StatusCodeLabel: that component + * wraps its body in a Tooltip, and a focusable element + * inside the row's link is unreachable by keyboard. + */} + + {monitor.type} ·{" "} + {formatStatusCode(latestCheck(monitor)?.statusCode, t)} + + + + + + ))} diff --git a/client/src/Pages/Dashboard/Components/Cards/MonitorsByGroupCard.tsx b/client/src/Pages/Dashboard/Components/Cards/MonitorsByGroupCard.tsx index 215771cf3..c620a7651 100644 --- a/client/src/Pages/Dashboard/Components/Cards/MonitorsByGroupCard.tsx +++ b/client/src/Pages/Dashboard/Components/Cards/MonitorsByGroupCard.tsx @@ -1,36 +1,22 @@ -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import { useTheme } from "@mui/material/styles"; -import { useTranslation } from "react-i18next"; import { useMemo } from "react"; -import { LAYOUT } from "@/Utils/Theme/constants"; -import { typographyLevels } from "@/Utils/Theme/Palette"; -import { DashboardCard, CardMessage } from "../DashboardCard"; -import { CardBar, CardRow } from "../CardPrimitives"; +import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard"; import { useMonitors } from "../../useDashboardData"; -interface GroupBucket { - group: string; - total: number; - down: number; -} - export const MonitorsByGroupCard = () => { - const theme = useTheme(); - const { t } = useTranslation(); const { data, isLoading, isValidating, error } = useMonitors(); // `group` is a plain string | null on the monitor — there is no groups // endpoint, so the set is whatever monitors actually declare. - const buckets = useMemo(() => { - const byGroup = new Map(); + const buckets = useMemo(() => { + const byGroup = new Map(); for (const monitor of data?.monitors ?? []) { if (!monitor.group) { continue; } const bucket = byGroup.get(monitor.group) ?? { - group: monitor.group, + key: monitor.group, + label: monitor.group, total: 0, down: 0, }; @@ -43,78 +29,15 @@ export const MonitorsByGroupCard = () => { return [...byGroup.values()].sort((a, b) => b.total - a.total); }, [data]); - const max = buckets[0]?.total ?? 0; - return ( - - {buckets.length === 0 ? ( - - ) : ( - - {buckets.map((bucket) => ( - - - - - {bucket.group} - - - {bucket.down > 0 && ( - - {t("pages.dashboard.cards.monitorsByType.down", { - count: bucket.down, - })} - - )} - - {bucket.total} - - - - 0 - ? theme.palette.error.main - : theme.palette.primary.main - } - /> - - - ))} - - )} - + error={error} + /> ); }; diff --git a/client/src/Pages/Dashboard/Components/Cards/MonitorsByTypeCard.tsx b/client/src/Pages/Dashboard/Components/Cards/MonitorsByTypeCard.tsx index a36c87d0b..a42b9c4cb 100644 --- a/client/src/Pages/Dashboard/Components/Cards/MonitorsByTypeCard.tsx +++ b/client/src/Pages/Dashboard/Components/Cards/MonitorsByTypeCard.tsx @@ -1,37 +1,23 @@ -import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import { useTheme } from "@mui/material/styles"; -import { useTranslation } from "react-i18next"; import { useMemo } from "react"; -import { LAYOUT } from "@/Utils/Theme/constants"; -import { typographyLevels } from "@/Utils/Theme/Palette"; import { getMonitorPath } from "@/Utils/MonitorUtils"; -import { DashboardCard, CardMessage } from "../DashboardCard"; -import { CardBar, CardRow } from "../CardPrimitives"; +import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard"; import { useMonitors } from "../../useDashboardData"; import type { MonitorType } from "@/Types/Monitor"; -interface TypeBucket { - type: MonitorType; - total: number; - down: number; -} - export const MonitorsByTypeCard = () => { - const theme = useTheme(); - const { t } = useTranslation(); const { data, isLoading, isValidating, error } = useMonitors(); - const buckets = useMemo(() => { - const byType = new Map(); + const buckets = useMemo(() => { + const byType = new Map(); for (const monitor of data?.monitors ?? []) { const bucket = byType.get(monitor.type) ?? { - type: monitor.type, + key: monitor.type, + label: monitor.type, total: 0, down: 0, + to: `/${getMonitorPath(monitor.type)}`, }; bucket.total += 1; if (monitor.status === "down" || monitor.status === "breached") { @@ -42,81 +28,15 @@ export const MonitorsByTypeCard = () => { return [...byType.values()].sort((a, b) => b.total - a.total); }, [data]); - const max = buckets[0]?.total ?? 0; - return ( - - {buckets.length === 0 ? ( - - ) : ( - - {buckets.map((bucket) => ( - - - - - {bucket.type} - - - {bucket.down > 0 && ( - - {t("pages.dashboard.cards.monitorsByType.down", { - count: bucket.down, - })} - - )} - - {bucket.total} - - - - - 0 - ? theme.palette.error.main - : theme.palette.primary.main - } - /> - - - - ))} - - )} - + error={error} + /> ); }; diff --git a/client/src/Pages/Dashboard/Components/Cards/RankingCards.tsx b/client/src/Pages/Dashboard/Components/Cards/RankingCards.tsx index d405c5392..20938a615 100644 --- a/client/src/Pages/Dashboard/Components/Cards/RankingCards.tsx +++ b/client/src/Pages/Dashboard/Components/Cards/RankingCards.tsx @@ -8,7 +8,7 @@ import { LAYOUT } from "@/Utils/Theme/constants"; import { typographyLevels } from "@/Utils/Theme/Palette"; import { getMonitorPath, getUptimePercentageColor } from "@/Utils/MonitorUtils"; import { DashboardCard, CardMessage } from "../DashboardCard"; -import { CardBar, CardRow, CardRowLabel } from "../CardPrimitives"; +import { CardMeterRow, CardRow, CardRowLabel } from "../CardPrimitives"; import { useMonitors } from "../../useDashboardData"; import type { Monitor } from "@/Types/Monitor"; @@ -83,18 +83,12 @@ export const SlowestMonitorsCard = () => { key={monitor.id} to={`/${getMonitorPath(monitor.type)}/${monitor.id}`} > - - - + } + value={average} + max={slowest} + color={theme.palette.primary.main} + trailing={ { > {Math.round(average)} ms - - - + } + /> ))} @@ -166,18 +155,11 @@ export const LowestUptimeCard = () => { key={monitor.id} to={`/${getMonitorPath(monitor.type)}/${monitor.id}`} > - - - + } + value={percentage} + color={theme.palette[palette].main} + trailing={ { > {percentage.toFixed(1)}% - - - + } + /> ); })} diff --git a/client/src/Pages/Dashboard/Components/Cards/SummaryCards.tsx b/client/src/Pages/Dashboard/Components/Cards/SummaryCards.tsx index 8d5b49fad..f627ea122 100644 --- a/client/src/Pages/Dashboard/Components/Cards/SummaryCards.tsx +++ b/client/src/Pages/Dashboard/Components/Cards/SummaryCards.tsx @@ -10,39 +10,13 @@ import { LAYOUT } from "@/Utils/Theme/constants"; import { typographyLevels } from "@/Utils/Theme/Palette"; import { formatTimestamp } from "@/Utils/TimeUtils"; import { DashboardCard, CardMessage } from "../DashboardCard"; -import { CardFigure, CardRow, CardRowLabel } from "../CardPrimitives"; +import { CardFigure, CardRow, CardRowLabel, CardStatLine } from "../CardPrimitives"; import type { IncidentSummary } from "@/Types/Incident"; import type { Notification } from "@/Types/Notification"; import type { MaintenanceWindow } from "@/Types/MaintenanceWindow"; import type { StatusPage } from "@/Types/StatusPage"; -const StatLine = ({ label, value }: { label: string; value: string }) => { - const theme = useTheme(); - return ( - - - {label} - - - {value} - - - ); -}; - /** Free when Recent incidents is present — same summary call, shared by SWR. */ export const IncidentStatsCard = () => { const theme = useTheme(); @@ -68,14 +42,14 @@ export const IncidentStatsCard = () => { data.totalActive > 0 ? theme.palette.error.main : theme.palette.text.primary } /> - {data.topMonitor?.monitorName && ( - @@ -115,7 +89,7 @@ export const NotificationChannelsCard = () => { ) : ( {byType.map(([type, count]) => ( - { caption={t("pages.dashboard.cards.maintenance.runningNow")} /> {next && ( - diff --git a/client/src/Pages/Dashboard/cards.ts b/client/src/Pages/Dashboard/cards.ts index f16a61407..d9fafa320 100644 --- a/client/src/Pages/Dashboard/cards.ts +++ b/client/src/Pages/Dashboard/cards.ts @@ -1,13 +1,17 @@ import type { ComponentType } from "react"; -// The 12-column grid the dashboard lays cards out on. A card declares the -// minimum number of columns it needs; the grid may stretch the last card in a -// short row up to STRETCH_MAX_WIDTH. Declared width is never a user setting. -export const CardWidths = [4, 6, 8, 12] as const; +// The 12-column grid the dashboard lays cards out on. +// +// Only two widths exist. Cards are half-width by default and pair up down the +// page; a card is full-width only when its content genuinely needs the room, +// which makes the wide cards read as a deliberate signal rather than as an +// inconsistent grid. Width is declared per card type in code and is never a +// user setting. +export const CardWidths = [6, 12] as const; export type CardWidth = (typeof CardWidths)[number]; export const GRID_COLUMNS = 12; -export const STRETCH_MAX_WIDTH = 8; +export const HALF_WIDTH: CardWidth = 6; // Picker groups mirror the sidebar sections: you look for a card where you // look for the feature it belongs to. diff --git a/client/src/Pages/Dashboard/layout.ts b/client/src/Pages/Dashboard/layout.ts index 509f1ed26..5e9d2efe5 100644 --- a/client/src/Pages/Dashboard/layout.ts +++ b/client/src/Pages/Dashboard/layout.ts @@ -1,57 +1,40 @@ -import { GRID_COLUMNS, STRETCH_MAX_WIDTH, type CardDefinition } from "./cards"; +import { GRID_COLUMNS, HALF_WIDTH, type CardDefinition } from "./cards"; export interface PlacedCard { card: CardDefinition; - /** Columns actually rendered — the declared width, or more if stretched. */ + /** Columns rendered. Equal to the declared width except for a lone half. */ renderedWidth: number; } /** - * Packs cards into rows of GRID_COLUMNS. Declared width is a minimum; when a - * row ends short, its last card absorbs the remainder. + * Packs cards into rows of GRID_COLUMNS. * - * A row in the middle of the page always fills completely — leaving a hole - * there reads as a rendering fault. Only the final row may end short, and only - * when a single card sits in it: stretching a lone trailing card to full width - * makes a 4-column card look like a banner. There, STRETCH_MAX_WIDTH caps the - * growth instead. + * With only two widths this is nearly trivial: two halves fill a row exactly + * and a full-width card fills its own, so no card is ever stretched to close a + * gap. The one case needing a decision is a half-width card left alone at the + * end of the page — it is promoted to full width so the page does not end on a + * ragged half. * - * `grid-auto-flow: dense` was tried instead and does not help: no later card is - * small enough to backfill the gaps that occur in practice. + * `grid-auto-flow: dense` was tried and does not help: with a single body width + * there is never a smaller card available to backfill. */ export const layoutCards = (cards: CardDefinition[]): PlacedCard[] => { - const placed: PlacedCard[] = []; - let row: PlacedCard[] = []; - let used = 0; + const placed: PlacedCard[] = cards.map((card) => ({ + card, + renderedWidth: card.width, + })); - const flush = (isFinalRow: boolean) => { - if (row.length === 0) { - return; - } - const remainder = GRID_COLUMNS - used; - if (remainder > 0) { - const last = row[row.length - 1]; - // A lone card ending the page grows only to the cap; every other short - // row is filled completely so no gap is left mid-page. - const ceiling = isFinalRow && row.length === 1 ? STRETCH_MAX_WIDTH : GRID_COLUMNS; - last.renderedWidth = Math.min(last.renderedWidth + remainder, ceiling); - } - placed.push(...row); - row = []; - used = 0; - }; + // Columns occupied in the row still open once every card is placed. A + // trailing half-width card leaves exactly HALF_WIDTH here. + const trailing = placed.reduce( + (used, entry) => (used + entry.renderedWidth) % GRID_COLUMNS, + 0 + ); - for (const card of cards) { - if (used + card.width > GRID_COLUMNS) { - flush(false); - } - row.push({ card, renderedWidth: card.width }); - used += card.width; - if (used === GRID_COLUMNS) { - flush(false); - } + const last = placed[placed.length - 1]; + if (last && trailing === HALF_WIDTH) { + last.renderedWidth = GRID_COLUMNS; } - flush(true); return placed; }; diff --git a/client/src/Pages/Dashboard/registry.ts b/client/src/Pages/Dashboard/registry.ts index b8a87ea27..2aaddb125 100644 --- a/client/src/Pages/Dashboard/registry.ts +++ b/client/src/Pages/Dashboard/registry.ts @@ -24,10 +24,10 @@ import type { CardDefinition, CardId } from "./cards"; // Declared width is the minimum number of columns a card needs. The grid may // stretch the last card of a short row; nothing here is a user setting. // -// Order is the render order, and the order the picker lists them in. Cards are -// sequenced so their declared widths pair into full rows: a card left alone in -// a row gets stretched to fill it, so an 8-wide card is kept next to a 4-wide -// one rather than being widened back to 12. +// Order is the render order, and the order the picker lists them in. +// +// Full width is reserved for Monitor status alone, which lays six counts across +// a single row. Every other card is half-width and pairs up down the page. export const CARD_REGISTRY: CardDefinition[] = [ { id: "monitorStatus", @@ -40,16 +40,14 @@ export const CARD_REGISTRY: CardDefinition[] = [ id: "currentlyDown", key: "currentlyDown", group: "uptime", - // Six columns per row — the densest card — but it does not need the - // full width, and 8 leaves room for a 4-wide card beside it. - width: 8, + width: 6, component: CurrentlyDownCard, }, { id: "failedChecks", key: "failedChecks", group: "checks", - width: 4, + width: 6, component: FailedChecksCard, }, { @@ -91,42 +89,42 @@ export const CARD_REGISTRY: CardDefinition[] = [ id: "recentIncidents", key: "recentIncidents", group: "incidents", - width: 4, + width: 6, component: RecentIncidentsCard, }, { id: "incidentStats", key: "incidentStats", group: "incidents", - width: 4, + width: 6, component: IncidentStatsCard, }, { id: "notificationChannels", key: "notificationChannels", group: "notifications", - width: 4, + width: 6, component: NotificationChannelsCard, }, { id: "maintenance", key: "maintenance", group: "maintenance", - width: 4, + width: 6, component: MaintenanceCard, }, { id: "statusPages", key: "statusPages", group: "statusPages", - width: 4, + width: 6, component: StatusPagesCard, }, { id: "checkmateServer", key: "checkmateServer", group: "logs", - width: 4, + width: 6, adminOnly: true, component: CheckmateServerCard, }, @@ -134,7 +132,7 @@ export const CARD_REGISTRY: CardDefinition[] = [ id: "checksOnSchedule", key: "checksOnSchedule", group: "logs", - width: 4, + width: 6, adminOnly: true, component: ChecksOnScheduleCard, }, @@ -142,7 +140,7 @@ export const CARD_REGISTRY: CardDefinition[] = [ id: "recentErrors", key: "recentErrors", group: "logs", - width: 4, + width: 6, adminOnly: true, component: RecentErrorsCard, }, @@ -150,7 +148,7 @@ export const CARD_REGISTRY: CardDefinition[] = [ id: "team", key: "team", group: "settings", - width: 4, + width: 6, adminOnly: true, component: TeamCard, }, diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 9ea65b301..2c3967e2e 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -323,7 +323,7 @@ "empty": "No channels yet." }, "maintenance": { - "title": "Maintenance", + "title": "Maintenance windows", "empty": "No maintenance scheduled.", "runningNow": "Running now", "next": "Next window" @@ -336,7 +336,7 @@ "draft": "Draft" }, "checksOnSchedule": { - "title": "Checks on schedule", + "title": "Check queue", "healthy": "All {{jobs}} jobs on schedule across {{workers}} worker(s).", "failing": "Failing jobs", "scheduled": "Scheduled jobs", @@ -349,7 +349,7 @@ "warnings": "Warnings" }, "team": { - "title": "Team", + "title": "Team members", "members": "{{count}} members", "unverified": "Unverified" } @@ -358,7 +358,18 @@ "trigger": "Edit cards", "title": "Dashboard cards", "done": "Done", - "reset": "Reset to defaults" + "reset": "Reset to defaults", + "groups": { + "uptime": "Uptime", + "infrastructure": "Infrastructure", + "incidents": "Incidents", + "notifications": "Notifications", + "maintenance": "Maintenance", + "statusPages": "Status pages", + "checks": "Checks", + "logs": "System", + "settings": "Settings" + } }, "empty": { "title": "No cards on your dashboard",