noodcon commited on
Commit
cfbec3f
·
verified ·
1 Parent(s): 936658d

Upload 7 files

Browse files
Files changed (7) hide show
  1. README.md +42 -5
  2. app.js +786 -0
  3. config.js +23 -0
  4. cost.js +24 -0
  5. hf.js +116 -0
  6. index.html +15 -17
  7. style.css +156 -17
README.md CHANGED
@@ -1,12 +1,49 @@
1
  ---
2
  title: Training Process
3
- emoji: 🚀
4
- colorFrom: green
5
- colorTo: yellow
6
  sdk: static
7
  pinned: false
8
  license: mit
9
- short_description: Beta
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Training Process
3
+ emoji: 📈
4
+ colorFrom: gray
5
+ colorTo: red
6
  sdk: static
7
  pinned: false
8
  license: mit
 
9
  ---
10
 
11
+ # RL Training Dashboard (static)
12
+
13
+ Plain HTML, CSS and JavaScript. No build step, no React. Public visitors see the data. The owner (`mondk`) signs in with Hugging Face and can edit the cost counter, notices, about text and raw JSON.
14
+
15
+ ## Files
16
+
17
+ - `index.html`: entry page
18
+ - `style.css`: dark theme, light toggle
19
+ - `config.js`: constants (client ID, owner, data repo, timezones)
20
+ - `cost.js`: cost counter math (pure functions)
21
+ - `hf.js`: Hugging Face login (PKCE), read and write of `dashboard.json`
22
+ - `app.js`: rendering, charts, admin panels
23
+
24
+ ## Setup
25
+
26
+ 1. **Create the OAuth app** (logged in as `mondk`): huggingface.co → Settings → Applications → New OAuth application.
27
+ - Redirect URLs: add `http://localhost:8000/` (for local testing) and the Space URL, for example `https://noodcon-training-process.hf.space/`. The URL must match exactly, including the trailing slash.
28
+ - Scopes: `openid`, `profile`, `write-repos`.
29
+ - Copy the **Client ID**.
30
+ 2. **Fill in `config.js`**: set `HF_OAUTH_CLIENT_ID`. Check that `OWNER_HF_USERNAME` is `mondk` and `DATA_REPO` is `mondk/Test`.
31
+ 3. **Upload the files** to the Space: open the Space, go to Files → Add file → Upload files, and upload every file in this folder (or push them with git). Keep the files at the root of the repo.
32
+ 4. **Open the direct URL** `https://<space-name>.hf.space` and not the preview inside huggingface.co. The preview is an iframe and can block the login session.
33
+
34
+ ## Test locally
35
+
36
+ Run a small server from this folder, not by double-clicking `index.html` (modules and OAuth need http):
37
+
38
+ ```
39
+ python3 -m http.server 8000
40
+ ```
41
+
42
+ Then open `http://localhost:8000`.
43
+
44
+ ## Notes
45
+
46
+ - The dataset `mondk/Test` is public. Do not put tokens or private data in `dashboard.json`.
47
+ - Admin edits save as a commit to the dataset. Visitors see them within `REFRESH_MS` (30 s).
48
+ - The owner's token is kept in `sessionStorage` and is removed by Log out or when the tab closes.
49
+ - If the browser blocks reading `dashboard.json` (CORS), tell me and I will switch the read path.
app.js ADDED
@@ -0,0 +1,786 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CONFIG } from "./config.js";
2
+ import { costAt, rateText } from "./cost.js";
3
+ import * as hf from "./hf.js";
4
+
5
+ const CAT_COLORS = { code: "#3b82f6", general: "#f97316", cyber: "#10b981", visual: "#f59e0b", chat: "#ec4899" };
6
+ const RUN_COLORS = { pro: "#f0643c", flash: "#3b82f6" };
7
+ const UNITS = ["second", "minute", "hour", "day", "month", "year"];
8
+
9
+ const state = {
10
+ data: null,
11
+ error: null,
12
+ authError: null,
13
+ lastSync: null,
14
+ tab: "overview",
15
+ token: null,
16
+ me: null,
17
+ isAdmin: false,
18
+ dirty: false,
19
+ saveStatus: "",
20
+ saveFailed: false,
21
+ jsonDraft: null,
22
+ jsonError: "",
23
+ ui: { xMode: "step", yLog: false, smooth: 0, compMode: "count" },
24
+ };
25
+
26
+ let chartSeq = 0;
27
+ const chartStore = {};
28
+
29
+ /* ---------- helpers ---------- */
30
+
31
+ const $ = (sel) => document.querySelector(sel);
32
+ const esc = (s) =>
33
+ String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
34
+
35
+ const fmtInt = (n) => (n == null || isNaN(n) ? "—" : Math.round(n).toLocaleString("en-US"));
36
+ const fmtMoney = (n) => "$" + fmtInt(n);
37
+
38
+ function fmtNum(v) {
39
+ if (v == null || isNaN(v)) return "—";
40
+ const a = Math.abs(v);
41
+ if (a >= 1000) return Math.round(v).toLocaleString("en-US");
42
+ if (a >= 1) return v.toFixed(2);
43
+ if (a === 0) return "0";
44
+ return v.toPrecision(3);
45
+ }
46
+
47
+ function fmtBig(n) {
48
+ if (n == null || isNaN(n)) return "—";
49
+ const a = Math.abs(n);
50
+ if (a >= 1e9) return (n / 1e9).toFixed(2) + "B";
51
+ if (a >= 1e6) return (n / 1e6).toFixed(1) + "M";
52
+ if (a >= 1e3) return (n / 1e3).toFixed(0) + "k";
53
+ return String(Math.round(n));
54
+ }
55
+
56
+ function fmtUtc(iso) {
57
+ const d = new Date(iso);
58
+ return isNaN(d) ? "—" : d.toISOString().slice(0, 16).replace("T", " ") + " UTC";
59
+ }
60
+
61
+ function fmtTime(t) {
62
+ return isNaN(Date.parse(t)) ? String(t ?? "") : fmtUtc(t);
63
+ }
64
+
65
+ function fmtDuration(ms) {
66
+ if (isNaN(ms)) return "—";
67
+ const s = Math.max(0, Math.floor(ms / 1000));
68
+ const d = Math.floor(s / 86400);
69
+ const h = String(Math.floor((s % 86400) / 3600)).padStart(2, "0");
70
+ const m = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
71
+ const sec = String(s % 60).padStart(2, "0");
72
+ return `${d}d ${h}:${m}:${sec}`;
73
+ }
74
+
75
+ const last = (vals) => (vals || []).filter((v) => v != null).slice(-1)[0];
76
+ const runName = (k) => state.data?.runs?.find((r) => r.id === k)?.name ?? k;
77
+ const runColor = (k) => state.data?.runs?.find((r) => r.id === k)?.color ?? RUN_COLORS[k] ?? "#888";
78
+ const costNow = () => (state.data ? costAt(state.data.cost, Date.now()) : 0);
79
+
80
+ function metricLast(group, name, runId) {
81
+ const m = state.data.metrics.find((x) => x.group === group && x.name === name);
82
+ return m ? last(m.series[runId]) : undefined;
83
+ }
84
+
85
+ function delta(vals) {
86
+ const v = (vals || []).filter((x) => x != null);
87
+ if (v.length < 2) return "";
88
+ const a = v[v.length - 1];
89
+ const b = v[v.length - 2];
90
+ if (v.every((x) => x >= 0 && x <= 1)) {
91
+ const d = (a - b) * 100;
92
+ return `${d >= 0 ? "▲" : "▼"} ${Math.abs(d).toFixed(2)} pt`;
93
+ }
94
+ const p = b ? ((a - b) / Math.abs(b)) * 100 : 0;
95
+ return `${p >= 0 ? "▲" : "▼"} ${Math.abs(p).toFixed(1)}%`;
96
+ }
97
+ const deltaClass = (vals) => (delta(vals).startsWith("▲") ? "up" : "down");
98
+
99
+ function smooth(vals, w) {
100
+ if (w <= 1) return vals;
101
+ return vals.map((_, i) => {
102
+ const s = vals.slice(Math.max(0, i - w + 1), i + 1).filter((v) => v != null);
103
+ return s.length ? s.reduce((x, y) => x + y, 0) / s.length : null;
104
+ });
105
+ }
106
+
107
+ // Hours per training step, used for the "time" x-axis option.
108
+ function stepHours() {
109
+ const r = state.data?.runs?.find((x) => x.step > 0 && x.startedAt);
110
+ if (!r) return 1;
111
+ const end = r.stoppedAt ? Date.parse(r.stoppedAt) : Date.now();
112
+ return (end - Date.parse(r.startedAt)) / 3600000 / r.step;
113
+ }
114
+
115
+ function xLabel(i) {
116
+ return state.ui.xMode === "time" ? (i * stepHours()).toFixed(1) + "h" : String(i + 1);
117
+ }
118
+
119
+ /* ---------- charts ---------- */
120
+
121
+ function chartSvg(series) {
122
+ const n = Math.max(0, ...series.map((s) => s.values.length));
123
+ const logOn = state.ui.yLog;
124
+ const ser = series.map((s) => ({ ...s, values: smooth(s.values, state.ui.smooth) }));
125
+ const ok = (v) => v != null && !isNaN(v) && (!logOn || v > 0);
126
+ const tv = (v) => (logOn ? Math.log10(v) : v);
127
+ const flat = ser.flatMap((s) => s.values.filter(ok));
128
+ if (n < 2 || !flat.length) return `<div class="empty">no data</div>`;
129
+
130
+ let lo = tv(Math.min(...flat));
131
+ let hi = tv(Math.max(...flat));
132
+ if (hi - lo < 1e-9) { lo -= 1; hi += 1; }
133
+ const pad = (hi - lo) * 0.08;
134
+ lo -= pad;
135
+ hi += pad;
136
+
137
+ const W = 600, H = 220, pl = 48, pr = 12, pt = 10, pb = 24;
138
+ const X = (i) => pl + ((W - pl - pr) * i) / (n - 1);
139
+ const Y = (v) => pt + (H - pt - pb) * (1 - (tv(v) - lo) / (hi - lo));
140
+ const inv = (t) => (logOn ? 10 ** t : t);
141
+
142
+ let grid = "";
143
+ for (let k = 0; k <= 4; k++) {
144
+ const y = pt + (H - pt - pb) * (1 - k / 4);
145
+ const val = inv(lo + ((hi - lo) * k) / 4);
146
+ grid += `<line class="grid" x1="${pl}" x2="${W - pr}" y1="${y}" y2="${y}"/>`;
147
+ grid += `<text x="${pl - 6}" y="${y + 4}" text-anchor="end">${fmtNum(val)}</text>`;
148
+ }
149
+
150
+ const labels = Array.from({ length: n }, (_, i) => xLabel(i));
151
+ let xl = "";
152
+ for (let k = 0; k <= 4; k++) {
153
+ const i = Math.round(((n - 1) * k) / 4);
154
+ xl += `<text x="${X(i)}" y="${H - 6}" text-anchor="middle">${labels[i]}</text>`;
155
+ }
156
+
157
+ const paths = ser
158
+ .map((s) => {
159
+ let d = "";
160
+ let pen = false;
161
+ s.values.forEach((v, i) => {
162
+ if (!ok(v)) { pen = false; return; }
163
+ d += (pen ? "L" : "M") + X(i).toFixed(1) + " " + Y(v).toFixed(1) + " ";
164
+ pen = true;
165
+ });
166
+ return `<path d="${d}" fill="none" stroke="${s.color}" stroke-width="1.8"/>`;
167
+ })
168
+ .join("");
169
+
170
+ const id = "c" + ++chartSeq;
171
+ chartStore[id] = { ser, labels, n, W, pl, pr };
172
+ return `<svg class="chart" data-chart="${id}" viewBox="0 0 ${W} ${H}">${grid}${xl}${paths}</svg>`;
173
+ }
174
+
175
+ function stackedSvg(cats) {
176
+ const keys = Object.keys(cats);
177
+ const n = Math.max(0, ...keys.map((k) => cats[k].length));
178
+ if (!n) return `<div class="empty">no data</div>`;
179
+ const totals = Array.from({ length: n }, (_, i) => keys.reduce((s, k) => s + (cats[k][i] || 0), 0));
180
+ const share = state.ui.compMode === "share";
181
+ const W = 600, H = 220, pl = 48, pt = 10, pb = 24;
182
+ const max = share ? 1 : Math.max(...totals) || 1;
183
+ const bw = (W - pl) / n;
184
+
185
+ let bars = "";
186
+ for (let i = 0; i < n; i++) {
187
+ let acc = 0;
188
+ for (const k of keys) {
189
+ const raw = cats[k][i] || 0;
190
+ const v = share ? (totals[i] ? raw / totals[i] : 0) : raw;
191
+ const h = ((H - pt - pb) * v) / max;
192
+ 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>`;
193
+ acc += h;
194
+ }
195
+ }
196
+ const ticks = [0, 0.5, 1]
197
+ .map((f) => {
198
+ const y = pt + (H - pt - pb) * (1 - f);
199
+ const label = share ? Math.round(f * 100) + "%" : fmtInt(max * f);
200
+ return `<line class="grid" x1="${pl}" x2="${W}" y1="${y}" y2="${y}"/><text x="${pl - 6}" y="${y + 4}" text-anchor="end">${label}</text>`;
201
+ })
202
+ .join("");
203
+ const xl = [0, Math.floor((n - 1) / 2), n - 1]
204
+ .map((i) => `<text x="${pl + i * bw + bw / 2}" y="${H - 6}" text-anchor="middle">${i + 1}</text>`)
205
+ .join("");
206
+ return `<svg class="chart" viewBox="0 0 ${W} ${H}">${ticks}${bars}${xl}</svg>`;
207
+ }
208
+
209
+ function legendHtml(series, showDelta = false) {
210
+ return `<div class="legend">${series
211
+ .map(
212
+ (s) => `<span><i style="background:${s.color}"></i>${esc(s.name)} <b class="mono">${fmtNum(last(s.values))}</b>${
213
+ showDelta ? ` <span class="mono ${deltaClass(s.values)}">${delta(s.values)}</span>` : ""
214
+ }</span>`
215
+ )
216
+ .join("")}</div>`;
217
+ }
218
+
219
+ /* ---------- sections ---------- */
220
+
221
+ const tabBtn = (id, label) =>
222
+ `<button class="tab ${state.tab === id ? "on" : ""}" data-action="tab" data-tab="${id}">${label}</button>`;
223
+
224
+ const segBtn = (k, v, on) =>
225
+ `<button class="${on ? "on" : ""}" data-action="ui" data-k="${k}" data-v="${v}">${v}</button>`;
226
+
227
+ function authHtml() {
228
+ if (state.isAdmin) {
229
+ return `<span class="badge">ADMIN · @${esc(state.me.name)}</span><button class="btn ghost" data-action="logout">Log out</button>`;
230
+ }
231
+ if (state.me) {
232
+ return `<span class="muted">@${esc(state.me.name)} · read-only</span><button class="btn ghost" data-action="logout">Log out</button>`;
233
+ }
234
+ return `<button class="btn" data-action="login">Sign in with Hugging Face</button>`;
235
+ }
236
+
237
+ function saveHtml() {
238
+ if (!state.saveStatus) return "";
239
+ return (
240
+ `<span class="save ${state.saveFailed ? "err" : ""}">${esc(state.saveStatus)}</span>` +
241
+ (state.saveFailed ? `<button class="btn" data-action="save">Retry</button>` : "")
242
+ );
243
+ }
244
+
245
+ function runCard(r) {
246
+ const running = r.status === "running" && !r.stoppedAt;
247
+ const start = Date.parse(r.startedAt);
248
+ const stop = r.stoppedAt ? Date.parse(r.stoppedAt) : "";
249
+ const stat = (label, value) =>
250
+ `<div class="stat"><span class="lbl">${label}</span><span class="mono">${value}</span></div>`;
251
+ return `
252
+ <div class="card">
253
+ <div class="run-head">
254
+ <span class="dot" style="background:${esc(r.color)}"></span>
255
+ <b>${esc(r.name)}</b>
256
+ <span class="status ${running ? "live" : ""}">${running ? "running" : "stopped"}</span>
257
+ <span class="mono">step ${fmtInt(r.step)}</span>
258
+ <span class="mono elapsed" data-start="${start}" data-stop="${stop}">${fmtDuration((stop || Date.now()) - start)}</span>
259
+ </div>
260
+ <div class="stats">
261
+ ${stat("avg@n", fmtNum(metricLast("dynsam", "avg@n", r.id)))}
262
+ ${stat("cost so far", fmtMoney(r.costSoFar))}
263
+ ${stat("tokens · step", fmtBig(r.tokensAtStep))}
264
+ ${stat("tokens · total", fmtBig(r.tokensTotal))}
265
+ ${stat("samples trained", fmtBig(r.samplesTrained))}
266
+ ${stat("train batch × rollouts", `${fmtInt(r.trainBatch)} × ${r.rollouts ?? "—"}`)}
267
+ </div>
268
+ </div>`;
269
+ }
270
+
271
+ function noticesHtml() {
272
+ const items = [...state.data.notices].sort((a, b) => Date.parse(b.time) - Date.parse(a.time));
273
+ const form = state.isAdmin
274
+ ? `<div class="notice-form" data-admin>
275
+ <textarea id="notice-text" rows="2" placeholder="Write a notice for everyone…"></textarea>
276
+ <div class="row"><button class="btn" data-action="post-notice">Post notice</button>${saveHtml()}</div>
277
+ </div>`
278
+ : "";
279
+ const list = items.length
280
+ ? items
281
+ .map(
282
+ (n) => `
283
+ <div class="notice">
284
+ <span class="mono muted">${fmtUtc(n.time)}</span>
285
+ <span class="ntext">${esc(n.text)}</span>
286
+ ${state.isAdmin ? `<button class="x" data-action="del-notice" data-id="${esc(n.id)}" title="Delete">×</button>` : ""}
287
+ </div>`
288
+ )
289
+ .join("")
290
+ : `<div class="muted">No notices.</div>`;
291
+ return `
292
+ <section class="card">
293
+ <div class="mhead"><b>notices</b><span class="muted">UTC</span></div>
294
+ ${form}
295
+ ${list}
296
+ </section>`;
297
+ }
298
+
299
+ function readCostForm() {
300
+ const raw = $("#cost-correct").value.trim();
301
+ const correct = raw === "" ? Math.round(costNow()) : Number(raw);
302
+ const inc = Number($("#cost-inc").value);
303
+ const every = Number($("#cost-every").value);
304
+ const unit = $("#cost-unit").value;
305
+ const ok = isFinite(correct) && isFinite(inc) && inc >= 0 && isFinite(every) && every > 0;
306
+ return { ok, correct, inc, every, unit };
307
+ }
308
+
309
+ function updateCostPreview() {
310
+ const el = $("#cost-preview");
311
+ if (!el) return;
312
+ const f = readCostForm();
313
+ el.textContent = f.ok
314
+ ? `Preview: shows ${fmtMoney(f.correct)} now, then ${rateText({ increment: f.inc, every: f.every, unit: f.unit })}`
315
+ : "Enter valid numbers.";
316
+ }
317
+
318
+ function adminCostHtml() {
319
+ const c = state.data.cost;
320
+ return `
321
+ <section class="card admin" data-admin>
322
+ <div class="mhead"><b>admin · cost</b><span class="muted">saved to the dataset repo</span></div>
323
+ <div class="form-grid">
324
+ <label>Correct current value ($)<input id="cost-correct" type="number" step="any" placeholder="${Math.round(costNow())}"></label>
325
+ <label>Increase ($)<input id="cost-inc" type="number" min="0" step="any" value="${c.increment}"></label>
326
+ <label>every<input id="cost-every" type="number" min="0.0001" step="any" value="${c.every}"></label>
327
+ <label>unit<select id="cost-unit">${UNITS.map((u) => `<option value="${u}" ${u === c.unit ? "selected" : ""}>${u}</option>`).join("")}</select></label>
328
+ </div>
329
+ <div class="muted" id="cost-preview"></div>
330
+ <div class="row"><button class="btn" data-action="save-cost">Save cost</button>${saveHtml()}</div>
331
+ </section>`;
332
+ }
333
+
334
+ function benchHtml() {
335
+ const bs = state.data.benchmarks;
336
+ if (!bs.length) return "";
337
+ const cards = bs
338
+ .map((b) => {
339
+ const series = Object.keys(b.series).map((k) => ({ name: runName(k), color: runColor(k), values: b.series[k] }));
340
+ return `
341
+ <div class="card">
342
+ <div class="mhead"><b>${esc(b.name)}</b><span class="muted">${esc(b.subtitle || "")}</span></div>
343
+ ${legendHtml(series)}
344
+ ${chartSvg(series)}
345
+ </div>`;
346
+ })
347
+ .join("");
348
+ return `<h3 class="sec">benchmarks</h3><section class="grid">${cards}</section>`;
349
+ }
350
+
351
+ function batchHtml() {
352
+ const bc = state.data.batchComposition || {};
353
+ const cats = bc.categories || {};
354
+ const keys = Object.keys(cats);
355
+ if (!keys.length) return "";
356
+ const legend = keys
357
+ .map((c) => `<span><i style="background:${CAT_COLORS[c] || "#888"}"></i>${esc(c)}</span>`)
358
+ .join("");
359
+ return `
360
+ <h3 class="sec">batch composition</h3>
361
+ <section class="card">
362
+ <div class="mhead"><span class="muted">what each step's samples are made of · by data source</span></div>
363
+ <div class="toolbar">
364
+ <div class="seg">${segBtn("compMode", "count", state.ui.compMode === "count")}${segBtn("compMode", "share", state.ui.compMode === "share")}</div>
365
+ </div>
366
+ <div class="legend">${legend}</div>
367
+ ${stackedSvg(cats)}
368
+ </section>`;
369
+ }
370
+
371
+ function sourcesHtml() {
372
+ const src = state.data.sources;
373
+ if (!src.length) return "";
374
+ const latest = Math.max(...src.map((s) => s.step));
375
+ const rows = src
376
+ .filter((s) => s.step === latest)
377
+ .sort((a, b) => b.accepted - a.accepted)
378
+ .map((r) => {
379
+ const pct = r.target ? Math.min(100, (r.accepted / r.target) * 100) : 0;
380
+ return `<tr>
381
+ <td>${esc(runName(r.run))}</td>
382
+ <td>${esc(r.name)}</td>
383
+ <td><span style="color:${CAT_COLORS[r.category] || "#888"}">● ${esc(r.category)}</span></td>
384
+ <td class="num">${fmtInt(r.accepted)} / ${fmtInt(r.target)}<div class="bar"><i style="width:${pct}%"></i></div></td>
385
+ <td class="num">${fmtInt(r.remaining)}</td>
386
+ <td class="num">${r.judged == null ? "—" : fmtInt(r.judged)}</td>
387
+ <td class="num">${fmtInt(r.inFlight)}</td>
388
+ </tr>`;
389
+ })
390
+ .join("");
391
+ return `
392
+ <h3 class="sec">sources</h3>
393
+ <section class="card">
394
+ <div class="mhead"><b>latest step · ${latest}</b></div>
395
+ <div class="table-wrap"><table>
396
+ <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>
397
+ <tbody>${rows}</tbody>
398
+ </table></div>
399
+ </section>`;
400
+ }
401
+
402
+ function feedHtml() {
403
+ const f = state.data.feed;
404
+ const items = f
405
+ .map((x) => `<div><span class="muted">${esc(fmtTime(x.time))}</span> ${esc(x.message)}</div>`)
406
+ .join("");
407
+ return `
408
+ <h3 class="sec">feed</h3>
409
+ <section class="card">
410
+ <div class="mhead"><b>feed</b><span class="muted">UTC</span></div>
411
+ <div class="feed">${items || `<div class="muted">No events.</div>`}</div>
412
+ </section>`;
413
+ }
414
+
415
+ function overviewHtml() {
416
+ return `
417
+ ${state.isAdmin ? adminCostHtml() : ""}
418
+ ${noticesHtml()}
419
+ <section class="runs">${state.data.runs.map(runCard).join("")}</section>
420
+ ${benchHtml()}
421
+ ${batchHtml()}
422
+ ${sourcesHtml()}
423
+ ${feedHtml()}`;
424
+ }
425
+
426
+ function metricCard(m) {
427
+ const series = Object.keys(m.series).map((k) => ({ name: runName(k), color: runColor(k), values: m.series[k] }));
428
+ return `
429
+ <div class="card">
430
+ <div class="mhead"><span class="muted">${esc(m.group)}/</span><b>${esc(m.name)}</b></div>
431
+ ${legendHtml(series, true)}
432
+ ${chartSvg(series)}
433
+ </div>`;
434
+ }
435
+
436
+ function metricGridHtml() {
437
+ const ms = state.data.metrics;
438
+ return ms.length ? ms.map(metricCard).join("") : `<div class="muted">No metrics.</div>`;
439
+ }
440
+
441
+ function metricsHtml() {
442
+ const ui = state.ui;
443
+ return `
444
+ <div class="toolbar">
445
+ <div class="seg">${segBtn("xMode", "step", ui.xMode === "step")}${segBtn("xMode", "time", ui.xMode === "time")}</div>
446
+ <div class="seg">${segBtn("yLog", "linear", !ui.yLog)}${segBtn("yLog", "log", ui.yLog)}</div>
447
+ <label class="smooth">smoothing
448
+ <input id="smooth" type="range" min="0" max="10" step="1" value="${ui.smooth}">
449
+ <span class="mono" id="smooth-val">${ui.smooth > 1 ? ui.smooth + " pts" : "off"}</span>
450
+ </label>
451
+ </div>
452
+ <div class="grid" id="metric-grid">${metricGridHtml()}</div>`;
453
+ }
454
+
455
+ function renderMd(src) {
456
+ return esc(src || "")
457
+ .split(/\n\s*\n/)
458
+ .map((block) => {
459
+ if (/^#\s/.test(block)) return `<h2>${block.replace(/^#\s/, "")}</h2>`;
460
+ if (/^- /m.test(block)) {
461
+ const items = block.split("\n").map((l) => `<li>${l.replace(/^- /, "")}</li>`).join("");
462
+ return `<ul>${items}</ul>`;
463
+ }
464
+ return `<p>${block.replace(/\n/g, "<br>")}</p>`;
465
+ })
466
+ .join("");
467
+ }
468
+
469
+ function aboutHtml() {
470
+ const admin = state.isAdmin
471
+ ? `<section class="card" data-admin>
472
+ <div class="mhead"><b>edit about</b><span class="muted"># heading · - bullets · blank line = new paragraph</span></div>
473
+ <textarea id="about-text" rows="12">${esc(state.data.about)}</textarea>
474
+ <div class="row"><button class="btn" data-action="save-about">Save</button>${saveHtml()}</div>
475
+ </section>`
476
+ : "";
477
+ return `<section class="card prose">${renderMd(state.data.about)}</section>${admin}`;
478
+ }
479
+
480
+ function jsonHtml() {
481
+ const text = state.jsonDraft ?? JSON.stringify(state.data, null, 2);
482
+ return `
483
+ <section class="card admin" data-admin>
484
+ <div class="mhead"><b>Edit data (JSON)</b><span class="muted">validated before saving</span></div>
485
+ <textarea id="json-edit" class="json" rows="28" spellcheck="false">${esc(text)}</textarea>
486
+ <div class="row">
487
+ <span id="json-status" class="${state.jsonError ? "err" : "muted"}">${state.jsonError ? "⚠ " + esc(state.jsonError) : "valid JSON"}</span>
488
+ ${state.dirty ? `<span class="warn">unsaved changes</span>` : ""}
489
+ <button class="btn ghost" data-action="json-discard">Discard</button>
490
+ <button class="btn" data-action="json-save">Save</button>
491
+ ${saveHtml()}
492
+ </div>
493
+ </section>`;
494
+ }
495
+
496
+ function mainHtml() {
497
+ if (state.tab === "metrics") return metricsHtml();
498
+ if (state.tab === "about") return aboutHtml();
499
+ if (state.tab === "json" && state.isAdmin) return jsonHtml();
500
+ return overviewHtml();
501
+ }
502
+
503
+ function footerHtml() {
504
+ const f = state.data.footer || {};
505
+ const synced = state.lastSync ? ` · synced ${state.lastSync.toLocaleTimeString("en-GB", { hour12: false })}` : "";
506
+ return `<footer>@${esc(CONFIG.OWNER_HF_USERNAME)} · streaming since ${fmtUtc(f.streamingSince)} · ${esc(f.tagline || "")}${synced}</footer>`;
507
+ }
508
+
509
+ /* ---------- render & ticks ---------- */
510
+
511
+ function render() {
512
+ const d = state.data;
513
+ const banner = state.error || state.authError;
514
+ $("#app").innerHTML = `
515
+ <header class="topbar">
516
+ <div class="brand">RL training</div>
517
+ <nav class="tabs">
518
+ ${tabBtn("overview", "overview")}${tabBtn("metrics", "metrics")}${tabBtn("about", "about")}
519
+ ${state.isAdmin ? tabBtn("json", "edit data (JSON)") : ""}
520
+ </nav>
521
+ <div class="cost">
522
+ <span class="lbl">total cost</span>
523
+ <span class="val" data-cost>${d ? fmtMoney(costNow()) : "—"}</span>
524
+ <span class="rate">${d ? rateText(d.cost) : ""}</span>
525
+ </div>
526
+ <div class="clocks">${CONFIG.DISPLAY_TIMEZONES
527
+ .map((z) => `<div><span class="lbl">${esc(z.label)}</span><span class="mono" data-clock="${z.tz}"></span></div>`)
528
+ .join("")}</div>
529
+ <div class="auth">${authHtml()}<button class="btn ghost" data-action="theme" title="Toggle theme">◐</button></div>
530
+ </header>
531
+ ${banner && d ? `<div class="banner">Sync problem (${esc(banner)}), showing last good data.</div>` : ""}
532
+ ${banner && !d ? `<div class="banner err">Could not load data: ${esc(banner)}</div>` : ""}
533
+ <main>${d ? mainHtml() : `<div class="muted pad">Loading…</div>`}</main>
534
+ ${d ? footerHtml() : ""}
535
+ `;
536
+ tickTime();
537
+ tickCost();
538
+ updateCostPreview();
539
+ }
540
+
541
+ function tickCost() {
542
+ if (!state.data) return;
543
+ const txt = fmtMoney(costNow());
544
+ document.querySelectorAll("[data-cost]").forEach((el) => (el.textContent = txt));
545
+ }
546
+
547
+ function tickTime() {
548
+ const now = new Date();
549
+ document.querySelectorAll("[data-clock]").forEach((el) => {
550
+ el.textContent = new Intl.DateTimeFormat("en-GB", {
551
+ timeZone: el.dataset.clock,
552
+ hour: "2-digit",
553
+ minute: "2-digit",
554
+ second: "2-digit",
555
+ hourCycle: "h23",
556
+ }).format(now);
557
+ });
558
+ document.querySelectorAll(".elapsed").forEach((el) => {
559
+ const start = Number(el.dataset.start);
560
+ const end = el.dataset.stop ? Number(el.dataset.stop) : Date.now();
561
+ el.textContent = fmtDuration(end - start);
562
+ });
563
+ }
564
+
565
+ /* ---------- data flow ---------- */
566
+
567
+ function normalize(d) {
568
+ const cost = {
569
+ base: 0,
570
+ baseTime: new Date().toISOString(),
571
+ increment: 0,
572
+ every: 1,
573
+ unit: "hour",
574
+ ...(d.cost || {}),
575
+ };
576
+ return {
577
+ notices: [],
578
+ runs: [],
579
+ metrics: [],
580
+ benchmarks: [],
581
+ sources: [],
582
+ feed: [],
583
+ about: "",
584
+ footer: {},
585
+ ...d,
586
+ cost,
587
+ batchComposition: d.batchComposition || { categories: {} },
588
+ };
589
+ }
590
+
591
+ async function sync(force = false) {
592
+ if (state.dirty && !force) return;
593
+ try {
594
+ state.data = normalize(await hf.readData());
595
+ state.error = null;
596
+ state.lastSync = new Date();
597
+ } catch (e) {
598
+ state.error = e.message;
599
+ }
600
+ render();
601
+ }
602
+
603
+ async function save() {
604
+ if (!state.token) return;
605
+ state.saveStatus = "Saving...";
606
+ state.saveFailed = false;
607
+ render();
608
+ try {
609
+ await hf.writeData(state.token, state.data);
610
+ state.dirty = false;
611
+ state.jsonDraft = null;
612
+ state.jsonError = "";
613
+ state.saveStatus = "Saved";
614
+ await sync(true);
615
+ } catch (e) {
616
+ state.saveStatus = "Failed: " + e.message;
617
+ state.saveFailed = true;
618
+ render();
619
+ }
620
+ }
621
+
622
+ async function refreshIdentity() {
623
+ state.me = await hf.whoami(state.token).catch(() => null);
624
+ if (!state.me) {
625
+ hf.logout();
626
+ state.token = null;
627
+ }
628
+ state.isAdmin = !!state.me && state.me.name?.toLowerCase() === CONFIG.OWNER_HF_USERNAME.toLowerCase();
629
+ }
630
+
631
+ async function init() {
632
+ try {
633
+ state.token = (await hf.handleRedirect()) || sessionStorage.getItem("hf_token");
634
+ } catch (e) {
635
+ state.authError = e.message;
636
+ }
637
+ if (state.token) await refreshIdentity();
638
+ await sync(true);
639
+ setInterval(() => sync(false), CONFIG.REFRESH_MS);
640
+ setInterval(tickCost, 100);
641
+ setInterval(tickTime, 1000);
642
+ }
643
+
644
+ /* ---------- events ---------- */
645
+
646
+ document.addEventListener("click", async (e) => {
647
+ const el = e.target.closest("[data-action]");
648
+ if (!el) return;
649
+ switch (el.dataset.action) {
650
+ case "tab":
651
+ state.tab = el.dataset.tab;
652
+ render();
653
+ break;
654
+ case "login":
655
+ hf.login();
656
+ break;
657
+ case "logout":
658
+ hf.logout();
659
+ state.token = null;
660
+ state.me = null;
661
+ state.isAdmin = false;
662
+ state.dirty = false;
663
+ render();
664
+ break;
665
+ case "theme":
666
+ document.body.classList.toggle("light");
667
+ break;
668
+ case "ui": {
669
+ const k = el.dataset.k;
670
+ const v = el.dataset.v;
671
+ state.ui[k] = k === "yLog" ? v === "log" : v;
672
+ render();
673
+ break;
674
+ }
675
+ case "post-notice": {
676
+ const text = $("#notice-text").value.trim();
677
+ if (!text) break;
678
+ state.data.notices.push({ id: "n" + Date.now(), time: new Date().toISOString(), text });
679
+ save();
680
+ break;
681
+ }
682
+ case "del-notice":
683
+ if (confirm("Delete this notice?")) {
684
+ state.data.notices = state.data.notices.filter((n) => n.id !== el.dataset.id);
685
+ save();
686
+ }
687
+ break;
688
+ case "save-cost": {
689
+ const f = readCostForm();
690
+ if (!f.ok) {
691
+ state.saveStatus = "Invalid cost values";
692
+ state.saveFailed = true;
693
+ render();
694
+ break;
695
+ }
696
+ state.data.cost = {
697
+ base: f.correct,
698
+ baseTime: new Date().toISOString(),
699
+ increment: f.inc,
700
+ every: f.every,
701
+ unit: f.unit,
702
+ };
703
+ save();
704
+ break;
705
+ }
706
+ case "save-about":
707
+ state.data.about = $("#about-text").value;
708
+ save();
709
+ break;
710
+ case "json-save": {
711
+ try {
712
+ const parsed = JSON.parse(state.jsonDraft ?? JSON.stringify(state.data));
713
+ state.data = normalize(parsed);
714
+ state.jsonError = "";
715
+ save();
716
+ } catch (err) {
717
+ state.jsonError = err.message;
718
+ render();
719
+ }
720
+ break;
721
+ }
722
+ case "json-discard":
723
+ state.jsonDraft = null;
724
+ state.jsonError = "";
725
+ state.dirty = false;
726
+ sync(true);
727
+ break;
728
+ case "save":
729
+ save();
730
+ break;
731
+ }
732
+ });
733
+
734
+ function handleField(e) {
735
+ const t = e.target;
736
+ if (t.closest?.("[data-admin]")) state.dirty = true;
737
+
738
+ if (t.id === "json-edit") {
739
+ state.jsonDraft = t.value;
740
+ try {
741
+ JSON.parse(t.value);
742
+ state.jsonError = "";
743
+ } catch (err) {
744
+ state.jsonError = err.message;
745
+ }
746
+ const s = $("#json-status");
747
+ if (s) {
748
+ s.textContent = state.jsonError ? "⚠ " + state.jsonError : "valid JSON";
749
+ s.className = state.jsonError ? "err" : "muted";
750
+ }
751
+ }
752
+
753
+ if (t.id?.startsWith("cost-")) updateCostPreview();
754
+
755
+ if (t.id === "smooth") {
756
+ state.ui.smooth = Number(t.value);
757
+ $("#smooth-val").textContent = state.ui.smooth > 1 ? state.ui.smooth + " pts" : "off";
758
+ $("#metric-grid").innerHTML = metricGridHtml();
759
+ }
760
+ }
761
+ document.addEventListener("input", handleField);
762
+ document.addEventListener("change", handleField);
763
+
764
+ document.addEventListener("pointermove", (e) => {
765
+ const tip = $("#tip");
766
+ const svg = e.target.closest?.("svg[data-chart]");
767
+ const c = svg && chartStore[svg.dataset.chart];
768
+ if (!c) {
769
+ tip.hidden = true;
770
+ return;
771
+ }
772
+ const r = svg.getBoundingClientRect();
773
+ const x = ((e.clientX - r.left) / r.width) * c.W;
774
+ const frac = (x - c.pl) / (c.W - c.pl - c.pr);
775
+ const i = Math.max(0, Math.min(c.n - 1, Math.round(frac * (c.n - 1))));
776
+ tip.innerHTML =
777
+ `<div class="muted">step ${esc(c.labels[i])}</div>` +
778
+ c.ser
779
+ .map((s) => `<div><i style="background:${s.color}"></i>${esc(s.name)}: <b>${fmtNum(s.values[i])}</b></div>`)
780
+ .join("");
781
+ tip.hidden = false;
782
+ tip.style.left = e.clientX + 14 + "px";
783
+ tip.style.top = e.clientY + 14 + "px";
784
+ });
785
+
786
+ init();
config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Edit these values. The OAuth client ID is public and safe to commit.
2
+ // Never put an access token or password in this file.
3
+ export const CONFIG = {
4
+ // Hugging Face OAuth app client ID (create it under the mondk account)
5
+ HF_OAUTH_CLIENT_ID: "",
6
+
7
+ // Only this HF user gets ADMIN mode (case-insensitive)
8
+ OWNER_HF_USERNAME: "mondk",
9
+
10
+ // Dataset repo and file that hold dashboard.json
11
+ DATA_REPO: "mondk/Test",
12
+ DATA_FILE: "dashboard.json",
13
+
14
+ // How often visitors re-fetch the data (ms)
15
+ REFRESH_MS: 30000,
16
+
17
+ DISPLAY_TIMEZONES: [
18
+ { tz: "Asia/Ho_Chi_Minh", label: "Hanoi" },
19
+ { tz: "Europe/London", label: "London" },
20
+ { tz: "America/New_York", label: "New York" },
21
+ { tz: "America/Los_Angeles", label: "Los Angeles" },
22
+ ],
23
+ };
cost.js ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const UNIT_SECONDS = {
2
+ second: 1,
3
+ minute: 60,
4
+ hour: 3600,
5
+ day: 86400,
6
+ month: 30.44 * 86400,
7
+ year: 365.25 * 86400,
8
+ };
9
+
10
+ export function unitSeconds(unit) {
11
+ return UNIT_SECONDS[unit] ?? 3600;
12
+ }
13
+
14
+ // displayed = base + increment * elapsed / (every * unitSeconds)
15
+ export function costAt(cost, nowMs) {
16
+ const t0 = Date.parse(cost.baseTime);
17
+ const elapsedSec = (nowMs - t0) / 1000;
18
+ return cost.base + (cost.increment * elapsedSec) / (cost.every * unitSeconds(cost.unit));
19
+ }
20
+
21
+ export function rateText(cost) {
22
+ const unit = cost.every === 1 ? cost.unit : `${cost.every} ${cost.unit}s`;
23
+ return `+$${Number(cost.increment).toLocaleString("en-US")} / ${unit}`;
24
+ }
hf.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CONFIG } from "./config.js";
2
+
3
+ const AUTH_URL = "https://huggingface.co/oauth/authorize";
4
+ const TOKEN_URL = "https://huggingface.co/oauth/token";
5
+ const redirectUri = () => location.origin + location.pathname;
6
+
7
+ function b64url(bytes) {
8
+ let s = "";
9
+ bytes.forEach((b) => (s += String.fromCharCode(b)));
10
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
11
+ }
12
+
13
+ function randomString(len) {
14
+ const a = new Uint8Array(len);
15
+ crypto.getRandomValues(a);
16
+ return b64url(a).slice(0, len);
17
+ }
18
+
19
+ export async function login() {
20
+ if (!CONFIG.HF_OAUTH_CLIENT_ID) {
21
+ alert("HF_OAUTH_CLIENT_ID is empty in config.js");
22
+ return;
23
+ }
24
+ const verifier = randomString(64);
25
+ const state = randomString(24);
26
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
27
+ const challenge = b64url(new Uint8Array(digest));
28
+ sessionStorage.setItem("hf_pkce", JSON.stringify({ verifier, state }));
29
+
30
+ const params = new URLSearchParams({
31
+ client_id: CONFIG.HF_OAUTH_CLIENT_ID,
32
+ redirect_uri: redirectUri(),
33
+ response_type: "code",
34
+ scope: "openid profile write-repos",
35
+ state,
36
+ code_challenge: challenge,
37
+ code_challenge_method: "S256",
38
+ });
39
+ location.href = `${AUTH_URL}?${params}`;
40
+ }
41
+
42
+ export function logout() {
43
+ sessionStorage.removeItem("hf_token");
44
+ sessionStorage.removeItem("hf_pkce");
45
+ }
46
+
47
+ // Call once on load. Returns the access token if this page was opened from the OAuth redirect.
48
+ export async function handleRedirect() {
49
+ const url = new URL(location.href);
50
+ const code = url.searchParams.get("code");
51
+ if (!code) return null;
52
+
53
+ const saved = JSON.parse(sessionStorage.getItem("hf_pkce") || "null");
54
+ history.replaceState({}, "", location.pathname);
55
+ if (!saved || saved.state !== url.searchParams.get("state")) {
56
+ throw new Error("OAuth state mismatch, please sign in again");
57
+ }
58
+
59
+ const body = new URLSearchParams({
60
+ grant_type: "authorization_code",
61
+ code,
62
+ redirect_uri: redirectUri(),
63
+ client_id: CONFIG.HF_OAUTH_CLIENT_ID,
64
+ code_verifier: saved.verifier,
65
+ });
66
+ const res = await fetch(TOKEN_URL, {
67
+ method: "POST",
68
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
69
+ body,
70
+ });
71
+ sessionStorage.removeItem("hf_pkce");
72
+ if (!res.ok) throw new Error(`token exchange failed (${res.status})`);
73
+
74
+ const json = await res.json();
75
+ sessionStorage.setItem("hf_token", json.access_token);
76
+ return json.access_token;
77
+ }
78
+
79
+ export async function whoami(token) {
80
+ const res = await fetch("https://huggingface.co/api/whoami-v2", {
81
+ headers: { Authorization: `Bearer ${token}` },
82
+ });
83
+ if (!res.ok) return null;
84
+ return res.json();
85
+ }
86
+
87
+ // Public read. The timestamp query defeats stale caches.
88
+ export async function readData() {
89
+ const url = `https://huggingface.co/datasets/${CONFIG.DATA_REPO}/resolve/main/${CONFIG.DATA_FILE}?t=${Date.now()}`;
90
+ const res = await fetch(url, { cache: "no-store" });
91
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
92
+ return res.json();
93
+ }
94
+
95
+ // Admin write: commit the full JSON file to the dataset repo.
96
+ export async function writeData(token, data) {
97
+ const bytes = new TextEncoder().encode(JSON.stringify(data, null, 2));
98
+ let bin = "";
99
+ bytes.forEach((b) => (bin += String.fromCharCode(b)));
100
+ const content = btoa(bin);
101
+
102
+ const ndjson = [
103
+ JSON.stringify({ key: "header", value: { summary: `Update ${CONFIG.DATA_FILE} from dashboard`, description: "" } }),
104
+ JSON.stringify({ key: "file", value: { path: CONFIG.DATA_FILE, encoding: "base64", content } }),
105
+ ].join("\n");
106
+
107
+ const res = await fetch(`https://huggingface.co/api/datasets/${CONFIG.DATA_REPO}/commit/main`, {
108
+ method: "POST",
109
+ headers: {
110
+ Authorization: `Bearer ${token}`,
111
+ "Content-Type": "application/x-ndjson",
112
+ },
113
+ body: ndjson,
114
+ });
115
+ if (!res.ok) throw new Error(`commit failed (${res.status})`);
116
+ }
index.html CHANGED
@@ -1,19 +1,17 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>RL Training Dashboard</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="style.css">
11
+ </head>
12
+ <body>
13
+ <div id="app"><div class="muted pad">Loading…</div></div>
14
+ <div id="tip" class="tip" hidden></div>
15
+ <script type="module" src="app.js"></script>
16
+ </body>
 
 
17
  </html>
style.css CHANGED
@@ -1,28 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
 
 
 
 
 
4
  }
 
 
 
 
 
 
5
 
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  }
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
  }
 
 
 
 
 
 
 
 
 
 
