diff --git a/apps/dashboard/src/components/forms/components/form-import.tsx b/apps/dashboard/src/components/forms/components/form-import.tsx index 99844d80..cdd18599 100644 --- a/apps/dashboard/src/components/forms/components/form-import.tsx +++ b/apps/dashboard/src/components/forms/components/form-import.tsx @@ -12,7 +12,11 @@ import { } from "@/components/forms/form-card"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { InstatusIcon, StatuspageIcon } from "@openstatus/icons"; +import { + BetterstackIcon, + InstatusIcon, + StatuspageIcon, +} from "@openstatus/icons"; import type { ImportSummary } from "@openstatus/importers/types"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Button } from "@openstatus/ui/components/ui/button"; @@ -40,10 +44,12 @@ import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ - provider: z.enum(["statuspage", "instatus"]), + provider: z.enum(["statuspage", "betterstack", "instatus"]), apiKey: z.string().min(1, "API key is required"), statuspagePageId: z.string().optional(), + betterstackStatusPageId: z.string().optional(), instatusPageId: z.string().optional(), + includeMonitors: z.boolean(), includeStatusReports: z.boolean(), includeSubscribers: z.boolean(), includeComponents: z.boolean(), @@ -56,7 +62,10 @@ function getPhaseCount(preview: ImportSummary, phase: string): number { } const PHASE_LABELS: Record = { + monitors: "Monitors", componentGroups: "Component Groups", + monitorGroups: "Monitor Groups", + sections: "Sections", components: "Components", incidents: "Status Reports", maintenances: "Maintenances", @@ -76,7 +85,9 @@ export function FormImport({ provider: undefined, apiKey: "", statuspagePageId: "", + betterstackStatusPageId: "", instatusPageId: "", + includeMonitors: true, includeStatusReports: true, includeSubscribers: false, includeComponents: true, @@ -87,6 +98,7 @@ export function FormImport({ const watchProvider = form.watch("provider"); const watchApiKey = form.watch("apiKey"); const watchStatuspagePageId = form.watch("statuspagePageId"); + const watchBetterstackStatusPageId = form.watch("betterstackStatusPageId"); const watchInstatusPageId = form.watch("instatusPageId"); const previewMutation = useMutation( @@ -114,6 +126,10 @@ export function FormImport({ watchProvider === "statuspage" ? watchStatuspagePageId || undefined : undefined, + betterstackStatusPageId: + watchProvider === "betterstack" + ? watchBetterstackStatusPageId || undefined + : undefined, instatusPageId: watchProvider === "instatus" ? watchInstatusPageId || undefined @@ -188,6 +204,21 @@ export function FormImport({ Atlassian Statuspage + + + + + - {watchProvider === "instatus" - ? "Your Instatus API key. Found in your Instatus account under Settings > API." - : "Your Statuspage API key. Found in your Statuspage account under Manage Account > API."} + {watchProvider === "betterstack" + ? "Your Better Stack API token. Found in Better Stack \u2192 API tokens." + : watchProvider === "instatus" + ? "Your Instatus API key. Found in your Instatus account under Settings > API." + : "Your Statuspage API key. Found in your Statuspage account under Manage Account > API."} )} @@ -262,6 +297,24 @@ export function FormImport({ )} /> ) : null} + {watchProvider === "betterstack" ? ( + ( + + Status Page ID (optional) + + + + + Import a specific status page. Leave empty to use the + first available. + + + )} + /> + ) : null} {watchProvider === "instatus" ? ( ) : null} + {watchProvider === "betterstack" ? ( + ( + +
+ Monitors + + Import monitors with their URL, frequency, and + regions. + +
+ + + +
+ )} + /> + ) : null} )} /> - ( - -
- Subscribers - - Import email subscribers. - -
- - - -
- )} - /> + {watchProvider !== "betterstack" ? ( + ( + +
+ Subscribers + + Import email subscribers. + +
+ + + +
+ )} + /> + ) : null} ) : null} diff --git a/apps/dashboard/src/components/forms/components/update.tsx b/apps/dashboard/src/components/forms/components/update.tsx index 73626fbd..0c4f8802 100644 --- a/apps/dashboard/src/components/forms/components/update.tsx +++ b/apps/dashboard/src/components/forms/components/update.tsx @@ -19,7 +19,9 @@ export function FormComponentsUpdate() { const { data: pageComponents, refetch: refetchComponents } = useQuery( trpc.pageComponent.list.queryOptions({ pageId: Number.parseInt(id) }), ); - const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); + const { data: monitors, refetch: refetchMonitors } = useQuery( + trpc.monitor.list.queryOptions(), + ); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const updateComponentsMutation = useMutation( @@ -40,7 +42,7 @@ export function FormComponentsUpdate() { const importMutation = useMutation( trpc.import.run.mutationOptions({ onSuccess: async () => { - await Promise.all([refetch(), refetchComponents()]); + await Promise.all([refetch(), refetchComponents(), refetchMonitors()]); setFormKey((k) => k + 1); }, }), @@ -137,10 +139,14 @@ export function FormComponentsUpdate() { apiKey: values.apiKey, pageId: statusPage.id, statuspagePageId: values.statuspagePageId ?? undefined, + betterstackStatusPageId: + values.betterstackStatusPageId ?? undefined, + instatusPageId: values.instatusPageId ?? undefined, options: { includeStatusReports: values.includeStatusReports, includeSubscribers: values.includeSubscribers, includeComponents: values.includeComponents, + includeMonitors: values.includeMonitors, }, }); }} diff --git a/packages/api/src/router/import.ts b/packages/api/src/router/import.ts index c92c78e4..72cd922a 100644 --- a/packages/api/src/router/import.ts +++ b/packages/api/src/router/import.ts @@ -9,9 +9,10 @@ export const importRouter = createTRPCRouter({ preview: protectedProcedure .input( z.object({ - provider: z.enum(["statuspage", "instatus"]), + provider: z.enum(["statuspage", "betterstack", "instatus"]), apiKey: z.string().min(1), statuspagePageId: z.string().nullish(), + betterstackStatusPageId: z.string().nullish(), instatusPageId: z.string().nullish(), pageId: z.number().optional(), }), @@ -21,6 +22,8 @@ export const importRouter = createTRPCRouter({ provider: opts.input.provider, apiKey: opts.input.apiKey, statuspagePageId: opts.input.statuspagePageId ?? undefined, + betterstackStatusPageId: + opts.input.betterstackStatusPageId ?? undefined, instatusPageId: opts.input.instatusPageId ?? undefined, workspaceId: opts.ctx.workspace.id, pageId: opts.input.pageId, @@ -31,16 +34,18 @@ export const importRouter = createTRPCRouter({ run: protectedProcedure .input( z.object({ - provider: z.enum(["statuspage", "instatus"]), + provider: z.enum(["statuspage", "betterstack", "instatus"]), apiKey: z.string().min(1), pageId: z.number().optional(), statuspagePageId: z.string().nullish(), + betterstackStatusPageId: z.string().nullish(), instatusPageId: z.string().nullish(), options: z .object({ includeStatusReports: z.boolean().default(true), includeSubscribers: z.boolean().default(false), includeComponents: z.boolean().default(true), + includeMonitors: z.boolean().default(true), }) .optional(), }), @@ -71,6 +76,8 @@ export const importRouter = createTRPCRouter({ provider: opts.input.provider, apiKey: opts.input.apiKey, statuspagePageId: opts.input.statuspagePageId ?? undefined, + betterstackStatusPageId: + opts.input.betterstackStatusPageId ?? undefined, instatusPageId: opts.input.instatusPageId ?? undefined, workspaceId: opts.ctx.workspace.id, pageId: opts.input.pageId, diff --git a/packages/api/src/service/import.test.ts b/packages/api/src/service/import.test.ts index 633c806c..05791670 100644 --- a/packages/api/src/service/import.test.ts +++ b/packages/api/src/service/import.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { allPlans } from "@openstatus/db/src/schema/plan/config"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; import type { ImportSummary } from "@openstatus/importers"; -import { addLimitWarnings } from "./import"; +import { addLimitWarnings, clampPeriodicity } from "./import"; function makeSummary(overrides?: Partial): ImportSummary { return { @@ -361,3 +361,48 @@ describe("addLimitWarnings", () => { expect(summary.errors).toEqual([]); }); }); + +describe("clampPeriodicity", () => { + const freePlan = allPlans.free.limits.periodicity as string[]; + const starterPlan = allPlans.starter.limits.periodicity as string[]; + + describe("free plan", () => { + test("allows 10m (already in plan)", () => { + expect(clampPeriodicity("10m", freePlan)).toBe("10m"); + }); + + test("allows 30m (already in plan)", () => { + expect(clampPeriodicity("30m", freePlan)).toBe("30m"); + }); + + test("allows 1h (already in plan)", () => { + expect(clampPeriodicity("1h", freePlan)).toBe("1h"); + }); + + test("clamps 1m to 10m", () => { + expect(clampPeriodicity("1m", freePlan)).toBe("10m"); + }); + + test("clamps 30s to 10m", () => { + expect(clampPeriodicity("30s", freePlan)).toBe("10m"); + }); + + test("clamps 5m to 10m", () => { + expect(clampPeriodicity("5m", freePlan)).toBe("10m"); + }); + }); + + describe("starter plan", () => { + test("allows 1m (in plan)", () => { + expect(clampPeriodicity("1m", starterPlan)).toBe("1m"); + }); + + test("allows 5m (in plan)", () => { + expect(clampPeriodicity("5m", starterPlan)).toBe("5m"); + }); + + test("clamps 30s to 1m", () => { + expect(clampPeriodicity("30s", starterPlan)).toBe("1m"); + }); + }); +}); diff --git a/packages/api/src/service/import.ts b/packages/api/src/service/import.ts index 593aa2de..7fdeea70 100644 --- a/packages/api/src/service/import.ts +++ b/packages/api/src/service/import.ts @@ -1,7 +1,8 @@ -import { and, db, eq } from "@openstatus/db"; +import { and, count, db, eq, isNull } from "@openstatus/db"; import { maintenance, maintenancesToPageComponents, + monitor, page, pageComponent, pageComponentGroup, @@ -13,10 +14,12 @@ import { } from "@openstatus/db/src/schema"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; import type { + ImportProvider, ImportSummary, PhaseResult, ResourceResult, } from "@openstatus/importers"; +import { createBetterstackProvider } from "@openstatus/importers/betterstack"; import { createInstatusProvider } from "@openstatus/importers/instatus"; import { createStatuspageProvider } from "@openstatus/importers/statuspage"; import { TRPCError } from "@trpc/server"; @@ -25,14 +28,20 @@ type ImportOptions = { includeStatusReports?: boolean; includeSubscribers?: boolean; includeComponents?: boolean; + includeMonitors?: boolean; }; -type ProviderName = "statuspage" | "instatus"; +type ProviderName = "statuspage" | "betterstack" | "instatus"; -function createProvider(name: ProviderName) { - return name === "instatus" - ? createInstatusProvider() - : createStatuspageProvider(); +function createProvider(name: ProviderName): ImportProvider { + switch (name) { + case "betterstack": + return createBetterstackProvider(); + case "instatus": + return createInstatusProvider(); + default: + return createStatuspageProvider(); + } } function buildProviderConfig(config: { @@ -41,12 +50,21 @@ function buildProviderConfig(config: { workspaceId: number; pageId?: number; statuspagePageId?: string; + betterstackStatusPageId?: string; instatusPageId?: string; }) { const { provider, ...rest } = config; - return provider === "instatus" - ? { ...rest, instatusPageId: config.instatusPageId } - : { ...rest, statuspagePageId: config.statuspagePageId }; + switch (provider) { + case "betterstack": + return { + ...rest, + betterstackStatusPageId: config.betterstackStatusPageId, + }; + case "instatus": + return { ...rest, instatusPageId: config.instatusPageId }; + default: + return { ...rest, statuspagePageId: config.statuspagePageId }; + } } /** @@ -69,17 +87,16 @@ export async function addLimitWarnings( const maxComponents = config.limits["page-components"]; let existingCount = 0; if (config.pageId) { - const existing = await db - .select() + const [result] = await db + .select({ count: count() }) .from(pageComponent) .where( and( eq(pageComponent.pageId, config.pageId), eq(pageComponent.workspaceId, config.workspaceId), ), - ) - .all(); - existingCount = existing.length; + ); + existingCount = result?.count ?? 0; } const remaining = maxComponents - existingCount; if (remaining <= 0) { @@ -106,7 +123,48 @@ export async function addLimitWarnings( } } - // 3. Subscribers + // 3. Monitor count limit + const monitorsPhase = summary.phases.find((p) => p.phase === "monitors"); + if (monitorsPhase && monitorsPhase.resources.length > 0) { + const maxMonitors = config.limits.monitors; + const [monitorCount] = await db + .select({ count: count() }) + .from(monitor) + .where( + and( + eq(monitor.workspaceId, config.workspaceId), + isNull(monitor.deletedAt), + ), + ); + const remaining = maxMonitors - (monitorCount?.count ?? 0); + if (remaining <= 0) { + summary.errors.push( + `Monitor limit reached (${maxMonitors}). Upgrade your plan to import monitors.`, + ); + } else if (monitorsPhase.resources.length > remaining) { + summary.errors.push( + `Only ${remaining} of ${monitorsPhase.resources.length} monitors can be imported due to plan limit (${maxMonitors}).`, + ); + } + } + + // 4. Monitor periodicity clamping + if (monitorsPhase && monitorsPhase.resources.length > 0) { + const allowedPeriodicity: string[] = config.limits.periodicity; + const clamped = monitorsPhase.resources.filter((r) => { + const data = r.data as { periodicity?: string } | undefined; + return ( + data?.periodicity && !allowedPeriodicity.includes(data.periodicity) + ); + }); + if (clamped.length > 0) { + summary.errors.push( + `${clamped.length} monitor${clamped.length === 1 ? "'s" : "s'"} check frequency will be adjusted to fit your plan's allowed intervals.`, + ); + } + } + + // 5. Subscribers if (!config.limits["status-subscribers"]) { const subscribersPhase = summary.phases.find( (p) => p.phase === "subscribers", @@ -123,6 +181,7 @@ export async function previewImport(config: { provider: ProviderName; apiKey: string; statuspagePageId?: string; + betterstackStatusPageId?: string; instatusPageId?: string; workspaceId: number; pageId?: number; @@ -151,6 +210,7 @@ export async function runImport(config: { provider: ProviderName; apiKey: string; statuspagePageId?: string; + betterstackStatusPageId?: string; instatusPageId?: string; workspaceId: number; pageId?: number; @@ -178,6 +238,7 @@ export async function runImport(config: { const idMaps = { groups: new Map(), // sourceId -> openstatusId components: new Map(), // sourceId -> openstatusId + monitors: new Map(), // sourceId -> openstatusId }; let targetPageId = config.pageId; @@ -191,6 +252,18 @@ export async function runImport(config: { try { switch (phase.phase) { + case "monitors": + if (config.options?.includeMonitors !== false) { + await writeMonitorsPhase( + phase, + config.workspaceId, + idMaps.monitors, + config.limits, + ); + } else { + phase.status = "skipped"; + } + break; case "page": targetPageId = await writePagePhase( phase, @@ -214,18 +287,17 @@ export async function runImport(config: { case "components": if (targetPageId && config.options?.includeComponents !== false) { // Check page-components limit - const existingCount = await db - .select() + const [compCount] = await db + .select({ count: count() }) .from(pageComponent) .where( and( eq(pageComponent.pageId, targetPageId), eq(pageComponent.workspaceId, config.workspaceId), ), - ) - .all(); + ); const maxComponents = config.limits["page-components"]; - const remaining = maxComponents - existingCount.length; + const remaining = maxComponents - (compCount?.count ?? 0); if (remaining <= 0) { phase.status = "failed"; break; @@ -245,6 +317,7 @@ export async function runImport(config: { targetPageId, idMaps.groups, idMaps.components, + idMaps.monitors, ); } else if (config.options?.includeComponents === false) { phase.status = "skipped"; @@ -313,6 +386,32 @@ export async function runImport(config: { return summary; } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const PERIODICITY_ORDER = ["30s", "1m", "5m", "10m", "30m", "1h"] as const; + +/** + * Clamp a periodicity to the nearest allowed value for the plan. + * Picks the closest allowed periodicity that is >= the requested one + * (i.e. never faster than what the plan permits). + */ +export function clampPeriodicity(requested: string, allowed: string[]): string { + if (allowed.includes(requested)) return requested; + const reqIdx = PERIODICITY_ORDER.indexOf( + requested as (typeof PERIODICITY_ORDER)[number], + ); + // Find the smallest allowed periodicity that is >= requested + for (let i = Math.max(reqIdx, 0); i < PERIODICITY_ORDER.length; i++) { + if (allowed.includes(PERIODICITY_ORDER[i])) { + return PERIODICITY_ORDER[i]; + } + } + // Fallback to the slowest allowed + return allowed[allowed.length - 1] ?? "10m"; +} + // --------------------------------------------------------------------------- // Phase writers // --------------------------------------------------------------------------- @@ -485,6 +584,7 @@ async function writeComponentsPhase( pageId: number, groupIdMap: Map, componentIdMap: Map, + monitorIdMap?: Map, ): Promise { for (const resource of phase.resources) { if (resource.status === "skipped") continue; @@ -493,14 +593,26 @@ async function writeComponentsPhase( const data = resource.data as { workspaceId: number; pageId: number; - type: "static"; - monitorId: null; + type: "static" | "monitor"; + monitorId: number | null; + sourceMonitorId?: string | null; name: string; description: string | null; order: number; sourceGroupId: string | null; }; + // Resolve monitor ID from source monitor ID + if (data.type === "monitor") { + if (data.sourceMonitorId && monitorIdMap) { + data.monitorId = monitorIdMap.get(data.sourceMonitorId) ?? null; + } + if (!data.monitorId) { + // Monitor wasn't imported or no source — fall back to static + data.type = "static"; + } + } + // Check idempotency by name + pageId const existing = await db .select() @@ -704,6 +816,142 @@ async function writeMaintenancesPhase( phase.status = computePhaseStatus(phase.resources); } +async function writeMonitorsPhase( + phase: PhaseResult, + workspaceId: number, + monitorIdMap: Map, + limits: Limits, +): Promise { + const [monitorCount] = await db + .select({ count: count() }) + .from(monitor) + .where( + and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), + ); + const maxMonitors = limits.monitors; + const remaining = maxMonitors - (monitorCount?.count ?? 0); + + if (remaining <= 0) { + for (const resource of phase.resources) { + resource.status = "skipped"; + resource.error = `Skipped: monitor limit reached (${maxMonitors})`; + } + phase.status = computePhaseStatus(phase.resources); + return; + } + + let importedCount = 0; + for (const resource of phase.resources) { + if (importedCount >= remaining) { + resource.status = "skipped"; + resource.error = `Skipped: would exceed monitor limit (${maxMonitors})`; + continue; + } + + try { + const data = resource.data as { + workspaceId: number; + jobType: string; + periodicity: string; + status: string; + active: boolean; + regions: string; + url: string; + name: string; + description: string; + headers: string; + body: string; + method: string; + timeout: number; + sourceMonitorGroupId: string | null; + }; + + // Idempotency check by url + workspaceId (exclude soft-deleted) + const existing = await db + .select() + .from(monitor) + .where( + and( + eq(monitor.url, data.url), + eq(monitor.workspaceId, workspaceId), + isNull(monitor.deletedAt), + ), + ) + .get(); + + if (existing) { + monitorIdMap.set(resource.sourceId, existing.id); + resource.openstatusId = existing.id; + resource.status = "skipped"; + continue; + } + + // Clamp periodicity to what the plan allows + const periodicity = clampPeriodicity( + data.periodicity, + limits.periodicity, + ); + + const [inserted] = await db + .insert(monitor) + .values({ + workspaceId, + jobType: data.jobType as + | "http" + | "tcp" + | "imcp" + | "udp" + | "dns" + | "ssl", + periodicity: periodicity as + | "30s" + | "1m" + | "5m" + | "10m" + | "30m" + | "1h" + | "other", + status: "active", + active: data.active, + regions: data.regions, + url: data.url, + name: data.name, + description: data.description, + headers: data.headers, + body: data.body, + method: data.method as + | "GET" + | "POST" + | "HEAD" + | "PUT" + | "PATCH" + | "DELETE" + | "TRACE" + | "CONNECT" + | "OPTIONS", + timeout: data.timeout, + }) + .returning({ id: monitor.id }); + + if (!inserted) { + resource.status = "failed"; + resource.error = "Insert returned no result"; + continue; + } + + monitorIdMap.set(resource.sourceId, inserted.id); + resource.openstatusId = inserted.id; + resource.status = "created"; + importedCount++; + } catch (err) { + resource.status = "failed"; + resource.error = err instanceof Error ? err.message : String(err); + } + } + + phase.status = computePhaseStatus(phase.resources); +} + // TODO: migrate to new `pageSubscription` + `pageSubscriptionToPageComponent` tables async function writeSubscribersPhase( phase: PhaseResult, diff --git a/packages/icons/src/betterstack.tsx b/packages/icons/src/betterstack.tsx new file mode 100644 index 00000000..b3ad514a --- /dev/null +++ b/packages/icons/src/betterstack.tsx @@ -0,0 +1,15 @@ +export function BetterstackIcon(props: React.ComponentProps<"svg">) { + return ( + + Better Stack + + + ); +} diff --git a/packages/icons/src/index.tsx b/packages/icons/src/index.tsx index 1f51368c..4535c65c 100644 --- a/packages/icons/src/index.tsx +++ b/packages/icons/src/index.tsx @@ -12,4 +12,5 @@ export * from "./telegram"; export * from "./whatsapp"; export * from "./markdown"; export * from "./statuspage"; +export * from "./betterstack"; export * from "./instatus"; diff --git a/packages/importers/README.md b/packages/importers/README.md new file mode 100644 index 00000000..f21e1d46 --- /dev/null +++ b/packages/importers/README.md @@ -0,0 +1,216 @@ +# @openstatus/importers + +Migrate monitors, status pages, components, incidents, and subscribers from third-party platforms into OpenStatus. + +## Supported Providers + +| Provider | What it imports | Auth | +|----------|----------------|------| +| **Atlassian Statuspage** | Status pages, components, component groups, incidents (as status reports), scheduled maintenances, email subscribers | OAuth API key | +| **Better Stack Uptime** | Monitors (HTTP checks), status pages, component groups (from sections + monitor groups), incidents (as status reports), maintenances | Bearer API token | +| **Instatus** | Status pages, component groups, components, incidents (as status reports), maintenances, email subscribers | API key | + +## Architecture + +``` +packages/importers/ + src/ + types.ts # Shared types (ImportProvider, ImportSummary, etc.) + index.ts # Provider registry + re-exports + providers/ + statuspage/ # Atlassian Statuspage provider + api-types.ts # Zod schemas for Statuspage API responses + client.ts # HTTP client (OAuth auth, page-based pagination) + mapper.ts # Statuspage -> OpenStatus data transformations + provider.ts # Import orchestration (validate + multi-phase run) + fixtures.ts # Mock data for tests + index.ts # Barrel exports + *.test.ts # Tests (client, mapper, provider) + betterstack/ # Better Stack Uptime provider + api-types.ts # Zod schemas for BetterStack API responses + client.ts # HTTP client (Bearer auth, JSON:API pagination) + mapper.ts # BetterStack -> OpenStatus data transformations + provider.ts # Import orchestration (validate + multi-phase run) + fixtures.ts # Mock data for tests + index.ts # Barrel exports + *.test.ts # Tests (client, mapper, provider) +``` + +## Core Types + +Every provider implements `ImportProvider`: + +```typescript +type ImportProvider = { + name: string; + validate: (config: TConfig) => Promise<{ valid: boolean; error?: string }>; + run: (config: TConfig) => Promise; +}; +``` + +A `run()` call returns an `ImportSummary` containing sequential `PhaseResult` entries. Each phase (e.g. `"monitors"`, `"page"`, `"components"`, `"incidents"`) contains an array of `ResourceResult` items with a `status` of `"created"`, `"skipped"`, or `"failed"` and an opaque `data` payload that the service layer writes to the database. + +The importers package is **read-only** -- it fetches data from external APIs and maps it into OpenStatus shapes. The actual database writes happen in `packages/api/src/service/import.ts`. + +## How the Import Pipeline Works + +``` +Dashboard UI (form-import.tsx) + | + | tRPC mutation: import.preview / import.run + v +API Router (packages/api/src/router/import.ts) + | + | Calls previewImport() or runImport() + v +Service Layer (packages/api/src/service/import.ts) + | + | 1. Creates provider via createProvider(name) + | 2. Validates API key via provider.validate() + | 3. Fetches + maps all data via provider.run() + | 4. Checks plan limits (monitors, components, custom domain, subscribers) + | 5. Writes to DB phase-by-phase with idempotency checks + v +Importers Package (this package) + | + | provider.run() orchestrates: + | - API client fetches resources (with pagination) + | - Mapper transforms external -> OpenStatus shapes + | - Returns ImportSummary with all phases + v +External API (Statuspage / BetterStack / ...) +``` + +## Import Phases by Provider + +### Statuspage + +| Phase | Source | Target | +|-------|--------|--------| +| `page` | Statuspage Page | `page` table | +| `componentGroups` | Component Groups | `pageComponentGroup` table | +| `components` | Components | `pageComponent` table (type: `"static"`) | +| `incidents` | Real-time Incidents | `statusReport` + `statusReportUpdate` tables | +| `maintenances` | Scheduled Incidents | `maintenance` table | +| `subscribers` | Email Subscribers | `pageSubscriber` table | + +### BetterStack + +| Phase | Source | Target | +|-------|--------|--------| +| `monitors` | Monitors | `monitor` table | +| `page` | Status Page (first or filtered by ID) | `page` table | +| `componentGroups` | Status Page Sections + Monitor Groups | `pageComponentGroup` table | +| `incidents` | Incidents (with synthetic updates from timestamps) | `statusReport` + `statusReportUpdate` tables | + +## Adding a New Provider + +Each provider follows the same 6-file pattern. Here is how to add one: + +### 1. Create the provider directory + +``` +packages/importers/src/providers// +``` + +### 2. Define API types (`api-types.ts`) + +Use Zod schemas matching the external API's response format. Export both the schema and the inferred TypeScript type: + +```typescript +import { z } from "zod"; + +export const MyResourceSchema = z.object({ + id: z.string(), + name: z.string(), + // ... +}); + +export type MyResource = z.infer; +``` + +### 3. Build the API client (`client.ts`) + +- Define a `Client` type with methods for each endpoint +- Handle authentication (Bearer, OAuth, API key header, etc.) +- Implement pagination (page-based, cursor-based, JSON:API, etc.) +- Parse all responses through Zod schemas for runtime safety + +```typescript +export type MyClient = { + getResources: () => Promise; +}; + +export function createMyClient(apiKey: string): MyClient { + // ... +} +``` + +### 4. Write mappers (`mapper.ts`) + +Pure functions that transform external API shapes into OpenStatus insert values. Each mapper takes the source object + `workspaceId` (and optionally `pageId`) and returns a plain object: + +```typescript +export function mapResource(resource: MyResource, workspaceId: number) { + return { + workspaceId, + name: resource.name, + // ... + }; +} +``` + +### 5. Create the provider (`provider.ts`) + +Implement `ImportProvider`: + +- `validate()` -- make a lightweight API call to verify the key works +- `run()` -- fetch all resources, map them, return `ImportSummary` with phases + +Each phase produces `ResourceResult[]` with the mapped data in `data`. The service layer reads `data` to write to the database. + +The `data` shape must match what the corresponding phase writer in `packages/api/src/service/import.ts` casts it to. For incidents, this means the mapper must return `{ report, updates, sourceComponentIds }` -- even if `sourceComponentIds` is empty. + +### 6. Register the provider + +1. Create `index.ts` with barrel exports +2. Add to `IMPORT_PROVIDERS` in `src/index.ts` +3. Add export paths in `package.json` +4. Add a case in `createProvider()` in `packages/api/src/service/import.ts` +5. Add the provider name to the `z.enum` in `packages/api/src/router/import.ts` +6. Add a radio button in `apps/dashboard/src/components/forms/components/form-import.tsx` + +### 7. Write tests + +Follow the existing pattern: + +- **`client.test.ts`** -- mock `globalThis.fetch`, test each endpoint, auth headers, pagination, and error handling +- **`mapper.test.ts`** -- test every mapping function with edge cases (nulls, unknown values, empty arrays) +- **`provider.test.ts`** -- mock all endpoints, verify phase structure, resource counts, and filtering +- **`fixtures.ts`** -- realistic mock data used by all three test files + +## Key Design Decisions + +- **Zod for API validation**: All external API responses are parsed through Zod schemas. This catches API changes at runtime rather than letting bad data silently propagate. +- **Idempotent writes**: The service layer checks for existing resources before inserting (by slug, name+pageId, url+workspaceId, etc.). Re-running an import skips already-imported resources. +- **Phase-based execution**: Imports run sequentially by phase so that ID mappings from earlier phases (e.g. group IDs) are available to later phases (e.g. components). If a phase fails, subsequent phases are skipped. +- **Dry-run support**: Setting `dryRun: true` in the config still fetches and maps all data but the service layer skips database writes. This powers the preview UI. +- **Plan limit enforcement**: The service layer checks workspace plan limits (max monitors, max components, custom domain support, subscriber support) and either warns (preview) or truncates (run). + +## Running Tests + +```sh +cd packages/importers +bun test +``` + +## Package Exports + +| Path | Description | +|------|-------------| +| `@openstatus/importers` | Main entry: types, provider registry, factory functions | +| `@openstatus/importers/types` | Just the shared types | +| `@openstatus/importers/statuspage` | Statuspage provider, client, schemas, types | +| `@openstatus/importers/statuspage/fixtures` | Statuspage mock data for tests | +| `@openstatus/importers/betterstack` | BetterStack provider, client, schemas, types | +| `@openstatus/importers/betterstack/fixtures` | BetterStack mock data for tests | diff --git a/packages/importers/package.json b/packages/importers/package.json index 64e8c470..bcd5fe57 100644 --- a/packages/importers/package.json +++ b/packages/importers/package.json @@ -8,6 +8,8 @@ "./types": "./src/types.ts", "./statuspage": "./src/providers/statuspage/index.ts", "./statuspage/fixtures": "./src/providers/statuspage/fixtures.ts", + "./betterstack": "./src/providers/betterstack/index.ts", + "./betterstack/fixtures": "./src/providers/betterstack/fixtures.ts", "./instatus": "./src/providers/instatus/index.ts", "./instatus/fixtures": "./src/providers/instatus/fixtures.ts" }, diff --git a/packages/importers/src/index.ts b/packages/importers/src/index.ts index 1932b40c..ff91058d 100644 --- a/packages/importers/src/index.ts +++ b/packages/importers/src/index.ts @@ -9,6 +9,9 @@ export type { export { createStatuspageProvider } from "./providers/statuspage"; export type { StatuspageImportConfig } from "./providers/statuspage"; +export { createBetterstackProvider } from "./providers/betterstack"; +export type { BetterstackImportConfig } from "./providers/betterstack"; + export { createInstatusProvider } from "./providers/instatus"; export type { InstatusImportConfig } from "./providers/instatus"; @@ -16,5 +19,9 @@ export type { InstatusImportConfig } from "./providers/instatus"; * Registry of all available import providers. * Add new providers here as they are implemented. */ -export const IMPORT_PROVIDERS = ["statuspage", "instatus"] as const; +export const IMPORT_PROVIDERS = [ + "statuspage", + "betterstack", + "instatus", +] as const; export type ImportProviderName = (typeof IMPORT_PROVIDERS)[number]; diff --git a/packages/importers/src/providers/betterstack/api-types.ts b/packages/importers/src/providers/betterstack/api-types.ts new file mode 100644 index 00000000..e7a4b4c3 --- /dev/null +++ b/packages/importers/src/providers/betterstack/api-types.ts @@ -0,0 +1,174 @@ +import { z } from "zod"; + +export const BetterstackMonitorSchema = z.object({ + id: z.string(), + type: z.literal("monitor"), + attributes: z.object({ + url: z.string(), + pronounceable_name: z.string(), + monitor_type: z.string(), + monitor_group_id: z + .union([z.string(), z.number()]) + .nullable() + .transform((v) => (v != null ? String(v) : null)), + http_method: z.string().default("get"), + check_frequency: z.number(), + request_timeout: z.number(), + request_headers: z + .array( + z.object({ + id: z.string().optional(), + name: z.string(), + value: z.string(), + }), + ) + .default([]), + request_body: z.string().default(""), + expected_status_codes: z.array(z.number()).default([]), + required_keyword: z.string().nullable().default(null), + verify_ssl: z.boolean().default(true), + regions: z.array(z.string()).default([]), + status: z.string(), + paused_at: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), + }), +}); + +export type BetterstackMonitor = z.infer; + +export const BetterstackMonitorGroupSchema = z.object({ + id: z.string(), + type: z.literal("monitor_group"), + attributes: z.object({ + name: z.string(), + sort_index: z.number().nullable(), + paused: z.boolean(), + created_at: z.string(), + updated_at: z.string(), + }), +}); + +export type BetterstackMonitorGroup = z.infer< + typeof BetterstackMonitorGroupSchema +>; + +export const BetterstackStatusPageSchema = z.object({ + id: z.string(), + type: z.literal("status_page"), + attributes: z.object({ + company_name: z.string(), + company_url: z.string().nullable(), + subdomain: z.string(), + custom_domain: z.string().nullable(), + timezone: z.string().nullable(), + subscribable: z.boolean().default(false), + aggregate_state: z.string(), + created_at: z.string(), + updated_at: z.string(), + }), +}); + +export type BetterstackStatusPage = z.infer; + +export const BetterstackStatusPageSectionSchema = z.object({ + id: z.string(), + type: z.literal("status_page_section"), + attributes: z.object({ + name: z.string(), + position: z.number(), + status_page_id: z.number(), + }), +}); + +export type BetterstackStatusPageSection = z.infer< + typeof BetterstackStatusPageSectionSchema +>; + +export const BetterstackIncidentSchema = z.object({ + id: z.string(), + type: z.literal("incident"), + attributes: z.object({ + name: z.string().nullable(), + url: z.string().nullable(), + cause: z.string().nullable(), + started_at: z.string(), + acknowledged_at: z.string().nullable(), + resolved_at: z.string().nullable(), + status: z.string(), + regions: z.array(z.string()).default([]), + }), +}); + +export type BetterstackIncident = z.infer; + +export const BetterstackStatusPageResourceSchema = z.object({ + id: z.string(), + type: z.literal("status_page_resource"), + attributes: z.object({ + status_page_section_id: z.number().nullable(), + resource_id: z.number().nullable(), + resource_type: z.string().nullable(), + public_name: z.string(), + explanation: z.string().nullable().default(null), + position: z.number().default(0), + widget_type: z.string().nullable().default(null), + status: z.string().nullable().default(null), + }), +}); + +export type BetterstackStatusPageResource = z.infer< + typeof BetterstackStatusPageResourceSchema +>; + +const BetterstackAffectedResourceSchema = z.object({ + status_page_resource_id: z.string(), + status: z.string(), +}); + +export const BetterstackStatusReportSchema = z.object({ + id: z.string(), + type: z.literal("status_report"), + attributes: z.object({ + title: z.string(), + report_type: z.string(), + starts_at: z.string().nullable(), + ends_at: z.string().nullable(), + status_page_id: z.number(), + affected_resources: z.array(BetterstackAffectedResourceSchema).default([]), + aggregate_state: z.string().nullable(), + }), +}); + +export type BetterstackStatusReport = z.infer< + typeof BetterstackStatusReportSchema +>; + +export const BetterstackStatusUpdateSchema = z.object({ + id: z.string(), + type: z.literal("status_update"), + attributes: z.object({ + message: z.string().nullable(), + published_at: z.string(), + affected_resources: z.array(BetterstackAffectedResourceSchema).default([]), + status_report_id: z.number().nullable(), + }), +}); + +export type BetterstackStatusUpdate = z.infer< + typeof BetterstackStatusUpdateSchema +>; + +export const PaginationSchema = z.object({ + first: z.string().nullable(), + last: z.string().nullable(), + prev: z.string().nullable(), + next: z.string().nullable(), +}); + +export function paginatedResponse(dataSchema: T) { + return z.object({ + data: z.array(dataSchema), + pagination: PaginationSchema, + }); +} diff --git a/packages/importers/src/providers/betterstack/client.test.ts b/packages/importers/src/providers/betterstack/client.test.ts new file mode 100644 index 00000000..9b874afd --- /dev/null +++ b/packages/importers/src/providers/betterstack/client.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { createBetterstackClient } from "./client"; +import { + MOCK_INCIDENTS, + MOCK_MONITORS, + MOCK_MONITOR_GROUPS, + MOCK_STATUS_PAGES, + MOCK_STATUS_PAGE_RESOURCES, + MOCK_STATUS_PAGE_SECTIONS, + MOCK_STATUS_REPORTS, + MOCK_STATUS_UPDATES_REPORT_001, +} from "./fixtures"; + +const originalFetch = globalThis.fetch; + +function mockFetchPaginated(data: unknown[], status = 200) { + let callCount = 0; + globalThis.fetch = mock(() => { + callCount++; + const items = callCount === 1 ? data : []; + const body = { + data: items, + pagination: { + first: "https://uptime.betterstack.com/api/v2/test?page=1", + last: "https://uptime.betterstack.com/api/v2/test?page=1", + prev: null, + next: null, + }, + }; + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + statusText: status === 200 ? "OK" : "Unauthorized", + headers: { "Content-Type": "application/json" }, + }), + ); + }) as typeof globalThis.fetch; +} + +function mockFetchError(status: number, statusText: string) { + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ error: statusText }), { + status, + statusText, + headers: { "Content-Type": "application/json" }, + }), + ), + ) as typeof globalThis.fetch; +} + +describe("BetterstackClient", () => { + let client: ReturnType; + + beforeEach(() => { + client = createBetterstackClient("test-api-key"); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("getMonitors returns parsed monitors", async () => { + mockFetchPaginated(MOCK_MONITORS); + const monitors = await client.getMonitors(); + expect(monitors).toEqual(MOCK_MONITORS); + expect(monitors).toHaveLength(3); + expect(monitors[0].attributes.pronounceable_name).toBe("API Health Check"); + }); + + test("getMonitorGroups returns parsed groups", async () => { + mockFetchPaginated(MOCK_MONITOR_GROUPS); + const groups = await client.getMonitorGroups(); + expect(groups).toEqual(MOCK_MONITOR_GROUPS); + expect(groups).toHaveLength(1); + expect(groups[0].attributes.name).toBe("Core Services"); + }); + + test("getStatusPages returns parsed status pages", async () => { + mockFetchPaginated(MOCK_STATUS_PAGES); + const pages = await client.getStatusPages(); + expect(pages).toEqual(MOCK_STATUS_PAGES); + expect(pages).toHaveLength(1); + expect(pages[0].attributes.company_name).toBe("Acme Corp"); + }); + + test("getStatusPageSections returns parsed sections", async () => { + mockFetchPaginated(MOCK_STATUS_PAGE_SECTIONS); + const sections = await client.getStatusPageSections("bs_sp_001"); + expect(sections).toEqual(MOCK_STATUS_PAGE_SECTIONS); + expect(sections).toHaveLength(2); + expect(sections[0].attributes.name).toBe("API Services"); + }); + + test("getStatusPageResources returns parsed resources", async () => { + mockFetchPaginated(MOCK_STATUS_PAGE_RESOURCES); + const resources = await client.getStatusPageResources("bs_sp_001"); + expect(resources).toEqual(MOCK_STATUS_PAGE_RESOURCES); + expect(resources).toHaveLength(3); + expect(resources[0].attributes.public_name).toBe("API Gateway"); + }); + + test("getStatusPageReports returns parsed reports", async () => { + mockFetchPaginated(MOCK_STATUS_REPORTS); + const reports = await client.getStatusPageReports("bs_sp_001"); + expect(reports).toEqual(MOCK_STATUS_REPORTS); + expect(reports).toHaveLength(3); + expect(reports[0].attributes.title).toBe( + "API Gateway Elevated Error Rates", + ); + expect(reports[2].attributes.report_type).toBe("maintenance"); + }); + + test("getStatusReportUpdates returns parsed updates", async () => { + mockFetchPaginated(MOCK_STATUS_UPDATES_REPORT_001); + const updates = await client.getStatusReportUpdates( + "bs_sp_001", + "bs_report_001", + ); + expect(updates).toEqual(MOCK_STATUS_UPDATES_REPORT_001); + expect(updates).toHaveLength(3); + expect(updates[0].attributes.message).toContain("investigating"); + }); + + test("getIncidents returns parsed incidents", async () => { + mockFetchPaginated(MOCK_INCIDENTS); + const incidents = await client.getIncidents(); + expect(incidents).toEqual(MOCK_INCIDENTS); + expect(incidents).toHaveLength(3); + expect(incidents[0].attributes.status).toBe("resolved"); + expect(incidents[1].attributes.acknowledged_at).not.toBeNull(); + expect(incidents[2].attributes.name).toBeNull(); + }); + + test("throws on API error (401)", async () => { + mockFetchError(401, "Unauthorized"); + await expect(client.getMonitors()).rejects.toThrow( + "BetterStack API error: 401 Unauthorized", + ); + }); + + test("sends correct auth header", async () => { + mockFetchPaginated(MOCK_MONITORS); + await client.getMonitors(); + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://uptime.betterstack.com/api/v2/monitors"); + expect((options.headers as Record).Authorization).toBe( + "Bearer test-api-key", + ); + }); + + test("follows pagination.next for multiple pages", async () => { + let callCount = 0; + globalThis.fetch = mock(() => { + callCount++; + const body = + callCount === 1 + ? { + data: [MOCK_MONITORS[0]], + pagination: { + first: null, + last: null, + prev: null, + next: "https://uptime.betterstack.com/api/v2/monitors?page=2", + }, + } + : { + data: [MOCK_MONITORS[1]], + pagination: { + first: null, + last: null, + prev: null, + next: null, + }, + }; + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + }), + ); + }) as typeof globalThis.fetch; + + const monitors = await client.getMonitors(); + expect(monitors).toHaveLength(2); + expect(monitors[0].id).toBe("1001"); + expect(monitors[1].id).toBe("1002"); + expect(callCount).toBe(2); + }); +}); diff --git a/packages/importers/src/providers/betterstack/client.ts b/packages/importers/src/providers/betterstack/client.ts new file mode 100644 index 00000000..32dd8da5 --- /dev/null +++ b/packages/importers/src/providers/betterstack/client.ts @@ -0,0 +1,112 @@ +import type { z } from "zod"; +import { + type BetterstackIncident, + BetterstackIncidentSchema, + type BetterstackMonitor, + type BetterstackMonitorGroup, + BetterstackMonitorGroupSchema, + BetterstackMonitorSchema, + type BetterstackStatusPage, + type BetterstackStatusPageResource, + BetterstackStatusPageResourceSchema, + BetterstackStatusPageSchema, + type BetterstackStatusPageSection, + BetterstackStatusPageSectionSchema, + type BetterstackStatusReport, + BetterstackStatusReportSchema, + type BetterstackStatusUpdate, + BetterstackStatusUpdateSchema, + paginatedResponse, +} from "./api-types"; + +export type BetterstackClient = { + getMonitors: () => Promise; + getMonitorGroups: () => Promise; + getStatusPages: () => Promise; + getStatusPageSections: ( + statusPageId: string, + ) => Promise; + getStatusPageResources: ( + statusPageId: string, + ) => Promise; + getStatusPageReports: ( + statusPageId: string, + ) => Promise; + getStatusReportUpdates: ( + statusPageId: string, + reportId: string, + ) => Promise; + getIncidents: () => Promise; +}; + +export function createBetterstackClient( + apiKey: string, + baseUrl = "https://uptime.betterstack.com", +): BetterstackClient { + async function request(url: string, schema: z.ZodType): Promise { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const path = new URL(url).pathname; + throw new Error( + `BetterStack API error: ${response.status} ${response.statusText} for ${path}`, + ); + } + + const data = await response.json(); + return schema.parse(data); + } + + async function requestAllPages( + path: string, + itemSchema: T, + ): Promise[]> { + const all: z.infer[] = []; + let url: string | null = `${baseUrl}${path}`; + const pageSchema = paginatedResponse(itemSchema); + + while (url) { + const result: z.infer = await request(url, pageSchema); + all.push(...result.data); + url = result.pagination.next; + } + + return all; + } + + return { + getMonitors: () => + requestAllPages("/api/v2/monitors", BetterstackMonitorSchema), + getMonitorGroups: () => + requestAllPages("/api/v2/monitor-groups", BetterstackMonitorGroupSchema), + getStatusPages: () => + requestAllPages("/api/v2/status-pages", BetterstackStatusPageSchema), + getStatusPageSections: (statusPageId) => + requestAllPages( + `/api/v2/status-pages/${statusPageId}/sections`, + BetterstackStatusPageSectionSchema, + ), + getStatusPageResources: (statusPageId) => + requestAllPages( + `/api/v2/status-pages/${statusPageId}/resources`, + BetterstackStatusPageResourceSchema, + ), + getStatusPageReports: (statusPageId) => + requestAllPages( + `/api/v2/status-pages/${statusPageId}/status-reports`, + BetterstackStatusReportSchema, + ), + getStatusReportUpdates: (statusPageId, reportId) => + requestAllPages( + `/api/v2/status-pages/${statusPageId}/status-reports/${reportId}/status-updates`, + BetterstackStatusUpdateSchema, + ), + getIncidents: () => + requestAllPages("/api/v3/incidents", BetterstackIncidentSchema), + }; +} diff --git a/packages/importers/src/providers/betterstack/fixtures.ts b/packages/importers/src/providers/betterstack/fixtures.ts new file mode 100644 index 00000000..0a947a79 --- /dev/null +++ b/packages/importers/src/providers/betterstack/fixtures.ts @@ -0,0 +1,360 @@ +import type { + BetterstackIncident, + BetterstackMonitor, + BetterstackMonitorGroup, + BetterstackStatusPage, + BetterstackStatusPageResource, + BetterstackStatusPageSection, + BetterstackStatusReport, + BetterstackStatusUpdate, +} from "./api-types"; + +export const MOCK_MONITORS: BetterstackMonitor[] = [ + { + id: "1001", + type: "monitor", + attributes: { + url: "https://api.acmecorp.com/health", + pronounceable_name: "API Health Check", + monitor_type: "status", + monitor_group_id: "bs_group_001", + http_method: "get", + check_frequency: 180, + request_timeout: 15, + request_headers: [ + { id: "h1", name: "X-Custom-Header", value: "monitoring" }, + ], + request_body: "", + expected_status_codes: [200], + required_keyword: null, + verify_ssl: true, + regions: ["us", "eu"], + status: "up", + paused_at: null, + created_at: "2024-01-10T08:00:00.000Z", + updated_at: "2024-06-01T12:00:00.000Z", + }, + }, + { + id: "1002", + type: "monitor", + attributes: { + url: "https://app.acmecorp.com", + pronounceable_name: "Dashboard", + monitor_type: "keyword", + monitor_group_id: "bs_group_001", + http_method: "get", + check_frequency: 60, + request_timeout: 30, + request_headers: [], + request_body: "", + expected_status_codes: [], + required_keyword: "Welcome", + verify_ssl: true, + regions: ["us", "eu", "as"], + status: "up", + paused_at: null, + created_at: "2024-02-01T10:00:00.000Z", + updated_at: "2024-06-01T12:00:00.000Z", + }, + }, + { + id: "1003", + type: "monitor", + attributes: { + url: "https://cdn.acmecorp.com", + pronounceable_name: "CDN Check", + monitor_type: "expected_status_code", + monitor_group_id: null, + http_method: "head", + check_frequency: 300, + request_timeout: 10, + request_headers: [], + request_body: "", + expected_status_codes: [200, 301], + required_keyword: null, + verify_ssl: false, + regions: ["us", "eu", "as", "au"], + status: "paused", + paused_at: "2024-05-15T00:00:00.000Z", + created_at: "2024-03-10T09:00:00.000Z", + updated_at: "2024-05-15T00:00:00.000Z", + }, + }, +]; + +export const MOCK_MONITOR_GROUPS: BetterstackMonitorGroup[] = [ + { + id: "bs_group_001", + type: "monitor_group", + attributes: { + name: "Core Services", + sort_index: 0, + paused: false, + created_at: "2024-01-10T08:00:00.000Z", + updated_at: "2024-06-01T12:00:00.000Z", + }, + }, +]; + +export const MOCK_STATUS_PAGES: BetterstackStatusPage[] = [ + { + id: "bs_sp_001", + type: "status_page", + attributes: { + company_name: "Acme Corp", + company_url: "https://acmecorp.com", + subdomain: "acmecorp", + custom_domain: "status.acmecorp.com", + timezone: "America/New_York", + subscribable: true, + aggregate_state: "operational", + created_at: "2024-01-15T10:00:00.000Z", + updated_at: "2024-06-01T12:00:00.000Z", + }, + }, +]; + +export const MOCK_STATUS_PAGE_SECTIONS: BetterstackStatusPageSection[] = [ + { + id: "100001", + type: "status_page_section", + attributes: { + name: "API Services", + position: 0, + status_page_id: 123456789, + }, + }, + { + id: "100002", + type: "status_page_section", + attributes: { + name: "Web Applications", + position: 1, + status_page_id: 123456789, + }, + }, +]; + +export const MOCK_STATUS_PAGE_RESOURCES: BetterstackStatusPageResource[] = [ + { + id: "bs_res_001", + type: "status_page_resource", + attributes: { + status_page_section_id: 100001, + resource_id: 1001, + resource_type: "Monitor", + public_name: "API Gateway", + explanation: "Main API endpoint health", + position: 0, + widget_type: "history", + status: "operational", + }, + }, + { + id: "bs_res_002", + type: "status_page_resource", + attributes: { + status_page_section_id: 100001, + resource_id: 1002, + resource_type: "Monitor", + public_name: "Authentication Service", + explanation: null, + position: 1, + widget_type: "history", + status: "operational", + }, + }, + { + id: "bs_res_003", + type: "status_page_resource", + attributes: { + status_page_section_id: null, + resource_id: 1003, + resource_type: "Monitor", + public_name: "CDN", + explanation: null, + position: 2, + widget_type: "plain", + status: "degraded", + }, + }, +]; + +export const MOCK_STATUS_REPORTS: BetterstackStatusReport[] = [ + { + id: "bs_report_001", + type: "status_report", + attributes: { + title: "API Gateway Elevated Error Rates", + report_type: "manual", + starts_at: "2024-06-10T14:00:00.000Z", + ends_at: "2024-06-10T16:30:00.000Z", + status_page_id: 123456789, + affected_resources: [ + { status_page_resource_id: "bs_res_001", status: "downtime" }, + ], + aggregate_state: "Downtime", + }, + }, + { + id: "bs_report_002", + type: "status_report", + attributes: { + title: "Dashboard Slow Responses", + report_type: "manual", + starts_at: "2024-06-11T09:00:00.000Z", + ends_at: null, + status_page_id: 123456789, + affected_resources: [ + { status_page_resource_id: "bs_res_002", status: "degraded" }, + ], + aggregate_state: "Degraded", + }, + }, + { + id: "bs_report_003", + type: "status_report", + attributes: { + title: "Scheduled Database Maintenance", + report_type: "maintenance", + starts_at: "2024-06-15T02:00:00.000Z", + ends_at: "2024-06-15T06:00:00.000Z", + status_page_id: 123456789, + affected_resources: [ + { status_page_resource_id: "bs_res_001", status: "maintenance" }, + { status_page_resource_id: "bs_res_002", status: "maintenance" }, + ], + aggregate_state: "Maintenance", + }, + }, +]; + +export const MOCK_STATUS_UPDATES_REPORT_001: BetterstackStatusUpdate[] = [ + { + id: "bs_update_001", + type: "status_update", + attributes: { + message: "We are investigating elevated error rates on the API Gateway.", + published_at: "2024-06-10T14:05:00.000Z", + affected_resources: [ + { status_page_resource_id: "bs_res_001", status: "downtime" }, + ], + status_report_id: 1, + }, + }, + { + id: "bs_update_002", + type: "status_update", + attributes: { + message: + "The root cause has been identified as an upstream provider issue.", + published_at: "2024-06-10T14:30:00.000Z", + affected_resources: [ + { status_page_resource_id: "bs_res_001", status: "downtime" }, + ], + status_report_id: 1, + }, + }, + { + id: "bs_update_003", + type: "status_update", + attributes: { + message: "The issue has been resolved. All systems are operational.", + published_at: "2024-06-10T16:30:00.000Z", + affected_resources: [ + { status_page_resource_id: "bs_res_001", status: "resolved" }, + ], + status_report_id: 1, + }, + }, +]; + +export const MOCK_STATUS_UPDATES_REPORT_002: BetterstackStatusUpdate[] = [ + { + id: "bs_update_004", + type: "status_update", + attributes: { + message: "We are aware of slow response times on the dashboard.", + published_at: "2024-06-11T09:10:00.000Z", + affected_resources: [ + { status_page_resource_id: "bs_res_002", status: "degraded" }, + ], + status_report_id: 2, + }, + }, +]; + +export const MOCK_STATUS_UPDATES_REPORT_003: BetterstackStatusUpdate[] = [ + { + id: "bs_update_005", + type: "status_update", + attributes: { + message: "Scheduled database maintenance will begin shortly.", + published_at: "2024-06-15T01:55:00.000Z", + affected_resources: [ + { status_page_resource_id: "bs_res_001", status: "maintenance" }, + { status_page_resource_id: "bs_res_002", status: "maintenance" }, + ], + status_report_id: 3, + }, + }, + { + id: "bs_update_006", + type: "status_update", + attributes: { + message: "Maintenance completed successfully.", + published_at: "2024-06-15T05:30:00.000Z", + affected_resources: [ + { status_page_resource_id: "bs_res_001", status: "resolved" }, + { status_page_resource_id: "bs_res_002", status: "resolved" }, + ], + status_report_id: 3, + }, + }, +]; + +export const MOCK_INCIDENTS: BetterstackIncident[] = [ + { + id: "bs_inc_001", + type: "incident", + attributes: { + name: "API Gateway Outage", + url: "https://api.acmecorp.com/health", + cause: "Upstream provider failure caused 502 errors", + started_at: "2024-06-10T14:00:00.000Z", + acknowledged_at: "2024-06-10T14:15:00.000Z", + resolved_at: "2024-06-10T16:30:00.000Z", + status: "resolved", + regions: ["us", "eu"], + }, + }, + { + id: "bs_inc_002", + type: "incident", + attributes: { + name: "Dashboard Slow Responses", + url: "https://app.acmecorp.com", + cause: "Database connection pool exhaustion", + started_at: "2024-06-11T09:00:00.000Z", + acknowledged_at: "2024-06-11T09:30:00.000Z", + resolved_at: null, + status: "acknowledged", + regions: ["us"], + }, + }, + { + id: "bs_inc_003", + type: "incident", + attributes: { + name: null, + url: "https://cdn.acmecorp.com", + cause: null, + started_at: "2024-06-12T06:00:00.000Z", + acknowledged_at: null, + resolved_at: "2024-06-12T06:05:00.000Z", + status: "resolved", + regions: ["au"], + }, + }, +]; diff --git a/packages/importers/src/providers/betterstack/index.ts b/packages/importers/src/providers/betterstack/index.ts new file mode 100644 index 00000000..863f5f4d --- /dev/null +++ b/packages/importers/src/providers/betterstack/index.ts @@ -0,0 +1,24 @@ +export { createBetterstackClient } from "./client"; +export type { BetterstackClient } from "./client"; +export type { + BetterstackIncident, + BetterstackMonitor, + BetterstackMonitorGroup, + BetterstackStatusPage, + BetterstackStatusPageResource, + BetterstackStatusPageSection, + BetterstackStatusReport, + BetterstackStatusUpdate, +} from "./api-types"; +export { + BetterstackIncidentSchema, + BetterstackMonitorGroupSchema, + BetterstackMonitorSchema, + BetterstackStatusPageResourceSchema, + BetterstackStatusPageSchema, + BetterstackStatusPageSectionSchema, + BetterstackStatusReportSchema, + BetterstackStatusUpdateSchema, +} from "./api-types"; +export { createBetterstackProvider } from "./provider"; +export type { BetterstackImportConfig } from "./provider"; diff --git a/packages/importers/src/providers/betterstack/mapper.test.ts b/packages/importers/src/providers/betterstack/mapper.test.ts new file mode 100644 index 00000000..7fce9864 --- /dev/null +++ b/packages/importers/src/providers/betterstack/mapper.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, test } from "bun:test"; +import { + MOCK_INCIDENTS, + MOCK_MONITORS, + MOCK_MONITOR_GROUPS, + MOCK_STATUS_PAGES, + MOCK_STATUS_PAGE_RESOURCES, + MOCK_STATUS_PAGE_SECTIONS, + MOCK_STATUS_REPORTS, + MOCK_STATUS_UPDATES_REPORT_001, + MOCK_STATUS_UPDATES_REPORT_002, + MOCK_STATUS_UPDATES_REPORT_003, +} from "./fixtures"; +import { + mapFrequency, + mapIncidentStatus, + mapIncidentToStatusReport, + mapMethod, + mapMonitor, + mapMonitorGroup, + mapMonitorType, + mapRegions, + mapReportAggregateState, + mapReportToMaintenance, + mapReportToStatusReport, + mapResource, + mapSection, + mapStatusPage, +} from "./mapper"; + +describe("mapFrequency", () => { + test("maps known values", () => { + expect(mapFrequency(30)).toBe("30s"); + expect(mapFrequency(60)).toBe("1m"); + expect(mapFrequency(300)).toBe("5m"); + expect(mapFrequency(600)).toBe("10m"); + expect(mapFrequency(1800)).toBe("30m"); + expect(mapFrequency(3600)).toBe("1h"); + }); + + test("snaps to nearest supported value", () => { + expect(mapFrequency(45)).toBe("30s"); + expect(mapFrequency(90)).toBe("1m"); + expect(mapFrequency(400)).toBe("5m"); + expect(mapFrequency(900)).toBe("10m"); + expect(mapFrequency(2400)).toBe("30m"); + }); +}); + +describe("mapRegions", () => { + test("maps known regions", () => { + expect(mapRegions(["us"])).toBe("iad"); + expect(mapRegions(["eu"])).toBe("fra"); + expect(mapRegions(["as"])).toBe("sin"); + expect(mapRegions(["au"])).toBe("syd"); + }); + + test("maps multiple regions", () => { + expect(mapRegions(["us", "eu"])).toBe("iad,fra"); + expect(mapRegions(["us", "eu", "as", "au"])).toBe("iad,fra,sin,syd"); + }); + + test("returns default for unknown regions", () => { + expect(mapRegions([])).toBe("iad"); + expect(mapRegions(["unknown"])).toBe("iad"); + }); +}); + +describe("mapMonitorType", () => { + test("maps known types", () => { + expect(mapMonitorType("status")).toBe("http"); + expect(mapMonitorType("keyword")).toBe("http"); + expect(mapMonitorType("expected_status_code")).toBe("http"); + expect(mapMonitorType("tcp")).toBe("tcp"); + expect(mapMonitorType("udp")).toBe("udp"); + expect(mapMonitorType("dns")).toBe("dns"); + }); + + test("defaults to http for unknown types", () => { + expect(mapMonitorType("unknown")).toBe("http"); + }); +}); + +describe("mapMethod", () => { + test("uppercases method", () => { + expect(mapMethod("get")).toBe("GET"); + expect(mapMethod("post")).toBe("POST"); + expect(mapMethod("head")).toBe("HEAD"); + }); +}); + +describe("mapMonitor", () => { + test("maps a full monitor", () => { + const result = mapMonitor(MOCK_MONITORS[0], 42); + expect(result.workspaceId).toBe(42); + expect(result.url).toBe("https://api.acmecorp.com/health"); + expect(result.name).toBe("API Health Check"); + expect(result.jobType).toBe("http"); + expect(result.method).toBe("GET"); + expect(result.periodicity).toBe("1m"); + expect(result.timeout).toBe(15000); + expect(result.active).toBe(true); + expect(result.regions).toBe("iad,fra"); + expect(result.headers).toContain("X-Custom-Header"); + }); + + test("maps a paused monitor", () => { + const result = mapMonitor(MOCK_MONITORS[2], 42); + expect(result.active).toBe(false); + expect(result.method).toBe("HEAD"); + }); + + test("maps monitor with empty headers", () => { + const result = mapMonitor(MOCK_MONITORS[1], 42); + expect(result.headers).toBe(""); + }); +}); + +describe("mapMonitorGroup", () => { + test("maps a monitor group", () => { + const result = mapMonitorGroup(MOCK_MONITOR_GROUPS[0], 42, 1); + expect(result.workspaceId).toBe(42); + expect(result.pageId).toBe(1); + expect(result.name).toBe("Core Services"); + }); +}); + +describe("mapStatusPage", () => { + test("maps a status page", () => { + const result = mapStatusPage(MOCK_STATUS_PAGES[0], 42); + expect(result.workspaceId).toBe(42); + expect(result.title).toBe("Acme Corp"); + expect(result.slug).toBe("acmecorp"); + expect(result.customDomain).toBe("status.acmecorp.com"); + expect(result.published).toBe(true); + }); +}); + +describe("mapSection", () => { + test("maps a section", () => { + const result = mapSection(MOCK_STATUS_PAGE_SECTIONS[0], 42, 1); + expect(result.workspaceId).toBe(42); + expect(result.pageId).toBe(1); + expect(result.name).toBe("API Services"); + }); + + test("section id matches resource sourceGroupId", () => { + // Section id (string) must match String(resource.status_page_section_id) + const sectionId = MOCK_STATUS_PAGE_SECTIONS[0].id; + const resourceSectionId = String( + MOCK_STATUS_PAGE_RESOURCES[0].attributes.status_page_section_id, + ); + expect(sectionId).toBe(resourceSectionId); + }); +}); + +describe("mapResource", () => { + const monitorMap = new Map([ + ["1001", "1001"], + ["1002", "1002"], + ["1003", "1003"], + ]); + + test("maps a monitor resource with section", () => { + const result = mapResource( + MOCK_STATUS_PAGE_RESOURCES[0], + 42, + 1, + monitorMap, + ); + expect(result.workspaceId).toBe(42); + expect(result.pageId).toBe(1); + expect(result.type).toBe("monitor"); + expect(result.monitorId).toBeNull(); + expect(result.sourceMonitorId).toBe("1001"); + expect(result.name).toBe("API Gateway"); + expect(result.description).toBe("Main API endpoint health"); + expect(result.order).toBe(0); + expect(result.sourceGroupId).toBe("100001"); + }); + + test("maps a resource without section", () => { + const result = mapResource( + MOCK_STATUS_PAGE_RESOURCES[2], + 42, + 1, + monitorMap, + ); + expect(result.name).toBe("CDN"); + expect(result.description).toBeNull(); + expect(result.sourceGroupId).toBeNull(); + }); + + test("maps a resource without pageId", () => { + const result = mapResource( + MOCK_STATUS_PAGE_RESOURCES[0], + 42, + undefined, + monitorMap, + ); + expect(result.pageId).toBeUndefined(); + }); + + test("sourceMonitorId is null without lookup map", () => { + const result = mapResource(MOCK_STATUS_PAGE_RESOURCES[0], 42, 1); + expect(result.type).toBe("monitor"); + expect(result.sourceMonitorId).toBeNull(); + }); + + test("sourceMonitorId is null when resource_id not in lookup map", () => { + const emptyMap = new Map(); + const result = mapResource(MOCK_STATUS_PAGE_RESOURCES[0], 42, 1, emptyMap); + expect(result.type).toBe("monitor"); + expect(result.sourceMonitorId).toBeNull(); + }); +}); + +describe("mapReportAggregateState", () => { + test("maps known states", () => { + expect(mapReportAggregateState("Operational")).toBe("resolved"); + expect(mapReportAggregateState("Downtime")).toBe("investigating"); + expect(mapReportAggregateState("Degraded")).toBe("identified"); + expect(mapReportAggregateState("Maintenance")).toBe("monitoring"); + }); + + test("maps lowercase states", () => { + expect(mapReportAggregateState("resolved")).toBe("resolved"); + expect(mapReportAggregateState("downtime")).toBe("investigating"); + expect(mapReportAggregateState("degraded")).toBe("identified"); + }); + + test("defaults to investigating for null/unknown", () => { + expect(mapReportAggregateState(null)).toBe("investigating"); + expect(mapReportAggregateState("unknown")).toBe("investigating"); + }); +}); + +describe("mapReportToStatusReport", () => { + test("maps a resolved report with 3 updates", () => { + const result = mapReportToStatusReport( + MOCK_STATUS_REPORTS[0], + MOCK_STATUS_UPDATES_REPORT_001, + 42, + 1, + ); + expect(result.report.title).toBe("API Gateway Elevated Error Rates"); + expect(result.report.workspaceId).toBe(42); + expect(result.report.pageId).toBe(1); + expect(result.updates).toHaveLength(3); + // Updates sorted by published_at + expect(result.updates[0].message).toContain("investigating"); + expect(result.updates[2].message).toContain("resolved"); + // Last update determines report status + expect(result.report.status).toBe("resolved"); + // sourceComponentIds from affected_resources + expect(result.sourceComponentIds).toEqual(["bs_res_001"]); + }); + + test("maps an ongoing report with 1 update", () => { + const result = mapReportToStatusReport( + MOCK_STATUS_REPORTS[1], + MOCK_STATUS_UPDATES_REPORT_002, + 42, + 1, + ); + expect(result.report.title).toBe("Dashboard Slow Responses"); + expect(result.updates).toHaveLength(1); + expect(result.report.status).toBe("identified"); + expect(result.sourceComponentIds).toEqual(["bs_res_002"]); + }); + + test("creates synthetic update when no updates exist", () => { + const result = mapReportToStatusReport(MOCK_STATUS_REPORTS[0], [], 42); + expect(result.updates).toHaveLength(1); + expect(result.updates[0].message).toBe("API Gateway Elevated Error Rates"); + expect(result.report.pageId).toBeUndefined(); + }); +}); + +describe("mapReportToMaintenance", () => { + test("maps a maintenance report with dates", () => { + const result = mapReportToMaintenance( + MOCK_STATUS_REPORTS[2], + MOCK_STATUS_UPDATES_REPORT_003, + 42, + 1, + ); + expect(result.title).toBe("Scheduled Database Maintenance"); + expect(result.workspaceId).toBe(42); + expect(result.pageId).toBe(1); + expect(result.from).toEqual(new Date("2024-06-15T02:00:00.000Z")); + expect(result.to).toEqual(new Date("2024-06-15T06:00:00.000Z")); + expect(result.sourceComponentIds).toEqual(["bs_res_001", "bs_res_002"]); + // Message is joined from update messages + expect(result.message).toContain("begin shortly"); + expect(result.message).toContain("completed successfully"); + }); + + test("uses title as message when no updates", () => { + const result = mapReportToMaintenance(MOCK_STATUS_REPORTS[2], [], 42); + expect(result.message).toBe("Scheduled Database Maintenance"); + }); +}); + +describe("mapIncidentStatus", () => { + test("maps known statuses", () => { + expect(mapIncidentStatus("started")).toBe("investigating"); + expect(mapIncidentStatus("acknowledged")).toBe("identified"); + expect(mapIncidentStatus("resolved")).toBe("resolved"); + }); + + test("defaults to investigating", () => { + expect(mapIncidentStatus("unknown")).toBe("investigating"); + }); +}); + +describe("mapIncidentToStatusReport", () => { + test("maps a fully resolved incident with 3 updates", () => { + const result = mapIncidentToStatusReport(MOCK_INCIDENTS[0], 42, 1); + expect(result.report.title).toBe("API Gateway Outage"); + expect(result.report.status).toBe("resolved"); + expect(result.report.workspaceId).toBe(42); + expect(result.report.pageId).toBe(1); + expect(result.updates).toHaveLength(3); + expect(result.updates[0].status).toBe("investigating"); + expect(result.updates[1].status).toBe("identified"); + expect(result.updates[2].status).toBe("resolved"); + expect(result.sourceComponentIds).toEqual([]); + }); + + test("maps an acknowledged (unresolved) incident", () => { + const result = mapIncidentToStatusReport(MOCK_INCIDENTS[1], 42, 1); + expect(result.report.status).toBe("identified"); + expect(result.updates).toHaveLength(2); + expect(result.sourceComponentIds).toEqual([]); + }); + + test("maps an incident with null name", () => { + const result = mapIncidentToStatusReport(MOCK_INCIDENTS[2], 42, 1); + expect(result.report.title).toBe("Incident on https://cdn.acmecorp.com"); + expect(result.report.status).toBe("resolved"); + expect(result.updates).toHaveLength(2); + }); + + test("maps incident with null cause", () => { + const result = mapIncidentToStatusReport(MOCK_INCIDENTS[2], 42); + expect(result.updates[0].message).toBe( + "Incident detected on https://cdn.acmecorp.com", + ); + expect(result.report.pageId).toBeUndefined(); + }); +}); diff --git a/packages/importers/src/providers/betterstack/mapper.ts b/packages/importers/src/providers/betterstack/mapper.ts new file mode 100644 index 00000000..14ebfb8b --- /dev/null +++ b/packages/importers/src/providers/betterstack/mapper.ts @@ -0,0 +1,342 @@ +import type { + BetterstackIncident, + BetterstackMonitor, + BetterstackMonitorGroup, + BetterstackStatusPage, + BetterstackStatusPageResource, + BetterstackStatusPageSection, + BetterstackStatusReport, + BetterstackStatusUpdate, +} from "./api-types"; + +export type StatusReportStatus = + | "investigating" + | "identified" + | "monitoring" + | "resolved"; + +const FREQUENCY_MAP: Record = { + 30: "30s", + 60: "1m", + 120: "1m", + 180: "1m", + 300: "5m", + 600: "10m", + 1800: "30m", + 3600: "1h", +}; + +const REGION_MAP: Record = { + us: "iad", + eu: "fra", + as: "sin", + au: "syd", +}; + +const MONITOR_TYPE_MAP: Record = { + status: "http", + keyword: "http", + expected_status_code: "http", + tcp: "tcp", + udp: "udp", + ping: "http", + ping_icmp: "http", + dns: "dns", + smtp: "http", + pop: "http", + imap: "http", +}; + +const INCIDENT_STATUS_MAP: Record = { + started: "investigating", + acknowledged: "identified", + resolved: "resolved", +}; + +export function mapFrequency(seconds: number): string { + if (FREQUENCY_MAP[seconds]) return FREQUENCY_MAP[seconds]; + // Snap to nearest supported value + const supported = [30, 60, 300, 600, 1800, 3600]; + let closest = supported[0]; + for (const s of supported) { + if (Math.abs(s - seconds) < Math.abs(closest - seconds)) { + closest = s; + } + } + return FREQUENCY_MAP[closest] ?? "10m"; +} + +export function mapRegions(regions: string[]): string { + const mapped = regions + .map((r) => REGION_MAP[r]) + .filter((r): r is string => r != null); + return mapped.length > 0 ? mapped.join(",") : "iad"; +} + +export function mapMonitorType(type: string): string { + return MONITOR_TYPE_MAP[type] ?? "http"; +} + +export function mapMethod(method: string): string { + return method.toUpperCase(); +} + +export function mapMonitor(monitor: BetterstackMonitor, workspaceId: number) { + const attrs = monitor.attributes; + + const headers = + attrs.request_headers.length > 0 + ? JSON.stringify( + attrs.request_headers.map((h) => ({ + key: h.name, + value: h.value, + })), + ) + : ""; + + return { + workspaceId, + jobType: mapMonitorType(attrs.monitor_type), + periodicity: mapFrequency(attrs.check_frequency), + status: "active" as const, + active: attrs.status !== "paused", + regions: mapRegions(attrs.regions), + url: attrs.url, + name: attrs.pronounceable_name, + description: "", + headers, + body: attrs.request_body, + method: mapMethod(attrs.http_method), + timeout: attrs.request_timeout * 1000, + }; +} + +export function mapMonitorGroup( + group: BetterstackMonitorGroup, + workspaceId: number, + pageId?: number, +) { + return { + workspaceId, + pageId, + name: group.attributes.name, + }; +} + +export function mapStatusPage( + page: BetterstackStatusPage, + workspaceId: number, +) { + return { + workspaceId, + title: page.attributes.company_name, + description: "", + slug: page.attributes.subdomain, + customDomain: page.attributes.custom_domain ?? "", + published: true, + icon: "", + }; +} + +export function mapSection( + section: BetterstackStatusPageSection, + workspaceId: number, + pageId?: number, +) { + return { + workspaceId, + pageId, + name: section.attributes.name, + }; +} + +export function mapResource( + resource: BetterstackStatusPageResource, + workspaceId: number, + pageId?: number, + resourceIdToMonitorSourceId?: Map, +) { + const isMonitor = resource.attributes.resource_type === "Monitor"; + const resourceId = resource.attributes.resource_id; + + // Resolve sourceMonitorId via the lookup map (resource_id → monitor sourceId) + let sourceMonitorId: string | null = null; + if (isMonitor && resourceId != null) { + sourceMonitorId = + resourceIdToMonitorSourceId?.get(String(resourceId)) ?? null; + } + + return { + workspaceId, + pageId, + type: isMonitor ? ("monitor" as const) : ("static" as const), + monitorId: null as number | null, + sourceMonitorId, + name: resource.attributes.public_name, + description: resource.attributes.explanation ?? null, + order: resource.attributes.position, + sourceGroupId: resource.attributes.status_page_section_id + ? String(resource.attributes.status_page_section_id) + : null, + }; +} + +const REPORT_STATUS_MAP: Record = { + operational: "resolved", + resolved: "resolved", + downtime: "investigating", + degraded: "identified", + maintenance: "monitoring", +}; + +export function mapReportAggregateState( + state: string | null, +): StatusReportStatus { + if (!state) return "investigating"; + return REPORT_STATUS_MAP[state.toLowerCase()] ?? "investigating"; +} + +export function mapReportToStatusReport( + report: BetterstackStatusReport, + updates: BetterstackStatusUpdate[], + workspaceId: number, + pageId?: number, +) { + const sortedUpdates = [...updates].sort( + (a, b) => + new Date(a.attributes.published_at).getTime() - + new Date(b.attributes.published_at).getTime(), + ); + + const mappedUpdates = sortedUpdates.map((u) => { + const aggregateStatus = + u.attributes.affected_resources.length > 0 + ? mapReportAggregateState(u.attributes.affected_resources[0].status) + : "investigating"; + return { + status: aggregateStatus, + message: u.attributes.message ?? "", + date: new Date(u.attributes.published_at), + }; + }); + + // If no updates, create a synthetic one from the report itself + if (mappedUpdates.length === 0 && report.attributes.starts_at) { + mappedUpdates.push({ + status: mapReportAggregateState(report.attributes.aggregate_state), + message: report.attributes.title, + date: new Date(report.attributes.starts_at), + }); + } + + const lastUpdate = mappedUpdates[mappedUpdates.length - 1]; + const reportStatus = lastUpdate?.status ?? "investigating"; + + const sourceComponentIds = report.attributes.affected_resources.map( + (r) => r.status_page_resource_id, + ); + + return { + report: { + title: report.attributes.title, + status: reportStatus, + workspaceId, + pageId, + }, + updates: mappedUpdates, + sourceComponentIds, + }; +} + +export function mapReportToMaintenance( + report: BetterstackStatusReport, + updates: BetterstackStatusUpdate[], + workspaceId: number, + pageId?: number, +) { + const from = report.attributes.starts_at + ? new Date(report.attributes.starts_at) + : new Date(); + const to = report.attributes.ends_at + ? new Date(report.attributes.ends_at) + : from; + + const sortedUpdates = [...updates].sort( + (a, b) => + new Date(a.attributes.published_at).getTime() - + new Date(b.attributes.published_at).getTime(), + ); + + const message = sortedUpdates + .map((u) => u.attributes.message ?? "") + .filter(Boolean) + .join("\n"); + + const sourceComponentIds = report.attributes.affected_resources.map( + (r) => r.status_page_resource_id, + ); + + return { + title: report.attributes.title, + message: message || report.attributes.title, + from, + to, + workspaceId, + pageId, + sourceComponentIds, + }; +} + +export function mapIncidentStatus(status: string): StatusReportStatus { + return INCIDENT_STATUS_MAP[status] ?? "investigating"; +} + +export function mapIncidentToStatusReport( + incident: BetterstackIncident, + workspaceId: number, + pageId?: number, +) { + const attrs = incident.attributes; + const updates: Array<{ + status: StatusReportStatus; + message: string; + date: Date; + }> = []; + + // Create synthetic updates from timestamps + updates.push({ + status: "investigating", + message: attrs.cause ?? `Incident detected on ${attrs.url ?? "monitor"}`, + date: new Date(attrs.started_at), + }); + + if (attrs.acknowledged_at) { + updates.push({ + status: "identified", + message: "Incident acknowledged", + date: new Date(attrs.acknowledged_at), + }); + } + + if (attrs.resolved_at) { + updates.push({ + status: "resolved", + message: "Incident resolved", + date: new Date(attrs.resolved_at), + }); + } + + const lastUpdate = updates[updates.length - 1]; + const reportStatus = lastUpdate?.status ?? "investigating"; + + return { + report: { + title: attrs.name ?? `Incident on ${attrs.url ?? "monitor"}`, + status: reportStatus, + workspaceId, + pageId, + }, + updates, + sourceComponentIds: [] as string[], + }; +} diff --git a/packages/importers/src/providers/betterstack/provider.test.ts b/packages/importers/src/providers/betterstack/provider.test.ts new file mode 100644 index 00000000..30665f79 --- /dev/null +++ b/packages/importers/src/providers/betterstack/provider.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { + MOCK_INCIDENTS, + MOCK_MONITORS, + MOCK_MONITOR_GROUPS, + MOCK_STATUS_PAGES, + MOCK_STATUS_PAGE_RESOURCES, + MOCK_STATUS_PAGE_SECTIONS, + MOCK_STATUS_REPORTS, + MOCK_STATUS_UPDATES_REPORT_001, + MOCK_STATUS_UPDATES_REPORT_002, + MOCK_STATUS_UPDATES_REPORT_003, +} from "./fixtures"; +import { createBetterstackProvider } from "./provider"; + +const originalFetch = globalThis.fetch; + +function makePaginated(data: unknown[]) { + return { + data, + pagination: { first: null, last: null, prev: null, next: null }, + }; +} + +function setupMockFetch() { + globalThis.fetch = mock((url: string) => { + const path = new URL(url as string).pathname; + let body: unknown; + + if (path.includes("/api/v2/monitors")) { + body = makePaginated(MOCK_MONITORS); + } else if (path.includes("/api/v2/monitor-groups")) { + body = makePaginated(MOCK_MONITOR_GROUPS); + } else if (path.includes("/status-updates")) { + // Match status updates by report ID in the path + if (path.includes("/bs_report_001/")) { + body = makePaginated(MOCK_STATUS_UPDATES_REPORT_001); + } else if (path.includes("/bs_report_002/")) { + body = makePaginated(MOCK_STATUS_UPDATES_REPORT_002); + } else if (path.includes("/bs_report_003/")) { + body = makePaginated(MOCK_STATUS_UPDATES_REPORT_003); + } else { + body = makePaginated([]); + } + } else if (path.includes("/status-reports")) { + body = makePaginated(MOCK_STATUS_REPORTS); + } else if (path.includes("/resources")) { + body = makePaginated(MOCK_STATUS_PAGE_RESOURCES); + } else if (path.includes("/sections")) { + body = makePaginated(MOCK_STATUS_PAGE_SECTIONS); + } else if (path.includes("/api/v2/status-pages")) { + body = makePaginated(MOCK_STATUS_PAGES); + } else if (path.includes("/api/v3/incidents")) { + body = makePaginated(MOCK_INCIDENTS); + } else { + body = makePaginated([]); + } + + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + }), + ); + }) as typeof globalThis.fetch; +} + +describe("BetterstackProvider", () => { + beforeEach(() => { + setupMockFetch(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("validate returns valid with good API key", async () => { + const provider = createBetterstackProvider(); + const result = await provider.validate({ + apiKey: "test-key", + workspaceId: 1, + }); + expect(result.valid).toBe(true); + }); + + test("validate returns friendly 401 error", async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + statusText: "Unauthorized", + headers: { "Content-Type": "application/json" }, + }), + ), + ) as typeof globalThis.fetch; + + const provider = createBetterstackProvider(); + const result = await provider.validate({ + apiKey: "bad-key", + workspaceId: 1, + }); + expect(result.valid).toBe(false); + expect(result.error).toContain("Invalid BetterStack API token"); + }); + + test("run produces correct phase structure with status page", async () => { + const provider = createBetterstackProvider(); + const summary = await provider.run({ + apiKey: "test-key", + workspaceId: 42, + }); + + expect(summary.provider).toBe("betterstack"); + expect(summary.status).toBe("completed"); + expect(summary.errors).toEqual([]); + + const phaseNames = summary.phases.map((p) => p.phase); + expect(phaseNames).toContain("monitors"); + expect(phaseNames).toContain("page"); + expect(phaseNames).toContain("componentGroups"); + expect(phaseNames).toContain("components"); + expect(phaseNames).toContain("incidents"); + expect(phaseNames).toContain("maintenances"); + }); + + test("monitors phase has correct resource count", async () => { + const provider = createBetterstackProvider(); + const summary = await provider.run({ + apiKey: "test-key", + workspaceId: 42, + }); + + const monitorsPhase = summary.phases.find((p) => p.phase === "monitors"); + expect(monitorsPhase).toBeDefined(); + expect(monitorsPhase?.resources).toHaveLength(3); + expect(monitorsPhase?.resources[0].name).toBe("API Health Check"); + }); + + test("page phase maps status page correctly", async () => { + const provider = createBetterstackProvider(); + const summary = await provider.run({ + apiKey: "test-key", + workspaceId: 42, + }); + + const pagePhase = summary.phases.find((p) => p.phase === "page"); + expect(pagePhase).toBeDefined(); + expect(pagePhase?.resources).toHaveLength(1); + expect(pagePhase?.resources[0].name).toBe("Acme Corp"); + }); + + test("components phase maps status page resources", async () => { + const provider = createBetterstackProvider(); + const summary = await provider.run({ + apiKey: "test-key", + workspaceId: 42, + }); + + const componentsPhase = summary.phases.find( + (p) => p.phase === "components", + ); + expect(componentsPhase).toBeDefined(); + expect(componentsPhase?.resources).toHaveLength(3); + expect(componentsPhase?.resources[0].name).toBe("API Gateway"); + expect(componentsPhase?.resources[2].name).toBe("CDN"); + }); + + test("incidents phase uses status page reports (not v3 incidents)", async () => { + const provider = createBetterstackProvider(); + const summary = await provider.run({ + apiKey: "test-key", + workspaceId: 42, + }); + + const incidentsPhase = summary.phases.find((p) => p.phase === "incidents"); + expect(incidentsPhase).toBeDefined(); + // 2 manual reports (report_001 and report_002), not the 3 v3 incidents + expect(incidentsPhase?.resources).toHaveLength(2); + expect(incidentsPhase?.resources[0].name).toBe( + "API Gateway Elevated Error Rates", + ); + + const data = incidentsPhase?.resources[0].data as Record; + expect(data.sourceComponentIds).toEqual(["bs_res_001"]); + }); + + test("maintenances phase maps maintenance reports", async () => { + const provider = createBetterstackProvider(); + const summary = await provider.run({ + apiKey: "test-key", + workspaceId: 42, + }); + + const maintenancesPhase = summary.phases.find( + (p) => p.phase === "maintenances", + ); + expect(maintenancesPhase).toBeDefined(); + expect(maintenancesPhase?.resources).toHaveLength(1); + expect(maintenancesPhase?.resources[0].name).toBe( + "Scheduled Database Maintenance", + ); + + const data = maintenancesPhase?.resources[0].data as Record< + string, + unknown + >; + expect(data.sourceComponentIds).toEqual(["bs_res_001", "bs_res_002"]); + }); + + test("falls back to v3 incidents when no status pages", async () => { + const provider = createBetterstackProvider(); + const summary = await provider.run({ + apiKey: "test-key", + workspaceId: 42, + betterstackStatusPageId: "nonexistent", + }); + + // No status page matched → no page, components, sections, maintenances phases + const pagePhase = summary.phases.find((p) => p.phase === "page"); + expect(pagePhase).toBeUndefined(); + + const componentsPhase = summary.phases.find( + (p) => p.phase === "components", + ); + expect(componentsPhase).toBeUndefined(); + + const maintenancesPhase = summary.phases.find( + (p) => p.phase === "maintenances", + ); + expect(maintenancesPhase).toBeUndefined(); + + // Falls back to v3 incidents + const incidentsPhase = summary.phases.find((p) => p.phase === "incidents"); + expect(incidentsPhase).toBeDefined(); + expect(incidentsPhase?.resources).toHaveLength(3); + + // Still has monitors and monitorGroups + const monitorsPhase = summary.phases.find((p) => p.phase === "monitors"); + expect(monitorsPhase).toBeDefined(); + expect(monitorsPhase?.resources).toHaveLength(3); + }); + + test("monitor resources include sourceMonitorGroupId", async () => { + const provider = createBetterstackProvider(); + const summary = await provider.run({ + apiKey: "test-key", + workspaceId: 42, + }); + + const monitorsPhase = summary.phases.find((p) => p.phase === "monitors"); + const firstMonitor = monitorsPhase?.resources[0].data as Record< + string, + unknown + >; + expect(firstMonitor.sourceMonitorGroupId).toBe("bs_group_001"); + + const thirdMonitor = monitorsPhase?.resources[2].data as Record< + string, + unknown + >; + expect(thirdMonitor.sourceMonitorGroupId).toBeNull(); + }); +}); diff --git a/packages/importers/src/providers/betterstack/provider.ts b/packages/importers/src/providers/betterstack/provider.ts new file mode 100644 index 00000000..8f4ae450 --- /dev/null +++ b/packages/importers/src/providers/betterstack/provider.ts @@ -0,0 +1,239 @@ +import type { + ImportConfig, + ImportProvider, + PhaseResult, + ResourceResult, +} from "../../types"; +import { createBetterstackClient } from "./client"; +import { + mapIncidentToStatusReport, + mapMonitor, + mapMonitorGroup, + mapReportToMaintenance, + mapReportToStatusReport, + mapResource, + mapSection, + mapStatusPage, +} from "./mapper"; + +export interface BetterstackImportConfig extends ImportConfig { + betterstackStatusPageId?: string; +} + +export function createBetterstackProvider(): ImportProvider { + return { + name: "betterstack", + + validate: async (config) => { + try { + const client = createBetterstackClient(config.apiKey); + await client.getMonitors(); + return { valid: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("401")) { + return { + valid: false, + error: + "Invalid BetterStack API token. You can find your token in Better Stack → Settings → API tokens.", + }; + } + return { valid: false, error: message }; + } + }, + + run: async (config) => { + const startedAt = new Date(); + const client = createBetterstackClient(config.apiKey); + const phases: PhaseResult[] = []; + const errors: string[] = []; + + // Phase 1: Monitors + const monitors = await client.getMonitors(); + + // Build a lookup from resource_id (numeric, used in status page resources) + // to monitor sourceId (JSON:API id string). This bridges the two ID formats. + const resourceIdToMonitorSourceId = new Map(); + for (const m of monitors) { + // The monitor's JSON:API id is the string form of its internal ID, + // which is what resource_id references on status page resources. + resourceIdToMonitorSourceId.set(m.id, m.id); + } + + const monitorResources: ResourceResult[] = monitors.map((m) => ({ + sourceId: m.id, + name: m.attributes.pronounceable_name, + status: "created" as const, + data: { + ...mapMonitor(m, config.workspaceId), + sourceMonitorGroupId: m.attributes.monitor_group_id, + }, + })); + phases.push({ + phase: "monitors", + status: "completed", + resources: monitorResources, + }); + + // Phase 2: Status Pages + let statusPages = await client.getStatusPages(); + if (config.betterstackStatusPageId) { + statusPages = statusPages.filter( + (p) => p.id === config.betterstackStatusPageId, + ); + } + + const pageId = config.pageId; + + if (statusPages.length > 0) { + const sp = statusPages[0]; + const mappedPage = mapStatusPage(sp, config.workspaceId); + phases.push({ + phase: "page", + status: "completed", + resources: [ + { + sourceId: sp.id, + name: sp.attributes.company_name, + status: "created", + data: mappedPage, + }, + ], + }); + + // Phase 3: Sections + Monitor Groups → Component Groups + const [sections, monitorGroups] = await Promise.all([ + client.getStatusPageSections(sp.id), + client.getMonitorGroups(), + ]); + const componentGroupResources: ResourceResult[] = [ + ...sections.map((s) => ({ + sourceId: s.id, + name: s.attributes.name, + status: "created" as const, + data: mapSection(s, config.workspaceId, pageId), + })), + ...monitorGroups.map((g) => ({ + sourceId: g.id, + name: g.attributes.name, + status: "created" as const, + data: mapMonitorGroup(g, config.workspaceId, pageId), + })), + ]; + phases.push({ + phase: "componentGroups", + status: "completed", + resources: componentGroupResources, + }); + + // Phase 5: Status Page Resources → Components + const resources = await client.getStatusPageResources(sp.id); + const componentResources: ResourceResult[] = resources.map((r) => ({ + sourceId: r.id, + name: r.attributes.public_name, + status: "created" as const, + data: mapResource( + r, + config.workspaceId, + pageId, + resourceIdToMonitorSourceId, + ), + })); + phases.push({ + phase: "components", + status: "completed", + resources: componentResources, + }); + + // Phase 6 & 7: Status Page Reports → Incidents + Maintenances + const reports = await client.getStatusPageReports(sp.id); + const incidentResources: ResourceResult[] = []; + const maintenanceResources: ResourceResult[] = []; + + const reportsWithUpdates = await Promise.all( + reports.map(async (report) => ({ + report, + updates: await client.getStatusReportUpdates(sp.id, report.id), + })), + ); + + for (const { report, updates } of reportsWithUpdates) { + if (report.attributes.report_type === "maintenance") { + maintenanceResources.push({ + sourceId: report.id, + name: report.attributes.title, + status: "created", + data: mapReportToMaintenance( + report, + updates, + config.workspaceId, + pageId, + ), + }); + } else { + incidentResources.push({ + sourceId: report.id, + name: report.attributes.title, + status: "created", + data: mapReportToStatusReport( + report, + updates, + config.workspaceId, + pageId, + ), + }); + } + } + + phases.push({ + phase: "incidents", + status: "completed", + resources: incidentResources, + }); + + phases.push({ + phase: "maintenances", + status: "completed", + resources: maintenanceResources, + }); + } else { + // Monitor groups as component groups + const monitorGroups = await client.getMonitorGroups(); + const groupResources: ResourceResult[] = monitorGroups.map((g) => ({ + sourceId: g.id, + name: g.attributes.name, + status: "created" as const, + data: mapMonitorGroup(g, config.workspaceId, pageId), + })); + phases.push({ + phase: "componentGroups", + status: "completed", + resources: groupResources, + }); + + // Fallback: use /v3/incidents for monitor-level incidents when no status page + const incidents = await client.getIncidents(); + const incidentResources: ResourceResult[] = incidents.map((inc) => ({ + sourceId: inc.id, + name: inc.attributes.name ?? `Incident ${inc.id}`, + status: "created" as const, + data: mapIncidentToStatusReport(inc, config.workspaceId, pageId), + })); + phases.push({ + phase: "incidents", + status: "completed", + resources: incidentResources, + }); + } + + return { + provider: "betterstack", + status: "completed", + startedAt, + completedAt: new Date(), + phases, + errors, + }; + }, + }; +}