Add result curve discovery dashboard
This commit is contained in:
@@ -50,6 +50,8 @@ type Catalog = {
|
||||
type UploadedDataset = {
|
||||
name: string;
|
||||
description?: string;
|
||||
absolute_layout?: Record<"images" | "labels" | "masks", string>;
|
||||
layout?: Record<"images" | "labels" | "masks", string>;
|
||||
counts: { images: number; labels: number; masks: number };
|
||||
samples: Record<string, Array<{ name: string; relative_path: string; size: number; previewable: boolean }>>;
|
||||
};
|
||||
@@ -61,6 +63,28 @@ type ResultItem = {
|
||||
size: number;
|
||||
modified: number;
|
||||
kind: string;
|
||||
family?: string;
|
||||
role?: string;
|
||||
previewable?: boolean;
|
||||
};
|
||||
|
||||
type CurveSeries = {
|
||||
name: string;
|
||||
points: Array<{ x: number; y: number }>;
|
||||
last: number;
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
type TrainingCurve = {
|
||||
name: string;
|
||||
file_name: string;
|
||||
relative_path: string;
|
||||
modified: number;
|
||||
family: string;
|
||||
x_key: string;
|
||||
row_count: number;
|
||||
series: CurveSeries[];
|
||||
};
|
||||
|
||||
type CoveragePayload = {
|
||||
@@ -163,6 +187,7 @@ function useData() {
|
||||
const [gpus, setGpus] = useState<GpuPayload | null>(null);
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [results, setResults] = useState<ResultItem[]>([]);
|
||||
const [curves, setCurves] = useState<TrainingCurve[]>([]);
|
||||
const [datasets, setDatasets] = useState<UploadedDataset[]>([]);
|
||||
const [coverage, setCoverage] = useState<CoveragePayload | null>(null);
|
||||
const [acceptance, setAcceptance] = useState<AcceptancePayload | null>(null);
|
||||
@@ -170,11 +195,12 @@ function useData() {
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [catalogNext, gpusNext, jobsNext, resultsNext, datasetsNext, coverageNext, acceptanceNext] = await Promise.all([
|
||||
const [catalogNext, gpusNext, jobsNext, resultsNext, curvesNext, datasetsNext, coverageNext, acceptanceNext] = await Promise.all([
|
||||
api<Catalog>("/api/catalog"),
|
||||
api<GpuPayload>("/api/system/gpus"),
|
||||
api<Job[]>("/api/jobs"),
|
||||
api<ResultItem[]>("/api/results"),
|
||||
api<TrainingCurve[]>("/api/results/curves"),
|
||||
api<UploadedDataset[]>("/api/datasets"),
|
||||
api<CoveragePayload>("/api/coverage"),
|
||||
api<AcceptancePayload>("/api/acceptance/latest")
|
||||
@@ -183,6 +209,7 @@ function useData() {
|
||||
setGpus(gpusNext);
|
||||
setJobs(jobsNext);
|
||||
setResults(resultsNext.slice(0, 80));
|
||||
setCurves(curvesNext.slice(0, 12));
|
||||
setDatasets(datasetsNext);
|
||||
setCoverage(coverageNext);
|
||||
setAcceptance(acceptanceNext);
|
||||
@@ -198,7 +225,7 @@ function useData() {
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return { catalog, gpus, jobs, results, datasets, coverage, acceptance, error, refresh };
|
||||
return { catalog, gpus, jobs, results, curves, datasets, coverage, acceptance, error, refresh };
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
@@ -206,7 +233,7 @@ function StatusPill({ status }: { status: string }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { catalog, gpus, jobs, results, datasets, coverage, acceptance, error, refresh } = useData();
|
||||
const { catalog, gpus, jobs, results, curves, datasets, coverage, acceptance, error, refresh } = useData();
|
||||
const [taskType, setTaskType] = useState("mock.echo");
|
||||
const [params, setParams] = useState(JSON.stringify(defaultParams["mock.echo"], null, 2));
|
||||
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
||||
@@ -214,6 +241,8 @@ function App() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [datasetName, setDatasetName] = useState("demo_dataset");
|
||||
const [datasetDescription, setDatasetDescription] = useState("");
|
||||
const [selectedDatasetName, setSelectedDatasetName] = useState("");
|
||||
const [selectedCurvePath, setSelectedCurvePath] = useState("");
|
||||
const [uploadKind, setUploadKind] = useState<"images" | "labels" | "masks">("images");
|
||||
const [uploadFiles, setUploadFiles] = useState<FileList | null>(null);
|
||||
|
||||
@@ -234,6 +263,11 @@ function App() {
|
||||
}, [catalog]);
|
||||
|
||||
const datasetOps = taskGroups.dataset.filter((task) => task in taskLabels);
|
||||
const selectedDataset = useMemo(
|
||||
() => datasets.find((dataset) => dataset.name === selectedDatasetName) ?? datasets.find((dataset) => dataset.name === datasetName),
|
||||
[datasetName, datasets, selectedDatasetName]
|
||||
);
|
||||
const selectedCurve = curves.find((curve) => curve.relative_path === selectedCurvePath) ?? curves[0];
|
||||
|
||||
function pickTask(next: string) {
|
||||
setTaskType(next);
|
||||
@@ -241,10 +275,28 @@ function App() {
|
||||
}
|
||||
|
||||
function pickDatasetTask(next: string) {
|
||||
pickTask(next);
|
||||
setTaskType(next);
|
||||
setParams(JSON.stringify(datasetParamsForTask(next), null, 2));
|
||||
window.location.hash = "jobs";
|
||||
}
|
||||
|
||||
function datasetParamsForTask(next: string): Record<string, unknown> {
|
||||
const base = { ...(catalog?.task_defaults?.[next] ?? defaultParams[next] ?? {}) };
|
||||
const layout = selectedDataset?.absolute_layout;
|
||||
if (!layout) return base;
|
||||
const resultDir = `${layout.images.replace(/\/images$/, "")}/results/${next.replace(".", "_")}`;
|
||||
if (["dataset.pair", "dataset.resize", "dataset.stack", "dataset.stitch", "dataset.yolo_check_pairs", "dataset.yolo_stack"].includes(next)) {
|
||||
return { ...base, image_dir: layout.images, label_dir: layout.labels, result_dir: resultDir };
|
||||
}
|
||||
if (["dataset.rebuild_labels", "dataset.yolo_rebuild_labels", "dataset.yolo_txt_sort"].includes(next)) {
|
||||
return { ...base, label_dir: layout.labels, folder: layout.labels };
|
||||
}
|
||||
if (["dataset.to_png", "dataset.yolo_convert_png", "dataset.yolo_resize"].includes(next)) {
|
||||
return { ...base, input_dir: layout.images, output_dir: resultDir, folder: layout.images };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
async function createJob() {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -288,6 +340,7 @@ function App() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: datasetName, description: datasetDescription })
|
||||
});
|
||||
setSelectedDatasetName(datasetName);
|
||||
await refresh();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -495,7 +548,22 @@ function App() {
|
||||
</div>
|
||||
<div className="datasetList">
|
||||
{datasets.map((dataset) => (
|
||||
<div className="datasetCard" key={dataset.name}>
|
||||
<div
|
||||
className={`datasetCard ${selectedDataset?.name === dataset.name ? "selected" : ""}`}
|
||||
key={dataset.name}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
setDatasetName(dataset.name);
|
||||
setSelectedDatasetName(dataset.name);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
setDatasetName(dataset.name);
|
||||
setSelectedDatasetName(dataset.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="datasetCardHead">
|
||||
<strong>{dataset.name}</strong>
|
||||
<span>{dataset.counts.images} image · {dataset.counts.labels} label · {dataset.counts.masks} mask</span>
|
||||
@@ -510,7 +578,7 @@ function App() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -641,7 +709,7 @@ function App() {
|
||||
</div>
|
||||
<Wand2 size={22} />
|
||||
</div>
|
||||
<ResultPreview results={results.filter((item) => /predict|mask|comparison|prediction/i.test(item.relative_path) && ["png", "jpg", "jpeg"].includes(item.kind)).slice(0, 6)} />
|
||||
<ResultPreview results={results.filter((item) => item.role === "segmentation" && ["png", "jpg", "jpeg", "svg"].includes(item.kind)).slice(0, 6)} />
|
||||
</div>
|
||||
<div className="panel insight">
|
||||
<div className="panelHead">
|
||||
@@ -651,7 +719,7 @@ function App() {
|
||||
</div>
|
||||
<Zap size={22} />
|
||||
</div>
|
||||
<ResultPreview results={results.filter((item) => /heat|cam|grad/i.test(item.relative_path) && ["png", "jpg", "jpeg"].includes(item.kind)).slice(0, 6)} />
|
||||
<ResultPreview results={results.filter((item) => item.role === "heatmap" && ["png", "jpg", "jpeg", "svg"].includes(item.kind)).slice(0, 6)} />
|
||||
</div>
|
||||
<div className="panel insight">
|
||||
<div className="panelHead">
|
||||
@@ -662,12 +730,7 @@ function App() {
|
||||
<BarChart3 size={22} />
|
||||
</div>
|
||||
<div className="resultList tight">
|
||||
{results.filter((item) => /loss|metric|miou|iou|csv|curve/i.test(item.relative_path)).slice(0, 10).map((item) => (
|
||||
<a key={item.path} href={`${API_BASE}/api/artifacts/${item.relative_path}`} target="_blank" rel="noreferrer">
|
||||
<span>{item.name}</span>
|
||||
<small>{formatBytes(item.size)}</small>
|
||||
</a>
|
||||
))}
|
||||
<CurvePanel curves={curves} selected={selectedCurve} selectedPath={selectedCurvePath} onSelect={setSelectedCurvePath} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -711,7 +774,7 @@ function App() {
|
||||
|
||||
function ResultPreview({ results }: { results: ResultItem[] }) {
|
||||
if (!results.length) {
|
||||
return <p className="muted">暂无结果,运行预测、热度图或分析任务后会自动出现在这里。</p>;
|
||||
return <p className="muted">暂无结果</p>;
|
||||
}
|
||||
return (
|
||||
<div className="previewGrid">
|
||||
@@ -725,6 +788,82 @@ function ResultPreview({ results }: { results: ResultItem[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CurvePanel({
|
||||
curves,
|
||||
selected,
|
||||
selectedPath,
|
||||
onSelect
|
||||
}: {
|
||||
curves: TrainingCurve[];
|
||||
selected?: TrainingCurve;
|
||||
selectedPath: string;
|
||||
onSelect: (path: string) => void;
|
||||
}) {
|
||||
if (!curves.length || !selected) {
|
||||
return <p className="muted">暂无曲线数据</p>;
|
||||
}
|
||||
const visibleSeries = selected.series.slice(0, 5);
|
||||
return (
|
||||
<div className="curvePanel">
|
||||
<select value={selectedPath || selected.relative_path} onChange={(event) => onSelect(event.target.value)} aria-label="curve">
|
||||
{curves.map((curve) => (
|
||||
<option key={curve.relative_path} value={curve.relative_path}>
|
||||
{curve.family} · {curve.name} · {curve.row_count} epochs
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<MiniCurvePlot series={visibleSeries} />
|
||||
<div className="curveLegend">
|
||||
{visibleSeries.map((item, index) => (
|
||||
<a key={item.name} href={`${API_BASE}/api/artifacts/${selected.relative_path}`} target="_blank" rel="noreferrer">
|
||||
<i style={{ background: curveColor(index) }} />
|
||||
<span>{item.name}</span>
|
||||
<small>{item.last.toFixed(4)}</small>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniCurvePlot({ series }: { series: CurveSeries[] }) {
|
||||
const points = series.flatMap((item) => item.points);
|
||||
if (!points.length) return <div className="curveEmpty" />;
|
||||
const minX = Math.min(...points.map((point) => point.x));
|
||||
const maxX = Math.max(...points.map((point) => point.x));
|
||||
const minY = Math.min(...points.map((point) => point.y));
|
||||
const maxY = Math.max(...points.map((point) => point.y));
|
||||
const width = 520;
|
||||
const height = 190;
|
||||
const pad = 16;
|
||||
const scaleX = (x: number) => pad + ((x - minX) / Math.max(maxX - minX, 1)) * (width - pad * 2);
|
||||
const scaleY = (y: number) => height - pad - ((y - minY) / Math.max(maxY - minY, 1)) * (height - pad * 2);
|
||||
|
||||
return (
|
||||
<svg className="curveSvg" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="training curve">
|
||||
<path d={`M ${pad} ${height - pad} H ${width - pad} M ${pad} ${pad} V ${height - pad}`} className="axis" />
|
||||
{[0.25, 0.5, 0.75].map((tick) => (
|
||||
<path key={tick} d={`M ${pad} ${pad + tick * (height - pad * 2)} H ${width - pad}`} className="gridLine" />
|
||||
))}
|
||||
{series.map((item, index) => (
|
||||
<polyline
|
||||
key={item.name}
|
||||
points={item.points.map((point) => `${scaleX(point.x).toFixed(2)},${scaleY(point.y).toFixed(2)}`).join(" ")}
|
||||
fill="none"
|
||||
stroke={curveColor(index)}
|
||||
strokeWidth="2.4"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function curveColor(index: number) {
|
||||
return ["#9de26f", "#73d2de", "#d3b35b", "#7aa2ff", "#f07167"][index % 5];
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -32,7 +32,7 @@ button, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input {
|
||||
input, select {
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -477,10 +477,20 @@ textarea {
|
||||
}
|
||||
|
||||
.datasetCard {
|
||||
width: 100%;
|
||||
display: block;
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: #101310;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.datasetCard.selected {
|
||||
border-color: var(--green);
|
||||
background: rgba(157, 226, 111, 0.08);
|
||||
}
|
||||
|
||||
.datasetCardHead {
|
||||
@@ -619,6 +629,136 @@ meter {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.curvePanel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.curvePanel select {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--field);
|
||||
}
|
||||
|
||||
.curveSvg, .curveEmpty {
|
||||
width: 100%;
|
||||
aspect-ratio: 2.7 / 1;
|
||||
min-height: 160px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: #080a08;
|
||||
}
|
||||
|
||||
.curveSvg .axis {
|
||||
stroke: rgba(238, 242, 232, 0.28);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.curveSvg .gridLine {
|
||||
stroke: rgba(238, 242, 232, 0.08);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.curveLegend {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.curveLegend a {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #101310;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.curveLegend i {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.curveLegend span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.rail {
|
||||
position: static;
|
||||
height: auto;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
nav {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
nav a {
|
||||
justify-content: center;
|
||||
padding: 10px 6px;
|
||||
}
|
||||
|
||||
nav a svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
nav a {
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar, .panelHead {
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.grid.two,
|
||||
.grid.three,
|
||||
.taskColumns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.coverageGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.opGrid,
|
||||
.sampleStrip,
|
||||
.taskCheckList {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.resultList a:hover, .jobRow:hover {
|
||||
border-color: var(--green);
|
||||
}
|
||||
@@ -665,7 +805,7 @@ meter {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
@media (min-width: 981px) and (max-width: 1180px) {
|
||||
body { min-width: 960px; }
|
||||
.shell { grid-template-columns: 220px 1fr; }
|
||||
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
|
||||
Reference in New Issue
Block a user