Compare commits

...
Author SHA1 Message Date
Malena Aguiar e0e5a334bb feat: create main dashboard 2026-09-09 17:15:34 -06:00
Alexander Holliday 22232489a8 Merge pull request #3934 from bluewave-labs/fix/admin-gate-docker-logs
fix: admin gate docker logs
2026-09-08 15:59:17 -07:00
Alex Holliday 04277a6eb4 add admin check to fetch request 2026-09-08 15:55:17 -07:00
Alex Holliday a8dc867e78 add admin gate to API route 2026-09-08 15:52:43 -07:00
Alex Holliday 533783ba3e gate client side log page behind admin check 2026-09-08 15:51:17 -07:00
Alexander Holliday fd7b3e5f20 Merge pull request #3933 from bluewave-labs/feat/docker-state-palette
feat: docker state palette
2026-09-08 15:36:31 -07:00
Alex Holliday ae4b851620 pass palette to stat box for docker state 2026-09-08 15:33:50 -07:00
Alex Holliday e83f97a05a add function to translate docker state to palette keys 2026-09-08 15:33:31 -07:00
Alexander Holliday 070682af74 Merge pull request #3932 from bluewave-labs/fix/test-all-admin-guard
fix: add admin guard to test all button, fix tests
2026-09-08 11:01:21 -07:00
Alex Holliday 8609e4bca0 add admin guard to test all button, fix tests 2026-09-08 10:46:36 -07:00
Alexander Holliday 2661d7a384 Merge pull request #3922 from alishanahmad907/fix/rbac-auth
fix(api): enforce RBAC role checks on maintenance windows, notifications, and check deletion
2026-09-08 10:43:01 -07:00
Alexander Holliday 5b9d554f46 Merge pull request #3910 from bluewave-labs/fix/checks-table-copy-message
fix(client): don't navigate away when selecting text in a table row
2026-09-08 10:31:56 -07:00
Alex Holliday 7fb8bab4be purge narrative comment 2026-09-08 10:21:44 -07:00
Alexander Holliday 75d29124b6 Merge pull request #3909 from signalgridco/feat/signalgrid-notifications
feat: add Signalgrid notification provider
2026-09-08 10:14:19 -07:00
Alexander Holliday 1f0ea6cedc Merge pull request #3914 from eegrok/fix/maintenance-window-timezone-copy
fix Start Time description to accurately reflect that the time zone is local not UTC
2026-09-08 10:07:10 -07:00
Alexander Holliday 07a38b9c2c Merge pull request #3925 from egeoztass/fix/create-monitor-return-document
fix: return the created monitor from createMonitor
2026-09-08 10:03:34 -07:00
egeoztassandClaude Fable 5.1 67806c94b1 fix: return the created monitor from createMonitor
The controller writes the service's return value into the response, but
createMonitor was typed Promise<void> and returned nothing, so every
successful POST /monitors answered data: null and callers had no way to
read the new monitor's id.

Fixes #3917

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7eViZ2Np5ZpWUYNoRhmAt
2026-09-07 12:31:07 +02:00
Ali Shan Ahmad 2eeb088b61 [FIX]: rbac by adding the missing isAllowed(["admin", "superadmin"]) authorization middleware to mutating API endpoints for maintenance windows, notifications, and monitor check history. 2026-09-06 08:35:43 +00:00
gorkem-bwl 9c796edbde refactor(client): drop the copy button, keep the selection fix
The selection guard alone closes #3907: drag-selecting a check's error
message no longer navigates away, so the text can be copied. The copy
button was a second, larger answer to the same problem, and it brought a
reusable component, an HTTP clipboard fallback, four translation keys and
a timer to maintain.

Reverting it leaves the fix at ~29 lines in one file, and keeps the
behaviour change to the thing that was actually broken. CopyButton is
worth having for monitor URLs, tokens and incident ids, but it should
land on its own merits rather than inside a bug fix.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:24:07 -04:00
gorkem-bwl 84a1b994a1 fix(client): address review feedback on the copy-message change
Four issues from code review:

- The selection guard read the document-wide selection, so a leftover
  selection anywhere on the page could swallow the first click on any
  row. It now checks that the selection is inside the row that was
  clicked, with the event threaded through to both call sites.

