feat(client): add the main dashboard page

Adds /dashboard, a cross-type overview answering "is anything wrong, and
where" — the question no single-type page can answer today. /uptime shows
uptime monitors, /infrastructure shows hardware, /incidents shows history;
nothing joined them.

Layout is a 12-column grid. Each card type declares a minimum width (4, 6,
8 or 12) in the registry; the grid stretches the last card of a short row
to fill the remainder, capped at 8 so one card never balloons across a
near-empty row. Declared width is never a user setting. grid-auto-flow:
dense was tried and does not help — no card is small enough to backfill
the gaps that occur in practice.

Six cards ship as the default set:

  Monitor status    the five fleet counts, server-computed
  Currently down    the centrepiece: everything wrong now, worst first
  Failed checks     failure rate over the last day
  Recent incidents  latest five, with the open count
  Monitors by type  composition, flagging types with something down
  Checkmate server  the only card that works with zero monitors (admin)

Cards 1-3 and 5 ride a single /monitors/team/with-checks call shared
through context, so adding a monitor-derived card costs no extra request.

Notes on the data:

- Ordering in "Currently down" is down -> breached -> initializing, tied
  on longest downtime. That is ordering derived from existing fields, not
  a new severity scale — Checkmate has none, and inventing one would be a
  product decision dressed as a design choice.
- The healthy state is deliberately quiet: one line, not a wall of green.
- Failed checks ships the day range only. dateRange=week returns figures
  identical to day, so a range selector would silently lie.
- Admin-only cards are hidden rather than disabled, and are filtered out
  of the selection entirely for non-admins.

Card selection persists to localStorage, validated on read so a card
removed in a later release cannot break the page. The picker and the
remaining catalogue cards are not built yet.

The Containers card from the spec is deliberately absent: it needs
containerSummary on the check snapshot, which arrives with the Docker
monitoring work. cards.ts records where it slots in.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
This commit is contained in:
gorkem-bwl committed 2026-08-29 09:10:46 -04:00
1 parent 46211ac61d
commit c58087c787
19 files changed
+1428 -1

No files matched your search

