diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java index 4a8a1d5..6c04dea 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java @@ -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 listAll() { diff --git a/ui/src/api/vendor-hooks.ts b/ui/src/api/vendor-hooks.ts index 7ae09a7..6323a97 100644 --- a/ui/src/api/vendor-hooks.ts +++ b/ui/src/api/vendor-hooks.ts @@ -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({ queryKey: ['vendor', 'tenants', id], queryFn: () => api.get(`/vendor/tenants/${id}`), enabled: !!id, + refetchInterval: options?.refetchInterval as any, }); } diff --git a/ui/src/pages/vendor/CreateTenantPage.tsx b/ui/src/pages/vendor/CreateTenantPage.tsx index 5c1bad7..2c8bcac 100644 --- a/ui/src/pages/vendor/CreateTenantPage.tsx +++ b/ui/src/pages/vendor/CreateTenantPage.tsx @@ -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' }); diff --git a/ui/src/pages/vendor/TenantDetailPage.tsx b/ui/src/pages/vendor/TenantDetailPage.tsx index bf0e9ed..4bc95df 100644 --- a/ui/src/pages/vendor/TenantDetailPage.tsx +++ b/ui/src/pages/vendor/TenantDetailPage.tsx @@ -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() {

{tenant.name}

+ {currentStatus === 'PROVISIONING' && ( + + Provisioning... + + )} {/* KPI Strip */}