Source management console: pause/resume, flag/clear, decision metrics

Turn the Sources tab into a real management console (per Codex):

* source_health now lists ALL sources (active + paused) with backing metrics:
  served / accepted_total / total_articles / duplicates + acceptance & duplicate
  rates + review_reason, alongside last success/attempt, next poll, failures.
* Admin endpoints (gated, 404 on missing): POST sources/{id}/active (pause/
  resume) and /review (flag/clear with reason).
* Pausing only stops future polling — the feed query has no active filter, so a
  paused source's accepted articles stay live.
* Frontend: metric table + Paused filter + per-row Pause/Resume & Flag/Clear
  (optimistic, revert on failure). Attention 'resting' now scoped to active.

Retire/Delete intentionally deferred (distinct lifecycle state, later).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-08 14:04:40 -04:00
parent f90324c5a6
commit 84bc5b0267
5 changed files with 160 additions and 25 deletions
+60 -14
View File
@@ -49,19 +49,43 @@
}
let sources = $derived(stats?.sources ?? []);
let healthy = $derived(sources.filter((s) => !s.failures && !s.review_flag).length);
let resting = $derived(sources.filter((s) => s.failures > 0).length);
let healthy = $derived(sources.filter((s) => s.active && !s.failures && !s.review_flag).length);
let resting = $derived(sources.filter((s) => s.active && s.failures > 0).length);
let flagged = $derived(sources.filter((s) => s.review_flag).length);
let paused = $derived(sources.filter((s) => !s.active).length);
// Sources table filter.
let srcFilter = $state('all');
let shownSources = $derived(
srcFilter === 'healthy' ? sources.filter((s) => !s.failures && !s.review_flag)
: srcFilter === 'resting' ? sources.filter((s) => s.failures > 0)
srcFilter === 'healthy' ? sources.filter((s) => s.active && !s.failures && !s.review_flag)
: srcFilter === 'resting' ? sources.filter((s) => s.active && s.failures > 0)
: srcFilter === 'flagged' ? sources.filter((s) => s.review_flag)
: sources
: srcFilter === 'paused' ? sources.filter((s) => !s.active)
: sources
);
// Pause/resume (stops polling only; existing articles stay live) + flag/clear.
// Optimistic against the deep-reactive stats; revert on failure.
async function toggleActive(s) {
const next = !s.active;
s.active = next ? 1 : 0;
try { await postJSON(`/api/admin/sources/${s.id}/active`, { active: next }); }
catch { s.active = next ? 0 : 1; }
}
async function toggleReview(s) {
if (s.review_flag) {
const prevR = s.review_reason;
s.review_flag = 0; s.review_reason = null;
try { await postJSON(`/api/admin/sources/${s.id}/review`, { flag: false }); }
catch { s.review_flag = 1; s.review_reason = prevR; }
} else {
const reason = (prompt('Flag this source for review — optional note:') || '').trim();
s.review_flag = 1; s.review_reason = reason || null;
try { await postJSON(`/api/admin/sources/${s.id}/review`, { flag: true, reason: reason || null }); }
catch { s.review_flag = 0; s.review_reason = null; }
}
}
// Feedback inbox: filter + read/unread + delete.
let fbCat = $state('all'); // 'all' | 'unread' | a category
let shownFeedback = $derived(
@@ -218,37 +242,50 @@
</div>
{:else if section === 'sources'}
<h2>Source health</h2>
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {sources.length} active</p>
<h2>Sources</h2>
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {sources.length} total</p>
<div class="filterchips">
{#each [['all', 'All'], ['healthy', 'Healthy'], ['resting', 'Resting'], ['flagged', 'Flagged']] as [key, label] (key)}
{#each [['all', 'All'], ['healthy', 'Healthy'], ['resting', 'Resting'], ['flagged', 'Flagged'], ['paused', 'Paused']] as [key, label] (key)}
<button class="chip" class:on={srcFilter === key} onclick={() => (srcFilter = key)}>{label}</button>
{/each}
</div>
<div class="tablewrap">
<table class="srctable">
<thead>
<tr><th>Source</th><th class="num">Served</th><th>Last success</th><th>Next poll</th><th class="num">Fails</th><th>Status</th></tr>
<tr>
<th>Source</th><th class="num">Served</th><th class="num">Accept</th><th class="num">Dup</th>
<th>Last success</th><th>Next poll</th><th class="num">Fails</th><th>Status</th><th>Actions</th>
</tr>
</thead>
<tbody>
{#each shownSources as s (s.id)}
<tr class:warn={s.failures > 0} class:flag={s.review_flag}>
<td class="sname">{s.name}{#if s.category}<span class="cat">{s.category}</span>{/if}</td>
<tr class:warn={s.active && s.failures > 0} class:flag={s.review_flag} class:paused={!s.active}>
<td class="sname">
{s.name}{#if s.category}<span class="cat">{s.category}</span>{/if}
{#if s.review_flag && s.review_reason}<span class="rr">{s.review_reason}</span>{/if}
</td>
<td class="num">{s.served}</td>
<td class="num">{s.acceptance_rate != null ? s.acceptance_rate + '%' : '—'}</td>
<td class="num">{s.duplicate_rate != null ? s.duplicate_rate + '%' : '—'}</td>
<td class="dim">{s.last_success_at ? fwhen(s.last_success_at) : '—'}</td>
<td class="dim">{fwhen(s.next_due_at)}</td>
<td class="dim">{s.active ? fwhen(s.next_due_at) : '—'}</td>
<td class="num">{s.failures || ''}</td>
<td class="status">
{#if s.failures > 0}<span class="bad" title={s.last_error || ''}> resting{#if s.review_flag} · review{/if}</span>
{#if !s.active}<span class="paustxt">paused</span>
{:else if s.failures > 0}<span class="bad" title={s.last_error || ''}> resting{#if s.review_flag} · review{/if}</span>
{:else if s.review_flag}<span class="flagtxt">⚑ review</span>
{:else}<span class="good">✓ ok</span>{/if}
</td>
<td class="rowactions">
<button class="act" onclick={() => toggleActive(s)}>{s.active ? 'Pause' : 'Resume'}</button>
<button class="act" onclick={() => toggleReview(s)}>{s.review_flag ? 'Clear' : 'Flag'}</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p class="legend2">“served” = accepted articles live from that source · times in your local zone</p>
<p class="legend2">“served” = accepted, non-duplicate articles live · accept/dup % is of all ingested · pausing stops polling but keeps existing articles live · times in your local zone</p>
{:else if section === 'audience'}
<section>
@@ -445,11 +482,20 @@
.srctable .dim { color: var(--muted); white-space: nowrap; font-size: 0.82rem; }
.srctable .sname { font-weight: 600; color: var(--ink); }
.srctable .sname .cat { display: block; font-weight: 400; font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
.srctable .sname .rr { display: block; font-weight: 400; font-size: 0.72rem; color: var(--accent-deep); font-style: italic; }
.srctable tr.warn { background: #fdf3e3; }
.srctable tr.flag { background: #eef4f8; }
.srctable tr.paused { opacity: 0.6; }
.srctable .status .bad { color: #875a16; }
.srctable .status .flagtxt { color: var(--accent-deep); }
.srctable .status .good { color: #3f7048; }
.srctable .status .paustxt { color: var(--muted); font-style: italic; }
.srctable .rowactions { white-space: nowrap; }
.srctable .rowactions .act {
background: none; border: 1px solid var(--line); color: var(--accent-deep);
border-radius: 999px; padding: 3px 11px; font-size: 0.76rem; cursor: pointer; margin-right: 5px;
}
.srctable .rowactions .act:hover { border-color: var(--accent); }
.count { color: var(--muted); font-weight: 400; font-size: 0.9rem; }
ul.fb { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }