1(() => { 2 const PAGE_SIZE = 25; 3 4 const tbody = document.querySelector("#runs tbody"); 5 const empty = document.getElementById("empty"); 6 const noMatch = document.getElementById("no-match"); 7 const pager = document.getElementById("pager"); 8 const prevBtn = document.getElementById("pager-prev"); 9 const nextBtn = document.getElementById("pager-next"); 10 const meta = document.getElementById("pager-meta"); 11 const filterInput = document.getElementById("filter"); 12 const filterCount = document.getElementById("filter-count"); 13 const chipBox = document.getElementById("verdict-chips"); 14 const headers = document.querySelectorAll("#runs thead th[data-sort]"); 15 16 // Column index â sort key map mirrors the table column order. 17 const COL_INDEX = { ts: 0, label: 1, verdict: 2, signer: 3 }; 18 const COLUMN_COUNT = Object.keys(COL_INDEX).length; 19 20 let activeVerdict = ""; 21 let sortKey = "ts"; 22 let sortDir = "desc"; 23 24 // Relative time, news-feed style. Uses the fixed-width "h"/"m"/"d" 25 // suffixes so columns align without monospace and a 60-char cell 26 // doesn't have to fit a full ISO timestamp. 27 const relTime = (s) => { 28 if (!s) return "?"; 29 const d = new Date(s); 30 if (Number.isNaN(d.getTime())) return "?"; 31 const secs = Math.max(0, Math.round((Date.now() - d.getTime()) / 1000)); 32 if (secs < 45) return "just now"; 33 if (secs < 90) return "1m ago"; 34 const mins = Math.round(secs / 60); 35 if (mins < 60) return `${mins}m ago`; 36 const hrs = Math.round(mins / 60); 37 if (hrs < 24) return `${hrs}h ago`; 38 const days = Math.round(hrs / 24); 39 if (days < 7) return `${days}d ago`; 40 if (days < 30) return `${Math.round(days / 7)}w ago`; 41 return d.toISOString().slice(0, 10); // long-tail: ISO date 42 }; 43 44 // Day key for grouping into "Today / Yesterday / Apr 24" headers 45 // between rows. Same TZ as the user's browser so the boundaries feel 46 // right on a phone in a non-UTC timezone. 47 const dayKey = (s) => { 48 if (!s) return ""; 49 const d = new Date(s); 50 return Number.isNaN(d.getTime()) ? "" : d.toDateString(); 51 }; 52 const dayLabel = (key) => { 53 if (!key) return ""; 54 const today = new Date().toDateString(); 55 if (key === today) return "Today"; 56 const y = new Date(); y.setDate(y.getDate() - 1); 57 if (key === y.toDateString()) return "Yesterday"; 58 const d = new Date(key); 59 if (d.getFullYear() === new Date().getFullYear()) { 60 return d.toLocaleDateString(undefined, { month: "short", day: "
60numeric" }); 61 } 62 return d.toLocaleDateString(undefined, { 63 year: "numeric", month: "short", day: "numeric", 64 }); 65 }; 66 const esc = (s) => 67 (s ?? "").replace( 68 /[<>&"']/g, 69 (c) => 70 ({ 71 "<": "<", 72 ">": ">", 73 "&": "&", 74 '"': """, 75 "'": "'", 76 })[c], 77 ); 78 const labelOf = (r) => { 79 const p = (r.prompt || "").trim(); 80 if (p) return p; 81 const ai = (r.ai_description || "").trim(); 82 if (ai) return ai; 83 const f = (r.original_filename || "").trim(); 84 if (f) return f; 85 return ""; 86 }; 87 88 const params = new URLSearchParams(location.search); 89 let offset = Math.max(0, parseInt(params.get("offset") || "0", 10) || 0); 90 91 bindRowLinks(); 92 upgradeServerRenderedTimes(); 93 insertDaySeparators(); 94 applyFilters(); 95 wireToolbar(); 96 wireKeyboard(); 97 wireHoverPreview(); 98 99 function upgradeServerRenderedTimes() { 100 // Server-side render emits an absolute UTC timestamp in `<time>` 101 // elements so the page is readable before JS boots; once we're up, 102 // swap them to relative ("3m ago") so the feed reads like a feed. 103 document.querySelectorAll("#runs tbody tr.row-link time").forEach((el) => { 104 const dt = el.getAttribute("datetime"); 105 if (dt) el.textContent = relTime(dt); 106 }); 107 } 108 109 function insertDaySeparators() { 110 // Server render doesn't know about the user's local TZ, so we add 111 // day-grouping rows here on hydration. Idempotent: skips rows that 112 // already have a separator immediately above. 113 const rows = Array.from(tbody.querySelectorAll("tr.row-link")); 114 let lastDay = null; 115 rows.forEach((tr) => { 116 const dt = tr.querySelector("time")?.getAttribute("datetime"); 117 const day = dayKey(dt); 118 if (!day) return; 119 if (day !== lastDay) { 120 if (!tr.previousElementSibling?.classList?.contains("day-separator")) { 121 const sep = document.createElement("tr"); 122 sep.className = "day-separator"; 123 sep.setAttribute("aria-hidden", "true"); 124 sep.innerHTML = `<td colspan="${COLUMN_COUNT}"><span>${esc(dayLabel(day))}</span></td>`; 125 tr.parentNode.insertBefore(sep, tr); 126 } 127 lastDay = day; 128 } 129 }); 130 } 131 132 function bindRowLinks() { 133 tbody.querySelectorAll(".row-link").forEach((tr) => { 134 if (tr.dataset.bound) return; 135 tr.dataset.bound = "1"; 136 const go = () => { 137 const href = tr.getAttribute("data-href"); 138 if (href) location.assign(href); 139 }; 140 tr.addEventListener("click", go); 141 tr.addEventListener("keydown", (e) => { 142 if (e.key === "Enter" || e.key === " ") { 143 e.preventDefault(); 144 go(); 145 } 146 }); 147 }); 148 } 149 150 function showSkeleton() { 151 const cells = "<td></td>".repeat(COLUMN_COUNT); 152 tbody.innerHTML = Array.from( 153 { length: PAGE_SIZE }, 154 () => `<tr class="row-skeleton" aria-hidden="true">${cells}</tr>`, 155 ).join(""); 156 pager.setAttribute("aria-busy", "true"); 157 prevBtn.disabled = true; 158 nextBtn.disabled = true; 159 noMatch.hidden = true; 160 } 161 162 async function load() { 163 const url = `/api/recent?limit=${PAGE_SIZE}&offset=${offset}`; 164 let data; 165 try { 166 const r = await fetch(url); 167 if (!r.ok) { 168 tbody.innerHTML = `<tr><td colspan="${COLUMN_COUNT}">failed to load recent runs (${r.status})</td></tr>`; 169 pager.removeAttribute("aria-busy"); 170 return; 171 } 172 data = await r.json(); 173 } catch (err) { 174 tbody.innerHTML = `<tr><td colspan="${COLUMN_COUNT}">failed to load recent runs</td></tr>`; 175 pager.removeAttribute("aria-busy"); 176 return; 177 } 178 render(data); 179 pager.removeAttribute("aria-busy"); 180 } 181 182 function render(data) { 183 const runs = data.runs || []; 184 if (!runs.length && offset === 0) { 185 empty.hidden = false; 186 document.getElementById("runs").hidden = true; 187 pager.hidden = true; 188 return; 189 } 190 empty.hidden = true; 191 document.getElementById("runs").hidden = false; 192 pager.hidden = false; 193 194 // Insert day-separator pseudo-rows between groups. The separator row 195 // is non-interactive and skipped by sort/filter/keyboard nav. 196 const out = []; 197 let lastDay = null; 198 runs.forEach((r) => { 199 const day = dayKey(r.ts); 200 if (day && day !== lastDay) { 201 out.push( 202 `<tr class="day-separator" aria-hidden="true">` + 203 `<td colspan="${COLUMN_COUNT}"><span>${esc(dayLabel(day))}</span></td>` + 204 `</tr>`, 205 ); 206 lastDay = day; 207 } 208 const label = labelOf(r); 209 const labelHtml = label 210 ? `<span title="${esc(label)}">${esc(label)}</span>` 211 : `<span class="muted">â</span>`;
212 // Prefer the canonical /r/{slug-shortsha} share form so a click 213 // doesn't bounce through a 301; fall back to the legacy id. 214 const href = `/r/${esc(r.share_token || r.id)}`; 215 // Two-line "signed by" cell: signer on top, root dimmed below. 216 // Keeps both anchors visible without a wide column. 217 const signer = (r.signer_cn || "").trim(); 218 const root = (r.root_cn || "").trim(); 219 let signedBy; 220 if (signer && root && signer !== root) { 221 signedBy = 222 `<span class="signed-by__signer">${esc(signer)}</span>` + 223 `<span class="signed-by__root">${esc(root)}</span>`; 224 } else if (signer) { 225 signedBy = `<span class="signed-by__signer">${esc(signer)}</span>`; 226 } else if (root) { 227 signedBy = `<span class="signed-by__signer">${esc(root)}</span>`; 228 } else { 229 signedBy = `<span class="muted">â</span>`; 230 } 231 // Subject names and issuers are evidence, not a trust verdict. Keep 232 // each assertion paired through pagination, including incomplete ones. 233 const identities = r.cawg_identities || (r.cawg_issuers || []).map(issuer => ({ names: [], issuer })); 234 for (const identity of identities) { 235 const name = esc((identity.names || []).join(", ") || "Name not reported"); 236 const issuer = esc(identity.issuer || "Issuer not reported"); 237 signedBy += `<span class="signed-by__identity">` + 238 `<span class="signed-by__name">${name}</span>` + 239 `<span class="signed-by__label" title="Recorded CAWG identity; see report for verification">CAWG</span>` + 240 `<span class="signed-by__issuer" title="${issuer}">${issuer}</span></span>`; 241 } 242 out.push(` 243 <tr class="row-link" data-href="${href}" data-sha="${esc(r.image_sha256 || "")}" tabindex="0"> 244 <td><time datetime="${esc(r.ts)}" title="${esc(r.ts)}">${esc(relTime(r.ts))}</time></td> 245 <td class="label-cell">${labelHtml}</td> 246 <td><span class="badge" data-verdict="${esc(r.verdict)}">${esc(r.verdict)}</span></td> 247 <td class="signer-cell">${signedBy}</td> 248 </tr> 249 `); 250 }); 251 tbody.innerHTML = out.join(""); 252 253 bindRowLinks(); 254 applySort(); 255 applyFilters(); 256 257 const start = offset + 1; 258 const end = offset + runs.length; 259 meta.textContent = `${start.toLocaleString()}â${end.toLocaleString()}`; 260 prevBtn.disabled = offset === 0; 261 nextBtn.disabled = !data.has_more; 262 } 263 264 function navigate(newOffset) { 265 offset = Math.max(0, newOffset); 266 const qs = offset === 0 ? "" : `?offset=${offset}`; 267 history.replaceState(null, "", `${location.pathname}${qs}`); 268 window.scrollTo({ top: 0, behavior: "instant" }); 269 showSkeleton(); 270 load(); 271 } 272 273 prevBtn.addEventListener("click", () => navigate(offset - PAGE_SIZE)); 274 nextBtn.addEventListener("click", () => navigate(offset + PAGE_SIZE)); 275 276 // ---- toolbar (filter, chips, sort) ----------------------------------- 277 278 function wireToolbar() { 279 let filterTimer = null; 280 filterInput.addEventListener("input", () => { 281 // Debounce so typing fast doesn't thrash the DOM hide/show pass. 282 clearTimeout(filterTimer); 283 filterTimer = setTimeout(applyFilters, 80); 284 }); 285 filterInput.addEventListener("keydown", (e) => { 286 if (e.key === "Escape") { 287 filterInput.value = ""; 288 applyFilters(); 289 filterInput.blur(); 290 } 291 }); 292 293 chipBox.addEventListener("click", (e) => { 294 const chip = e.target.closest(".chip"); 295 if (!chip) return; 296 activeVerdict = chip.dataset.verdict || ""; 297 chipBox.querySelectorAll(".chip").forEach((c) => 298 c.classList.toggle("is-active", c === chip), 299 ); 300 applyFilters(); 301 }); 302 303 headers.forEach((th) => { 304 const btn = th.querySelector(".th-sort"); 305 if (!btn) return; 306 btn.addEventListener("click", () => { 307 const key = th.dataset.sort; 308 if (sortKey === key) { 309 sortDir = sortDir === "asc" ? "desc" : "asc"; 310 } else { 311 sortKey = key; 312 // Sensible defaults: timestamps default newest-first, text columns 313 // default AâZ so the first click reads naturally. 314 sortDir = key === "ts" ? "desc" : "asc"; 315 } 316 updateSortIndicators(); 317 applySort(); 318 }); 319 }); 320 updateSortIndicators(); 321 } 322 323 function updateSortIndicators() {
324 headers.forEach((th) => { 325 const isActive = th.dataset.sort === sortKey; 326 th.setAttribute( 327 "aria-sort", 328 isActive 329 ? sortDir === "asc" 330 ? "ascending" 331 : "descending" 332 : "none", 333 ); 334 th.classList.toggle("is-sorted", isActive); 335 th.classList.toggle("is-asc", isActive && sortDir === "asc"); 336 }); 337 } 338 339 function applySort() { 340 const idx = COL_INDEX[sortKey]; 341 if (idx === undefined) return; 342 const rows = Array.from(tbody.querySelectorAll("tr.row-link")); 343 rows.sort((a, b) => { 344 const va = sortValue(a, sortKey, idx); 345 const vb = sortValue(b, sortKey, idx); 346 const cmp = va < vb ? -1 : va > vb ? 1 : 0; 347 return sortDir === "asc" ? cmp : -cmp; 348 }); 349 const frag = document.createDocumentFragment(); 350 rows.forEach((r) => frag.appendChild(r)); 351 tbody.appendChild(frag); 352 } 353 354 function sortValue(tr, key, idx) { 355 const cell = tr.children[idx]; 356 if (!cell) return ""; 357 if (key === "ts") { 358 const t = cell.querySelector("time"); 359 return (t && t.getAttribute("datetime")) || cell.textContent.trim(); 360 } 361 return cell.textContent.trim().toLowerCase(); 362 } 363 364 function applyFilters() { 365 const q = filterInput.value.trim().toLowerCase(); 366 const rows = tbody.querySelectorAll("tr.row-link"); 367 let visible = 0; 368 rows.forEach((tr) => { 369 const matchQ = !q || tr.textContent.toLowerCase().includes(q) || (tr.dataset.sha || "").toLowerCase().includes(q); 370 const verdict = 371 tr.querySelector(".badge")?.dataset.verdict || ""; 372 const matchV = !activeVerdict || verdict === activeVerdict; 373 const show = matchQ && matchV; 374 tr.hidden = !show; 375 if (show) visible++; 376 }); 377 const total = rows.length; 378 if (q || activeVerdict) { 379 filterCount.textContent = `${visible} of ${total}`; 380 } else { 381 filterCount.textContent = ""; 382 } 383 noMatch.hidden = !(total > 0 && visible === 0); 384 } 385 386 // ---- keyboard -------------------------------------------------------- 387 388 function wireKeyboard() { 389 document.addEventListener("keydown", (e) => { 390 // Don't hijack typing inside form fields. 391 const inField = 392 e.target instanceof HTMLElement && 393 (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA"); 394 395 if (e.key === "/" && !inField) { 396 e.preventDefault(); 397 filterInput.focus(); 398 filterInput.select(); 399 return; 400 } 401 if (inField) return; 402 if (e.key === "j" || e.key === "ArrowDown") { 403 e.preventDefault(); 404 moveFocus(1); 405 } else if (e.key === "k" || e.key === "ArrowUp") { 406 e.preventDefault(); 407 moveFocus(-1); 408 } else if (e.key === "Escape") { 409 if (activeVerdict || filterInput.value) { 410 activeVerdict = ""; 411 filterInput.value = ""; 412 chipBox.querySelectorAll(".chip").forEach((c, i) => 413 c.classList.toggle("is-active", i === 0), 414 ); 415 applyFilters(); 416 } 417 } 418 }); 419 } 420 421 // ---- hover preview -------------------------------------------------- 422 // Float the rendered summary card next to the cursor on row hover. 423 // Skipped on touch devices (no real hover signal) and on narrow viewports 424 // where there's no room. Uses the og-image route so the URL is stable 425 // and the underlying presigned blob URL can rotate. 426 function wireHoverPreview() { 427 const preview = document.getElementById("row-preview"); 428 if (!preview) return; 429 const previewImg = preview.querySelector("img"); 430 if (window.matchMedia("(hover: none), (max-width: 720px)").matches) return; 431 432 let showTimer = null; 433 let activeRow = null; 434 435 const PAD = 18; 436 const W = 320; 437 const H = 480; 438 439 function place(e) { 440 let x = e.clientX + PAD; 441 let y = e.clientY + PAD; 442 if (x + W + 8 > window.innerWidth) x = e.clientX - W - PAD; 443 if (y + H + 8 > window.innerHeight) y = window.innerHeight - H - 8; 444 if (y < 8) y = 8; 445 preview.style.left = `${x}px`; 446 preview.style.top = `${y}px`; 447 } 448 449 function show(tr, e) { 450 const href = tr.getAttribute("data-href"); 451 if (!href) return; 452 activeRow = tr; 453 const url = `${href}/og-image`; 454 // Only swap src if it changed; lets the browser cache one preview per row 455 // instead of re-fetching the redirect chain on every re-hover. 456 const abs = new URL(url, location.href).href; 457 if (previewImg.src !== abs) { 458 previewImg.removeAttribute("src"); 459 previewImg.src = url; 460 } 461 place(e); 462 preview.classList.add("is-visible"); 463 } 464 465 function hide() { 466 activeRow = null; 467 preview.classList.remove("is-visible"); 468 } 469 470 document.addEventListener( 471 "mouseover", 472 (e) => { 473 const tr = e.target.closest("#runs tbody tr.row-link"); 474 if (!tr) { 475 hide(); 476 return; 477 } 478 if (tr === activeRow) return; 479 clearTimeout(showTimer); 480 showTimer = setTimeout(() => show(tr, e), 120); 481 }, 482 true, 483 ); 484 document.addEventListener("mousemove", (e) => { 485 if (preview.classList.contains("is-visible")) place(e); 486 }); 487 document.addEventListener("mouseout", (e) => { 488 if (e.target.closest("#runs tbody tr.row-link") && !e.relatedTarget) { 489 clearTimeout(showTimer); 490 hide(); 491 } 492 }); 493 // Hide on scroll â otherwise the preview drifts off cursor. 494 window.addEventListener("scroll", hide, { passive: true }); 495 } 496 497 function moveFocus(delta) { 498 const visible = Array.from( 499 tbody.querySelectorAll("tr.row-link:not([hidden])"), 500 ); 501 if (!visible.length) return; 502 const cur = document.activeElement; 503 let i = visible.indexOf(cur); 504 if (i === -1) { 505 i = delta > 0 ? -1 : visible.length; 506 } 507 const next = visible[Math.max(0, Math.min(visible.length - 1, i + delta))]; 508 if (next) next.focus(); 509 } 510})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.