Skip to content

Commit c2bbde1

Browse files
committed
feat: implement environment provisioning retry mechanism and enhance UI with status badges
1 parent eb8d009 commit c2bbde1

9 files changed

Lines changed: 689 additions & 57 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* EnvironmentStatusBadge — color-coded pill for the environment lifecycle
5+
* status (provisioning / active / failed / suspended / archived / migrating).
6+
*
7+
* Rendered alongside {@link EnvironmentBadge} (which encodes envType) on the
8+
* environment list and detail pages so operators can tell at a glance whether
9+
* a given environment is ready, still coming up, or broken.
10+
*
11+
* Keep this component purely presentational — no data fetching or navigation
12+
* side-effects — so it can be rendered in tables, badges, and dialogs
13+
* without pulling in context.
14+
*/
15+
16+
import { Loader2, AlertTriangle, CheckCircle2, PauseCircle, Archive, MoveRight } from 'lucide-react';
17+
import { cn } from '@/lib/utils';
18+
import type { EnvironmentStatus } from '@objectstack/spec/cloud';
19+
20+
const VARIANT: Record<EnvironmentStatus, string> = {
21+
provisioning:
22+
'border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300',
23+
active:
24+
'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
25+
failed:
26+
'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300',
27+
suspended:
28+
'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300',
29+
archived:
30+
'border-muted bg-muted text-muted-foreground',
31+
migrating:
32+
'border-violet-500/40 bg-violet-500/10 text-violet-700 dark:text-violet-300',
33+
};
34+
35+
const LABEL: Record<EnvironmentStatus, string> = {
36+
provisioning: 'Provisioning',
37+
active: 'Active',
38+
failed: 'Provisioning failed',
39+
suspended: 'Suspended',
40+
archived: 'Archived',
41+
migrating: 'Migrating',
42+
};
43+
44+
function StatusIcon({ status, className }: { status: EnvironmentStatus; className?: string }) {
45+
switch (status) {
46+
case 'provisioning':
47+
return <Loader2 className={cn('h-3 w-3 animate-spin', className)} />;
48+
case 'failed':
49+
return <AlertTriangle className={cn('h-3 w-3', className)} />;
50+
case 'active':
51+
return <CheckCircle2 className={cn('h-3 w-3', className)} />;
52+
case 'suspended':
53+
return <PauseCircle className={cn('h-3 w-3', className)} />;
54+
case 'archived':
55+
return <Archive className={cn('h-3 w-3', className)} />;
56+
case 'migrating':
57+
return <MoveRight className={cn('h-3 w-3', className)} />;
58+
default:
59+
return null;
60+
}
61+
}
62+
63+
export interface EnvironmentStatusBadgeProps {
64+
status: EnvironmentStatus;
65+
/** Omit the label and show just the icon chip. Useful in dense lists. */
66+
iconOnly?: boolean;
67+
className?: string;
68+
}
69+
70+
export function EnvironmentStatusBadge({ status, iconOnly, className }: EnvironmentStatusBadgeProps) {
71+
return (
72+
<span
73+
className={cn(
74+
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider',
75+
VARIANT[status],
76+
className,
77+
)}
78+
title={LABEL[status]}
79+
>
80+
<StatusIcon status={status} />
81+
{!iconOnly && <span>{LABEL[status]}</span>}
82+
</span>
83+
);
84+
}

apps/studio/src/hooks/useEnvironments.ts

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -100,38 +100,39 @@ export function useEnvironmentDetail(environmentId: string | undefined) {
100100
const [loading, setLoading] = useState(false);
101101
const [error, setError] = useState<Error | null>(null);
102102

103-
useEffect(() => {
103+
const load = useCallback(async () => {
104104
if (!environmentId || !client?.environments) {
105105
setDetail(null);
106106
client?.setEnvironmentId?.(undefined);
107107
return;
108108
}
109-
let alive = true;
110109
setLoading(true);
111110
setError(null);
112111
client.setEnvironmentId(environmentId);
113112
rememberActiveEnvironment(environmentId);
113+
try {
114+
const result = await client.environments.get(environmentId);
115+
setDetail(result as EnvironmentDetail);
116+
} catch (err) {
117+
setError(err as Error);
118+
setDetail(null);
119+
} finally {
120+
setLoading(false);
121+
}
122+
}, [client, environmentId]);
114123

124+
useEffect(() => {
125+
let alive = true;
115126
(async () => {
116-
try {
117-
const result = await client.environments.get(environmentId);
118-
if (!alive) return;
119-
setDetail(result as EnvironmentDetail);
120-
} catch (err) {
121-
if (!alive) return;
122-
setError(err as Error);
123-
setDetail(null);
124-
} finally {
125-
if (alive) setLoading(false);
126-
}
127+
if (!alive) return;
128+
await load();
127129
})();
128-
129130
return () => {
130131
alive = false;
131132
};
132-
}, [client, environmentId]);
133+
}, [load]);
133134

134-
return { detail, loading, error };
135+
return { detail, loading, error, reload: load };
135136
}
136137

