mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## Summary * Adds a new global status banner (`StatusBanner`) driven by the [incident.io](<http://incident.io>) status page data, showing at most one of: an active incident, in-progress maintenance, or upcoming maintenance, in that priority order. * All three types are independently dismissible (persisted to a new localStorage key, `status-banner-dismissed-keys`); dismissal hides the banner for items still active, and a new incident reappears even if a related item was previously dismissed. * Only shows to users who are actually affected (based on their projects' regions) or when region data is incomplete (fails open), and is bypassed entirely by the existing emergency incident override. * Behind the existing `incidentIoStatusPage` ConfigCat flag — `AppBannerWrapper` renders this new banner instead of the legacy `StatusPageBanner` only when the flag is on; default behavior is unchanged. * This is PR 5b in a stacked series for Linear [FE-4057](https://linear.app/supabase/issue/FE-4057) — see that issue for full design context. ## Test plan * New unit tests (`StatusBanner.utils.test.ts`) covering banner-selection priority, dismissal-key handling, and copy generation * New MSW component test (`StatusBanner.test.tsx`) covering loading state, dismiss-and-persist, and the emergency-override path * `pnpm --filter studio run typecheck`, `lint:ratchet`, `pnpm knip --workspace apps/studio`, `pnpm test:prettier`, and relevant vitest suites all pass 🤖 Generated with [Claude Code](<https://claude.com/claude-code>) Co-Authored-By: Claude [noreply@anthropic.com](<mailto:noreply@anthropic.com>) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added status banners for relevant incidents and scheduled maintenance, including incident details, maintenance timing, and links to the status page. * Banners can be dismissed, and dismissed items stay hidden while new incidents or maintenance updates can still appear. * Upcoming maintenance banners appear within the relevant lead time, and maintenance timing is shown when available. * Emergency overrides display a warning banner without a dismiss option. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
138 lines
4.9 KiB
TypeScript
138 lines
4.9 KiB
TypeScript
import {
|
|
getDismissalKey,
|
|
isRelevantToUser,
|
|
type StatusItem,
|
|
type UserRegionContext,
|
|
} from '@/lib/status-page/status-page.utils'
|
|
|
|
export type IncidentItem = Extract<StatusItem, { kind: 'incident' }>
|
|
export type MaintenanceItem = Extract<StatusItem, { kind: 'maintenance' }>
|
|
export type UpcomingMaintenanceItem = Extract<StatusItem, { kind: 'upcoming_maintenance' }>
|
|
|
|
export type BannerSelection =
|
|
| { kind: 'incident'; items: Array<IncidentItem> }
|
|
| { kind: 'maintenance'; item: MaintenanceItem }
|
|
| { kind: 'upcoming_maintenance'; item: UpcomingMaintenanceItem }
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
|
|
/**
|
|
* True once the maintenance is close enough to start (within its lead time) and
|
|
* hasn't already ended. `startsAt`/`endsAt` can be null when the upstream data is
|
|
* incomplete; treat that as outside the window rather than guessing a date.
|
|
*/
|
|
export function isWithinUpcomingWindow(item: UpcomingMaintenanceItem, nowMs: number): boolean {
|
|
if (item.startsAt === null || item.endsAt === null) return false
|
|
|
|
const startsAtMs = new Date(item.startsAt).getTime()
|
|
const endsAtMs = new Date(item.endsAt).getTime()
|
|
if (Number.isNaN(startsAtMs) || Number.isNaN(endsAtMs)) return false
|
|
|
|
const leadMs = item.leadDays * DAY_MS
|
|
return startsAtMs - nowMs <= leadMs && endsAtMs > nowMs
|
|
}
|
|
|
|
function getUpcomingStartMs(item: UpcomingMaintenanceItem): number {
|
|
return item.startsAt !== null ? new Date(item.startsAt).getTime() : Infinity
|
|
}
|
|
|
|
function earliestBy<T>(items: ReadonlyArray<T>, getTimeMs: (item: T) => number): T {
|
|
return items.reduce((earliest, item) => (getTimeMs(item) < getTimeMs(earliest) ? item : earliest))
|
|
}
|
|
|
|
/**
|
|
* Picks the single highest-priority banner to show, or null when nothing qualifies.
|
|
* Priority: incident (all relevant, undismissed incidents together) > in-progress
|
|
* maintenance (earliest start) > upcoming maintenance (soonest start).
|
|
*/
|
|
export function selectBanner(args: {
|
|
items: Array<StatusItem>
|
|
user: UserRegionContext
|
|
dismissedKeys: ReadonlySet<string>
|
|
nowMs: number
|
|
}): BannerSelection | null {
|
|
const { items, user, dismissedKeys, nowMs } = args
|
|
|
|
const eligible = items.filter(
|
|
(item) => item.showBanner && !item.isProjectCreationOnly && isRelevantToUser(item, user)
|
|
)
|
|
|
|
const withinWindow = eligible.filter(
|
|
(item) => item.kind !== 'upcoming_maintenance' || isWithinUpcomingWindow(item, nowMs)
|
|
)
|
|
|
|
const undismissed = withinWindow.filter((item) => !dismissedKeys.has(getDismissalKey(item)))
|
|
|
|
const incidents = undismissed.filter((item): item is IncidentItem => item.kind === 'incident')
|
|
if (incidents.length > 0) return { kind: 'incident', items: incidents }
|
|
|
|
const maintenances = undismissed.filter(
|
|
(item): item is MaintenanceItem => item.kind === 'maintenance'
|
|
)
|
|
if (maintenances.length > 0) {
|
|
return {
|
|
kind: 'maintenance',
|
|
item: earliestBy(maintenances, (item) => new Date(item.startedAt).getTime()),
|
|
}
|
|
}
|
|
|
|
const upcoming = undismissed.filter(
|
|
(item): item is UpcomingMaintenanceItem => item.kind === 'upcoming_maintenance'
|
|
)
|
|
if (upcoming.length > 0) {
|
|
return { kind: 'upcoming_maintenance', item: earliestBy(upcoming, getUpcomingStartMs) }
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export function getKeysToDismiss(selection: BannerSelection): Array<string> {
|
|
if (selection.kind === 'incident') return selection.items.map(getDismissalKey)
|
|
return [getDismissalKey(selection.item)]
|
|
}
|
|
|
|
/**
|
|
* Drops dismissed keys for items that are no longer in the (unfiltered) current
|
|
* item list — a finished incident or maintenance can't be re-dismissed, so there's
|
|
* no reason to keep growing the list forever — then adds the newly dismissed keys.
|
|
*/
|
|
export function pruneDismissedKeys(params: {
|
|
prev: Array<string>
|
|
allItems: Array<StatusItem>
|
|
add: Array<string>
|
|
}): Array<string> {
|
|
const { prev, allItems, add } = params
|
|
const activeKeys = new Set(allItems.map(getDismissalKey))
|
|
const kept = prev.filter((key) => activeKeys.has(key))
|
|
return Array.from(new Set([...kept, ...add]))
|
|
}
|
|
|
|
export function getBannerCopy(selection: BannerSelection): { title: string; description: string } {
|
|
switch (selection.kind) {
|
|
case 'incident': {
|
|
const title =
|
|
selection.items.length > 1
|
|
? 'We are investigating multiple technical issues'
|
|
: 'We are investigating a technical issue'
|
|
return { title, description: 'Follow the status page for updates' }
|
|
}
|
|
case 'maintenance': {
|
|
const { scheduledEndAt } = selection.item
|
|
const hasFutureEnd =
|
|
scheduledEndAt !== null && new Date(scheduledEndAt).getTime() > Date.now()
|
|
return {
|
|
title: 'Scheduled maintenance in progress',
|
|
description: hasFutureEnd
|
|
? 'Some services may be temporarily unavailable until maintenance ends'
|
|
: 'Some services may be temporarily unavailable',
|
|
}
|
|
}
|
|
case 'upcoming_maintenance': {
|
|
return {
|
|
title: 'Upcoming scheduled maintenance',
|
|
description: 'Some services may be temporarily unavailable during this window',
|
|
}
|
|
}
|
|
}
|
|
}
|