Files
cameleer-saas/ui/src/pages/vendor/TenantDetailPage.tsx
hsiegeln e2e5c794a2
All checks were successful
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 48s
feat: add server upgrade action — force-pull latest images and re-provision
Restart only stops/starts existing containers with the same image. The new
upgrade action removes server + UI containers, force-pulls the latest
Docker images, then re-provisions (preserving app containers, volumes, and
networks). Available to both vendor (tenant detail) and tenant admin
(dashboard).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:45:45 +02:00

323 lines
11 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router';
import {
AlertDialog,
Badge,
Button,
Card,
KpiStrip,
Spinner,
useToast,
} from '@cameleer/design-system';
import { ArrowLeft, ArrowUpCircle, RefreshCw, Trash2 } from 'lucide-react';
import {
useVendorTenant,
useSuspendTenant,
useActivateTenant,
useDeleteTenant,
useRenewLicense,
useRestartServer,
useUpgradeServer,
} from '../../api/vendor-hooks';
import { ServerStatusBadge } from '../../components/ServerStatusBadge';
import { tierColor } from '../../utils/tier';
import styles from '../../styles/platform.module.css';
function licenseDaysRemaining(expiresAt: string | null | undefined): number {
if (!expiresAt) return 0;
return Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000);
}
function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' {
switch (status?.toUpperCase()) {
case 'ACTIVE': return 'success';
case 'SUSPENDED': return 'warning';
case 'PROVISIONING': return 'auto';
case 'ERROR': return 'error';
default: return 'auto';
}
}
export function TenantDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { toast } = useToast();
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();
const renewLicense = useRenewLicense();
const restartServer = useRestartServer();
const upgradeServer = useUpgradeServer();
const [deleteOpen, setDeleteOpen] = useState(false);
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
<Spinner />
</div>
);
}
if (!data) {
return (
<div style={{ padding: 24 }}>
<p className={styles.description}>Tenant not found.</p>
</div>
);
}
const { tenant, serverState, license } = data;
const daysRemaining = licenseDaysRemaining(license?.expiresAt);
const isSuspended = tenant.status?.toUpperCase() === 'SUSPENDED';
async function handleSuspendToggle() {
if (!id) return;
try {
if (isSuspended) {
await activateTenant.mutateAsync(id);
toast({ title: 'Tenant activated', variant: 'success' });
} else {
await suspendTenant.mutateAsync(id);
toast({ title: 'Tenant suspended', variant: 'warning' });
}
} catch (err) {
toast({ title: 'Action failed', description: String(err), variant: 'error' });
}
}
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 handleUpgrade() {
if (!id) return;
try {
await upgradeServer.mutateAsync(id);
toast({ title: 'Server upgrade started — pulling latest images', variant: 'success' });
} catch (err) {
toast({ title: 'Upgrade failed', description: String(err), variant: 'error' });
}
}
async function handleDelete() {
if (!id) return;
try {
await deleteTenant.mutateAsync(id);
toast({ title: 'Tenant deleted', variant: 'success' });
navigate('/vendor/tenants');
} catch (err) {
toast({ title: 'Delete failed', description: String(err), variant: 'error' });
}
}
async function handleRenewLicense() {
if (!id) return;
try {
await renewLicense.mutateAsync(id);
toast({ title: 'License renewed', variant: 'success' });
} catch (err) {
toast({ title: 'Renewal failed', description: String(err), variant: 'error' });
}
}
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button variant="ghost" onClick={() => navigate('/vendor/tenants')} style={{ padding: '4px 8px' }}>
<ArrowLeft size={16} />
</Button>
<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 */}
<KpiStrip
items={[
{ label: 'Server', value: serverState },
{ label: 'Tier', value: tenant.tier },
{ label: 'Status', value: tenant.status },
{ label: 'License days', value: license ? `${daysRemaining}d` : 'None' },
]}
/>
{/* Cards grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16 }}>
{/* Server Info */}
<Card title="Server">
<div className={styles.dividerList}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>State</span>
<ServerStatusBadge state={serverState} />
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Endpoint</span>
<span className={styles.kvValueMono}>{tenant.serverEndpoint ?? '—'}</span>
</div>
{tenant.provisionError && (
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Error</span>
<span className={styles.kvValue} style={{ color: 'var(--error)', maxWidth: 200, textAlign: 'right' }}>
{tenant.provisionError}
</span>
</div>
)}
</div>
</Card>
{/* License Info */}
<Card title="License">
{license ? (
<div className={styles.dividerList}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Tier</span>
<Badge label={license.tier} color={tierColor(license.tier)} />
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Expires</span>
<span className={styles.kvValue}>
{new Date(license.expiresAt).toLocaleDateString()}
</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Days remaining</span>
<span className={styles.kvValue} style={{ color: daysRemaining <= 30 ? 'var(--warning)' : undefined }}>
{daysRemaining}d
</span>
</div>
<div style={{ paddingTop: 8 }}>
<Button
variant="secondary"
onClick={handleRenewLicense}
loading={renewLicense.isPending}
>
<RefreshCw size={14} style={{ marginRight: 6 }} />
Renew License
</Button>
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<p className={styles.description}>No license issued.</p>
<Button
variant="secondary"
onClick={handleRenewLicense}
loading={renewLicense.isPending}
>
Issue License
</Button>
</div>
)}
</Card>
{/* Tenant Info */}
<Card title="Tenant Info">
<div className={styles.dividerList}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>ID</span>
<span className={styles.kvValueMono}>{tenant.id}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Slug</span>
<span className={styles.kvValueMono}>{tenant.slug}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Created</span>
<span className={styles.kvValue}>{new Date(tenant.createdAt).toLocaleDateString()}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Updated</span>
<span className={styles.kvValue}>{new Date(tenant.updatedAt).toLocaleDateString()}</span>
</div>
</div>
</Card>
{/* Actions */}
<Card title="Actions">
<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
variant="secondary"
onClick={handleUpgrade}
loading={upgradeServer.isPending}
>
<ArrowUpCircle size={14} style={{ marginRight: 6 }} />
Upgrade Server
</Button>
<Button
variant="secondary"
onClick={handleSuspendToggle}
loading={suspendTenant.isPending || activateTenant.isPending}
>
{isSuspended ? 'Activate Tenant' : 'Suspend Tenant'}
</Button>
<Button
variant="danger"
onClick={() => setDeleteOpen(true)}
>
<Trash2 size={14} style={{ marginRight: 6 }} />
Delete Tenant
</Button>
</div>
</Card>
</div>
{/* Delete confirmation dialog */}
<AlertDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDelete}
title="Delete Tenant"
description={`Are you sure you want to delete "${tenant.name}"? This action cannot be undone.`}
confirmLabel="Delete"
cancelLabel="Cancel"
variant="danger"
loading={deleteTenant.isPending}
/>
</div>
);
}