page.html 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786
  1. <!doctype html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. <title>sm121 — tested recipes</title>
  6. <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%235ee0a0'/%3E%3Cpath d='M16 5.5l2.7 7.8 7.8 2.7-7.8 2.7L16 26.5l-2.7-7.8L5.5 16l7.8-2.7z' fill='%23141a21'/%3E%3C/svg%3E">
  7. <style>
  8. :root { color-scheme: light dark; }
  9. body { font: 13px/1.45 system-ui, sans-serif; margin: 16px; }
  10. h1 { font-size: 18px; margin: 0 0 10px; }
  11. #bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
  12. #q { flex: 1 1 220px; max-width: 380px; padding: 4px 8px; }
  13. select, button, #cols summary { padding: 3px 8px; cursor: pointer; }
  14. #cols { position: relative; display: inline-block; }
  15. #colbox { position: absolute; z-index: 5; background: canvas; border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
  16. padding: 6px 10px; max-height: 60vh; overflow: auto; white-space: nowrap; }
  17. #colbox label { display: block; }
  18. table { border-collapse: collapse; width: 100%; }
  19. th, td { border: 1px solid color-mix(in srgb, currentColor 20%, transparent); padding: 3px 8px; text-align: left; vertical-align: top; }
  20. th { cursor: pointer; user-select: none; white-space: nowrap; position: sticky; top: 0; background: canvas; }
  21. tr.g1 td { font-weight: 700; background: color-mix(in srgb, currentColor 8%, canvas); cursor: pointer; }
  22. tr.g2 td { font-style: italic; background: color-mix(in srgb, currentColor 4%, canvas); cursor: pointer; }
  23. tr.hist td { font-family: ui-monospace, monospace; font-size: 12px; background: color-mix(in srgb, currentColor 3%, canvas); }
  24. tr.hist .actbar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 2px 0 6px; }
  25. tr.hist .actbar button.act { padding: 2px 8px; font-size: 12px; }
  26. tr.hist .actnote { font-size: 11px; color: gray; }
  27. a.open { color: inherit; text-decoration: none; border: 1px solid color-mix(in srgb, currentColor 30%, transparent); border-radius: 3px; padding: 0 4px; }
  28. .prov { font-family: ui-monospace, monospace; font-size: 10px; opacity: .8; white-space: nowrap; }
  29. a.open:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
  30. tr.row { cursor: pointer; }
  31. /* hover-revealed cell buttons sit at the right edge, so both cells
  32. reserve room for them instead of letting the text slide under */
  33. td.recipe, td.model { position: relative; }
  34. td.recipe { padding-right: 26px; }
  35. td.model { padding-right: 64px; }
  36. td.recipe .copy, td.model .copy, td.model .hf { position: absolute; top: 50%; transform: translateY(-50%);
  37. visibility: hidden; cursor: pointer; padding: 0 4px; border-radius: 3px; border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
  38. background: canvas; color: inherit; font: inherit; line-height: 1.3; text-decoration: none; }
  39. td.recipe .copy, td.model .copy { right: 3px; }
  40. td.model .hf { right: 32px; font-size: 10px; font-weight: 600; letter-spacing: .3px; }
  41. td.recipe:hover .copy, td.model:hover .copy, td.model:hover .hf { visibility: visible; }
  42. td.recipe .copy:hover, td.model .copy:hover, td.model .hf:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
  43. td.recipe .copied, td.model .copied { color: green; font-weight: 600; }
  44. td.recipe .medals { letter-spacing: 1px; }
  45. .half { -webkit-background-clip: text; background-clip: text; color: transparent; }
  46. td.num { text-align: right; white-space: nowrap; }
  47. #count { color: gray; }
  48. #live { color: #2a9d2a; font-size: 11px; white-space: nowrap; }
  49. #live.dim { color: gray; }
  50. #bell { position: relative; padding: 3px 8px; cursor: pointer; background: none; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 4px; }
  51. #bell svg { vertical-align: -3px; }
  52. #bellcount { position: absolute; top: -7px; right: -7px; min-width: 17px; height: 17px; line-height: 17px; text-align: center;
  53. font-size: 10px; font-weight: 700; color: #fff; background: #c0392b; border-radius: 9px; padding: 0 3px; }
  54. #notifbox { position: absolute; z-index: 6; right: 0; top: 100%; width: min(460px, 90vw); max-height: 60vh; overflow: auto;
  55. background: canvas; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 5px; padding: 6px; }
  56. #bar { position: relative; }
  57. .noti { display: flex; gap: 8px; padding: 5px 6px; border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent); cursor: pointer; }
  58. .noti:hover { background: color-mix(in srgb, currentColor 6%, canvas); }
  59. .noti:last-child { border-bottom: none; }
  60. .nic { font-weight: 700; }
  61. @keyframes ring { 0% { transform: rotate(0) } 20% { transform: rotate(13deg) } 45% { transform: rotate(-11deg) } 70% { transform: rotate(6deg) } 100% { transform: rotate(0) } }
  62. #bell.ring svg { animation: ring 0.6s ease; transform-origin: 50% 0; }
  63. .dim { color: gray; }
  64. /* Model-settings dialog (⚙ settings): per-agent client config for the
  65. recipe's served model. */
  66. #msdlg { position: fixed; inset: 0; z-index: 40; display: none; align-items: center; justify-content: center;
  67. background: rgba(0, 0, 0, .45); }
  68. #msdlg.on { display: flex; }
  69. #msdlg .panel { background: canvas; color: canvastext; border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
  70. border-radius: 8px; padding: 14px 16px; width: min(760px, 94vw); max-height: 88vh; overflow: auto;
  71. box-shadow: 0 12px 40px rgba(0, 0, 0, .4); }
  72. #msdlg h2 { font-size: 15px; margin: 0 0 4px; }
  73. #msdlg .ep { font-family: ui-monospace, monospace; font-size: 11px; color: gray; margin-bottom: 10px; word-break: break-all; }
  74. #msdlg .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; }
  75. #msdlg label.inc { font-size: 12px; display: inline-flex; gap: 5px; align-items: center; cursor: pointer; }
  76. #msdlg .tgt { font-family: ui-monospace, monospace; font-size: 11px; color: gray; margin: 0 0 4px; }
  77. #msdlg pre { margin: 0 0 10px; padding: 10px 12px; border-radius: 6px; font-size: 12px; line-height: 1.4; overflow: auto; max-height: 46vh;
  78. background: color-mix(in srgb, currentColor 6%, canvas); border: 1px solid color-mix(in srgb, currentColor 18%, transparent); }
  79. #msdlg .foot { display: flex; gap: 8px; justify-content: flex-end; }
  80. #msdlg .err { color: #c0392b; font-size: 12px; }
  81. #msdlg .meta { font-size: 11px; color: gray; margin-bottom: 8px; }
  82. /* Embedded in the dashboard's Recipes window: the window bar carries the
  83. title and the dashboard header carries the connection indicator. Both
  84. SSE streams come from the same server (only the paths differ), so the
  85. panel's own chip can never disagree with it — it is pure duplication. */
  86. body.embed h1 { display: none; }
  87. body.embed #bar { margin-top: 8px; }
  88. body.embed #live { display: none; }
  89. </style>
  90. </head>
  91. <body>
  92. <h1>sm121 — tested recipes &amp; benchmark history</h1>
  93. <div id="bar">
  94. <input id="q" type="search" placeholder="Search recipes, models, images, labels…">
  95. <label>Group:
  96. <select id="group">
  97. <option value="pm">Parent → Model</option>
  98. <option value="parent">Parent</option>
  99. <option value="mq">Model → Quant</option>
  100. <option value="model">Model</option>
  101. <option value="quant">Quant</option>
  102. <option value="engine">Engine</option>
  103. <option value="" selected>None (flat)</option>
  104. </select>
  105. </label>
  106. <label>TP:
  107. <select id="tp"><option value="">All</option></select>
  108. </label>
  109. <label>Medals:
  110. <select id="medal">
  111. <option value="" selected>All</option>
  112. <option value="m">🥇🥈🥉</option>
  113. <option value="1">🥇 gold</option>
  114. <option value="2">🥈 silver</option>
  115. <option value="3">🥉 bronze</option>
  116. </select>
  117. </label>
  118. <label><input type="checkbox" id="uponly"> ● running</label>
  119. <details id="cols"><summary>Columns ▾</summary><div id="colbox"></div></details>
  120. <label>Rows/page:
  121. <select id="size"><option>25</option><option selected>50</option><option>100</option><option value="0">All</option></select>
  122. </label>
  123. <button id="prev">‹</button><span id="count"></span><button id="next">›</button>
  124. <button id="syncbtn" title="sync the recipe repos now (dashboard job; new recipes appear via the live stream)" style="display:none">⟳ sync</button>
  125. <span id="repostat" class="dim" style="display:none"></span>
  126. <span id="live" class="dim" title="live updates">● off</span>
  127. <button id="bell" title="updates" aria-label="updates">
  128. <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>
  129. <span id="bellcount" style="display:none"></span>
  130. </button>
  131. <div id="notifbox" style="display:none"></div>
  132. </div>
  133. <table id="tbl"><thead></thead><tbody></tbody></table>
  134. <div id="msdlg" role="dialog" aria-modal="true" aria-labelledby="msdtitle">
  135. <div class="panel">
  136. <h2 id="msdtitle">Model settings</h2>
  137. <div class="ep" id="msdep"></div>
  138. <div class="row">
  139. <select id="msagent" title="pick the agent you want settings for"></select>
  140. <label class="inc" title="include the endpoint/provider section (base URL, api key, wire api) as well as the model itself">
  141. <input type="checkbox" id="msprov"> include provider
  142. </label>
  143. </div>
  144. <div class="meta" id="msmeta"></div>
  145. <div class="tgt" id="mstgt"></div>
  146. <pre id="mscode"></pre>
  147. <div class="err" id="mserr"></div>
  148. <div class="foot">
  149. <button id="mscopy">copy</button>
  150. <button id="msclose">close</button>
  151. </div>
  152. </div>
  153. </div>
  154. <script>
  155. "use strict";
  156. // Auth (dashboard embedding): the same-origin dashboard page stores the
  157. // `sm121 web --token` value in localStorage — the SSE stream needs it as a
  158. // query param (EventSource cannot set headers). `sm121 report --serve`
  159. // (standalone) never sets a token, so auth() is a no-op there.
  160. // Auth: when embedded in the sm121 web dashboard's recipes panel, the
  161. // parent has already exchanged the dashboard token for a HttpOnly session
  162. // cookie — every same-origin request (SSE, the /r/ links) carries it. The
  163. // standalone `report --serve` is ungated. The legacy ?token= form is gone
  164. // (URLs must never carry the token).
  165. const auth = (p) => p;
  166. let DATA = {{DATA}};
  167. const COLS = [
  168. ["name", "Recipe"], ["state", "State"], ["uptime", "Uptime"], ["runs", "Launches"],
  169. ["title", "Title"], ["model", "Model"], ["quant", "Quant"],
  170. ["engine", "Engine"], ["image", "Image"], ["nodes", "Nodes"], ["tp", "TP"],
  171. ["deploy", "Deploy"], ["port", "Port"], ["n", "Reports"], ["last", "Last bench"],
  172. ["points", "Points"], ["pct", "Score /100"], ["stars", "Bench"],
  173. ["tps1", "Tok/s c1"], ["tps2", "Tok/s c2"], ["parent", "Parent"],
  174. ];
  175. const NUM = new Set(["nodes", "tp", "port", "n", "pct", "tps1", "tps2", "stars", "state", "uptime", "runs"]);
  176. const MEDAL = { 1: "🥇", 2: "🥈", 3: "🥉" };
  177. // three-tier medal badge: family | model | tp (slots shown only when earned)
  178. function medalBadge(r) {
  179. const M = r.medals || {};
  180. const g = v => MEDAL[v] || "";
  181. const parts = [g(M.p), g(M.m), g(M.t)].filter(Boolean);
  182. if (!parts.length) return "";
  183. return `<span class="medals" title="family ${g(M.p) || "–"} · model ${g(M.m) || "–"} · tp${r.tp} ${g(M.t) || "–"}">${parts.join("|")}</span> `;
  184. }
  185. // run-ledger helpers: who is up right now, for how long (live — a 30 s tick
  186. // re-renders while anything is running)
  187. const nowSecs = () => Math.floor(Date.now() / 1000);
  188. const fmtUp = s => {
  189. s = Math.max(0, s | 0);
  190. const d = (s / 86400) | 0, h = ((s % 86400) / 3600) | 0, m = ((s % 3600) / 60) | 0;
  191. return d ? d + "d " + h + "h" : h ? h + "h " + m + "m" : m ? m + "m" : s + "s";
  192. };
  193. const fmtSince = u => new Date(u * 1000).toISOString().slice(0, 16).replace("T", " ");
  194. // star grade of the latest completed bench. The 10-color palette covers
  195. // 0..100 a decade per color (color = which decade the score is in); the
  196. // star count shows the position WITHIN that decade (1 star = 2 points of
  197. // the band, half star = 1) — 95 → green, 2.5 stars. Sort key is the
  198. // ABSOLUTE score.
  199. const PALETTE = [
  200. "red", "firebrick", "brown", "sienna", "chocolate",
  201. "darkorange", "gold", "yellowgreen", "greenyellow", "green",
  202. ];
  203. function starInfo(r) {
  204. const e = latest(r, x => x.done && x.pct != null);
  205. if (!e) return null;
  206. const band = Math.min(9, Math.floor(e.pct / 10));
  207. return { pct: e.pct, band, s: Math.round(e.pct - band * 10) / 2 };
  208. }
  209. function starGlyphs(si) {
  210. const col = PALETTE[si.band];
  211. let out = "";
  212. for (let i = 1; i <= 5; i++) {
  213. if (si.s >= i) out += `<span style="color:${col}">★</span>`;
  214. else if (si.s >= i - 0.5)
  215. out += `<span class="half" style="background-image:linear-gradient(90deg,${col} 50%,color-mix(in srgb,${col} 30%,transparent) 50%)">★</span>`;
  216. else out += `<span style="color:${col};opacity:.3">☆</span>`;
  217. }
  218. return out;
  219. }
  220. // decode speed (tg t/s) from a run's llama-benchy perf cells
  221. 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; }
  222. // latest report matching a predicate, for the points / score columns
  223. 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; }
  224. function cell(r, k) {
  225. if (k === "n") return r.reports.length;
  226. if (k === "last") return r.reports.length ? r.reports[r.reports.length - 1].date : "";
  227. if (k === "points") { const e = latest(r, e => e.points); return e ? e.points : ""; }
  228. if (k === "pct") { const e = latest(r, e => e.pct != null); return e ? e.pct : ""; }
  229. if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? ""; }
  230. if (k === "stars") { const si = starInfo(r); return si ? si.pct : -1; } // sort by the absolute score
  231. if (k === "state" || k === "uptime") return r.up != null ? nowSecs() - r.up : r.fleet_up ? 0 : -1; // live uptime secs (fleet-up: unknown start)
  232. if (k === "runs") return r.runs ?? 0;
  233. return r[k];
  234. }
  235. // deep-link state: the full view (filter, grouping, sort, columns, paging,
  236. // collapsed groups) lives in the URL — survives any reload and is shareable
  237. // Update notifications: pure diff between two rows snapshots → the list of
  238. // what changed (new bench, new/removed recipe, changed entry or row config).
  239. // Newest first. Probed by the node test, extracted between the markers.
  240. /*NOTIFDIFF:START*/
  241. function notifDiff(prev, next) {
  242. const out = [];
  243. const byName = new Map(prev.map(r => [r.name, r]));
  244. const meta = r => JSON.stringify({ ...r, reports: [] });
  245. const item = (t, r, e) => ({
  246. t, name: r.name,
  247. label: e ? e.label : (t === "recipe" ? (r.model || r.title) : "recipe config"),
  248. date: e ? e.date : "", time: e ? e.time : null,
  249. score: e ? e.pct : null, points: e ? e.points : null,
  250. files: e ? (e.files || [e.file]) : [],
  251. });
  252. for (const r of next) {
  253. const old = byName.get(r.name);
  254. if (!old) { out.push(item("recipe", r, null)); continue; }
  255. if (meta(old) !== meta(r)) out.push(item("config", r, null));
  256. const oldR = new Map(old.reports.map(e => [e.date + "\u0000" + e.label, e]));
  257. for (const e of r.reports) {
  258. const oe = oldR.get(e.date + "\u0000" + e.label);
  259. if (!oe) out.push(item("bench", r, e));
  260. else if (JSON.stringify(oe) !== JSON.stringify(e)) out.push(item("change", r, e));
  261. }
  262. }
  263. const nextNames = new Set(next.map(r => r.name));
  264. for (const r of prev)
  265. if (!nextNames.has(r.name)) out.push(item("removed", r, null));
  266. return out.reverse();
  267. }
  268. /*NOTIFDIFF:END*/
  269. // LIVE: the server re-scans the reports/ dirs on file-system events and
  270. // pushes the rows JSON over SSE whenever something changed (new bench,
  271. // finishing run, new report file). The view state (filter, sort, paging,
  272. // columns, collapsed groups) is untouched — render() re-derives everything
  273. // from DATA.
  274. const liveEl = document.getElementById("live");
  275. function syncTp() {
  276. const vals = [...new Set(DATA.map(r => r.tp))].sort((a, b) => a - b);
  277. const sel = document.getElementById("tp");
  278. if (vals.map(String).join(",") !== [...sel.options].map(o => o.value).join(","))
  279. sel.innerHTML = '<option value="">All</option>' + vals.map(v => `<option>${v}</option>`).join("");
  280. if (S.tp && ![...sel.options].some(o => o.value === S.tp)) S.tp = "";
  281. sel.value = S.tp;
  282. }
  283. // Bell + notifications: UNREAD counts updates since the panel was last
  284. // opened; NOTIFS (capped) is the list the panel shows. Clicking an item
  285. // expands the matching row in the table.
  286. const bellEl = document.getElementById("bell");
  287. const notifBox = document.getElementById("notifbox");
  288. let UNREAD = 0;
  289. let NOTIFS = [];
  290. const NICON = { bench: "+", change: "↻", recipe: "★", config: "✎", removed: "−" };
  291. function renderBell() {
  292. const c = document.getElementById("bellcount");
  293. c.textContent = String(UNREAD);
  294. c.style.display = UNREAD ? "" : "none";
  295. }
  296. function renderNotifs() {
  297. if (!NOTIFS.length) {
  298. notifBox.innerHTML = '<div class="dim" style="padding:8px">no updates yet</div>';
  299. return;
  300. }
  301. notifBox.innerHTML = NOTIFS.map(n => {
  302. const when = n.date ? `${n.date}${n.time ? "·" + n.time : ""}` : "";
  303. const score = n.score != null ? `${n.score} /100` : n.points || "";
  304. const links = n.files.map(f =>
  305. `<a class="open" target="_blank" href="${auth("/r/" + encodeURIComponent(n.name) + "/" + encodeURIComponent(f))}" title="open ${esc(f)}">${esc(f.split(".").pop())}</a>`).join(" ");
  306. return `<div class="noti" data-name="${esc(n.name)}" title="show in table">
  307. <span class="nic">${NICON[n.t] || "•"}</span>
  308. <div><b>${esc(n.name)}</b> <span class="dim">${esc(n.label)}</span><br>
  309. <span class="dim">${esc(when)}</span> ${score ? esc(score) : ""} ${links}</div>
  310. </div>`;
  311. }).join("");
  312. }
  313. bellEl.addEventListener("click", e => {
  314. e.stopPropagation();
  315. const open = notifBox.style.display === "block";
  316. notifBox.style.display = open ? "none" : "block";
  317. if (!open) { UNREAD = 0; renderBell(); }
  318. });
  319. document.addEventListener("click", () => { notifBox.style.display = "none"; });
  320. notifBox.addEventListener("click", e => {
  321. const it = e.target.closest(".noti");
  322. if (!it) return;
  323. S.openRow = it.dataset.name;
  324. notifBox.style.display = "none";
  325. render();
  326. });
  327. let es = null;
  328. function connect() {
  329. if (es) es.close();
  330. es = new EventSource(auth("/events"));
  331. es.addEventListener("open", () => { liveEl.textContent = "● live"; liveEl.classList.remove("dim"); });
  332. es.addEventListener("error", () => {
  333. liveEl.textContent = "● off"; liveEl.classList.add("dim");
  334. // readyState CLOSED = EventSource gave up for good — recreate it
  335. if (es.readyState === 2) setTimeout(connect, 1000);
  336. });
  337. es.onmessage = ev => {
  338. let rows; try { rows = JSON.parse(ev.data); } catch { return; }
  339. if (JSON.stringify(rows) === JSON.stringify(DATA)) return;
  340. const upd = notifDiff(DATA, rows);
  341. DATA = rows; syncTp(); render();
  342. liveEl.title = "updated " + new Date().toLocaleTimeString();
  343. if (upd.length) {
  344. NOTIFS.unshift(...upd);
  345. if (NOTIFS.length > 100) NOTIFS.length = 100;
  346. UNREAD += upd.length;
  347. renderBell(); renderNotifs();
  348. bellEl.classList.remove("ring"); void bellEl.offsetWidth; bellEl.classList.add("ring");
  349. }
  350. };
  351. }
  352. connect();
  353. renderBell(); renderNotifs();
  354. const P = new URLSearchParams(location.search);
  355. // Persistence: URL params (deep links) win; without them the last view is
  356. // restored from localStorage — bare reloads (server restart, new tab,
  357. // binary update) keep the column/settings state.
  358. const LS = (() => {
  359. try { return JSON.parse(localStorage.getItem("report-view") || "{}"); } catch { return {}; }
  360. })();
  361. const pick = (k, dflt) => P.get(k) ?? (LS[k] ?? dflt);
  362. const size0 = +(pick("size", 50));
  363. // distinct TP values from the data, for the exact-TP filter
  364. const TPVALS = [...new Set(DATA.map(r => r.tp))].sort((a, b) => a - b);
  365. for (const v of TPVALS) {
  366. const o = document.createElement("option");
  367. o.value = String(v);
  368. o.textContent = String(v);
  369. document.getElementById("tp").appendChild(o);
  370. }
  371. const S = {
  372. q: pick("q", ""),
  373. group: pick("group", ""),
  374. sort: pick("sort", "name"),
  375. dir: pick("dir", 1) * 1 || 1,
  376. size: [25, 50, 100, 0].includes(size0) ? size0 : 50,
  377. page: Math.max(0, +(P.get("page") ?? 0)), // page stays URL-only (fresh view = top)
  378. hidden: new Set(String(pick("hidden", "title,image,port,parent")).split(",").filter(Boolean)),
  379. closed: new Set(String(pick("closed", "")).split("|").map(decodeURIComponent).filter(Boolean)),
  380. openRow: pick("open", null),
  381. medal: pick("medal", ""),
  382. uponly: pick("uponly", "") === "1",
  383. tp: TPVALS.includes(+(pick("tp", ""))) ? String(pick("tp", "")) : "",
  384. };
  385. document.getElementById("q").value = S.q;
  386. document.getElementById("group").value = S.group;
  387. document.getElementById("size").value = String(S.size);
  388. document.getElementById("tp").value = S.tp;
  389. document.getElementById("medal").value = S.medal;
  390. document.getElementById("uponly").checked = S.uponly;
  391. function syncUrl() {
  392. const p = new URLSearchParams();
  393. if (S.q) p.set("q", S.q);
  394. if (S.group !== "") p.set("group", S.group);
  395. if (S.sort !== "name") p.set("sort", S.sort);
  396. if (S.dir !== 1) p.set("dir", String(S.dir));
  397. if (S.size !== 50) p.set("size", String(S.size));
  398. if (S.page) p.set("page", String(S.page));
  399. const hidden = [...S.hidden].sort();
  400. if (hidden.join(",") !== ["title", "image", "port", "parent"].sort().join(",")) p.set("hidden", hidden.join(","));
  401. const closed = [...S.closed].map(encodeURIComponent);
  402. if (closed.length) p.set("closed", closed.join("|"));
  403. if (S.openRow) p.set("open", S.openRow);
  404. if (S.medal) p.set("medal", S.medal);
  405. if (S.uponly) p.set("uponly", "1");
  406. if (S.tp) p.set("tp", S.tp);
  407. const qs = p.toString();
  408. history.replaceState(null, "", qs ? "?" + qs : location.pathname);
  409. try {
  410. localStorage.setItem("report-view", JSON.stringify({
  411. q: S.q, group: S.group, sort: S.sort, dir: S.dir, size: S.size,
  412. hidden: [...S.hidden], closed: [...S.closed], openRow: S.openRow,
  413. medal: S.medal, tp: S.tp, uponly: S.uponly ? "1" : "",
  414. }));
  415. } catch { /* private mode etc. — URL still carries the state */ }
  416. }
  417. const esc = s => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
  418. function filtered() {
  419. const q = S.q.toLowerCase();
  420. return DATA.filter(r => (!S.tp || String(r.tp) === S.tp) &&
  421. (!S.uponly || r.up != null || r.fleet_up) &&
  422. (!S.medal || (S.medal === "m" ? (r.medals && (r.medals.p || r.medals.m || r.medals.t)) : r.medals && String(r.medals.p) === S.medal)) &&
  423. (!q || (r.name + " " + r.title + " " + r.model + " " + r.parent + " " + r.quant + " " +
  424. r.engine + " " + r.image + " " + r.deploy + " " +
  425. r.reports.map(e => e.date + " " + e.label).join(" ")).toLowerCase().includes(q)));
  426. }
  427. function sorted(rows) {
  428. const gk = { mq: r => r.parent + "\0" + r.quant, pm: r => r.parent + "\0" + r.model,
  429. parent: r => r.parent, model: r => r.parent,
  430. quant: r => r.quant, engine: r => r.engine }[S.group];
  431. const k = S.sort, d = S.dir;
  432. const val = r => {
  433. if (k === "points") { const e = latest(r, e => e.ratio != null); return e ? e.ratio : -1; }
  434. if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? -1; }
  435. const v = cell(r, k); return NUM.has(k) || k === "last" ? v : String(v).toLowerCase();
  436. };
  437. return rows.slice().sort((a, b) => {
  438. if (gk) { const ga = gk(a), gb = gk(b); if (ga !== gb) return ga.localeCompare(gb); }
  439. let x = val(a), y = val(b);
  440. if (x === y) return a.name.localeCompare(b.name);
  441. if (typeof x === "number" && typeof y === "number") return (x - y) * d;
  442. return String(x).localeCompare(String(y)) * d;
  443. });
  444. }
  445. function gkey(r, level) {
  446. if (S.group === "pm") return level === 1 ? r.parent : r.parent + "\u0001" + r.model;
  447. if (S.group === "parent") return level === 1 ? r.parent : null;
  448. if (S.group === "mq") return level === 1 ? r.model : r.model + "\u0001" + r.quant;
  449. const f = { model: "model", quant: "quant", engine: "engine" }[S.group];
  450. return level === 1 ? r[f] : null;
  451. }
  452. function render() {
  453. syncUrl();
  454. // visible columns
  455. const vis = COLS.filter(c => !S.hidden.has(c[0]));
  456. document.querySelector("thead").innerHTML = "<tr>" + vis.map(c => {
  457. const arrow = S.sort === c[0] ? (S.dir > 0 ? " ▲" : " ▼") : "";
  458. return `<th data-k="${c[0]}">${esc(c[1])}${arrow}</th>`;
  459. }).join("") + "</tr>";
  460. const all = sorted(filtered());
  461. const size = +document.getElementById("size").value;
  462. const pages = size ? Math.max(1, Math.ceil(all.length / size)) : 1;
  463. if (S.page >= pages) S.page = pages - 1;
  464. const rows = size ? all.slice(S.page * size, S.page * size + size) : all;
  465. document.getElementById("count").textContent = size
  466. ? `${all.length ? S.page * size + 1 : 0}–${Math.min(all.length, S.page * size + size)} / ${all.length}` : `${all.length}`;
  467. // group tree over the current page slice
  468. const out = [];
  469. let g1 = null, g2 = null;
  470. for (const r of rows) {
  471. if (S.group) {
  472. const k1 = gkey(r, 1);
  473. if (k1 !== g1) {
  474. g1 = k1; g2 = null;
  475. const closed = S.closed.has("1\0" + k1);
  476. 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>`);
  477. }
  478. if (S.closed.has("1\0" + k1)) continue;
  479. const k2 = gkey(r, 2);
  480. if (k2 !== null) {
  481. if (k2 !== g2) {
  482. g2 = k2;
  483. const closed = S.closed.has("2\0" + k2);
  484. 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>`);
  485. }
  486. if (S.closed.has("2\0" + k2)) continue;
  487. }
  488. }
  489. out.push("<tr class=\"row\" data-n=\"" + esc(r.name) + "\">" + vis.map(c => {
  490. const v = cell(r, c[0]);
  491. const lr = r.reports.length ? r.reports[r.reports.length - 1] : null;
  492. const tip = c[0] === "last" && lr
  493. ? ` title="${esc(lr.date + (lr.time ? " " + lr.time : ""))}"` : "";
  494. const cls = (NUM.has(c[0]) ? "num" : "") + (c[0] === "name" ? " recipe" : "") +
  495. (c[0] === "model" ? " model" : "");
  496. let body;
  497. if (c[0] === "stars") {
  498. const si = starInfo(r);
  499. const pe = si && latest(r, e => e.points); // both scores in the hint
  500. body = !si
  501. ? r.incomplete
  502. ? '<span class="dim" title="bench started, not finished">◐</span>'
  503. : '<span class="dim" title="no bench reports">—</span>'
  504. : `<span title="${si.pct}/100${pe && pe.points ? " (" + pe.points + ")" : ""}">${starGlyphs(si)}</span>`;
  505. } else if (c[0] === "state") {
  506. body = r.up != null
  507. ? (r.stale
  508. ? `<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>`
  509. : `<span title="up since ${fmtSince(r.up)} UTC"><span style="color:green">●</span><span class="dim"> up</span></span>`)
  510. : (r.fleet_up
  511. ? `<span title="running in the fleet — a container was found on a cluster node, but this machine has no local run ledger (served by another launcher)"><span style="color:green">●</span><span class="dim"> up</span><span class="dim" style="font-size:.8em">(fleet)</span></span>`
  512. : '<span class="dim" title="not running">○</span>');
  513. } else if (c[0] === "uptime") {
  514. body = r.up != null ? fmtUp(v) : '<span class="dim">—</span>';
  515. } else if (c[0] === "runs") {
  516. body = v > 0 ? esc(v)
  517. : '<span class="dim" title="no launch ledger on this machine (or never launched)">—</span>';
  518. } else if (v === "" || v == null) {
  519. body = '<span class="dim">—</span>';
  520. } else if (c[0] === "name") {
  521. body = medalBadge(r) + esc(v) +
  522. `<button class="copy" data-copy="${esc(r.name)}" title="copy recipe name">⧉</button>`;
  523. } else if (c[0] === "model") {
  524. // model id: copy it, and jump to the checkpoint's page on the Hub
  525. // (a bare org/name is a Hub repo id; local paths are not linked)
  526. const m = String(v);
  527. const repo = /^[\w.-]+\/[\w.-]+$/.test(m);
  528. body = esc(m) +
  529. `<button class="copy" data-copy="${esc(m)}" title="copy model id">⧉</button>` +
  530. (repo
  531. ? `<a class="hf" href="https://huggingface.co/${encodeURI(m)}" target="_blank" rel="noopener"` +
  532. ` title="open ${esc(m)} on Hugging Face">HF</a>`
  533. : "");
  534. } else {
  535. body = esc(v);
  536. }
  537. return `<td${cls ? ` class="${cls}"` : ""}${tip}>${body}</td>`;
  538. }).join("") + "</tr>");
  539. if (S.openRow === r.name) {
  540. // one toml section per recipe (the recipe.toml is not versioned);
  541. // per-run report files stay on each line
  542. const toml = `<a class="open" href="${auth("/r/" + encodeURIComponent(r.name) + "/recipe.toml")}" target="_blank" title="recipe.toml (highlighted)">⚙ toml</a>`;
  543. const lines = r.reports.map(e => {
  544. const fl = e.files && e.files.length ? e.files : [e.file];
  545. const ts = (e.perf && e.perf.length)
  546. ? ` <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>`
  547. : "";
  548. // run package provenance chip: image build-checksum · recipe sha
  549. // (⚠ = the recipe has since changed — this run's copy is pinned)
  550. const pr = e.provenance;
  551. const chip = pr && (pr.image_sum || pr.recipe_sha)
  552. ? `<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> `
  553. : "";
  554. return `<span>${fl.map(f =>
  555. `<a class="open" href="${auth("/r/" + encodeURIComponent(r.name) + "/" + encodeURIComponent(f))}" target="_blank" title="open ${esc(f)}">${esc(f.split('.').pop())}</a>`).join(" ")}</span> ` +
  556. `${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}`;
  557. }).join("<br>");
  558. out.push(`<tr class="hist"><td colspan="${vis.length}">${actionBar(r)}<div>${toml}${r.reports.length ? " " + lines : ' <span class="dim">no reports</span>'}</div></td></tr>`);
  559. }
  560. }
  561. document.querySelector("tbody").innerHTML = out.join("");
  562. }
  563. document.querySelector("thead").addEventListener("click", e => {
  564. const th = e.target.closest("th"); if (!th) return;
  565. const k = th.dataset.k;
  566. if (S.sort === k) S.dir = -S.dir; else { S.sort = k; S.dir = 1; }
  567. render();
  568. });
  569. // copy a recipe name to the clipboard (clipboard API, textarea fallback)
  570. function fallbackCopy(text) {
  571. const ta = document.createElement("textarea");
  572. ta.value = text; document.body.appendChild(ta); ta.select();
  573. let ok = false; try { ok = document.execCommand("copy"); } catch {}
  574. ta.remove(); return ok;
  575. }
  576. function copyName(btn) {
  577. const done = ok => {
  578. btn.textContent = ok ? "✓" : "✗";
  579. btn.classList.toggle("copied", ok);
  580. setTimeout(() => { btn.textContent = "⧉"; btn.classList.remove("copied"); }, 900);
  581. };
  582. if (navigator.clipboard) navigator.clipboard.writeText(btn.dataset.copy).then(() => done(true), () => done(fallbackCopy(btn.dataset.copy)));
  583. else done(fallbackCopy(btn.dataset.copy));
  584. }
  585. document.querySelector("tbody").addEventListener("click", e => {
  586. const cp = e.target.closest("button.copy");
  587. if (cp) { copyName(cp); return; } // no row toggle under the copy button
  588. if (e.target.closest("a.hf")) return; // open the Hub page, don't toggle the row
  589. const g = e.target.closest("tr.g1,tr.g2");
  590. if (g) { const k = decodeURIComponent(g.dataset.g); S.closed.has(k) ? S.closed.delete(k) : S.closed.add(k); render(); return; }
  591. const row = e.target.closest("tr.row");
  592. if (row) {
  593. const n = row.dataset.n;
  594. const next = S.openRow === n ? null : n;
  595. S.openRow = next;
  596. render();
  597. }
  598. });
  599. document.getElementById("q").addEventListener("input", e => { S.q = e.target.value; S.page = 0; render(); });
  600. document.getElementById("group").addEventListener("change", e => { S.group = e.target.value; S.closed.clear(); S.page = 0; render(); });
  601. document.getElementById("tp").addEventListener("change", e => { S.tp = e.target.value; S.page = 0; render(); });
  602. document.getElementById("medal").addEventListener("change", e => { S.medal = e.target.value; S.page = 0; render(); });
  603. document.getElementById("uponly").addEventListener("change", e => { S.uponly = e.target.checked; S.page = 0; render(); });
  604. // live uptime: re-render while anything is running (the cell values are
  605. // computed at render time)
  606. setInterval(() => { if (DATA.some(r => r.up != null || r.fleet_up)) render(); }, 30000);
  607. document.getElementById("size").addEventListener("change", () => { S.page = 0; render(); });
  608. document.getElementById("prev").addEventListener("click", () => { if (S.page > 0) { S.page--; render(); } });
  609. document.getElementById("next").addEventListener("click", () => { S.page++; render(); });
  610. // ---- dashboard actions (hosted/embed mode only) --------------------------
  611. // The standalone `report --serve` has no job/repo API — the controls exist
  612. // only when the page is mounted by the web dashboard (body.embed), and they
  613. // call the dashboard's same-origin endpoints (the session cookie rides
  614. // along). State flips come back through the fleet-probe live stream.
  615. const EMBED = document.body.classList.contains("embed");
  616. const NOTES = {};
  617. function setNote(recipe, text) {
  618. NOTES[recipe] = text;
  619. const n = document.querySelector(`.actbar .actnote[data-r="${CSS.escape(recipe)}"]`);
  620. if (n) n.textContent = text;
  621. }
  622. // The expanded row carries the controls before the toml link: serve/stop
  623. // (tracked dashboard jobs), a logs button (opens a logs WINDOW in the parent
  624. // dashboard via postMessage — same window as the cluster overview, not an
  625. // inline stream), and a bench launch (POST /api/bench — the standard campaign
  626. // as a job; the report lands in the shared recipe repo and re-appears in this
  627. // table via the live stream). The settings button opens the model-settings
  628. // dialog (per-agent client config, endpoint included on request).
  629. function actionBar(r) {
  630. if (!EMBED) return "";
  631. const running = r.up != null || r.fleet_up;
  632. let b = running
  633. ? `<button class="act" data-act="stop" data-r="${esc(r.name)}" title="stop (dashboard job)">⏹ stop</button>`
  634. : `<button class="act" data-act="serve" data-r="${esc(r.name)}" title="serve (dashboard job, cluster auto)">▶ serve</button>`;
  635. if (running) b += `<button class="act" data-act="logs" data-r="${esc(r.name)}" title="open a logs window (head node)">▤ logs</button>`;
  636. b += `<button class="act" data-act="bench" data-r="${esc(r.name)}" title="standard bench (seed 42, hardmode, perf c1,2) as a dashboard job — the report lands in the recipe repo">⚡ bench</button>`;
  637. b += `<button class="act" data-act="settings" data-r="${esc(r.name)}" title="client settings for this recipe's model — pick your agent (pi, Claude Code, Codex, Cline, generic) and copy the block">⚙ settings</button>`;
  638. return `<div class="actbar">${b}<span class="actnote" data-r="${esc(r.name)}">${esc(NOTES[r.name] || "")}</span></div>`;
  639. }
  640. document.addEventListener("click", async (e) => {
  641. const b = e.target.closest("button.act");
  642. if (!b) return;
  643. e.stopPropagation(); // no row toggle under the action button
  644. const kind = b.dataset.act, recipe = b.dataset.r;
  645. if (kind === "logs") { parent.postMessage({ sm121: "openLogs", recipe }, "*"); return; }
  646. if (kind === "settings") { msOpen(recipe); return; }
  647. const path = kind === "serve" ? "/api/serve" : kind === "stop" ? "/api/stop" : "/api/bench";
  648. b.disabled = true;
  649. try {
  650. const res = await fetch(path, { // "/api/serve" | "/api/stop" | "/api/bench"
  651. method: "POST",
  652. headers: { "Content-Type": "application/json" },
  653. body: JSON.stringify({ cluster: "auto", recipe }),
  654. });
  655. const j = await res.json().catch(() => ({}));
  656. if (j.job != null) setNote(recipe, `${kind} job ${j.job} running — the state updates when the fleet probe sees it`);
  657. else setNote(recipe, `✗ ${kind}: ` + (j.error || res.status));
  658. } catch (err) { setNote(recipe, `✗ ${kind}: ` + err); }
  659. setTimeout(() => { b.disabled = false; }, 3000);
  660. });
  661. // ---- model settings dialog (⚙ settings) -----------------------------------
  662. // Client configuration for the recipe's served model, per agent. The server
  663. // derives everything from the recipe file (served name, context window,
  664. // reasoning/tools flags) plus the endpoint (the node currently hosting it,
  665. // else the recipe's cluster head node), so the dialog opens instantly.
  666. const MS = { recipe: null, data: null, cache: {} };
  667. const msdlg = document.getElementById("msdlg");
  668. function msRender(agentId) {
  669. const d = MS.data; if (!d) return;
  670. const withProv = document.getElementById("msprov").checked;
  671. const a = (d.agents || []).find(x => x.id === agentId) || (d.agents || [])[0];
  672. if (!a) return;
  673. document.getElementById("mstgt").textContent = a.target ? a.target : "";
  674. document.getElementById("mscode").textContent = withProv ? a.full : a.model;
  675. const m = d.model, e = d.endpoint;
  676. document.getElementById("msdep").textContent =
  677. `${e.api_url} · ${e.host}${e.node ? " (" + e.node + ")" : ""}${e.cluster ? " · " + e.cluster : ""}` +
  678. (e.running ? " · running" : " · not running");
  679. document.getElementById("msmeta").textContent =
  680. `${m.served} · ${m.engine} · context ${m.context}${m.context_known ? "" : " (assumed)"}` +
  681. `${m.reasoning ? " · reasoning" : ""}${m.tools ? " · tools" : ""} · repo ${m.repo}`;
  682. document.getElementById("msagent").value = a.id;
  683. }
  684. async function msOpen(recipe) {
  685. MS.recipe = recipe;
  686. document.getElementById("msdlg").classList.add("on");
  687. document.getElementById("mserr").textContent = "";
  688. document.getElementById("mscode").textContent = "loading…";
  689. document.getElementById("msdtitle").textContent = `Model settings — ${recipe}`;
  690. let d = MS.cache[recipe];
  691. if (!d) {
  692. try {
  693. const res = await fetch("/api/model-settings?recipe=" + encodeURIComponent(recipe));
  694. const j = await res.json().catch(() => ({}));
  695. if (!res.ok) throw new Error(j.error || j.message || String(res.status));
  696. d = MS.cache[recipe] = j;
  697. } catch (err) {
  698. document.getElementById("mscode").textContent = "";
  699. document.getElementById("mserr").textContent = "✗ " + err;
  700. return;
  701. }
  702. }
  703. MS.data = d;
  704. const sel = document.getElementById("msagent");
  705. sel.innerHTML = d.agents.map(a => `<option value="${esc(a.id)}">${esc(a.label)}</option>`).join("");
  706. document.getElementById("msprov").checked = true;
  707. msRender(d.agents[0] && d.agents[0].id);
  708. }
  709. document.getElementById("msagent").addEventListener("change", e => msRender(e.target.value));
  710. document.getElementById("msprov").addEventListener("change", () => msRender(document.getElementById("msagent").value));
  711. document.getElementById("msclose").addEventListener("click", () => msdlg.classList.remove("on"));
  712. msdlg.addEventListener("click", e => { if (e.target === msdlg) msdlg.classList.remove("on"); });
  713. document.addEventListener("keydown", e => { if (e.key === "Escape") msdlg.classList.remove("on"); });
  714. document.getElementById("mscopy").addEventListener("click", async (e) => {
  715. const t = document.getElementById("mscode").textContent;
  716. try { await navigator.clipboard.writeText(t); e.target.textContent = "copied ✓";
  717. setTimeout(() => { e.target.textContent = "copy"; }, 1200); } catch { /* clipboard blocked */ }
  718. });
  719. const syncBtn = document.getElementById("syncbtn");
  720. const repoStat = document.getElementById("repostat");
  721. // An empty recipe table must say WHY: no repo configured, an archived repo
  722. // and a repo whose clone keeps failing all render the same otherwise.
  723. function showRepoStat(text, bad) {
  724. if (!repoStat) return;
  725. repoStat.style.display = text ? "" : "none";
  726. repoStat.textContent = text;
  727. repoStat.style.color = bad ? "#e8833a" : "";
  728. repoStat.title = text;
  729. }
  730. async function loadRepoStatus() {
  731. try {
  732. const j = await (await fetch("/api/repos")).json();
  733. if (!j.configured) {
  734. // Cached clones still serve recipes even with an empty [[recipe_repos]].
  735. if (j.cached_roots) showRepoStat(`repos: ${j.cached_roots} cached (none configured)`, false);
  736. else showRepoStat("⚠ no recipe repos configured", true);
  737. return;
  738. }
  739. const arch = (j.repos || []).filter(r => r.archived).length;
  740. const bad = (j.repos || []).filter(r => !r.cached && !r.archived).length;
  741. if (bad) { showRepoStat(`⚠ ${bad}/${j.configured} repo(s) not synced`, true); return; }
  742. if (arch) { showRepoStat(`repos: ${j.configured - arch}/${j.configured} synced (${arch} archived)`, false); return; }
  743. showRepoStat(`repos: ${j.configured} synced`, false);
  744. } catch { /* hosted mode has no /api/repos (standalone report) */ }
  745. }
  746. if (EMBED) { syncBtn.style.display = ""; loadRepoStatus(); }
  747. syncBtn.addEventListener("click", async () => {
  748. syncBtn.disabled = true; syncBtn.textContent = "⟳ …";
  749. try {
  750. const res = await fetch("/api/repos/sync", { method: "POST" });
  751. const j = await res.json().catch(() => ({}));
  752. const bad = !j.ok;
  753. syncBtn.textContent = bad ? "⟳ ✗" : `⟳ ${j.repos} ok`;
  754. syncBtn.title = j.detail || (bad ? String(res.status) : "recipe repos synced");
  755. showRepoStat(j.detail || "", bad);
  756. if (!bad) loadRepoStatus();
  757. } catch (err) { syncBtn.textContent = "⟳ ✗"; syncBtn.title = String(err); showRepoStat(String(err), true); }
  758. setTimeout(() => { syncBtn.disabled = false; syncBtn.textContent = "⟳ sync"; }, 6000);
  759. });
  760. // column checkboxes
  761. document.getElementById("colbox").innerHTML = COLS.map(c =>
  762. `<label><input type="checkbox" data-k="${c[0]}" ${S.hidden.has(c[0]) ? "" : "checked"}> ${esc(c[1])}</label>`).join("");
  763. document.getElementById("colbox").addEventListener("change", e => {
  764. const k = e.target.dataset.k; if (!k) return;
  765. e.target.checked ? S.hidden.delete(k) : S.hidden.add(k);
  766. render();
  767. });
  768. render();
  769. </script>
  770. </body>
  771. </html>