diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 5e10ded26b6..5a51cf97ce5 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -11,6 +11,7 @@ import { OrganizationResourceBanner } from '../Organization/HeaderBanner' import { isLogsOrObservabilityPath, isOrganizationLandingPath } from './AppBannerWrapper.utils' import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner' import { NoticeBanner } from '@/components/layouts/AppLayout/NoticeBanner' +import { StatusBanner } from '@/components/layouts/AppLayout/StatusBanner' import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner' import { BannerLogsAllDeprecation } from '@/components/ui/BannerStack/Banners/BannerLogsAllDeprecation' import { BannerPrivacyPolicyUpdate } from '@/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate' @@ -33,6 +34,7 @@ const MAX_TIMEOUT_MS = 2_147_483_647 export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { const showNoticeBanner = useFlag('showNoticeBanner') const clockSkewBanner = useFlag('clockSkewBanner') + const useStatusPageWidget = useFlag('incidentIoStatusPage') const { addBanner, dismissBanner } = useBannerStack() const pathname = usePathname() @@ -158,7 +160,7 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { return (
- + {useStatusPageWidget ? : } {showNoticeBanner && } {clockSkewBanner && } diff --git a/apps/studio/components/layouts/AppLayout/StatusBanner.test.tsx b/apps/studio/components/layouts/AppLayout/StatusBanner.test.tsx new file mode 100644 index 00000000000..38976d1a86a --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/StatusBanner.test.tsx @@ -0,0 +1,228 @@ +import { QueryClient } from '@tanstack/react-query' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { platformComponents as components } from 'api-types' +import { http, HttpResponse } from 'msw' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { StatusBanner } from './StatusBanner' +import { platformKeys } from '@/data/platform/keys' +import { BASE_PATH } from '@/lib/constants' +import type { StatusPageResponse } from '@/lib/status-page/status-page.schema' +import { createMockOrganizationResponse } from '@/tests/helpers' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock, mswServer } from '@/tests/lib/msw' +import { createMockProfileContext } from '@/tests/lib/profile-helpers' + +type OrganizationResponse = components['schemas']['OrganizationResponse_Output'] +type OrganizationProjectsResponse = components['schemas']['OrganizationProjectsResponse_Output'] +type OrgProject = OrganizationProjectsResponse['projects'][number] +type OrgDatabase = OrgProject['databases'][number] + +function mockProject( + ref: string, + databases: Array<{ region: string; type: 'PRIMARY' | 'READ_REPLICA' }> +): OrgProject { + return { + cloud_provider: 'AWS', + databases: databases.map( + ({ region, type }): OrgDatabase => ({ + cloud_provider: 'AWS', + identifier: ref, + region, + status: 'ACTIVE_HEALTHY', + type, + }) + ), + inserted_at: new Date().toISOString(), + integration_source: null, + is_branch: false, + name: ref, + ref, + region: databases[0]?.region ?? 'us-east-1', + status: 'ACTIVE_HEALTHY', + } +} + +function buildStatusPage(overrides: Partial = {}): StatusPageResponse { + return { + page_title: 'Supabase status', + page_url: 'https://status.supabase.com/', + ongoing_incidents: [], + in_progress_maintenances: [], + scheduled_maintenances: [], + ...overrides, + } +} + +function buildIncident( + overrides: Partial = {} +): StatusPageResponse['ongoing_incidents'][number] { + return { + id: 'inc-1', + name: 'Elevated errors', + url: 'https://status.supabase.com/incidents/inc-1', + last_update_at: '2026-01-01T00:00:00Z', + last_update_message: null, + affected_components: [], + status: 'investigating', + current_worst_impact: 'full_outage', + visible: true, + show_banner: true, + ...overrides, + } +} + +const INCIDENT_TITLE = 'We are investigating a technical issue' + +const mockOrgProjectEndpoints = () => { + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: () => + HttpResponse.json([ + createMockOrganizationResponse({ slug: 'org-a' }), + ]), + }) + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/projects', + response: () => + HttpResponse.json({ + pagination: { count: 1, limit: 100, offset: 0 }, + projects: [mockProject('project-a', [{ region: 'us-east-1', type: 'PRIMARY' }])], + }), + }) +} + +const { mockUseFlag } = vi.hoisted(() => ({ + mockUseFlag: vi.fn().mockReturnValue(false), +})) + +vi.mock(import('common'), async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useFlag: mockUseFlag } +}) + +vi.mock(import('@/lib/constants'), async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, IS_PLATFORM: true } +}) + +describe('StatusBanner', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + test('renders nothing while orgs are loading', async () => { + mswServer.use( + http.get(`${BASE_PATH}/api/status-page`, () => + HttpResponse.json( + buildStatusPage({ ongoing_incidents: [buildIncident()] }) + ) + ) + ) + const { promise: orgsRequested, resolve: releaseOrgsResponse } = Promise.withResolvers() + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: async () => { + await orgsRequested + return HttpResponse.json([ + createMockOrganizationResponse({ slug: 'org-a' }), + ]) + }, + }) + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/projects', + response: () => + HttpResponse.json({ + pagination: { count: 1, limit: 100, offset: 0 }, + projects: [mockProject('project-a', [{ region: 'us-east-1', type: 'PRIMARY' }])], + }), + }) + + customRender(, { profileContext: createMockProfileContext() }) + + expect(screen.queryByText(INCIDENT_TITLE)).not.toBeInTheDocument() + + releaseOrgsResponse() + + await waitFor(() => { + expect(screen.getByText(INCIDENT_TITLE)).toBeInTheDocument() + }) + }) + + test('dismissing hides the banner and writes the dismissed key to local storage', async () => { + mswServer.use( + http.get(`${BASE_PATH}/api/status-page`, () => + HttpResponse.json( + buildStatusPage({ ongoing_incidents: [buildIncident({ id: 'inc-1' })] }) + ) + ) + ) + mockOrgProjectEndpoints() + + customRender(, { profileContext: createMockProfileContext() }) + + await screen.findByText(INCIDENT_TITLE) + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss banner' })) + + await waitFor(() => { + expect(screen.queryByText(INCIDENT_TITLE)).not.toBeInTheDocument() + }) + + expect(JSON.parse(window.localStorage.getItem('status-banner-dismissed-keys') ?? '[]')).toEqual( + ['incident:inc-1'] + ) + }) + + test('a new incident brings the banner back even though the previous one is dismissed', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + mswServer.use( + http.get(`${BASE_PATH}/api/status-page`, () => + HttpResponse.json( + buildStatusPage({ ongoing_incidents: [buildIncident({ id: 'inc-1' })] }) + ) + ) + ) + mockOrgProjectEndpoints() + + customRender(, { profileContext: createMockProfileContext(), queryClient }) + + await screen.findByText(INCIDENT_TITLE) + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss banner' })) + + await waitFor(() => { + expect(screen.queryByText(INCIDENT_TITLE)).not.toBeInTheDocument() + }) + + mswServer.use( + http.get(`${BASE_PATH}/api/status-page`, () => + HttpResponse.json( + buildStatusPage({ + ongoing_incidents: [buildIncident({ id: 'inc-2', name: 'A different incident' })], + }) + ) + ) + ) + + await queryClient.invalidateQueries({ queryKey: platformKeys.statusPage() }) + + await waitFor(() => { + expect(screen.getByText(INCIDENT_TITLE)).toBeInTheDocument() + }) + }) + + test('the emergency override renders without fetching anything', async () => { + mockUseFlag.mockImplementation((name: string) => name === 'ongoingIncident') + + customRender(, { profileContext: createMockProfileContext() }) + + expect(await screen.findByText(INCIDENT_TITLE)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Dismiss banner' })).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/layouts/AppLayout/StatusBanner.tsx b/apps/studio/components/layouts/AppLayout/StatusBanner.tsx new file mode 100644 index 00000000000..fae92f7b77b --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/StatusBanner.tsx @@ -0,0 +1,108 @@ +import dayjs from 'dayjs' +import type { ReactNode } from 'react' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' + +import { getBannerCopy, type BannerSelection } from './StatusBanner.utils' +import { useStatusBanner } from './useStatusBanner' +import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner' +import { InlineLink } from '@/components/ui/InlineLink' + +const OVERRIDE_TITLE = 'We are investigating a technical issue' + +const TIMESTAMP_FORMAT = 'DD MMM, HH:mm' + +function formatTimestampLabel(value: string): string { + return dayjs(value).format(TIMESTAMP_FORMAT) +} + +function getBannerDescription({ + selection, + description, + pageUrl, +}: { + selection: BannerSelection + description: string + pageUrl: string +}): ReactNode { + if (selection.kind === 'incident') { + return ( + <> + {description} Status page + + ) + } + + if (selection.kind === 'maintenance') { + const { scheduledEndAt } = selection.item + const hasFutureEnd = scheduledEndAt !== null && new Date(scheduledEndAt).getTime() > Date.now() + return ( + <> + {description} + {hasFutureEnd && ( + <> + {' '} + ( + + ) + + )} + + ) + } + + const { startsAt, endsAt } = selection.item + return ( + <> + {description} + {startsAt !== null && endsAt !== null && ( + <> + {' '} + ( + {' – '} + ) + + )} + + ) +} + +/** + * Global banner for incident.io status page items — a relevant active incident, + * in-progress maintenance, or upcoming maintenance, in that priority order. + * Replaces the legacy StatusPageBanner while `incidentIoStatusPage` is on. + */ +export const StatusBanner = () => { + const state = useStatusBanner() + + if (state.type === 'hidden') return null + + if (state.type === 'override') { + return ( + + Follow the status page for + updates + + } + /> + ) + } + + const { selection, pageUrl, onDismiss } = state + const { title, description } = getBannerCopy(selection) + + return ( + + ) +} diff --git a/apps/studio/components/layouts/AppLayout/StatusBanner.utils.test.ts b/apps/studio/components/layouts/AppLayout/StatusBanner.utils.test.ts new file mode 100644 index 00000000000..7cd16219350 --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/StatusBanner.utils.test.ts @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + getBannerCopy, + getKeysToDismiss, + isWithinUpcomingWindow, + pruneDismissedKeys, + selectBanner, + type IncidentItem, + type MaintenanceItem, + type UpcomingMaintenanceItem, +} from './StatusBanner.utils' +import type { + RegionScope, + StatusItem, + UserRegionContext, +} from '@/lib/status-page/status-page.utils' + +const GLOBAL_SCOPE: RegionScope = { type: 'global' } + +const DEFAULT_USER: UserRegionContext = { + hasProjects: true, + regions: new Set(), + isComplete: true, +} + +function makeIncident(overrides: Partial = {}): IncidentItem { + return { + id: 'inc-1', + name: 'Elevated errors', + url: 'https://status.example.com/incidents/inc-1', + lastUpdateAt: '2026-01-01T00:00:00Z', + lastUpdateMessage: null, + components: [], + scope: GLOBAL_SCOPE, + projectCreationScope: null, + isProjectCreationOnly: false, + showBanner: true, + kind: 'incident', + status: 'investigating', + impact: 'full_outage', + ...overrides, + } +} + +function makeMaintenance(overrides: Partial = {}): MaintenanceItem { + return { + id: 'maint-1', + name: 'Database maintenance', + url: 'https://status.example.com/incidents/maint-1', + lastUpdateAt: '2026-01-01T00:00:00Z', + lastUpdateMessage: null, + components: [], + scope: GLOBAL_SCOPE, + projectCreationScope: null, + isProjectCreationOnly: false, + showBanner: true, + kind: 'maintenance', + startedAt: '2026-01-01T00:00:00Z', + scheduledEndAt: null, + ...overrides, + } +} + +function makeUpcoming(overrides: Partial = {}): UpcomingMaintenanceItem { + return { + id: 'upcoming-1', + name: 'Upcoming maintenance', + url: 'https://status.example.com/incidents/upcoming-1', + lastUpdateAt: '2026-01-01T00:00:00Z', + lastUpdateMessage: null, + components: [], + scope: GLOBAL_SCOPE, + projectCreationScope: null, + isProjectCreationOnly: false, + showBanner: true, + kind: 'upcoming_maintenance', + startsAt: '2026-02-01T00:00:00Z', + endsAt: '2026-02-01T02:00:00Z', + leadDays: 7, + ...overrides, + } +} + +const NOW_MS = Date.UTC(2026, 0, 25, 0, 0, 0) +const DAY_MS = 24 * 60 * 60 * 1000 + +describe('isWithinUpcomingWindow', () => { + it('is true exactly at the default lead time boundary', () => { + const item = makeUpcoming({ + startsAt: new Date(NOW_MS + 7 * DAY_MS).toISOString(), + endsAt: new Date(NOW_MS + 8 * DAY_MS).toISOString(), + leadDays: 7, + }) + expect(isWithinUpcomingWindow(item, NOW_MS)).toBe(true) + }) + + it('is false just outside the lead time boundary', () => { + const item = makeUpcoming({ + startsAt: new Date(NOW_MS + 7 * DAY_MS + 1).toISOString(), + endsAt: new Date(NOW_MS + 8 * DAY_MS).toISOString(), + leadDays: 7, + }) + expect(isWithinUpcomingWindow(item, NOW_MS)).toBe(false) + }) + + it('respects a custom leadDays', () => { + const withinCustomLead = makeUpcoming({ + startsAt: new Date(NOW_MS + 2 * DAY_MS).toISOString(), + endsAt: new Date(NOW_MS + 3 * DAY_MS).toISOString(), + leadDays: 2, + }) + expect(isWithinUpcomingWindow(withinCustomLead, NOW_MS)).toBe(true) + + const outsideCustomLead = makeUpcoming({ + startsAt: new Date(NOW_MS + 3 * DAY_MS).toISOString(), + endsAt: new Date(NOW_MS + 4 * DAY_MS).toISOString(), + leadDays: 2, + }) + expect(isWithinUpcomingWindow(outsideCustomLead, NOW_MS)).toBe(false) + }) + + it('is false once endsAt is already past, even if startsAt is within the lead window', () => { + const item = makeUpcoming({ + startsAt: new Date(NOW_MS - 2 * DAY_MS).toISOString(), + endsAt: new Date(NOW_MS - 1 * DAY_MS).toISOString(), + leadDays: 7, + }) + expect(isWithinUpcomingWindow(item, NOW_MS)).toBe(false) + }) + + it('is false when startsAt or endsAt is null', () => { + expect(isWithinUpcomingWindow(makeUpcoming({ startsAt: null }), NOW_MS)).toBe(false) + expect(isWithinUpcomingWindow(makeUpcoming({ endsAt: null }), NOW_MS)).toBe(false) + }) +}) + +describe('selectBanner', () => { + it('prioritizes incidents over maintenance and upcoming maintenance', () => { + const items: Array = [ + makeUpcoming({ id: 'upcoming-1' }), + makeMaintenance({ id: 'maint-1' }), + makeIncident({ id: 'inc-1' }), + ] + const result = selectBanner({ + items, + user: DEFAULT_USER, + dismissedKeys: new Set(), + nowMs: NOW_MS, + }) + expect(result).toEqual({ kind: 'incident', items: [makeIncident({ id: 'inc-1' })] }) + }) + + it('prioritizes in-progress maintenance over upcoming maintenance', () => { + const items: Array = [ + makeUpcoming({ id: 'upcoming-1' }), + makeMaintenance({ id: 'maint-1' }), + ] + const result = selectBanner({ + items, + user: DEFAULT_USER, + dismissedKeys: new Set(), + nowMs: NOW_MS, + }) + expect(result).toEqual({ kind: 'maintenance', item: makeMaintenance({ id: 'maint-1' }) }) + }) + + it('falls through to maintenance when the only incident is dismissed', () => { + const items: Array = [ + makeIncident({ id: 'inc-1' }), + makeMaintenance({ id: 'maint-1' }), + ] + const result = selectBanner({ + items, + user: DEFAULT_USER, + dismissedKeys: new Set(['incident:inc-1']), + nowMs: NOW_MS, + }) + expect(result).toEqual({ kind: 'maintenance', item: makeMaintenance({ id: 'maint-1' }) }) + }) + + it('excludes items with showBanner: false', () => { + const items: Array = [makeIncident({ showBanner: false })] + expect( + selectBanner({ items, user: DEFAULT_USER, dismissedKeys: new Set(), nowMs: NOW_MS }) + ).toBeNull() + }) + + it('excludes project-creation-only items', () => { + const items: Array = [makeIncident({ isProjectCreationOnly: true })] + expect( + selectBanner({ items, user: DEFAULT_USER, dismissedKeys: new Set(), nowMs: NOW_MS }) + ).toBeNull() + }) + + it('excludes items irrelevant to the user (region-scoped, no matching project region)', () => { + const items: Array = [ + makeIncident({ scope: { type: 'regions', regions: ['us-east-1'] } }), + ] + const user: UserRegionContext = { + hasProjects: true, + regions: new Set(['eu-west-1']), + isComplete: true, + } + expect(selectBanner({ items, user, dismissedKeys: new Set(), nowMs: NOW_MS })).toBeNull() + }) + + it('excludes upcoming maintenance outside its window', () => { + const items: Array = [ + makeUpcoming({ startsAt: new Date(NOW_MS + 30 * DAY_MS).toISOString(), leadDays: 7 }), + ] + expect( + selectBanner({ items, user: DEFAULT_USER, dismissedKeys: new Set(), nowMs: NOW_MS }) + ).toBeNull() + }) + + it('keeps distinct dismissal keys for the same ID across kinds', () => { + const items: Array = [makeMaintenance({ id: 'shared-id' })] + const result = selectBanner({ + items, + user: DEFAULT_USER, + dismissedKeys: new Set(['upcoming_maintenance:shared-id']), + nowMs: NOW_MS, + }) + expect(result).toEqual({ kind: 'maintenance', item: makeMaintenance({ id: 'shared-id' }) }) + }) + + it('includes all undismissed relevant incidents together', () => { + const items: Array = [makeIncident({ id: 'inc-1' }), makeIncident({ id: 'inc-2' })] + const result = selectBanner({ + items, + user: DEFAULT_USER, + dismissedKeys: new Set(), + nowMs: NOW_MS, + }) + expect(result).toEqual({ + kind: 'incident', + items: [makeIncident({ id: 'inc-1' }), makeIncident({ id: 'inc-2' })], + }) + }) +}) + +describe('getKeysToDismiss', () => { + it('returns one key per incident for an incident selection', () => { + expect( + getKeysToDismiss({ + kind: 'incident', + items: [makeIncident({ id: 'a' }), makeIncident({ id: 'b' })], + }) + ).toEqual(['incident:a', 'incident:b']) + }) + + it('returns a single key for a maintenance selection', () => { + expect(getKeysToDismiss({ kind: 'maintenance', item: makeMaintenance({ id: 'm' }) })).toEqual([ + 'maintenance:m', + ]) + }) + + it('returns a single key for an upcoming maintenance selection', () => { + expect( + getKeysToDismiss({ kind: 'upcoming_maintenance', item: makeUpcoming({ id: 'u' }) }) + ).toEqual(['upcoming_maintenance:u']) + }) +}) + +describe('pruneDismissedKeys', () => { + it('keeps keys for items still in the unfiltered item list and adds the new ones', () => { + const result = pruneDismissedKeys({ + prev: ['incident:still-active', 'incident:finished'], + allItems: [makeIncident({ id: 'still-active' })], + add: ['incident:new'], + }) + expect(result).toEqual(expect.arrayContaining(['incident:still-active', 'incident:new'])) + expect(result).not.toContain('incident:finished') + }) + + it('checks against the unfiltered item list, not a relevance-filtered one', () => { + const result = pruneDismissedKeys({ + prev: ['incident:hidden-but-active'], + allItems: [makeIncident({ id: 'hidden-but-active', showBanner: false })], + add: [], + }) + expect(result).toEqual(['incident:hidden-but-active']) + }) +}) + +describe('getBannerCopy', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW_MS) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns singular incident copy for one incident', () => { + const copy = getBannerCopy({ kind: 'incident', items: [makeIncident()] }) + expect(copy.title).toBe('We are investigating a technical issue') + }) + + it('returns plural incident copy for multiple incidents', () => { + const copy = getBannerCopy({ + kind: 'incident', + items: [makeIncident({ id: 'a' }), makeIncident({ id: 'b' })], + }) + expect(copy.title).toBe('We are investigating multiple technical issues') + }) + + it('mentions an end time when scheduledEndAt is in the future', () => { + const copy = getBannerCopy({ + kind: 'maintenance', + item: makeMaintenance({ scheduledEndAt: new Date(NOW_MS + DAY_MS).toISOString() }), + }) + expect(copy.description).toContain('until maintenance ends') + }) + + it('omits the end-time mention when scheduledEndAt is null', () => { + const copy = getBannerCopy({ + kind: 'maintenance', + item: makeMaintenance({ scheduledEndAt: null }), + }) + expect(copy.description).not.toContain('until maintenance ends') + }) + + it('omits the end-time mention when scheduledEndAt is already past', () => { + const copy = getBannerCopy({ + kind: 'maintenance', + item: makeMaintenance({ scheduledEndAt: '2020-01-01T00:00:00Z' }), + }) + expect(copy.description).not.toContain('until maintenance ends') + }) + + it('returns upcoming maintenance copy', () => { + const copy = getBannerCopy({ kind: 'upcoming_maintenance', item: makeUpcoming() }) + expect(copy.title).toBe('Upcoming scheduled maintenance') + }) +}) diff --git a/apps/studio/components/layouts/AppLayout/StatusBanner.utils.ts b/apps/studio/components/layouts/AppLayout/StatusBanner.utils.ts new file mode 100644 index 00000000000..a8e055b8523 --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/StatusBanner.utils.ts @@ -0,0 +1,137 @@ +import { + getDismissalKey, + isRelevantToUser, + type StatusItem, + type UserRegionContext, +} from '@/lib/status-page/status-page.utils' + +export type IncidentItem = Extract +export type MaintenanceItem = Extract +export type UpcomingMaintenanceItem = Extract + +export type BannerSelection = + | { kind: 'incident'; items: Array } + | { 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(items: ReadonlyArray, 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 + user: UserRegionContext + dismissedKeys: ReadonlySet + 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 { + 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 + allItems: Array + add: Array +}): Array { + 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', + } + } + } +} diff --git a/apps/studio/components/layouts/AppLayout/useStatusBanner.ts b/apps/studio/components/layouts/AppLayout/useStatusBanner.ts new file mode 100644 index 00000000000..85797ea0bf0 --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/useStatusBanner.ts @@ -0,0 +1,65 @@ +import { useQuery } from '@tanstack/react-query' +import { LOCAL_STORAGE_KEYS } from 'common' + +import { + getKeysToDismiss, + pruneDismissedKeys, + selectBanner, + type BannerSelection, +} from './StatusBanner.utils' +import { DEFAULT_STATUS_PAGE_URL } from '@/components/interfaces/Support/SupportStatus.utils' +import { statusPageQueryOptions } from '@/data/platform/status-page-query' +import { useEmergencyIncidentOverride } from '@/hooks/misc/useEmergencyIncidentOverride' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import { useUserProjectRegions } from '@/hooks/misc/useUserProjectRegions' +import { normalizeStatusPage } from '@/lib/status-page/status-page.utils' + +export type StatusBannerState = + | { type: 'override' } + | { type: 'hidden' } + | { type: 'shown'; selection: BannerSelection; pageUrl: string; onDismiss: () => void } + +const HIDDEN: StatusBannerState = { type: 'hidden' } + +export function useStatusBanner(): StatusBannerState { + const isEmergencyOverride = useEmergencyIncidentOverride() + + const statusPageOptions = statusPageQueryOptions() + const { data } = useQuery({ + ...statusPageOptions, + select: normalizeStatusPage, + enabled: statusPageOptions.enabled && !isEmergencyOverride, + }) + + const items = data?.items ?? [] + + const userProjectRegions = useUserProjectRegions({ + enabled: !isEmergencyOverride && items.length > 0, + }) + + const [dismissedKeys, setDismissedKeys, { isSuccess: isDismissedKeysLoaded }] = + useLocalStorageQuery>(LOCAL_STORAGE_KEYS.STATUS_BANNER_DISMISSED_KEYS, []) + + if (isEmergencyOverride) return { type: 'override' } + + if (data === undefined || userProjectRegions.status === 'loading' || !isDismissedKeysLoaded) { + return HIDDEN + } + + const selection = selectBanner({ + items, + user: userProjectRegions.context, + dismissedKeys: new Set(dismissedKeys), + nowMs: Date.now(), + }) + + if (selection === null) return HIDDEN + + const onDismiss = () => { + setDismissedKeys((prev) => + pruneDismissedKeys({ prev, allItems: items, add: getKeysToDismiss(selection) }) + ) + } + + return { type: 'shown', selection, pageUrl: data?.pageUrl ?? DEFAULT_STATUS_PAGE_URL, onDismiss } +} diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 08c2acb6dd6..262980f4cd7 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -15,6 +15,7 @@ export const LOCAL_STORAGE_KEYS = { FEEDBACK_WIDGET_CONTENT: 'feedback-widget-content', FEEDBACK_WIDGET_SCREENSHOT: 'feedback-widget-screenshot', INCIDENT_BANNER_DISMISSED_IDS: 'incident-banner-dismissed-ids', + STATUS_BANNER_DISMISSED_KEYS: 'status-banner-dismissed-keys', DASHBOARD_PREFERENCES: (ref: string) => `dashboard-preferences-${ref}`, UNIFIED_LOGS_DOCK: 'unified-logs-dock',