From f86ead08066a8c04bb0760379dccdd0c1ca76daa Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Sat, 29 Aug 2026 09:37:34 -0400 Subject: [PATCH] feat(client): add the card picker and the remaining catalogue cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the catalogue at 17 cards and adds the picker that chooses between them. New cards: Slowest monitors, Lowest uptime, Monitors by group, Busiest servers, Incident stats, Notification channels, Maintenance, Status pages, Checks on schedule, Recent errors, Team. Data caveats honoured from the specs: - Rankings exclude paused monitors. A paused ping monitor reads 0% uptime and would otherwise rank beside a genuinely down one; neither is a live problem. Rankings also hide below two entries — a ranking of one is not a ranking. - Busiest servers reads cpu/memory/disk from the newest recentChecks entry (snake_case, 0-1 fractions) and renders a dash for a down server rather than 0%. - Maintenance compares start/end client-side: `active` is a user on/off flag, not 'running now', and repeat > 0 means end may sit in the past for a live window. - Notification channels renders only the type. The response also carries accessToken and accountSid; those must never reach the DOM. - Recent errors is worded 'recent' because /logs is a 1000-entry in-memory ring buffer that rotates fast and is wiped on restart. Also fixes a layout gap the six default cards never exposed: a 6-wide card followed by an 8-wide one left a lone card stretched to 8 in a middle row, with 4 empty columns beside it. Mid-page rows now always fill to 12; only the final row may end short, and the cap applies only there, so a lone trailing 4-wide card does not become a banner. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- .../Pages/Dashboard/Components/CardPicker.tsx | 102 +++++++ .../Dashboard/Components/Cards/AdminCards.tsx | 256 ++++++++++++++++++ .../Components/Cards/BusiestServersCard.tsx | 152 +++++++++++ .../Components/Cards/MonitorsByGroupCard.tsx | 120 ++++++++ .../Components/Cards/RankingCards.tsx | 201 ++++++++++++++ .../Components/Cards/SummaryCards.tsx | 235 ++++++++++++++++ client/src/Pages/Dashboard/index.tsx | 57 +++- client/src/Pages/Dashboard/layout.ts | 19 +- client/src/Pages/Dashboard/registry.ts | 96 +++++++ client/src/locales/en.json | 73 +++++ 10 files changed, 1300 insertions(+), 11 deletions(-) create mode 100644 client/src/Pages/Dashboard/Components/CardPicker.tsx create mode 100644 client/src/Pages/Dashboard/Components/Cards/AdminCards.tsx create mode 100644 client/src/Pages/Dashboard/Components/Cards/BusiestServersCard.tsx create mode 100644 client/src/Pages/Dashboard/Components/Cards/MonitorsByGroupCard.tsx create mode 100644 client/src/Pages/Dashboard/Components/Cards/RankingCards.tsx create mode 100644 client/src/Pages/Dashboard/Components/Cards/SummaryCards.tsx diff --git a/client/src/Pages/Dashboard/Components/CardPicker.tsx b/client/src/Pages/Dashboard/Components/CardPicker.tsx new file mode 100644 index 000000000..f99e346a1 --- /dev/null +++ b/client/src/Pages/Dashboard/Components/CardPicker.tsx @@ -0,0 +1,102 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import { useTheme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import { useMemo } from "react"; + +import { Dialog, Checkbox } from "@/Components/inputs"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { typographyLevels } from "@/Utils/Theme/Palette"; +import { CardGroups, type CardDefinition, type CardGroup, type CardId } from "../cards"; + +interface CardPickerProps { + open: boolean; + available: CardDefinition[]; + selected: CardId[]; + onAdd: (id: CardId) => void; + onRemove: (id: CardId) => 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, + onAdd, + onRemove, + 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); + + return ( + + + {grouped.map(({ group, cards }) => ( + + + {t(`components.sidebar.menu.${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/Cards/AdminCards.tsx b/client/src/Pages/Dashboard/Components/Cards/AdminCards.tsx new file mode 100644 index 000000000..949d4cf4e --- /dev/null +++ b/client/src/Pages/Dashboard/Components/Cards/AdminCards.tsx @@ -0,0 +1,256 @@ +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 { typographyLevels } from "@/Utils/Theme/Palette"; +import { formatTimestamp } from "@/Utils/TimeUtils"; +import { DashboardCard, CardMessage } from "../DashboardCard"; +import { CardFigure, CardRow, CardRowLabel } from "../CardPrimitives"; + +import type { QueueMetrics } from "@/Types/Queue"; +import type { Log } from "@/Types/Log"; +import type { User } from "@/Types/User"; + +const MAX_ERROR_ROWS = 5; + +const StatLine = ({ + label, + value, + color, +}: { + label: string; + value: string; + color?: string; +}) => { + const theme = useTheme(); + return ( + + + {label} + + + {value} + + + ); +}; + +/** 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: 30000 } + ); + + 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: 60000 } + ); + + 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/BusiestServersCard.tsx b/client/src/Pages/Dashboard/Components/Cards/BusiestServersCard.tsx new file mode 100644 index 000000000..d06a2e270 --- /dev/null +++ b/client/src/Pages/Dashboard/Components/Cards/BusiestServersCard.tsx @@ -0,0 +1,152 @@ +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 { typographyLevels } from "@/Utils/Theme/Palette"; +import { DashboardCard, CardMessage } from "../DashboardCard"; +import { CardBar, CardRow, CardRowLabel } from "../CardPrimitives"; +import { useMonitors } from "../../useDashboardData"; + +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 checks = monitor.recentChecks ?? []; + // recentChecks is oldest-first: the newest snapshot is last. + const latest = checks[checks.length - 1]; + // Note snake_case here, unlike camelCase in HardwareCheckStats. + // 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/MonitorsByGroupCard.tsx b/client/src/Pages/Dashboard/Components/Cards/MonitorsByGroupCard.tsx new file mode 100644 index 000000000..841b90c47 --- /dev/null +++ b/client/src/Pages/Dashboard/Components/Cards/MonitorsByGroupCard.tsx @@ -0,0 +1,120 @@ +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 { typographyLevels } from "@/Utils/Theme/Palette"; +import { DashboardCard, CardMessage } from "../DashboardCard"; +import { CardBar, CardRow } from "../CardPrimitives"; +import { useMonitors } from "../../useDashboardData"; + +interface GroupBucket { + group: string; + total: number; + down: number; +} + +export const MonitorsByGroupCard = () => { + const theme = useTheme(); + const { t } = useTranslation(); + 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) ?? { + group: 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]); + + const max = buckets[0]?.total ?? 0; + + return ( + + {buckets.length === 0 ? ( + + ) : ( + + {buckets.map((bucket) => ( + + + + + {bucket.group} + + + {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/RankingCards.tsx b/client/src/Pages/Dashboard/Components/Cards/RankingCards.tsx new file mode 100644 index 000000000..e76171287 --- /dev/null +++ b/client/src/Pages/Dashboard/Components/Cards/RankingCards.tsx @@ -0,0 +1,201 @@ +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 { typographyLevels } from "@/Utils/Theme/Palette"; +import { getMonitorPath, getUptimePercentageColor } from "@/Utils/MonitorUtils"; +import { DashboardCard, CardMessage } from "../DashboardCard"; +import { CardBar, 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 }) => ( + + + + + + {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 ( + + + + + + {percentage.toFixed(1)}% + + + + + + ); + })} + + )} + + ); +}; 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..c3e763ea2 --- /dev/null +++ b/client/src/Pages/Dashboard/Components/Cards/SummaryCards.tsx @@ -0,0 +1,235 @@ +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 { typographyLevels } from "@/Utils/Theme/Palette"; +import { formatTimestamp } from "@/Utils/TimeUtils"; +import { DashboardCard, CardMessage } from "../DashboardCard"; +import { CardFigure, CardRow, CardRowLabel } 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"; + +const StatLine = ({ label, value }: { label: string; value: string }) => { + const theme = useTheme(); + return ( + + + {label} + + + {value} + + + ); +}; + +/** 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 && ( + + 0 ? theme.palette.error.main : theme.palette.text.primary + } + /> + + {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/index.tsx b/client/src/Pages/Dashboard/index.tsx index 9e158e5fa..4f1b76a11 100644 --- a/client/src/Pages/Dashboard/index.tsx +++ b/client/src/Pages/Dashboard/index.tsx @@ -1,5 +1,14 @@ -import { BasePage } from "@/Components/design-elements"; +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 } 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"; @@ -8,13 +17,55 @@ import { useCardSelection } from "./useCardSelection"; * the question no single-type page can answer. Problems first and biggest. */ const Dashboard = () => { + const theme = useTheme(); + const { t } = useTranslation(); const monitorsRequest = useMonitorsRequest(); - const { cards } = useCardSelection(); + const { cards, available, selected, addCard, removeCard, resetCards } = + useCardSelection(); + const [isPickerOpen, setIsPickerOpen] = useState(false); return ( - + + + + + {cards.length === 0 ? ( + setIsPickerOpen(true)} + /> + ) : ( + + )} + + setIsPickerOpen(false)} + /> ); diff --git a/client/src/Pages/Dashboard/layout.ts b/client/src/Pages/Dashboard/layout.ts index 12a7166a4..509f1ed26 100644 --- a/client/src/Pages/Dashboard/layout.ts +++ b/client/src/Pages/Dashboard/layout.ts @@ -8,8 +8,13 @@ export interface PlacedCard { /** * 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. + * row ends short, its last card absorbs the remainder. + * + * A row in the middle of the page always fills completely — leaving a hole + * there reads as a rendering fault. Only the final row may end short, and only + * when a single card sits in it: stretching a lone trailing card to full width + * makes a 4-column card look like a banner. There, STRETCH_MAX_WIDTH caps the + * growth instead. * * `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. @@ -26,12 +31,10 @@ export const layoutCards = (cards: CardDefinition[]): PlacedCard[] => { 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; - } + // A lone card ending the page grows only to the cap; every other short + // row is filled completely so no gap is left mid-page. + const ceiling = isFinalRow && row.length === 1 ? STRETCH_MAX_WIDTH : GRID_COLUMNS; + last.renderedWidth = Math.min(last.renderedWidth + remainder, ceiling); } placed.push(...row); row = []; diff --git a/client/src/Pages/Dashboard/registry.ts b/client/src/Pages/Dashboard/registry.ts index 74be7f36c..893f6b0be 100644 --- a/client/src/Pages/Dashboard/registry.ts +++ b/client/src/Pages/Dashboard/registry.ts @@ -3,12 +3,28 @@ 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 order the picker lists them in, grouped by sidebar section. export const CARD_REGISTRY: CardDefinition[] = [ { id: "monitorStatus", @@ -24,6 +40,20 @@ export const CARD_REGISTRY: CardDefinition[] = [ width: 12, component: CurrentlyDownCard, }, + { + id: "slowestMonitors", + key: "slowestMonitors", + group: "uptime", + width: 6, + component: SlowestMonitorsCard, + }, + { + id: "lowestUptime", + key: "lowestUptime", + group: "uptime", + width: 6, + component: LowestUptimeCard, + }, { id: "monitorsByType", key: "monitorsByType", @@ -31,6 +61,20 @@ export const CARD_REGISTRY: CardDefinition[] = [ 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", @@ -38,6 +82,34 @@ export const CARD_REGISTRY: CardDefinition[] = [ width: 8, component: RecentIncidentsCard, }, + { + id: "incidentStats", + key: "incidentStats", + group: "incidents", + width: 4, + component: IncidentStatsCard, + }, + { + id: "notificationChannels", + key: "notificationChannels", + group: "notifications", + width: 4, + component: NotificationChannelsCard, + }, + { + id: "maintenance", + key: "maintenance", + group: "maintenance", + width: 4, + component: MaintenanceCard, + }, + { + id: "statusPages", + key: "statusPages", + group: "statusPages", + width: 4, + component: StatusPagesCard, + }, { id: "failedChecks", key: "failedChecks", @@ -53,6 +125,30 @@ export const CARD_REGISTRY: CardDefinition[] = [ adminOnly: true, component: CheckmateServerCard, }, + { + id: "checksOnSchedule", + key: "checksOnSchedule", + group: "logs", + width: 4, + adminOnly: true, + component: ChecksOnScheduleCard, + }, + { + id: "recentErrors", + key: "recentErrors", + group: "logs", + width: 6, + adminOnly: true, + component: RecentErrorsCard, + }, + { + id: "team", + key: "team", + group: "settings", + width: 4, + adminOnly: true, + component: TeamCard, + }, ]; const BY_ID = new Map( diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 4b938e4e9..10fd07e8f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -289,7 +289,80 @@ "uptime": "Uptime", "database": "Database", "readsWrites": "{{reads}} reads/s · {{writes}} writes/s" + }, + "ranking": { + "topOf": "Top {{shown}} of {{total}}", + "notEnough": "Not enough monitors to rank yet." + }, + "slowestMonitors": { + "title": "Slowest monitors" + }, + "lowestUptime": { + "title": "Lowest uptime" + }, + "monitorsByGroup": { + "title": "Monitors by group", + "empty": "No groups yet. Groups are set per monitor." + }, + "busiestServers": { + "title": "Busiest servers", + "empty": "No infrastructure monitors yet.", + "cpu": "CPU", + "memory": "Memory", + "disk": "Disk" + }, + "incidentStats": { + "title": "Incident stats", + "openNow": "Open now", + "avgResolution": "Average time to resolve", + "hours": "{{hours}} h", + "mostIncidents": "Most incidents" + }, + "notificationChannels": { + "title": "Notification channels", + "empty": "No channels yet." + }, + "maintenance": { + "title": "Maintenance", + "empty": "No maintenance scheduled.", + "runningNow": "Running now", + "next": "Next window" + }, + "statusPages": { + "title": "Status pages", + "empty": "No status pages yet.", + "monitorCount": "{{count}} monitors", + "published": "Published", + "draft": "Draft" + }, + "checksOnSchedule": { + "title": "Checks on schedule", + "healthy": "All {{jobs}} jobs on schedule across {{workers}} worker(s).", + "failing": "Failing jobs", + "scheduled": "Scheduled jobs", + "runningNow": "Running now" + }, + "recentErrors": { + "title": "Recent errors", + "empty": "No errors logged.", + "count": "{{count}} errors", + "warnings": "Warnings" + }, + "team": { + "title": "Team", + "members": "{{count}} members", + "unverified": "Unverified" } + }, + "picker": { + "trigger": "Edit cards", + "title": "Dashboard cards", + "done": "Done", + "reset": "Reset to defaults" + }, + "empty": { + "title": "No cards on your dashboard", + "description": "Choose the cards you want to see. Your selection is remembered in this browser." } }, "proxies": {