import { useState, useMemo, useEffect } from 'react'; import { Avatar, Badge, Button, Input, MonoText, SectionHeader, Tag, Toggle, InlineEdit, ConfirmDialog, SplitPane, EntityList, Spinner, useToast, } from '@cameleer/design-system'; import { useEnvironments, useCreateEnvironment, useUpdateEnvironment, useDeleteEnvironment, useUpdateDefaultContainerConfig, useUpdateJarRetention, } from '../../api/queries/admin/environments'; import type { Environment } from '../../api/queries/admin/environments'; import styles from './UserManagement.module.css'; export default function EnvironmentsPage() { const { toast } = useToast(); const { data: environments = [], isLoading } = useEnvironments(); const [search, setSearch] = useState(''); const [selectedId, setSelectedId] = useState(null); const [creating, setCreating] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); // Create form state const [newSlug, setNewSlug] = useState(''); const [newDisplayName, setNewDisplayName] = useState(''); const [newProduction, setNewProduction] = useState(false); // Mutations const createEnv = useCreateEnvironment(); const updateEnv = useUpdateEnvironment(); const deleteEnv = useDeleteEnvironment(); const updateDefaults = useUpdateDefaultContainerConfig(); const updateRetention = useUpdateJarRetention(); const selected = useMemo( () => environments.find((e) => e.id === selectedId) ?? null, [environments, selectedId], ); const filtered = useMemo(() => { if (!search) return environments; const q = search.toLowerCase(); return environments.filter( (e) => e.slug.toLowerCase().includes(q) || e.displayName.toLowerCase().includes(q), ); }, [environments, search]); const isDefault = selected?.slug === 'default'; const duplicateSlug = newSlug.trim() !== '' && environments.some((e) => e.slug.toLowerCase() === newSlug.trim().toLowerCase()); async function handleCreate() { if (!newSlug.trim() || !newDisplayName.trim()) return; try { await createEnv.mutateAsync({ slug: newSlug.trim(), displayName: newDisplayName.trim(), production: newProduction, }); toast({ title: 'Environment created', description: newSlug.trim(), variant: 'success' }); setCreating(false); setNewSlug(''); setNewDisplayName(''); setNewProduction(false); } catch { toast({ title: 'Failed to create environment', variant: 'error', duration: 86_400_000 }); } } async function handleDelete() { if (!deleteTarget) return; try { await deleteEnv.mutateAsync(deleteTarget.id); toast({ title: 'Environment deleted', description: deleteTarget.slug, variant: 'warning' }); if (selectedId === deleteTarget.id) setSelectedId(null); setDeleteTarget(null); } catch { toast({ title: 'Failed to delete environment', variant: 'error', duration: 86_400_000 }); setDeleteTarget(null); } } async function handleRename(newName: string) { if (!selected) return; try { await updateEnv.mutateAsync({ id: selected.id, displayName: newName, production: selected.production, enabled: selected.enabled, }); toast({ title: 'Environment renamed', variant: 'success' }); } catch { toast({ title: 'Failed to rename', variant: 'error', duration: 86_400_000 }); } } async function handleToggleProduction(value: boolean) { if (!selected) return; try { await updateEnv.mutateAsync({ id: selected.id, displayName: selected.displayName, production: value, enabled: selected.enabled, }); toast({ title: value ? 'Marked as production' : 'Marked as non-production', variant: 'success' }); } catch { toast({ title: 'Failed to update', variant: 'error', duration: 86_400_000 }); } } async function handleToggleEnabled(value: boolean) { if (!selected) return; try { await updateEnv.mutateAsync({ id: selected.id, displayName: selected.displayName, production: selected.production, enabled: value, }); toast({ title: value ? 'Environment enabled' : 'Environment disabled', variant: 'success' }); } catch { toast({ title: 'Failed to update', variant: 'error', duration: 86_400_000 }); } } if (isLoading) return ; return ( <> {creating && (
setNewSlug(e.target.value)} /> {duplicateSlug && ( Slug already exists )} setNewDisplayName(e.target.value)} />
)} ( <>
{env.displayName}
{env.slug}
{env.production && } {!env.production && } {!env.enabled && }
)} getItemId={(env) => env.id} selectedId={selectedId ?? undefined} onSelect={setSelectedId} searchPlaceholder="Search environments..." onSearch={setSearch} addLabel="+ Add environment" onAdd={() => setCreating(true)} emptyMessage="No environments match your search" /> } detail={ selected ? ( <>
{isDefault ? ( selected.displayName ) : ( )}
{selected.slug} {isDefault && ' (built-in)'}
ID {selected.id} Slug {selected.slug} Created {new Date(selected.createdAt).toLocaleDateString()}
Configuration
handleToggleProduction(!selected.production)} /> Production environment {selected.production ? ( ) : ( )}
Status
handleToggleEnabled(!selected.enabled)} /> {selected.enabled ? 'Enabled' : 'Disabled'} {!selected.enabled && ( )}
{!selected.enabled && (

Disabled environments do not allow new deployments. Active deployments can only be started, stopped, or deleted.

)}
{ try { await updateDefaults.mutateAsync({ id: selected.id, config }); toast({ title: 'Default resources updated', variant: 'success' }); } catch { toast({ title: 'Failed to update defaults', variant: 'error', duration: 86_400_000 }); } }} saving={updateDefaults.isPending} /> { try { await updateRetention.mutateAsync({ id: selected.id, jarRetentionCount: count }); toast({ title: 'Retention policy updated', variant: 'success' }); } catch { toast({ title: 'Failed to update retention', variant: 'error', duration: 86_400_000 }); } }} saving={updateRetention.isPending} /> ) : null } emptyMessage="Select an environment to view details" /> setDeleteTarget(null)} onConfirm={handleDelete} message={`Delete environment "${deleteTarget?.displayName}"? All apps and deployments in this environment will be removed. This cannot be undone.`} confirmText={deleteTarget?.slug ?? ''} loading={deleteEnv.isPending} /> ); } // ── Default Resource Limits ───────────────────────────────────────── function DefaultResourcesSection({ environment, onSave, saving }: { environment: Environment; onSave: (config: Record) => Promise; saving: boolean; }) { const defaults = environment.defaultContainerConfig ?? {}; const [editing, setEditing] = useState(false); const [memoryLimit, setMemoryLimit] = useState(''); const [memoryReserve, setMemoryReserve] = useState(''); const [cpuShares, setCpuShares] = useState(''); const [cpuLimit, setCpuLimit] = useState(''); useEffect(() => { setMemoryLimit(String(defaults.memoryLimitMb ?? '')); setMemoryReserve(String(defaults.memoryReserveMb ?? '')); setCpuShares(String(defaults.cpuShares ?? '')); setCpuLimit(String(defaults.cpuLimit ?? '')); setEditing(false); }, [environment.id]); function handleCancel() { setMemoryLimit(String(defaults.memoryLimitMb ?? '')); setMemoryReserve(String(defaults.memoryReserveMb ?? '')); setCpuShares(String(defaults.cpuShares ?? '')); setCpuLimit(String(defaults.cpuLimit ?? '')); setEditing(false); } async function handleSave() { await onSave({ memoryLimitMb: memoryLimit ? parseInt(memoryLimit) : null, memoryReserveMb: memoryReserve ? parseInt(memoryReserve) : null, cpuShares: cpuShares ? parseInt(cpuShares) : null, cpuLimit: cpuLimit ? parseFloat(cpuLimit) : null, }); setEditing(false); } return ( <> Default Resource Limits

