| |
| |
| |
| |
| (() => { |
| "use strict"; |
|
|
| |
| |
| |
| 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", |
| }; |
|
|
| |
| 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; |
|
|
| |
| |
| |
| const state = { |
| imageBase64: null, |
| imageMeta: null, |
| imageUrl: null, |
| currentView: "analyze", |
| activeJobId: null, |
| requestInFlight: null, |
| }; |
|
|
| |
| |
| |
| 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) { |
| |
| |
| 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); }; |
|
|
| |
| |
| |
| 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, """).replace(/'/g, "'"); |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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 }; |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| 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; |
|
|
| |
| if (view === "providers") loadProviders(); |
| if (view === "stats") loadStats(); |
| if (view === "jobs") loadJobs(); |
| if (view === "health") loadHealth(); |
|
|
| closeSidebarMobile(); |
| } |
|
|
| |
| |
| |
| 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"); |
| } |
|
|
| |
| |
| |
| 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(); |
| } |
|
|
| |
| function buildActionBody(actionKey) { |
| const action = ACTIONS[actionKey]; |
| const base = {}; |
| if (state.imageBase64) { |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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; |
|
|
| |
| let report = null; |
| let multiReport = null; |
| let jobMeta = null; |
|
|
| if (action.kind === "full_pipeline") { |
| |
| 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 { |
| |
| if (data && data.report) { |
| report = data.report; |
| } else if (data && data.success === false) { |
| throw new Error(data.error || "Action failed"); |
| } |
| } |
|
|
| if (!report && !multiReport) { |
| |
| if (data && (data.error || data.detail)) { |
| throw new Error(data.error || data.detail); |
| } |
| |
| if (data && data.job_id && data.result === undefined && data.report) { |
| report = data.report; |
| } |
| } |
|
|
| |
| $("#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")); |
|
|
| |
| 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" }); |
|
|
| |
| 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(); |
| } |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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 : []; |
|
|
| |
| container.appendChild(reportHeader(meta, ctx)); |
|
|
| |
| if (conflicts.length) container.appendChild(renderConflicts(conflicts)); |
|
|
| |
| container.appendChild(renderMetadata(meta)); |
|
|
| |
| if (confidence) container.appendChild(renderConfidence(confidence)); |
|
|
| |
| if (report.detections && report.detections.length) { |
| container.appendChild(renderDetections(report.detections)); |
| } |
|
|
| |
| if (report.matches && report.matches.length) { |
| container.appendChild(renderMatches(report.matches)); |
| } |
|
|
| |
| if (report.image_analyses && report.image_analyses.length) { |
| container.appendChild(renderImageAnalyses(report.image_analyses)); |
| } |
|
|
| |
| if (report.metadata_extractions && report.metadata_extractions.length) { |
| container.appendChild(renderMetadataExtractions(report.metadata_extractions)); |
| } |
|
|
| |
| if (report.forensics && report.forensics.length) { |
| container.appendChild(renderForensics(report.forensics)); |
| } |
|
|
| |
| if (report.reverse_matches && report.reverse_matches.length) { |
| container.appendChild(renderReverseMatches(report.reverse_matches)); |
| } |
|
|
| |
| if (report.scraped_images && report.scraped_images.length) { |
| container.appendChild(renderScrapedImages(report.scraped_images)); |
| } |
|
|
| |
| if (report.evidence && report.evidence.length) { |
| container.appendChild(renderEvidence(report.evidence)); |
| } |
|
|
| |
| 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) { |
| |
| section.appendChild(renderInnerReport(info.report)); |
| } else if (info.success) { |
| section.appendChild(el("p", { class: "muted small" }, "No report payload returned.")); |
| } |
| container.appendChild(section); |
| } |
| } |
|
|
| |
| 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" }); |
|
|
| |
| 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); |
|
|
| |
| 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))); |
| } |
| |
| 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); |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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)) { |
| |
| 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; |
| 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); |
| } |
|
|
| |
| |
| |
| 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"; |
| } |
|
|
| |
| |
| |
| 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" }); |
| } |
| } |
|
|
| |
| |
| |
| 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")))); |
| |
| 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; |
| |
| $$("#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) { |
| |
| |
| 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; |
| } |
|
|
| |
| 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)}`; |
| |
| const a = el("a", { href: url, download: `${jobId}.json` }); |
| document.body.appendChild(a); |
| a.click(); |
| a.remove(); |
| } |
|
|
| |
| |
| |
| 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" |
| ? `<svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="m9 16.2-3.5-3.5L4 14.2 9 19l11-11-1.4-1.4z" style="color:var(--success)"/></svg>` |
| : status === "degraded" |
| ? `<svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="M12 2 1 21h22zm0 6 7.5 13h-15zm-1 4v4h2v-4zm0 6v2h2v-2z" style="color:var(--warning)"/></svg>` |
| : `<svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2m1 15h-2v-2h2zm0-4h-2V7h2z" style="color:var(--danger)"/></svg>`; |
| 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) : "—")); |
| } |
|
|
| |
| |
| |
| 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"}`)); |
| } |
|
|
| |
| |
| |
| function wireEvents() { |
| |
| $$(".nav-item").forEach((btn) => { |
| btn.addEventListener("click", () => switchView(btn.dataset.view)); |
| }); |
|
|
| |
| $("#theme-toggle").addEventListener("click", toggleTheme); |
|
|
| |
| $("#menu-toggle").addEventListener("click", () => { |
| const sb = $("#sidebar"); |
| if (sb.classList.contains("is-open")) closeSidebarMobile(); |
| else openSidebarMobile(); |
| }); |
|
|
| |
| 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").addEventListener("click", (e) => { |
| e.stopPropagation(); |
| clearPreview(); |
| }); |
|
|
| |
| $("#load-url-btn").addEventListener("click", loadImageFromUrl); |
| $("#image-url").addEventListener("keydown", (e) => { |
| if (e.key === "Enter") loadImageFromUrl(); |
| }); |
|
|
| |
| $$("#action-grid .action-btn").forEach((btn) => { |
| btn.addEventListener("click", () => runAction(btn.dataset.action)); |
| }); |
|
|
| |
| $("#refresh-providers").addEventListener("click", loadProviders); |
| $("#refresh-stats").addEventListener("click", loadStats); |
| $("#refresh-jobs").addEventListener("click", loadJobs); |
| $("#refresh-health").addEventListener("click", loadHealth); |
|
|
| |
| $("#clear-cache-btn").addEventListener("click", clearCache); |
|
|
| |
| document.addEventListener("keydown", (e) => { |
| if (e.key === "Escape") closeSidebarMobile(); |
| }); |
|
|
| |
| setInterval(() => { |
| if (state.currentView === "jobs") { |
| |
| $$("#jobs-tbody tr").forEach((r) => { |
| |
| }); |
| } |
| }, 60000); |
| } |
|
|
| |
| |
| |
| async function init() { |
| initTheme(); |
| wireEvents(); |
| updateSourceHint(); |
| switchView("analyze"); |
| await checkConnection(); |
| |
| loadJobs().catch(() => {}); |
| } |
|
|
| if (document.readyState === "loading") { |
| document.addEventListener("DOMContentLoaded", init); |
| } else { |
| init(); |
| } |
| })(); |
|
|