feat: JAR retention policy with nightly cleanup job
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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user