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 (
+
+ );
+};
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": {