page.html 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. <!doctype html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. <title>sm121 — tested recipes</title>
  6. <style>
  7. :root { color-scheme: light dark; }
  8. body { font: 13px/1.45 system-ui, sans-serif; margin: 16px; }
  9. h1 { font-size: 18px; margin: 0 0 10px; }
  10. #bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
  11. #q { flex: 1 1 220px; max-width: 380px; padding: 4px 8px; }
  12. select, button, #cols summary { padding: 3px 8px; cursor: pointer; }
  13. #cols { position: relative; display: inline-block; }
  14. #colbox { position: absolute; z-index: 5; background: canvas; border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
  15. padding: 6px 10px; max-height: 60vh; overflow: auto; white-space: nowrap; }
  16. #colbox label { display: block; }
  17. table { border-collapse: collapse; width: 100%; }
  18. th, td { border: 1px solid color-mix(in srgb, currentColor 20%, transparent); padding: 3px 8px; text-align: left; vertical-align: top; }
  19. th { cursor: pointer; user-select: none; white-space: nowrap; position: sticky; top: 0; background: canvas; }
  20. tr.g1 td { font-weight: 700; background: color-mix(in srgb, currentColor 8%, canvas); cursor: pointer; }
  21. tr.g2 td { font-style: italic; background: color-mix(in srgb, currentColor 4%, canvas); cursor: pointer; }
  22. tr.hist td { font-family: ui-monospace, monospace; font-size: 12px; background: color-mix(in srgb, currentColor 3%, canvas); }
  23. a.open { color: inherit; text-decoration: none; border: 1px solid color-mix(in srgb, currentColor 30%, transparent); border-radius: 3px; padding: 0 4px; }
  24. a.open:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
  25. tr.row { cursor: pointer; }
  26. td.recipe { position: relative; }
  27. td.recipe .copy { position: absolute; right: 3px; top: 50%; transform: translateY(-50%);
  28. visibility: hidden; cursor: pointer; padding: 0 4px; border-radius: 3px; border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
  29. background: canvas; color: inherit; font: inherit; line-height: 1.3; }
  30. td.recipe:hover .copy { visibility: visible; }
  31. td.recipe .copy:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
  32. td.recipe .copied { color: green; font-weight: 600; }
  33. .half { -webkit-background-clip: text; background-clip: text; color: transparent; }
  34. td.num { text-align: right; white-space: nowrap; }
  35. #count { color: gray; }
  36. #live { color: #2a9d2a; font-size: 11px; white-space: nowrap; }
  37. #live.dim { color: gray; }
  38. #bell { position: relative; padding: 3px 8px; cursor: pointer; background: none; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 4px; }
  39. #bell svg { vertical-align: -3px; }
  40. #bellcount { position: absolute; top: -7px; right: -7px; min-width: 17px; height: 17px; line-height: 17px; text-align: center;
  41. font-size: 10px; font-weight: 700; color: #fff; background: #c0392b; border-radius: 9px; padding: 0 3px; }
  42. #notifbox { position: absolute; z-index: 6; right: 0; top: 100%; width: min(460px, 90vw); max-height: 60vh; overflow: auto;
  43. background: canvas; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 5px; padding: 6px; }
  44. #bar { position: relative; }
  45. .noti { display: flex; gap: 8px; padding: 5px 6px; border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent); cursor: pointer; }
  46. .noti:hover { background: color-mix(in srgb, currentColor 6%, canvas); }
  47. .noti:last-child { border-bottom: none; }
  48. .nic { font-weight: 700; }
  49. @keyframes ring { 0% { transform: rotate(0) } 20% { transform: rotate(13deg) } 45% { transform: rotate(-11deg) } 70% { transform: rotate(6deg) } 100% { transform: rotate(0) } }
  50. #bell.ring svg { animation: ring 0.6s ease; transform-origin: 50% 0; }
  51. .dim { color: gray; }
  52. </style>
  53. </head>
  54. <body>
  55. <h1>sm121 — tested recipes &amp; benchmark history</h1>
  56. <div id="bar">
  57. <input id="q" type="search" placeholder="Search recipes, models, images, labels…">
  58. <label>Group:
  59. <select id="group">
  60. <option value="mq">Model → Quant</option>
  61. <option value="model">Model</option>
  62. <option value="quant">Quant</option>
  63. <option value="engine">Engine</option>
  64. <option value="" selected>None (flat)</option>
  65. </select>
  66. </label>
  67. <label>TP:
  68. <select id="tp"><option value="">All</option></select>
  69. </label>
  70. <label>Medals:
  71. <select id="medal">
  72. <option value="" selected>All</option>
  73. <option value="m">🥇🥈🥉</option>
  74. <option value="1">🥇 gold</option>
  75. <option value="2">🥈 silver</option>
  76. <option value="3">🥉 bronze</option>
  77. </select>
  78. </label>
  79. <details id="cols"><summary>Columns ▾</summary><div id="colbox"></div></details>
  80. <label>Rows/page:
  81. <select id="size"><option>25</option><option selected>50</option><option>100</option><option value="0">All</option></select>
  82. </label>
  83. <button id="prev">‹</button><span id="count"></span><button id="next">›</button>
  84. <span id="live" class="dim" title="live updates">● off</span>
  85. <button id="bell" title="updates" aria-label="updates">
  86. <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
  87. <span id="bellcount" style="display:none"></span>
  88. </button>
  89. <div id="notifbox" style="display:none"></div>
  90. </div>
  91. <table id="tbl"><thead></thead><tbody></tbody></table>
  92. <script>
  93. "use strict";
  94. let DATA = {{DATA}};
  95. const COLS = [
  96. ["name", "Recipe"], ["title", "Title"], ["model", "Model"], ["quant", "Quant"],
  97. ["engine", "Engine"], ["image", "Image"], ["nodes", "Nodes"], ["tp", "TP"],
  98. ["deploy", "Deploy"], ["port", "Port"], ["n", "Reports"], ["last", "Last bench"],
  99. ["points", "Points"], ["pct", "Score /100"], ["stars", "Bench"],
  100. ["tps1", "Tok/s c1"], ["tps2", "Tok/s c2"], ["parent", "Parent"],
  101. ];
  102. const NUM = new Set(["nodes", "tp", "port", "n", "pct", "tps1", "tps2", "stars"]);
  103. const MEDAL = { 1: "🥇 ", 2: "🥈 ", 3: "🥉 " };
  104. // star grade of the latest completed bench: half-star precision (1 star =
  105. // 20 points), colored by score band — sort key is the ABSOLUTE score
  106. function starInfo(r) {
  107. const e = latest(r, x => x.done && x.pct != null);
  108. return e ? { pct: e.pct, s: Math.round(e.pct / 10) / 2 } : null;
  109. }
  110. const STAR_BAND = p => p < 20 ? "red" : p < 40 ? "brown" : p < 60 ? "orange" : p < 80 ? "gold" : "green";
  111. function starGlyphs(si) {
  112. const col = STAR_BAND(si.pct);
  113. let out = "";
  114. for (let i = 1; i <= 5; i++) {
  115. if (si.s >= i) out += `<span style="color:${col}">★</span>`;
  116. else if (si.s >= i - 0.5)
  117. out += `<span class="half" style="background:linear-gradient(90deg,${col} 50%,color-mix(in srgb,currentColor 25%,transparent) 50%)">★</span>`;
  118. else out += '<span class="dim">☆</span>';
  119. }
  120. return out;
  121. }
  122. // decode speed (tg t/s) from a run's llama-benchy perf cells
  123. function tg(e, d, c) { const p = e && e.perf ? e.perf.find(p => p.d === d && p.c === c) : null; return p ? p.tg : null; }
  124. // latest report matching a predicate, for the points / score columns
  125. function latest(r, f) { for (let i = r.reports.length - 1; i >= 0; i--) if (f(r.reports[i])) return r.reports[i]; return null; }
  126. function cell(r, k) {
  127. if (k === "n") return r.reports.length;
  128. if (k === "last") return r.reports.length ? r.reports[r.reports.length - 1].date : "";
  129. if (k === "points") { const e = latest(r, e => e.points); return e ? e.points : ""; }
  130. if (k === "pct") { const e = latest(r, e => e.pct != null); return e ? e.pct : ""; }
  131. if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? ""; }
  132. if (k === "stars") { const si = starInfo(r); return si ? si.pct : -1; } // sort by the absolute score
  133. return r[k];
  134. }
  135. // deep-link state: the full view (filter, grouping, sort, columns, paging,
  136. // collapsed groups) lives in the URL — survives any reload and is shareable
  137. // Update notifications: pure diff between two rows snapshots → the list of
  138. // what changed (new bench, new/removed recipe, changed entry or row config).
  139. // Newest first. Probed by the node test, extracted between the markers.
  140. /*NOTIFDIFF:START*/
  141. function notifDiff(prev, next) {
  142. const out = [];
  143. const byName = new Map(prev.map(r => [r.name, r]));
  144. const meta = r => JSON.stringify({ ...r, reports: [] });
  145. const item = (t, r, e) => ({
  146. t, name: r.name,
  147. label: e ? e.label : (t === "recipe" ? (r.model || r.title) : "recipe config"),
  148. date: e ? e.date : "", time: e ? e.time : null,
  149. score: e ? e.pct : null, points: e ? e.points : null,
  150. files: e ? (e.files || [e.file]) : [],
  151. });
  152. for (const r of next) {
  153. const old = byName.get(r.name);
  154. if (!old) { out.push(item("recipe", r, null)); continue; }
  155. if (meta(old) !== meta(r)) out.push(item("config", r, null));
  156. const oldR = new Map(old.reports.map(e => [e.date + "\u0000" + e.label, e]));
  157. for (const e of r.reports) {
  158. const oe = oldR.get(e.date + "\u0000" + e.label);
  159. if (!oe) out.push(item("bench", r, e));
  160. else if (JSON.stringify(oe) !== JSON.stringify(e)) out.push(item("change", r, e));
  161. }
  162. }
  163. const nextNames = new Set(next.map(r => r.name));
  164. for (const r of prev)
  165. if (!nextNames.has(r.name)) out.push(item("removed", r, null));
  166. return out.reverse();
  167. }
  168. /*NOTIFDIFF:END*/
  169. // LIVE: the server re-scans the reports/ dirs on file-system events and
  170. // pushes the rows JSON over SSE whenever something changed (new bench,
  171. // finishing run, new report file). The view state (filter, sort, paging,
  172. // columns, collapsed groups) is untouched — render() re-derives everything
  173. // from DATA.
  174. const liveEl = document.getElementById("live");
  175. function syncTp() {
  176. const vals = [...new Set(DATA.map(r => r.tp))].sort((a, b) => a - b);
  177. const sel = document.getElementById("tp");
  178. if (vals.map(String).join(",") !== [...sel.options].map(o => o.value).join(","))
  179. sel.innerHTML = '<option value="">All</option>' + vals.map(v => `<option>${v}</option>`).join("");
  180. if (S.tp && ![...sel.options].some(o => o.value === S.tp)) S.tp = "";
  181. sel.value = S.tp;
  182. }
  183. // Bell + notifications: UNREAD counts updates since the panel was last
  184. // opened; NOTIFS (capped) is the list the panel shows. Clicking an item
  185. // expands the matching row in the table.
  186. const bellEl = document.getElementById("bell");
  187. const notifBox = document.getElementById("notifbox");
  188. let UNREAD = 0;
  189. let NOTIFS = [];
  190. const NICON = { bench: "+", change: "↻", recipe: "★", config: "✎", removed: "−" };
  191. function renderBell() {
  192. const c = document.getElementById("bellcount");
  193. c.textContent = String(UNREAD);
  194. c.style.display = UNREAD ? "" : "none";
  195. }
  196. function renderNotifs() {
  197. if (!NOTIFS.length) {
  198. notifBox.innerHTML = '<div class="dim" style="padding:8px">no updates yet</div>';
  199. return;
  200. }
  201. notifBox.innerHTML = NOTIFS.map(n => {
  202. const when = n.date ? `${n.date}${n.time ? "·" + n.time : ""}` : "";
  203. const score = n.score != null ? `${n.score} /100` : n.points || "";
  204. const links = n.files.map(f =>
  205. `<a class="open" target="_blank" href="/r/${encodeURIComponent(n.name)}/${encodeURIComponent(f)}" title="open ${esc(f)}">${esc(f.split(".").pop())}</a>`).join(" ");
  206. return `<div class="noti" data-name="${esc(n.name)}" title="show in table">
  207. <span class="nic">${NICON[n.t] || "•"}</span>
  208. <div><b>${esc(n.name)}</b> <span class="dim">${esc(n.label)}</span><br>
  209. <span class="dim">${esc(when)}</span> ${score ? esc(score) : ""} ${links}</div>
  210. </div>`;
  211. }).join("");
  212. }
  213. bellEl.addEventListener("click", e => {
  214. e.stopPropagation();
  215. const open = notifBox.style.display === "block";
  216. notifBox.style.display = open ? "none" : "block";
  217. if (!open) { UNREAD = 0; renderBell(); }
  218. });
  219. document.addEventListener("click", () => { notifBox.style.display = "none"; });
  220. notifBox.addEventListener("click", e => {
  221. const it = e.target.closest(".noti");
  222. if (!it) return;
  223. S.openRow = it.dataset.name;
  224. notifBox.style.display = "none";
  225. render();
  226. });
  227. const es = new EventSource("/events");
  228. es.addEventListener("open", () => { liveEl.textContent = "● live"; liveEl.classList.remove("dim"); });
  229. es.addEventListener("error", () => { liveEl.textContent = "● off"; liveEl.classList.add("dim"); });
  230. es.onmessage = ev => {
  231. let rows; try { rows = JSON.parse(ev.data); } catch { return; }
  232. if (JSON.stringify(rows) === JSON.stringify(DATA)) return;
  233. const upd = notifDiff(DATA, rows);
  234. DATA = rows; syncTp(); render();
  235. liveEl.title = "updated " + new Date().toLocaleTimeString();
  236. if (upd.length) {
  237. NOTIFS.unshift(...upd);
  238. if (NOTIFS.length > 100) NOTIFS.length = 100;
  239. UNREAD += upd.length;
  240. renderBell(); renderNotifs();
  241. bellEl.classList.remove("ring"); void bellEl.offsetWidth; bellEl.classList.add("ring");
  242. }
  243. };
  244. renderBell(); renderNotifs();
  245. const P = new URLSearchParams(location.search);
  246. const size0 = +(P.get("size") ?? 50);
  247. // distinct TP values from the data, for the exact-TP filter
  248. const TPVALS = [...new Set(DATA.map(r => r.tp))].sort((a, b) => a - b);
  249. const tpParam = P.get("tp");
  250. for (const v of TPVALS) {
  251. const o = document.createElement("option");
  252. o.value = String(v);
  253. o.textContent = String(v);
  254. document.getElementById("tp").appendChild(o);
  255. }
  256. const S = {
  257. q: P.get("q") ?? "",
  258. group: P.get("group") ?? "",
  259. sort: P.get("sort") ?? "name",
  260. dir: P.get("dir") ? +P.get("dir") : 1,
  261. size: [25, 50, 100, 0].includes(size0) ? size0 : 50,
  262. page: Math.max(0, +(P.get("page") ?? 0)),
  263. hidden: new Set((P.get("hidden") ?? "title,image,port,parent").split(",").filter(Boolean)),
  264. closed: new Set((P.get("closed") ?? "").split("|").map(decodeURIComponent).filter(Boolean)),
  265. openRow: P.get("open") ?? null,
  266. medal: P.get("medal") ?? "",
  267. tp: tpParam != null && TPVALS.includes(+tpParam) ? tpParam : "",
  268. };
  269. document.getElementById("q").value = S.q;
  270. document.getElementById("group").value = S.group;
  271. document.getElementById("size").value = String(S.size);
  272. document.getElementById("tp").value = S.tp;
  273. document.getElementById("medal").value = S.medal;
  274. function syncUrl() {
  275. const p = new URLSearchParams();
  276. if (S.q) p.set("q", S.q);
  277. if (S.group !== "") p.set("group", S.group);
  278. if (S.sort !== "name") p.set("sort", S.sort);
  279. if (S.dir !== 1) p.set("dir", String(S.dir));
  280. if (S.size !== 50) p.set("size", String(S.size));
  281. if (S.page) p.set("page", String(S.page));
  282. const hidden = [...S.hidden].sort();
  283. if (hidden.join(",") !== ["title", "image", "port"].sort().join(",")) p.set("hidden", hidden.join(","));
  284. const closed = [...S.closed].map(encodeURIComponent);
  285. if (closed.length) p.set("closed", closed.join("|"));
  286. if (S.openRow) p.set("open", S.openRow);
  287. if (S.medal) p.set("medal", S.medal);
  288. if (S.tp) p.set("tp", S.tp);
  289. const qs = p.toString();
  290. history.replaceState(null, "", qs ? "?" + qs : location.pathname);
  291. }
  292. const esc = s => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
  293. function filtered() {
  294. const q = S.q.toLowerCase();
  295. return DATA.filter(r => (!S.tp || String(r.tp) === S.tp) &&
  296. (!S.medal || (S.medal === "m" ? r.medal != null : String(r.medal) === S.medal)) &&
  297. (!q || (r.name + " " + r.title + " " + r.model + " " + r.parent + " " + r.quant + " " +
  298. r.engine + " " + r.image + " " + r.deploy + " " +
  299. r.reports.map(e => e.date + " " + e.label).join(" ")).toLowerCase().includes(q)));
  300. }
  301. function sorted(rows) {
  302. const gk = { mq: r => r.parent + "\0" + r.quant, model: r => r.parent,
  303. quant: r => r.quant, engine: r => r.engine }[S.group];
  304. const k = S.sort, d = S.dir;
  305. const val = r => {
  306. if (k === "points") { const e = latest(r, e => e.ratio != null); return e ? e.ratio : -1; }
  307. if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? -1; }
  308. const v = cell(r, k); return NUM.has(k) || k === "last" ? v : String(v).toLowerCase();
  309. };
  310. return rows.slice().sort((a, b) => {
  311. if (gk) { const ga = gk(a), gb = gk(b); if (ga !== gb) return ga.localeCompare(gb); }
  312. let x = val(a), y = val(b);
  313. if (x === y) return a.name.localeCompare(b.name);
  314. if (typeof x === "number" && typeof y === "number") return (x - y) * d;
  315. return String(x).localeCompare(String(y)) * d;
  316. });
  317. }
  318. function gkey(r, level) {
  319. if (S.group === "mq") return level === 1 ? r.model : r.model + "\u0001" + r.quant;
  320. const f = { model: "model", quant: "quant", engine: "engine" }[S.group];
  321. return level === 1 ? r[f] : null;
  322. }
  323. function render() {
  324. syncUrl();
  325. // visible columns
  326. const vis = COLS.filter(c => !S.hidden.has(c[0]));
  327. document.querySelector("thead").innerHTML = "<tr>" + vis.map(c => {
  328. const arrow = S.sort === c[0] ? (S.dir > 0 ? " ▲" : " ▼") : "";
  329. return `<th data-k="${c[0]}">${esc(c[1])}${arrow}</th>`;
  330. }).join("") + "</tr>";
  331. const all = sorted(filtered());
  332. const size = +document.getElementById("size").value;
  333. const pages = size ? Math.max(1, Math.ceil(all.length / size)) : 1;
  334. if (S.page >= pages) S.page = pages - 1;
  335. const rows = size ? all.slice(S.page * size, S.page * size + size) : all;
  336. document.getElementById("count").textContent = size
  337. ? `${all.length ? S.page * size + 1 : 0}–${Math.min(all.length, S.page * size + size)} / ${all.length}` : `${all.length}`;
  338. // group tree over the current page slice
  339. const out = [];
  340. let g1 = null, g2 = null;
  341. for (const r of rows) {
  342. if (S.group) {
  343. const k1 = gkey(r, 1);
  344. if (k1 !== g1) {
  345. g1 = k1; g2 = null;
  346. const closed = S.closed.has("1\0" + k1);
  347. out.push(`<tr class="g1" data-g="${encodeURIComponent("1\0" + k1)}"><td colspan="${vis.length}">${closed ? "▶" : "▼"} ${esc(k1)} <span class="dim">(${all.filter(x => gkey(x, 1) === k1).length})</span></td></tr>`);
  348. }
  349. if (S.closed.has("1\0" + k1)) continue;
  350. const k2 = gkey(r, 2);
  351. if (k2 !== null) {
  352. if (k2 !== g2) {
  353. g2 = k2;
  354. const closed = S.closed.has("2\0" + k2);
  355. out.push(`<tr class="g2" data-g="${encodeURIComponent("2\0" + k2)}"><td colspan="${vis.length}">${closed ? "▶" : "▼"} ${esc(r.quant)} <span class="dim">(${all.filter(x => gkey(x, 2) === k2).length})</span></td></tr>`);
  356. }
  357. if (S.closed.has("2\0" + k2)) continue;
  358. }
  359. }
  360. out.push("<tr class=\"row\" data-n=\"" + esc(r.name) + "\">" + vis.map(c => {
  361. const v = cell(r, c[0]);
  362. const lr = r.reports.length ? r.reports[r.reports.length - 1] : null;
  363. const tip = c[0] === "last" && lr
  364. ? ` title="${esc(lr.date + (lr.time ? " " + lr.time : ""))}"` : "";
  365. const cls = (NUM.has(c[0]) ? "num" : "") + (c[0] === "name" ? " recipe" : "");
  366. let body;
  367. if (c[0] === "stars") {
  368. const si = starInfo(r);
  369. const pe = si && latest(r, e => e.points); // both scores in the hint
  370. body = !si
  371. ? r.incomplete
  372. ? '<span class="dim" title="bench started, not finished">◐</span>'
  373. : '<span class="dim" title="no bench reports">—</span>'
  374. : `<span title="${si.pct} /100${pe && pe.points ? " (" + pe.points + ")" : ""}">${starGlyphs(si)}</span>`;
  375. } else if (v === "" || v == null) {
  376. body = '<span class="dim">—</span>';
  377. } else if (c[0] === "name") {
  378. body = (MEDAL[r.medal] || "") + esc(v) +
  379. `<button class="copy" data-copy="${esc(r.name)}" title="copy recipe name">⧉</button>`;
  380. } else {
  381. body = esc(v);
  382. }
  383. return `<td${cls ? ` class="${cls}"` : ""}${tip}>${body}</td>`;
  384. }).join("") + "</tr>");
  385. if (S.openRow === r.name) {
  386. // one toml section per recipe (the recipe.toml is not versioned);
  387. // per-run report files stay on each line
  388. const toml = `<a class="open" href="/r/${encodeURIComponent(r.name)}/recipe.toml" target="_blank" title="recipe.toml (highlighted)">⚙ toml</a>`;
  389. const lines = r.reports.map(e => {
  390. const fl = e.files && e.files.length ? e.files : [e.file];
  391. const ts = (e.perf && e.perf.length)
  392. ? ` <span title="${esc(e.perf.map(p => "d" + p.d + " c" + p.c + ": " + p.tg + " t/s").join(" · "))}"> ${tg(e, 0, 1) ?? "—"} / ${tg(e, 0, 2) ?? "—"} t/s</span>`
  393. : "";
  394. return `<span>${fl.map(f =>
  395. `<a class="open" href="/r/${encodeURIComponent(r.name)}/${encodeURIComponent(f)}" target="_blank" title="open ${esc(f)}">${esc(f.split('.').pop())}</a>`).join(" ")}</span> ` +
  396. `${esc(e.date)}${e.time ? "·" + esc(e.time) : ""} ${esc(e.label)} — ${e.pct != null ? esc(e.pct) + " /100" : '<span class="dim">no score</span>'}${e.points ? " (" + esc(e.points) + ")" : ""}${ts}`;
  397. }).join("<br>");
  398. out.push(`<tr class="hist"><td colspan="${vis.length}">${r.reports.length ? toml + " " + lines : "no reports"}</td></tr>`);
  399. }
  400. }
  401. document.querySelector("tbody").innerHTML = out.join("");
  402. }
  403. document.querySelector("thead").addEventListener("click", e => {
  404. const th = e.target.closest("th"); if (!th) return;
  405. const k = th.dataset.k;
  406. if (S.sort === k) S.dir = -S.dir; else { S.sort = k; S.dir = 1; }
  407. render();
  408. });
  409. // copy a recipe name to the clipboard (clipboard API, textarea fallback)
  410. function fallbackCopy(text) {
  411. const ta = document.createElement("textarea");
  412. ta.value = text; document.body.appendChild(ta); ta.select();
  413. let ok = false; try { ok = document.execCommand("copy"); } catch {}
  414. ta.remove(); return ok;
  415. }
  416. function copyName(btn) {
  417. const done = ok => {
  418. btn.textContent = ok ? "✓" : "✗";
  419. btn.classList.toggle("copied", ok);
  420. setTimeout(() => { btn.textContent = "⧉"; btn.classList.remove("copied"); }, 900);
  421. };
  422. if (navigator.clipboard) navigator.clipboard.writeText(btn.dataset.copy).then(() => done(true), () => done(fallbackCopy(btn.dataset.copy)));
  423. else done(fallbackCopy(btn.dataset.copy));
  424. }
  425. document.querySelector("tbody").addEventListener("click", e => {
  426. const cp = e.target.closest("button.copy");
  427. if (cp) { copyName(cp); return; } // no row toggle under the copy button
  428. const g = e.target.closest("tr.g1,tr.g2");
  429. if (g) { const k = decodeURIComponent(g.dataset.g); S.closed.has(k) ? S.closed.delete(k) : S.closed.add(k); render(); return; }
  430. const row = e.target.closest("tr.row");
  431. if (row) { S.openRow = S.openRow === row.dataset.n ? null : row.dataset.n; render(); }
  432. });
  433. document.getElementById("q").addEventListener("input", e => { S.q = e.target.value; S.page = 0; render(); });
  434. document.getElementById("group").addEventListener("change", e => { S.group = e.target.value; S.closed.clear(); S.page = 0; render(); });
  435. document.getElementById("tp").addEventListener("change", e => { S.tp = e.target.value; S.page = 0; render(); });
  436. document.getElementById("medal").addEventListener("change", e => { S.medal = e.target.value; S.page = 0; render(); });
  437. document.getElementById("size").addEventListener("change", () => { S.page = 0; render(); });
  438. document.getElementById("prev").addEventListener("click", () => { if (S.page > 0) { S.page--; render(); } });
  439. document.getElementById("next").addEventListener("click", () => { S.page++; render(); });
  440. // column checkboxes
  441. document.getElementById("colbox").innerHTML = COLS.map(c =>
  442. `<label><input type="checkbox" data-k="${c[0]}" ${S.hidden.has(c[0]) ? "" : "checked"}> ${esc(c[1])}</label>`).join("");
  443. document.getElementById("colbox").addEventListener("change", e => {
  444. const k = e.target.dataset.k; if (!k) return;
  445. e.target.checked ? S.hidden.delete(k) : S.hidden.add(k);
  446. render();
  447. });
  448. render();
  449. </script>
  450. </body>
  451. </html>