"use strict";
/* BestPerf 控制台 — 探针表 + 发起测试 + 结果 */

const API = window.location.origin || "http://127.0.0.1:8768";
const PROBE_KEY = "fr.probes.v1";
const TOKEN_KEY = "fr.token.v1";
const TAB_KEY = "fr.tab.v1";          // 记住当前标签页,刷新后停在原页
const TABS = ["home", "test", "probes", "results"];

const $ = (id) => document.getElementById(id);

/* ---------- 访问令牌(本机自动取;远程可手填) ---------- */
// Auto-inject X-FR-Token on every same-origin /api/* request so the local GUI (loopback) is
// authenticated transparently. The token comes from /api/identity (returned only to loopback)
// or a manually-saved one (for driving a controller you're browsing remotely).
let AUTH_TOKEN = localStorage.getItem(TOKEN_KEY) || "";
const NATIVE_FETCH = window.fetch.bind(window);
window.fetch = (url, opts = {}) => {
  try {
    if (AUTH_TOKEN && String(url).includes("/api/")) {
      opts = { ...opts, headers: { ...(opts.headers || {}), "X-FR-Token": AUTH_TOKEN } };
    }
  } catch (_) {}
  return NATIVE_FETCH(url, opts);
};

function hostFromUrl(u) {
  try { return new URL(/^https?:\/\//i.test(u) ? u : "http://" + u).hostname; }
  catch (_) { return ""; }
}
// 取对端控制台的 identity(跨机、用原生 fetch 避免注入本机令牌)。对端开了「允许被别人测」才可达。
async function fetchPeerIdentity(ctrlUrl, token) {
  try {
    const base = (/^https?:\/\//i.test(ctrlUrl) ? ctrlUrl : "http://" + ctrlUrl).replace(/\/+$/, "");
    const r = await NATIVE_FETCH(base + "/api/identity", {
      cache: "no-store",
      headers: token ? { "X-FR-Token": token } : {},
    });
    if (!r.ok) return null;
    return await r.json();
  } catch (_) { return null; }
}

let IDENTITY = null;
async function loadIdentity() {
  try {
    IDENTITY = await (await fetch(`${API}/api/identity`, { cache: "no-store" })).json();
    if (IDENTITY && IDENTITY.token) {  // loopback -> controller handed us the token
      AUTH_TOKEN = IDENTITY.token;
      localStorage.setItem(TOKEN_KEY, AUTH_TOKEN);
    }
  } catch (_) { IDENTITY = null; }
  renderIdentityCard();
}

let measureType = "light";  // "light" | "load" | "tcp" — one measurement per test
const MEASURE_DESC = {
  light: "用少量探测流量,被动测这条网络当前的延迟、丢包、抖动和突发/中断;按统计粒度随时间记录。流量小、不打扰网络。",
  load: "主动从小到大加压,找出网络能扛多大(饱和点),再测这个负载下的延迟/丢包/突发,随时间记录。看满负荷时的表现。",
  tcp: "模拟下载/上传(TCP),测应用真正拿到的上行/下行速度 + 应用层延迟。",
  selfcheck: "只测本机:用环回(loopback)从小到大加压,不走网络,得出这台机器自己的 UDP 处理上限和负载下的延迟。用来判断负载测试的瓶颈到底是网络,还是本机性能。无需对端。",
};

/* ---------- 探针表（localStorage） ---------- */
function defaultProbes() {
  return [
    { id: "self", name: "本机", host: "127.0.0.1", port: 39701, agentId: 102, self: true },
    { id: "p100", name: "对端 A", host: "192.168.1.100", port: 39700, agentId: 100, ctrlUrl: "http://192.168.1.100:8768" },
  ];
}
function loadProbes() {
  try {
    const raw = JSON.parse(localStorage.getItem(PROBE_KEY));
    if (Array.isArray(raw) && raw.length) return raw;
  } catch (_) {}
  return defaultProbes();
}
function saveProbes(list) {
  localStorage.setItem(PROBE_KEY, JSON.stringify(list));
}
let probes = loadProbes();

function renderProbes() {
  const ul = $("probeList");
  ul.innerHTML = "";
  probes.forEach((p) => {
    const li = document.createElement("li");
    li.className = "probe-item" + (p.self ? " self" : "");
    li.innerHTML =
      `<span class="dot"></span>` +
      `<span class="pname">${escapeHtml(p.name)}</span>` +
      (p.self ? `<span class="tag">本机</span>` : "") +
      (!p.self && p.ctrlUrl ? `<span class="tag ctrl" title="${escapeHtml(p.ctrlUrl)}">控制台</span>` : "") +
      `<span class="paddr">${escapeHtml(p.host)}:${p.port}</span>`;
    if (!p.self) {
      const del = document.createElement("button");
      del.className = "del";
      del.textContent = "×";
      del.title = "删除";
      del.onclick = () => {
        probes = probes.filter((x) => x.id !== p.id);
        saveProbes(probes);
        renderProbes();
        renderEndpoints();
      };
      li.appendChild(del);
    }
    ul.appendChild(li);
  });
}

function renderEndpoints() {
  const from = $("fromProbe");
  const to = $("toProbe");
  const prevFrom = from.value;
  const prevTo = to.value;
  from.innerHTML = "";
  to.innerHTML = "";
  probes.forEach((p) => {
    from.appendChild(opt(p.id, p.name + "  (" + p.host + ")"));
    to.appendChild(opt(p.id, p.name + "  (" + p.host + ")"));
  });
  from.value = prevFrom && byId(prevFrom) ? prevFrom : (probes.find((p) => p.self) || probes[0]).id;
  const peer = probes.find((p) => !p.self) || probes[0];
  to.value = prevTo && byId(prevTo) && prevTo !== from.value ? prevTo : peer.id;
  updateOrbSub();
}
function opt(value, label) {
  const o = document.createElement("option");
  o.value = value;
  o.textContent = label;
  return o;
}
const byId = (id) => probes.find((p) => p.id === id);

function showTab(name) {
  document.querySelectorAll(".view").forEach((v) => { v.style.display = v.dataset.tab === name ? "" : "none"; });
  document.querySelectorAll("#tabs .tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === name));
  if (TABS.includes(name)) { try { localStorage.setItem(TAB_KEY, name); } catch (_) {} }  // 刷新后回到这页
}

// The orb now shows only 「开始」 — no sub-label. Kept as a no-op so its call sites stay valid.
function updateOrbSub() {}

// Switch the measurement type: update the blurb, show only this type's params, hide
// direction for TCP (it always tests up + down separately).
function setMeasure(type) {
  measureType = type;
  if ($("measureDesc")) $("measureDesc").textContent = MEASURE_DESC[type] || "";
  document.querySelectorAll(".for-light, .for-load, .for-tcp").forEach((el) => {
    const show = el.classList.contains("for-" + type);
    el.style.display = show ? "" : "none";
  });
  // 本机性能自检是纯本机环回,不需要对端,也没有方向。
  const local = type === "selfcheck";
  document.querySelectorAll(".endpoint-row").forEach((el) => { el.style.display = local ? "none" : ""; });
  // Direction only applies to the load profile (single-direction capacity). Light is always
  // bidirectional; TCP and selfcheck have no direction selector.
  if ($("directionSeg")) $("directionSeg").style.display = (type === "load") ? "" : "none";
  if ($("durationLabel")) $("durationLabel").textContent =
    type === "load" ? "刻画时长" : type === "tcp" ? "每向时长" : "时长";
  if ($("orbLabel")) $("orbLabel").textContent = "开始";
  updateOrbSub();
}

/* ---------- 健康检查 + 自动进入"测试进行中"实时页 ---------- */
let autoOpenedRun = null;
async function pollHealth() {
  const badge = $("healthBadge");
  const text = $("healthText");
  try {
    const r = await fetch(`${API}/api/health`, { cache: "no-store" });
    const j = await r.json();
    badge.dataset.state = "ok";
    const act = j.active_run_id;
    text.textContent = act ? "● 测试进行中" : "控制器就绪";
    // Whoever started the test (this GUI, the API, or a peer), surface its live view
    // automatically the first time we see it — so the operator "enters the test process".
    if (act && act !== autoOpenedRun) {
      enterLiveMode(act);  // turn the 发起测试 page into the live view (any initiator)
    } else if (!act) {
      autoOpenedRun = null;
    }
  } catch (_) {
    badge.dataset.state = "down";
    text.textContent = "控制器离线";
  }
}

/* ---------- 发起测试 ---------- */
// All measurements are between two probes; the peer must run a controller (b.ctrlUrl).
function buildEnv() {
  const a = byId($("fromProbe").value);
  const b = byId($("toProbe").value);
  const direction = document.querySelector('input[name="direction"]:checked').value;
  const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);

  if (measureType === "selfcheck") {
    // Local-only: a loopback ramp on THIS controller's machine. No peer, no direction.
    return withContext({
      FR_RUN_ID: `selfcheck-${stamp}`,
      FR_MODE: "selfcheck",
      FR_SELFCHECK_MAX_MBPS: "2000",
      FR_SELFCHECK_DURATION_SEC: "3",
      FR_SELFCHECK_LEVEL_DWELL_MS: "600",
    });
  }
  if (measureType === "tcp") {
    return withContext({
      FR_RUN_ID: `tcp-${stamp}`,
      FR_MODE: "tcp",
      FR_PEER_CTRL_URL: b.ctrlUrl,
      FR_PEER_CTRL_TOKEN: b.ctrlToken || "",
      FR_PEER_HOST: b.host,
      FR_TCP_PORT: "39800",
      FR_TCP_DURATION_SEC: String(int($("durationSec").value, 10)),
      FR_TCP_RTT_ROUNDS: String(int($("tcpRttRounds").value, 20)),
    });
  }
  if (measureType === "load") {
    return withContext({
      FR_RUN_ID: `load-${stamp}`,
      FR_MODE: "load_bidir",
      FR_DIRECTION: direction,                 // both | a_to_b | b_to_a (single-direction load)
      FR_PEER_CTRL_URL: b.ctrlUrl,
      FR_PEER_CTRL_TOKEN: b.ctrlToken || "",
      FR_PEER_HOST: b.host,
      // For b_to_a (peer sends to us) the peer needs our LAN address; supply it from identity.
      FR_LOCAL_HOST: (IDENTITY && IDENTITY.lan_ips && IDENTITY.lan_ips[0]) || "",
      FR_RESPONDER_PORT: String(b.port),       // peer responder (we send here)
      FR_LOCAL_RESPONDER_PORT: "39710",        // our responder (peer sends here)
      FR_INITIATOR_PORT: "39701",
      FR_PEER_INITIATOR_PORT: "39711",
      FR_LOAD_MAX_MBPS: String(int($("loadMaxMbps").value, 50)),
      FR_LOAD_DURATION_SEC: String(int($("durationSec").value, 5)),
      FR_LOAD_LEVEL_DWELL_MS: "1000",
      FR_LOAD_TIMELINE_WINDOW_MS: String(int($("timelineWindowMs").value, 1000)),
      FR_LOAD_PACKET_BYTES: String(int($("payloadSize").value, 1200)),
    });
  }
  // light measurement (two-probe; peer runs the responder via its controller)
  // Prefix with the kind so the run is recognized as 轻量 in the list (matches runKind) and
  // always has a readable leading token even when probe names are non-ASCII (cleaned to "-").
  return withContext({
    FR_RUN_ID: `light-${a.name}-${b.name}-${stamp}`,
    FR_MODE: "peer",
    FR_PEER_CTRL_URL: b.ctrlUrl,
      FR_PEER_CTRL_TOKEN: b.ctrlToken || "",
    FR_PEER_HOST: b.host,
    FR_RESPONDER_PORT: String(b.port),
    FR_INITIATOR_PORT: String(a.self ? a.port || 39701 : 39701),
    FR_LOCAL_AGENT_ID: String((a.self ? a.agentId : 102) || 102),
    FR_PEER_AGENT_ID: String(b.agentId || 100),
    FR_DURATION_SEC: String(int($("durationSec").value, 30)),
    FR_WINDOW_MS: String(int($("windowMs").value, 1000)),
    FR_ECHO_INTERVAL_MS: String(int($("echoIntervalMs").value, 50)),
    FR_STREAM_INTERVAL_MS: String(int($("streamIntervalMs").value, 50)),
    FR_TIMEOUT_MS: String(int($("timeoutMs").value, 500)),
    FR_PAYLOAD_SIZES: String(int($("payloadSize").value, 1200)),
  });
}

// Capture-context tags (for the model library): merged into every test's env.
function netContext() {
  const v = (id) => ($(id) && $(id).value.trim()) || "";
  return {
    FR_NET_ACCESS: ($("netAccess") && $("netAccess").value) || "unknown",
    FR_NET_OPERATOR: v("netOperator"),
    FR_NET_LOCATION: v("netLocation"),
    FR_NET_NOTE: v("netNote"),
  };
}
function withContext(env) { return Object.assign(env, netContext()); }
const int = (v, d) => {
  // Number(), not parseInt(): parseInt silently MANGLED scientific/decimal input ("1e3" -> 1,
  // "12.7" -> 12) instead of reading it as the user meant. Number handles both; non-numeric
  // input still falls back to the field's default. EMPTY input must check first: Number("") is 0
  // (not NaN), which would turn a cleared field into 0 instead of its default.
  const s = String(v ?? "").trim();
  if (s === "") return d;
  const n = Math.round(Number(s));
  return Number.isFinite(n) ? n : d;
};

async function startTest() {
  const orb = $("startBtn");
  // 本机性能自检是纯本机操作,不需要选择探针/对端。
  if (measureType !== "selfcheck") {
    const a = byId($("fromProbe").value);
    const b = byId($("toProbe").value);
    // Every measurement is between two probes; the peer must run a controller.
    if (!a || !b || a.id === b.id) {
      setNote("请选两台不同的探针(发起方 → 对端)。", "err");
      return;
    }
    if (!a.self) {
      // 选了非本机做发起方:这个控制台只能让本机发起(跨控制台发起会被浏览器同源策略拦截,
      // 结果也会落在对端)。以前这里被静默忽略 —— 测试照跑、但发起方其实还是本机,
      // 用户以为换了方向。现在明确拒绝并指路。
      setNote(`发起方目前只能是「本机」。要从「${a.name}」发起,请在那台机器上打开它的控制台` +
        `${a.ctrlUrl ? `(${a.ctrlUrl})` : ""}操作;在本机测它的方向,选 本机 → ${a.name} 即可。`, "err");
      return;
    }
    if (!b.ctrlUrl) {
      setNote(`对端「${b.name}」没有控制台地址。请在对端机器上运行本控制台,并把它的地址填到该探针里。`, "err");
      return;
    }
  }
  orb.classList.add("running");
  orb.disabled = true;
  $("orbLabel").textContent = "测试中";
  setNote("正在协调对端控制台,开始测量…", "ok");
  try {
    const r = await fetch(`${API}/api/runs`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ dry_run: false, env: buildEnv() }),
    });
    const j = await r.json();
    if (!r.ok) throw new Error(j.error || "启动失败");
    enterLiveMode(j.run_id);  // turn THIS (launch) page into the live-test view
  } catch (e) {
    setNote("启动失败：" + e.message, "err");
  } finally {
    orb.classList.remove("running");
    orb.disabled = false;
    $("orbLabel").textContent = "开始";
  }
}

