419 lines
15 KiB
JavaScript
419 lines
15 KiB
JavaScript
// dashboard.js — 指标看板:运行选择器 + 折线图(指标趋势) + 柱状图(vs 达标阈值)。
|
||||
|
|
// 纯前端,数据来自 GET /api/runs,复用 MetricPresenter 的方向语义与阈值。
|
|||
|
|
|
|||
|
|
(function attachDashboard(globalObj) {
|
|||
|
|
const Dashboard = {
|
|||
|
|
_runs: [], // 全量 runs(已按 finished_at 倒序来自 API)
|
|||
|
|
_selected: new Set(), // 当前勾选的 run_id 集合
|
|||
|
|
_focusId: null, // 柱状图聚焦的 run_id
|
|||
|
|
_trendChart: null,
|
|||
|
|
_barChart: null,
|
|||
|
|
|
|||
|
|
// ── 入口 ─────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async load() {
|
|||
|
|
const wrap = document.getElementById("dashboard-wrap");
|
|||
|
|
if (!wrap) return;
|
|||
|
|
wrap.innerHTML = '<p class="muted">加载中…</p>';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const data = await API.runs();
|
|||
|
|
Dashboard._runs = (data.runs || []).slice().sort(
|
|||
|
|
(a, b) => (a.finished_at || "").localeCompare(b.finished_at || "")
|
|||
|
|
);
|
|||
|
|
Dashboard._selected = new Set(Dashboard._runs.map((r) => r.run_id));
|
|||
|
|
Dashboard._focusId = Dashboard._runs.length
|
|||
|
|
? Dashboard._runs[Dashboard._runs.length - 1].run_id
|
|||
|
|
: null;
|
|||
|
|
Dashboard._render(wrap);
|
|||
|
|
} catch (err) {
|
|||
|
|
wrap.innerHTML = `<p class="muted">加载失败:${App.escape(err.message)}</p>`;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ── 渲染 ─────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
_render(wrap) {
|
|||
|
|
if (!Dashboard._runs.length) {
|
|||
|
|
wrap.innerHTML = `
|
|||
|
|
<div class="empty">
|
|||
|
|
<p>暂无评测运行数据。</p>
|
|||
|
|
<p class="muted">触发一次评测或通过 Dify 工具调用后,数据将在此显示。</p>
|
|||
|
|
</div>`;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
wrap.innerHTML = "";
|
|||
|
|
|
|||
|
|
// 运行选择器面板
|
|||
|
|
wrap.appendChild(Dashboard._buildSelector());
|
|||
|
|
|
|||
|
|
// 图表区
|
|||
|
|
const chartRow = document.createElement("div");
|
|||
|
|
chartRow.className = "dashboard-charts";
|
|||
|
|
chartRow.innerHTML = `
|
|||
|
|
<div class="panel dashboard-chart-panel">
|
|||
|
|
<div class="db-panel-head-bar">
|
|||
|
|
<div>
|
|||
|
|
<div class="section-label tight">📈 指标趋势折线图</div>
|
|||
|
|
<div class="muted" style="font-size:12px;margin-top:2px">按时间顺序展示所选运行的各指标均值变化</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<canvas id="db-trend-chart"></canvas>
|
|||
|
|
<p class="db-chart-hint" id="db-trend-hint"></p>
|
|||
|
|
</div>
|
|||
|
|
<div class="panel dashboard-chart-panel">
|
|||
|
|
<div class="db-panel-head-bar">
|
|||
|
|
<div>
|
|||
|
|
<div class="section-label tight">📊 指标达标对比柱状图</div>
|
|||
|
|
<div class="muted" style="font-size:12px;margin-top:2px">实际均值 vs 达标阈值(深绿柱)</div>
|
|||
|
|
</div>
|
|||
|
|
<select class="select" id="db-focus-select" style="min-width:220px"></select>
|
|||
|
|
</div>
|
|||
|
|
<canvas id="db-bar-chart"></canvas>
|
|||
|
|
<p class="db-chart-hint">达标阈值:higher-better 指标 0.85 · noise_sensitivity 0.15</p>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
wrap.appendChild(chartRow);
|
|||
|
|
|
|||
|
|
Dashboard._populateFocusSelect();
|
|||
|
|
Dashboard._drawTrend();
|
|||
|
|
Dashboard._drawBar();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 运行选择器
|
|||
|
|
_buildSelector() {
|
|||
|
|
const panel = document.createElement("div");
|
|||
|
|
panel.className = "panel";
|
|||
|
|
panel.innerHTML = `
|
|||
|
|
<div class="panel-head">
|
|||
|
|
<div>
|
|||
|
|
<span class="section-label tight">运行选择(折线图数据源)</span>
|
|||
|
|
<span class="muted" style="font-size:12px; margin-left:10px">勾选≥2个运行可看趋势</span>
|
|||
|
|
</div>
|
|||
|
|
<div style="display:flex;gap:8px">
|
|||
|
|
<button class="btn btn-sm" id="db-sel-all">全选</button>
|
|||
|
|
<button class="btn btn-sm" id="db-sel-none">清空</button>
|
|||
|
|
<input class="form-input" id="db-filter-input" placeholder="按场景名过滤…" style="width:180px;padding:6px 10px" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="db-run-list" id="db-run-list"></div>
|
|||
|
|
`;
|
|||
|
|
setTimeout(() => {
|
|||
|
|
Dashboard._renderRunList();
|
|||
|
|
document.getElementById("db-sel-all").onclick = () => {
|
|||
|
|
Dashboard._runs.forEach((r) => Dashboard._selected.add(r.run_id));
|
|||
|
|
Dashboard._renderRunList();
|
|||
|
|
Dashboard._drawTrend();
|
|||
|
|
};
|
|||
|
|
document.getElementById("db-sel-none").onclick = () => {
|
|||
|
|
Dashboard._selected.clear();
|
|||
|
|
Dashboard._renderRunList();
|
|||
|
|
Dashboard._drawTrend();
|
|||
|
|
};
|
|||
|
|
document.getElementById("db-filter-input").oninput = (e) => {
|
|||
|
|
Dashboard._renderRunList(e.target.value.toLowerCase());
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
return panel;
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
_renderRunList(filter) {
|
|||
|
|
const list = document.getElementById("db-run-list");
|
|||
|
|
if (!list) return;
|
|||
|
|
list.innerHTML = "";
|
|||
|
|
const visible = filter
|
|||
|
|
? Dashboard._runs.filter((r) =>
|
|||
|
|
(r.scenario_name || r.run_id).toLowerCase().includes(filter)
|
|||
|
|
)
|
|||
|
|
: Dashboard._runs;
|
|||
|
|
[...visible].reverse().forEach((run) => {
|
|||
|
|
const row = document.createElement("label");
|
|||
|
|
row.className = "db-run-row";
|
|||
|
|
const chips = (run.metrics || [])
|
|||
|
|
.slice(0, 4)
|
|||
|
|
.map((m) => {
|
|||
|
|
const v = run.metric_means ? run.metric_means[m] : null;
|
|||
|
|
const cls = App.scoreClass(m, v);
|
|||
|
|
const text = v === null || v === undefined ? "n/a" : Number(v).toFixed(2);
|
|||
|
|
return `<span class="metric-chip"><span class="db-chip-name">${App.escape(App.shortMetric(m))}</span> <b class="${cls}">${text}</b></span>`;
|
|||
|
|
})
|
|||
|
|
.join("");
|
|||
|
|
row.innerHTML = `
|
|||
|
|
<input type="checkbox" class="db-run-cb" data-id="${App.escape(run.run_id)}"
|
|||
|
|
${Dashboard._selected.has(run.run_id) ? "checked" : ""} />
|
|||
|
|
<span class="db-run-label">
|
|||
|
|
<span class="db-run-name">${App.escape(run.scenario_name || run.run_id)}</span>
|
|||
|
|
<span class="muted" style="font-size:11px">${App.escape(App.shortTime(run.finished_at))} · ${App.escape(run.judge_model || "")}</span>
|
|||
|
|
</span>
|
|||
|
|
<span class="db-run-chips">${chips}</span>
|
|||
|
|
`;
|
|||
|
|
row.querySelector(".db-run-cb").addEventListener("change", (e) => {
|
|||
|
|
if (e.target.checked) Dashboard._selected.add(run.run_id);
|
|||
|
|
else Dashboard._selected.delete(run.run_id);
|
|||
|
|
Dashboard._drawTrend();
|
|||
|
|
});
|
|||
|
|
list.appendChild(row);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 填充柱状图聚焦下拉
|
|||
|
|
_populateFocusSelect() {
|
|||
|
|
const sel = document.getElementById("db-focus-select");
|
|||
|
|
if (!sel) return;
|
|||
|
|
sel.innerHTML = "";
|
|||
|
|
[...Dashboard._runs].reverse().forEach((run) => {
|
|||
|
|
const opt = document.createElement("option");
|
|||
|
|
opt.value = run.run_id;
|
|||
|
|
opt.textContent = `${run.scenario_name || run.run_id} ${App.shortTime(run.finished_at)}`;
|
|||
|
|
if (run.run_id === Dashboard._focusId) opt.selected = true;
|
|||
|
|
sel.appendChild(opt);
|
|||
|
|
});
|
|||
|
|
sel.onchange = () => {
|
|||
|
|
Dashboard._focusId = sel.value;
|
|||
|
|
Dashboard._drawBar();
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ── 折线图 ────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
_drawTrend() {
|
|||
|
|
const canvas = document.getElementById("db-trend-chart");
|
|||
|
|
const hint = document.getElementById("db-trend-hint");
|
|||
|
|
if (!canvas) return;
|
|||
|
|
|
|||
|
|
const selected = Dashboard._runs.filter((r) => Dashboard._selected.has(r.run_id));
|
|||
|
|
if (selected.length === 0) {
|
|||
|
|
if (Dashboard._trendChart) { Dashboard._trendChart.destroy(); Dashboard._trendChart = null; }
|
|||
|
|
if (hint) hint.textContent = "请在上方勾选至少 1 个运行。";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (hint) {
|
|||
|
|
hint.textContent = selected.length === 1
|
|||
|
|
? "只有 1 个运行,折线退化为单点——勾选更多运行可看趋势。"
|
|||
|
|
: `共 ${selected.length} 个运行 · 横轴按完成时间升序`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { labels, datasets } = Dashboard._buildTrendDatasets(selected);
|
|||
|
|
// 精选对比度高、色盲友好的颜色组合
|
|||
|
|
const colors = [
|
|||
|
|
"#009999", // petrol brand
|
|||
|
|
"#3b82f6", // blue
|
|||
|
|
"#f97316", // orange
|
|||
|
|
"#8b5cf6", // violet
|
|||
|
|
"#ec4899", // pink
|
|||
|
|
"#06b6d4", // cyan
|
|||
|
|
"#f59e0b", // amber
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
if (Dashboard._trendChart) Dashboard._trendChart.destroy();
|
|||
|
|
Dashboard._trendChart = new Chart(canvas, {
|
|||
|
|
type: "line",
|
|||
|
|
data: {
|
|||
|
|
labels,
|
|||
|
|
datasets: [
|
|||
|
|
// 达标参考线(0.85,虚线,不显示在图例前列)
|
|||
|
|
{
|
|||
|
|
label: "达标参考线 0.85",
|
|||
|
|
data: Array(labels.length).fill(0.85),
|
|||
|
|
borderColor: "#16a34a",
|
|||
|
|
borderDash: [6, 4],
|
|||
|
|
borderWidth: 1.5,
|
|||
|
|
pointRadius: 0,
|
|||
|
|
fill: false,
|
|||
|
|
order: 99,
|
|||
|
|
},
|
|||
|
|
...datasets.map((ds, i) => ({
|
|||
|
|
label: ds.label,
|
|||
|
|
data: ds.data,
|
|||
|
|
borderColor: colors[i % colors.length],
|
|||
|
|
backgroundColor: colors[i % colors.length] + "18",
|
|||
|
|
borderWidth: 2.5,
|
|||
|
|
pointRadius: 5,
|
|||
|
|
pointHoverRadius: 7,
|
|||
|
|
pointBackgroundColor: colors[i % colors.length],
|
|||
|
|
pointBorderColor: "#fff",
|
|||
|
|
pointBorderWidth: 2,
|
|||
|
|
tension: 0.25,
|
|||
|
|
spanGaps: false,
|
|||
|
|
})),
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
options: {
|
|||
|
|
responsive: true,
|
|||
|
|
maintainAspectRatio: false,
|
|||
|
|
interaction: { mode: "index", intersect: false },
|
|||
|
|
plugins: {
|
|||
|
|
legend: {
|
|||
|
|
position: "bottom",
|
|||
|
|
labels: { font: { size: 12 }, boxWidth: 14, padding: 16, usePointStyle: true, pointStyleWidth: 12 },
|
|||
|
|
},
|
|||
|
|
tooltip: {
|
|||
|
|
backgroundColor: "#1a2942",
|
|||
|
|
titleColor: "#e2e8f0",
|
|||
|
|
bodyColor: "#cbd5e1",
|
|||
|
|
borderColor: "#334155",
|
|||
|
|
borderWidth: 1,
|
|||
|
|
padding: 10,
|
|||
|
|
callbacks: {
|
|||
|
|
label: (ctx) => {
|
|||
|
|
if (ctx.raw === null) return ` ${ctx.dataset.label}: —`;
|
|||
|
|
return ` ${ctx.dataset.label}: ${Number(ctx.raw).toFixed(3)}`;
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
scales: {
|
|||
|
|
y: {
|
|||
|
|
min: 0, max: 1,
|
|||
|
|
ticks: { stepSize: 0.1, font: { size: 11 }, color: "#94a3b8" },
|
|||
|
|
grid: { color: "#f1f5f9" },
|
|||
|
|
border: { display: false },
|
|||
|
|
},
|
|||
|
|
x: {
|
|||
|
|
ticks: { font: { size: 11 }, color: "#64748b", maxRotation: 30 },
|
|||
|
|
grid: { display: false },
|
|||
|
|
border: { display: false },
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 柱状图 ──────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
_drawBar() {
|
|||
|
|
const canvas = document.getElementById("db-bar-chart");
|
|||
|
|
if (!canvas) return;
|
|||
|
|
|
|||
|
|
const run = Dashboard._runs.find((r) => r.run_id === Dashboard._focusId);
|
|||
|
|
if (!run) return;
|
|||
|
|
|
|||
|
|
const { labels, actual, thresholds, colors, targetMet } =
|
|||
|
|
Dashboard._buildComparisonData(run);
|
|||
|
|
|
|||
|
|
if (Dashboard._barChart) Dashboard._barChart.destroy();
|
|||
|
|
Dashboard._barChart = new Chart(canvas, {
|
|||
|
|
type: "bar",
|
|||
|
|
data: {
|
|||
|
|
labels,
|
|||
|
|
datasets: [
|
|||
|
|
{
|
|||
|
|
label: "实际分数",
|
|||
|
|
data: actual,
|
|||
|
|
backgroundColor: colors,
|
|||
|
|
borderRadius: 5,
|
|||
|
|
barPercentage: 0.6,
|
|||
|
|
categoryPercentage: 0.75,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: "达标阈值",
|
|||
|
|
data: thresholds,
|
|||
|
|
// 深绿实心柱(不透明,无边框线)
|
|||
|
|
backgroundColor: "#15803d",
|
|||
|
|
borderRadius: 5,
|
|||
|
|
barPercentage: 0.6,
|
|||
|
|
categoryPercentage: 0.75,
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
options: {
|
|||
|
|
responsive: true,
|
|||
|
|
maintainAspectRatio: false,
|
|||
|
|
interaction: { mode: "index", intersect: false },
|
|||
|
|
plugins: {
|
|||
|
|
legend: {
|
|||
|
|
position: "bottom",
|
|||
|
|
labels: { font: { size: 12 }, boxWidth: 14, padding: 16, usePointStyle: false },
|
|||
|
|
},
|
|||
|
|
tooltip: {
|
|||
|
|
backgroundColor: "#1a2942",
|
|||
|
|
titleColor: "#e2e8f0",
|
|||
|
|
bodyColor: "#cbd5e1",
|
|||
|
|
borderColor: "#334155",
|
|||
|
|
borderWidth: 1,
|
|||
|
|
padding: 10,
|
|||
|
|
callbacks: {
|
|||
|
|
afterBody: (ctx) => {
|
|||
|
|
if (!ctx.length) return [];
|
|||
|
|
const idx = ctx[0].dataIndex;
|
|||
|
|
return [targetMet[idx] ? "✓ 达标" : "✗ 未达标"];
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
scales: {
|
|||
|
|
y: {
|
|||
|
|
min: 0, max: 1,
|
|||
|
|
ticks: { stepSize: 0.1, font: { size: 11 }, color: "#94a3b8" },
|
|||
|
|
grid: { color: "#f1f5f9" },
|
|||
|
|
border: { display: false },
|
|||
|
|
},
|
|||
|
|
x: {
|
|||
|
|
ticks: { font: { size: 11 }, color: "#64748b" },
|
|||
|
|
grid: { display: false },
|
|||
|
|
border: { display: false },
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ── 纯数据函数(便于测试)────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 从选中的 runs(按时间升序)构建折线图数据集。
|
|||
|
|
* 每条线 = 一个指标;X 轴 = 各运行的简短标签;无值处补 null(断线)。
|
|||
|
|
* @param {Array} runs - 已按 finished_at 升序排序的 run 对象数组
|
|||
|
|
* @returns {{ labels: string[], datasets: Array<{label:string, data:Array<number|null>}> }}
|
|||
|
|
*/
|
|||
|
|
_buildTrendDatasets(runs) {
|
|||
|
|
// 合并所有 runs 出现过的指标(保持首次出现顺序)
|
|||
|
|
const metricSet = [];
|
|||
|
|
runs.forEach((r) => {
|
|||
|
|
(r.metrics || []).forEach((m) => {
|
|||
|
|
if (!metricSet.includes(m)) metricSet.push(m);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const labels = runs.map(
|
|||
|
|
(r) => `${r.scenario_name || r.run_id}\n${App.shortTime(r.finished_at)}`
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const datasets = metricSet.map((m) => ({
|
|||
|
|
label: m + (MetricPresenter.isLowerBetter(m) ? " (越低越好)" : ""),
|
|||
|
|
data: runs.map((r) => {
|
|||
|
|
const v = r.metric_means ? r.metric_means[m] : null;
|
|||
|
|
return v !== null && v !== undefined ? Number(v) : null;
|
|||
|
|
}),
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
return { labels, datasets };
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 从单个 run 构建柱状图对比数据。
|
|||
|
|
* @param {Object} run
|
|||
|
|
* @returns {{ labels, actual, thresholds, colors, targetMet }}
|
|||
|
|
*/
|
|||
|
|
_buildComparisonData(run) {
|
|||
|
|
const metrics = run.metrics || [];
|
|||
|
|
const labels = metrics.map((m) => App.shortMetric(m));
|
|||
|
|
const actual = metrics.map((m) => {
|
|||
|
|
const v = run.metric_means ? run.metric_means[m] : null;
|
|||
|
|
return v !== null && v !== undefined ? Number(v) : null;
|
|||
|
|
});
|
|||
|
|
const thresholds = metrics.map((m) => MetricPresenter.passThreshold(m));
|
|||
|
|
const targetMet = metrics.map((m, i) =>
|
|||
|
|
MetricPresenter.meetsTarget(m, actual[i])
|
|||
|
|
);
|
|||
|
|
const colorMap = { good: "#4ade80", warn: "#fbbf24", bad: "#f87171", na: "#cbd5e1" };
|
|||
|
|
const colors = metrics.map((m, i) => colorMap[App.scoreClass(m, actual[i])] || "#cbd5e1");
|
|||
|
|
|
|||
|
|
return { labels, actual, thresholds, colors, targetMet };
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
globalObj.Dashboard = Dashboard;
|
|||
|
|
})(typeof window !== "undefined" ? window : this);
|