+6
View File
@@ -1,6 +1,7 @@
import { Icon } from "@/Components/design-elements";
import {
LayoutDashboard,
Globe,
Gauge,
Link,
@@ -22,6 +23,11 @@ import {
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
name: t("components.sidebar.menu.dashboard"),
path: "dashboard",
icon: <Icon icon={LayoutDashboard} />,
},
{
name: t("components.sidebar.menu.uptime"),
path: "uptime",
@@ -0,0 +1,39 @@
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import { useMemo } from "react";
import { LAYOUT } from "@/Utils/Theme/constants";
import { GRID_COLUMNS, type CardDefinition } from "../cards";
import { layoutCards } from "../layout";
export const CardGrid = ({ cards }: { cards: CardDefinition[] }) => {
const theme = useTheme();
const placed = useMemo(() => layoutCards(cards), [cards]);
return (
<Box
display="grid"
gap={theme.spacing(LAYOUT.MD)}
sx={{
gridTemplateColumns: {
xs: "1fr",
md: `repeat(${GRID_COLUMNS}, 1fr)`,
},
alignItems: "stretch",
}}
>
{placed.map(({ card, renderedWidth }) => {
const Component = card.component;
return (
<Box
key={card.id}
minWidth={0}
gridColumn={{ xs: "auto", md: `span ${renderedWidth}` }}
>
<Component />
</Box>
);
})}
</Box>
);
};
@@ -0,0 +1,140 @@
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 { Link as RouterLink } from "react-router-dom";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
import type { ReactNode } from "react";
/**
* A proportional bar. `value` and `max` share whatever unit the caller uses;
* percentages come in as 0-100, not as the API's 0-1 fractions.
*/
export const CardBar = ({
value,
max = 100,
color,
}: {
value: number;
max?: number;
color: string;
}) => {
const theme = useTheme();
const ratio = max > 0 ? Math.min(Math.max(value / max, 0), 1) : 0;
return (
<Box
width="100%"
height={4}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.hover}
sx={{ overflow: "hidden" }}
>
<Box
width={`${ratio * 100}%`}
height="100%"
bgcolor={color}
sx={{ transition: "width 0.3s ease" }}
/>
</Box>
);
};
/** A clickable list row. Rows link to the entity they describe. */
export const CardRow = ({ to, children }: { to?: string; children: ReactNode }) => {
const theme = useTheme();
return (
<Stack
{...(to ? { component: RouterLink, to } : {})}
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.SM)}
px={theme.spacing(LAYOUT.XS)}
py={theme.spacing(LAYOUT.XS)}
mx={`-${theme.spacing(LAYOUT.XS)}`}
borderRadius={theme.shape.borderRadius}
color="inherit"
sx={{
textDecoration: "none",
...(to && {
"&:hover": { backgroundColor: theme.palette.action.hover },
}),
}}
>
{children}
</Stack>
);
};
/** Name plus optional secondary line, truncating rather than wrapping. */
export const CardRowLabel = ({
primary,
secondary,
}: {
primary: string;
secondary?: string | null;
}) => {
const theme = useTheme();
return (
<Stack
minWidth={0}
flex={1}
>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
noWrap
>
{primary}
</Typography>
{secondary && (
<Typography
fontSize={typographyLevels.s}
color={theme.palette.text.secondary}
noWrap
>
{secondary}
</Typography>
)}
</Stack>
);
};
/** A single large figure with a caption — the shape of the stat cards. */
export const CardFigure = ({
value,
caption,
color,
}: {
value: string;
caption?: string;
color?: string;
}) => {
const theme = useTheme();
return (
<Stack gap={theme.spacing(LAYOUT.XXS)}>
<Typography
fontSize={typographyLevels.xxl}
fontWeight={300}
color={color ?? theme.palette.text.primary}
lineHeight={1.1}
>
{value}
</Typography>
{caption && (
<Typography
fontSize={typographyLevels.s}
color={theme.palette.text.secondary}
>
{caption}
</Typography>
)}
</Stack>
);
};
@@ -0,0 +1,125 @@
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 type { Diagnostics } from "@/Types/Diagnostics";
const BYTES_PER_MB = 1024 * 1024;
// Banding from CARD-SPECS: warm at 50ms of event-loop delay, hot at 200ms;
// heap warm past 80% of the V8 limit.
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.s}
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.
*/
export const CheckmateServerCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, error } = useGet<Diagnostics>(
"/diagnostic/system",
{},
{ refreshInterval: 30000 }
);
const usedHeap = data?.v8HeapStats.usedHeapSizeBytes ?? 0;
const heapLimit = data?.v8HeapStats.heapSizeLimitBytes ?? 0;
const heapRatio = heapLimit > 0 ? usedHeap / heapLimit : 0;
const heapColor =
heapRatio > HEAP_WARM_RATIO ? theme.palette.warning.main : theme.palette.primary.main;
const eventLoopMs = data?.eventLoopDelayMs ?? 0;
const eventLoopColor =
eventLoopMs > EVENT_LOOP_HOT_MS
? theme.palette.error.main
: eventLoopMs > EVENT_LOOP_WARM_MS
? theme.palette.warning.main
: theme.palette.text.primary;
return (
<DashboardCard
title={t("pages.dashboard.cards.checkmateServer.title")}
to="/logs"
isLoading={isLoading && !data}
error={error}
>
{data && (
<Stack gap={theme.spacing(LAYOUT.SM)}>
<Stack gap={theme.spacing(LAYOUT.XXS)}>
<MetricLine
label={t("pages.dashboard.cards.checkmateServer.memory")}
value={`${Math.round(usedHeap / BYTES_PER_MB)} / ${Math.round(
heapLimit / BYTES_PER_MB
)} MB`}
color={heapColor}
/>
<CardBar
value={heapRatio * 100}
color={heapColor}
/>
</Stack>
<MetricLine
label={t("pages.dashboard.cards.checkmateServer.eventLoop")}
value={`${eventLoopMs.toFixed(1)} ms`}
color={eventLoopColor}
/>
<MetricLine
label={t("pages.dashboard.cards.checkmateServer.uptime")}
value={formatDuration(data.uptimeMs)}
/>
<MetricLine
label={t("pages.dashboard.cards.checkmateServer.database")}
value={t("pages.dashboard.cards.checkmateServer.readsWrites", {
reads: data.mongoStats.readsPerSecond.toFixed(1),
writes: data.mongoStats.writesPerSecond.toFixed(1),
})}
/>
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,140 @@
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 { Dot, StatusCodeLabel } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { getStatusColor, getMonitorPath } from "@/Utils/MonitorUtils";
import { formatDuration } from "@/Utils/TimeUtils";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardRow, CardRowLabel } from "../CardPrimitives";
import { Sparkline } from "../Sparkline";
import { useMonitors } from "../../useDashboardData";
import type { Monitor, MonitorStatus } from "@/Types/Monitor";
// Ordering, not a severity scale — Checkmate has none. Every input already
// exists in the API response.
const STATUS_RANK: Partial<Record<MonitorStatus, number>> = {
down: 1,
breached: 2,
initializing: 3,
};
const isProblem = (monitor: Monitor) => STATUS_RANK[monitor.status] !== undefined;
/**
* How long the monitor has been in its current state, inferred from the oldest
* consecutive failing check. recentChecks is newest-first.
*/
const downSince = (monitor: Monitor): string | null => {
const checks = monitor.recentChecks ?? [];
let oldestFailing: string | null = null;
for (const check of checks) {
if (check.status === true) {
break;
}
oldestFailing = check.createdAt;
}
return oldestFailing;
};
export const CurrentlyDownCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, error } = useMonitors();
const problems = useMemo(() => {
const monitors = data?.monitors ?? [];
return monitors
.filter(isProblem)
.map((monitor) => ({ monitor, since: downSince(monitor) }))
.sort((a, b) => {
const rank =
(STATUS_RANK[a.monitor.status] ?? 99) - (STATUS_RANK[b.monitor.status] ?? 99);
if (rank !== 0) {
return rank;
}
// Tie-break on longest downtime: the older timestamp ranks first.
return new Date(a.since ?? 0).getTime() - new Date(b.since ?? 0).getTime();
});
}, [data]);
return (
<DashboardCard
title={t("pages.dashboard.cards.currentlyDown.title")}
to="/incidents"
isLoading={isLoading && !data}
error={error}
action={
problems.length > 0 ? (
<Typography
fontSize={typographyLevels.s}
color={theme.palette.error.main}
>
{t("pages.dashboard.cards.currentlyDown.count", {
count: problems.length,
})}
</Typography>
) : null
}
>
{problems.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.currentlyDown.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.XS)}>
{problems.map(({ monitor, since }) => (
<CardRow
key={monitor.id}
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
>
<Dot
color={getStatusColor(monitor.status, theme)}
size="md"
/>
<CardRowLabel
primary={monitor.name}
secondary={monitor.url}
/>
<Typography
fontSize={typographyLevels.s}
color={theme.palette.text.secondary}
flexShrink={0}
>
{monitor.type}
</Typography>
{since && (
<Typography
fontSize={typographyLevels.s}
color={theme.palette.error.main}
flexShrink={0}
minWidth={64}
textAlign="right"
>
{formatDuration(Date.now() - new Date(since).getTime())}
</Typography>
)}
<Box
width={80}
flexShrink={0}
display={{ xs: "none", md: "block" }}
>
<Sparkline
checks={monitor.recentChecks ?? []}
color={theme.palette.error.main}
/>
</Box>
<Box flexShrink={0}>
<StatusCodeLabel statusCode={monitor.recentChecks?.[0]?.statusCode} />
</Box>
</CardRow>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,53 @@
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardBar, CardFigure } from "../CardPrimitives";
import type { ChecksSummary } from "@/Types/Check";
// Day only. `dateRange=week` returns figures identical to `day`, so a range
// selector here would silently lie.
const SUMMARY_URL = "/checks/team/summary?dateRange=day";
export const FailedChecksCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, error } = useGet<ChecksSummary>(SUMMARY_URL);
const total = data?.totalChecks ?? 0;
const failed = data?.downChecks ?? 0;
const percentage = total > 0 ? (failed / total) * 100 : 0;
const color = percentage > 10 ? theme.palette.error.main : theme.palette.success.main;
return (
<DashboardCard
title={t("pages.dashboard.cards.failedChecks.title")}
to="/checks"
isLoading={isLoading && !data}
error={error}
>
{total === 0 ? (
<CardMessage text={t("pages.dashboard.cards.failedChecks.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
<CardFigure
value={`${percentage.toFixed(1)}%`}
caption={t("pages.dashboard.cards.failedChecks.ratio", {
failed: failed.toLocaleString(),
total: total.toLocaleString(),
})}
color={color}
/>
<CardBar
value={percentage}
color={color}
/>
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,93 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { Link as RouterLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Dot } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { getStatusColor } from "@/Utils/MonitorUtils";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { useMonitors } from "../../useDashboardData";
import type { MonitorStatus, MonitorsSummary } from "@/Types/Monitor";
// The five counts, in the order they read: healthy first, then what is wrong,
// then what is not being checked.
const COUNTS: { key: string; status: MonitorStatus; field: keyof MonitorsSummary }[] = [
{ key: "up", status: "up", field: "upMonitors" },
{ key: "down", status: "down", field: "downMonitors" },
{ key: "breached", status: "breached", field: "breachedMonitors" },
{ key: "paused", status: "paused", field: "pausedMonitors" },
{ key: "initializing", status: "initializing", field: "initializingMonitors" },
];
export const MonitorStatusCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, error } = useMonitors();
const summary = data?.summary ?? null;
return (
<DashboardCard
title={t("pages.dashboard.cards.monitorStatus.title")}
to="/uptime"
isLoading={isLoading && !summary}
error={error}
>
{!summary || summary.totalMonitors === 0 ? (
<CardMessage text={t("pages.dashboard.cards.monitorStatus.empty")} />
) : (
<Stack
direction="row"
flexWrap="wrap"
gap={theme.spacing(LAYOUT.LG)}
>
{COUNTS.map(({ key, status, field }) => (
<Stack
key={key}
component={RouterLink}
to={`/uptime?status=${key}`}
gap={theme.spacing(LAYOUT.XXS)}
flex="1 1 0"
minWidth={96}
px={theme.spacing(LAYOUT.SM)}
py={theme.spacing(LAYOUT.XS)}
borderRadius={theme.shape.borderRadius}
sx={{
textDecoration: "none",
"&:hover": { backgroundColor: theme.palette.action.hover },
}}
>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.XS)}
>
<Dot
color={getStatusColor(status, theme)}
size="md"
/>
<Typography
fontSize={typographyLevels.s}
color={theme.palette.text.secondary}
>
{t(`pages.common.monitors.status.${key}`)}
</Typography>
</Stack>
<Typography
fontSize={typographyLevels.xxl}
fontWeight={300}
color={theme.palette.text.primary}
lineHeight={1.1}
>
{summary[field]}
</Typography>
</Stack>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,121 @@
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 { 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, error } = useMonitors();
const buckets = useMemo<TypeBucket[]>(() => {
const byType = new Map<MonitorType, TypeBucket>();
for (const monitor of data?.monitors ?? []) {
const bucket = byType.get(monitor.type) ?? {
type: monitor.type,
total: 0,
down: 0,
};
bucket.total += 1;
if (monitor.status === "down" || monitor.status === "breached") {
bucket.down += 1;
}
byType.set(monitor.type, bucket);
}
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")}
to="/uptime"
isLoading={isLoading && !data}
error={error}
>
{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.s}
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>
);
};
@@ -0,0 +1,102 @@
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 { Dot } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardRow, CardRowLabel } from "../CardPrimitives";
import type { IncidentSummary, IncidentSummaryItem } from "@/Types/Incident";
const MAX_ROWS = 5;
/** Open incidents have no end time; closed ones report how long they ran. */
const incidentDuration = (incident: IncidentSummaryItem): string | null => {
if (!incident.endTime) {
return null;
}
const ms =
new Date(incident.endTime).getTime() - new Date(incident.startTime).getTime();
return ms > 0 ? formatDuration(ms) : null;
};
export const RecentIncidentsCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, error } = useGet<IncidentSummary>("/incidents/team/summary");
const incidents = (data?.latestIncidents ?? []).slice(0, MAX_ROWS);
return (
<DashboardCard
title={t("pages.dashboard.cards.recentIncidents.title")}
to="/incidents"
isLoading={isLoading && !data}
error={error}
action={
data && data.totalActive > 0 ? (
<Typography
fontSize={typographyLevels.s}
color={theme.palette.error.main}
>
{t("pages.dashboard.cards.recentIncidents.active", {
count: data.totalActive,
})}
</Typography>
) : null
}
>
{incidents.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.recentIncidents.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.XS)}>
{incidents.map((incident) => {
const isOpen = incident.status === true;
const duration = incidentDuration(incident);
return (
<CardRow
key={incident.id}
to="/incidents"
>
<Dot
color={isOpen ? theme.palette.error.light : theme.palette.success.light}
size="md"
/>
<CardRowLabel
primary={
incident.monitorName ??
t("pages.dashboard.cards.recentIncidents.unknownMonitor")
}
secondary={formatTimestamp(incident.startTime)}
/>
<Typography
fontSize={typographyLevels.s}
color={theme.palette.text.secondary}
sx={{ flexShrink: 0 }}
>
{isOpen
? t("pages.dashboard.cards.recentIncidents.ongoing")
: (duration ?? "—")}
</Typography>
{incident.resolutionType === "manual" && (
<Typography
fontSize={typographyLevels.s}
color={theme.palette.warning.main}
sx={{ flexShrink: 0 }}
>
{t("pages.dashboard.cards.recentIncidents.manual")}
</Typography>
)}
</CardRow>
);
})}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,144 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import Skeleton from "@mui/material/Skeleton";
import { useTheme } from "@mui/material/styles";
import { Link as RouterLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { BaseBox } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
import type { ReactNode } from "react";
interface DashboardCardProps {
title: string;
/** Where the title links to — the page this card belongs to. */
to?: string;
/** Rendered at the right of the header: a count, an average, a filter. */
action?: ReactNode;
isLoading?: boolean;
error?: unknown;
/** Dimmed rather than blanked while a refresh is in flight over stale data. */
isStale?: boolean;
children: ReactNode;
}
export const DashboardCard = ({
title,
to,
action,
isLoading,
error,
isStale,
children,
}: DashboardCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<BaseBox
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
px={theme.spacing(LAYOUT.MD)}
py={theme.spacing(LAYOUT.SM)}
borderBottom={`1px solid ${theme.palette.divider}`}
minHeight={44}
>
<Typography
{...(to ? { component: RouterLink, to } : {})}
fontSize={typographyLevels.m}
fontWeight={500}
color={theme.palette.text.primary}
sx={{
textDecoration: "none",
...(to && {
"&:hover": { color: theme.palette.primary.main },
}),
}}
>
{title}
</Typography>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.SM)}
sx={{ flexShrink: 0 }}
>
{isStale && (
<CircularProgress
size={12}
color="primary"
/>
)}
{action}
</Stack>
</Stack>
<Box
flex={1}
p={theme.spacing(LAYOUT.MD)}
sx={{
...(isStale && { opacity: 0.5, transition: "opacity 0.2s ease" }),
}}
>
{isLoading ? (
<CardSkeleton />
) : error ? (
<CardMessage text={t("pages.dashboard.card.error")} />
) : (
children
)}
</Box>
</BaseBox>
);
};
/** Placeholder rows, sized to the row list most cards render. */
export const CardSkeleton = ({ rows = 3 }: { rows?: number }) => {
const theme = useTheme();
return (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{Array.from({ length: rows }).map((_, index) => (
<Skeleton
key={index}
variant="rounded"
height={20}
width={index === rows - 1 ? "60%" : "100%"}
/>
))}
</Stack>
);
};
/** The quiet one-line state a healthy card collapses to. */
export const CardMessage = ({ text, action }: { text: string; action?: ReactNode }) => {
const theme = useTheme();
return (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography
fontSize={typographyLevels.s}
color={theme.palette.text.secondary}
>
{text}
</Typography>
{action}
</Stack>
);
};
@@ -0,0 +1,70 @@
import Box from "@mui/material/Box";
import { useMemo } from "react";
import type { CheckSnapshot } from "@/Types/Check";
const VIEWBOX_WIDTH = 100;
const VIEWBOX_HEIGHT = 24;
/**
* Response times as a bare polyline. Deliberately axis-free and label-free —
* at this size the shape is the only readable signal.
*
* `checks` arrives newest-first, so it is reversed to read left-to-right.
*/
export const Sparkline = ({
checks,
color,
}: {
checks: CheckSnapshot[];
color: string;
}) => {
const points = useMemo(() => {
const values = checks
.map((check) => check.responseTime)
.filter((value): value is number => typeof value === "number")
.reverse();
if (values.length < 2) {
return null;
}
const max = Math.max(...values);
const min = Math.min(...values);
const span = max - min || 1;
return values
.map((value, index) => {
const x = (index / (values.length - 1)) * VIEWBOX_WIDTH;
const y = VIEWBOX_HEIGHT - ((value - min) / span) * VIEWBOX_HEIGHT;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
}, [checks]);
if (!points) {
return null;
}
return (
<Box
component="svg"
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
preserveAspectRatio="none"
aria-hidden="true"
width="100%"
height={24}
display="block"
>
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</Box>
);
};
+73
View File
@@ -0,0 +1,73 @@
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;
export type CardWidth = (typeof CardWidths)[number];
export const GRID_COLUMNS = 12;
export const STRETCH_MAX_WIDTH = 8;
// Picker groups mirror the sidebar sections: you look for a card where you
// look for the feature it belongs to.
export const CardGroups = [
"uptime",
"infrastructure",
"incidents",
"notifications",
"maintenance",
"statusPages",
"checks",
"logs",
"settings",
] as const;
export type CardGroup = (typeof CardGroups)[number];
// The Containers card (CARD-SPECS.md #11) is deliberately absent: it needs
// `containerSummary` on the check snapshot, which arrives with the Docker
// monitoring work. Add it here once that lands on develop.
export const CardIds = [
"monitorStatus",
"currentlyDown",
"slowestMonitors",
"lowestUptime",
"monitorsByType",
"monitorsByGroup",
"recentIncidents",
"incidentStats",
"notificationChannels",
"maintenance",
"statusPages",
"failedChecks",
"busiestServers",
"checkmateServer",
"checksOnSchedule",
"recentErrors",
"team",
] as const;
export type CardId = (typeof CardIds)[number];
export interface CardDefinition {
id: CardId;
/** i18n key fragment under pages.dashboard.cards.<key>. */
key: string;
group: CardGroup;
width: CardWidth;
/** Hidden from the page and absent from the picker for non-admins. */
adminOnly?: boolean;
component: ComponentType;
}
// The cards a fresh dashboard starts with. Chosen so a brand new install still
// shows something real: Checkmate server works with zero monitors.
export const DEFAULT_CARD_IDS: CardId[] = [
"monitorStatus",
"currentlyDown",
"failedChecks",
"recentIncidents",
"monitorsByType",
"checkmateServer",
];
export const DASHBOARD_CARDS_STORAGE_KEY = "checkmate-dashboard-cards";
+23
View File
@@ -0,0 +1,23 @@
import { BasePage } from "@/Components/design-elements";
import { CardGrid } from "./Components/CardGrid";
import { MonitorsContext, useMonitorsRequest } from "./useDashboardData";
import { useCardSelection } from "./useCardSelection";
/**
* Answers "is anything wrong, and where" across every monitor type at once —
* the question no single-type page can answer. Problems first and biggest.
*/
const Dashboard = () => {
const monitorsRequest = useMonitorsRequest();
const { cards } = useCardSelection();
return (
<MonitorsContext.Provider value={monitorsRequest}>
<BasePage headerKey="dashboard">
<CardGrid cards={cards} />
</BasePage>
</MonitorsContext.Provider>
);
};
export default Dashboard;
+54
View File
@@ -0,0 +1,54 @@
import { GRID_COLUMNS, STRETCH_MAX_WIDTH, type CardDefinition } from "./cards";
export interface PlacedCard {
card: CardDefinition;
/** Columns actually rendered — the declared width, or more if stretched. */
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, capped at
* STRETCH_MAX_WIDTH so one card never balloons across a near-empty row.
*
* `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.
*/
export const layoutCards = (cards: CardDefinition[]): PlacedCard[] => {
const placed: PlacedCard[] = [];
let row: PlacedCard[] = [];
let used = 0;
const flush = (isFinalRow: boolean) => {
if (row.length === 0) {
return;
}
const remainder = GRID_COLUMNS - used;
if (remainder > 0) {
const last = row[row.length - 1];
// A full-width card already spans the row; only grow a short one, and
// never past the cap.
const grown = Math.min(last.renderedWidth + remainder, STRETCH_MAX_WIDTH);
if (grown > last.renderedWidth && !(isFinalRow && row.length === 1)) {
last.renderedWidth = grown;
}
}
placed.push(...row);
row = [];
used = 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);
}
}
flush(true);
return placed;
};
+62
View File
@@ -0,0 +1,62 @@
import { MonitorStatusCard } from "./Components/Cards/MonitorStatusCard";
import { CurrentlyDownCard } from "./Components/Cards/CurrentlyDownCard";
import { FailedChecksCard } from "./Components/Cards/FailedChecksCard";
import { RecentIncidentsCard } from "./Components/Cards/RecentIncidentsCard";
import { MonitorsByTypeCard } from "./Components/Cards/MonitorsByTypeCard";
import { CheckmateServerCard } from "./Components/Cards/CheckmateServerCard";
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.
export const CARD_REGISTRY: CardDefinition[] = [
{
id: "monitorStatus",
key: "monitorStatus",
group: "uptime",
width: 12,
component: MonitorStatusCard,
},
{
id: "currentlyDown",
key: "currentlyDown",
group: "uptime",
width: 12,
component: CurrentlyDownCard,
},
{
id: "monitorsByType",
key: "monitorsByType",
group: "uptime",
width: 6,
component: MonitorsByTypeCard,
},
{
id: "recentIncidents",
key: "recentIncidents",
group: "incidents",
width: 8,
component: RecentIncidentsCard,
},
{
id: "failedChecks",
key: "failedChecks",
group: "checks",
width: 4,
component: FailedChecksCard,
},
{
id: "checkmateServer",
key: "checkmateServer",
group: "logs",
width: 4,
adminOnly: true,
component: CheckmateServerCard,
},
];
const BY_ID = new Map<CardId, CardDefinition>(
CARD_REGISTRY.map((card) => [card.id, card])
);
export const getCard = (id: CardId): CardDefinition | undefined => BY_ID.get(id);
@@ -0,0 +1,89 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { logger } from "@/Utils/logger";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import {
CardIds,
DASHBOARD_CARDS_STORAGE_KEY,
DEFAULT_CARD_IDS,
type CardId,
} from "./cards";
import { CARD_REGISTRY, getCard } from "./registry";
import type { CardDefinition } from "./cards";
const isCardId = (value: unknown): value is CardId =>
typeof value === "string" && (CardIds as readonly string[]).includes(value);
/** Stored ids are validated: a card removed from a release must not break the page. */
const readStored = (): CardId[] | null => {
try {
const raw = window.localStorage.getItem(DASHBOARD_CARDS_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return null;
}
const valid = parsed.filter(isCardId);
return valid.length > 0 ? valid : null;
} catch (error) {
logger.error(
"Could not read dashboard card selection",
error instanceof Error ? error : undefined
);
return null;
}
};
/**
* Which cards are on the page, persisted per browser. Admin-only cards are
* filtered out entirely for non-admins — hidden, not disabled.
*/
export const useCardSelection = () => {
const isAdmin = useIsAdmin();
const [selected, setSelected] = useState<CardId[]>(
() => readStored() ?? DEFAULT_CARD_IDS
);
useEffect(() => {
try {
window.localStorage.setItem(DASHBOARD_CARDS_STORAGE_KEY, JSON.stringify(selected));
} catch (error) {
logger.error(
"Could not persist dashboard card selection",
error instanceof Error ? error : undefined
);
}
}, [selected]);
const isVisible = useCallback(
(card: CardDefinition) => !card.adminOnly || isAdmin,
[isAdmin]
);
// Selection order is the render order.
const cards = useMemo(
() =>
selected
.map(getCard)
.filter((card): card is CardDefinition => card !== undefined)
.filter(isVisible),
[selected, isVisible]
);
const available = useMemo(() => CARD_REGISTRY.filter(isVisible), [isVisible]);
const addCard = useCallback((id: CardId) => {
setSelected((current) => (current.includes(id) ? current : [...current, id]));
}, []);
const removeCard = useCallback((id: CardId) => {
setSelected((current) => current.filter((cardId) => cardId !== id));
}, []);
const resetCards = useCallback(() => setSelected(DEFAULT_CARD_IDS), []);
return { cards, available, selected, addCard, removeCard, resetCards };
};
@@ -0,0 +1,39 @@
import { createContext, useContext } from "react";
import { useGet } from "@/Hooks/UseApi";
import {
MAX_RECENT_CHECKS,
MonitorTypes,
type MonitorsWithChecksResponse,
} from "@/Types/Monitor";
const REFRESH_INTERVAL = 30000;
// One request feeds every monitor-derived card. Cards read it from context so
// adding a monitor card costs no extra call.
const buildMonitorsUrl = () => {
const params = new URLSearchParams();
MonitorTypes.forEach((type) => params.append("type", type));
// No rowsPerPage: the repository treats 0 as "no limit", so every monitor
// comes back. The dashboard summarises the whole fleet, it never pages.
params.append("limit", String(MAX_RECENT_CHECKS));
return `/monitors/team/with-checks?${params.toString()}`;
};
export const useMonitorsRequest = () =>
useGet<MonitorsWithChecksResponse>(
buildMonitorsUrl(),
{},
{ refreshInterval: REFRESH_INTERVAL, keepPreviousData: true }
);
export type MonitorsRequest = ReturnType<typeof useMonitorsRequest>;
export const MonitorsContext = createContext<MonitorsRequest | null>(null);
export const useMonitors = (): MonitorsRequest => {
const context = useContext(MonitorsContext);
if (!context) {
throw new Error("useMonitors must be used inside the dashboard's MonitorsContext");
}
return context;
};
+7
View File
@@ -8,6 +8,9 @@ import AuthRegister from "@/Pages/Auth/Register";
import AuthForgotPassword from "@/Pages/Auth/Recovery";
import AuthSetNewPassword from "@/Pages/Auth/SetNewPassword";
// Dashboard
import Dashboard from "@/Pages/Dashboard";
// Uptime
import Uptime from "@/Pages/Uptime/Monitors";
import UptimeDetails from "@/Pages/Uptime/Details";
@@ -87,6 +90,10 @@ const Routes = () => {
path="/"
element={<Navigate to="/uptime" />}
/>
<Route
path="/dashboard"
element={<Dashboard />}
/>
<Route
path="/uptime"
element={<Uptime />}
+48 -1
View File
@@ -211,7 +211,8 @@
"statusPages": "Status pages",
"maintenance": "Maintenance",
"logs": "Logs",
"settings": "Settings"
"settings": "Settings",
"dashboard": "Dashboard"
},
"bottomMenu": {
"support": "Support",
@@ -245,6 +246,52 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Dashboard",
"description": "What is wrong right now, across every monitor type."
},
"card": {
"error": "Could not load this card."
},
"cards": {
"monitorStatus": {
"title": "Monitor status",
"empty": "No monitors yet."
},
"currentlyDown": {
"title": "Currently down",
"empty": "All monitors are up. No incidents open.",
"count": "{{count}} needing attention"
},
"monitorsByType": {
"title": "Monitors by type",
"empty": "No monitors yet.",
"down": "{{count}} down"
},
"recentIncidents": {
"title": "Recent incidents",
"empty": "No incidents — all systems healthy.",
"active": "{{count}} open",
"ongoing": "Ongoing",
"manual": "Manual",
"unknownMonitor": "Unknown monitor"
},
"failedChecks": {
"title": "Failed checks",
"empty": "No checks yet.",
"ratio": "{{failed}} of {{total}} in the last day"
},
"checkmateServer": {
"title": "Checkmate server",
"memory": "Memory",
"eventLoop": "Event loop delay",
"uptime": "Uptime",
"database": "Database",
"readsWrites": "{{reads}} reads/s · {{writes}} writes/s"
}
}
},
"proxies": {
"header": {
"title": "Proxies",