/* ---------- 实时测试:把"发起测试"页换成实时进度,测完再换回 ---------- */
// While a run is active the launch form is replaced in-place by the live panel, so it's never
// ambiguous whether a test is running (no hunting in 结果·样本库, no accidental re-launch).
let liveLoopToken = 0;
function showTestForm(show) {
  document.querySelectorAll(".hero-config, .hero-launch").forEach((e) => { e.style.display = show ? "" : "none"; });
  const lv = $("testLive");
  if (lv) { lv.hidden = show; if (show) lv.innerHTML = ""; }
}
function exitLiveMode() { liveLoopToken++; showTestForm(true); }

async function enterLiveMode(runId) {
  autoOpenedRun = runId;       // tell pollHealth this run is already being shown
  showTab("test");
  showTestForm(false);
  const lv = $("testLive");
  const myToken = ++liveLoopToken;
  while (myToken === liveLoopToken) {
    const stillRunning = await renderLive(runId, lv);
    if (!stillRunning) break;
    await sleep(1800);  // window/bucket cadence
  }
  if (myToken === liveLoopToken) await endLiveMode(runId, lv);
}

async function endLiveMode(runId, lv) {
  let run = null;
  try { run = await (await fetch(`${API}/api/runs/${encodeURIComponent(runId)}`, { cache: "no-store" })).json(); } catch (_) {}
  await refreshRuns();
  // Local self-check: show the device verdict (UDP ceiling + latency-under-load) right here.
  if (runId.startsWith("selfcheck-")) {
    let sc = null;
    try { sc = await (await fetch(`${API}/api/runs/${encodeURIComponent(runId)}/artifacts/selfcheck.json`, { cache: "no-store" })).json(); } catch (_) {}
    if (sc && sc.schema === "field_replay.selfcheck.v1") {
      renderSelfcheckResult(lv, sc, runId);
      loadIdentity().then(renderIdentityCard);  // refresh the 本机信息 calibration line
      return;
    }
  }
  const ok = run && run.status === "exited";
  lv.hidden = false;
  lv.innerHTML =
    `<div class="live-head"><span class="live-dot" style="background:${ok ? "var(--teal)" : "var(--red)"};box-shadow:none"></span>` +
    `<span>${ok ? "测试完成" : "测试结束"}</span>` +
    `<span class="live-sub">${escapeHtml(runId)} · ${statusText(run ? run.status : "?")}</span></div>` +
    `<div class="artifact-bar" style="margin-top:14px">` +
      `<button class="btn dl-scenario" id="liveViewReport">查看报告</button>` +
      `<button class="btn ghost" id="liveBackForm">再发起一次</button>` +
    `</div>`;
  $("liveViewReport").onclick = () => { exitLiveMode(); showTab("results"); selectRun(runId); };
  $("liveBackForm").onclick = () => exitLiveMode();
}

const SELFCHECK_GRADE = {
  strong: { label: "强", color: "var(--teal)", chip: "teal" },
  moderate: { label: "中等", color: "var(--amber)", chip: "amber" },
  weak: { label: "弱", color: "var(--red)", chip: "red" },
};
function renderSelfcheckResult(lv, sc, runId) {
  const g = SELFCHECK_GRADE[sc.grade] || SELFCHECK_GRADE.moderate;
  const curve = sc.rtt_curve || [];
  const onset = sc.latency_onset_mbps != null ? fmtNum(sc.latency_onset_mbps) + " Mbps" : "未出现";
  lv.hidden = false;
  lv.innerHTML =
    `<div class="live-head"><span class="live-dot" style="background:${g.color};box-shadow:none"></span>` +
    `<span>本机性能自检完成</span>` +
    `<span class="live-sub">${escapeHtml(sc.hostname || "")}${sc.platform ? " · " + escapeHtml(sc.platform) : ""}</span></div>` +
    `<div class="live-tiles">` +
      liveTile("UDP 处理上限", fmtNum(sc.udp_ceiling_mbps || 0), "Mbps", g.chip) +
      liveTile("最佳延迟", num(sc.latency_floor_ms || 0), "ms", "") +
      liveTile("延迟拐点", onset === "未出现" ? "—" : fmtNum(sc.latency_onset_mbps), onset === "未出现" ? "未出现" : "Mbps", "") +
      liveTile("评级", g.label, "", g.chip) +
    `</div>` +
    `<p class="measure-desc" style="margin-top:10px">${escapeHtml(sc.verdict || "")}</p>` +
    (curve.length ? `<div class="live-chart-label">环回延迟 vs 负载(纯本机,无网络)</div>` +
      `<canvas class="live-canvas" id="scCanvas" width="900" height="220"></canvas>` +
      `<div class="live-legend"><span class="lg teal">RTT p50 (ms)</span></div>` : "") +
    `<div class="artifact-bar" style="margin-top:14px"><button class="btn ghost" id="scAgain">再测一次</button></div>`;
  if (curve.length) {
    drawLineChart("scCanvas", curve.map((p) => p.mbps), [
      { data: curve.map((p) => p.rtt_p50_ms || 0), color: "#3ad29f" },
    ], { yUnit: "ms", xUnit: "Mbps" });
  }
  $("scAgain").onclick = () => exitLiveMode();
}

async function renderLive(runId, el) {
  if (!el) return false;
  let live = null;
  try {
    const r = await fetch(`${API}/api/runs/${encodeURIComponent(runId)}/live`, { cache: "no-store" });
    if (!r.ok) return false;
    live = await r.json();
  } catch (_) { return false; }
  if (!live || live.status !== "running") return false;
  const series = live.series || [];
  el.hidden = false;
  if (!series.length) { el.innerHTML = liveHead("已连接 · 等待第一段窗口数据…", liveTimerText(live)); return true; }
  if (live.test_kind === "tcp") renderLiveTcp(el, live);
  else if (live.test_kind === "load") renderLiveLoad(el, live);
  else renderLiveStream(el, live);
  return true;
}

function liveHead(sub, timer) {
  return `<div class="live-head"><span class="live-dot"></span><span>测试中</span>` +
    `<span class="live-sub">${escapeHtml(sub)}</span>` +
    (timer ? `<span class="live-timer">${escapeHtml(timer)}</span>` : "") + `</div>`;
}

const mmss = (s) => { s = Math.max(0, Math.round(s)); return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); };
function liveTimerText(live) {
  const el = live.elapsed_sec != null ? live.elapsed_sec
    : (live.started_at_unix_ns ? (Date.now() * 1e6 - live.started_at_unix_ns) / 1e9 : 0);
  const dur = live.duration_sec;
  if (live.test_kind === "tcp" || !dur) return "⏱ 已运行 " + mmss(el);   // no fixed duration -> count up
  const rem = dur - el;
  return rem > 0 ? "⏳ 剩余 " + mmss(rem) + " / 共 " + mmss(dur) : "测量完成,收集数据中…";
}

function renderLiveTcp(panel, live) {
  const series = live.series || [], cur = live.current || {};
  const t = series.map((s) => (s.t_ms || 0) / 1000);
  panel.innerHTML =
    liveHead(`TCP 体感吞吐 · ${series.length} 个采样点`, liveTimerText(live)) +
    `<div class="live-tiles">` +
      liveTile("上行", fmtNum(cur.up_mbps || 0), "Mbps", "teal") +
      liveTile("下行", fmtNum(cur.down_mbps || 0), "Mbps", "amber") +
      liveTile("RTT", num(cur.rtt_ms || 0), "ms", "") +
    `</div>` +
    `<div class="live-chart-label">吞吐 Mbps(随时间)</div>` +
    `<canvas class="live-canvas" id="liveCanvasA" width="900" height="220"></canvas>` +
    `<div class="live-legend"><span class="lg teal">上行</span><span class="lg amber">下行</span></div>`;
  drawLineChart("liveCanvasA", t, [
    { data: series.map((s) => s.up_mbps || 0), color: "#3ad29f" },
    { data: series.map((s) => s.down_mbps || 0), color: "#f0b357" },
  ], { yUnit: "Mbps" });
}

function renderLiveLoad(panel, live) {
  const series = live.series || [], cur = live.current || {};
  const t = series.map((s) => (s.t_ms || 0) / 1000);
  panel.innerHTML =
    liveHead(`负载爬坡 · 发出 vs 到达 · ${series.length} 秒`, liveTimerText(live)) +
    `<div class="live-tiles">` +
      liveTile("发送速率", fmtNum(cur.sent_mbps || 0), "Mbps L1", "teal") +
      liveTile("到达速率", fmtNum(cur.recv_mbps || 0), "Mbps L1", "amber") +
      liveTile("发包", Math.round(cur.sent_pps || 0), "包/秒", "") +
      liveTile("丢包率", num(cur.loss_pct || 0), "%", (cur.loss_pct || 0) > 1 ? "warn" : "") +
    `</div>` +
    `<div class="live-chart-label">吞吐 Mbps(发出 vs 到达)随时间</div>` +
    `<canvas class="live-canvas" id="liveCanvasLoad" width="900" height="200"></canvas>` +
    `<div class="live-legend"><span class="lg teal">发出(offered)</span><span class="lg amber">到达(delivered)</span></div>`;
  drawLineChart("liveCanvasLoad", t, [
    { data: series.map((s) => s.sent_mbps || 0), color: "#3ad29f" },
    { data: series.map((s) => s.recv_mbps || 0), color: "#f0b357" },
  ], { yUnit: "Mbps" });
}