17
 
 
 
 
 
 
18
  .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
 
 
28
  }
 
1
+ :root {
2
+ --bg: #0b0c0e;
3
+ --card: #14161a;
4
+ --border: #22252b;
5
+ --text: #e6e6e6;
6
+ --muted: #8a8f98;
7
+ --up: #34d399;
8
+ --down: #f87171;
9
+ --warn: #f59e0b;
10
+ --accent: #f0643c;
11
+ color-scheme: dark;
12
+ }
13
+ body.light {
14
+ --bg: #f4f5f7;
15
+ --card: #ffffff;
16
+ --border: #dde1e7;
17
+ --text: #15171a;
18
+ --muted: #646b75;
19
+ color-scheme: light;
20
+ }
21
+ * { box-sizing: border-box; }
22
  body {
23
+ margin: 0;
24
+ background: var(--bg);
25
+ color: var(--text);
26
+ font: 14px/1.4 Inter, system-ui, -apple-system, sans-serif;
27
+ }
28
+ .mono, .val, .elapsed, .rate, .clocks .mono, .stat span:last-child, table, .json, .tip, .feed {
29
+ font-family: "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace;
30
  }
31
+ .muted { color: var(--muted); }
32
+ .warn { color: var(--warn); }
33
+ .err { color: var(--down); }
34
+ .up { color: var(--up); }
35
+ .down { color: var(--down); }
36
+ .pad { padding: 40px; }
37
 
