From 7e7a07470bac45ffcbe4e5ae850b25dd2872ee25 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:21:14 +0200 Subject: [PATCH] feat: add restart server action for vendor and tenant Vendor: POST /api/vendor/tenants/{id}/restart (platform:admin scope) Tenant: POST /api/tenant/server/restart (tenant:manage scope) Both call TenantProvisioner.stop() then start() on the server + UI containers. Restart button on vendor TenantDetailPage (Actions card) and tenant TenantDashboardPage (Server card). Allowed in any status including PROVISIONING. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../saas/portal/TenantPortalController.java | 6 +++++ .../saas/portal/TenantPortalService.java | 14 ++++++++++- .../saas/vendor/VendorTenantController.java | 10 ++++++++ .../saas/vendor/VendorTenantService.java | 9 +++++++ ui/src/api/tenant-hooks.ts | 8 +++++++ ui/src/api/vendor-hooks.ts | 8 +++++++ ui/src/pages/tenant/TenantDashboardPage.tsx | 24 +++++++++++++++++-- ui/src/pages/vendor/TenantDetailPage.tsx | 20 ++++++++++++++++ 8 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java index fb85e3f..21adb40 100644 --- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java @@ -70,6 +70,12 @@ public class TenantPortalController { return ResponseEntity.ok().build(); } + @PostMapping("/server/restart") + public ResponseEntity restartServer() { + portalService.restartServer(); + return ResponseEntity.ok().build(); + } + @GetMapping("/settings") public ResponseEntity getSettings() { return ResponseEntity.ok(portalService.getSettings()); diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java index 0518d6e..39cdf43 100644 --- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java @@ -5,6 +5,7 @@ import net.siegeln.cameleer.saas.identity.LogtoManagementClient; import net.siegeln.cameleer.saas.identity.ServerApiClient; import net.siegeln.cameleer.saas.license.LicenseEntity; import net.siegeln.cameleer.saas.license.LicenseService; +import net.siegeln.cameleer.saas.provisioning.TenantProvisioner; import net.siegeln.cameleer.saas.tenant.TenantEntity; import net.siegeln.cameleer.saas.tenant.TenantService; import org.springframework.stereotype.Service; @@ -22,15 +23,18 @@ public class TenantPortalService { private final LicenseService licenseService; private final ServerApiClient serverApiClient; private final LogtoManagementClient logtoClient; + private final TenantProvisioner tenantProvisioner; public TenantPortalService(TenantService tenantService, LicenseService licenseService, ServerApiClient serverApiClient, - LogtoManagementClient logtoClient) { + LogtoManagementClient logtoClient, + TenantProvisioner tenantProvisioner) { this.tenantService = tenantService; this.licenseService = licenseService; this.serverApiClient = serverApiClient; this.logtoClient = logtoClient; + this.tenantProvisioner = tenantProvisioner; } // --- Inner records --- @@ -160,4 +164,12 @@ public class TenantPortalService { tenant.getServerEndpoint(), tenant.getCreatedAt() ); } + + public void restartServer() { + TenantEntity tenant = resolveTenant(); + if (tenantProvisioner.isAvailable()) { + tenantProvisioner.stop(tenant.getSlug()); + tenantProvisioner.start(tenant.getSlug()); + } + } } diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java index f2d55c6..9850737 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java @@ -109,6 +109,16 @@ public class VendorTenantController { .orElse(ResponseEntity.notFound().build()); } + @PostMapping("/{id}/restart") + public ResponseEntity restart(@PathVariable UUID id) { + try { + vendorTenantService.restartServer(id); + return ResponseEntity.ok().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + @PostMapping("/{id}/suspend") public ResponseEntity suspend(@PathVariable UUID id, @AuthenticationPrincipal Jwt jwt) { 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 6c04dea..55bde86 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java @@ -213,6 +213,15 @@ public class VendorTenantService { return serverApiClient.getHealth(endpoint); } + public void restartServer(UUID tenantId) { + TenantEntity tenant = tenantService.getById(tenantId) + .orElseThrow(() -> new IllegalArgumentException("Tenant not found")); + if (tenantProvisioner.isAvailable()) { + tenantProvisioner.stop(tenant.getSlug()); + tenantProvisioner.start(tenant.getSlug()); + } + } + @Transactional public TenantEntity suspend(UUID tenantId, UUID actorId) { TenantEntity tenant = tenantService.getById(tenantId) diff --git a/ui/src/api/tenant-hooks.ts b/ui/src/api/tenant-hooks.ts index 85b2371..a411672 100644 --- a/ui/src/api/tenant-hooks.ts +++ b/ui/src/api/tenant-hooks.ts @@ -10,6 +10,14 @@ export function useTenantDashboard() { }); } +export function useRestartServer() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post('/tenant/server/restart'), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'dashboard'] }), + }); +} + export function useTenantLicense() { return useQuery({ queryKey: ['tenant', 'license'], diff --git a/ui/src/api/vendor-hooks.ts b/ui/src/api/vendor-hooks.ts index 6323a97..4c2588a 100644 --- a/ui/src/api/vendor-hooks.ts +++ b/ui/src/api/vendor-hooks.ts @@ -54,6 +54,14 @@ export function useDeleteTenant() { }); } +export function useRestartServer() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id) => api.post(`/vendor/tenants/${id}/restart`), + onSuccess: (_, id) => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', id] }), + }); +} + export function useRenewLicense() { const qc = useQueryClient(); return useMutation({ diff --git a/ui/src/pages/tenant/TenantDashboardPage.tsx b/ui/src/pages/tenant/TenantDashboardPage.tsx index fde710a..9263377 100644 --- a/ui/src/pages/tenant/TenantDashboardPage.tsx +++ b/ui/src/pages/tenant/TenantDashboardPage.tsx @@ -6,9 +6,10 @@ import { Card, KpiStrip, Spinner, + useToast, } from '@cameleer/design-system'; -import { ExternalLink, Key, Settings } from 'lucide-react'; -import { useTenantDashboard } from '../../api/tenant-hooks'; +import { ExternalLink, Key, RefreshCw, Settings } from 'lucide-react'; +import { useTenantDashboard, useRestartServer } from '../../api/tenant-hooks'; import { ServerStatusBadge } from '../../components/ServerStatusBadge'; import { UsageIndicator } from '../../components/UsageIndicator'; import { tierColor } from '../../utils/tier'; @@ -26,7 +27,9 @@ function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' { export function TenantDashboardPage() { const navigate = useNavigate(); + const { toast } = useToast(); const { data, isLoading, isError } = useTenantDashboard(); + const restartServer = useRestartServer(); if (isLoading) { return ( @@ -96,6 +99,23 @@ export function TenantDashboardPage() { {data.serverEndpoint} )} +
+ +
diff --git a/ui/src/pages/vendor/TenantDetailPage.tsx b/ui/src/pages/vendor/TenantDetailPage.tsx index 4bc95df..c50ab85 100644 --- a/ui/src/pages/vendor/TenantDetailPage.tsx +++ b/ui/src/pages/vendor/TenantDetailPage.tsx @@ -16,6 +16,7 @@ import { useActivateTenant, useDeleteTenant, useRenewLicense, + useRestartServer, } from '../../api/vendor-hooks'; import { ServerStatusBadge } from '../../components/ServerStatusBadge'; import { tierColor } from '../../utils/tier'; @@ -67,6 +68,7 @@ export function TenantDetailPage() { const activateTenant = useActivateTenant(); const deleteTenant = useDeleteTenant(); const renewLicense = useRenewLicense(); + const restartServer = useRestartServer(); const [deleteOpen, setDeleteOpen] = useState(false); @@ -105,6 +107,16 @@ export function TenantDetailPage() { } } + async function handleRestart() { + if (!id) return; + try { + await restartServer.mutateAsync(id); + toast({ title: 'Server restarted', variant: 'success' }); + } catch (err) { + toast({ title: 'Restart failed', description: String(err), variant: 'error' }); + } + } + async function handleDelete() { if (!id) return; try { @@ -247,6 +259,14 @@ export function TenantDetailPage() { {/* Actions */}
+