function renderLiveStream(panel, live) {
  const series = live.series || [], cur = live.current || {};
  const groups = {};  // group by direction so A→B and B→A draw as separate lines
  series.forEach((s) => { const k = `${s.from_agent_id}_${s.to_agent_id}`; (groups[k] = groups[k] || []).push(s); });
  const palette = ["#3ad29f", "#f0b357", "#7fb4ff", "#e57"];
  const lossSets = [], delaySets = [], legend = [];
  Object.keys(groups).forEach((k, i) => {
    const g = groups[k], color = palette[i % palette.length];
    lossSets.push({ data: g.map((s) => s.loss_pct || 0), t: g.map((s) => (s.t_ms || 0) / 1000), color });
    // 显示真实测到的 RTT(往返,同一时钟,准),不再显示 RTT/2 的"单向估算"——后者要两端对时
    // 才准,在不对称网络里会误导(导出场景仍保留有标注的 RTT/2 对称拆分供回放建模)。
    delaySets.push({ data: g.map((s) => (s.rtt_p50_ms ?? s.rtt_ms_p50) || 0), t: g.map((s) => (s.t_ms || 0) / 1000), color });
    legend.push(`<span class="lg" style="--lg:${color}">${escapeHtml(dirShort(g[0]))}</span>`);
  });
  // Per-direction tiles. A bidirectional stream interleaves A→B and B→A window_summaries with the
  // SAME window times, so a single `cur` tile flipped between directions each poll and pps divided
  // by Δt=0. Show each direction's OWN latest window, using that direction's own window delta (or
  // the window's real duration) for pps — never a 1s-by-fallback.
  const multiDir = Object.keys(groups).length > 1;
  const dirTiles = Object.keys(groups).map((k) => {
    const g = groups[k], last = g[g.length - 1] || {};
    const dt = g.length >= 2 ? (last.t_ms - g[g.length - 2].t_ms) / 1000 : 0;
    const win = dt > 0 ? dt : ((last.duration_ms || 1000) / 1000);
    const sp = Math.round((last.expected || 0) / win), rp = Math.round((last.received || 0) / win);
    const label = multiDir
      ? `<div class="live-dir-label" style="font-size:12px;color:#9fb0c8;margin:8px 2px 2px">${escapeHtml(dirShort(g[0]))}</div>`
      : "";
    return label + `<div class="live-tiles">` +
      liveTile("收发速率", `↑${sp} / ↓${rp}`, "包/秒", "teal") +
      liveTile("丢包率", num(last.loss_pct || 0), "%", (last.loss_pct || 0) > 1 ? "warn" : "") +
      liveTile("RTT p50 / p95", `${num((last.rtt_p50_ms ?? last.rtt_ms_p50) || 0)} / ${num((last.rtt_p95_ms ?? last.rtt_ms_p95) || 0)}`, "ms", "",
        "RTT=往返时延。p50 是典型值,p95 是尾部 —— 被 Wi-Fi 缓冲/抖动的那批包就落在 p95(常和'到达间隔抖动'同量级)。两端未对时只给 RTT,不给单向延迟。") +
      liveTile("到达间隔抖动 p95", num((last.jitter_p95_ms ?? last.jitter_ms_p95) || 0), "ms", "",
        "包到达节奏相对设定发送间隔的偏离(p95):衡量到达忽快忽慢/成簇投递。它不是往返时延(RTT),也不等于纯网络抖动——含网络成簇与收发端定时,所以可能比 RTT 大。") +
    `</div>`;
  }).join("");
  panel.innerHTML =
    liveHead(`丢包 / 时延随时间 · ${series.length} 个窗口`, liveTimerText(live)) +
    dirTiles +
    `<div class="live-chart-label">丢包率(%)随时间</div>` +
    `<canvas class="live-canvas" id="liveCanvasLoss" width="900" height="150"></canvas>` +
    `<div class="live-chart-label">RTT(往返时延, ms)随时间</div>` +
    `<canvas class="live-canvas" id="liveCanvasDelay" width="900" height="150"></canvas>` +
    (legend.length > 1 ? `<div class="live-legend">${legend.join("")}</div>` : "");
  drawLineChartMulti("liveCanvasLoss", lossSets, { yUnit: "%" });
  drawLineChartMulti("liveCanvasDelay", delaySets, { yUnit: "ms" });
}

function liveTile(label, val, unit, cls, tip) {
  return `<div class="live-tile ${cls || ""}"${tip ? ` title="${escapeHtml(tip)}"` : ""}><span class="lt-k">${label}</span>` +
    `<span class="lt-v">${val}<i>${unit}</i></span></div>`;
}
const dirShort = (s) => (s ? dirLabel(`${s.from_agent_id}_TO_${s.to_agent_id}`.toUpperCase()) : "方向");

// axis helpers
const niceMax = (v) => { if (v <= 0) return 1; const p = Math.pow(10, Math.floor(Math.log10(v))); const n = v / p; return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10) * p; };
const fmtAxis = (v) => (v >= 100 ? Math.round(v).toString() : v >= 10 ? v.toFixed(0) : v >= 1 ? v.toFixed(1) : v > 0 ? v.toFixed(2) : "0");
const fmtSecAxis = (s) => { s = Math.round(s); return s < 60 ? s + "s" : Math.floor(s / 60) + "m" + (s % 60 ? (s % 60) + "s" : ""); };

function drawLineChart(canvasId, t, sets, opts) {
  drawLineChartMulti(canvasId, sets.map((s) => ({ data: s.data, t, color: s.color })), opts);
}

// Line chart with labelled axes: y ticks (0 / mid / max in `opts.yUnit`), x ticks (elapsed seconds),
// gridlines, and a dot at each series' latest value. Each set carries its own t (seconds) array.
function drawLineChartMulti(canvasId, sets, opts) {
  opts = opts || {};
  const cv = document.getElementById(canvasId);
  if (!cv) return;
  const ctx = cv.getContext("2d");
  const W = cv.width, H = cv.height, ML = 46, MR = 12, MT = 10, MB = 22;
  const PW = W - ML - MR, PH = H - MT - MB;
  ctx.clearRect(0, 0, W, H);
  let vmax = 0, vmin = Infinity, maxT = 0; const allV = [];
  sets.forEach((s) => { s.data.forEach((v) => { allV.push(v); if (v > vmax) vmax = v; if (v < vmin) vmin = v; }); (s.t || []).forEach((v) => { if (v > maxT) maxT = v; }); });
  if (vmin === Infinity) vmin = 0;
  maxT = maxT <= 0 ? 1 : maxT;
  // Y range: default 0..max (loss/throughput — distance from 0 is the point). With opts.minBase
  // (RTT-like), use a ROBUST range so a few big spikes don't squish the common band into a flat
  // line: top ≈ the 92nd percentile (spikes above clip at the top edge; their exact values are in
  // the 最差窗口 list), bottom just under the data min. -> the everyday variation fills the height.
  let loY = 0, hiY;
  if (opts.minBase && vmax > 0) {
    const sorted = allV.slice().sort((a, b) => a - b);
    const q = (p) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.floor(p * (sorted.length - 1))))];
    hiY = Math.max(q(0.92) * 1.15, q(0.5) * 1.5, vmin + 1);
    if (hiY >= vmax) hiY = vmax * 1.05;          // no real spike -> just pad the top
    loY = Math.max(0, vmin - (hiY - vmin) * 0.08);
  } else {
    hiY = vmax <= 0 ? 1 : niceMax(vmax * 1.1);
  }
  if (hiY <= loY) hiY = loY + 1;
  const clampY = (y) => Math.max(MT, Math.min(MT + PH, y));   // keep clipped spikes on the canvas
  const px = (tv) => ML + PW * (maxT ? tv / maxT : 0);
  const py = (v) => MT + PH * (1 - (v - loY) / (hiY - loY));
  ctx.font = '11px "JetBrains Mono", ui-monospace, monospace';
  // y gridlines + labels (4 intervals -> 5 reference lines so the curve is readable)
  for (let g = 0; g <= 4; g++) {
    const frac = g / 4, y = MT + PH * (1 - frac);
    ctx.strokeStyle = "rgba(255,255,255,0.08)"; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(ML, y); ctx.lineTo(W - MR, y); ctx.stroke();
    ctx.fillStyle = "#7a93a6"; ctx.textAlign = "right"; ctx.textBaseline = "middle";
    ctx.fillText(fmtAxis(loY + (hiY - loY) * frac), ML - 6, y);
  }
  // x labels (0, mid, max). Time series (no xUnit) format as elapsed time; a non-time x axis
  // (e.g. the self-check's load axis, xUnit:"Mbps") formats as a plain number.
  const xfmt = opts.xUnit ? fmtAxis : fmtSecAxis;
  ctx.fillStyle = "#7a93a6"; ctx.textAlign = "center"; ctx.textBaseline = "top";
  for (let g = 0; g <= 2; g++) { const frac = g / 2; ctx.fillText(xfmt(maxT * frac), ML + PW * frac, H - MB + 5); }
  // y-axis unit (top-left)
  if (opts.yUnit) { ctx.fillStyle = "#566677"; ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.fillText(opts.yUnit, 4, 2); }
  // x-axis unit (bottom-right)
  if (opts.xUnit) { ctx.fillStyle = "#566677"; ctx.textAlign = "right"; ctx.textBaseline = "bottom"; ctx.fillText(opts.xUnit, W - MR, H - 2); }
  // series + latest-value dot
  sets.forEach((s) => {
    const data = s.data, tt = s.t || data.map((_, i) => i);
    if (!data.length) return;
    ctx.strokeStyle = s.color; ctx.lineWidth = 2; ctx.beginPath();
    data.forEach((v, i) => { const x = px(tt[i]), y = clampY(py(v)); i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); });
    ctx.stroke();
    const li = data.length - 1; ctx.fillStyle = s.color;
    ctx.beginPath(); ctx.arc(px(tt[li]), clampY(py(data[li])), 2.6, 0, 6.2832); ctx.fill();
  });
  // Flat-zero series (e.g. 0% loss the whole run): the line sits on the axis, so add a centered
  // hint making clear it's "no impairment", not an empty/broken chart.
  if ((vmax <= 0 || opts.forceEmpty) && opts.emptyHint) {
    ctx.fillStyle = "#5a6f68"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
    ctx.font = '13px "JetBrains Mono", ui-monospace, monospace';
    ctx.fillText(opts.emptyHint, ML + PW / 2, MT + PH / 2);
  }
}

/* ---------- 结果 ---------- */
let selectedRunId = null;

let allRuns = [];
const ACCESS_LABEL = { wifi: "WiFi", "4g": "4G", "5g": "5G", satellite: "卫星", enterprise_wan: "企业WAN", home_broadband: "家宽", wired_lan: "有线LAN", other: "其它", unknown: "未知" };

async function refreshRuns() {
  try {
    const r = await fetch(`${API}/api/runs`, { cache: "no-store" });
    const j = await r.json();
    allRuns = j.runs || [];
    renderRunList();
  } catch (_) {}
}

function runMatchesFilter(run) {
  const t = run.model_tags || {};
  const access = ($("filterAccess") && $("filterAccess").value) || "";
  const text = (($("filterText") && $("filterText").value) || "").trim().toLowerCase();
  if (access && t.access_type !== access) return false;
  if (text) {
    const hay = [run.run_id, t.operator, t.location, t.note].filter(Boolean).join(" ").toLowerCase();
    if (!hay.includes(text)) return false;
  }
  return true;
}

function renderRunList() {
  const ul = $("runList");
  ul.innerHTML = "";
  const shown = allRuns.filter(runMatchesFilter);
  if ($("sampleCount")) $("sampleCount").textContent = `样本库 ${allRuns.length} 条`;
  if (!shown.length) {
    ul.innerHTML = `<li class="empty" style="color:var(--ink-faint);font-size:.82rem;list-style:none">无匹配记录</li>`;
    return;
  }
  shown.forEach((run) => {
    const t = run.model_tags;
    const chip = t
      ? `<span class="tag env" title="${escapeHtml([t.operator, t.location, t.note].filter(Boolean).join(" · "))}">${escapeHtml(ACCESS_LABEL[t.access_type] || t.access_type || "未知")}${t.location ? " · " + escapeHtml(t.location) : ""}</span>`
      : "";
    const li = document.createElement("li");
    li.className = "run-row" + (run.run_id === selectedRunId ? " sel" : "");
    li.onclick = () => selectRun(run.run_id);
    const k = runKind(run.run_id);
    li.innerHTML =
      `<span class="rhead"><input type="checkbox" class="cmp" ${selectedSet.has(run.run_id) ? "checked" : ""} title="选择(用于批量删除)" />` +
      `<span class="kind-badge ${k.cls}">${k.label}</span>` +
      `<span class="rid" title="${escapeHtml(run.run_id)}">${escapeHtml(run.run_id)}</span></span>` +
      `<span class="rmeta"><span class="status-pill ${run.status}">${statusText(run.status)}</span>` +
      `${fmtTime(run.started_at_unix_ns)}</span>` + chip;
    const cb = li.querySelector(".cmp");
    cb.onclick = (e) => { e.stopPropagation(); toggleSelect(run.run_id); };
    ul.appendChild(li);
  });
  updateBatchBar();
}

// Multi-select for batch actions (delete) and 2-way compare. Persists across row clicks
// (clicking a row only previews its detail; it does not clear the selection).
let selectedSet = new Set();

function toggleSelect(rid) {
  if (selectedSet.has(rid)) selectedSet.delete(rid);
  else selectedSet.add(rid);
  renderRunList();
}

function updateBatchBar() {
  const n = selectedSet.size;
  const cnt = $("batchCount"); if (cnt) cnt.textContent = `已选 ${n} 项`;
  const del = $("batchDelete"); if (del) del.hidden = n === 0;     // 没选中就不显示删除
  // 全选框反映"当前筛选出的样本"的选中状态:全选→打勾,部分→半选,无→不勾
  const vis = allRuns.filter(runMatchesFilter);
  const visSel = vis.filter((r) => selectedSet.has(r.run_id)).length;
  const sa = $("selectAll");
  if (sa) {
    sa.checked = vis.length > 0 && visSel === vis.length;
    sa.indeterminate = visSel > 0 && visSel < vis.length;
  }
}

// 全选 / 取消全选:作用于"当前筛选出的样本"。已全选→清掉它们;否则→把它们都选上。
function toggleSelectAll() {
  const vis = allRuns.filter(runMatchesFilter);
  const allSel = vis.length > 0 && vis.every((r) => selectedSet.has(r.run_id));
  vis.forEach((r) => allSel ? selectedSet.delete(r.run_id) : selectedSet.add(r.run_id));
  renderRunList();
}

