incidents.ts90 lines · main
| 1 | /** |
| 2 | * Status-page incident feed. The status page (server component) and the |
| 3 | * RSS route (server route handler) both call `fetchIncidents()` at |
| 4 | * request time and degrade to an empty list when the API is unreachable |
| 5 | * — the alternative is the status page itself going down while the |
| 6 | * operator is trying to publish an incident about the API being down. |
| 7 | * |
| 8 | * The source of truth is the `incidents` table on the api, written by |
| 9 | * admins via /dashboard/admin/incidents (step-up gated). Drizzle |
| 10 | * serializes `started_at`/`resolved_at` to ISO strings over JSON. |
| 11 | */ |
| 12 | |
| 13 | const API_ORIGIN = process.env.BRIVEN_API_ORIGIN ?? 'https://api.briven.tech'; |
| 14 | const FETCH_TIMEOUT_MS = 3000; |
| 15 | |
| 16 | export interface IncidentEntry { |
| 17 | readonly id: string; |
| 18 | readonly startedAt: string; |
| 19 | readonly resolvedAt: string | null; |
| 20 | readonly severity: 'critical' | 'major' | 'minor' | 'maintenance'; |
| 21 | readonly services: readonly string[]; |
| 22 | readonly summary: string; |
| 23 | readonly postmortem: string; |
| 24 | } |
| 25 | |
| 26 | interface FetchOpts { |
| 27 | activeOnly?: boolean; |
| 28 | limit?: number; |
| 29 | /** Skip the 30s server cache. The /status page passes true so the |
| 30 | * incident list is always fresh; the docs-shell banner leaves it |
| 31 | * false to avoid a fetch on every docs page render. */ |
| 32 | fresh?: boolean; |
| 33 | } |
| 34 | |
| 35 | export async function fetchIncidents(opts: FetchOpts = {}): Promise<readonly IncidentEntry[]> { |
| 36 | const params = new URLSearchParams(); |
| 37 | if (opts.activeOnly) params.set('active', 'true'); |
| 38 | if (opts.limit) params.set('limit', String(opts.limit)); |
| 39 | const qs = params.toString(); |
| 40 | const url = `${API_ORIGIN}/v1/status/incidents${qs ? `?${qs}` : ''}`; |
| 41 | try { |
| 42 | const res = await fetch(url, { |
| 43 | signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), |
| 44 | headers: { accept: 'application/json' }, |
| 45 | ...(opts.fresh |
| 46 | ? { cache: 'no-store' as const } |
| 47 | : { next: { revalidate: 30, tags: ['incidents'] } }), |
| 48 | }); |
| 49 | if (!res.ok) return []; |
| 50 | const body = (await res.json()) as { incidents?: unknown }; |
| 51 | if (!Array.isArray(body.incidents)) return []; |
| 52 | return body.incidents.map(normalize).filter((x): x is IncidentEntry => x !== null); |
| 53 | } catch { |
| 54 | return []; |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | export function isOngoing(inc: IncidentEntry): boolean { |
| 59 | return inc.resolvedAt === null; |
| 60 | } |
| 61 | |
| 62 | function normalize(raw: unknown): IncidentEntry | null { |
| 63 | if (!raw || typeof raw !== 'object') return null; |
| 64 | const r = raw as Record<string, unknown>; |
| 65 | const id = typeof r.id === 'string' ? r.id : null; |
| 66 | const startedAt = toIso(r.startedAt); |
| 67 | const severity = r.severity; |
| 68 | const services = Array.isArray(r.services) ? r.services.filter((s) => typeof s === 'string') : null; |
| 69 | const summary = typeof r.summary === 'string' ? r.summary : null; |
| 70 | if (!id || !startedAt || !services || !summary) return null; |
| 71 | if (severity !== 'critical' && severity !== 'major' && severity !== 'minor' && severity !== 'maintenance') { |
| 72 | return null; |
| 73 | } |
| 74 | return { |
| 75 | id, |
| 76 | startedAt, |
| 77 | resolvedAt: toIso(r.resolvedAt), |
| 78 | severity, |
| 79 | services: services as string[], |
| 80 | summary, |
| 81 | postmortem: typeof r.postmortem === 'string' ? r.postmortem : '', |
| 82 | }; |
| 83 | } |
| 84 | |
| 85 | function toIso(v: unknown): string | null { |
| 86 | if (typeof v !== 'string') return null; |
| 87 | const d = new Date(v); |
| 88 | if (Number.isNaN(d.getTime())) return null; |
| 89 | return d.toISOString(); |
| 90 | } |