From c58087c787bff43fff4632f4ad73db46a1fd4a6a Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Sat, 29 Aug 2026 09:10:46 -0400 Subject: [PATCH] feat(client): add the main dashboard page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /dashboard, a cross-type overview answering "is anything wrong, and where" — the question no single-type page can answer today. /uptime shows uptime monitors, /infrastructure shows hardware, /incidents shows history; nothing joined them. Layout is a 12-column grid. Each card type declares a minimum width (4, 6, 8 or 12) in the registry; the grid stretches the last card of a short row to fill the remainder, capped at 8 so one card never balloons across a near-empty row. Declared width is never a user setting. grid-auto-flow: dense was tried and does not help — no card is small enough to backfill the gaps that occur in practice. Six cards ship as the default set: Monitor status the five fleet counts, server-computed Currently down the centrepiece: everything wrong now, worst first Failed checks failure rate over the last day Recent incidents latest five, with the open count Monitors by type composition, flagging types with something down Checkmate server the only card that works with zero monitors (admin) Cards 1-3 and 5 ride a single /monitors/team/with-checks call shared through context, so adding a monitor-derived card costs no extra request. Notes on the data: - Ordering in "Currently down" is down -> breached -> initializing, tied on longest downtime. That is ordering derived from existing fields, not a new severity scale — Checkmate has none, and inventing one would be a product decision dressed as a design choice. - The healthy state is deliberately quiet: one line, not a wall of green. - Failed checks ships the day range only. dateRange=week returns figures identical to day, so a range selector would silently lie. - Admin-only cards are hidden rather than disabled, and are filtered out of the selection entirely for non-admins. Card selection persists to localStorage, validated on read so a card removed in a later release cannot break the page. The picker and the remaining catalogue cards are not built yet. The Containers card from the spec is deliberately absent: it needs containerSummary on the check snapshot, which arrives with the Docker monitoring work. cards.ts records where it slots in. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/sidebar/Menu.tsx | 6 + .../Pages/Dashboard/Components/CardGrid.tsx | 39 +++++ .../Dashboard/Components/CardPrimitives.tsx | 140 +++++++++++++++++ .../Components/Cards/CheckmateServerCard.tsx | 125 +++++++++++++++ .../Components/Cards/CurrentlyDownCard.tsx | 140 +++++++++++++++++ .../Components/Cards/FailedChecksCard.tsx | 53 +++++++ .../Components/Cards/MonitorStatusCard.tsx | 93 +++++++++++ .../Components/Cards/MonitorsByTypeCard.tsx | 121 +++++++++++++++ .../Components/Cards/RecentIncidentsCard.tsx | 102 +++++++++++++ .../Dashboard/Components/DashboardCard.tsx | 144 ++++++++++++++++++ .../Pages/Dashboard/Components/Sparkline.tsx | 70 +++++++++ client/src/Pages/Dashboard/cards.ts | 73 +++++++++ client/src/Pages/Dashboard/index.tsx | 23 +++ client/src/Pages/Dashboard/layout.ts | 54 +++++++ client/src/Pages/Dashboard/registry.ts | 62 ++++++++ .../src/Pages/Dashboard/useCardSelection.ts | 89 +++++++++++ .../src/Pages/Dashboard/useDashboardData.ts | 39 +++++ client/src/Routes/index.tsx | 7 + client/src/locales/en.json | 49 +++++- 19 files changed, 1428 insertions(+), 1 deletion(-) create mode 100644 client/src/Pages/Dashboard/Components/CardGrid.tsx create mode 100644 client/src/Pages/Dashboard/Components/CardPrimitives.tsx create mode 100644 client/src/Pages/Dashboard/Components/Cards/CheckmateServerCard.tsx create mode 100644 client/src/Pages/Dashboard/Components/Cards/CurrentlyDownCard.tsx create mode 100644 client/src/Pages/Dashboard/Components/Cards/FailedChecksCard.tsx create mode 100644 client/src/Pages/Dashboard/Components/Cards/MonitorStatusCard.tsx create mode 100644 client/src/Pages/Dashboard/Components/Cards/MonitorsByTypeCard.tsx create mode 100644 client/src/Pages/Dashboard/Components/Cards/RecentIncidentsCard.tsx create mode 100644 client/src/Pages/Dashboard/Components/DashboardCard.tsx create mode 100644 client/src/Pages/Dashboard/Components/Sparkline.tsx create mode 100644 client/src/Pages/Dashboard/cards.ts create mode 100644 client/src/Pages/Dashboard/index.tsx create mode 100644 client/src/Pages/Dashboard/layout.ts create mode 100644 client/src/Pages/Dashboard/registry.ts create mode 100644 client/src/Pages/Dashboard/useCardSelection.ts create mode 100644 client/src/Pages/Dashboard/useDashboardData.ts 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",