async function batchDeleteSelected() {
  const ids = [...selectedSet];
  if (!ids.length) return;
  if (!confirm(`确定删除选中的 ${ids.length} 个样本?\n这些测试的全部数据将被永久删除,不可恢复。`)) return;
  let ok = 0, busy = 0, fail = 0;
  for (const id of ids) {
    try {
      const r = await fetch(`${API}/api/runs/${encodeURIComponent(id)}`, { method: "DELETE" });
      if (r.ok) { ok++; selectedSet.delete(id); if (selectedRunId === id) selectedRunId = null; }
      else { const j = await r.json().catch(() => ({})); if (j.error === "run_active") busy++; else fail++; }
    } catch (_) { fail++; }
  }
  await refreshRuns();          // re-render the list (also refreshes updateBatchBar)
  if (!selectedRunId) { const box = $("runDetail"); if (box) box.innerHTML = `<p class="muted">已删除 ${ok} 个样本。</p>`; }
  if (busy || fail) alert(`删除完成:成功 ${ok}${busy ? `,跳过 ${busy} 个进行中` : ""}${fail ? `,失败 ${fail} 个` : ""}。`);
}

const fmtNum = (v) => (v == null || isNaN(v)) ? "—" : (Math.round(v * 100) / 100);

// Derive the test kind (and its badge style) from the run_id prefix, so the run
// list is scannable by type at a glance (轻量 / 负载 / TCP / 自检).
function runKind(runId) {
  const id = String(runId || "");
  // Prefix is authoritative (orchestrator names runs <kind>-<ts>); fall back to a
  // substring match so legacy/custom ids (e.g. "gui-tcp-mac-102") still classify.
  if (id.startsWith("selfcheck-") || id.includes("selfcheck")) return { label: "自检", cls: "k-self" };
  if (id.startsWith("load-") || id.includes("load")) return { label: "负载", cls: "k-load" };
  if (id.startsWith("tcp-") || id.includes("tcp")) return { label: "TCP", cls: "k-tcp" };
  if (id.startsWith("light-") || id.includes("light")) return { label: "轻量", cls: "k-light" };
  return { label: "测试", cls: "k-other" };
}

async function selectRun(runId) {
  // Clicking a row only previews its detail -- the multi-select (for batch delete) persists,
  // so the operator can review a sample without losing their checked set.
  selectedRunId = runId;
  const r = await fetch(`${API}/api/runs/${encodeURIComponent(runId)}`, { cache: "no-store" });
  if (!r.ok) {  // 404 etc: render a placeholder, don't treat the error JSON as run data
    const box = $("runDetail");
    if (box) box.innerHTML = '<p class="muted">样本不存在或已删除。</p>';
    selectedRunId = null;
    await refreshRuns();
    return;
  }
  const run = await r.json();
  await refreshRuns();
  renderDetail(run);
  if (run.status === "running") enterLiveMode(runId);  // a running run -> live view on the 发起测试 page
}

/* ---------- 跨机对端日志收集(可续传) ---------- */
function renderCollectionCard(runId, cs) {
  const status = cs.status || "pending";
  const total = cs.total_bytes || 0, done = cs.bytes_done || 0;
  const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
  const label = { pending: "待收集", collecting: "收集中", paused: "已暂停", complete: "已完成" }[status] || status;
  const cls = { pending: "warn", collecting: "go", paused: "muted", complete: "ok" }[status] || "";
  const kb = (n) => (n >= 1048576 ? (n / 1048576).toFixed(1) + " MB" : Math.round(n / 1024) + " KB");
  let actions = "";
  if (status === "pending" || status === "paused")
    actions = `<button class="btn dl-scenario" onclick="collectRun('${runId}')">${status === "paused" ? "继续收集" : "开始收集"}</button>`;
  else if (status === "collecting")
    actions = `<button class="btn ghost" onclick="pauseCollect('${runId}')">暂停</button>`;
  return `<div class="dir-card collect-card">` +
    `<h4>对端数据收集 <span class="collect-state ${cls}">${label}</span></h4>` +
    `<div class="collect-bar"><i style="width:${pct}%"></i></div>` +
    `<div class="events-line">${total ? `${kb(done)} / ${kb(total)}（${pct}%）` : "等待对端清单…"}` +
    (cs.note ? ` · ${escapeHtml(cs.note)}` : "") + `</div>` +
    (status !== "complete" ? `<div class="events-line">完整双向画像需要对端日志。网络差可暂停,稍后续传,数据不丢、不退化为往返估计。</div>` : "") +
    `<div class="artifact-bar">${actions}</div></div>`;
}

let collectPollToken = 0;
async function collectRun(runId) {
  try { await fetch(`${API}/api/runs/${encodeURIComponent(runId)}/collect`, { method: "POST" }); } catch (_) {}
  pollCollection(runId);
}
async function pauseCollect(runId) {
  try { await fetch(`${API}/api/runs/${encodeURIComponent(runId)}/collect/pause`, { method: "POST" }); } catch (_) {}
  if (selectedRunId === runId) selectRun(runId);
}
async function deleteRun(runId) {
  if (!confirm(`确定删除样本「${runId}」?\n该次测试的全部数据将被永久删除,不可恢复。`)) return;
  try {
    const r = await fetch(`${API}/api/runs/${encodeURIComponent(runId)}`, { method: "DELETE" });
    const j = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(j.error === "run_active" ? "测试进行中,无法删除" : (j.error || "删除失败"));
    selectedSet.delete(runId);  // drop it from any multi-select too
    if (selectedRunId === runId) {
      selectedRunId = null;
      const box = $("runDetail");
      if (box) box.innerHTML = `<p class="muted">样本已删除。</p>`;
    }
    await refreshRuns();
  } catch (e) {
    alert("删除失败:" + e.message);
  }
}
async function pollCollection(runId) {
  const myToken = ++collectPollToken;
  for (let i = 0; i < 600 && myToken === collectPollToken && selectedRunId === runId; i++) {
    let cs = null;
    try { cs = await (await fetch(`${API}/api/runs/${encodeURIComponent(runId)}/artifacts/collection_state.json`, { cache: "no-store" })).json(); } catch (_) {}
    if (selectedRunId === runId) await selectRun(runId);  // re-render (also picks up the built profile on complete)
    if (!cs || cs.status === "complete" || cs.status === "paused") break;
    await sleep(1500);
  }
}

async function renderDetail(run) {
  const box = $("runDetail");
  const artifacts = (run.artifacts || []).map((a) => a.name);
  const hasProfile = artifacts.includes("network_profile.json");
  const hasLoad = artifacts.includes("load_report.json");
  const hasTcp = artifacts.includes("tcp_throughput.json");
  const dk = runKind(run.run_id);
  let html =
    `<div class="detail-head"><span class="kind-badge ${dk.cls}">${dk.label}</span>` +
    `<span class="rid">${escapeHtml(run.run_id)}</span>` +
    `<span class="status-pill ${run.status}">${statusText(run.status)}</span></div>`;

  // Actions up top (导出 + 删除), one click away. Every export is the SAME button style (class
  // "dl": ⬇ icon + soft-teal) so the row reads as a tidy set of downloads, not a loud/faint mix.
  const has = (n) => artifacts.includes(n);
  const hasDev = has("device_scenario.json");
  const btns = [], rawBtns = [];   // btns: ready-made HTML strings (some are custom-onclick)
  if (hasDev) btns.push(dlBtn(run.run_id, "device_scenario.json", "⬇ 损伤仪场景(可直接导入)", "dl",
    "可直接导入你的损伤仪/仿真器的场景文件 —— 导进去就能复刻这次测到的网络"));
  if (has("wan_emulator_scenario.json"))
    btns.push(dlBtn(run.run_id, "wan_emulator_scenario.json", "⬇ 回放场景(通用格式)", "dl",
      "同样是回放文件,但用通用/中立格式 —— 给不支持上面专用格式的仿真器用"));
  // ONE load-report button: it bundles whichever directions this test produced -- both A→B and
  // B→A for a bidirectional run, or just the single direction tested. (No separate 反向报告.)
  if (hasLoad || has("load_report_peer.json")) {
    btns.push(`<button type="button" class="btn dl" title="排版好的报告,浏览器打印→另存为 PDF,方便阅读/留档" onclick="printLoadReport('${encodeURIComponent(run.run_id)}')">📄 负载报告(PDF)</button>`);
    btns.push(`<button type="button" class="btn dl" title="完整原始数据(容量/拐点/丢包/时间线),按方向打包,供工具/分析" onclick="downloadLoadReport('${encodeURIComponent(run.run_id)}')">⬇ 负载报告(JSON)</button>`);
  }
  if (hasTcp) btns.push(dlBtn(run.run_id, "tcp_throughput.json", "⬇ TCP 报告", "dl",
    "TCP 体感测试结果:上行/下行吞吐 + RTT"));
  if (has("network_model.json")) btns.push(dlBtn(run.run_id, "network_model.json", "⬇ 网络模型", "dl",
    "本次测量提炼出的标准网络画像 —— 汇入样本库,做长期积累/建模用"));
  if (hasProfile) {
    btns.push(`<button type="button" class="btn dl" title="排版好的画像报告,浏览器打印→另存为 PDF,方便阅读/留档" onclick="printProfileReport('${encodeURIComponent(run.run_id)}')">📄 画像报告(PDF)</button>`);
    btns.push(dlBtn(run.run_id, "network_profile.json", "⬇ 网络画像", "dl",
      "本次测量提炼出的网络画像(延迟/抖动/丢包/乱序…)"));
  }
  ["session_config.json", "agent_a_raw.ndjson", "agent_b_raw.ndjson"].forEach((n) => {
    if (has(n)) rawBtns.push([ n, n.replace(".ndjson", "").replace(".json", ""), "ghost raw" ]);
  });
  // Manual delete (destructive): removes this sample's data. Never offered while it's running.
  const delBtn = run.status !== "running"
    ? `<button class="btn ghost danger" onclick="deleteRun('${run.run_id}')">🗑 删除样本</button>` : "";
  html += `<div class="detail-actions">` + btns.join("") +
    `<span class="spacer"></span>` + delBtn + `</div>`;
  if (rawBtns.length)
    html += `<div class="raw-downloads"><span class="raw-label">原始数据</span>` +
      rawBtns.map(([n, l, c]) => dlBtn(run.run_id, n, l, c)).join("") + `</div>`;

  // Cross-machine peer-log collection state (resumable; rebuilds the bidir profile when done).
  if (artifacts.includes("collection_state.json") && !hasProfile) {
    try {
      const cs = await (await fetch(`${API}/api/runs/${encodeURIComponent(run.run_id)}/artifacts/collection_state.json`, { cache: "no-store" })).json();
      html += renderCollectionCard(run.run_id, cs);
    } catch (_) {}
  }

  // Capture context (model-library sample tags), if this run produced a network model.
  if (artifacts.includes("network_model.json")) {
    try {
      const m = await (await fetch(`${API}/api/runs/${encodeURIComponent(run.run_id)}/artifacts/network_model.json`, { cache: "no-store" })).json();
      const c = m.capture_context || {};
      const access = ({ wifi: "WiFi", "4g": "4G", "5g": "5G", satellite: "卫星", enterprise_wan: "企业WAN", home_broadband: "家宽", wired_lan: "有线LAN", other: "其它", unknown: "未知" }[c.access_type] || c.access_type || "未知") + (c.access_auto ? "(自动)" : "");
      const parts = [access, c.operator, c.location, c.note].filter((x) => x && x !== "未知" && x !== "未知(自动)");
      html += `<div class="events-line">环境标签:${parts.length ? escapeHtml(parts.join(" · ")) : "未填(建议填写以便入库归类)"}</div>`;
    } catch (_) {}
  }

  if (hasTcp) {
    try {
      const rep = await (await fetch(`${API}/api/runs/${encodeURIComponent(run.run_id)}/artifacts/tcp_throughput.json`, { cache: "no-store" })).json();
      html += renderTcpReport(rep);
    } catch (_) {
      html += `<p class="events-line">TCP 吞吐报告读取失败。</p>`;
    }
  } else if (hasLoad || has("load_report_peer.json")) {
    // A single-direction b_to_a run produces ONLY load_report_peer.json (the peer was the sole
    // sender) — the old `hasLoad`-only branch skipped it entirely and fell through to the light-
    // test "collect peer logs" hint, so the user saw no report for a finished load test.
    if (artifacts.includes("load_limits.json")) {
      try {
        const lim = await (await fetch(`${API}/api/runs/${encodeURIComponent(run.run_id)}/artifacts/load_limits.json`, { cache: "no-store" })).json();
        if (lim && lim.note) {
          const warn = lim.device_limited;
          html += `<div class="events-line" style="border-left:3px solid var(--${warn ? "amber" : "teal"});padding-left:10px;${warn ? "color:var(--amber)" : ""}">` +
            `${warn ? "⚠ 受设备限制:" : "✓ "}${escapeHtml(lim.note)}</div>`;
        }
      } catch (_) {}
    }
    try {
      const primaryName = hasLoad ? "load_report.json" : "load_report_peer.json";
      const both = hasLoad && has("load_report_peer.json");
      const rep = await (await fetch(`${API}/api/runs/${encodeURIComponent(run.run_id)}/artifacts/${primaryName}`, { cache: "no-store" })).json();
      // Show the direction heading whenever it isn't the plain single A→B case (two directions,
      // or a b_to_a-only run whose one report is NOT the default direction).
      if (both || !hasLoad) html += `<h4 class="dir-head">${escapeHtml(dirLabel(rep.direction))}</h4>`;
      html += renderLoadReport(rep);
      if (both) {
        const rep2 = await (await fetch(`${API}/api/runs/${encodeURIComponent(run.run_id)}/artifacts/load_report_peer.json`, { cache: "no-store" })).json();
        html += `<h4 class="dir-head">${escapeHtml(dirLabel(rep2.direction))}</h4>`;
        html += renderLoadReport(rep2);
      }
    } catch (_) {
      html += `<p class="events-line">负载报告读取失败。</p>`;
    }
  } else if (hasProfile) {
    try {
      const prof = await (await fetch(`${API}/api/runs/${encodeURIComponent(run.run_id)}/artifacts/network_profile.json`, { cache: "no-store" })).json();
      html += renderProfile(prof);
    } catch (_) {
      html += `<p class="events-line">画像文件读取失败。</p>`;
    }
  } else if (run.status === "exited") {
    html += `<p class="events-line">已完成本机发起端采集。完整双向画像需要对端日志 —— 点上方「开始收集」从对端控制台拉取(支持断点续传),收齐后自动重建双向画像。</p>`;
  } else if (run.status === "failed") {
    html += `<p class="events-line" style="color:var(--red)">测试失败，见下方日志。</p>`;
  }

  if (run.log_tail) html += `<pre class="log-tail">${escapeHtml(run.log_tail.slice(-1400))}</pre>`;
  // Guard against a stale async render overwriting a newer selection (rapid run-switching).
  if (selectedRunId && selectedRunId !== run.run_id) return;
  box.innerHTML = html;
  flushCharts();  // paint the queued timeline canvases now that they're in the DOM
}

