Files
supabase/apps/studio/lib/api/incident-status.ts

147 lines
4.0 KiB
TypeScript

import { IS_PLATFORM } from 'common'
import z from 'zod'
import { InternalServerError } from '@/lib/api/apiHelpers'
export type IncidentCache = {
affected_regions: Array<string> | null
affects_project_creation: boolean
/** When true, the banner is shown unconditionally regardless of regions or project state. */
force?: boolean
}
export type IncidentMetadata = {
dashboard_metadata?: {
show_banner?: boolean
}
}
export type IncidentInfo = {
id: string
name: string
status: string
impact: string
active_since: string
metadata: IncidentMetadata
cache?: IncidentCache | null
}
const STATUSPAGE_API_URL = 'https://api.statuspage.io/v1'
const STATUSPAGE_PAGE_ID = process.env.STATUSPAGE_PAGE_ID
const STATUSPAGE_API_KEY = process.env.STATUSPAGE_API_KEY
function getIncidentsEndpoint(): string {
return `${STATUSPAGE_API_URL}/pages/${STATUSPAGE_PAGE_ID}/incidents/unresolved`
}
const StatusPageIncidentsSchema = z.array(
z.object({
id: z.string(),
name: z.string(),
status: z.string(),
created_at: z.string(),
scheduled_for: z.string().nullable(),
impact: z.string(),
metadata: z
.object({
dashboard_metadata: z
.object({
show_banner: z.boolean().optional(),
})
.optional(),
})
.optional()
.default({}),
})
)
/**
* Fetches active incidents from the StatusPage API.
* This function is used both by the API route and the AI assistant.
*
* @returns Array of active incidents
* @throws InternalServerError if StatusPage is not configured or returns an error
*/
export async function getActiveIncidents(): Promise<IncidentInfo[]> {
if (!IS_PLATFORM) {
return []
}
if (!STATUSPAGE_PAGE_ID) {
throw new InternalServerError('StatusPage page ID is not configured')
}
if (!STATUSPAGE_API_KEY) {
throw new InternalServerError('StatusPage API key is not configured')
}
const response = await fetch(getIncidentsEndpoint(), {
headers: {
Authorization: `OAuth ${STATUSPAGE_API_KEY}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
next: { revalidate: 180 },
signal: AbortSignal.timeout(30_000),
})
const responseText = await response.text()
if (!response.ok) {
const retryAfter = response.headers.get('Retry-After') ?? undefined
throw new InternalServerError(`StatusPage API responded with ${response.status}`, {
status: response.status,
body: responseText,
...(retryAfter !== undefined && { retryAfter }),
})
}
let incidentsJson: unknown
try {
incidentsJson = JSON.parse(responseText)
} catch (error) {
throw new InternalServerError('StatusPage API response could not be parsed as JSON', {
error: error instanceof Error ? error.message : error,
body: responseText,
})
}
const result = StatusPageIncidentsSchema.safeParse(incidentsJson)
if (!result.success) {
throw new InternalServerError('StatusPage API response did not match expected schema', {
issues: result.error.issues,
})
}
const now = Date.now()
const activeIncidents = result.data.filter((incident) => {
const hasNoScheduledTime = !incident.scheduled_for
if (hasNoScheduledTime) {
return true
}
const scheduledTime = Date.parse(incident.scheduled_for!)
const isScheduledTimeInvalid = Number.isNaN(scheduledTime)
if (isScheduledTimeInvalid) {
// Keep the record but note it locally for debugging
console.warn('Encountered incident with invalid scheduled_for date', {
incidentId: incident.id,
scheduled_for: incident.scheduled_for,
})
return true
}
const hasScheduledTimePassed = scheduledTime <= now
return hasScheduledTimePassed
})
return activeIncidents.map((incident) => ({
id: incident.id,
name: incident.name,
status: incident.status,
impact: incident.impact,
active_since: incident.scheduled_for ?? incident.created_at,
metadata: incident.metadata,
}))
}