mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +08:00
## What kind of change does this PR introduce? Feature. Stack 3 of 5 for [PIPE-1007](https://linear.app/supabase/issue/PIPE-1007/move-read-replicas-out-of-replication-into-infrastructure). ## What is the current behavior? Replica detail lives at `/database/replication/replica/:id`. ## What is the new behavior? Detail moves to `/settings/infrastructure/replica/:id`. Old URLs redirect. List and diagram View/Manage replica links follow. ## Additional context Stacked on [#49044](https://github.com/supabase/supabase/pull/49044). Please review, but do not merge. Merge 2→5 in succession once they are all reviewed, so users never sit on a split create/list vs detail path. Replication still lists and creates replicas until [#49046](https://github.com/supabase/supabase/pull/49046). ## To test `infrastructure:read_replicas` is an enabled-feature, on by default. There is no Feature Preview or ConfigCat switch. You should already see the Infrastructure Read replicas section. If you do not, your profile lists `infrastructure:read_replicas` in `disabled_features`. From [Infrastructure](https://studio-staging-git-danny-pipe-1007-03-detail-redirects-supabase.vercel.app/dashboard/project/_/settings/infrastructure), open View replica on a row. Confirm you land on `/settings/infrastructure/replica/:id`. If you have an old bookmark, `/database/replication/replica/:id` should redirect there. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added read replica management to Infrastructure settings, including replica creation, status monitoring, details, restart, and removal actions. * Added eligibility guidance and estimated pricing details during replica setup. * Added support for topology and replica information within infrastructure configuration. * **Improvements** * Legacy database replication links now permanently redirect to the corresponding Infrastructure pages. * Added clearer empty, loading, error, and transition states for read replica management. * **Tests** * Expanded coverage for replica navigation, redirects, eligibility warnings, and empty states. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
492 lines
16 KiB
TypeScript
492 lines
16 KiB
TypeScript
// Shared redirect tables consumed by both `next.config.ts` (the Next.js
|
|
// build) and `vercel.ts` (the TanStack/Vercel deploy). Defined once here
|
|
// so the two configs don't drift.
|
|
//
|
|
// Shape mirrors Next's `redirects()` entry and Vercel's `Redirect` type
|
|
// (they're identical — `source`, `destination`, `permanent`, optional
|
|
// `has`). Basepath handling differs between the two: Next auto-prefixes,
|
|
// Vercel doesn't — each consumer is responsible for that.
|
|
|
|
export interface StudioRedirect {
|
|
source: string
|
|
destination: string
|
|
permanent: boolean
|
|
has?: Array<{ type: 'query'; key: string; value: string }>
|
|
}
|
|
|
|
export const PLATFORM_REDIRECTS: StudioRedirect[] = [
|
|
{
|
|
source: '/',
|
|
has: [{ type: 'query', key: 'next', value: 'new-project' }],
|
|
destination: '/new/new-project',
|
|
permanent: false,
|
|
},
|
|
{ source: '/', destination: '/org', permanent: false },
|
|
{ source: '/register', destination: '/sign-up', permanent: false },
|
|
{ source: '/signup', destination: '/sign-up', permanent: false },
|
|
{ source: '/signin', destination: '/sign-in', permanent: false },
|
|
{ source: '/login', destination: '/sign-in', permanent: false },
|
|
{ source: '/log-in', destination: '/sign-in', permanent: false },
|
|
{ source: '/project/:ref/building', destination: '/project/:ref', permanent: false },
|
|
]
|
|
|
|
export const SELF_HOSTED_REDIRECTS: StudioRedirect[] = [
|
|
{ source: '/', destination: '/project/default', permanent: false },
|
|
{ source: '/register', destination: '/project/default', permanent: false },
|
|
{ source: '/signup', destination: '/project/default', permanent: false },
|
|
{ source: '/signin', destination: '/project/default', permanent: false },
|
|
{ source: '/login', destination: '/project/default', permanent: false },
|
|
{ source: '/log-in', destination: '/project/default', permanent: false },
|
|
{ source: '/project/:ref/building', destination: '/project/:ref', permanent: false },
|
|
]
|
|
|
|
export const SHARED_REDIRECTS: StudioRedirect[] = [
|
|
{ source: '/project/:ref/auth', destination: '/project/:ref/auth/users', permanent: true },
|
|
{
|
|
source: '/project/:ref/auth/advanced',
|
|
destination: '/project/:ref/auth/performance',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/auth/policies',
|
|
destination: '/project/:ref/database/policies',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database',
|
|
destination: '/project/:ref/database/tables',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database/graphiql',
|
|
destination: '/project/:ref/api/graphiql',
|
|
permanent: true,
|
|
},
|
|
{ source: '/project/:ref/storage', destination: '/project/:ref/storage/files', permanent: true },
|
|
{
|
|
source: '/project/:ref/storage/buckets',
|
|
destination: '/project/:ref/storage/files',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/storage/policies',
|
|
destination: '/project/:ref/storage/files/policies',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/storage/buckets/:bucketId',
|
|
destination: '/project/:ref/storage/files/buckets/:bucketId',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/api-keys/new',
|
|
destination: '/project/:ref/settings/api-keys',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/storage',
|
|
destination: '/project/:ref/storage/files/settings',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/storage/settings',
|
|
destination: '/project/:ref/storage/files/settings',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/database',
|
|
destination: '/project/:ref/database/settings',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings',
|
|
destination: '/project/:ref/settings/general',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/auth/settings',
|
|
destination: '/project/:ref/auth/providers',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/billing/subscription',
|
|
has: [{ type: 'query', key: 'panel', value: 'subscriptionPlan' }],
|
|
destination: '/org/_/billing?panel=subscriptionPlan',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/compute-and-disk',
|
|
destination: '/project/:ref/settings/infrastructure',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database/replication/replica/:replicaId',
|
|
destination: '/project/:ref/settings/infrastructure/replica/:replicaId',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/billing/subscription',
|
|
has: [{ type: 'query', key: 'panel', value: 'pitr' }],
|
|
destination: '/project/:ref/settings/addons?panel=pitr',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/billing/subscription',
|
|
has: [{ type: 'query', key: 'panel', value: 'computeInstance' }],
|
|
destination: '/project/:ref/settings/infrastructure#cpu',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/billing/subscription',
|
|
has: [{ type: 'query', key: 'panel', value: 'customDomain' }],
|
|
destination: '/project/:ref/settings/addons?panel=customDomain',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/billing/subscription',
|
|
destination: '/org/_/billing',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/jwt/signing-keys',
|
|
destination: '/project/:ref/settings/jwt',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database/api-logs',
|
|
destination: '/project/:ref/logs/edge-logs',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database/postgres-logs',
|
|
destination: '/project/:ref/logs/postgres-logs',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database/postgrest-logs',
|
|
destination: '/project/:ref/logs/postgrest-logs',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database/pgbouncer-logs',
|
|
destination: '/project/:ref/logs/pooler-logs',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/logs/pgbouncer-logs',
|
|
destination: '/project/:ref/logs/pooler-logs',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database/realtime-logs',
|
|
destination: '/project/:ref/logs/realtime-logs',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/storage/logs',
|
|
destination: '/project/:ref/logs/storage-logs',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/auth/logs',
|
|
destination: '/project/:ref/logs/auth-logs',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/logs-explorer',
|
|
destination: '/project/:ref/logs/explorer',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/sql/quickstarts',
|
|
destination: '/project/:ref/sql/examples',
|
|
permanent: true,
|
|
},
|
|
{ source: '/org/:slug/settings', destination: '/org/:slug/general', permanent: true },
|
|
{
|
|
source: '/project/:ref/settings/billing/update',
|
|
destination: '/org/_/billing',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/billing/update/free',
|
|
destination: '/org/_/billing',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/billing/update/pro',
|
|
destination: '/org/_/billing',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/billing/update/team',
|
|
destination: '/org/_/billing',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/billing/update/enterprise',
|
|
destination: '/org/_/billing',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/reports/linter',
|
|
destination: '/project/:ref/database/linter',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/reports',
|
|
destination: '/project/:ref/observability',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/reports/:path*',
|
|
destination: '/project/:ref/observability/:path*',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/query-performance',
|
|
destination: '/project/:ref/observability/query-performance',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/advisors/query-performance',
|
|
destination: '/project/:ref/observability/query-performance',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database/query-performance',
|
|
destination: '/project/:ref/observability/query-performance',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/auth/column-privileges',
|
|
destination: '/project/:ref/database/column-privileges',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database/linter',
|
|
destination: '/project/:ref/database/security-advisor',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database/security-advisor',
|
|
destination: '/project/:ref/advisors/security',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database/performance-advisor',
|
|
destination: '/project/:ref/advisors/performance',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database/webhooks',
|
|
destination: '/project/:ref/integrations/webhooks/overview',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database/wrappers',
|
|
destination: '/project/:ref/integrations?category=wrapper',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/database/cron-jobs',
|
|
destination: '/project/:ref/integrations/cron',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/api/graphiql',
|
|
destination: '/project/:ref/integrations/graphiql',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/vault/secrets',
|
|
destination: '/project/:ref/integrations/vault/secrets',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/vault/keys',
|
|
destination: '/project/:ref/integrations/vault/keys',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/integrations/cron-jobs',
|
|
destination: '/project/:ref/integrations/cron',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/warehouse',
|
|
destination: '/project/:ref/settings/general',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/functions',
|
|
destination: '/project/:ref/functions/secrets',
|
|
permanent: true,
|
|
},
|
|
{ source: '/org/:slug/invoices', destination: '/org/:slug/billing#invoices', permanent: true },
|
|
{ source: '/projects', destination: '/organizations', permanent: false },
|
|
{
|
|
source: '/project/:ref/settings/auth',
|
|
destination: '/project/:ref/auth/providers',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/project/:ref/settings/api',
|
|
destination: '/project/:ref/integrations/data_api/overview',
|
|
permanent: false,
|
|
},
|
|
{
|
|
source: '/project/:ref/api',
|
|
destination: '/project/:ref/integrations/data_api/docs',
|
|
permanent: false,
|
|
},
|
|
]
|
|
|
|
// The two maintenance-mode rules are mutually exclusive; pick by env at
|
|
// build time. Same shape for Next and Vercel.
|
|
export function getMaintenanceRedirects(maintenanceMode: boolean): StudioRedirect[] {
|
|
return maintenanceMode
|
|
? [{ source: '/((?!maintenance|img).*)', destination: '/maintenance', permanent: false }]
|
|
: [{ source: '/maintenance', destination: '/', permanent: false }]
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Runtime matcher — used by TanStack's `__root` beforeLoad so self-hosted
|
|
// (and any request Vercel's edge didn't already intercept) still gets the
|
|
// same redirect behaviour as the Next.js / Vercel deploys.
|
|
//
|
|
// The patterns above are written in Next/Vercel-flavoured syntax: `:name`
|
|
// for a single segment, `:name*` for a trailing catch-all, literals
|
|
// otherwise. That's all the redirect rules use, so a hand-rolled matcher
|
|
// is simpler (and dependency-free) than pulling in path-to-regexp here.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type RedirectMatch = { destination: string; permanent: boolean }
|
|
|
|
function matchPattern(pattern: string, pathname: string): Record<string, string> | null {
|
|
const patternParts = pattern.split('/')
|
|
const pathParts = pathname.split('/')
|
|
const params: Record<string, string> = {}
|
|
|
|
for (let i = 0; i < patternParts.length; i++) {
|
|
const p = patternParts[i]
|
|
if (p?.startsWith(':') && p.endsWith('*')) {
|
|
params[p.slice(1, -1)] = pathParts.slice(i).join('/')
|
|
return params
|
|
}
|
|
if (p?.startsWith(':')) {
|
|
const v = pathParts[i]
|
|
if (!v) return null
|
|
params[p.slice(1)] = decodeURIComponent(v)
|
|
continue
|
|
}
|
|
if (p !== pathParts[i]) return null
|
|
}
|
|
return patternParts.length === pathParts.length ? params : null
|
|
}
|
|
|
|
function substituteDestination(dest: string, params: Record<string, string>): string {
|
|
return dest.replace(/:(\w+)\*?/g, (_, name) => params[name] ?? '')
|
|
}
|
|
|
|
function hasQueryMatches(
|
|
has: StudioRedirect['has'],
|
|
search: URLSearchParams | Record<string, string | string[] | undefined>
|
|
): boolean {
|
|
if (!has?.length) return true
|
|
const get = (k: string) =>
|
|
search instanceof URLSearchParams
|
|
? search.get(k)
|
|
: Array.isArray(search[k])
|
|
? (search[k] as string[])[0]
|
|
: (search[k] as string | undefined)
|
|
return has.every((h) => h.type === 'query' && get(h.key) === h.value)
|
|
}
|
|
|
|
// Merge the incoming request's query params (and hash) onto a redirect
|
|
// destination, mirroring Next.js `redirects()` semantics: the query string is
|
|
// carried through to the destination; params the destination already sets
|
|
// win; params consumed by the matched rule (its `has` query keys) are
|
|
// dropped; a hash on the destination wins over the incoming one. `hash` is
|
|
// the bare fragment without the leading `#`. Runtime-agnostic (also usable
|
|
// from next.config / Vercel edge). Exported for unit tests.
|
|
export function preserveQueryAndHash(
|
|
destination: string,
|
|
search: URLSearchParams | Record<string, string | string[] | undefined>,
|
|
options: { consumedKeys?: string[]; hash?: string } = {}
|
|
): string {
|
|
const { consumedKeys = [], hash = '' } = options
|
|
|
|
const hashIdx = destination.indexOf('#')
|
|
const destHash = hashIdx >= 0 ? destination.slice(hashIdx + 1) : ''
|
|
const beforeHash = hashIdx >= 0 ? destination.slice(0, hashIdx) : destination
|
|
const queryIdx = beforeHash.indexOf('?')
|
|
const destPath = queryIdx >= 0 ? beforeHash.slice(0, queryIdx) : beforeHash
|
|
const params = new URLSearchParams(queryIdx >= 0 ? beforeHash.slice(queryIdx + 1) : '')
|
|
|
|
const incoming =
|
|
search instanceof URLSearchParams
|
|
? search
|
|
: new URLSearchParams(
|
|
Object.entries(search).flatMap(([key, value]): Array<[string, string]> => {
|
|
if (value === undefined || value === null) return []
|
|
if (Array.isArray(value)) return value.map((v) => [key, v])
|
|
return [[key, value]]
|
|
})
|
|
)
|
|
for (const key of new Set(incoming.keys())) {
|
|
if (consumedKeys.includes(key)) continue
|
|
if (params.has(key)) continue // destination's own value wins
|
|
for (const value of incoming.getAll(key)) params.append(key, value)
|
|
}
|
|
|
|
const queryStr = params.toString()
|
|
const finalHash = destHash || hash
|
|
return `${destPath}${queryStr ? `?${queryStr}` : ''}${finalHash ? `#${finalHash}` : ''}`
|
|
}
|
|
|
|
export function matchRedirect(input: {
|
|
pathname: string
|
|
search: URLSearchParams | Record<string, string | string[] | undefined>
|
|
isPlatform: boolean
|
|
maintenanceMode?: boolean
|
|
// Bare fragment without the leading `#` (TanStack's `location.hash` shape).
|
|
hash?: string
|
|
}): RedirectMatch | null {
|
|
const { pathname, search, isPlatform, maintenanceMode = false, hash } = input
|
|
|
|
// Maintenance mode handled inline — the maintenance-on rule uses a
|
|
// negative-lookahead regex source that the segment matcher above can't
|
|
// parse. Cheap to special-case here.
|
|
if (maintenanceMode) {
|
|
if (!pathname.startsWith('/maintenance') && !pathname.startsWith('/img')) {
|
|
return {
|
|
destination: preserveQueryAndHash('/maintenance', search, { hash }),
|
|
permanent: false,
|
|
}
|
|
}
|
|
} else if (pathname === '/maintenance') {
|
|
return { destination: preserveQueryAndHash('/', search, { hash }), permanent: false }
|
|
}
|
|
|
|
const ordered = [
|
|
...(isPlatform ? PLATFORM_REDIRECTS : SELF_HOSTED_REDIRECTS),
|
|
...SHARED_REDIRECTS,
|
|
]
|
|
for (const rule of ordered) {
|
|
const params = matchPattern(rule.source, pathname)
|
|
if (!params) continue
|
|
if (!hasQueryMatches(rule.has, search)) continue
|
|
return {
|
|
// Next.js carries the incoming query and hash through to the
|
|
// destination; keys the rule matched on (`has`) are consumed.
|
|
destination: preserveQueryAndHash(substituteDestination(rule.destination, params), search, {
|
|
consumedKeys: rule.has?.map((h) => h.key) ?? [],
|
|
hash,
|
|
}),
|
|
permanent: rule.permanent,
|
|
}
|
|
}
|
|
return null
|
|
}
|