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) <noreply@anthropic.com>
This commit is contained in:
@@ -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());
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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>({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
20
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
20
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user