Redesign dataset workbench management

This commit is contained in:
2026-07-01 11:43:30 +08:00
parent 9d66f65212
commit b1c0c982b8
6 changed files with 740 additions and 75 deletions

View File

@@ -6,6 +6,7 @@ import {
Bot,
Boxes,
ClipboardCheck,
Copy,
Cpu,
Database,
FileImage,
@@ -18,6 +19,7 @@ import {
ShieldCheck,
Square,
Terminal,
Trash2,
UploadCloud,
PackageOpen,
Wand2,
@@ -69,13 +71,26 @@ type Catalog = {
weights: { count: number; total_bytes: number; updated_at?: string };
};
type DatasetFileItem = {
name: string;
stem?: string;
pair_stem?: string;
path?: string;
relative_path: string;
size: number;
ext?: string;
previewable: boolean;
media_type?: string;
};
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 }>>;
samples: Record<string, DatasetFileItem[]>;
files?: Record<string, DatasetFileItem[]>;
};
type DatasetValidation = {
@@ -90,6 +105,7 @@ type DatasetValidation = {
masks_without_images: string[];
};
classes: number[];
sample_shapes?: Array<{ name: string; width: number; height: number; channels: number }>;
checks: Array<{ name: string; passed: boolean; count?: number; labels?: number; masks?: number; errors?: unknown[] }>;
ready: { yolo: boolean; mask: boolean; any: boolean };
};
@@ -378,6 +394,55 @@ function artifactUrl(path: string) {
return `${API_BASE}/api/artifacts/${path.split("/").map(encodeURIComponent).join("/")}`;
}
const MASK_STEM_SUFFIXES = ["-mask", "_mask", "-label", "_label", "-gt", "_gt", "-seg", "_seg"];
function slugifyName(value: string) {
const text = value.trim().replace(/[^A-Za-z0-9_.\-\u4e00-\u9fff]+/g, "_").replace(/^[._]+|[._]+$/g, "");
return text || "dataset";
}
function stemFromName(name: string) {
const last = name.split("/").pop() ?? name;
const dot = last.lastIndexOf(".");
return dot > 0 ? last.slice(0, dot) : last;
}
function normalizeMaskStem(stem: string) {
const lower = stem.toLowerCase();
const suffix = MASK_STEM_SUFFIXES.find((item) => lower.endsWith(item) && stem.length > item.length);
return suffix ? stem.slice(0, -suffix.length) : stem;
}
function datasetFiles(dataset: UploadedDataset | undefined, kind: "images" | "masks") {
if (!dataset) return [];
return (dataset.files?.[kind] ?? dataset.samples?.[kind] ?? []).map((item) => ({
...item,
stem: item.stem ?? stemFromName(item.name),
pair_stem: item.pair_stem ?? (kind === "masks" ? normalizeMaskStem(stemFromName(item.name)) : stemFromName(item.name))
}));
}
function datasetPairRows(dataset: UploadedDataset | undefined) {
const images = datasetFiles(dataset, "images");
const masks = datasetFiles(dataset, "masks");
const masksByStem = new Map<string, DatasetFileItem>();
for (const mask of masks) {
if (mask.pair_stem && !masksByStem.has(mask.pair_stem)) {
masksByStem.set(mask.pair_stem, mask);
}
}
const usedMasks = new Set<string>();
const imageRows = images.map((image) => {
const mask = image.stem ? masksByStem.get(image.stem) : undefined;
if (mask) usedMasks.add(mask.relative_path);
return { key: `image-${image.relative_path}`, stem: image.stem ?? image.name, image, mask, status: mask ? "paired" : "missing_mask" };
});
const extraMaskRows = masks
.filter((mask) => !usedMasks.has(mask.relative_path))
.map((mask) => ({ key: `mask-${mask.relative_path}`, stem: mask.pair_stem ?? mask.name, image: undefined, mask, status: "extra_mask" }));
return [...imageRows, ...extraMaskRows];
}
const defaultParams: Record<string, Record<string, unknown>> = {
"mock.echo": { message: "hello from Seg Data Server" },
"dataset.rename": { image_dir: "../DataSet_Own/ORI", label_dir: "../DataSet_Own/Label" },
@@ -550,9 +615,16 @@ function App() {
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
const [log, setLog] = useState("");
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState("");
const [datasetName, setDatasetName] = useState("demo_dataset");
const [datasetDescription, setDatasetDescription] = useState("");
const [copyDatasetName, setCopyDatasetName] = useState("");
const [selectedDatasetName, setSelectedDatasetName] = useState("");
const [preprocessWidth, setPreprocessWidth] = useState(1920);
const [preprocessHeight, setPreprocessHeight] = useState(1080);
const [preprocessPrefix, setPreprocessPrefix] = useState("");
const [preprocessSuffix, setPreprocessSuffix] = useState("-mask");
const [preprocessAlpha, setPreprocessAlpha] = useState(0.3);
const [selectedCurvePath, setSelectedCurvePath] = useState("");
const [resultFamilyFilter, setResultFamilyFilter] = useState("all");
const [resultRoleFilter, setResultRoleFilter] = useState("all");
@@ -597,6 +669,8 @@ function App() {
}, [catalog]);
const datasetOps = taskGroups.dataset.filter((task) => task in taskLabels);
const manualDatasetOps = ["dataset.rename", "dataset.to_png", "dataset.resize", "dataset.pair", "dataset.rebuild_labels", "dataset.stack", "dataset.stitch"]
.filter((task) => datasetOps.includes(task));
const selectedDataset = useMemo(
() => datasets.find((dataset) => dataset.name === selectedDatasetName) ?? datasets.find((dataset) => dataset.name === datasetName),
[datasetName, datasets, selectedDatasetName]
@@ -623,6 +697,8 @@ function App() {
};
}, [curves, results, selectedDataset]);
const selectedYoloWeightReady = Boolean(selectedYoloOutputs.bestWeight);
const datasetNameExists = datasets.some((item) => item.name === slugifyName(datasetName));
const copyNameExists = datasets.some((item) => item.name === slugifyName(copyDatasetName));
const availableGpus = gpus?.gpus ?? [];
const condaEnvOptions = useMemo(() => ["auto", ...Array.from(new Set((condaEnvs?.envs ?? []).map((item) => item.name))).sort()], [condaEnvs]);
const selectedGpuDevice = selectedGpuIds.length ? selectedGpuIds.join(",") : "cpu";
@@ -678,7 +754,7 @@ function App() {
const activePage = pages.find((item) => item.id === page) ?? pages[0];
const inferenceSource = inferenceSourcePath.trim() || selectedDataset?.absolute_layout?.images || "";
const uploadAccept = uploadKind === "images"
? "image/*,.zip,.tar,.tar.gz,.tgz"
? "image/*,video/*,.zip,.tar,.tar.gz,.tgz"
: "image/*,.txt,.json,.yaml,.yml,.zip,.tar,.tar.gz,.tgz";
const inferenceOutputs = useMemo(() => {
const predictions = results.filter((item) => item.role === "segmentation" && item.previewable).slice(0, 8);
@@ -698,6 +774,12 @@ function App() {
}
}, [datasets, selectedDatasetName]);
useEffect(() => {
if (selectedDataset) {
setCopyDatasetName(`${selectedDataset.name}_copy`);
}
}, [selectedDataset?.name]);
function navigate(next: PageId) {
setPage(next);
window.location.hash = next;
@@ -748,7 +830,19 @@ function App() {
const maskDir = layout.masks || layout.labels;
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: maskDir, result_dir: resultDir };
const nextParams: Record<string, unknown> = { ...base, image_dir: layout.images, label_dir: maskDir, result_dir: resultDir };
if (next === "dataset.resize") {
nextParams.width = preprocessWidth;
nextParams.height = preprocessHeight;
}
if (["dataset.pair", "dataset.stack", "dataset.stitch", "dataset.yolo_check_pairs", "dataset.yolo_stack"].includes(next)) {
nextParams.prefix = preprocessPrefix;
nextParams.suffix = preprocessSuffix;
}
if (["dataset.stack", "dataset.yolo_stack"].includes(next)) {
nextParams.alpha = preprocessAlpha;
}
return nextParams;
}
if (["dataset.rebuild_labels", "dataset.yolo_rebuild_labels", "dataset.yolo_txt_sort"].includes(next)) {
return { ...base, label_dir: maskDir, folder: maskDir };
@@ -858,14 +952,21 @@ function App() {
}
async function createDataset() {
if (datasetNameExists) {
setActionError(`数据集已存在:${slugifyName(datasetName)}`);
return;
}
setBusy(true);
try {
setActionError("");
await api("/api/datasets", {
method: "POST",
body: JSON.stringify({ name: datasetName, description: datasetDescription })
});
setSelectedDatasetName(datasetName);
await refresh();
} catch (err) {
setActionError(String(err));
} finally {
setBusy(false);
}
@@ -875,6 +976,7 @@ function App() {
if (!uploadFiles || uploadFiles.length === 0) return;
setBusy(true);
try {
setActionError("");
const body = new FormData();
Array.from(uploadFiles).forEach((file) => body.append("files", file));
let res: Response;
@@ -888,6 +990,67 @@ function App() {
}
if (!res.ok) throw new Error(await res.text());
await refresh();
} catch (err) {
setActionError(String(err));
} finally {
setBusy(false);
}
}
async function copySelectedDataset() {
if (!selectedDataset || !copyDatasetName.trim()) return;
if (copyNameExists) {
setActionError(`数据集已存在:${slugifyName(copyDatasetName)}`);
return;
}
setBusy(true);
try {
setActionError("");
const copied = await api<UploadedDataset>(`/api/datasets/${encodeURIComponent(selectedDataset.name)}/copy`, {
method: "POST",
body: JSON.stringify({ name: copyDatasetName, description: `Copy of ${selectedDataset.name}` })
});
setSelectedDatasetName(copied.name);
setDatasetName(copied.name);
await refresh();
} catch (err) {
setActionError(String(err));
} finally {
setBusy(false);
}
}
async function deleteSelectedDataset() {
if (!selectedDataset) return;
if (!window.confirm(`删除数据集 ${selectedDataset.name}?此操作会删除上传目录。`)) return;
setBusy(true);
try {
setActionError("");
await api(`/api/datasets/${encodeURIComponent(selectedDataset.name)}`, { method: "DELETE" });
setSelectedDatasetName("");
setDatasetName("demo_dataset");
await refresh();
} catch (err) {
setActionError(String(err));
} finally {
setBusy(false);
}
}
async function startDatasetPreprocessTask(next: string) {
if (!selectedDataset?.absolute_layout) return;
setBusy(true);
try {
setActionError("");
const job = await api<Job>("/api/jobs", {
method: "POST",
body: JSON.stringify(jobPayload(next, datasetParamsForTask(next)))
});
await inspectJob(job);
navigate("training");
await refresh();
} catch (err) {
setActionError(String(err));
} finally {
setBusy(false);
}
@@ -1071,7 +1234,7 @@ function App() {
</button>
</header>
{error && <div className="alert">{error}</div>}
{(error || actionError) && <div className="alert">{actionError || error}</div>}
<section className="metrics" data-page-section="overview">
<div className="metric">
@@ -1226,12 +1389,12 @@ function App() {
</div>
</section>
<section className="grid two" id="datasets" data-page-section="datasets">
<div className="panel">
<section className="datasetWorkbench" id="datasets" data-page-section="datasets">
<div className="panel datasetSidePanel">
<div className="panelHead">
<div>
<p className="eyebrow">Dataset Bench</p>
<h2> Masks </h2>
<h2></h2>
</div>
<Database size={22} />
</div>
@@ -1244,6 +1407,7 @@ function App() {
<span></span>
<input value={datasetDescription} onChange={(event) => setDatasetDescription(event.target.value)} />
</label>
{datasetNameExists && <small className="formWarn"></small>}
<div className="segmented">
{(["images", "masks"] as const).map((kind) => (
<button key={kind} className={uploadKind === kind ? "active" : ""} onClick={() => setUploadKind(kind)}>
@@ -1253,17 +1417,78 @@ function App() {
</div>
<label className="drop">
{uploadFiles?.length ? <PackageOpen size={24} /> : <UploadCloud size={24} />}
<span>{uploadFiles?.length ? `${uploadFiles.length} 个文件待上传` : `选择 ${uploadKind} 图片或压缩包`}</span>
<small>.png/.jpg/.tif/.zip/.tar.gz </small>
<span>{uploadFiles?.length ? `${uploadFiles.length} 个文件待上传` : `选择 ${uploadKind} 图片、视频或压缩包`}</span>
<small>.png/.jpg/.tif/.mp4/.zip/.tar.gz </small>
<input multiple type="file" accept={uploadAccept} onChange={(event) => setUploadFiles(event.target.files)} />
</label>
<div className="buttonRow">
<button className="primary" disabled={busy} onClick={createDataset}><Boxes size={17} /></button>
<button className="primary" disabled={busy || datasetNameExists} onClick={createDataset}><Boxes size={17} /></button>
<button className="primary secondary" disabled={busy || !uploadFiles?.length} onClick={uploadDatasetFiles}><UploadCloud size={17} /></button>
</div>
<div className="opGrid">
{datasetOps.map((task) => (
<button key={task} type="button" onClick={() => pickDatasetTask(task)}>
</div>
<div className="datasetManager">
<label className="field compact">
<span></span>
<select
value={selectedDataset?.name ?? ""}
onChange={(event) => {
setSelectedDatasetName(event.target.value);
setDatasetName(event.target.value || "demo_dataset");
}}
aria-label="uploaded datasets"
>
{datasets.length ? datasets.map((dataset) => (
<option key={dataset.name} value={dataset.name}>
{dataset.name} · {dataset.counts.images}/{dataset.counts.masks}
</option>
)) : (
<option value=""></option>
)}
</select>
</label>
<div className="datasetMiniList">
{datasets.map((dataset) => (
<button
key={dataset.name}
type="button"
className={selectedDataset?.name === dataset.name ? "selected" : ""}
onClick={() => {
setSelectedDatasetName(dataset.name);
setDatasetName(dataset.name);
}}
>
<span>{dataset.name}</span>
<small>{datasetValidations[dataset.name]?.pairs.image_mask ?? 0} pair · {dataset.counts.images} images · {dataset.counts.masks} masks</small>
</button>
))}
</div>
<label className="field compact">
<span></span>
<input value={copyDatasetName} onChange={(event) => setCopyDatasetName(event.target.value)} />
</label>
{copyNameExists && <small className="formWarn"></small>}
<div className="buttonRow">
<button className="primary secondary" disabled={busy || !selectedDataset || !copyDatasetName.trim() || copyNameExists} onClick={copySelectedDataset}><Copy size={17} /></button>
<button className="primary danger" disabled={busy || !selectedDataset} onClick={deleteSelectedDataset}><Trash2 size={17} /></button>
</div>
</div>
<div className="preprocessBox">
<div className="qualityHead">
<strong></strong>
<span>使</span>
</div>
<div className="preprocessControls">
<label><span></span><input type="number" min={1} value={preprocessWidth} onChange={(event) => setPreprocessWidth(Number(event.target.value) || 1)} /></label>
<label><span></span><input type="number" min={1} value={preprocessHeight} onChange={(event) => setPreprocessHeight(Number(event.target.value) || 1)} /></label>
<label><span></span><input value={preprocessPrefix} onChange={(event) => setPreprocessPrefix(event.target.value)} /></label>
<label><span></span><input value={preprocessSuffix} onChange={(event) => setPreprocessSuffix(event.target.value)} /></label>
<label><span></span><input type="number" min={0} max={1} step={0.05} value={preprocessAlpha} onChange={(event) => setPreprocessAlpha(Number(event.target.value) || 0)} /></label>
</div>
<div className="opGrid compactOps">
{manualDatasetOps.map((task) => (
<button key={task} type="button" disabled={busy || !selectedDataset?.absolute_layout} onClick={() => startDatasetPreprocessTask(task)}>
<Wand2 size={16} />
<span>{taskLabels[task]}</span>
</button>
@@ -1272,11 +1497,11 @@ function App() {
</div>
</div>
<div className="panel">
<div className="panel datasetDetailPanel">
<div className="panelHead">
<div>
<p className="eyebrow">Files</p>
<h2></h2>
<h2>{selectedDataset?.name ?? "选择一个数据集"}</h2>
</div>
<div className="buttonRow compactButtons">
<button className="iconButton" disabled={busy || !selectedValidation?.ready.yolo} onClick={generateSelectedYoloYaml} title="生成 YOLO dataset.yaml">
@@ -1291,52 +1516,17 @@ function App() {
<button className="iconButton" disabled={busy || !selectedDataset?.absolute_layout || !selectedYoloWeightReady} onClick={startSelectedYoloHeatmap} title={selectedYoloWeightReady ? "使用自定义 best.pt 生成热度图" : "best.pt 尚未生成"}>
<Zap size={18} />
</button>
<FileImage size={22} />
</div>
</div>
<div className="datasetList">
{datasets.map((dataset) => (
<div key={dataset.name}>
<div
className={`datasetCard ${selectedDataset?.name === dataset.name ? "selected" : ""}`}
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} images · {dataset.counts.masks} masks</span>
</div>
<div className="readinessLine">
<StatusPill status={datasetValidations[dataset.name]?.ready.mask ? "success" : "queued"} />
<small>Mask {datasetValidations[dataset.name]?.pairs.image_mask ?? 0} pair</small>
</div>
<div className="sampleStrip">
{(["images", "masks"] as const).flatMap((kind) =>
(dataset.samples[kind] ?? []).slice(0, 4).map((sample) => (
<a key={`${kind}-${sample.relative_path}`} href={artifactUrl(sample.relative_path)} target="_blank" rel="noreferrer">
{sample.previewable && <img src={artifactUrl(sample.relative_path)} alt={sample.name} />}
<span>{kind}</span>
<small>{sample.name}</small>
</a>
))
)}
</div>
</div>
</div>
))}
</div>
{selectedValidation && <DatasetQuality validation={selectedValidation} />}
{selectedDataset && <DatasetYoloOutputs dataset={selectedDataset} outputs={selectedYoloOutputs} />}
{selectedDataset ? (
<>
<DatasetDetail dataset={selectedDataset} validation={selectedValidation} />
{selectedValidation && <DatasetQuality validation={selectedValidation} />}
<DatasetYoloOutputs dataset={selectedDataset} outputs={selectedYoloOutputs} />
</>
) : (
<p className="muted">masks</p>
)}
</div>
</section>
@@ -1967,6 +2157,78 @@ function AcceptanceArtifactLinks({ artifacts }: { artifacts?: AcceptancePayload[
);
}
function DatasetDetail({ dataset, validation }: { dataset: UploadedDataset; validation?: DatasetValidation }) {
const rows = datasetPairRows(dataset);
const images = datasetFiles(dataset, "images");
const masks = datasetFiles(dataset, "masks");
const problemRows = rows.filter((row) => row.status !== "paired");
const shapeErrors = validation?.checks.find((check) => check.name === "mask_shapes_match")?.errors ?? [];
const ready = validation?.ready.mask;
return (
<div className="datasetDetailStack">
<div className="datasetSummaryStrip">
<div><span>images / video</span><strong>{dataset.counts.images}</strong></div>
<div><span>masks</span><strong>{dataset.counts.masks}</strong></div>
<div><span>paired</span><strong>{validation?.pairs.image_mask ?? rows.filter((row) => row.status === "paired").length}</strong></div>
<div><span>status</span><strong>{ready ? "MASK READY" : "CHECK"}</strong></div>
</div>
<div className="datasetPreviewBand">
{[...images.slice(0, 4), ...masks.slice(0, 4)].slice(0, 8).map((item) => (
<a key={item.relative_path} href={artifactUrl(item.relative_path)} target="_blank" rel="noreferrer">
{item.previewable ? <img src={artifactUrl(item.relative_path)} alt={item.name} /> : <FileImage size={22} />}
<span>{item.name}</span>
</a>
))}
</div>
<div className="fileTable">
<div className="fileTableHead">
<span></span>
<span>images / video</span>
<span></span>
<span>masks</span>
<span></span>
<span></span>
</div>
<div className="fileTableBody">
{rows.length ? rows.slice(0, 500).map((row) => (
<div key={row.key} className={`fileTableRow ${row.status}`}>
<span>{row.stem}</span>
<a href={row.image ? artifactUrl(row.image.relative_path) : undefined} target="_blank" rel="noreferrer">
{row.image?.name ?? "-"}
</a>
<span>{row.image ? formatBytes(row.image.size) : "-"}</span>
<a href={row.mask ? artifactUrl(row.mask.relative_path) : undefined} target="_blank" rel="noreferrer">
{row.mask?.name ?? "-"}
</a>
<span>{row.mask ? formatBytes(row.mask.size) : "-"}</span>
<strong>{row.status === "paired" ? "已配对" : row.status === "missing_mask" ? "缺少 mask" : "多余 mask"}</strong>
</div>
)) : (
<p className="muted"></p>
)}
</div>
</div>
<div className="issueGrid">
<div>
<strong></strong>
{problemRows.length ? problemRows.slice(0, 80).map((row) => (
<small key={row.key}>{row.status === "missing_mask" ? row.image?.name : row.mask?.name}</small>
)) : <small></small>}
</div>
<div>
<strong></strong>
{shapeErrors.length ? shapeErrors.slice(0, 20).map((item, index) => (
<small key={index}>{JSON.stringify(item)}</small>
)) : <small> image/mask </small>}
</div>
</div>
</div>
);
}
function DatasetQuality({ validation }: { validation: DatasetValidation }) {
return (
<div className="qualityBox">

View File

@@ -198,6 +198,10 @@ h2 {
background: var(--cyan);
}
.primary.danger {
background: var(--red);
}
.primary:disabled, .iconButton:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -718,6 +722,43 @@ textarea {
gap: 12px;
}
.datasetWorkbench {
display: grid;
grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.datasetSidePanel,
.datasetDetailPanel {
min-height: 720px;
}
.datasetSidePanel {
display: grid;
align-content: start;
gap: 16px;
}
.datasetManager,
.preprocessBox,
.datasetDetailStack {
display: grid;
gap: 12px;
}
.datasetManager,
.preprocessBox {
padding-top: 14px;
border-top: 1px solid var(--line);
}
.formWarn {
display: block;
color: var(--amber);
line-height: 1.35;
}
.segmented {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -779,6 +820,10 @@ textarea {
gap: 8px;
}
.buttonRow .primary {
flex: 0 0 auto;
}
.opGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -811,6 +856,243 @@ textarea {
white-space: nowrap;
}
.opGrid.compactOps {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.datasetMiniList {
display: grid;
gap: 7px;
max-height: 240px;
overflow: auto;
}
.datasetMiniList button {
min-width: 0;
display: grid;
gap: 3px;
padding: 9px;
text-align: left;
border: 1px solid var(--line);
border-radius: 6px;
background: #101310;
color: var(--ink);
}
.datasetMiniList button.selected {
border-color: var(--green);
background: rgba(157, 226, 111, 0.08);
}
.datasetMiniList span,
.datasetMiniList small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.datasetMiniList small {
color: var(--muted);
}
.preprocessControls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.preprocessControls label {
min-width: 0;
display: grid;
gap: 5px;
}
.preprocessControls label:last-child {
grid-column: 1 / -1;
}
.preprocessControls span {
color: var(--muted);
font-size: 11px;
}
.preprocessControls input {
width: 100%;
height: 34px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--field);
color: var(--ink);
}
.datasetSummaryStrip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.datasetSummaryStrip div {
min-width: 0;
padding: 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: #101310;
}
.datasetSummaryStrip span,
.datasetSummaryStrip strong {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.datasetSummaryStrip span {
color: var(--muted);
font-size: 11px;
}
.datasetSummaryStrip strong {
margin-top: 3px;
font-size: 20px;
}
.datasetPreviewBand {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 8px;
}
.datasetPreviewBand a {
min-width: 0;
min-height: 92px;
display: grid;
align-content: start;
gap: 6px;
padding: 7px;
border: 1px solid var(--line);
border-radius: 6px;
background: #0b0d0b;
color: var(--ink);
text-decoration: none;
}
.datasetPreviewBand img {
width: 100%;
aspect-ratio: 1.25 / 1;
object-fit: cover;
border-radius: 4px;
background: #060806;
}
.datasetPreviewBand svg {
width: 100%;
height: 54px;
color: var(--muted);
}
.datasetPreviewBand span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: 11px;
}
.fileTable {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 7px;
background: #0b0d0b;
}
.fileTableHead,
.fileTableRow {
display: grid;
grid-template-columns: minmax(86px, 0.8fr) minmax(140px, 1.5fr) 82px minmax(140px, 1.5fr) 82px 88px;
gap: 8px;
align-items: center;
}
.fileTableHead {
padding: 9px 10px;
color: var(--muted);
font-size: 11px;
border-bottom: 1px solid var(--line);
background: #101310;
}
.fileTableBody {
max-height: 420px;
overflow: auto;
}
.fileTableRow {
min-height: 38px;
padding: 8px 10px;
border-bottom: 1px solid rgba(51, 59, 50, 0.7);
color: var(--muted);
font-size: 12px;
}
.fileTableRow:last-child {
border-bottom: 0;
}
.fileTableRow a,
.fileTableRow span,
.fileTableRow strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fileTableRow a {
color: var(--ink);
text-decoration: none;
}
.fileTableRow.paired strong {
color: var(--green);
}
.fileTableRow.missing_mask strong,
.fileTableRow.extra_mask strong {
color: var(--amber);
}
.issueGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.issueGrid div {
min-width: 0;
display: grid;
gap: 6px;
align-content: start;
max-height: 180px;
overflow: auto;
padding: 10px;
border-radius: 7px;
border: 1px solid var(--line);
background: #101310;
}
.issueGrid strong,
.issueGrid small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.datasetList {
display: grid;
gap: 10px;
@@ -1879,6 +2161,7 @@ meter {
.metrics,
.capSummary,
.capabilityGrid,
.datasetWorkbench,
.grid.two,
.grid.three,
.grid.four,
@@ -1904,9 +2187,17 @@ meter {
.pipelineSteps,
.pipelineStats,
.inferencePreview,
.datasetSummaryStrip,
.datasetPreviewBand,
.issueGrid,
.userAgentDataset {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.fileTableHead,
.fileTableRow {
grid-template-columns: minmax(80px, 0.8fr) minmax(120px, 1.3fr) 74px minmax(120px, 1.3fr) 74px 82px;
}
}
.resultList a:hover, .jobRow:hover {
@@ -1961,6 +2252,8 @@ meter {
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.taskColumns { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.opGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.datasetWorkbench { grid-template-columns: 1fr; }
.datasetPreviewBand { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.coverageGrid,
.taskCheckList { grid-template-columns: 1fr; }
.grid.three { grid-template-columns: 1fr; }