feat: JAR retention policy with nightly cleanup job
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m23s
CI / docker (push) Successful in 1m9s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 40s

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>
This commit is contained in:
hsiegeln
2026-04-08 19:06:28 +02:00
parent 863a992cc4
commit 7e47f1628d
11 changed files with 252 additions and 3 deletions

View File

@@ -21,6 +21,7 @@ import {
useUpdateEnvironment,
useDeleteEnvironment,
useUpdateDefaultContainerConfig,
useUpdateJarRetention,
} from '../../api/queries/admin/environments';
import type { Environment } from '../../api/queries/admin/environments';
import styles from './UserManagement.module.css';
@@ -44,6 +45,7 @@ export default function EnvironmentsPage() {
const updateEnv = useUpdateEnvironment();
const deleteEnv = useDeleteEnvironment();
const updateDefaults = useUpdateDefaultContainerConfig();
const updateRetention = useUpdateJarRetention();
const selected = useMemo(
() => environments.find((e) => e.id === selectedId) ?? null,
@@ -291,6 +293,15 @@ export default function EnvironmentsPage() {
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
}
@@ -389,3 +400,71 @@ function DefaultResourcesSection({ environment, onSave, saving }: {
</>
);
}
// ── 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>
</>
);
}