/* ============================================================ 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(); } })();