page.html 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  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. .prov { font-family: ui-monospace, monospace; font-size: 10px; opacity: .8; white-space: nowrap; }
  25. a.open:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
  26. tr.row { cursor: pointer; }
  27. td.recipe { position: relative; }
  28. td.recipe .copy { position: absolute; right: 3px; top: 50%; transform: translateY(-50%);
  29. visibility: hidden; cursor: pointer; padding: 0 4px; border-radius: 3px; border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
  30. background: canvas; color: inherit; font: inherit; line-height: 1.3; }
  31. td.recipe:hover .copy { visibility: visible; }
  32. td.recipe .copy:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
  33. td.recipe .copied { color: green; font-weight: 600; }
  34. td.recipe .medals { letter-spacing: 1px; }
  35. .half { -webkit-background-clip: text; background-clip: text; color: transparent; }
  36. td.num { text-align: right; white-space: nowrap; }
  37. #count { color: gray; }
  38. #live { color: #2a9d2a; font-size: 11px; white-space: nowrap; }
  39. #live.dim { color: gray; }
  40. #bell { position: relative; padding: 3px 8px; cursor: pointer; background: none; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 4px; }
  41. #bell svg { vertical-align: -3px; }
  42. #bellcount { position: absolute; top: -7px; right: -7px; min-width: 17px; height: 17px; line-height: 17px; text-align: center;
  43. font-size: 10px; font-weight: 700; color: #fff; background: #c0392b; border-radius: 9px; padding: 0 3px; }
  44. #notifbox { position: absolute; z-index: 6; right: 0; top: 100%; width: min(460px, 90vw); max-height: 60vh; overflow: auto;
  45. background: canvas; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 5px; padding: 6px; }
  46. #bar { position: relative; }
  47. .noti { display: flex; gap: 8px; padding: 5px 6px; border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent); cursor: pointer; }
  48. .noti:hover { background: color-mix(in srgb, currentColor 6%, canvas); }
  49. .noti:last-child { border-bottom: none; }
  50. .nic { font-weight: 700; }
  51. @keyframes ring { 0% { transform: rotate(0) } 20% { transform: rotate(13deg) } 45% { transform: rotate(-11deg) } 70% { transform: rotate(6deg) } 100% { transform: rotate(0) } }
  52. #bell.ring svg { animation: ring 0.6s ease; transform-origin: 50% 0; }
  53. .dim { color: gray; }
  54. /* Embedded in the dashboard's Recipes window: the window bar carries the
  55. title and the dashboard header carries the connection indicator. Both
  56. SSE streams come from the same server (only the paths differ), so the
  57. panel's own chip can never disagree with it — it is pure duplication. */
  58. body.embed h1 { display: none; }
  59. body.embed #bar { margin-top: 8px; }
  60. body.embed #live { display: none; }
  61. </style>
  62. </head>
  63. <body>
  64. <h1>sm121 — tested recipes &amp; benchmark history</h1>
  65. <div id="bar">
  66. <input id="q" type="search" placeholder="Search recipes, models, images, labels…">
  67. <label>Group:
  68. <select id="group">
  69. <option value="pm">Parent → Model</option>
  70. <option value="parent">Parent</option>
  71. <option value="mq">Model → Quant</option>
  72. <option value="model">Model</option>
  73. <option value="quant">Quant</option>
  74. <option value="engine">Engine</option>
  75. <option value="" selected>None (flat)</option>
  76. </select>
  77. </label>
  78. <label>TP:
  79. <select id="tp"><option value="">All</option></select>
  80. </label>
  81. <label>Medals:
  82. <select id="medal">
  83. <option value="" selected>All</option>
  84. <option value="m">🥇🥈🥉</option>
  85. <option value="1">🥇 gold</option>
  86. <option value="2">🥈 silver</option>
  87. <option value="3">🥉 bronze</option>
  88. </select>
  89. </label>
  90. <label><input type="checkbox" id="uponly"> ● running</label>
  91. <details id="cols"><summary>Columns ▾</summary><div id="colbox"></div></details>
  92. <label>Rows/page:
  93. <select id="size"><option>25</option><option selected>50</option><option>100</option><option value="0">All</option></select>
  94. </label>
  95. <button id="prev">‹</button><span id="count"></span><button id="next">›</button>
  96. <span id="live" class="dim" title="live updates">● off</span>
  97. <button id="bell" title="updates" aria-label="updates">
  98. <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>
  99. <span id="bellcount" style="display:none"></span>
  100. </button>
  101. <div id="notifbox" style="display:none"></div>
  102. </div>
  103. <table id="tbl"><thead></thead><tbody></tbody></table>
  104. <script>
  105. "use strict";
  106. let DATA = {{DATA}};
  107. const COLS = [
  108. ["name", "Recipe"], ["state", "State"], ["uptime", "Uptime"], ["runs", "Launches"],
  109. ["title", "Title"], ["model", "Model"], ["quant", "Quant"],
  110. ["engine", "Engine"], ["image", "Image"], ["nodes", "Nodes"], ["tp", "TP"],
  111. ["deploy", "Deploy"], ["port", "Port"], ["n", "Reports"], ["last", "Last bench"],
  112. ["points", "Points"], ["pct", "Score /100"], ["stars", "Bench"],
  113. ["tps1", "Tok/s c1"], ["tps2", "Tok/s c2"], ["parent", "Parent"],
  114. ];
  115. const NUM = new Set(["nodes", "tp", "port", "n", "pct", "tps1", "tps2", "stars", "state", "uptime", "runs"]);
  116. const MEDAL = { 1: "🥇", 2: "🥈", 3: "🥉" };
  117. // three-tier medal badge: family | model | tp (slots shown only when earned)
  118. function medalBadge(r) {
  119. const M = r.medals || {};
  120. const g = v => MEDAL[v] || "";
  121. const parts = [g(M.p), g(M.m), g(M.t)].filter(Boolean);
  122. if (!parts.length) return "";
  123. return `<span class="medals" title="family ${g(M.p) || "–"} · model ${g(M.m) || "–"} · tp${r.tp} ${g(M.t) || "–"}">${parts.join("|")}</span> `;
  124. }
  125. // run-ledger helpers: who is up right now, for how long (live — a 30 s tick
  126. // re-renders while anything is running)
  127. const nowSecs = () => Math.floor(Date.now() / 1000);
  128. const fmtUp = s => {
  129. s = Math.max(0, s | 0);
  130. const d = (s / 86400) | 0, h = ((s % 86400) / 3600) | 0, m = ((s % 3600) / 60) | 0;
  131. return d ? d + "d " + h + "h" : h ? h + "h " + m + "m" : m ? m + "m" : s + "s";
  132. };
  133. const fmtSince = u => new Date(u * 1000).toISOString().slice(0, 16).replace("T", " ");
  134. // star grade of the latest completed bench. The 10-color palette covers
  135. // 0..100 a decade per color (color = which decade the score is in); the
  136. // star count shows the position WITHIN that decade (1 star = 2 points of
  137. // the band, half star = 1) — 95 → green, 2.5 stars. Sort key is the
  138. // ABSOLUTE score.
  139. const PALETTE = [
  140. "red", "firebrick", "brown", "sienna", "chocolate",
  141. "darkorange", "gold", "yellowgreen", "greenyellow", "green",
  142. ];
  143. function starInfo(r) {
  144. const e = latest(r, x => x.done && x.pct != null);
  145. if (!e) return null;
  146. const band = Math.min(9, Math.floor(e.pct / 10));
  147. return { pct: e.pct, band, s: Math.round(e.pct - band * 10) / 2 };
  148. }
  149. function starGlyphs(si) {
  150. const col = PALETTE[si.band];
  151. let out = "";
  152. for (let i = 1; i <= 5; i++) {
  153. if (si.s >= i) out += `<span style="color:${col}">★</span>`;
  154. else if (si.s >= i - 0.5)
  155. out += `<span class="half" style="background-image:linear-gradient(90deg,${col} 50%,color-mix(in srgb,${col} 30%,transparent) 50%)">★</span>`;
  156. else out += `<span style="color:${col};opacity:.3">☆</span>`;
  157. }
  158. return out;
  159. }
  160. // decode speed (tg t/s) from a run's llama-benchy perf cells
  161. 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; }
  162. // latest report matching a predicate, for the points / score columns
  163. 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; }
  164. function cell(r, k) {
  165. if (k === "n") return r.reports.length;
  166. if (k === "last") return r.reports.length ? r.reports[r.reports.length - 1].date : "";
  167. if (k === "points") { const e = latest(r, e => e.points); return e ? e.points : ""; }
  168. if (k === "pct") { const e = latest(r, e => e.pct != null); return e ? e.pct : ""; }
  169. if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? ""; }
  170. if (k === "stars") { const si = starInfo(r); return si ? si.pct : -1; } // sort by the absolute score
  171. if (k === "state" || k === "uptime") return r.up != null ? nowSecs() - r.up : -1; // live uptime secs
  172. if (k === "runs") return r.runs ?? 0;
  173. return r[k];
  174. }
  175. // deep-link state: the full view (filter, grouping, sort, columns, paging,
  176. // collapsed groups) lives in the URL — survives any reload and is shareable
  177. // Update notifications: pure diff between two rows snapshots → the list of
  178. // what changed (new bench, new/removed recipe, changed entry or row config).
  179. // Newest first. Probed by the node test, extracted between the markers.
  180. /*NOTIFDIFF:START*/
  181. function notifDiff(prev, next) {
  182. const out = [];
  183. const byName = new Map(prev.map(r => [r.name, r]));
  184. const meta = r => JSON.stringify({ ...r, reports: [] });
  185. const item = (t, r, e) => ({
  186. t, name: r.name,
  187. label: e ? e.label : (t === "recipe" ? (r.model || r.title) : "recipe config"),
  188. date: e ? e.date : "", time: e ? e.time : null,
  189. score: e ? e.pct : null, points: e ? e.points : null,
  190. files: e ? (e.files || [e.file]) : [],
  191. });
  192. for (const r of next) {
  193. const old = byName.get(r.name);
  194. if (!old) { out.push(item("recipe", r, null)); continue; }
  195. if (meta(old) !== meta(r)) out.push(item("config", r, null));
  196. const oldR = new Map(old.reports.map(e => [e.date + "\u0000" + e.label, e]));
  197. for (const e of r.reports) {
  198. const oe = oldR.get(e.date + "\u0000" + e.label);
  199. if (!oe) out.push(item("bench", r, e));
  200. else if (JSON.stringify(oe) !== JSON.stringify(e)) out.push(item("change", r, e));
  201. }
  202. }
  203. const nextNames = new Set(next.map(r => r.name));
  204. for (const r of prev)
  205. if (!nextNames.has(r.name)) out.push(item("removed", r, null));
  206. return out.reverse();
  207. }
  208. /*NOTIFDIFF:END*/
  209. // LIVE: the server re-scans the reports/ dirs on file-system events and
  210. // pushes the rows JSON over SSE whenever something changed (new bench,
  211. // finishing run, new report file). The view state (filter, sort, paging,
  212. // columns, collapsed groups) is untouched — render() re-derives everything
  213. // from DATA.
  214. const liveEl = document.getElementById("live");
  215. function syncTp() {
  216. const vals = [...new Set(DATA.map(r => r.tp))].sort((a, b) => a - b);
  217. const sel = document.getElementById("tp");
  218. if (vals.map(String).join(",") !== [...sel.options].map(o => o.value).join(","))
  219. sel.innerHTML = '<option value="">All</option>' + vals.map(v => `<option>${v}</option>`).join("");
  220. if (S.tp && ![...sel.options].some(o => o.value === S.tp)) S.tp = "";
  221. sel.value = S.tp;
  222. }
  223. // Bell + notifications: UNREAD counts updates since the panel was last
  224. // opened; NOTIFS (capped) is the list the panel shows. Clicking an item
  225. // expands the matching row in the table.
  226. const bellEl = document.getElementById("bell");
  227. const notifBox = document.getElementById("notifbox");
  228. let UNREAD = 0;
  229. let NOTIFS = [];
  230. const NICON = { bench: "+", change: "↻", recipe: "★", config: "✎", removed: "−" };
  231. function renderBell() {
  232. const c = document.getElementById("bellcount");
  233. c.textContent = String(UNREAD);
  234. c.style.display = UNREAD ? "" : "none";
  235. }
  236. function renderNotifs() {
  237. if (!NOTIFS.length) {
  238. notifBox.innerHTML = '<div class="dim" style="padding:8px">no updates yet</div>';
  239. return;
  240. }
  241. notifBox.innerHTML = NOTIFS.map(n => {
  242. const when = n.date ? `${n.date}${n.time ? "·" + n.time : ""}` : "";
  243. const score = n.score != null ? `${n.score} /100` : n.points || "";
  244. const links = n.files.map(f =>
  245. `<a class="open" target="_blank" href="/r/${encodeURIComponent(n.name)}/${encodeURIComponent(f)}" title="open ${esc(f)}">${esc(f.split(".").pop())}</a>`).join(" ");
  246. return `<div class="noti" data-name="${esc(n.name)}" title="show in table">
  247. <span class="nic">${NICON[n.t] || "•"}</span>
  248. <div><b>${esc(n.name)}</b> <span class="dim">${esc(n.label)}</span><br>
  249. <span class="dim">${esc(when)}</span> ${score ? esc(score) : ""} ${links}</div>
  250. </div>`;
  251. }).join("");
  252. }
  253. bellEl.addEventListener("click", e => {
  254. e.stopPropagation();
  255. const open = notifBox.style.display === "block";
  256. notifBox.style.display = open ? "none" : "block";
  257. if (!open) { UNREAD = 0; renderBell(); }
  258. });
  259. document.addEventListener("click", () => { notifBox.style.display = "none"; });
  260. notifBox.addEventListener("click", e => {
  261. const it = e.target.closest(".noti");
  262. if (!it) return;
  263. S.openRow = it.dataset.name;
  264. notifBox.style.display = "none";
  265. render();
  266. });
  267. let es = null;
  268. function connect() {
  269. if (es) es.close();
  270. es = new EventSource("/events");
  271. es.addEventListener("open", () => { liveEl.textContent = "● live"; liveEl.classList.remove("dim"); });
  272. es.addEventListener("error", () => {
  273. liveEl.textContent = "● off"; liveEl.classList.add("dim");
  274. // readyState CLOSED = EventSource gave up for good — recreate it
  275. if (es.readyState === 2) setTimeout(connect, 1000);
  276. });
  277. es.onmessage = ev => {
  278. let rows; try { rows = JSON.parse(ev.data); } catch { return; }
  279. if (JSON.stringify(rows) === JSON.stringify(DATA)) return;
  280. const upd = notifDiff(DATA, rows);
  281. DATA = rows; syncTp(); render();
  282. liveEl.title = "updated " + new Date().toLocaleTimeString();
  283. if (upd.length) {
  284. NOTIFS.unshift(...upd);
  285. if (NOTIFS.length > 100) NOTIFS.length = 100;
  286. UNREAD += upd.length;
  287. renderBell(); renderNotifs();
  288. bellEl.classList.remove("ring"); void bellEl.offsetWidth; bellEl.classList.add("ring");
  289. }
  290. };
  291. }
  292. connect();
  293. renderBell(); renderNotifs();
  294. const P = new URLSearchParams(location.search);
  295. // Persistence: URL params (deep links) win; without them the last view is
  296. // restored from localStorage — bare reloads (server restart, new tab,
  297. // binary update) keep the column/settings state.
  298. const LS = (() => {
  299. try { return JSON.parse(localStorage.getItem("report-view") || "{}"); } catch { return {}; }
  300. })();
  301. const pick = (k, dflt) => P.get(k) ?? (LS[k] ?? dflt);
  302. const size0 = +(pick("size", 50));
  303. // distinct TP values from the data, for the exact-TP filter
  304. const TPVALS = [...new Set(DATA.map(r => r.tp))].sort((a, b) => a - b);
  305. for (const v of TPVALS) {
  306. const o = document.createElement("option");
  307. o.value = String(v);
  308. o.textContent = String(v);
  309. document.getElementById("tp").appendChild(o);
  310. }
  311. const S = {
  312. q: pick("q", ""),
  313. group: pick("group", ""),
  314. sort: pick("sort", "name"),
  315. dir: pick("dir", 1) * 1 || 1,
  316. size: [25, 50, 100, 0].includes(size0) ? size0 : 50,
  317. page: Math.max(0, +(P.get("page") ?? 0)), // page stays URL-only (fresh view = top)
  318. hidden: new Set(String(pick("hidden", "title,image,port,parent")).split(",").filter(Boolean)),
  319. closed: new Set(String(pick("closed", "")).split("|").map(decodeURIComponent).filter(Boolean)),
  320. openRow: pick("open", null),
  321. medal: pick("medal", ""),
  322. uponly: pick("uponly", "") === "1",
  323. tp: TPVALS.includes(+(pick("tp", ""))) ? String(pick("tp", "")) : "",
  324. };
  325. document.getElementById("q").value = S.q;
  326. document.getElementById("group").value = S.group;
  327. document.getElementById("size").value = String(S.size);
  328. document.getElementById("tp").value = S.tp;
  329. document.getElementById("medal").value = S.medal;
  330. document.getElementById("uponly").checked = S.uponly;
  331. function syncUrl() {
  332. const p = new URLSearchParams();
  333. if (S.q) p.set("q", S.q);
  334. if (S.group !== "") p.set("group", S.group);
  335. if (S.sort !== "name") p.set("sort", S.sort);
  336. if (S.dir !== 1) p.set("dir", String(S.dir));
  337. if (S.size !== 50) p.set("size", String(S.size));
  338. if (S.page) p.set("page", String(S.page));
  339. const hidden = [...S.hidden].sort();
  340. if (hidden.join(",") !== ["title", "image", "port", "parent"].sort().join(",")) p.set("hidden", hidden.join(","));
  341. const closed = [...S.closed].map(encodeURIComponent);
  342. if (closed.length) p.set("closed", closed.join("|"));
  343. if (S.openRow) p.set("open", S.openRow);
  344. if (S.medal) p.set("medal", S.medal);
  345. if (S.uponly) p.set("uponly", "1");
  346. if (S.tp) p.set("tp", S.tp);
  347. const qs = p.toString();
  348. history.replaceState(null, "", qs ? "?" + qs : location.pathname);
  349. try {
  350. localStorage.setItem("report-view", JSON.stringify({
  351. q: S.q, group: S.group, sort: S.sort, dir: S.dir, size: S.size,
  352. hidden: [...S.hidden], closed: [...S.closed], openRow: S.openRow,
  353. medal: S.medal, tp: S.tp, uponly: S.uponly ? "1" : "",
  354. }));
  355. } catch { /* private mode etc. — URL still carries the state */ }
  356. }
  357. const esc = s => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
  358. function filtered() {
  359. const q = S.q.toLowerCase();
  360. return DATA.filter(r => (!S.tp || String(r.tp) === S.tp) &&
  361. (!S.uponly || r.up != null) &&
  362. (!S.medal || (S.medal === "m" ? (r.medals && (r.medals.p || r.medals.m || r.medals.t)) : r.medals && String(r.medals.p) === S.medal)) &&
  363. (!q || (r.name + " " + r.title + " " + r.model + " " + r.parent + " " + r.quant + " " +
  364. r.engine + " " + r.image + " " + r.deploy + " " +
  365. r.reports.map(e => e.date + " " + e.label).join(" ")).toLowerCase().includes(q)));
  366. }
  367. function sorted(rows) {
  368. const gk = { mq: r => r.parent + "\0" + r.quant, pm: r => r.parent + "\0" + r.model,
  369. parent: r => r.parent, model: r => r.parent,
  370. quant: r => r.quant, engine: r => r.engine }[S.group];
  371. const k = S.sort, d = S.dir;
  372. const val = r => {
  373. if (k === "points") { const e = latest(r, e => e.ratio != null); return e ? e.ratio : -1; }
  374. if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? -1; }
  375. const v = cell(r, k); return NUM.has(k) || k === "last" ? v : String(v).toLowerCase();
  376. };
  377. return rows.slice().sort((a, b) => {
  378. if (gk) { const ga = gk(a), gb = gk(b); if (ga !== gb) return ga.localeCompare(gb); }
  379. let x = val(a), y = val(b);
  380. if (x === y) return a.name.localeCompare(b.name);
  381. if (typeof x === "number" && typeof y === "number") return (x - y) * d;
  382. return String(x).localeCompare(String(y)) * d;
  383. });
  384. }
  385. function gkey(r, level) {
  386. if (S.group === "pm") return level === 1 ? r.parent : r.parent + "\u0001" + r.model;
  387. if (S.group === "parent") return level === 1 ? r.parent : null;
  388. if (S.group === "mq") return level === 1 ? r.model : r.model + "\u0001" + r.quant;
  389. const f = { model: "model", quant: "quant", engine: "engine" }[S.group];
  390. return level === 1 ? r[f] : null;
  391. }
  392. function render() {
  393. syncUrl();
  394. // visible columns
  395. const vis = COLS.filter(c => !S.hidden.has(c[0]));
  396. document.querySelector("thead").innerHTML = "<tr>" + vis.map(c => {
  397. const arrow = S.sort === c[0] ? (S.dir > 0 ? " ▲" : " ▼") : "";
  398. return `<th data-k="${c[0]}">${esc(c[1])}${arrow}</th>`;
  399. }).join("") + "</tr>";
  400. const all = sorted(filtered());
  401. const size = +document.getElementById("size").value;
  402. const pages = size ? Math.max(1, Math.ceil(all.length / size)) : 1;
  403. if (S.page >= pages) S.page = pages - 1;
  404. const rows = size ? all.slice(S.page * size, S.page * size + size) : all;
  405. document.getElementById("count").textContent = size
  406. ? `${all.length ? S.page * size + 1 : 0}–${Math.min(all.length, S.page * size + size)} / ${all.length}` : `${all.length}`;
  407. // group tree over the current page slice
  408. const out = [];
  409. let g1 = null, g2 = null;
  410. for (const r of rows) {
  411. if (S.group) {
  412. const k1 = gkey(r, 1);
  413. if (k1 !== g1) {
  414. g1 = k1; g2 = null;
  415. const closed = S.closed.has("1\0" + k1);
  416. 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>`);
  417. }
  418. if (S.closed.has("1\0" + k1)) continue;
  419. const k2 = gkey(r, 2);
  420. if (k2 !== null) {
  421. if (k2 !== g2) {
  422. g2 = k2;
  423. const closed = S.closed.has("2\0" + k2);
  424. out.push(`<tr class="g2" data-g="${encodeURIComponent("2\0" + k2)}"><td colspan="${vis.length}">${closed ? "▶" : "▼"} ${esc(S.group === "pm" ? r.model : r.quant)} <span class="dim">(${all.filter(x => gkey(x, 2) === k2).length})</span></td></tr>`);
  425. }
  426. if (S.closed.has("2\0" + k2)) continue;
  427. }
  428. }
  429. out.push("<tr class=\"row\" data-n=\"" + esc(r.name) + "\">" + vis.map(c => {
  430. const v = cell(r, c[0]);
  431. const lr = r.reports.length ? r.reports[r.reports.length - 1] : null;
  432. const tip = c[0] === "last" && lr
  433. ? ` title="${esc(lr.date + (lr.time ? " " + lr.time : ""))}"` : "";
  434. const cls = (NUM.has(c[0]) ? "num" : "") + (c[0] === "name" ? " recipe" : "");
  435. let body;
  436. if (c[0] === "stars") {
  437. const si = starInfo(r);
  438. const pe = si && latest(r, e => e.points); // both scores in the hint
  439. body = !si
  440. ? r.incomplete
  441. ? '<span class="dim" title="bench started, not finished">◐</span>'
  442. : '<span class="dim" title="no bench reports">—</span>'
  443. : `<span title="${si.pct}/100${pe && pe.points ? " (" + pe.points + ")" : ""}">${starGlyphs(si)}</span>`;
  444. } else if (c[0] === "state") {
  445. body = r.up != null
  446. ? (r.stale
  447. ? `<span title="ledger says running but no live container found (node down? killed serve?) — verify and close: sm121 runlock close ${esc(r.name)}"><span style="color:#d2822a">●</span><span class="dim"> stale</span></span>`
  448. : `<span title="up since ${fmtSince(r.up)} UTC"><span style="color:green">●</span><span class="dim"> up</span></span>`)
  449. : '<span class="dim" title="not running">○</span>';
  450. } else if (c[0] === "uptime") {
  451. body = r.up != null ? fmtUp(v) : '<span class="dim">—</span>';
  452. } else if (c[0] === "runs") {
  453. body = v > 0 ? esc(v) : '<span class="dim">0</span>';
  454. } else if (v === "" || v == null) {
  455. body = '<span class="dim">—</span>';
  456. } else if (c[0] === "name") {
  457. body = medalBadge(r) + esc(v) +
  458. `<button class="copy" data-copy="${esc(r.name)}" title="copy recipe name">⧉</button>`;
  459. } else {
  460. body = esc(v);
  461. }
  462. return `<td${cls ? ` class="${cls}"` : ""}${tip}>${body}</td>`;
  463. }).join("") + "</tr>");
  464. if (S.openRow === r.name) {
  465. // one toml section per recipe (the recipe.toml is not versioned);
  466. // per-run report files stay on each line
  467. const toml = `<a class="open" href="/r/${encodeURIComponent(r.name)}/recipe.toml" target="_blank" title="recipe.toml (highlighted)">⚙ toml</a>`;
  468. const lines = r.reports.map(e => {
  469. const fl = e.files && e.files.length ? e.files : [e.file];
  470. const ts = (e.perf && e.perf.length)
  471. ? ` <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>`
  472. : "";
  473. // run package provenance chip: image build-checksum · recipe sha
  474. // (⚠ = the recipe has since changed — this run's copy is pinned)
  475. const pr = e.provenance;
  476. const chip = pr && (pr.image_sum || pr.recipe_sha)
  477. ? `<span class="prov" title="recipe ${esc(pr.recipe_sha)}${pr.recipe_drift ? " (CHANGED SINCE THIS RUN — see the run's recipe.toml copy)" : ""} · image ${esc(pr.image || "—")} @ ${esc(pr.image_sum || "—")} (docker id ${esc(pr.image_id || "—")}) · tool ${esc(pr.tool || "—")} · launcher ${esc(pr.launcher || "—")}">⚙ ${esc((pr.image_sum || "–").slice(0, 12))}·${esc(pr.recipe_sha || "–")}${pr.recipe_drift ? "⚠" : ""}</span> `
  478. : "";
  479. return `<span>${fl.map(f =>
  480. `<a class="open" href="/r/${encodeURIComponent(r.name)}/${encodeURIComponent(f)}" target="_blank" title="open ${esc(f)}">${esc(f.split('.').pop())}</a>`).join(" ")}</span> ` +
  481. `${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}${chip}`;
  482. }).join("<br>");
  483. out.push(`<tr class="hist"><td colspan="${vis.length}">${r.reports.length ? toml + " " + lines : "no reports"}</td></tr>`);
  484. }
  485. }
  486. document.querySelector("tbody").innerHTML = out.join("");
  487. }
  488. document.querySelector("thead").addEventListener("click", e => {
  489. const th = e.target.closest("th"); if (!th) return;
  490. const k = th.dataset.k;
  491. if (S.sort === k) S.dir = -S.dir; else { S.sort = k; S.dir = 1; }
  492. render();
  493. });
  494. // copy a recipe name to the clipboard (clipboard API, textarea fallback)
  495. function fallbackCopy(text) {
  496. const ta = document.createElement("textarea");
  497. ta.value = text; document.body.appendChild(ta); ta.select();
  498. let ok = false; try { ok = document.execCommand("copy"); } catch {}
  499. ta.remove(); return ok;
  500. }
  501. function copyName(btn) {
  502. const done = ok => {
  503. btn.textContent = ok ? "✓" : "✗";
  504. btn.classList.toggle("copied", ok);
  505. setTimeout(() => { btn.textContent = "⧉"; btn.classList.remove("copied"); }, 900);
  506. };
  507. if (navigator.clipboard) navigator.clipboard.writeText(btn.dataset.copy).then(() => done(true), () => done(fallbackCopy(btn.dataset.copy)));
  508. else done(fallbackCopy(btn.dataset.copy));
  509. }
  510. document.querySelector("tbody").addEventListener("click", e => {
  511. const cp = e.target.closest("button.copy");
  512. if (cp) { copyName(cp); return; } // no row toggle under the copy button
  513. const g = e.target.closest("tr.g1,tr.g2");
  514. if (g) { const k = decodeURIComponent(g.dataset.g); S.closed.has(k) ? S.closed.delete(k) : S.closed.add(k); render(); return; }
  515. const row = e.target.closest("tr.row");
  516. if (row) { S.openRow = S.openRow === row.dataset.n ? null : row.dataset.n; render(); }
  517. });
  518. document.getElementById("q").addEventListener("input", e => { S.q = e.target.value; S.page = 0; render(); });
  519. document.getElementById("group").addEventListener("change", e => { S.group = e.target.value; S.closed.clear(); S.page = 0; render(); });
  520. document.getElementById("tp").addEventListener("change", e => { S.tp = e.target.value; S.page = 0; render(); });
  521. document.getElementById("medal").addEventListener("change", e => { S.medal = e.target.value; S.page = 0; render(); });
  522. document.getElementById("uponly").addEventListener("change", e => { S.uponly = e.target.checked; S.page = 0; render(); });
  523. // live uptime: re-render while anything is running (the cell values are
  524. // computed at render time)
  525. setInterval(() => { if (DATA.some(r => r.up != null)) render(); }, 30000);
  526. document.getElementById("size").addEventListener("change", () => { S.page = 0; render(); });
  527. document.getElementById("prev").addEventListener("click", () => { if (S.page > 0) { S.page--; render(); } });
  528. document.getElementById("next").addEventListener("click", () => { S.page++; render(); });
  529. // column checkboxes
  530. document.getElementById("colbox").innerHTML = COLS.map(c =>
  531. `<label><input type="checkbox" data-k="${c[0]}" ${S.hidden.has(c[0]) ? "" : "checked"}> ${esc(c[1])}</label>`).join("");
  532. document.getElementById("colbox").addEventListener("change", e => {
  533. const k = e.target.dataset.k; if (!k) return;
  534. e.target.checked ? S.hidden.delete(k) : S.hidden.add(k);
  535. render();
  536. });
  537. render();
  538. </script>
  539. </body>
  540. </html>