825 lines
76 KiB
HTML
825 lines
76 KiB
HTML
<!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; }
|
||
.cores { display: flex; gap: 2px; height: 26px; margin-top: 2px; background: #1b2433; border-radius: 3px; padding: 2px; }
|
||
.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="每 1 秒刷新"><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 / CPU</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 || ' '}</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('还没有训练日志:预训练开始后这里出现损失、吞吐和评估曲线。'); }
|
||
}
|
||
|
||
let polling = false;
|
||
async function poll() {
|
||
if (polling) return; polling = true; // 1 秒一轮:上一轮没回来就跳过,避免请求堆叠
|
||
try {
|
||
const [st, jb] = await Promise.all([api('/api/status'), api('/api/job').catch(() => null)]);
|
||
lastStatus = st; applyStageMeta(st.stage_meta); job = jb;
|
||
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);
|
||
} finally { polling = false; }
|
||
}
|
||
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 + CPU(同一个框:上面是 GPU 环 + 显存/温度/功耗,下面接 CPU 总占用与每核条)
|
||
const G = s.gpu || [], CP = s.cpu || {};
|
||
const BUD = (s.vram_budget_gb || 24) * 1024; // 显存预算(MB):桌面常驻占 4–5 GB,不能按整卡 24 GB 看
|
||
const hr = '<hr style="border:none;border-top:1px solid var(--line);margin:14px 0">';
|
||
const gr = (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>`;
|
||
$('gputime').textContent = (G.length || CP.n) ? '实时' : '';
|
||
const gpuBlk = 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');
|
||
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">${gr('显存', `${(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'))}
|
||
${gr('温度', `${g.temp} °C`, g.temp, tc)}${gr('功耗', 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) : '';
|
||
const cpuBlk = CP.n ? (() => {
|
||
const tot = CP.total ?? 0, C = 2 * Math.PI * 50, col = tot > 85 ? css('--bad') : tot > 60 ? css('--warn') : css('--ok');
|
||
const bars = (CP.per || []).map((v, i) => `<div title="核 ${i + 1}: ${v}%" style="flex:1;min-width:0;height:100%;border-radius:2px;background:${v > 85 ? css('--bad') : v > 60 ? css('--warn') : css('--accent2')};opacity:${0.18 + 0.82 * Math.min(1, v / 100)}"></div>`).join('');
|
||
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 - tot / 100)}" style="transition:stroke-dashoffset .6s"/></svg>
|
||
<div class="c"><div><b>${tot}%</b><span>CPU</span></div></div></div>
|
||
<div class="gpurows">${gr('占用', `${tot}% · ${CP.n} 核`, tot, col)}
|
||
<div class="cores">${bars}</div></div></div>
|
||
<div class="gname">每核占用(深色=忙)</div>`;
|
||
})() : '';
|
||
$('gpu').innerHTML = (gpuBlk + (gpuBlk && cpuBlk ? hr : '') + cpuBlk) || empty('未检测到 nvidia-smi / psutil');
|
||
|
||
// 数据
|
||
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, '&').replace(/</g, '<');
|
||
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, 1000); };
|
||
timer = setInterval(poll, 1000); 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>
|