Compare commits

...
Author SHA1 Message Date
mannilakash 0fc013246d fix: displaying valid metrics and removing type assertions 2026-04-08 17:54:00 -04:00
mannilakash 5a8d0432ff fix: naming conventions changes and using more native props 2026-04-01 12:45:43 -04:00
mannilakash 866388c09e fix: refactoring changes and removing unnecessary changes 2026-03-30 13:00:20 -04:00
mannilakash c7d93311ce adding changes to adhere to timescale db changes 2026-03-30 08:24:44 -04:00
mannilakash 41fa2717a9 fix: changes fixing type errors and adding colorFn comment 2026-03-30 08:15:43 -04:00
mannilakash 6cea199e4f feat: statusPage - adding pagespeed section 2026-03-30 08:15:43 -04:00
Alexander Holliday 8ab049aa6c Merge pull request #3447 from bluewave-labs/feat/dlq
Feat(DLQ): Add DLQ data layer -> types, model, and repository
2026-03-27 12:35:31 -07:00
Alexander Holliday 69b6955ced Merge pull request #3439 from LeC-D/fix/telegram-notification-type
feat(notifications): add Telegram notification channel (#2753)
2026-03-27 12:04:01 -07:00
LeC-D 04dfff4ebd fix(notifications): remove redundant defaultValue prop and fix incident URL in telegram provider
- Remove redundant 'defaultValue' prop on Telegram chat ID Controller in create/index.tsx;
  the form already initialises via 'defaultValues: defaults' at the useForm level, making
  the per-Controller prop unnecessary and inconsistent with the reviewer's expectation.
- Fix 'View Incident' URL in telegram.ts: was pointing to the infrastructure details page
  (/infrastructure/:id) instead of the incidents page (/incidents/:id).
2026-03-27 14:19:27 -04:00
LeC-D 13a7ae82e3 fix(notifications): remove type casts with exhaustive type narrowing in useNotificationForm
Replace the loose NotificationDefaults intermediate type and 'as NotificationFormData'
cast with an exhaustive buildDefaults() helper that narrows each branch of the
discriminated union explicitly. TypeScript now infers the return type correctly from
the discriminated union without any casts, satisfying strong typing for all channels
(email, slack, discord, webhook, pager_duty, teams, matrix, telegram).
2026-03-26 14:07:50 -04:00
Leandre Chamberland-Dozois 4c94a7c21e fix: resolve TypeScript build errors in notification form types 2026-03-25 19:13:37 -04:00
LeC-D 40968c370b refactor(notifications): hoist default values, remove else block in useNotificationForm
Per @ajhollid review: define defaults up-front with a NotificationDefaults type
and eliminate the trailing else branch entirely. The if/else-if blocks only
override when the type is matrix or telegram.
2026-03-25 18:06:26 -04:00
LeC-DandClaude Opus 4.6 a864bc0c05 fix: run format and fix build errors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 16:52:48 -04:00
LeC-D fe242b1977 fix: replace nested ternaries and remove type casts per review
- useNotificationForm: replace nested ternary with if/else if/else block
- TelegramProvider: type error as unknown and access properties safely
  via instanceof checks instead of casting to HTTPError
2026-03-25 14:03:49 -04:00
LeC-D d1f1d31ea2 fix(i18n): revert en.json to upstream format, keep only telegram keys
The previous commit reformatted the entire en.json (spaces → kept as-is
but accumulated unrelated upstream changes), creating a 1.3k line diff.

Restore the file to match upstream/develop exactly and add only the
Telegram-specific i18n keys required by this PR.
2026-03-24 17:22:34 -04:00
Léo eacbb227c3 feat(notifications): add Telegram notification channel (#2753)
Add Telegram as a new notification type, allowing users to receive
monitor alerts via a Telegram bot.

Configuration requires:
- Bot token (obtained from @BotFather)
- Chat ID (user, group, or channel)

Changes:
- TelegramProvider: sends HTML-formatted messages via Telegram Bot API
- NotificationsService: route telegram type to TelegramProvider
- Notification model/types: add 'telegram' to NotificationChannel enum
- Server validation: telegram schema (accessToken + address/chat_id)
- Client Types/Validation: telegram schema with accessToken + address
- useNotificationForm: handle telegram defaults
- Notifications create page: render Telegram config UI
- en.json: add telegram i18n keys
2026-03-24 10:15:46 -04:00
29 changed files with 499 additions and 44 deletions

No files matched your search

@@ -3,7 +3,7 @@ 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 { useTheme, type Theme } from "@mui/material/styles";
import { useMemo, useState, useEffect } from "react";
import { getInfraGaugeColor } from "@/Utils/MonitorUtils";
@@ -17,6 +17,10 @@ export const Gauge = ({
strokeWidth = 15,
precision = 1,
unit = "%",
// colorFn lets callers override the default color logic.
// The Gauge was originally designed for metrics where higher values are worse (e.g., CPU usage).
// PageSpeed scores are the opposite—higher is better—so a custom colorFn can be used to invert the behavior.
colorFn,
}: {
isLoading?: boolean;
progress?: number;
@@ -24,6 +28,7 @@ export const Gauge = ({
strokeWidth?: number;
precision?: number;
unit?: string;
colorFn?: (val: number, theme: Theme) => string;
}) => {
const theme = useTheme();
const progressWithinRange = Math.max(MINIMUM_VALUE, Math.min(progress, MAXIMUM_VALUE));
@@ -48,7 +53,8 @@ export const Gauge = ({
return () => clearTimeout(timer);
}, [progress, circumference, strokeLength]);
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
const resolvedColorFn = colorFn ?? getInfraGaugeColor;
const fillColor = resolvedColorFn(progressWithinRange, theme);
if (isLoading) {
return;
+63 -15
View File
@@ -1,28 +1,76 @@
import { useMemo } from "react";
import { notificationSchema } from "@/Validation/notifications";
import type { NotificationFormData } from "@/Validation/notifications";
import type { Notification } from "@/Types/Notification";
interface UseNotificationFormOptions {
data?: Notification | null;
}
function buildDefaults(data: Notification | null): NotificationFormData {
if (data?.type === "matrix") {
return {
type: "matrix",
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "telegram") {
return {
type: "telegram",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "slack") {
return {
type: "slack",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "discord") {
return {
type: "discord",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "webhook") {
return {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "pager_duty") {
return {
type: "pager_duty",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "teams") {
return {
type: "teams",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: "email",
notificationName: data?.notificationName || "",
address: data?.address || "",
};
}
export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => {
return useMemo(() => {
const defaults =
data?.type === "matrix"
? {
type: "matrix" as const,
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
}
: {
type: (data?.type || "email") as Exclude<Notification["type"], "matrix">,
notificationName: data?.notificationName || "",
address: data?.address || "",
};
const defaults = buildDefaults(data);
return { schema: notificationSchema, defaults };
}, [data]);
};
+1
View File
@@ -35,6 +35,7 @@ export const useStatusPageForm = ({
showUptimePercentage: data?.showUptimePercentage ?? true,
showAdminLoginLink: data?.showAdminLoginLink ?? false,
showInfrastructure: data?.showInfrastructure ?? false,
showPageSpeed: data?.showPageSpeed ?? false,
customCSS: data?.customCSS || "",
logo: transformLogo(data?.logo),
};
@@ -147,7 +147,7 @@ const NotificationsCreatePage = () => {
/>
}
/>
{watchedType !== "matrix" && (
{watchedType !== "matrix" && watchedType !== "telegram" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
@@ -155,7 +155,7 @@ const NotificationsCreatePage = () => {
<Controller
name="address"
control={control}
defaultValue={defaults.address}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -171,6 +171,49 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
subtitle={t("pages.notifications.form.telegram.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
placeholder={t(
"pages.notifications.form.telegram.placeholderBotToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
@@ -180,7 +223,7 @@ const NotificationsCreatePage = () => {
<Controller
name="homeserverUrl"
control={control}
defaultValue={defaults.homeserverUrl}
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -196,7 +239,7 @@ const NotificationsCreatePage = () => {
<Controller
name="roomId"
control={control}
defaultValue={defaults.roomId}
defaultValue={"roomId" in defaults ? defaults.roomId : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -212,7 +255,7 @@ const NotificationsCreatePage = () => {
<Controller
name="accessToken"
control={control}
defaultValue={defaults.accessToken}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
+36 -8
View File
@@ -28,7 +28,7 @@ import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
import type { StatusPageFormData } from "@/Validation/statusPage";
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
import type { StatusPageType, StatusPageResponse } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom";
@@ -37,9 +37,17 @@ import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusContr
const monitorsUrl = (() => {
const params = new URLSearchParams();
["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach(
(type) => params.append("type", type)
);
[
"http",
"ping",
"port",
"docker",
"game",
"grpc",
"websocket",
"hardware",
"pagespeed",
].forEach((type) => params.append("type", type));
return `/monitors/team?${params.toString()}`;
})();
@@ -59,7 +67,9 @@ const CreateStatusPage = () => {
// Fetch existing status page data when configuring
const { data: statusPageData, isLoading: isLoadingStatusPage } =
useGet<StatusPageResponse>(
isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
isCreate
? null
: `/status-page/${url}?type=uptime&type=infrastructure&type=pagespeed`
);
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
@@ -91,14 +101,16 @@ const CreateStatusPage = () => {
}, [defaults, reset]);
const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
const computedTypes: MonitorDisplayType[] = useMemo(() => {
const computedTypes: StatusPageType[] = useMemo(() => {
const selectedMonitors = (watchedMonitorIds ?? [])
.map((id) => monitors.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
const typesSet = new Set<MonitorDisplayType>();
const typesSet = new Set<StatusPageType>();
selectedMonitors.forEach((m) => {
typesSet.add(m.type === "hardware" ? "infrastructure" : "uptime");
if (m.type === "hardware") typesSet.add("infrastructure");
else if (m.type === "pagespeed") typesSet.add("pagespeed");
else typesSet.add("uptime");
});
return typesSet.size ? Array.from(typesSet) : ["uptime"];
@@ -139,6 +151,7 @@ const CreateStatusPage = () => {
fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
fd.append("showPageSpeed", String(data.showPageSpeed));
data.monitors.forEach((monitorId) => {
fd.append("monitors[]", monitorId);
@@ -475,6 +488,21 @@ const CreateStatusPage = () => {
/>
)}
/>
<Controller
name="showPageSpeed"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t("pages.statusPages.form.features.option.showPageSpeed.label")}
/>
)}
/>
{/* <Controller
name="showUptimePercentage"
control={control}
@@ -6,6 +6,7 @@ import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common"
import { StatusLabel, BaseBox } from "@/Components/design-elements";
import { SwitchComponent } from "@/Components/inputs";
import { InfrastructureMetrics } from "@/Pages/StatusPage/Status/Components/InfrastructureMetrics";
import { PageSpeedMetrics } from "@/Pages/StatusPage/Status/Components/PageSpeedMetrics";
import { useTheme, type Theme } from "@mui/material/styles";
import { useSelector } from "react-redux";
@@ -26,8 +27,12 @@ interface MonitorsListProps {
}
const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => {
const bg =
monitorType === "hardware" ? theme.palette.info.light : theme.palette.success.light;
const monitorColors: Record<string, string> = {
hardware: theme.palette.info.light,
pagespeed: theme.palette.warning.light,
};
const bg = monitorColors[monitorType] || theme.palette.success.light;
return {
backgroundColor: bg,
color: theme.palette.background.paper,
@@ -105,6 +110,12 @@ const MonitorContent = ({
return <InfrastructureMetrics monitor={monitor} />;
}
if (monitor.type === "pagespeed") {
return statusPage.showPageSpeed === false ? null : (
<PageSpeedMetrics monitor={monitor} />
);
}
if (statusPage.showCharts === false) return null;
const checks = monitor.checks?.slice().reverse() ?? [];
@@ -0,0 +1,122 @@
import Typography from "@mui/material/Typography";
import { Gauge } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { CheckSnapshot } from "@/Types/Check";
import Grid from "@mui/material/Grid";
import Box from "@mui/material/Box";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { getPageSpeedGaugeColor } from "@/Utils/MonitorUtils";
const GAUGE_RADIUS = 60;
const GAUGE_STROKE_WIDTH = 12;
interface StatusPageMonitor extends Monitor {
checks?: Monitor["recentChecks"];
}
interface MetricConfig {
key: string;
label: string;
progress: number;
}
const MetricItem = ({ label, progress }: MetricConfig) => {
const theme = useTheme();
return (
<Grid
size={{ xs: 12, md: 3 }}
display="flex"
flexDirection="column"
alignItems="center"
textAlign="center"
gap={theme.spacing(SPACING.LG)}
padding={theme.spacing(LAYOUT.XS)}
sx={{
borderRight: { xs: "none", md: `1px solid ${theme.palette.divider}` },
borderBottom: { xs: `1px solid ${theme.palette.divider}`, md: "none" },
"&:last-child": {
borderRight: "none",
borderBottom: "none",
paddingBottom: theme.spacing(SPACING.LG),
},
}}
>
<Box
display="flex"
flexDirection="column"
alignItems="center"
>
<Gauge
progress={progress}
radius={GAUGE_RADIUS}
strokeWidth={GAUGE_STROKE_WIDTH}
colorFn={getPageSpeedGaugeColor}
/>
<Typography variant="body2">{label}</Typography>
</Box>
</Grid>
);
};
const buildMetrics = (check: CheckSnapshot, t: (key: string) => string): MetricConfig[] =>
[
{
key: "performance",
label: t("pages.statusPages.monitorsList.pagespeed.performance"),
progress: check.performance,
},
{
key: "accessibility",
label: t("pages.statusPages.monitorsList.pagespeed.accessibility"),
progress: check.accessibility,
},
{
key: "bestPractices",
label: t("pages.statusPages.monitorsList.pagespeed.bestPractices"),
progress: check.bestPractices,
},
{
key: "seo",
label: t("pages.statusPages.monitorsList.pagespeed.seo"),
progress: check.seo,
},
].filter((m): m is MetricConfig => m.progress !== undefined);
export const PageSpeedMetrics = ({ monitor }: { monitor: StatusPageMonitor }) => {
const theme = useTheme();
const { t } = useTranslation();
const latestCheck = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
if (!latestCheck) {
return (
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{t("pages.statusPages.monitorsList.noData")}
</Typography>
);
}
const metrics = buildMetrics(latestCheck, t);
return (
<Grid
container
alignItems="center"
padding={theme.spacing(LAYOUT.LG)}
flex={1}
>
{metrics.map((metric) => (
<MetricItem
key={metric.key}
label={metric.label}
progress={metric.progress}
/>
))}
</Grid>
);
};
+3 -1
View File
@@ -23,7 +23,9 @@ const StatusPageView = () => {
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isPublic = location.pathname.startsWith("/status/public");
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
const apiUrl = url
? `/status-page/${url}?type=uptime&type=infrastructure&type=pagespeed`
: null;
const { data, isLoading, error } = useGet<StatusPageResponse>(
apiUrl,
+1
View File
@@ -6,6 +6,7 @@ export const NotificationChannels = [
"pager_duty",
"matrix",
"teams",
"telegram",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
+4 -2
View File
@@ -1,5 +1,6 @@
import type { Monitor, MonitorType } from "@/Types/Monitor";
export type MonitorDisplayType = "uptime" | "infrastructure";
export const StatusPageTypes = ["uptime", "infrastructure", "pagespeed"] as const;
export type StatusPageType = (typeof StatusPageTypes)[number];
export const MONITOR_TYPE_KEYS: Partial<Record<MonitorType, string>> = {
http: "pages.common.monitors.monitorTypes.optionHttp",
@@ -25,7 +26,7 @@ export interface StatusPage {
id: string;
userId: string;
teamId: string;
type: MonitorDisplayType[];
type: StatusPageType[];
companyName: string;
url: string;
timezone?: string;
@@ -42,6 +43,7 @@ export interface StatusPage {
showUptimePercentage: boolean;
showAdminLoginLink: boolean;
showInfrastructure: boolean;
showPageSpeed: boolean;
customCSS: string;
createdAt: string;
updatedAt: string;
+6
View File
@@ -78,6 +78,12 @@ export const getPageSpeedPalette = (score: number): PaletteKey => {
else return "error";
};
export const getPageSpeedGaugeColor = (val: number, theme: any) => {
if (val >= 90) return theme.palette.success.main;
else if (val >= 50) return theme.palette.warning.light;
else return theme.palette.error.light;
};
export const formatUrl = (url: string, maxLength: number = 55) => {
if (!url) return "";
+7
View File
@@ -50,6 +50,12 @@ const teamsSchema = baseSchema.extend({
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
});
const telegramSchema = baseSchema.extend({
type: z.literal("telegram"),
address: z.string().min(1, "Chat ID is required"),
accessToken: z.string().min(1, "Bot token is required"),
});
export const notificationSchema = z.discriminatedUnion("type", [
emailSchema,
slackSchema,
@@ -58,6 +64,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
pagerDutySchema,
matrixSchema,
teamsSchema,
telegramSchema,
]);
export type NotificationFormData = z.infer<typeof notificationSchema>;
+3 -3
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { StatusPageTypes } from "@/Types/StatusPage";
export const statusPageSchema = z.object({
companyName: z
@@ -14,9 +15,7 @@ export const statusPageSchema = z.object({
"URL can only contain lowercase letters, numbers, and hyphens"
),
timezone: z.string().optional(),
type: z
.array(z.enum(["uptime", "infrastructure"]))
.min(1, "At least one type is required"),
type: z.array(z.enum(StatusPageTypes)).min(1, "At least one type is required"),
color: z.string().min(1, "Color is required"),
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
isPublished: z.boolean(),
@@ -24,6 +23,7 @@ export const statusPageSchema = z.object({
showUptimePercentage: z.boolean(),
showAdminLoginLink: z.boolean(),
showInfrastructure: z.boolean(),
showPageSpeed: z.boolean(),
customCSS: z.string().optional(),
logo: z
.object({
+1
View File
@@ -324,6 +324,7 @@ const statusPageValidation = joi.object({
showCharts: joi.boolean(),
showAdminLoginLink: joi.boolean(),
showInfrastructure: joi.boolean(),
showPageSpeed: joi.boolean(),
});
const settingsValidation = joi.object({
+18
View File
@@ -951,6 +951,14 @@
"description": "Select the type of notification channel to create.",
"optionType": "Type",
"title": "Channel type"
},
"telegram": {
"title": "Telegram configuration",
"description": "Configure your Telegram bot to send notifications.",
"optionBotToken": "Bot token",
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "Chat ID",
"placeholderChatId": "-1001234567890"
}
},
"table": {
@@ -1194,6 +1202,13 @@
"used": "Used",
"total": "Total"
},
"pagespeed": {
"title": "PageSpeed",
"performance": "Performance",
"accessibility": "Accessibility",
"bestPractices": "Best Practices",
"seo": "SEO"
},
"uptime": {
"title": "Uptime",
"responseTime": "Response time"
@@ -1292,6 +1307,9 @@
},
"showInfrastructure": {
"label": "Show infrastructure metrics"
},
"showPageSpeed": {
"label": "Show PageSpeed scores"
}
}
}
+3
View File
@@ -28,6 +28,7 @@ import {
PagerDutyProvider,
MatrixProvider,
TeamsProvider,
TelegramProvider,
// Interfaces
INetworkService,
IEmailService,
@@ -296,6 +297,7 @@ export const initializeServices = async ({
const pagerDutyProvider = new PagerDutyProvider(logger);
const matrixProvider = new MatrixProvider(logger);
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const notificationsService = new NotificationsService(
notificationsRepository,
@@ -307,6 +309,7 @@ export const initializeServices = async ({
pagerDutyProvider,
matrixProvider,
teamsProvider,
telegramProvider,
settingsService,
logger,
notificationMessageBuilder
@@ -0,0 +1,15 @@
import type { Pool } from "pg";
export const addShowPageSpeedToStatusPages = async (pool: Pool) => {
await pool.query(`
ALTER TABLE status_pages
ADD COLUMN IF NOT EXISTS show_page_speed BOOLEAN DEFAULT FALSE;
`);
};
export const dropShowPageSpeedFromStatusPages = async (pool: Pool) => {
await pool.query(`
ALTER TABLE status_pages
DROP COLUMN IF EXISTS show_page_speed;
`);
};
@@ -21,6 +21,7 @@ import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.j
import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js";
import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js";
import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js";
import { addShowPageSpeedToStatusPages, dropShowPageSpeedFromStatusPages } from "./0022_add_show_page_speed_to_status_pages.js";
const SERVICE_NAME = "TimescaleDB Migrations";
@@ -52,6 +53,7 @@ const migrations: MigrationEntry[] = [
{ name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings },
{ name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates },
{ name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression },
{ name: "0022_add_show_page_speed_to_status_pages", up: addShowPageSpeedToStatusPages, down: dropShowPageSpeedFromStatusPages },
];
const ensureMigrationsTable = async (pool: Pool) => {
+1 -1
View File
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
},
type: {
type: String,
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as NotificationChannel[],
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as NotificationChannel[],
required: true,
},
notificationName: {
+4
View File
@@ -104,6 +104,10 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
type: Boolean,
default: false,
},
showPageSpeed: {
type: Boolean,
default: false,
},
customCSS: {
type: String,
default: "",
@@ -58,6 +58,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
showUptimePercentage: doc.showUptimePercentage,
showAdminLoginLink: doc.showAdminLoginLink,
showInfrastructure: doc.showInfrastructure,
showPageSpeed: doc.showPageSpeed,
customCSS: doc.customCSS,
createdAt: this.toDateString(doc.createdAt),
updatedAt: this.toDateString(doc.updatedAt),
@@ -26,6 +26,7 @@ interface StatusPageRow {
show_uptime_percentage: boolean;
show_admin_login_link: boolean;
show_infrastructure: boolean;
show_page_speed: boolean;
custom_css: string | null;
created_at: Date;
updated_at: Date;
@@ -33,7 +34,7 @@ interface StatusPageRow {
const COLUMNS = `id, user_id, team_id, types, company_name, url, timezone, color,
logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage,
show_admin_login_link, show_infrastructure, custom_css, created_at, updated_at`;
show_admin_login_link, show_infrastructure, show_page_speed, custom_css, created_at, updated_at`;
export class TimescaleStatusPagesRepository implements IStatusPagesRepository {
constructor(private pool: Pool) {}
@@ -42,8 +43,8 @@ export class TimescaleStatusPagesRepository implements IStatusPagesRepository {
const result = await this.pool.query<StatusPageRow>(
`INSERT INTO status_pages (user_id, team_id, types, company_name, url, timezone, color,
logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage,
show_admin_login_link, show_infrastructure, custom_css)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
show_admin_login_link, show_infrastructure, show_page_speed, custom_css)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
RETURNING ${COLUMNS}`,
[
userId,
@@ -60,6 +61,7 @@ export class TimescaleStatusPagesRepository implements IStatusPagesRepository {
data.showUptimePercentage ?? false,
data.showAdminLoginLink ?? false,
data.showInfrastructure ?? false,
data.showPageSpeed ?? false,
data.customCSS ?? null,
]
);
@@ -128,6 +130,7 @@ export class TimescaleStatusPagesRepository implements IStatusPagesRepository {
["showUptimePercentage", "show_uptime_percentage"],
["showAdminLoginLink", "show_admin_login_link"],
["showInfrastructure", "show_infrastructure"],
["showPageSpeed", "show_page_speed"],
["customCSS", "custom_css"],
];
@@ -252,6 +255,7 @@ export class TimescaleStatusPagesRepository implements IStatusPagesRepository {
showUptimePercentage: row.show_uptime_percentage,
showAdminLoginLink: row.show_admin_login_link,
showInfrastructure: row.show_infrastructure,
showPageSpeed: row.show_page_speed,
customCSS: row.custom_css ?? "",
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
+1
View File
@@ -29,6 +29,7 @@ export * from "@/service/infrastructure/notificationProviders/pagerduty.js";
export * from "@/service/infrastructure/notificationProviders/slack.js";
export * from "@/service/infrastructure/notificationProviders/teams.js";
export * from "@/service/infrastructure/notificationProviders/webhook.js";
export * from "@/service/infrastructure/notificationProviders/telegram.js";
// System services
export * from "@/service/system/settingsService.js";
@@ -0,0 +1,113 @@
const SERVICE_NAME = "TelegramProvider";
import type { Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got from "got";
import { ILogger } from "@/utils/logger.js";
export class TelegramProvider implements INotificationProvider {
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
try {
await got.post(`https://api.telegram.org/bot${notification.accessToken}/sendMessage`, {
json: {
chat_id: notification.address,
text: getTestMessage(),
parse_mode: "HTML",
},
});
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: "Telegram 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;
}
const text = this.buildTelegramText(message);
try {
await got.post(`https://api.telegram.org/bot${notification.accessToken}/sendMessage`, {
json: {
chat_id: notification.address,
text,
parse_mode: "HTML",
},
});
this.logger.info({
message: "Telegram 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: "Telegram alert failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
private buildTelegramText(message: NotificationMessage): string {
const lines: string[] = [];
lines.push(`<b>${message.content.title}</b>`);
lines.push(message.content.summary);
lines.push("");
lines.push("<b>Monitor Details:</b>");
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("<b>Additional Information:</b>");
message.content.details.forEach((detail) => lines.push(`• ${detail}`));
}
if (message.content.thresholds && message.content.thresholds.length > 0) {
lines.push("");
lines.push("<b>Threshold Breaches:</b>");
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(`<a href="${message.clientHost}/incidents/${message.monitor.id}">View Incident</a>`);
}
return lines.join("\n");
}
}
@@ -33,6 +33,7 @@ export class NotificationsService implements INotificationsService {
private pagerDutyProvider: INotificationProvider;
private matrixProvider: INotificationProvider;
private teamsProvider: INotificationProvider;
private telegramProvider: INotificationProvider;
private logger: ILogger;
private settingsService: ISettingsService;
private notificationMessageBuilder: INotificationMessageBuilder;
@@ -47,6 +48,7 @@ export class NotificationsService implements INotificationsService {
pagerDutyProvider: INotificationProvider,
matrixProvider: INotificationProvider,
teamsProvider: INotificationProvider,
telegramProvider: INotificationProvider,
settingsService: ISettingsService,
logger: ILogger,
notificationMessageBuilder: INotificationMessageBuilder
@@ -60,6 +62,7 @@ export class NotificationsService implements INotificationsService {
this.pagerDutyProvider = pagerDutyProvider;
this.matrixProvider = matrixProvider;
this.teamsProvider = teamsProvider;
this.telegramProvider = telegramProvider;
this.settingsService = settingsService;
this.logger = logger;
this.notificationMessageBuilder = notificationMessageBuilder;
@@ -97,6 +100,8 @@ export class NotificationsService implements INotificationsService {
return await this.emailProvider.sendMessage!(notification, notificationMessage);
case "teams":
return await this.teamsProvider.sendMessage!(notification, notificationMessage);
case "telegram":
return await this.telegramProvider.sendMessage!(notification, notificationMessage);
default:
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
@@ -157,6 +162,8 @@ export class NotificationsService implements INotificationsService {
return await this.webhookProvider.sendTestAlert(notification);
case "teams":
return await this.teamsProvider.sendTestAlert(notification);
case "telegram":
return await this.telegramProvider.sendTestAlert(notification);
default:
return false;
}
+1 -1
View File
@@ -1,4 +1,4 @@
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as const;
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
export interface Notification {
+2 -1
View File
@@ -1,4 +1,4 @@
export const StatusPageTypes = ["uptime", "infrastructure"] as const;
export const StatusPageTypes = ["uptime", "infrastructure", "pagespeed"] as const;
export type StatusPageType = (typeof StatusPageTypes)[number];
export interface StatusPageLogo {
@@ -29,6 +29,7 @@ export interface StatusPage {
showUptimePercentage: boolean;
showAdminLoginLink: boolean;
showInfrastructure: boolean;
showPageSpeed: boolean;
customCSS: string;
createdAt: string;
updatedAt: string;
@@ -65,6 +65,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
type: z.literal("teams"),
address: z.url({ message: "Please enter a valid Webhook URL" }),
}),
// Telegram notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("telegram"),
address: z.string().min(1, "Chat ID is required"),
accessToken: z.string().min(1, "Bot token is required"),
}),
]);
export const testNotificationBodyValidation = createNotificationBodyValidation;
@@ -32,6 +32,7 @@ export const createStatusPageBodyValidation = z
showUptimePercentage: booleanCoercion,
showAdminLoginLink: booleanCoercion.optional(),
showInfrastructure: booleanCoercion.optional(),
showPageSpeed: booleanCoercion.optional(),
removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(),
})
.strip();