feat: persist failure journal and bounded retries (F11)

This commit is contained in:
Rawman Grey 2026-10-30 10:22:29 +03:00
parent ec3e0c16e0
commit 871182d6d5
15 changed files with 2769 additions and 23 deletions

View file

@ -195,6 +195,34 @@
</table>
</section>
<section class="ip__card">
<header class="ip__head">
<h2>Failed previews</h2>
<v-button @click="loadFailures">Refresh</v-button>
</header>
<p class="ip__hint">
Persisted failure journal. Retries run automatically inside the maintenance window with no
active backfill: up to 3 attempts, then 5 and 15 minute backoff.
</p>
<table class="ip__table">
<thead>
<tr><th>file</th><th>preset</th><th>format</th><th>attempts</th><th>last error</th><th>next retry</th><th>status</th></tr>
</thead>
<tbody>
<tr v-for="failure in failures" :key="failure.file_id + failure.preset_key + (failure.format ?? '')">
<td class="ip__mono">{{ failure.file?.filename_download ?? failure.file_id.slice(0, 8) }}</td>
<td>{{ failure.preset_key }}</td>
<td>{{ failure.format ?? 'original' }}</td>
<td>{{ failure.attempts }}</td>
<td class="ip__error">{{ failure.error ?? '' }}</td>
<td>{{ failure.next_retry_at_human ?? '—' }}</td>
<td>{{ failure.status }}</td>
</tr>
</tbody>
</table>
<p v-if="failures.length === 0" class="ip__hint">No failures recorded.</p>
</section>
<section class="ip__card">
<header class="ip__head"><h2>API reference</h2></header>
<p class="ip__hint">
@ -250,11 +278,23 @@ interface PresetInfo {
enabled: boolean;
}
interface FailureRecord {
file_id: string;
preset_key: string;
format: string | null;
attempts: number;
status: string;
error: string | null;
next_retry_at_human: string | null;
file: { filename_download: string | null; type: string | null; folder: string | null } | null;
}
const base = '/image-previews';
const status = ref<Status | null>(null);
const config = ref<Record<string, any> | null>(null);
const disk = ref<{ previews: Bucket; originals: Bucket; total: Bucket } | null>(null);
const ops = ref<any[]>([]);
const failures = ref<FailureRecord[]>([]);
const presets = ref<PresetInfo[]>([]);
const shadowedKeys = ref<string[]>([]);
const fileId = ref('');
@ -351,6 +391,13 @@ const endpoints = [
description: 'In-memory ring buffer of the last 200 generation attempts with timing and error messages.',
example: 'curl -H "Authorization: Bearer $TOKEN" "$BASE/image-previews/ops?limit=50"',
},
{
method: 'GET',
path: '/failures',
summary: 'persisted failures',
description: 'Failure journal records with file metadata, attempts, timestamps, next retry and status. Filter with status=failed|recovered|retry, limit (max 200) and offset.',
example: 'curl -H "Authorization: Bearer $TOKEN" "$BASE/image-previews/failures?status=failed&limit=50&offset=0"',
},
];
async function api(path: string, init?: RequestInit) {
@ -389,6 +436,15 @@ async function loadPresets() {
shadowedKeys.value = result.shadowedKeys ?? [];
}
async function loadFailures() {
try {
const result = await api('/failures?limit=100');
failures.value = result.records ?? [];
} catch {
/* ignore polling errors */
}
}
async function saveConfig() {
if (!config.value) return;
@ -456,7 +512,7 @@ async function generate(dryRun: boolean) {
}
onMounted(async () => {
await Promise.all([ loadStatus(), loadConfig(), loadDisk(), loadPresets() ]);
await Promise.all([ loadStatus(), loadConfig(), loadDisk(), loadPresets(), loadFailures() ]);
timer = setInterval(loadStatus, 3000);
});