Add filterable artifact browser

This commit is contained in:
2026-06-30 15:41:58 +08:00
parent 90dccbea0e
commit 41cb0235cd
4 changed files with 148 additions and 11 deletions

View File

@@ -57,8 +57,9 @@ written under `var/custom_yolo_runs` and are scanned by the results dashboard.
When a dataset is selected, the dataset panel shows its custom YOLO `best.pt`, When a dataset is selected, the dataset panel shows its custom YOLO `best.pt`,
prediction previews, heatmap previews, and inline training curve previews. prediction previews, heatmap previews, and inline training curve previews.
Segmentation previews, YOLO heatmaps, and loss/metric artifacts are grouped on Segmentation previews, YOLO heatmaps, and loss/metric artifacts are grouped on
the results dashboard, and YOLO-style `results.csv` files are parsed into the results dashboard. The artifact browser can filter by model family and
lightweight training curves. artifact role, and YOLO-style `results.csv` files are parsed into lightweight
training curves.
Job APIs and the SSE log stream also expose structured progress parsed from Job APIs and the SSE log stream also expose structured progress parsed from
YOLO, MMSeg/MMEngine, SegModel-style epoch logs, and generic tqdm percentages, YOLO, MMSeg/MMEngine, SegModel-style epoch logs, and generic tqdm percentages,
so the queue and live log panel can show stage, epoch/iteration, and percent so the queue and live log panel can show stage, epoch/iteration, and percent

View File

@@ -50,6 +50,11 @@ def evaluate_project() -> dict:
and "datasetOutputCurve" in frontend_text and "datasetOutputCurve" in frontend_text
and "MiniCurvePlot" in frontend_text, and "MiniCurvePlot" in frontend_text,
"loss_result_ui": "loss" in frontend_text.lower() and "heatmap" in frontend_text.lower() and "CurvePanel" in frontend_text, "loss_result_ui": "loss" in frontend_text.lower() and "heatmap" in frontend_text.lower() and "CurvePanel" in frontend_text,
"result_browser_filter_ui": "ResultBrowser" in frontend_text
and "resultFamilyFilter" in frontend_text
and "resultRoleFilter" in frontend_text
and "familyOptions" in frontend_text
and "roleOptions" in frontend_text,
"job_progress_ui": "JobProgressBar" in frontend_text and "progressTrack" in frontend_text, "job_progress_ui": "JobProgressBar" in frontend_text and "progressTrack" in frontend_text,
"runtime_readiness_ui": "runtimeReadiness" in frontend_text and "环境就绪" in frontend_text, "runtime_readiness_ui": "runtimeReadiness" in frontend_text and "环境就绪" in frontend_text,
"capability_matrix_ui": "capabilities" in frontend_text and "全功能矩阵" in frontend_text, "capability_matrix_ui": "capabilities" in frontend_text and "全功能矩阵" in frontend_text,

View File

