390 lines
13 KiB
JavaScript
390 lines
13 KiB
JavaScript
const state = {
|
|
runs: [],
|
|
selectedId: null,
|
|
metrics: [],
|
|
refreshTimer: null,
|
|
};
|
|
|
|
const elements = {
|
|
connectionStatus: document.getElementById("connectionStatus"),
|
|
refreshButton: document.getElementById("refreshButton"),
|
|
runCount: document.getElementById("runCount"),
|
|
runList: document.getElementById("runList"),
|
|
emptyRuns: document.getElementById("emptyRuns"),
|
|
emptySelection: document.getElementById("emptySelection"),
|
|
runDetail: document.getElementById("runDetail"),
|
|
runStatus: document.getElementById("runStatus"),
|
|
runTask: document.getElementById("runTask"),
|
|
runName: document.getElementById("runName"),
|
|
runDescription: document.getElementById("runDescription"),
|
|
updatedAt: document.getElementById("updatedAt"),
|
|
epochValue: document.getElementById("epochValue"),
|
|
stepValue: document.getElementById("stepValue"),
|
|
lossValue: document.getElementById("lossValue"),
|
|
lossTrend: document.getElementById("lossTrend"),
|
|
qualityLabel: document.getElementById("qualityLabel"),
|
|
qualityValue: document.getElementById("qualityValue"),
|
|
qualityContext: document.getElementById("qualityContext"),
|
|
gpuValue: document.getElementById("gpuValue"),
|
|
gpuContext: document.getElementById("gpuContext"),
|
|
qualityLegend: document.getElementById("qualityLegend"),
|
|
trainingChart: document.getElementById("trainingChart"),
|
|
gpuChart: document.getElementById("gpuChart"),
|
|
metadata: document.getElementById("metadata"),
|
|
logOutput: document.getElementById("logOutput"),
|
|
};
|
|
|
|
const statusLabels = {
|
|
queued: "排队中",
|
|
running: "训练中",
|
|
completed: "已完成",
|
|
failed: "失败",
|
|
cancelled: "已取消",
|
|
};
|
|
|
|
const metricLabels = {
|
|
map: "mAP",
|
|
miou: "mIoU",
|
|
accuracy: "Accuracy",
|
|
loss: "Loss",
|
|
};
|
|
|
|
async function requestJson(path) {
|
|
const response = await fetch(path, { cache: "no-store" });
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(data.error || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
function formatNumber(value, digits = 4) {
|
|
return Number.isFinite(Number(value)) ? Number(value).toFixed(digits) : "—";
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return "—";
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime())
|
|
? value
|
|
: new Intl.DateTimeFormat("zh-CN", {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
}).format(date);
|
|
}
|
|
|
|
function durationText(run) {
|
|
const start = run.started_at ? new Date(run.started_at).getTime() : null;
|
|
const end = run.ended_at ? new Date(run.ended_at).getTime() : Date.now();
|
|
if (!start || Number.isNaN(start) || Number.isNaN(end)) return "—";
|
|
const seconds = Math.max(0, Math.round((end - start) / 1000));
|
|
if (seconds < 60) return `${seconds} 秒`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainder = seconds % 60;
|
|
if (minutes < 60) return `${minutes} 分 ${remainder} 秒`;
|
|
return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`;
|
|
}
|
|
|
|
function renderRunList() {
|
|
elements.runCount.textContent = String(state.runs.length);
|
|
elements.emptyRuns.hidden = state.runs.length > 0;
|
|
elements.runList.replaceChildren();
|
|
|
|
state.runs.forEach((run) => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = `run-button${run.id === state.selectedId ? " active" : ""}`;
|
|
button.dataset.runId = run.id;
|
|
button.setAttribute("aria-pressed", run.id === state.selectedId ? "true" : "false");
|
|
|
|
const title = document.createElement("strong");
|
|
title.textContent = run.name || run.id;
|
|
const meta = document.createElement("span");
|
|
meta.className = "run-meta";
|
|
const task = document.createElement("span");
|
|
task.textContent = run.task || "custom";
|
|
const status = document.createElement("span");
|
|
status.className = "run-state";
|
|
status.textContent = statusLabels[run.status] || run.status || "未知";
|
|
meta.append(task, status);
|
|
button.append(title, meta);
|
|
button.addEventListener("click", () => selectRun(run.id));
|
|
elements.runList.append(button);
|
|
});
|
|
}
|
|
|
|
function latestByKind(kind) {
|
|
for (let index = state.metrics.length - 1; index >= 0; index -= 1) {
|
|
if (state.metrics[index].kind === kind) return state.metrics[index];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function trainMetrics() {
|
|
return state.metrics.filter((metric) => metric.kind === "train");
|
|
}
|
|
|
|
function qualityKey(run, train) {
|
|
const preferred = run.primary_metric;
|
|
if (preferred && train.some((item) => Number.isFinite(Number(item[preferred])))) {
|
|
return preferred;
|
|
}
|
|
return ["map", "miou", "accuracy"].find((key) =>
|
|
train.some((item) => Number.isFinite(Number(item[key])))
|
|
) || "loss";
|
|
}
|
|
|
|
function renderSummary(run) {
|
|
const train = trainMetrics();
|
|
const latestTrain = train.at(-1);
|
|
const latestGpu = latestByKind("gpu");
|
|
const key = qualityKey(run, train);
|
|
const qualityValues = train
|
|
.map((item) => Number(item[key]))
|
|
.filter(Number.isFinite);
|
|
const bestQuality = qualityValues.length
|
|
? (run.higher_is_better ? Math.max(...qualityValues) : Math.min(...qualityValues))
|
|
: null;
|
|
|
|
elements.runStatus.textContent = statusLabels[run.status] || run.status || "未知";
|
|
elements.runStatus.className = `status-badge ${run.status || ""}`;
|
|
elements.runTask.textContent = run.task || "custom";
|
|
elements.runName.textContent = run.name || run.id;
|
|
elements.runDescription.textContent = run.description || "未填写任务说明。";
|
|
elements.updatedAt.textContent = new Intl.DateTimeFormat("zh-CN", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
}).format(new Date());
|
|
|
|
elements.epochValue.textContent = latestTrain?.epoch ?? "—";
|
|
elements.stepValue.textContent = latestTrain?.step
|
|
? `Step ${latestTrain.step}`
|
|
: "尚无 step";
|
|
elements.lossValue.textContent = formatNumber(latestTrain?.loss);
|
|
if (train.length > 1 && Number.isFinite(Number(latestTrain?.loss))) {
|
|
const firstLoss = Number(train.find((item) => Number.isFinite(Number(item.loss)))?.loss);
|
|
const latestLoss = Number(latestTrain.loss);
|
|
const delta = firstLoss ? ((latestLoss - firstLoss) / firstLoss) * 100 : null;
|
|
elements.lossTrend.textContent = Number.isFinite(delta)
|
|
? `较起点 ${delta <= 0 ? "下降" : "上升"} ${Math.abs(delta).toFixed(1)}%`
|
|
: "已记录训练指标";
|
|
} else {
|
|
elements.lossTrend.textContent = "等待指标";
|
|
}
|
|
|
|
const label = metricLabels[key] || key;
|
|
elements.qualityLabel.textContent = `最佳 ${label}`;
|
|
elements.qualityLegend.textContent = label;
|
|
elements.qualityValue.textContent = formatNumber(bestQuality);
|
|
elements.qualityContext.textContent =
|
|
key === "loss" ? "数值越低越好" : "数值越高越好";
|
|
|
|
elements.gpuValue.textContent = latestGpu
|
|
? `${formatNumber(latestGpu.gpu_utilization, 0)}%`
|
|
: "—";
|
|
elements.gpuContext.textContent = latestGpu
|
|
? `显存 ${formatNumber(latestGpu.gpu_memory_percent, 0)}% · ${formatNumber(latestGpu.gpu_temperature_c, 0)}°C`
|
|
: "等待采样";
|
|
}
|
|
|
|
function pathFor(values, width, height, padding, minValue, maxValue) {
|
|
if (!values.length) return "";
|
|
const range = maxValue - minValue || 1;
|
|
const span = Math.max(1, values.length - 1);
|
|
return values
|
|
.map((value, index) => {
|
|
const x = padding.left + (index / span) * (width - padding.left - padding.right);
|
|
const y =
|
|
padding.top +
|
|
(1 - (value - minValue) / range) *
|
|
(height - padding.top - padding.bottom);
|
|
return `${index === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`;
|
|
})
|
|
.join(" ");
|
|
}
|
|
|
|
function chartSvg({ series, emptyText, height = 250, ariaLabel }) {
|
|
const validSeries = series
|
|
.map((item) => ({
|
|
...item,
|
|
values: item.values.filter((value) => Number.isFinite(Number(value))).map(Number),
|
|
}))
|
|
.filter((item) => item.values.length);
|
|
|
|
if (!validSeries.length) {
|
|
return `<div class="chart-empty">${emptyText}</div>`;
|
|
}
|
|
|
|
const width = 920;
|
|
const padding = { left: 52, right: 18, top: 15, bottom: 32 };
|
|
const lines = [];
|
|
const labels = [];
|
|
[0, 0.5, 1].forEach((ratio) => {
|
|
const y = padding.top + ratio * (height - padding.top - padding.bottom);
|
|
lines.push(
|
|
`<line class="grid-line" x1="${padding.left}" y1="${y}" x2="${width - padding.right}" y2="${y}"></line>`
|
|
);
|
|
});
|
|
labels.push(
|
|
`<text class="axis-label" x="${padding.left}" y="${height - 9}">起点</text>`,
|
|
`<text class="axis-label" x="${width - padding.right}" y="${height - 9}" text-anchor="end">最新</text>`
|
|
);
|
|
|
|
validSeries.forEach((item) => {
|
|
const minValue = item.fixedMin ?? Math.min(...item.values);
|
|
const maxValue = item.fixedMax ?? Math.max(...item.values);
|
|
const d = pathFor(item.values, width, height, padding, minValue, maxValue);
|
|
lines.push(`<path class="${item.className}" d="${d}"></path>`);
|
|
const latest = item.values.at(-1);
|
|
labels.push(
|
|
`<text class="axis-label" x="${width - padding.right}" y="${item.labelY}" text-anchor="end">${item.label}: ${formatNumber(latest, item.digits ?? 2)}</text>`
|
|
);
|
|
});
|
|
|
|
return `
|
|
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${ariaLabel}">
|
|
${lines.join("")}
|
|
${labels.join("")}
|
|
</svg>
|
|
`;
|
|
}
|
|
|
|
function renderCharts(run) {
|
|
const train = trainMetrics();
|
|
const quality = qualityKey(run, train);
|
|
const lossValues = train.map((item) => item.loss).filter((value) => value !== undefined);
|
|
const qualityValues = train
|
|
.map((item) => item[quality])
|
|
.filter((value) => value !== undefined);
|
|
const qualityLabel = metricLabels[quality] || quality;
|
|
|
|
elements.trainingChart.innerHTML = chartSvg({
|
|
emptyText: "等待训练指标",
|
|
ariaLabel: `Loss 和 ${qualityLabel} 训练曲线`,
|
|
series: [
|
|
{
|
|
values: lossValues,
|
|
className: "loss-line",
|
|
label: "Loss",
|
|
labelY: 28,
|
|
digits: 4,
|
|
},
|
|
{
|
|
values: qualityValues,
|
|
className: "quality-line",
|
|
label: qualityLabel,
|
|
labelY: 44,
|
|
digits: 4,
|
|
fixedMin: quality === "loss" ? undefined : 0,
|
|
fixedMax: quality === "loss" ? undefined : 1,
|
|
},
|
|
],
|
|
});
|
|
|
|
const gpu = state.metrics.filter((metric) => metric.kind === "gpu");
|
|
elements.gpuChart.innerHTML = chartSvg({
|
|
emptyText: "等待 GPU 采样",
|
|
height: 185,
|
|
ariaLabel: "GPU 利用率和显存占用百分比曲线",
|
|
series: [
|
|
{
|
|
values: gpu.map((item) => item.gpu_utilization),
|
|
className: "gpu-line",
|
|
label: "GPU",
|
|
labelY: 28,
|
|
digits: 0,
|
|
fixedMin: 0,
|
|
fixedMax: 100,
|
|
},
|
|
{
|
|
values: gpu.map((item) => item.gpu_memory_percent),
|
|
className: "memory-line",
|
|
label: "显存",
|
|
labelY: 44,
|
|
digits: 0,
|
|
fixedMin: 0,
|
|
fixedMax: 100,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
function renderMetadata(run) {
|
|
const rows = [
|
|
["框架", run.framework || "—"],
|
|
["状态", statusLabels[run.status] || run.status || "—"],
|
|
["创建时间", formatDate(run.created_at)],
|
|
["开始时间", formatDate(run.started_at)],
|
|
["运行时长", durationText(run)],
|
|
["主指标", metricLabels[run.primary_metric] || run.primary_metric || "—"],
|
|
["工作目录", run.working_dir || "—"],
|
|
["配置文件", run.source_path || "演示任务"],
|
|
];
|
|
elements.metadata.replaceChildren();
|
|
rows.forEach(([label, value]) => {
|
|
const term = document.createElement("dt");
|
|
term.textContent = label;
|
|
const detail = document.createElement("dd");
|
|
detail.textContent = String(value);
|
|
elements.metadata.append(term, detail);
|
|
});
|
|
}
|
|
|
|
async function selectRun(runId, { silent = false } = {}) {
|
|
state.selectedId = runId;
|
|
renderRunList();
|
|
try {
|
|
const [run, metricsData, logData] = await Promise.all([
|
|
requestJson(`/api/runs/${encodeURIComponent(runId)}`),
|
|
requestJson(`/api/runs/${encodeURIComponent(runId)}/metrics?limit=5000`),
|
|
requestJson(`/api/runs/${encodeURIComponent(runId)}/log?tail=120`),
|
|
]);
|
|
if (state.selectedId !== runId) return;
|
|
state.metrics = metricsData.metrics || [];
|
|
elements.emptySelection.hidden = true;
|
|
elements.runDetail.hidden = false;
|
|
renderSummary(run);
|
|
renderCharts(run);
|
|
renderMetadata(run);
|
|
elements.logOutput.textContent = logData.log || "尚无日志输出。";
|
|
if (!silent) elements.runName.focus?.();
|
|
} catch (error) {
|
|
elements.logOutput.textContent = `加载失败:${error.message}`;
|
|
}
|
|
}
|
|
|
|
async function refresh() {
|
|
try {
|
|
const data = await requestJson("/api/runs");
|
|
state.runs = data.runs || [];
|
|
elements.connectionStatus.textContent = "本地连接正常";
|
|
elements.connectionStatus.classList.add("online");
|
|
renderRunList();
|
|
if (!state.selectedId && state.runs.length) {
|
|
await selectRun(state.runs[0].id, { silent: true });
|
|
} else if (state.selectedId) {
|
|
const stillExists = state.runs.some((run) => run.id === state.selectedId);
|
|
if (stillExists) {
|
|
await selectRun(state.selectedId, { silent: true });
|
|
} else {
|
|
state.selectedId = null;
|
|
state.metrics = [];
|
|
elements.runDetail.hidden = true;
|
|
elements.emptySelection.hidden = false;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
elements.connectionStatus.textContent = "连接失败";
|
|
elements.connectionStatus.classList.remove("online");
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
elements.refreshButton.addEventListener("click", refresh);
|
|
refresh();
|
|
state.refreshTimer = window.setInterval(refresh, 4000);
|