Promote-candidate UI: add-a-source pipeline in the admin console

Bring the supervised source-candidate flow into Sources (Codex's v1 scope), so
adding feeds no longer needs the CLI.

* feeds.safe_fetch_feed: SSRF-safe fetch for UNTRUSTED (admin-pasted) URLs —
  http(s) only, every redirect hop re-validated via enrich._host_is_public,
  body size-capped, bounded redirects, no cookies. preview_feed gains a
  `fetcher` param; the API path passes safe_fetch_feed (NOT the raw fetch_feed
  used for already-vetted polling).
* API (admin-gated): GET /candidates; POST /candidates (suggest+preview, gated
  before the outbound fetch, no DB conn held during network); /{id}/preview
  (explicit re-preview); /{id}/promote (paused by default, returns the new
  source + updated candidate); /{id}/reject. rejected stays on candidates only.
* Admin Sources tab: "Add a source" field + a candidate queue showing the
  preview (pass rate, recent count, example headlines) with Promote (as paused,
  or Activate immediately) / Re-preview / Reject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-09 10:28:00 -04:00
parent 35aaeece6d
commit 1a8d1b3bf1
4 changed files with 276 additions and 4 deletions
+105
View File
@@ -30,6 +30,7 @@
try {
await loadStats();
feedback = await getJSON('/api/admin/feedback');
candidates = await getJSON('/api/admin/candidates');
} catch {
error = "Couldn't load stats.";
}
@@ -116,6 +117,51 @@
catch { s.review_flag = 1; s.review_reason = prevR; }
}
// --- Source candidates: supervised "add a source" pipeline ---
let candidates = $state([]);
let newFeedUrl = $state('');
let newFeedName = $state('');
let addBusy = $state(false);
let addErr = $state('');
let pendingCandidates = $derived(candidates.filter((c) => c.status !== 'promoted' && c.status !== 'rejected'));
async function addCandidate() {
const url = newFeedUrl.trim();
if (!url || addBusy) return;
addBusy = true; addErr = '';
try {
const c = await postJSON('/api/admin/candidates', { feed_url: url, name: newFeedName.trim() || null });
candidates = [c, ...candidates.filter((x) => x.id !== c.id)];
newFeedUrl = ''; newFeedName = '';
} catch (e) {
addErr = e?.message || "Couldn't preview that feed.";
} finally {
addBusy = false;
}
}
async function repreviewCandidate(c) {
c._err = '';
try { const u = await postJSON(`/api/admin/candidates/${c.id}/preview`); c.preview = u.preview; }
catch (e) { c._err = e?.message || 'Re-preview failed.'; }
}
async function promoteCandidate(c) {
c._err = '';
try {
await postJSON(`/api/admin/candidates/${c.id}/promote`, {
default_category: (c._cat || '').trim() || null,
active: !!c._activate,
});
c.status = 'promoted';
await loadStats(); // surface the new (paused) source in the table below
} catch (e) {
c._err = e?.message || 'Promote failed.';
}
}
async function rejectCandidate(c) {
try { await postJSON(`/api/admin/candidates/${c.id}/reject`); c.status = 'rejected'; }
catch { /* leave as-is */ }
}
// Feedback inbox: filter + read/unread + delete.
let fbCat = $state('all'); // 'all' | 'unread' | a category
let shownFeedback = $derived(
@@ -338,6 +384,48 @@
</div>
{:else if section === 'sources'}
<section class="addsrc">
<h2>Add a source</h2>
<div class="addrow">
<input type="url" placeholder="Feed URL (RSS / Atom)" bind:value={newFeedUrl} onkeydown={(e) => e.key === 'Enter' && addCandidate()} />
<input type="text" placeholder="Name (optional)" bind:value={newFeedName} />
<button class="csend" onclick={addCandidate} disabled={addBusy || !newFeedUrl.trim()}>{addBusy ? 'Previewing…' : 'Preview & add'}</button>
</div>
{#if addErr}<p class="cerr">{addErr}</p>{/if}
<p class="legend2">Fetches a sample (safely) and stages it as a candidate — nothing is added live until you Promote.</p>
</section>
{#if pendingCandidates.length}
<section>
<h2>Candidate queue <span class="count">({pendingCandidates.length})</span></h2>
<ul class="candlist">
{#each pendingCandidates as c (c.id)}
<li>
<div class="chead">
<span class="cname">{c.name || c.feed_url}</span>
<span class="cstatus">{c.status}</span>
</div>
<div class="curl">{c.feed_url}</div>
{#if c.preview}
<div class="cprev">
{c.preview.accepted ?? 0}/{c.preview.sampled ?? 0} sampled would pass{#if c.preview.acceptance_rate != null} · {Math.round(c.preview.acceptance_rate * 100)}% accept{/if}{#if c.preview.recent_7d != null} · {c.preview.recent_7d} in last 7d{/if}
{#if c.preview.examples_accepted?.length}<div class="cex">e.g. {c.preview.examples_accepted.slice(0, 3).join(' · ')}</div>{/if}
</div>
{/if}
{#if c._err}<p class="cerr">{c._err}</p>{/if}
<div class="cactions">
<input class="ccat" type="text" placeholder="category (optional)" bind:value={c._cat} />
<label class="cchk"><input type="checkbox" bind:checked={c._activate} /> Activate immediately</label>
<button class="csend" onclick={() => promoteCandidate(c)}>Promote{c._activate ? '' : ' as paused'}</button>
<button class="act" onclick={() => repreviewCandidate(c)}>Re-preview</button>
<button class="act del" onclick={() => rejectCandidate(c)}>Reject</button>
</div>
</li>
{/each}
</ul>
</section>
{/if}
<h2>Sources</h2>
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {retired} retired · {sources.length} total</p>
<div class="filterchips">
@@ -664,6 +752,23 @@
.filterchips .chip:hover { border-color: var(--accent); }
.filterchips .chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Add a source + candidate queue */
.addsrc { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; margin-bottom: 6px; }
.addrow { display: flex; gap: 8px; flex-wrap: wrap; }
.addrow input { flex: 1 1 200px; box-sizing: border-box; font: inherit; font-size: 0.9rem; padding: 8px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg); color: var(--ink); }
.addrow input:focus { outline: none; border-color: var(--accent); }
ul.candlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
ul.candlist li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
.chead { display: flex; align-items: baseline; gap: 10px; }
.chead .cname { font-weight: 600; color: var(--ink); }
.chead .cstatus { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
.curl { font-size: 0.76rem; color: var(--muted); word-break: break-all; margin-top: 2px; }
.cprev { font-size: 0.84rem; color: var(--ink); margin-top: 7px; }
.cprev .cex { color: var(--muted); font-size: 0.8rem; margin-top: 2px; font-style: italic; }
.cactions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
.cactions .ccat { font: inherit; font-size: 0.8rem; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); width: 150px; }
.cactions .cchk { font-size: 0.8rem; color: var(--muted); display: inline-flex; align-items: center; gap: 5px; }
/* Source health table */
.tablewrap { overflow-x: auto; }
.srctable { width: 100%; border-collapse: collapse; font-size: 0.86rem; min-width: 560px; }