- CopyButton's reset timer was never cleared, so it fired against an
  unmounted component when a row went away on pagination, and a rapid
  second click could not cancel the pending reset. Held in a ref and
  cleared on unmount and on re-copy.

- navigator.clipboard only exists on secure origins, and the README
  documents reaching Checkmate over plain HTTP on a LAN address. There
  the API is undefined, so every click showed "could not copy" with no
  way forward. Falls back to a hidden textarea.

- The two message cells were byte-identical, so they are now one shared
  CopyableText component.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:24:07 -04:00
gorkem-bwl db4689207a fix(client): make check error messages selectable and copyable
Closes #3907.

Selecting the text of a failed check's error message navigated away
before the selection could be copied. The mouseup that ends a drag-select
lands on the table row, which fires the row's onClick.

Two changes:

- DataTable ignores a row click when the user has an active text
  selection, so drag-selecting text inside any row no longer navigates.
  This applies to all eleven tables that pass onRowClick, not just checks.
- The message column gets an explicit copy button, which is the more
  discoverable of the two fixes the reporter suggested. Added as a shared
  CopyButton in design-elements so other columns can reuse it.

Note the row click on the uptime details checks table navigates to
/checks/<check id>, but that route's parameter is :monitorId. A check id
never matches a monitor, so the destination page renders empty - which is
the "all this information disappears" in the report. Left alone here to
keep this change reviewable; it needs its own decision about what that
row should link to.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:24:07 -04:00
eegrok af465766c8 fix Start Time description to accurately reflect that the time zone is local time, not UTC 2026-09-03 09:13:32 +12:00
signalgridco c13f26a4c3 feat: add Signalgrid notification provider 2026-09-01 23:17:11 +02:00
53 changed files with 3519 additions and 55 deletions

No files matched your search

