Spaces:
Running
Running
Upload 7 files
Browse files
README.md
CHANGED
|
@@ -1,12 +1,49 @@
|
|
| 1 |
---
|
| 2 |
title: Training Process
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
license: mit
|
| 9 |
-
short_description: Beta
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 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 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
margin-top: 5px;
|
| 16 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
.card {
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
| 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 |
}
|