Files
supabase/apps/studio/lib/status-page/status-page.utils.ts
Charis 8b8569bd1a feat(studio): status page client data layer + support form (#50984)
## Summary

* Adds the client query layer for `/api/status-page`
(`statusPageQueryOptions` in `data/platform/status-page-query.ts`) and a
shared normalization module (`lib/status-page/status-page.utils.ts`)
that maps the endpoint response into region- and project-creation-aware
`StatusItem`s — later PRs in this stack (assistant tool,
project-creation admonition, global banner) build on this same module.
* Wires the support form's status pill and incident admonition to the
new data behind the `incidentIoStatusPage` ConfigCat flag.
`useSupportStatus` branches between the new endpoint and the existing
`useIncidentStatusQuery`/`processIncidentData` path, both mapped to the
same `SupportStatus` shape.
* `IncidentAdmonition` becomes purely presentational; the status link
now points at the `pageUrl` returned by the endpoint instead of a
hardcoded URL.

Part of
[FE-4057](https://linear.app/supabase/issue/FE-4057/frontend-bannerbot-reconfigured)
— see Linear for full design context.

## Test plan

- [X] `pnpm --filter studio run typecheck`
- [X] `pnpm --filter studio run lint:ratchet`
- [X] `pnpm knip --workspace apps/studio`
- [X] `pnpm test:prettier`
- [X] `pnpm --filter studio exec vitest run` (touched files) — 70 tests
passing, including a flag-on case for `SupportFormPage`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Support pages now display current incident and maintenance
information, including relevant status descriptions and links to the
status page.
* Status details can reflect items affecting a user’s region or
project-creation services. Upcoming maintenance is excluded from active
alerts.
* **Bug Fixes**
* Status labels and alerts now account for loading, errors, incidents,
and maintenance consistently across support pages.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-29 07:53:28 -04:00

167 lines
5.2 KiB
TypeScript

import type { AffectedComponent, StatusPageResponse, WorstImpact } from './status-page.schema'
export const PROJECT_CREATION_GROUP_NAME = 'project creation'
// Pattern, not a hardcoded region list, so new or test regions (e.g. a new AWS region, or a
// staging-only region) are recognized without a code change.
const REGION_CODE_PATTERN = /^[a-z]{2}(-[a-z]+)+-\d+$/
export const IMPACT_RANK: Record<WorstImpact, number> = {
full_outage: 3,
partial_outage: 2,
degraded_performance: 1,
}
export const DEFAULT_UPCOMING_LEAD_DAYS = 7
function normalizeLabel(value: string): string {
return value.trim().toLowerCase()
}
export function normalizeRegion(region: string): string {
return normalizeLabel(region)
}
export function isRegionCode(name: string): boolean {
return REGION_CODE_PATTERN.test(normalizeRegion(name))
}
export function isProjectCreationComponent(component: AffectedComponent): boolean {
return normalizeLabel(component.group_name ?? '') === PROJECT_CREATION_GROUP_NAME
}
export type RegionScope = { type: 'global' } | { type: 'regions'; regions: ReadonlyArray<string> }
export function getRegionScope(components: ReadonlyArray<AffectedComponent>): RegionScope {
if (components.length > 0 && components.every((component) => isRegionCode(component.name))) {
return {
type: 'regions',
regions: components.map((component) => normalizeRegion(component.name)),
}
}
return { type: 'global' }
}
export function isProjectCreationOnly(components: ReadonlyArray<AffectedComponent>): boolean {
return components.length > 0 && components.every(isProjectCreationComponent)
}
type StatusItemBase = {
id: string
name: string
url: string
lastUpdateAt: string
lastUpdateMessage: string | null
components: ReadonlyArray<AffectedComponent>
/** Scope derived from the components that aren't Project Creation. */
scope: RegionScope
/** Scope derived from Project Creation components; null when none affect creation. */
projectCreationScope: RegionScope | null
isProjectCreationOnly: boolean
showBanner: boolean
}
export type StatusItem =
| (StatusItemBase & {
kind: 'incident'
status: 'investigating' | 'identified' | 'monitoring'
impact: WorstImpact
})
| (StatusItemBase & { kind: 'maintenance'; startedAt: string; scheduledEndAt: string | null })
| (StatusItemBase & {
kind: 'upcoming_maintenance'
startsAt: string | null
endsAt: string | null
leadDays: number
})
export type NormalizedStatusPage = { pageUrl: string; items: Array<StatusItem> }
type WidgetItemCommon = {
id: string
name: string
url: string
last_update_at: string
last_update_message?: string | null
affected_components: Array<AffectedComponent>
visible: boolean
show_banner: boolean
}
function buildBase(item: WidgetItemCommon): StatusItemBase {
const nonCreationComponents = item.affected_components.filter(
(component) => !isProjectCreationComponent(component)
)
const creationComponents = item.affected_components.filter(isProjectCreationComponent)
return {
id: item.id,
name: item.name,
url: item.url,
lastUpdateAt: item.last_update_at,
lastUpdateMessage: item.last_update_message ?? null,
components: item.affected_components,
scope: getRegionScope(nonCreationComponents),
projectCreationScope: creationComponents.length > 0 ? getRegionScope(creationComponents) : null,
isProjectCreationOnly: isProjectCreationOnly(item.affected_components),
showBanner: item.show_banner,
}
}
export function normalizeStatusPage(res: StatusPageResponse): NormalizedStatusPage {
const items: Array<StatusItem> = [
...res.ongoing_incidents
.filter((incident) => incident.visible)
.map(
(incident): StatusItem => ({
...buildBase(incident),
kind: 'incident',
status: incident.status,
impact: incident.current_worst_impact,
})
),
...res.in_progress_maintenances
.filter((maintenance) => maintenance.visible)
.map(
(maintenance): StatusItem => ({
...buildBase(maintenance),
kind: 'maintenance',
startedAt: maintenance.started_at,
scheduledEndAt: maintenance.scheduled_end_at ?? null,
})
),
...res.scheduled_maintenances
.filter((maintenance) => maintenance.visible)
.map(
(maintenance): StatusItem => ({
...buildBase(maintenance),
kind: 'upcoming_maintenance',
startsAt: maintenance.starts_at ?? null,
endsAt: maintenance.ends_at ?? null,
leadDays: maintenance.banner_lead_days ?? DEFAULT_UPCOMING_LEAD_DAYS,
})
),
]
return { pageUrl: res.page_url, items }
}
export function getDismissalKey(item: Pick<StatusItem, 'kind' | 'id'>): string {
return `${item.kind}:${item.id}`
}
export type UserRegionContext = {
hasProjects: boolean
regions: ReadonlySet<string>
isComplete: boolean
}
export function isRelevantToUser(item: StatusItem, user: UserRegionContext): boolean {
if (!user.hasProjects) return false
if (item.scope.type === 'global') return true
if (!user.isComplete) return true
const userRegions = new Set(Array.from(user.regions, normalizeRegion))
return item.scope.regions.some((region) => userRegions.has(normalizeRegion(region)))
}