These defaults apply to new apps in this environment unless overridden per-app.

Memory Limit {editing ? setMemoryLimit(e.target.value)} placeholder="e.g. 512" style={{ width: 100 }} /> : {defaults.memoryLimitMb ? `${defaults.memoryLimitMb} MB` : '—'}} Memory Reserve {editing ? setMemoryReserve(e.target.value)} placeholder="e.g. 256" style={{ width: 100 }} /> : {defaults.memoryReserveMb ? `${defaults.memoryReserveMb} MB` : '—'}} CPU Shares {editing ? setCpuShares(e.target.value)} placeholder="e.g. 512" style={{ width: 100 }} /> : {String(defaults.cpuShares ?? '—')}} CPU Limit {editing ? setCpuLimit(e.target.value)} placeholder="e.g. 1.0" style={{ width: 100 }} /> : {defaults.cpuLimit ? `${defaults.cpuLimit} cores` : '—'}}
{editing ? ( <> ) : ( )}
); } // ── JAR Retention Policy ──────────────────────────────────────────── function JarRetentionSection({ environment, onSave, saving }: { environment: Environment; onSave: (count: number | null) => Promise; saving: boolean; }) { const current = environment.jarRetentionCount; const [editing, setEditing] = useState(false); const [unlimited, setUnlimited] = useState(current === null); const [count, setCount] = useState(String(current ?? 5)); useEffect(() => { setUnlimited(environment.jarRetentionCount === null); setCount(String(environment.jarRetentionCount ?? 5)); setEditing(false); }, [environment.id]); function handleCancel() { setUnlimited(current === null); setCount(String(current ?? 5)); setEditing(false); } async function handleSave() { await onSave(unlimited ? null : Math.max(1, parseInt(count) || 5)); setEditing(false); } return ( <> JAR Retention

Old JAR versions are cleaned up nightly. Currently deployed versions are never deleted.

Policy {editing ? (
setUnlimited(!unlimited)} /> {unlimited ? Keep all versions (unlimited) : <> Keep last setCount(e.target.value)} style={{ width: 60 }} /> versions }
) : ( {current === null ? 'Unlimited (no cleanup)' : `Keep last ${current} versions`} )}
{editing ? ( <> ) : ( )}
); }