feat: add restart server action for vendor and tenant
Some checks failed
CI / build (push) Failing after 36s
CI / docker (push) Has been skipped

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-10 17:21:14 +02:00
parent 252c18bcff
commit 7e7a07470b
8 changed files with 96 additions and 3 deletions

View File

@@ -70,6 +70,12 @@ public class TenantPortalController {
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
@PostMapping("/server/restart")
public ResponseEntity<Void> restartServer() {
portalService.restartServer();
return ResponseEntity.ok().build();
}
@GetMapping("/settings") @GetMapping("/settings")
public ResponseEntity<TenantPortalService.TenantSettingsData> getSettings() { public ResponseEntity<TenantPortalService.TenantSettingsData> getSettings() {
return ResponseEntity.ok(portalService.getSettings()); return ResponseEntity.ok(portalService.getSettings());

View File

@@ -5,6 +5,7 @@ import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.identity.ServerApiClient; import net.siegeln.cameleer.saas.identity.ServerApiClient;
import net.siegeln.cameleer.saas.license.LicenseEntity; import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseService; 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.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantService; import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -22,15 +23,18 @@ public class TenantPortalService {
private final LicenseService licenseService; private final LicenseService licenseService;
private final ServerApiClient serverApiClient; private final ServerApiClient serverApiClient;
private final LogtoManagementClient logtoClient; private final LogtoManagementClient logtoClient;
private final TenantProvisioner tenantProvisioner;
public TenantPortalService(TenantService tenantService, public TenantPortalService(TenantService tenantService,
LicenseService licenseService, LicenseService licenseService,
ServerApiClient serverApiClient, ServerApiClient serverApiClient,
LogtoManagementClient logtoClient) { LogtoManagementClient logtoClient,
TenantProvisioner tenantProvisioner) {
this.tenantService = tenantService; this.tenantService = tenantService;
this.licenseService = licenseService; this.licenseService = licenseService;
this.serverApiClient = serverApiClient; this.serverApiClient = serverApiClient;
this.logtoClient = logtoClient; this.logtoClient = logtoClient;
this.tenantProvisioner = tenantProvisioner;
} }
// --- Inner records --- // --- Inner records ---
@@ -160,4 +164,12 @@ public class TenantPortalService {
tenant.getServerEndpoint(), tenant.getCreatedAt() tenant.getServerEndpoint(), tenant.getCreatedAt()
); );
} }
public void restartServer() {
TenantEntity tenant = resolveTenant();
if (tenantProvisioner.isAvailable()) {
tenantProvisioner.stop(tenant.getSlug());
tenantProvisioner.start(tenant.getSlug());
}
}
} }

View File

