feat: async tenant provisioning with polling UX
Backend: extract Docker provisioning into @Async method so the API returns immediately with status=PROVISIONING. The tenant record, Logto org, admin user, and license are created synchronously; container provisioning, health check, license push, and OIDC config happen in a background thread. Frontend: navigate to tenant detail page immediately after creation. Detail page polls every 3s while status=PROVISIONING and shows a spinner indicator. Toast notification when provisioning completes. Fixes #52. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,11 +10,15 @@ export function useVendorTenants() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useVendorTenant(id: string | null) {
|
||||
export function useVendorTenant(
|
||||
id: string | null,
|
||||
options?: { refetchInterval?: number | false | ((query: { state: { data: VendorTenantDetail | undefined } }) => number | false) },
|
||||
) {
|
||||
return useQuery<VendorTenantDetail>({
|
||||
queryKey: ['vendor', 'tenants', id],
|
||||
queryFn: () => api.get(`/vendor/tenants/${id}`),
|
||||
enabled: !!id,
|
||||
refetchInterval: options?.refetchInterval as any,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
2
ui/src/pages/vendor/CreateTenantPage.tsx
vendored
2
ui/src/pages/vendor/CreateTenantPage.tsx
vendored
@@ -32,7 +32,7 @@ export function CreateTenantPage() {
|
||||
adminUsername: adminUsername || undefined,
|
||||
adminPassword: adminPassword || undefined,
|
||||
});
|
||||
toast({ title: 'Tenant created', variant: 'success' });
|
||||
toast({ title: 'Tenant created — provisioning in progress', variant: 'success' });
|
||||
navigate(`/vendor/tenants/${result.id}`);
|
||||
} catch (err) {
|
||||
toast({ title: 'Failed to create tenant', description: String(err), variant: 'error' });
|
||||
|
||||
30
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
30
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -41,7 +41,28 @@ export function TenantDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data, isLoading } = useVendorTenant(id ?? null);
|
||||
const wasProvisioning = useRef(false);
|
||||
const { data, isLoading } = useVendorTenant(id ?? null, {
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.tenant?.status?.toUpperCase();
|
||||
return status === 'PROVISIONING' ? 3_000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
// Toast when provisioning completes
|
||||
const currentStatus = data?.tenant?.status?.toUpperCase();
|
||||
useEffect(() => {
|
||||
if (currentStatus === 'PROVISIONING') {
|
||||
wasProvisioning.current = true;
|
||||
} else if (wasProvisioning.current && currentStatus) {
|
||||
wasProvisioning.current = false;
|
||||
toast({
|
||||
title: currentStatus === 'ACTIVE' ? 'Tenant provisioned' : `Tenant status: ${currentStatus}`,
|
||||
variant: currentStatus === 'ACTIVE' ? 'success' : 'warning',
|
||||
});
|
||||
}
|
||||
}, [currentStatus, toast]);
|
||||
|
||||
const suspendTenant = useSuspendTenant();
|
||||
const activateTenant = useActivateTenant();
|
||||
const deleteTenant = useDeleteTenant();
|
||||
@@ -115,6 +136,11 @@ export function TenantDetailPage() {
|
||||
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>{tenant.name}</h1>
|
||||
<Badge label={tenant.tier} color={tierColor(tenant.tier)} />
|
||||
<Badge label={tenant.status} color={statusColor(tenant.status)} />
|
||||
{currentStatus === 'PROVISIONING' && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
<Spinner size="sm" /> Provisioning...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* KPI Strip */}
|
||||
|
||||
Reference in New Issue
Block a user