38
+ /* top bar */
39
+ .topbar {
40
+ display: flex; flex-wrap: wrap; align-items: center; gap: 14px 22px;
41
+ padding: 10px 16px; border-bottom: 1px solid var(--border);
42
+ position: sticky; top: 0; background: var(--bg); z-index: 5;
43
+ }
44
+ .brand { font-weight: 700; }
45
+ .tabs { display: flex; gap: 4px; flex-wrap: wrap; }
46
+ .tab {
47
+ background: none; border: 0; color: var(--muted); padding: 6px 10px;
48
+ border-radius: 6px; cursor: pointer; font: inherit;
49
+ }
50
+ .tab.on { color: var(--text); background: var(--card); }
51
+ .cost { display: flex; flex-direction: column; }
52
+ .cost .val { font-size: 22px; font-weight: 600; }
53
+ .rate { color: var(--muted); font-size: 12px; }
54
+ .lbl { color: var(--muted); font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
55
+ .clocks { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; }
56
+ .clocks > div { display: flex; flex-direction: column; }
57
+ .auth { margin-left: auto; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
58
+ .badge {
59
+ background: rgba(240, 100, 60, .15); color: var(--accent);
60
+ padding: 3px 8px; border-radius: 999px; font-size: 12px;
61
  }
62
 
63
+ /* buttons, inputs */
64
+ .btn {
65
+ background: var(--accent); color: #fff; border: 0; border-radius: 6px;
66
+ padding: 7px 12px; cursor: pointer; font: inherit;
 
67
  }
68
+ .btn.ghost { background: transparent; color: var(--text); border: 1px solid var(--border); }
69
+ textarea, input, select {
70
+ background: var(--bg); color: var(--text); border: 1px solid var(--border);
71
+ border-radius: 6px; padding: 6px 8px; font: inherit;
72
+ }
73
+ textarea { width: 100%; }
74
+ .json { width: 100%; font-size: 12px; }
75
+ .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 8px; }
76
+ .save { font-size: 12px; color: var(--muted); }
77
+ .save.err { color: var(--down); }
78
 
79
+ /* layout */
80
+ main {
81
+ padding: 16px; display: flex; flex-direction: column; gap: 14px;
82
+ max-width: 1400px; margin: 0 auto;
83
+ }
84
  .card {
85
+ background: var(--card); border: 1px solid var(--border);
86
+ border-radius: 10px; padding: 14px; min-width: 0;
87
+ }
88
+ .mhead { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; margin-bottom: 8px; }
89
+ .sec { margin: 8px 0 -4px; font-size: 13px; color: var(--muted); font-weight: 500; }
90
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 14px; }
91
+ .runs { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; }
92
+ .run-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
93
+ .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
94
+ .status { font-size: 12px; color: var(--muted); }
95
+ .status.live { color: var(--up); }
96
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; }
97
+ .stat { display: flex; flex-direction: column; gap: 2px; }
98
+ .stat .lbl { font-size: 10px; }
99
+
100
+ /* charts & legends */
101
+ .legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
102
+ .legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: middle; }
103
+ .legend b { color: var(--text); margin-left: 4px; }
104
+ .chart { width: 100%; height: auto; display: block; }
105
+ .chart text { fill: var(--muted); font-size: 11px; }
106
+ .chart .grid { stroke: var(--border); stroke-width: 1; }
107
+ .empty { color: var(--muted); padding: 30px 0; text-align: center; }
108
+ .tip {
109
+ position: fixed; pointer-events: none; background: var(--card);
110
+ border: 1px solid var(--border); padding: 6px 8px; border-radius: 6px;
111
+ font-size: 12px; z-index: 20; max-width: 260px;
112
  }
