import { sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core"; import { httpFetchWithRetry } from "./fetcher"; import { NormalizeError, type WebSensorConnector } from "./types"; /** * Status-page connector for the non-Atlassian providers, normalized to the same item shape as * `statuspage` (incident: / maintenance: / overall) so downstream heuristics are shared. * * instatus — https:///summary.json { page:{name,url,status}, activeIncidents?:[…], activeMaintenances?:[…] } * incidentio — https:///api/v1/summary { page_title, page_url, ongoing_incidents, in_progress_maintenances, scheduled_maintenances } * statusio — https://api.status.io/1.0/status/ { result:{ status_overall, status:[…components], incidents:[…], maintenance:{active,upcoming} } } * hund — https:///issues.json? (not supported yet) * * Config: { flavor?: "instatus" | "incidentio" | "statusio" } — auto-detected from the JSON shape. */ type Flavor = "instatus" | "incidentio" | "statusio"; interface Item { key: string; kind: string; title: string; status: string; impact?: string; url: string; summary: string; publishedAt: string | null; updatedAt: string | null; [k: string]: unknown; } function iso(v: unknown): string | null { if (!v) return null; const d = new Date(String(v)); return Number.isNaN(d.getTime()) ? null : d.toISOString(); } export function detectFlavor(j: Record): Flavor | null { if (Array.isArray(j.subpages) && (j.subpages as Record[]).some((p) => p && typeof p === "object" && "summary" in p)) return "incidentio"; if (j.page && typeof j.page === "object" && ("activeIncidents" in j || "activeMaintenances" in j || typeof (j.page as { status?: unknown }).status === "string")) return "instatus"; if ("ongoing_incidents" in j || "page_title" in j) return "incidentio"; if (j.result && typeof j.result === "object" && "status_overall" in (j.result as object)) return "statusio"; return null; } export function normalizeStatusJson(j: Record, flavor: Flavor): { items: Item[]; title: string; indicator: string } { const items: Item[] = []; let title = ""; let indicator = ""; if (flavor === "instatus") { const page = (j.page ?? {}) as Record; title = String(page.name ?? ""); indicator = String(page.status ?? "").toLowerCase(); for (const i of (j.activeIncidents ?? []) as Record[]) { items.push({ key: `incident:${String(i.id)}`, kind: "incident", title: `${String(i.name)} — ${String(i.status).toLowerCase().replace(/_/g, " ")}`, status: String(i.status).toLowerCase(), impact: String(i.impact ?? "").toLowerCase(), url: String(i.url ?? page.url ?? ""), summary: "", publishedAt: iso(i.started), updatedAt: iso(i.updated ?? i.started) }); } for (const m of (j.activeMaintenances ?? []) as Record[]) { items.push({ key: `maintenance:${String(m.id)}`, kind: "maintenance", title: `${String(m.name)} — ${String(m.status).toLowerCase().replace(/_/g, " ")}`, status: String(m.status).toLowerCase(), url: String(m.url ?? page.url ?? ""), summary: m.duration ? `Duration ${String(m.duration)} min` : "", publishedAt: iso(m.start), updatedAt: iso(m.start) }); } items.push({ key: "overall", kind: "overall", title: `Overall: ${indicator || "unknown"}`, status: indicator, url: String(page.url ?? ""), summary: "", publishedAt: null, updatedAt: null }); } else if (flavor === "incidentio" && Array.isArray(j.subpages)) { // Multi-region incident.io pages (e.g. status.miro.com → eu/us/au): merge the sub-summaries, prefix keys. let anyIncident = false; for (const sp of j.subpages as { subpage?: string; summary?: Record }[]) { if (!sp?.summary) continue; const sub = normalizeStatusJson(sp.summary, "incidentio"); const prefix = String(sp.subpage ?? sub.title ?? "").toLowerCase(); for (const it of sub.items) { if (it.key === "overall") continue; items.push({ ...it, key: `${prefix}:${it.key}`, title: `[${prefix.toUpperCase()}] ${it.title}` }); } if (sub.indicator !== "operational") anyIncident = true; title = title || String(sp.summary.page_title ?? "").replace(/\s*\((eu|us|au|[a-z]{2})\)$/i, ""); } indicator = anyIncident ? "incident" : "operational"; items.push({ key: "overall", kind: "overall", title: `Overall: ${indicator}`, status: indicator, url: String(((j.subpages as { summary?: { page_url?: string } }[])[0]?.summary?.page_url ?? "")).replace(/\/[a-z]{2}$/, ""), summary: "", publishedAt: null, updatedAt: null }); } else if (flavor === "incidentio") { title = String(j.page_title ?? ""); const url = String(j.page_url ?? ""); const inc = (j.ongoing_incidents ?? []) as Record[]; for (const i of inc) { const updates = (i.updates ?? []) as Record[]; const latest = updates[0]; items.push({ key: `incident:${String(i.id)}`, kind: "incident", title: `${String(i.name)} — ${String(i.status ?? i.current_status ?? "").toLowerCase().replace(/_/g, " ")}`, status: String(i.status ?? i.current_status ?? "").toLowerCase(), impact: String(i.impact ?? i.worst_impact ?? "").toLowerCase(), url: String(i.url ?? url), summary: latest ? String(latest.message ?? latest.message_string ?? "").slice(0, 800) : "", publishedAt: iso(i.created_at ?? i.started_at), updatedAt: iso(i.updated_at ?? i.last_update_at) }); } for (const m of [...((j.in_progress_maintenances ?? []) as Record[]), ...((j.scheduled_maintenances ?? []) as Record[])]) { items.push({ key: `maintenance:${String(m.id)}`, kind: "maintenance", title: `${String(m.name)} — ${String(m.status ?? "scheduled").toLowerCase().replace(/_/g, " ")}`, status: String(m.status ?? "scheduled").toLowerCase(), url: String(m.url ?? url), summary: "", publishedAt: iso(m.starts_at ?? m.created_at), updatedAt: iso(m.updated_at ?? m.starts_at) }); } indicator = inc.length ? "incident" : "operational"; items.push({ key: "overall", kind: "overall", title: `Overall: ${indicator}`, status: indicator, url, summary: "", publishedAt: null, updatedAt: null }); } else { const r = (j.result ?? {}) as Record; const overall = (r.status_overall ?? {}) as Record; indicator = String(overall.status ?? "").toLowerCase(); title = ""; for (const i of (r.incidents ?? []) as Record[]) { const msgs = (i.messages ?? []) as Record[]; const latest = msgs[0]; items.push({ key: `incident:${String(i._id)}`, kind: "incident", title: `${String(i.name)} — ${String(i.current_status ?? "").toLowerCase()}`, status: String(i.current_status ?? "").toLowerCase(), impact: String(i.current_impact ?? "").toLowerCase(), url: String(i.shortlink ?? ""), summary: latest ? String(latest.details ?? "").slice(0, 800) : "", publishedAt: iso(i.datetime_open), updatedAt: iso(latest?.datetime ?? i.datetime_open) }); } const maint = (r.maintenance ?? {}) as Record; for (const m of [...((maint.active ?? []) as Record[]), ...((maint.upcoming ?? []) as Record[])]) { items.push({ key: `maintenance:${String(m._id)}`, kind: "maintenance", title: `${String(m.name)} — ${String(m.current_status ?? "scheduled").toLowerCase()}`, status: String(m.current_status ?? "scheduled").toLowerCase(), url: String(m.shortlink ?? ""), summary: "", publishedAt: iso(m.datetime_planned_start), updatedAt: iso(m.datetime_planned_start) }); } for (const c of (r.status ?? []) as Record[]) { for (const cont of (c.containers ?? []) as Record[]) { const st = String(cont.status ?? "").toLowerCase(); if (st && st !== "operational") items.push({ key: `component:${String(cont.id)}`, kind: "component", title: `${String(c.name)} / ${String(cont.name)} — ${st}`, status: st, url: "", summary: "", publishedAt: iso(cont.updated), updatedAt: iso(cont.updated) }); } } items.push({ key: "overall", kind: "overall", title: `Overall: ${indicator || "unknown"}`, status: indicator, url: "", summary: "", publishedAt: null, updatedAt: null }); } return { items, title, indicator }; } export class StatusJsonConnector implements WebSensorConnector { mode = "list" as const; metadata(): ConnectorMetadata { return { key: "statusjson", name: "Status page (Instatus / incident.io / Status.io)", sensorTypes: ["STATUSPAGE", "JSON"], description: "Non-Atlassian status providers normalized to incidents, maintenances, components and overall indicator", 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", "Status response is not JSON"); } const flavor = ((endpoint.config as { flavor?: Flavor }).flavor ?? detectFlavor(j)) as Flavor | null; if (!flavor) throw new NormalizeError("bad_shape", "Unrecognized status JSON (expected Instatus summary.json, incident.io /api/v1/summary or Status.io /1.0/status)"); const { items, title, indicator } = normalizeStatusJson(j, flavor); const canonical = items.map((i) => `${i.key}\t${i.status}\t${i.title}\t${i.updatedAt ?? ""}`).join("\n"); const newest = items.map((i) => i.publishedAt).filter((x): x is string => Boolean(x)).sort().at(-1); const incidents = items.filter((i) => i.kind === "incident").length; return { mode: "list", items, compareFields: ["status", "title", "updatedAt"], title: title || new URL(endpoint.url).hostname, rawHash: sha256(text), canonicalHash: sha256(canonical), semanticHash: simhash(items.map((i) => i.title).join("\n")), publishedAt: newest ? new Date(newest) : null, extractionConfidence: 1, extra: { flavor, indicator, activeIncidents: incidents, maintenances: items.filter((i) => i.kind === "maintenance").length, degradedComponents: items.filter((i) => i.kind === "component").length }, }; } }