Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0e5a334bb |
No files matched your search
Generated
+58
-22
@@ -9,6 +9,9 @@
|
|||||||
"version": "3.11.0",
|
"version": "3.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/css-tokenizer": "^3.0.4",
|
"@csstools/css-tokenizer": "^3.0.4",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.13.3",
|
"@emotion/react": "^11.13.3",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
"@fontsource-variable/geist": "^5.3.0",
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
@@ -93,7 +96,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.7",
|
||||||
@@ -363,6 +365,59 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emotion/babel-plugin": {
|
"node_modules/@emotion/babel-plugin": {
|
||||||
"version": "11.13.5",
|
"version": "11.13.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -409,7 +464,6 @@
|
|||||||
"node_modules/@emotion/react": {
|
"node_modules/@emotion/react": {
|
||||||
"version": "11.14.0",
|
"version": "11.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
@@ -447,7 +501,6 @@
|
|||||||
"node_modules/@emotion/styled": {
|
"node_modules/@emotion/styled": {
|
||||||
"version": "11.14.1",
|
"version": "11.14.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
@@ -1236,7 +1289,6 @@
|
|||||||
"node_modules/@mui/material": {
|
"node_modules/@mui/material": {
|
||||||
"version": "7.3.7",
|
"version": "7.3.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@mui/core-downloads-tracker": "^7.3.7",
|
"@mui/core-downloads-tracker": "^7.3.7",
|
||||||
@@ -1341,7 +1393,6 @@
|
|||||||
"node_modules/@mui/system": {
|
"node_modules/@mui/system": {
|
||||||
"version": "7.3.7",
|
"version": "7.3.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@mui/private-theming": "^7.3.7",
|
"@mui/private-theming": "^7.3.7",
|
||||||
@@ -2386,7 +2437,6 @@
|
|||||||
"version": "24.5.2",
|
"version": "24.5.2",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.12.0"
|
"undici-types": "~7.12.0"
|
||||||
}
|
}
|
||||||
@@ -2402,7 +2452,6 @@
|
|||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.27",
|
"version": "18.3.27",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -2493,7 +2542,6 @@
|
|||||||
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
|
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "7.18.0",
|
"@typescript-eslint/scope-manager": "7.18.0",
|
||||||
"@typescript-eslint/types": "7.18.0",
|
"@typescript-eslint/types": "7.18.0",
|
||||||
@@ -2774,7 +2822,6 @@
|
|||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3131,7 +3178,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.42",
|
"baseline-browser-mapping": "^2.10.42",
|
||||||
"caniuse-lite": "^1.0.30001800",
|
"caniuse-lite": "^1.0.30001800",
|
||||||
@@ -3575,8 +3621,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.13",
|
"version": "1.11.13",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
@@ -3975,7 +4020,6 @@
|
|||||||
"version": "8.57.1",
|
"version": "8.57.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -4788,7 +4832,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.6"
|
"@babel/runtime": "^7.27.6"
|
||||||
},
|
},
|
||||||
@@ -5600,7 +5643,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
|
||||||
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
|
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mapbox/geojson-rewind": "^0.5.2",
|
"@mapbox/geojson-rewind": "^0.5.2",
|
||||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||||
@@ -6382,7 +6424,6 @@
|
|||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -6394,7 +6435,6 @@
|
|||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.71.1",
|
"version": "7.71.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -6598,8 +6638,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/redux": {
|
"node_modules/redux": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/redux-persist": {
|
"node_modules/redux-persist": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
@@ -6749,7 +6788,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.9"
|
"@types/estree": "1.0.9"
|
||||||
},
|
},
|
||||||
@@ -7591,7 +7629,6 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -7729,7 +7766,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/css-tokenizer": "^3.0.4",
|
"@csstools/css-tokenizer": "^3.0.4",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.13.3",
|
"@emotion/react": "^11.13.3",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
"@fontsource-variable/geist": "^5.3.0",
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Icon } from "@/Components/design-elements";
|
import { Icon } from "@/Components/design-elements";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
Globe,
|
Globe,
|
||||||
Gauge,
|
Gauge,
|
||||||
Link,
|
Link,
|
||||||
@@ -26,6 +27,11 @@ export const getMenu = (t: Function) => {
|
|||||||
{
|
{
|
||||||
group: t("components.sidebar.menu.groups.monitoring"),
|
group: t("components.sidebar.menu.groups.monitoring"),
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
name: t("components.sidebar.menu.dashboard"),
|
||||||
|
path: "dashboard",
|
||||||
|
icon: <Icon icon={LayoutDashboard} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: t("components.sidebar.menu.uptime"),
|
name: t("components.sidebar.menu.uptime"),
|
||||||
path: "uptime",
|
path: "uptime",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
import type { CardId } from "@/Pages/Dashboard/cards";
|
||||||
|
|
||||||
type ThemeMode = "light" | "dark";
|
type ThemeMode = "light" | "dark";
|
||||||
type ChartType = "histogram" | "line";
|
type ChartType = "histogram" | "line";
|
||||||
@@ -35,6 +36,7 @@ interface UIState {
|
|||||||
language: string;
|
language: string;
|
||||||
starPromptOpen: boolean;
|
starPromptOpen: boolean;
|
||||||
chartType: ChartType;
|
chartType: ChartType;
|
||||||
|
dashboardCards: CardId[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
|
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
|
||||||
@@ -74,6 +76,7 @@ const initialState: UIState = {
|
|||||||
language: "en",
|
language: "en",
|
||||||
starPromptOpen: true,
|
starPromptOpen: true,
|
||||||
chartType: "histogram",
|
chartType: "histogram",
|
||||||
|
dashboardCards: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const uiSlice = createSlice({
|
const uiSlice = createSlice({
|
||||||
@@ -117,6 +120,9 @@ const uiSlice = createSlice({
|
|||||||
setChartType: (state, action: PayloadAction<ChartType>) => {
|
setChartType: (state, action: PayloadAction<ChartType>) => {
|
||||||
state.chartType = action.payload;
|
state.chartType = action.payload;
|
||||||
},
|
},
|
||||||
|
setDashboardCards: (state, action: PayloadAction<CardId[] | null>) => {
|
||||||
|
state.dashboardCards = action.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,4 +139,5 @@ export const {
|
|||||||
setLanguage,
|
setLanguage,
|
||||||
setStarPromptOpen,
|
setStarPromptOpen,
|
||||||
setChartType,
|
setChartType,
|
||||||
|
setDashboardCards,
|
||||||
} = uiSlice.actions;
|
} = uiSlice.actions;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { GripVertical } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import type { DragEndEvent } from "@dnd-kit/core";
|
||||||
|
import { SortableContext, useSortable, rectSortingStrategy } from "@dnd-kit/sortable";
|
||||||
|
|
||||||
|
import { Icon } from "@/Components/design-elements";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { GRID_COLUMNS, type CardDefinition, type CardId } from "../cards";
|
||||||
|
import { CardSlotContext } from "./CardSlotContext";
|
||||||
|
|
||||||
|
const SortableCard = ({ card }: { card: CardDefinition }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
|
useSortable({ id: card.id });
|
||||||
|
const Component = card.component;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={setNodeRef}
|
||||||
|
minWidth={0}
|
||||||
|
sx={{
|
||||||
|
width: {
|
||||||
|
xs: "100%",
|
||||||
|
md:
|
||||||
|
card.width >= GRID_COLUMNS
|
||||||
|
? "100%"
|
||||||
|
: `calc(${(card.width / GRID_COLUMNS) * 100}% - ${theme.spacing(LAYOUT.MD / 2)})`,
|
||||||
|
},
|
||||||
|
transform: transform
|
||||||
|
? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)`
|
||||||
|
: undefined,
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.4 : 1,
|
||||||
|
zIndex: isDragging ? 1 : "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardSlotContext.Provider
|
||||||
|
value={
|
||||||
|
<Box
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
cursor: "grab",
|
||||||
|
"&:active": { cursor: "grabbing" },
|
||||||
|
touchAction: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={GripVertical}
|
||||||
|
size={16}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Component />
|
||||||
|
</CardSlotContext.Provider>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CardGrid = ({
|
||||||
|
cards,
|
||||||
|
onReorder,
|
||||||
|
}: {
|
||||||
|
cards: CardDefinition[];
|
||||||
|
onReorder: (sourceIndex: number, destIndex: number) => void;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: { distance: 8 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const sourceIndex = cards.findIndex((c) => c.id === active.id);
|
||||||
|
const destIndex = cards.findIndex((c) => c.id === over.id);
|
||||||
|
if (sourceIndex !== -1 && destIndex !== -1) onReorder(sourceIndex, destIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardIds = cards.map((c) => c.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={cardIds}
|
||||||
|
strategy={rectSortingStrategy}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexWrap="wrap"
|
||||||
|
gap={theme.spacing(LAYOUT.MD)}
|
||||||
|
alignItems="stretch"
|
||||||
|
>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<SortableCard
|
||||||
|
key={card.id}
|
||||||
|
card={card}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { Dialog, Checkbox, Button } from "@/Components/inputs";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { CardGroups, type CardDefinition, type CardGroup, type CardId } from "../cards";
|
||||||
|
|
||||||
|
interface CardPickerProps {
|
||||||
|
open: boolean;
|
||||||
|
available: CardDefinition[];
|
||||||
|
selected: CardId[];
|
||||||
|
allSelected: boolean;
|
||||||
|
onAdd: (id: CardId) => void;
|
||||||
|
onRemove: (id: CardId) => void;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onDeselectAll: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards are grouped by sidebar section, not by load cost: you look for a card
|
||||||
|
* where you look for the feature it belongs to. Admin-only cards never reach
|
||||||
|
* `available` for a non-admin, so nobody is offered a card they cannot load.
|
||||||
|
*/
|
||||||
|
export const CardPicker = ({
|
||||||
|
open,
|
||||||
|
available,
|
||||||
|
selected,
|
||||||
|
allSelected,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
onSelectAll,
|
||||||
|
onDeselectAll,
|
||||||
|
onReset,
|
||||||
|
onClose,
|
||||||
|
}: CardPickerProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
return CardGroups.map((group: CardGroup) => ({
|
||||||
|
group,
|
||||||
|
cards: available.filter((card) => card.group === group),
|
||||||
|
})).filter((entry) => entry.cards.length > 0);
|
||||||
|
}, [available]);
|
||||||
|
|
||||||
|
const chosen = new Set(selected);
|
||||||
|
const someSelected = selected.length > 0 && !allSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
title={t("pages.dashboard.picker.title")}
|
||||||
|
// Reset sits alone on the left, away from Done — a destructive action
|
||||||
|
// should not be adjacent to the one people reach for by reflex.
|
||||||
|
onCancel={onReset}
|
||||||
|
cancelText={t("pages.dashboard.picker.reset")}
|
||||||
|
onConfirm={onClose}
|
||||||
|
confirmText={t("pages.dashboard.picker.done")}
|
||||||
|
additionalButtons={
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
onClick={allSelected ? onDeselectAll : onSelectAll}
|
||||||
|
startIcon={
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={someSelected}
|
||||||
|
size="small"
|
||||||
|
sx={{ p: 0 }}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.picker.selectAll")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
* Two columns: seventeen cards in one list makes the dialog scroll,
|
||||||
|
* which hides most of the catalogue. Groups flow down the columns and
|
||||||
|
* are kept whole so a group is never split across the gap.
|
||||||
|
*/}
|
||||||
|
<Box
|
||||||
|
pt={theme.spacing(LAYOUT.SM)}
|
||||||
|
sx={{
|
||||||
|
columnCount: { xs: 1, sm: 2 },
|
||||||
|
columnGap: theme.spacing(LAYOUT.XL),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{grouped.map(({ group, cards }) => (
|
||||||
|
<Stack
|
||||||
|
key={group}
|
||||||
|
gap={theme.spacing(LAYOUT.XXS)}
|
||||||
|
pb={theme.spacing(LAYOUT.MD)}
|
||||||
|
sx={{ breakInside: "avoid" }}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="eyebrow"
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t(`pages.dashboard.picker.groups.${group}`, {
|
||||||
|
defaultValue: group,
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={card.id}
|
||||||
|
label={
|
||||||
|
<Typography color={theme.palette.text.primary}>
|
||||||
|
{t(`pages.dashboard.cards.${card.key}.title`)}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={chosen.has(card.id)}
|
||||||
|
onChange={() =>
|
||||||
|
chosen.has(card.id) ? onRemove(card.id) : onAdd(card.id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Tooltip } from "@/Components/design-elements";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One thickness for every bar on the dashboard. Plain and segmented bars sit
|
||||||
|
* side by side across cards, so differing weights read as an inconsistency
|
||||||
|
* rather than as emphasis.
|
||||||
|
*/
|
||||||
|
const BAR_HEIGHT = 8;
|
||||||
|
|
||||||
|
export interface BarSegment {
|
||||||
|
key: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
/** Tooltip text, since the segments themselves carry no labels. */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A proportional bar. `value` and `max` share whatever unit the caller uses;
|
||||||
|
* percentages come in as 0-100, not as the API's 0-1 fractions.
|
||||||
|
*/
|
||||||
|
export const CardBar = ({
|
||||||
|
value,
|
||||||
|
max = 100,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
max?: number;
|
||||||
|
color: string;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const ratio = max > 0 ? Math.min(Math.max(value / max, 0), 1) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
width="100%"
|
||||||
|
height={BAR_HEIGHT}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
bgcolor={theme.palette.action.hover}
|
||||||
|
sx={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
width={`${ratio * 100}%`}
|
||||||
|
height="100%"
|
||||||
|
bgcolor={color}
|
||||||
|
sx={{ transition: "width 0.3s ease" }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One bar divided into proportional segments — the shape of a whole split into
|
||||||
|
* parts, where the relative sizes are the point rather than any single value.
|
||||||
|
*
|
||||||
|
* Zero-value segments are dropped so they cannot appear as hairlines, and each
|
||||||
|
* segment carries a tooltip because the bar itself has no room for labels.
|
||||||
|
*/
|
||||||
|
export const CardSegmentedBar = ({ segments }: { segments: BarSegment[] }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const visible = segments.filter((segment) => segment.value > 0);
|
||||||
|
const total = visible.reduce((sum, segment) => sum + segment.value, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
width="100%"
|
||||||
|
height={BAR_HEIGHT}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
bgcolor={theme.palette.action.hover}
|
||||||
|
gap="1px"
|
||||||
|
sx={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
{total > 0 &&
|
||||||
|
visible.map((segment) => (
|
||||||
|
<Tooltip
|
||||||
|
key={segment.key}
|
||||||
|
title={segment.label}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
width={`${(segment.value / total) * 100}%`}
|
||||||
|
height="100%"
|
||||||
|
bgcolor={segment.color}
|
||||||
|
sx={{ transition: "width 0.3s ease" }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A clickable list row. Rows link to the entity they describe. */
|
||||||
|
export const CardRow = ({ to, children }: { to?: string; children: ReactNode }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
{...(to ? { component: RouterLink, to } : {})}
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={theme.spacing(LAYOUT.SM)}
|
||||||
|
px={theme.spacing(LAYOUT.XS)}
|
||||||
|
py={theme.spacing(LAYOUT.XS)}
|
||||||
|
mx={`-${theme.spacing(LAYOUT.XS)}`}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
color="inherit"
|
||||||
|
sx={{
|
||||||
|
textDecoration: "none",
|
||||||
|
...(to && {
|
||||||
|
"&:hover": { backgroundColor: theme.palette.action.hover },
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Name plus optional secondary line, truncating rather than wrapping. */
|
||||||
|
export const CardRowLabel = ({
|
||||||
|
primary,
|
||||||
|
secondary,
|
||||||
|
}: {
|
||||||
|
primary: string;
|
||||||
|
secondary?: string | null;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
minWidth={0}
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
noWrap
|
||||||
|
>
|
||||||
|
{primary}
|
||||||
|
</Typography>
|
||||||
|
{secondary && (
|
||||||
|
<Typography
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
noWrap
|
||||||
|
>
|
||||||
|
{secondary}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A single large figure with a caption — the shape of the stat cards. */
|
||||||
|
export const CardFigure = ({
|
||||||
|
value,
|
||||||
|
caption,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
caption?: string;
|
||||||
|
color?: string;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.xxl}
|
||||||
|
fontWeight={300}
|
||||||
|
color={color ?? theme.palette.text.primary}
|
||||||
|
lineHeight={1.1}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
{caption && <Typography color={theme.palette.text.secondary}>{caption}</Typography>}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A label with its value on the right — the shape almost every stat card
|
||||||
|
* repeats. `color` highlights the value alone, leaving the label quiet.
|
||||||
|
*/
|
||||||
|
export const CardStatLine = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
color?: string;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="baseline"
|
||||||
|
justifyContent="space-between"
|
||||||
|
gap={theme.spacing(LAYOUT.SM)}
|
||||||
|
>
|
||||||
|
<Typography color={theme.palette.text.secondary}>{label}</Typography>
|
||||||
|
<Typography
|
||||||
|
color={color ?? theme.palette.text.primary}
|
||||||
|
noWrap
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A CardStatLine with a proportional bar beneath it — the ranking and
|
||||||
|
* breakdown row. `max` defaults to a percentage scale; pass the largest value
|
||||||
|
* in the set to make bars relative to each other instead.
|
||||||
|
*
|
||||||
|
* `label` may be a node so callers can put a "N down" flag beside the name.
|
||||||
|
*/
|
||||||
|
export const CardMeterRow = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
max,
|
||||||
|
color,
|
||||||
|
trailing,
|
||||||
|
}: {
|
||||||
|
label: ReactNode;
|
||||||
|
value: number;
|
||||||
|
max?: number;
|
||||||
|
color: string;
|
||||||
|
trailing?: ReactNode;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
flex={1}
|
||||||
|
minWidth={0}
|
||||||
|
gap={theme.spacing(LAYOUT.XXS)}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
gap={theme.spacing(LAYOUT.SM)}
|
||||||
|
>
|
||||||
|
{typeof label === "string" ? (
|
||||||
|
<Typography
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
noWrap
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
{trailing}
|
||||||
|
</Stack>
|
||||||
|
<CardBar
|
||||||
|
value={value}
|
||||||
|
max={max}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export const CardSlotContext = createContext<ReactNode>(null);
|
||||||
|
|
||||||
|
export const useCardSlot = (): ReactNode => useContext(CardSlotContext);
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { formatTimestamp } from "@/Utils/TimeUtils";
|
||||||
|
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||||
|
import { CardFigure, CardRow, CardRowLabel, CardStatLine } from "../CardPrimitives";
|
||||||
|
|
||||||
|
import { REFRESH_INTERVAL_MS, SLOW_REFRESH_INTERVAL_MS } from "../../cards";
|
||||||
|
|
||||||
|
import type { QueueMetrics } from "@/Types/Queue";
|
||||||
|
import type { Log } from "@/Types/Log";
|
||||||
|
import type { User } from "@/Types/User";
|
||||||
|
|
||||||
|
const MAX_ERROR_ROWS = 5;
|
||||||
|
|
||||||
|
/** Reads as a single reassurance line while nothing is failing. */
|
||||||
|
export const ChecksOnScheduleCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } = useGet<QueueMetrics>(
|
||||||
|
"/queue/metrics",
|
||||||
|
{},
|
||||||
|
{ refreshInterval: REFRESH_INTERVAL_MS }
|
||||||
|
);
|
||||||
|
|
||||||
|
const healthy = data?.failingJobs === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.checksOnSchedule.title")}
|
||||||
|
to="/logs"
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
>
|
||||||
|
{data && (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{healthy ? (
|
||||||
|
<CardMessage
|
||||||
|
text={t("pages.dashboard.cards.checksOnSchedule.healthy", {
|
||||||
|
jobs: data.jobs,
|
||||||
|
workers: data.workers.length,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CardFigure
|
||||||
|
value={String(data.failingJobs)}
|
||||||
|
caption={t("pages.dashboard.cards.checksOnSchedule.failing")}
|
||||||
|
color={theme.palette.error.main}
|
||||||
|
/>
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||||
|
{data.jobsWithFailures.slice(0, MAX_ERROR_ROWS).map((job) => (
|
||||||
|
<CardRow key={String(job.monitorId)}>
|
||||||
|
<CardRowLabel
|
||||||
|
primary={job.monitorUrl ?? String(job.monitorId)}
|
||||||
|
secondary={job.failReason}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
color={theme.palette.error.main}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
{job.failCount}
|
||||||
|
</Typography>
|
||||||
|
</CardRow>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<CardStatLine
|
||||||
|
label={t("pages.dashboard.cards.checksOnSchedule.scheduled")}
|
||||||
|
value={String(data.jobs)}
|
||||||
|
/>
|
||||||
|
<CardStatLine
|
||||||
|
label={t("pages.dashboard.cards.checksOnSchedule.runningNow")}
|
||||||
|
value={String(data.activeJobs)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /logs is an in-memory ring buffer capped at 1000 entries, wiped on restart,
|
||||||
|
* and it ignores page/rowsPerPage. It rotates fast, so this is deliberately
|
||||||
|
* worded "recent" rather than "all".
|
||||||
|
*/
|
||||||
|
export const RecentErrorsCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } = useGet<Log[]>(
|
||||||
|
"/logs",
|
||||||
|
{},
|
||||||
|
{ refreshInterval: SLOW_REFRESH_INTERVAL_MS }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { errors, warnings, newest } = useMemo(() => {
|
||||||
|
const relevant = (data ?? []).filter(
|
||||||
|
(entry) => entry.level === "error" || entry.level === "warn"
|
||||||
|
);
|
||||||
|
const sorted = [...relevant].sort(
|
||||||
|
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
errors: relevant.filter((entry) => entry.level === "error").length,
|
||||||
|
warnings: relevant.filter((entry) => entry.level === "warn").length,
|
||||||
|
newest: sorted.slice(0, MAX_ERROR_ROWS),
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.recentErrors.title")}
|
||||||
|
to="/logs"
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
action={
|
||||||
|
errors > 0 ? (
|
||||||
|
<Typography color={theme.palette.error.main}>
|
||||||
|
{t("pages.dashboard.cards.recentErrors.count", { count: errors })}
|
||||||
|
</Typography>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{newest.length === 0 ? (
|
||||||
|
<CardMessage text={t("pages.dashboard.cards.recentErrors.empty")} />
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
<CardStatLine
|
||||||
|
label={t("pages.dashboard.cards.recentErrors.warnings")}
|
||||||
|
value={String(warnings)}
|
||||||
|
/>
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||||
|
{newest.map((entry, index) => (
|
||||||
|
<CardRow key={`${entry.timestamp}-${index}`}>
|
||||||
|
<CardRowLabel
|
||||||
|
primary={entry.message}
|
||||||
|
secondary={[entry.service, entry.method].filter(Boolean).join(" · ")}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
color={
|
||||||
|
entry.level === "error"
|
||||||
|
? theme.palette.error.main
|
||||||
|
: theme.palette.warning.main
|
||||||
|
}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
{formatTimestamp(entry.timestamp)}
|
||||||
|
</Typography>
|
||||||
|
</CardRow>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Never empty — at minimum the current user exists. */
|
||||||
|
export const TeamCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } = useGet<User[]>("/auth/users");
|
||||||
|
|
||||||
|
const { total, byRole, unverified } = useMemo(() => {
|
||||||
|
const users = data ?? [];
|
||||||
|
const roles = new Map<string, number>();
|
||||||
|
for (const user of users) {
|
||||||
|
for (const role of user.role ?? []) {
|
||||||
|
roles.set(role, (roles.get(role) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total: users.length,
|
||||||
|
byRole: [...roles.entries()].sort((a, b) => b[1] - a[1]),
|
||||||
|
unverified: users.filter((user) => !user.isVerified).length,
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.team.title")}
|
||||||
|
to="/account/team"
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
>
|
||||||
|
{data && (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
<CardFigure
|
||||||
|
value={String(total)}
|
||||||
|
caption={t("pages.dashboard.cards.team.members", { count: total })}
|
||||||
|
/>
|
||||||
|
{byRole.map(([role, count]) => (
|
||||||
|
<CardStatLine
|
||||||
|
key={role}
|
||||||
|
label={t(`common.auth.roles.${role}`, { defaultValue: role })}
|
||||||
|
value={String(count)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{unverified > 0 && (
|
||||||
|
<CardStatLine
|
||||||
|
label={t("pages.dashboard.cards.team.unverified")}
|
||||||
|
value={String(unverified)}
|
||||||
|
color={theme.palette.warning.main}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||||
|
import { CardMeterRow, CardRow } from "../CardPrimitives";
|
||||||
|
|
||||||
|
export interface BreakdownBucket {
|
||||||
|
/** Stable key and displayed label. */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
total: number;
|
||||||
|
down: number;
|
||||||
|
/** Where the row links to, if anywhere. */
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "How the fleet splits by X" — one proportional row per bucket, flagging any
|
||||||
|
* bucket with something down. Monitors by type and Monitors by group are the
|
||||||
|
* same card over different keys, so they share this shell.
|
||||||
|
*/
|
||||||
|
export const BreakdownCard = ({
|
||||||
|
titleKey,
|
||||||
|
emptyKey,
|
||||||
|
to,
|
||||||
|
buckets,
|
||||||
|
isLoading,
|
||||||
|
isStale,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
titleKey: string;
|
||||||
|
emptyKey: string;
|
||||||
|
to?: string;
|
||||||
|
buckets: BreakdownBucket[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
isStale?: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const max = Math.max(0, ...buckets.map((bucket) => bucket.total));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t(titleKey)}
|
||||||
|
to={to}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
isStale={isStale}
|
||||||
|
>
|
||||||
|
{buckets.length === 0 ? (
|
||||||
|
<CardMessage text={t(emptyKey)} />
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{buckets.map((bucket) => (
|
||||||
|
<CardRow
|
||||||
|
key={bucket.key}
|
||||||
|
to={bucket.to}
|
||||||
|
>
|
||||||
|
<CardMeterRow
|
||||||
|
label={bucket.label}
|
||||||
|
value={bucket.total}
|
||||||
|
max={max}
|
||||||
|
color={
|
||||||
|
bucket.down > 0 ? theme.palette.error.main : theme.palette.primary.main
|
||||||
|
}
|
||||||
|
trailing={
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
{bucket.down > 0 && (
|
||||||
|
<Typography color={theme.palette.error.main}>
|
||||||
|
{t("pages.dashboard.cards.monitorsByType.down", {
|
||||||
|
count: bucket.down,
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography color={theme.palette.text.secondary}>
|
||||||
|
{bucket.total}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardRow>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||||
|
import { CardBar, CardRow, CardRowLabel } from "../CardPrimitives";
|
||||||
|
import { useMonitors } from "../../useDashboardData";
|
||||||
|
|
||||||
|
import { latestCheck } from "../../checks";
|
||||||
|
|
||||||
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
// <60% fine, 60-85% warm, above that hot.
|
||||||
|
const WARM = 60;
|
||||||
|
const HOT = 85;
|
||||||
|
|
||||||
|
interface ServerUsage {
|
||||||
|
monitor: Monitor;
|
||||||
|
cpu: number | null;
|
||||||
|
memory: number | null;
|
||||||
|
disk: number | null;
|
||||||
|
peak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Metric = ({ label, value }: { label: string; value: number | null }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const color =
|
||||||
|
value === null
|
||||||
|
? theme.palette.text.secondary
|
||||||
|
: value > HOT
|
||||||
|
? theme.palette.error.main
|
||||||
|
: value > WARM
|
||||||
|
? theme.palette.warning.main
|
||||||
|
: theme.palette.primary.main;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
flex={1}
|
||||||
|
minWidth={0}
|
||||||
|
gap={theme.spacing(LAYOUT.XXS)}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="baseline"
|
||||||
|
justifyContent="space-between"
|
||||||
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
|
>
|
||||||
|
<Typography color={theme.palette.text.secondary}>{label}</Typography>
|
||||||
|
<Typography color={color}>
|
||||||
|
{/* A down server reports nothing — show a dash, never 0%. */}
|
||||||
|
{value === null ? "—" : `${value.toFixed(1)}%`}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<CardBar
|
||||||
|
value={value ?? 0}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BusiestServersCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } = useMonitors();
|
||||||
|
|
||||||
|
const servers = useMemo<ServerUsage[]>(() => {
|
||||||
|
return (data?.monitors ?? [])
|
||||||
|
.filter((monitor) => monitor.type === "hardware")
|
||||||
|
.map((monitor) => {
|
||||||
|
const latest = latestCheck(monitor);
|
||||||
|
// snake_case here, unlike camelCase in HardwareCheckStats, and
|
||||||
|
// these are 0-1 fractions, so scale to percent.
|
||||||
|
const toPercent = (value: number | undefined) =>
|
||||||
|
typeof value === "number" ? value * 100 : null;
|
||||||
|
const cpu = toPercent(latest?.cpu?.usage_percent);
|
||||||
|
const memory = toPercent(latest?.memory?.usage_percent);
|
||||||
|
const disks = (latest?.disk ?? [])
|
||||||
|
.map((entry) => entry.usage_percent)
|
||||||
|
.filter((value): value is number => typeof value === "number");
|
||||||
|
const disk = disks.length > 0 ? Math.max(...disks) * 100 : null;
|
||||||
|
return {
|
||||||
|
monitor,
|
||||||
|
cpu,
|
||||||
|
memory,
|
||||||
|
disk,
|
||||||
|
peak: Math.max(cpu ?? 0, memory ?? 0, disk ?? 0),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.peak - a.peak);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.busiestServers.title")}
|
||||||
|
to="/infrastructure"
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
>
|
||||||
|
{servers.length === 0 ? (
|
||||||
|
<CardMessage text={t("pages.dashboard.cards.busiestServers.empty")} />
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||||
|
{servers.map(({ monitor, cpu, memory, disk }) => (
|
||||||
|
<CardRow
|
||||||
|
key={monitor.id}
|
||||||
|
to={`/infrastructure/${monitor.id}`}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
flex={1}
|
||||||
|
minWidth={0}
|
||||||
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
|
>
|
||||||
|
<CardRowLabel primary={monitor.name} />
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
gap={theme.spacing(LAYOUT.MD)}
|
||||||
|
>
|
||||||
|
<Metric
|
||||||
|
label={t("pages.dashboard.cards.busiestServers.cpu")}
|
||||||
|
value={cpu}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label={t("pages.dashboard.cards.busiestServers.memory")}
|
||||||
|
value={memory}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label={t("pages.dashboard.cards.busiestServers.disk")}
|
||||||
|
value={disk}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</CardRow>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { formatDuration } from "@/Utils/TimeUtils";
|
||||||
|
import { DashboardCard } from "../DashboardCard";
|
||||||
|
import { CardBar, CardStatLine } from "../CardPrimitives";
|
||||||
|
|
||||||
|
import { REFRESH_INTERVAL_MS } from "../../cards";
|
||||||
|
|
||||||
|
import type { Diagnostics } from "@/Types/Diagnostics";
|
||||||
|
|
||||||
|
const BYTES_PER_MB = 1024 * 1024;
|
||||||
|
|
||||||
|
// Banding from CARD-SPECS: warm at 50ms of event-loop delay, hot at 200ms;
|
||||||
|
// heap warm past 80% of the V8 limit.
|
||||||
|
const EVENT_LOOP_WARM_MS = 50;
|
||||||
|
const EVENT_LOOP_HOT_MS = 200;
|
||||||
|
const HEAP_WARM_RATIO = 0.8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one card that is never empty — it reports on Checkmate itself, so it
|
||||||
|
* works on a fresh install with zero monitors. Admin only.
|
||||||
|
*/
|
||||||
|
export const CheckmateServerCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } = useGet<Diagnostics>(
|
||||||
|
"/diagnostic/system",
|
||||||
|
{},
|
||||||
|
{ refreshInterval: REFRESH_INTERVAL_MS }
|
||||||
|
);
|
||||||
|
|
||||||
|
const usedHeap = data?.v8HeapStats.usedHeapSizeBytes ?? 0;
|
||||||
|
const heapLimit = data?.v8HeapStats.heapSizeLimitBytes ?? 0;
|
||||||
|
const heapRatio = heapLimit > 0 ? usedHeap / heapLimit : 0;
|
||||||
|
const heapColor =
|
||||||
|
heapRatio > HEAP_WARM_RATIO ? theme.palette.warning.main : theme.palette.primary.main;
|
||||||
|
|
||||||
|
const eventLoopMs = data?.eventLoopDelayMs ?? 0;
|
||||||
|
const eventLoopColor =
|
||||||
|
eventLoopMs > EVENT_LOOP_HOT_MS
|
||||||
|
? theme.palette.error.main
|
||||||
|
: eventLoopMs > EVENT_LOOP_WARM_MS
|
||||||
|
? theme.palette.warning.main
|
||||||
|
: theme.palette.text.primary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.checkmateServer.title")}
|
||||||
|
to="/logs"
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
>
|
||||||
|
{data && (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
||||||
|
<CardStatLine
|
||||||
|
label={t("pages.dashboard.cards.checkmateServer.memory")}
|
||||||
|
value={`${Math.round(usedHeap / BYTES_PER_MB)} / ${Math.round(
|
||||||
|
heapLimit / BYTES_PER_MB
|
||||||
|
)} MB`}
|
||||||
|
color={heapColor}
|
||||||
|
/>
|
||||||
|
<CardBar
|
||||||
|
value={heapRatio * 100}
|
||||||
|
color={heapColor}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<CardStatLine
|
||||||
|
label={t("pages.dashboard.cards.checkmateServer.eventLoop")}
|
||||||
|
value={`${eventLoopMs.toFixed(1)} ms`}
|
||||||
|
color={eventLoopColor}
|
||||||
|
/>
|
||||||
|
<CardStatLine
|
||||||
|
label={t("pages.dashboard.cards.checkmateServer.uptime")}
|
||||||
|
value={formatDuration(data.uptimeMs)}
|
||||||
|
/>
|
||||||
|
<CardStatLine
|
||||||
|
label={t("pages.dashboard.cards.checkmateServer.database")}
|
||||||
|
value={t("pages.dashboard.cards.checkmateServer.readsWrites", {
|
||||||
|
reads: data.mongoStats.readsPerSecond.toFixed(1),
|
||||||
|
writes: data.mongoStats.writesPerSecond.toFixed(1),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { Dot } from "@/Components/design-elements";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { getStatusColor, getMonitorPath } from "@/Utils/MonitorUtils";
|
||||||
|
import { formatStatusCode } from "@/Utils/statusCode";
|
||||||
|
import { formatDuration } from "@/Utils/TimeUtils";
|
||||||
|
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||||
|
import { CardRow } from "../CardPrimitives";
|
||||||
|
import { Sparkline } from "../Sparkline";
|
||||||
|
import { useMonitors } from "../../useDashboardData";
|
||||||
|
|
||||||
|
import { latestCheck } from "../../checks";
|
||||||
|
|
||||||
|
import type { Monitor, MonitorStatus } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
const SPARKLINE_WIDTH = 80;
|
||||||
|
|
||||||
|
const STATUS_RANK: Partial<Record<MonitorStatus, number>> = {
|
||||||
|
down: 1,
|
||||||
|
breached: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isProblem = (monitor: Monitor) => STATUS_RANK[monitor.status] !== undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the monitor has been down, from the oldest consecutive failing
|
||||||
|
* check. recentChecks is oldest-first, so the run is walked back from the end.
|
||||||
|
*/
|
||||||
|
const downSince = (monitor: Monitor): string | null => {
|
||||||
|
const checks = monitor.recentChecks ?? [];
|
||||||
|
let oldestFailing: string | null = null;
|
||||||
|
for (let index = checks.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (checks[index].status !== false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
oldestFailing = checks[index].createdAt;
|
||||||
|
}
|
||||||
|
return oldestFailing;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CurrentlyDownCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } = useMonitors();
|
||||||
|
|
||||||
|
const problems = useMemo(() => {
|
||||||
|
const monitors = data?.monitors ?? [];
|
||||||
|
return monitors
|
||||||
|
.filter(isProblem)
|
||||||
|
.map((monitor) => ({ monitor, since: downSince(monitor) }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const rank =
|
||||||
|
(STATUS_RANK[a.monitor.status] ?? 99) - (STATUS_RANK[b.monitor.status] ?? 99);
|
||||||
|
if (rank !== 0) {
|
||||||
|
return rank;
|
||||||
|
}
|
||||||
|
// Tie-break on longest downtime: the older timestamp ranks first.
|
||||||
|
return new Date(a.since ?? 0).getTime() - new Date(b.since ?? 0).getTime();
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.currentlyDown.title")}
|
||||||
|
to="/incidents"
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
action={
|
||||||
|
problems.length > 0 ? (
|
||||||
|
<Typography color={theme.palette.error.main}>
|
||||||
|
{t("pages.dashboard.cards.currentlyDown.count", {
|
||||||
|
count: problems.length,
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{problems.length === 0 ? (
|
||||||
|
<CardMessage text={t("pages.dashboard.cards.currentlyDown.empty")} />
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||||
|
{problems.map(({ monitor, since }) => (
|
||||||
|
<CardRow
|
||||||
|
key={monitor.id}
|
||||||
|
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
||||||
|
>
|
||||||
|
<Dot
|
||||||
|
color={getStatusColor(monitor.status, theme)}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<Stack
|
||||||
|
flex={1}
|
||||||
|
minWidth={0}
|
||||||
|
gap={theme.spacing(LAYOUT.XXS)}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="baseline"
|
||||||
|
justifyContent="space-between"
|
||||||
|
gap={theme.spacing(LAYOUT.SM)}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
noWrap
|
||||||
|
>
|
||||||
|
{monitor.name}
|
||||||
|
</Typography>
|
||||||
|
{since && (
|
||||||
|
<Typography
|
||||||
|
color={theme.palette.error.main}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
{formatDuration(Date.now() - new Date(since).getTime())}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
gap={theme.spacing(LAYOUT.SM)}
|
||||||
|
>
|
||||||
|
{/* Not StatusCodeLabel: its Tooltip is focusable, and a
|
||||||
|
focusable element inside the row's link is unreachable
|
||||||
|
by keyboard. */}
|
||||||
|
<Typography
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
noWrap
|
||||||
|
>
|
||||||
|
{monitor.type} ·{" "}
|
||||||
|
{formatStatusCode(latestCheck(monitor)?.statusCode, t)}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
width={SPARKLINE_WIDTH}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
<Sparkline
|
||||||
|
checks={monitor.recentChecks ?? []}
|
||||||
|
color={theme.palette.error.main}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</CardRow>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||||
|
import { CardBar, CardFigure } from "../CardPrimitives";
|
||||||
|
|
||||||
|
import type { ChecksSummary } from "@/Types/Check";
|
||||||
|
|
||||||
|
// Day only. `dateRange=week` returns figures identical to `day`, so a range
|
||||||
|
// selector here would silently lie.
|
||||||
|
const SUMMARY_URL = "/checks/team/summary?dateRange=day";
|
||||||
|
|
||||||
|
export const FailedChecksCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } = useGet<ChecksSummary>(SUMMARY_URL);
|
||||||
|
|
||||||
|
const total = data?.totalChecks ?? 0;
|
||||||
|
const failed = data?.downChecks ?? 0;
|
||||||
|
const percentage = total > 0 ? (failed / total) * 100 : 0;
|
||||||
|
const color = percentage > 10 ? theme.palette.error.main : theme.palette.success.main;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.failedChecks.title")}
|
||||||
|
to="/checks"
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
>
|
||||||
|
{total === 0 ? (
|
||||||
|
<CardMessage text={t("pages.dashboard.cards.failedChecks.empty")} />
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
<CardFigure
|
||||||
|
value={`${percentage.toFixed(1)}%`}
|
||||||
|
caption={t("pages.dashboard.cards.failedChecks.ratio", {
|
||||||
|
failed: failed.toLocaleString(),
|
||||||
|
total: total.toLocaleString(),
|
||||||
|
})}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
<CardBar
|
||||||
|
value={percentage}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Dot } from "@/Components/design-elements";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { getStatusColor } from "@/Utils/MonitorUtils";
|
||||||
|
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||||
|
import { CardSegmentedBar, type BarSegment } from "../CardPrimitives";
|
||||||
|
import { useMonitors } from "../../useDashboardData";
|
||||||
|
|
||||||
|
import type { MonitorStatus, MonitorsSummary } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
// Every state the summary reports, ordered worst first so the bar reads
|
||||||
|
// problems-to-healthy left to right and the chips below list what is wrong
|
||||||
|
// before what is merely idle. `up` is handled separately as the headline.
|
||||||
|
const STATES: { key: string; status: MonitorStatus; field: keyof MonitorsSummary }[] = [
|
||||||
|
{ key: "down", status: "down", field: "downMonitors" },
|
||||||
|
{ key: "breached", status: "breached", field: "breachedMonitors" },
|
||||||
|
{ key: "initializing", status: "initializing", field: "initializingMonitors" },
|
||||||
|
{ key: "maintenance", status: "maintenance", field: "maintenanceMonitors" },
|
||||||
|
{ key: "paused", status: "paused", field: "pausedMonitors" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MonitorStatusCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } = useMonitors();
|
||||||
|
const summary = data?.summary ?? null;
|
||||||
|
|
||||||
|
// Only states that actually have monitors are shown. Padding the card with
|
||||||
|
// zeros says nothing, and a healthy fleet should read as calm rather than as
|
||||||
|
// a row of empty counters.
|
||||||
|
const present = summary
|
||||||
|
? STATES.map((state) => ({ ...state, count: summary[state.field] })).filter(
|
||||||
|
(state) => state.count > 0
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const segments: BarSegment[] = summary
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "up",
|
||||||
|
value: summary.upMonitors,
|
||||||
|
color: getStatusColor("up", theme),
|
||||||
|
label: `${t("pages.common.monitors.status.up")} — ${summary.upMonitors}`,
|
||||||
|
},
|
||||||
|
...present.map((state) => ({
|
||||||
|
key: state.key,
|
||||||
|
value: state.count,
|
||||||
|
color: getStatusColor(state.status, theme),
|
||||||
|
label: `${t(`pages.common.monitors.status.${state.key}`)} — ${state.count}`,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.monitorStatus.title")}
|
||||||
|
to="/uptime"
|
||||||
|
isLoading={isLoading && !summary}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
>
|
||||||
|
{!summary || summary.totalMonitors === 0 ? (
|
||||||
|
<CardMessage text={t("pages.dashboard.cards.monitorStatus.empty")} />
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="baseline"
|
||||||
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.xxl}
|
||||||
|
fontWeight={300}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
lineHeight={1.1}
|
||||||
|
>
|
||||||
|
{summary.upMonitors}
|
||||||
|
</Typography>
|
||||||
|
<Typography color={theme.palette.text.secondary}>
|
||||||
|
{t("pages.dashboard.cards.monitorStatus.upOfTotal", {
|
||||||
|
total: summary.totalMonitors,
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<CardSegmentedBar segments={segments} />
|
||||||
|
</Stack>
|
||||||
|
{present.length > 0 && (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
flexWrap="wrap"
|
||||||
|
gap={theme.spacing(LAYOUT.MD)}
|
||||||
|
>
|
||||||
|
{present.map((state) => (
|
||||||
|
<Stack
|
||||||
|
key={state.key}
|
||||||
|
component={RouterLink}
|
||||||
|
// Plain /uptime: the monitors list does not read query
|
||||||
|
// params, so a ?status= filter would be silently dropped
|
||||||
|
// and the link would lie about where it goes.
|
||||||
|
to="/uptime"
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
|
px={theme.spacing(LAYOUT.XS)}
|
||||||
|
py={theme.spacing(LAYOUT.XXS)}
|
||||||
|
mx={`-${theme.spacing(LAYOUT.XS)}`}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
sx={{
|
||||||
|
textDecoration: "none",
|
||||||
|
"&:hover": { backgroundColor: theme.palette.action.hover },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dot
|
||||||
|
color={getStatusColor(state.status, theme)}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<Typography color={theme.palette.text.secondary}>
|
||||||
|
{t(`pages.common.monitors.status.${state.key}`)}
|
||||||
|
</Typography>
|
||||||
|
<Typography color={theme.palette.text.primary}>
|
||||||
|
{state.count}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard";
|
||||||
|
import { useMonitors } from "../../useDashboardData";
|
||||||
|
|
||||||
|
export const MonitorsByGroupCard = () => {
|
||||||
|
const { data, isLoading, isValidating, error } = useMonitors();
|
||||||
|
|
||||||
|
// `group` is a plain string | null on the monitor — there is no groups
|
||||||
|
// endpoint, so the set is whatever monitors actually declare.
|
||||||
|
const buckets = useMemo<BreakdownBucket[]>(() => {
|
||||||
|
const byGroup = new Map<string, BreakdownBucket>();
|
||||||
|
for (const monitor of data?.monitors ?? []) {
|
||||||
|
if (!monitor.group) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bucket = byGroup.get(monitor.group) ?? {
|
||||||
|
key: monitor.group,
|
||||||
|
label: monitor.group,
|
||||||
|
total: 0,
|
||||||
|
down: 0,
|
||||||
|
};
|
||||||
|
bucket.total += 1;
|
||||||
|
if (monitor.status === "down" || monitor.status === "breached") {
|
||||||
|
bucket.down += 1;
|
||||||
|
}
|
||||||
|
byGroup.set(monitor.group, bucket);
|
||||||
|
}
|
||||||
|
return [...byGroup.values()].sort((a, b) => b.total - a.total);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BreakdownCard
|
||||||
|
titleKey="pages.dashboard.cards.monitorsByGroup.title"
|
||||||
|
emptyKey="pages.dashboard.cards.monitorsByGroup.empty"
|
||||||
|
to="/uptime"
|
||||||
|
buckets={buckets}
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { getMonitorPath } from "@/Utils/MonitorUtils";
|
||||||
|
import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard";
|
||||||
|
import { useMonitors } from "../../useDashboardData";
|
||||||
|
|
||||||
|
import type { MonitorType } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
export const MonitorsByTypeCard = () => {
|
||||||
|
const { data, isLoading, isValidating, error } = useMonitors();
|
||||||
|
|
||||||
|
const buckets = useMemo<BreakdownBucket[]>(() => {
|
||||||
|
const byType = new Map<MonitorType, BreakdownBucket>();
|
||||||
|
for (const monitor of data?.monitors ?? []) {
|
||||||
|
const bucket = byType.get(monitor.type) ?? {
|
||||||
|
key: monitor.type,
|
||||||
|
label: monitor.type,
|
||||||
|
total: 0,
|
||||||
|
down: 0,
|
||||||
|
to: `/${getMonitorPath(monitor.type)}`,
|
||||||
|
};
|
||||||
|
bucket.total += 1;
|
||||||
|
if (monitor.status === "down" || monitor.status === "breached") {
|
||||||
|
bucket.down += 1;
|
||||||
|
}
|
||||||
|
byType.set(monitor.type, bucket);
|
||||||
|
}
|
||||||
|
return [...byType.values()].sort((a, b) => b.total - a.total);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BreakdownCard
|
||||||
|
titleKey="pages.dashboard.cards.monitorsByType.title"
|
||||||
|
emptyKey="pages.dashboard.cards.monitorsByType.empty"
|
||||||
|
to="/uptime"
|
||||||
|
buckets={buckets}
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { getMonitorPath, getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
||||||
|
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||||
|
import { CardMeterRow, CardRow, CardRowLabel } from "../CardPrimitives";
|
||||||
|
import { useMonitors } from "../../useDashboardData";
|
||||||
|
|
||||||
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
const TOP_N = 5;
|
||||||
|
// A ranking of one is not a ranking.
|
||||||
|
const MIN_RANKABLE = 2;
|
||||||
|
|
||||||
|
const averageResponseTime = (monitor: Monitor): number | null => {
|
||||||
|
const times = (monitor.recentChecks ?? [])
|
||||||
|
.map((check) => check.responseTime)
|
||||||
|
.filter((value): value is number => typeof value === "number");
|
||||||
|
if (times.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return times.reduce((sum, value) => sum + value, 0) / times.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Header reads "top 5 of 12" so a truncated ranking says so. */
|
||||||
|
const RankingCount = ({ shown, total }: { shown: number; total: number }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Typography color={theme.palette.text.secondary}>
|
||||||
|
{t("pages.dashboard.cards.ranking.topOf", { shown, total })}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SlowestMonitorsCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } = useMonitors();
|
||||||
|
|
||||||
|
const ranked = useMemo(() => {
|
||||||
|
return (data?.monitors ?? [])
|
||||||
|
.filter((monitor) => monitor.status !== "paused")
|
||||||
|
.map((monitor) => ({ monitor, average: averageResponseTime(monitor) }))
|
||||||
|
.filter(
|
||||||
|
(entry): entry is { monitor: Monitor; average: number } => entry.average !== null
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.average - a.average);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const slowest = ranked[0]?.average ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.slowestMonitors.title")}
|
||||||
|
to="/uptime"
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
action={
|
||||||
|
ranked.length >= MIN_RANKABLE ? (
|
||||||
|
<RankingCount
|
||||||
|
shown={Math.min(TOP_N, ranked.length)}
|
||||||
|
total={ranked.length}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ranked.length < MIN_RANKABLE ? (
|
||||||
|
<CardMessage text={t("pages.dashboard.cards.ranking.notEnough")} />
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{ranked.slice(0, TOP_N).map(({ monitor, average }) => (
|
||||||
|
<CardRow
|
||||||
|
key={monitor.id}
|
||||||
|
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
||||||
|
>
|
||||||
|
<CardMeterRow
|
||||||
|
label={<CardRowLabel primary={monitor.name} />}
|
||||||
|
value={average}
|
||||||
|
max={slowest}
|
||||||
|
color={theme.palette.primary.main}
|
||||||
|
trailing={
|
||||||
|
<Typography
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
{Math.round(average)} ms
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardRow>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LowestUptimeCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } = useMonitors();
|
||||||
|
|
||||||
|
const ranked = useMemo(() => {
|
||||||
|
return (
|
||||||
|
(data?.monitors ?? [])
|
||||||
|
// Paused monitors keep whatever uptime they had, or zero — a paused
|
||||||
|
// ping monitor reads 0% and would rank beside a genuinely down one.
|
||||||
|
// Neither is a live problem, so neither belongs in a worst-uptime list.
|
||||||
|
.filter((monitor) => monitor.status !== "paused")
|
||||||
|
.filter(
|
||||||
|
(monitor): monitor is Monitor & { uptimePercentage: number } =>
|
||||||
|
typeof monitor.uptimePercentage === "number"
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.uptimePercentage - b.uptimePercentage)
|
||||||
|
);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.lowestUptime.title")}
|
||||||
|
to="/uptime"
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
action={
|
||||||
|
ranked.length >= MIN_RANKABLE ? (
|
||||||
|
<RankingCount
|
||||||
|
shown={Math.min(TOP_N, ranked.length)}
|
||||||
|
total={ranked.length}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ranked.length < MIN_RANKABLE ? (
|
||||||
|
<CardMessage text={t("pages.dashboard.cards.ranking.notEnough")} />
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{ranked.slice(0, TOP_N).map((monitor) => {
|
||||||
|
// uptimePercentage is a 0-1 fraction, not 0-100.
|
||||||
|
const percentage = monitor.uptimePercentage * 100;
|
||||||
|
const palette = getUptimePercentageColor(monitor.uptimePercentage);
|
||||||
|
return (
|
||||||
|
<CardRow
|
||||||
|
key={monitor.id}
|
||||||
|
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
||||||
|
>
|
||||||
|
<CardMeterRow
|
||||||
|
label={<CardRowLabel primary={monitor.name} />}
|
||||||
|
// The card ranks worst uptime first, so the bar shows the
|
||||||
|
// shortfall - the worst monitor gets the longest bar.
|
||||||
|
value={100 - percentage}
|
||||||
|
color={theme.palette[palette].main}
|
||||||
|
trailing={
|
||||||
|
<Typography
|
||||||
|
color={theme.palette[palette].main}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
{percentage.toFixed(1)}%
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
|
import { Dot } from "@/Components/design-elements";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils";
|
||||||
|
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||||
|
import { CardRow, CardRowLabel } from "../CardPrimitives";
|
||||||
|
|
||||||
|
import type { IncidentSummary, IncidentSummaryItem } from "@/Types/Incident";
|
||||||
|
|
||||||
|
const MAX_ROWS = 5;
|
||||||
|
|
||||||
|
/** Open incidents have no end time; closed ones report how long they ran. */
|
||||||
|
const incidentDuration = (incident: IncidentSummaryItem): string | null => {
|
||||||
|
if (!incident.endTime) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ms =
|
||||||
|
new Date(incident.endTime).getTime() - new Date(incident.startTime).getTime();
|
||||||
|
return ms > 0 ? formatDuration(ms) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RecentIncidentsCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } = useGet<IncidentSummary>(
|
||||||
|
"/incidents/team/summary"
|
||||||
|
);
|
||||||
|
|
||||||
|
const incidents = (data?.latestIncidents ?? []).slice(0, MAX_ROWS);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.recentIncidents.title")}
|
||||||
|
to="/incidents"
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
action={
|
||||||
|
data && data.totalActive > 0 ? (
|
||||||
|
<Typography color={theme.palette.error.main}>
|
||||||
|
{t("pages.dashboard.cards.recentIncidents.active", {
|
||||||
|
count: data.totalActive,
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{incidents.length === 0 ? (
|
||||||
|
<CardMessage text={t("pages.dashboard.cards.recentIncidents.empty")} />
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||||
|
{incidents.map((incident) => {
|
||||||
|
const isOpen = incident.status === true;
|
||||||
|
const duration = incidentDuration(incident);
|
||||||
|
return (
|
||||||
|
<CardRow
|
||||||
|
key={incident.id}
|
||||||
|
to="/incidents"
|
||||||
|
>
|
||||||
|
<Dot
|
||||||
|
color={isOpen ? theme.palette.error.light : theme.palette.success.light}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<CardRowLabel
|
||||||
|
primary={
|
||||||
|
incident.monitorName ??
|
||||||
|
t("pages.dashboard.cards.recentIncidents.unknownMonitor")
|
||||||
|
}
|
||||||
|
secondary={formatTimestamp(incident.startTime)}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{isOpen
|
||||||
|
? t("pages.dashboard.cards.recentIncidents.ongoing")
|
||||||
|
: (duration ?? "—")}
|
||||||
|
</Typography>
|
||||||
|
{incident.resolutionType === "manual" && (
|
||||||
|
<Typography
|
||||||
|
color={theme.palette.warning.main}
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.cards.recentIncidents.manual")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
|
import { Dot } from "@/Components/design-elements";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { formatTimestamp } from "@/Utils/TimeUtils";
|
||||||
|
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||||
|
import {
|
||||||
|
CardFigure,
|
||||||
|
CardRow,
|
||||||
|
CardRowLabel,
|
||||||
|
CardSegmentedBar,
|
||||||
|
CardStatLine,
|
||||||
|
} from "../CardPrimitives";
|
||||||
|
|
||||||
|
import type { IncidentSummary } from "@/Types/Incident";
|
||||||
|
import type { Notification } from "@/Types/Notification";
|
||||||
|
import type { MaintenanceWindow } from "@/Types/MaintenanceWindow";
|
||||||
|
import type { StatusPage } from "@/Types/StatusPage";
|
||||||
|
|
||||||
|
/** Free when Recent incidents is present — same summary call, shared by SWR. */
|
||||||
|
export const IncidentStatsCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } = useGet<IncidentSummary>(
|
||||||
|
"/incidents/team/summary"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.incidentStats.title")}
|
||||||
|
to="/incidents"
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
>
|
||||||
|
{data && (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||||
|
{/* Open now leads, paired with the all-time total for context: two
|
||||||
|
open means something different against 12 incidents than 183. */}
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
gap={theme.spacing(LAYOUT.XXL)}
|
||||||
|
>
|
||||||
|
<CardFigure
|
||||||
|
value={String(data.totalActive)}
|
||||||
|
caption={t("pages.dashboard.cards.incidentStats.openNow")}
|
||||||
|
color={
|
||||||
|
data.totalActive > 0
|
||||||
|
? theme.palette.error.main
|
||||||
|
: theme.palette.success.main
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardFigure
|
||||||
|
value={data.total.toLocaleString()}
|
||||||
|
caption={t("pages.dashboard.cards.incidentStats.total")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
{/* How much of the fleet heals itself — the automatic/manual split
|
||||||
|
is already computed server-side and said nothing on the card
|
||||||
|
before. A high manual share means people are doing the work. */}
|
||||||
|
{data.totalAutomaticResolutions + data.totalManualResolutions > 0 && (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||||
|
<CardSegmentedBar
|
||||||
|
segments={[
|
||||||
|
{
|
||||||
|
key: "automatic",
|
||||||
|
value: data.totalAutomaticResolutions,
|
||||||
|
color: theme.palette.success.main,
|
||||||
|
label: `${t("pages.dashboard.cards.incidentStats.automatic")} — ${data.totalAutomaticResolutions}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "manual",
|
||||||
|
value: data.totalManualResolutions,
|
||||||
|
color: theme.palette.warning.main,
|
||||||
|
label: `${t("pages.dashboard.cards.incidentStats.manual")} — ${data.totalManualResolutions}`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<CardStatLine
|
||||||
|
label={t("pages.dashboard.cards.incidentStats.resolved")}
|
||||||
|
value={t("pages.dashboard.cards.incidentStats.autoManual", {
|
||||||
|
automatic: data.totalAutomaticResolutions,
|
||||||
|
manual: data.totalManualResolutions,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||||
|
<CardStatLine
|
||||||
|
label={t("pages.dashboard.cards.incidentStats.avgResolution")}
|
||||||
|
value={t("pages.dashboard.cards.incidentStats.hours", {
|
||||||
|
hours: data.avgResolutionTimeHours.toFixed(1),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{data.topMonitor?.monitorName && (
|
||||||
|
<CardStatLine
|
||||||
|
label={t("pages.dashboard.cards.incidentStats.mostIncidents")}
|
||||||
|
value={`${data.topMonitor.monitorName} (${data.topMonitor.incidentCount})`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NotificationChannelsCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } =
|
||||||
|
useGet<Notification[]>("/notifications/team");
|
||||||
|
|
||||||
|
// The response also carries accessToken and accountSid. Only `type` is read
|
||||||
|
// here — credential fields must never reach the DOM.
|
||||||
|
const byType = useMemo(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const notification of data ?? []) {
|
||||||
|
counts.set(notification.type, (counts.get(notification.type) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.notificationChannels.title")}
|
||||||
|
to="/notifications"
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
>
|
||||||
|
{byType.length === 0 ? (
|
||||||
|
<CardMessage text={t("pages.dashboard.cards.notificationChannels.empty")} />
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||||
|
{byType.map(([type, count]) => (
|
||||||
|
<CardStatLine
|
||||||
|
key={type}
|
||||||
|
label={type}
|
||||||
|
value={String(count)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MaintenanceCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } = useGet<{
|
||||||
|
maintenanceWindows: MaintenanceWindow[];
|
||||||
|
maintenanceWindowCount: number;
|
||||||
|
}>("/maintenance-window/team?page=0&rowsPerPage=0");
|
||||||
|
|
||||||
|
// `active` is a user on/off flag, not "running now" — compare the window
|
||||||
|
// client-side. repeat > 0 means recurring, so `end` may sit in the past for
|
||||||
|
// a window that is currently live.
|
||||||
|
const { running, next } = useMemo(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const windows = (data?.maintenanceWindows ?? []).filter((entry) => entry.active);
|
||||||
|
const live = windows.filter((entry) => {
|
||||||
|
const start = new Date(entry.start).getTime();
|
||||||
|
const end = new Date(entry.end).getTime();
|
||||||
|
return start <= now && (now <= end || entry.repeat > 0);
|
||||||
|
});
|
||||||
|
const upcoming = windows
|
||||||
|
.filter((entry) => new Date(entry.start).getTime() > now)
|
||||||
|
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||||
|
return { running: live.length, next: upcoming[0] ?? null };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.maintenance.title")}
|
||||||
|
to="/maintenance"
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
>
|
||||||
|
{!data || data.maintenanceWindows.length === 0 ? (
|
||||||
|
<CardMessage text={t("pages.dashboard.cards.maintenance.empty")} />
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
<CardFigure
|
||||||
|
value={String(running)}
|
||||||
|
caption={t("pages.dashboard.cards.maintenance.runningNow")}
|
||||||
|
/>
|
||||||
|
{next && (
|
||||||
|
<CardStatLine
|
||||||
|
label={t("pages.dashboard.cards.maintenance.next")}
|
||||||
|
value={formatTimestamp(next.start)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StatusPagesCard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isValidating, error } =
|
||||||
|
useGet<StatusPage[]>("/status-page/team");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.statusPages.title")}
|
||||||
|
to="/status"
|
||||||
|
isLoading={isLoading && !data}
|
||||||
|
error={error}
|
||||||
|
isStale={isValidating && Boolean(data)}
|
||||||
|
>
|
||||||
|
{!data || data.length === 0 ? (
|
||||||
|
<CardMessage text={t("pages.dashboard.cards.statusPages.empty")} />
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||||
|
{data.map((page) => (
|
||||||
|
<CardRow
|
||||||
|
key={page.id}
|
||||||
|
to={`/status/${page.url}`}
|
||||||
|
>
|
||||||
|
<Dot
|
||||||
|
color={
|
||||||
|
page.isPublished
|
||||||
|
? theme.palette.success.light
|
||||||
|
: theme.palette.text.secondary
|
||||||
|
}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<CardRowLabel
|
||||||
|
primary={page.companyName}
|
||||||
|
secondary={t("pages.dashboard.cards.statusPages.monitorCount", {
|
||||||
|
count: page.monitors.length,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
{page.isPublished
|
||||||
|
? t("pages.dashboard.cards.statusPages.published")
|
||||||
|
: t("pages.dashboard.cards.statusPages.draft")}
|
||||||
|
</Typography>
|
||||||
|
</CardRow>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import Skeleton from "@mui/material/Skeleton";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { BaseBox } from "@/Components/design-elements";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { useCardSlot } from "./CardSlotContext";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Control height plus the header's vertical padding, so a card with no action
|
||||||
|
// still lines up with one that has a button in its header.
|
||||||
|
const HEADER_MIN_HEIGHT = 44;
|
||||||
|
const SKELETON_ROW_HEIGHT = 20;
|
||||||
|
|
||||||
|
interface DashboardCardProps {
|
||||||
|
title: string;
|
||||||
|
/** Where the title links to — the page this card belongs to. */
|
||||||
|
to?: string;
|
||||||
|
/** Rendered at the right of the header: a count, an average, a filter. */
|
||||||
|
action?: ReactNode;
|
||||||
|
isLoading?: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
/** Dimmed rather than blanked while a refresh is in flight over stale data. */
|
||||||
|
isStale?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardCard = ({
|
||||||
|
title,
|
||||||
|
to,
|
||||||
|
action,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isStale,
|
||||||
|
children,
|
||||||
|
}: DashboardCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const slot = useCardSlot();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseBox
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
height="100%"
|
||||||
|
sx={{
|
||||||
|
overflow: "hidden",
|
||||||
|
"&:hover .dashboard-card-slot": {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
gap={theme.spacing(LAYOUT.SM)}
|
||||||
|
px={theme.spacing(LAYOUT.MD)}
|
||||||
|
py={theme.spacing(LAYOUT.SM)}
|
||||||
|
borderBottom={`1px solid ${theme.palette.divider}`}
|
||||||
|
minHeight={HEADER_MIN_HEIGHT}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
{...(to ? { component: RouterLink, to } : {})}
|
||||||
|
fontWeight={500}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
sx={{
|
||||||
|
textDecoration: "none",
|
||||||
|
...(to && {
|
||||||
|
"&:hover": { color: theme.palette.primary.main },
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={theme.spacing(LAYOUT.SM)}
|
||||||
|
flexShrink={0}
|
||||||
|
position="relative"
|
||||||
|
>
|
||||||
|
{isStale && (
|
||||||
|
<CircularProgress
|
||||||
|
size={16}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{action && <Box>{action}</Box>}
|
||||||
|
{slot && (
|
||||||
|
<Box
|
||||||
|
className="dashboard-card-slot"
|
||||||
|
sx={{
|
||||||
|
opacity: 0.3,
|
||||||
|
transition: "opacity 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{slot}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
<Box
|
||||||
|
flex={1}
|
||||||
|
p={theme.spacing(LAYOUT.MD)}
|
||||||
|
sx={{
|
||||||
|
...(isStale && { opacity: 0.5, transition: "opacity 0.2s ease" }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<CardSkeleton />
|
||||||
|
) : error ? (
|
||||||
|
<CardMessage text={t("pages.dashboard.card.error")} />
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</BaseBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Placeholder rows, sized to the row list most cards render. */
|
||||||
|
export const CardSkeleton = ({ rows = 3 }: { rows?: number }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
return (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{Array.from({ length: rows }).map((_, index) => (
|
||||||
|
<Skeleton
|
||||||
|
key={index}
|
||||||
|
variant="rounded"
|
||||||
|
height={SKELETON_ROW_HEIGHT}
|
||||||
|
width={index === rows - 1 ? "60%" : "100%"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The quiet one-line state a healthy card collapses to. */
|
||||||
|
export const CardMessage = ({ text, action }: { text: string; action?: ReactNode }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
gap={theme.spacing(LAYOUT.SM)}
|
||||||
|
>
|
||||||
|
<Typography color={theme.palette.text.secondary}>{text}</Typography>
|
||||||
|
{action}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import type { CheckSnapshot } from "@/Types/Check";
|
||||||
|
|
||||||
|
const VIEWBOX_WIDTH = 100;
|
||||||
|
// The svg renders at its viewBox height, so one constant serves both.
|
||||||
|
const VIEWBOX_HEIGHT = 24;
|
||||||
|
// Matches the 1.5 stroke the shared Icon component uses.
|
||||||
|
const STROKE_WIDTH = 1.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response times as a bare polyline. Deliberately axis-free and label-free —
|
||||||
|
* at this size the shape is the only readable signal.
|
||||||
|
*
|
||||||
|
* `checks` arrives oldest-first, which is already left-to-right reading order.
|
||||||
|
*/
|
||||||
|
export const Sparkline = ({
|
||||||
|
checks,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
checks: CheckSnapshot[];
|
||||||
|
color: string;
|
||||||
|
}) => {
|
||||||
|
const points = useMemo(() => {
|
||||||
|
const values = checks
|
||||||
|
.map((check) => check.responseTime)
|
||||||
|
.filter((value): value is number => typeof value === "number");
|
||||||
|
|
||||||
|
if (values.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const span = max - min || 1;
|
||||||
|
|
||||||
|
return values
|
||||||
|
.map((value, index) => {
|
||||||
|
const x = (index / (values.length - 1)) * VIEWBOX_WIDTH;
|
||||||
|
const y = VIEWBOX_HEIGHT - ((value - min) / span) * VIEWBOX_HEIGHT;
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}, [checks]);
|
||||||
|
|
||||||
|
if (!points) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="svg"
|
||||||
|
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
width="100%"
|
||||||
|
height={VIEWBOX_HEIGHT}
|
||||||
|
display="block"
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
points={points}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={STROKE_WIDTH}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
|
// The 12-column grid the dashboard lays cards out on.
|
||||||
|
//
|
||||||
|
// Every card is currently half-width, so they pair up down the page and the
|
||||||
|
// grid reads as one system. Full width stays in the type for a card whose
|
||||||
|
// content genuinely needs it. Width is declared per card type in code and is
|
||||||
|
// never a user setting.
|
||||||
|
export const CardWidths = [6, 12] as const;
|
||||||
|
export type CardWidth = (typeof CardWidths)[number];
|
||||||
|
|
||||||
|
export const GRID_COLUMNS = 12;
|
||||||
|
|
||||||
|
// Picker groups mirror the sidebar sections: you look for a card where you
|
||||||
|
// look for the feature it belongs to.
|
||||||
|
export const CardGroups = [
|
||||||
|
"uptime",
|
||||||
|
"infrastructure",
|
||||||
|
"incidents",
|
||||||
|
"notifications",
|
||||||
|
"maintenance",
|
||||||
|
"statusPages",
|
||||||
|
"checks",
|
||||||
|
"logs",
|
||||||
|
"settings",
|
||||||
|
] as const;
|
||||||
|
export type CardGroup = (typeof CardGroups)[number];
|
||||||
|
|
||||||
|
// The Containers card (CARD-SPECS.md #11) is deliberately absent: it needs
|
||||||
|
// `containerSummary` on the check snapshot, which arrives with the Docker
|
||||||
|
// monitoring work. Add it here once that lands on develop.
|
||||||
|
export const CardIds = [
|
||||||
|
"monitorStatus",
|
||||||
|
"currentlyDown",
|
||||||
|
"slowestMonitors",
|
||||||
|
"lowestUptime",
|
||||||
|
"monitorsByType",
|
||||||
|
"monitorsByGroup",
|
||||||
|
"recentIncidents",
|
||||||
|
"incidentStats",
|
||||||
|
"notificationChannels",
|
||||||
|
"maintenance",
|
||||||
|
"statusPages",
|
||||||
|
"failedChecks",
|
||||||
|
"busiestServers",
|
||||||
|
"checkmateServer",
|
||||||
|
"checksOnSchedule",
|
||||||
|
"recentErrors",
|
||||||
|
"team",
|
||||||
|
] as const;
|
||||||
|
export type CardId = (typeof CardIds)[number];
|
||||||
|
|
||||||
|
export interface CardDefinition {
|
||||||
|
id: CardId;
|
||||||
|
/** i18n key fragment under pages.dashboard.cards.<key>. */
|
||||||
|
key: string;
|
||||||
|
group: CardGroup;
|
||||||
|
width: CardWidth;
|
||||||
|
/** Hidden from the page and absent from the picker for non-admins. */
|
||||||
|
adminOnly?: boolean;
|
||||||
|
component: ComponentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cards a fresh dashboard starts with. Chosen so a brand new install still
|
||||||
|
// shows something real: Checkmate server works with zero monitors.
|
||||||
|
// Ordered so the declared widths pair into full rows: a card left alone in a
|
||||||
|
// row is stretched to fill it, so 8- and 4-wide cards are kept adjacent.
|
||||||
|
export const DEFAULT_CARD_IDS: readonly CardId[] = [
|
||||||
|
"monitorStatus",
|
||||||
|
"currentlyDown",
|
||||||
|
"failedChecks",
|
||||||
|
"monitorsByType",
|
||||||
|
"recentIncidents",
|
||||||
|
"checkmateServer",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DASHBOARD_CARDS_STORAGE_KEY = "checkmate-dashboard-cards";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll rates, in ms. Monitor and server figures move continuously; the log ring
|
||||||
|
* buffer rotates fast but is expensive to re-read, so it is polled slowly.
|
||||||
|
*/
|
||||||
|
export const REFRESH_INTERVAL_MS = 30000;
|
||||||
|
export const SLOW_REFRESH_INTERVAL_MS = 60000;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `recentChecks` is oldest-first — the server appends each snapshot with
|
||||||
|
* `$push … $slice: -N` — so the newest retained check is the last element.
|
||||||
|
*/
|
||||||
|
export const latestCheck = (monitor: Monitor) =>
|
||||||
|
monitor.recentChecks?.[monitor.recentChecks.length - 1];
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { LayoutGrid } from "lucide-react";
|
||||||
|
|
||||||
|
import { BasePage, EmptyState, Icon } from "@/Components/design-elements";
|
||||||
|
import { Button } from "@/Components/inputs";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { CardGrid } from "./Components/CardGrid";
|
||||||
|
import { CardPicker } from "./Components/CardPicker";
|
||||||
|
import { MonitorsContext, useMonitorsRequest } from "./useDashboardData";
|
||||||
|
import { useCardSelection } from "./useCardSelection";
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const monitorsRequest = useMonitorsRequest();
|
||||||
|
const {
|
||||||
|
cards,
|
||||||
|
available,
|
||||||
|
selected,
|
||||||
|
addCard,
|
||||||
|
removeCard,
|
||||||
|
reorderCards,
|
||||||
|
resetCards,
|
||||||
|
selectAll,
|
||||||
|
deselectAll,
|
||||||
|
allSelected,
|
||||||
|
} = useCardSelection();
|
||||||
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MonitorsContext.Provider value={monitorsRequest}>
|
||||||
|
<BasePage headerKey="dashboard">
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="flex-end"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
onClick={() => setIsPickerOpen(true)}
|
||||||
|
startIcon={
|
||||||
|
<Icon
|
||||||
|
icon={LayoutGrid}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.picker.trigger")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
{cards.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t("pages.dashboard.empty.title")}
|
||||||
|
description={t("pages.dashboard.empty.description")}
|
||||||
|
actionText={t("pages.dashboard.picker.trigger")}
|
||||||
|
onAction={() => setIsPickerOpen(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CardGrid
|
||||||
|
cards={cards}
|
||||||
|
onReorder={reorderCards}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<CardPicker
|
||||||
|
open={isPickerOpen}
|
||||||
|
available={available}
|
||||||
|
selected={selected}
|
||||||
|
allSelected={allSelected}
|
||||||
|
onAdd={addCard}
|
||||||
|
onRemove={removeCard}
|
||||||
|
onSelectAll={selectAll}
|
||||||
|
onDeselectAll={deselectAll}
|
||||||
|
onReset={resetCards}
|
||||||
|
onClose={() => setIsPickerOpen(false)}
|
||||||
|
/>
|
||||||
|
</BasePage>
|
||||||
|
</MonitorsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { CardDefinition } from "./cards";
|
||||||
|
|
||||||
|
export interface PlacedCard {
|
||||||
|
card: CardDefinition;
|
||||||
|
/** Columns rendered — currently always the declared width. */
|
||||||
|
renderedWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packs cards into rows of GRID_COLUMNS.
|
||||||
|
*
|
||||||
|
* With a single body width this is a straight pass-through: two halves fill a
|
||||||
|
* row exactly, so no card is ever stretched to close a gap.
|
||||||
|
*
|
||||||
|
* An odd number of cards leaves the last one alone on its row. It keeps its
|
||||||
|
* declared width rather than being promoted to full: widening one card to fill
|
||||||
|
* the row makes it look more important than it is, and an odd count is a
|
||||||
|
* property of the selection, not something the layout should hide.
|
||||||
|
*/
|
||||||
|
export const layoutCards = (cards: CardDefinition[]): PlacedCard[] =>
|
||||||
|
cards.map((card) => ({ card, renderedWidth: card.width }));
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { MonitorStatusCard } from "./Components/Cards/MonitorStatusCard";
|
||||||
|
import { CurrentlyDownCard } from "./Components/Cards/CurrentlyDownCard";
|
||||||
|
import { FailedChecksCard } from "./Components/Cards/FailedChecksCard";
|
||||||
|
import { RecentIncidentsCard } from "./Components/Cards/RecentIncidentsCard";
|
||||||
|
import { MonitorsByTypeCard } from "./Components/Cards/MonitorsByTypeCard";
|
||||||
|
import { MonitorsByGroupCard } from "./Components/Cards/MonitorsByGroupCard";
|
||||||
|
import { CheckmateServerCard } from "./Components/Cards/CheckmateServerCard";
|
||||||
|
import { BusiestServersCard } from "./Components/Cards/BusiestServersCard";
|
||||||
|
import { SlowestMonitorsCard, LowestUptimeCard } from "./Components/Cards/RankingCards";
|
||||||
|
import {
|
||||||
|
IncidentStatsCard,
|
||||||
|
NotificationChannelsCard,
|
||||||
|
MaintenanceCard,
|
||||||
|
StatusPagesCard,
|
||||||
|
} from "./Components/Cards/SummaryCards";
|
||||||
|
import {
|
||||||
|
ChecksOnScheduleCard,
|
||||||
|
RecentErrorsCard,
|
||||||
|
TeamCard,
|
||||||
|
} from "./Components/Cards/AdminCards";
|
||||||
|
|
||||||
|
import type { CardDefinition, CardId } from "./cards";
|
||||||
|
|
||||||
|
// Declared width is the minimum number of columns a card needs. The grid may
|
||||||
|
// stretch the last card of a short row; nothing here is a user setting.
|
||||||
|
//
|
||||||
|
// Order is the render order, and the order the picker lists them in.
|
||||||
|
//
|
||||||
|
// Every card is half-width and pairs up down the page. The grid promotes a
|
||||||
|
// lone trailing card to full width so the page does not end on a ragged half.
|
||||||
|
export const CARD_REGISTRY: CardDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "monitorStatus",
|
||||||
|
key: "monitorStatus",
|
||||||
|
group: "uptime",
|
||||||
|
width: 6,
|
||||||
|
component: MonitorStatusCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "currentlyDown",
|
||||||
|
key: "currentlyDown",
|
||||||
|
group: "uptime",
|
||||||
|
width: 6,
|
||||||
|
component: CurrentlyDownCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "failedChecks",
|
||||||
|
key: "failedChecks",
|
||||||
|
group: "checks",
|
||||||
|
width: 6,
|
||||||
|
component: FailedChecksCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slowestMonitors",
|
||||||
|
key: "slowestMonitors",
|
||||||
|
group: "uptime",
|
||||||
|
width: 6,
|
||||||
|
component: SlowestMonitorsCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lowestUptime",
|
||||||
|
key: "lowestUptime",
|
||||||
|
group: "uptime",
|
||||||
|
width: 6,
|
||||||
|
component: LowestUptimeCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "monitorsByType",
|
||||||
|
key: "monitorsByType",
|
||||||
|
group: "uptime",
|
||||||
|
width: 6,
|
||||||
|
component: MonitorsByTypeCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "monitorsByGroup",
|
||||||
|
key: "monitorsByGroup",
|
||||||
|
group: "uptime",
|
||||||
|
width: 6,
|
||||||
|
component: MonitorsByGroupCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "busiestServers",
|
||||||
|
key: "busiestServers",
|
||||||
|
group: "infrastructure",
|
||||||
|
width: 6,
|
||||||
|
component: BusiestServersCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "recentIncidents",
|
||||||
|
key: "recentIncidents",
|
||||||
|
group: "incidents",
|
||||||
|
width: 6,
|
||||||
|
component: RecentIncidentsCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "incidentStats",
|
||||||
|
key: "incidentStats",
|
||||||
|
group: "incidents",
|
||||||
|
width: 6,
|
||||||
|
component: IncidentStatsCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notificationChannels",
|
||||||
|
key: "notificationChannels",
|
||||||
|
group: "notifications",
|
||||||
|
width: 6,
|
||||||
|
component: NotificationChannelsCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maintenance",
|
||||||
|
key: "maintenance",
|
||||||
|
group: "maintenance",
|
||||||
|
width: 6,
|
||||||
|
component: MaintenanceCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "statusPages",
|
||||||
|
key: "statusPages",
|
||||||
|
group: "statusPages",
|
||||||
|
width: 6,
|
||||||
|
component: StatusPagesCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "checkmateServer",
|
||||||
|
key: "checkmateServer",
|
||||||
|
group: "logs",
|
||||||
|
width: 6,
|
||||||
|
adminOnly: true,
|
||||||
|
component: CheckmateServerCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "checksOnSchedule",
|
||||||
|
key: "checksOnSchedule",
|
||||||
|
group: "logs",
|
||||||
|
width: 6,
|
||||||
|
adminOnly: true,
|
||||||
|
component: ChecksOnScheduleCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "recentErrors",
|
||||||
|
key: "recentErrors",
|
||||||
|
group: "logs",
|
||||||
|
width: 6,
|
||||||
|
adminOnly: true,
|
||||||
|
component: RecentErrorsCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "team",
|
||||||
|
key: "team",
|
||||||
|
group: "settings",
|
||||||
|
width: 6,
|
||||||
|
adminOnly: true,
|
||||||
|
component: TeamCard,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BY_ID = new Map<CardId, CardDefinition>(
|
||||||
|
CARD_REGISTRY.map((card) => [card.id, card])
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getCard = (id: CardId): CardDefinition | undefined => BY_ID.get(id);
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
|
||||||
|
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||||
|
import { setDashboardCards } from "@/Features/UI/uiSlice";
|
||||||
|
import { CardIds, DEFAULT_CARD_IDS, type CardId } from "./cards";
|
||||||
|
import { CARD_REGISTRY, getCard } from "./registry";
|
||||||
|
|
||||||
|
import type { CardDefinition } from "./cards";
|
||||||
|
import type { RootState } from "@/store";
|
||||||
|
|
||||||
|
const isCardId = (value: unknown): value is CardId =>
|
||||||
|
typeof value === "string" && (CardIds as readonly string[]).includes(value);
|
||||||
|
|
||||||
|
export const useCardSelection = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
|
// null means the user has never customised; fall back to defaults.
|
||||||
|
const stored = useSelector((state: RootState) => state.ui.dashboardCards);
|
||||||
|
const selected: CardId[] = useMemo(
|
||||||
|
() => (stored !== null ? stored.filter(isCardId) : [...DEFAULT_CARD_IDS]),
|
||||||
|
[stored]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isVisible = useCallback(
|
||||||
|
(card: CardDefinition) => !card.adminOnly || isAdmin,
|
||||||
|
[isAdmin]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cards = useMemo(
|
||||||
|
() =>
|
||||||
|
selected
|
||||||
|
.map(getCard)
|
||||||
|
.filter((card): card is CardDefinition => card !== undefined)
|
||||||
|
.filter(isVisible),
|
||||||
|
[selected, isVisible]
|
||||||
|
);
|
||||||
|
|
||||||
|
const available = useMemo(() => CARD_REGISTRY.filter(isVisible), [isVisible]);
|
||||||
|
|
||||||
|
const addCard = useCallback(
|
||||||
|
(id: CardId) => {
|
||||||
|
if (!selected.includes(id)) dispatch(setDashboardCards([...selected, id]));
|
||||||
|
},
|
||||||
|
[dispatch, selected]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeCard = useCallback(
|
||||||
|
(id: CardId) => {
|
||||||
|
dispatch(setDashboardCards(selected.filter((cardId) => cardId !== id)));
|
||||||
|
},
|
||||||
|
[dispatch, selected]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetCards = useCallback(() => dispatch(setDashboardCards(null)), [dispatch]);
|
||||||
|
|
||||||
|
const selectAll = useCallback(
|
||||||
|
() =>
|
||||||
|
dispatch(
|
||||||
|
setDashboardCards(
|
||||||
|
available.map((card) => card.id).filter((id) => !selected.includes(id)).length >
|
||||||
|
0
|
||||||
|
? [...new Set([...selected, ...available.map((card) => card.id)])]
|
||||||
|
: selected
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[dispatch, available, selected]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deselectAll = useCallback(() => dispatch(setDashboardCards([])), [dispatch]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorders cards based on DnD source/dest indices into the visible `cards` array.
|
||||||
|
* Hidden admin cards keep their stored positions unchanged.
|
||||||
|
*/
|
||||||
|
const reorderCards = useCallback(
|
||||||
|
(sourceIndex: number, destIndex: number) => {
|
||||||
|
const visibleIds = cards.map((c) => c.id);
|
||||||
|
const reordered = [...visibleIds];
|
||||||
|
const [moved] = reordered.splice(sourceIndex, 1);
|
||||||
|
reordered.splice(destIndex, 0, moved);
|
||||||
|
let visibleIdx = 0;
|
||||||
|
dispatch(
|
||||||
|
setDashboardCards(
|
||||||
|
selected.map((cardId) =>
|
||||||
|
visibleIds.includes(cardId) ? reordered[visibleIdx++] : cardId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch, cards, selected]
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSelected = available.every((card) => selected.includes(card.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards,
|
||||||
|
available,
|
||||||
|
selected,
|
||||||
|
addCard,
|
||||||
|
removeCard,
|
||||||
|
reorderCards,
|
||||||
|
resetCards,
|
||||||
|
selectAll,
|
||||||
|
deselectAll,
|
||||||
|
allSelected,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
|
import { MonitorTypes, type MonitorsWithChecksResponse } from "@/Types/Monitor";
|
||||||
|
import { REFRESH_INTERVAL_MS } from "./cards";
|
||||||
|
|
||||||
|
// One request feeds every monitor-derived card, read from context so adding a
|
||||||
|
// card costs no extra call. No rowsPerPage: the dashboard summarises the whole
|
||||||
|
// fleet, and the repository treats its absence as "no limit".
|
||||||
|
const buildMonitorsUrl = () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
MonitorTypes.forEach((type) => params.append("type", type));
|
||||||
|
return `/monitors/team/with-checks?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMonitorsRequest = () =>
|
||||||
|
useGet<MonitorsWithChecksResponse>(
|
||||||
|
buildMonitorsUrl(),
|
||||||
|
{},
|
||||||
|
{ refreshInterval: REFRESH_INTERVAL_MS, keepPreviousData: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MonitorsRequest = ReturnType<typeof useMonitorsRequest>;
|
||||||
|
|
||||||
|
export const MonitorsContext = createContext<MonitorsRequest | null>(null);
|
||||||
|
|
||||||
|
export const useMonitors = (): MonitorsRequest => {
|
||||||
|
const context = useContext(MonitorsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useMonitors must be used inside the dashboard's MonitorsContext");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -8,6 +8,9 @@ import AuthRegister from "@/Pages/Auth/Register";
|
|||||||
import AuthForgotPassword from "@/Pages/Auth/Recovery";
|
import AuthForgotPassword from "@/Pages/Auth/Recovery";
|
||||||
import AuthSetNewPassword from "@/Pages/Auth/SetNewPassword";
|
import AuthSetNewPassword from "@/Pages/Auth/SetNewPassword";
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
import Dashboard from "@/Pages/Dashboard";
|
||||||
|
|
||||||
// Uptime
|
// Uptime
|
||||||
import Uptime from "@/Pages/Uptime/Monitors";
|
import Uptime from "@/Pages/Uptime/Monitors";
|
||||||
import UptimeDetails from "@/Pages/Uptime/Details";
|
import UptimeDetails from "@/Pages/Uptime/Details";
|
||||||
@@ -90,7 +93,11 @@ const Routes = () => {
|
|||||||
>
|
>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={<Navigate to="/uptime" />}
|
element={<Navigate to="/dashboard" />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={<Dashboard />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/uptime"
|
path="/uptime"
|
||||||
|
|||||||
@@ -206,6 +206,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"menu": {
|
"menu": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
"uptime": "Uptime",
|
"uptime": "Uptime",
|
||||||
"pagespeed": "Pagespeed",
|
"pagespeed": "Pagespeed",
|
||||||
"infrastructure": "Infrastructure",
|
"infrastructure": "Infrastructure",
|
||||||
@@ -258,6 +259,147 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
|
"dashboard": {
|
||||||
|
"header": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"description": "See every monitor type in one place and spot what needs attention."
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"error": "Could not load this card."
|
||||||
|
},
|
||||||
|
"cards": {
|
||||||
|
"monitorStatus": {
|
||||||
|
"title": "Monitor status",
|
||||||
|
"empty": "No monitors yet.",
|
||||||
|
"upOfTotal": "of {{total}} up"
|
||||||
|
},
|
||||||
|
"currentlyDown": {
|
||||||
|
"title": "Currently down",
|
||||||
|
"empty": "All monitors are up. No incidents open.",
|
||||||
|
"count": "{{count}} needing attention"
|
||||||
|
},
|
||||||
|
"monitorsByType": {
|
||||||
|
"title": "Monitors by type",
|
||||||
|
"empty": "No monitors yet.",
|
||||||
|
"down": "{{count}} down"
|
||||||
|
},
|
||||||
|
"recentIncidents": {
|
||||||
|
"title": "Recent incidents",
|
||||||
|
"empty": "No incidents — all systems healthy.",
|
||||||
|
"active": "{{count}} open",
|
||||||
|
"ongoing": "Ongoing",
|
||||||
|
"manual": "Manual",
|
||||||
|
"unknownMonitor": "Unknown monitor"
|
||||||
|
},
|
||||||
|
"failedChecks": {
|
||||||
|
"title": "Failed checks",
|
||||||
|
"empty": "No checks yet.",
|
||||||
|
"ratio": "{{failed}} of {{total}} in the last day"
|
||||||
|
},
|
||||||
|
"checkmateServer": {
|
||||||
|
"title": "Checkmate server",
|
||||||
|
"memory": "Memory",
|
||||||
|
"eventLoop": "Event loop delay",
|
||||||
|
"uptime": "Uptime",
|
||||||
|
"database": "Database",
|
||||||
|
"readsWrites": "{{reads}} reads/s · {{writes}} writes/s"
|
||||||
|
},
|
||||||
|
"ranking": {
|
||||||
|
"topOf": "Top {{shown}} of {{total}}",
|
||||||
|
"notEnough": "Not enough monitors to rank yet."
|
||||||
|
},
|
||||||
|
"slowestMonitors": {
|
||||||
|
"title": "Slowest monitors"
|
||||||
|
},
|
||||||
|
"lowestUptime": {
|
||||||
|
"title": "Lowest uptime"
|
||||||
|
},
|
||||||
|
"monitorsByGroup": {
|
||||||
|
"title": "Monitors by group",
|
||||||
|
"empty": "No groups yet. Groups are set per monitor."
|
||||||
|
},
|
||||||
|
"busiestServers": {
|
||||||
|
"title": "Busiest servers",
|
||||||
|
"empty": "No infrastructure monitors yet.",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"memory": "Memory",
|
||||||
|
"disk": "Disk"
|
||||||
|
},
|
||||||
|
"incidentStats": {
|
||||||
|
"title": "Incident stats",
|
||||||
|
"openNow": "Open now",
|
||||||
|
"avgResolution": "Average time to resolve",
|
||||||
|
"hours": "{{hours}} h",
|
||||||
|
"mostIncidents": "Most incidents",
|
||||||
|
"total": "total",
|
||||||
|
"resolved": "Resolved",
|
||||||
|
"automatic": "Automatic",
|
||||||
|
"manual": "Manual",
|
||||||
|
"autoManual": "{{automatic}} automatic · {{manual}} manual"
|
||||||
|
},
|
||||||
|
"notificationChannels": {
|
||||||
|
"title": "Notification channels",
|
||||||
|
"empty": "No channels yet."
|
||||||
|
},
|
||||||
|
"maintenance": {
|
||||||
|
"title": "Maintenance windows",
|
||||||
|
"empty": "No maintenance scheduled.",
|
||||||
|
"runningNow": "Running now",
|
||||||
|
"next": "Next window"
|
||||||
|
},
|
||||||
|
"statusPages": {
|
||||||
|
"title": "Status pages",
|
||||||
|
"empty": "No status pages yet.",
|
||||||
|
"monitorCount": "{{count}} monitors",
|
||||||
|
"published": "Published",
|
||||||
|
"draft": "Draft"
|
||||||
|
},
|
||||||
|
"checksOnSchedule": {
|
||||||
|
"title": "Check queue",
|
||||||
|
"healthy": "All {{jobs}} jobs on schedule across {{workers}} worker(s).",
|
||||||
|
"failing": "Failing jobs",
|
||||||
|
"scheduled": "Scheduled jobs",
|
||||||
|
"runningNow": "Running now"
|
||||||
|
},
|
||||||
|
"recentErrors": {
|
||||||
|
"title": "Recent errors",
|
||||||
|
"empty": "No errors logged.",
|
||||||
|
"count": "{{count}} errors",
|
||||||
|
"warnings": "Warnings"
|
||||||
|
},
|
||||||
|
"team": {
|
||||||
|
"title": "Team members",
|
||||||
|
"members": "{{count}} members",
|
||||||
|
"unverified": "Unverified"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"picker": {
|
||||||
|
"trigger": "Edit cards",
|
||||||
|
"title": "Dashboard cards",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"done": "Done",
|
||||||
|
"reset": "Reset to defaults",
|
||||||
|
"groups": {
|
||||||
|
"uptime": "Uptime",
|
||||||
|
"infrastructure": "Infrastructure",
|
||||||
|
"incidents": "Incidents",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"maintenance": "Maintenance",
|
||||||
|
"statusPages": "Status pages",
|
||||||
|
"checks": "Checks",
|
||||||
|
"logs": "System",
|
||||||
|
"settings": "Settings"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "No cards on your dashboard",
|
||||||
|
"description": "Choose the cards you want to see. Your selection is remembered in this browser."
|
||||||
|
},
|
||||||
|
"reorder": {
|
||||||
|
"moveUp": "Move up",
|
||||||
|
"moveDown": "Move down"
|
||||||
|
}
|
||||||
|
},
|
||||||
"proxies": {
|
"proxies": {
|
||||||
"header": {
|
"header": {
|
||||||
"title": "Proxies",
|
"title": "Proxies",
|
||||||
|
|||||||
Reference in new issue
Block a user