diff --git a/client/src/Components/sidebar/Menu.tsx b/client/src/Components/sidebar/Menu.tsx
index 93cd94623..2a7c359ec 100644
--- a/client/src/Components/sidebar/Menu.tsx
+++ b/client/src/Components/sidebar/Menu.tsx
@@ -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: ,
+ },
{
name: t("components.sidebar.menu.uptime"),
path: "uptime",
diff --git a/client/src/Pages/Dashboard/Components/CardGrid.tsx b/client/src/Pages/Dashboard/Components/CardGrid.tsx
new file mode 100644
index 000000000..569700834
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/CardGrid.tsx
@@ -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 (
+
+ {placed.map(({ card, renderedWidth }) => {
+ const Component = card.component;
+ return (
+
+
+
+ );
+ })}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/CardPrimitives.tsx b/client/src/Pages/Dashboard/Components/CardPrimitives.tsx
new file mode 100644
index 000000000..d150b8c49
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/CardPrimitives.tsx
@@ -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 (
+
+
+
+ );
+};
+
+/** A clickable list row. Rows link to the entity they describe. */
+export const CardRow = ({ to, children }: { to?: string; children: ReactNode }) => {
+ const theme = useTheme();
+
+ return (
+
+ {children}
+
+ );
+};
+
+/** Name plus optional secondary line, truncating rather than wrapping. */
+export const CardRowLabel = ({
+ primary,
+ secondary,
+}: {
+ primary: string;
+ secondary?: string | null;
+}) => {
+ const theme = useTheme();
+
+ return (
+
+
+ {primary}
+
+ {secondary && (
+
+ {secondary}
+
+ )}
+
+ );
+};
+
+/** 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 (
+
+
+ {value}
+
+ {caption && (
+
+ {caption}
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/Cards/CheckmateServerCard.tsx b/client/src/Pages/Dashboard/Components/Cards/CheckmateServerCard.tsx
new file mode 100644
index 000000000..2d8c11cbd
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/CheckmateServerCard.tsx
@@ -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 (
+
+
+ {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.
+ */
+export const CheckmateServerCard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { data, isLoading, error } = useGet(
+ "/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 (
+
+ {data && (
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/Cards/CurrentlyDownCard.tsx b/client/src/Pages/Dashboard/Components/Cards/CurrentlyDownCard.tsx
new file mode 100644
index 000000000..40f995327
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/CurrentlyDownCard.tsx
@@ -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> = {
+ 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 (
+ 0 ? (
+
+ {t("pages.dashboard.cards.currentlyDown.count", {
+ count: problems.length,
+ })}
+
+ ) : null
+ }
+ >
+ {problems.length === 0 ? (
+
+ ) : (
+
+ {problems.map(({ monitor, since }) => (
+
+
+
+
+ {monitor.type}
+
+ {since && (
+
+ {formatDuration(Date.now() - new Date(since).getTime())}
+
+ )}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/Cards/FailedChecksCard.tsx b/client/src/Pages/Dashboard/Components/Cards/FailedChecksCard.tsx
new file mode 100644
index 000000000..4abec8ad5
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/FailedChecksCard.tsx
@@ -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(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 (
+
+ {total === 0 ? (
+
+ ) : (
+
+
+
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/Cards/MonitorStatusCard.tsx b/client/src/Pages/Dashboard/Components/Cards/MonitorStatusCard.tsx
new file mode 100644
index 000000000..439862451
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/MonitorStatusCard.tsx
@@ -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 (
+
+ {!summary || summary.totalMonitors === 0 ? (
+
+ ) : (
+
+ {COUNTS.map(({ key, status, field }) => (
+
+
+
+
+ {t(`pages.common.monitors.status.${key}`)}
+
+
+
+ {summary[field]}
+
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/Cards/MonitorsByTypeCard.tsx b/client/src/Pages/Dashboard/Components/Cards/MonitorsByTypeCard.tsx
new file mode 100644
index 000000000..3fec373e4
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/MonitorsByTypeCard.tsx
@@ -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(() => {
+ const byType = new Map();
+ 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 (
+
+ {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
+ }
+ />
+
+
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/Cards/RecentIncidentsCard.tsx b/client/src/Pages/Dashboard/Components/Cards/RecentIncidentsCard.tsx
new file mode 100644
index 000000000..0ad22443a
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/RecentIncidentsCard.tsx
@@ -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("/incidents/team/summary");
+
+ const incidents = (data?.latestIncidents ?? []).slice(0, MAX_ROWS);
+
+ return (
+ 0 ? (
+
+ {t("pages.dashboard.cards.recentIncidents.active", {
+ count: data.totalActive,
+ })}
+
+ ) : null
+ }
+ >
+ {incidents.length === 0 ? (
+
+ ) : (
+
+ {incidents.map((incident) => {
+ const isOpen = incident.status === true;
+ const duration = incidentDuration(incident);
+ return (
+
+
+
+
+ {isOpen
+ ? t("pages.dashboard.cards.recentIncidents.ongoing")
+ : (duration ?? "—")}
+
+ {incident.resolutionType === "manual" && (
+
+ {t("pages.dashboard.cards.recentIncidents.manual")}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/DashboardCard.tsx b/client/src/Pages/Dashboard/Components/DashboardCard.tsx
new file mode 100644
index 000000000..c53323e85
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/DashboardCard.tsx
@@ -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 (
+
+
+
+ {title}
+
+
+ {isStale && (
+
+ )}
+ {action}
+
+
+
+ {isLoading ? (
+
+ ) : error ? (
+
+ ) : (
+ children
+ )}
+
+
+ );
+};
+
+/** Placeholder rows, sized to the row list most cards render. */
+export const CardSkeleton = ({ rows = 3 }: { rows?: number }) => {
+ const theme = useTheme();
+ return (
+
+ {Array.from({ length: rows }).map((_, index) => (
+
+ ))}
+
+ );
+};
+
+/** The quiet one-line state a healthy card collapses to. */
+export const CardMessage = ({ text, action }: { text: string; action?: ReactNode }) => {
+ const theme = useTheme();
+ return (
+
+
+ {text}
+
+ {action}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/Sparkline.tsx b/client/src/Pages/Dashboard/Components/Sparkline.tsx
new file mode 100644
index 000000000..17b39b196
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Sparkline.tsx
@@ -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 (
+
+
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/cards.ts b/client/src/Pages/Dashboard/cards.ts
new file mode 100644
index 000000000..e54a1c168
--- /dev/null
+++ b/client/src/Pages/Dashboard/cards.ts
@@ -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: 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";
diff --git a/client/src/Pages/Dashboard/index.tsx b/client/src/Pages/Dashboard/index.tsx
new file mode 100644
index 000000000..9e158e5fa
--- /dev/null
+++ b/client/src/Pages/Dashboard/index.tsx
@@ -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 (
+
+
+
+
+
+ );
+};
+
+export default Dashboard;
diff --git a/client/src/Pages/Dashboard/layout.ts b/client/src/Pages/Dashboard/layout.ts
new file mode 100644
index 000000000..12a7166a4
--- /dev/null
+++ b/client/src/Pages/Dashboard/layout.ts
@@ -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;
+};
diff --git a/client/src/Pages/Dashboard/registry.ts b/client/src/Pages/Dashboard/registry.ts
new file mode 100644
index 000000000..74be7f36c
--- /dev/null
+++ b/client/src/Pages/Dashboard/registry.ts
@@ -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(
+ CARD_REGISTRY.map((card) => [card.id, card])
+);
+
+export const getCard = (id: CardId): CardDefinition | undefined => BY_ID.get(id);
diff --git a/client/src/Pages/Dashboard/useCardSelection.ts b/client/src/Pages/Dashboard/useCardSelection.ts
new file mode 100644
index 000000000..267aa42c7
--- /dev/null
+++ b/client/src/Pages/Dashboard/useCardSelection.ts
@@ -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(
+ () => 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 };
+};
diff --git a/client/src/Pages/Dashboard/useDashboardData.ts b/client/src/Pages/Dashboard/useDashboardData.ts
new file mode 100644
index 000000000..ca25b16b8
--- /dev/null
+++ b/client/src/Pages/Dashboard/useDashboardData.ts
@@ -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(
+ buildMonitorsUrl(),
+ {},
+ { refreshInterval: REFRESH_INTERVAL, keepPreviousData: true }
+ );
+
+export type MonitorsRequest = ReturnType;
+
+export const MonitorsContext = createContext(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;
+};
diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx
index 968b468c2..1ed458b2f 100644
--- a/client/src/Routes/index.tsx
+++ b/client/src/Routes/index.tsx
@@ -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={}
/>
+ }
+ />
}
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index e82f84949..4b938e4e9 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -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",