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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
+27
-1
@@ -37,6 +37,20 @@
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
<span class="new-chat-label">New chat</span>
|
||||
</button>
|
||||
<!-- Phase 32: the admin-only "Sync sources" button (TODO.md:5) —
|
||||
SHIPS hidden (anonymous-safe), header.js reveals it for the
|
||||
admin on the SAME cached whoami that reveals #nav-sources /
|
||||
#nav-tuning (one fetch, no extra whoami call). sources.js
|
||||
drives the §7.4 "never stale" lifecycle: idle → "Syncing…"
|
||||
(disabled + spinning icon + 2 s GET /api/sync/status poll) →
|
||||
last result ("Synced HH:MM" + counts in #sync-result) or the
|
||||
role="alert" error banner. #sync-result is the aria-live
|
||||
announcer for the last result. -->
|
||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
||||
</button>
|
||||
<span class="sync-result" id="sync-result" role="status" aria-live="polite"></span>
|
||||
<a href="/login.html?next=/sources.html" class="auth-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
@@ -50,11 +64,23 @@
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<div class="container sources-shell">
|
||||
<!-- Phase 32: the sync failure banner — the chat error-banner
|
||||
markup style (kb-banner + is-error), role="alert" so a failed
|
||||
sync is announced. sources.js fills #sync-error-text and
|
||||
un-hides it on a failed run (the button re-enables,
|
||||
retry-ready); a new sync hides it again. -->
|
||||
<div class="kb-banner is-error" id="sync-error-banner" role="alert" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.6 22.2 20.4H1.8Z"/><path d="M12 9.5v4.6"/><path d="M12 17.4h.01"/></svg>
|
||||
<span id="sync-error-text"></span>
|
||||
</div>
|
||||
|
||||
<div class="page-head">
|
||||
<h1>Knowledge base</h1>
|
||||
<p class="page-sub">
|
||||
Every <code>*.md</code> file indexed from <code>~/Homelab</code> and
|
||||
<code>~/Deployments</code>. Re-run the import to refresh.
|
||||
<code>~/Deployments</code>. Re-run the import to refresh — or hit
|
||||
<strong>Sync sources</strong> in the header to clone the repos and
|
||||
re-import.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user