+58 -22
View File
@@ -9,6 +9,9 @@
"version": "3.11.0",
"dependencies": {
"@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/styled": "^11.13.0",
"@fontsource-variable/geist": "^5.3.0",
@@ -93,7 +96,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -363,6 +365,59 @@
"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": {
"version": "11.13.5",
"license": "MIT",
@@ -409,7 +464,6 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -447,7 +501,6 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1236,7 +1289,6 @@
"node_modules/@mui/material": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7",
@@ -1341,7 +1393,6 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -2386,7 +2437,6 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2402,7 +2452,6 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2493,7 +2542,6 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -2774,7 +2822,6 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3131,7 +3178,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3575,8 +3621,7 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3975,7 +4020,6 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4788,7 +4832,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5600,7 +5643,6 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -6382,7 +6424,6 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6394,7 +6435,6 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6598,8 +6638,7 @@
},
"node_modules/redux": {
"version": "5.0.1",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6749,7 +6788,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.9"
},
@@ -7591,7 +7629,6 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7729,7 +7766,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
+3
View File
@@ -20,6 +20,9 @@
},
"dependencies": {
"@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/styled": "^11.13.0",
"@fontsource-variable/geist": "^5.3.0",
@@ -81,6 +81,21 @@ export function DataTable<
};
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const hasTextSelectionWithin = (element: Element) => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
return false;
}
return (
selection.anchorNode !== null &&
element.contains(
selection.anchorNode.nodeType === Node.TEXT_NODE
? selection.anchorNode.parentNode
: selection.anchorNode
)
);
};
const isInteractive = Boolean(onRowClick) || expandableRows;
if (data.length === 0 || headers.length === 0) {
@@ -102,7 +117,10 @@ export function DataTable<
keys.push(key);
return (
<Stack
onClick={() => (onRowClick ? onRowClick(row) : null)}
onClick={(e) => {
if (hasTextSelectionWithin(e.currentTarget)) return;
if (onRowClick) onRowClick(row);
}}
spacing={theme.spacing(LAYOUT.XS)}
sx={{
borderStyle: "solid",
@@ -256,7 +274,8 @@ export function DataTable<
}),
...(getRowSx?.(row) as object),
}}
onClick={() => {
onClick={(e) => {
if (hasTextSelectionWithin(e.currentTarget)) return;
if (expandableRows) handleExpand(row);
else if (onRowClick) onRowClick(row);
}}
@@ -60,20 +60,22 @@ export const HeaderMonitorControls = ({
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(2)}
>
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={<Icon icon={Mail} />}
onClick={async () => {
await post(`/notifications/test/all`, { monitorId: monitor.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("common.buttons.testNotifications")}
</Button>
{isAdmin && (
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={<Icon icon={Mail} />}
onClick={async () => {
await post(`/notifications/test/all`, { monitorId: monitor.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("common.buttons.testNotifications")}
</Button>
)}
<Button
variant="contained"
color="secondary"
+6
View File
@@ -1,6 +1,7 @@
import { Icon } from "@/Components/design-elements";
import {
LayoutDashboard,
Globe,
Gauge,
Link,
@@ -26,6 +27,11 @@ export const getMenu = (t: Function) => {
{
group: t("components.sidebar.menu.groups.monitoring"),
items: [
{
name: t("components.sidebar.menu.dashboard"),
path: "dashboard",
icon: <Icon icon={LayoutDashboard} />,
},
{
name: t("components.sidebar.menu.uptime"),
path: "uptime",
+7
View File
@@ -1,4 +1,5 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { CardId } from "@/Pages/Dashboard/cards";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
@@ -35,6 +36,7 @@ interface UIState {
language: string;
starPromptOpen: boolean;
chartType: ChartType;
dashboardCards: CardId[] | null;
}
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
@@ -74,6 +76,7 @@ const initialState: UIState = {
language: "en",
starPromptOpen: true,
chartType: "histogram",
dashboardCards: null,
};
const uiSlice = createSlice({
@@ -117,6 +120,9 @@ const uiSlice = createSlice({
setChartType: (state, action: PayloadAction<ChartType>) => {
state.chartType = action.payload;
},
setDashboardCards: (state, action: PayloadAction<CardId[] | null>) => {
state.dashboardCards = action.payload;
},
},
});
@@ -133,4 +139,5 @@ export const {
setLanguage,
setStarPromptOpen,
setChartType,
setDashboardCards,
} = 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>
);
};
+84
View File
@@ -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;
+8
View File
@@ -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];
+86
View File
@@ -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;
+21
View File
@@ -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 }));
+161
View File
@@ -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;
};
@@ -10,6 +10,7 @@ import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatDuration } from "@/Utils/TimeUtils";
import { getDockerPalette } from "@/Utils/MonitorUtils";
export const DockerContainerStatusBoxes = ({
container,
@@ -24,6 +25,7 @@ export const DockerContainerStatusBoxes = ({
const { state, restartCount, health } = container;
const startedAt = container.startedAt ?? "0";
const runningFor = Date.now() - new Date(startedAt).getTime();
const palette = getDockerPalette(state);
return (
<Stack
direction="row"
@@ -33,6 +35,7 @@ export const DockerContainerStatusBoxes = ({
<StatBox
title={t("common.labels.state")}
subtitle={state}
palette={palette}
/>
<StatBox
title={t("common.labels.uptime")}
@@ -23,6 +23,7 @@ import {
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useToast } from "@/Hooks/UseToast";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
// Utils
import { get } from "@/Utils/ApiClient";
@@ -50,6 +51,7 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
const theme = useTheme();
const { t } = useTranslation();
const { toastError } = useToast();
const isAdmin = useIsAdmin();
// logs is newest-first, matching the API and what flattenDockerLogs expects.
const [logs, setLogs] = useState<DockerLog[]>([]);
@@ -59,7 +61,7 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
const logsUrl =
enabled && monitorId && containerName
isAdmin && enabled && monitorId && containerName
? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs`
: null;
@@ -168,6 +170,15 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
}
}, [logsUrl, oldestCursor, loadingOlder, t, toastError]);
if (!isAdmin) {
return (
<EmptyState
title={t("pages.docker.container.logs.forbidden.title")}
description={t("pages.docker.container.logs.forbidden.description")}
/>
);
}
if (!enabled) {
return (
<EmptyState
@@ -126,6 +126,7 @@ const NotificationsCreatePage = () => {
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "signalgrid" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
@@ -201,6 +202,31 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "signalgrid" && (
<ConfigBox
title={t("pages.notifications.form.signalgrid.title")}
subtitle={t("pages.notifications.form.signalgrid.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.signalgrid.optionClientKey")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderClientKey"
)}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.signalgrid.optionChannel")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderChannel"
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
+8 -1
View File
@@ -8,6 +8,9 @@ import AuthRegister from "@/Pages/Auth/Register";
import AuthForgotPassword from "@/Pages/Auth/Recovery";
import AuthSetNewPassword from "@/Pages/Auth/SetNewPassword";
// Dashboard
import Dashboard from "@/Pages/Dashboard";
// Uptime
import Uptime from "@/Pages/Uptime/Monitors";
import UptimeDetails from "@/Pages/Uptime/Details";
@@ -90,7 +93,11 @@ const Routes = () => {
>
<Route
path="/"
element={<Navigate to="/uptime" />}
element={<Navigate to="/dashboard" />}
/>
<Route
path="/dashboard"
element={<Dashboard />}
/>
<Route
path="/uptime"
+1
View File
@@ -9,6 +9,7 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as const;
+8
View File
@@ -40,6 +40,14 @@ export const getStatusPalette = (status: MonitorStatus): PaletteKey => {
return "warning";
};
export const getDockerPalette = (dockerState: DockerContainerState): PaletteKey => {
if (dockerState === "created") return "success";
if (dockerState === "running") return "success";
if (dockerState === "dead") return "error";
if (dockerState === "exited") return "error";
return "warning";
};
export const getValuePalette = (value: ValueType): PaletteKey => {
const paletteMap: Record<ValueType, PaletteKey> = {
positive: "success",
+7
View File
@@ -70,6 +70,12 @@ const pushoverSchema = baseSchema.extend({
accessToken: z.string().min(1, "App token is required"),
});
const signalgridSchema = baseSchema.extend({
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
});
const twilioSchema = baseSchema.extend({
type: z.literal("twilio"),
accountSid: z.string().min(1, "Account SID is required"),
@@ -95,6 +101,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
teamsSchema,
telegramSchema,
pushoverSchema,
signalgridSchema,
twilioSchema,
ntfySchema,
]);
+156 -1
View File
@@ -206,6 +206,7 @@
},
"sidebar": {
"menu": {
"dashboard": "Dashboard",
"uptime": "Uptime",
"pagespeed": "Pagespeed",
"infrastructure": "Infrastructure",
@@ -258,6 +259,147 @@
}
},
"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": {
"header": {
"title": "Proxies",
@@ -985,6 +1127,10 @@
"title": "Log collection is off for this host",
"description": "Turn on container logs in the monitor settings to start collecting.",
"action": "Configure monitor"
},
"forbidden": {
"title": "You don't have access to container logs",
"description": "Only administrators can view logs. Ask an administrator for access if you need to see them."
}
}
},
@@ -1247,7 +1393,7 @@
},
"startTime": {
"title": "Start time",
"description": "Set the start time and duration for your maintenance window. All values are in UTC",
"description": "Set the start time and duration for your maintenance window. All values are in your local time zone.",
"option": {
"duration": {
"label": "Duration"
@@ -1329,6 +1475,7 @@
"teams": "Teams",
"telegram": "Telegram",
"pushover": "Pushover",
"signalgrid": "Signalgrid",
"twilio": "Twilio",
"ntfy": "ntfy"
}
@@ -1349,6 +1496,14 @@
"optionUserKey": "User key",
"placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
},
"signalgrid": {
"title": "Signalgrid configuration",
"description": "Configure Signalgrid to receive push notifications on your devices.",
"optionClientKey": "Client key",
"placeholderClientKey": "Your Signalgrid client key",
"optionChannel": "Channel",
"placeholderChannel": "Your Signalgrid channel"
},
"twilio": {
"title": "Twilio SMS configuration",
"description": "Configure Twilio to send SMS notifications to a phone number.",
+4
View File
@@ -65,6 +65,10 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
component: "PushoverNotification",
example: { notificationName: "Pushover personal", type: "pushover", address: "u1234567890abcdef", accessToken: "a1234567890abcdef" },
},
signalgrid: {
component: "SignalgridNotification",
example: { notificationName: "Signalgrid", type: "signalgrid", address: "your-channel", accessToken: "your-client-key" },
},
twilio: {
component: "TwilioNotification",
example: {
+1 -1
View File
@@ -9,6 +9,6 @@ export const createCheckRoutes = (checkController: ICheckController): Router =>
router.get("/team", checkController.getChecksByTeam);
router.delete("/team", isAllowed(["admin", "superadmin"]), checkController.deleteChecksByTeamId);
router.get("/:monitorId", checkController.getChecksByMonitor);
router.delete("/:monitorId", checkController.deleteChecks);
router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), checkController.deleteChecks);
return router;
};
@@ -1,15 +1,16 @@
import { IMaintenanceWindowController } from "@/api/controllers/maintenanceWindowController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createMaintenanceWindowRoutes = (maintenanceWindowController: IMaintenanceWindowController): Router => {
const router = Router();
router.post("/", maintenanceWindowController.createMaintenanceWindows);
router.post("/", isAllowed(["admin", "superadmin"]), maintenanceWindowController.createMaintenanceWindows);
router.get("/team/", maintenanceWindowController.getMaintenanceWindowsByTeamId);
router.get("/monitor/:monitorId", maintenanceWindowController.getMaintenanceWindowsByMonitorId);
router.get("/:id", maintenanceWindowController.getMaintenanceWindowById);
router.patch("/:id", maintenanceWindowController.editMaintenanceWindow);
router.delete("/:id", maintenanceWindowController.deleteMaintenanceWindow);
router.patch("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.editMaintenanceWindow);
router.delete("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.deleteMaintenanceWindow);
return router;
};
+5 -1
View File
@@ -21,7 +21,11 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout
// Docker routes
router.get("/docker/details/:monitorId", monitorController.getDockerDetailsById);
router.get("/docker/details/:monitorId/containers/:containerName", monitorController.getDockerContainerByName);
router.get("/docker/details/:monitorId/containers/:containerName/logs", monitorController.getDockerContainerLogs);
router.get(
"/docker/details/:monitorId/containers/:containerName/logs",
isAllowed(["admin", "superadmin"]),
monitorController.getDockerContainerLogs
);
// Geo checks routes
router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId);
+6 -5
View File
@@ -1,14 +1,15 @@
import { INotificationController } from "@/api/controllers/notificationController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createNotificationRoutes = (notificationController: INotificationController): Router => {
const router = Router();
router.post("/", notificationController.createNotification);
router.post("/test/all", notificationController.testAllNotifications);
router.post("/test", notificationController.testNotification);
router.post("/", isAllowed(["admin", "superadmin"]), notificationController.createNotification);
router.post("/test/all", isAllowed(["admin", "superadmin"]), notificationController.testAllNotifications);
router.post("/test", isAllowed(["admin", "superadmin"]), notificationController.testNotification);
router.get("/team", notificationController.getNotificationsByTeamId);
router.get("/:id", notificationController.getNotificationById);
router.delete("/:id", notificationController.deleteNotification);
router.patch("/:id", notificationController.editNotification);
router.delete("/:id", isAllowed(["admin", "superadmin"]), notificationController.deleteNotification);
router.patch("/:id", isAllowed(["admin", "superadmin"]), notificationController.editNotification);
return router;
};
@@ -123,6 +123,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
address: z.string().min(1, "User key is required"),
accessToken: z.string().min(1, "App token is required"),
}),
// Signalgrid notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
}),
// Twilio SMS notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
+3
View File
@@ -33,6 +33,7 @@ import { MatrixProvider } from "@/domain/notifications/providers/matrix.js";
import { TeamsProvider } from "@/domain/notifications/providers/teams.js";
import { TelegramProvider } from "@/domain/notifications/providers/telegram.js";
import { PushoverProvider } from "@/domain/notifications/providers/pushover.js";
import { SignalgridProvider } from "@/domain/notifications/providers/signalgrid.js";
import { TwilioProvider } from "@/domain/notifications/providers/twilio.js";
import { NtfyProvider } from "@/domain/notifications/providers/ntfy.js";
@@ -178,6 +179,7 @@ export const buildShared = async ({
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const signalgridProvider = new SignalgridProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const ntfyProvider = new NtfyProvider(logger);
@@ -192,6 +194,7 @@ export const buildShared = async ({
teams: teamsProvider,
telegram: telegramProvider,
pushover: pushoverProvider,
signalgrid: signalgridProvider,
twilio: twilioProvider,
ntfy: ntfyProvider,
};
@@ -60,7 +60,7 @@ const computeRestartsInRange = (aggregate: DockerContainerStatsBucket[]): number
export interface IMonitorService {
// create
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<void>;
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<Monitor>;
createMonitors(monitors: Array<Monitor>): Promise<Monitor[] | null>;
addDemoMonitors(args: { userId: string; teamId: string }): Promise<Monitor[]>;
@@ -180,7 +180,7 @@ export class MonitorService implements IMonitorService {
this.incidentsRepository = incidentsRepository;
}
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<Monitor> => {
// proxyId is only needed in custom mode
if (body.proxyMode !== "custom") {
delete body.proxyId;
@@ -191,6 +191,7 @@ export class MonitorService implements IMonitorService {
}
this.scheduler.addJob(monitor.id, monitor);
return monitor;
};
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
@@ -37,6 +37,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as NotificationChannel[],
@@ -9,6 +9,7 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as const;
@@ -0,0 +1,123 @@
const SERVICE_NAME = "SignalgridProvider";
import type { Notification, NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
import got from "got";
export class SignalgridProvider extends NotificationProvider {
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
try {
await got.post("https://api.signalgrid.co/v1/push", {
form: {
client_key: notification.accessToken,
channel: notification.address,
title: "Checkmate Test Notification",
body: getTestMessage(),
type: "INFO",
critical: false,
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Signalgrid test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
try {
await got.post("https://api.signalgrid.co/v1/push", {
form: {
client_key: notification.accessToken,
channel: notification.address,
title: message.content.title,
body: this.buildSignalgridText(message),
type: this.mapSeverity(message.severity),
critical: false,
},
...this.gotRequestOptions(),
});
this.logger.info({
message: "Signalgrid notification sent",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Signalgrid alert failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
private mapSeverity(severity: NotificationSeverity): "CRIT" | "WARN" | "INFO" | "SUCCESS" {
switch (severity) {
case "critical":
return "CRIT";
case "warning":
return "WARN";
case "success":
return "SUCCESS";
default:
return "INFO";
}
}
private buildSignalgridText(message: NotificationMessage): string {
const lines: string[] = [];
lines.push(message.content.summary);
lines.push("");
lines.push("Monitor Details:");
lines.push(`• Name: ${message.monitor.name}`);
lines.push(`• URL: ${message.monitor.url}`);
lines.push(`• Type: ${message.monitor.type}`);
lines.push(`• Status: ${message.monitor.status}`);
if (message.content.details && message.content.details.length > 0) {
lines.push("");
lines.push("Additional Information:");
message.content.details.forEach((detail) => lines.push(`• ${detail}`));
}
if (message.content.thresholds && message.content.thresholds.length > 0) {
lines.push("");
lines.push("Threshold Breaches:");
message.content.thresholds.forEach((breach) => {
lines.push(`• ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
});
}
if (message.content.incident) {
lines.push("");
lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`);
}
return lines.join("\n");
}
}
@@ -0,0 +1,79 @@
import { describe, expect, it, jest } from "@jest/globals";
import type { NextFunction, Request, Response } from "express";
import { isAllowed } from "../../../src/api/middleware/isAllowed.ts";
import { AppError } from "../../../src/utils/AppError.ts";
describe("isAllowed middleware", () => {
it("calls next() without error when user has an allowed role", () => {
const middleware = isAllowed(["admin", "superadmin"]);
const req = {
user: {
id: "user-1",
teamId: "team-1",
email: "admin@example.com",
role: ["admin"],
},
} as unknown as Request;
const res = {} as Response;
const next = jest.fn() as unknown as NextFunction;
middleware(req, res, next);
expect(next).toHaveBeenCalledWith();
});
it("calls next(error) with 403 when user does not have an allowed role", () => {
const middleware = isAllowed(["admin", "superadmin"]);
const req = {
user: {
id: "user-2",
teamId: "team-1",
email: "user@example.com",
role: ["user"],
},
} as unknown as Request;
const res = {} as Response;
const next = jest.fn() as unknown as NextFunction;
middleware(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
const error = (next as unknown as jest.Mock).mock.calls[0][0] as AppError;
expect(error).toBeInstanceOf(AppError);
expect(error.status).toBe(403);
expect(error.message).toBe("Unauthorized");
});
it("calls next(error) with 403 when req.user is undefined", () => {
const middleware = isAllowed(["admin", "superadmin"]);
const req = {} as Request;
const res = {} as Response;
const next = jest.fn() as unknown as NextFunction;
middleware(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
const error = (next as unknown as jest.Mock).mock.calls[0][0] as AppError;
expect(error).toBeInstanceOf(AppError);
expect(error.status).toBe(403);
expect(error.message).toBe("Unauthorized");
});
it("calls next() when user has multiple roles and at least one matches", () => {
const middleware = isAllowed(["admin", "superadmin"]);
const req = {
user: {
id: "user-3",
teamId: "team-1",
email: "multi@example.com",
role: ["user", "admin"],
},
} as unknown as Request;
const res = {} as Response;
const next = jest.fn() as unknown as NextFunction;
middleware(req, res, next);
expect(next).toHaveBeenCalledWith();
});
});
@@ -0,0 +1,155 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { SignalgridProvider } = await import("../../../../src/domain/notifications/providers/signalgrid.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new SignalgridProvider(logger as any), logger };
};
testNotificationProviderContract("SignalgridProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("SignalgridProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("sends to Signalgrid API and returns true", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
"https://api.signalgrid.co/v1/push",
expect.objectContaining({
form: expect.objectContaining({
client_key: "token-abc",
channel: "https://hooks.example.com/webhook",
type: "INFO",
critical: false,
}),
})
);
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } }));
});
it("handles non-Error thrown values in sendTestAlert", async () => {
mockGotPost.mockRejectedValue("string error");
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } }));
});
});
describe("sendMessage", () => {
it("sends message and returns true", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
const form = mockGotPost.mock.calls[0][1].form;
expect(form.body).toContain("Monitor Details:");
expect(form.title).toBeDefined();
expect(form.critical).toBe(false);
});
it("maps critical severity to CRIT", async () => {
const { provider } = createProvider();
const message = makeMessage();
message.severity = "critical";
await provider.sendMessage(makeNotification() as any, message);
expect(mockGotPost.mock.calls[0][1].form.type).toBe("CRIT");
});
it("maps warning severity to WARN", async () => {
const { provider } = createProvider();
const message = makeMessage();
message.severity = "warning";
await provider.sendMessage(makeNotification() as any, message);
expect(mockGotPost.mock.calls[0][1].form.type).toBe("WARN");
});
it("maps success severity to SUCCESS", async () => {
const { provider } = createProvider();
const message = makeMessage();
message.severity = "success";
await provider.sendMessage(makeNotification() as any, message);
expect(mockGotPost.mock.calls[0][1].form.type).toBe("SUCCESS");
});
it("maps info severity to INFO", async () => {
const { provider } = createProvider();
const message = makeMessage();
message.severity = "info";
await provider.sendMessage(makeNotification() as any, message);
expect(mockGotPost.mock.calls[0][1].form.type).toBe("INFO");
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
});
it("handles non-Error thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(42);
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } }));
});
it("includes thresholds in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
expect(mockGotPost.mock.calls[0][1].form.body).toContain("CPU");
});
it("includes incident link in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
expect(mockGotPost.mock.calls[0][1].form.body).toContain("View Incident");
});
it("omits optional sections when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
msg.content.incident = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const text = mockGotPost.mock.calls[0][1].form.body;
expect(text).not.toContain("Threshold");
expect(text).not.toContain("Additional");
expect(text).not.toContain("View Incident");
});
});
});
@@ -122,16 +122,17 @@ const createService = (
describe("MonitorService", () => {
describe("createMonitor", () => {
it("creates a monitor and adds a job", async () => {
it("creates a monitor, adds a job and returns the created document", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const monitor = makeMonitor();
(monitorsRepository.create as jest.Mock).mockResolvedValue(monitor);
const { service, jobQueue } = createService({ monitorsRepository });
await service.createMonitor(TEAM_ID, USER_ID, monitor as any);
const result = await service.createMonitor(TEAM_ID, USER_ID, monitor as any);
expect(monitorsRepository.create).toHaveBeenCalledWith(monitor, TEAM_ID, USER_ID);
expect(jobQueue.addJob).toHaveBeenCalledWith(MONITOR_ID, monitor);
expect(result).toBe(monitor);
});
it("strips a stray proxyId when proxyMode is not custom", async () => {