page.html 50 KB

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