Files
AItrackwalker/model-training/railtrain/static/app.js
T

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);