feat(admin): one-click sources sync — admin-only button triggers git clone/pull + re-import + KB overview refresh with polled live status

This commit is contained in:
2026-08-25 21:39:38 -04:00
parent 0654b304e1
commit 52136fe307
13 changed files with 1621 additions and 2 deletions
+8 -1
View File
@@ -12,7 +12,10 @@
* every page that has a nav (chat, sources, tuning, login),
* revealed for admin. The links SHIP hidden in the HTML
* (anonymous-safe default — the phase-16 "absent, not hidden"
* spirit), so no anonymous user ever sees one for a frame;
* spirit), so no anonymous user ever sees one for a frame; and the
* Sources page's "Sync sources" button (#sync-btn, phase 32) —
* the same ship-hidden / reveal-for-admin contract on the SAME
* cached whoami (one fetch, no extra request);
* • the sign-out click binding (POST /api/logout → reload) — moved
* here from app.js so there is exactly one implementation;
* • clearChatStorage() — the phase-14 conversation key, for the
@@ -68,6 +71,10 @@ export async function initSharedHeader() {
// same ship-hidden / reveal-for-admin contract as the Sources link.
const navTuning = document.querySelector("#nav-tuning");
if (navTuning) navTuning.hidden = !admin;
// Phase 32: the Sources page's "Sync sources" button — admin-only,
// revealed on this same cached whoami (anonymous users never see it).
const syncBtn = document.querySelector("#sync-btn");
if (syncBtn) syncBtn.hidden = !admin;
return admin;
}
+231
View File
@@ -146,6 +146,236 @@ function showEmpty() {
if (tableWrap) tableWrap.hidden = true;
}
/* ---------- Phase 32: the admin "Sync sources" button (§7.4) ----------
* The "never stale" lifecycle for a long background job:
*
* idle → click → POST /api/sync
* 202 → "Syncing…" (disabled, aria-busy, spinning icon) + a
* 2 s poll of GET /api/sync/status — the feedback loop;
* 409 adopts the in-flight run the same way (one poll
* loop at a time, never two);
* success → "Synced HH:MM" + last-result counts in #sync-result
* (aria-live — announced to screen readers) + the
* catalog re-fetches live (never a stale table);
* failed → "Sync sources" (retry-ready) + the role="alert"
* banner naming the error.
*
* NO client-side hard timeout (phase locked decision): a sync can
* legitimately run for minutes (clone + embed), so the 2 s poll is the
* feedback loop and the server state is authoritative — the button is
* disabled until the run reaches a terminal state, so it can never sit
* stale OR stuck. On load (admin only) the page re-attaches: a running
* run re-enters the running state (reload mid-sync), a terminal run
* renders its last result. A 403 anywhere hides the button (defense in
* depth — header.js's whoami reveal is the primary gate).
*/
const syncBtn = document.querySelector("#sync-btn");
const syncLabel = document.querySelector("#sync-label");
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
const syncResult = document.querySelector("#sync-result");
const syncErrorBanner = document.querySelector("#sync-error-banner");
const syncErrorText = document.querySelector("#sync-error-text");
const SYNC_POLL_MS = 2000; // the 2 s status poll (task 02)
let syncPollTimer = null; // at most ONE live poll loop
function stopSyncPolling() {
if (syncPollTimer !== null) {
clearTimeout(syncPollTimer);
syncPollTimer = null;
}
}
function showSyncError(detail) {
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
if (syncErrorBanner) syncErrorBanner.hidden = false;
}
function hideSyncError() {
if (syncErrorText) syncErrorText.textContent = "";
if (syncErrorBanner) syncErrorBanner.hidden = true;
}
/* The local HH:MM of finished_at — 24-hour, locale-independent, so the
* "Synced 14:32" last-result label is deterministic. */
function fmtSyncTime(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/* The last-result line for #sync-result (aria-live). "added" is ALWAYS
* announced (the run's headline term); "updated" / "pruned" only when
* they happened (zero terms omitted); "unchanged" whenever it is
* non-zero — or whenever nothing was added or updated, so a no-op
* re-sync reads "0 added · 1 unchanged" instead of an empty live
* region (the story gate's idempotency check). */
function fmtSyncResult(detail) {
const d = detail || {};
const added = d.added || 0;
const updated = d.updated || 0;
const parts = [`${added} added`];
if (updated > 0) parts.push(`${updated} updated`);
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
parts.push(`${d.unchanged || 0} unchanged`);
}
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
return parts.join(" · ");
}
function enterRunningState() {
syncBtn.disabled = true;
syncBtn.setAttribute("aria-busy", "true");
if (syncIcon) syncIcon.classList.add("is-spinning");
syncLabel.textContent = "Syncing…";
if (syncResult) syncResult.textContent = "";
hideSyncError();
}
/* Settle the button back to clickable + un-spun with the given label. */
function settleSyncButton(label) {
syncBtn.disabled = false;
syncBtn.removeAttribute("aria-busy");
if (syncIcon) syncIcon.classList.remove("is-spinning");
syncLabel.textContent = label;
}
function applySyncSuccess(status) {
const time = fmtSyncTime(status.finished_at);
settleSyncButton(time ? `Synced ${time}` : "Synced");
if (syncResult) syncResult.textContent = fmtSyncResult(status.detail);
hideSyncError();
// The KB just changed — refresh the catalog live so the table, stats,
// and empty state never sit stale under the "Synced" label (the sync is
// the page's own action; a reload should not be needed to see it).
loadDocs();
}
function applySyncFailure(status) {
settleSyncButton("Sync sources"); // retry-ready
if (syncResult) syncResult.textContent = "";
showSyncError(status.error);
}
/* A run can only vanish with a server restart mid-sync (status resets
* to idle — the phase-accepted behavior): re-enable retry-ready with no
* banner (there is no error to name; the next click re-syncs).
* Idempotent — also the post-403 cleanup. */
function applySyncIdle() {
settleSyncButton("Sync sources");
if (syncResult) syncResult.textContent = "";
hideSyncError();
}
/* The 2 s poll loop — the ONLY feedback timer (no client-side hard
* timeout, phase locked decision). One tick at a time (re-scheduled
* only while the run is still live, so an in-flight fetch can never
* overlap the next tick), and startSyncPolling refuses to run a second
* loop (a 409 adoption or a reload never doubles the polling). */
function startSyncPolling() {
if (syncPollTimer !== null) return;
const tick = async () => {
let status = null;
let notAdmin = false;
try {
const r = await fetch("/api/sync/status");
if (r.status === 403) notAdmin = true;
else if (r.ok) status = await r.json();
} catch {
/* network blip — the next tick retries (no client timeout to trip) */
}
if (notAdmin) {
// Session lost mid-sync: defense in depth — hide the button.
stopSyncPolling();
syncBtn.hidden = true;
applySyncIdle();
return;
}
if (status && status.state === "success") {
stopSyncPolling();
applySyncSuccess(status);
return;
}
if (status && status.state === "failed") {
stopSyncPolling();
applySyncFailure(status);
return;
}
if (status && status.state === "idle") {
// The run died with a server restart — retry-ready, no banner.
stopSyncPolling();
applySyncIdle();
return;
}
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
};
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
}
/* Click → POST /api/sync. 202 starts the run; 409 adopts the in-flight
* one (started elsewhere — e.g. a second tab); 403 hides the button
* (defense in depth); anything else names the failure in the banner and
* leaves the button retry-ready (the never-stale contract). */
async function startSync() {
let r;
try {
r = await fetch("/api/sync", { method: "POST" });
} catch {
showSyncError("Could not reach the server to start the sync — try again.");
return;
}
if (r.status === 403) {
stopSyncPolling();
syncBtn.hidden = true;
applySyncIdle();
return;
}
if (r.status === 202 || r.status === 409) {
enterRunningState();
startSyncPolling();
return;
}
let detail = "";
try {
detail = (await r.json()).detail || "";
} catch {
/* non-JSON error body */
}
showSyncError(detail || `The server refused to start the sync (${r.status}).`);
}
/* Load-time re-attach (admin only — the IIFE runs this after the
* whoami gate): a running run re-enters the running state (the user may
* have reloaded mid-sync), a terminal run renders its last result, idle
* renders nothing. */
async function initSyncButton() {
if (!syncBtn) return;
let status;
try {
const r = await fetch("/api/sync/status");
if (r.status === 403) {
syncBtn.hidden = true; // defense in depth
return;
}
if (!r.ok) return;
status = await r.json();
} catch {
return; // network blip — the button stays idle and clickable
}
if (status.state === "running") {
enterRunningState();
startSyncPolling();
} else if (status.state === "success") {
applySyncSuccess(status);
} else if (status.state === "failed") {
applySyncFailure(status);
}
/* idle → nothing to render */
}
if (syncBtn) syncBtn.addEventListener("click", startSync);
(async () => {
await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar
if (!(await isAdmin())) {
@@ -158,4 +388,5 @@ function showEmpty() {
}
if (gateEl) gateEl.hidden = true;
loadDocs();
initSyncButton(); // phase 32: re-attach to a running / last sync run
})();
+59
View File
@@ -313,6 +313,48 @@ html::after {
.auth-link:disabled { opacity: 0.6; cursor: wait; }
.auth-link svg { width: 16px; height: 16px; display: none; }
/* Phase 32: the admin-only "Sync sources" pill (Sources header) — the
same ghost pill as New chat / the auth links, so the bar keeps one
visual language. ink-soft on surface ≈6.9:1 (WCAG AA); hover pair
brand-ink/brand-soft ≈6.9:1. The refresh icon is always visible (it
doubles as the running-state spinner); icon-only below 640px like
the other pills (aria-label keeps the accessible name). ≥44px touch
target at every width; :focus-visible via the global rule. */
.sync-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
}
.sync-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
.sync-btn:disabled { opacity: 0.6; cursor: wait; }
.sync-icon { width: 16px; height: 16px; display: block; flex: 0 0 auto; }
/* Running state: the refresh icon spins (reuses the shared spin
keyframes) — the visible half of "Syncing…" while the 2 s poll waits. */
.sync-btn .sync-icon.is-spinning { animation: spin 1s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.sync-btn .sync-icon.is-spinning { animation: none; }
}
/* The aria-live last-result announcer ("2 added · 1 pruned") — soft ink
on the header surface (≈6.9:1), small mono to match the stat cards. */
.sync-result {
color: var(--ink-soft);
font-family: var(--mono);
font-size: 0.8rem;
white-space: nowrap;
}
/* "Tuning" toggle (phase 15): ghost pill like New chat + a mono count
badge (brand-ink on brand-soft ≈6.9:1). The label is visually-hidden
(not removed) below 640px so the accessible name keeps the word.
@@ -1598,6 +1640,23 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.auth-link { padding: 0.4rem 0.55rem; }
.auth-label { display: none; }
.auth-link svg { display: block; }
/* Phase 32: the sync pill goes icon-only like the other pills (the
aria-label keeps the accessible name); the spinning icon is the
visible running state on a touch screen. */
.sync-btn { padding: 0.4rem 0.55rem; }
.sync-label { display: none; }
/* The last-result counts stay ANNOUNCED (aria-live is untouched) but
go visually hidden — the 58px bar has no room for the text; the
icon carries the visible state. Same clip recipe as .steering-label. */
.sync-result {
position: absolute !important;
width: 1px; height: 1px;
margin: -1px; padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.steering-toggle { padding: 0.4rem 0.55rem; }
/* Visually hidden, NOT display:none — the accessible name keeps the
word "Tuning" next to the count badge. */