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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user