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:
@@ -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() {
|
||||
|
||||
@@ -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