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