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 ( + {}} + /> + } + > + {t("pages.dashboard.picker.selectAll")} + + } + > + {/* + * Two columns: seventeen cards in one list makes the dialog scroll, + * which hides most of the catalogue. Groups flow down the columns and + * are kept whole so a group is never split across the gap. + */} + + {grouped.map(({ group, cards }) => ( + + + {t(`pages.dashboard.picker.groups.${group}`, { + defaultValue: group, + })} + + {cards.map((card) => ( + + {t(`pages.dashboard.cards.${card.key}.title`)} + + } + control={ + + chosen.has(card.id) ? onRemove(card.id) : onAdd(card.id) + } + /> + } + /> + ))} + + ))} + + + ); +}; 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={} + /> + } />