SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
10.5 KB · 152 lines typescript
Raw Blame History
1import { sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core";2import { httpFetchWithRetry } from "./fetcher";3import { NormalizeError, type WebSensorConnector } from "./types";45/**6 * Status-page connector for the non-Atlassian providers, normalized to the same item shape as7 * `statuspage` (incident:<id> / maintenance:<id> / overall) so downstream heuristics are shared.8 *9 *   instatus   — https://<page>/summary.json         { page:{name,url,status}, activeIncidents?:[…], activeMaintenances?:[…] }10 *   incidentio — https://<page>/api/v1/summary       { page_title, page_url, ongoing_incidents, in_progress_maintenances, scheduled_maintenances }11 *   statusio   — https://api.status.io/1.0/status/<pageId>   { result:{ status_overall, status:[…components], incidents:[…], maintenance:{active,upcoming} } }12 *   hund       — https://<page>/issues.json? (not supported yet)13 *14 * Config: { flavor?: "instatus" | "incidentio" | "statusio" } — auto-detected from the JSON shape.15 */16type Flavor = "instatus" | "incidentio" | "statusio";1718interface Item {19  key: string;20  kind: string;21  title: string;22  status: string;23  impact?: string;24  url: string;25  summary: string;26  publishedAt: string | null;27  updatedAt: string | null;28  [k: string]: unknown;29}3031function iso(v: unknown): string | null {32  if (!v) return null;33  const d = new Date(String(v));34  return Number.isNaN(d.getTime()) ? null : d.toISOString();35}3637export function detectFlavor(j: Record<string, unknown>): Flavor | null {38  if (Array.isArray(j.subpages) && (j.subpages as Record<string, unknown>[]).some((p) => p && typeof p === "object" && "summary" in p)) return "incidentio";39  if (j.page && typeof j.page === "object" && ("activeIncidents" in j || "activeMaintenances" in j || typeof (j.page as { status?: unknown }).status === "string")) return "instatus";40  if ("ongoing_incidents" in j || "page_title" in j) return "incidentio";41  if (j.result && typeof j.result === "object" && "status_overall" in (j.result as object)) return "statusio";42  return null;43}4445export function normalizeStatusJson(j: Record<string, unknown>, flavor: Flavor): { items: Item[]; title: string; indicator: string } {46  const items: Item[] = [];47  let title = "";48  let indicator = "";49  if (flavor === "instatus") {50    const page = (j.page ?? {}) as Record<string, unknown>;51    title = String(page.name ?? "");52    indicator = String(page.status ?? "").toLowerCase();53    for (const i of (j.activeIncidents ?? []) as Record<string, unknown>[]) {54      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) });55    }56    for (const m of (j.activeMaintenances ?? []) as Record<string, unknown>[]) {57      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) });58    }59    items.push({ key: "overall", kind: "overall", title: `Overall: ${indicator || "unknown"}`, status: indicator, url: String(page.url ?? ""), summary: "", publishedAt: null, updatedAt: null });60  } else if (flavor === "incidentio" && Array.isArray(j.subpages)) {61    // Multi-region incident.io pages (e.g. status.miro.com → eu/us/au): merge the sub-summaries, prefix keys.62    let anyIncident = false;63    for (const sp of j.subpages as { subpage?: string; summary?: Record<string, unknown> }[]) {64      if (!sp?.summary) continue;65      const sub = normalizeStatusJson(sp.summary, "incidentio");66      const prefix = String(sp.subpage ?? sub.title ?? "").toLowerCase();67      for (const it of sub.items) {68        if (it.key === "overall") continue;69        items.push({ ...it, key: `${prefix}:${it.key}`, title: `[${prefix.toUpperCase()}] ${it.title}` });70      }71      if (sub.indicator !== "operational") anyIncident = true;72      title = title || String(sp.summary.page_title ?? "").replace(/\s*\((eu|us|au|[a-z]{2})\)$/i, "");73    }74    indicator = anyIncident ? "incident" : "operational";75    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 });76  } else if (flavor === "incidentio") {77    title = String(j.page_title ?? "");78    const url = String(j.page_url ?? "");79    const inc = (j.ongoing_incidents ?? []) as Record<string, unknown>[];80    for (const i of inc) {81      const updates = (i.updates ?? []) as Record<string, unknown>[];82      const latest = updates[0];83      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) });84    }85    for (const m of [...((j.in_progress_maintenances ?? []) as Record<string, unknown>[]), ...((j.scheduled_maintenances ?? []) as Record<string, unknown>[])]) {86      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) });87    }88    indicator = inc.length ? "incident" : "operational";89    items.push({ key: "overall", kind: "overall", title: `Overall: ${indicator}`, status: indicator, url, summary: "", publishedAt: null, updatedAt: null });90  } else {91    const r = (j.result ?? {}) as Record<string, unknown>;92    const overall = (r.status_overall ?? {}) as Record<string, unknown>;93    indicator = String(overall.status ?? "").toLowerCase();94    title = "";95    for (const i of (r.incidents ?? []) as Record<string, unknown>[]) {96      const msgs = (i.messages ?? []) as Record<string, unknown>[];97      const latest = msgs[0];98      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) });99    }100    const maint = (r.maintenance ?? {}) as Record<string, unknown[]>;101    for (const m of [...((maint.active ?? []) as Record<string, unknown>[]), ...((maint.upcoming ?? []) as Record<string, unknown>[])]) {102      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) });103    }104    for (const c of (r.status ?? []) as Record<string, unknown>[]) {105      for (const cont of (c.containers ?? []) as Record<string, unknown>[]) {106        const st = String(cont.status ?? "").toLowerCase();107        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) });108      }109    }110    items.push({ key: "overall", kind: "overall", title: `Overall: ${indicator || "unknown"}`, status: indicator, url: "", summary: "", publishedAt: null, updatedAt: null });111  }112  return { items, title, indicator };113}114115export class StatusJsonConnector implements WebSensorConnector {116  mode = "list" as const;117  metadata(): ConnectorMetadata {118    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" };119  }120  async fetch(endpoint: SensorEndpoint): Promise<Observation> {121    return httpFetchWithRetry(endpoint.id, endpoint.url, { etag: endpoint.etag, lastModified: endpoint.lastModified, accept: "application/json" });122  }123  async normalize(endpoint: SensorEndpoint, obs: Observation): Promise<NormalizedContent> {124    if (!obs.body) throw new NormalizeError("no_body", "Observation has no body");125    const text = obs.body.toString("utf8");126    let j: Record<string, unknown>;127    try {128      j = JSON.parse(text) as Record<string, unknown>;129    } catch {130      throw new NormalizeError("bad_json", "Status response is not JSON");131    }132    const flavor = ((endpoint.config as { flavor?: Flavor }).flavor ?? detectFlavor(j)) as Flavor | null;133    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)");134    const { items, title, indicator } = normalizeStatusJson(j, flavor);135    const canonical = items.map((i) => `${i.key}\t${i.status}\t${i.title}\t${i.updatedAt ?? ""}`).join("\n");136    const newest = items.map((i) => i.publishedAt).filter((x): x is string => Boolean(x)).sort().at(-1);137    const incidents = items.filter((i) => i.kind === "incident").length;138    return {139      mode: "list",140      items,141      compareFields: ["status", "title", "updatedAt"],142      title: title || new URL(endpoint.url).hostname,143      rawHash: sha256(text),144      canonicalHash: sha256(canonical),145      semanticHash: simhash(items.map((i) => i.title).join("\n")),146      publishedAt: newest ? new Date(newest) : null,147      extractionConfidence: 1,148      extra: { flavor, indicator, activeIncidents: incidents, maintenances: items.filter((i) => i.kind === "maintenance").length, degradedComponents: items.filter((i) => i.kind === "component").length },149    };150  }151}152