feat: create dashboard of updates
This commit is contained in:
28 files changed
+1465
-3
No files matched your search
@@ -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: <Icon icon={LayoutDashboard} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.uptime"),
|
||||
path: "uptime",
|
||||
|
||||
@@ -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<DashboardCardId, boolean>;
|
||||
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<ChartType>) => {
|
||||
state.chartType = action.payload;
|
||||
},
|
||||
setDashboardCards: (
|
||||
state,
|
||||
action: PayloadAction<Record<DashboardCardId, boolean>>
|
||||
) => {
|
||||
state.dashboardCards = action.payload;
|
||||
},
|
||||
setDashboardCardsVisibility: (
|
||||
state,
|
||||
action: PayloadAction<{ id: DashboardCardId; visible: boolean }>
|
||||
) => {
|
||||
state.dashboardCards[action.payload.id] = action.payload.visible;
|
||||
},
|
||||
toggleDashboardCard: (state, action: PayloadAction<DashboardCardId>) => {
|
||||
state.dashboardCards[action.payload] = !state.dashboardCards[action.payload];
|
||||
},
|
||||
resetDashboardCards: (state) => {
|
||||
state.dashboardCards = { ...defaultVisibleCards };
|
||||
},
|
||||
setDashboardCardOrder: (state, action: PayloadAction<DashboardCardId[]>) => {
|
||||
state.dashboardCardOrder = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -133,4 +163,9 @@ export const {
|
||||
setLanguage,
|
||||
setStarPromptOpen,
|
||||
setChartType,
|
||||
setDashboardCards,
|
||||
setDashboardCardsVisibility,
|
||||
toggleDashboardCard,
|
||||
resetDashboardCards,
|
||||
setDashboardCardOrder,
|
||||
} = uiSlice.actions;
|
||||
@@ -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 (
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{[80, 60, 90, 50, 70].map((w, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="rounded"
|
||||
height={14}
|
||||
width={`${w}%`}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.error.main}
|
||||
>
|
||||
{t("pages.dashboard.error")}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bgcolor={theme.palette.background.paper}
|
||||
border={1}
|
||||
borderColor={theme.palette.divider}
|
||||
borderRadius={`${theme.shape.borderRadius}px`}
|
||||
padding={theme.spacing(LAYOUT.MD)}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
height="100%"
|
||||
boxSizing="border-box"
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
fontWeight={500}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
{topRight && (
|
||||
<Typography
|
||||
fontSize={typographyLevels.s}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{topRight}
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
color={theme.palette.text.disabled}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
{...(dragHandleProps ?? {})}
|
||||
sx={{
|
||||
cursor: dragHandleProps ? "grab" : "default",
|
||||
"&:active": { cursor: dragHandleProps ? "grabbing" : "default" },
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<GripVertical size={LAYOUT.XXL} />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{renderContent()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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]) => (
|
||||
<Stack
|
||||
key={category.labelKey}
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
>
|
||||
<Typography
|
||||
fontSize={typographyLevels.s}
|
||||
fontWeight={600}
|
||||
letterSpacing="0.08em"
|
||||
color={theme.palette.text.disabled}
|
||||
textTransform="uppercase"
|
||||
mb={theme.spacing(LAYOUT.XXS)}
|
||||
>
|
||||
{t(category.labelKey)}
|
||||
</Typography>
|
||||
{category.cards.map((id) => (
|
||||
<FormControlLabel
|
||||
key={id}
|
||||
label={
|
||||
<Typography fontSize={typographyLevels.m}>
|
||||
{t(`pages.dashboard.cards.${id}`)}
|
||||
</Typography>
|
||||
}
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={visibility[id] ?? false}
|
||||
onChange={(e) => handleToggleCard(id, e.target.checked)}
|
||||
/>
|
||||
}
|
||||
sx={{ m: 0, gap: theme.spacing(LAYOUT.SM) }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle
|
||||
pb={theme.spacing(LAYOUT.XS)}
|
||||
bgcolor={theme.palette.action.hover}
|
||||
borderBottom={`1px solid ${theme.palette.divider}`}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
fontWeight={600}
|
||||
fontSize={typographyLevels.l}
|
||||
display="block"
|
||||
>
|
||||
{t("pages.dashboard.modal.title")}
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ pt: theme.spacing(LAYOUT.MD) }}>
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns="1fr 1fr"
|
||||
gap={theme.spacing(LAYOUT.XL)}
|
||||
pt={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
<Stack gap={theme.spacing(LAYOUT.LG)}>
|
||||
{leftCategories.map(renderCategory)}
|
||||
</Stack>
|
||||
<Stack gap={theme.spacing(LAYOUT.LG)}>
|
||||
{rightCategories.map(renderCategory)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions
|
||||
sx={{
|
||||
p: theme.spacing(LAYOUT.MD),
|
||||
pt: theme.spacing(LAYOUT.SM),
|
||||
bgcolor: theme.palette.action.hover,
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t("pages.dashboard.modal.resetToDefaults")}
|
||||
</Button>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<FormControlLabel
|
||||
label={
|
||||
<Typography fontSize={typographyLevels.m}>
|
||||
{t("pages.dashboard.modal.selectAll")}
|
||||
</Typography>
|
||||
}
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={allVisible}
|
||||
indeterminate={selectAllIndeterminate}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
}
|
||||
sx={{ m: 0, gap: theme.spacing(LAYOUT.SM) }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("pages.dashboard.modal.done")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
height="100%"
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
{children(listeners)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Stack gap={theme.spacing(SPACING.LG)}>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={valueColor ?? theme.palette.primary.main}
|
||||
fontWeight={500}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box
|
||||
height={4}
|
||||
borderRadius={2}
|
||||
bgcolor={theme.palette.action.disabledBackground}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box
|
||||
height="100%"
|
||||
width={`${clampedRatio * 100}%`}
|
||||
borderRadius={2}
|
||||
bgcolor={theme.palette.primary.main}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.currentlyDown")}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
dragHandleProps={dragHandleProps}
|
||||
>
|
||||
{downMonitors.length === 0 ? (
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{t("pages.dashboard.emptyStates.allMonitorsUp")}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack gap={theme.spacing(1)}>
|
||||
{downMonitors.map((m) => (
|
||||
<Typography
|
||||
key={m.id}
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.error.main}
|
||||
>
|
||||
{m.name}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.lowestUptime")}
|
||||
dragHandleProps={dragHandleProps}
|
||||
topRight={
|
||||
total > 0
|
||||
? t("pages.dashboard.emptyStates.topOf", {
|
||||
count: Math.min(TOP_N, total),
|
||||
total,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
>
|
||||
<Stack gap={LAYOUT.SM}>
|
||||
{top.map((monitor) => {
|
||||
const uptime = monitor.uptimePercentage;
|
||||
const downtime = 100 - uptime;
|
||||
return (
|
||||
<BarRow
|
||||
key={monitor.id}
|
||||
label={monitor.name}
|
||||
value={formatPercentageFromWhole(uptime)}
|
||||
fillRatio={downtime / 100}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.monitorStatus")}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
dragHandleProps={dragHandleProps}
|
||||
>
|
||||
<Stack gap={theme.spacing(SPACING.SM)}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
gap={theme.spacing(SPACING.XS)}
|
||||
>
|
||||
<Typography
|
||||
fontSize={typographyLevels.xxl}
|
||||
fontWeight={500}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{up}
|
||||
</Typography>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{t("pages.dashboard.monitorStatus.ofUp", { total })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box
|
||||
height={4}
|
||||
borderRadius={2}
|
||||
bgcolor={theme.palette.action.disabledBackground}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box
|
||||
height="100%"
|
||||
width={`${ratio * 100}%`}
|
||||
borderRadius={2}
|
||||
bgcolor={theme.palette.primary.main}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.monitorsByGroup")}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
dragHandleProps={dragHandleProps}
|
||||
>
|
||||
{monitorsByGroup.length === 0 ? (
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{t("pages.dashboard.emptyStates.noGroups")}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack gap={LAYOUT.SM}>
|
||||
{monitorsByGroup.map(({ group, count }) => (
|
||||
<BarRow
|
||||
key={group}
|
||||
label={group}
|
||||
value={String(count)}
|
||||
fillRatio={count / maxCount}
|
||||
valueColor={theme.palette.text.primary}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.monitorsByType")}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
dragHandleProps={dragHandleProps}
|
||||
>
|
||||
<Stack gap={LAYOUT.SM}>
|
||||
{monitorsByType.map(({ type, count }) => (
|
||||
<BarRow
|
||||
key={type}
|
||||
label={type}
|
||||
value={String(count)}
|
||||
fillRatio={count / maxCount}
|
||||
valueColor={theme.palette.text.primary}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.slowestMonitors")}
|
||||
dragHandleProps={dragHandleProps}
|
||||
topRight={
|
||||
total > 0
|
||||
? t("pages.dashboard.emptyStates.topOf", {
|
||||
count: Math.min(TOP_N, total),
|
||||
total,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
>
|
||||
<Stack gap={LAYOUT.SM}>
|
||||
{top.map((m) => (
|
||||
<BarRow
|
||||
key={m.id}
|
||||
label={m.name}
|
||||
value={formatMs(m.avgResponseTime)}
|
||||
fillRatio={m.avgResponseTime / maxAvg}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<DashboardCard
|
||||
title={t(`pages.dashboard.cards.${cardId}`)}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
dragHandleProps={dragHandleProps}
|
||||
>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.disabled}
|
||||
fontStyle="italic"
|
||||
>
|
||||
{t("pages.dashboard.comingSoon")}
|
||||
</Typography>
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -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<DashboardCardId, boolean> = {
|
||||
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"],
|
||||
},
|
||||
];
|
||||
@@ -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<DashboardSummaryResult>(
|
||||
"/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 ?? [],
|
||||
};
|
||||
};
|
||||
@@ -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<DashboardCardId | null>(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 (
|
||||
<MonitorStatusCard
|
||||
summary={summary}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
case "currentlyDown":
|
||||
return (
|
||||
<CurrentlyDownCard
|
||||
downMonitors={downMonitors}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
case "slowestMonitors":
|
||||
return (
|
||||
<SlowestMonitorsCard
|
||||
slowestMonitors={slowestMonitors}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
case "lowestUptime":
|
||||
return (
|
||||
<LowestUptimeCard
|
||||
lowestUptimeMonitors={lowestUptimeMonitors}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
case "monitorsByType":
|
||||
return (
|
||||
<MonitorsByTypeCard
|
||||
monitorsByType={monitorsByType}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
case "monitorsByGroup":
|
||||
return (
|
||||
<MonitorsByGroupCard
|
||||
monitorsByGroup={monitorsByGroup}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<StubCard
|
||||
cardId={id}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BasePage headerKey="dashboard">
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<LayoutGrid size={16} />}
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
{t("pages.dashboard.editCards")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={visibleCards}
|
||||
strategy={noAnimationStrategy}
|
||||
>
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns={isSmall ? "1fr" : "1fr 1fr"}
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
{visibleCards.map((id) => (
|
||||
<SortableCard
|
||||
key={id}
|
||||
id={id}
|
||||
isDragging={activeId === id}
|
||||
>
|
||||
{(listeners) => renderCardContent(id, listeners)}
|
||||
</SortableCard>
|
||||
))}
|
||||
</Box>
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId ? (
|
||||
<Box sx={{ opacity: 0.85, pointerEvents: "none" }}>
|
||||
{renderCardContent(activeId)}
|
||||
</Box>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
<EditCardsModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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 = () => {
|
||||
>
|
||||
<Route
|
||||
path="/"
|
||||
element={<Navigate to="/uptime" />}
|
||||
element={<Navigate to="/dashboard" />}
|
||||
/>
|
||||
<Route
|
||||
path="dashboard"
|
||||
element={<Dashboard />}
|
||||
/>
|
||||
<Route
|
||||
path="/uptime"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChartType } from "@/Features/UI/uiSlice";
|
||||
import type { User } from "./User";
|
||||
import type { DashboardCardId } from "@/Pages/Dashboard/dashboardCards";
|
||||
|
||||
export interface AuthState {
|
||||
isLoading: boolean;
|
||||
@@ -39,6 +40,8 @@ export interface UIState {
|
||||
language: string;
|
||||
starPromptOpen: boolean;
|
||||
chartType: ChartType;
|
||||
dashboardCards: Record<DashboardCardId, boolean>;
|
||||
dashboardCardOrder: DashboardCardId[];
|
||||
}
|
||||
|
||||
export interface RootState {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-1
@@ -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<string, unknown>) => {
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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<DashboardSummaryResult>;
|
||||
}
|
||||
|
||||
export class DashboardService implements IDashboardService {
|
||||
private monitorsRepository: IMonitorsRepository;
|
||||
|
||||
constructor(monitorsRepository: IMonitorsRepository) {
|
||||
this.monitorsRepository = monitorsRepository;
|
||||
}
|
||||
|
||||
getSummary = async (teamId: string): Promise<DashboardSummaryResult> => {
|
||||
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<string, number>();
|
||||
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<string, number>();
|
||||
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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in new issue
Block a user