import { sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core"; import { httpFetchWithRetry } from "./fetcher"; import { NormalizeError, type WebSensorConnector } from "./types"; /** * Statuspage connector (Atlassian Statuspage `/api/v2/summary.json` and compatible clones: * instatus, status.io exports, Google/AWS style JSON where configured). * Emits list items for incidents + scheduled maintenances and component states so we * detect: incident created / updated / resolved, maintenance scheduled, component degraded. */ export class StatuspageConnector implements WebSensorConnector { mode = "list" as const; metadata(): ConnectorMetadata { return { key: "statuspage", name: "Statuspage", sensorTypes: ["STATUSPAGE"], description: "Atlassian Statuspage API v2 summary → incidents, maintenances, component status", version: "1.0.0" }; } async fetch(endpoint: SensorEndpoint): Promise { return httpFetchWithRetry(endpoint.id, endpoint.url, { etag: endpoint.etag, lastModified: endpoint.lastModified, accept: "application/json" }); } async normalize(endpoint: SensorEndpoint, obs: Observation): Promise { if (!obs.body) throw new NormalizeError("no_body", "Observation has no body"); const text = obs.body.toString("utf8"); let j: Record; try { j = JSON.parse(text) as Record; } catch { throw new NormalizeError("bad_json", "Statuspage response is not JSON"); } const page = (j.page ?? {}) as Record; const status = (j.status ?? {}) as Record; const incidents = ((j.incidents ?? []) as Record[]).map((i) => incidentItem(i, "incident")); const maints = ((j.scheduled_maintenances ?? []) as Record[]).map((i) => incidentItem(i, "maintenance")); const components = ((j.components ?? []) as Record[]) .filter((c) => !c.group && c.status !== "operational") .map((c) => ({ key: `component:${String(c.id)}`, kind: "component", title: `${String(c.name)} — ${String(c.status).replace(/_/g, " ")}`, status: String(c.status), url: String(page.url ?? ""), summary: `Component ${String(c.name)} is ${String(c.status).replace(/_/g, " ")}.`, publishedAt: c.updated_at ? new Date(String(c.updated_at)).toISOString() : null })); const items: { key: string; [k: string]: unknown }[] = [...incidents, ...maints, ...components]; const overall = { key: "overall", kind: "overall", title: `Overall: ${String(status.description ?? status.indicator ?? "unknown")}`, status: String(status.indicator ?? ""), summary: String(status.description ?? ""), url: String(page.url ?? ""), publishedAt: null, updatedAt: null }; items.push(overall); const canonical = items.map((i) => `${i.key}\t${String(i.status)}\t${String(i.title)}\t${String(i.updatedAt ?? "")}`).join("\n"); const newest = items.map((i) => i.publishedAt as string | null).filter((x): x is string => Boolean(x)).sort().at(-1); return { mode: "list", items, compareFields: ["status", "title", "updatedAt"], title: String(page.name ?? ""), rawHash: sha256(text), canonicalHash: sha256(canonical), semanticHash: simhash(items.map((i) => String(i.title)).join("\n")), publishedAt: newest ? new Date(newest) : null, extractionConfidence: 1, extra: { indicator: status.indicator, activeIncidents: incidents.length, maintenances: maints.length, degradedComponents: components.length }, }; } } function incidentItem(i: Record, kind: "incident" | "maintenance"): { key: string; kind: string; title: string; status: string; impact: string; url: string; summary: string; publishedAt: string | null; updatedAt: string | null } { const updates = (i.incident_updates ?? []) as Record[]; const latest = updates[0]; return { key: `${kind}:${String(i.id)}`, kind, title: `${String(i.name)} — ${String(i.status).replace(/_/g, " ")}`, status: String(i.status), impact: String(i.impact ?? ""), url: String(i.shortlink ?? ""), summary: latest ? String(latest.body ?? "").slice(0, 800) : "", publishedAt: i.created_at ? new Date(String(i.created_at)).toISOString() : null, updatedAt: i.updated_at ? new Date(String(i.updated_at)).toISOString() : null, }; }