function renderProfile(prof) {
  const rtt = (prof.summary && prof.summary.combined_rtt_ms) || {};
  let html = "";
  // Degenerate run: the initiator got no echoes back (peer never replied -- dead / contended
  // responder, firewall). Surface a loud, actionable banner instead of presenting all-zero stats
  // as a clean, lossless network. Prefer the orchestrator-authored warning (carries the hint);
  // fall back to detecting sample_count===0 for a profile built before that field existed.
  const warnings = Array.isArray(prof.warnings) ? prof.warnings : [];
  if (warnings.length || rtt.sample_count === 0) {
    const msg = warnings.length
      ? warnings.map((w) => escapeHtml(w.message || "")).join("<br>")
      : "本次测试没有收到任何回包(所有探测超时)。对端可能没收到、或收到了但回不来 —— 通常是对端 responder 没启动 / 被残留进程占了 UDP 端口,或防火墙挡了 UDP。请清理对端残留进程或检查防火墙后重测;本次统计无意义。";
    html += `<div class="warn-banner">⚠ ${msg}</div>`;
  }
  html +=
    `<div class="dir-card"><h4>综合 RTT</h4>` +
    kv("p50", num(rtt.p50), "ms") +
    kv("p95", num(rtt.p95), "ms") +
    kv("p99", num(rtt.p99), "ms") +
    kv("最大", num(rtt.max), "ms") +
    kv("样本数", rtt.sample_count != null ? rtt.sample_count : "—", "", "muted") +
    kv("超时", rtt.timeout_count != null ? rtt.timeout_count : "—", "", rtt.timeout_count ? "warn" : "muted") +
    `</div>`;
  (prof.directions || []).forEach((d) => {
    const loss = d.loss || {}, jit = d.jitter || {}, reo = d.reorder || {}, dup = d.duplicate || {}, out = d.outage || {};
    const evs = d.events || [];
    html +=
      `<div class="dir-card"><h4>${escapeHtml(dirLabel(d.id))}</h4>` +
      kv("丢包率", num(loss.avg_loss_pct), "%", lossClass(loss.avg_loss_pct)) +
      kv("到达间隔抖动 p95", num(jit.inter_arrival_jitter_p95_ms ?? jit.inter_arrival_jitter_ms_p95), "ms", "muted") +
      kv("乱序率", num(reo.rate_pct), "%", "muted") +
      kv("重复率", num(dup.rate_pct), "%", "muted") +
      kv("中断次数", out.count != null ? out.count : "—", "", out.count ? "warn" : "muted") +
      `</div>`;
    if (evs.length) {
      // Aggregate by type AND surface each type's span. A burst_loss is 100% loss *within its own
      // span only*, so show packet count + worst duration — never a bare "100%" that could be misread
      // as the whole window being dead. The per-window 丢包率 above stays the real (lower) rate.
      const agg = {};
      evs.forEach((e) => {
        const a = (agg[e.type] = agg[e.type] || { count: 0, maxDurMs: 0, maxPkts: 0, totPkts: 0, maxP95: 0 });
        a.count += 1;
        a.maxDurMs = Math.max(a.maxDurMs, Number(e.duration_ms) || 0);
        a.maxPkts = Math.max(a.maxPkts, Number(e.packets) || 0);
        a.totPkts += Number(e.packets) || 0;
        a.maxP95 = Math.max(a.maxP95, Number(e.rtt_p95_ms) || 0);
      });
      html +=
        `<div class="events-line">事件：` +
        Object.entries(agg).map(([t, a]) => {
          let scope = "";
          if (t === "burst_loss") scope = `（共 ${a.totPkts} 包，最长一阵 ${a.maxPkts} 包/${a.maxDurMs}ms 内 100% 丢失）`;
          else if (t === "micro_outage") scope = `（最长 ${a.maxDurMs}ms 无包到达）`;
          else if (t === "rtt_spike") scope = `（峰值 ${num(a.maxP95)}ms，最长 ${a.maxDurMs}ms）`;
          else if (a.maxDurMs) scope = `（最长 ${a.maxDurMs}ms）`;
          return `<span class="ev">${escapeHtml(evLabel(t))} ×${a.count}${scope}</span>`;
        }).join("") +
        `</div>`;
    }
    // quality-over-time for this direction: RTT p50/p95 (round-trip, accurate) + loss per window.
    html += renderTimeline(lightTl(d.timeline), `${dirLabel(d.id)} · 质量随时间`, "RTT");
  });
  return html;
}

function renderLoadReport(rep) {
  const knee = rep.knee || {};
  const wp = rep.working_point || {};
  const b = rep.burst_loss || {};
  const kneeText = knee.found ? `${num(knee.working_mbps)} Mbps(饱和·${escapeHtml(kneeTrigger(knee.trigger))})` : "未饱和";
  let html =
    `<div class="dir-card"><h4>容量 / 工作点</h4>` +
    kv("可用容量", num(rep.capacity_mbps), "Mbps L1线速") +
    (rep.capacity_payload_mbps != null
        ? kv("可用容量(应用层)", num(rep.capacity_payload_mbps), "Mbps payload", "muted") : "") +
    kv("工作负载", knee.found ? num(knee.working_mbps) : num(rep.capacity_mbps), "Mbps L1线速") +
    kv("饱和判据", knee.found ? escapeHtml(kneeTrigger(knee.trigger)) : "未饱和", "", "muted") +
    kv("工作点丢包", num(wp.loss_pct), "%", lossClass(wp.loss_pct)) +
    kv("工作点 RTT p50", num(wp.rtt_p50_ms), "ms", "muted") +
    `<div class="events-line">容量/吞吐为 <b>L1 线速率</b>(含 IP/UDP/以太/FCS/前导/IFG 帧开销,即链路真实占用);"应用层"为 UDP payload,可与下方 TCP 体感吞吐直接对照。</div>` +
    `</div>`;
  // Gilbert-Elliott burst model
  html +=
    `<div class="dir-card"><h4>突发丢包模型 (Gilbert-Elliott)</h4>` +
    kv("丢包率", num(b.loss_pct), "%", lossClass(b.loss_pct)) +
    kv("平均突发长度", num(b.mean_burst_len), "包", b.mean_burst_len > 1.5 ? "warn" : "muted") +
    kv("突发阵数", b.burst_count != null ? b.burst_count : "—", "", "muted") +
    kv("p (进坏态)", num(b.p), "", "muted") +
    kv("r (出坏态)", num(b.r), "", "muted") +
    `</div>`;
  // how the loss number was obtained: forward truth (peer's receive record) vs round-trip proxy.
  const method = rep.loss_method === "forward_truth"
    ? `<span style="color:var(--teal)">实测丢包(对端收包记录)</span>`
    : rep.loss_method === "round_trip"
      ? `<span style="color:var(--amber)">丢包估计(往返近似,含反向路径)</span>` : "";
  if (method) html += `<div class="events-line">测量方式:${method}</div>`;
  // honest confidence: a "0% loss" reading only means loss is below the upper bound.
  const c = rep.confidence || {};
  if (c.loss_samples != null) {
    html += `<div class="events-line">置信:丢包 ${num(c.loss_pct)}% · 95%上界 &lt; ${num(c.loss_upper95_pct)}% · 样本 ${c.loss_samples}` +
      (c.loss_reliable ? "" : ` <span style="color:var(--amber)">· 样本偏少,延长时长更可信</span>`) + `</div>`;
  }
  // working-point quality over time (the timeline profile)
  // Loss is honest only when forward-truth corrected; a round_trip report's per-window loss is the
  // nulled sparse-reflection proxy, so don't render it as an affirmative 0% timeline.
  html += renderTimeline(loadTl(rep.working_timeline), undefined, undefined,
                         rep.loss_method === "forward_truth");
  // load-vs-quality ramp curve
  const ramp = rep.ramp || [];
  if (ramp.length) {
    html += `<div class="dir-card"><h4>负载 vs 质量(爬坡)</h4>`;
    ramp.forEach((lvl) => {
      const over = knee.found && lvl.offered_mbps >= knee.working_mbps;
      html += `<div class="kv"><span class="k">${num(lvl.offered_mbps)}→${num(lvl.delivered_mbps)} Mbps</span>` +
        `<span class="v ${over ? "warn" : "muted"}">丢 ${num(lvl.loss_pct)}% · RTT ${num(lvl.rtt_p50_ms)}ms</span></div>`;
    });
    html += `</div>`;
  }
  return html;
}
// TCP felt-throughput: up/down goodput (what an app actually gets, since TCP backs off) + app RTT.
function renderTcpReport(rep) {
  const up = rep.up_series_mbps || [], down = rep.down_series_mbps || [];
  const lo = (a) => a.length ? Math.min(...a) : null;
  const swing = (a) => a.length ? (Math.max(...a) - Math.min(...a)) : null;  // 波动幅度
  // steady-state: drop the first second (TCP slow-start ramp), average the rest.
  const steady = (a) => { if (!a.length) return null; const s = a.length > 1 ? a.slice(1) : a; return s.reduce((x, y) => x + y, 0) / s.length; };
  const upS = steady(up), downS = steady(down);
  let html = `<div class="dir-card"><h4>TCP 体感吞吐 (RFC 6349)</h4>` +
    kv("上行 稳态 (去慢启动)", upS != null ? fmtNum(upS) : num(rep.up_mbps), "Mbps") +
    kv("下行 稳态 (去慢启动)", downS != null ? fmtNum(downS) : num(rep.down_mbps), "Mbps") +
    kv("上行 整段平均", num(rep.up_mbps), "Mbps", "muted") +
    kv("下行 整段平均", num(rep.down_mbps), "Mbps", "muted") +
    kv("应用层 RTT", num(rep.rtt_p50_ms ?? rep.rtt_ms_p50), "ms", "muted") +
    kv("时长", rep.duration_sec != null ? rep.duration_sec : "—", "s", "muted") +
    `</div>`;
  if (up.length || down.length) {
    html += `<div class="dir-card"><h4>稳定度(每秒)</h4>` +
      kv("上行 最低 / 波动", up.length ? `${fmtNum(lo(up))} / ±${fmtNum(swing(up))}` : "—", "Mbps", "muted") +
      kv("下行 最低 / 波动", down.length ? `${fmtNum(lo(down))} / ±${fmtNum(swing(down))}` : "—", "Mbps", "muted") +
      `</div>`;
    html += renderTcpTimeline(up, down);
  }
  html += `<div class="events-line">体感吞吐 = TCP 实际跑到的吞吐(会被拥塞控制限制),对照负载画像测的物理容量。最低/波动看稳不稳。</div>`;
  return html;
}

// Per-second TCP goodput timeline: one row per second, up (teal) + down (amber) bars.
// Charts are returned as HTML (canvas) but must be painted AFTER the container's innerHTML is set.
// renderTimeline/renderTcpTimeline queue their draws here; flushCharts() drains it post-render.
let _pendingCharts = [];
let _chartSeq = 0;
function flushCharts() {
  const jobs = _pendingCharts;
  _pendingCharts = [];
  jobs.forEach((j) => drawLineChartMulti(j.id, j.sets, j.opts));
}

