Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
13 kB ยท 498 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499import { COLORS, COLOR_DECIMALS } from "@openstatus/notification-base";import { assertSafeUrl, safeFetch, statusLabel } from "@openstatus/utils";import { z } from "zod";
import { WEBHOOK_PAYLOAD_VERSION } from "../payload";import type { PageUpdate, Subscription } from "../types";import { postWebhookWithRetry } from "./retry";
export type WebhookFlavor = "slack" | "discord" | "generic";
const SLACK_PREFIX = "https://hooks.slack.com/services/";const DISCORD_PREFIX = "https://discord.com/api/webhooks/";const TIMEOUT_MS = 5000; // 5 seconds
/** * Classify a webhook URL by its incoming-webhook origin so we can emit * channel-native payloads. Unknown URLs fall back to generic JSON. */export function detectWebhookFlavor(url: string): WebhookFlavor { if (url.startsWith(SLACK_PREFIX)) return "slack"; if (url.startsWith(DISCORD_PREFIX)) return "discord"; return "generic";}
function redactWebhookUrl(url: string): string { try { return `${new URL(url).origin}/***`; } catch { return "<invalid-url>"; }}
function resolveStatusPageOrigin(subscription: Subscription): string { return subscription.customDomain ? `https://${subscription.customDomain}` : `https://${subscription.pageSlug}.openstatus.dev`;}
// Deep link to the specific event on the status page, mirroring the path// shape used by the Slack app integration (apps/server slack/page-urls.ts).function resolveEventUrl( pageUpdate: PageUpdate, subscription: Subscription,): string { const kind = pageUpdate.status === "maintenance" ? "maintenance" : "report"; return `${resolveStatusPageOrigin(subscription)}/events/${kind}/${pageUpdate.id}`;}
function buildManagementLinks(subscription: Subscription) { if (!subscription.token) { return { manageUrl: null, unsubscribeUrl: null }; } const origin = resolveStatusPageOrigin(subscription); return { manageUrl: `${origin}/manage/${subscription.token}`, unsubscribeUrl: `${origin}/unsubscribe/${subscription.token}`, };}
type StatusColor = "red" | "yellow" | "green" | "blue";
function statusColor(status: PageUpdate["status"]): StatusColor { switch (status) { case "investigating": case "identified": return "red"; case "monitoring": return "yellow"; case "resolved": return "green"; case "maintenance": return "blue"; }}
const webhookConfigSchema = z.object({ headers: z .array( z.object({ key: z.string().min(1), value: z.string(), }), ) .optional(), secret: z.string().optional(),});
export async function validateWebhookConfig(config: unknown) { const result = webhookConfigSchema.safeParse(config); return { valid: result.success, error: result.error?.message };}
function hasWebhookUrl( sub: Subscription,): sub is Subscription & { webhookUrl: string } { return sub.webhookUrl !== undefined && sub.webhookUrl !== null;}
export async function sendWebhookVerification( subscription: Subscription, verifyUrl: string,) { if (!subscription.webhookUrl) { throw new Error("Webhook URL is required for webhook channel"); }
const response = await safeFetch(subscription.webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "verification", token: subscription.token, verifyUrl, }), signal: AbortSignal.timeout(TIMEOUT_MS), });
if (!response.ok) { throw new Error( `Webhook verification failed: ${response.status} ${response.statusText}`, ); }}
type ManagementLinks = ReturnType<typeof buildManagementLinks>;
function buildSlackPayload( pageUpdate: PageUpdate, subscription: Subscription, links: ManagementLinks,) { const color = statusColor(pageUpdate.status); const eventUrl = resolveEventUrl(pageUpdate, subscription); const pageOrigin = resolveStatusPageOrigin(subscription);
const blocks: Record<string, unknown>[] = [ { type: "header", text: { type: "plain_text", text: pageUpdate.title, emoji: true, }, }, { type: "section", fields: [ { type: "mrkdwn", text: `*Status*\n${statusLabel(pageUpdate.status)}`, }, { type: "mrkdwn", text: `*Page*\n<${pageOrigin}|${subscription.pageName}>`, }, ], }, ];
if (pageUpdate.message) { blocks.push({ type: "section", text: { type: "mrkdwn", text: pageUpdate.message, }, }); }
if (pageUpdate.pageComponents.length > 0) { blocks.push({ type: "section", text: { type: "mrkdwn", text: `*Affected*\n${pageUpdate.pageComponents.join(", ")}`, }, }); }
blocks.push({ type: "context", elements: [ { type: "mrkdwn", text: pageUpdate.date, }, ], });
const footerLinks = [`<${eventUrl}|View details>`]; if (links.manageUrl && links.unsubscribeUrl) { footerLinks.push( `<${links.manageUrl}|Manage>`, `<${links.unsubscribeUrl}|Unsubscribe>`, ); } blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: footerLinks.join(" ยท ") }], });
return { attachments: [ { color: COLORS[color], blocks, }, ], };}
function buildDiscordPayload( pageUpdate: PageUpdate, subscription: Subscription, links: ManagementLinks,) { const color = statusColor(pageUpdate.status); const eventUrl = resolveEventUrl(pageUpdate, subscription); const pageOrigin = resolveStatusPageOrigin(subscription);
const descriptionParts: string[] = []; if (pageUpdate.message) descriptionParts.push(pageUpdate.message); if (pageUpdate.pageComponents.length > 0) { descriptionParts.push( `**Affected:** ${pageUpdate.pageComponents.join(", ")}`, ); } if (links.manageUrl && links.unsubscribeUrl) { descriptionParts.push( `[Manage](${links.manageUrl}) ยท [Unsubscribe](${links.unsubscribeUrl})`, ); }
return { embeds: [ { title: pageUpdate.title, url: eventUrl, description: descriptionParts.join("\n\n") || undefined, color: COLOR_DECIMALS[color], fields: [ { name: "Status", value: statusLabel(pageUpdate.status), inline: true, }, { name: "Page", value: `[${subscription.pageName}](${pageOrigin})`, inline: true, }, ], footer: { text: pageUpdate.date, }, }, ], };}
/** * Generic JSON webhook payload, sent to any non-Slack/Discord URL. * Shape is pinned by `webhookPayloadSchema` in `../payload`. */export function buildGenericPayload( pageUpdate: PageUpdate, subscription: Subscription, links: ManagementLinks,) { const origin = resolveStatusPageOrigin(subscription); const eventUrl = resolveEventUrl(pageUpdate, subscription); const page = { id: subscription.pageId, name: subscription.pageName, slug: subscription.pageSlug, url: origin, }; const components = pageUpdate.componentsWithImpact ?? pageUpdate.pageComponentsWithId?.map((c) => ({ ...c, impact: "operational" as const, })) ?? pageUpdate.pageComponents.map((name, i) => ({ id: i, // synthetic id: legacy bare-names path has no real pageComponentId name, impact: "operational" as const, })); const subscriptionBlock = { manage_url: links.manageUrl, unsubscribe_url: links.unsubscribeUrl, };
if (pageUpdate.status === "maintenance") { return { version: WEBHOOK_PAYLOAD_VERSION, type: "maintenance" as const, data: { maintenance: { id: pageUpdate.id, title: pageUpdate.title, url: eventUrl, message: pageUpdate.message, starts_at: pageUpdate.startsAt, ends_at: pageUpdate.endsAt, page, components, }, }, subscription: subscriptionBlock, }; }
if (pageUpdate.updateId == null) { throw new Error("status_report webhook payload requires updateId"); }
return { version: WEBHOOK_PAYLOAD_VERSION, type: "status_report" as const, data: { status_report: { id: pageUpdate.id, title: pageUpdate.title, url: eventUrl, update: { id: pageUpdate.updateId, status: pageUpdate.status, message: pageUpdate.message, occurred_at: pageUpdate.date, }, page, components, }, }, subscription: subscriptionBlock, };}
function buildNotificationPayload( pageUpdate: PageUpdate, subscription: Subscription,) { if (!hasWebhookUrl(subscription)) { throw new Error("Subscription has no webhook URL"); } const flavor = detectWebhookFlavor(subscription.webhookUrl); const links = buildManagementLinks(subscription);
switch (flavor) { case "slack": return buildSlackPayload(pageUpdate, subscription, links); case "discord": return buildDiscordPayload(pageUpdate, subscription, links); case "generic": return buildGenericPayload(pageUpdate, subscription, links); }}
export async function sendWebhookNotifications( subscriptions: Subscription[], pageUpdate: PageUpdate,) { const validSubscriptions = subscriptions.filter(hasWebhookUrl); if (validSubscriptions.length === 0) return;
await Promise.allSettled( validSubscriptions.map(async (subscription) => { let config: Record<string, unknown> = {}; try { config = subscription.channelConfig ? JSON.parse(subscription.channelConfig) : {}; } catch { console.error( `Invalid channelConfig JSON for subscription ${subscription.id}`, ); }
const payload = buildNotificationPayload(pageUpdate, subscription);
const headers: Record<string, string> = { "Content-Type": "application/json", "User-Agent": "OpenStatus-Webhooks/1.0", };
const parsedConfig = webhookConfigSchema.safeParse(config); if (parsedConfig.success) { for (const header of parsedConfig.data.headers ?? []) { headers[header.key] = header.value; } }
try { await assertSafeUrl(subscription.webhookUrl); await postWebhookWithRetry({ url: subscription.webhookUrl, headers, body: JSON.stringify(payload), timeoutMs: TIMEOUT_MS, }); } catch (error) { console.error( `Failed to send webhook notification to ${redactWebhookUrl(subscription.webhookUrl)}:`, error, ); throw error; } }), );}
/** * Build a flavor-specific test payload (used by the "Send test" row action). * Exported for unit-test coverage; also called by `sendTestWebhookRequest`. */export function buildTestPayload(flavor: WebhookFlavor) { switch (flavor) { case "slack": return { attachments: [ { color: COLORS.green, blocks: [ { type: "header", text: { type: "plain_text", text: "Test Notification", emoji: false, }, }, { type: "section", text: { type: "mrkdwn", text: "Your openstatus webhook is configured correctly.", }, }, ], }, ], }; case "discord": return { embeds: [ { title: "Test Notification", description: "Your openstatus webhook is configured correctly.", color: COLOR_DECIMALS.green, timestamp: new Date().toISOString(), }, ], }; case "generic": return { version: WEBHOOK_PAYLOAD_VERSION, type: "test" as const, data: { test: { message: "Your openstatus webhook is configured correctly.", timestamp: new Date().toISOString(), }, }, }; }}
/** * Send a test payload to the given webhook URL. SSRF-checked and timeout-capped. * Throws on non-2xx or network error. */export async function sendTestWebhookRequest(input: { url: string; flavor: WebhookFlavor; headers?: Record<string, string>;}) { const { url, flavor, headers: extraHeaders = {} } = input;
const headers: Record<string, string> = { "Content-Type": "application/json", "User-Agent": "OpenStatus-Webhooks/1.0", ...extraHeaders, };
const response = await safeFetch(url, { method: "POST", headers, body: JSON.stringify(buildTestPayload(flavor)), signal: AbortSignal.timeout(TIMEOUT_MS), });
if (!response.ok) { throw new Error( `Test webhook failed: ${response.status} ${response.statusText}`, ); }}