137138
/**
@@ -200,3 +201,37 @@ export function useProvisionEnvironment() {
200201

201202
return { provision, provisioning, error };
202203
}
204+
205+
/**
206+
* Hook: retry provisioning for an environment stuck in `failed` state.
207+
*
208+
* Wraps `client.environments.retryProvisioning(id)`. Exposes `retrying`
209+
* state so callers can disable the button and show a spinner while the
210+
* server re-runs the driver handshake.
211+
*/
212+
export function useRetryProvisioning() {
213+
const client = useClient() as any;
214+
const [retrying, setRetrying] = useState(false);
215+
const [error, setError] = useState<Error | null>(null);
216+
217+
const retry = useCallback(
218+
async (environmentId: string) => {
219+
if (!client?.environments?.retryProvisioning) {
220+
throw new Error('Client not ready');
221+
}
222+
setRetrying(true);
223+
setError(null);
224+
try {
225+
return await client.environments.retryProvisioning(environmentId);
226+
} catch (err) {
227+
setError(err as Error);
228+
throw err;
229+
} finally {
230+
setRetrying(false);
231+
}
232+
},
233+
[client],
234+
);
235+
236+
return { retry, retrying, error };
237+
}

apps/studio/src/routes/environments.$environmentId.index.tsx

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,20 @@ import {
1717
KeyRound,
1818
MapPin,
1919
RefreshCw,
20+
RotateCw,
2021
Trash,
2122
AlertTriangle,
23+
Loader2,
2224
Package,
2325
} from 'lucide-react';
2426
import { SiteHeader } from '@/components/site-header';
2527
import { EnvironmentBadge } from '@/components/environment-badge';
28+
import { EnvironmentStatusBadge } from '@/components/environment-status-badge';
2629
import { Card } from '@/components/ui/card';
2730
import { Button } from '@/components/ui/button';
2831
import { Badge } from '@/components/ui/badge';
2932
import { Separator } from '@/components/ui/separator';
30-
import { useEnvironmentDetail } from '@/hooks/useEnvironments';
33+
import { useEnvironmentDetail, useRetryProvisioning } from '@/hooks/useEnvironments';
3134
import { useClient } from '@objectstack/client-react';
3235
import { useProductionGuard } from '@/components/production-guard';
3336
import { toast } from '@/hooks/use-toast';
@@ -36,13 +39,47 @@ function EnvironmentOverviewComponent() {
3639
const { environmentId } = useParams({
3740
from: '/environments/$environmentId',
3841
});
39-
const { detail, loading } = useEnvironmentDetail(environmentId);
42+
const { detail, loading, reload } = useEnvironmentDetail(environmentId);
4043
const client = useClient() as any;
4144
const navigate = useNavigate();
4245
const guard = useProductionGuard();
4346
const [rotating, setRotating] = useState(false);
47+
const { retry, retrying } = useRetryProvisioning();
4448

4549
const env = detail?.environment;
50+
const provisioningError =
51+
(env?.metadata as Record<string, any> | undefined)?.provisioningError as
52+
| { message?: string; failedAt?: string }
53+
| undefined;
54+
55+
const handleRetry = async () => {
56+
if (!env) return;
57+
try {
58+
const result = await retry(env.id);
59+
const nextStatus = (result as any)?.environment?.status;
60+
if (nextStatus === 'active') {
61+
toast({
62+
title: 'Provisioning complete',
63+
description: 'The environment is now active and ready to use.',
64+
});
65+
} else if (nextStatus === 'failed') {
66+
toast({
67+
title: 'Retry failed',
68+
description:
69+
(result as any)?.environment?.metadata?.provisioningError?.message ??
70+
'Provisioning failed again. Check server logs.',
71+
variant: 'destructive',
72+
});
73+
}
74+
await reload();
75+
} catch (err) {
76+
toast({
77+
title: 'Retry failed',
78+
description: (err as Error).message,
79+
variant: 'destructive',
80+
});
81+
}
82+
};
4683

4784
const handleRotate = async () => {
4885
if (!env) return;
@@ -100,20 +137,32 @@ function EnvironmentOverviewComponent() {
100137
{env.isDefault && (
101138
<Badge variant="outline">default</Badge>
102139
)}
103-
<Badge variant="secondary">{env.status}</Badge>
140+
<EnvironmentStatusBadge status={env.status} />
104141
</div>
105142
<p className="mt-1 font-mono text-xs text-muted-foreground">
106143
{env.id}
107144
</p>
108145
</div>
109146
<div className="flex gap-2">
147+
<Button
148+
variant="outline"
149+
size="sm"
150+
onClick={() => reload()}
151+
disabled={loading}
152+
className="gap-2"
153+
title="Refresh environment status"
154+
>
155+
<RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
156+
Refresh
157+
</Button>
110158
<Button
111159
variant="outline"
112160
className="gap-2"
113161
onClick={() => navigate({
114162
to: '/environments/$environmentId/packages',
115163
params: { environmentId: env.id },
116164
})}
165+
disabled={env.status !== 'active'}
117166
>
118167
<Package className="h-4 w-4" />
119168
Packages
@@ -127,7 +176,56 @@ function EnvironmentOverviewComponent() {
127176
</div>
128177
</div>
129178

130-
{env.envType === 'production' && (
179+
{env.status === 'provisioning' && (
180+
<Card className="flex items-start gap-3 border-sky-500/40 bg-sky-500/5 p-4">
181+
<Loader2 className="mt-0.5 h-5 w-5 shrink-0 animate-spin text-sky-600" />
182+
<div className="flex-1 text-sm">
183+
<p className="font-medium text-sky-700 dark:text-sky-300">
184+
Provisioning in progress
185+
</p>
186+
<p className="text-muted-foreground">
187+
We&rsquo;re allocating the physical database and
188+
minting credentials for this environment. This
189+
normally takes a few seconds — click Refresh to
190+
check the latest status.
191+
</p>
192+
</div>
193+
</Card>
194+
)}
195+
196+
{env.status === 'failed' && (
197+
<Card className="flex items-start gap-3 border-red-500/40 bg-red-500/5 p-4">
198+
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-600" />
199+
<div className="flex-1 text-sm">
200+
<p className="font-medium text-red-700 dark:text-red-300">
201+
Provisioning failed
202+
</p>
203+
<p className="text-muted-foreground">
204+
{provisioningError?.message ??
205+
'The environment could not be provisioned. Retry to run the driver handshake again.'}
206+
</p>
207+
{provisioningError?.failedAt && (
208+
<p className="mt-1 text-xs text-muted-foreground">
209+
Last attempt: {new Date(provisioningError.failedAt).toLocaleString()}
210+
</p>
211+
)}
212+
<div className="mt-3">
213+
<Button
214+
size="sm"
215+
variant="destructive"
216+
onClick={handleRetry}
217+
disabled={retrying}
218+
className="gap-2"
219+
>
220+
<RotateCw className={`h-3.5 w-3.5 ${retrying ? 'animate-spin' : ''}`} />
221+
{retrying ? 'Retrying…' : 'Retry provisioning'}
222+
</Button>
223+
</div>
224+
</div>
225+
</Card>
226+
)}
227+
228+
{env.envType === 'production' && env.status === 'active' && (
131229
<Card className="flex items-start gap-3 border-red-500/40 bg-red-500/5 p-4">
132230
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-600" />
133231
<div className="text-sm">
@@ -193,7 +291,7 @@ function EnvironmentOverviewComponent() {
193291
size="sm"
194292
variant="outline"
195293
onClick={handleRotate}
196-
disabled={rotating}
294+
disabled={rotating || env.status !== 'active'}
197295
className="gap-2"
198296
>
199297
<RefreshCw

apps/studio/src/routes/environments.index.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@
1414

1515
import { createFileRoute, useNavigate } from '@tanstack/react-router';
1616
import { useState } from 'react';
17-
import { Plus, Database, MapPin } from 'lucide-react';
17+
import { Plus, Database, MapPin, RefreshCw } from 'lucide-react';
1818
import { SiteHeader } from '@/components/site-header';
1919
import { Button } from '@/components/ui/button';
2020
import { Card } from '@/components/ui/card';
2121
import { Badge } from '@/components/ui/badge';
2222
import { EnvironmentBadge } from '@/components/environment-badge';
23+
import { EnvironmentStatusBadge } from '@/components/environment-status-badge';
2324
import { NewEnvironmentDialog } from '@/components/new-environment-dialog';
2425
import { useEnvironments } from '@/hooks/useEnvironments';
2526

@@ -48,6 +49,20 @@ function EnvironmentsListComponent() {
4849
</Button>
4950
</div>
5051

52+
<div className="mb-3 flex items-center justify-end">
53+
<Button
54+
variant="outline"
55+
size="sm"
56+
onClick={() => reload()}
57+
disabled={loading}
58+
className="gap-2"
59+
title="Refresh the environment list (does not auto-poll)"
60+
>
61+
<RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
62+
Refresh
63+
</Button>
64+
</div>
65+
5166
{loading && (
5267
<div className="text-sm text-muted-foreground">Loading…</div>
5368
)}
@@ -92,9 +107,7 @@ function EnvironmentsListComponent() {
92107
default
93108
</Badge>
94109
)}
95-
<Badge variant="secondary" className="text-[10px]">
96-
{env.status}
97-
</Badge>
110+
<EnvironmentStatusBadge status={env.status} />
98111
</div>
99112
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
100113
<code className="font-mono">{env.slug}</code>

0 commit comments

Comments
 (0)