| import { useCallback, useEffect, useRef, useState } from "react"; |
|
|
| |
| |
| |
|
|
| const STATUS = { |
| waiting: { color: "#e0af68", label: "waiting for start" }, |
| training: { color: "#e8963a", label: "training" }, |
| paused: { color: "#7aa2f7", label: "paused" }, |
| done: { color: "#9ece6a", label: "done" }, |
| stopped: { color: "#8b95a8", label: "stopped" }, |
| }; |
|
|
| const S = { |
| dock: { |
| position: "relative", |
| flexShrink: 0, |
| background: "linear-gradient(180deg, rgba(13,17,26,0.72) 0%, rgba(11,14,20,0.95) 42%)", |
| borderTop: "1px solid #1e2635", |
| backdropFilter: "blur(10px)", |
| display: "flex", |
| alignItems: "stretch", |
| gap: 18, |
| padding: "12px 18px", |
| }, |
| stat: { minWidth: 92 }, |
| statLabel: { |
| color: "#5d6b84", |
| fontSize: 10, |
| fontWeight: 700, |
| letterSpacing: 1.2, |
| textTransform: "uppercase", |
| }, |
| statValue: { |
| fontSize: 19, |
| fontWeight: 600, |
| fontVariantNumeric: "tabular-nums", |
| marginTop: 2, |
| }, |
| chip: { |
| fontSize: 11, |
| padding: "1px 7px", |
| borderRadius: 99, |
| background: "#233049", |
| color: "#9fb3d9", |
| }, |
| btn: { |
| background: "#1c2536", |
| color: "#d6dbe4", |
| border: "1px solid #2b3850", |
| borderRadius: 6, |
| padding: "7px 14px", |
| cursor: "pointer", |
| fontSize: 13, |
| }, |
| btnStart: { |
| background: "#173423", |
| color: "#9ece6a", |
| border: "1px solid #2f6e42", |
| borderRadius: 6, |
| padding: "8px 16px", |
| cursor: "pointer", |
| fontSize: 13.5, |
| fontWeight: 650, |
| }, |
| btnStop: { |
| background: "#2a1418", |
| color: "#fca5a5", |
| border: "1px solid #7f1d1d", |
| borderRadius: 6, |
| padding: "7px 14px", |
| cursor: "pointer", |
| fontSize: 13, |
| }, |
| toggle: { |
| position: "absolute", |
| right: 14, |
| top: -34, |
| background: "rgba(16,20,29,0.9)", |
| color: "#9fb3d9", |
| border: "1px solid #2b3850", |
| borderRadius: 6, |
| padding: "4px 10px", |
| cursor: "pointer", |
| fontSize: 12, |
| }, |
| }; |
|
|
| |
| |
| |
| function LossChart({ metrics, width, height }) { |
| const pad = { l: 46, r: 10, t: 8, b: 16 }; |
| const w = Math.max(160, width); |
| const h = height; |
| const logs = metrics.map((m) => Math.log10(Math.max(m.loss, 1e-9))); |
| const held = metrics |
| .map((m, i) => |
| m.held_out == null ? null : [i, Math.log10(Math.max(m.held_out, 1e-9))], |
| ) |
| .filter(Boolean); |
| const all = logs.concat(held.map((d) => d[1])); |
| const lo = Math.min(...all); |
| const hi = Math.max(...all, lo + 1e-6); |
| const x = (i) => |
| pad.l + (i * (w - pad.l - pad.r)) / Math.max(metrics.length - 1, 1); |
| const y = (v) => pad.t + ((hi - v) * (h - pad.t - pad.b)) / (hi - lo); |
|
|
| const line = logs.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); |
| const area = `${pad.l},${y(lo)} ${line} ${x(logs.length - 1)},${y(lo)}`; |
| const heldLine = held |
| .map(([i, v]) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`) |
| .join(" "); |
| const ticks = [hi, (hi + lo) / 2, lo]; |
|
|
| return ( |
| <svg width={w} height={h} style={{ display: "block" }}> |
| <defs> |
| <linearGradient id="td-loss" x1="0" y1="0" x2="0" y2="1"> |
| <stop offset="0%" stopColor="#e8963a" stopOpacity="0.34" /> |
| <stop offset="100%" stopColor="#e8963a" stopOpacity="0.02" /> |
| </linearGradient> |
| </defs> |
| {ticks.map((v, i) => ( |
| <g key={i}> |
| <line |
| x1={pad.l} |
| x2={w - pad.r} |
| y1={y(v)} |
| y2={y(v)} |
| stroke="#1c2437" |
| strokeWidth="1" |
| /> |
| <text x={4} y={y(v) + 3.5} fill="#5d6b84" fontSize="9.5"> |
| {(10 ** v).toExponential(0)} |
| </text> |
| </g> |
| ))} |
| <polygon points={area} fill="url(#td-loss)" /> |
| <polyline points={line} fill="none" stroke="#e8963a" strokeWidth="1.8" /> |
| {held.length > 1 && ( |
| <polyline |
| points={heldLine} |
| fill="none" |
| stroke="#7aa2f7" |
| strokeWidth="1.5" |
| strokeDasharray="4 3" |
| /> |
| )} |
| <text x={pad.l} y={h - 3} fill="#5d6b84" fontSize="9.5"> |
| step 0 |
| </text> |
| <text x={w - pad.r} y={h - 3} fill="#5d6b84" fontSize="9.5" textAnchor="end"> |
| loss (log) |
| </text> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| function useElementWidth() { |
| const ref = useRef(null); |
| const [width, setWidth] = useState(320); |
| useEffect(() => { |
| const el = ref.current; |
| if (!el || typeof ResizeObserver === "undefined") return undefined; |
| const ro = new ResizeObserver(([entry]) => { |
| setWidth(Math.max(160, Math.floor(entry.contentRect.width))); |
| }); |
| ro.observe(el); |
| return () => ro.disconnect(); |
| }, []); |
| return [ref, width]; |
| } |
|
|
|
|
| function Stat({ label, value, color }) { |
| return ( |
| <div style={S.stat}> |
| <div style={S.statLabel}>{label}</div> |
| <div style={{ ...S.statValue, color: color ?? "#d6dbe4" }}>{value}</div> |
| </div> |
| ); |
| } |
|
|
| export default function Analytics({ live, parsed, open, onToggle, stepRate }) { |
| const [sendError, setSendError] = useState(null); |
| const [chartRef, chartWidth] = useElementWidth(); |
| const post = useCallback( |
| (action) => { |
| setSendError(null); |
| fetch(`${live.control}/control`, { |
| method: "POST", |
| headers: { "Content-Type": "text/plain" }, |
| body: JSON.stringify({ action }), |
| }).catch(() => |
| setSendError("control server unreachable — is the training script running?"), |
| ); |
| }, |
| [live], |
| ); |
|
|
| const toggle = ( |
| <button style={S.toggle} className="td-btn" onClick={onToggle}> |
| {open ? "▾ hide analytics" : "▴ analytics"} |
| </button> |
| ); |
|
|
| if (!open) return <div style={{ ...S.dock, padding: 0, background: "none", border: "none" }}>{toggle}</div>; |
|
|
| |
| |
| if (!live) { |
| const m = parsed.spec.model; |
| const cells = parsed.spec.lattice.cells; |
| return ( |
| <div style={S.dock}> |
| {toggle} |
| <Stat label="model" value={m.kind} /> |
| <Stat label="parameters" value={m.n_params.toLocaleString()} /> |
| <Stat label="layers" value={m.n_layers} /> |
| <Stat |
| label="cells" |
| value={`${cells.present}/${cells.total}`} |
| color={cells.dense ? "#d6dbe4" : "#e0af68"} |
| /> |
| <div style={{ flex: 1 }} /> |
| <div style={{ alignSelf: "center", color: "#5d6b84", fontSize: 12.5 }}> |
| no run attached — start one with{" "} |
| <code style={{ color: "#8b95a8" }}>examples/viewer_live.py</code> and |
| metrics stream in here |
| </div> |
| </div> |
| ); |
| } |
|
|
| const st = STATUS[live.status] ?? STATUS.stopped; |
| const metrics = live.metrics ?? []; |
| const last = metrics.length ? metrics[metrics.length - 1] : null; |
| const lastHeld = [...metrics].reverse().find((e) => e.held_out != null); |
| const pct = live.total_steps && last ? ((last.step + 1) / live.total_steps) * 100 : 0; |
|
|
| return ( |
| <div style={S.dock}> |
| {toggle} |
| <div style={{ minWidth: 168 }}> |
| <div style={S.statLabel}> |
| <span className={live.status === "training" ? "td-live" : undefined}> |
| ● </span> |
| {st.label} |
| </div> |
| <div style={{ ...S.statValue, color: st.color, fontSize: 15 }}> |
| {last ? `${last.step + 1} / ${live.total_steps}` : "—"} |
| </div> |
| <div |
| style={{ |
| height: 5, |
| borderRadius: 99, |
| background: "#0c1018", |
| border: "1px solid #232d40", |
| overflow: "hidden", |
| marginTop: 6, |
| }} |
| > |
| <div |
| style={{ |
| height: "100%", |
| width: `${pct}%`, |
| background: st.color, |
| transition: "width 0.4s", |
| }} |
| /> |
| </div> |
| <div style={{ marginTop: 8, display: "flex", gap: 6, flexWrap: "wrap" }}> |
| <span style={S.chip}>{live.device}</span> |
| {live.task && ( |
| <span style={{ ...S.chip, background: "#1b2437" }}>{live.task}</span> |
| )} |
| </div> |
| </div> |
| |
| <Stat |
| label="train loss" |
| value={last ? last.loss.toFixed(5) : "—"} |
| color="#e8963a" |
| /> |
| <Stat |
| label="held-out" |
| value={lastHeld ? lastHeld.held_out.toFixed(5) : "—"} |
| color="#7aa2f7" |
| /> |
| {/* Throughput, and the clock the sweep animation runs on: one pass over |
| every layer is one training step, so the wavefront moves at the rate |
| the model is actually training. */} |
| <Stat |
| label="steps/s" |
| value={stepRate ? stepRate.toFixed(1) : "—"} |
| color="#9ece6a" |
| /> |
| |
| <div ref={chartRef} style={{ flex: 1, minWidth: 0 }}> |
| {metrics.length > 1 ? ( |
| <LossChart metrics={metrics} width={chartWidth} height={92} /> |
| ) : ( |
| <div style={{ color: "#5d6b84", fontSize: 12.5, paddingTop: 30 }}> |
| waiting for the first steps… |
| </div> |
| )} |
| </div> |
| |
| <div |
| style={{ |
| display: "flex", |
| flexDirection: "column", |
| gap: 7, |
| justifyContent: "center", |
| }} |
| > |
| {live.status === "waiting" && ( |
| <button style={S.btnStart} className="td-btn" onClick={() => post("start")}> |
| ▶ start training |
| </button> |
| )} |
| {(live.status === "training" || live.status === "paused") && ( |
| <> |
| <button |
| style={S.btn} |
| className="td-btn" |
| onClick={() => post(live.status === "training" ? "pause" : "resume")} |
| > |
| {live.status === "training" ? "⏸ pause" : "▶ resume"} |
| </button> |
| <button style={S.btnStop} className="td-btn" onClick={() => post("stop")}> |
| ■ stop |
| </button> |
| </> |
| )} |
| {sendError && ( |
| <div style={{ color: "#fca5a5", fontSize: 11, maxWidth: 150 }}> |
| {sendError} |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|