From d6e08649866d92462e92709e44df737dcd2e4c00 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 13:12:40 +0000 Subject: [PATCH 01/12] feat: add notification channels schema, migration, and delivery drivers Add NotificationChannel and AlertRuleChannel models to Prisma schema with corresponding SQL migration. Create channel delivery service with drivers for Slack (Block Kit), Email (nodemailer/SMTP), PagerDuty (Events API v2), and generic Webhook (HMAC-signed). Install nodemailer dependency. --- package.json | 2 + pnpm-lock.yaml | 41 ++++-- .../migration.sql | 37 ++++++ prisma/schema.prisma | 34 ++++- src/server/services/channels/email.ts | 108 +++++++++++++++ src/server/services/channels/index.ts | 103 +++++++++++++++ src/server/services/channels/pagerduty.ts | 123 ++++++++++++++++++ src/server/services/channels/slack.ts | 123 ++++++++++++++++++ src/server/services/channels/types.ts | 30 +++++ src/server/services/channels/webhook.ts | 90 +++++++++++++ 10 files changed, 680 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/20260307100000_add_notification_channels/migration.sql create mode 100644 src/server/services/channels/email.ts create mode 100644 src/server/services/channels/index.ts create mode 100644 src/server/services/channels/pagerduty.ts create mode 100644 src/server/services/channels/slack.ts create mode 100644 src/server/services/channels/types.ts create mode 100644 src/server/services/channels/webhook.ts diff --git a/package.json b/package.json index 5cb628ba..b25d16ef 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "next-auth": "5.0.0-beta.30", "next-themes": "^0.4.6", "node-cron": "^4.2.1", + "nodemailer": "^8.0.1", "otpauth": "^9.5.0", "qrcode": "^1.5.4", "radix-ui": "^1.4.3", @@ -67,6 +68,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^20", "@types/node-cron": "^3.0.11", + "@types/nodemailer": "^7.0.11", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b8e2528..ccf5a90b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,7 +14,7 @@ importers: dependencies: '@auth/prisma-adapter': specifier: ^2.11.1 - version: 2.11.1(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)) + version: 2.11.1(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(nodemailer@8.0.1) '@dagrejs/dagre': specifier: ^2.0.4 version: 2.0.4 @@ -80,13 +80,16 @@ importers: version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-auth: specifier: 5.0.0-beta.30 - version: 5.0.0-beta.30(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 5.0.0-beta.30(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@8.0.1)(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) node-cron: specifier: ^4.2.1 version: 4.2.1 + nodemailer: + specifier: ^8.0.1 + version: 8.0.1 otpauth: specifier: ^9.5.0 version: 9.5.0 @@ -145,6 +148,9 @@ importers: '@types/node-cron': specifier: ^3.0.11 version: 3.0.11 + '@types/nodemailer': + specifier: ^7.0.11 + version: 7.0.11 '@types/react': specifier: ^19 version: 19.2.14 @@ -1915,6 +1921,9 @@ packages: '@types/node@20.19.35': resolution: {integrity: sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==} + '@types/nodemailer@7.0.11': + resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==} + '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -3737,6 +3746,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + nodemailer@8.0.1: + resolution: {integrity: sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==} + engines: {node: '>=6.0.0'} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -4814,25 +4827,29 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@auth/core@0.41.0': + '@auth/core@0.41.0(nodemailer@8.0.1)': dependencies: '@panva/hkdf': 1.2.1 jose: 6.1.3 oauth4webapi: 3.8.5 preact: 10.24.3 preact-render-to-string: 6.5.11(preact@10.24.3) + optionalDependencies: + nodemailer: 8.0.1 - '@auth/core@0.41.1': + '@auth/core@0.41.1(nodemailer@8.0.1)': dependencies: '@panva/hkdf': 1.2.1 jose: 6.1.3 oauth4webapi: 3.8.5 preact: 10.24.3 preact-render-to-string: 6.5.11(preact@10.24.3) + optionalDependencies: + nodemailer: 8.0.1 - '@auth/prisma-adapter@2.11.1(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))': + '@auth/prisma-adapter@2.11.1(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(nodemailer@8.0.1)': dependencies: - '@auth/core': 0.41.1 + '@auth/core': 0.41.1(nodemailer@8.0.1) '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) transitivePeerDependencies: - '@simplewebauthn/browser' @@ -6533,6 +6550,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@7.0.11': + dependencies: + '@types/node': 20.19.35 + '@types/qrcode@1.5.6': dependencies: '@types/node': 20.19.35 @@ -8409,11 +8430,13 @@ snapshots: negotiator@1.0.0: {} - next-auth@5.0.0-beta.30(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): + next-auth@5.0.0-beta.30(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@8.0.1)(react@19.2.3): dependencies: - '@auth/core': 0.41.0 + '@auth/core': 0.41.0(nodemailer@8.0.1) next: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 + optionalDependencies: + nodemailer: 8.0.1 next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: @@ -8465,6 +8488,8 @@ snapshots: node-releases@2.0.27: {} + nodemailer@8.0.1: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 diff --git a/prisma/migrations/20260307100000_add_notification_channels/migration.sql b/prisma/migrations/20260307100000_add_notification_channels/migration.sql new file mode 100644 index 00000000..c1ce720a --- /dev/null +++ b/prisma/migrations/20260307100000_add_notification_channels/migration.sql @@ -0,0 +1,37 @@ +-- CreateTable +CREATE TABLE "NotificationChannel" ( + "id" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "type" TEXT NOT NULL, + "config" JSONB NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NotificationChannel_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AlertRuleChannel" ( + "id" TEXT NOT NULL, + "alertRuleId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + + CONSTRAINT "AlertRuleChannel_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "NotificationChannel_environmentId_idx" ON "NotificationChannel"("environmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AlertRuleChannel_alertRuleId_channelId_key" ON "AlertRuleChannel"("alertRuleId", "channelId"); + +-- AddForeignKey +ALTER TABLE "NotificationChannel" ADD CONSTRAINT "NotificationChannel_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AlertRuleChannel" ADD CONSTRAINT "AlertRuleChannel_alertRuleId_fkey" FOREIGN KEY ("alertRuleId") REFERENCES "AlertRule"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AlertRuleChannel" ADD CONSTRAINT "AlertRuleChannel_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "NotificationChannel"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c30c9b3a..998c445e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -83,9 +83,10 @@ model Environment { gitRepoUrl String? gitBranch String? @default("main") gitToken String? // Stored encrypted via crypto.ts - alertRules AlertRule[] - alertWebhooks AlertWebhook[] - createdAt DateTime @default(now()) + alertRules AlertRule[] + alertWebhooks AlertWebhook[] + notificationChannels NotificationChannel[] + createdAt DateTime @default(now()) } model VectorNode { @@ -524,6 +525,7 @@ model AlertRule { threshold Float durationSeconds Int @default(60) events AlertEvent[] + channels AlertRuleChannel[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -576,3 +578,29 @@ model DashboardView { @@index([userId]) } + +model NotificationChannel { + id String @id @default(cuid()) + environmentId String + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + name String + type String // "slack" | "email" | "pagerduty" | "webhook" + config Json // Type-specific config + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + alertRules AlertRuleChannel[] + + @@index([environmentId]) +} + +model AlertRuleChannel { + id String @id @default(cuid()) + alertRuleId String + alertRule AlertRule @relation(fields: [alertRuleId], references: [id], onDelete: Cascade) + channelId String + channel NotificationChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) + + @@unique([alertRuleId, channelId]) +} diff --git a/src/server/services/channels/email.ts b/src/server/services/channels/email.ts new file mode 100644 index 00000000..7d1de490 --- /dev/null +++ b/src/server/services/channels/email.ts @@ -0,0 +1,108 @@ +import nodemailer from "nodemailer"; +import type { ChannelDriver, ChannelPayload, ChannelDeliveryResult } from "./types"; + +function buildHtml(payload: ChannelPayload): string { + const statusColor = payload.status === "firing" ? "#dc2626" : "#16a34a"; + const statusLabel = payload.status === "firing" ? "FIRING" : "RESOLVED"; + const statusEmoji = payload.status === "firing" ? "\ud83d\udd34" : "\u2705"; + + return ` + + + + +
+
+

${statusEmoji} Alert ${statusLabel}: ${payload.ruleName}

+
+
+

${payload.message}

+ + + + + + + ${payload.node ? `` : ""} + ${payload.pipeline ? `` : ""} + ${payload.team ? `` : ""} + +
Metric${payload.metric}
Value${payload.value.toFixed(2)}
Threshold${payload.threshold}
Severity${payload.severity}
Environment${payload.environment}
Node${payload.node}
Pipeline${payload.pipeline}
Team${payload.team}
Time${payload.timestamp}
+
+ View in Dashboard +
+
+
+ +`.trim(); +} + +export const emailDriver: ChannelDriver = { + async deliver( + config: Record, + payload: ChannelPayload, + ): Promise { + const smtpHost = config.smtpHost as string; + const smtpPort = config.smtpPort as number; + const smtpUser = config.smtpUser as string | undefined; + const smtpPass = config.smtpPass as string | undefined; + const from = config.from as string; + const recipients = config.recipients as string[]; + + if (!smtpHost || !smtpPort || !from || !recipients?.length) { + return { + channelId: "", + success: false, + error: "Missing required email config (smtpHost, smtpPort, from, recipients)", + }; + } + + const statusLabel = payload.status === "firing" ? "FIRING" : "RESOLVED"; + const subject = `[VectorFlow] Alert ${statusLabel}: ${payload.ruleName}`; + + try { + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpPort === 465, + ...(smtpUser && smtpPass + ? { auth: { user: smtpUser, pass: smtpPass } } + : {}), + }); + + await transporter.sendMail({ + from, + to: recipients.join(", "), + subject, + html: buildHtml(payload), + }); + + return { channelId: "", success: true }; + } catch (err) { + return { + channelId: "", + success: false, + error: err instanceof Error ? err.message : "Unknown email error", + }; + } + }, + + async test(config: Record): Promise { + const testPayload: ChannelPayload = { + alertId: "test-alert-id", + status: "firing", + ruleName: "Test Alert Rule", + severity: "warning", + environment: "Test Environment", + node: "test-node.example.com", + metric: "cpu_usage", + value: 85.5, + threshold: 80, + message: "This is a test alert from VectorFlow.", + timestamp: new Date().toISOString(), + dashboardUrl: `${process.env.NEXTAUTH_URL ?? ""}/alerts`, + }; + + return this.deliver(config, testPayload); + }, +}; diff --git a/src/server/services/channels/index.ts b/src/server/services/channels/index.ts new file mode 100644 index 00000000..a4bad240 --- /dev/null +++ b/src/server/services/channels/index.ts @@ -0,0 +1,103 @@ +import { prisma } from "@/lib/prisma"; +import type { ChannelDriver, ChannelPayload, ChannelDeliveryResult } from "./types"; +import { slackDriver } from "./slack"; +import { emailDriver } from "./email"; +import { pagerdutyDriver } from "./pagerduty"; +import { webhookDriver } from "./webhook"; + +export type { ChannelPayload, ChannelDeliveryResult, ChannelDriver }; + +const drivers: Record = { + slack: slackDriver, + email: emailDriver, + pagerduty: pagerdutyDriver, + webhook: webhookDriver, +}; + +/** + * Get the channel driver for a given type. + * Throws if the type is not supported. + */ +export function getDriver(type: string): ChannelDriver { + const driver = drivers[type]; + if (!driver) { + throw new Error(`Unsupported notification channel type: ${type}`); + } + return driver; +} + +/** + * Deliver a payload to all relevant notification channels for an environment. + * + * If alertRuleId is provided, delivers only to channels linked via + * AlertRuleChannel. Falls back to all enabled channels in the environment + * if no specific channels are linked. + */ +export async function deliverToChannels( + environmentId: string, + alertRuleId: string | null, + payload: ChannelPayload, +): Promise { + let channels: Array<{ + id: string; + type: string; + config: unknown; + }>; + + if (alertRuleId) { + // Find channels explicitly linked to this alert rule + const linkedChannels = await prisma.alertRuleChannel.findMany({ + where: { alertRuleId }, + include: { + channel: { + select: { id: true, type: true, config: true, enabled: true }, + }, + }, + }); + + const enabledLinked = linkedChannels + .filter((lc) => lc.channel.enabled) + .map((lc) => lc.channel); + + if (enabledLinked.length > 0) { + channels = enabledLinked; + } else { + // Fall back to all enabled channels in the environment + channels = await prisma.notificationChannel.findMany({ + where: { environmentId, enabled: true }, + select: { id: true, type: true, config: true }, + }); + } + } else { + // No specific rule — use all enabled channels in the environment + channels = await prisma.notificationChannel.findMany({ + where: { environmentId, enabled: true }, + select: { id: true, type: true, config: true }, + }); + } + + const results: ChannelDeliveryResult[] = []; + + for (const channel of channels) { + try { + const driver = getDriver(channel.type); + const result = await driver.deliver( + channel.config as Record, + payload, + ); + results.push({ ...result, channelId: channel.id }); + } catch (err) { + console.error( + `Channel delivery error (${channel.type} / ${channel.id}):`, + err, + ); + results.push({ + channelId: channel.id, + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }); + } + } + + return results; +} diff --git a/src/server/services/channels/pagerduty.ts b/src/server/services/channels/pagerduty.ts new file mode 100644 index 00000000..18821139 --- /dev/null +++ b/src/server/services/channels/pagerduty.ts @@ -0,0 +1,123 @@ +import type { ChannelDriver, ChannelPayload, ChannelDeliveryResult } from "./types"; + +const PAGERDUTY_EVENTS_URL = "https://events.pagerduty.com/v2/enqueue"; + +const DEFAULT_SEVERITY_MAP: Record = { + critical: "critical", + warning: "warning", + info: "info", +}; + +export const pagerdutyDriver: ChannelDriver = { + async deliver( + config: Record, + payload: ChannelPayload, + ): Promise { + const integrationKey = config.integrationKey as string; + if (!integrationKey) { + return { + channelId: "", + success: false, + error: "Missing integrationKey in config", + }; + } + + const severityMap = { + ...DEFAULT_SEVERITY_MAP, + ...((config.severityMap as Record) ?? {}), + }; + + const pdSeverity = severityMap[payload.severity] ?? "warning"; + + // Use alertId as dedup_key for PagerDuty incident correlation + const dedupKey = `vectorflow-${payload.alertId}`; + + const eventAction = payload.status === "firing" ? "trigger" : "resolve"; + + const pdPayload: Record = { + routing_key: integrationKey, + dedup_key: dedupKey, + event_action: eventAction, + }; + + if (eventAction === "trigger") { + pdPayload.payload = { + summary: `${payload.ruleName}: ${payload.message}`, + severity: pdSeverity, + source: payload.node ?? payload.environment, + component: payload.pipeline ?? undefined, + group: payload.environment, + class: payload.metric, + timestamp: payload.timestamp, + custom_details: { + metric: payload.metric, + value: payload.value, + threshold: payload.threshold, + environment: payload.environment, + team: payload.team, + node: payload.node, + pipeline: payload.pipeline, + dashboardUrl: payload.dashboardUrl, + }, + }; + pdPayload.links = [ + { + href: payload.dashboardUrl, + text: "View in VectorFlow Dashboard", + }, + ]; + } + + try { + const res = await fetch(PAGERDUTY_EVENTS_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(pdPayload), + signal: AbortSignal.timeout(10000), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + return { + channelId: "", + success: false, + error: `PagerDuty returned ${res.status}: ${text}`, + }; + } + + return { channelId: "", success: true }; + } catch (err) { + return { + channelId: "", + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }; + } + }, + + async test(config: Record): Promise { + // For PagerDuty, we trigger and immediately resolve a test event + const testPayload: ChannelPayload = { + alertId: `test-${Date.now()}`, + status: "firing", + ruleName: "VectorFlow Test Alert", + severity: "info", + environment: "Test Environment", + node: "test-node.example.com", + metric: "cpu_usage", + value: 85.5, + threshold: 80, + message: "This is a test alert from VectorFlow. It will auto-resolve.", + timestamp: new Date().toISOString(), + dashboardUrl: `${process.env.NEXTAUTH_URL ?? ""}/alerts`, + }; + + // Trigger + const triggerResult = await this.deliver(config, testPayload); + if (!triggerResult.success) return triggerResult; + + // Immediately resolve + const resolvePayload = { ...testPayload, status: "resolved" as const }; + return this.deliver(config, resolvePayload); + }, +}; diff --git a/src/server/services/channels/slack.ts b/src/server/services/channels/slack.ts new file mode 100644 index 00000000..a57702c7 --- /dev/null +++ b/src/server/services/channels/slack.ts @@ -0,0 +1,123 @@ +import type { ChannelDriver, ChannelPayload, ChannelDeliveryResult } from "./types"; +import { validatePublicUrl } from "@/server/services/url-validation"; + +function buildSlackBlocks(payload: ChannelPayload) { + const statusEmoji = payload.status === "firing" ? "\ud83d\udd34" : "\u2705"; + const statusText = payload.status === "firing" ? "FIRING" : "RESOLVED"; + + return { + blocks: [ + { + type: "header", + text: { + type: "plain_text", + text: `${statusEmoji} Alert ${statusText}: ${payload.ruleName}`, + emoji: true, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `> ${payload.message}`, + }, + }, + { + type: "section", + fields: [ + { type: "mrkdwn", text: `*Metric:*\n${payload.metric}` }, + { type: "mrkdwn", text: `*Value:*\n${payload.value.toFixed(2)}` }, + { type: "mrkdwn", text: `*Threshold:*\n${payload.threshold}` }, + { type: "mrkdwn", text: `*Severity:*\n${payload.severity}` }, + { type: "mrkdwn", text: `*Environment:*\n${payload.environment}` }, + ...(payload.node + ? [{ type: "mrkdwn", text: `*Node:*\n${payload.node}` }] + : []), + ...(payload.pipeline + ? [{ type: "mrkdwn", text: `*Pipeline:*\n${payload.pipeline}` }] + : []), + ...(payload.team + ? [{ type: "mrkdwn", text: `*Team:*\n${payload.team}` }] + : []), + ], + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `<${payload.dashboardUrl}|View in Dashboard> | ${payload.timestamp}`, + }, + ], + }, + ], + }; +} + +export const slackDriver: ChannelDriver = { + async deliver( + config: Record, + payload: ChannelPayload, + ): Promise { + const webhookUrl = config.webhookUrl as string; + if (!webhookUrl) { + return { channelId: "", success: false, error: "Missing webhookUrl in config" }; + } + + try { + await validatePublicUrl(webhookUrl); + } catch (err) { + return { + channelId: "", + success: false, + error: err instanceof Error ? err.message : "URL validation failed", + }; + } + + const body = JSON.stringify(buildSlackBlocks(payload)); + + try { + const res = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: AbortSignal.timeout(10000), + }); + + if (!res.ok) { + return { + channelId: "", + success: false, + error: `Slack webhook returned ${res.status} ${res.statusText}`, + }; + } + + return { channelId: "", success: true }; + } catch (err) { + return { + channelId: "", + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }; + } + }, + + async test(config: Record): Promise { + const testPayload: ChannelPayload = { + alertId: "test-alert-id", + status: "firing", + ruleName: "Test Alert Rule", + severity: "warning", + environment: "Test Environment", + node: "test-node.example.com", + metric: "cpu_usage", + value: 85.5, + threshold: 80, + message: "CPU usage is 85.50 (threshold: > 80)", + timestamp: new Date().toISOString(), + dashboardUrl: `${process.env.NEXTAUTH_URL ?? ""}/alerts`, + }; + + return this.deliver(config, testPayload); + }, +}; diff --git a/src/server/services/channels/types.ts b/src/server/services/channels/types.ts new file mode 100644 index 00000000..cb55a258 --- /dev/null +++ b/src/server/services/channels/types.ts @@ -0,0 +1,30 @@ +export interface ChannelPayload { + alertId: string; + status: "firing" | "resolved"; + ruleName: string; + severity: string; + environment: string; + team?: string; + node?: string; + pipeline?: string; + metric: string; + value: number; + threshold: number; + message: string; + timestamp: string; + dashboardUrl: string; +} + +export interface ChannelDeliveryResult { + channelId: string; + success: boolean; + error?: string; +} + +export interface ChannelDriver { + deliver( + config: Record, + payload: ChannelPayload, + ): Promise; + test(config: Record): Promise; +} diff --git a/src/server/services/channels/webhook.ts b/src/server/services/channels/webhook.ts new file mode 100644 index 00000000..18304a58 --- /dev/null +++ b/src/server/services/channels/webhook.ts @@ -0,0 +1,90 @@ +import crypto from "crypto"; +import type { ChannelDriver, ChannelPayload, ChannelDeliveryResult } from "./types"; +import { validatePublicUrl } from "@/server/services/url-validation"; +import { formatWebhookMessage } from "@/server/services/webhook-delivery"; + +export const webhookDriver: ChannelDriver = { + async deliver( + config: Record, + payload: ChannelPayload, + ): Promise { + const url = config.url as string; + if (!url) { + return { channelId: "", success: false, error: "Missing url in config" }; + } + + try { + await validatePublicUrl(url); + } catch (err) { + return { + channelId: "", + success: false, + error: err instanceof Error ? err.message : "URL validation failed", + }; + } + + const outgoing = { + ...payload, + content: formatWebhookMessage(payload), + }; + + const body = JSON.stringify(outgoing); + const headers: Record = { + "Content-Type": "application/json", + ...((config.headers as Record) ?? {}), + }; + + const hmacSecret = config.hmacSecret as string | undefined; + if (hmacSecret) { + const signature = crypto + .createHmac("sha256", hmacSecret) + .update(body) + .digest("hex"); + headers["X-VectorFlow-Signature"] = `sha256=${signature}`; + } + + try { + const res = await fetch(url, { + method: "POST", + headers, + body, + signal: AbortSignal.timeout(10000), + }); + + if (!res.ok) { + return { + channelId: "", + success: false, + error: `Webhook returned ${res.status} ${res.statusText}`, + }; + } + + return { channelId: "", success: true }; + } catch (err) { + return { + channelId: "", + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }; + } + }, + + async test(config: Record): Promise { + const testPayload: ChannelPayload = { + alertId: "test-alert-id", + status: "firing", + ruleName: "Test Alert Rule", + severity: "warning", + environment: "Test Environment", + node: "test-node.example.com", + metric: "cpu_usage", + value: 85.5, + threshold: 80, + message: "CPU usage is 85.50 (threshold: > 80)", + timestamp: new Date().toISOString(), + dashboardUrl: `${process.env.NEXTAUTH_URL ?? ""}/alerts`, + }; + + return this.deliver(config, testPayload); + }, +}; From 5beff8d7756152a4bacca4284f815c0c094b370e Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 13:14:50 +0000 Subject: [PATCH 02/12] feat: add notification channel CRUD procedures to alert router Add listChannels, createChannel, updateChannel, deleteChannel, and testChannel tRPC procedures. Update createRule and updateRule to accept optional channelIds for linking alert rules to specific channels. Update withTeamAccess and audit middleware to resolve NotificationChannel entities. --- src/server/middleware/audit.ts | 24 ++++ src/server/routers/alert.ts | 230 ++++++++++++++++++++++++++++++++- src/trpc/init.ts | 10 ++ 3 files changed, 261 insertions(+), 3 deletions(-) diff --git a/src/server/middleware/audit.ts b/src/server/middleware/audit.ts index fb96ed42..b279907b 100644 --- a/src/server/middleware/audit.ts +++ b/src/server/middleware/audit.ts @@ -100,6 +100,14 @@ async function resolveTeamId( return webhook?.environment.teamId ?? null; } + if (inputData.id && entityType === "NotificationChannel") { + const channel = await prisma.notificationChannel.findUnique({ + where: { id: inputData.id as string }, + select: { environment: { select: { teamId: true } } }, + }); + return channel?.environment.teamId ?? null; + } + if (inputData.id && entityType === "VrlSnippet") { const snippet = await prisma.vrlSnippet.findUnique({ where: { id: inputData.id as string }, @@ -184,6 +192,14 @@ async function resolveEnvironmentId( return webhook?.environmentId ?? null; } + if (inputData.id && entityType === "NotificationChannel") { + const channel = await prisma.notificationChannel.findUnique({ + where: { id: inputData.id as string }, + select: { environmentId: true }, + }); + return channel?.environmentId ?? null; + } + return null; } @@ -259,6 +275,14 @@ const ENTITY_LOADERS: Record Promise | null>, + NotificationChannel: (id) => + prisma.notificationChannel.findUnique({ + where: { id }, + select: { + id: true, name: true, type: true, environmentId: true, + enabled: true, createdAt: true, updatedAt: true, + }, + }) as Promise | null>, Certificate: (id) => prisma.certificate.findUnique({ where: { id }, diff --git a/src/server/routers/alert.ts b/src/server/routers/alert.ts index 2e232182..3fe9b282 100644 --- a/src/server/routers/alert.ts +++ b/src/server/routers/alert.ts @@ -10,6 +10,7 @@ import { formatWebhookMessage, } from "@/server/services/webhook-delivery"; import { validatePublicUrl } from "@/server/services/url-validation"; +import { getDriver } from "@/server/services/channels"; export const alertRouter = router({ // ─── Alert Rules ─────────────────────────────────────────────────── @@ -22,6 +23,9 @@ export const alertRouter = router({ where: { environmentId: input.environmentId }, include: { pipeline: { select: { id: true, name: true } }, + channels: { + select: { channelId: true }, + }, }, orderBy: { createdAt: "desc" }, }); @@ -38,6 +42,7 @@ export const alertRouter = router({ threshold: z.number(), durationSeconds: z.number().int().min(1).default(60), teamId: z.string(), + channelIds: z.array(z.string()).optional(), }), ) .use(withTeamAccess("EDITOR")) @@ -65,7 +70,7 @@ export const alertRouter = router({ } } - return prisma.alertRule.create({ + const rule = await prisma.alertRule.create({ data: { name: input.name, environmentId: input.environmentId, @@ -77,6 +82,18 @@ export const alertRouter = router({ durationSeconds: input.durationSeconds, }, }); + + if (input.channelIds?.length) { + await prisma.alertRuleChannel.createMany({ + data: input.channelIds.map((channelId) => ({ + alertRuleId: rule.id, + channelId, + })), + skipDuplicates: true, + }); + } + + return rule; }), updateRule: protectedProcedure @@ -87,12 +104,13 @@ export const alertRouter = router({ enabled: z.boolean().optional(), threshold: z.number().optional(), durationSeconds: z.number().int().min(1).optional(), + channelIds: z.array(z.string()).optional(), }), ) .use(withTeamAccess("EDITOR")) .use(withAudit("alertRule.updated", "AlertRule")) .mutation(async ({ input }) => { - const { id, ...data } = input; + const { id, channelIds, ...data } = input; const existing = await prisma.alertRule.findUnique({ where: { id }, }); @@ -103,10 +121,28 @@ export const alertRouter = router({ }); } - return prisma.alertRule.update({ + const rule = await prisma.alertRule.update({ where: { id }, data, }); + + if (channelIds !== undefined) { + // Replace all channel links: delete old ones, create new ones + await prisma.alertRuleChannel.deleteMany({ + where: { alertRuleId: id }, + }); + if (channelIds.length > 0) { + await prisma.alertRuleChannel.createMany({ + data: channelIds.map((channelId) => ({ + alertRuleId: id, + channelId, + })), + skipDuplicates: true, + }); + } + } + + return rule; }), deleteRule: protectedProcedure @@ -317,6 +353,194 @@ export const alertRouter = router({ } }), + // ─── Notification Channels ───────────────────────────────────────── + + listChannels: protectedProcedure + .input(z.object({ environmentId: z.string() })) + .use(withTeamAccess("VIEWER")) + .query(async ({ input }) => { + const channels = await prisma.notificationChannel.findMany({ + where: { environmentId: input.environmentId }, + select: { + id: true, + environmentId: true, + name: true, + type: true, + config: true, + enabled: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { createdAt: "desc" }, + }); + + // Redact sensitive config fields before sending to the client + return channels.map((ch) => { + const config = ch.config as Record; + const safeConfig = { ...config }; + + // Redact passwords and secrets + if ("smtpPass" in safeConfig) safeConfig.smtpPass = "••••••••"; + if ("hmacSecret" in safeConfig && safeConfig.hmacSecret) + safeConfig.hmacSecret = "••••••••"; + if ("integrationKey" in safeConfig) + safeConfig.integrationKey = "••••••••"; + + return { ...ch, config: safeConfig }; + }); + }), + + createChannel: protectedProcedure + .input( + z.object({ + environmentId: z.string(), + name: z.string().min(1).max(200), + type: z.enum(["slack", "email", "pagerduty", "webhook"]), + config: z.record(z.string(), z.unknown()), + }), + ) + .use(withTeamAccess("EDITOR")) + .use(withAudit("notificationChannel.created", "NotificationChannel")) + .mutation(async ({ input }) => { + const env = await prisma.environment.findUnique({ + where: { id: input.environmentId }, + }); + if (!env) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Environment not found", + }); + } + + // Validate URLs for Slack and Webhook types (SSRF protection) + if (input.type === "slack") { + const webhookUrl = input.config.webhookUrl as string | undefined; + if (!webhookUrl) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Slack channels require a webhookUrl", + }); + } + await validatePublicUrl(webhookUrl); + } + + if (input.type === "webhook") { + const url = input.config.url as string | undefined; + if (!url) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Webhook channels require a url", + }); + } + await validatePublicUrl(url); + } + + return prisma.notificationChannel.create({ + data: { + environmentId: input.environmentId, + name: input.name, + type: input.type, + config: input.config as Prisma.InputJsonValue, + }, + }); + }), + + updateChannel: protectedProcedure + .input( + z.object({ + id: z.string(), + name: z.string().min(1).max(200).optional(), + config: z.record(z.string(), z.unknown()).optional(), + enabled: z.boolean().optional(), + }), + ) + .use(withTeamAccess("EDITOR")) + .use(withAudit("notificationChannel.updated", "NotificationChannel")) + .mutation(async ({ input }) => { + const { id, config, ...rest } = input; + const existing = await prisma.notificationChannel.findUnique({ + where: { id }, + }); + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Notification channel not found", + }); + } + + // Validate URLs if config is being updated for slack/webhook + if (config) { + if (existing.type === "slack") { + const webhookUrl = config.webhookUrl as string | undefined; + if (webhookUrl) await validatePublicUrl(webhookUrl); + } + if (existing.type === "webhook") { + const url = config.url as string | undefined; + if (url) await validatePublicUrl(url); + } + } + + return prisma.notificationChannel.update({ + where: { id }, + data: { + ...rest, + ...(config !== undefined + ? { config: config as Prisma.InputJsonValue } + : {}), + }, + }); + }), + + deleteChannel: protectedProcedure + .input(z.object({ id: z.string() })) + .use(withTeamAccess("EDITOR")) + .use(withAudit("notificationChannel.deleted", "NotificationChannel")) + .mutation(async ({ input }) => { + const existing = await prisma.notificationChannel.findUnique({ + where: { id: input.id }, + }); + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Notification channel not found", + }); + } + + await prisma.notificationChannel.delete({ where: { id: input.id } }); + return { deleted: true }; + }), + + testChannel: protectedProcedure + .input(z.object({ id: z.string() })) + .use(withTeamAccess("EDITOR")) + .mutation(async ({ input }) => { + const channel = await prisma.notificationChannel.findUnique({ + where: { id: input.id }, + }); + if (!channel) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Notification channel not found", + }); + } + + try { + const driver = getDriver(channel.type); + const result = await driver.test( + channel.config as Record, + ); + return { + success: result.success, + error: result.error, + }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }; + } + }), + // ─── Alert Events ────────────────────────────────────────────────── listEvents: protectedProcedure diff --git a/src/trpc/init.ts b/src/trpc/init.ts index 7aeb171f..a0a63ae8 100644 --- a/src/trpc/init.ts +++ b/src/trpc/init.ts @@ -216,6 +216,16 @@ export const withTeamAccess = (minRole: Role) => } } + if (!teamId && rawInput?.id) { + const notifChannel = await prisma.notificationChannel.findUnique({ + where: { id: rawInput.id as string }, + select: { environment: { select: { teamId: true } } }, + }); + if (notifChannel) { + teamId = notifChannel.environment.teamId ?? undefined; + } + } + // Resolve requestId → EventSampleRequest → pipeline → environment.teamId if (!teamId && rawInput?.requestId) { const req = await prisma.eventSampleRequest.findUnique({ From d4de5729e4738d654edb4bb69b680abf5c3be900 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 13:16:15 +0000 Subject: [PATCH 03/12] feat: integrate notification channels into alert delivery pipeline Call deliverToChannels alongside existing deliverWebhooks in the heartbeat route for each fired/resolved alert. Legacy webhook delivery is preserved for backward compatibility. --- src/app/api/agent/heartbeat/route.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/app/api/agent/heartbeat/route.ts b/src/app/api/agent/heartbeat/route.ts index 067e3932..34bbe349 100644 --- a/src/app/api/agent/heartbeat/route.ts +++ b/src/app/api/agent/heartbeat/route.ts @@ -10,6 +10,7 @@ import { cleanupOldMetrics } from "@/server/services/metrics-cleanup"; import { metricStore } from "@/server/services/metric-store"; import { evaluateAlerts } from "@/server/services/alert-evaluator"; import { deliverWebhooks } from "@/server/services/webhook-delivery"; +import { deliverToChannels } from "@/server/services/channels"; import { DeploymentMode } from "@/generated/prisma"; import { isVersionOlder } from "@/lib/version"; @@ -411,7 +412,7 @@ export async function POST(request: Request) { }) : null; - await deliverWebhooks(alert.rule.environmentId, { + const channelPayload = { alertId: alert.event.id, status: alert.event.status as "firing" | "resolved", ruleName: alert.rule.name, @@ -426,7 +427,19 @@ export async function POST(request: Request) { message: alert.event.message ?? "", timestamp: alert.event.firedAt.toISOString(), dashboardUrl: `${process.env.NEXTAUTH_URL ?? ""}/alerts`, - }); + }; + + // Deliver to legacy webhooks (backward compatibility) + await deliverWebhooks(alert.rule.environmentId, channelPayload); + + // Deliver to notification channels + deliverToChannels( + alert.rule.environmentId, + alert.rule.id, + channelPayload, + ).catch((err) => + console.error("Channel delivery error:", err), + ); } } } catch (err) { From 94c8fbf8ea99630acdaa1a2016cf0b90342a09fe Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 13:20:41 +0000 Subject: [PATCH 04/12] feat: add notification channels UI to alerts page Replace the standalone Webhooks section with a full Notification Channels section supporting Slack, Email, PagerDuty, and Webhook types. Each type has a dedicated config form. Channels can be tested, toggled, edited, and deleted. Alert rule create/edit dialogs now include a multi-select for notification channels. Legacy webhooks section is preserved but only shown when legacy webhooks exist. --- src/app/(dashboard)/alerts/page.tsx | 777 +++++++++++++++++++++++++++- 1 file changed, 768 insertions(+), 9 deletions(-) diff --git a/src/app/(dashboard)/alerts/page.tsx b/src/app/(dashboard)/alerts/page.tsx index 3cefec68..58a07fb9 100644 --- a/src/app/(dashboard)/alerts/page.tsx +++ b/src/app/(dashboard)/alerts/page.tsx @@ -19,6 +19,10 @@ import { Bell, Webhook, History, + BellRing, + Mail, + MessageSquare, + AlertTriangle, } from "lucide-react"; import { AlertMetric, AlertCondition } from "@/generated/prisma"; @@ -76,6 +80,20 @@ const CONDITION_LABELS: Record = { const BINARY_METRICS = new Set(["node_unreachable", "pipeline_crashed"]); +const CHANNEL_TYPE_LABELS: Record = { + slack: "Slack", + email: "Email", + pagerduty: "PagerDuty", + webhook: "Webhook", +}; + +const CHANNEL_TYPE_ICONS: Record = { + slack: MessageSquare, + email: Mail, + pagerduty: AlertTriangle, + webhook: Webhook, +}; + // ─── Alert Rules Section ──────────────────────────────────────────────────────── interface RuleFormState { @@ -85,6 +103,7 @@ interface RuleFormState { condition: string; threshold: string; durationSeconds: string; + channelIds: string[]; } const EMPTY_RULE_FORM: RuleFormState = { @@ -94,6 +113,7 @@ const EMPTY_RULE_FORM: RuleFormState = { condition: "", threshold: "", durationSeconds: "60", + channelIds: [], }; function AlertRulesSection({ environmentId }: { environmentId: string }) { @@ -123,6 +143,13 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { ), ); + const channelsQuery = useQuery( + trpc.alert.listChannels.queryOptions( + { environmentId }, + { enabled: !!environmentId }, + ), + ); + const invalidateRules = useCallback(() => { queryClient.invalidateQueries({ queryKey: trpc.alert.listRules.queryKey({ environmentId }), @@ -182,6 +209,7 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { const rules = rulesQuery.data ?? []; const pipelines = pipelinesQuery.data ?? []; + const channels = channelsQuery.data ?? []; const openCreate = () => { setEditingRuleId(null); @@ -198,10 +226,20 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { condition: rule.condition, threshold: String(rule.threshold), durationSeconds: String(rule.durationSeconds), + channelIds: rule.channels?.map((c) => c.channelId) ?? [], }); setDialogOpen(true); }; + const toggleChannel = (channelId: string) => { + setForm((f) => ({ + ...f, + channelIds: f.channelIds.includes(channelId) + ? f.channelIds.filter((id) => id !== channelId) + : [...f.channelIds, channelId], + })); + }; + const handleSubmit = () => { const isBinary = BINARY_METRICS.has(form.metric); if (!form.name || !form.metric || (!isBinary && !form.threshold)) { @@ -215,6 +253,7 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { name: form.name, threshold: parseFloat(form.threshold), durationSeconds: parseInt(form.durationSeconds, 10) || 60, + channelIds: form.channelIds, }); } else { createMutation.mutate({ @@ -226,6 +265,7 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { threshold: parseFloat(form.threshold), durationSeconds: parseInt(form.durationSeconds, 10) || 60, teamId: selectedTeamId!, + channelIds: form.channelIds.length > 0 ? form.channelIds : undefined, }); } }; @@ -452,6 +492,31 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { } /> + + {channels.length > 0 && ( +
+ +

+ Select channels for this rule. If none are selected, all + enabled channels will be used. +

+
+ {channels.map((ch) => { + const selected = form.channelIds.includes(ch.id); + return ( + toggleChannel(ch.id)} + > + {CHANNEL_TYPE_LABELS[ch.type] ?? ch.type}: {ch.name} + + ); + })} +
+
+ )} @@ -502,7 +567,696 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { ); } -// ─── Webhooks Section ─────────────────────────────────────────────────────────── +// ─── Notification Channels Section ─────────────────────────────────────────────── + +type ChannelType = "slack" | "email" | "pagerduty" | "webhook"; + +interface ChannelFormState { + name: string; + type: ChannelType; + // Slack + webhookUrl: string; + // Email + smtpHost: string; + smtpPort: string; + smtpUser: string; + smtpPass: string; + emailFrom: string; + recipients: string; + // PagerDuty + integrationKey: string; + // Webhook + url: string; + headers: string; + hmacSecret: string; +} + +const EMPTY_CHANNEL_FORM: ChannelFormState = { + name: "", + type: "slack", + webhookUrl: "", + smtpHost: "", + smtpPort: "587", + smtpUser: "", + smtpPass: "", + emailFrom: "", + recipients: "", + integrationKey: "", + url: "", + headers: "", + hmacSecret: "", +}; + +function buildConfigFromForm(form: ChannelFormState): Record { + switch (form.type) { + case "slack": + return { webhookUrl: form.webhookUrl }; + case "email": + return { + smtpHost: form.smtpHost, + smtpPort: parseInt(form.smtpPort, 10) || 587, + smtpUser: form.smtpUser || undefined, + smtpPass: form.smtpPass || undefined, + from: form.emailFrom, + recipients: form.recipients + .split(",") + .map((e) => e.trim()) + .filter(Boolean), + }; + case "pagerduty": + return { integrationKey: form.integrationKey }; + case "webhook": { + const config: Record = { url: form.url }; + if (form.headers.trim()) { + try { + config.headers = JSON.parse(form.headers); + } catch { + // Will be caught by validation + } + } + if (form.hmacSecret) config.hmacSecret = form.hmacSecret; + return config; + } + } +} + +function formFromConfig( + type: string, + name: string, + config: Record, +): ChannelFormState { + const base = { ...EMPTY_CHANNEL_FORM, name, type: type as ChannelType }; + + switch (type) { + case "slack": + return { ...base, webhookUrl: (config.webhookUrl as string) ?? "" }; + case "email": + return { + ...base, + smtpHost: (config.smtpHost as string) ?? "", + smtpPort: String(config.smtpPort ?? 587), + smtpUser: (config.smtpUser as string) ?? "", + smtpPass: "", + emailFrom: (config.from as string) ?? "", + recipients: Array.isArray(config.recipients) + ? (config.recipients as string[]).join(", ") + : "", + }; + case "pagerduty": + return { ...base, integrationKey: "" }; + case "webhook": + return { + ...base, + url: (config.url as string) ?? "", + headers: config.headers + ? JSON.stringify(config.headers, null, 2) + : "", + hmacSecret: "", + }; + default: + return base; + } +} + +function NotificationChannelsSection({ + environmentId, +}: { + environmentId: string; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingChannelId, setEditingChannelId] = useState(null); + const [form, setForm] = useState(EMPTY_CHANNEL_FORM); + const [deleteTarget, setDeleteTarget] = useState<{ + id: string; + name: string; + } | null>(null); + + const channelsQuery = useQuery( + trpc.alert.listChannels.queryOptions( + { environmentId }, + { enabled: !!environmentId }, + ), + ); + + const invalidateChannels = useCallback(() => { + queryClient.invalidateQueries({ + queryKey: trpc.alert.listChannels.queryKey({ environmentId }), + }); + }, [queryClient, trpc, environmentId]); + + const createMutation = useMutation( + trpc.alert.createChannel.mutationOptions({ + onSuccess: () => { + toast.success("Notification channel created"); + invalidateChannels(); + setDialogOpen(false); + }, + onError: (error) => { + toast.error(error.message || "Failed to create channel"); + }, + }), + ); + + const updateMutation = useMutation( + trpc.alert.updateChannel.mutationOptions({ + onSuccess: () => { + toast.success("Notification channel updated"); + invalidateChannels(); + setDialogOpen(false); + setEditingChannelId(null); + }, + onError: (error) => { + toast.error(error.message || "Failed to update channel"); + }, + }), + ); + + const toggleMutation = useMutation( + trpc.alert.updateChannel.mutationOptions({ + onSuccess: () => { + invalidateChannels(); + }, + onError: (error) => { + toast.error(error.message || "Failed to toggle channel"); + }, + }), + ); + + const deleteMutation = useMutation( + trpc.alert.deleteChannel.mutationOptions({ + onSuccess: () => { + toast.success("Notification channel deleted"); + invalidateChannels(); + setDeleteTarget(null); + }, + onError: (error) => { + toast.error(error.message || "Failed to delete channel"); + }, + }), + ); + + const testMutation = useMutation( + trpc.alert.testChannel.mutationOptions({ + onSuccess: (result) => { + if (result.success) { + toast.success("Channel test successful"); + } else { + toast.error(`Channel test failed: ${result.error ?? "Unknown error"}`); + } + }, + onError: (error) => { + toast.error(error.message || "Failed to test channel"); + }, + }), + ); + + const channels = channelsQuery.data ?? []; + + const openCreate = () => { + setEditingChannelId(null); + setForm(EMPTY_CHANNEL_FORM); + setDialogOpen(true); + }; + + const openEdit = (channel: (typeof channels)[0]) => { + setEditingChannelId(channel.id); + setForm( + formFromConfig( + channel.type, + channel.name, + channel.config as Record, + ), + ); + setDialogOpen(true); + }; + + const validateForm = (): boolean => { + if (!form.name.trim()) { + toast.error("Name is required"); + return false; + } + + switch (form.type) { + case "slack": + if (!form.webhookUrl.trim()) { + toast.error("Webhook URL is required"); + return false; + } + break; + case "email": + if (!form.smtpHost.trim() || !form.emailFrom.trim() || !form.recipients.trim()) { + toast.error("SMTP host, from address, and recipients are required"); + return false; + } + break; + case "pagerduty": + if (!form.integrationKey.trim()) { + toast.error("Integration key is required"); + return false; + } + break; + case "webhook": + if (!form.url.trim()) { + toast.error("URL is required"); + return false; + } + if (form.headers.trim()) { + try { + const parsed = JSON.parse(form.headers); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + toast.error("Headers must be a JSON object"); + return false; + } + } catch { + toast.error("Invalid JSON in headers field"); + return false; + } + } + break; + } + + return true; + }; + + const handleSubmit = () => { + if (!validateForm()) return; + + const config = buildConfigFromForm(form); + + if (editingChannelId) { + updateMutation.mutate({ + id: editingChannelId, + name: form.name, + config, + }); + } else { + createMutation.mutate({ + environmentId, + name: form.name, + type: form.type, + config, + }); + } + }; + + const isSaving = createMutation.isPending || updateMutation.isPending; + + return ( +
+
+
+ +

Notification Channels

+
+ +
+ + {channelsQuery.isLoading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+ ) : channels.length === 0 ? ( +
+

No notification channels configured

+

+ Add a notification channel to receive alerts via Slack, Email, + PagerDuty, or Webhook. +

+
+ ) : ( + + + + Name + Type + Enabled + Actions + + + + {channels.map((channel) => { + const Icon = + CHANNEL_TYPE_ICONS[channel.type] ?? Webhook; + return ( + + {channel.name} + + + + {CHANNEL_TYPE_LABELS[channel.type] ?? channel.type} + + + + + toggleMutation.mutate({ + id: channel.id, + enabled: checked, + }) + } + /> + + +
+ + + +
+
+
+ ); + })} +
+
+ )} + + {/* Create / Edit Dialog */} + { + setDialogOpen(open); + if (!open) setEditingChannelId(null); + }} + > + + + + {editingChannelId + ? "Edit Notification Channel" + : "Add Notification Channel"} + + + {editingChannelId + ? "Update the channel configuration." + : "Configure a new notification channel for alert delivery."} + + + +
+
+ + + setForm((f) => ({ ...f, name: e.target.value })) + } + /> +
+ + {!editingChannelId && ( +
+ + +
+ )} + + {/* Type-specific config forms */} + {form.type === "slack" && ( +
+ + + setForm((f) => ({ ...f, webhookUrl: e.target.value })) + } + /> +

+ Create an Incoming Webhook in your Slack workspace settings. +

+
+ )} + + {form.type === "email" && ( + <> +
+
+ + + setForm((f) => ({ ...f, smtpHost: e.target.value })) + } + /> +
+
+ + + setForm((f) => ({ ...f, smtpPort: e.target.value })) + } + /> +
+
+
+
+ + + setForm((f) => ({ ...f, smtpUser: e.target.value })) + } + /> +
+
+ + + setForm((f) => ({ ...f, smtpPass: e.target.value })) + } + /> +
+
+
+ + + setForm((f) => ({ ...f, emailFrom: e.target.value })) + } + /> +
+
+ + + setForm((f) => ({ ...f, recipients: e.target.value })) + } + /> +

+ Comma-separated list of email addresses. +

+
+ + )} + + {form.type === "pagerduty" && ( +
+ + + setForm((f) => ({ + ...f, + integrationKey: e.target.value, + })) + } + /> +

+ Found in PagerDuty under Service > Integrations > Events + API v2. +

+
+ )} + + {form.type === "webhook" && ( + <> +
+ + + setForm((f) => ({ ...f, url: e.target.value })) + } + /> +
+
+ +