@@ -6,6 +6,12 @@ import { useRouter } from 'next/navigation';
66import { CloudOff , Loader2 , Zap } from 'lucide-react' ;
77import { Button , Card , CardContent } from '@nubase/ui' ;
88import { apiFetch , fetchAllProjects , type ApiError } from '@/lib/api' ;
9+ import {
10+ pollProjectProvisioning ,
11+ ProjectProvisioningFailedError ,
12+ ProjectProvisioningTimeoutError ,
13+ type ProjectProvisioningStatus ,
14+ } from '@/lib/project-provisioning' ;
915import { useSession , type ProjectContext } from '@/lib/session' ;
1016import { useProjectRef } from '@/lib/route-params' ;
1117
@@ -14,28 +20,69 @@ interface NotProvisionedProps {
1420 initStatus ?: string | null ;
1521}
1622
23+ type ProvisioningFailureKind = 'database' | 'monitoring' | 'request' ;
24+
25+ interface ProvisioningFailure {
26+ kind : ProvisioningFailureKind ;
27+ message : string ;
28+ }
29+
1730/**
1831 * Shown on data pages while the project's database isn't initialised yet. The user
1932 * shouldn't have to click anything: provisioning starts automatically on mount and
2033 * this just reports progress, falling back to a manual retry if it fails.
2134 */
22- export function NotProvisioned ( { projectRef, initStatus } : NotProvisionedProps ) {
35+ export function NotProvisioned ( {
36+ projectRef,
37+ initStatus,
38+ } : NotProvisionedProps ) {
2339 const router = useRouter ( ) ;
2440 const { platformKey, project, setProject } = useSession ( ) ;
2541 const resolvedProjectRef = useProjectRef ( projectRef ) ;
2642 const [ running , setRunning ] = useState ( false ) ;
27- const [ error , setError ] = useState < string | null > ( null ) ;
28- const autoStarted = useRef ( false ) ;
43+ const [ observedStatus , setObservedStatus ] = useState ( initStatus ?? null ) ;
44+ const [ failure , setFailure ] = useState < ProvisioningFailure | null > ( null ) ;
45+ const controllerRef = useRef < AbortController | null > ( null ) ;
46+ const runIdRef = useRef ( 0 ) ;
2947
3048 async function provision ( ) {
3149 if ( ! platformKey || ! resolvedProjectRef ) return ;
50+ const runId = ++ runIdRef . current ;
51+ controllerRef . current ?. abort ( ) ;
52+ const controller = new AbortController ( ) ;
53+ controllerRef . current = controller ;
3254 setRunning ( true ) ;
33- setError ( null ) ;
55+ setFailure ( null ) ;
3456 try {
35- await apiFetch ( `/auth/v1/admin/projects/${ encodeURIComponent ( resolvedProjectRef ) } /provision` , {
36- method : 'POST' ,
37- apikey : platformKey ,
38- } ) ;
57+ await apiFetch (
58+ `/auth/v1/admin/projects/${ encodeURIComponent ( resolvedProjectRef ) } /provision` ,
59+ {
60+ method : 'POST' ,
61+ apikey : platformKey ,
62+ authScope : 'platform' ,
63+ signal : controller . signal ,
64+ } ,
65+ ) ;
66+ await pollProjectProvisioning (
67+ ( ) =>
68+ apiFetch < ProjectProvisioningStatus > (
69+ `/auth/v1/admin/projects/${ encodeURIComponent ( resolvedProjectRef ) } /provision` ,
70+ {
71+ apikey : platformKey ,
72+ authScope : 'platform' ,
73+ signal : controller . signal ,
74+ } ,
75+ ) ,
76+ {
77+ signal : controller . signal ,
78+ onStatus : ( status ) => {
79+ if ( runId === runIdRef . current ) {
80+ setObservedStatus ( status . initStatus ) ;
81+ }
82+ } ,
83+ } ,
84+ ) ;
85+ if ( runId !== runIdRef . current ) return ;
3986 const refreshed = await fetchProject ( platformKey , resolvedProjectRef ) ;
4087 if ( refreshed ) {
4188 setProject ( refreshed ) ;
@@ -44,23 +91,51 @@ export function NotProvisioned({ projectRef, initStatus }: NotProvisionedProps)
4491 }
4592 router . refresh ( ) ;
4693 } catch ( err ) {
47- setError ( ( err as ApiError ) . message ?? 'Provision failed.' ) ;
94+ if ( controller . signal . aborted || runId !== runIdRef . current ) return ;
95+ if ( err instanceof ProjectProvisioningFailedError ) {
96+ setObservedStatus ( err . status . initStatus ) ;
97+ setFailure ( { kind : 'database' , message : err . message } ) ;
98+ } else if ( err instanceof ProjectProvisioningTimeoutError ) {
99+ setObservedStatus ( err . lastStatus . initStatus ) ;
100+ setFailure ( { kind : 'monitoring' , message : err . message } ) ;
101+ } else {
102+ setFailure ( {
103+ kind : 'request' ,
104+ message :
105+ ( err as ApiError ) . message ??
106+ 'Unable to start or monitor provisioning.' ,
107+ } ) ;
108+ }
48109 } finally {
49- setRunning ( false ) ;
110+ if ( runId === runIdRef . current ) {
111+ setRunning ( false ) ;
112+ }
50113 }
51114 }
52115
53- // Kick off provisioning automatically the moment this lands — no manual click.
54- // Fires once per mount; on failure the manual retry button below takes over .
116+ // Strict Mode mounts this effect twice in development. Aborting the previous run and
117+ // relying on backend per-project deduplication keeps both the browser and worker safe .
55118 useEffect ( ( ) => {
56- if ( autoStarted . current ) return ;
57119 if ( ! platformKey || ! resolvedProjectRef ) return ;
58- autoStarted . current = true ;
59120 void provision ( ) ;
121+ return ( ) => {
122+ controllerRef . current ?. abort ( ) ;
123+ } ;
60124 // eslint-disable-next-line react-hooks/exhaustive-deps
61125 } , [ platformKey , resolvedProjectRef ] ) ;
62126
63- const failed = ! ! error && ! running ;
127+ const failed = ! ! failure && ! running ;
128+ const currentStatus = observedStatus ?? initStatus ?? 'unknown' ;
129+ const failureTitle =
130+ failure ?. kind === 'database'
131+ ? 'Database provisioning failed'
132+ : failure ?. kind === 'monitoring'
133+ ? 'Database provisioning is taking longer than expected'
134+ : 'Unable to monitor database provisioning' ;
135+ const failureDescription =
136+ failure ?. kind === 'database'
137+ ? 'Postgres reported an initialization failure. Retry after resolving the persisted error below.'
138+ : 'The backend may still be provisioning this project. Checking again is safe and will not start a duplicate worker.' ;
64139
65140 return (
66141 < div className = "p-8" >
@@ -69,16 +144,19 @@ export function NotProvisioned({ projectRef, initStatus }: NotProvisionedProps)
69144 { failed ? (
70145 < >
71146 < CloudOff className = "h-8 w-8 text-muted-foreground" />
72- < h2 className = "text-lg font-semibold" > Database provisioning failed </ h2 >
147+ < h2 className = "text-lg font-semibold" > { failureTitle } </ h2 >
73148 < p className = "max-w-md text-sm text-muted-foreground" >
74149 This project is in state{ ' ' }
75- < code className = "font-mono text-xs" > { initStatus ?? 'unknown' } </ code > . The underlying Postgres
76- database couldn't be initialised.
150+ < code className = "font-mono text-xs" > { currentStatus } </ code > .{ ' ' }
151+ { failureDescription }
152+ </ p >
153+ < p className = "max-w-md text-xs text-destructive" >
154+ { failure ?. message }
77155 </ p >
78- < p className = "max-w-md text-xs text-destructive" > { error } </ p >
79156 < div className = "flex gap-2 pt-2" >
80157 < Button size = "sm" onClick = { provision } disabled = { running } >
81- < Zap className = "h-3.5 w-3.5" /> Retry
158+ < Zap className = "h-3.5 w-3.5" />
159+ { failure ?. kind === 'database' ? 'Retry' : 'Check status' }
82160 </ Button >
83161 < Link href = { `/project/${ resolvedProjectRef } /settings` } >
84162 < Button variant = "outline" size = "sm" >
@@ -92,9 +170,11 @@ export function NotProvisioned({ projectRef, initStatus }: NotProvisionedProps)
92170 < Loader2 className = "h-8 w-8 animate-spin text-muted-foreground" />
93171 < h2 className = "text-lg font-semibold" > Initializing database…</ h2 >
94172 < p className = "max-w-md text-sm text-muted-foreground" >
95- Provisioning the Postgres database and running the auth/storage schema for{ ' ' }
96- < code className = "font-mono text-xs" > { resolvedProjectRef } </ code > . This takes ~30 seconds — the
97- Database, Auth and Storage pages will populate automatically once it's done.
173+ Provisioning the Postgres database and running the auth/storage
174+ schema for{ ' ' }
175+ < code className = "font-mono text-xs" > { resolvedProjectRef } </ code > .
176+ This can take a few minutes — the Database, Auth and Storage
177+ pages will populate automatically once it's done.
98178 </ p >
99179 </ >
100180 ) }
@@ -112,7 +192,10 @@ interface ProjectSummary {
112192 apikey ?: string | null ;
113193}
114194
115- async function fetchProject ( platformKey : string , projectRef : string ) : Promise < ProjectContext | null > {
195+ async function fetchProject (
196+ platformKey : string ,
197+ projectRef : string ,
198+ ) : Promise < ProjectContext | null > {
116199 const projects = await fetchAllProjects < ProjectSummary > ( platformKey ) ;
117200 const project = projects . find ( ( p ) => p . ref === projectRef ) ;
118201 if ( ! project ) return null ;
0 commit comments