Files
cameleer-server/ui/src/pages/Admin/EnvironmentsPage.tsx
hsiegeln 8b3c4ba2fe
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m26s
CI / docker (push) Successful in 1m9s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 36s
feat: routing mode, domain, server URL, SSL offloading on Environments page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:35:23 +02:00

510 lines
20 KiB
TypeScript

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<string | null>(null);
const [creating, setCreating] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Environment | null>(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 <Spinner size="md" />;
return (
<>
<SplitPane
list={
<>
{creating && (
<div className={styles.createForm}>
<Input
placeholder="Slug (e.g. staging) *"
value={newSlug}
onChange={(e) => setNewSlug(e.target.value)}
/>
{duplicateSlug && (
<span className={styles.errorText}>Slug already exists</span>
)}
<Input
placeholder="Display name *"
value={newDisplayName}
onChange={(e) => setNewDisplayName(e.target.value)}
/>
<label className={styles.securityRow}>
<Toggle checked={newProduction} onChange={() => setNewProduction(!newProduction)} />
Production environment
</label>
<div className={styles.createFormActions}>
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>
Cancel
</Button>
<Button
size="sm"
variant="primary"
onClick={handleCreate}
loading={createEnv.isPending}
disabled={!newSlug.trim() || !newDisplayName.trim() || duplicateSlug}
>
Create
</Button>
</div>
</div>
)}
<EntityList
items={filtered}
renderItem={(env) => (
<>
<Avatar name={env.displayName} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{env.displayName}</div>
<div className={styles.entityMeta}>
{env.slug}
</div>
<div className={styles.entityTags}>
{env.production && <Badge label="PROD" color="error" />}
{!env.production && <Badge label="NON-PROD" color="auto" />}
{!env.enabled && <Badge label="DISABLED" color="warning" />}
</div>
</div>
</>
)}
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 ? (
<>
<div className={styles.detailHeader}>
<Avatar name={selected.displayName} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>
{isDefault ? (
selected.displayName
) : (
<InlineEdit value={selected.displayName} onSave={handleRename} />
)}
</div>
<div className={styles.detailEmail}>
{selected.slug}
{isDefault && ' (built-in)'}
</div>
</div>
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(selected)}
disabled={isDefault}
>
Delete
</Button>
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{selected.id}</MonoText>
<span className={styles.metaLabel}>Slug</span>
<span className={styles.metaValue}>{selected.slug}</span>
<span className={styles.metaLabel}>Created</span>
<span className={styles.metaValue}>
{new Date(selected.createdAt).toLocaleDateString()}
</span>
</div>
<SectionHeader>Configuration</SectionHeader>
<div className={styles.securitySection}>
<div className={styles.securityRow}>
<Toggle checked={selected.production} onChange={() => handleToggleProduction(!selected.production)} />
<span>Production environment</span>
{selected.production ? (
<Tag label="Dedicated resources" color="error" />
) : (
<Tag label="Shared resources" color="auto" />
)}
</div>
</div>
<SectionHeader>Status</SectionHeader>
<div className={styles.securitySection}>
<div className={styles.securityRow}>
<Toggle checked={selected.enabled} onChange={() => handleToggleEnabled(!selected.enabled)} />
<span>{selected.enabled ? 'Enabled' : 'Disabled'}</span>
{!selected.enabled && (
<Tag label="No new deployments" color="warning" />
)}
</div>
{!selected.enabled && (
<p className={styles.inheritedNote}>
Disabled environments do not allow new deployments. Active
deployments can only be started, stopped, or deleted.
</p>
)}
</div>
<DefaultResourcesSection environment={selected} onSave={async (config) => {
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} />
<JarRetentionSection environment={selected} onSave={async (count) => {
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"
/>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => 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<string, unknown>) => Promise<void>;
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('');
const [routingMode, setRoutingMode] = useState(String(defaults.routingMode ?? 'path'));
const [routingDomain, setRoutingDomain] = useState(String(defaults.routingDomain ?? ''));
const [serverUrl, setServerUrl] = useState(String(defaults.serverUrl ?? ''));
const [envSslOffloading, setEnvSslOffloading] = useState(defaults.sslOffloading !== false);
useEffect(() => {
setMemoryLimit(String(defaults.memoryLimitMb ?? ''));
setMemoryReserve(String(defaults.memoryReserveMb ?? ''));
setCpuShares(String(defaults.cpuShares ?? ''));
setCpuLimit(String(defaults.cpuLimit ?? ''));
setRoutingMode(String(environment.defaultContainerConfig.routingMode ?? 'path'));
setRoutingDomain(String(environment.defaultContainerConfig.routingDomain ?? ''));
setServerUrl(String(environment.defaultContainerConfig.serverUrl ?? ''));
setEnvSslOffloading(environment.defaultContainerConfig.sslOffloading !== false);
setEditing(false);
}, [environment.id]);
function handleCancel() {
setMemoryLimit(String(defaults.memoryLimitMb ?? ''));
setMemoryReserve(String(defaults.memoryReserveMb ?? ''));
setCpuShares(String(defaults.cpuShares ?? ''));
setCpuLimit(String(defaults.cpuLimit ?? ''));
setRoutingMode(String(defaults.routingMode ?? 'path'));
setRoutingDomain(String(defaults.routingDomain ?? ''));
setServerUrl(String(defaults.serverUrl ?? ''));
setEnvSslOffloading(defaults.sslOffloading !== false);
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,
routingMode,
routingDomain: routingDomain || null,
serverUrl: serverUrl || null,
sslOffloading: envSslOffloading,
});
setEditing(false);
}
return (
<>
<SectionHeader>Default Resource Limits</SectionHeader>
<p className={styles.inheritedNote}>
These defaults apply to new apps in this environment unless overridden per-app.
</p>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>Memory Limit</span>
{editing
? <Input value={memoryLimit} onChange={(e) => setMemoryLimit(e.target.value)} placeholder="e.g. 512" style={{ width: 100 }} />
: <span className={styles.metaValue}>{defaults.memoryLimitMb ? `${defaults.memoryLimitMb} MB` : '—'}</span>}
<span className={styles.metaLabel}>Memory Reserve</span>
{editing
? <Input value={memoryReserve} onChange={(e) => setMemoryReserve(e.target.value)} placeholder="e.g. 256" style={{ width: 100 }} />
: <span className={styles.metaValue}>{defaults.memoryReserveMb ? `${defaults.memoryReserveMb} MB` : '—'}</span>}
<span className={styles.metaLabel}>CPU Shares</span>
{editing
? <Input value={cpuShares} onChange={(e) => setCpuShares(e.target.value)} placeholder="e.g. 512" style={{ width: 100 }} />
: <span className={styles.metaValue}>{String(defaults.cpuShares ?? '—')}</span>}
<span className={styles.metaLabel}>CPU Limit</span>
{editing
? <Input value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1.0" style={{ width: 100 }} />
: <span className={styles.metaValue}>{defaults.cpuLimit ? `${defaults.cpuLimit} cores` : '—'}</span>}
<span className={styles.metaLabel}>Routing Mode</span>
{editing
? <select value={routingMode} onChange={(e) => setRoutingMode(e.target.value)} style={{ padding: '4px 8px', border: '1px solid var(--border-subtle)', borderRadius: 4, background: 'var(--bg-surface)', color: 'var(--text-primary)', fontSize: 12 }}>
<option value="path">Path-based</option>
<option value="subdomain">Subdomain</option>
</select>
: <span className={styles.metaValue}>{routingMode === 'subdomain' ? 'Subdomain' : 'Path-based'}</span>}
<span className={styles.metaLabel}>Routing Domain</span>
{editing
? <Input value={routingDomain} onChange={(e) => setRoutingDomain(e.target.value)} placeholder="e.g. apps.example.com" style={{ width: 200 }} />
: <span className={styles.metaValue}>{String(defaults.routingDomain || '—')}</span>}
<span className={styles.metaLabel}>Server URL</span>
{editing
? <Input value={serverUrl} onChange={(e) => setServerUrl(e.target.value)} placeholder="auto-detect" style={{ width: 200 }} />
: <span className={styles.metaValue}>{String(defaults.serverUrl || '(global default)')}</span>}
<span className={styles.metaLabel}>SSL Offloading</span>
{editing
? <Toggle checked={envSslOffloading} onChange={() => setEnvSslOffloading(!envSslOffloading)} />
: <span className={styles.metaValue}>{envSslOffloading ? 'Enabled' : 'Disabled'}</span>}
</div>
<div style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{editing ? (
<>
<Button size="sm" variant="ghost" onClick={handleCancel}>Cancel</Button>
<Button size="sm" variant="primary" onClick={handleSave} loading={saving}>Save</Button>
</>
) : (
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}>Edit Defaults</Button>
)}
</div>
</>
);
}
// ── JAR Retention Policy ────────────────────────────────────────────
function JarRetentionSection({ environment, onSave, saving }: {
environment: Environment;
onSave: (count: number | null) => Promise<void>;
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 (
<>
<SectionHeader>JAR Retention</SectionHeader>
<p className={styles.inheritedNote}>
Old JAR versions are cleaned up nightly. Currently deployed versions are never deleted.
</p>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>Policy</span>
{editing ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Toggle checked={!unlimited} onChange={() => setUnlimited(!unlimited)} />
{unlimited
? <span>Keep all versions (unlimited)</span>
: <>
<span>Keep last</span>
<Input value={count} onChange={(e) => setCount(e.target.value)} style={{ width: 60 }} />
<span>versions</span>
</>}
</div>
) : (
<span className={styles.metaValue}>
{current === null ? 'Unlimited (no cleanup)' : `Keep last ${current} versions`}
</span>
)}
</div>
<div style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{editing ? (
<>
<Button size="sm" variant="ghost" onClick={handleCancel}>Cancel</Button>
<Button size="sm" variant="primary" onClick={handleSave} loading={saving}>Save</Button>
</>
) : (
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}>Edit Policy</Button>
)}
</div>
</>
);
}