@@ -109,6 +109,16 @@ public class VendorTenantController {
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
} }
@PostMapping("/{id}/restart")
public ResponseEntity<Void> restart(@PathVariable UUID id) {
try {
vendorTenantService.restartServer(id);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/{id}/suspend") @PostMapping("/{id}/suspend")
public ResponseEntity<TenantResponse> suspend(@PathVariable UUID id, public ResponseEntity<TenantResponse> suspend(@PathVariable UUID id,
@AuthenticationPrincipal Jwt jwt) { @AuthenticationPrincipal Jwt jwt) {

View File

@@ -213,6 +213,15 @@ public class VendorTenantService {
return serverApiClient.getHealth(endpoint); 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 @Transactional
public TenantEntity suspend(UUID tenantId, UUID actorId) { public TenantEntity suspend(UUID tenantId, UUID actorId) {
TenantEntity tenant = tenantService.getById(tenantId) TenantEntity tenant = tenantService.getById(tenantId)

View File

@@ -10,6 +10,14 @@ export function useTenantDashboard() {
}); });
} }
export function useRestartServer() {
const qc = useQueryClient();
return useMutation<void, Error, void>({
mutationFn: () => api.post('/tenant/server/restart'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'dashboard'] }),
});
}
export function useTenantLicense() { export function useTenantLicense() {
return useQuery<TenantLicenseData>({ return useQuery<TenantLicenseData>({
queryKey: ['tenant', 'license'], queryKey: ['tenant', 'license'],

View File

@@ -54,6 +54,14 @@ export function useDeleteTenant() {
}); });
} }
export function useRestartServer() {
const qc = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (id) => api.post(`/vendor/tenants/${id}/restart`),
onSuccess: (_, id) => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', id] }),
});
}
export function useRenewLicense() { export function useRenewLicense() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation<LicenseResponse, Error, string>({ return useMutation<LicenseResponse, Error, string>({

View File

@@ -6,9 +6,10 @@ import {
Card, Card,
KpiStrip, KpiStrip,
Spinner, Spinner,
useToast,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import { ExternalLink, Key, Settings } from 'lucide-react'; import { ExternalLink, Key, RefreshCw, Settings } from 'lucide-react';
import { useTenantDashboard } from '../../api/tenant-hooks'; import { useTenantDashboard, useRestartServer } from '../../api/tenant-hooks';
import { ServerStatusBadge } from '../../components/ServerStatusBadge'; import { ServerStatusBadge } from '../../components/ServerStatusBadge';
import { UsageIndicator } from '../../components/UsageIndicator'; import { UsageIndicator } from '../../components/UsageIndicator';
import { tierColor } from '../../utils/tier'; import { tierColor } from '../../utils/tier';
@@ -26,7 +27,9 @@ function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' {
export function TenantDashboardPage() { export function TenantDashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast();
const { data, isLoading, isError } = useTenantDashboard(); const { data, isLoading, isError } = useTenantDashboard();
const restartServer = useRestartServer();
if (isLoading) { if (isLoading) {
return ( return (
@@ -96,6 +99,23 @@ export function TenantDashboardPage() {
<span className={styles.kvValueMono}>{data.serverEndpoint}</span> <span className={styles.kvValueMono}>{data.serverEndpoint}</span>
</div> </div>
)} )}
<div style={{ paddingTop: 8 }}>
<Button
variant="secondary"
onClick={async () => {
try {
await restartServer.mutateAsync();
toast({ title: 'Server restarted', variant: 'success' });
} catch (err) {
toast({ title: 'Restart failed', description: String(err), variant: 'error' });
}
}}
loading={restartServer.isPending}
>
<RefreshCw size={14} style={{ marginRight: 6 }} />
Restart Server
</Button>
</div>
</div> </div>
</Card> </Card>

View File

@@ -16,6 +16,7 @@ import {
useActivateTenant, useActivateTenant,
useDeleteTenant, useDeleteTenant,
useRenewLicense, useRenewLicense,
useRestartServer,
} from '../../api/vendor-hooks'; } from '../../api/vendor-hooks';
import { ServerStatusBadge } from '../../components/ServerStatusBadge'; import { ServerStatusBadge } from '../../components/ServerStatusBadge';
import { tierColor } from '../../utils/tier'; import { tierColor } from '../../utils/tier';
@@ -67,6 +68,7 @@ export function TenantDetailPage() {
const activateTenant = useActivateTenant(); const activateTenant = useActivateTenant();
const deleteTenant = useDeleteTenant(); const deleteTenant = useDeleteTenant();
const renewLicense = useRenewLicense(); const renewLicense = useRenewLicense();
const restartServer = useRestartServer();
const [deleteOpen, setDeleteOpen] = useState(false); 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() { async function handleDelete() {
if (!id) return; if (!id) return;
try { try {
@@ -247,6 +259,14 @@ export function TenantDetailPage() {
{/* Actions */} {/* Actions */}
<Card title="Actions"> <Card title="Actions">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Button
variant="secondary"
onClick={handleRestart}
loading={restartServer.isPending}
>
<RefreshCw size={14} style={{ marginRight: 6 }} />
Restart Server
</Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={handleSuspendToggle} onClick={handleSuspendToggle}