// Aggregate a per-window timeline into <= maxPoints buckets so an hours-long run (thousands of
// 1 s windows) renders as a fixed-size chart, not thousands of DOM rows. Loss takes the bucket
// MAX (worst-case visible); delay takes the bucket p95-ish max.
function bucketTimeline(tl, maxPoints) {
  if (tl.length <= maxPoints) {
    return tl.map((w) => ({ t: (w.t_ms || 0) / 1000, loss: w.loss_pct || 0, p50: w.p50 || 0, p95: w.p95 || 0 }));
  }
  const size = Math.ceil(tl.length / maxPoints);
  const out = [];
  for (let i = 0; i < tl.length; i += size) {
    const seg = tl.slice(i, i + size);
    out.push({
      t: (seg[0].t_ms || 0) / 1000,
      loss: Math.max(...seg.map((w) => w.loss_pct || 0)),
      p50: Math.max(...seg.map((w) => w.p50 || 0)),
      p95: Math.max(...seg.map((w) => w.p95 || 0)),
    });
  }
  return out;
}
// Downsample a per-window timeline to <= maxRows for a PRINTED TABLE without hiding spikes: each row
// aggregates its segment by MAX of every numeric metric (worst-in-segment), like the on-screen
// bucketTimeline. Stride sampling (every Nth window) silently dropped the worst windows from the PDF.
function maxBucketRows(rows, maxRows) {
  if (rows.length <= maxRows) return rows;
  const step = Math.ceil(rows.length / maxRows);
  const out = [];
  for (let i = 0; i < rows.length; i += step) {
    const seg = rows.slice(i, i + step);
    const agg = { ...seg[0] };  // segment-start t_ms + any non-numeric fields
    for (const key of Object.keys(seg[0])) {
      if (key === "t_ms" || key === "duration_ms") continue;
      if (typeof seg[0][key] === "number") agg[key] = Math.max(...seg.map((w) => w[key] || 0));
    }
    out.push(agg);
  }
  return out;
}
// The N most-degraded windows (by loss, then p95), so detail isn't lost when downsampling.
function worstWindows(tl, n) {
  return tl.map((w, i) => ({ ...w, i }))
    .filter((w) => (w.loss_pct || 0) > 0 || (w.p95 || 0) > 0)
    .sort((a, b) => (b.loss_pct - a.loss_pct) || (b.p95 - a.p95) || (b.p50 - a.p50))
    .slice(0, n);
}

function renderTcpTimeline(up, down) {
  const n = Math.max(up.length, down.length);
  if (!n) return "";
  const id = "tcpcv" + (++_chartSeq);
  const t = Array.from({ length: n }, (_, i) => i);
  _pendingCharts.push({ id, sets: [
    { data: up.map((v) => v || 0), t, color: "#3ad29f" },
    { data: down.map((v) => v || 0), t, color: "#f0b357" },
  ] });
  return `<div class="dir-card"><h4>TCP 吞吐随时间 · ${n}s</h4>` +
    `<canvas class="live-canvas" id="${id}" width="900" height="170"></canvas>` +
    `<div class="live-legend"><span class="lg teal">上行 Mbps</span><span class="lg amber">下行 Mbps</span></div></div>`;
}

// Per-window quality-over-time, scaled for any duration: a downsampled canvas chart (loss% +
// delay) plus a "最差窗口 Top-N" list. Windows: {t_ms, duration_ms, p50, p95, loss_pct}.
function renderTimeline(tl, title = "工作点 · 质量随时间", metric = "RTT", lossMeasured = true) {
  if (!tl.length) return "";
  const last = tl[tl.length - 1];
  const durSec = Math.round(((last.t_ms || 0) + (last.duration_ms || 0)) / 1000);
  const pts = bucketTimeline(tl, 300);
  const t = pts.map((p) => p.t);
  const lossId = "lcv" + (++_chartSeq), delayId = "dcv" + (++_chartSeq);
  // loss: keep a 0 baseline (distance from 0 is the point); show a hint when it's flat-zero so an
  // empty chart reads as "no loss", not "broken". delay: zoom the y-axis to the data band (minBase)
  // so the p50/p95 detail isn't squished into a flat line near the bottom.
  // When loss was NOT measured (a round-trip report whose per-window loss is the nulled sparse-
  // reflection proxy), do NOT plot the nulls as a flat 0% line claiming "no loss" -- label it
  // unmeasured so the chart never affirms 0% loss it didn't measure.
  const lossHint = lossMeasured ? "全程 0% 丢包" : "丢包未测量(往返近似;运行 recompute 或参考 light 测量)";
  _pendingCharts.push({ id: lossId, sets: [{ data: pts.map((p) => (lossMeasured ? p.loss : 0)), t, color: "#f0857f" }], opts: { yUnit: "%", emptyHint: lossHint, forceEmpty: !lossMeasured } });
  _pendingCharts.push({ id: delayId, sets: [
    { data: pts.map((p) => p.p95), t, color: "#7fb4ff" },
    { data: pts.map((p) => p.p50), t, color: "#3ad29f" },
  ], opts: { yUnit: "ms", minBase: true } });
  const worst = worstWindows(tl, 8);
  const worstRows = worst.map((w) => {
    const t0 = Math.round((w.t_ms || 0) / 1000);
    return `<div class="worst-row"><span class="wt">${t0}s</span>` +
      (lossMeasured ? `<span class="wl ${lossClass(w.loss_pct)}">丢 ${num(w.loss_pct)}%</span>`
                    : `<span class="wl muted">丢包未测量</span>`) +
      `<span class="wd">${metric} 中位 ${num(w.p50)} · p95 ${num(w.p95)}${w.p99 != null ? ` · p99 ${num(w.p99)}` : ""}${w.max != null ? ` · 峰值 ${num(w.max)}` : ""} ms</span></div>`;
  }).join("");
  return `<div class="dir-card"><h4>${escapeHtml(title)}</h4>` +
    `<div class="tl-meta">时长 ${durSec}s · ${tl.length} 个窗口${tl.length > pts.length ? `(降采样到 ${pts.length} 点)` : ""}</div>` +
    `<div class="live-chart-label">丢包率 %</div><canvas class="live-canvas" id="${lossId}" width="900" height="120"></canvas>` +
    `<div class="live-chart-label">${escapeHtml(metric)} ms(青=p50 中位 · 蓝=p95;纵轴按数据范围放大)</div><canvas class="live-canvas" id="${delayId}" width="900" height="120"></canvas>` +
    (worstRows ? `<div class="live-chart-label">最差窗口 Top ${worst.length}(p95 高=偶发卡顿)</div><div class="worst-list">${worstRows}</div>` : "") +
    `</div>`;
}
// Map a load working_timeline window to the generic shape. Load RTT has ~200 reflections/s (the
// responder reflects sparsely, ~1 per 5 ms), so ~200 samples/window -> p99 is meaningful here; also
// carry max as the absolute worst.
const loadTl = (tl) => (tl || []).map((w) => ({ t_ms: w.t_ms, duration_ms: w.duration_ms, p50: w.rtt_p50_ms, p95: w.rtt_p95_ms, p99: w.rtt_p99_ms, max: w.rtt_max_ms, loss_pct: w.loss_pct }));
// Map a light-path direction timeline to the generic shape. The profile stores round-trip rtt_ms_*
// (raw); halve here for the one-way display (the /2 symmetric split is a view choice, not baked in).
// Light echo is ~20/window so its per-window p99 is rough (≈ max) -- shown for parity, not precision.
// 逐窗显示真实 RTT(往返,准),不再用 RTT/2 的单向估算(不对称网络会误导)。
const lightTl = (tl) => (tl || []).map((w) => ({ t_ms: w.t_ms, duration_ms: w.duration_ms, p50: (w.rtt_p50_ms ?? w.rtt_ms_p50) || 0, p95: (w.rtt_p95_ms ?? w.rtt_ms_p95) || 0, p99: (w.rtt_p99_ms ?? w.rtt_ms_p99) || 0, max: (w.rtt_max_ms ?? w.rtt_ms_max) || 0, loss_pct: w.loss_pct }));
const kneeTrigger = (t) => ({ latency: "时延上翘", delivered_plateau: "投递封顶", loss: "丢包起点" }[t] || t || "");

/* ---------- helpers ---------- */
function kv(k, v, u, cls) {
  return `<div class="kv"><span class="k">${k}</span><span class="v ${cls || ""}">${v}${u ? `<span class="u">${u}</span>` : ""}</span></div>`;
}
function dlBtn(runId, name, label, cls, title) {
  // A plain <a href download> is a native browser GET that bypasses our fetch wrapper, so it
  // carries no X-FR-Token and the controller answers 401 ("需要授权"). Fetch it through the
  // (token-injecting) wrapper instead and save the blob — token stays in the header, not the URL.
  const tip = title ? ` title="${escapeHtml(title)}"` : "";
  return `<button type="button" class="btn ${cls}"${tip} onclick="downloadArtifact('${encodeURIComponent(runId)}','${encodeURIComponent(name)}')">${escapeHtml(label)}</button>`;
}
async function downloadArtifact(runIdEnc, nameEnc) {
  const name = decodeURIComponent(nameEnc);
  try {
    const r = await fetch(`${API}/api/runs/${runIdEnc}/artifacts/${nameEnc}`, { cache: "no-store" });
    if (!r.ok) throw new Error(r.status === 401 ? "需要令牌(到「本机信息」确认令牌已填)" : `HTTP ${r.status}`);
    const blob = await r.blob();
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url; a.download = name;
    document.body.appendChild(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 2000);
  } catch (e) {
    alert("下载失败:" + e.message);
  }
}

// Shared print-to-PDF styling + machinery for the load and profile reports (clean light theme).
const REPORT_CSS = `body{font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;color:#16261f;margin:32px;background:#fff;line-height:1.5}
    header{border-bottom:2px solid #16c3a6;padding-bottom:12px;margin-bottom:18px}
    h1{font-size:20px;margin:0 0 4px} .sub{color:#5a6b66;font-size:12px;font-family:ui-monospace,monospace}
    h2{font-size:15px;color:#0e8f7a;margin:24px 0 10px;border-left:4px solid #16c3a6;padding-left:9px}
    h3{font-size:12px;margin:15px 0 5px;color:#33443f;text-transform:none}
    .cards{display:flex;flex-wrap:wrap;gap:9px;margin:8px 0 4px}
    .card{border:1px solid #d8e0de;border-radius:8px;padding:7px 13px;min-width:96px}
    .card .cl{display:block;font-size:10.5px;color:#7a8a85} .card .cv{font-size:18px;font-weight:700;color:#0e8f7a}
    .card .cv.sm{font-size:14px} .card .cv i{font-size:10.5px;font-weight:400;color:#7a8a85;margin-left:2px}
    table{border-collapse:collapse;width:100%;font-size:11.5px;margin:3px 0}
    th,td{border:1px solid #e3eae8;padding:4px 9px;text-align:right} th{background:#f1f7f5;color:#445;font-weight:600}
    p{font-size:12px;margin:4px 0} .muted{color:#7a8a85;font-size:11px}
    footer{margin-top:26px;border-top:1px solid #e3eae8;padding-top:8px;color:#9aa8a3;font-size:10px}
    @media print{body{margin:12mm}h2{break-after:avoid}table{break-inside:avoid}}`;

// Print an HTML doc via a hidden iframe (NOT window.open): opening a popup after an `await` loses
// the click gesture and the browser blocks it. An in-page iframe has nothing for the blocker to catch.
function printHtmlDoc(doc) {
  const frame = document.createElement("iframe");
  frame.setAttribute("aria-hidden", "true");
  frame.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0;opacity:0;";
  document.body.appendChild(frame);
  const idoc = frame.contentWindow.document;
  idoc.open(); idoc.write(doc); idoc.close();
  let fired = false;
  const fire = () => {
    if (fired) return; fired = true;
    try {
      const win = frame.contentWindow;
      win.focus();
      win.onafterprint = () => setTimeout(() => frame.remove(), 0);
      win.print();                                    // opens the print dialog (-> 另存为 PDF)
    } catch (e) { alert("打印失败:" + e.message); frame.remove(); }
    setTimeout(() => { if (frame.parentNode) frame.remove(); }, 120000);  // safety cleanup
  };
  frame.onload = fire;
  setTimeout(fire, 400);  // document.write() may not fire onload -> fallback
}