113
+ .tip i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 4px; }
114
+ .toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin: 0 0 10px; }
115
+ .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
116
+ .seg button {
117
+ background: none; border: 0; color: var(--muted); padding: 5px 10px;
118
+ cursor: pointer; font: inherit;
119
+ }
120
+ .seg button.on { background: var(--border); color: var(--text); }
121
+ .smooth { font-size: 12px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
122
+
123
+ /* notices */
124
+ .notice {
125
+ display: grid; grid-template-columns: 150px 1fr auto; gap: 10px;
126
+ padding: 8px 0; border-bottom: 1px solid var(--border);
127
+ }
128
+ .notice:last-child { border-bottom: 0; }
129
+ .ntext { white-space: pre-wrap; }
130
+ .x { background: none; border: 0; color: var(--muted); cursor: pointer; font-size: 16px; }
131
+ .notice-form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
132
+
133
+ /* admin forms */
134
+ .admin { border-color: var(--accent); }
135
+ .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin-bottom: 8px; }
136
+ .form-grid label { display: flex; flex-direction: column; gap: 4px; color: var(--muted); font-size: 12px; }
137
+
138
+ /* tables */
139
+ .table-wrap { overflow-x: auto; }
140
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
141
+ th, td { padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
142
+ th { color: var(--muted); font-weight: 500; }
143
+ td.num { text-align: right; }
144
+ .bar { height: 4px; background: var(--border); border-radius: 2px; margin-top: 4px; width: 120px; margin-left: auto; }
145
+ .bar i { display: block; height: 100%; background: #3b82f6; border-radius: 2px; }
146
+ .feed { max-height: 260px; overflow-y: auto; font-size: 12px; line-height: 1.7; }
147
+
148
+ /* about */
149
+ .prose { line-height: 1.6; }
150
+ .prose h2 { font-size: 18px; margin: 0 0 8px; }
151
+ .prose p { margin: 0 0 10px; }
152
+ .prose ul { margin: 0 0 10px; padding-left: 20px; }
153
+
154
+ /* banners */
155
+ .banner {
156
+ margin: 12px 16px 0; padding: 8px 12px; border-radius: 8px;
157
+ background: rgba(245, 158, 11, .12); color: var(--warn); font-size: 13px;
158
+ }
159
+ .banner.err { background: rgba(248, 113, 113, .12); color: var(--down); }
160
+
161
+ footer { text-align: center; color: var(--muted); font-size: 12px; padding: 20px; }
162
 
163
+ @media (max-width: 640px) {
164
+ .notice { grid-template-columns: 1fr; }
165
+ .cost .val { font-size: 18px; }
166
+ .auth { margin-left: 0; }
167
  }