diff --git a/.env.example b/.env.example index 9a99dd6fc..433bc4109 100644 --- a/.env.example +++ b/.env.example @@ -68,7 +68,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key # NEXT_PUBLIC_INCLUDE_REPORT_ISSUE=0 ### Enable dashboard status indicator feature: set to 1 to enable -### When enabled, the E2B status is read from https://status.e2b.dev +### When enabled, the E2B status is read from the incident.io summary API # NEXT_PUBLIC_INCLUDE_STATUS_INDICATOR=0 ### Set to 1 to use mock data diff --git a/src/features/dashboard/layouts/status-indicator.server.tsx b/src/features/dashboard/layouts/status-indicator.server.tsx index 1eed06e30..11983c048 100644 --- a/src/features/dashboard/layouts/status-indicator.server.tsx +++ b/src/features/dashboard/layouts/status-indicator.server.tsx @@ -4,26 +4,16 @@ import { cacheLife } from 'next/cache' import Link from 'next/link' import { l } from '@/core/shared/clients/logger/logger' import { LiveDot } from '@/ui/live' +import { + type AggregateState, + getStatusPageStateFromSummary, + type IncidentIOStatusPageSummaryResponse, + STATUS_PAGE_LINK_URL, + STATUS_PAGE_SUMMARY_URL, +} from './status-indicator' -const STATUS_PAGE_URL = 'https://status.e2b.dev' -const STATUS_PAGE_INDEX_URL = `${STATUS_PAGE_URL}/index.json` const STATUS_PAGE_FETCH_TIMEOUT_MS = 5_000 -const STATUS_PAGE_CACHE_SECONDS = 300 - -type AggregateState = - | 'operational' - | 'degraded' - | 'downtime' - | 'maintenance' - | 'unknown' - -interface StatusPageIndexResponse { - data?: { - attributes?: { - aggregate_state?: string - } - } -} +const STATUS_PAGE_CACHE_SECONDS = 30 interface StatusUI { label: string @@ -31,14 +21,6 @@ interface StatusUI { dotClassName: string } -function toAggregateState(value: string | undefined): AggregateState { - if (value === 'operational') return 'operational' - if (value === 'degraded') return 'degraded' - if (value === 'downtime') return 'downtime' - if (value === 'maintenance') return 'maintenance' - return 'unknown' -} - function getStatusUI(state: AggregateState): StatusUI { switch (state) { case 'operational': @@ -83,7 +65,7 @@ async function getStatusPageState(): Promise { }) try { - const response = await fetch(STATUS_PAGE_INDEX_URL, { + const response = await fetch(STATUS_PAGE_SUMMARY_URL, { cache: 'force-cache', next: { revalidate: STATUS_PAGE_CACHE_SECONDS }, signal: AbortSignal.timeout(STATUS_PAGE_FETCH_TIMEOUT_MS), @@ -101,8 +83,8 @@ async function getStatusPageState(): Promise { return 'unknown' } - const data = (await response.json()) as StatusPageIndexResponse - return toAggregateState(data.data?.attributes?.aggregate_state) + const data = (await response.json()) as IncidentIOStatusPageSummaryResponse + return getStatusPageStateFromSummary(data) } catch { return 'unknown' } @@ -114,7 +96,7 @@ export default async function DashboardStatusBadgeServer() { return ( + scheduled_maintenances?: Array<{ + status?: string + }> +} + +export const STATUS_PAGE_LINK_URL = 'https://status.e2b.dev' +const INCIDENT_IO_STATUS_PAGE_URL = 'https://statuspage.incident.io/e2b-service' +export const STATUS_PAGE_SUMMARY_URL = `${INCIDENT_IO_STATUS_PAGE_URL}/api/v2/summary.json` + +const STATUS_PRIORITY: Record = { + unknown: 0, + operational: 1, + maintenance: 2, + degraded: 3, + downtime: 4, +} + +const INDICATOR_STATE: Record = { + none: 'operational', + minor: 'degraded', + major: 'degraded', + critical: 'downtime', + maintenance: 'maintenance', +} + +const COMPONENT_STATE: Record = { + operational: 'operational', + under_maintenance: 'maintenance', + degraded_performance: 'degraded', + partial_outage: 'degraded', + full_outage: 'downtime', + major_outage: 'downtime', +} + +const MAINTENANCE_IN_PROGRESS_STATUSES = new Set([ + 'in_progress', + 'maintenance_in_progress', +]) + +function stateFromValue( + value: string | undefined, + stateMap: Record +) { + return value ? stateMap[value] : undefined +} + +function highestPriorityState( + states: Array +): AggregateState | undefined { + return states.reduce((highest, state) => { + if (!state) return highest + if (!highest) return state + + return STATUS_PRIORITY[state] > STATUS_PRIORITY[highest] ? state : highest + }, undefined) +} + +function getWorstComponentState( + components: IncidentIOStatusPageSummaryResponse['components'] +): AggregateState | undefined { + return highestPriorityState( + components?.map((component) => + stateFromValue(component.status, COMPONENT_STATE) + ) ?? [] + ) +} + +function hasMaintenanceInProgress( + maintenances: IncidentIOStatusPageSummaryResponse['scheduled_maintenances'] +): boolean { + return ( + maintenances?.some( + (maintenance) => + !!maintenance.status && + MAINTENANCE_IN_PROGRESS_STATUSES.has(maintenance.status) + ) ?? false + ) +} + +export function getStatusPageStateFromSummary( + data: IncidentIOStatusPageSummaryResponse +): AggregateState { + const indicatorState = stateFromValue(data.status?.indicator, INDICATOR_STATE) + const componentState = getWorstComponentState(data.components) + const maintenanceState = hasMaintenanceInProgress(data.scheduled_maintenances) + ? 'maintenance' + : undefined + + return ( + highestPriorityState([indicatorState, componentState, maintenanceState]) ?? + 'unknown' + ) +} diff --git a/tests/unit/status-indicator.test.ts b/tests/unit/status-indicator.test.ts new file mode 100644 index 000000000..01d3871fe --- /dev/null +++ b/tests/unit/status-indicator.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' +import { getStatusPageStateFromSummary } from '@/features/dashboard/layouts/status-indicator' + +describe('status-indicator', () => { + it('should report operational for summary indicator none', () => { + expect( + getStatusPageStateFromSummary({ + status: { + indicator: 'none', + }, + }) + ).toBe('operational') + }) + + it('should report maintenance for in-progress maintenance', () => { + expect( + getStatusPageStateFromSummary({ + scheduled_maintenances: [ + { + status: 'in_progress', + }, + ], + }) + ).toBe('maintenance') + }) + + it('should support incident.io maintenance status naming', () => { + expect( + getStatusPageStateFromSummary({ + scheduled_maintenances: [ + { + status: 'maintenance_in_progress', + }, + ], + }) + ).toBe('maintenance') + }) + + it('should prioritize critical indicator over maintenance', () => { + expect( + getStatusPageStateFromSummary({ + status: { + indicator: 'critical', + }, + scheduled_maintenances: [ + { + status: 'in_progress', + }, + ], + }) + ).toBe('downtime') + }) + + it('should report downtime for full outage components', () => { + expect( + getStatusPageStateFromSummary({ + status: { + indicator: 'none', + }, + components: [ + { + status: 'degraded_performance', + }, + { + status: 'full_outage', + }, + ], + }) + ).toBe('downtime') + }) + + it('should support major outage as a Statuspage compatibility alias', () => { + expect( + getStatusPageStateFromSummary({ + status: { + indicator: 'none', + }, + components: [ + { + status: 'major_outage', + }, + ], + }) + ).toBe('downtime') + }) + + it('should report degraded for partial outage components', () => { + expect( + getStatusPageStateFromSummary({ + status: { + indicator: 'none', + }, + components: [ + { + status: 'partial_outage', + }, + ], + }) + ).toBe('degraded') + }) + + it('should report maintenance for under maintenance components', () => { + expect( + getStatusPageStateFromSummary({ + status: { + indicator: 'none', + }, + components: [ + { + status: 'under_maintenance', + }, + ], + }) + ).toBe('maintenance') + }) + + it('should prioritize degraded components over maintenance indicator', () => { + expect( + getStatusPageStateFromSummary({ + status: { + indicator: 'maintenance', + }, + components: [ + { + status: 'degraded_performance', + }, + ], + }) + ).toBe('degraded') + }) + + it('should report downtime for critical summary indicator', () => { + expect( + getStatusPageStateFromSummary({ + status: { + indicator: 'critical', + }, + }) + ).toBe('downtime') + }) + + it('should report degraded for major summary indicator', () => { + expect( + getStatusPageStateFromSummary({ + status: { + indicator: 'major', + }, + }) + ).toBe('degraded') + }) + + it('should report degraded for minor summary indicator', () => { + expect( + getStatusPageStateFromSummary({ + status: { + indicator: 'minor', + }, + }) + ).toBe('degraded') + }) +})