// Human-readable, print-to-PDF report for a LIGHT measurement's network profile: combined RTT +
// per-direction loss/jitter/reorder/duplicate + Gilbert-Elliott burst model + per-window timeline.
async function printProfileReport(runIdEnc) {
  const runId = decodeURIComponent(runIdEnc);
  const grab = async (name) => {
    try { const r = await fetch(`${API}/api/runs/${runIdEnc}/artifacts/${name}`, { cache: "no-store" }); return r.ok ? await r.json() : null; }
    catch (_) { return null; }
  };
  const prof = await grab("network_profile.json"), nm = await grab("network_model.json");
  if (!prof) { alert("没有网络画像可生成。"); return; }
  const n = (v, dp = 2) => (v == null || isNaN(v)) ? "—" : (Math.round(v * 10 ** dp) / 10 ** dp);
  const cc = (nm && nm.capture_context) || {};
  const envParts = [ACCESS_LABEL[cc.access_type] || cc.access_type, cc.operator, cc.location, cc.note]
    .filter((x) => x && x !== "未知").map(escapeHtml);
  const s = prof.summary || {}, rtt = s.combined_rtt_ms || {}, rel = s.reliability || {};
  const m = prof.measurement || {}, q = prof.quality || {};
  const dirName = (id) => id === "B_TO_A" ? "对端 → 发起方 (B→A)" : "发起方 → 对端 (A→B)";
  const dirSection = (d) => {
    const loss = d.loss || {}, ge = loss.gilbert_elliott || {}, j = d.jitter || {},
          ro = d.reorder || {}, du = d.duplicate || {}, og = d.outage || {};
    const tl = d.timeline || [];
    let tlBlock = "";
    if (tl.length) {
      const shown = maxBucketRows(tl, 48);  // worst-in-segment, never drop a spike (was stride sampling)
      const rows = shown.map((w) =>
        `<tr><td>${n((w.t_ms || 0) / 1000, 1)}</td><td>${n(w.loss_pct, 2)}</td><td>${n(w.rtt_p50_ms ?? w.rtt_ms_p50)}</td><td>${n(w.rtt_p95_ms ?? w.rtt_ms_p95)}</td><td>${n(w.rtt_p99_ms ?? w.rtt_ms_p99)}</td><td>${n(w.rtt_max_ms ?? w.rtt_ms_max)}</td><td>${n(w.jitter_p95_ms ?? w.jitter_ms_p95)}</td><td>${n(w.reorder_pct, 2)}</td><td>${n(w.duplicate_pct, 2)}</td></tr>`).join("");
      tlBlock = `<h3>逐窗质量（时间线 · 共 ${tl.length} 窗${tl.length > shown.length ? `,降采样到 ${shown.length} 行(每段取最差)` : ""}）</h3>
        <table><thead><tr><th>时刻 s</th><th>丢包 %</th><th>RTT p50</th><th>RTT p95</th><th>RTT p99</th><th>RTT max</th><th>抖动 p95</th><th>乱序 %</th><th>重复 %</th></tr></thead><tbody>${rows}</tbody></table>`;
    }
    return `<h2>${dirName(d.id)}</h2>
      <div class="cards">
        <div class="card"><span class="cl">平均丢包</span><span class="cv">${n(loss.avg_loss_pct)}<i>%</i></span></div>
        <div class="card"><span class="cl">抖动 p95</span><span class="cv">${n(j.inter_arrival_jitter_p95_ms ?? j.inter_arrival_jitter_ms_p95)}<i>ms</i></span></div>
        <div class="card"><span class="cl">乱序</span><span class="cv">${n(ro.rate_pct)}<i>%</i></span></div>
        <div class="card"><span class="cl">重复</span><span class="cv">${n(du.rate_pct)}<i>%</i></span></div>
        <div class="card"><span class="cl">中断次数</span><span class="cv">${og.count != null ? og.count : "—"}</span></div>
      </div>
      <h3>抖动（到达间隔)</h3>
      <p>p50 ${n(j.inter_arrival_jitter_p50_ms ?? j.inter_arrival_jitter_ms_p50)} ms · p95 ${n(j.inter_arrival_jitter_p95_ms ?? j.inter_arrival_jitter_ms_p95)} ms · p99 ${n(j.inter_arrival_jitter_p99_ms ?? j.inter_arrival_jitter_ms_p99)} ms</p>
      <h3>突发丢包模型 (Gilbert-Elliott)</h3>
      <p>丢包率 ${n((ge.loss_rate || 0) * 100)}% · 平均突发长度 ${n(ge.mean_burst_len, 1)} 包 · 突发阵数 ${ge.burst_count != null ? ge.burst_count : "—"} · p=${n(ge.p, 4)} r=${n(ge.r, 3)}</p>
      <p class="muted">置信:丢包样本 ${loss.loss_samples != null ? loss.loss_samples : "—"} · 95%上界 &lt; ${n(loss.loss_upper95_pct)}% · 可信=${loss.loss_reliable === true ? "是" : "否"}</p>
      ${tlBlock}`;
  };
  const doc = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>轻量测量报告 ${escapeHtml(runId)}</title><style>${REPORT_CSS}</style></head><body>
    <header><h1>BestPerf 轻量测量报告</h1>
      <div class="sub">样本 ${escapeHtml(runId)}　·　生成于 ${escapeHtml(new Date().toLocaleString())}${envParts.length ? "　·　环境 " + envParts.join(" · ") : ""}</div></header>
    <h2>综合 RTT（往返时延）</h2>
    <div class="cards">
      <div class="card"><span class="cl">p50</span><span class="cv">${n(rtt.p50)}<i>ms</i></span></div>
      <div class="card"><span class="cl">p95</span><span class="cv">${n(rtt.p95)}<i>ms</i></span></div>
      <div class="card"><span class="cl">p99</span><span class="cv">${n(rtt.p99)}<i>ms</i></span></div>
      <div class="card"><span class="cl">最大</span><span class="cv">${n(rtt.max)}<i>ms</i></span></div>
      <div class="card"><span class="cl">RTT 样本</span><span class="cv">${rtt.sample_count != null ? rtt.sample_count : "—"}</span></div>
    </div>
    <p class="muted">测量 ${n(m.duration_sec, 0)}s · 窗口 ${m.window_ms}ms · echo ${m.echo_interval_ms}ms · stream ${m.stream_interval_ms}ms · 置信 ${escapeHtml(q.profile_confidence || "—")} · RTT p95可信=${rel.rtt_p95_reliable ? "是" : "否"} · p99可信=${rel.rtt_p99_reliable ? "是" : "否"}</p>
    <p class="muted">注:显示的是实测 RTT(往返)。两端未对时,不提供准确单向延迟;回放场景里按对称 RTT/2 拆分,已标注为估算。</p>
    ${(prof.directions || []).map(dirSection).join("")}
    <footer>BestPerf · 现网实测 → 复刻　|　RTT=往返时延(准);丢包/抖动/乱序按方向测量;突发丢包=Gilbert-Elliott 两态模型</footer>
    </body></html>`;
  printHtmlDoc(doc);
}

// A human-readable, print-to-PDF load report. Builds a clean LIGHT-theme HTML doc (summary cards +
// ramp/refine tables + burst-loss/confidence) from whichever directions exist, opens it in a new
// window and triggers the browser's print dialog (-> "Save as PDF"). No deps, works offline.
async function printLoadReport(runIdEnc) {
  const runId = decodeURIComponent(runIdEnc);
  const grab = async (name) => {
    try { const r = await fetch(`${API}/api/runs/${runIdEnc}/artifacts/${name}`, { cache: "no-store" }); return r.ok ? await r.json() : null; }
    catch (_) { return null; }
  };
  const a = await grab("load_report.json"), b = await grab("load_report_peer.json"), nm = await grab("network_model.json");
  const reps = [a, b].filter(Boolean);
  if (!reps.length) { alert("没有负载报告可生成。"); return; }
  const n = (v, dp = 2) => (v == null || isNaN(v)) ? "—" : (Math.round(v * 10 ** dp) / 10 ** dp);
  const cc = (nm && nm.capture_context) || {};
  const envParts = [ACCESS_LABEL[cc.access_type] || cc.access_type, cc.operator, cc.location, cc.note]
    .filter((x) => x && x !== "未知").map(escapeHtml);
  const dirName = (d) => d === "B_TO_A" ? "对端 → 发起方 (B→A)" : "发起方 → 对端 (A→B)";
  const rowsOf = (arr) => (arr || []).map((L) =>
    `<tr><td>${n(L.offered_mbps ?? L.mbps, 1)}</td><td>${n(L.delivered_mbps, 1)}</td><td>${n(L.loss_pct, 2)}</td><td>${n(L.rtt_p50_ms ?? L.rtt_avg_ms, 2)}</td></tr>`).join("");
  const section = (rep) => {
    const k = rep.knee || {}, wp = rep.working_point || {}, bl = rep.burst_loss || {}, cf = rep.confidence || {};
    const refine = rep.refine_probes || [];
    // Working-point characterize timeline: per-window quality DURING the sustained working-point
    // hold (the final result, NOT the ramp). Downsample long runs to keep the table readable.
    const wt = rep.working_timeline || [];
    let wtBlock = "";
    if (wt.length) {
      const shown = maxBucketRows(wt, 48);  // worst-in-segment, never drop a spike (was stride sampling)
      const wtRows = shown.map((w) =>
        `<tr><td>${n((w.t_ms || 0) / 1000, 1)}</td><td>${n(w.loss_pct, 2)}</td><td>${n(w.rtt_p50_ms)}</td><td>${n(w.rtt_p95_ms)}</td><td>${n(w.rtt_p99_ms)}</td><td>${n(w.rtt_max_ms)}</td><td>${n(w.reorder_pct, 2)}</td><td>${n(w.duplicate_pct, 2)}</td></tr>`).join("");
      // Loss is honest only when forward-truth corrected: a round_trip report's loss windows are
      // NULLED (unmeasured), and `(w.loss_pct || 0)` averaged them into an affirmative "0%".
      const lossMeasured = rep.loss_method === "forward_truth";
      const avgLoss = wt.reduce((s, w) => s + (w.loss_pct || 0), 0) / wt.length;
      wtBlock = `<h3>工作点持续刻画（在 ${n(wp.offered_mbps ?? k.working_mbps, 0)} Mbps 持续 ${n((wt.length * (wt[0] && wt[0].duration_ms || 1000)) / 1000, 0)}s · 共 ${wt.length} 窗${wt.length > shown.length ? `,降采样到 ${shown.length} 行(每段取最差)` : ""}）</h3>
        <p class="muted">${lossMeasured ? `平均丢包 ${n(avgLoss)}%` : "丢包未测量(往返近似,不可作回放依据)"}；下表为逐窗质量(时间线)。</p>
        <table><thead><tr><th>时刻 s</th><th>丢包 %</th><th>RTT p50</th><th>RTT p95</th><th>RTT p99</th><th>RTT max</th><th>乱序 %</th><th>重复 %</th></tr></thead><tbody>${wtRows}</tbody></table>`;
    }
    return `<h2>${dirName(rep.direction)}</h2>
      <div class="cards">
        <div class="card"><span class="cl">可用容量 (L1线速)</span><span class="cv">${n(rep.capacity_mbps)}<i>Mbps</i></span></div>
        <div class="card"><span class="cl">工作负载 (L1线速)</span><span class="cv">${n(k.working_mbps, 0)}<i>Mbps</i></span></div>
        <div class="card"><span class="cl">饱和判据</span><span class="cv sm">${escapeHtml(k.trigger || "—")}</span></div>
        <div class="card"><span class="cl">工作点丢包</span><span class="cv">${n(wp.loss_pct)}<i>%</i></span></div>
        <div class="card"><span class="cl">工作点 RTT p50</span><span class="cv">${n(wp.rtt_p50_ms)}<i>ms</i></span></div>
      </div>
      ${wtBlock}
      <h3>爬坡逐档（探路找拐点过程）</h3>
      <table><thead><tr><th>发送 Mbps</th><th>到达 Mbps</th><th>丢包 %</th><th>RTT p50 ms</th></tr></thead><tbody>${rowsOf(rep.ramp || rep.levels)}</tbody></table>
      ${refine.length ? `<h3>二分细化探测（拐点附近精测容量）</h3><table><thead><tr><th>发送 Mbps</th><th>到达 Mbps</th><th>丢包 %</th><th>RTT p50 ms</th></tr></thead><tbody>${rowsOf(refine)}</tbody></table>` : ""}
      <h3>突发丢包模型 (Gilbert-Elliott)</h3>
      <p>${rep.loss_method === "forward_truth"
        ? `丢包率 ${n((bl.loss_rate || 0) * 100)}% · 平均突发长度 ${n(bl.mean_burst_len, 1)} 包 · 突发阵数 ${bl.burst_count ?? "—"} · p=${n(bl.p, 4)} r=${n(bl.r, 3)}`
        : "未测量 —— 本方向缺前向真值(往返近似),突发模型不可用;请收集对端日志后 recompute,或参考轻量测量"}</p>
      <p class="muted">置信:丢包 ${n(cf.loss_pct)}% · 95%上界 &lt; ${n(cf.loss_upper95_pct)}% · 样本 ${cf.loss_samples ?? "—"} · 可信=${cf.loss_reliable === true ? "是" : "否"}</p>`;
  };
  const doc = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>负载报告 ${escapeHtml(runId)}</title><style>${REPORT_CSS}</style></head><body>
    <header><h1>BestPerf 负载测试报告</h1>
      <div class="sub">样本 ${escapeHtml(runId)}　·　生成于 ${escapeHtml(new Date().toLocaleString())}${envParts.length ? "　·　环境 " + envParts.join(" · ") : ""}</div></header>
    ${reps.map(section).join("")}
    <footer>BestPerf · 现网实测 → 复刻　|　容量=拐点处验证容量;工作点=拐点以下可用速率;突发丢包=Gilbert-Elliott 两态模型</footer>
    </body></html>`;
  printHtmlDoc(doc);
}

