Per-environment "keep last N versions" setting (default 5, null for unlimited). Nightly scheduled job at 03:00 deletes old versions from both database and disk, skipping any version that is currently deployed. Full stack: - V6 migration: adds jar_retention_count column to environments - Environment record, repository, service, admin controller endpoint - JarRetentionJob: @Scheduled nightly, iterates environments and apps - UI: retention policy editor on admin Environments page with toggle between limited/unlimited and version count input - AppVersionRepository.delete() for version cleanup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
471 lines
17 KiB
TypeScript
471 lines
17 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('');
|
|
|
|
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 (
|
|
<>
|
|
<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>}
|
|
</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>
|
|
</>
|
|
);
|
|
}
|