Files
Robot_Arm/ml/monitor.html
T

810 lines
74 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Transformer 多峰策略端到端训练与浏览器部署 · 训练监控</title>
<style>
:root {
--bg: #0b0f17; --bg2: #0f1521; --panel: #131a27; --panel2: #18212f; --line: #222c3c; --line2: #2c3850;
--text: #e8edf5; --dim: #8c99ad; --dim2: #5d6a7e;
--accent: #5aa9ff; --accent2: #7c6cff; --ok: #34d399; --warn: #fbbf24; --bad: #f87171;
--c1: #5aa9ff; --c2: #fbbf24; --c3: #34d399; --c4: #f87171; --c5: #a78bfa; --c6: #22d3ee; --c7: #fb923c;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--r: 14px;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body { background: radial-gradient(1200px 600px at 10% -10%, #16223a 0%, transparent 60%), radial-gradient(900px 500px at 100% 0%, #1c1838 0%, transparent 55%), var(--bg);
background-attachment: fixed; color: var(--text); font: 14px/1.5 -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; min-height: 100vh; }
/* ---------- 顶栏 ---------- */
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 12px 24px;
background: rgba(11,15,23,.78); backdrop-filter: blur(12px); border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: center; gap: 10px; }
.logo { width: 30px; height: 30px; border-radius: 9px; background: linear-gradient(135deg, var(--accent), var(--accent2)); display: grid; place-items: center; box-shadow: 0 4px 18px #5aa9ff44; }
.logo svg { width: 18px; height: 18px; }
.brand h1 { font-size: 15px; margin: 0; font-weight: 650; letter-spacing: .3px; }
.brand .sub { font-size: 12px; color: var(--dim); margin-top: -2px; }
header .sp { flex: 1; }
.ctl { display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 13px; }
select, button { background: var(--panel2); color: var(--text); border: 1px solid var(--line2); border-radius: 9px; padding: 6px 10px; font: inherit; font-size: 13px; outline: none; }
select:hover, button:hover { border-color: #3d4c68; }
button { cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
button.on { border-color: var(--accent); color: var(--accent); background: #5aa9ff14; }
input[type=range] { accent-color: var(--accent); width: 90px; }
.pill { display: inline-flex; align-items: center; gap: 7px; padding: 4px 11px; border-radius: 99px; font-size: 12px; border: 1px solid var(--line2); color: var(--dim); background: var(--panel); }
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dim2); }
.pill.live { color: var(--ok); border-color: #34d39955; } .pill.live .dot { background: var(--ok); box-shadow: 0 0 0 0 #34d399aa; animation: pulse 1.6s infinite; }
.pill.stale { color: var(--warn); border-color: #fbbf2455; } .pill.stale .dot { background: var(--warn); }
.pill.dead { color: var(--bad); border-color: #f8717155; } .pill.dead .dot { background: var(--bad); }
.pill.demo { color: var(--c5); border-color: #a78bfa55; } .pill.demo .dot { background: var(--c5); }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 #34d39999; } 70% { box-shadow: 0 0 0 7px #34d39900; } 100% { box-shadow: 0 0 0 0 #34d39900; } }
main { padding: 20px 24px 48px; max-width: 1680px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
.card { background: linear-gradient(180deg, #151d2b 0%, #121926 100%); border: 1px solid var(--line); border-radius: var(--r); padding: 16px 18px; min-width: 0; box-shadow: 0 1px 0 #ffffff05 inset, 0 8px 24px #00000030; }
.card h2 { font-size: 12.5px; font-weight: 600; color: var(--dim); margin: 0 0 12px; display: flex; justify-content: space-between; align-items: center; letter-spacing: .4px; }
.card h2 .r { font-weight: 400; color: var(--dim2); font-size: 12px; letter-spacing: 0; }
/* ---------- 流水线阶段 ---------- */
.steps { display: flex; gap: 0; overflow-x: auto; padding: 4px 2px 2px; scrollbar-width: thin; }
.step { flex: 1 0 96px; display: flex; flex-direction: column; align-items: center; gap: 6px; position: relative; min-width: 96px; }
.step::before { content: ""; position: absolute; top: 14px; left: -50%; width: 100%; height: 2px; background: var(--line2); z-index: 0; }
.step:first-child::before { display: none; }
.step.done::before, .step.active::before { background: linear-gradient(90deg, var(--ok), var(--ok)); }
.step .ic { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font: 600 12px var(--mono); background: var(--panel2); border: 2px solid var(--line2); color: var(--dim); z-index: 1; }
.step.done .ic { background: #34d39922; border-color: var(--ok); color: var(--ok); }
.step.active .ic { background: #5aa9ff22; border-color: var(--accent); color: var(--accent); box-shadow: 0 0 0 4px #5aa9ff1f; }
.step.fail .ic { background: #f8717122; border-color: var(--bad); color: var(--bad); }
.step .nm { font-size: 12.5px; color: var(--text); white-space: nowrap; } .step .ds { font-size: 11px; color: var(--dim2); white-space: nowrap; }
.step.pending .nm { color: var(--dim); }
.step.active .ds { color: var(--accent); }
/* ---------- 进度主卡 + GPU ---------- */
.hero { display: grid; grid-template-columns: minmax(0, 2.2fr) minmax(300px, 1fr); gap: 16px; }
.prog-top { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.prog-top .ttl { font-size: 13px; color: var(--dim); } .prog-top .ttl b { color: var(--text); font-weight: 600; font-size: 16px; margin-right: 8px; }
.tag { display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 6px; background: #5aa9ff1c; color: var(--accent); border: 1px solid #5aa9ff33; vertical-align: 2px; }
.tag.apg { background: #a78bfa1c; color: var(--c5); border-color: #a78bfa33; } .tag.bench { background: #fbbf241c; color: var(--warn); border-color: #fbbf2433; }
.bigpct { font: 700 44px/1 var(--mono); letter-spacing: -1px; background: linear-gradient(90deg, #fff, #b9d7ff); -webkit-background-clip: text; background-clip: text; color: transparent; }
.bigpct small { font-size: 18px; margin-left: 2px; }
.bar { position: relative; height: 12px; background: #0d131d; border: 1px solid var(--line); border-radius: 99px; overflow: visible; margin: 16px 0 10px; }
.bar .fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--accent), var(--c6)); box-shadow: 0 0 14px #5aa9ff66; transition: width .8s ease; position: relative; }
.bar .fill::after { content: ""; position: absolute; inset: 0; border-radius: 99px; background: repeating-linear-gradient(45deg, #ffffff14 0 8px, transparent 8px 16px); animation: move 1.2s linear infinite; }
.bar.idle .fill::after { animation: none; display: none; }
@keyframes move { from { background-position: 0 0; } to { background-position: 22px 0; } }
.bar .mk { position: absolute; top: -4px; width: 2px; height: 20px; border-radius: 1px; background: #ffffff30; }
.bar .mk.best { background: var(--ok); }
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-top: 14px; }
.fact { background: #0f1520; border: 1px solid var(--line); border-radius: 10px; padding: 9px 12px; }
.fact .k { font-size: 11.5px; color: var(--dim); } .fact .v { font: 600 15px/1.35 var(--mono); margin-top: 2px; white-space: nowrap; }
.gpuwrap { display: flex; gap: 16px; align-items: center; }
.ring { position: relative; width: 116px; height: 116px; flex: none; }
.ring svg { transform: rotate(-90deg); }
.ring .c { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; }
.ring .c b { font: 700 24px/1 var(--mono); display: block; } .ring .c span { font-size: 11px; color: var(--dim); }
.gpurows { flex: 1; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.gr .l { display: flex; justify-content: space-between; font-size: 12px; color: var(--dim); margin-bottom: 4px; } .gr .l b { color: var(--text); font: 500 12.5px var(--mono); }
.mini { height: 6px; background: #0d131d; border-radius: 99px; overflow: hidden; } .mini > div { height: 100%; border-radius: 99px; transition: width .6s; }
.gname { font-size: 12px; color: var(--dim2); margin-top: 12px; }
/* ---------- 指标卡 ---------- */
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
.kpi { position: relative; overflow: hidden; padding-bottom: 12px; }
.kpi .v { font: 650 26px/1.15 var(--mono); letter-spacing: -.5px; } .kpi .v small { font-size: 13px; color: var(--dim); margin-left: 4px; font-weight: 500; }
.kpi .s { color: var(--dim); font-size: 12px; margin-top: 3px; } .kpi canvas { position: absolute; right: 0; bottom: 0; width: 55%; height: 46px; opacity: .9; }
.delta { font: 500 12px var(--mono); margin-left: 6px; }
/* ---------- 图表 ---------- */
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(520px, 1fr)); gap: 16px; }
.chart canvas.plot { width: 100%; height: 260px; display: block; cursor: crosshair; }
.legend { display: flex; flex-wrap: wrap; gap: 6px 8px; margin-top: 10px; font-size: 12px; color: var(--dim); }
.legend span { cursor: pointer; user-select: none; padding: 2px 9px; border-radius: 99px; border: 1px solid var(--line); background: #0f1520; display: inline-flex; align-items: center; gap: 6px; }
.legend span b { font: 500 11.5px var(--mono); color: var(--text); }
.legend span.off { opacity: .4; } .legend i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
/* ---------- 底部面板 ---------- */
.side { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 7px 6px; border-bottom: 1px solid #1d2635; white-space: nowrap; }
tr:last-child td { border-bottom: none; }
th { color: var(--dim2); font-weight: 500; font-size: 12px; } td.n { font-family: var(--mono); text-align: right; } th.n { text-align: right; }
.ok { color: var(--ok); } .bad { color: var(--bad); } .warn { color: var(--warn); } .dim { color: var(--dim); }
.chip { display: inline-block; font-size: 11.5px; padding: 1px 8px; border-radius: 99px; border: 1px solid; }
.chip.ok { border-color: #34d39955; background: #34d39914; } .chip.bad { border-color: #f8717155; background: #f8717114; } .chip.dim { border-color: var(--line2); }
.chip.warn { border-color: #fbbf2455; background: #fbbf2414; }
.empty { display: flex; align-items: center; gap: 10px; color: var(--dim2); font-size: 13px; padding: 18px 4px; }
.empty svg { width: 18px; height: 18px; opacity: .6; }
.accbanner { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; margin-bottom: 8px; font-weight: 600; }
.accbanner.ok { background: #34d39914; color: var(--ok); border: 1px solid #34d39944; } .accbanner.bad { background: #f8717114; color: var(--bad); border: 1px solid #f8717144; }
.tip { position: fixed; pointer-events: none; background: #0a0e15f2; border: 1px solid var(--line2); border-radius: 10px; padding: 8px 10px; font: 12px/1.6 var(--mono); display: none; z-index: 30; white-space: nowrap; box-shadow: 0 10px 30px #0008; }
.tip .h { color: var(--dim); margin-bottom: 2px; }
footer { color: var(--dim2); font-size: 12px; text-align: center; padding-top: 6px; }
/* ---------- 控制:开始/停止、对话框、终端 ---------- */
button.primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); border: none; color: #fff; font-weight: 600; padding: 7px 16px; box-shadow: 0 4px 16px #5aa9ff40; }
button.primary:hover { filter: brightness(1.08); } button.primary:disabled { opacity: .45; cursor: not-allowed; filter: none; }
button.danger { color: var(--bad); border-color: #f8717166; } button.danger:hover { background: #f8717114; }
.overall { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.overall .now { font-size: 13px; color: var(--dim); } .overall .now b { display: block; color: var(--text); font-size: 18px; font-weight: 650; margin: 2px 0 2px; }
.sublabel { font-size: 12.5px; color: var(--dim); display: flex; justify-content: space-between; margin-top: 4px; }
.bar.thin { height: 8px; margin: 8px 0 4px; } .bar.ind .fill { width: 30% !important; animation: ind 1.6s ease-in-out infinite; }
@keyframes ind { 0% { margin-left: 0; } 50% { margin-left: 70%; } 100% { margin-left: 0; } }
.ctlrow { display: flex; gap: 10px; margin-top: 14px; align-items: center; flex-wrap: wrap; }
.ctlrow .hint { font-size: 12px; color: var(--dim2); }
.term { background: #070a10; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; height: 260px; overflow: auto; font: 12px/1.55 var(--mono); color: #c9d4e3; white-space: pre-wrap; word-break: break-all; }
.term .e { color: var(--bad); } .term .c { color: var(--accent); } .term .g { color: var(--ok); }
.modal { position: fixed; inset: 0; background: #0009; backdrop-filter: blur(3px); display: none; align-items: center; justify-content: center; z-index: 40; }
.modal.show { display: flex; }
.dlg { width: min(560px, 92vw); background: #131a27; border: 1px solid var(--line2); border-radius: 16px; padding: 20px 22px; box-shadow: 0 30px 80px #000a; }
.dlg h3 { margin: 0 0 4px; font-size: 16px; } .dlg .d { color: var(--dim); font-size: 12.5px; margin-bottom: 14px; }
.form { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 14px; }
.form label { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--dim); }
.form input, .form select { background: #0f1520; color: var(--text); border: 1px solid var(--line2); border-radius: 8px; padding: 7px 9px; font: 13px var(--mono); }
.form .full { grid-column: 1 / -1; }
.ckline { margin-top: 12px; font-size: 12px; color: var(--dim); } .ckline.bad { color: var(--bad); } .ckline .warn { color: var(--warn); }
.demobar { background: #fbbf2418; border: 1px solid #fbbf2455; color: var(--warn); border-radius: 10px; padding: 8px 12px; margin: 10px 0; font-size: 13px; }
.dlg .acts { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
.dlg .warnbox { margin-top: 12px; font-size: 12px; color: var(--warn); background: #fbbf2410; border: 1px solid #fbbf2433; border-radius: 8px; padding: 8px 10px; display: none; }
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: #1b2433; border: 1px solid var(--line2); padding: 9px 16px; border-radius: 10px; font-size: 13px; z-index: 50; display: none; box-shadow: 0 10px 30px #0008; }
@media (max-width: 900px) { .hero { grid-template-columns: 1fr; } .charts { grid-template-columns: 1fr; } main { padding: 14px; } }
</style>
</head>
<body>
<header>
<div class="brand">
<div class="logo"><svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h6"/><path d="M7 20v-5l5-6"/><path d="M12 9l5 2"/><circle cx="12" cy="9" r="1.6"/><path d="M17 11l2-3"/></svg></div>
<div><h1>Transformer 多峰策略端到端训练与浏览器部署</h1><div class="sub">机械臂避障策略 · 训练监控</div></div>
</div>
<select id="run" title="选择运行(ml/runs/*)"></select>
<select id="scenario" title="示例场景" style="display:none"><option value="train">示例:预训练进行中</option><option value="gen">示例:生成数据中</option><option value="late">示例:挖错第 3 轮</option><option value="idle">示例:还没开始</option></select>
<span id="state" class="pill"><span class="dot"></span><span>连接中</span></span>
<span class="sp"></span>
<label class="ctl">平滑 <input id="smooth" type="range" min="0" max="0.99" step="0.01" value="0.85"><span id="smoothv" style="font-family:var(--mono);width:30px">0.85</span></label>
<button id="logy" title="损失图纵轴取对数">对数轴</button>
<button id="auto" class="on" title="每 5 秒刷新"><span>●</span>自动刷新</button>
<button id="btnStart" class="primary" title="一键运行流水线">▶ 开始训练</button>
<button id="btnStop" class="danger" style="display:none" title="停止正在运行的流水线">■ 停止</button>
</header>
<main>
<section class="card"><h2><span>流水线</span><span class="r" id="stepnote"></span></h2><div class="steps" id="steps"></div></section>
<section class="hero">
<div class="card" id="pipe"></div>
<div class="card"><h2><span>GPU</span><span class="r" id="gputime"></span></h2><div id="gpu"></div></div>
</section>
<section class="card" id="prog"></section>
<section class="kpis" id="kpis"></section>
<section class="charts" id="charts"></section>
<section class="side">
<div class="card"><h2><span>数据阶段</span><span class="r">ml/data</span></h2><div id="data"></div></div>
<div class="card"><h2><span>M5 头门禁</span><span class="r">纯策略穿柱率 ≤ 2%</span></h2><div id="m5"></div></div>
<div class="card"><h2><span>挖错循环</span><span class="r">ml/runs/mine</span></h2><div id="mine"></div></div>
<div class="card"><h2><span>部署形态评测</span><span class="r">策略 + 安全层</span></h2><div id="bench"></div></div>
<div class="card"><h2><span>验收汇总</span><span class="r">方案 §9.2</span></h2><div id="accept"></div></div>
</section>
<section class="card"><h2><span>终端输出</span><span class="r" id="termnote">ml/runs/console.log</span></h2><div class="term" id="term"></div></section>
<footer id="foot"></footer>
</main>
<div class="tip" id="tip"></div>
<div class="toast" id="toast"></div>
<div class="modal" id="modal"><div class="dlg">
<h3>开始训练</h3>
<div class="d">后台执行 <code>bash ml/pipeline.sh all</code>,从对拍一路跑到验收。已完成的步骤自动跳过;训练每 10 分钟存档、生成按条续写,断了再点一次就从断点接着跑。网页和控制台窗口都可以关,训练不受影响。</div>
<div class="form">
<label>运行方式<select id="fMode"><option value="resume">续跑:跳过已完成的步骤</option><option value="fresh">从头重跑(旧产物移到 ml/archive/)</option></select></label>
<label>动作头 CFG<select id="fCfg"><option value="ml/configs/base.json">MeanFlow(base,M5 不过自动转 WTA)</option><option value="ml/configs/wta.json">WTA(wta)</option><option value="ml/configs/small.json">小模型 d=128(R4 不过时用)</option></select></label>
<label>共识过滤 CONSENSUS<select id="fCons"><option value="">按老师门禁自动</option><option value="0">关</option><option value="1">开(生成约慢 5 倍)</option></select></label>
<label>任务数 N_TASKS<input id="fN" value="50000"></label>
<label>生成进程数 NPROC<input id="fP" placeholder="默认 = CPU 核数"></label>
<label>目标样本 TARGET_SAMPLES<input id="fT" value="1700000"></label>
<label>挖错轮数 ROUNDS<input id="fR" value="6"></label>
<label>老师间隙下限 MIN_GAP_MM<input id="fG" value="3"></label>
</div>
<div class="warnbox" id="fWarn"></div>
<div class="acts"><button id="fCancel">取消</button><button id="fGo" class="primary">▶ 开始</button></div>
</div></div>
<script>
'use strict';
/* =====================================================================
数据层:python -m ml.monitor 提供 /api/runs、/api/log、/api/status。
直接双击打开本文件、地址带 ?demo=1,或 FORCE_DEMO=true 时用内置示例数据(每次刷新模拟训练推进)。
2026-10-18 新增(用户要求:训练中随时可视化查看进度)。
===================================================================== */
const FORCE_DEMO = false;
const DEMO = FORCE_DEMO || new URLSearchParams(location.search).has('demo') || location.protocol === 'file:';
const $ = id => document.getElementById(id);
const isNum = v => typeof v === 'number' && isFinite(v);
const fmt = (v, d = 3) => !isNum(v) ? '—' : (Math.abs(v) >= 1000 ? Math.round(v).toLocaleString() : Math.abs(v) >= 100 ? v.toFixed(1) : Math.abs(v) >= 1 ? v.toFixed(Math.min(d, 3)) : v === 0 ? '0' : v.toPrecision(3));
const pct = (v, d = 1) => !isNum(v) ? '—' : (100 * v).toFixed(d) + '%';
const hms = s => { if (!isNum(s)) return '—'; s = Math.max(0, s); const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? `${h} 小时 ${m} 分` : m ? `${m} 分 ${Math.floor(s % 60)} 秒` : `${Math.floor(s)} 秒`; };
const hmsShort = s => !isNum(s) ? '—' : s >= 3600 ? `${(s / 3600).toFixed(1)} h` : s >= 60 ? `${Math.round(s / 60)} 分` : `${Math.round(s)} 秒`;
const ago = t => { const s = Date.now() / 1000 - t; return s < 60 ? `${Math.max(0, Math.floor(s))} 秒前` : s < 3600 ? `${Math.floor(s / 60)} 分钟前` : `${(s / 3600).toFixed(1)} 小时前`; };
const ICON_WAIT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
const empty = t => `<div class="empty">${ICON_WAIT}<span>${t}</span></div>`;
const KIND = { train: ['预训练', ''], apg: ['APG 微调', 'apg'], bench: ['测速', 'bench'] };
const api = DEMO ? demoApi() : async (path) => { const r = await fetch(path, { cache: 'no-store' }); if (!r.ok) throw new Error(r.status); return r.json(); };
if (DEMO) $('scenario').style.display = '';
/* ===================== 图表:零依赖 canvas ===================== */
const css = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const COLORS = ['--c1', '--c2', '--c3', '--c4', '--c5', '--c6', '--c7'].map(css);
const tip = $('tip');
const hexA = (hex, a) => { const n = parseInt(hex.slice(1), 16); return `rgba(${n >> 16},${n >> 8 & 255},${n & 255},${a})`; };
class Chart {
constructor(parent, title, opts = {}) {
this.o = opts; this.hidden = new Set(opts.hidden || []);
const card = document.createElement('div'); card.className = 'card chart';
card.innerHTML = `<h2><span>${title}</span><span class="r">${opts.note || ''}</span></h2><canvas class="plot"></canvas><div class="legend"></div>`;
parent.appendChild(card);
this.cv = card.querySelector('canvas'); this.lg = card.querySelector('.legend'); this.series = [];
this.cv.addEventListener('mousemove', e => this.hover(e));
this.cv.addEventListener('mouseleave', () => { tip.style.display = 'none'; this.hx = null; this.draw(); });
new ResizeObserver(() => this.draw()).observe(this.cv);
}
set(series) {
this.series = series.map((s, i) => ({ ...s, color: s.color || COLORS[i % COLORS.length] }));
const last = s => { for (let i = s.pts.length - 1; i >= 0; i--) if (isNum(s.pts[i][1])) return s.pts[i][1]; return null; };
this.lg.innerHTML = this.series.filter(s => !s.noLegend).map(s => `<span data-k="${s.name}" class="${this.hidden.has(s.name) ? 'off' : ''}"><i style="background:${s.color}"></i>${s.label || s.name}<b>${this.o.percent ? pct(last(s)) : fmt(last(s))}</b></span>`).join('');
this.lg.querySelectorAll('span').forEach(el => el.onclick = () => { const k = el.dataset.k; this.hidden.has(k) ? this.hidden.delete(k) : this.hidden.add(k); el.classList.toggle('off'); this.draw(); });
this.draw();
}
vis() { return this.series.filter(s => !this.hidden.has(s.name) && s.pts.length); }
draw() {
const cv = this.cv, dpr = devicePixelRatio || 1, W = cv.clientWidth, H = cv.clientHeight;
if (!W) return;
cv.width = W * dpr; cv.height = H * dpr;
const g = cv.getContext('2d'); g.scale(dpr, dpr); g.clearRect(0, 0, W, H);
const S = this.vis(), L = 54, R = 14, T = 10, B = 24;
g.font = '11px ui-monospace, Menlo, Consolas, monospace';
if (!S.length) { g.fillStyle = css('--dim2'); g.textAlign = 'center'; g.fillText(this.o.emptyText || '暂无数据', W / 2, H / 2); this.map = null; return; }
const logy = this.o.logy && this.o.logy();
const ty = v => logy ? Math.log10(Math.max(v, 1e-12)) : v;
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
for (const s of S) for (const [x, y] of [...s.pts, ...(s.raw || [])]) { if (!isNum(y)) continue; x0 = Math.min(x0, x); x1 = Math.max(x1, x); if (s.noScale) continue; const v = ty(y); y0 = Math.min(y0, v); y1 = Math.max(y1, v); }
if (this.o.xmax && this.o.xmax()) x1 = Math.max(x1, this.o.xmax());
if (this.o.xmin0) x0 = Math.min(0, x0);
if (this.o.range) [y0, y1] = this.o.range;
if (this.o.zero && !logy) y0 = Math.min(0, y0);
if (!isFinite(y0)) { y0 = 0; y1 = 1; }
if (y1 - y0 < 1e-12) { y1 += 0.5; y0 -= 0.5; }
if (!this.o.range) { const pad = (y1 - y0) * 0.08; y1 += pad; if (!(this.o.zero && !logy && y0 === 0)) y0 -= pad; }
if (x1 === x0) x1 = x0 + 1;
const X = x => L + (x - x0) / (x1 - x0) * (W - L - R), Y = v => T + (1 - (v - y0) / (y1 - y0)) * (H - T - B);
this.map = { X, Y, x0, x1, L, R, W, ty };
// 网格与刻度
g.strokeStyle = '#1e2838'; g.lineWidth = 1; g.fillStyle = css('--dim2'); g.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const v = y0 + (y1 - y0) * i / 4, y = Math.round(Y(v)) + .5;
g.beginPath(); g.moveTo(L, y); g.lineTo(W - R, y); g.stroke();
g.fillText(logy ? fmt(10 ** v) : this.o.percent ? (100 * v).toFixed(0) + '%' : fmt(v), L - 8, y + 4);
}
g.textAlign = 'center';
for (let i = 0; i <= 5; i++) { const x = x0 + (x1 - x0) * i / 5; const lab = x >= 1000 ? (x / 1000).toFixed(x1 - x0 < 5000 ? 1 : 0) + 'k' : x.toFixed(0); g.fillText(lab, Math.max(L + 10, Math.min(X(x), W - R - 12)), H - 6); }
// 未到达区域(总步数之后)淡色
const lastX = Math.max(...S.map(s => s.pts.length ? s.pts[s.pts.length - 1][0] : -Infinity));
if (this.o.xmax && lastX < x1) { g.fillStyle = 'rgba(255,255,255,0.018)'; g.fillRect(X(lastX), T, X(x1) - X(lastX), H - T - B); }
// 参考线
for (const ref of this.o.refs || []) {
const y = Y(ty(ref.y)); g.strokeStyle = hexA(ref.color, .7); g.setLineDash([4, 4]); g.beginPath(); g.moveTo(L, y); g.lineTo(W - R, y); g.stroke(); g.setLineDash([]);
g.fillStyle = ref.color; g.textAlign = 'left'; g.fillText(ref.label, L + 6, y - 5);
}
const path = (pts) => { g.beginPath(); let f = true; for (const [x, y] of pts) { if (!isNum(y)) { f = true; continue; } const px = X(x), py = Y(ty(y)); f ? g.moveTo(px, py) : g.lineTo(px, py); f = false; } };
for (const s of S) {
if (s.raw) { path(s.raw); g.strokeStyle = hexA(s.color, .22); g.lineWidth = 1; g.stroke(); }
if (s.area) {
path(s.pts); const pts = s.pts.filter(p => isNum(p[1]));
if (pts.length > 1) {
g.lineTo(X(pts[pts.length - 1][0]), H - B); g.lineTo(X(pts[0][0]), H - B); g.closePath();
const gr = g.createLinearGradient(0, T, 0, H - B); gr.addColorStop(0, hexA(s.color, .28)); gr.addColorStop(1, hexA(s.color, 0)); g.fillStyle = gr; g.fill();
}
}
path(s.pts); g.strokeStyle = s.color; g.lineWidth = s.width || 2; g.lineJoin = 'round'; g.lineCap = 'round'; g.setLineDash(s.dash || []); g.stroke(); g.setLineDash([]);
if (s.dots) for (const [x, y] of s.pts) if (isNum(y)) { g.beginPath(); g.arc(X(x), Y(ty(y)), 3, 0, 7); g.fillStyle = '#0b0f17'; g.fill(); g.lineWidth = 1.8; g.strokeStyle = s.color; g.stroke(); }
if (s.best) { const [bx, by] = s.best; g.beginPath(); g.arc(X(bx), Y(ty(by)), 6, 0, 7); g.strokeStyle = s.color; g.lineWidth = 1.5; g.stroke(); g.fillStyle = s.color; g.textAlign = 'left'; g.fillText('最好', X(bx) + 9, Y(ty(by)) - 6); }
}
if (this.hx != null) { g.strokeStyle = '#8c99ad88'; g.setLineDash([3, 3]); g.beginPath(); g.moveTo(X(this.hx), T); g.lineTo(X(this.hx), H - B); g.stroke(); g.setLineDash([]);
for (const s of S) { const p = nearest(s.pts, this.hx); if (p && isNum(p[1])) { g.beginPath(); g.arc(X(p[0]), Y(ty(p[1])), 4, 0, 7); g.fillStyle = s.color; g.fill(); } } }
}
hover(e) {
if (!this.map) return;
const r = this.cv.getBoundingClientRect(), { x0, x1, L, R, W } = this.map;
const x = x0 + (e.clientX - r.left - L) / (W - L - R) * (x1 - x0);
const rows = []; let hx = null;
for (const s of this.vis()) { if (s.noLegend) continue; const p = nearest(s.pts, x); if (p) { hx = hx ?? p[0]; rows.push(`<span style="color:${s.color}">●</span> ${s.label || s.name} ${this.o.percent ? pct(p[1], 2) : fmt(p[1], 4)}`); } }
this.hx = hx; this.draw();
if (!rows.length) return;
tip.innerHTML = `<div class="h">第 ${hx.toLocaleString()} 步</div>` + rows.join('<br>'); tip.style.display = 'block';
tip.style.left = Math.min(e.clientX + 16, innerWidth - tip.offsetWidth - 10) + 'px'; tip.style.top = (e.clientY + 16) + 'px';
}
}
function nearest(pts, x) { let lo = 0, hi = pts.length - 1; if (hi < 0) return null; while (hi - lo > 1) { const m = (lo + hi) >> 1; pts[m][0] < x ? lo = m : hi = m; } return Math.abs(pts[lo][0] - x) <= Math.abs(pts[hi][0] - x) ? pts[lo] : pts[hi]; }
function spark(cv, pts, color) {
const dpr = devicePixelRatio || 1, W = cv.clientWidth, H = cv.clientHeight; if (!W || pts.length < 2) return;
cv.width = W * dpr; cv.height = H * dpr; const g = cv.getContext('2d'); g.scale(dpr, dpr);
const ys = pts.map(p => p[1]).filter(isNum); const lo = Math.min(...ys), hi = Math.max(...ys) || 1, x0 = pts[0][0], x1 = pts[pts.length - 1][0] || 1;
const X = x => (x - x0) / (x1 - x0 || 1) * W, Y = y => H - 4 - (y - lo) / ((hi - lo) || 1) * (H - 10);
g.beginPath(); pts.forEach(([x, y], i) => i ? g.lineTo(X(x), Y(y)) : g.moveTo(X(x), Y(y)));
g.strokeStyle = color; g.lineWidth = 1.6; g.stroke(); g.lineTo(W, H); g.lineTo(0, H); g.closePath();
const gr = g.createLinearGradient(0, 0, 0, H); gr.addColorStop(0, hexA(color, .25)); gr.addColorStop(1, hexA(color, 0)); g.fillStyle = gr; g.fill();
}
const thin = (pts, n = 400) => { if (pts.length <= n) return pts; const k = pts.length / n, o = []; for (let i = 0; i < n; i++) o.push(pts[Math.floor(i * k)]); o.push(pts[pts.length - 1]); return o; };
/* ===================== 状态 ===================== */
let cur = null, recs = [], evals = [], offset = 0, timer = null, charts = null, lastMtime = 0, lastStatus = null, runsCache = [];
let logyOn = false, job = null, userPicked = false, conOff = 0;
/* 阶段表:名称、粗估耗时(小时,作全流程百分比权重)、超时上限。
2026-10-18 第五轮(审查 N-14):真实模式下由 /api/status 的 stage_meta 下发(来源 ml/pstate.py STAGES,与超时同源);
下面这份只给示例数据用,数值与 pstate.py 一致。 */
let STAGE_META = [
['setup', '安装依赖', 0.2], ['kin', '对拍', 0.2], ['r4', '推理延迟', 0.2], ['gate1', '一致性门禁', 0.5], ['gen', '生成老师轨迹', 5.0],
['prep', '打包样本', 0.5], ['train', '预训练', 3.0], ['m5', '头门禁', 0.5], ['apg', 'APG 微调', 4.0], ['mine', '挖错循环', 9.0],
['export', '导出', 0.1], ['bench', '部署评测', 1.0], ['accept', '验收汇总', 0.5]];
let SM = Object.fromEntries(STAGE_META.map(([k, n, w]) => [k, { n, w, to: Math.max(1, 3 * w) }]));
function applyStageMeta(m) {
if (!m || !m.length) return;
STAGE_META = m.map(x => [x.id, x.name, x.est_h]); SM = Object.fromEntries(m.map(x => [x.id, { n: x.name, w: x.est_h, to: x.timeout_h }]));
}
/* 开始 / 停止请求带自定义头:跨站网页发不出这种请求(会触发 CORS 预检,控制台不应答),见 monitor.py 审查 N-8 */
const post = (path, body) => fetch(path, { method: 'POST', headers: { 'X-Console': '1', 'Content-Type': 'application/json' }, body: body || '{}' }).then(x => x.json()).catch(e => ({ error: String(e) }));
const smoothv = () => +$('smooth').value;
const ema = (pts, a) => { if (!a) return pts; let m = null; return pts.map(([x, y]) => { if (!isNum(y)) return [x, y]; m = m == null ? y : a * m + (1 - a) * y; return [x, m]; }); };
const xOf = r => r.step ?? r.it ?? 0;
function buildCharts(kind) {
const c = $('charts'); c.innerHTML = '';
const total = () => { const r = recs[recs.length - 1]; return r && r.total; };
if (kind === 'apg') {
charts = {
a: new Chart(c, '到达误差(mm,展开末)', { logy: () => logyOn, xmax: total, xmin0: true, note: '越低越好 · 浅色为原始值' }),
b: new Chart(c, '损失分量', { logy: () => logyOn, xmax: total, xmin0: true }),
e: new Chart(c, '闭环评估(val,部署节奏)', { percent: true, range: [0, 1], xmax: total, xmin0: true, note: '每 500 步', emptyText: '第一次评估在第 500 步' }),
t: new Chart(c, '展开长度与梯度', { xmax: total, xmin0: true, zero: true, note: '展开 60–150 次推理随批变化' }),
};
} else {
charts = {
a: new Chart(c, '总损失', { logy: () => logyOn, xmax: total, xmin0: true, note: '浅色为原始值' }),
b: new Chart(c, '损失分量', { logy: () => logyOn, xmax: total, xmin0: true, hidden: ['smooth'] }),
e: new Chart(c, '闭环评估(val,部署节奏,关安全层)', { percent: true, range: [0, 1], xmax: total, xmin0: true, note: kind === 'bench' ? '测速运行不评估' : '每 2000 步',
emptyText: kind === 'bench' ? '测速运行不做闭环评估' : '第一次评估在第 2000 步' }),
t: new Chart(c, '吞吐(样本/s)', { xmax: total, xmin0: true, zero: true, refs: [{ y: 5000, label: 'R10 通过线 5000', color: css('--bad') }] }),
};
}
}
function render() {
if (!cur || !charts) return;
const a = smoothv(), kind = cur.kind;
const raw = k => thin(recs.filter(r => isNum(r[k])).map(r => [xOf(r), r[k]]));
const S = (k, label, extra = {}) => ({ name: k, label: label || k, pts: thin(ema(recs.filter(r => isNum(r[k])).map(r => [xOf(r), r[k]]), a)), ...extra });
if (kind === 'apg') {
charts.a.set([S('err_mm_mean', '平均到达误差', { raw: raw('err_mm_mean'), area: true })]);
charts.b.set([S('tgt', '到达项'), S('col', '碰撞铰链'), S('bc', 'BC 锚定'), S('smooth', '平滑 /10', { pts: thin(ema(recs.filter(r => isNum(r.smooth)).map(r => [xOf(r), r.smooth / 10]), a)) })]);
charts.t.set([{ name: 'plans', label: '展开推理次数', pts: thin(recs.filter(r => r.plans).map(r => [xOf(r), r.plans])), width: 1.4 }, S('gnorm', '梯度范数 ×50', { pts: thin(ema(recs.filter(r => isNum(r.gnorm)).map(r => [xOf(r), r.gnorm * 50]), a)) })]);
} else {
charts.a.set([S('loss', '总损失', { raw: raw('loss'), area: true })]);
charts.b.set([S('mf', cur.name.includes('wta') ? 'WTA' : 'MeanFlow'), S('fk_cm', 'FK(cm)'), S('value', '价值头'), S('sub', '子目标头'), S('smooth', '平滑')]);
charts.t.set([S('samples_per_s', '样本/s', { area: true })]);
}
const E = (k, label, best) => { const pts = evals.filter(e => isNum(e[k])).map(e => [e.x, e[k]]); let b = null; if (best && pts.length) b = pts.reduce((m, p) => p[1] > m[1] ? p : m); return { name: k, label, dots: true, pts, best: b }; };
charts.e.set([E('success', '到达率', true), E('collision_rate', '碰撞率'), E('time_ok_1p3', '≤ 老师时长×1.3'), E('start_fail', '起步失败')]);
renderProgress(); renderKpis();
}
function renderProgress() {
const r = recs[recs.length - 1] || {}, kind = cur.kind;
const step = xOf(r), total = r.total, done = total ? Math.min(1, step / total) : null;
const t0 = recs.find(v => v.t)?.t, elapsed = r.t && t0 ? r.t - t0 : (isNum(r.min) ? r.min * 60 : null);
let eta = isNum(r.eta_h) ? r.eta_h * 3600 : null;
if (eta == null && elapsed && done) eta = elapsed / done * (1 - done);
const running = !DEMO ? (Date.now() / 1000 - lastMtime < 120) : cur.live;
const finished = done != null && done >= 0.999;
const bestE = evals.length ? evals.reduce((m, e) => (e.success ?? -1) > (m.success ?? -1) ? e : m) : null;
const marks = total ? evals.map(e => `<div class="mk ${bestE && e === bestE ? 'best' : ''}" style="left:${100 * e.x / total}%" title="评估 @${e.x}:到达 ${pct(e.success)}"></div>`).join('') : '';
const [kl, kc] = KIND[kind];
const fin = eta != null && running && !finished ? new Date(Date.now() + eta * 1000).toLocaleString('zh-CN', { hour12: false, month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—';
const fact = (k, v, cls = '') => `<div class="fact"><div class="k">${k}</div><div class="v ${cls}">${v}</div></div>`;
$('prog').innerHTML = `<h2><span>当前运行 · 训练曲线</span><span class="r">${finished ? '已完成' : running ? '运行中' : '未在运行'}</span></h2>
<div class="prog-top"><div class="ttl"><b>${cur.name}</b><span class="tag ${kc}">${kl}</span>${cur.diverged ? ' <span class="chip bad">已发散 · diverged.pt</span>' : ''}</div>
<div class="bigpct">${done != null ? (100 * done).toFixed(1) : '—'}<small>%</small></div></div>
<div class="bar ${running && !finished ? '' : 'idle'}"><div class="fill" style="width:${done != null ? 100 * done : 0}%"></div>${marks}</div>
<div class="facts">
${fact('步数', `${step.toLocaleString()}${total ? ' / ' + total.toLocaleString() : ''}`)}
${fact('已用时间', hms(elapsed))}
${fact('剩余时间', finished ? '—' : hms(eta))}
${fact('预计完成', fin)}
${kind === 'apg' ? fact('本批展开', r.plans ? `${r.plans} 次推理` : '—') : fact('学习率', isNum(r.lr) ? r.lr.toExponential(2) : '—')}
${fact('日志更新', lastMtime ? ago(lastMtime) : '—', running ? '' : 'warn')}
</div>`;
}
function renderKpis() {
const r = recs[recs.length - 1] || {}, e = evals[evals.length - 1] || {}, pe = evals[evals.length - 2] || {}, kind = cur.kind;
const best = evals.length ? Math.max(...evals.map(v => v.success ?? -1)) : null;
const dlt = (k, good = 1) => { if (!isNum(e[k]) || !isNum(pe[k])) return ''; const d = (e[k] - pe[k]) * 100; if (Math.abs(d) < 0.05) return ''; const up = d > 0; return `<span class="delta ${(up ? 1 : -1) * good > 0 ? 'ok' : 'bad'}">${up ? '▲' : '▼'} ${Math.abs(d).toFixed(1)}pp</span>`; };
const last = k => recs.filter(v => isNum(v[k])).slice(-300).map(v => [xOf(v), v[k]]);
const evs = k => evals.filter(v => isNum(v[k])).map(v => [v.x, v[k]]);
const tiles = [];
const tile = (label, v, s, cls, sp, color) => tiles.push({ html: `<div class="card kpi"><h2><span>${label}</span></h2><div class="v ${cls || ''}">${v}</div><div class="s">${s || '&nbsp;'}</div><canvas></canvas></div>`, sp, color });
if (kind === 'apg') {
tile('到达误差', `${fmt(r.err_mm_mean)}<small>mm</small>`, '展开末平均', '', last('err_mm_mean'), COLORS[0]);
tile('展开内到达比例', pct(r.reach_frac), '误差 < 1 mm 且姿态 < 0.5°', '', last('reach_frac'), COLORS[2]);
tile('闭环到达率', pct(e.success) + dlt('success'), `最好 ${pct(best)}`, '', evs('success'), COLORS[2]);
tile('碰撞率', pct(e.collision_rate, 2) + dlt('collision_rate', -1), '关安全层', e.collision_rate > 0.03 ? 'warn' : '', evs('collision_rate'), COLORS[3]);
tile('显存峰值', isNum(r.vram) ? `${r.vram.toFixed(2)}<small>GiB</small>` : '—', '预算 19–20 GB(整卡 24,桌面占 4–5)', r.vram > 17 ? 'bad' : r.vram > 14 ? 'warn' : '', last('vram'), COLORS[1]);
} else {
const sps = r.samples_per_s;
tile('总损失', fmt(r.loss, 4), `MF ${fmt(r.mf)} · FK ${fmt(r.fk_cm)} cm`, '', ema(last('loss'), 0.9), COLORS[0]);
tile('吞吐', sps ? `${(sps / 1000).toFixed(2)}<small>k 样本/s</small>` : '—', sps ? (sps >= 5000 ? 'R10 达标(≥ 5k/s)' : 'R10 未达标(< 5k/s)') : 'R10 通过线 5k/s', sps ? (sps >= 5000 ? 'ok' : 'bad') : '', last('samples_per_s'), COLORS[5]);
tile('闭环到达率', pct(e.success) + dlt('success'), evals.length ? `最好 ${pct(best)} · @${e.x.toLocaleString()} 步` : '尚未评估', '', evs('success'), COLORS[2]);
tile('碰撞率', pct(e.collision_rate, 2) + dlt('collision_rate', -1), '关安全层,部署另有护栏', e.collision_rate > 0.03 ? 'warn' : '', evs('collision_rate'), COLORS[3]);
tile('梯度范数', fmt(r.gnorm), '裁剪阈值 1.0', r.gnorm > 5 ? 'warn' : '', ema(last('gnorm'), 0.9), COLORS[4]);
tile('显存峰值', isNum(r.vram) ? `${r.vram.toFixed(2)}<small>GiB</small>` : '—', '预算 19–20 GB(整卡 24,桌面占 4–5)', r.vram > 17 ? 'bad' : r.vram > 14 ? 'warn' : '', last('vram'), COLORS[1]);
}
$('kpis').innerHTML = tiles.map(t => t.html).join('');
[...$('kpis').querySelectorAll('canvas')].forEach((cv, i) => spark(cv, tiles[i].sp, tiles[i].color));
}
/* ===================== 轮询 ===================== */
async function loadRuns() {
const runs = await api('/api/runs'); runsCache = runs;
const sel = $('run'), prev = sel.value;
sel.innerHTML = runs.map(r => `<option value="${r.name}">${r.name} · ${KIND[r.kind][0]}${r.diverged ? ' · 发散' : ''}</option>`).join('') || '<option value="">ml/runs 下还没有日志</option>';
const follow = lastStatus && lastStatus.pipeline && lastStatus.pipeline.progress && lastStatus.pipeline.progress.run;
if (!userPicked) sel.value = runs.some(r => r.name === follow) ? follow : (runs[0] ? runs[0].name : ''); // 自动跟随正在跑的那条
else if (runs.some(r => r.name === prev)) sel.value = prev;
const r = runs.find(v => v.name === sel.value) || runs[0];
if (r && (!cur || cur.name !== r.name)) { cur = { ...r }; recs = []; evals = []; offset = 0; buildCharts(r.kind); }
else if (r) Object.assign(cur, r);
if (!r) { cur = null; $('charts').innerHTML = ''; $('kpis').innerHTML = ''; $('prog').innerHTML = '<h2><span>当前运行 · 训练曲线</span></h2>' + empty('还没有训练日志:预训练开始后这里出现损失、吞吐和评估曲线。'); }
}
async function poll() {
try {
lastStatus = await api('/api/status'); applyStageMeta(lastStatus.stage_meta);
job = await api('/api/job').catch(() => null);
await loadRuns();
if (cur) {
const d = await api(`/api/log?run=${encodeURIComponent(cur.name)}&offset=${offset}`);
if (d.reset && offset) { recs = []; evals = []; }
for (const r of d.records || []) {
if (r.eval) { const e = r.eval; evals.push({ ...e, x: e.step ?? e.it ?? r.step ?? 0 }); } else recs.push(r);
}
offset = d.offset ?? offset; lastMtime = d.mtime || lastMtime; cur.live = d.live;
render();
}
const P = lastStatus.pipeline, busy = (job && job.running) || (P && P.current);
if (DEMO) { setPill('demo', '示例数据'); if (!$('demobar')) $('pipe').closest('.hero').insertAdjacentHTML('beforebegin', `<div class="demobar" id="demobar">这是示例数据,不是你的训练。${location.protocol === 'file:' ? '直接双击 monitor.html 只能看示例;' : ''}看真实训练请双击 ml/console.bat(或 python -m ml.monitor --open)。</div>`); }
else if (busy) setPill('live', `运行中 · ${P && P.current ? SM[P.current]?.n || P.current : '启动中'}`);
else if (cur && Date.now() / 1000 - lastMtime < 120) setPill('live', `运行中 · ${ago(lastMtime)}更新`);
else setPill('', '空闲');
renderStatus(lastStatus); renderPipe(lastStatus); renderJob();
await pollConsole();
} catch (err) {
setPill('dead', '连不上控制台(双击 ml/console.bat 或 python -m ml.monitor)'); console.error(err);
}
}
function setPill(cls, text) { const st = $('state'); st.className = 'pill ' + cls; st.lastElementChild.textContent = text; }
/* ===================== 流水线 / GPU / 各阶段面板 ===================== */
function stages(s) {
const P = s.pipeline;
if (P) { // 有流水线记录:以它为准
const plan = P.plan && P.plan.length ? P.plan : STAGE_META.map(x => x[0]).filter(k => k !== 'setup');
const keys = STAGE_META.map(x => x[0]).filter(k => plan.includes(k) || P.stages[k]);
return keys.map(k => {
const r = P.stages[k], nm = SM[k].n;
if (!r) return [nm, 'pending', '—'];
if (P.current === k) return [nm, 'active', P.progress && P.progress.frac != null ? pct(P.progress.frac, 0) : '进行中'];
if (r.rc === 0) return [nm, 'done', '用时 ' + hmsShort(r.end - r.start)];
if (r.rc === -15 || r.rc === 143) return [nm, 'fail', '已停止'];
if (r.rc != null) return [nm, 'fail', `失败(返回码 ${r.rc})`];
return [nm, 'fail', '中断'];
});
}
const D = s.data || {}, has = n => runsCache.find(r => r.name === n || r.name.startsWith(n));
const live = n => { const r = has(n); return r && Date.now() / 1000 - r.mtime < 120; };
const anyTrain = runsCache.find(r => r.kind === 'train');
const m5ok = s.m5 && s.m5.some(m => m.pass), m5fail = s.m5 && s.m5.length && !m5ok;
const mineN = (s.mine || []).length, mineDone = (s.mine || []).filter(m => m.gate && m.gate.accept != null).length;
const L = [
['对拍', D.kin_pass != null ? (D.kin_pass ? 'done' : 'fail') : (D.consistency != null ? 'done' : 'pending'), '—'],
['老师门禁', D.consistency != null ? 'done' : 'pending', D.consistency != null ? `一致率 ${pct(D.consistency, 0)}` : '—'],
['生成数据', D.stats ? 'done' : D.gen_tasks_written ? 'active' : 'pending', D.stats ? `${(D.stats.tasks / 1000).toFixed(1)}k 条` : D.gen_tasks_written ? `${D.gen_tasks_written.toLocaleString()} 条…` : '—'],
['打包样本', D.index ? 'done' : 'pending', D.index ? `${(D.index.train_samples / 1e6).toFixed(2)}M 样本` : '—'],
['预训练', s.m5 && s.m5.length ? 'done' : anyTrain ? (live(anyTrain.name) ? 'active' : 'done') : 'pending', anyTrain ? anyTrain.name : '—'],
['M5 门禁', m5ok ? 'done' : m5fail ? 'fail' : 'pending', m5ok ? '通过' : m5fail ? '未通过' : '—'],
['APG 微调', has('apg') && !has('apg_r') ? (live('apg') ? 'active' : 'done') : has('apg_r') || mineN ? 'done' : 'pending', has('apg') ? 'apg' : '—'],
['挖错循环', mineN ? (mineDone === mineN && !live('apg_r') ? 'done' : 'active') : 'pending', mineN ? `第 ${mineN} 轮` : '—'],
['导出/评测', s.bench ? 'done' : 'pending', s.bench ? '已评测' : '—'],
['验收汇总', s.acceptance ? (s.acceptance.all_pass ? 'done' : 'fail') : 'pending', s.acceptance ? (s.acceptance.all_pass ? '全部通过' : `${s.acceptance.failed.length} 项未过`) : '—'],
];
return L;
}
function renderStatus(s) {
const L = stages(s);
$('steps').innerHTML = L.map(([nm, st, ds], i) => `<div class="step ${st}"><div class="ic">${st === 'done' ? '✓' : st === 'fail' ? '!' : i + 1}</div><div class="nm">${nm}</div><div class="ds">${ds}</div></div>`).join('');
const nDone = L.filter(x => x[1] === 'done').length; $('stepnote').textContent = `${nDone} / ${L.length} 完成`;
// GPU
const G = s.gpu || [];
const BUD = (s.vram_budget_gb || 24) * 1024; // 显存预算(MB):桌面常驻占 4–5 GB,不能按整卡 24 GB 看
$('gputime').textContent = G.length ? '实时' : '';
$('gpu').innerHTML = G.length ? G.map(g => {
const u = g.util ?? 0, C = 2 * Math.PI * 50, col = u > 85 ? css('--ok') : u > 50 ? css('--accent') : css('--warn');
const memP = 100 * g.mem_used / BUD, tc = g.temp > 83 ? css('--bad') : g.temp > 75 ? css('--warn') : css('--c6');
const row = (k, v, p, c) => `<div class="gr"><div class="l"><span>${k}</span><b>${v}</b></div><div class="mini"><div style="width:${Math.min(100, p)}%;background:${c}"></div></div></div>`;
return `<div class="gpuwrap"><div class="ring"><svg width="116" height="116"><circle cx="58" cy="58" r="50" stroke="#1b2433" stroke-width="10" fill="none"/>
<circle cx="58" cy="58" r="50" stroke="${col}" stroke-width="10" fill="none" stroke-linecap="round" stroke-dasharray="${C}" stroke-dashoffset="${C * (1 - u / 100)}" style="transition:stroke-dashoffset .6s"/></svg>
<div class="c"><div><b>${u}%</b><span>利用率</span></div></div></div>
<div class="gpurows">${row('显存', `${(g.mem_used / 1024).toFixed(1)} / ${(BUD / 1024).toFixed(0)} GB 预算(整卡 ${(g.mem_total / 1024).toFixed(0)})`, memP, memP > 100 ? css('--bad') : memP > 85 ? css('--warn') : css('--accent2'))}
${row('温度', `${g.temp} °C`, g.temp, tc)}${row('功耗', g.power != null ? `${Math.round(g.power)} W` : '—', g.power ? g.power / 3.5 : 0, css('--c7'))}</div></div>
<div class="gname">${g.name}</div>`;
}).join('<hr style="border:none;border-top:1px solid var(--line);margin:14px 0">') : empty('未检测到 nvidia-smi');
// 数据
const D = s.data || {}, st = D.stats, ix = D.index;
const row = (k, v, cls = '') => `<tr><td>${k}</td><td class="n ${cls}">${v}</td></tr>`;
if (D.consistency == null && !st && !ix && !D.gen_tasks_written) $('data').innerHTML = empty('还没开始生成数据');
else {
let h = '<table>';
h += row('老师一致性', D.consistency == null ? '—' : pct(D.consistency), D.consistency == null ? '' : D.consistency >= 0.9 ? 'ok' : 'warn');
h += row('已生成任务', D.gen_tasks_written ? `${D.gen_tasks_written.toLocaleString()} 条 · ${D.shards} 个分片` : '—');
if (st) {
h += row('保留率', fmt(st.keepRate), st.keepRate >= 0.75 ? 'ok' : 'bad');
if (st.gapDrop != null) h += row(`间隙 < ${st.minGapFilterMm ?? 3} mm 剔除`, `${st.gapDrop.toLocaleString()}(${pct(st.gapDrop / Math.max(1, st.keep + st.gapDrop))})`, st.gapDrop / Math.max(1, st.keep + st.gapDrop) > 0.25 ? 'bad' : '');
h += row('样本数(每 3 步)', (st.samplesEvery3 || 0).toLocaleString(), st.samplesEvery3 >= 1.7e6 ? 'ok' : 'warn');
if (st.consensusDrop != null) h += row('共识丢弃', st.consensusDrop.toLocaleString());
if (st.vertexMinGapMm) h += row('最小间隙 p10 / p50 / p90', `${st.vertexMinGapMm.p10 ?? '—'} / ${st.vertexMinGapMm.p50} / ${st.vertexMinGapMm.p90} mm`);
}
if (ix) h += row('训练 / 验证样本', `${ix.train_samples.toLocaleString()} / ${ix.val_samples.toLocaleString()}`);
if (D.r2) h += row('R2 近邻标签 std', D.r2.pass == null ? '未判定' : `${fmt(D.r2.label_std_mean)}(< 0.10)`, D.r2.pass == null ? 'dim' : D.r2.pass ? 'ok' : 'bad');
$('data').innerHTML = h + '</table>';
}
$('m5').innerHTML = s.m5 && s.m5.length ? '<table><tr><th>动作头</th><th class="n">纯策略穿柱</th><th class="n">到达</th><th class="n">加安全层穿柱</th><th class="n">结论</th></tr>' + s.m5.map(m =>
`<tr><td>${m.head}</td><td class="n ${m.policy_only.collision_rate <= 0.02 ? 'ok' : 'bad'}">${pct(m.policy_only.collision_rate, 2)}</td><td class="n">${pct(m.policy_only.success)}</td><td class="n">${pct(m.with_sim_safety.collision_rate, 2)}</td><td class="n"><span class="chip ${m.pass ? 'ok' : 'bad'}">${m.pass ? '通过' : '不通过'}</span></td></tr>`).join('')
+ `</table><div class="dim" style="margin-top:8px;font-size:12px">选定模型:<span style="font-family:var(--mono)">${s.selected || '—'}</span></div>` : empty('预训练完成后运行 m5');
$('mine').innerHTML = s.mine && s.mine.length ? '<table><tr><th>轮</th><th class="n">E1 撞</th><th class="n">E2 极小</th><th class="n">正确</th><th class="n">提升</th><th class="n">R7 左右</th><th class="n">门禁</th></tr>' + s.mine.map(m => {
const r = m.report || {}, g = m.gate || {};
const chip = g.accept == null ? '<span class="chip dim">进行中</span>' : g.accept ? '<span class="chip ok">接受</span>' : '<span class="chip bad">回滚</span>';
return `<tr><td>${m.round}</td><td class="n">${pct(r.E1, 2)}</td><td class="n">${pct(r.E2, 2)}</td><td class="n">${pct(r.ok)}</td><td class="n">${isNum(g.gain_pp) ? '+' + g.gain_pp.toFixed(1) + 'pp' : '—'}</td><td class="n">${isNum(g.R7_left_share_shift_pp) ? g.R7_left_share_shift_pp.toFixed(1) + 'pp' : '—'}</td><td class="n">${chip}</td></tr>`;
}).join('') + '</table>' : empty('APG 之后开始挖错');
$('bench').innerHTML = s.bench ? '<table><tr><th>集合</th><th class="n">通过</th><th class="n">穿模帧</th><th class="n">刹停率</th><th class="n">推理 P99</th></tr>' + Object.entries(s.bench).map(([k, v]) =>
`<tr><td>${k}</td><td class="n ${v.ok === v.n ? 'ok' : 'bad'}">${v.ok}/${v.n}</td><td class="n ${v.collisionFrames ? 'bad' : 'ok'}">${v.collisionFrames}</td><td class="n">${pct(v.brakeRate, 2)}</td><td class="n">${v.inferMsP99 ?? '—'} ms</td></tr>`).join('') + '</table>' : empty('导出 ONNX 后运行 bench');
const A = s.acceptance;
$('accept').innerHTML = A ? `<div class="accbanner ${A.all_pass ? 'ok' : 'bad'}">${A.all_pass ? '✓ 全部通过' : `✕ ${A.failed.length} 项未通过`}</div><table>` + A.items.map(i =>
`<tr><td>${i.item}</td><td class="n">${typeof i.value === 'number' ? (/率|比例/.test(i.item) && i.value <= 1 ? pct(i.value, 1) : fmt(i.value)) : (Array.isArray(i.value) || i.value == null ? '' : i.value)}</td><td class="n"><span class="chip ${i.pass === true ? 'ok' : i.pass === false ? 'bad' : 'dim'}">${i.pass === true ? '通过' : i.pass === false ? '未过' : '—'}</span></td></tr>`).join('') + '</table>' : empty('最后一步 accept');
$('foot').textContent = `${DEMO ? '示例数据 · ' : ''}每 5 秒刷新 · 网页可随时关闭,训练不受影响 · ${new Date().toLocaleTimeString('zh-CN', { hour12: false })}`;
}
/* ===================== 全流程卡片 / 开始停止 / 终端 ===================== */
function renderPipe(s) {
const P = s.pipeline, running = job && job.running;
const ck = s.check || {}, lk = s.lock;
const ckline = ck.pending ? '<div class="ckline">环境自检中…</div>'
: ck.problems && ck.problems.length ? `<div class="ckline bad">✕ ${ck.problems.join(';')}</div>`
: ck.python ? `<div class="ckline">Python ${ck.python} · torch ${ck.torch}${ck.gpu ? ' · ' + ck.gpu : ''}${ck.warn ? ` · <span class="warn">${ck.warn}</span>` : ''}</div>` : '';
const who = job && job.external ? `由${(lk && lk.owner || '').startsWith('网页') ? '上一个控制台窗口' : '终端'}启动(pid ${lk && lk.pid},${Math.round(lk && lk.age_s || 0)} 秒前心跳)` : job && job.pid ? `PID ${job.pid}` : '';
const btns = `${ckline}<div class="ctlrow">${running || (P && P.current)
? `<button class="danger" onclick="stopJob()">■ 停止</button><span class="hint">${who}</span>`
: `<button class="primary" onclick="openStart()" ${ck.problems && ck.problems.length ? 'disabled' : ''}>▶ ${P ? '继续训练' : '开始训练'}</button><span class="hint">${job && job.rc != null ? (job.stopped ? '上次已手动停止,再点即从停下的那步继续' : job.rc === 0 ? '上次运行已全部完成' : `上次运行失败(返回码 ${job.rc}),看终端输出;修好后再点即续跑`) : '已完成的步骤会自动跳过'}</span>`}</div>`;
if (!P) { $('pipe').innerHTML = `<h2><span>全流程</span><span class="r">尚未开始</span></h2>${empty('还没运行过流水线。点「开始训练」从对拍一直跑到验收;中途每一步的进度都显示在这里。')}${btns}`; return; }
const plan = P.plan && P.plan.length ? P.plan : Object.keys(P.stages);
let tot = 0, got = 0;
for (const k of plan) { const w = SM[k]?.w || 0.1, r = P.stages[k]; tot += w; if (r && r.rc === 0 && P.current !== k) got += w; else if (P.current === k) got += w * ((P.progress && P.progress.frac) || 0); }
const frac = tot ? got / tot : 0, now = Date.now() / 1000;
const cur = P.current, cr = cur && P.stages[cur], pr = P.progress || {};
const doneN = plan.filter(k => P.stages[k] && P.stages[k].rc === 0 && P.current !== k).length;
const failed = plan.find(k => P.stages[k] && P.stages[k].rc != null && P.stages[k].rc !== 0);
const allDone = !cur && doneN === plan.length;
let remain = 0; for (const k of plan) { const r = P.stages[k]; if (!r || r.rc == null) remain += (SM[k]?.w || 0.1) * (k === cur ? 1 - (pr.frac || 0) : 1); }
const head = cur ? `<div class="now">正在运行<b>${SM[cur]?.n || cur}</b>${pr.label || ''}</div>`
: allDone ? `<div class="now">状态<b class="ok">全部完成</b>共 ${plan.length} 步</div>`
: failed ? `<div class="now">状态<b class="bad">停在「${SM[failed]?.n || failed}」</b>${(P.stages[failed].info || {}).timed_out || P.stages[failed].rc === 124 ? '超过时间上限,判定卡死' : P.stages[failed].rc === -15 || P.stages[failed].rc === 143 ? '已停止' : '失败,看终端输出'}</div>`
: `<div class="now">状态<b>未在运行</b>已完成 ${doneN} / ${plan.length} 步</div>`;
$('pipe').innerHTML = `<h2><span>全流程</span><span class="r">${doneN} / ${plan.length} 步完成</span></h2>
<div class="overall">${head}<div class="bigpct">${(100 * frac).toFixed(1)}<small>%</small></div></div>
<div class="bar ${cur ? '' : 'idle'}"><div class="fill" style="width:${100 * frac}%"></div></div>
${cur ? `<div class="sublabel"><span>当前步骤 · ${SM[cur]?.n}</span><span>${pr.frac != null ? pct(pr.frac) : '无法计量,按用时估'}</span></div>
<div class="bar thin ${pr.frac == null ? 'ind' : ''}"><div class="fill" style="width:${pr.frac != null ? 100 * pr.frac : 30}%"></div></div>` : ''}
<div class="facts">
<div class="fact"><div class="k">总用时</div><div class="v">${hms((cur ? now : Math.max(...Object.values(P.stages).map(r => r.end || r.start))) - P.started)}</div></div>
<div class="fact"><div class="k">本步用时</div><div class="v">${cr ? hms(now - cr.start) : '—'}</div></div>
<div class="fact"><div class="k">本步粗估 / 上限</div><div class="v">${cur ? hmsShort((SM[cur]?.w || 0) * 3600) + ' / ' + hmsShort((cr && cr.timeout_h || SM[cur]?.to || 0) * 3600) : '—'}</div></div>
<div class="fact"><div class="k">全流程剩余(粗估)</div><div class="v">${allDone ? '—' : '约 ' + hmsShort(remain * 3600)}</div></div>
</div>${btns}`;
}
function renderJob() {
const busy = (job && job.running) || (lastStatus && lastStatus.pipeline && lastStatus.pipeline.current);
$('btnStart').style.display = busy ? 'none' : ''; $('btnStop').style.display = busy ? '' : 'none';
}
async function pollConsole() {
const d = await api(`/api/console?offset=${conOff}`).catch(() => null); if (!d) return;
const t = $('term'), atBottom = t.scrollTop + t.clientHeight >= t.scrollHeight - 30;
if (d.reset) t.innerHTML = '';
if (d.text) {
const esc = x => x.replace(/&/g, '&amp;').replace(/</g, '&lt;');
t.insertAdjacentHTML('beforeend', d.text.split('\n').map(l => /error|Error|失败|Traceback|不通过|Terminated/.test(l) ? `<span class="e">${esc(l)}</span>` : /^== |^\$ /.test(l) ? `<span class="c">${esc(l)}</span>` : /PASS|通过|完成/.test(l) ? `<span class="g">${esc(l)}</span>` : esc(l)).join('\n'));
if (t.textContent.length > 400000) t.textContent = t.textContent.slice(-200000);
}
if (!t.textContent) t.innerHTML = '<span style="color:var(--dim2)">点「开始训练」后,这里实时显示命令行输出。</span>';
conOff = d.offset; if (atBottom || d.reset) t.scrollTop = t.scrollHeight;
}
function toast(msg) { const t = $('toast'); t.textContent = msg; t.style.display = 'block'; clearTimeout(toast.h); toast.h = setTimeout(() => t.style.display = 'none', 3500); }
function openStart() {
const P = lastStatus && lastStatus.pipeline, ck = lastStatus && lastStatus.check;
if (ck && ck.problems && ck.problems.length) { toast('环境自检未通过:' + ck.problems.join(';')); return; }
const w = $('fWarn'); const upd = () => { const msgs = [];
const done = P ? (P.plan || []).filter(k => P.stages[k] && P.stages[k].rc === 0) : [];
if ($('fMode').value === 'resume' && done.length) msgs.push(`将跳过已完成的 ${done.length} 步:${done.map(k => SM[k]?.n || k).join('、')}。改了参数会自动改为从头重跑。`);
if ($('fMode').value === 'fresh' && P) msgs.push('从头重跑:上次的数据、模型、评测结果整体移到 ml/archive/<时间>/(不删除)。');
w.style.display = msgs.length ? 'block' : 'none'; w.innerHTML = msgs.join('<br>'); };
$('fMode').onchange = upd; upd();
$('modal').classList.add('show');
}
async function startJob() {
const env = { CFG: $('fCfg').value, CONSENSUS: $('fCons').value, N_TASKS: $('fN').value.trim(), NPROC: $('fP').value.trim(), TARGET_SAMPLES: $('fT').value.trim(), ROUNDS: $('fR').value.trim(), MIN_GAP_MM: $('fG').value.trim() };
const body = JSON.stringify({ fresh: $('fMode').value === 'fresh', env });
const r = DEMO ? await api('/api/start', body) : await post('/api/start', body);
if (r.error) { toast('没能开始:' + r.error); return; }
$('modal').classList.remove('show'); toast('已开始'); conOff = 0; userPicked = false; poll();
}
async function stopJob() {
if (!confirm('确定停止?训练会先存档再退出(最多等 90 秒),生成/打包按已写完的部分保留。之后点「续跑」从断点接着来。')) return;
toast('正在停止…训练阶段会先存档');
const r = DEMO ? await api('/api/stop', '{}') : await post('/api/stop');
toast(r.error ? '没能停止:' + r.error : '已停止'); poll();
}
$('btnStart').onclick = openStart; $('btnStop').onclick = stopJob;
$('fCancel').onclick = () => $('modal').classList.remove('show'); $('fGo').onclick = startJob;
$('modal').onclick = e => { if (e.target === $('modal')) $('modal').classList.remove('show'); };
/* ===================== 控件 ===================== */
$('smooth').oninput = () => { $('smoothv').textContent = smoothv().toFixed(2); render(); };
$('logy').onclick = () => { logyOn = !logyOn; $('logy').classList.toggle('on', logyOn); render(); };
$('run').onchange = () => { userPicked = true; cur = null; poll(); };
$('scenario').onchange = () => { api.scenario($('scenario').value); cur = null; userPicked = false; conOff = 0; $('run').innerHTML = ''; poll(); };
$('auto').onclick = () => { const on = !$('auto').classList.contains('on'); $('auto').classList.toggle('on', on); clearInterval(timer); if (on) timer = setInterval(poll, 5000); };
timer = setInterval(poll, 5000); poll();
/* =====================================================================
示例数据:两种场景,每次刷新让"运行中"的那条日志向前推进。
===================================================================== */
function demoApi() {
let seed = 7; const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const gauss = () => { let u = 0; for (let i = 0; i < 6; i++) u += rnd(); return (u - 3) * 1.41; };
const now = () => Date.now() / 1000;
const T0 = now() - 2.05 * 3600;
function trainRec(st, total, sps, t0, wta) {
const lr = 1e-3 * (st < 1000 ? st / 1000 : 0.5 * (1 + Math.cos(Math.PI * (st - 1000) / (total - 1000))));
const mf = (wta ? 0.42 : 0.55) * Math.exp(-st / 9000) + 0.12 + 0.012 * gauss(), fk = 2.6 * Math.exp(-st / 6000) + 0.35 + 0.04 * gauss();
const sm = 3.2 * Math.exp(-st / 4000) + 0.6 + 0.06 * gauss(), val = 0.4 * Math.exp(-st / 5000) + 0.05 + 0.006 * gauss(), sub = 0.25 * Math.exp(-st / 7000) + 0.04 + 0.004 * gauss();
const sp = sps * (1 - 0.18 * Math.exp(-st / 250)) * (1 + 0.012 * gauss());
return { step: st, total, t: t0 + st * 1024 / sps, loss: mf + 0.5 * fk + 0.05 * sm + 0.1 * val + 0.1 * sub, mf, fk_cm: fk, smooth: sm, value: val, sub,
gnorm: Math.max(0.15, 0.55 + 2.4 * Math.exp(-st / 2500) + 0.12 * gauss()), lr, samples_per_s: sp, eta_h: (total - st) * 1024 / sps / 3600 };
}
function trainEval(x, t0, sps) {
const q = 1 - Math.exp(-x / 14000);
return { eval: { step: x, t: t0 + x * 1024 / sps, n: 512, n_ref: 512, success: Math.min(0.975, 0.08 + 0.87 * q + 0.012 * gauss()), collision_rate: Math.max(0.004, 0.31 * (1 - q) + 0.012 + 0.005 * gauss()),
time_ok_1p3: Math.min(0.95, 0.05 + 0.8 * q + 0.012 * gauss()), start_fail: Math.max(0, 0.05 * (1 - q) + 0.003 * gauss()) } };
}
function trainLog(total, from, to, sps, t0, evalEvery = 2000, wta = false) {
const L = [];
for (let st = from; st <= to; st += 50) { L.push(trainRec(st, total, sps, t0, wta)); if (evalEvery && st > 0 && st % evalEvery === 0) L.push(trainEval(st, t0, sps)); }
return L;
}
function apgRec(it, total, t0) {
const q = 1 - Math.exp(-it / 900);
return { it, total, t: t0 + it * 3.1, plans: 60 + Math.floor(rnd() * 45), err_mm_mean: Math.max(3, 140 * (1 - q) + 9 + 5 * gauss()), reach_frac: Math.min(1, 0.35 + 0.52 * q + 0.03 * gauss()),
bc: 0.9 - 0.15 * q + 0.02 * gauss(), gnorm: Math.max(0.2, 1.2 + 0.25 * gauss()), col: Math.max(0, 2.4 * (1 - q) + 0.15 + 0.06 * gauss()), tgt: Math.max(0, 3.5 * (1 - q) + 0.2 + 0.08 * gauss()), smooth: 40 - 12 * q + 1.5 * gauss() };
}
function apgLog(total, from, to, t0, base = 0.93) {
const L = [];
for (let it = from; it <= to; it += 10) { L.push(apgRec(it, total, t0)); if (it > 0 && it % 500 === 0) { const q = 1 - Math.exp(-it / 900); L.push({ eval: { it, success: base + 0.035 * q + 0.004 * gauss(), collision_rate: 0.028 * (1 - q) + 0.006, time_ok_1p3: 0.86 + 0.06 * q, start_fail: 0.001 } }); } }
return L;
}
let sc, runs, logs, pipe, jobS, con, genAt = 0;
// 示例流水线:stages 里的 [阶段, 开始距今小时, 用时小时]
const mkPipe = (done, cur, curStartH) => { const t = now(); const st = {}; let tt = t - curStartH * 3600;
for (let i = done.length - 1; i >= 0; i--) { const [k, dur] = done[i]; st[k] = { start: tt - dur * 3600, end: tt, rc: 0, info: {} }; tt -= dur * 3600; }
if (cur) st[cur] = { start: t - curStartH * 3600, end: null, rc: null, info: {} };
return { started: tt, plan: ['kin', 'r4', 'gate1', 'gen', 'prep', 'train', 'm5', 'apg', 'mine', 'export', 'bench', 'accept'], stages: st, current: cur }; };
const CON = {
train: '$ FROM=kin TO=accept bash ml/pipeline.sh all\n计划:kin r4 gate1 gen prep train m5 apg mine export bench accept\n== node ml/prep/export_kin.cjs\n== node ml/eval/dump_parity.cjs 2000\n== python -m ml.eval.check_parity\n{"PASS": true, "fk_mm": 0.0004, "gap_mm": 0.0011}\n== node ml/gen/consistency.cjs 300 4242\n{"consistentByGateRule": 0.86}\n一致性门禁结果 → CONSENSUS=1\n== bash ml/gen/run_parallel.sh 1786 28 1 --consensus 4\n样本数 1702000 ≥ 目标,完成\n== bash ml/prep/run_prep.sh 28\n== python -m ml.eval.check_r2 --samples ml/data/samples --out ml/data/r2.json\n{"pairs": 250, "label_std_mean": 0.071, "pass": true}\n== python -m ml.train --samples ml/data/samples --out ml/runs/bench_base --steps 2000 --no-eval\nstep 2000 loss 1.912 6150 samples/s\n== python -m ml.train --samples ml/data/samples --out ml/runs/base\n',
gen: '$ FROM=kin TO=accept bash ml/pipeline.sh all\n计划:kin r4 gate1 gen prep train m5 apg mine export bench accept\n== node ml/prep/export_kin.cjs\n== python -m ml.eval.check_parity\n{"PASS": true}\n== node ml/gen/consistency.cjs 300 4242\n{"consistentByGateRule": 0.86}\n一致性门禁结果 → CONSENSUS=1\n== bash ml/gen/run_parallel.sh 1786 28 1 --consensus 4\n',
late: '... 挖错第 2 轮\nround 2 gain 1.1 pp\n== python -m ml.mining.mine_round --ckpt ml/runs/current.pt --seed 1003 --n 50000 --out ml/runs/mine/r3\n{"n": 50000, "E1": 0.009, "E2": 0.019, "ok": 0.938}\n== python -m ml.apg.apg_finetune --init ml/runs/current.pt --out ml/runs/apg_r3 --steps 1000\n', idle: '' };
const GPU = () => [{ name: 'NVIDIA GeForce RTX 3090', util: 93 + Math.round(5 * rnd()), mem_used: 15872 + Math.round(500 * rnd()), mem_total: 24576, temp: 71 + Math.round(3 * rnd()), power: 318 + Math.round(24 * rnd()) }];
const DATA = { kin_pass: true, consistency: 0.86, gen_tasks_written: 56412, shards: 56,
stats: { tasks: 56412, keep: 43120, gapDrop: 4830, minGapFilterMm: 3, keepRate: 0.85, samplesEvery3: 1702000, samplesPerTask: 36.5, consensusDrop: 7520, vertexMinGapMm: { p10: 3.1, p50: 4.4, p90: 5.9, max: 12.3 } },
index: { train_samples: 1617000, val_samples: 85000 }, r2: { label_std_mean: 0.071, pass: true } };
function setup(name) {
sc = name; seed = 7; con = CON[name] || ''; jobS = { running: name !== 'idle', pid: 21844, from: 'kin', to: 'accept', started: now() - 5 * 3600, rc: null, bash: true }; genAt = 31200;
if (name === 'idle') { runs = {}; logs = {}; pipe = null; jobS = { running: false, bash: true }; return; }
if (name === 'gen') { runs = {}; logs = {}; pipe = mkPipe([['kin', 0.02], ['r4', 0.05], ['gate1', 0.47]], 'gen', 2.7); pipe.stages.gen.info.target_tasks = 50008; return; }
if (name === 'train') { pipe = mkPipe([['kin', 0.02], ['r4', 0.05], ['gate1', 0.47], ['gen', 4.6], ['prep', 0.38]], 'train', 2.15);
runs = { base: { kind: 'train', at: 36000, total: 60000, sps: 6208, t0: T0, live: true },
bench_base: { kind: 'bench', at: 2000, total: 2000, sps: 6150, t0: T0 - 700 } };
logs = { base: trainLog(60000, 0, 36000, 6208, T0), bench_base: trainLog(2000, 0, 2000, 6150, T0 - 700, 0) };
} else { pipe = mkPipe([['kin', 0.02], ['r4', 0.05], ['gate1', 0.47], ['gen', 4.6], ['prep', 0.38], ['train', 2.9], ['m5', 0.2], ['apg', 2.6]], 'mine', 3.4); pipe.stages.mine.info.rounds = 6;
pipe.stages.export = pipe.stages.bench = pipe.stages.accept = undefined; delete pipe.stages.export; delete pipe.stages.bench; delete pipe.stages.accept;
const t1 = now() - 9 * 3600;
runs = { base: { kind: 'train', at: 60000, total: 60000, sps: 6208, t0: t1 - 3 * 3600 },
bench_base: { kind: 'bench', at: 2000, total: 2000, sps: 6150, t0: t1 - 3.2 * 3600 },
apg: { kind: 'apg', at: 3000, total: 3000, t0: t1 },
apg_r3: { kind: 'apg', at: 620, total: 1000, t0: now() - 620 * 3.1, live: true } };
logs = { base: trainLog(60000, 0, 60000, 6208, t1 - 3 * 3600), bench_base: trainLog(2000, 0, 2000, 6150, t1 - 3.2 * 3600, 0),
apg: apgLog(3000, 0, 3000, t1), apg_r3: apgLog(1000, 0, 620, now() - 620 * 3.1, 0.955) };
}
}
setup('train');
const mtime = name => { const r = runs[name]; const L = logs[name]; const last = L[L.length - 1]; return r.live ? now() - 2 : (last.t || last.eval?.t || now() - 3600); };
const status = () => {
let P = pipe && JSON.parse(JSON.stringify(pipe));
if (P && P.current) {
const r = sc === 'train' ? runs.base : runs.apg_r3;
P.progress = !['gen', 'train', 'late'].includes(sc) || (sc !== 'gen' && !(sc === 'train' ? runs.base : runs.apg_r3)) ? { frac: null, label: '进行中(示例)' } : sc === 'gen' ? { frac: Math.min(1, (genAt += 180) / 50008), label: `已生成 ${genAt.toLocaleString()} / 50,008 条任务` }
: sc === 'train' ? { frac: (2000 + r.at) / 62000, label: `正式训练 ${r.at.toLocaleString()} / 60,000 步`, run: 'base' }
: { frac: (2 + 0.3 + 0.6 * r.at / r.total) / 6, label: `第 3 轮:APG ${r.at} / 1,000 步(共最多 6 轮,提升不足会提前停)`, run: 'apg_r3' };
}
const gpuIdle = [{ name: 'NVIDIA GeForce RTX 3090', util: 0, mem_used: 412, mem_total: 24576, temp: 38, power: 24 }];
const base = { now: now(), gpu: sc === 'train' || sc === 'late' ? GPU() : gpuIdle, pipeline: P, data: DATA, m5: [], selected: null, mine: [], bench: null, acceptance: null };
if (sc === 'idle') return { ...base, data: {} };
if (sc === 'gen') return { ...base, data: { kin_pass: true, consistency: 0.86, gen_tasks_written: genAt, shards: 28, stats: null, index: null, r2: null } };
if (sc === 'train') return base;
return { ...base,
m5: [{ head: 'base', pass: true, policy_only: { collision_rate: 0.012, success: 0.912 }, with_sim_safety: { collision_rate: 0.0, success: 0.948 } }],
selected: 'ml/runs/base/best.pt',
mine: [{ round: 'r1', report: { E1: 0.018, E2: 0.031, ok: 0.902 }, gate: { gain_pp: 2.4, R7_left_share_shift_pp: 1.2, accept: true } },
{ round: 'r2', report: { E1: 0.011, E2: 0.022, ok: 0.931 }, gate: { gain_pp: 1.1, R7_left_share_shift_pp: 0.8, accept: true } },
{ round: 'r3', report: { E1: 0.009, E2: 0.019, ok: 0.938 }, gate: null }],
bench: { nc: { n: 30, ok: 30, collisionFrames: 0, brakeRate: 0.004, inferMsP99: 9.8 }, route: { n: 40, ok: 40, collisionFrames: 0, brakeRate: 0.002, inferMsP99: 9.6 },
r8: { n: 50, ok: 48, collisionFrames: 0, brakeRate: 0.031, inferMsP99: 10.1 }, nc1000: { n: 1000, ok: 997, collisionFrames: 0, brakeRate: 0.006, inferMsP99: 9.9 } },
acceptance: { all_pass: false, failed: ['R8 恢复率'], items: [
{ item: 'held-out 可达任务到达率', value: 0.983, pass: true }, { item: '起步失败率(R2)', value: 0.0008, pass: true },
{ item: 'D 类停靠距离 ≤ 老师 + 5 mm 的比例', value: 0.991, pass: true }, { item: '部署形态穿模帧', value: 0, pass: true },
{ item: 'near_column 30/30', value: '30/30', pass: true }, { item: 'R7 左右比例变化', value: '1.2pp', pass: true },
{ item: 'R8 恢复率', value: 0.96, pass: false }, { item: 'R10 训练吞吐(样本/s)', value: 6208, pass: true, rule: '样本/s' } ] } };
};
const fn = async (path, body) => {
const u = new URL(path, 'http://x');
if (u.pathname === '/api/runs') return Object.entries(runs).map(([name, r]) => ({ name, kind: r.kind, size: 0, mtime: mtime(name), has_best: true, diverged: false })).sort((a, b) => b.mtime - a.mtime);
if (u.pathname === '/api/log') {
const name = u.searchParams.get('run'), off = +u.searchParams.get('offset'), r = runs[name];
if (!r) return { records: [], offset: 0, reset: true, mtime: now() };
if (off === 0) return { records: logs[name], offset: logs[name].length, reset: true, mtime: mtime(name), live: !!r.live };
if (r.live && r.at < r.total) { // 模拟推进
const step = r.kind === 'apg' ? 10 : 250, from = r.at + (r.kind === 'apg' ? 10 : 50); r.at = Math.min(r.total, r.at + step);
const add = r.kind === 'apg' ? apgLog(r.total, from, r.at, r.t0, 0.955) : trainLog(r.total, from, r.at, r.sps, r.t0);
if (r.kind !== 'apg') add.forEach(v => { if (v.t) v.t = now(); });
logs[name].push(...add); return { records: add, offset: off + add.length, reset: false, mtime: now(), live: true };
}
return { records: [], offset: off, reset: false, mtime: mtime(name), live: !!r.live };
}
if (u.pathname === '/api/status') return status();
if (u.pathname === '/api/job') return jobS;
if (u.pathname === '/api/console') { const off = +u.searchParams.get('offset'); return { text: con.slice(off), offset: con.length, reset: off === 0 || off > con.length }; }
if (u.pathname === '/api/start') { const b = JSON.parse(body || '{}'); b.from = b.from || 'kin'; b.to = b.to || 'accept'; jobS = { running: true, pid: 23001, from: b.from, to: b.to, started: now(), rc: null, bash: true };
con = `$ ${b.fresh ? 'FRESH=1 ' : ''}bash ml/pipeline.sh all\n(示例数据:真实使用时这里是命令行输出)\n`;
if (!pipe) pipe = mkPipe([], b.from, 0.001); else { pipe.current = b.from; pipe.stages[b.from] = { start: now(), end: null, rc: null, info: {} }; }
return { ok: true, pid: 23001 }; }
if (u.pathname === '/api/stop') { jobS = { ...jobS, running: false, rc: -15, stopped: true };
if (pipe && pipe.current) { pipe.stages[pipe.current].end = now(); pipe.stages[pipe.current].rc = -15; pipe.current = null; }
Object.values(runs).forEach(r => r.live = false); con += '\n[已在网页上停止]\n'; return { ok: true }; }
};
fn.scenario = setup;
return fn;
}
</script>
</body>
</html>