diff --git a/client/package-lock.json b/client/package-lock.json
index f6adb320d..7c2b06da9 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -9,6 +9,9 @@
"version": "3.11.0",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource-variable/geist": "^5.3.0",
@@ -93,7 +96,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -363,6 +365,59 @@
"node": ">=14"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"license": "MIT",
@@ -409,7 +464,6 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -447,7 +501,6 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1236,7 +1289,6 @@
"node_modules/@mui/material": {
"version": "7.3.7",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7",
@@ -1341,7 +1393,6 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -2386,7 +2437,6 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2402,7 +2452,6 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2493,7 +2542,6 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -2774,7 +2822,6 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3131,7 +3178,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3575,8 +3621,7 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3975,7 +4020,6 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4788,7 +4832,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5600,7 +5643,6 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"license": "BSD-3-Clause",
- "peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -6382,7 +6424,6 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6394,7 +6435,6 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6598,8 +6638,7 @@
},
"node_modules/redux": {
"version": "5.0.1",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6749,7 +6788,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/estree": "1.0.9"
},
@@ -7591,7 +7629,6 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7729,7 +7766,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
diff --git a/client/package.json b/client/package.json
index 16f06aced..f041a4ab1 100644
--- a/client/package.json
+++ b/client/package.json
@@ -20,6 +20,9 @@
},
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource-variable/geist": "^5.3.0",
diff --git a/client/src/Components/sidebar/Menu.tsx b/client/src/Components/sidebar/Menu.tsx
index be42fa6e5..dea3fbeb5 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,
@@ -26,6 +27,11 @@ export const getMenu = (t: Function) => {
{
group: t("components.sidebar.menu.groups.monitoring"),
items: [
+ {
+ name: t("components.sidebar.menu.dashboard"),
+ path: "dashboard",
+ icon: ,
+ },
{
name: t("components.sidebar.menu.uptime"),
path: "uptime",
diff --git a/client/src/Features/UI/uiSlice.ts b/client/src/Features/UI/uiSlice.ts
index a07b629f8..87b689789 100644
--- a/client/src/Features/UI/uiSlice.ts
+++ b/client/src/Features/UI/uiSlice.ts
@@ -1,4 +1,5 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
+import type { CardId } from "@/Pages/Dashboard/cards";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
@@ -35,6 +36,7 @@ interface UIState {
language: string;
starPromptOpen: boolean;
chartType: ChartType;
+ dashboardCards: CardId[] | null;
}
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
@@ -74,6 +76,7 @@ const initialState: UIState = {
language: "en",
starPromptOpen: true,
chartType: "histogram",
+ dashboardCards: null,
};
const uiSlice = createSlice({
@@ -117,6 +120,9 @@ const uiSlice = createSlice({
setChartType: (state, action: PayloadAction) => {
state.chartType = action.payload;
},
+ setDashboardCards: (state, action: PayloadAction) => {
+ state.dashboardCards = action.payload;
+ },
},
});
@@ -133,4 +139,5 @@ export const {
setLanguage,
setStarPromptOpen,
setChartType,
+ setDashboardCards,
} = uiSlice.actions;
diff --git a/client/src/Pages/Dashboard/Components/CardGrid.tsx b/client/src/Pages/Dashboard/Components/CardGrid.tsx
new file mode 100644
index 000000000..eaffcbbfc
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/CardGrid.tsx
@@ -0,0 +1,123 @@
+import Box from "@mui/material/Box";
+import { useTheme } from "@mui/material/styles";
+import { GripVertical } from "lucide-react";
+import {
+ DndContext,
+ closestCenter,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import type { DragEndEvent } from "@dnd-kit/core";
+import { SortableContext, useSortable, rectSortingStrategy } from "@dnd-kit/sortable";
+
+import { Icon } from "@/Components/design-elements";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { GRID_COLUMNS, type CardDefinition, type CardId } from "../cards";
+import { CardSlotContext } from "./CardSlotContext";
+
+const SortableCard = ({ card }: { card: CardDefinition }) => {
+ const theme = useTheme();
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
+ useSortable({ id: card.id });
+ const Component = card.component;
+
+ return (
+ = GRID_COLUMNS
+ ? "100%"
+ : `calc(${(card.width / GRID_COLUMNS) * 100}% - ${theme.spacing(LAYOUT.MD / 2)})`,
+ },
+ transform: transform
+ ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)`
+ : undefined,
+ transition,
+ opacity: isDragging ? 0.4 : 1,
+ zIndex: isDragging ? 1 : "auto",
+ }}
+ >
+
+
+
+ }
+ >
+
+
+
+ );
+};
+
+export const CardGrid = ({
+ cards,
+ onReorder,
+}: {
+ cards: CardDefinition[];
+ onReorder: (sourceIndex: number, destIndex: number) => void;
+}) => {
+ const theme = useTheme();
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: { distance: 8 },
+ })
+ );
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+ const sourceIndex = cards.findIndex((c) => c.id === active.id);
+ const destIndex = cards.findIndex((c) => c.id === over.id);
+ if (sourceIndex !== -1 && destIndex !== -1) onReorder(sourceIndex, destIndex);
+ };
+
+ const cardIds = cards.map((c) => c.id);
+
+ return (
+
+
+
+ {cards.map((card) => (
+
+ ))}
+
+
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/CardPicker.tsx b/client/src/Pages/Dashboard/Components/CardPicker.tsx
new file mode 100644
index 000000000..86d1c1833
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/CardPicker.tsx
@@ -0,0 +1,137 @@
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
+import FormControlLabel from "@mui/material/FormControlLabel";
+import Stack from "@mui/material/Stack";
+import { useTheme } from "@mui/material/styles";
+import { useTranslation } from "react-i18next";
+import { useMemo } from "react";
+
+import { Dialog, Checkbox, Button } from "@/Components/inputs";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { CardGroups, type CardDefinition, type CardGroup, type CardId } from "../cards";
+
+interface CardPickerProps {
+ open: boolean;
+ available: CardDefinition[];
+ selected: CardId[];
+ allSelected: boolean;
+ onAdd: (id: CardId) => void;
+ onRemove: (id: CardId) => void;
+ onSelectAll: () => void;
+ onDeselectAll: () => void;
+ onReset: () => void;
+ onClose: () => void;
+}
+
+/**
+ * Cards are grouped by sidebar section, not by load cost: you look for a card
+ * where you look for the feature it belongs to. Admin-only cards never reach
+ * `available` for a non-admin, so nobody is offered a card they cannot load.
+ */
+export const CardPicker = ({
+ open,
+ available,
+ selected,
+ allSelected,
+ onAdd,
+ onRemove,
+ onSelectAll,
+ onDeselectAll,
+ onReset,
+ onClose,
+}: CardPickerProps) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+
+ const grouped = useMemo(() => {
+ return CardGroups.map((group: CardGroup) => ({
+ group,
+ cards: available.filter((card) => card.group === group),
+ })).filter((entry) => entry.cards.length > 0);
+ }, [available]);
+
+ const chosen = new Set(selected);
+ const someSelected = selected.length > 0 && !allSelected;
+
+ 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..176e37fad
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/CardPrimitives.tsx
@@ -0,0 +1,275 @@
+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 { Tooltip } from "@/Components/design-elements";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { typographyLevels } from "@/Utils/Theme/Palette";
+
+import type { ReactNode } from "react";
+
+/**
+ * One thickness for every bar on the dashboard. Plain and segmented bars sit
+ * side by side across cards, so differing weights read as an inconsistency
+ * rather than as emphasis.
+ */
+const BAR_HEIGHT = 8;
+
+export interface BarSegment {
+ key: string;
+ value: number;
+ color: string;
+ /** Tooltip text, since the segments themselves carry no labels. */
+ label: string;
+}
+
+/**
+ * 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 (
+
+
+
+ );
+};
+
+/**
+ * One bar divided into proportional segments — the shape of a whole split into
+ * parts, where the relative sizes are the point rather than any single value.
+ *
+ * Zero-value segments are dropped so they cannot appear as hairlines, and each
+ * segment carries a tooltip because the bar itself has no room for labels.
+ */
+export const CardSegmentedBar = ({ segments }: { segments: BarSegment[] }) => {
+ const theme = useTheme();
+ const visible = segments.filter((segment) => segment.value > 0);
+ const total = visible.reduce((sum, segment) => sum + segment.value, 0);
+
+ return (
+
+ {total > 0 &&
+ visible.map((segment) => (
+
+
+
+ ))}
+
+ );
+};
+
+/** 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}}
+
+ );
+};
+
+/**
+ * A label with its value on the right — the shape almost every stat card
+ * repeats. `color` highlights the value alone, leaving the label quiet.
+ */
+export const CardStatLine = ({
+ label,
+ value,
+ color,
+}: {
+ label: string;
+ value: string;
+ color?: string;
+}) => {
+ const theme = useTheme();
+
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+};
+
+/**
+ * A CardStatLine with a proportional bar beneath it — the ranking and
+ * breakdown row. `max` defaults to a percentage scale; pass the largest value
+ * in the set to make bars relative to each other instead.
+ *
+ * `label` may be a node so callers can put a "N down" flag beside the name.
+ */
+export const CardMeterRow = ({
+ label,
+ value,
+ max,
+ color,
+ trailing,
+}: {
+ label: ReactNode;
+ value: number;
+ max?: number;
+ color: string;
+ trailing?: ReactNode;
+}) => {
+ const theme = useTheme();
+
+ return (
+
+
+ {typeof label === "string" ? (
+
+ {label}
+
+ ) : (
+ label
+ )}
+ {trailing}
+
+
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/CardSlotContext.tsx b/client/src/Pages/Dashboard/Components/CardSlotContext.tsx
new file mode 100644
index 000000000..f5f493309
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/CardSlotContext.tsx
@@ -0,0 +1,7 @@
+import { createContext, useContext } from "react";
+
+import type { ReactNode } from "react";
+
+export const CardSlotContext = createContext(null);
+
+export const useCardSlot = (): ReactNode => useContext(CardSlotContext);
diff --git a/client/src/Pages/Dashboard/Components/Cards/AdminCards.tsx b/client/src/Pages/Dashboard/Components/Cards/AdminCards.tsx
new file mode 100644
index 000000000..cef8b0361
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/AdminCards.tsx
@@ -0,0 +1,219 @@
+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 { useGet } from "@/Hooks/UseApi";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { formatTimestamp } from "@/Utils/TimeUtils";
+import { DashboardCard, CardMessage } from "../DashboardCard";
+import { CardFigure, CardRow, CardRowLabel, CardStatLine } from "../CardPrimitives";
+
+import { REFRESH_INTERVAL_MS, SLOW_REFRESH_INTERVAL_MS } from "../../cards";
+
+import type { QueueMetrics } from "@/Types/Queue";
+import type { Log } from "@/Types/Log";
+import type { User } from "@/Types/User";
+
+const MAX_ERROR_ROWS = 5;
+
+/** Reads as a single reassurance line while nothing is failing. */
+export const ChecksOnScheduleCard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { data, isLoading, isValidating, error } = useGet(
+ "/queue/metrics",
+ {},
+ { refreshInterval: REFRESH_INTERVAL_MS }
+ );
+
+ const healthy = data?.failingJobs === 0;
+
+ return (
+
+ {data && (
+
+ {healthy ? (
+
+ ) : (
+ <>
+
+
+ {data.jobsWithFailures.slice(0, MAX_ERROR_ROWS).map((job) => (
+
+
+
+ {job.failCount}
+
+
+ ))}
+
+ >
+ )}
+
+
+
+ )}
+
+ );
+};
+
+/**
+ * /logs is an in-memory ring buffer capped at 1000 entries, wiped on restart,
+ * and it ignores page/rowsPerPage. It rotates fast, so this is deliberately
+ * worded "recent" rather than "all".
+ */
+export const RecentErrorsCard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { data, isLoading, isValidating, error } = useGet(
+ "/logs",
+ {},
+ { refreshInterval: SLOW_REFRESH_INTERVAL_MS }
+ );
+
+ const { errors, warnings, newest } = useMemo(() => {
+ const relevant = (data ?? []).filter(
+ (entry) => entry.level === "error" || entry.level === "warn"
+ );
+ const sorted = [...relevant].sort(
+ (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
+ );
+ return {
+ errors: relevant.filter((entry) => entry.level === "error").length,
+ warnings: relevant.filter((entry) => entry.level === "warn").length,
+ newest: sorted.slice(0, MAX_ERROR_ROWS),
+ };
+ }, [data]);
+
+ return (
+ 0 ? (
+
+ {t("pages.dashboard.cards.recentErrors.count", { count: errors })}
+
+ ) : null
+ }
+ >
+ {newest.length === 0 ? (
+
+ ) : (
+
+
+
+ {newest.map((entry, index) => (
+
+
+
+ {formatTimestamp(entry.timestamp)}
+
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+/** Never empty — at minimum the current user exists. */
+export const TeamCard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { data, isLoading, isValidating, error } = useGet("/auth/users");
+
+ const { total, byRole, unverified } = useMemo(() => {
+ const users = data ?? [];
+ const roles = new Map();
+ for (const user of users) {
+ for (const role of user.role ?? []) {
+ roles.set(role, (roles.get(role) ?? 0) + 1);
+ }
+ }
+ return {
+ total: users.length,
+ byRole: [...roles.entries()].sort((a, b) => b[1] - a[1]),
+ unverified: users.filter((user) => !user.isVerified).length,
+ };
+ }, [data]);
+
+ return (
+
+ {data && (
+
+
+ {byRole.map(([role, count]) => (
+
+ ))}
+ {unverified > 0 && (
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/Cards/BreakdownCard.tsx b/client/src/Pages/Dashboard/Components/Cards/BreakdownCard.tsx
new file mode 100644
index 000000000..b2b7cadef
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/BreakdownCard.tsx
@@ -0,0 +1,97 @@
+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 { 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();
+
+ const max = Math.max(0, ...buckets.map((bucket) => bucket.total));
+
+ return (
+
+ {buckets.length === 0 ? (
+
+ ) : (
+
+ {buckets.map((bucket) => (
+
+ 0 ? theme.palette.error.main : theme.palette.primary.main
+ }
+ trailing={
+
+ {bucket.down > 0 && (
+
+ {t("pages.dashboard.cards.monitorsByType.down", {
+ count: bucket.down,
+ })}
+
+ )}
+
+ {bucket.total}
+
+
+ }
+ />
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/Cards/BusiestServersCard.tsx b/client/src/Pages/Dashboard/Components/Cards/BusiestServersCard.tsx
new file mode 100644
index 000000000..eb19ed65e
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/BusiestServersCard.tsx
@@ -0,0 +1,143 @@
+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 { DashboardCard, CardMessage } from "../DashboardCard";
+import { CardBar, CardRow, CardRowLabel } from "../CardPrimitives";
+import { useMonitors } from "../../useDashboardData";
+
+import { latestCheck } from "../../checks";
+
+import type { Monitor } from "@/Types/Monitor";
+
+// <60% fine, 60-85% warm, above that hot.
+const WARM = 60;
+const HOT = 85;
+
+interface ServerUsage {
+ monitor: Monitor;
+ cpu: number | null;
+ memory: number | null;
+ disk: number | null;
+ peak: number;
+}
+
+const Metric = ({ label, value }: { label: string; value: number | null }) => {
+ const theme = useTheme();
+ const color =
+ value === null
+ ? theme.palette.text.secondary
+ : value > HOT
+ ? theme.palette.error.main
+ : value > WARM
+ ? theme.palette.warning.main
+ : theme.palette.primary.main;
+
+ return (
+
+
+ {label}
+
+ {/* A down server reports nothing — show a dash, never 0%. */}
+ {value === null ? "—" : `${value.toFixed(1)}%`}
+
+
+
+
+ );
+};
+
+export const BusiestServersCard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { data, isLoading, isValidating, error } = useMonitors();
+
+ const servers = useMemo(() => {
+ return (data?.monitors ?? [])
+ .filter((monitor) => monitor.type === "hardware")
+ .map((monitor) => {
+ const latest = latestCheck(monitor);
+ // snake_case here, unlike camelCase in HardwareCheckStats, and
+ // these are 0-1 fractions, so scale to percent.
+ const toPercent = (value: number | undefined) =>
+ typeof value === "number" ? value * 100 : null;
+ const cpu = toPercent(latest?.cpu?.usage_percent);
+ const memory = toPercent(latest?.memory?.usage_percent);
+ const disks = (latest?.disk ?? [])
+ .map((entry) => entry.usage_percent)
+ .filter((value): value is number => typeof value === "number");
+ const disk = disks.length > 0 ? Math.max(...disks) * 100 : null;
+ return {
+ monitor,
+ cpu,
+ memory,
+ disk,
+ peak: Math.max(cpu ?? 0, memory ?? 0, disk ?? 0),
+ };
+ })
+ .sort((a, b) => b.peak - a.peak);
+ }, [data]);
+
+ return (
+
+ {servers.length === 0 ? (
+
+ ) : (
+
+ {servers.map(({ monitor, cpu, memory, disk }) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+};
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..9219ebbc5
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/CheckmateServerCard.tsx
@@ -0,0 +1,93 @@
+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 { formatDuration } from "@/Utils/TimeUtils";
+import { DashboardCard } from "../DashboardCard";
+import { CardBar, CardStatLine } from "../CardPrimitives";
+
+import { REFRESH_INTERVAL_MS } from "../../cards";
+
+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;
+
+/**
+ * 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, isValidating, error } = useGet(
+ "/diagnostic/system",
+ {},
+ { refreshInterval: REFRESH_INTERVAL_MS }
+ );
+
+ 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..af2295a3c
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/CurrentlyDownCard.tsx
@@ -0,0 +1,157 @@
+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 } from "@/Components/design-elements";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { getStatusColor, getMonitorPath } from "@/Utils/MonitorUtils";
+import { formatStatusCode } from "@/Utils/statusCode";
+import { formatDuration } from "@/Utils/TimeUtils";
+import { DashboardCard, CardMessage } from "../DashboardCard";
+import { CardRow } from "../CardPrimitives";
+import { Sparkline } from "../Sparkline";
+import { useMonitors } from "../../useDashboardData";
+
+import { latestCheck } from "../../checks";
+
+import type { Monitor, MonitorStatus } from "@/Types/Monitor";
+
+const SPARKLINE_WIDTH = 80;
+
+const STATUS_RANK: Partial> = {
+ down: 1,
+ breached: 2,
+};
+
+const isProblem = (monitor: Monitor) => STATUS_RANK[monitor.status] !== undefined;
+
+/**
+ * How long the monitor has been down, from the oldest consecutive failing
+ * check. recentChecks is oldest-first, so the run is walked back from the end.
+ */
+const downSince = (monitor: Monitor): string | null => {
+ const checks = monitor.recentChecks ?? [];
+ let oldestFailing: string | null = null;
+ for (let index = checks.length - 1; index >= 0; index -= 1) {
+ if (checks[index].status !== false) {
+ break;
+ }
+ oldestFailing = checks[index].createdAt;
+ }
+ return oldestFailing;
+};
+
+export const CurrentlyDownCard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { data, isLoading, isValidating, 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.name}
+
+ {since && (
+
+ {formatDuration(Date.now() - new Date(since).getTime())}
+
+ )}
+
+
+ {/* Not StatusCodeLabel: its Tooltip is focusable, and a
+ focusable element inside the row's link is unreachable
+ by keyboard. */}
+
+ {monitor.type} ·{" "}
+ {formatStatusCode(latestCheck(monitor)?.statusCode, t)}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/Cards/FailedChecksCard.tsx b/client/src/Pages/Dashboard/Components/Cards/FailedChecksCard.tsx
new file mode 100644
index 000000000..7b51ce317
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/FailedChecksCard.tsx
@@ -0,0 +1,54 @@
+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, isValidating, 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..8f221488b
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/MonitorStatusCard.tsx
@@ -0,0 +1,138 @@
+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 { CardSegmentedBar, type BarSegment } from "../CardPrimitives";
+import { useMonitors } from "../../useDashboardData";
+
+import type { MonitorStatus, MonitorsSummary } from "@/Types/Monitor";
+
+// Every state the summary reports, ordered worst first so the bar reads
+// problems-to-healthy left to right and the chips below list what is wrong
+// before what is merely idle. `up` is handled separately as the headline.
+const STATES: { key: string; status: MonitorStatus; field: keyof MonitorsSummary }[] = [
+ { key: "down", status: "down", field: "downMonitors" },
+ { key: "breached", status: "breached", field: "breachedMonitors" },
+ { key: "initializing", status: "initializing", field: "initializingMonitors" },
+ { key: "maintenance", status: "maintenance", field: "maintenanceMonitors" },
+ { key: "paused", status: "paused", field: "pausedMonitors" },
+];
+
+export const MonitorStatusCard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { data, isLoading, isValidating, error } = useMonitors();
+ const summary = data?.summary ?? null;
+
+ // Only states that actually have monitors are shown. Padding the card with
+ // zeros says nothing, and a healthy fleet should read as calm rather than as
+ // a row of empty counters.
+ const present = summary
+ ? STATES.map((state) => ({ ...state, count: summary[state.field] })).filter(
+ (state) => state.count > 0
+ )
+ : [];
+
+ const segments: BarSegment[] = summary
+ ? [
+ {
+ key: "up",
+ value: summary.upMonitors,
+ color: getStatusColor("up", theme),
+ label: `${t("pages.common.monitors.status.up")} — ${summary.upMonitors}`,
+ },
+ ...present.map((state) => ({
+ key: state.key,
+ value: state.count,
+ color: getStatusColor(state.status, theme),
+ label: `${t(`pages.common.monitors.status.${state.key}`)} — ${state.count}`,
+ })),
+ ]
+ : [];
+
+ return (
+
+ {!summary || summary.totalMonitors === 0 ? (
+
+ ) : (
+
+
+
+
+ {summary.upMonitors}
+
+
+ {t("pages.dashboard.cards.monitorStatus.upOfTotal", {
+ total: summary.totalMonitors,
+ })}
+
+
+
+
+ {present.length > 0 && (
+
+ {present.map((state) => (
+
+
+
+ {t(`pages.common.monitors.status.${state.key}`)}
+
+
+ {state.count}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/Cards/MonitorsByGroupCard.tsx b/client/src/Pages/Dashboard/Components/Cards/MonitorsByGroupCard.tsx
new file mode 100644
index 000000000..c620a7651
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/MonitorsByGroupCard.tsx
@@ -0,0 +1,43 @@
+import { useMemo } from "react";
+
+import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard";
+import { useMonitors } from "../../useDashboardData";
+
+export const MonitorsByGroupCard = () => {
+ const { data, isLoading, isValidating, error } = useMonitors();
+
+ // `group` is a plain string | null on the monitor — there is no groups
+ // endpoint, so the set is whatever monitors actually declare.
+ const buckets = useMemo(() => {
+ const byGroup = new Map();
+ for (const monitor of data?.monitors ?? []) {
+ if (!monitor.group) {
+ continue;
+ }
+ const bucket = byGroup.get(monitor.group) ?? {
+ key: monitor.group,
+ label: monitor.group,
+ total: 0,
+ down: 0,
+ };
+ bucket.total += 1;
+ if (monitor.status === "down" || monitor.status === "breached") {
+ bucket.down += 1;
+ }
+ byGroup.set(monitor.group, bucket);
+ }
+ return [...byGroup.values()].sort((a, b) => b.total - a.total);
+ }, [data]);
+
+ return (
+
+ );
+};
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..a42b9c4cb
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/MonitorsByTypeCard.tsx
@@ -0,0 +1,42 @@
+import { useMemo } from "react";
+
+import { getMonitorPath } from "@/Utils/MonitorUtils";
+import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard";
+import { useMonitors } from "../../useDashboardData";
+
+import type { MonitorType } from "@/Types/Monitor";
+
+export const MonitorsByTypeCard = () => {
+ const { data, isLoading, isValidating, error } = useMonitors();
+
+ const buckets = useMemo(() => {
+ const byType = new Map();
+ for (const monitor of data?.monitors ?? []) {
+ const bucket = byType.get(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") {
+ bucket.down += 1;
+ }
+ byType.set(monitor.type, bucket);
+ }
+ return [...byType.values()].sort((a, b) => b.total - a.total);
+ }, [data]);
+
+ return (
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/Cards/RankingCards.tsx b/client/src/Pages/Dashboard/Components/Cards/RankingCards.tsx
new file mode 100644
index 000000000..1ddbfb6c2
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/RankingCards.tsx
@@ -0,0 +1,175 @@
+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 { getMonitorPath, getUptimePercentageColor } from "@/Utils/MonitorUtils";
+import { DashboardCard, CardMessage } from "../DashboardCard";
+import { CardMeterRow, CardRow, CardRowLabel } from "../CardPrimitives";
+import { useMonitors } from "../../useDashboardData";
+
+import type { Monitor } from "@/Types/Monitor";
+
+const TOP_N = 5;
+// A ranking of one is not a ranking.
+const MIN_RANKABLE = 2;
+
+const averageResponseTime = (monitor: Monitor): number | null => {
+ const times = (monitor.recentChecks ?? [])
+ .map((check) => check.responseTime)
+ .filter((value): value is number => typeof value === "number");
+ if (times.length === 0) {
+ return null;
+ }
+ return times.reduce((sum, value) => sum + value, 0) / times.length;
+};
+
+/** Header reads "top 5 of 12" so a truncated ranking says so. */
+const RankingCount = ({ shown, total }: { shown: number; total: number }) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ return (
+
+ {t("pages.dashboard.cards.ranking.topOf", { shown, total })}
+
+ );
+};
+
+export const SlowestMonitorsCard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { data, isLoading, isValidating, error } = useMonitors();
+
+ const ranked = useMemo(() => {
+ return (data?.monitors ?? [])
+ .filter((monitor) => monitor.status !== "paused")
+ .map((monitor) => ({ monitor, average: averageResponseTime(monitor) }))
+ .filter(
+ (entry): entry is { monitor: Monitor; average: number } => entry.average !== null
+ )
+ .sort((a, b) => b.average - a.average);
+ }, [data]);
+
+ const slowest = ranked[0]?.average ?? 0;
+
+ return (
+ = MIN_RANKABLE ? (
+
+ ) : null
+ }
+ >
+ {ranked.length < MIN_RANKABLE ? (
+
+ ) : (
+
+ {ranked.slice(0, TOP_N).map(({ monitor, average }) => (
+
+ }
+ value={average}
+ max={slowest}
+ color={theme.palette.primary.main}
+ trailing={
+
+ {Math.round(average)} ms
+
+ }
+ />
+
+ ))}
+
+ )}
+
+ );
+};
+
+export const LowestUptimeCard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { data, isLoading, isValidating, error } = useMonitors();
+
+ const ranked = useMemo(() => {
+ return (
+ (data?.monitors ?? [])
+ // Paused monitors keep whatever uptime they had, or zero — a paused
+ // ping monitor reads 0% and would rank beside a genuinely down one.
+ // Neither is a live problem, so neither belongs in a worst-uptime list.
+ .filter((monitor) => monitor.status !== "paused")
+ .filter(
+ (monitor): monitor is Monitor & { uptimePercentage: number } =>
+ typeof monitor.uptimePercentage === "number"
+ )
+ .sort((a, b) => a.uptimePercentage - b.uptimePercentage)
+ );
+ }, [data]);
+
+ return (
+ = MIN_RANKABLE ? (
+
+ ) : null
+ }
+ >
+ {ranked.length < MIN_RANKABLE ? (
+
+ ) : (
+
+ {ranked.slice(0, TOP_N).map((monitor) => {
+ // uptimePercentage is a 0-1 fraction, not 0-100.
+ const percentage = monitor.uptimePercentage * 100;
+ const palette = getUptimePercentageColor(monitor.uptimePercentage);
+ return (
+
+ }
+ // The card ranks worst uptime first, so the bar shows the
+ // shortfall - the worst monitor gets the longest bar.
+ value={100 - percentage}
+ color={theme.palette[palette].main}
+ trailing={
+
+ {percentage.toFixed(1)}%
+
+ }
+ />
+
+ );
+ })}
+
+ )}
+
+ );
+};
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..f69aca839
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/RecentIncidentsCard.tsx
@@ -0,0 +1,99 @@
+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 { 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, isValidating, 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/Cards/SummaryCards.tsx b/client/src/Pages/Dashboard/Components/Cards/SummaryCards.tsx
new file mode 100644
index 000000000..a8d89907f
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Cards/SummaryCards.tsx
@@ -0,0 +1,258 @@
+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 { useGet } from "@/Hooks/UseApi";
+import { Dot } from "@/Components/design-elements";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { formatTimestamp } from "@/Utils/TimeUtils";
+import { DashboardCard, CardMessage } from "../DashboardCard";
+import {
+ CardFigure,
+ CardRow,
+ CardRowLabel,
+ CardSegmentedBar,
+ 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";
+
+/** Free when Recent incidents is present — same summary call, shared by SWR. */
+export const IncidentStatsCard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { data, isLoading, isValidating, error } = useGet(
+ "/incidents/team/summary"
+ );
+
+ return (
+
+ {data && (
+
+ {/* Open now leads, paired with the all-time total for context: two
+ open means something different against 12 incidents than 183. */}
+
+ 0
+ ? theme.palette.error.main
+ : theme.palette.success.main
+ }
+ />
+
+
+ {/* How much of the fleet heals itself — the automatic/manual split
+ is already computed server-side and said nothing on the card
+ before. A high manual share means people are doing the work. */}
+ {data.totalAutomaticResolutions + data.totalManualResolutions > 0 && (
+
+
+
+
+ )}
+
+
+ {data.topMonitor?.monitorName && (
+
+ )}
+
+
+ )}
+
+ );
+};
+
+export const NotificationChannelsCard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { data, isLoading, isValidating, error } =
+ useGet("/notifications/team");
+
+ // The response also carries accessToken and accountSid. Only `type` is read
+ // here — credential fields must never reach the DOM.
+ const byType = useMemo(() => {
+ const counts = new Map();
+ for (const notification of data ?? []) {
+ counts.set(notification.type, (counts.get(notification.type) ?? 0) + 1);
+ }
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]);
+ }, [data]);
+
+ return (
+
+ {byType.length === 0 ? (
+
+ ) : (
+
+ {byType.map(([type, count]) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+export const MaintenanceCard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { data, isLoading, isValidating, error } = useGet<{
+ maintenanceWindows: MaintenanceWindow[];
+ maintenanceWindowCount: number;
+ }>("/maintenance-window/team?page=0&rowsPerPage=0");
+
+ // `active` is a user on/off flag, not "running now" — compare the window
+ // client-side. repeat > 0 means recurring, so `end` may sit in the past for
+ // a window that is currently live.
+ const { running, next } = useMemo(() => {
+ const now = Date.now();
+ const windows = (data?.maintenanceWindows ?? []).filter((entry) => entry.active);
+ const live = windows.filter((entry) => {
+ const start = new Date(entry.start).getTime();
+ const end = new Date(entry.end).getTime();
+ return start <= now && (now <= end || entry.repeat > 0);
+ });
+ const upcoming = windows
+ .filter((entry) => new Date(entry.start).getTime() > now)
+ .sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
+ return { running: live.length, next: upcoming[0] ?? null };
+ }, [data]);
+
+ return (
+
+ {!data || data.maintenanceWindows.length === 0 ? (
+
+ ) : (
+
+
+ {next && (
+
+ )}
+
+ )}
+
+ );
+};
+
+export const StatusPagesCard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { data, isLoading, isValidating, error } =
+ useGet("/status-page/team");
+
+ return (
+
+ {!data || data.length === 0 ? (
+
+ ) : (
+
+ {data.map((page) => (
+
+
+
+
+ {page.isPublished
+ ? t("pages.dashboard.cards.statusPages.published")
+ : t("pages.dashboard.cards.statusPages.draft")}
+
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/DashboardCard.tsx b/client/src/Pages/Dashboard/Components/DashboardCard.tsx
new file mode 100644
index 000000000..28cddb8b0
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/DashboardCard.tsx
@@ -0,0 +1,159 @@
+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 { useCardSlot } from "./CardSlotContext";
+
+import type { ReactNode } from "react";
+
+// Control height plus the header's vertical padding, so a card with no action
+// still lines up with one that has a button in its header.
+const HEADER_MIN_HEIGHT = 44;
+const SKELETON_ROW_HEIGHT = 20;
+
+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();
+ const slot = useCardSlot();
+
+ return (
+
+
+
+ {title}
+
+
+ {isStale && (
+
+ )}
+ {action && {action}}
+ {slot && (
+
+ {slot}
+
+ )}
+
+
+
+ {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..348eba589
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/Sparkline.tsx
@@ -0,0 +1,72 @@
+import Box from "@mui/material/Box";
+import { useMemo } from "react";
+
+import type { CheckSnapshot } from "@/Types/Check";
+
+const VIEWBOX_WIDTH = 100;
+// The svg renders at its viewBox height, so one constant serves both.
+const VIEWBOX_HEIGHT = 24;
+// Matches the 1.5 stroke the shared Icon component uses.
+const STROKE_WIDTH = 1.5;
+
+/**
+ * Response times as a bare polyline. Deliberately axis-free and label-free —
+ * at this size the shape is the only readable signal.
+ *
+ * `checks` arrives oldest-first, which is already left-to-right reading order.
+ */
+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");
+
+ 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..f18ad84b9
--- /dev/null
+++ b/client/src/Pages/Dashboard/cards.ts
@@ -0,0 +1,84 @@
+import type { ComponentType } from "react";
+
+// The 12-column grid the dashboard lays cards out on.
+//
+// Every card is currently half-width, so they pair up down the page and the
+// grid reads as one system. Full width stays in the type for a card whose
+// content genuinely needs it. 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;
+
+// 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.
+// Ordered so the declared widths pair into full rows: a card left alone in a
+// row is stretched to fill it, so 8- and 4-wide cards are kept adjacent.
+export const DEFAULT_CARD_IDS: readonly CardId[] = [
+ "monitorStatus",
+ "currentlyDown",
+ "failedChecks",
+ "monitorsByType",
+ "recentIncidents",
+ "checkmateServer",
+];
+
+export const DASHBOARD_CARDS_STORAGE_KEY = "checkmate-dashboard-cards";
+
+/**
+ * Poll rates, in ms. Monitor and server figures move continuously; the log ring
+ * buffer rotates fast but is expensive to re-read, so it is polled slowly.
+ */
+export const REFRESH_INTERVAL_MS = 30000;
+export const SLOW_REFRESH_INTERVAL_MS = 60000;
diff --git a/client/src/Pages/Dashboard/checks.ts b/client/src/Pages/Dashboard/checks.ts
new file mode 100644
index 000000000..44fa81b65
--- /dev/null
+++ b/client/src/Pages/Dashboard/checks.ts
@@ -0,0 +1,8 @@
+import type { Monitor } from "@/Types/Monitor";
+
+/**
+ * `recentChecks` is oldest-first — the server appends each snapshot with
+ * `$push … $slice: -N` — so the newest retained check is the last element.
+ */
+export const latestCheck = (monitor: Monitor) =>
+ monitor.recentChecks?.[monitor.recentChecks.length - 1];
diff --git a/client/src/Pages/Dashboard/index.tsx b/client/src/Pages/Dashboard/index.tsx
new file mode 100644
index 000000000..605e200d4
--- /dev/null
+++ b/client/src/Pages/Dashboard/index.tsx
@@ -0,0 +1,86 @@
+import Stack from "@mui/material/Stack";
+import { useTheme } from "@mui/material/styles";
+import { useTranslation } from "react-i18next";
+import { useState } from "react";
+import { LayoutGrid } from "lucide-react";
+
+import { BasePage, EmptyState, Icon } from "@/Components/design-elements";
+import { Button } from "@/Components/inputs";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { CardGrid } from "./Components/CardGrid";
+import { CardPicker } from "./Components/CardPicker";
+import { MonitorsContext, useMonitorsRequest } from "./useDashboardData";
+import { useCardSelection } from "./useCardSelection";
+
+const Dashboard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const monitorsRequest = useMonitorsRequest();
+ const {
+ cards,
+ available,
+ selected,
+ addCard,
+ removeCard,
+ reorderCards,
+ resetCards,
+ selectAll,
+ deselectAll,
+ allSelected,
+ } = useCardSelection();
+ const [isPickerOpen, setIsPickerOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
+ {cards.length === 0 ? (
+ setIsPickerOpen(true)}
+ />
+ ) : (
+
+ )}
+
+ setIsPickerOpen(false)}
+ />
+
+
+ );
+};
+
+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..bdfc33f91
--- /dev/null
+++ b/client/src/Pages/Dashboard/layout.ts
@@ -0,0 +1,21 @@
+import type { CardDefinition } from "./cards";
+
+export interface PlacedCard {
+ card: CardDefinition;
+ /** Columns rendered — currently always the declared width. */
+ renderedWidth: number;
+}
+
+/**
+ * Packs cards into rows of GRID_COLUMNS.
+ *
+ * With a single body width this is a straight pass-through: two halves fill a
+ * row exactly, so no card is ever stretched to close a gap.
+ *
+ * An odd number of cards leaves the last one alone on its row. It keeps its
+ * declared width rather than being promoted to full: widening one card to fill
+ * the row makes it look more important than it is, and an odd count is a
+ * property of the selection, not something the layout should hide.
+ */
+export const layoutCards = (cards: CardDefinition[]): PlacedCard[] =>
+ cards.map((card) => ({ card, renderedWidth: card.width }));
diff --git a/client/src/Pages/Dashboard/registry.ts b/client/src/Pages/Dashboard/registry.ts
new file mode 100644
index 000000000..ddbdb7930
--- /dev/null
+++ b/client/src/Pages/Dashboard/registry.ts
@@ -0,0 +1,161 @@
+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 { MonitorsByGroupCard } from "./Components/Cards/MonitorsByGroupCard";
+import { CheckmateServerCard } from "./Components/Cards/CheckmateServerCard";
+import { BusiestServersCard } from "./Components/Cards/BusiestServersCard";
+import { SlowestMonitorsCard, LowestUptimeCard } from "./Components/Cards/RankingCards";
+import {
+ IncidentStatsCard,
+ NotificationChannelsCard,
+ MaintenanceCard,
+ StatusPagesCard,
+} from "./Components/Cards/SummaryCards";
+import {
+ ChecksOnScheduleCard,
+ RecentErrorsCard,
+ TeamCard,
+} from "./Components/Cards/AdminCards";
+
+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.
+//
+// Every card is half-width and pairs up down the page. The grid promotes a
+// lone trailing card to full width so the page does not end on a ragged half.
+export const CARD_REGISTRY: CardDefinition[] = [
+ {
+ id: "monitorStatus",
+ key: "monitorStatus",
+ group: "uptime",
+ width: 6,
+ component: MonitorStatusCard,
+ },
+ {
+ id: "currentlyDown",
+ key: "currentlyDown",
+ group: "uptime",
+ width: 6,
+ component: CurrentlyDownCard,
+ },
+ {
+ id: "failedChecks",
+ key: "failedChecks",
+ group: "checks",
+ width: 6,
+ component: FailedChecksCard,
+ },
+ {
+ id: "slowestMonitors",
+ key: "slowestMonitors",
+ group: "uptime",
+ width: 6,
+ component: SlowestMonitorsCard,
+ },
+ {
+ id: "lowestUptime",
+ key: "lowestUptime",
+ group: "uptime",
+ width: 6,
+ component: LowestUptimeCard,
+ },
+ {
+ id: "monitorsByType",
+ key: "monitorsByType",
+ group: "uptime",
+ width: 6,
+ component: MonitorsByTypeCard,
+ },
+ {
+ id: "monitorsByGroup",
+ key: "monitorsByGroup",
+ group: "uptime",
+ width: 6,
+ component: MonitorsByGroupCard,
+ },
+ {
+ id: "busiestServers",
+ key: "busiestServers",
+ group: "infrastructure",
+ width: 6,
+ component: BusiestServersCard,
+ },
+ {
+ id: "recentIncidents",
+ key: "recentIncidents",
+ group: "incidents",
+ width: 6,
+ component: RecentIncidentsCard,
+ },
+ {
+ id: "incidentStats",
+ key: "incidentStats",
+ group: "incidents",
+ width: 6,
+ component: IncidentStatsCard,
+ },
+ {
+ id: "notificationChannels",
+ key: "notificationChannels",
+ group: "notifications",
+ width: 6,
+ component: NotificationChannelsCard,
+ },
+ {
+ id: "maintenance",
+ key: "maintenance",
+ group: "maintenance",
+ width: 6,
+ component: MaintenanceCard,
+ },
+ {
+ id: "statusPages",
+ key: "statusPages",
+ group: "statusPages",
+ width: 6,
+ component: StatusPagesCard,
+ },
+ {
+ id: "checkmateServer",
+ key: "checkmateServer",
+ group: "logs",
+ width: 6,
+ adminOnly: true,
+ component: CheckmateServerCard,
+ },
+ {
+ id: "checksOnSchedule",
+ key: "checksOnSchedule",
+ group: "logs",
+ width: 6,
+ adminOnly: true,
+ component: ChecksOnScheduleCard,
+ },
+ {
+ id: "recentErrors",
+ key: "recentErrors",
+ group: "logs",
+ width: 6,
+ adminOnly: true,
+ component: RecentErrorsCard,
+ },
+ {
+ id: "team",
+ key: "team",
+ group: "settings",
+ width: 6,
+ adminOnly: true,
+ component: TeamCard,
+ },
+];
+
+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..36234c7da
--- /dev/null
+++ b/client/src/Pages/Dashboard/useCardSelection.ts
@@ -0,0 +1,109 @@
+import { useCallback, useMemo } from "react";
+import { useSelector, useDispatch } from "react-redux";
+
+import { useIsAdmin } from "@/Hooks/useIsAdmin";
+import { setDashboardCards } from "@/Features/UI/uiSlice";
+import { CardIds, DEFAULT_CARD_IDS, type CardId } from "./cards";
+import { CARD_REGISTRY, getCard } from "./registry";
+
+import type { CardDefinition } from "./cards";
+import type { RootState } from "@/store";
+
+const isCardId = (value: unknown): value is CardId =>
+ typeof value === "string" && (CardIds as readonly string[]).includes(value);
+
+export const useCardSelection = () => {
+ const dispatch = useDispatch();
+ const isAdmin = useIsAdmin();
+
+ // null means the user has never customised; fall back to defaults.
+ const stored = useSelector((state: RootState) => state.ui.dashboardCards);
+ const selected: CardId[] = useMemo(
+ () => (stored !== null ? stored.filter(isCardId) : [...DEFAULT_CARD_IDS]),
+ [stored]
+ );
+
+ const isVisible = useCallback(
+ (card: CardDefinition) => !card.adminOnly || isAdmin,
+ [isAdmin]
+ );
+
+ 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) => {
+ if (!selected.includes(id)) dispatch(setDashboardCards([...selected, id]));
+ },
+ [dispatch, selected]
+ );
+
+ const removeCard = useCallback(
+ (id: CardId) => {
+ dispatch(setDashboardCards(selected.filter((cardId) => cardId !== id)));
+ },
+ [dispatch, selected]
+ );
+
+ const resetCards = useCallback(() => dispatch(setDashboardCards(null)), [dispatch]);
+
+ const selectAll = useCallback(
+ () =>
+ dispatch(
+ setDashboardCards(
+ available.map((card) => card.id).filter((id) => !selected.includes(id)).length >
+ 0
+ ? [...new Set([...selected, ...available.map((card) => card.id)])]
+ : selected
+ )
+ ),
+ [dispatch, available, selected]
+ );
+
+ const deselectAll = useCallback(() => dispatch(setDashboardCards([])), [dispatch]);
+
+ /**
+ * Reorders cards based on DnD source/dest indices into the visible `cards` array.
+ * Hidden admin cards keep their stored positions unchanged.
+ */
+ const reorderCards = useCallback(
+ (sourceIndex: number, destIndex: number) => {
+ const visibleIds = cards.map((c) => c.id);
+ const reordered = [...visibleIds];
+ const [moved] = reordered.splice(sourceIndex, 1);
+ reordered.splice(destIndex, 0, moved);
+ let visibleIdx = 0;
+ dispatch(
+ setDashboardCards(
+ selected.map((cardId) =>
+ visibleIds.includes(cardId) ? reordered[visibleIdx++] : cardId
+ )
+ )
+ );
+ },
+ [dispatch, cards, selected]
+ );
+
+ const allSelected = available.every((card) => selected.includes(card.id));
+
+ return {
+ cards,
+ available,
+ selected,
+ addCard,
+ removeCard,
+ reorderCards,
+ resetCards,
+ selectAll,
+ deselectAll,
+ allSelected,
+ };
+};
diff --git a/client/src/Pages/Dashboard/useDashboardData.ts b/client/src/Pages/Dashboard/useDashboardData.ts
new file mode 100644
index 000000000..5a2fe193c
--- /dev/null
+++ b/client/src/Pages/Dashboard/useDashboardData.ts
@@ -0,0 +1,32 @@
+import { createContext, useContext } from "react";
+import { useGet } from "@/Hooks/UseApi";
+import { MonitorTypes, type MonitorsWithChecksResponse } from "@/Types/Monitor";
+import { REFRESH_INTERVAL_MS } from "./cards";
+
+// One request feeds every monitor-derived card, read from context so adding a
+// card costs no extra call. No rowsPerPage: the dashboard summarises the whole
+// fleet, and the repository treats its absence as "no limit".
+const buildMonitorsUrl = () => {
+ const params = new URLSearchParams();
+ MonitorTypes.forEach((type) => params.append("type", type));
+ return `/monitors/team/with-checks?${params.toString()}`;
+};
+
+export const useMonitorsRequest = () =>
+ useGet(
+ buildMonitorsUrl(),
+ {},
+ { refreshInterval: REFRESH_INTERVAL_MS, 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 37e49d1ee..6d0d194de 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";
@@ -90,7 +93,11 @@ const Routes = () => {
>
}
+ element={}
+ />
+ }
/>