diff --git a/client/src/Components/sidebar/Menu.tsx b/client/src/Components/sidebar/Menu.tsx
index be42fa6e5..0e89c22a2 100644
--- a/client/src/Components/sidebar/Menu.tsx
+++ b/client/src/Components/sidebar/Menu.tsx
@@ -20,12 +20,18 @@ import {
Tag,
Waypoints,
Container,
+ LayoutDashboard,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
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..630523e9e 100644
--- a/client/src/Features/UI/uiSlice.ts
+++ b/client/src/Features/UI/uiSlice.ts
@@ -1,4 +1,9 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
+import {
+ DASHBOARD_CARD_IDS,
+ defaultVisibleCards,
+ type DashboardCardId,
+} from "@/Pages/Dashboard/dashboardCards";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
@@ -35,6 +40,8 @@ interface UIState {
language: string;
starPromptOpen: boolean;
chartType: ChartType;
+ dashboardCards: Record;
+ dashboardCardOrder: DashboardCardId[];
}
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
@@ -74,6 +81,8 @@ const initialState: UIState = {
language: "en",
starPromptOpen: true,
chartType: "histogram",
+ dashboardCards: { ...defaultVisibleCards },
+ dashboardCardOrder: [...DASHBOARD_CARD_IDS],
};
const uiSlice = createSlice({
@@ -117,6 +126,27 @@ const uiSlice = createSlice({
setChartType: (state, action: PayloadAction) => {
state.chartType = action.payload;
},
+ setDashboardCards: (
+ state,
+ action: PayloadAction>
+ ) => {
+ state.dashboardCards = action.payload;
+ },
+ setDashboardCardsVisibility: (
+ state,
+ action: PayloadAction<{ id: DashboardCardId; visible: boolean }>
+ ) => {
+ state.dashboardCards[action.payload.id] = action.payload.visible;
+ },
+ toggleDashboardCard: (state, action: PayloadAction) => {
+ state.dashboardCards[action.payload] = !state.dashboardCards[action.payload];
+ },
+ resetDashboardCards: (state) => {
+ state.dashboardCards = { ...defaultVisibleCards };
+ },
+ setDashboardCardOrder: (state, action: PayloadAction) => {
+ state.dashboardCardOrder = action.payload;
+ },
},
});
@@ -133,4 +163,9 @@ export const {
setLanguage,
setStarPromptOpen,
setChartType,
+ setDashboardCards,
+ setDashboardCardsVisibility,
+ toggleDashboardCard,
+ resetDashboardCards,
+ setDashboardCardOrder,
} = uiSlice.actions;
diff --git a/client/src/Pages/Dashboard/components/DashboardCard.tsx b/client/src/Pages/Dashboard/components/DashboardCard.tsx
new file mode 100644
index 000000000..49f7f4395
--- /dev/null
+++ b/client/src/Pages/Dashboard/components/DashboardCard.tsx
@@ -0,0 +1,116 @@
+import Box from "@mui/material/Box";
+import Skeleton from "@mui/material/Skeleton";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { useTheme } from "@mui/material/styles";
+import { GripVertical } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import type { DraggableSyntheticListeners } from "@dnd-kit/core";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { typographyLevels } from "@/Utils/Theme/Palette";
+
+interface DashboardCardProps {
+ title: string;
+ topRight?: React.ReactNode;
+ isLoading?: boolean;
+ error?: unknown;
+ dragHandleProps?: DraggableSyntheticListeners;
+ children: React.ReactNode;
+}
+
+export const DashboardCard = ({
+ title,
+ topRight,
+ isLoading,
+ error,
+ dragHandleProps,
+ children,
+}: DashboardCardProps) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+
+ const renderContent = () => {
+ if (isLoading) {
+ return (
+
+ {[80, 60, 90, 50, 70].map((w, i) => (
+
+ ))}
+
+ );
+ }
+ if (error) {
+ return (
+
+ {t("pages.dashboard.error")}
+
+ );
+ }
+ return children;
+ };
+
+ return (
+
+
+
+ {title}
+
+
+ {topRight && (
+
+ {topRight}
+
+ )}
+
+
+
+
+
+ {renderContent()}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/components/EditCardsModal.tsx b/client/src/Pages/Dashboard/components/EditCardsModal.tsx
new file mode 100644
index 000000000..a2487bf6d
--- /dev/null
+++ b/client/src/Pages/Dashboard/components/EditCardsModal.tsx
@@ -0,0 +1,181 @@
+import Dialog from "@mui/material/Dialog";
+import DialogTitle from "@mui/material/DialogTitle";
+import DialogContent from "@mui/material/DialogContent";
+import DialogActions from "@mui/material/DialogActions";
+import Stack from "@mui/material/Stack";
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
+import FormControlLabel from "@mui/material/FormControlLabel";
+import { useTheme } from "@mui/material/styles";
+import { useDispatch, useSelector } from "react-redux";
+import { useTranslation } from "react-i18next";
+import { Button, Checkbox } from "@/Components/inputs";
+import { setDashboardCardsVisibility, resetDashboardCards } from "@/Features/UI/uiSlice";
+import {
+ cardCategories,
+ defaultVisibleCards,
+ DASHBOARD_CARD_IDS,
+} from "@/Pages/Dashboard/dashboardCards";
+import type { DashboardCardId } from "@/Pages/Dashboard/dashboardCards";
+import type { RootState } from "@/Types/state";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { typographyLevels } from "@/Utils/Theme/Palette";
+
+interface EditCardsModalProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+const leftCategories = cardCategories.slice(0, Math.ceil(cardCategories.length / 2));
+const rightCategories = cardCategories.slice(Math.ceil(cardCategories.length / 2));
+
+export const EditCardsModal = ({ open, onClose }: EditCardsModalProps) => {
+ const theme = useTheme();
+ const dispatch = useDispatch();
+ const { t } = useTranslation();
+
+ const visibility = useSelector(
+ (state: RootState) => state.ui.dashboardCards ?? defaultVisibleCards
+ );
+
+ const allVisible = DASHBOARD_CARD_IDS.every((id) => visibility[id]);
+ const someVisible = DASHBOARD_CARD_IDS.some((id) => visibility[id]);
+ const selectAllIndeterminate = someVisible && !allVisible;
+
+ const handleToggleCard = (id: DashboardCardId, visible: boolean) => {
+ dispatch(setDashboardCardsVisibility({ id, visible }));
+ };
+
+ const handleSelectAll = () => {
+ const next = !allVisible;
+ DASHBOARD_CARD_IDS.forEach((id) => {
+ dispatch(setDashboardCardsVisibility({ id, visible: next }));
+ });
+ };
+
+ const handleReset = () => {
+ dispatch(resetDashboardCards());
+ };
+
+ const renderCategory = (category: (typeof cardCategories)[number]) => (
+
+
+ {t(category.labelKey)}
+
+ {category.cards.map((id) => (
+
+ {t(`pages.dashboard.cards.${id}`)}
+
+ }
+ control={
+ handleToggleCard(id, e.target.checked)}
+ />
+ }
+ sx={{ m: 0, gap: theme.spacing(LAYOUT.SM) }}
+ />
+ ))}
+
+ );
+
+ return (
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/components/SortableCard.tsx b/client/src/Pages/Dashboard/components/SortableCard.tsx
new file mode 100644
index 000000000..5e8848117
--- /dev/null
+++ b/client/src/Pages/Dashboard/components/SortableCard.tsx
@@ -0,0 +1,31 @@
+import Box from "@mui/material/Box";
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import type { DraggableSyntheticListeners } from "@dnd-kit/core";
+
+interface SortableCardProps {
+ id: string;
+ isDragging?: boolean;
+ children: (listeners: DraggableSyntheticListeners) => React.ReactNode;
+}
+
+export const SortableCard = ({ id, isDragging, children }: SortableCardProps) => {
+ const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
+ id,
+ });
+
+ return (
+
+ {children(listeners)}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/components/cards/BarRow.tsx b/client/src/Pages/Dashboard/components/cards/BarRow.tsx
new file mode 100644
index 000000000..f9c7df09f
--- /dev/null
+++ b/client/src/Pages/Dashboard/components/cards/BarRow.tsx
@@ -0,0 +1,55 @@
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { useTheme } from "@mui/material/styles";
+import { typographyLevels } from "@/Utils/Theme/Palette";
+import { SPACING } from "@/Utils/Theme/constants";
+
+interface BarRowProps {
+ label: string;
+ value: string;
+ fillRatio: number;
+ valueColor?: string;
+}
+
+export const BarRow = ({ label, value, fillRatio, valueColor }: BarRowProps) => {
+ const theme = useTheme();
+ const clampedRatio = Math.min(1, Math.max(0, fillRatio));
+
+ return (
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+
+
+
+ );
+};
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..4f7b9a672
--- /dev/null
+++ b/client/src/Pages/Dashboard/components/cards/CurrentlyDownCard.tsx
@@ -0,0 +1,55 @@
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { useTheme } from "@mui/material/styles";
+import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
+import { typographyLevels } from "@/Utils/Theme/Palette";
+import { useTranslation } from "react-i18next";
+import type { DashboardMonitorRow } from "@/Pages/Dashboard/types";
+import type { DraggableSyntheticListeners } from "@dnd-kit/core";
+
+interface CurrentlyDownCardProps {
+ downMonitors: DashboardMonitorRow[];
+ isLoading?: boolean;
+ error?: unknown;
+ dragHandleProps?: DraggableSyntheticListeners;
+}
+
+export const CurrentlyDownCard = ({
+ downMonitors,
+ isLoading,
+ error,
+ dragHandleProps,
+}: CurrentlyDownCardProps) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+
+ return (
+
+ {downMonitors.length === 0 ? (
+
+ {t("pages.dashboard.emptyStates.allMonitorsUp")}
+
+ ) : (
+
+ {downMonitors.map((m) => (
+
+ {m.name}
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/components/cards/LowestUptimeCard.tsx b/client/src/Pages/Dashboard/components/cards/LowestUptimeCard.tsx
new file mode 100644
index 000000000..fb3658dee
--- /dev/null
+++ b/client/src/Pages/Dashboard/components/cards/LowestUptimeCard.tsx
@@ -0,0 +1,61 @@
+import Stack from "@mui/material/Stack";
+import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
+import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
+import { useTranslation } from "react-i18next";
+import { formatPercentageFromWhole } from "@/Utils/FormatUtils";
+import type { DashboardMonitorRow } from "@/Pages/Dashboard/types";
+import type { DraggableSyntheticListeners } from "@dnd-kit/core";
+import { LAYOUT } from "@/Utils/Theme/constants";
+
+const TOP_N = 5;
+
+interface LowestUptimeCardProps {
+ lowestUptimeMonitors: DashboardMonitorRow[];
+ isLoading?: boolean;
+ error?: unknown;
+ dragHandleProps?: DraggableSyntheticListeners;
+}
+
+export const LowestUptimeCard = ({
+ lowestUptimeMonitors,
+ isLoading,
+ error,
+ dragHandleProps,
+}: LowestUptimeCardProps) => {
+ const { t } = useTranslation();
+
+ const top = lowestUptimeMonitors.slice(0, TOP_N);
+ const total = lowestUptimeMonitors.length;
+
+ return (
+ 0
+ ? t("pages.dashboard.emptyStates.topOf", {
+ count: Math.min(TOP_N, total),
+ total,
+ })
+ : undefined
+ }
+ isLoading={isLoading}
+ error={error}
+ >
+
+ {top.map((monitor) => {
+ const uptime = monitor.uptimePercentage;
+ const downtime = 100 - uptime;
+ return (
+
+ );
+ })}
+
+
+ );
+};
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..451f7d948
--- /dev/null
+++ b/client/src/Pages/Dashboard/components/cards/MonitorStatusCard.tsx
@@ -0,0 +1,75 @@
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { useTheme } from "@mui/material/styles";
+import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
+import { typographyLevels } from "@/Utils/Theme/Palette";
+import { SPACING } from "@/Utils/Theme/constants";
+import { useTranslation } from "react-i18next";
+import type { MonitorsSummary } from "@/Types/Monitor";
+import type { DraggableSyntheticListeners } from "@dnd-kit/core";
+
+interface MonitorStatusCardProps {
+ summary: MonitorsSummary;
+ isLoading?: boolean;
+ error?: unknown;
+ dragHandleProps?: DraggableSyntheticListeners;
+}
+
+export const MonitorStatusCard = ({
+ summary,
+ isLoading,
+ error,
+ dragHandleProps,
+}: MonitorStatusCardProps) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+
+ const up = summary.upMonitors;
+ const total = summary.totalMonitors;
+ const ratio = total > 0 ? up / total : 0;
+
+ return (
+
+
+
+
+ {up}
+
+
+ {t("pages.dashboard.monitorStatus.ofUp", { total })}
+
+
+
+
+
+
+
+ );
+};
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..1f7581c59
--- /dev/null
+++ b/client/src/Pages/Dashboard/components/cards/MonitorsByGroupCard.tsx
@@ -0,0 +1,59 @@
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { useTheme } from "@mui/material/styles";
+import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
+import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
+import { typographyLevels } from "@/Utils/Theme/Palette";
+import { useTranslation } from "react-i18next";
+import type { DashboardGroupCount } from "@/Pages/Dashboard/types";
+import type { DraggableSyntheticListeners } from "@dnd-kit/core";
+import { LAYOUT } from "@/Utils/Theme/constants";
+
+interface MonitorsByGroupCardProps {
+ monitorsByGroup: DashboardGroupCount[];
+ isLoading?: boolean;
+ error?: unknown;
+ dragHandleProps?: DraggableSyntheticListeners;
+}
+
+export const MonitorsByGroupCard = ({
+ monitorsByGroup,
+ isLoading,
+ error,
+ dragHandleProps,
+}: MonitorsByGroupCardProps) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+
+ const maxCount = monitorsByGroup[0]?.count ?? 1;
+
+ return (
+
+ {monitorsByGroup.length === 0 ? (
+
+ {t("pages.dashboard.emptyStates.noGroups")}
+
+ ) : (
+
+ {monitorsByGroup.map(({ group, count }) => (
+
+ ))}
+
+ )}
+
+ );
+};
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..70430f96e
--- /dev/null
+++ b/client/src/Pages/Dashboard/components/cards/MonitorsByTypeCard.tsx
@@ -0,0 +1,48 @@
+import Stack from "@mui/material/Stack";
+import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
+import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
+import { useTranslation } from "react-i18next";
+import { useTheme } from "@mui/material/styles";
+import type { DashboardTypeCount } from "@/Pages/Dashboard/types";
+import type { DraggableSyntheticListeners } from "@dnd-kit/core";
+import { LAYOUT } from "@/Utils/Theme/constants";
+
+interface MonitorsByTypeCardProps {
+ monitorsByType: DashboardTypeCount[];
+ isLoading?: boolean;
+ error?: unknown;
+ dragHandleProps?: DraggableSyntheticListeners;
+}
+
+export const MonitorsByTypeCard = ({
+ monitorsByType,
+ isLoading,
+ error,
+ dragHandleProps,
+}: MonitorsByTypeCardProps) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+
+ const maxCount = monitorsByType[0]?.count ?? 1;
+
+ return (
+
+
+ {monitorsByType.map(({ type, count }) => (
+
+ ))}
+
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/components/cards/SlowestMonitorsCard.tsx b/client/src/Pages/Dashboard/components/cards/SlowestMonitorsCard.tsx
new file mode 100644
index 000000000..a149189be
--- /dev/null
+++ b/client/src/Pages/Dashboard/components/cards/SlowestMonitorsCard.tsx
@@ -0,0 +1,58 @@
+import Stack from "@mui/material/Stack";
+import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
+import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
+import { useTranslation } from "react-i18next";
+import { formatMs } from "@/Utils/TimeUtils";
+import type { DashboardMonitorRow } from "@/Pages/Dashboard/types";
+import type { DraggableSyntheticListeners } from "@dnd-kit/core";
+import { LAYOUT } from "@/Utils/Theme/constants";
+
+const TOP_N = 5;
+
+interface SlowestMonitorsCardProps {
+ slowestMonitors: DashboardMonitorRow[];
+ isLoading?: boolean;
+ error?: unknown;
+ dragHandleProps?: DraggableSyntheticListeners;
+}
+
+export const SlowestMonitorsCard = ({
+ slowestMonitors,
+ isLoading,
+ error,
+ dragHandleProps,
+}: SlowestMonitorsCardProps) => {
+ const { t } = useTranslation();
+
+ const top = slowestMonitors.slice(0, TOP_N);
+ const maxAvg = top[0]?.avgResponseTime ?? 1;
+ const total = slowestMonitors.length;
+
+ return (
+ 0
+ ? t("pages.dashboard.emptyStates.topOf", {
+ count: Math.min(TOP_N, total),
+ total,
+ })
+ : undefined
+ }
+ isLoading={isLoading}
+ error={error}
+ >
+
+ {top.map((m) => (
+
+ ))}
+
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/components/cards/StubCard.tsx b/client/src/Pages/Dashboard/components/cards/StubCard.tsx
new file mode 100644
index 000000000..c853bb853
--- /dev/null
+++ b/client/src/Pages/Dashboard/components/cards/StubCard.tsx
@@ -0,0 +1,41 @@
+import Typography from "@mui/material/Typography";
+import { useTheme } from "@mui/material/styles";
+import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
+import { typographyLevels } from "@/Utils/Theme/Palette";
+import { useTranslation } from "react-i18next";
+import type { DashboardCardId } from "@/Pages/Dashboard/dashboardCards";
+import type { DraggableSyntheticListeners } from "@dnd-kit/core";
+
+interface StubCardProps {
+ cardId: DashboardCardId;
+ isLoading?: boolean;
+ error?: unknown;
+ dragHandleProps?: DraggableSyntheticListeners;
+}
+
+export const StubCard = ({
+ cardId,
+ isLoading,
+ error,
+ dragHandleProps,
+}: StubCardProps) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t("pages.dashboard.comingSoon")}
+
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/dashboardCards.ts b/client/src/Pages/Dashboard/dashboardCards.ts
new file mode 100644
index 000000000..f8e1469a6
--- /dev/null
+++ b/client/src/Pages/Dashboard/dashboardCards.ts
@@ -0,0 +1,90 @@
+export const DASHBOARD_CARD_IDS = [
+ "monitorStatus",
+ "currentlyDown",
+ "slowestMonitors",
+ "lowestUptime",
+ "monitorsByType",
+ "monitorsByGroup",
+ "busiestServers",
+ "recentIncidents",
+ "incidentStats",
+ "notificationChannels",
+ "maintenanceWindows",
+ "statusPages",
+ "failedChecks",
+ "checkmateServer",
+ "checkQueue",
+ "recentErrors",
+ "teamMembers",
+] as const;
+
+export type DashboardCardId = (typeof DASHBOARD_CARD_IDS)[number];
+
+export const defaultVisibleCards: Record = {
+ monitorStatus: true,
+ currentlyDown: true,
+ slowestMonitors: true,
+ lowestUptime: true,
+ monitorsByType: true,
+ monitorsByGroup: true,
+ busiestServers: false,
+ recentIncidents: false,
+ incidentStats: false,
+ notificationChannels: false,
+ maintenanceWindows: false,
+ statusPages: false,
+ failedChecks: false,
+ checkmateServer: false,
+ checkQueue: false,
+ recentErrors: false,
+ teamMembers: false,
+};
+
+export const cardCategories: {
+ labelKey: string;
+ cards: DashboardCardId[];
+}[] = [
+ {
+ labelKey: "pages.dashboard.modal.categories.uptime",
+ cards: [
+ "monitorStatus",
+ "currentlyDown",
+ "slowestMonitors",
+ "lowestUptime",
+ "monitorsByType",
+ "monitorsByGroup",
+ ],
+ },
+ {
+ labelKey: "pages.dashboard.modal.categories.infrastructure",
+ cards: ["busiestServers"],
+ },
+ {
+ labelKey: "pages.dashboard.modal.categories.incidents",
+ cards: ["recentIncidents", "incidentStats"],
+ },
+ {
+ labelKey: "pages.dashboard.modal.categories.notifications",
+ cards: ["notificationChannels"],
+ },
+ {
+ labelKey: "pages.dashboard.modal.categories.maintenance",
+ cards: ["maintenanceWindows"],
+ },
+ {
+ labelKey: "pages.dashboard.modal.categories.statusPages",
+ cards: ["statusPages"],
+ },
+ {
+ labelKey: "pages.dashboard.modal.categories.checks",
+ cards: ["failedChecks"],
+ },
+ {
+ labelKey: "pages.dashboard.modal.categories.system",
+ cards: ["checkmateServer", "checkQueue", "recentErrors"],
+ },
+ {
+ labelKey: "pages.dashboard.modal.categories.settings",
+ cards: ["teamMembers"],
+ },
+];
diff --git a/client/src/Pages/Dashboard/hooks/useDashboardData.ts b/client/src/Pages/Dashboard/hooks/useDashboardData.ts
new file mode 100644
index 000000000..983cae295
--- /dev/null
+++ b/client/src/Pages/Dashboard/hooks/useDashboardData.ts
@@ -0,0 +1,35 @@
+import { useGet } from "@/Hooks/UseApi";
+import type { DashboardSummaryResult } from "@/Pages/Dashboard/types";
+import type { MonitorsSummary } from "@/Types/Monitor";
+
+const EMPTY_SUMMARY: MonitorsSummary = {
+ totalMonitors: 0,
+ upMonitors: 0,
+ downMonitors: 0,
+ pausedMonitors: 0,
+ initializingMonitors: 0,
+ maintenanceMonitors: 0,
+ breachedMonitors: 0,
+};
+
+export const useDashboardData = () => {
+ const { data, isLoading, error } = useGet(
+ "/dashboard/summary",
+ undefined,
+ {
+ refreshInterval: 30000,
+ }
+ );
+
+ return {
+ isLoading,
+ error,
+ summary: data?.summary ?? EMPTY_SUMMARY,
+ monitors: data?.monitors ?? [],
+ slowestMonitors: data?.slowestMonitors ?? [],
+ lowestUptimeMonitors: data?.lowestUptimeMonitors ?? [],
+ downMonitors: data?.downMonitors ?? [],
+ monitorsByType: data?.monitorsByType ?? [],
+ monitorsByGroup: data?.monitorsByGroup ?? [],
+ };
+};
diff --git a/client/src/Pages/Dashboard/index.tsx b/client/src/Pages/Dashboard/index.tsx
new file mode 100644
index 000000000..c68ca9fae
--- /dev/null
+++ b/client/src/Pages/Dashboard/index.tsx
@@ -0,0 +1,234 @@
+import { useState } from "react";
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import { useTheme } from "@mui/material/styles";
+import useMediaQuery from "@mui/material/useMediaQuery";
+import { LayoutGrid } from "lucide-react";
+import { useDispatch, useSelector } from "react-redux";
+import { useTranslation } from "react-i18next";
+import {
+ DndContext,
+ DragOverlay,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ closestCenter,
+} from "@dnd-kit/core";
+import type {
+ DragStartEvent,
+ DragEndEvent,
+ DraggableSyntheticListeners,
+} from "@dnd-kit/core";
+import { SortableContext, arrayMove } from "@dnd-kit/sortable";
+import type { SortingStrategy } from "@dnd-kit/sortable";
+
+import { BasePage } from "@/Components/design-elements/BasePage";
+import { Button } from "@/Components/inputs";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import type { RootState } from "@/Types/state";
+import { setDashboardCardOrder } from "@/Features/UI/uiSlice";
+
+import {
+ DASHBOARD_CARD_IDS,
+ defaultVisibleCards,
+ type DashboardCardId,
+} from "@/Pages/Dashboard/dashboardCards";
+import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
+import { useDashboardData } from "@/Pages/Dashboard/hooks/useDashboardData";
+import { SortableCard } from "@/Pages/Dashboard/components/SortableCard";
+
+import { MonitorStatusCard } from "@/Pages/Dashboard/components/cards/MonitorStatusCard";
+import { CurrentlyDownCard } from "@/Pages/Dashboard/components/cards/CurrentlyDownCard";
+import { SlowestMonitorsCard } from "@/Pages/Dashboard/components/cards/SlowestMonitorsCard";
+import { LowestUptimeCard } from "@/Pages/Dashboard/components/cards/LowestUptimeCard";
+import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
+import { MonitorsByGroupCard } from "@/Pages/Dashboard/components/cards/MonitorsByGroupCard";
+import { StubCard } from "@/Pages/Dashboard/components/cards/StubCard";
+
+// No displacement transforms during drag — only the DragOverlay moves
+const noAnimationStrategy: SortingStrategy = () => null;
+
+const Dashboard = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const dispatch = useDispatch();
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"));
+ const [modalOpen, setModalOpen] = useState(false);
+ const [activeId, setActiveId] = useState(null);
+
+ const visibility = useSelector(
+ (state: RootState) => state.ui.dashboardCards ?? defaultVisibleCards
+ );
+ const cardOrder = useSelector(
+ (state: RootState) => state.ui.dashboardCardOrder ?? [...DASHBOARD_CARD_IDS]
+ );
+
+ const {
+ isLoading,
+ error,
+ summary,
+ downMonitors,
+ slowestMonitors,
+ lowestUptimeMonitors,
+ monitorsByType,
+ monitorsByGroup,
+ } = useDashboardData();
+
+ const visibleCards = cardOrder.filter((id) => visibility[id]);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: { distance: 8 },
+ })
+ );
+
+ const handleDragStart = (event: DragStartEvent) => {
+ setActiveId(event.active.id as DashboardCardId);
+ };
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ setActiveId(null);
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+
+ const oldIndex = visibleCards.indexOf(active.id as DashboardCardId);
+ const newIndex = visibleCards.indexOf(over.id as DashboardCardId);
+ if (oldIndex === -1 || newIndex === -1) return;
+
+ const newVisible = arrayMove(visibleCards, oldIndex, newIndex);
+ let vi = 0;
+ const newFullOrder = cardOrder.map((id) => (visibility[id] ? newVisible[vi++] : id));
+ dispatch(setDashboardCardOrder(newFullOrder));
+ };
+
+ const renderCardContent = (
+ id: DashboardCardId,
+ dragHandleProps?: DraggableSyntheticListeners
+ ) => {
+ switch (id) {
+ case "monitorStatus":
+ return (
+
+ );
+ case "currentlyDown":
+ return (
+
+ );
+ case "slowestMonitors":
+ return (
+
+ );
+ case "lowestUptime":
+ return (
+
+ );
+ case "monitorsByType":
+ return (
+
+ );
+ case "monitorsByGroup":
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+ };
+
+ return (
+
+
+ }
+ onClick={() => setModalOpen(true)}
+ >
+ {t("pages.dashboard.editCards")}
+
+
+
+
+
+
+ {visibleCards.map((id) => (
+
+ {(listeners) => renderCardContent(id, listeners)}
+
+ ))}
+
+
+
+
+ {activeId ? (
+
+ {renderCardContent(activeId)}
+
+ ) : null}
+
+
+
+ setModalOpen(false)}
+ />
+
+ );
+};
+
+export default Dashboard;
diff --git a/client/src/Pages/Dashboard/types.ts b/client/src/Pages/Dashboard/types.ts
new file mode 100644
index 000000000..f9d4c34fb
--- /dev/null
+++ b/client/src/Pages/Dashboard/types.ts
@@ -0,0 +1,31 @@
+import type { MonitorsSummary } from "@/Types/Monitor";
+
+export interface DashboardMonitorRow {
+ id: string;
+ name: string;
+ status: string;
+ type: string;
+ group: string | null;
+ uptimePercentage: number;
+ avgResponseTime: number;
+}
+
+export interface DashboardTypeCount {
+ type: string;
+ count: number;
+}
+
+export interface DashboardGroupCount {
+ group: string;
+ count: number;
+}
+
+export interface DashboardSummaryResult {
+ summary: MonitorsSummary;
+ monitors: DashboardMonitorRow[];
+ slowestMonitors: DashboardMonitorRow[];
+ lowestUptimeMonitors: DashboardMonitorRow[];
+ downMonitors: DashboardMonitorRow[];
+ monitorsByType: DashboardTypeCount[];
+ monitorsByGroup: DashboardGroupCount[];
+}
diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx
index 37e49d1ee..c96ecd7d0 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={}
+ />
+ }
/>
;
+ dashboardCardOrder: DashboardCardId[];
}
export interface RootState {
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index d7c3dfe05..cc4cd1dda 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -226,7 +226,8 @@
"alerting": "Alerting",
"reporting": "Reporting",
"configuration": "Configuration"
- }
+ },
+ "dashboard": "Dashboard"
},
"bottomMenu": {
"support": "Support",
@@ -1884,6 +1885,60 @@
"title": "Uptime monitors",
"description": "Watch HTTP endpoints, pings, containers, and ports — and get alerted the moment something goes down."
}
+ },
+ "dashboard": {
+ "editCards": "Edit cards",
+ "error": "Failed to load data",
+ "monitorStatus": {
+ "ofUp": "of {{total}} up"
+ },
+ "emptyStates": {
+ "allMonitorsUp": "All monitors are up. No incidents open.",
+ "noGroups": "No groups yet. Groups are set per monitor.",
+ "topOf": "Top {{count}} of {{total}}"
+ },
+ "cards": {
+ "monitorStatus": "Monitor status",
+ "currentlyDown": "Currently down",
+ "slowestMonitors": "Slowest monitors",
+ "lowestUptime": "Lowest uptime",
+ "monitorsByType": "Monitors by type",
+ "monitorsByGroup": "Monitors by group",
+ "busiestServers": "Busiest servers",
+ "recentIncidents": "Recent incidents",
+ "incidentStats": "Incident statistics",
+ "notificationChannels": "Notification channels",
+ "maintenanceWindows": "Maintenance windows",
+ "statusPages": "Status pages",
+ "failedChecks": "Failed checks",
+ "checkmateServer": "Checkmate server",
+ "checkQueue": "Check queue",
+ "recentErrors": "Recent errors",
+ "teamMembers": "Team members"
+ },
+ "modal": {
+ "title": "Edit dashboard cards",
+ "description": "Choose which cards to display on your dashboard.",
+ "categories": {
+ "uptime": "Uptime",
+ "infrastructure": "Infrastructure",
+ "incidents": "Incidents",
+ "notifications": "Notifications",
+ "maintenance": "Maintenance",
+ "statusPages": "Status pages",
+ "checks": "Checks",
+ "system": "System",
+ "settings": "Settings"
+ },
+ "resetToDefaults": "Reset to defaults",
+ "selectAll": "Select all",
+ "done": "Done"
+ },
+ "comingSoon": "Coming soon",
+ "header": {
+ "title": "Dashboard",
+ "description": "See every monitor type in one place and spot what needs attention."
+ }
}
}
}
diff --git a/client/src/store.ts b/client/src/store.ts
index d2a1b96d8..6c8d1b53c 100644
--- a/client/src/store.ts
+++ b/client/src/store.ts
@@ -7,9 +7,14 @@ import {
persistReducer,
persistStore,
createTransform,
+ createMigrate,
PERSIST,
REHYDRATE,
} from "redux-persist";
+import {
+ defaultVisibleCards,
+ DASHBOARD_CARD_IDS,
+} from "@/Pages/Dashboard/dashboardCards";
const authTransform = createTransform(
(inboundState: Record) => {
@@ -20,13 +25,44 @@ const authTransform = createTransform(
{ whitelist: ["auth"] }
);
+const migrations = {
+ 0: (state: any) => ({
+ ...state,
+ ui: {
+ ...state?.ui,
+ dashboardCards: state?.ui?.dashboardCards ?? { ...defaultVisibleCards },
+ },
+ }),
+ 1: (state: any) => {
+ const cards = state?.ui?.dashboardCards;
+ const hasAnyVisible = cards && Object.values(cards).some((v) => v);
+ return {
+ ...state,
+ ui: {
+ ...state?.ui,
+ dashboardCards: hasAnyVisible ? cards : { ...defaultVisibleCards },
+ },
+ };
+ },
+ 2: (state: any) => {
+ const cards = state?.ui?.dashboardCards ?? { ...defaultVisibleCards };
+ const stubCards = DASHBOARD_CARD_IDS.filter((id) => !defaultVisibleCards[id]);
+ const resetCards = { ...cards };
+ stubCards.forEach((id) => {
+ resetCards[id] = false;
+ });
+ return { ...state, ui: { ...state?.ui, dashboardCards: resetCards } };
+ },
+};
+
const persistConfig = {
key: "root",
+ version: 2,
storage,
whitelist: ["auth", "ui"],
transforms: [authTransform],
- // Merge two levels deep on rehydrate so a slice key added in a new release keeps initial value
stateReconciler: autoMergeLevel2,
+ migrate: createMigrate(migrations, { debug: false }),
};
const rootReducer = combineReducers({
diff --git a/server/src/api/controllers/dashboardController.ts b/server/src/api/controllers/dashboardController.ts
new file mode 100644
index 000000000..2c2d66791
--- /dev/null
+++ b/server/src/api/controllers/dashboardController.ts
@@ -0,0 +1,28 @@
+import { Request, Response, RequestHandler } from "express";
+import { catchAsync } from "@/utils/catchAsync.js";
+import { requireTeamId } from "@/api/controllers/controllerUtils.js";
+import type { IDashboardService } from "@/domain/dashboard/dashboard.service.js";
+
+export interface IDashboardController {
+ getSummary: RequestHandler;
+}
+
+class DashboardController implements IDashboardController {
+ private dashboardService: IDashboardService;
+
+ constructor(dashboardService: IDashboardService) {
+ this.dashboardService = dashboardService;
+ }
+
+ getSummary = catchAsync(async (req: Request, res: Response) => {
+ const teamId = requireTeamId(req.user?.teamId);
+ const data = await this.dashboardService.getSummary(teamId);
+ return res.status(200).json({
+ success: true,
+ msg: "Dashboard summary retrieved successfully",
+ data,
+ });
+ });
+}
+
+export default DashboardController;
diff --git a/server/src/api/routes/dashboardRoutes.ts b/server/src/api/routes/dashboardRoutes.ts
new file mode 100644
index 000000000..385e46c17
--- /dev/null
+++ b/server/src/api/routes/dashboardRoutes.ts
@@ -0,0 +1,8 @@
+import { Router } from "express";
+import type { IDashboardController } from "@/api/controllers/dashboardController.js";
+
+export const createDashboardRoutes = (dashboardController: IDashboardController): Router => {
+ const router = Router();
+ router.get("/summary", dashboardController.getSummary);
+ return router;
+};
diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts
index 7787a389e..406e2c18d 100644
--- a/server/src/config/controllers.ts
+++ b/server/src/config/controllers.ts
@@ -13,6 +13,8 @@ import TagsController from "../api/controllers/tagController.js";
import DiagnosticController from "../api/controllers/diagnosticController.js";
import IncidentController from "../api/controllers/incidentController.js";
import ProxyController from "@/api/controllers/proxyController.js";
+import DashboardController from "@/api/controllers/dashboardController.js";
+import type { IDashboardController } from "@/api/controllers/dashboardController.js";
import { ApiServices } from "@/config/services.api.js";
export interface InitializedControllers {
@@ -31,6 +33,7 @@ export interface InitializedControllers {
diagnosticController: DiagnosticController;
incidentController: IncidentController;
proxyController: ProxyController;
+ dashboardController: IDashboardController;
}
export const initializeControllers = (apiServices: ApiServices): InitializedControllers => {
return {
@@ -49,5 +52,6 @@ export const initializeControllers = (apiServices: ApiServices): InitializedCont
diagnosticController: new DiagnosticController(apiServices.diagnosticService),
incidentController: new IncidentController(apiServices.incidentService),
proxyController: new ProxyController(apiServices.proxiesService),
+ dashboardController: new DashboardController(apiServices.dashboardService),
};
};
diff --git a/server/src/config/routes.ts b/server/src/config/routes.ts
index 6704fa83b..5a89f9bc7 100644
--- a/server/src/config/routes.ts
+++ b/server/src/config/routes.ts
@@ -20,6 +20,7 @@ import { createNotificationRoutes } from "@/api/routes/notificationRoutes.js";
import { createTagRoutes } from "@/api/routes/tagRoutes.js";
import { createIncidentRoutes } from "@/api/routes/incidentRoutes.js";
import { createProxyRoutes } from "@/api/routes/proxyRoutes.js";
+import { createDashboardRoutes } from "@/api/routes/dashboardRoutes.js";
export const setupRoutes = (app: Application, controllers: InitializedControllers, apiServices: ApiServices) => {
const verifyJWT = createVerifyJWT(apiServices.settingsService);
@@ -39,6 +40,7 @@ export const setupRoutes = (app: Application, controllers: InitializedController
const diagnosticRoutes = createDiagnosticRoutes(controllers.diagnosticController, verifyJWT);
const incidentRoutes = createIncidentRoutes(controllers.incidentController);
const proxyRoutes = createProxyRoutes(controllers.proxyController);
+ const dashboardRoutes = createDashboardRoutes(controllers.dashboardController);
app.use("/api/v1/auth", authApiLimiter, authRoutes);
app.use("/api/v1/monitors", verifyJWT, monitorRoutes);
@@ -55,4 +57,5 @@ export const setupRoutes = (app: Application, controllers: InitializedController
app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes);
app.use("/api/v1/incidents", verifyJWT, incidentRoutes);
app.use("/api/v1/proxies", verifyJWT, proxyRoutes);
+ app.use("/api/v1/dashboard", verifyJWT, dashboardRoutes);
};
diff --git a/server/src/config/services.api.ts b/server/src/config/services.api.ts
index 65607107c..a8feec846 100644
--- a/server/src/config/services.api.ts
+++ b/server/src/config/services.api.ts
@@ -7,6 +7,7 @@ import { ITagsService, TagsService } from "@/domain/tags/tag.service.js";
import { IUserService, UserService } from "@/domain/users/user.service.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import { ProxiesService, IProxiesService } from "@/domain/proxies/proxy.service.js";
+import { DashboardService, IDashboardService } from "@/domain/dashboard/dashboard.service.js";
import { SharedServices } from "@/config/services.shared.js";
// Third-party
@@ -24,6 +25,7 @@ export interface ApiServices extends SharedServices {
tagsService: ITagsService;
diagnosticService: IDiagnosticService;
proxiesService: IProxiesService;
+ dashboardService: IDashboardService;
}
export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): ApiServices => {
@@ -99,6 +101,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
const tagsService = new TagsService(tagsRepository, monitorsRepository);
const diagnosticService = new DiagnosticService(db);
const proxiesService = new ProxiesService(proxiesRepository, monitorsRepository, settingsService);
+ const dashboardService = new DashboardService(monitorsRepository);
return {
...shared,
worker: jobScheduler,
@@ -110,5 +113,6 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
tagsService,
diagnosticService,
proxiesService,
+ dashboardService,
};
};
diff --git a/server/src/domain/dashboard/dashboard.service.ts b/server/src/domain/dashboard/dashboard.service.ts
new file mode 100644
index 000000000..aca10d54c
--- /dev/null
+++ b/server/src/domain/dashboard/dashboard.service.ts
@@ -0,0 +1,72 @@
+import type { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
+import type { Monitor } from "@/domain/monitors/monitor.type.js";
+import type { DashboardMonitorRow, DashboardSummaryResult, DashboardTypeCount, DashboardGroupCount } from "@/domain/dashboard/dashboard.type.js";
+
+const TOP_N = 5;
+
+const avgResponseTime = (monitor: Monitor): number => {
+ const times = (monitor.recentChecks ?? []).map((c) => c.responseTime).filter((t): t is number => t != null && t > 0);
+ return times.length ? times.reduce((a, b) => a + b, 0) / times.length : 0;
+};
+
+const toRow = (monitor: Monitor): DashboardMonitorRow => ({
+ id: monitor.id,
+ name: monitor.name,
+ status: monitor.status,
+ type: monitor.type,
+ group: monitor.group ?? null,
+ uptimePercentage: (monitor.uptimePercentage ?? 1) * 100,
+ avgResponseTime: avgResponseTime(monitor),
+});
+
+export interface IDashboardService {
+ getSummary(teamId: string): Promise;
+}
+
+export class DashboardService implements IDashboardService {
+ private monitorsRepository: IMonitorsRepository;
+
+ constructor(monitorsRepository: IMonitorsRepository) {
+ this.monitorsRepository = monitorsRepository;
+ }
+
+ getSummary = async (teamId: string): Promise => {
+ const [summary, monitors] = await Promise.all([
+ this.monitorsRepository.findMonitorsSummaryByTeamId(teamId),
+ this.monitorsRepository.findByTeamIdWithStats(teamId, { limit: 200 }),
+ ]);
+
+ const rows = monitors.map(toRow);
+
+ const slowestMonitors = [...rows]
+ .filter((r) => r.avgResponseTime > 0)
+ .sort((a, b) => b.avgResponseTime - a.avgResponseTime)
+ .slice(0, TOP_N);
+
+ const lowestUptimeMonitors = [...rows].sort((a, b) => a.uptimePercentage - b.uptimePercentage).slice(0, TOP_N);
+
+ const downMonitors = rows.filter((r) => r.status === "down");
+
+ const typeMap = new Map();
+ rows.forEach((r) => typeMap.set(r.type, (typeMap.get(r.type) ?? 0) + 1));
+ const monitorsByType: DashboardTypeCount[] = [...typeMap.entries()].map(([type, count]) => ({ type, count })).sort((a, b) => b.count - a.count);
+
+ const groupMap = new Map();
+ rows.forEach((r) => {
+ if (r.group) groupMap.set(r.group, (groupMap.get(r.group) ?? 0) + 1);
+ });
+ const monitorsByGroup: DashboardGroupCount[] = [...groupMap.entries()]
+ .map(([group, count]) => ({ group, count }))
+ .sort((a, b) => b.count - a.count);
+
+ return {
+ summary,
+ monitors: rows,
+ slowestMonitors,
+ lowestUptimeMonitors,
+ downMonitors,
+ monitorsByType,
+ monitorsByGroup,
+ };
+ };
+}
diff --git a/server/src/domain/dashboard/dashboard.type.ts b/server/src/domain/dashboard/dashboard.type.ts
new file mode 100644
index 000000000..c5e445ebc
--- /dev/null
+++ b/server/src/domain/dashboard/dashboard.type.ts
@@ -0,0 +1,31 @@
+import type { MonitorsSummary } from "@/domain/monitors/monitor.type.js";
+
+export interface DashboardMonitorRow {
+ id: string;
+ name: string;
+ status: string;
+ type: string;
+ group: string | null;
+ uptimePercentage: number;
+ avgResponseTime: number;
+}
+
+export interface DashboardTypeCount {
+ type: string;
+ count: number;
+}
+
+export interface DashboardGroupCount {
+ group: string;
+ count: number;
+}
+
+export interface DashboardSummaryResult {
+ summary: MonitorsSummary;
+ monitors: DashboardMonitorRow[];
+ slowestMonitors: DashboardMonitorRow[];
+ lowestUptimeMonitors: DashboardMonitorRow[];
+ downMonitors: DashboardMonitorRow[];
+ monitorsByType: DashboardTypeCount[];
+ monitorsByGroup: DashboardGroupCount[];
+}