/* ============================================================
Face Intel · Intelligence Console — application logic
Vanilla JS, no external dependencies, ES2019+.
============================================================ */
(() => {
"use strict";
/* ----------------------------------------------------------------
* Constants
* ---------------------------------------------------------------- */
const API = {
health: "/health",
healthProviders: "/health/providers",
stats: "/stats",
providers: "/providers",
cache: "/cache",
jobs: "/jobs",
facesDetect: "/faces/detect",
facesRecognize: "/faces/recognize",
searchReverse: "/search/reverse",
analysisImage: "/analysis/image",
analysisMetadata: "/analysis/metadata",
analysisForensics: "/analysis/forensics",
};
// Map action keys to endpoint, label, and body builder.
const ACTIONS = {
detect: {
label: "Detect Faces", endpoint: API.facesDetect, kind: null, primary: false,
},
recognize: {
label: "Recognize", endpoint: API.facesRecognize, kind: null, primary: false,
},
reverse: {
label: "Reverse Search", endpoint: API.searchReverse, kind: null, primary: false,
},
image_analysis: {
label: "Image Analysis", endpoint: API.analysisImage, kind: null, primary: false,
},
metadata: {
label: "Metadata", endpoint: API.analysisMetadata, kind: null, primary: false,
},
forensics: {
label: "Forensics", endpoint: API.analysisForensics, kind: null, primary: false,
},
full_pipeline: {
label: "Full Pipeline", endpoint: API.jobs, kind: "full_pipeline", primary: true,
},
};
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
/* ----------------------------------------------------------------
* State
* ---------------------------------------------------------------- */
const state = {
imageBase64: null, // data URL ("data:image/...;base64,...")
imageMeta: null, // {name, type, size, dims}
imageUrl: null, // when URL is used instead of file
currentView: "analyze",
activeJobId: null,
requestInFlight: null, // AbortController
};
/* ----------------------------------------------------------------
* DOM helpers
* ---------------------------------------------------------------- */
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const SVG_TAGS = new Set([
"svg", "g", "path", "circle", "rect", "line", "polyline", "polygon",
"ellipse", "defs", "use", "symbol", "text", "tspan", "linearGradient",
"radialGradient", "stop", "clipPath", "mask", "pattern", "image", "title",
"desc", "foreignObject",
]);
const el = (tag, attrs = {}, ...children) => {
const node = SVG_TAGS.has(tag)
? document.createElementNS("http://www.w3.org/2000/svg", tag)
: document.createElement(tag);
const isSvg = SVG_TAGS.has(tag);
for (const [k, v] of Object.entries(attrs)) {
if (v == null) continue;
if (k === "class") {
if (isSvg) node.setAttribute("class", v);
else node.className = v;
} else if (k === "dataset") Object.assign(node.dataset, v);
else if (k === "html") node.innerHTML = v;
else if (k === "text") node.textContent = v;
else if (k.startsWith("aria-") || k.startsWith("data-")) node.setAttribute(k, v);
else if (k === "style" && typeof v === "object") Object.assign(node.style, v);
else if (isSvg) {
// SVG attributes must be set via setAttribute — property assignment
// fails silently for geometry like cx/cy/r/width/height/viewBox.
node.setAttribute(k, v);
} else if (k in node) {
try { node[k] = v; } catch { node.setAttribute(k, v); }
} else node.setAttribute(k, v);
}
for (const child of children.flat()) {
if (child == null || child === false) continue;
node.append(child.nodeType ? child : document.createTextNode(String(child)));
}
return node;
};
const clear = (node) => { while (node.firstChild) node.removeChild(node.firstChild); };
/* ----------------------------------------------------------------
* Formatting helpers
* ---------------------------------------------------------------- */
const fmtBytes = (n) => {
if (n == null) return "—";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
};
const fmtMs = (n) => (n == null ? "—" : `${Math.round(n).toLocaleString()} ms`);
const fmtNum = (n, d = 2) => (n == null || isNaN(n) ? "—" : Number(n).toFixed(d));
const fmtPct = (n, d = 1) => (n == null || isNaN(n) ? "—" : `${(n * 100).toFixed(d)}%`);
const fmtTime = (iso) => {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d)) return iso;
return d.toLocaleString(undefined, {
year: "numeric", month: "short", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
};
const fmtRelative = (iso) => {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d)) return iso;
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return `${Math.max(1, Math.floor(diff))}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
};
const truncate = (s, n = 60) => {
if (s == null) return "—";
s = String(s);
return s.length > n ? s.slice(0, n - 1) + "…" : s;
};
const shortId = (id) => (id ? String(id).slice(0, 10) : "—");
const escapeHtml = (s) => String(s ?? "")
.replace(/&/g, "&").replace(//g, ">")
.replace(/"/g, """).replace(/'/g, "'");
/* ----------------------------------------------------------------
* API client
* ---------------------------------------------------------------- */
async function api(method, path, { body, signal, raw = false } = {}) {
const opts = {
method,
headers: {},
signal,
};
if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
let resp;
try {
resp = await fetch(path, opts);
} catch (e) {
if (e.name === "AbortError") throw e;
const err = new Error(`Network error: ${e.message}`);
err.networkError = true;
throw err;
}
let payload = null;
const ct = resp.headers.get("content-type") || "";
if (ct.includes("application/json") || ct.includes("text/")) {
try {
if (ct.includes("application/json")) payload = await resp.json();
else payload = await resp.text();
} catch { payload = null; }
}
if (!resp.ok) {
const msg = (payload && (payload.detail || payload.error || payload.message))
|| `HTTP ${resp.status} ${resp.statusText}`;
const err = new Error(msg);
err.status = resp.status;
err.payload = payload;
throw err;
}
return raw ? resp : payload;
}
/* ----------------------------------------------------------------
* Toasts
* ---------------------------------------------------------------- */
let toastSeq = 0;
function toast(message, { type = "info", title, timeout = 5000 } = {}) {
const container = $("#toast-container");
const id = `toast-${++toastSeq}`;
const icon = { success: "✓", error: "!", warn: "!", info: "i" }[type] || "i";
const titles = { success: "Success", error: "Error", warn: "Warning", info: "Info" };
const node = el("div", { class: `toast ${type}`, id, role: "alert" },
el("div", { class: "toast-icon", "aria-hidden": "true" }, icon),
el("div", { class: "toast-body" },
el("div", { class: "toast-title" }, title || titles[type] || "Info"),
el("div", { class: "toast-msg" }, message),
),
el("button", {
class: "toast-close", "aria-label": "Dismiss notification", type: "button",
}, "×"),
);
node.querySelector(".toast-close").addEventListener("click", () => dismiss());
container.appendChild(node);
const timer = timeout > 0 ? setTimeout(dismiss, timeout) : null;
function dismiss() {
if (timer) clearTimeout(timer);
node.classList.add("leaving");
setTimeout(() => node.remove(), 200);
}
return { dismiss };
}
/* ----------------------------------------------------------------
* Loading overlay
* ---------------------------------------------------------------- */
let loadingDepth = 0;
function showLoading(text = "Working…") {
loadingDepth += 1;
$("#loading-text").textContent = text;
$("#loading-overlay").hidden = false;
}
function hideLoading() {
loadingDepth = Math.max(0, loadingDepth - 1);
if (loadingDepth === 0) $("#loading-overlay").hidden = true;
}
/* ----------------------------------------------------------------
* Connection pill
* ---------------------------------------------------------------- */
function setConnection(state, text) {
const pill = $("#connection-pill");
pill.classList.remove("ok", "warn", "err");
if (state) pill.classList.add(state);
pill.querySelector(".pill-text").textContent = text;
}
async function checkConnection() {
try {
const data = await api("GET", API.health);
if (data && data.status === "ok") {
setConnection("ok", "Online");
return true;
}
setConnection("warn", "Degraded");
return false;
} catch (e) {
setConnection("err", "Offline");
return false;
}
}
/* ----------------------------------------------------------------
* Theme
* ---------------------------------------------------------------- */
function initTheme() {
const saved = localStorage.getItem("fi-theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = saved || (prefersDark ? "dark" : "light");
document.documentElement.setAttribute("data-theme", theme);
}
function toggleTheme() {
const cur = document.documentElement.getAttribute("data-theme") || "light";
const next = cur === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("fi-theme", next);
}
/* ----------------------------------------------------------------
* Navigation
* ---------------------------------------------------------------- */
function switchView(view) {
state.currentView = view;
$$(".nav-item").forEach((btn) => {
const active = btn.dataset.view === view;
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-selected", active ? "true" : "false");
});
$$(".view").forEach((v) => {
const active = v.id === `view-${view}`;
v.classList.toggle("is-active", active);
v.hidden = !active;
});
const titles = {
analyze: "Analyze", report: "Report", providers: "Providers",
stats: "Metrics", jobs: "Jobs", health: "Health",
};
$("#view-title").textContent = titles[view] || view;
// Lazy-load panel data on first view
if (view === "providers") loadProviders();
if (view === "stats") loadStats();
if (view === "jobs") loadJobs();
if (view === "health") loadHealth();
closeSidebarMobile();
}
/* ----------------------------------------------------------------
* Mobile sidebar
* ---------------------------------------------------------------- */
let backdropNode = null;
function openSidebarMobile() {
$("#sidebar").classList.add("is-open");
$("#menu-toggle").setAttribute("aria-expanded", "true");
if (!backdropNode) {
backdropNode = el("div", { class: "backdrop" });
backdropNode.addEventListener("click", closeSidebarMobile);
document.body.appendChild(backdropNode);
}
requestAnimationFrame(() => backdropNode.classList.add("is-open"));
}
function closeSidebarMobile() {
if (!$("#sidebar").classList.contains("is-open")) return;
$("#sidebar").classList.remove("is-open");
$("#menu-toggle").setAttribute("aria-expanded", "false");
if (backdropNode) backdropNode.classList.remove("is-open");
}
/* ----------------------------------------------------------------
* Image input
* ---------------------------------------------------------------- */
function setActionButtonsEnabled(enabled) {
$$("#action-grid .action-btn").forEach((b) => { b.disabled = !enabled; });
$("#action-source-hint").textContent = enabled
? "Choose an action below"
: "Add an image or URL to enable actions";
}
function updateSourceHint() {
const hasImg = !!(state.imageBase64 || state.imageUrl);
setActionButtonsEnabled(hasImg);
}
function setPreviewFromDataUrl(dataUrl, meta) {
state.imageBase64 = dataUrl;
state.imageUrl = null;
state.imageMeta = meta || null;
const img = $("#preview-img");
img.src = dataUrl;
img.onload = () => {
if (meta && !meta.dims) {
meta.dims = `${img.naturalWidth} × ${img.naturalHeight}`;
}
$("#meta-dims").textContent = meta?.dims || `${img.naturalWidth} × ${img.naturalHeight}`;
};
$("#meta-name").textContent = meta?.name || "uploaded-image";
$("#meta-type").textContent = meta?.type || "—";
$("#meta-size").textContent = fmtBytes(meta?.size);
$("#meta-dims").textContent = meta?.dims || "—";
$("#preview-area").hidden = false;
updateSourceHint();
}
function clearPreview() {
state.imageBase64 = null;
state.imageMeta = null;
$("#preview-img").src = "";
$("#preview-area").hidden = true;
$("#file-input").value = "";
updateSourceHint();
}
function handleFile(file) {
if (!file) return;
if (!/^image\/(jpeg|png)$/.test(file.type)) {
toast("Only JPEG and PNG images are supported.", { type: "error" });
return;
}
if (file.size > MAX_IMAGE_BYTES) {
toast(`Image exceeds the 20 MB limit (${fmtBytes(file.size)}).`, { type: "error" });
return;
}
const reader = new FileReader();
reader.onload = () => {
setPreviewFromDataUrl(reader.result, {
name: file.name, type: file.type, size: file.size,
});
};
reader.onerror = () => toast("Failed to read file.", { type: "error" });
reader.readAsDataURL(file);
}
function loadImageFromUrl() {
const url = $("#image-url").value.trim();
if (!url) {
toast("Enter an image URL first.", { type: "warn" });
return;
}
try { new URL(url); } catch {
toast("That doesn't look like a valid URL.", { type: "warn" });
return;
}
state.imageUrl = url;
state.imageBase64 = null;
state.imageMeta = { name: url, type: "url", size: null, dims: null };
const img = $("#preview-img");
img.onload = () => {
$("#meta-dims").textContent = `${img.naturalWidth} × ${img.naturalHeight}`;
};
img.onerror = () => {
toast("Could not load the image preview (it may be cross-origin). The URL will still be sent to the API.",
{ type: "warn", timeout: 7000 });
};
img.src = url;
$("#meta-name").textContent = truncate(url, 50);
$("#meta-name").title = url;
$("#meta-type").textContent = "image/url";
$("#meta-size").textContent = "—";
$("#meta-dims").textContent = "—";
$("#preview-area").hidden = false;
updateSourceHint();
}
/** Build the request body for an action based on current image source. */
function buildActionBody(actionKey) {
const action = ACTIONS[actionKey];
const base = {};
if (state.imageBase64) {
// Strip "data:image/...;base64," prefix — API expects raw base64.
const b64 = state.imageBase64.includes(",")
? state.imageBase64.slice(state.imageBase64.indexOf(",") + 1)
: state.imageBase64;
base.image_base64 = b64;
} else if (state.imageUrl) {
base.image_url = state.imageUrl;
} else {
throw new Error("No image selected");
}
if (action.kind) base.kind = action.kind;
return base;
}
/* ----------------------------------------------------------------
* Run an action
* ---------------------------------------------------------------- */
async function runAction(actionKey) {
const action = ACTIONS[actionKey];
if (!action) return;
let body;
try {
body = buildActionBody(actionKey);
} catch (e) {
toast(e.message, { type: "warn" });
return;
}
if (state.requestInFlight) state.requestInFlight.abort();
const controller = new AbortController();
state.requestInFlight = controller;
showLoading(`Running ${action.label}…`);
setActionButtonsEnabled(false);
const startedAt = performance.now();
try {
const data = await api("POST", action.endpoint, { body, signal: controller.signal });
const elapsedFromApi = data?.elapsed_ms;
const clientElapsed = performance.now() - startedAt;
const elapsed = (typeof elapsedFromApi === "number") ? elapsedFromApi : clientElapsed;
// Extract report(s) — shape differs between convenience endpoints and /jobs.
let report = null;
let multiReport = null;
let jobMeta = null;
if (action.kind === "full_pipeline") {
// /jobs response: { job_id, status, result: {...}, elapsed_ms }
jobMeta = { job_id: data.job_id, status: data.status, elapsed_ms: data.elapsed_ms };
if (data.result && typeof data.result === "object") {
multiReport = extractPipelineReports(data.result);
}
} else {
// Convenience endpoint: { success, report, elapsed_ms }
if (data && data.report) {
report = data.report;
} else if (data && data.success === false) {
throw new Error(data.error || "Action failed");
}
}
if (!report && !multiReport) {
// Possibly an error payload
if (data && (data.error || data.detail)) {
throw new Error(data.error || data.detail);
}
// Or a JobResult without a stored report (e.g. re-loaded full_pipeline)
if (data && data.job_id && data.result === undefined && data.report) {
report = data.report;
}
}
// Update last-run summary
$("#last-run").hidden = false;
$("#last-run-name").textContent = action.label;
$("#last-run-elapsed").textContent = fmtMs(elapsed);
const ok = data?.success !== false && data?.status !== "failed";
$("#last-run-status").innerHTML = "";
$("#last-run-status").appendChild(
el("span", { class: `badge ${ok ? "success" : "danger"}` },
ok ? "success" : "failed"));
// Render report
if (multiReport) {
renderMultiReport(multiReport, jobMeta);
} else if (report) {
renderReport(report, { source: action.label, elapsed_ms: elapsed, jobMeta });
} else {
showReportEmpty("The action completed but no report was returned.", action.label);
}
switchView("report");
toast(`${action.label} completed in ${fmtMs(elapsed)}.`, { type: "success" });
// Refresh jobs list if a job was created
if (action.kind === "full_pipeline" || jobMeta) {
loadJobs();
}
} catch (e) {
if (e.name === "AbortError") return;
const msg = e.networkError
? `Cannot reach the API (${e.message}). Is the server running?`
: (e.message || "Request failed");
toast(msg, { type: "error", timeout: 8000 });
$("#last-run").hidden = false;
$("#last-run-name").textContent = action.label;
$("#last-run-elapsed").textContent = "—";
$("#last-run-status").innerHTML = "";
$("#last-run-status").appendChild(el("span", { class: "badge danger" }, "failed"));
} finally {
hideLoading();
state.requestInFlight = null;
updateSourceHint();
}
}
/** Extract a map of {capability: UnifiedFaceReport} from a full_pipeline result. */
function extractPipelineReports(result) {
const out = {};
const known = ["detection", "recognition", "image_analysis", "metadata", "forensics", "search"];
for (const key of known) {
const sub = result[key];
if (sub && typeof sub === "object" && sub.report) {
out[key] = { report: sub.report, elapsed_ms: sub.elapsed_ms, success: sub.success !== false, error: sub.error };
} else if (sub && typeof sub === "object" && sub.success === false) {
out[key] = { report: null, elapsed_ms: sub.elapsed_ms, success: false, error: sub.error || "failed" };
}
}
return Object.keys(out).length ? out : null;
}
/* ----------------------------------------------------------------
* Report rendering
* ---------------------------------------------------------------- */
function showReportEmpty(msg, source) {
$("#report-empty").hidden = false;
$("#report-content").hidden = true;
if (msg) {
const p = $("#report-empty p");
if (p) p.textContent = msg;
}
}
function renderReport(report, ctx = {}) {
$("#report-empty").hidden = true;
const container = $("#report-content");
container.hidden = false;
clear(container);
if (!report || typeof report !== "object") {
container.appendChild(el("div", { class: "callout warn" },
el("div", {},
el("div", { class: "callout-title" }, "No report data"),
el("div", {}, "The API returned a successful response but no report payload."))));
return;
}
const meta = report.metadata || {};
const confidence = report.overall_confidence || null;
const conflicts = Array.isArray(report.conflicts) ? report.conflicts : [];
// Top: source + elapsed chip
container.appendChild(reportHeader(meta, ctx));
// Conflicts banner (if any)
if (conflicts.length) container.appendChild(renderConflicts(conflicts));
// Metadata card
container.appendChild(renderMetadata(meta));
// Overall confidence
if (confidence) container.appendChild(renderConfidence(confidence));
// Detections
if (report.detections && report.detections.length) {
container.appendChild(renderDetections(report.detections));
}
// Recognition matches
if (report.matches && report.matches.length) {
container.appendChild(renderMatches(report.matches));
}
// Image analyses
if (report.image_analyses && report.image_analyses.length) {
container.appendChild(renderImageAnalyses(report.image_analyses));
}
// Metadata extractions
if (report.metadata_extractions && report.metadata_extractions.length) {
container.appendChild(renderMetadataExtractions(report.metadata_extractions));
}
// Forensics
if (report.forensics && report.forensics.length) {
container.appendChild(renderForensics(report.forensics));
}
// Reverse matches
if (report.reverse_matches && report.reverse_matches.length) {
container.appendChild(renderReverseMatches(report.reverse_matches));
}
// Scraped images
if (report.scraped_images && report.scraped_images.length) {
container.appendChild(renderScrapedImages(report.scraped_images));
}
// Evidence (collapsible)
if (report.evidence && report.evidence.length) {
container.appendChild(renderEvidence(report.evidence));
}
// Raw JSON (collapsible)
container.appendChild(renderRawJson(report));
}
function renderMultiReport(reports, jobMeta) {
$("#report-empty").hidden = true;
const container = $("#report-content");
container.hidden = false;
clear(container);
const header = el("section", { class: "report-section" },
el("h2", { class: "report-section-title" }, "Full Pipeline Summary"));
if (jobMeta) {
const grid = el("div", { class: "report-meta-grid" });
grid.appendChild(metaCell("Job ID", jobMeta.job_id || "—"));
grid.appendChild(metaCell("Status", jobMeta.status || "—"));
grid.appendChild(metaCell("Elapsed", fmtMs(jobMeta.elapsed_ms)));
header.appendChild(grid);
}
container.appendChild(header);
const labels = {
detection: "Detection", recognition: "Recognition",
image_analysis: "Image Analysis", metadata: "Metadata",
forensics: "Forensics", search: "Search",
};
for (const [key, info] of Object.entries(reports)) {
const section = el("section", { class: "report-section" });
section.appendChild(el("h2", { class: "report-section-title" },
el("span", {}, labels[key] || key),
info.success
? el("span", { class: "badge success" }, "ok")
: el("span", { class: "badge danger" }, "failed"),
info.elapsed_ms != null
? el("span", { class: "badge muted" }, fmtMs(info.elapsed_ms))
: null,
));
if (!info.success && info.error) {
section.appendChild(el("div", { class: "callout error" },
el("div", {},
el("div", { class: "callout-title" }, "Provider error"),
el("div", {}, info.error))));
}
if (info.report) {
// Render the inner sections only (skip the metadata card header)
section.appendChild(renderInnerReport(info.report));
} else if (info.success) {
section.appendChild(el("p", { class: "muted small" }, "No report payload returned."));
}
container.appendChild(section);
}
}
/** Render the substantive sections of a report (without the header). */
function renderInnerReport(report) {
const wrap = el("div");
const confidence = report.overall_confidence;
const conflicts = Array.isArray(report.conflicts) ? report.conflicts : [];
if (conflicts.length) wrap.appendChild(renderConflicts(conflicts));
if (confidence) wrap.appendChild(renderConfidence(confidence));
if (report.detections && report.detections.length) wrap.appendChild(renderDetections(report.detections));
if (report.matches && report.matches.length) wrap.appendChild(renderMatches(report.matches));
if (report.image_analyses && report.image_analyses.length) wrap.appendChild(renderImageAnalyses(report.image_analyses));
if (report.metadata_extractions && report.metadata_extractions.length) wrap.appendChild(renderMetadataExtractions(report.metadata_extractions));
if (report.forensics && report.forensics.length) wrap.appendChild(renderForensics(report.forensics));
if (report.reverse_matches && report.reverse_matches.length) wrap.appendChild(renderReverseMatches(report.reverse_matches));
if (report.scraped_images && report.scraped_images.length) wrap.appendChild(renderScrapedImages(report.scraped_images));
if (report.evidence && report.evidence.length) wrap.appendChild(renderEvidence(report.evidence));
return wrap;
}
function reportHeader(meta, ctx) {
const section = el("section", { class: "report-section" });
section.appendChild(el("h2", { class: "report-section-title" }, "Report"));
const grid = el("div", { class: "report-meta-grid" });
grid.appendChild(metaCell("Job ID", meta.job_id || ctx.jobMeta?.job_id || "—"));
grid.appendChild(metaCell("Image hash", meta.image_hash || "—"));
grid.appendChild(metaCell("Total elapsed", fmtMs(meta.total_elapsed_ms ?? ctx.elapsed_ms)));
grid.appendChild(metaCell("Providers invoked", (meta.providers_invoked || []).join(", ") || "—"));
grid.appendChild(metaCell("Succeeded", (meta.providers_succeeded || []).join(", ") || "—"));
grid.appendChild(metaCell("Failed", (meta.providers_failed || []).join(", ") || "—"));
if (meta.created_at) grid.appendChild(metaCell("Created", fmtTime(meta.created_at)));
section.appendChild(grid);
if (meta.limitations && meta.limitations.length) {
const ul = el("ul", { class: "limitations-list" });
meta.limitations.forEach((l) => ul.appendChild(el("li", {}, l)));
section.appendChild(ul);
}
return section;
}
function metaCell(key, val) {
return el("div", { class: "report-meta-cell" },
el("div", { class: "k" }, key),
el("div", { class: "v" }, String(val ?? "—")));
}
function renderConflicts(conflicts) {
const banner = el("div", { class: "conflicts-banner", role: "alert" },
el("div", { class: "head" },
el("span", { "aria-hidden": "true" }, "⚠"),
el("span", {}, `${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"} detected`)));
conflicts.forEach((c) => {
const sev = (c.severity || "info").toLowerCase();
const sevClass = sev === "error" ? "danger" : sev === "warning" ? "warning" : "info";
const item = el("div", { class: "conflict-item" },
el("div", { class: "conflict-head" },
el("span", { class: `badge ${sevClass}` }, c.severity || "info"),
el("strong", {}, c.kind || "conflict"),
),
el("div", { class: "conflict-desc" }, c.description || ""),
(c.providers && c.providers.length)
? el("div", { class: "conflict-providers" },
c.providers.map((p) => el("span", { class: "badge neutral" }, p)))
: null,
);
banner.appendChild(item);
});
return banner;
}
function renderConfidence(confidence) {
const overall = typeof confidence.overall === "number" ? confidence.overall : 0;
const tier = overall > 0.7 ? "high" : overall >= 0.4 ? "mid" : "low";
const tierLabel = overall > 0.7 ? "High confidence" : overall >= 0.4 ? "Moderate confidence" : "Low confidence";
const tierColor = overall > 0.7 ? "var(--success)" : overall >= 0.4 ? "var(--warning)" : "var(--danger)";
const circumference = 2 * Math.PI * 42;
const offset = circumference * (1 - Math.max(0, Math.min(1, overall)));
const card = el("section", { class: "report-section" },
el("h2", { class: "report-section-title" }, "Overall confidence"));
const wrap = el("div", { class: "confidence-card" });
// Ring
const ring = el("div", { class: "confidence-ring" },
el("svg", { width: 96, height: 96, viewBox: "0 0 96 96" },
el("circle", { class: "ring-bg", cx: 48, cy: 48, r: 42 }),
el("circle", {
class: "ring-fg", cx: 48, cy: 48, r: 42,
stroke: tierColor,
"stroke-dasharray": String(circumference),
"stroke-dashoffset": String(offset),
})));
ring.appendChild(el("div", { class: "confidence-pct" }, `${(overall * 100).toFixed(1)}%`));
wrap.appendChild(ring);
// Info
const info = el("div", { class: "confidence-info" });
info.appendChild(el("div", { class: `confidence-tier ${tier}` },
el("span", { "aria-hidden": "true" }, "●"), tierLabel));
if (confidence.explanation) {
const expl = el("div", { class: "confidence-explanation" });
expl.textContent = confidence.explanation;
info.appendChild(expl);
}
if (confidence.method) {
info.appendChild(el("div", { class: "confidence-explanation", style: { marginTop: "4px" } },
"Method: ", el("code", {}, confidence.method)));
}
// Components
if (confidence.components && Object.keys(confidence.components).length) {
const comps = el("div", { class: "confidence-components" });
for (const [k, v] of Object.entries(confidence.components)) {
const num = typeof v === "number" ? v : parseFloat(v);
const pct = (isNaN(num) ? 0 : Math.max(0, Math.min(1, num))) * 100;
comps.appendChild(el("div", { class: "conf-comp" },
el("span", { class: "conf-comp-name" }, k),
el("span", { class: "conf-comp-bar" },
el("span", { class: "conf-comp-fill", style: { width: `${pct}%`, background: tierColor } })),
el("span", { class: "conf-comp-val" }, isNaN(num) ? "—" : num.toFixed(3))));
}
info.appendChild(comps);
}
wrap.appendChild(info);
card.appendChild(wrap);
return card;
}
function renderDetections(detections) {
const section = el("section", { class: "report-section" },
el("h2", { class: "report-section-title" }, "Detections",
el("span", { class: "count" }, detections.length)));
const list = el("div", { class: "det-list" });
detections.forEach((det, i) => {
const box = det.box || {};
const conf = det.confidence?.overall;
const confClass = conf == null ? "muted"
: conf > 0.7 ? "success" : conf >= 0.4 ? "warning" : "danger";
list.appendChild(el("div", { class: "det-row" },
el("div", { class: "det-num" }, String(i + 1)),
el("div", { class: "det-info" },
el("div", { class: "det-box" },
`bbox: x=${box.x ?? "?"}, y=${box.y ?? "?"}, w=${box.w ?? "?"}, h=${box.h ?? "?"}`),
(det.detected_by && det.detected_by.length)
? el("div", { class: "det-providers" },
det.detected_by.map((p) => el("span", { class: "badge neutral" }, p)))
: null,
),
el("div", { class: "det-confidence" },
el("div", { class: "det-conf-val", style: { color: `var(--${confClass === "muted" ? "text" : confClass})` } },
conf == null ? "—" : `${(conf * 100).toFixed(1)}%`),
el("div", { class: "det-conf-label" }, "confidence"))));
});
section.appendChild(list);
return section;
}
function renderMatches(matches) {
const section = el("section", { class: "report-section" },
el("h2", { class: "report-section-title" }, "Recognition matches",
el("span", { class: "count" }, matches.length)));
const grid = el("div", { class: "card-grid" });
matches.forEach((m, i) => {
const conf = m.confidence?.overall;
const confClass = conf == null ? "muted"
: conf > 0.7 ? "success" : conf >= 0.4 ? "warning" : "danger";
const card = el("div", { class: "info-card" },
el("div", { class: "card-head" },
el("span", { class: "card-title" }, `Face #${(m.query_face_index ?? i) + 1}`),
conf != null ? el("span", { class: `badge ${confClass}` }, `${(conf * 100).toFixed(1)}%`) : null));
card.appendChild(kv("Best match", m.best_match || "no match"));
if (m.distances && Object.keys(m.distances).length) {
for (const [k, v] of Object.entries(m.distances)) {
card.appendChild(kv(`dist · ${k}`, fmtNum(v, 4)));
}
}
grid.appendChild(card);
});
section.appendChild(grid);
return section;
}
function renderImageAnalyses(analyses) {
const section = el("section", { class: "report-section" },
el("h2", { class: "report-section-title" }, "Image analyses",
el("span", { class: "count" }, analyses.length)));
const grid = el("div", { class: "card-grid" });
analyses.forEach((a) => {
const card = el("div", { class: "info-card" },
el("div", { class: "card-head" },
el("span", { class: "card-title" }, a.provider || "provider"),
a.quality_score != null
? el("span", { class: "badge info" }, `quality ${(a.quality_score * 100).toFixed(0)}%`)
: null));
card.appendChild(kv("Brightness", fmtNum(a.brightness)));
card.appendChild(kv("Contrast", fmtNum(a.contrast)));
card.appendChild(kv("Sharpness", fmtNum(a.sharpness)));
card.appendChild(kv("Noise level", fmtNum(a.noise_level)));
if (a.width && a.height) card.appendChild(kv("Dimensions", `${a.width} × ${a.height}`));
if (a.channels) card.appendChild(kv("Channels", a.channels));
if (a.color_profile) card.appendChild(kv("Color profile", a.color_profile));
if (a.dominant_colors && a.dominant_colors.length) {
const swWrap = el("div", { class: "kv" },
el("span", { class: "k" }, "Dominant colors"),
el("span", { class: "v" }, renderSwatches(a.dominant_colors)));
card.appendChild(swWrap);
}
if (a.aspects && Object.keys(a.aspects).length) {
for (const [k, v] of Object.entries(a.aspects)) {
card.appendChild(kv(k, typeof v === "object" ? JSON.stringify(v) : v));
}
}
grid.appendChild(card);
});
section.appendChild(grid);
return section;
}
function renderMetadataExtractions(extractions) {
const section = el("section", { class: "report-section" },
el("h2", { class: "report-section-title" }, "Metadata extractions",
el("span", { class: "count" }, extractions.length)));
extractions.forEach((m) => {
const det = el("details", { class: "disclosure", open: "" });
det.appendChild(el("summary", {}, m.provider || "provider"));
const body = el("div", { class: "disclosure-body" });
const grid = el("div", { class: "card-grid" });
const card = el("div", { class: "info-card" });
card.appendChild(kv("Format", m.format || "—"));
card.appendChild(kv("Camera make", m.camera_make || "—"));
card.appendChild(kv("Camera model", m.camera_model || "—"));
card.appendChild(kv("Software", m.software || "—"));
card.appendChild(kv("Capture time", m.capture_time ? fmtTime(m.capture_time) : "—"));
if (m.gps) {
const g = m.gps;
const latStr = g.lat != null ? (typeof g.lat === "number" ? g.lat.toFixed(6) : g.lat) : "—";
const lonStr = g.lon != null ? (typeof g.lon === "number" ? g.lon.toFixed(6) : g.lon) : "—";
card.appendChild(kv("GPS", `${latStr}, ${lonStr}`));
}
grid.appendChild(card);
body.appendChild(grid);
// EXIF / XMP / IPTC collapsible
for (const tag of ["exif", "xmp", "iptc"]) {
const data = m[tag];
if (data && typeof data === "object" && Object.keys(data).length) {
const sub = el("details", { class: "disclosure" });
sub.appendChild(el("summary", {},
`${tag.toUpperCase()} tags (${Object.keys(data).length})`));
const subBody = el("div", { class: "disclosure-body" });
subBody.appendChild(jsonTree(data));
sub.appendChild(subBody);
body.appendChild(sub);
}
}
det.appendChild(body);
section.appendChild(det);
});
return section;
}
function renderForensics(forensics) {
const section = el("section", { class: "report-section" },
el("h2", { class: "report-section-title" }, "Forensics",
el("span", { class: "count" }, forensics.length)));
const grid = el("div", { class: "card-grid" });
forensics.forEach((f) => {
const card = el("div", { class: "info-card" },
el("div", { class: "card-head" },
el("span", { class: "card-title" }, f.provider || "provider"),
f.is_duplicate
? el("span", { class: "badge warning" }, "duplicate")
: el("span", { class: "badge success" }, "unique")));
card.appendChild(kv("Integrity score",
f.integrity_score != null ? `${(f.integrity_score * 100).toFixed(1)}%` : "—"));
card.appendChild(kv("Similarity score",
f.similarity_score != null ? fmtNum(f.similarity_score, 4) : "—"));
if (f.duplicate_of) card.appendChild(kv("Duplicate of", truncate(f.duplicate_of, 40)));
card.appendChild(kv("ELA score", fmtNum(f.elA_score)));
card.appendChild(kv("Noise inconsistency", fmtNum(f.noise_inconsistency)));
if (f.manipulation_indicators && f.manipulation_indicators.length) {
const ind = el("div", { class: "kv", style: { flexDirection: "column", alignItems: "flex-start" } },
el("span", { class: "k" }, "Manipulation indicators"),
el("div", { class: "det-providers" },
f.manipulation_indicators.map((s) => el("span", { class: "badge warning" }, s))));
card.appendChild(ind);
}
grid.appendChild(card);
});
section.appendChild(grid);
return section;
}
function renderReverseMatches(matches) {
const section = el("section", { class: "report-section" },
el("h2", { class: "report-section-title" }, "Reverse matches",
el("span", { class: "count" }, matches.length)));
const list = el("div", { class: "match-list" });
matches.forEach((m) => {
const thumb = m.thumbnail || m.image_url || m.thumb;
const card = el("div", { class: "match-card" });
if (thumb) {
const img = el("img", { class: "match-thumb", alt: m.title || "match thumbnail",
loading: "lazy", referrerPolicy: "no-referrer" });
img.addEventListener("error", () => {
img.replaceWith(el("div", { class: "match-thumb-placeholder" },
el("svg", { viewBox: "0 0 24 24", width: 28, height: 28, "aria-hidden": "true" },
el("path", { fill: "currentColor", d: "M21 19V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2M8.9 13.6l2.1 2.7 3-3.9 4 5H5z" }))));
});
img.src = thumb;
card.appendChild(img);
} else {
card.appendChild(el("div", { class: "match-thumb-placeholder" },
el("svg", { viewBox: "0 0 24 24", width: 28, height: 28, "aria-hidden": "true" },
el("path", { fill: "currentColor", d: "M21 19V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2M8.9 13.6l2.1 2.7 3-3.9 4 5H5z" }))));
}
const info = el("div", { class: "match-info" });
if (m.title) info.appendChild(el("div", { class: "match-title" }, m.title));
if (m.source_page) {
const link = el("a", { class: "match-source", href: m.source_page, target: "_blank", rel: "noopener noreferrer" },
truncate(m.source_page, 70));
info.appendChild(link);
}
if (m.snippet) info.appendChild(el("div", { class: "match-snippet" }, m.snippet));
if (m.confidence != null) {
info.appendChild(el("div", { class: "match-snippet" }, `confidence: ${fmtNum(m.confidence, 3)}`));
}
card.appendChild(info);
list.appendChild(card);
});
section.appendChild(list);
return section;
}
function renderScrapedImages(images) {
const section = el("section", { class: "report-section" },
el("h2", { class: "report-section-title" }, "Scraped images",
el("span", { class: "count" }, images.length)));
const grid = el("div", { class: "scrape-grid" });
images.forEach((img) => {
const url = typeof img === "string" ? img : (img.url || img.src || img.image_url);
const alt = typeof img === "object" ? (img.alt || img.title || "") : "";
if (!url) return;
const cell = el("div", { class: "scrape-cell" });
const i = el("img", { alt: alt || "scraped image", loading: "lazy", referrerPolicy: "no-referrer" });
i.addEventListener("error", () => {
i.replaceWith(el("span", { class: "muted small" }, "load failed"));
});
i.src = url;
cell.appendChild(i);
cell.appendChild(el("div", { class: "scrape-overlay" }, truncate(url, 60)));
grid.appendChild(cell);
});
section.appendChild(grid);
return section;
}
function renderEvidence(evidence) {
const section = el("section", { class: "report-section" });
const det = el("details", { class: "disclosure" });
det.appendChild(el("summary", {}, `Evidence (${evidence.length} provider outputs)`));
const body = el("div", { class: "disclosure-body" });
evidence.forEach((ev) => {
const sub = el("details", { class: "disclosure" });
const statusBadge = ev.success
? el("span", { class: "badge success" }, "ok")
: el("span", { class: "badge danger" }, "failed");
sub.appendChild(el("summary", {},
el("span", { style: { marginRight: "8px" } }, `${ev.provider || "provider"} · ${ev.capability || ""}`),
statusBadge,
ev.elapsed_ms != null ? el("span", { class: "badge muted", style: { marginLeft: "6px" } }, fmtMs(ev.elapsed_ms)) : null));
const subBody = el("div", { class: "disclosure-body" });
if (ev.error) {
subBody.appendChild(el("div", { class: "callout error" },
el("div", {},
el("div", { class: "callout-title" }, ev.error_type || "Error"),
el("div", {}, ev.error))));
}
if (ev.limitations && ev.limitations.length) {
const ul = el("ul", { class: "limitations-list" });
ev.limitations.forEach((l) => ul.appendChild(el("li", {}, l)));
subBody.appendChild(ul);
}
if (ev.raw !== undefined && ev.raw !== null) {
subBody.appendChild(el("div", { class: "muted small", style: { margin: "8px 0 4px" } }, "Raw output:"));
subBody.appendChild(jsonTree(ev.raw));
}
if (ev.normalized && Object.keys(ev.normalized).length) {
subBody.appendChild(el("div", { class: "muted small", style: { margin: "8px 0 4px" } }, "Normalized:"));
subBody.appendChild(jsonTree(ev.normalized));
}
sub.appendChild(subBody);
body.appendChild(sub);
});
det.appendChild(body);
section.appendChild(det);
return section;
}
function renderRawJson(report) {
const section = el("section", { class: "report-section" });
const det = el("details", { class: "disclosure" });
det.appendChild(el("summary", {}, "Raw report JSON"));
const body = el("div", { class: "disclosure-body" });
body.appendChild(jsonTree(report));
det.appendChild(body);
section.appendChild(det);
return section;
}
function kv(k, v) {
return el("div", { class: "kv" },
el("span", { class: "k" }, k),
el("span", { class: "v" }, String(v ?? "—")));
}
function renderSwatches(colors) {
const wrap = el("div", { class: "swatches" });
colors.slice(0, 8).forEach((c) => {
const color = String(c);
const chip = el("span", { class: "swatch" },
el("span", { class: "swatch-chip", style: { background: color } }),
color);
wrap.appendChild(chip);
});
if (colors.length > 8) {
wrap.appendChild(el("span", { class: "muted small" }, `+${colors.length - 8}`));
}
return wrap;
}
/* ----------------------------------------------------------------
* JSON tree viewer (collapsible)
* ---------------------------------------------------------------- */
function jsonTree(value) {
const tree = el("div", { class: "json-tree" });
tree.appendChild(jsonNode(value, null, 0, new Set()));
return tree;
}
function jsonNode(value, key, depth, seen) {
const row = el("div", { class: "json-row" });
if (value && typeof value === "object" && !Array.isArray(value) && seen.has(value)) {
// Circular reference
return row.appendChild(simpleValueNode(key, "[circular]", "null"));
}
const isContainer = value !== null && typeof value === "object";
let toggle, children, preview;
if (key !== null) {
row.appendChild(el("span", { class: "json-key" }, String(key)));
}
if (isContainer) {
seen.add(value);
const isArray = Array.isArray(value);
const open = depth < 1; // auto-expand first level
const count = isArray ? value.length : Object.keys(value).length;
toggle = el("button", {
class: `json-toggle ${open ? "" : "collapsed"}`,
type: "button",
"aria-label": open ? "Collapse" : "Expand",
"aria-expanded": open ? "true" : "false",
}, "▼");
row.appendChild(toggle);
row.appendChild(el("span", { class: "json-bracket" }, isArray ? "[" : "{"));
preview = el("span", { class: "json-preview" },
open ? "" : ` ${count} ${isArray ? "items" : "keys"} `);
row.appendChild(preview);
children = el("div", { class: `json-children ${open ? "" : "collapsed"}` });
if (isArray) {
value.forEach((v, i) => children.appendChild(jsonNode(v, i, depth + 1, seen)));
} else {
for (const [k, v] of Object.entries(value)) {
children.appendChild(jsonNode(v, k, depth + 1, seen));
}
}
children.appendChild(el("div", { class: "json-row" },
el("span", { class: "json-bracket" }, isArray ? "]" : "}")));
toggle.addEventListener("click", (e) => {
e.stopPropagation();
const willOpen = toggle.classList.contains("collapsed");
toggle.classList.toggle("collapsed", !willOpen);
toggle.setAttribute("aria-expanded", willOpen ? "true" : "false");
toggle.setAttribute("aria-label", willOpen ? "Collapse" : "Expand");
children.classList.toggle("collapsed", !willOpen);
preview.textContent = willOpen ? "" : ` ${count} ${isArray ? "items" : "keys"} `;
});
} else {
row.appendChild(el("span", { class: `json-${scalarKind(value)}` }, scalarRepr(value)));
}
const wrap = el("div", { class: "json-node" });
wrap.appendChild(row);
if (children) wrap.appendChild(children);
return wrap;
}
function simpleValueNode(key, repr, kind) {
const row = el("div", { class: "json-row" });
if (key !== null) row.appendChild(el("span", { class: "json-key" }, String(key)));
row.appendChild(el("span", { class: `json-${kind}` }, repr));
const wrap = el("div", { class: "json-node" });
wrap.appendChild(row);
return wrap;
}
function scalarKind(v) {
if (v === null) return "null";
if (typeof v === "string") return "string";
if (typeof v === "number") return "number";
if (typeof v === "boolean") return "bool";
return "string";
}
function scalarRepr(v) {
if (v === null) return "null";
if (typeof v === "string") return JSON.stringify(v);
return String(v);
}
/* ----------------------------------------------------------------
* Providers
* ---------------------------------------------------------------- */
async function loadProviders() {
const tbody = $("#providers-tbody");
const errBox = $("#providers-errors");
errBox.hidden = true;
clear(errBox);
tbody.appendChild(loadingRow(4, "Loading providers…"));
try {
const data = await api("GET", API.providers);
const providers = data?.providers || [];
const errors = data?.errors || {};
clear(tbody);
if (!providers.length) {
$("#providers-empty").hidden = false;
} else {
$("#providers-empty").hidden = true;
providers.forEach((p) => tbody.appendChild(providerRow(p)));
}
if (errors && Object.keys(errors).length) {
const items = Object.entries(errors).map(([k, v]) => `${k}: ${v}`).join(" · ");
errBox.appendChild(el("div", {},
el("div", { class: "callout-title" }, "Provider manifest errors"),
el("div", {}, items)));
errBox.hidden = false;
}
} catch (e) {
clear(tbody);
tbody.appendChild(errorRow(4, e.message));
}
}
function providerRow(p) {
const status = (p.status || "").toLowerCase();
const cls = statusClass(status);
return el("tr", {},
el("td", { class: "mono" }, p.name || "—"),
el("td", {}, p.capability || "—"),
el("td", {}, el("span", { class: `badge ${cls}` }, status || "unknown")),
el("td", {}, p.description || "—"));
}
function statusClass(status) {
if (status === "healthy") return "success";
if (status === "degraded") return "warning";
if (status === "unhealthy") return "danger";
if (status === "not_configured") return "neutral";
if (status === "disabled") return "muted";
return "neutral";
}
/* ----------------------------------------------------------------
* Stats
* ---------------------------------------------------------------- */
async function loadStats() {
try {
const [stats, cache] = await Promise.all([
api("GET", API.stats),
api("GET", API.cache),
]);
renderMetrics(stats?.providers || []);
renderCounters(stats?.counters || {});
renderCache(cache || {});
} catch (e) {
$("#metrics-tbody").appendChild(errorRow(8, e.message));
}
}
function renderMetrics(providers) {
const tbody = $("#metrics-tbody");
clear(tbody);
if (!providers.length) {
$("#metrics-empty").hidden = false;
return;
}
$("#metrics-empty").hidden = true;
providers.forEach((m) => {
const rate = m.success_rate;
const rateCls = rate == null ? "muted"
: rate >= 0.9 ? "success" : rate >= 0.5 ? "warning" : "danger";
tbody.appendChild(el("tr", {},
el("td", { class: "mono" }, m.name || "—"),
el("td", { class: "num" }, String(m.invocations ?? 0)),
el("td", { class: "num" }, String(m.successes ?? 0)),
el("td", { class: "num" }, String(m.failures ?? 0)),
el("td", { class: "num" }, String(m.retries ?? 0)),
el("td", { class: "num" }, fmtMs(m.avg_latency_ms)),
el("td", { class: "num" }, fmtMs(m.p95_latency_ms)),
el("td", { class: "num" },
el("span", { class: `badge ${rateCls}` },
rate == null ? "—" : `${(rate * 100).toFixed(1)}%`))));
});
}
function renderCounters(counters) {
const list = $("#counter-list");
clear(list);
const entries = Object.entries(counters || {});
if (!entries.length) {
$("#counters-empty").hidden = false;
return;
}
$("#counters-empty").hidden = true;
entries.sort((a, b) => a[0].localeCompare(b[0]));
entries.forEach(([k, v]) => {
list.appendChild(el("div", { class: "counter-row" },
el("span", { class: "counter-key" }, k),
el("span", { class: "counter-val" }, String(v))));
});
}
function renderCache(cache) {
const keys = ["entries", "max_entries", "hits", "misses", "hit_ratio", "evictions", "ttl_seconds"];
$$("#cache-stats .stat-value").forEach((cell) => {
const k = cell.dataset.key;
let v = cache[k];
if (k === "hit_ratio" && typeof v === "number") {
cell.textContent = `${(v * 100).toFixed(1)}%`;
} else {
cell.textContent = v == null ? "—" : String(v).toLocaleString();
}
});
const ratio = typeof cache.hit_ratio === "number" ? cache.hit_ratio : 0;
$("#hit-ratio-fill").style.width = `${Math.max(0, Math.min(1, ratio)) * 100}%`;
}
async function clearCache() {
if (!confirm("Clear all cached entries?")) return;
try {
const data = await api("DELETE", API.cache);
toast(`Cleared ${data?.cleared ?? 0} cache entries.`, { type: "success" });
renderCache(await api("GET", API.cache));
} catch (e) {
toast(`Failed to clear cache: ${e.message}`, { type: "error" });
}
}
/* ----------------------------------------------------------------
* Jobs
* ---------------------------------------------------------------- */
async function loadJobs() {
const tbody = $("#jobs-tbody");
tbody.appendChild(loadingRow(6, "Loading jobs…"));
try {
const data = await api("GET", `${API.jobs}?limit=50`);
const jobs = data?.jobs || [];
clear(tbody);
if (!jobs.length) {
$("#jobs-empty").hidden = false;
return;
}
$("#jobs-empty").hidden = true;
jobs.forEach((j) => tbody.appendChild(jobRow(j)));
} catch (e) {
clear(tbody);
tbody.appendChild(errorRow(6, e.message));
}
}
function jobRow(j) {
const status = (j.status || "").toLowerCase();
const cls = statusClass(status === "completed" ? "healthy"
: status === "failed" || status === "timeout" ? "unhealthy"
: status === "running" || status === "pending" || status === "queued" ? "degraded"
: "not_configured");
const tr = el("tr", { class: "is-clickable", dataset: { jobId: j.id } },
el("td", { class: "mono" },
el("span", { title: j.id }, shortId(j.id))),
el("td", {}, j.kind || "—"),
el("td", {}, el("span", { class: `badge ${cls}` }, status || "—")),
el("td", {}, fmtRelative(j.created_at)),
el("td", { class: "truncate" }, j.error || "—"),
el("td", { class: "col-actions" },
el("div", { class: "row-actions" },
el("button", {
class: "ghost-btn sm", type: "button",
"aria-label": `View result for job ${shortId(j.id)}`,
}, "View"),
el("button", {
class: "ghost-btn sm", type: "button",
"aria-label": `Download job ${shortId(j.id)} as JSON`,
dataset: { action: "export" },
}, "Export"))));
// Whole row click → view
tr.addEventListener("click", (e) => {
if (e.target.closest('[data-action="export"]')) return;
viewJobResult(j.id);
});
tr.querySelector('[data-action="export"]').addEventListener("click", (e) => {
e.stopPropagation();
exportJob(j.id);
});
return tr;
}
async function viewJobResult(jobId) {
if (!jobId) return;
// Highlight row
$$("#jobs-tbody tr").forEach((r) => r.classList.toggle("is-selected", r.dataset.jobId === jobId));
state.activeJobId = jobId;
showLoading("Fetching job result…");
try {
const result = await api("GET", `${API.jobs}/${encodeURIComponent(jobId)}/result`);
const report = result?.report;
const elapsed = result?.elapsed_ms;
const status = result?.status;
const error = result?.error;
if (status === "failed" || status === "timeout") {
const container = $("#report-content");
container.hidden = false;
$("#report-empty").hidden = true;
clear(container);
container.appendChild(el("div", { class: "callout error" },
el("div", {},
el("div", { class: "callout-title" }, `Job ${status}`),
el("div", {}, error || "The job did not complete successfully."))));
switchView("report");
hideLoading();
return;
}
if (!report) {
// Could be a full_pipeline job whose report wasn't persisted, or
// a job still in flight.
const container = $("#report-content");
container.hidden = false;
$("#report-empty").hidden = true;
clear(container);
const jobMeta = await api("GET", `${API.jobs}/${encodeURIComponent(jobId)}`).catch(() => null);
const kind = jobMeta?.kind;
container.appendChild(el("div", { class: "callout info" },
el("div", {},
el("div", { class: "callout-title" }, "Report not persisted"),
el("div", {},
kind === "full_pipeline"
? "Full-pipeline jobs return their results in the initial response only. Re-run the action to view the full report."
: "This job has no stored report. It may still be running or the result expired."))));
switchView("report");
hideLoading();
return;
}
// Single-kind report (full_pipeline stores nothing in .report)
renderReport(report, {
source: `Job ${shortId(jobId)}`,
elapsed_ms: elapsed,
jobMeta: { job_id: jobId, status, elapsed_ms: elapsed },
});
switchView("report");
toast(`Loaded job ${shortId(jobId)}.`, { type: "info", timeout: 2500 });
} catch (e) {
if (e.status === 404) {
toast("Job result not found (it may have expired).", { type: "warn" });
} else {
toast(`Failed to load job: ${e.message}`, { type: "error" });
}
} finally {
hideLoading();
}
}
function exportJob(jobId) {
if (!jobId) return;
const url = `/export/${encodeURIComponent(jobId)}`;
// Trigger download via anchor
const a = el("a", { href: url, download: `${jobId}.json` });
document.body.appendChild(a);
a.click();
a.remove();
}
/* ----------------------------------------------------------------
* Health
* ---------------------------------------------------------------- */
async function loadHealth() {
const tbody = $("#health-tbody");
tbody.appendChild(loadingRow(7, "Loading health…"));
try {
const data = await api("GET", API.healthProviders);
renderHealthBanner(data);
const providers = data?.providers || [];
clear(tbody);
if (!providers.length) {
$("#health-empty").hidden = false;
return;
}
$("#health-empty").hidden = true;
providers.forEach((p) => tbody.appendChild(healthRow(p)));
} catch (e) {
clear(tbody);
tbody.appendChild(errorRow(7, e.message));
renderHealthBanner({ status: "unhealthy", uptime_seconds: 0, version: "—", providers: [] });
}
}
function renderHealthBanner(data) {
const banner = $("#health-banner");
const status = (data?.status || "unknown").toLowerCase();
banner.classList.remove("healthy", "degraded", "unhealthy");
const cls = status === "healthy" ? "healthy" : status === "degraded" ? "degraded" : "unhealthy";
banner.classList.add(cls);
const icon = $("#health-banner-icon");
icon.innerHTML = "";
const iconSvg = status === "healthy"
? ``
: status === "degraded"
? ``
: ``;
icon.innerHTML = iconSvg;
$("#health-banner-title").textContent = status === "healthy" ? "System healthy"
: status === "degraded" ? "System degraded"
: status === "unhealthy" ? "System unhealthy"
: "System status unknown";
const providers = data?.providers || [];
const openCircuits = providers.filter((p) => p.circuit_open).length;
$("#health-banner-sub").textContent = `${providers.length} provider${providers.length === 1 ? "" : "s"} monitored · ${openCircuits} open circuit${openCircuits === 1 ? "" : "s"}`;
const meta = $("#health-banner-meta");
clear(meta);
meta.appendChild(metaStat("Version", data?.version || "—"));
meta.appendChild(metaStat("Uptime", formatUptime(data?.uptime_seconds)));
}
function metaStat(label, val) {
return el("span", {}, el("span", { class: "muted small" }, label), el("b", {}, String(val)));
}
function formatUptime(seconds) {
if (seconds == null || isNaN(seconds)) return "—";
const s = Math.floor(seconds);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function healthRow(p) {
const healthy = p.healthy !== false && !p.circuit_open;
return el("tr", {},
el("td", { class: "mono" }, p.name || "—"),
el("td", {}, el("span", { class: `badge ${healthy ? "success" : "danger"}` },
healthy ? "healthy" : "unhealthy")),
el("td", { class: "num" }, String(p.consecutive_failures ?? 0)),
el("td", { class: "num" }, fmtMs(p.avg_latency_ms)),
el("td", {}, p.circuit_open
? el("span", { class: "badge danger" }, "open")
: el("span", { class: "badge muted" }, "closed")),
el("td", {}, p.last_success ? fmtRelative(p.last_success) : "—"),
el("td", {}, p.last_failure ? fmtRelative(p.last_failure) : "—"));
}
/* ----------------------------------------------------------------
* Table helpers
* ---------------------------------------------------------------- */
function loadingRow(cols, msg) {
return el("tr", {},
el("td", { colspan: String(cols), class: "muted small center", style: { padding: "24px" } }, msg || "Loading…"));
}
function errorRow(cols, msg) {
return el("tr", {},
el("td", { colspan: String(cols), class: "center", style: { padding: "24px", color: "var(--danger)" } },
`Failed to load: ${msg || "unknown error"}`));
}
/* ----------------------------------------------------------------
* Event wiring
* ---------------------------------------------------------------- */
function wireEvents() {
// Nav
$$(".nav-item").forEach((btn) => {
btn.addEventListener("click", () => switchView(btn.dataset.view));
});
// Theme
$("#theme-toggle").addEventListener("click", toggleTheme);
// Mobile sidebar
$("#menu-toggle").addEventListener("click", () => {
const sb = $("#sidebar");
if (sb.classList.contains("is-open")) closeSidebarMobile();
else openSidebarMobile();
});
// File input + dropzone
const dropzone = $("#dropzone");
const fileInput = $("#file-input");
dropzone.addEventListener("click", () => fileInput.click());
dropzone.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fileInput.click();
}
});
fileInput.addEventListener("change", (e) => {
const file = e.target.files && e.target.files[0];
if (file) handleFile(file);
});
["dragenter", "dragover"].forEach((ev) => {
dropzone.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.add("is-dragover");
});
});
["dragleave", "dragend", "drop"].forEach((ev) => {
dropzone.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove("is-dragover");
});
});
dropzone.addEventListener("drop", (e) => {
const file = e.dataTransfer?.files?.[0];
if (file) handleFile(file);
});
// Clear preview
$("#clear-preview").addEventListener("click", (e) => {
e.stopPropagation();
clearPreview();
});
// URL load
$("#load-url-btn").addEventListener("click", loadImageFromUrl);
$("#image-url").addEventListener("keydown", (e) => {
if (e.key === "Enter") loadImageFromUrl();
});
// Action buttons
$$("#action-grid .action-btn").forEach((btn) => {
btn.addEventListener("click", () => runAction(btn.dataset.action));
});
// Refresh buttons
$("#refresh-providers").addEventListener("click", loadProviders);
$("#refresh-stats").addEventListener("click", loadStats);
$("#refresh-jobs").addEventListener("click", loadJobs);
$("#refresh-health").addEventListener("click", loadHealth);
// Cache clear
$("#clear-cache-btn").addEventListener("click", clearCache);
// Keyboard shortcut: Esc closes mobile sidebar
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeSidebarMobile();
});
// Repaint relative times once a minute
setInterval(() => {
if (state.currentView === "jobs") {
// Lightly refresh only the relative cells
$$("#jobs-tbody tr").forEach((r) => {
// No-op without re-fetching; full refresh is user-driven.
});
}
}, 60000);
}
/* ----------------------------------------------------------------
* Init
* ---------------------------------------------------------------- */
async function init() {
initTheme();
wireEvents();
updateSourceHint();
switchView("analyze");
await checkConnection();
// Try a soft refresh of the jobs list in background so the panel is ready
loadJobs().catch(() => {});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();