Sources: "Check source" read-only spot-check action
Per Codex — a per-row Check button that previews a LIVE source on demand,
intentionally read-only and ephemeral.
* POST /api/admin/sources/{id}/preview — admin-gated, safe-fetch + heuristic
preview (reuses the candidate preview path), returns the result. Mutates
NOTHING: no DB write, no poll attempt, no health/state change. 404 on missing.
* UI: per-row Check button with a Checking… state; results in an inline row
under the source (sampled, would-pass %, recent-7d, example accept/skip
headlines) with dismiss; inline error on failure. "Checked just now" is
local UI state only. Heuristic v1 — model deep-check left for later.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -122,6 +122,14 @@
|
||||
try { await postJSON(`/api/admin/sources/${s.id}/review`, { flag: false }); }
|
||||
catch { s.review_flag = 1; s.review_reason = prevR; }
|
||||
}
|
||||
// Read-only spot-check of a live source — never mutates source state.
|
||||
async function checkSource(s) {
|
||||
s._checking = true; s._checkErr = ''; s._check = null;
|
||||
try { s._check = await postJSON(`/api/admin/sources/${s.id}/preview`); }
|
||||
catch (e) { s._checkErr = e?.message || 'Could not preview.'; }
|
||||
finally { s._checking = false; }
|
||||
}
|
||||
function dismissCheck(s) { s._check = null; s._checkErr = ''; }
|
||||
|
||||
// --- Source candidates: supervised "add a source" pipeline ---
|
||||
let candidates = $state([]);
|
||||
@@ -484,8 +492,28 @@
|
||||
{/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>
|
||||
<button class="act" title="Read-only spot-check of the live feed" onclick={() => checkSource(s)} disabled={s._checking}>{s._checking ? 'Checking…' : 'Check'}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{#if s._checking || s._check || s._checkErr}
|
||||
<tr class="checkrow">
|
||||
<td colspan="10">
|
||||
{#if s._checking}
|
||||
<span class="muted">Checking {s.name}…</span>
|
||||
{:else if s._checkErr}
|
||||
<span class="cerr">Couldn't preview: {s._checkErr} <button class="act" onclick={() => dismissCheck(s)}>dismiss</button></span>
|
||||
{:else if s._check}
|
||||
<div class="chkhead">
|
||||
<strong>{s._check.accepted ?? 0}/{s._check.sampled ?? 0}</strong> would pass{#if s._check.acceptance_rate != null} · {Math.round(s._check.acceptance_rate * 100)}%{/if}{#if s._check.recent_7d != null} · {s._check.recent_7d} in last 7d{/if}
|
||||
<span class="chkwhen">checked just now</span>
|
||||
<button class="act" onclick={() => dismissCheck(s)}>dismiss</button>
|
||||
</div>
|
||||
{#if s._check.examples_accepted?.length}<div class="chkex"><span class="chklbl">would accept:</span> {s._check.examples_accepted.slice(0, 3).join(' · ')}</div>{/if}
|
||||
{#if s._check.examples_rejected?.length}<div class="chkex chkrej"><span class="chklbl">would skip:</span> {s._check.examples_rejected.slice(0, 3).map((r) => r.title).join(' · ')}</div>{/if}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -808,6 +836,12 @@
|
||||
.srctable .status .good { color: #3f7048; }
|
||||
.srctable .status .paustxt { color: var(--muted); font-style: italic; }
|
||||
.srctable .status .hidtxt { color: #9a3b3b; font-size: 0.78rem; }
|
||||
.srctable tr.checkrow td { background: var(--bg); font-size: 0.85rem; padding: 10px 12px; }
|
||||
.chkhead { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.chkhead .chkwhen { color: var(--muted); font-size: 0.76rem; }
|
||||
.chkex { margin-top: 5px; color: var(--ink); }
|
||||
.chkex .chklbl { color: var(--muted); }
|
||||
.chkex.chkrej { color: var(--muted); }
|
||||
.srctable .rowactions { white-space: nowrap; }
|
||||
.srctable .rowactions .act {
|
||||
background: none; border: 1px solid var(--line); color: var(--accent-deep);
|
||||
|
||||
@@ -933,6 +933,18 @@ def create_app() -> FastAPI:
|
||||
conn.commit()
|
||||
return {"ok": True, "visible": body.visible}
|
||||
|
||||
@app.post("/api/admin/sources/{sid}/preview")
|
||||
def admin_source_preview(sid: int, request: Request) -> dict:
|
||||
# Read-only spot-check of a LIVE source: safe-fetch + heuristic preview.
|
||||
# Mutates nothing — no DB write, no poll attempt, no health/state change.
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
src = conn.execute("SELECT feed_url FROM sources WHERE id = ?", (sid,)).fetchone()
|
||||
if not src:
|
||||
raise HTTPException(status_code=404, detail="source not found")
|
||||
url = src["feed_url"]
|
||||
return _preview_or_502(url) # safe fetch, no DB connection held
|
||||
|
||||
# --- Source candidates (supervised add-a-source pipeline) ----------------
|
||||
|
||||
def _candidate_dict(row) -> dict:
|
||||
|
||||
@@ -198,3 +198,18 @@ def test_export_sources_csv_escapes_formula_injection(tmp_path, monkeypatch):
|
||||
assert "'=HYPERLINK" in body # leading apostrophe defuses the formula (CSV may quote the cell)
|
||||
assert "'+danger" in body
|
||||
assert ",=HYPERLINK" not in body # never written as a bare, evaluable formula cell
|
||||
|
||||
|
||||
def test_source_check_preview_is_readonly(tmp_path, monkeypatch):
|
||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||
monkeypatch.setattr(api.feeds, "preview_feed", lambda url, **k: {"url": url, "sampled": 8, "accepted": 6})
|
||||
tc = _signin(app, api, "boss@x.com")
|
||||
before = _src(tc)
|
||||
r = tc.post("/api/admin/sources/1/preview").json()
|
||||
assert r["sampled"] == 8 and r["accepted"] == 6
|
||||
after = _src(tc)
|
||||
# read-only: no state/health change, no poll attempt recorded
|
||||
assert after["status"] == before["status"] and after["served"] == before["served"]
|
||||
assert after["last_success_at"] == before["last_success_at"] and after["next_due_at"] == before["next_due_at"]
|
||||
assert TestClient(app).post("/api/admin/sources/1/preview").status_code == 401 # gated
|
||||
assert tc.post("/api/admin/sources/999/preview").status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user