feat(downloads): per-post body-capture diagnostics in the event UI (#842)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m32s

Operator can't (and shouldn't have to) hunt worker logs to see why a recapture
left a post body empty. Surface per-post handling ON THE EVENT, in the UI.

The feed already requests post_type (in _FIELDS_POST), so ingest_core builds a
per-post diagnostic {post_id, title, post_type, body_chars} with zero extra
fetching — a 0-char body next to its post_type explains an empty post at a
glance (e.g. polls/embeds whose body the API never returns).

- ingest_core: accumulate post_diagnostics; thread via DownloadResult
- download_service: write to DownloadEvent.metadata_['post_diagnostics']
- DownloadDetailModal: 'Post capture' section — totals + empty-body table
  (post_type + chars, flagged) + all-posts table; included in Copy-all
- tests: ingester diag (post_type + body_chars), download_service metadata

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 23:09:37 -04:00
parent 3df191e255
commit bcc7266021
6 changed files with 154 additions and 2 deletions
@@ -28,6 +28,54 @@
<div><span class="fc-dl-grid__label">Errors</span><span>{{ summary.errors ?? 0 }}</span></div>
</div>
<template v-if="postDiag.length">
<h3 class="text-subtitle-2 mt-4">Post capture</h3>
<div class="fc-dl-grid">
<div><span class="fc-dl-grid__label">Posts</span><span>{{ postDiag.length }}</span></div>
<div><span class="fc-dl-grid__label">With body</span><span>{{ postWithBody }}</span></div>
<div><span class="fc-dl-grid__label">Empty body</span><span>{{ postEmpty.length }}</span></div>
</div>
<v-expansion-panels class="mt-2">
<v-expansion-panel v-if="postEmpty.length">
<v-expansion-panel-title>
<span>Empty-body posts ({{ postEmpty.length }})</span>
</v-expansion-panel-title>
<v-expansion-panel-text>
<table class="fc-dl-table">
<thead><tr><th>Post</th><th>Type</th><th>Chars</th></tr></thead>
<tbody>
<tr v-for="p in postEmpty" :key="p.post_id" class="fc-dl-empty">
<td>{{ p.title || `#${p.post_id}` }}</td>
<td><code>{{ p.post_type || '—' }}</code></td>
<td>{{ p.body_chars }}</td>
</tr>
</tbody>
</table>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel>
<v-expansion-panel-title>
<span>All posts ({{ postDiag.length }})</span>
</v-expansion-panel-title>
<v-expansion-panel-text>
<table class="fc-dl-table">
<thead><tr><th>Post</th><th>Type</th><th>Chars</th></tr></thead>
<tbody>
<tr
v-for="p in postDiag" :key="p.post_id"
:class="{ 'fc-dl-empty': !p.body_chars }"
>
<td>{{ p.title || `#${p.post_id}` }}</td>
<td><code>{{ p.post_type || '—' }}</code></td>
<td>{{ p.body_chars }}</td>
</tr>
</tbody>
</table>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</template>
<template v-if="quarantinedPaths.length">
<h3 class="text-subtitle-2 mt-4">Quarantined paths</h3>
<ul class="fc-dl-quar">
@@ -115,18 +163,31 @@ const summary = computed(() => props.event?.metadata?.import_summary || {})
const quarantinedPaths = computed(() => props.event?.metadata?.quarantined_paths || [])
const errorsWarnings = computed(() => props.event?.metadata?.stderr_errors_warnings || '')
// #842: per-post body-capture diagnostics — how each post was handled, with
// post_type so an empty body is self-explanatory in the UI.
const postDiag = computed(() => props.event?.metadata?.post_diagnostics || [])
const postEmpty = computed(() => postDiag.value.filter((p) => !p.body_chars))
const postWithBody = computed(() => postDiag.value.length - postEmpty.value.length)
// One combined block for "research the issue elsewhere" — header line +
// error + full stdout/stderr. Built lazily from the current event.
const allDiagnostics = computed(() => {
const e = props.event
if (!e) return ''
const md = e.metadata || {}
const emptyPosts = postEmpty.value
.map((p) => `${p.title || '#' + p.post_id} [${p.post_type || '—'}] ${p.body_chars} chars`)
.join('\n')
return [
`event #${e.id} · ${e.platform || '—'} · ${e.artist_name || '—'}`,
`status: ${e.status}`,
`started: ${e.started_at} finished: ${e.finished_at || '(running)'}`,
e.error ? `\n--- error ---\n${e.error}` : '',
errorsWarnings.value ? `\n--- errors & warnings ---\n${errorsWarnings.value}` : '',
postDiag.value.length
? `\n--- post capture (${postWithBody.value}/${postDiag.value.length} with body) ---`
+ (emptyPosts ? `\nempty-body posts:\n${emptyPosts}` : '')
: '',
`\n--- stdout ---\n${md.stdout || '(empty)'}`,
`\n--- stderr ---\n${md.stderr || '(empty)'}`,
].filter(Boolean).join('\n')
@@ -181,6 +242,25 @@ function onClose() {
word-break: break-all;
}
.fc-dl-quar { padding-left: 1.5rem; font-size: 0.85rem; }
.fc-dl-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.fc-dl-table th,
.fc-dl-table td {
text-align: left;
padding: 0.2rem 0.5rem;
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.14);
}
.fc-dl-table th:last-child,
.fc-dl-table td:last-child {
text-align: right;
font-variant-numeric: tabular-nums;
}
.fc-dl-table .fc-dl-empty {
color: rgb(var(--v-theme-warning));
}
.fc-dl-blockhead {
display: flex; align-items: center; justify-content: space-between;
gap: 0.5rem;