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

@@ -20,6 +20,7 @@ import net.siegeln.cameleer.saas.tenant.TenantStatus;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -110,23 +111,41 @@ public class VendorTenantService {
// 3. Generate license
LicenseEntity license = licenseService.generateLicense(tenant, DEFAULT_LICENSE_VALIDITY, actorId);
// 4. Provision server if provisioner is available
auditService.log(actorId, null, tenant.getId(),
AuditAction.TENANT_CREATE, "provision:" + tenant.getSlug(),
null, null, "SUCCESS", null);
// 4. Provision server asynchronously (Docker containers, health check, config push)
if (tenantProvisioner.isAvailable()) {
var provisionRequest = new TenantProvisionRequest(
tenant.getId(), tenant.getSlug(),
tenant.getTier().name(), license.getToken());
provisionAsync(tenant.getId(), tenant.getSlug(), tenant.getTier().name(), license.getToken(), actorId);
}
return tenant;
}
@Async
public void provisionAsync(UUID tenantId, String slug, String tier, String licenseToken, UUID actorId) {
try {
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken);
ProvisionResult result = tenantProvisioner.provision(provisionRequest);
TenantEntity tenant = tenantRepository.findById(tenantId).orElse(null);
if (tenant == null) {
log.error("Tenant {} disappeared during provisioning", slug);
return;
}
if (result.success()) {
tenant.setServerEndpoint(result.serverEndpoint());
tenant.setProvisionError(null);
tenant.setStatus(TenantStatus.ACTIVE);
tenant = tenantRepository.save(tenant);
tenantRepository.save(tenant);
// Push license to newly provisioned server
try {
serverApiClient.pushLicense(result.serverEndpoint(), license.getToken());
serverApiClient.pushLicense(result.serverEndpoint(), licenseToken);
} catch (Exception e) {
log.warn("License push failed for tenant {}: {}", tenant.getSlug(), e.getMessage());
log.warn("License push failed for tenant {}: {}", slug, e.getMessage());
}
// Configure OIDC on the provisioned server (SSO via Logto)
@@ -144,24 +163,25 @@ public class VendorTenantService {
"rolesClaim", "roles",
"audience", "https://api.cameleer.local"
));
log.info("Pushed OIDC config to server for tenant {}", tenant.getSlug());
log.info("Pushed OIDC config to server for tenant {}", slug);
} catch (Exception e) {
log.warn("OIDC config push failed for tenant {}: {}", tenant.getSlug(), e.getMessage());
log.warn("OIDC config push failed for tenant {}: {}", slug, e.getMessage());
}
}
log.info("Tenant {} provisioned successfully", slug);
} else {
tenant.setProvisionError(result.error());
tenant.setStatus(TenantStatus.PROVISIONING);
tenant = tenantRepository.save(tenant);
log.error("Provisioning failed for tenant {}: {}", tenant.getSlug(), result.error());
tenantRepository.save(tenant);
log.error("Provisioning failed for tenant {}: {}", slug, result.error());
}
} catch (Exception e) {
log.error("Unexpected error during async provisioning of tenant {}: {}", slug, e.getMessage(), e);
tenantRepository.findById(tenantId).ifPresent(t -> {
t.setProvisionError(e.getMessage());
tenantRepository.save(t);
});
}
auditService.log(actorId, null, tenant.getId(),
AuditAction.TENANT_CREATE, "provision:" + tenant.getSlug(),
null, null, "SUCCESS", null);
return tenant;
}
public List<TenantEntity> listAll() {

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