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 ( + + + + {t("pages.dashboard.modal.title")} + + + + + + + {leftCategories.map(renderCategory)} + + + {rightCategories.map(renderCategory)} + + + + + + + + + + {t("pages.dashboard.modal.selectAll")} + + } + control={ + + } + sx={{ m: 0, gap: theme.spacing(LAYOUT.SM) }} + /> + + + + + ); +}; 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 ( + + + + + + + + + {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[]; +}