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",