diff --git a/apps/studio/lib/api/apiHelpers.ts b/apps/studio/lib/api/apiHelpers.ts index b7a14da2d16..b8c6fd6e546 100644 --- a/apps/studio/lib/api/apiHelpers.ts +++ b/apps/studio/lib/api/apiHelpers.ts @@ -119,3 +119,13 @@ export function commaSeparatedStringIntoArray(value: string): string[] { .map((v) => v.trim()) .filter(Boolean) } + +export class InternalServerError extends Error { + constructor( + message: string, + public details?: Record + ) { + super(message) + this.name = 'InternalServerError' + } +} diff --git a/apps/studio/pages/api/incident-status.ts b/apps/studio/pages/api/incident-status.ts new file mode 100644 index 00000000000..0c6847f7cde --- /dev/null +++ b/apps/studio/pages/api/incident-status.ts @@ -0,0 +1,149 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import z from 'zod' + +import { IS_PLATFORM } from 'common' +import { InternalServerError } from 'lib/api/apiHelpers' + +type IncidentInfo = { + id: string + name: string + status: string + active_since: string +} + +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 + +const INCIDENTS_ENDPOINT = `${STATUSPAGE_API_URL}/pages/${STATUSPAGE_PAGE_ID}/incidents/unresolved` + +/** + * Cache on browser for 5 minutes + * Cache on CDN for 5 minutes + * Allow serving stale content for 1 minute while revalidating + */ +const CACHE_CONTROL_SETTINGS = 'public, max-age=300, s-maxage=300, stale-while-revalidate=60' + +const StatusPageIncidentsSchema = z.array( + z.object({ + id: z.string(), + name: z.string(), + status: z.string(), + created_at: z.string(), + scheduled_for: z.string().nullable(), + }) +) + +const getActiveIncidents = async (): Promise => { + 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(INCIDENTS_ENDPOINT, { + headers: { + Authorization: `OAuth ${STATUSPAGE_API_KEY}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + cache: 'no-store', + signal: AbortSignal.timeout(30_000), + }) + const responseText = await response.text() + + if (!response.ok) { + throw new InternalServerError(`StatusPage API responded with ${response.status}`, { + status: response.status, + body: responseText, + }) + } + + 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) => { + if (!incident.scheduled_for) { + return true + } + + const scheduledTime = Date.parse(incident.scheduled_for) + if (Number.isNaN(scheduledTime)) { + // 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 + } + + return scheduledTime <= now + }) + + return activeIncidents.map((incident) => ({ + id: incident.id, + name: incident.name, + status: incident.status, + active_since: incident.scheduled_for ?? incident.created_at, + })) +} + +// Default export needed by Next.js convention +// eslint-disable-next-line no-restricted-exports +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (!IS_PLATFORM) { + return res.status(404).end() + } + + const { method } = req + + if (method === 'HEAD') { + res.setHeader('Cache-Control', CACHE_CONTROL_SETTINGS) + return res.status(200).end() + } + + if (method !== 'GET') { + res.setHeader('Allow', ['GET', 'HEAD']) + return res.status(405).json({ error: `Method ${method} Not Allowed` }) + } + + try { + const incidents = await getActiveIncidents() + + res.setHeader('Cache-Control', CACHE_CONTROL_SETTINGS) + + return res.status(200).json(incidents) + } catch (error) { + if (error instanceof InternalServerError) { + console.error('Failed to fetch active StatusPage incidents: %O', { + message: error.message, + details: error.details, + }) + } else { + console.error('Unexpected error fetching active StatusPage incidents: %O', error) + } + + return res.status(500).json({ error: 'Unable to fetch incidents at this time' }) + } +} diff --git a/apps/studio/proxy.ts b/apps/studio/proxy.ts index 9f8c75aabc3..395a9d4661f 100644 --- a/apps/studio/proxy.ts +++ b/apps/studio/proxy.ts @@ -24,6 +24,7 @@ const HOSTED_SUPPORTED_API_URLS = [ '/edge-functions/test', '/edge-functions/body', '/generate-attachment-url', + '/incident-status', ] export function proxy(request: NextRequest) { diff --git a/turbo.json b/turbo.json index d88c231dfb9..36b429879c0 100644 --- a/turbo.json +++ b/turbo.json @@ -84,6 +84,8 @@ "NEXT_PUBLIC_VERCEL_ENV", "NEXT_PUBLIC_USERCENTRICS_RULESET_ID", "NEXT_PUBLIC_MCP_URL", + "NEXT_PUBLIC_IS_NIMBUS", + "NEXT_PUBLIC_ONGOING_INCIDENT", // These envs are technically passthrough env vars because they're only used on the server side of Nextjs "PLATFORM_PG_META_URL", "STUDIO_PG_META_URL", @@ -123,8 +125,8 @@ "AI_PRO_MODEL", "AI_NORMAL_MODEL", "SUPPORT_SUPABASE_SECRET_KEY", - "NEXT_PUBLIC_IS_NIMBUS", - "NEXT_PUBLIC_ONGOING_INCIDENT" + "STATUSPAGE_API_KEY", + "STATUSPAGE_PAGE_ID" ], "passThroughEnv": ["CURRENT_CLI_VERSION", "VERCEL_GIT_COMMIT_SHA"], "outputs": [".next/**", "!.next/cache/**"]