Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64a2c69540 | ||
|
|
c4ec14db5d | ||
|
|
4560820649 | ||
|
|
3051bc7edc | ||
|
|
22232489a8 | ||
|
|
04277a6eb4 | ||
|
|
a8dc867e78 | ||
|
|
533783ba3e | ||
|
|
fd7b3e5f20 | ||
|
|
ae4b851620 | ||
|
|
e83f97a05a | ||
|
|
070682af74 | ||
|
|
8609e4bca0 | ||
|
|
2661d7a384 | ||
|
|
5b9d554f46 | ||
|
|
7fb8bab4be | ||
|
|
75d29124b6 | ||
|
|
1f0ea6cedc | ||
|
|
07a38b9c2c | ||
|
|
67806c94b1 | ||
|
|
2eeb088b61 | ||
|
|
9c796edbde | ||
|
|
84a1b994a1 | ||
|
|
db4689207a | ||
|
|
af465766c8 | ||
|
|
c13f26a4c3 |
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import NotFoundSvg from "@/assets/Images/sushi_404.svg";
|
||||
import NotFoundSvg from "@/assets/Images/sushi_404.svg?react";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
@@ -36,10 +36,11 @@ const NotFoundPage = ({ title, desc }: NotFoundProps) => {
|
||||
alignItems="center"
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={NotFoundSvg}
|
||||
alt="404"
|
||||
maxHeight={"25rem"}
|
||||
component={NotFoundSvg}
|
||||
role="img"
|
||||
aria-label="404"
|
||||
width="100%"
|
||||
maxHeight="25rem"
|
||||
/>
|
||||
<Typography
|
||||
component="h1"
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const NotificationChannels = [
|
||||
"teams",
|
||||
"telegram",
|
||||
"pushover",
|
||||
"signalgrid",
|
||||
"twilio",
|
||||
"ntfy",
|
||||
] as const;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
+1290
-1574
File diff suppressed because it is too large.
Load diff
|
Before Width: | Height: | Size: 536 KiB After Width: | Height: | Size: 561 KiB |
@@ -985,6 +985,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 +1251,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 +1333,7 @@
|
||||
"teams": "Teams",
|
||||
"telegram": "Telegram",
|
||||
"pushover": "Pushover",
|
||||
"signalgrid": "Signalgrid",
|
||||
"twilio": "Twilio",
|
||||
"ntfy": "ntfy"
|
||||
}
|
||||
@@ -1349,6 +1354,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.",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user