refactor(client): uniform card widths, shared primitives, clearer titles

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
This commit is contained in:
gorkem-bwl committed 2026-08-29 10:03:59 -04:00
1 parent c46ead515b
commit e4718b814f
15 files changed
+405 -460

No files matched your search

+5
View File
@@ -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")}
</Button>
@@ -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={
<Button
variant="outlined"
color="secondary"
onClick={onReset}
>
{t("pages.dashboard.picker.reset")}
</Button>
}
// 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")}
>
<Stack
gap={theme.spacing(LAYOUT.MD)}
@@ -82,7 +78,14 @@ export const CardPicker = ({
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{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,
})}
</Typography>
{cards.map((card) => (
<FormControlLabel
@@ -141,3 +141,98 @@ export const CardFigure = ({
</Stack>
);
};
/**
* 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 (
<Stack
direction="row"
alignItems="baseline"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{label}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={color ?? theme.palette.text.primary}
noWrap
>
{value}
</Typography>
</Stack>
);
};
/**
* 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 (
<Stack
flex={1}
minWidth={0}
gap={theme.spacing(LAYOUT.XXS)}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
{typeof label === "string" ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
noWrap
>
{label}
</Typography>
) : (
label
)}
{trailing}
</Stack>
<CardBar
value={value}
max={max}
color={color}
/>
</Stack>
);
};
@@ -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 (
<Stack
direction="row"
alignItems="baseline"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{label}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={color ?? theme.palette.text.primary}
>
{value}
</Typography>
</Stack>
);
};
/** Reads as a single reassurance line while nothing is failing. */
export const ChecksOnScheduleCard = () => {
const theme = useTheme();
@@ -107,11 +74,11 @@ export const ChecksOnScheduleCard = () => {
</Stack>
</>
)}
<StatLine
<CardStatLine
label={t("pages.dashboard.cards.checksOnSchedule.scheduled")}
value={String(data.jobs)}
/>
<StatLine
<CardStatLine
label={t("pages.dashboard.cards.checksOnSchedule.runningNow")}
value={String(data.activeJobs)}
/>
@@ -171,7 +138,7 @@ export const RecentErrorsCard = () => {
<CardMessage text={t("pages.dashboard.cards.recentErrors.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
<StatLine
<CardStatLine
label={t("pages.dashboard.cards.recentErrors.warnings")}
value={String(warnings)}
/>
@@ -238,14 +205,14 @@ export const TeamCard = () => {
caption={t("pages.dashboard.cards.team.members", { count: total })}
/>
{byRole.map(([role, count]) => (
<StatLine
<CardStatLine
key={role}
label={t(`common.auth.roles.${role}`, { defaultValue: role })}
value={String(count)}
/>
))}
{unverified > 0 && (
<StatLine
<CardStatLine
label={t("pages.dashboard.cards.team.unverified")}
value={String(unverified)}
color={theme.palette.warning.main}
@@ -0,0 +1,106 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardMeterRow, CardRow } from "../CardPrimitives";
export interface BreakdownBucket {
/** Stable key and displayed label. */
key: string;
label: string;
total: number;
down: number;
/** Where the row links to, if anywhere. */
to?: string;
}
/**
* "How the fleet splits by X" — one proportional row per bucket, flagging any
* bucket with something down. Monitors by type and Monitors by group are the
* same card over different keys, so they share this shell.
*/
export const BreakdownCard = ({
titleKey,
emptyKey,
to,
buckets,
isLoading,
isStale,
error,
}: {
titleKey: string;
emptyKey: string;
to?: string;
buckets: BreakdownBucket[];
isLoading?: boolean;
isStale?: boolean;
error?: unknown;
}) => {
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 (
<DashboardCard
title={t(titleKey)}
to={to}
isLoading={isLoading}
error={error}
isStale={isStale}
>
{buckets.length === 0 ? (
<CardMessage text={t(emptyKey)} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{buckets.map((bucket) => (
<CardRow
key={bucket.key}
to={bucket.to}
>
<CardMeterRow
label={bucket.label}
value={bucket.total}
max={max}
color={
bucket.down > 0 ? theme.palette.error.main : theme.palette.primary.main
}
trailing={
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.XS)}
flexShrink={0}
>
{bucket.down > 0 && (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.error.main}
>
{t("pages.dashboard.cards.monitorsByType.down", {
count: bucket.down,
})}
</Typography>
)}
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{bucket.total}
</Typography>
</Stack>
}
/>
</CardRow>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -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 (
<Stack
direction="row"
alignItems="baseline"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{label}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={color ?? theme.palette.text.primary}
>
{value}
</Typography>
</Stack>
);
};
/**
* 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 && (
<Stack gap={theme.spacing(LAYOUT.SM)}>
<Stack gap={theme.spacing(LAYOUT.XXS)}>
<MetricLine
<CardStatLine
label={t("pages.dashboard.cards.checkmateServer.memory")}
value={`${Math.round(usedHeap / BYTES_PER_MB)} / ${Math.round(
heapLimit / BYTES_PER_MB
@@ -105,16 +70,16 @@ export const CheckmateServerCard = () => {
color={heapColor}
/>
</Stack>
<MetricLine
<CardStatLine
label={t("pages.dashboard.cards.checkmateServer.eventLoop")}
value={`${eventLoopMs.toFixed(1)} ms`}
color={eventLoopColor}
/>
<MetricLine
<CardStatLine
label={t("pages.dashboard.cards.checkmateServer.uptime")}
value={formatDuration(data.uptimeMs)}
/>
<MetricLine
<CardStatLine
label={t("pages.dashboard.cards.checkmateServer.database")}
value={t("pages.dashboard.cards.checkmateServer.readsWrites", {
reads: data.mongoStats.readsPerSecond.toFixed(1),
@@ -12,7 +12,7 @@ import { getStatusColor, getMonitorPath } from "@/Utils/MonitorUtils";
import { formatStatusCode } from "@/Utils/statusCode";
import { formatDuration } from "@/Utils/TimeUtils";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardRow, CardRowLabel } from "../CardPrimitives";
import { CardRow } from "../CardPrimitives";
import { Sparkline } from "../Sparkline";
import { useMonitors } from "../../useDashboardData";
@@ -24,10 +24,6 @@ import type { Monitor, MonitorStatus } from "@/Types/Monitor";
// `initializing` is deliberately excluded: on a fresh install every new monitor
// starts there, and counting it would open the card red claiming a fleet-wide
// outage that does not exist. It is "not checked yet", not "broken".
// Fixed column widths keep the right-hand figures aligned down the list
// instead of jittering with each row's content.
const DURATION_COLUMN_WIDTH = 64;
const STATUS_CODE_COLUMN_WIDTH = 32;
const SPARKLINE_WIDTH = 80;
const STATUS_RANK: Partial<Record<MonitorStatus, number>> = {
@@ -115,53 +111,70 @@ export const CurrentlyDownCard = () => {
color={getStatusColor(monitor.status, theme)}
size="md"
/>
<CardRowLabel
primary={monitor.name}
secondary={monitor.url}
/>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
flexShrink={0}
>
{monitor.type}
</Typography>
{since && (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.error.main}
flexShrink={0}
minWidth={DURATION_COLUMN_WIDTH}
textAlign="right"
>
{formatDuration(Date.now() - new Date(since).getTime())}
</Typography>
)}
<Box
width={SPARKLINE_WIDTH}
flexShrink={0}
display={{ xs: "none", md: "block" }}
>
<Sparkline
checks={monitor.recentChecks ?? []}
color={theme.palette.error.main}
/>
</Box>
{/*
* 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.
*/}
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
flexShrink={0}
minWidth={STATUS_CODE_COLUMN_WIDTH}
textAlign="right"
<Stack
flex={1}
minWidth={0}
gap={theme.spacing(LAYOUT.XXS)}
>
{formatStatusCode(latestCheck(monitor)?.statusCode, t)}
</Typography>
<Stack
direction="row"
alignItems="baseline"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
noWrap
>
{monitor.name}
</Typography>
{since && (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.error.main}
flexShrink={0}
>
{formatDuration(Date.now() - new Date(since).getTime())}
</Typography>
)}
</Stack>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
{/*
* 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.
*/}
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
noWrap
>
{monitor.type} ·{" "}
{formatStatusCode(latestCheck(monitor)?.statusCode, t)}
</Typography>
<Box
width={SPARKLINE_WIDTH}
flexShrink={0}
>
<Sparkline
checks={monitor.recentChecks ?? []}
color={theme.palette.error.main}
/>
</Box>
</Stack>
</Stack>
</CardRow>
))}
</Stack>
@@ -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<GroupBucket[]>(() => {
const byGroup = new Map<string, GroupBucket>();
const buckets = useMemo<BreakdownBucket[]>(() => {
const byGroup = new Map<string, BreakdownBucket>();
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 (
<DashboardCard
title={t("pages.dashboard.cards.monitorsByGroup.title")}
<BreakdownCard
titleKey="pages.dashboard.cards.monitorsByGroup.title"
emptyKey="pages.dashboard.cards.monitorsByGroup.empty"
to="/uptime"
buckets={buckets}
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{buckets.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.monitorsByGroup.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{buckets.map((bucket) => (
<CardRow key={bucket.group}>
<Stack
flex={1}
minWidth={0}
gap={theme.spacing(LAYOUT.XXS)}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
noWrap
>
{bucket.group}
</Typography>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.XS)}
flexShrink={0}
>
{bucket.down > 0 && (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.error.main}
>
{t("pages.dashboard.cards.monitorsByType.down", {
count: bucket.down,
})}
</Typography>
)}
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{bucket.total}
</Typography>
</Stack>
</Stack>
<CardBar
value={bucket.total}
max={max}
color={
bucket.down > 0
? theme.palette.error.main
: theme.palette.primary.main
}
/>
</Stack>
</CardRow>
))}
</Stack>
)}
</DashboardCard>
error={error}
/>
);
};
@@ -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<TypeBucket[]>(() => {
const byType = new Map<MonitorType, TypeBucket>();
const buckets = useMemo<BreakdownBucket[]>(() => {
const byType = new Map<MonitorType, BreakdownBucket>();
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 (
<DashboardCard
title={t("pages.dashboard.cards.monitorsByType.title")}
<BreakdownCard
titleKey="pages.dashboard.cards.monitorsByType.title"
emptyKey="pages.dashboard.cards.monitorsByType.empty"
to="/uptime"
buckets={buckets}
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{buckets.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.monitorsByType.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{buckets.map((bucket) => (
<CardRow
key={bucket.type}
to={`/${getMonitorPath(bucket.type)}`}
>
<Stack
gap={theme.spacing(LAYOUT.XXS)}
flex={1}
minWidth={0}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
>
{bucket.type}
</Typography>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.XS)}
>
{bucket.down > 0 && (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.error.main}
>
{t("pages.dashboard.cards.monitorsByType.down", {
count: bucket.down,
})}
</Typography>
)}
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{bucket.total}
</Typography>
</Stack>
</Stack>
<Box>
<CardBar
value={bucket.total}
max={max}
color={
bucket.down > 0
? theme.palette.error.main
: theme.palette.primary.main
}
/>
</Box>
</Stack>
</CardRow>
))}
</Stack>
)}
</DashboardCard>
error={error}
/>
);
};
@@ -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}`}
>
<Stack
flex={1}
minWidth={0}
gap={theme.spacing(LAYOUT.XXS)}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
<CardRowLabel primary={monitor.name} />
<CardMeterRow
label={<CardRowLabel primary={monitor.name} />}
value={average}
max={slowest}
color={theme.palette.primary.main}
trailing={
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
@@ -102,13 +96,8 @@ export const SlowestMonitorsCard = () => {
>
{Math.round(average)} ms
</Typography>
</Stack>
<CardBar
value={average}
max={slowest}
color={theme.palette.primary.main}
/>
</Stack>
}
/>
</CardRow>
))}
</Stack>
@@ -166,18 +155,11 @@ export const LowestUptimeCard = () => {
key={monitor.id}
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
>
<Stack
flex={1}
minWidth={0}
gap={theme.spacing(LAYOUT.XXS)}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
<CardRowLabel primary={monitor.name} />
<CardMeterRow
label={<CardRowLabel primary={monitor.name} />}
value={percentage}
color={theme.palette[palette].main}
trailing={
<Typography
fontSize={typographyLevels.m}
color={theme.palette[palette].main}
@@ -185,12 +167,8 @@ export const LowestUptimeCard = () => {
>
{percentage.toFixed(1)}%
</Typography>
</Stack>
<CardBar
value={percentage}
color={theme.palette[palette].main}
/>
</Stack>
}
/>
</CardRow>
);
})}
@@ -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 (
<Stack
direction="row"
alignItems="baseline"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{label}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
noWrap
>
{value}
</Typography>
</Stack>
);
};
/** 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
}
/>
<StatLine
<CardStatLine
label={t("pages.dashboard.cards.incidentStats.avgResolution")}
value={t("pages.dashboard.cards.incidentStats.hours", {
hours: data.avgResolutionTimeHours.toFixed(1),
})}
/>
{data.topMonitor?.monitorName && (
<StatLine
<CardStatLine
label={t("pages.dashboard.cards.incidentStats.mostIncidents")}
value={`${data.topMonitor.monitorName} (${data.topMonitor.incidentCount})`}
/>
@@ -115,7 +89,7 @@ export const NotificationChannelsCard = () => {
) : (
<Stack gap={theme.spacing(LAYOUT.XS)}>
{byType.map(([type, count]) => (
<StatLine
<CardStatLine
key={type}
label={type}
value={String(count)}
@@ -169,7 +143,7 @@ export const MaintenanceCard = () => {
caption={t("pages.dashboard.cards.maintenance.runningNow")}
/>
{next && (
<StatLine
<CardStatLine
label={t("pages.dashboard.cards.maintenance.next")}
value={formatTimestamp(next.start)}
/>
+9 -5
View File
@@ -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.
+23 -40
View File
@@ -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;
};
+15 -17
View File
@@ -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,
},
+15 -4
View File
@@ -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",