feat: async tenant provisioning with polling UX
Some checks failed
CI / build (push) Failing after 39s
CI / docker (push) Has been skipped

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:
hsiegeln
2026-04-10 17:14:26 +02:00
parent 269c679e9c
commit 252c18bcff
4 changed files with 72 additions and 22 deletions

View File

@@ -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,
});
}

View File

@@ -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' });

View File

@@ -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 */}