712 lines
31 KiB
JavaScript
712 lines
31 KiB
JavaScript
/* Brain of Reese — Git sources admin page (phase 35, task 04;
|
|
* archive uploads, phase 49 task 03).
|
|
*
|
|
* The page module for /git-sources.html: the admin-only manager for the
|
|
* stored source list (git-sources table, phase 35 tasks 01/02) — git
|
|
* repo URLs (kind "git") and uploaded archives unpacked under
|
|
* BOR_UPLOAD_DIR (kind "local", phase 49; the phase-38
|
|
* local-directory form is gone — the kind=local API POST is
|
|
* unchanged, the page just no longer offers it).
|
|
* This module is the single owner of the page's behaviour:
|
|
*
|
|
* • boot — initSharedHeader() (one cached whoami, shared with the
|
|
* header toggling): anonymous → the sign-in gate shows and the
|
|
* manager stays hidden (the exact Sources page gate pattern, and
|
|
* NO /api/git-sources call is made); admin → gate hidden,
|
|
* #git-sources-content revealed, loadSources().
|
|
* • loadSources() — GET /api/git-sources → the table rows
|
|
* (#git-sources-tbody), the env-fallback note's visibility
|
|
* (from_env), and the empty state. Each row leads with its kind
|
|
* badge (Git/Local — text + color, never color alone) plus the
|
|
* location in a mono <code>: the git URL, or the full local path
|
|
* for kind "local" rows (phase 38). Values are ALWAYS rendered
|
|
* with textContent — never innerHTML (URLs may embed user:pass@
|
|
* credentials; phase 32's masking discipline). Non-2xx or a
|
|
* network failure renders the role="alert" load error with a
|
|
* retry button — never a stuck page.
|
|
* • add — #git-source-form submit → POST /api/git-sources {url}.
|
|
* The §7.4 never-stale lifecycle (wireAddForm): the button
|
|
* disables + relabels "Adding…" while the request is out,
|
|
* re-enables on success AND failure. 201 clears the input,
|
|
* reloads the list, and focuses the new row's Remove button
|
|
* (a11y); a failure (409 duplicate, 422 validation) shows the
|
|
* server detail inline under the form (role="alert", 422
|
|
* shape-aware like the tuning forms) and keeps the input — the
|
|
* instruction survives. 409/422 details are fixed generic strings
|
|
* (credential safety — the URL is never echoed).
|
|
* • upload — #archive-upload-form submit (phase 49, reworked to the
|
|
* phase-64 202 contract in task 05 — the phase-49 synchronous
|
|
* 200 paragraph is superseded): POST
|
|
* /api/git-sources/upload with a FormData file (NO manual
|
|
* Content-Type — the browser sets the multipart boundary). The
|
|
* §7.4 never-stale lifecycle keeps its shape — the button
|
|
* disables + relabels "Uploading…" while the request is out —
|
|
* but the transfer is now short: the 202 arrives the moment the
|
|
* archive is safely on disk (A1). 202 → the page-local
|
|
* "Successfully uploaded — <file>" toast fires (showUploadToast,
|
|
* the phase-55 share-toast pattern; A2: safe to navigate away),
|
|
* the file input clears, and the button hands over to the scan —
|
|
* the processing state ("Processing…", disabled, title cleared)
|
|
* plus startUploadPolling(): a 2 s poll of
|
|
* GET /api/git-sources/upload/status renders the live
|
|
* "Processing… <file> (n/m)" label (A4 — bare during unpack; the
|
|
* full path rides the button title) and settles it: success →
|
|
* the sync-style count line (fmtUploadResult, the role=status
|
|
* result line) + the "Archive uploaded: …" announce +
|
|
* loadSources (the new/updated row lands with the Local badge;
|
|
* a re-upload refreshes the row — no duplicate; NO second toast
|
|
* — A2); failure → the sanitized server error in the role=alert
|
|
* banner + loadSources, the file selection KEPT for a one-click
|
|
* re-upload. 409 (an upload is already in progress) raises NO
|
|
* error banner — it re-attaches to the in-flight run (processing
|
|
* state + poll, never stale). Other non-2xx (422 format/name,
|
|
* 413 cap, 5xx) keep the phase-49 error banner + the kept file
|
|
* selection. The submit finally restores the button ONLY when no
|
|
* poll is active (§7.4). Boot re-attach (initUploadStatus, admin
|
|
* branch): a running scan re-enters the processing state + poll
|
|
* (a reload mid-scan re-attaches — no second upload), a terminal
|
|
* run re-renders its result line / error banner.
|
|
* • remove — a row's Remove button asks window.confirm first
|
|
* (removal prunes the documents only on the NEXT sync — the
|
|
* confirm says so). Cancel → nothing; ok → the row button
|
|
* disables, DELETE /api/git-sources/{id}, loadSources(). A
|
|
* failure shows a per-row role="alert" error and re-enables the
|
|
* button. Env-fallback rows (id null — the list comes from
|
|
* BOR_GIT_SOURCES, not the table) carry no Remove: nothing is
|
|
* stored to remove — they show a "from .env" tag instead.
|
|
* • announce(msg) — #git-sources-announcer (role=status,
|
|
* aria-live=polite): the screen-reader confirmation for loads,
|
|
* adds, and removals.
|
|
*
|
|
* Scope boundary (phase locked decisions): adding a git repo or
|
|
* removing a source does NOT clone, import, or prune — the sync
|
|
* service (server-side) performs that; the page's hint box says so.
|
|
* The phase-49 upload is the exception: it unpacks and scans the
|
|
* single source in place (the phase-64 background task — 202 +
|
|
* status endpoint), and its counts render as the result line.
|
|
*
|
|
* The shared header module loads through this script's own relative
|
|
* import ("./header.js") — a hoisted import evaluated before this body
|
|
* runs (single-evaluation design: no direct <script> tag; esbuild
|
|
* inlines it into the page bundle in the image build).
|
|
*/
|
|
|
|
import { initSharedHeader } from "./header.js";
|
|
|
|
/* ---------- page elements (git-sources.html, task 04) ---------- */
|
|
const gateEl = document.querySelector("#git-sources-gate");
|
|
const contentEl = document.querySelector("#git-sources-content");
|
|
const formEl = document.querySelector("#git-source-form");
|
|
const urlInput = document.querySelector("#git-source-url");
|
|
const addBtn = document.querySelector("#git-source-add");
|
|
const addError = document.querySelector("#git-source-error");
|
|
/* Phase 49: the archive upload form (replaces the phase-38 local
|
|
directory form — same card, a file input instead of a path input).
|
|
The response counts render in the role=status result line. */
|
|
const uploadFormEl = document.querySelector("#archive-upload-form");
|
|
const uploadFileInput = document.querySelector("#archive-upload-file");
|
|
const uploadBtn = document.querySelector("#archive-upload-btn");
|
|
const uploadError = document.querySelector("#archive-upload-error");
|
|
const uploadResult = document.querySelector("#archive-upload-result");
|
|
const loadErrorEl = document.querySelector("#git-sources-load-error");
|
|
const loadErrorText = document.querySelector("#git-sources-load-error-text");
|
|
const retryBtn = document.querySelector("#git-sources-retry");
|
|
const tableWrap = document.querySelector("#git-sources-table-wrap");
|
|
const tbody = document.querySelector("#git-sources-tbody");
|
|
const emptyEl = document.querySelector("#git-sources-empty");
|
|
const envNote = document.querySelector("#git-sources-env-note");
|
|
const announcer = document.querySelector("#git-sources-announcer");
|
|
|
|
/* Polite live region: the screen-reader confirmation for loads, adds,
|
|
and removals (the phase-15 announcer pattern). */
|
|
function announce(message) {
|
|
if (announcer) announcer.textContent = message;
|
|
}
|
|
|
|
/* Added date — localized (toLocaleString); env-fallback rows carry
|
|
added_at null, and a corrupt timestamp must not blank the cell. */
|
|
function fmtDate(iso) {
|
|
if (!iso) return "—";
|
|
try {
|
|
return new Date(iso).toLocaleString();
|
|
} catch {
|
|
return "—";
|
|
}
|
|
}
|
|
|
|
/* FastAPI error bodies: a string detail or the validation-error array
|
|
(the first entry's msg is the human line). Same extraction as
|
|
tuning.js — 422 shape-aware. */
|
|
async function apiDetail(r, fallback) {
|
|
try {
|
|
const data = await r.json();
|
|
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
|
return String(data.detail[0].msg);
|
|
}
|
|
if (typeof data.detail === "string" && data.detail) return data.detail;
|
|
} catch {
|
|
/* non-JSON error body */
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
/* ---------- load / render ---------- */
|
|
|
|
/* GET /api/git-sources → the table rows + env note + empty state.
|
|
The load error (role="alert" + retry) is the ONLY terminal state a
|
|
failed fetch may reach — never a stuck page. */
|
|
async function loadSources() {
|
|
let r;
|
|
try {
|
|
r = await fetch("/api/git-sources");
|
|
} catch {
|
|
showLoadError("Could not reach the server — is the app running?");
|
|
return;
|
|
}
|
|
if (!r.ok) {
|
|
showLoadError(await apiDetail(r, `The server could not list the git sources (${r.status}).`));
|
|
return;
|
|
}
|
|
let data;
|
|
try {
|
|
data = await r.json();
|
|
} catch {
|
|
showLoadError("The server sent an unreadable list — try again.");
|
|
return;
|
|
}
|
|
hideLoadError();
|
|
const sources = Array.isArray(data.sources) ? data.sources : [];
|
|
renderSources(sources, data.from_env === true);
|
|
announce(`${sources.length} source${sources.length === 1 ? "" : "s"} listed.`);
|
|
}
|
|
|
|
function showLoadError(message) {
|
|
if (loadErrorText) loadErrorText.textContent = message;
|
|
if (loadErrorEl) loadErrorEl.hidden = false;
|
|
// The list state is unknown — hide the table AND the empty state so
|
|
// the error is the only claim about the list's contents.
|
|
if (tableWrap) tableWrap.hidden = true;
|
|
if (emptyEl) emptyEl.hidden = true;
|
|
}
|
|
|
|
function hideLoadError() {
|
|
if (loadErrorEl) loadErrorEl.hidden = true;
|
|
if (loadErrorText) loadErrorText.textContent = "";
|
|
}
|
|
|
|
function renderSources(sources, fromEnv) {
|
|
if (envNote) envNote.hidden = !fromEnv;
|
|
if (tbody) {
|
|
tbody.replaceChildren();
|
|
for (const s of sources) tbody.appendChild(makeRow(s));
|
|
}
|
|
const hasRows = sources.length > 0;
|
|
if (tableWrap) tableWrap.hidden = !hasRows;
|
|
if (emptyEl) emptyEl.hidden = hasRows;
|
|
}
|
|
|
|
/* One row: the kind badge (Git/Local — phase 38) followed by the
|
|
location in a mono <code> (textContent only — git URLs may contain
|
|
credentials, local paths may contain anything), the localized added
|
|
date ("—" for env fallback rows), and the per-row Remove button —
|
|
or the "from .env" tag for env-fallback rows (id null: nothing is
|
|
stored to remove; the env note says where the active list comes
|
|
from). The value is the git URL for kind "git" rows and the full
|
|
local path for kind "local" rows (the API reports the path in both
|
|
`path` and `url`; `path` is the kind-typed field). */
|
|
const REMOVE_ICON =
|
|
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
|
|
|
|
function makeRow(s) {
|
|
const tr = document.createElement("tr");
|
|
if (s.id) tr.dataset.id = s.id;
|
|
|
|
const isLocal = s.kind === "local";
|
|
const value = isLocal ? (s.path ?? s.url) : s.url;
|
|
const kindLabel = isLocal ? "local" : "git";
|
|
|
|
const urlTd = document.createElement("td");
|
|
urlTd.className = "git-source-url-cell";
|
|
urlTd.title = value; // full URL/path on hover (long values scroll the wrapper)
|
|
const badge = document.createElement("span");
|
|
badge.className = `git-source-kind is-${isLocal ? "local" : "git"}`;
|
|
badge.textContent = isLocal ? "Local" : "Git"; // text + color, never color alone
|
|
const code = document.createElement("code");
|
|
code.textContent = value; // rendered as text, never as HTML
|
|
urlTd.append(badge, code);
|
|
tr.appendChild(urlTd);
|
|
|
|
const addedTd = document.createElement("td");
|
|
addedTd.textContent = fmtDate(s.added_at);
|
|
tr.appendChild(addedTd);
|
|
|
|
const actTd = document.createElement("td");
|
|
actTd.className = "git-source-actions-cell";
|
|
if (s.id) {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "git-source-remove";
|
|
btn.setAttribute("aria-label", `Remove ${kindLabel} source: ${value}`);
|
|
btn.innerHTML = REMOVE_ICON + "<span>Remove</span>";
|
|
const rowError = document.createElement("span");
|
|
rowError.className = "git-source-row-error";
|
|
rowError.setAttribute("role", "alert");
|
|
rowError.hidden = true;
|
|
btn.addEventListener("click", () => removeSource(s, btn, rowError, kindLabel));
|
|
actTd.append(btn, rowError);
|
|
} else {
|
|
const tag = document.createElement("span");
|
|
tag.className = "git-source-env-tag";
|
|
tag.textContent = "from .env";
|
|
actTd.appendChild(tag);
|
|
}
|
|
tr.appendChild(actTd);
|
|
return tr;
|
|
}
|
|
|
|
/* ---------- remove (DELETE /api/git-sources/{id}) ----------
|
|
* Removal does not prune anything immediately — the next sync does
|
|
* (phase scope boundary), so the confirm says exactly that. Cancel →
|
|
* nothing; a failed delete → per-row role="alert" error + re-enabled
|
|
* button (never a stuck row); success → the list reloads. */
|
|
async function removeSource(s, btn, rowError, kindLabel) {
|
|
const ok = window.confirm(
|
|
`Remove this ${kindLabel} source from the list? Its documents stay indexed until the next sync prunes them.`,
|
|
);
|
|
if (!ok) return;
|
|
btn.disabled = true; // one delete per click
|
|
rowError.hidden = true;
|
|
try {
|
|
const r = await fetch(`/api/git-sources/${encodeURIComponent(s.id)}`, { method: "DELETE" });
|
|
if (!r.ok) {
|
|
rowError.textContent = await apiDetail(r, `Could not remove the ${kindLabel} source — try again.`);
|
|
rowError.hidden = false;
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
announce("Source removed.");
|
|
await loadSources(); // 204: the server confirmed — the list re-renders
|
|
} catch {
|
|
rowError.textContent = `Could not remove the ${kindLabel} source — is the app reachable?`;
|
|
rowError.hidden = false;
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
/* ---------- add (POST /api/git-sources) — the git form ----------
|
|
* wireAddForm gives the form the §7.4 never-stale lifecycle: while
|
|
* the request is out the button disables + relabels "Adding…" and
|
|
* re-enables (idle label restored) on success AND failure. 201 clears
|
|
* the input, reloads the list, and focuses the new row's Remove button
|
|
* (a11y); a failure (409 duplicate, 422 validation) shows the server
|
|
* detail inline under the form (role="alert", 422 shape-aware via
|
|
* apiDetail) and keeps the input — the fix is one edit, not a re-type.
|
|
* 409/422 details are fixed generic strings (credential safety — the
|
|
* URL is never echoed). */
|
|
function wireAddForm(opts) {
|
|
const { form, input, btn, error } = opts;
|
|
if (!form || !input || !btn) return;
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
// Client-side non-empty check (the input is `required` too — the
|
|
// browser's native prompt is the first line, this one the second).
|
|
const value = input.value.trim();
|
|
if (!value) {
|
|
if (error) {
|
|
error.textContent = opts.emptyMessage;
|
|
error.hidden = false;
|
|
}
|
|
return;
|
|
}
|
|
if (error) error.hidden = true; // a new attempt starts clean
|
|
btn.disabled = true; // §7.4: one POST per click
|
|
btn.textContent = "Adding…";
|
|
try {
|
|
const r = await fetch("/api/git-sources", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(opts.body(value)),
|
|
});
|
|
if (r.ok) {
|
|
let createdId = null;
|
|
try {
|
|
createdId = (await r.json()).id ?? null;
|
|
} catch {
|
|
/* the 201 body is advisory — the reload is the truth */
|
|
}
|
|
input.value = ""; // 201: the source is stored
|
|
announce(opts.addedMessage);
|
|
await loadSources(); // the new row lands in the table
|
|
focusNewRow(createdId); // a11y: land the caret on the new row
|
|
return;
|
|
}
|
|
// 409 duplicate / 422 validation / anything else: the server
|
|
// detail inline, form kept — the input survives so the fix is
|
|
// one edit, not a re-type.
|
|
if (error) {
|
|
error.textContent = await apiDetail(r, opts.failMessage);
|
|
error.hidden = false;
|
|
}
|
|
} catch {
|
|
if (error) {
|
|
error.textContent = opts.networkMessage;
|
|
error.hidden = false;
|
|
}
|
|
} finally {
|
|
btn.disabled = false; // never stale — success OR failure
|
|
btn.textContent = opts.idleLabel;
|
|
}
|
|
});
|
|
}
|
|
|
|
wireAddForm({
|
|
form: formEl,
|
|
input: urlInput,
|
|
btn: addBtn,
|
|
error: addError,
|
|
body: (url) => ({ url }),
|
|
emptyMessage: "Enter a git URL to add.",
|
|
failMessage: "Could not add the git source — try again.",
|
|
networkMessage: "Could not add the git source — is the app reachable?",
|
|
addedMessage: "Git source added.",
|
|
idleLabel: "Add source",
|
|
});
|
|
|
|
/* ---------- upload (POST /api/git-sources/upload) — phase 64 (task 05) -------
|
|
* The archive upload form follows the phase-64 202 contract (A1):
|
|
* the file input's selection is posted as FormData (the browser sets
|
|
* the multipart boundary — no manual Content-Type), and the 202
|
|
* answers the moment the archive is safely on disk — the "Uploading…"
|
|
* label covers only that short receive. Then the button HANDS OVER to
|
|
* the scan: 202 → the page-local "Successfully uploaded — <file>"
|
|
* toast (showUploadToast — A2, safe to navigate away), the file input
|
|
* clears, and the processing state ("Processing…", disabled, title
|
|
* cleared) + startUploadPolling() own it — a 2 s poll of
|
|
* GET /api/git-sources/upload/status renders the live "Processing…
|
|
* <file> (n/m)" label (A4 — bare during unpack; the full path rides
|
|
* the button title) and settles it: success → the sync-style count
|
|
* line (fmtUploadResult) in the role=status result line + the
|
|
* "Archive uploaded: …" announce + loadSources (NO second toast — it
|
|
* already fired at the 202, A2); failure → the sanitized server
|
|
* error in the role=alert banner + loadSources, the file selection
|
|
* KEPT for a one-click re-upload. 409 (an upload is already in
|
|
* progress) raises NO error banner — it re-attaches to the in-flight
|
|
* run (processing state + poll, never stale); the phase-49 "server
|
|
* detail inline for 409" branch is superseded. Other non-2xx (422
|
|
* format/name, 413 cap, 5xx) keep the phase-49 error banner + the
|
|
* kept file selection; a network failure keeps the fixed line. The
|
|
* submit finally restores the button ONLY when no poll is active
|
|
* (PLAN §7.4 — while startUploadPolling owns the button it stays
|
|
* disabled / "Processing…"). Boot re-attach (initUploadStatus, the
|
|
* admin branch): a running scan re-enters the processing state + poll
|
|
* (no second upload, no error); a terminal run re-renders its result
|
|
* line (success) or error banner (failed); idle does nothing.
|
|
* (The phase-49 synchronous 200 paragraph is superseded by phase 64.) */
|
|
|
|
/* The success line's text — the sync-result shape (sources.js's
|
|
fmtSyncResult convention): "N added" always leads, then updated /
|
|
unchanged / pruned — zero parts omitted (unchanged is shown
|
|
when nothing was added or updated). Reads exactly the keys the
|
|
upload status's detail carries (task 03's UploadOut-shaped dict). */
|
|
function fmtUploadResult(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(" · ");
|
|
}
|
|
|
|
/* Upload-success toast (phase 64 task 05, A2 — owner-locked): the
|
|
* "successfully uploaded" confirmation, the phase-55 share-toast
|
|
* pattern (frontend/assets/app.js) made page-local. A SINGLE node —
|
|
* lazy-created on the first 202 and reused (toasts never stack): a
|
|
* new toast replaces a pending one (clear the prior dismiss timer,
|
|
* re-run the entry). role="status" aria-live="polite" — on THIS page
|
|
* the toast IS the a11y announcer for the 202 (there is no other
|
|
* live-region line for it). SUCCESS-ONLY (A2): failures are the
|
|
* #archive-upload-error banner, never a toast. The .toast CSS ships
|
|
* as-is (styles.css, phase 55). */
|
|
const UPLOAD_TOAST_MS = 5000; // ~5 s auto-dismiss (A2)
|
|
let uploadToastEl = null; // the single toast node — lazy-created, reused
|
|
let uploadToastTimer = 0; // the pending auto-dismiss (replaced by a new toast)
|
|
|
|
function showUploadToast(message) {
|
|
if (!uploadToastEl) {
|
|
uploadToastEl = document.createElement("div");
|
|
uploadToastEl.className = "toast";
|
|
uploadToastEl.setAttribute("role", "status");
|
|
uploadToastEl.setAttribute("aria-live", "polite");
|
|
document.body.appendChild(uploadToastEl);
|
|
}
|
|
uploadToastEl.textContent = message; // XSS-safe text assignment
|
|
// Re-trigger the entry even when a toast is already up (a second
|
|
// upload accepted while the first toast is showing): clear the
|
|
// pending dismiss, drop the visible state, force a reflow
|
|
// (restarts the CSS transition), then show again.
|
|
clearTimeout(uploadToastTimer);
|
|
uploadToastEl.classList.remove("is-visible");
|
|
void uploadToastEl.offsetWidth; // force reflow — the entry transition restarts
|
|
uploadToastEl.classList.add("is-visible");
|
|
uploadToastTimer = setTimeout(() => {
|
|
uploadToastEl.classList.remove("is-visible"); // auto-dismiss ~5 s
|
|
}, UPLOAD_TOAST_MS);
|
|
}
|
|
|
|
/* The scan poll (phase 64 task 05): a 2 s cadence — the SYNC_POLL_MS
|
|
* house value. Single timer, one loop at a time (the guard makes a
|
|
* double-start a no-op, and the submit finally reads this same
|
|
* variable to know whether the poll OWNS the button). Each tick
|
|
* fetches GET /api/git-sources/upload/status: running → the live
|
|
* "Processing… <file> (n/m)" label (A4 — bare "Processing…" during
|
|
* the unpack phase, before any file is indexed; the full untruncated
|
|
* path rides the button title) + reschedule; success → stop + the
|
|
* result line + the announcement + the row reload (NO toast — it
|
|
* fired at the 202, A2); failed → stop + the sanitized server error
|
|
* banner + the row reload (a post-swap failure keeps the row — the
|
|
* list state may have changed), the file selection kept for a
|
|
* one-click re-upload; idle → stop + the button restored
|
|
* (defensive — a started run never returns to idle). A network blip
|
|
* retries next tick. */
|
|
const UPLOAD_POLL_MS = 2000; // the SYNC_POLL_MS house value
|
|
let uploadPollTimer = null; // null = no poll active (the finally's guard)
|
|
|
|
function stopUploadPolling() {
|
|
if (uploadPollTimer !== null) {
|
|
clearTimeout(uploadPollTimer);
|
|
uploadPollTimer = null;
|
|
}
|
|
}
|
|
|
|
/* The button's processing entry (the 202 + the 409 re-attach): from
|
|
* here the poll OWNS it — disabled, "Processing…", title cleared (a
|
|
* live file lands on it at the first tick). */
|
|
function enterUploadProcessingState() {
|
|
uploadBtn.disabled = true;
|
|
uploadBtn.textContent = "Processing…";
|
|
uploadBtn.title = "";
|
|
}
|
|
|
|
/* The idle restore (the poll's terminal branches + the submit
|
|
* finally, which calls this ONLY when no poll is active — PLAN §7.4). */
|
|
function restoreUploadButton() {
|
|
uploadBtn.disabled = false; // never stale — success OR failure
|
|
uploadBtn.textContent = "Upload & scan";
|
|
uploadBtn.removeAttribute("title");
|
|
}
|
|
|
|
function startUploadPolling() {
|
|
if (uploadPollTimer !== null) return; // one poll loop at a time
|
|
const tick = async () => {
|
|
let status = null;
|
|
try {
|
|
const r = await fetch("/api/git-sources/upload/status");
|
|
if (r.ok) status = await r.json();
|
|
} catch { /* network blip — retry next tick */ }
|
|
if (!status) {
|
|
uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS);
|
|
return;
|
|
}
|
|
// running: the live file label (A4 — bare "Processing…" during
|
|
// the unpack phase, before any file is indexed).
|
|
if (status.state === "running") {
|
|
uploadBtn.textContent =
|
|
"Processing…" +
|
|
(status.current_file ? ` ${status.current_file}` : "") +
|
|
(status.files_total > 0 ? ` (${status.files_done}/${status.files_total})` : "");
|
|
uploadBtn.title = status.current_file || ""; // full path on hover
|
|
uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS);
|
|
return;
|
|
}
|
|
stopUploadPolling();
|
|
if (status.state === "success") {
|
|
// The scan finished: the result line (the existing helper reads
|
|
// exactly these keys), the announcement, the row lands. NO toast
|
|
// here — it already fired at the 202 (A2).
|
|
const detail = status.detail || {};
|
|
if (uploadResult) {
|
|
uploadResult.textContent = fmtUploadResult(detail);
|
|
uploadResult.hidden = false;
|
|
}
|
|
announce(`Archive uploaded: ${detail.source}.`);
|
|
uploadFileInput.value = "";
|
|
restoreUploadButton();
|
|
loadSources(); // the row lands / refreshes
|
|
return;
|
|
}
|
|
if (status.state === "failed") {
|
|
// Sanitized server-side (task 03's _sanitize_error): the banner
|
|
// is the failure UI (A2), the selection stays KEPT for a
|
|
// one-click re-upload, and the list reloads (a post-swap failure
|
|
// keeps the row — the list state may have changed).
|
|
if (uploadError) {
|
|
uploadError.textContent = status.error || "The upload scan failed.";
|
|
uploadError.hidden = false;
|
|
}
|
|
restoreUploadButton();
|
|
loadSources(); // the list state may have changed
|
|
return;
|
|
}
|
|
// idle: defensive — a started run never returns to idle; just
|
|
// settle the button (the poll stopped above).
|
|
restoreUploadButton();
|
|
};
|
|
uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS);
|
|
}
|
|
|
|
/* Boot re-attach (phase 64 task 05, the admin branch only): fetch the
|
|
* upload status ONCE — a running scan re-enters the processing state
|
|
* + the poll (a reload mid-scan re-attaches instead of dead-ending —
|
|
* no second upload, no error); a finished run re-renders its result
|
|
* line ONLY (no announce, no toast — the toast fired at the 202, A2);
|
|
* a failed run re-renders its error banner; idle does nothing (and a
|
|
* blip is a no-op — the page boots honest either way). */
|
|
async function initUploadStatus() {
|
|
if (!uploadBtn) return;
|
|
let status;
|
|
try {
|
|
const r = await fetch("/api/git-sources/upload/status");
|
|
if (!r.ok) return;
|
|
status = await r.json();
|
|
} catch {
|
|
/* network blip — boot without the re-attach */
|
|
}
|
|
if (!status) return;
|
|
if (status.state === "running") {
|
|
enterUploadProcessingState();
|
|
startUploadPolling(); // the first tick carries the live file
|
|
return;
|
|
}
|
|
if (status.state === "success") {
|
|
// The last run's result line only — no announce, no toast (A2).
|
|
if (uploadResult) {
|
|
uploadResult.textContent = fmtUploadResult(status.detail);
|
|
uploadResult.hidden = false;
|
|
}
|
|
return;
|
|
}
|
|
if (status.state === "failed") {
|
|
if (uploadError) {
|
|
uploadError.textContent = status.error || "The upload scan failed.";
|
|
uploadError.hidden = false;
|
|
}
|
|
}
|
|
// idle: nothing to re-attach.
|
|
}
|
|
|
|
if (uploadFormEl && uploadFileInput && uploadBtn) {
|
|
uploadFormEl.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
// Client-side no-file check (the input is `required` too — the
|
|
// browser's native prompt is the first line, this one the second).
|
|
const file = uploadFileInput.files && uploadFileInput.files[0];
|
|
if (!file) {
|
|
if (uploadError) {
|
|
uploadError.textContent = "Choose an archive file to upload.";
|
|
uploadError.hidden = false;
|
|
}
|
|
return;
|
|
}
|
|
if (uploadError) uploadError.hidden = true;
|
|
if (uploadResult) uploadResult.hidden = true; // a new attempt starts clean
|
|
uploadBtn.disabled = true; // §7.4: one upload per click
|
|
uploadBtn.textContent = "Uploading…"; // the transfer is now short — the 202
|
|
try {
|
|
// Multipart from the form itself (the file input's name is
|
|
// "file") — the browser sets the boundary; NO manual
|
|
// Content-Type header.
|
|
const r = await fetch("/api/git-sources/upload", {
|
|
method: "POST",
|
|
body: new FormData(uploadFormEl),
|
|
});
|
|
if (r.status === 202) {
|
|
// The archive is safely on disk (A1) — the "successfully
|
|
// uploaded" moment: the toast fires NOW (A2), the file input
|
|
// clears, and the scan's poll takes over the button. The 202
|
|
// body (UploadAccepted) carries the safe source name; a body
|
|
// parse failure degrades to the picked file's name.
|
|
let name = file.name;
|
|
try {
|
|
const data = await r.json();
|
|
if (data && typeof data.name === "string" && data.name) name = data.name;
|
|
} catch {
|
|
/* body parse failure — the picked file's name degrades fine */
|
|
}
|
|
showUploadToast(`Successfully uploaded — ${name}`);
|
|
uploadFileInput.value = ""; // 202: the archive is on the server
|
|
enterUploadProcessingState();
|
|
startUploadPolling();
|
|
return;
|
|
}
|
|
// 409 (an upload is already in progress): NO error banner —
|
|
// re-attach to the in-flight run (never stale): the processing
|
|
// state + the poll track it to the terminal. The phase-49
|
|
// "server detail inline" branch does not apply to 409 anymore.
|
|
if (r.status === 409) {
|
|
enterUploadProcessingState();
|
|
startUploadPolling();
|
|
return;
|
|
}
|
|
// Other non-2xx (422 format/name, 413 cap, 5xx): the server
|
|
// detail inline, the file selection KEPT — the fix is one
|
|
// re-pick, not a re-type.
|
|
if (uploadError) {
|
|
uploadError.textContent = await apiDetail(r, "Could not upload the archive — try again.");
|
|
uploadError.hidden = false;
|
|
}
|
|
} catch {
|
|
if (uploadError) {
|
|
uploadError.textContent = "Could not upload the archive — is the app reachable?";
|
|
uploadError.hidden = false;
|
|
}
|
|
} finally {
|
|
// Never stale (PLAN §7.4) — but ONLY when no poll owns the
|
|
// button: while startUploadPolling tracks the scan (202 / 409)
|
|
// it stays disabled / "Processing…", so a finally restore here
|
|
// would race the poll. No poll → the button is ours to restore.
|
|
if (uploadPollTimer === null) restoreUploadButton();
|
|
}
|
|
});
|
|
}
|
|
|
|
/* After a successful add, focus the new row's Remove button so the
|
|
keyboard/screen-reader user lands where the new data is. The 201
|
|
body carries the row id (tr[data-id]); without one, the first row
|
|
is the fallback (the list is small and ordered). */
|
|
function focusNewRow(createdId) {
|
|
if (!tbody) return;
|
|
const row = createdId
|
|
? tbody.querySelector(`tr[data-id="${CSS.escape(createdId)}"]`)
|
|
: null;
|
|
const target = (row || tbody.querySelector("tr"))?.querySelector(".git-source-remove");
|
|
if (target) target.focus();
|
|
}
|
|
|
|
/* ---------- retry + boot ---------- */
|
|
|
|
if (retryBtn) retryBtn.addEventListener("click", () => loadSources());
|
|
|
|
(async () => {
|
|
// The shared header FIRST (Sign in/out + the admin-only nav links +
|
|
// the Sync button — one cached whoami), then the gate: anonymous
|
|
// visitors get the gate and NO /api/git-sources call (the Sources
|
|
// page gate pattern); the admin gets the manager.
|
|
const admin = await initSharedHeader();
|
|
if (!admin) {
|
|
if (gateEl) gateEl.hidden = false;
|
|
if (contentEl) contentEl.hidden = true; // ships hidden — stays hidden
|
|
return;
|
|
}
|
|
if (gateEl) gateEl.hidden = true;
|
|
if (contentEl) contentEl.hidden = false;
|
|
await loadSources();
|
|
// Phase 64 (task 05): re-attach a running scan (a reload mid-scan
|
|
// resumes the Processing state) or re-render a terminal run's
|
|
// result line / error banner.
|
|
await initUploadStatus();
|
|
})();
|