Source Retire lifecycle (Phase 1: status + content_visible, active mirrored)
Per Codex's plan — introduce a lifecycle without a risky "change the source of
truth everywhere" moment.
* Schema: sources.status (active|paused|retired) + content_visible; migration
backfills status from active (active=1→active, else paused), content_visible=1.
* `active` is kept as a SYNCED MIRROR: status active→active=1, paused/retired→0,
so the scheduler/CLI/legacy code keep working unchanged.
* Retire stops polling but keeps articles visible (non-destructive). Hiding is a
separate, reversible lever: content_visible=0 drops a source's articles from
the public feed + brief (read AND build), behind a confirm. Personal saved/
history are untouched.
* API: /sources/{id}/status (validates, mirrors active) + /visibility, replacing
/active. source_health returns status + content_visible.
* Admin: status column (active/paused/retired + "hidden"), Retired filter,
Pause/Resume · Retire/Restore · Hide/Show actions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -61,28 +61,40 @@
|
||||
}
|
||||
|
||||
let sources = $derived(stats?.sources ?? []);
|
||||
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);
|
||||
// status falls back to the legacy active flag during the migration window.
|
||||
const sstatus = (s) => s.status || (s.active ? 'active' : 'paused');
|
||||
let healthy = $derived(sources.filter((s) => sstatus(s) === 'active' && !s.failures && !s.review_flag).length);
|
||||
let resting = $derived(sources.filter((s) => sstatus(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);
|
||||
let paused = $derived(sources.filter((s) => sstatus(s) === 'paused').length);
|
||||
let retired = $derived(sources.filter((s) => sstatus(s) === 'retired').length);
|
||||
|
||||
// Sources table filter.
|
||||
let srcFilter = $state('all');
|
||||
let shownSources = $derived(
|
||||
srcFilter === 'healthy' ? sources.filter((s) => s.active && !s.failures && !s.review_flag)
|
||||
: srcFilter === 'resting' ? sources.filter((s) => s.active && s.failures > 0)
|
||||
srcFilter === 'healthy' ? sources.filter((s) => sstatus(s) === 'active' && !s.failures && !s.review_flag)
|
||||
: srcFilter === 'resting' ? sources.filter((s) => sstatus(s) === 'active' && s.failures > 0)
|
||||
: srcFilter === 'flagged' ? sources.filter((s) => s.review_flag)
|
||||
: srcFilter === 'paused' ? sources.filter((s) => !s.active)
|
||||
: sources
|
||||
: srcFilter === 'paused' ? sources.filter((s) => sstatus(s) === 'paused')
|
||||
: srcFilter === 'retired' ? sources.filter((s) => sstatus(s) === 'retired')
|
||||
: 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; }
|
||||
// Lifecycle (status: active/paused/retired) keeps `active` mirrored server-side;
|
||||
// visibility hides existing articles. Both optimistic with revert-on-failure.
|
||||
async function setStatus(s, status) {
|
||||
const prev = { status: s.status, active: s.active };
|
||||
s.status = status; s.active = status === 'active' ? 1 : 0;
|
||||
try { await postJSON(`/api/admin/sources/${s.id}/status`, { status }); }
|
||||
catch { s.status = prev.status; s.active = prev.active; }
|
||||
}
|
||||
async function toggleVisible(s) {
|
||||
const next = !s.content_visible;
|
||||
if (!next && !confirm(`Hide all of “${s.name}”’s articles from the public feed and brief?\n(Reversible — nothing is deleted.)`)) return;
|
||||
const prev = s.content_visible;
|
||||
s.content_visible = next ? 1 : 0;
|
||||
try { await postJSON(`/api/admin/sources/${s.id}/visibility`, { visible: next }); }
|
||||
catch { s.content_visible = prev; }
|
||||
}
|
||||
// Flagging opens a small inline popover for the reason; clearing is immediate.
|
||||
let flagging = $state(null); // the source being flagged
|
||||
@@ -327,9 +339,9 @@
|
||||
|
||||
{:else if section === 'sources'}
|
||||
<h2>Sources</h2>
|
||||
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {sources.length} total</p>
|
||||
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {retired} retired · {sources.length} total</p>
|
||||
<div class="filterchips">
|
||||
{#each [['all', 'All'], ['healthy', 'Healthy'], ['resting', 'Resting'], ['flagged', 'Flagged'], ['paused', 'Paused']] as [key, label] (key)}
|
||||
{#each [['all', 'All'], ['healthy', 'Healthy'], ['resting', 'Resting'], ['flagged', 'Flagged'], ['paused', 'Paused'], ['retired', 'Retired']] as [key, label] (key)}
|
||||
<button class="chip" class:on={srcFilter === key} onclick={() => (srcFilter = key)}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -343,7 +355,8 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each shownSources as s (s.id)}
|
||||
<tr class:warn={s.active && s.failures > 0} class:flag={s.review_flag} class:paused={!s.active}>
|
||||
{@const st = sstatus(s)}
|
||||
<tr class:warn={st === 'active' && s.failures > 0} class:flag={s.review_flag} class:paused={st !== '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}
|
||||
@@ -352,17 +365,25 @@
|
||||
<td class="num">{s.acceptance_rate != null ? s.acceptance_rate + '%' : '—'}</td>
|
||||
<td class="num" title={s.accepted_dup_rate != null ? `${s.accepted_dup_rate}% of accepted were duplicates` : ''}>{s.duplicate_rate != null ? s.duplicate_rate + '%' : '—'}</td>
|
||||
<td class="dim">{s.last_success_at ? fwhen(s.last_success_at) : '—'}</td>
|
||||
<td class="dim">{s.active ? fwhen(s.next_due_at) : '—'}</td>
|
||||
<td class="dim">{st === 'active' ? fwhen(s.next_due_at) : '—'}</td>
|
||||
<td class="num">{s.failures || ''}</td>
|
||||
<td class="status">
|
||||
{#if !s.active}<span class="paustxt">paused</span>
|
||||
{#if st === 'retired'}<span class="paustxt">retired</span>
|
||||
{:else if st === 'paused'}<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}
|
||||
{#if !s.content_visible}<span class="hidtxt" title="Articles hidden from the public feed">· hidden</span>{/if}
|
||||
</td>
|
||||
<td class="rowactions">
|
||||
<button class="act" onclick={() => toggleActive(s)}>{s.active ? 'Pause' : 'Resume'}</button>
|
||||
{#if st === 'retired'}
|
||||
<button class="act" onclick={() => setStatus(s, 'active')}>Restore</button>
|
||||
{:else}
|
||||
<button class="act" onclick={() => setStatus(s, st === 'active' ? 'paused' : 'active')}>{st === 'active' ? 'Pause' : 'Resume'}</button>
|
||||
<button class="act" onclick={() => setStatus(s, 'retired')}>Retire</button>
|
||||
{/if}
|
||||
<button class="act" onclick={() => (s.review_flag ? clearReview(s) : openFlag(s))}>{s.review_flag ? 'Clear' : 'Flag'}</button>
|
||||
<button class="act" onclick={() => toggleVisible(s)}>{s.content_visible ? 'Hide' : 'Show'}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -663,6 +684,7 @@
|
||||
.srctable .status .flagtxt { color: var(--accent-deep); }
|
||||
.srctable .status .good { color: #3f7048; }
|
||||
.srctable .status .paustxt { color: var(--muted); font-style: italic; }
|
||||
.srctable .status .hidtxt { color: #9a3b3b; font-size: 0.78rem; }
|
||||
.srctable .rowactions { white-space: nowrap; }
|
||||
.srctable .rowactions .act {
|
||||
background: none; border: 1px solid var(--line); color: var(--accent-deep);
|
||||
|
||||
Reference in New Issue
Block a user