Add configurable history and storage retention policies

This commit is contained in:
2026-10-05 22:48:04 -04:00
parent 7c98df4fb0
commit 3952c4c938
37 changed files with 963 additions and 82 deletions
@@ -0,0 +1,67 @@
<script lang="ts">
import type { FleetSettings } from '$lib/fleet-settings.js';
let { settings }: { settings: FleetSettings } = $props();
import Button from './primitives/Button.svelte';
let busy = $state(false);
let error = $state('');
let notice = $state('');
let preview = $state<{ policies: { category: string; label: string; eligible: number; payloadBytes: number; records: { id: string }[] }[];
controls: { category: string; id: string; protected: number; resolvedAt: string | null }[] } | null>(null);
let category = $state('campaign');
let recordId = $state('');
const categories = ['campaign','provisioning','operation','observation','maintenance','recovery','match','beta','file-content','beta-file-content','version','audit','game-file','upload'];
async function refresh() {
busy = true; error = '';
try { const response = await fetch('/api/settings/retention/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }); if (!response.ok) throw new Error('Could not load cleanup preview.'); preview = await response.json(); }
catch (cause) { error = (cause as Error).message; } finally { busy = false; }
}
async function control(action: { protected?: boolean; resolve?: boolean }, selectedCategory = category, id = recordId) {
busy = true; error = ''; notice = '';
try {
const response = await fetch('/api/settings/retention',{ method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ category: selectedCategory,id,...action }) });
const result = await response.json(); if (!response.ok) throw new Error(result.error);
notice = action.resolve ? 'Failure marked resolved. Retention still checks all recovery dependencies.' : action.protected ? 'Record protected from cleanup.' : 'Record protection removed.';
await refresh();
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
}
</script>
<details>
<summary>Cleanup preview and protected records</summary>
<p>Preview uses the values currently entered above, without saving them. Background cleanup runs in batches. Known payload sizes are estimates; database file size may not shrink immediately. Expired data cannot be restored by increasing retention.</p>
<Button disabled={busy} onclick={refresh}>{busy ? 'Loading…' : 'Refresh cleanup preview'}</Button>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if preview}
<div class="table-wrap"><table><thead><tr><th>Category</th><th>Eligible records</th><th>Known payload size</th></tr></thead><tbody>
{#each preview.policies as policy}<tr><td>{policy.label}</td><td>{policy.eligible}</td><td>{policy.payloadBytes ? `${(policy.payloadBytes / 1024 / 1024).toFixed(2)} MiB` : '—'}</td></tr>{/each}
</tbody></table></div>
{#each preview.policies.filter(policy => policy.records.length) as policy}
<details><summary>{policy.label}: eligible IDs (up to 20)</summary>
{#each policy.records as record}<div class="record"><code>{record.id}</code><Button disabled={busy} onclick={() => control({ protected: true },policy.category,record.id)}>Protect</Button></div>{/each}
</details>
{/each}
<h3>Protected records and resolved failures</h3>
{#each preview.controls as record}<div class="record"><span>{record.category} · <code>{record.id}</code>{record.resolvedAt ? ' · Resolved' : ''}</span>
{#if record.protected}<Button disabled={busy} onclick={() => control({ protected: false },record.category,record.id)}>Remove protection</Button>{/if}</div>{:else}<p>No explicit record protections or resolutions.</p>{/each}
{/if}
<h3>Protect a record or resolve a failure</h3>
<p>Use the ID from its detail URL or the preview. Resolving a failed campaign stops automatic retry requests for that campaign; unfinished recovery still prevents cleanup.</p>
<div class="record">
<label>Category<select bind:value={category}>{#each categories as value}<option value={value}>{value}</option>{/each}</select></label>
<label>Record ID<input bind:value={recordId} maxlength="200" /></label>
<Button disabled={busy || !recordId} onclick={() => control({ protected: true })}>Protect record</Button>
{#if ['campaign','provisioning','operation'].includes(category)}<Button disabled={busy || !recordId} onclick={() => control({ resolve: true })}>Mark failure resolved</Button>{/if}
</div>
</details>
<style>
details { margin-top: 20px; }
summary { cursor: pointer; }
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.65; }
.record { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
code { overflow-wrap: anywhere; }
label { display: grid; gap: 8px; }
select,input { font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 8px; }
.table-wrap { overflow-x: auto; margin: 16px 0; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: var(--text-sm); }
th,td { padding: 10px; border-bottom: 1px solid var(--border-strong); }
</style>
@@ -84,7 +84,7 @@
The scoreboard uses telemetry; the fleet player count comes from RCON separately.
</p>
<p class="retention">
Newest first · up to {retention.maxPerServer} requests from the last {retention.maxAgeDays} days.
Newest first · {retention.maxPerServer ? `up to ${retention.maxPerServer} requests per server` : 'no per-server retention cap'} · {retention.maxAgeDays ? `last ${retention.maxAgeDays} days` : 'no age expiry'}. Fleet-wide limits also apply.
Refreshes every 5 seconds while visible.
{#if refreshedAt}<span>Last refreshed <time datetime={refreshedAt}>{timestamp(refreshedAt)}</time>.</span>{/if}
</p>