Files
supabase/apps/studio/components/layouts/AppLayout/FlyDeprecationBanner.tsx
Pamela Chia e55411da5e feat(studio): Fly.io deprecation banner (#45778)
## Summary

Adding an in-dashboard banner for the Fly.io May 31 suspension. Banner
targets users on a Fly project (or with a Fly project in their
currently-selected org) and surfaces a per-project breakdown of what's
affected in a dialog. Detection is self-correcting: as soon as the user
migrates off Fly, the banner disappears with no follow-up.

<img width="557" height="502" alt="Screenshot 2026-05-11 at 5 08 22 PM"
src="https://github.com/user-attachments/assets/7bafb712-3490-4555-9667-66e9909f1b1a"
/>
<img width="1675" height="536" alt="Screenshot 2026-05-11 at 3 55 06 PM"
src="https://github.com/user-attachments/assets/6c1bf9d1-4dcc-4aac-a679-2ed477d2ed1c"
/>


## Changes

- **Detection hook** (`useFlyDeprecationProjects`): reads only from
already-cached data — `useSelectedProjectQuery` for the current project,
plus `useOrgProjectsInfiniteQuery` scoped to the selected org. Zero
cross-org fan-out: worst case is one paginated query per session (the
same one the project list page already makes).
- **Banner component** (`FlyDeprecationBanner.tsx`): mounted in
`AppBannerWrapper`. Dynamic title (primaries / branches / both), dialog
lists affected projects with org name, numbered migration steps, links
to backup/restore CLI + Dashboard backup + branching docs. List
truncates to 5 entries with "…and N more." tail when more are affected.
- **Telemetry**: `fly_deprecation_banner_exposed` and
`fly_deprecation_banner_dismissed` events emitted via `useTrack`
(auto-injects project + org groups). Properties: `primaryCount`,
`branchCount`. CTA click tracking intentionally omitted — migration
outcome is measured via warehouse `cloud_provider = 'FLY'` decay.
- **LocalStorage**: dated dismissal key `FLY_DEPRECATION_2026_05_31`;
orphan `FLY_POSTGRES_DEPRECATION_WARNING` from PR #33510 removed in the
same change so users who dismissed the Feb 2025 banner still see this
one.
- **Support contact**: email `success@supabase.io` only (no support
ticket link), per Brian's outreach copy in the Linear issues.

## Coverage trade-off

Banner renders on project pages (selected-project check) and pages where
the selected org's projects list is cached (org overview, project list).
It does **not** render on `/dashboard` home or other pages without org
context. Email outreach from GROWTH-817 / GROWTH-819 handles those
users. This was a deliberate trade-off to avoid cross-org fan-out load.

## Lifecycle

Banner expires `2026-06-01T00:00:00Z` (right after the May 31 deadline).
Stale client bundles stop rendering it without a redeploy. Cleanup PR
planned post-deadline to remove the component, hook, localStorage key,
and telemetry events.

## Testing

Tested on the Vercel preview with React Query cache overrides to mock a
Fly project:

- [x] Banner renders for a user with at least one project where
`cloud_provider === 'FLY'`
- [x] Banner does **not** render for a user with no Fly projects
- [x] Banner does **not** render on `/sign-in`
- [x] Title varies by primaries-only / branches-only / both
- [x] Dialog lists affected projects with org name in parens
- [x] Dialog list truncates to 5 with "…and N more." for larger sets
- [x] Migration guide / Dashboard backup / branching links open in a new
tab
- [x] Dismiss (×) closes the banner and persists across hard reload
(localStorage `fly-deprecation-2026-05-31-dismissed`)
- [x] PostHog receives one `fly_deprecation_banner_exposed` per mount
with `primaryCount` + `branchCount` and `$groups.organization` populated
- [x] PostHog receives one `fly_deprecation_banner_dismissed` on close
with the same property shape

## Linear

- fixes GROWTH-817
- fixes GROWTH-819
2026-05-12 02:24:47 +08:00

223 lines
6.9 KiB
TypeScript

import { LOCAL_STORAGE_KEYS } from 'common'
import { useRouter } from 'next/router'
import { useEffect, useRef, type ReactNode } from 'react'
import {
Button,
cn,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
} from 'ui'
import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner'
import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink'
import {
useFlyDeprecationProjects,
type FlyDeprecationProject,
} from '@/hooks/misc/useFlyDeprecationProjects'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useTrack } from '@/lib/telemetry/track'
const BANNER_EXPIRES_AT = new Date('2026-06-01T00:00:00Z')
const BACKUP_RESTORE_CLI_URL =
'https://supabase.com/docs/guides/platform/migrating-within-supabase/backup-restore'
const DASHBOARD_RESTORE_URL =
'https://supabase.com/docs/guides/platform/migrating-within-supabase/dashboard-restore'
const BRANCHING_DASHBOARD_URL = 'https://supabase.com/docs/guides/deployment/branching/dashboard'
const SUPPORT_EMAIL = 'success@supabase.io'
export const FlyDeprecationBanner = () => {
const router = useRouter()
const [acknowledged, setAcknowledged, { isSuccess }] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.FLY_DEPRECATION_2026_05_31,
false
)
const isExpired = Date.now() >= BANNER_EXPIRES_AT.getTime()
const onSignIn = router.pathname.startsWith('/sign-in')
const shouldEvaluate = !isExpired && !onSignIn && isSuccess && !acknowledged
const { isReady, primaries, branches } = useFlyDeprecationProjects({ enabled: shouldEvaluate })
const hasFlyResources = primaries.length > 0 || branches.length > 0
const track = useTrack()
const exposedRef = useRef(false)
useEffect(() => {
if (!shouldEvaluate || !isReady || !hasFlyResources || exposedRef.current) return
exposedRef.current = true
track('fly_deprecation_banner_exposed', {
primaryCount: primaries.length,
branchCount: branches.length,
})
}, [shouldEvaluate, isReady, hasFlyResources, primaries.length, branches.length, track])
if (!shouldEvaluate || !isReady || !hasFlyResources) return null
const onDismiss = () => {
track('fly_deprecation_banner_dismissed', {
primaryCount: primaries.length,
branchCount: branches.length,
})
setAcknowledged(true)
}
const title =
primaries.length > 0 && branches.length > 0
? 'Action required: Fly.io project and branch suspensions begin May 31'
: primaries.length > 0
? 'Action required: Fly.io project suspensions begin May 31'
: 'Action required: Fly.io branch suspensions begin May 31'
return (
<HeaderBanner
variant="warning"
title={title}
description={<FlyDeprecationDialog primaries={primaries} branches={branches} />}
onDismiss={onDismiss}
/>
)
}
const FlyDeprecationDialog = ({
primaries,
branches,
}: {
primaries: FlyDeprecationProject[]
branches: FlyDeprecationProject[]
}) => {
return (
<Dialog>
<DialogTrigger className={cn(InlineLinkClassName, 'cursor-pointer')}>
View affected projects
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Fly.io deprecation: suspensions begin May 31, 2026</DialogTitle>
<DialogDescription>
Supabase will begin suspending projects and branches still running on Fly.io
infrastructure on May 31, 2026.
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<ProjectList
label="Projects on Fly.io"
items={primaries}
instructions={
<>
<p>
To preserve your data, migrate each project to Supabase's general infrastructure:
</p>
<ol className="list-decimal pl-5 space-y-1">
<li>
Back up your database using the{' '}
<InlineLink href={BACKUP_RESTORE_CLI_URL}>Supabase CLI</InlineLink> (or take a{' '}
<InlineLink href={DASHBOARD_RESTORE_URL}>Dashboard backup</InlineLink>).
</li>
<li>Create a new project on Supabase's general infrastructure.</li>
<li>Restore the backup into the new project.</li>
</ol>
</>
}
/>
<ProjectList
label="Branches on Fly.io"
items={branches}
instructions={
<>
<p>Merge preview branches before May 31. For persistent branches:</p>
<ol className="list-decimal pl-5 space-y-1">
<li>
Take a{' '}
<InlineLink href={BACKUP_RESTORE_CLI_URL}>
snapshot of the branch database
</InlineLink>{' '}
before shutting it down.
</li>
<li>
<InlineLink href={BRANCHING_DASHBOARD_URL}>
Deploy a new persistent branch
</InlineLink>{' '}
on Supabase's general infrastructure.
</li>
<li>Restore your data manually from the snapshot.</li>
</ol>
</>
}
/>
<DialogSection className="text-sm">
<p>
Questions or need an extension? Email{' '}
<a className={InlineLinkClassName} href={`mailto:${SUPPORT_EMAIL}`}>
{SUPPORT_EMAIL}
</a>
.
</p>
</DialogSection>
<DialogFooter>
<DialogClose asChild>
<Button type="default">Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
const MAX_LISTED = 5
const ProjectList = ({
label,
items,
instructions,
}: {
label: string
items: FlyDeprecationProject[]
instructions: ReactNode
}) => {
if (items.length === 0) return null
const visible = items.slice(0, MAX_LISTED)
const remaining = items.length - visible.length
return (
<>
<DialogSection className="text-sm flex flex-col gap-y-2">
<p className="font-medium">
{label} ({items.length})
</p>
{items.length === 1 ? (
<p>
{items[0].name} <span className="text-foreground-muted">({items[0].orgName})</span>
</p>
) : (
<ul className="list-disc pl-5 space-y-1">
{visible.map((p) => (
<li key={p.ref}>
{p.name} <span className="text-foreground-muted">({p.orgName})</span>
</li>
))}
{remaining > 0 && (
<li className="text-foreground-muted list-none -ml-5">…and {remaining} more.</li>
)}
</ul>
)}
</DialogSection>
<DialogSection className="text-sm flex flex-col gap-y-2">{instructions}</DialogSection>
</>
)
}