// One "负载报告" download that bundles whichever directions the test produced: load_report.json
// (发起方→对端 A→B) and/or load_report_peer.json (对端→发起方 B→A). Bidirectional -> both keys;
// single-direction -> just the one. Keyed by each report's own `direction` field.
async function downloadLoadReport(runIdEnc) {
  const runId = decodeURIComponent(runIdEnc);
  const grab = async (name) => {
    try { const r = await fetch(`${API}/api/runs/${runIdEnc}/artifacts/${name}`, { cache: "no-store" }); return r.ok ? await r.json() : null; }
    catch (_) { return null; }
  };
  const directions = {};
  for (const [name, fallbackKey] of [["load_report.json", "a_to_b"], ["load_report_peer.json", "b_to_a"]]) {
    const rep = await grab(name);
    if (rep) directions[(String(rep.direction || "").toLowerCase()) || fallbackKey] = rep;
  }
  if (!Object.keys(directions).length) { alert("没有负载报告可下载。"); return; }
  const bundle = { schema: "field_replay.load_report_bundle.v1", run_id: runId, directions };
  const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url; a.download = `load_report_${runId}.json`;
  document.body.appendChild(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 2000);
}
const num = (v) => { const x = (v == null) ? NaN : Number(v); return Number.isFinite(x) ? (Math.round(x * 100) / 100).toString() : "—"; };  // coerce first (like fmtNum) so a non-numeric string shows "—", not "NaN"
const lossClass = (v) => (v == null ? "muted" : v > 1 ? "warn" : "");
const dirLabel = (id) => (id === "A_TO_B" ? "发起方 → 对端 (A→B)" : id === "B_TO_A" ? "对端 → 发起方 (B→A)" : id || "方向");
const evLabel = (t) => ({ micro_outage: "微中断", burst_loss: "突发丢包", rtt_spike: "时延尖峰" }[t] || t);
const statusText = (s) => ({ running: "测试中", exited: "完成", failed: "失败", archived: "归档" }[s] || s);
function fmtTime(ns) {
  if (!ns) return "";
  const d = new Date(ns / 1e6);
  return d.toLocaleString("zh-CN", { hour12: false, month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function setNote(msg, cls) {
  const n = $("launchNote");
  n.textContent = msg;
  n.className = "launch-note" + (cls ? " " + cls : "");
}
function escapeHtml(s) {
  return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

/* ---------- 事件绑定 ---------- */
function init() {
  renderProbes();
  renderEndpoints();
  $("fromProbe").onchange = updateOrbSub;
  $("toProbe").onchange = updateOrbSub;
  $("swapBtn").onclick = () => {
    const f = $("fromProbe").value, t = $("toProbe").value;
    $("fromProbe").value = t;
    $("toProbe").value = f;
    updateOrbSub();
  };
  $("probeForm").onsubmit = async (e) => {
    e.preventDefault();
    const note = $("probeNote");
    const setPN = (msg, cls) => { if (note) { note.textContent = msg || ""; note.className = "probe-note" + (cls ? " " + cls : ""); } };
    const name = $("probeName").value.trim();
    const ctrlUrl = $("probeCtrl").value.trim();
    let host = $("probeHost").value.trim();
    let port = parseInt($("probePort").value, 10);   // NaN when left blank -> auto-detect
    const ctrlToken = ($("probeToken") && $("probeToken").value.trim()) || "";
    if (!name) { setPN("请填一个名称。", "err"); return; }
    if (!ctrlUrl) { setPN("请填对端控制台地址(http://对端IP:8768)。", "err"); return; }
    if (!host) host = hostFromUrl(ctrlUrl);
    // 端口会在对端自动避让(39700 被占就换),所以不能写死;直接从对端 identity 读真实端口。
    setPN("正在识别对端…");
    const id = await fetchPeerIdentity(ctrlUrl, ctrlToken);
    if (id) {
      if (!Number.isInteger(port)) port = id.agent_udp_port || 39700;
      if (!host && id.lan_ips && id.lan_ips[0]) host = id.lan_ips[0];
      setPN(`✓ 已连到对端「${id.hostname || host}」,UDP 探测端口 ${port}。`, "ok");
    } else {
      if (!Number.isInteger(port)) port = 39700;
      setPN(`⚠ 暂时没连到对端控制台(请确认对端已打开「允许被别人测向本机」开关)。已先按端口 ${port} 登记,之后可在该行修改。`, "err");
    }
    if (!host) { setPN("识别不到对端 IP,请在「对端 IP」里手动填写。", "err"); return; }
    const maxId = Math.max(100, ...probes.map((p) => p.agentId || 0));
    probes.push({ id: "p" + Date.now(), name, host, port, agentId: maxId + 1,
                  ctrlUrl: ctrlUrl || undefined, ctrlToken: ctrlToken || undefined });
    saveProbes(probes);
    $("probeName").value = "";
    $("probeHost").value = "";
    $("probePort").value = "";
    $("probeCtrl").value = "";
    if ($("probeToken")) $("probeToken").value = "";
    renderProbes();
    renderEndpoints();
  };
  document.querySelectorAll("#tabs .tab").forEach((t) => { t.onclick = () => showTab(t.dataset.tab); });
  // 刷新后停在上次所在的标签页;没有记录(或非法)则默认「本机信息」。
  let savedTab = "home";
  try { savedTab = localStorage.getItem(TAB_KEY) || "home"; } catch (_) {}
  showTab(TABS.includes(savedTab) ? savedTab : "home");
  document.querySelectorAll('input[name="measure"]').forEach((r) => {
    r.onchange = () => { if (r.checked) setMeasure(r.value); };
  });
  setMeasure("light");  // initial param visibility + blurb
  $("startBtn").onclick = () => startTest();
  if ($("batchDelete")) $("batchDelete").onclick = batchDeleteSelected;
  if ($("selectAll")) $("selectAll").onclick = toggleSelectAll;
  // Sample-library filters (access type + free text). runMatchesFilter reads these; re-render on change.
  if ($("filterAccess")) $("filterAccess").onchange = renderRunList;
  if ($("filterText")) $("filterText").oninput = renderRunList;

  loadIdentity().then(() => { pollHealth(); refreshRuns(); });
  setInterval(pollHealth, 5000);
}

function selfcheckIdLine(sc) {
  if (!sc) {
    return `<div class="id-field"><span class="id-k">本机性能自检</span>` +
      `<span class="id-v" style="color:var(--ink-faint)">未自检 — 去「发起测试 → 本机性能自检」</span></div>`;
  }
  const g = SELFCHECK_GRADE[sc.grade] || SELFCHECK_GRADE.moderate;
  return `<div class="id-field"><span class="id-k">本机性能自检</span>` +
    `<span class="id-v">UDP 上限 ~${fmtNum(sc.udp_ceiling_mbps || 0)} Mbps · 评级 ${g.label}</span></div>`;
}

/* ---------- 本机连接信息卡(ToDesk 式:地址=ID,令牌=密码) ---------- */
function renderIdentityCard() {
  const el = $("identityCard");
  if (!el) return;
  const d = IDENTITY;
  if (!d) {
    el.innerHTML = `<div class="id-head"><span class="id-dot" style="background:var(--ink-faint);box-shadow:none"></span>` +
      `<span>本机连接信息</span></div><div class="id-warn">控制器离线,无法读取本机信息。请确认控制台进程在运行。</div>`;
    return;
  }
  const masked = AUTH_TOKEN ? "•".repeat(Math.min(12, AUTH_TOKEN.length)) : "";
  // 只有本机(loopback)操作者能改暴露开关 —— /api/identity 只对本机返回 token,所以 d.token 在 = 本机。
  const isLocal = !!d.token;
  // 技术细节弱化成一行灰字(大多数人用不到)
  const techLine =
    `<div class="id-tech">` +
      `<span><em>本机 IP / 端口</em>${escapeHtml((d.lan_ips || []).join(", "))} : ${d.port}</span>` +
      `<span><em>UDP 探测端口</em>${d.agent_udp_port}</span>` +
    `</div>`;
  // 暴露开关 + 行内淡提示(取代原来那块吵闹的琥珀警告)
  const exposeControl =
    `<div class="id-expose-row"><div class="id-expose-top"><span class="id-k">允许被别人测向本机</span>` +
    (isLocal
      ? `<span class="switch ${d.exposed ? "on" : ""}" id="exposeSwitch" role="switch" aria-checked="${d.exposed ? "true" : "false"}" tabindex="0"><i></i></span>` +
        `<span class="id-v sm" id="exposeState" style="color:${d.exposed ? "var(--teal)" : "var(--ink-faint)"}">${d.exposed ? "已开启 · 对端可连" : "已关闭 · 仅本机"}</span>`
      : `<span class="id-v sm" style="color:var(--ink-faint)">${d.exposed ? "已开启" : "已关闭"} · 需在那台机器自己的控制台上开关</span>`) +
    `</div>` +
    `<p class="id-expose-hint ${d.exposed ? "ok" : ""}">` +
      (d.exposed
        ? `✓ 已允许对端连接。把上面的【地址 + 令牌】发给对方,他就能测向这台机器。`
        : `关闭时仅本机可用。开启后,别人用上面的【地址 + 令牌】即可测向你(Windows 用户还需双击桌面「2 开放网络权限」放行防火墙)。`) +
    `</p></div>`;
  // Token field: local (loopback) callers get it auto from /api/identity; a REMOTE browser gets
  // no token, so offer an input to paste the one shown on that machine's own console / terminal.
  const tokenField = AUTH_TOKEN
    ? `<span class="id-v mono" id="idTok" data-full="${escapeHtml(AUTH_TOKEN)}">${escapeHtml(masked)}</span>` +
      `<button class="id-copy" id="idReveal">显示</button>` +
      `<button class="id-copy" data-copy="${escapeHtml(AUTH_TOKEN)}">复制</button>`
    : `<input class="id-token-input" id="idTokInput" placeholder="远程访问:粘贴这台机器控制台上显示的令牌" />` +
      `<button class="id-copy" id="idTokSave">保存</button>`;
  el.innerHTML =
    `<div class="id-head"><span class="id-dot"></span><span>本机连接信息</span></div>` +
    // 主:发给对方的两样东西
    `<div class="id-grid id-share">` +
      `<div class="id-field"><span class="id-k">控制台地址</span>` +
        `<span class="id-v" id="idUrl">${escapeHtml(d.console_url)}</span>` +
        `<button class="id-copy" data-copy="${escapeHtml(d.console_url)}">复制</button></div>` +
      `<div class="id-field"><span class="id-k">访问令牌</span>${tokenField}</div>` +
    `</div>` +
    techLine +
    `<div class="id-divider"></div>` +
    exposeControl +
    `<div class="id-bench">${selfcheckIdLine(d.selfcheck)}</div>`;
  el.querySelectorAll(".id-copy[data-copy]").forEach((b) => {
    b.onclick = () => { navigator.clipboard && navigator.clipboard.writeText(b.dataset.copy); b.textContent = "已复制"; setTimeout(() => (b.textContent = "复制"), 1200); };
  });
  const reveal = $("idReveal");
  if (reveal) reveal.onclick = () => { const t = $("idTok"); const shown = t.textContent.includes("•"); t.textContent = shown ? t.dataset.full : "•".repeat(Math.min(12, AUTH_TOKEN.length)); reveal.textContent = shown ? "隐藏" : "显示"; };
  const save = $("idTokSave");
  if (save) save.onclick = () => {
    const v = ($("idTokInput").value || "").trim();
    if (!v) return;
    AUTH_TOKEN = v; localStorage.setItem(TOKEN_KEY, v);
    loadIdentity().then(() => { pollHealth(); refreshRuns(); });  // re-auth + reload with the token
  };
  const sw = $("exposeSwitch");
  if (sw) {
    const flip = async () => {
      const enabled = !sw.classList.contains("on");
      sw.classList.toggle("on", enabled);  // optimistic; loadIdentity re-renders authoritative state
      const st = $("exposeState");
      if (st) { st.textContent = enabled ? "切换中…" : "切换中…"; }
      try {
        const r = await fetch(`${API}/api/expose`, {
          method: "POST", headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ enabled }),
        });
        if (!r.ok) throw new Error("HTTP " + r.status);
        await loadIdentity();  // re-render card with the persisted state
      } catch (e) {
        sw.classList.toggle("on", !enabled);  // revert on failure
        if (typeof setNote === "function") setNote("切换暴露失败:" + e.message, "err");
        renderIdentityCard();
      }
    };
    sw.onclick = flip;
    sw.onkeydown = (ev) => { if (ev.key === " " || ev.key === "Enter") { ev.preventDefault(); flip(); } };
  }
}

document.addEventListener("DOMContentLoaded", init);