@@ -424,6 +424,8 @@ function App() {
const [datasetDescription, setDatasetDescription] = useState(""); const [datasetDescription, setDatasetDescription] = useState("");
const [selectedDatasetName, setSelectedDatasetName] = useState(""); const [selectedDatasetName, setSelectedDatasetName] = useState("");
const [selectedCurvePath, setSelectedCurvePath] = useState(""); const [selectedCurvePath, setSelectedCurvePath] = useState("");
const [resultFamilyFilter, setResultFamilyFilter] = useState("all");
const [resultRoleFilter, setResultRoleFilter] = useState("all");
const [uploadKind, setUploadKind] = useState<"images" | "labels" | "masks">("images"); const [uploadKind, setUploadKind] = useState<"images" | "labels" | "masks">("images");
const [uploadFiles, setUploadFiles] = useState<FileList | null>(null); const [uploadFiles, setUploadFiles] = useState<FileList | null>(null);
const [agentValidation, setAgentValidation] = useState<ValidationAgentPayload | null>(null); const [agentValidation, setAgentValidation] = useState<ValidationAgentPayload | null>(null);
@@ -472,6 +474,15 @@ function App() {
}; };
}, [curves, results, selectedDataset]); }, [curves, results, selectedDataset]);
const selectedYoloWeightReady = Boolean(selectedYoloOutputs.bestWeight); const selectedYoloWeightReady = Boolean(selectedYoloOutputs.bestWeight);
const resultFamilyOptions = useMemo(() => ["all", ...Array.from(new Set(results.map((item) => item.family ?? "artifact"))).sort()], [results]);
const resultRoleOptions = useMemo(() => ["all", ...Array.from(new Set(results.map((item) => item.role ?? "artifact"))).sort()], [results]);
const filteredResults = useMemo(
() => results.filter((item) =>
(resultFamilyFilter === "all" || (item.family ?? "artifact") === resultFamilyFilter) &&
(resultRoleFilter === "all" || (item.role ?? "artifact") === resultRoleFilter)
),
[resultFamilyFilter, resultRoleFilter, results]
);
function pickTask(next: string) { function pickTask(next: string) {
setTaskType(next); setTaskType(next);
@@ -1215,14 +1226,16 @@ function App() {
</div> </div>
<BarChart3 size={22} /> <BarChart3 size={22} />
</div> </div>
<div className="resultList"> <ResultBrowser
{results.map((item) => ( results={filteredResults}
<a key={item.path} href={`${API_BASE}/api/artifacts/${item.relative_path}`} target="_blank" rel="noreferrer"> total={results.length}
<span>{item.name}</span> familyOptions={resultFamilyOptions}
<small>{formatBytes(item.size)}</small> roleOptions={resultRoleOptions}
</a> familyFilter={resultFamilyFilter}
))} roleFilter={resultRoleFilter}
</div> onFamilyFilter={setResultFamilyFilter}
onRoleFilter={setResultRoleFilter}
/>
</div> </div>
</section> </section>
</section> </section>
@@ -1262,6 +1275,61 @@ function ResultPreview({ results }: { results: ResultItem[] }) {
); );
} }
function ResultBrowser({
results,
total,
familyOptions,
roleOptions,
familyFilter,
roleFilter,
onFamilyFilter,
onRoleFilter
}: {
results: ResultItem[];
total: number;
familyOptions: string[];
roleOptions: string[];
familyFilter: string;
roleFilter: string;
onFamilyFilter: (value: string) => void;
onRoleFilter: (value: string) => void;
}) {
return (
<div className="resultBrowser">
<div className="resultFilters">
<label>
<span>Family</span>
<select value={familyFilter} onChange={(event) => onFamilyFilter(event.target.value)} aria-label="result family">
{familyOptions.map((option) => (
<option key={option} value={option}>{resultFilterLabel(option)}</option>
))}
</select>
</label>
<label>
<span>Role</span>
<select value={roleFilter} onChange={(event) => onRoleFilter(event.target.value)} aria-label="result role">
{roleOptions.map((option) => (
<option key={option} value={option}>{resultFilterLabel(option)}</option>
))}
</select>
</label>
</div>
<div className="resultSummary">
<strong>{results.length}</strong>
<span>/ {total} artifacts</span>
</div>
<div className="resultList">
{results.map((item) => (
<a key={item.path} href={`${API_BASE}/api/artifacts/${item.relative_path}`} target="_blank" rel="noreferrer">
<span>{item.name}</span>
<small>{item.family ?? "artifact"} · {item.role ?? "artifact"} · {formatBytes(item.size)}</small>
</a>
))}
</div>
</div>
);
}
function DatasetQuality({ validation }: { validation: DatasetValidation }) { function DatasetQuality({ validation }: { validation: DatasetValidation }) {
return ( return (
<div className="qualityBox"> <div className="qualityBox">
@@ -1416,6 +1484,10 @@ function curveColor(index: number) {
return ["#9de26f", "#73d2de", "#d3b35b", "#7aa2ff", "#f07167"][index % 5]; return ["#9de26f", "#73d2de", "#d3b35b", "#7aa2ff", "#f07167"][index % 5];
} }
function resultFilterLabel(value: string) {
return value === "all" ? "All" : value;
}
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />

View File

@@ -936,8 +936,9 @@ textarea {
} }
.jobRow, .resultList a { .jobRow, .resultList a {
min-width: 0;
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: minmax(0, 1fr) minmax(0, auto);
gap: 8px; gap: 8px;
text-align: left; text-align: left;
padding: 10px; padding: 10px;
@@ -952,6 +953,64 @@ textarea {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.resultList span,
.resultList small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resultList small {
color: var(--muted);
}
.resultBrowser {
display: grid;
gap: 10px;
}
.resultFilters {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.resultFilters label {
min-width: 0;
display: grid;
gap: 6px;
}
.resultFilters span {
color: var(--muted);
font-size: 11px;
}
.resultFilters select {
width: 100%;
height: 36px;
padding: 0 9px;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--field);
color: var(--ink);
}
.resultSummary {
display: flex;
align-items: baseline;
gap: 5px;
color: var(--muted);
font-size: 12px;
}
.resultSummary strong {
color: var(--green);
font-size: 24px;
line-height: 1;
}
.jobRowTop { .jobRowTop {
min-width: 0; min-width: 0;
display: grid; display: grid;