Spaces:
Running
Running
Download app.js from noodcon/Training-Process: direct link, hf CLI and curl.
- Browser
- Download file 26.9 kB
-
https://huggingface.co/spaces/noodcon/Training-Process/resolve/main/app.js
- Command line
-
hf download hf://spaces/noodcon/Training-Process/app.js
-
curl -L -o app.js https://huggingface.co/spaces/noodcon/Training-Process/resolve/main/app.js
26.9 kB
| import { CONFIG } from "./config.js"; | |
| import { costAt, rateText } from "./cost.js"; | |
| import * as hf from "./hf.js"; | |
| const CAT_COLORS = { code: "#3b82f6", general: "#f97316", cyber: "#10b981", visual: "#f59e0b", chat: "#ec4899" }; | |
| const RUN_COLORS = { pro: "#f0643c", flash: "#3b82f6" }; | |
| const UNITS = ["second", "minute", "hour", "day", "month", "year"]; | |
| const state = { | |
| data: null, | |
| error: null, | |
| authError: null, | |
| lastSync: null, | |
| tab: "overview", | |
| token: null, | |
| me: null, | |
| isAdmin: false, | |
| dirty: false, | |
| saveStatus: "", | |
| saveFailed: false, | |
| jsonDraft: null, | |
| jsonError: "", | |
| ui: { xMode: "step", yLog: false, smooth: 0, compMode: "count" }, | |
| }; | |
| let chartSeq = 0; | |
| const chartStore = {}; | |
| /* ---------- helpers ---------- */ | |
| const $ = (sel) => document.querySelector(sel); | |
| const esc = (s) => | |
| String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); | |
| const fmtInt = (n) => (n == null || isNaN(n) ? "—" : Math.round(n).toLocaleString("en-US")); | |
| const fmtMoney = (n) => "$" + fmtInt(n); | |
| function fmtNum(v) { | |
| if (v == null || isNaN(v)) return "—"; | |
| const a = Math.abs(v); | |
| if (a >= 1000) return Math.round(v).toLocaleString("en-US"); | |
| if (a >= 1) return v.toFixed(2); | |
| if (a === 0) return "0"; | |
| return v.toPrecision(3); | |
| } | |
| function fmtBig(n) { | |
| if (n == null || isNaN(n)) return "—"; | |
| const a = Math.abs(n); | |
| if (a >= 1e9) return (n / 1e9).toFixed(2) + "B"; | |
| if (a >= 1e6) return (n / 1e6).toFixed(1) + "M"; | |
| if (a >= 1e3) return (n / 1e3).toFixed(0) + "k"; | |
| return String(Math.round(n)); | |
| } | |
| function fmtUtc(iso) { | |
| const d = new Date(iso); | |
| return isNaN(d) ? "—" : d.toISOString().slice(0, 16).replace("T", " ") + " UTC"; | |
| } | |
| function fmtTime(t) { | |
| return isNaN(Date.parse(t)) ? String(t ?? "") : fmtUtc(t); | |
| } | |
| function fmtDuration(ms) { | |
| if (isNaN(ms)) return "—"; | |
| const s = Math.max(0, Math.floor(ms / 1000)); | |
| const d = Math.floor(s / 86400); | |
| const h = String(Math.floor((s % 86400) / 3600)).padStart(2, "0"); | |
| const m = String(Math.floor((s % 3600) / 60)).padStart(2, "0"); | |
| const sec = String(s % 60).padStart(2, "0"); | |
| return `${d}d ${h}:${m}:${sec}`; | |
| } | |
| const last = (vals) => (vals || []).filter((v) => v != null).slice(-1)[0]; | |
| const runName = (k) => state.data?.runs?.find((r) => r.id === k)?.name ?? k; | |
| const runColor = (k) => state.data?.runs?.find((r) => r.id === k)?.color ?? RUN_COLORS[k] ?? "#888"; | |
| const costNow = () => (state.data ? costAt(state.data.cost, Date.now()) : 0); | |
| function metricLast(group, name, runId) { | |
| const m = state.data.metrics.find((x) => x.group === group && x.name === name); | |
| return m ? last(m.series[runId]) : undefined; | |
| } | |
| function delta(vals) { | |
| const v = (vals || []).filter((x) => x != null); | |
| if (v.length < 2) return ""; | |
| const a = v[v.length - 1]; | |
| const b = v[v.length - 2]; | |
| if (v.every((x) => x >= 0 && x <= 1)) { | |
| const d = (a - b) * 100; | |
| return `${d >= 0 ? "▲" : "▼"} ${Math.abs(d).toFixed(2)} pt`; | |
| } | |
| const p = b ? ((a - b) / Math.abs(b)) * 100 : 0; | |
| return `${p >= 0 ? "▲" : "▼"} ${Math.abs(p).toFixed(1)}%`; | |
| } | |
| const deltaClass = (vals) => (delta(vals).startsWith("▲") ? "up" : "down"); | |
| function smooth(vals, w) { | |
| if (w <= 1) return vals; | |
| return vals.map((_, i) => { | |
| const s = vals.slice(Math.max(0, i - w + 1), i + 1).filter((v) => v != null); | |
| return s.length ? s.reduce((x, y) => x + y, 0) / s.length : null; | |
| }); | |
| } | |
| // Hours per training step, used for the "time" x-axis option. | |
| function stepHours() { | |
| const r = state.data?.runs?.find((x) => x.step > 0 && x.startedAt); | |
| if (!r) return 1; | |
| const end = r.stoppedAt ? Date.parse(r.stoppedAt) : Date.now(); | |
| return (end - Date.parse(r.startedAt)) / 3600000 / r.step; | |
| } | |
| function xLabel(i) { | |
| return state.ui.xMode === "time" ? (i * stepHours()).toFixed(1) + "h" : String(i + 1); | |
| } | |
| /* ---------- charts ---------- */ | |
| function chartSvg(series) { | |
| const n = Math.max(0, ...series.map((s) => s.values.length)); | |
| const logOn = state.ui.yLog; | |
| const ser = series.map((s) => ({ ...s, values: smooth(s.values, state.ui.smooth) })); | |
| const ok = (v) => v != null && !isNaN(v) && (!logOn || v > 0); | |
| const tv = (v) => (logOn ? Math.log10(v) : v); | |
| const flat = ser.flatMap((s) => s.values.filter(ok)); | |
| if (n < 2 || !flat.length) return `<div class="empty">no data</div>`; | |
| let lo = tv(Math.min(...flat)); | |
| let hi = tv(Math.max(...flat)); | |
| if (hi - lo < 1e-9) { lo -= 1; hi += 1; } | |
| const pad = (hi - lo) * 0.08; | |
| lo -= pad; | |
| hi += pad; | |
| const W = 600, H = 220, pl = 48, pr = 12, pt = 10, pb = 24; | |
| const X = (i) => pl + ((W - pl - pr) * i) / (n - 1); | |
| const Y = (v) => pt + (H - pt - pb) * (1 - (tv(v) - lo) / (hi - lo)); | |
| const inv = (t) => (logOn ? 10 ** t : t); | |
| let grid = ""; | |
| for (let k = 0; k <= 4; k++) { | |
| const y = pt + (H - pt - pb) * (1 - k / 4); | |
| const val = inv(lo + ((hi - lo) * k) / 4); | |
| grid += `<line class="grid" x1="${pl}" x2="${W - pr}" y1="${y}" y2="${y}"/>`; | |
| grid += `<text x="${pl - 6}" y="${y + 4}" text-anchor="end">${fmtNum(val)}</text>`; | |
| } | |
| const labels = Array.from({ length: n }, (_, i) => xLabel(i)); | |
| let xl = ""; | |
| for (let k = 0; k <= 4; k++) { | |
| const i = Math.round(((n - 1) * k) / 4); | |
| xl += `<text x="${X(i)}" y="${H - 6}" text-anchor="middle">${labels[i]}</text>`; | |
| } | |
| const paths = ser | |
| .map((s) => { | |
| let d = ""; | |
| let pen = false; | |
| s.values.forEach((v, i) => { | |
| if (!ok(v)) { pen = false; return; } | |
| d += (pen ? "L" : "M") + X(i).toFixed(1) + " " + Y(v).toFixed(1) + " "; | |
| pen = true; | |
| }); | |
| return `<path d="${d}" fill="none" stroke="${s.color}" stroke-width="1.8"/>`; | |
| }) | |
| .join(""); | |
| const id = "c" + ++chartSeq; | |
| chartStore[id] = { ser, labels, n, W, pl, pr }; | |
| return `<svg class="chart" data-chart="${id}" viewBox="0 0 ${W} ${H}">${grid}${xl}${paths}</svg>`; | |
| } | |
| function stackedSvg(cats) { | |
| const keys = Object.keys(cats); | |
| const n = Math.max(0, ...keys.map((k) => cats[k].length)); | |
| if (!n) return `<div class="empty">no data</div>`; | |
| const totals = Array.from({ length: n }, (_, i) => keys.reduce((s, k) => s + (cats[k][i] || 0), 0)); | |
| const share = state.ui.compMode === "share"; | |
| const W = 600, H = 220, pl = 48, pt = 10, pb = 24; | |
| const max = share ? 1 : Math.max(...totals) || 1; | |
| const bw = (W - pl) / n; | |
| let bars = ""; | |
| for (let i = 0; i < n; i++) { | |
| let acc = 0; | |
| for (const k of keys) { | |
| const raw = cats[k][i] || 0; | |
| const v = share ? (totals[i] ? raw / totals[i] : 0) : raw; | |
| const h = ((H - pt - pb) * v) / max; | |
| bars += `<rect x="${(pl + i * bw + 1).toFixed(1)}" y="${(H - pb - acc - h).toFixed(1)}" width="${(bw - 2).toFixed(1)}" height="${h.toFixed(1)}" fill="${CAT_COLORS[k] || "#888"}"><title>step ${i + 1} · ${k}: ${fmtInt(raw)}</title></rect>`; | |
| acc += h; | |
| } | |
| } | |
| const ticks = [0, 0.5, 1] | |
| .map((f) => { | |
| const y = pt + (H - pt - pb) * (1 - f); | |
| const label = share ? Math.round(f * 100) + "%" : fmtInt(max * f); | |
| return `<line class="grid" x1="${pl}" x2="${W}" y1="${y}" y2="${y}"/><text x="${pl - 6}" y="${y + 4}" text-anchor="end">${label}</text>`; | |
| }) | |
| .join(""); | |
| const xl = [0, Math.floor((n - 1) / 2), n - 1] | |
| .map((i) => `<text x="${pl + i * bw + bw / 2}" y="${H - 6}" text-anchor="middle">${i + 1}</text>`) | |
| .join(""); | |
| return `<svg class="chart" viewBox="0 0 ${W} ${H}">${ticks}${bars}${xl}</svg>`; | |
| } | |
| function legendHtml(series, showDelta = false) { | |
| return `<div class="legend">${series | |
| .map( | |
| (s) => `<span><i style="background:${s.color}"></i>${esc(s.name)} <b class="mono">${fmtNum(last(s.values))}</b>${ | |
| showDelta ? ` <span class="mono ${deltaClass(s.values)}">${delta(s.values)}</span>` : "" | |
| }</span>` | |
| ) | |
| .join("")}</div>`; | |
| } | |
| /* ---------- sections ---------- */ | |
| const tabBtn = (id, label) => | |
| `<button class="tab ${state.tab === id ? "on" : ""}" data-action="tab" data-tab="${id}">${label}</button>`; | |
| const segBtn = (k, v, on) => | |
| `<button class="${on ? "on" : ""}" data-action="ui" data-k="${k}" data-v="${v}">${v}</button>`; | |
| function authHtml() { | |
| if (state.isAdmin) { | |
| return `<span class="badge">ADMIN · @${esc(state.me.name)}</span><button class="btn ghost" data-action="logout">Log out</button>`; | |
| } | |
| if (state.me) { | |
| return `<span class="muted">@${esc(state.me.name)} · read-only</span><button class="btn ghost" data-action="logout">Log out</button>`; | |
| } | |
| return `<button class="btn" data-action="login">Sign in with Hugging Face</button>`; | |
| } | |
| function saveHtml() { | |
| if (!state.saveStatus) return ""; | |
| return ( | |
| `<span class="save ${state.saveFailed ? "err" : ""}">${esc(state.saveStatus)}</span>` + | |
| (state.saveFailed ? `<button class="btn" data-action="save">Retry</button>` : "") | |
| ); | |
| } | |
| function runCard(r) { | |
| const running = r.status === "running" && !r.stoppedAt; | |
| const start = Date.parse(r.startedAt); | |
| const stop = r.stoppedAt ? Date.parse(r.stoppedAt) : ""; | |
| const stat = (label, value) => | |
| `<div class="stat"><span class="lbl">${label}</span><span class="mono">${value}</span></div>`; | |
| return ` | |
| <div class="card"> | |
| <div class="run-head"> | |
| <span class="dot" style="background:${esc(r.color)}"></span> | |
| <b>${esc(r.name)}</b> | |
| <span class="status ${running ? "live" : ""}">${running ? "running" : "stopped"}</span> | |
| <span class="mono">step ${fmtInt(r.step)}</span> | |
| <span class="mono elapsed" data-start="${start}" data-stop="${stop}">${fmtDuration((stop || Date.now()) - start)}</span> | |
| </div> | |
| <div class="stats"> | |
| ${stat("avg@n", fmtNum(metricLast("dynsam", "avg@n", r.id)))} | |
| ${stat("cost so far", fmtMoney(r.costSoFar))} | |
| ${stat("tokens · step", fmtBig(r.tokensAtStep))} | |
| ${stat("tokens · total", fmtBig(r.tokensTotal))} | |
| ${stat("samples trained", fmtBig(r.samplesTrained))} | |
| ${stat("train batch × rollouts", `${fmtInt(r.trainBatch)} × ${r.rollouts ?? "—"}`)} | |
| </div> | |
| </div>`; | |
| } | |
| function noticesHtml() { | |
| const items = [...state.data.notices].sort((a, b) => Date.parse(b.time) - Date.parse(a.time)); | |
| const form = state.isAdmin | |
| ? `<div class="notice-form" data-admin> | |
| <textarea id="notice-text" rows="2" placeholder="Write a notice for everyone…"></textarea> | |
| <div class="row"><button class="btn" data-action="post-notice">Post notice</button>${saveHtml()}</div> | |
| </div>` | |
| : ""; | |
| const list = items.length | |
| ? items | |
| .map( | |
| (n) => ` | |
| <div class="notice"> | |
| <span class="mono muted">${fmtUtc(n.time)}</span> | |
| <span class="ntext">${esc(n.text)}</span> | |
| ${state.isAdmin ? `<button class="x" data-action="del-notice" data-id="${esc(n.id)}" title="Delete">×</button>` : ""} | |
| </div>` | |
| ) | |
| .join("") | |
| : `<div class="muted">No notices.</div>`; | |
| return ` | |
| <section class="card"> | |
| <div class="mhead"><b>notices</b><span class="muted">UTC</span></div> | |
| ${form} | |
| ${list} | |
| </section>`; | |
| } | |
| function readCostForm() { | |
| const raw = $("#cost-correct").value.trim(); | |
| const correct = raw === "" ? Math.round(costNow()) : Number(raw); | |
| const inc = Number($("#cost-inc").value); | |
| const every = Number($("#cost-every").value); | |
| const unit = $("#cost-unit").value; | |
| const ok = isFinite(correct) && isFinite(inc) && inc >= 0 && isFinite(every) && every > 0; | |
| return { ok, correct, inc, every, unit }; | |
| } | |
| function updateCostPreview() { | |
| const el = $("#cost-preview"); | |
| if (!el) return; | |
| const f = readCostForm(); | |
| el.textContent = f.ok | |
| ? `Preview: shows ${fmtMoney(f.correct)} now, then ${rateText({ increment: f.inc, every: f.every, unit: f.unit })}` | |
| : "Enter valid numbers."; | |
| } | |
| function adminCostHtml() { | |
| const c = state.data.cost; | |
| return ` | |
| <section class="card admin" data-admin> | |
| <div class="mhead"><b>admin · cost</b><span class="muted">saved to the dataset repo</span></div> | |
| <div class="form-grid"> | |
| <label>Correct current value ($)<input id="cost-correct" type="number" step="any" placeholder="${Math.round(costNow())}"></label> | |
| <label>Increase ($)<input id="cost-inc" type="number" min="0" step="any" value="${c.increment}"></label> | |
| <label>every<input id="cost-every" type="number" min="0.0001" step="any" value="${c.every}"></label> | |
| <label>unit<select id="cost-unit">${UNITS.map((u) => `<option value="${u}" ${u === c.unit ? "selected" : ""}>${u}</option>`).join("")}</select></label> | |
| </div> | |
| <div class="muted" id="cost-preview"></div> | |
| <div class="row"><button class="btn" data-action="save-cost">Save cost</button>${saveHtml()}</div> | |
| </section>`; | |
| } | |
| function benchHtml() { | |
| const bs = state.data.benchmarks; | |
| if (!bs.length) return ""; | |
| const cards = bs | |
| .map((b) => { | |
| const series = Object.keys(b.series).map((k) => ({ name: runName(k), color: runColor(k), values: b.series[k] })); | |
| return ` | |
| <div class="card"> | |
| <div class="mhead"><b>${esc(b.name)}</b><span class="muted">${esc(b.subtitle || "")}</span></div> | |
| ${legendHtml(series)} | |
| ${chartSvg(series)} | |
| </div>`; | |
| }) | |
| .join(""); | |
| return `<h3 class="sec">benchmarks</h3><section class="grid">${cards}</section>`; | |
| } | |
| function batchHtml() { | |
| const bc = state.data.batchComposition || {}; | |
| const cats = bc.categories || {}; | |
| const keys = Object.keys(cats); | |
| if (!keys.length) return ""; | |
| const legend = keys | |
| .map((c) => `<span><i style="background:${CAT_COLORS[c] || "#888"}"></i>${esc(c)}</span>`) | |
| .join(""); | |
| return ` | |
| <h3 class="sec">batch composition</h3> | |
| <section class="card"> | |
| <div class="mhead"><span class="muted">what each step's samples are made of · by data source</span></div> | |
| <div class="toolbar"> | |
| <div class="seg">${segBtn("compMode", "count", state.ui.compMode === "count")}${segBtn("compMode", "share", state.ui.compMode === "share")}</div> | |
| </div> | |
| <div class="legend">${legend}</div> | |
| ${stackedSvg(cats)} | |
| </section>`; | |
| } | |
| function sourcesHtml() { | |
| const src = state.data.sources; | |
| if (!src.length) return ""; | |
| const latest = Math.max(...src.map((s) => s.step)); | |
| const rows = src | |
| .filter((s) => s.step === latest) | |
| .sort((a, b) => b.accepted - a.accepted) | |
| .map((r) => { | |
| const pct = r.target ? Math.min(100, (r.accepted / r.target) * 100) : 0; | |
| return `<tr> | |
| <td>${esc(runName(r.run))}</td> | |
| <td>${esc(r.name)}</td> | |
| <td><span style="color:${CAT_COLORS[r.category] || "#888"}">● ${esc(r.category)}</span></td> | |
| <td class="num">${fmtInt(r.accepted)} / ${fmtInt(r.target)}<div class="bar"><i style="width:${pct}%"></i></div></td> | |
| <td class="num">${fmtInt(r.remaining)}</td> | |
| <td class="num">${r.judged == null ? "—" : fmtInt(r.judged)}</td> | |
| <td class="num">${fmtInt(r.inFlight)}</td> | |
| </tr>`; | |
| }) | |
| .join(""); | |
| return ` | |
| <h3 class="sec">sources</h3> | |
| <section class="card"> | |
| <div class="mhead"><b>latest step · ${latest}</b></div> | |
| <div class="table-wrap"><table> | |
| <thead><tr><th>run</th><th>source</th><th>category</th><th class="num">accepted / target</th><th class="num">remaining</th><th class="num">judged</th><th class="num">in flight</th></tr></thead> | |
| <tbody>${rows}</tbody> | |
| </table></div> | |
| </section>`; | |
| } | |
| function feedHtml() { | |
| const f = state.data.feed; | |
| const items = f | |
| .map((x) => `<div><span class="muted">${esc(fmtTime(x.time))}</span> ${esc(x.message)}</div>`) | |
| .join(""); | |
| return ` | |
| <h3 class="sec">feed</h3> | |
| <section class="card"> | |
| <div class="mhead"><b>feed</b><span class="muted">UTC</span></div> | |
| <div class="feed">${items || `<div class="muted">No events.</div>`}</div> | |
| </section>`; | |
| } | |
| function overviewHtml() { | |
| return ` | |
| ${state.isAdmin ? adminCostHtml() : ""} | |
| ${noticesHtml()} | |
| <section class="runs">${state.data.runs.map(runCard).join("")}</section> | |
| ${benchHtml()} | |
| ${batchHtml()} | |
| ${sourcesHtml()} | |
| ${feedHtml()}`; | |
| } | |
| function metricCard(m) { | |
| const series = Object.keys(m.series).map((k) => ({ name: runName(k), color: runColor(k), values: m.series[k] })); | |
| return ` | |
| <div class="card"> | |
| <div class="mhead"><span class="muted">${esc(m.group)}/</span><b>${esc(m.name)}</b></div> | |
| ${legendHtml(series, true)} | |
| ${chartSvg(series)} | |
| </div>`; | |
| } | |
| function metricGridHtml() { | |
| const ms = state.data.metrics; | |
| return ms.length ? ms.map(metricCard).join("") : `<div class="muted">No metrics.</div>`; | |
| } | |
| function metricsHtml() { | |
| const ui = state.ui; | |
| return ` | |
| <div class="toolbar"> | |
| <div class="seg">${segBtn("xMode", "step", ui.xMode === "step")}${segBtn("xMode", "time", ui.xMode === "time")}</div> | |
| <div class="seg">${segBtn("yLog", "linear", !ui.yLog)}${segBtn("yLog", "log", ui.yLog)}</div> | |
| <label class="smooth">smoothing | |
| <input id="smooth" type="range" min="0" max="10" step="1" value="${ui.smooth}"> | |
| <span class="mono" id="smooth-val">${ui.smooth > 1 ? ui.smooth + " pts" : "off"}</span> | |
| </label> | |
| </div> | |
| <div class="grid" id="metric-grid">${metricGridHtml()}</div>`; | |
| } | |
| function renderMd(src) { | |
| return esc(src || "") | |
| .split(/\n\s*\n/) | |
| .map((block) => { | |
| if (/^#\s/.test(block)) return `<h2>${block.replace(/^#\s/, "")}</h2>`; | |
| if (/^- /m.test(block)) { | |
| const items = block.split("\n").map((l) => `<li>${l.replace(/^- /, "")}</li>`).join(""); | |
| return `<ul>${items}</ul>`; | |
| } | |
| return `<p>${block.replace(/\n/g, "<br>")}</p>`; | |
| }) | |
| .join(""); | |
| } | |
| function aboutHtml() { | |
| const admin = state.isAdmin | |
| ? `<section class="card" data-admin> | |
| <div class="mhead"><b>edit about</b><span class="muted"># heading · - bullets · blank line = new paragraph</span></div> | |
| <textarea id="about-text" rows="12">${esc(state.data.about)}</textarea> | |
| <div class="row"><button class="btn" data-action="save-about">Save</button>${saveHtml()}</div> | |
| </section>` | |
| : ""; | |
| return `<section class="card prose">${renderMd(state.data.about)}</section>${admin}`; | |
| } | |
| function jsonHtml() { | |
| const text = state.jsonDraft ?? JSON.stringify(state.data, null, 2); | |
| return ` | |
| <section class="card admin" data-admin> | |
| <div class="mhead"><b>Edit data (JSON)</b><span class="muted">validated before saving</span></div> | |
| <textarea id="json-edit" class="json" rows="28" spellcheck="false">${esc(text)}</textarea> | |
| <div class="row"> | |
| <span id="json-status" class="${state.jsonError ? "err" : "muted"}">${state.jsonError ? "⚠ " + esc(state.jsonError) : "valid JSON"}</span> | |
| ${state.dirty ? `<span class="warn">unsaved changes</span>` : ""} | |
| <button class="btn ghost" data-action="json-discard">Discard</button> | |
| <button class="btn" data-action="json-save">Save</button> | |
| ${saveHtml()} | |
| </div> | |
| </section>`; | |
| } | |
| function mainHtml() { | |
| if (state.tab === "metrics") return metricsHtml(); | |
| if (state.tab === "about") return aboutHtml(); | |
| if (state.tab === "json" && state.isAdmin) return jsonHtml(); | |
| return overviewHtml(); | |
| } | |
| function footerHtml() { | |
| const f = state.data.footer || {}; | |
| const synced = state.lastSync ? ` · synced ${state.lastSync.toLocaleTimeString("en-GB", { hour12: false })}` : ""; | |
| return `<footer>@${esc(CONFIG.OWNER_HF_USERNAME)} · streaming since ${fmtUtc(f.streamingSince)} · ${esc(f.tagline || "")}${synced}</footer>`; | |
| } | |
| /* ---------- render & ticks ---------- */ | |
| function render() { | |
| const d = state.data; | |
| const banner = state.error || state.authError; | |
| $("#app").innerHTML = ` | |
| <header class="topbar"> | |
| <div class="brand">RL training</div> | |
| <nav class="tabs"> | |
| ${tabBtn("overview", "overview")}${tabBtn("metrics", "metrics")}${tabBtn("about", "about")} | |
| ${state.isAdmin ? tabBtn("json", "edit data (JSON)") : ""} | |
| </nav> | |
| <div class="cost"> | |
| <span class="lbl">total cost</span> | |
| <span class="val" data-cost>${d ? fmtMoney(costNow()) : "—"}</span> | |
| <span class="rate">${d ? rateText(d.cost) : ""}</span> | |
| </div> | |
| <div class="clocks">${CONFIG.DISPLAY_TIMEZONES | |
| .map((z) => `<div><span class="lbl">${esc(z.label)}</span><span class="mono" data-clock="${z.tz}"></span></div>`) | |
| .join("")}</div> | |
| <div class="auth">${authHtml()}<button class="btn ghost" data-action="theme" title="Toggle theme">◐</button></div> | |
| </header> | |
| ${banner && d ? `<div class="banner">Sync problem (${esc(banner)}), showing last good data.</div>` : ""} | |
| ${banner && !d ? `<div class="banner err">Could not load data: ${esc(banner)}</div>` : ""} | |
| <main>${d ? mainHtml() : `<div class="muted pad">Loading…</div>`}</main> | |
| ${d ? footerHtml() : ""} | |
| `; | |
| tickTime(); | |
| tickCost(); | |
| updateCostPreview(); | |
| } | |
| function tickCost() { | |
| if (!state.data) return; | |
| const txt = fmtMoney(costNow()); | |
| document.querySelectorAll("[data-cost]").forEach((el) => (el.textContent = txt)); | |
| } | |
| function tickTime() { | |
| const now = new Date(); | |
| document.querySelectorAll("[data-clock]").forEach((el) => { | |
| el.textContent = new Intl.DateTimeFormat("en-GB", { | |
| timeZone: el.dataset.clock, | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| second: "2-digit", | |
| hourCycle: "h23", | |
| }).format(now); | |
| }); | |
| document.querySelectorAll(".elapsed").forEach((el) => { | |
| const start = Number(el.dataset.start); | |
| const end = el.dataset.stop ? Number(el.dataset.stop) : Date.now(); | |
| el.textContent = fmtDuration(end - start); | |
| }); | |
| } | |
| /* ---------- data flow ---------- */ | |
| function normalize(d) { | |
| const cost = { | |
| base: 0, | |
| baseTime: new Date().toISOString(), | |
| increment: 0, | |
| every: 1, | |
| unit: "hour", | |
| ...(d.cost || {}), | |
| }; | |
| return { | |
| notices: [], | |
| runs: [], | |
| metrics: [], | |
| benchmarks: [], | |
| sources: [], | |
| feed: [], | |
| about: "", | |
| footer: {}, | |
| ...d, | |
| cost, | |
| batchComposition: d.batchComposition || { categories: {} }, | |
| }; | |
| } | |
| async function sync(force = false) { | |
| if (state.dirty && !force) return; | |
| try { | |
| state.data = normalize(await hf.readData()); | |
| state.error = null; | |
| state.lastSync = new Date(); | |
| } catch (e) { | |
| state.error = e.message; | |
| } | |
| render(); | |
| } | |
| async function save() { | |
| if (!state.token) return; | |
| state.saveStatus = "Saving..."; | |
| state.saveFailed = false; | |
| render(); | |
| try { | |
| await hf.writeData(state.token, state.data); | |
| state.dirty = false; | |
| state.jsonDraft = null; | |
| state.jsonError = ""; | |
| state.saveStatus = "Saved"; | |
| await sync(true); | |
| } catch (e) { | |
| state.saveStatus = "Failed: " + e.message; | |
| state.saveFailed = true; | |
| render(); | |
| } | |
| } | |
| async function refreshIdentity() { | |
| state.me = await hf.whoami(state.token).catch(() => null); | |
| if (!state.me) { | |
| hf.logout(); | |
| state.token = null; | |
| } | |
| state.isAdmin = !!state.me && state.me.name?.toLowerCase() === CONFIG.OWNER_HF_USERNAME.toLowerCase(); | |
| } | |
| async function init() { | |
| try { | |
| state.token = (await hf.handleRedirect()) || sessionStorage.getItem("hf_token"); | |
| } catch (e) { | |
| state.authError = e.message; | |
| } | |
| if (state.token) await refreshIdentity(); | |
| await sync(true); | |
| setInterval(() => sync(false), CONFIG.REFRESH_MS); | |
| setInterval(tickCost, 100); | |
| setInterval(tickTime, 1000); | |
| } | |
| /* ---------- events ---------- */ | |
| document.addEventListener("click", async (e) => { | |
| const el = e.target.closest("[data-action]"); | |
| if (!el) return; | |
| switch (el.dataset.action) { | |
| case "tab": | |
| state.tab = el.dataset.tab; | |
| render(); | |
| break; | |
| case "login": | |
| hf.login(); | |
| break; | |
| case "logout": | |
| hf.logout(); | |
| state.token = null; | |
| state.me = null; | |
| state.isAdmin = false; | |
| state.dirty = false; | |
| render(); | |
| break; | |
| case "theme": | |
| document.body.classList.toggle("light"); | |
| break; | |
| case "ui": { | |
| const k = el.dataset.k; | |
| const v = el.dataset.v; | |
| state.ui[k] = k === "yLog" ? v === "log" : v; | |
| render(); | |
| break; | |
| } | |
| case "post-notice": { | |
| const text = $("#notice-text").value.trim(); | |
| if (!text) break; | |
| state.data.notices.push({ id: "n" + Date.now(), time: new Date().toISOString(), text }); | |
| save(); | |
| break; | |
| } | |
| case "del-notice": | |
| if (confirm("Delete this notice?")) { | |
| state.data.notices = state.data.notices.filter((n) => n.id !== el.dataset.id); | |
| save(); | |
| } | |
| break; | |
| case "save-cost": { | |
| const f = readCostForm(); | |
| if (!f.ok) { | |
| state.saveStatus = "Invalid cost values"; | |
| state.saveFailed = true; | |
| render(); | |
| break; | |
| } | |
| state.data.cost = { | |
| base: f.correct, | |
| baseTime: new Date().toISOString(), | |
| increment: f.inc, | |
| every: f.every, | |
| unit: f.unit, | |
| }; | |
| save(); | |
| break; | |
| } | |
| case "save-about": | |
| state.data.about = $("#about-text").value; | |
| save(); | |
| break; | |
| case "json-save": { | |
| try { | |
| const parsed = JSON.parse(state.jsonDraft ?? JSON.stringify(state.data)); | |
| state.data = normalize(parsed); | |
| state.jsonError = ""; | |
| save(); | |
| } catch (err) { | |
| state.jsonError = err.message; | |
| render(); | |
| } | |
| break; | |
| } | |
| case "json-discard": | |
| state.jsonDraft = null; | |
| state.jsonError = ""; | |
| state.dirty = false; | |
| sync(true); | |
| break; | |
| case "save": | |
| save(); | |
| break; | |
| } | |
| }); | |
| function handleField(e) { | |
| const t = e.target; | |
| if (t.closest?.("[data-admin]")) state.dirty = true; | |
| if (t.id === "json-edit") { | |
| state.jsonDraft = t.value; | |
| try { | |
| JSON.parse(t.value); | |
| state.jsonError = ""; | |
| } catch (err) { | |
| state.jsonError = err.message; | |
| } | |
| const s = $("#json-status"); | |
| if (s) { | |
| s.textContent = state.jsonError ? "⚠ " + state.jsonError : "valid JSON"; | |
| s.className = state.jsonError ? "err" : "muted"; | |
| } | |
| } | |
| if (t.id?.startsWith("cost-")) updateCostPreview(); | |
| if (t.id === "smooth") { | |
| state.ui.smooth = Number(t.value); | |
| $("#smooth-val").textContent = state.ui.smooth > 1 ? state.ui.smooth + " pts" : "off"; | |
| $("#metric-grid").innerHTML = metricGridHtml(); | |
| } | |
| } | |
| document.addEventListener("input", handleField); | |
| document.addEventListener("change", handleField); | |
| document.addEventListener("pointermove", (e) => { | |
| const tip = $("#tip"); | |
| const svg = e.target.closest?.("svg[data-chart]"); | |
| const c = svg && chartStore[svg.dataset.chart]; | |
| if (!c) { | |
| tip.hidden = true; | |
| return; | |
| } | |
| const r = svg.getBoundingClientRect(); | |
| const x = ((e.clientX - r.left) / r.width) * c.W; | |
| const frac = (x - c.pl) / (c.W - c.pl - c.pr); | |
| const i = Math.max(0, Math.min(c.n - 1, Math.round(frac * (c.n - 1)))); | |
| tip.innerHTML = | |
| `<div class="muted">step ${esc(c.labels[i])}</div>` + | |
| c.ser | |
| .map((s) => `<div><i style="background:${s.color}"></i>${esc(s.name)}: <b>${fmtNum(s.values[i])}</b></div>`) | |
| .join(""); | |
| tip.hidden = false; | |
| tip.style.left = e.clientX + 14 + "px"; | |
| tip.style.top = e.clientY + 14 + "px"; | |
| }); | |
| init(); | |