1734 lines
67 KiB
TypeScript
1734 lines
67 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||
import { createRoot } from "react-dom/client";
|
||
import {
|
||
Activity,
|
||
BarChart3,
|
||
Bot,
|
||
Boxes,
|
||
ClipboardCheck,
|
||
Cpu,
|
||
Database,
|
||
FileImage,
|
||
FileSearch,
|
||
Gauge,
|
||
HardDrive,
|
||
Layers3,
|
||
Play,
|
||
RefreshCcw,
|
||
ShieldCheck,
|
||
Square,
|
||
Terminal,
|
||
UploadCloud,
|
||
Wand2,
|
||
Zap
|
||
} from "lucide-react";
|
||
import "./styles.css";
|
||
|
||
const API_BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:8010";
|
||
|
||
type JobProgress = {
|
||
percent: number | null;
|
||
label: string;
|
||
stage: string;
|
||
current: number | null;
|
||
total: number | null;
|
||
unit: string | null;
|
||
source: string;
|
||
};
|
||
|
||
type Job = {
|
||
id: string;
|
||
type: string;
|
||
status: string;
|
||
description: string;
|
||
command?: string[];
|
||
cwd?: string;
|
||
pid?: number | null;
|
||
exit_code?: number | null;
|
||
error?: string | null;
|
||
created_at: string;
|
||
started_at?: string;
|
||
finished_at?: string;
|
||
log_path?: string;
|
||
log_tail?: string;
|
||
log_size?: number;
|
||
params: Record<string, unknown>;
|
||
progress?: JobProgress;
|
||
};
|
||
|
||
type Catalog = {
|
||
task_types: string[];
|
||
task_defaults: Record<string, Record<string, unknown>>;
|
||
segmodel_architectures: string[];
|
||
yolo_models: string[];
|
||
mmseg_algorithms: string[];
|
||
datasets: Array<{ name: string; path: string; source: string }>;
|
||
weights: { count: number; total_bytes: number; updated_at?: 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 }>>;
|
||
};
|
||
|
||
type DatasetValidation = {
|
||
dataset: string;
|
||
counts: { images: number; labels: number; masks: number; annotations: number };
|
||
pairs: {
|
||
image_label: number;
|
||
image_mask: number;
|
||
images_without_labels: string[];
|
||
labels_without_images: string[];
|
||
images_without_masks: string[];
|
||
masks_without_images: string[];
|
||
};
|
||
classes: number[];
|
||
checks: Array<{ name: string; passed: boolean; count?: number; labels?: number; masks?: number; errors?: unknown[] }>;
|
||
ready: { yolo: boolean; mask: boolean; any: boolean };
|
||
};
|
||
|
||
type ResultItem = {
|
||
name: string;
|
||
path: string;
|
||
relative_path: string;
|
||
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 DatasetYoloOutputsPayload = {
|
||
bestWeight?: ResultItem;
|
||
artifacts: ResultItem[];
|
||
curves: TrainingCurve[];
|
||
predictions: ResultItem[];
|
||
heatmaps: ResultItem[];
|
||
};
|
||
|
||
type WeightEntry = {
|
||
source_path: string;
|
||
stored_path: string;
|
||
size: number;
|
||
family: string;
|
||
role: string;
|
||
sha256?: string;
|
||
};
|
||
|
||
type WeightManifest = {
|
||
generated_at?: string | null;
|
||
updated_at?: string | null;
|
||
source_root?: string;
|
||
count: number;
|
||
total_bytes: number;
|
||
files: WeightEntry[];
|
||
};
|
||
|
||
type WeightVerifyPayload = {
|
||
count: number;
|
||
ok_count: number;
|
||
items: Array<{ stored_path: string; exists: boolean; size_ok: boolean; hash_ok?: boolean | null; ok: boolean }>;
|
||
};
|
||
|
||
type CoveragePayload = {
|
||
scripts_total: number;
|
||
user_scripts_total: number;
|
||
mapped_user_scripts: number;
|
||
unmapped_user_scripts: string[];
|
||
task_build_passed: boolean;
|
||
task_build_checks: Array<{ task: string; passed: boolean; script_exists?: boolean; error?: string }>;
|
||
};
|
||
|
||
type AcceptancePayload = {
|
||
available?: boolean;
|
||
passed?: boolean;
|
||
run_id?: string;
|
||
created_at?: string;
|
||
checks?: Array<{ name: string; passed: boolean }>;
|
||
model_family_readiness?: {
|
||
passed: boolean;
|
||
warnings: Array<{ name: string; passed: boolean }>;
|
||
checks: Array<{ name: string; passed: boolean; required: boolean }>;
|
||
};
|
||
};
|
||
|
||
type DeepAcceptancePayload = {
|
||
available?: boolean;
|
||
passed?: boolean;
|
||
run_id?: string;
|
||
created_at?: string;
|
||
checks?: Array<{ name: string; passed: boolean }>;
|
||
};
|
||
|
||
type GpuPayload = {
|
||
available: boolean;
|
||
gpus: Array<{
|
||
index: number;
|
||
name: string;
|
||
memory_total_mb: number;
|
||
memory_used_mb: number;
|
||
memory_free_mb: number;
|
||
utilization_gpu_percent: number;
|
||
temperature_c: number;
|
||
}>;
|
||
};
|
||
|
||
type CondaEnvPayload = {
|
||
available: boolean;
|
||
envs: Array<{ name: string; path: string; active?: boolean }>;
|
||
task_default?: string;
|
||
mmseg_default?: string;
|
||
};
|
||
|
||
type RuntimeCheck = {
|
||
module: string;
|
||
package?: string;
|
||
passed: boolean;
|
||
version?: string | null;
|
||
error?: string;
|
||
};
|
||
|
||
type RuntimeEnv = {
|
||
role: string;
|
||
name: string;
|
||
label: string;
|
||
env_file: string;
|
||
path?: string;
|
||
exists: boolean;
|
||
passed: boolean;
|
||
checks: RuntimeCheck[];
|
||
extra: Record<string, unknown>;
|
||
};
|
||
|
||
type RuntimeReadinessPayload = {
|
||
available: boolean;
|
||
passed: boolean;
|
||
generated_at: string;
|
||
cached: boolean;
|
||
cache_seconds: number;
|
||
envs: RuntimeEnv[];
|
||
specs: {
|
||
bootstrap_script: string;
|
||
verify_script: string;
|
||
env_files: string[];
|
||
task_default: string;
|
||
mmseg_default: string;
|
||
};
|
||
};
|
||
|
||
type CapabilityDomain = {
|
||
id: string;
|
||
label: string;
|
||
ready: boolean;
|
||
tasks: {
|
||
total: number;
|
||
required: number;
|
||
required_ready: number;
|
||
examples: string[];
|
||
missing_required: string[];
|
||
unbuildable_required: string[];
|
||
};
|
||
runtime: Array<{ role: string; name: string; passed: boolean }>;
|
||
evidence: {
|
||
count: number;
|
||
artifacts: Array<{ name: string; relative_path: string; role?: string; family?: string }>;
|
||
curves: Array<{ name: string; relative_path: string; family?: string; row_count?: number }>;
|
||
};
|
||
gaps: string[];
|
||
};
|
||
|
||
type CapabilityPayload = {
|
||
passed: boolean;
|
||
generated_at: string;
|
||
summary: {
|
||
ready_domains: number;
|
||
total_domains: number;
|
||
mapped_user_scripts: number;
|
||
user_scripts_total: number;
|
||
uploaded_datasets: number;
|
||
artifacts: number;
|
||
curves: number;
|
||
weights: number;
|
||
};
|
||
requirements: Array<{ id: string; label: string; passed: boolean; detail: string }>;
|
||
domains: CapabilityDomain[];
|
||
};
|
||
|
||
type AgentCheck = {
|
||
name: string;
|
||
passed: boolean;
|
||
detail?: unknown;
|
||
details?: unknown;
|
||
};
|
||
|
||
type EvaluationAgentPayload = {
|
||
agent: string;
|
||
score: number;
|
||
checks: AgentCheck[];
|
||
suggestions: string[];
|
||
};
|
||
|
||
type ValidationAgentPayload = {
|
||
agent: string;
|
||
passed: boolean;
|
||
checks: AgentCheck[];
|
||
};
|
||
|
||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||
const res = await fetch(`${API_BASE}${path}`, {
|
||
headers: { "Content-Type": "application/json" },
|
||
...init
|
||
});
|
||
if (!res.ok) throw new Error(await res.text());
|
||
return res.json();
|
||
}
|
||
|
||
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" },
|
||
"dataset.to_png": { input_dir: "../DataSet_Own/ORI", output_dir: "../DataSet_Own/ORI_PNG" },
|
||
"dataset.resize": { image_dir: "../DataSet_Own/ORI", label_dir: "../DataSet_Own/Label", width: 1920, height: 1080 },
|
||
"dataset.pair": { image_dir: "../DataSet_Own/ORI", label_dir: "../DataSet_Own/Label" },
|
||
"dataset.rebuild_labels": { label_dir: "../DataSet_Own/Label" },
|
||
"dataset.stack": { image_dir: "../DataSet_Own/ORI", label_dir: "../DataSet_Own/Label", result_dir: "../DataSet_Own/stacked", alpha: 0.3 },
|
||
"dataset.stitch": { image_dir: "../DataSet_Own/ORI", label_dir: "../DataSet_Own/Label", result_dir: "../DataSet_Own/stitch" },
|
||
"dataset.video_frames": { video: "../Seg_Predict_Own_Video_V2/LC_Video_1.mp4", interval: 0.5, resize: "1920x1080" },
|
||
"segmodel.train": { architecture: "Unet" },
|
||
"segmodel.predict": { architecture: "Unet", run_choice: 1 },
|
||
"yolo.train": { model: "YOLOv8n-seg" },
|
||
"yolo.train_custom": { model: "YOLO11n-seg", data: "var/uploads/datasets/example/dataset.yaml", epochs: 10, imgsz: 640, batch: 1, workers: 0, device: "cpu", exist_ok: true },
|
||
"yolo.predict": { model: "YOLOv8n-seg", pt_name: "best.pt", conf: 0.2, run_choice: 1 },
|
||
"yolo.predict_custom": { weights: "var/custom_yolo_runs/example/weights/best.pt", source: "var/uploads/datasets/example/images", imgsz: 640, conf: 0.25, device: "cpu", name: "example_predict", exist_ok: true },
|
||
"yolo.heatmap": { model: "YOLOv8n-seg", cam_method: "All", pt_name: "best.pt", run_choice: 1 },
|
||
"yolo.heatmap_custom": { weights: "var/custom_yolo_runs/example/weights/best.pt", source: "var/uploads/datasets/example/images", model_key: "YOLO11n-seg", cam_method: "GradCAM", target_layers: "model.model.model[9]", limit: 3, name: "example_heatmap" },
|
||
"mmseg.generate_alg": { dataset_choice: 1, gpu_count: 1, gpu_ids: [0], schedule_mode: 2, max_epochs: 300, algorithm_choice: 1 },
|
||
"mmseg.train": { config: "configs/example.py", work_dir: "../DataSet_Public_outputs/example" },
|
||
"mmseg.metrics": { input_dir: "../Hardisk", output_dir: "../BestMode_Predict_Results_DataSet_Public", dataset_choice: 1, algorithm_choice: 0 },
|
||
"mmseg.flops_fps": { input_dir: "../Hardisk", output_dir: "../BestMode_Predict_Results_DataSet_Public", repeat_times: 3, dataset_choice: 1, algorithm_choice: 0 },
|
||
"analysis.all": { input_dir: "../BestMode_Predict_Results_DataSet_Public", output_dir: "./", dataset_choice: 1 }
|
||
};
|
||
|
||
const taskLabels: Record<string, string> = {
|
||
"dataset.rename": "重命名",
|
||
"dataset.to_png": "转 PNG",
|
||
"dataset.resize": "Resize",
|
||
"dataset.pair": "图片/Label 配对",
|
||
"dataset.rebuild_labels": "重建 Label",
|
||
"dataset.stack": "透明叠加",
|
||
"dataset.stitch": "拼接检查",
|
||
"dataset.video_frames": "视频抽帧",
|
||
"dataset.yolo_check_pairs": "YOLO 配对",
|
||
"dataset.yolo_stack": "YOLO 叠加",
|
||
"dataset.yolo_rebuild_labels": "YOLO Label",
|
||
"dataset.yolo_txt_sort": "生成 TXT",
|
||
"dataset.yolo_convert_png": "批量 PNG",
|
||
"dataset.yolo_resize": "批量缩放"
|
||
};
|
||
|
||
function formatBytes(value?: number) {
|
||
if (!value) return "0 B";
|
||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||
let next = value;
|
||
let unit = 0;
|
||
while (next >= 1024 && unit < units.length - 1) {
|
||
next /= 1024;
|
||
unit += 1;
|
||
}
|
||
return `${next.toFixed(unit > 1 ? 2 : 0)} ${units[unit]}`;
|
||
}
|
||
|
||
function useData() {
|
||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||
const [gpus, setGpus] = useState<GpuPayload | null>(null);
|
||
const [condaEnvs, setCondaEnvs] = useState<CondaEnvPayload | null>(null);
|
||
const [jobs, setJobs] = useState<Job[]>([]);
|
||
const [results, setResults] = useState<ResultItem[]>([]);
|
||
const [curves, setCurves] = useState<TrainingCurve[]>([]);
|
||
const [weightManifest, setWeightManifest] = useState<WeightManifest | null>(null);
|
||
const [datasets, setDatasets] = useState<UploadedDataset[]>([]);
|
||
const [datasetValidations, setDatasetValidations] = useState<Record<string, DatasetValidation>>({});
|
||
const [coverage, setCoverage] = useState<CoveragePayload | null>(null);
|
||
const [acceptance, setAcceptance] = useState<AcceptancePayload | null>(null);
|
||
const [realAcceptance, setRealAcceptance] = useState<AcceptancePayload | null>(null);
|
||
const [deepAcceptance, setDeepAcceptance] = useState<DeepAcceptancePayload | null>(null);
|
||
const [runtimeReadiness, setRuntimeReadiness] = useState<RuntimeReadinessPayload | null>(null);
|
||
const [capabilities, setCapabilities] = useState<CapabilityPayload | null>(null);
|
||
const [agentEvaluation, setAgentEvaluation] = useState<EvaluationAgentPayload | null>(null);
|
||
const [error, setError] = useState<string>("");
|
||
|
||
async function refresh() {
|
||
try {
|
||
const [catalogNext, gpusNext, envsNext, readinessNext, capabilitiesNext, jobsNext, resultsNext, curvesNext, weightsNext, datasetsNext, coverageNext, acceptanceNext, realAcceptanceNext, deepAcceptanceNext, agentEvaluationNext] = await Promise.all([
|
||
api<Catalog>("/api/catalog"),
|
||
api<GpuPayload>("/api/system/gpus"),
|
||
api<CondaEnvPayload>("/api/system/envs"),
|
||
api<RuntimeReadinessPayload>("/api/system/readiness"),
|
||
api<CapabilityPayload>("/api/capabilities"),
|
||
api<Job[]>("/api/jobs"),
|
||
api<ResultItem[]>("/api/results?limit=1000"),
|
||
api<TrainingCurve[]>("/api/results/curves?limit=100"),
|
||
api<WeightManifest>("/api/weights"),
|
||
api<UploadedDataset[]>("/api/datasets"),
|
||
api<CoveragePayload>("/api/coverage"),
|
||
api<AcceptancePayload>("/api/acceptance/latest"),
|
||
api<AcceptancePayload>("/api/acceptance/real/latest"),
|
||
api<DeepAcceptancePayload>("/api/acceptance/deep/latest"),
|
||
api<EvaluationAgentPayload>("/api/agents/evaluate")
|
||
]);
|
||
setCatalog(catalogNext);
|
||
setGpus(gpusNext);
|
||
setCondaEnvs(envsNext);
|
||
setRuntimeReadiness(readinessNext);
|
||
setCapabilities(capabilitiesNext);
|
||
setJobs(jobsNext);
|
||
setResults(resultsNext);
|
||
setCurves(curvesNext);
|
||
setWeightManifest(weightsNext);
|
||
setDatasets(datasetsNext);
|
||
const validationEntries: Array<[string, DatasetValidation]> = [];
|
||
await Promise.all(
|
||
datasetsNext.map(async (dataset) => {
|
||
try {
|
||
const validation = await api<DatasetValidation>(`/api/datasets/${encodeURIComponent(dataset.name)}/validate`);
|
||
validationEntries.push([dataset.name, validation]);
|
||
} catch {
|
||
// Dataset validation is advisory; upload and job controls should remain usable.
|
||
}
|
||
})
|
||
);
|
||
setDatasetValidations(Object.fromEntries(validationEntries));
|
||
setCoverage(coverageNext);
|
||
setAcceptance(acceptanceNext);
|
||
setRealAcceptance(realAcceptanceNext);
|
||
setDeepAcceptance(deepAcceptanceNext);
|
||
setAgentEvaluation(agentEvaluationNext);
|
||
setError("");
|
||
} catch (err) {
|
||
setError(String(err));
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
refresh();
|
||
const timer = window.setInterval(refresh, 5000);
|
||
return () => window.clearInterval(timer);
|
||
}, []);
|
||
|
||
return { catalog, gpus, condaEnvs, runtimeReadiness, capabilities, agentEvaluation, jobs, results, curves, weightManifest, datasets, datasetValidations, coverage, acceptance, realAcceptance, deepAcceptance, error, refresh };
|
||
}
|
||
|
||
function StatusPill({ status }: { status: string }) {
|
||
return <span className={`pill pill-${status}`}>{status}</span>;
|
||
}
|
||
|
||
function JobProgressBar({ progress }: { progress?: JobProgress }) {
|
||
const percent = typeof progress?.percent === "number" ? Math.max(0, Math.min(100, progress.percent)) : 0;
|
||
return (
|
||
<div className="progressBox" data-stage={progress?.stage ?? "unknown"}>
|
||
<div className="progressTrack" aria-label={progress?.label ?? "job progress"}>
|
||
<span style={{ width: `${percent}%` }} />
|
||
</div>
|
||
<div className="progressMeta">
|
||
<span>{progress?.label ?? "等待日志"}</span>
|
||
<strong>{typeof progress?.percent === "number" ? `${progress.percent.toFixed(progress.percent % 1 ? 1 : 0)}%` : "..."}</strong>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function App() {
|
||
const { catalog, gpus, condaEnvs, runtimeReadiness, capabilities, agentEvaluation, jobs, results, curves, weightManifest, datasets, datasetValidations, coverage, acceptance, realAcceptance, deepAcceptance, 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);
|
||
const [log, setLog] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const [datasetName, setDatasetName] = useState("demo_dataset");
|
||
const [datasetDescription, setDatasetDescription] = useState("");
|
||
const [selectedDatasetName, setSelectedDatasetName] = useState("");
|
||
const [selectedCurvePath, setSelectedCurvePath] = useState("");
|
||
const [resultFamilyFilter, setResultFamilyFilter] = useState("all");
|
||
const [resultRoleFilter, setResultRoleFilter] = useState("all");
|
||
const [selectedGpuIds, setSelectedGpuIds] = useState<number[]>([]);
|
||
const [selectedCondaEnv, setSelectedCondaEnv] = useState("auto");
|
||
const [uploadKind, setUploadKind] = useState<"images" | "labels" | "masks">("images");
|
||
const [uploadFiles, setUploadFiles] = useState<FileList | null>(null);
|
||
const [agentValidation, setAgentValidation] = useState<ValidationAgentPayload | null>(null);
|
||
const [weightVerification, setWeightVerification] = useState<WeightVerifyPayload | null>(null);
|
||
const [agentBusy, setAgentBusy] = useState(false);
|
||
const eventSourceRef = useRef<EventSource | null>(null);
|
||
|
||
useEffect(() => () => {
|
||
eventSourceRef.current?.close();
|
||
}, []);
|
||
|
||
const runningCount = jobs.filter((job) => job.status === "running").length;
|
||
const successCount = jobs.filter((job) => job.status === "success").length;
|
||
const failedCount = jobs.filter((job) => job.status === "failed").length;
|
||
|
||
const taskGroups = useMemo<Record<string, string[]>>(() => {
|
||
const items = catalog?.task_types ?? [];
|
||
return {
|
||
dataset: items.filter((task) => task.startsWith("dataset.")),
|
||
segmodel: items.filter((task) => task.startsWith("segmodel.")),
|
||
yolo: items.filter((task) => task.startsWith("yolo.")),
|
||
visual: items.filter((task) => task.startsWith("visual.")),
|
||
mmseg: items.filter((task) => task.startsWith("mmseg.")),
|
||
analysis: items.filter((task) => task.startsWith("analysis.") || task.startsWith("system.") || task.startsWith("mock."))
|
||
};
|
||
}, [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 selectedValidation = selectedDataset ? datasetValidations[selectedDataset.name] : undefined;
|
||
const selectedCurve = curves.find((curve) => curve.relative_path === selectedCurvePath) ?? curves[0];
|
||
const selectedYoloOutputs = useMemo<DatasetYoloOutputsPayload>(() => {
|
||
if (!selectedDataset) {
|
||
return { artifacts: [], curves: [], predictions: [], heatmaps: [] };
|
||
}
|
||
const prefixes = [
|
||
`var/custom_yolo_runs/${selectedDataset.name}`,
|
||
`var/custom_yolo_runs/${selectedDataset.name}_predict`,
|
||
`var/custom_yolo_runs/${selectedDataset.name}_heatmap`
|
||
];
|
||
const matches = (relativePath: string) => prefixes.some((prefix) => relativePath === prefix || relativePath.startsWith(`${prefix}/`));
|
||
const artifacts = results.filter((item) => matches(item.relative_path));
|
||
return {
|
||
bestWeight: artifacts.find((item) => item.relative_path.endsWith("/weights/best.pt")),
|
||
artifacts,
|
||
curves: curves.filter((curve) => matches(curve.relative_path)),
|
||
predictions: artifacts.filter((item) => item.role === "segmentation" && item.previewable),
|
||
heatmaps: artifacts.filter((item) => item.role === "heatmap" && item.previewable)
|
||
};
|
||
}, [curves, results, selectedDataset]);
|
||
const selectedYoloWeightReady = Boolean(selectedYoloOutputs.bestWeight);
|
||
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.map((_, index) => index).join(",") : "cpu";
|
||
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) {
|
||
setTaskType(next);
|
||
setParams(JSON.stringify(catalog?.task_defaults?.[next] ?? defaultParams[next] ?? {}, null, 2));
|
||
}
|
||
|
||
function pickDatasetTask(next: string) {
|
||
setTaskType(next);
|
||
setParams(JSON.stringify(datasetParamsForTask(next), null, 2));
|
||
window.location.hash = "jobs";
|
||
}
|
||
|
||
function toggleGpu(index: number) {
|
||
setSelectedGpuIds((current) => current.includes(index) ? current.filter((item) => item !== index) : [...current, index].sort((a, b) => a - b));
|
||
}
|
||
|
||
function paramsWithGpuSelection(type: string, rawParams: Record<string, unknown>) {
|
||
const next = { ...rawParams };
|
||
if (!selectedGpuIds.length) return next;
|
||
if (type.startsWith("yolo.") || type.startsWith("visual.")) {
|
||
next.device = selectedGpuDevice;
|
||
}
|
||
if (type.startsWith("mmseg.generate_alg")) {
|
||
next.gpu_count = selectedGpuIds.length;
|
||
next.gpu_ids = selectedGpuIds;
|
||
}
|
||
return next;
|
||
}
|
||
|
||
function jobPayload(type: string, rawParams: Record<string, unknown>) {
|
||
const payload: { type: string; params: Record<string, unknown>; gpus?: number[]; conda_env?: string } = {
|
||
type,
|
||
params: paramsWithGpuSelection(type, rawParams)
|
||
};
|
||
if (selectedGpuIds.length) payload.gpus = selectedGpuIds;
|
||
if (selectedCondaEnv !== "auto") payload.conda_env = selectedCondaEnv;
|
||
return payload;
|
||
}
|
||
|
||
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 {
|
||
const job = await api<Job>("/api/jobs", {
|
||
method: "POST",
|
||
body: JSON.stringify(jobPayload(taskType, JSON.parse(params) as Record<string, unknown>))
|
||
});
|
||
await inspectJob(job);
|
||
await refresh();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function syncWeights() {
|
||
setBusy(true);
|
||
try {
|
||
await api("/api/weights/sync", {
|
||
method: "POST",
|
||
body: JSON.stringify({ mode: "copy", hash_files: true, skip_existing: true })
|
||
});
|
||
setWeightVerification(null);
|
||
await refresh();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function verifyWeights() {
|
||
setBusy(true);
|
||
try {
|
||
const result = await api<WeightVerifyPayload>("/api/weights/verify", { method: "POST" });
|
||
setWeightVerification(result);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function runAcceptanceSmoke() {
|
||
setBusy(true);
|
||
try {
|
||
await api("/api/acceptance/smoke", { method: "POST" });
|
||
await refresh();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function runDeepAcceptance() {
|
||
setBusy(true);
|
||
try {
|
||
await api("/api/acceptance/deep", { method: "POST" });
|
||
await refresh();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function runRealAcceptance() {
|
||
setBusy(true);
|
||
try {
|
||
await api("/api/acceptance/real", { method: "POST" });
|
||
await refresh();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function runAgentValidation() {
|
||
setAgentBusy(true);
|
||
try {
|
||
const result = await api<ValidationAgentPayload>("/api/agents/validate?run_build=false&run_acceptance=false&run_deep=false");
|
||
setAgentValidation(result);
|
||
} finally {
|
||
setAgentBusy(false);
|
||
}
|
||
}
|
||
|
||
async function createDataset() {
|
||
setBusy(true);
|
||
try {
|
||
await api("/api/datasets", {
|
||
method: "POST",
|
||
body: JSON.stringify({ name: datasetName, description: datasetDescription })
|
||
});
|
||
setSelectedDatasetName(datasetName);
|
||
await refresh();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function uploadDatasetFiles() {
|
||
if (!uploadFiles || uploadFiles.length === 0) return;
|
||
setBusy(true);
|
||
try {
|
||
const body = new FormData();
|
||
Array.from(uploadFiles).forEach((file) => body.append("files", file));
|
||
const res = await fetch(`${API_BASE}/api/datasets/${encodeURIComponent(datasetName)}/upload/${uploadKind}`, {
|
||
method: "POST",
|
||
body
|
||
});
|
||
if (!res.ok) throw new Error(await res.text());
|
||
await refresh();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function customYoloWeightPath(dataset: UploadedDataset) {
|
||
const expected = `var/custom_yolo_runs/${dataset.name}/weights/best.pt`;
|
||
return selectedYoloOutputs.bestWeight?.relative_path ?? results.find((item) => item.relative_path === expected || item.relative_path.endsWith(`/custom_yolo_runs/${dataset.name}/weights/best.pt`))?.relative_path ?? expected;
|
||
}
|
||
|
||
async function createSelectedYoloYaml() {
|
||
if (!selectedDataset) return;
|
||
const classNames = selectedValidation?.classes.map((classId) => `class_${classId}`) ?? undefined;
|
||
return api<{ relative_path: string; path: string }>(`/api/datasets/${encodeURIComponent(selectedDataset.name)}/yolo-yaml`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ class_names: classNames })
|
||
});
|
||
}
|
||
|
||
async function generateSelectedYoloYaml() {
|
||
if (!selectedDataset) return;
|
||
setBusy(true);
|
||
try {
|
||
const generated = await createSelectedYoloYaml();
|
||
if (!generated) return;
|
||
setTaskType("yolo.train_custom");
|
||
setParams(JSON.stringify(paramsWithGpuSelection("yolo.train_custom", { model: "YOLO11n-seg", data: generated.path, epochs: 10, imgsz: 640, batch: 1, workers: 0, device: selectedGpuDevice, project: "var/custom_yolo_runs", name: selectedDataset.name, exist_ok: true }), null, 2));
|
||
await refresh();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function startSelectedYoloTrain() {
|
||
if (!selectedDataset) return;
|
||
setBusy(true);
|
||
try {
|
||
const generated = await createSelectedYoloYaml();
|
||
if (!generated) return;
|
||
const job = await api<Job>("/api/jobs", {
|
||
method: "POST",
|
||
body: JSON.stringify(jobPayload("yolo.train_custom", { model: "YOLO11n-seg", data: generated.path, epochs: 10, imgsz: 640, batch: 1, workers: 0, device: selectedGpuDevice, project: "var/custom_yolo_runs", name: selectedDataset.name, exist_ok: true }))
|
||
});
|
||
await inspectJob(job);
|
||
window.location.hash = "jobs";
|
||
await refresh();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function startSelectedYoloPredict() {
|
||
if (!selectedDataset?.absolute_layout) return;
|
||
setBusy(true);
|
||
try {
|
||
const job = await api<Job>("/api/jobs", {
|
||
method: "POST",
|
||
body: JSON.stringify(jobPayload("yolo.predict_custom", { weights: customYoloWeightPath(selectedDataset), source: selectedDataset.absolute_layout.images, imgsz: 640, conf: 0.25, device: selectedGpuDevice, project: "var/custom_yolo_runs", name: `${selectedDataset.name}_predict`, exist_ok: true }))
|
||
});
|
||
await inspectJob(job);
|
||
window.location.hash = "jobs";
|
||
await refresh();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function startSelectedYoloHeatmap() {
|
||
if (!selectedDataset?.absolute_layout) return;
|
||
setBusy(true);
|
||
try {
|
||
const job = await api<Job>("/api/jobs", {
|
||
method: "POST",
|
||
body: JSON.stringify(jobPayload("yolo.heatmap_custom", { weights: customYoloWeightPath(selectedDataset), source: selectedDataset.absolute_layout.images, model_key: "YOLO11n-seg", cam_method: "GradCAM", target_layers: "model.model.model[9]", limit: 3, device: selectedGpuDevice, project: "var/custom_yolo_runs", name: `${selectedDataset.name}_heatmap` }))
|
||
});
|
||
await inspectJob(job);
|
||
window.location.hash = "jobs";
|
||
await refresh();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function inspectJob(job: Job) {
|
||
eventSourceRef.current?.close();
|
||
const detail = await api<Job>(`/api/jobs/${job.id}`);
|
||
setSelectedJob(detail);
|
||
setLog(detail.log_tail ?? "");
|
||
const source = new EventSource(`${API_BASE}/api/jobs/${job.id}/events?offset=${detail.log_size ?? 0}`);
|
||
eventSourceRef.current = source;
|
||
source.onmessage = (event) => {
|
||
const payload = JSON.parse(event.data);
|
||
if (payload.chunk) setLog((prev) => `${prev}${payload.chunk}`);
|
||
setSelectedJob(payload.job);
|
||
if (["success", "failed", "cancelled"].includes(payload.job.status)) {
|
||
source.close();
|
||
if (eventSourceRef.current === source) eventSourceRef.current = null;
|
||
}
|
||
};
|
||
source.onerror = () => {
|
||
source.close();
|
||
if (eventSourceRef.current === source) eventSourceRef.current = null;
|
||
};
|
||
}
|
||
|
||
async function cancelSelectedJob() {
|
||
if (!selectedJob) return;
|
||
await api(`/api/jobs/${selectedJob.id}/cancel`, { method: "POST" });
|
||
await refresh();
|
||
}
|
||
|
||
return (
|
||
<main className="shell">
|
||
<aside className="rail">
|
||
<div className="brand">
|
||
<div className="mark"><Layers3 size={24} /></div>
|
||
<div>
|
||
<strong>Seg Data Server</strong>
|
||
<span>Net Console</span>
|
||
</div>
|
||
</div>
|
||
<nav>
|
||
<a href="#jobs"><Terminal size={18} />任务</a>
|
||
<a href="#capabilities"><Gauge size={18} />能力</a>
|
||
<a href="#datasets"><Boxes size={18} />数据集</a>
|
||
<a href="#gpu"><Cpu size={18} />GPU</a>
|
||
<a href="#runtime"><ShieldCheck size={18} />环境</a>
|
||
<a href="#coverage"><ClipboardCheck size={18} />覆盖</a>
|
||
<a href="#agents"><Bot size={18} />Agent</a>
|
||
<a href="#weights"><HardDrive size={18} />权重</a>
|
||
<a href="#results"><BarChart3 size={18} />结果</a>
|
||
</nav>
|
||
</aside>
|
||
|
||
<section className="workspace">
|
||
<header className="topbar">
|
||
<div>
|
||
<p className="eyebrow">Segmentation Operations</p>
|
||
<h1>训练、预测、分析与权重资产控制台</h1>
|
||
</div>
|
||
<button className="iconButton" onClick={refresh} title="刷新">
|
||
<RefreshCcw size={18} />
|
||
</button>
|
||
</header>
|
||
|
||
{error && <div className="alert">{error}</div>}
|
||
|
||
<section className="metrics">
|
||
<div className="metric">
|
||
<Activity size={20} />
|
||
<span>运行中</span>
|
||
<strong>{runningCount}</strong>
|
||
</div>
|
||
<div className="metric">
|
||
<ShieldCheck size={20} />
|
||
<span>成功</span>
|
||
<strong>{successCount}</strong>
|
||
</div>
|
||
<div className="metric">
|
||
<Zap size={20} />
|
||
<span>失败</span>
|
||
<strong>{failedCount}</strong>
|
||
</div>
|
||
<div className="metric">
|
||
<Database size={20} />
|
||
<span>上传集</span>
|
||
<strong>{datasets.length}</strong>
|
||
</div>
|
||
<div className="metric">
|
||
<ClipboardCheck size={20} />
|
||
<span>任务</span>
|
||
<strong>{catalog?.task_types.length ?? 0}</strong>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="panel" id="capabilities">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Capability Matrix</p>
|
||
<h2>全功能矩阵</h2>
|
||
</div>
|
||
<StatusPill status={capabilities?.passed ? "success" : "queued"} />
|
||
</div>
|
||
<div className="capSummary">
|
||
<div><span>域</span><strong>{capabilities?.summary.ready_domains ?? 0}/{capabilities?.summary.total_domains ?? 0}</strong></div>
|
||
<div><span>脚本</span><strong>{capabilities?.summary.mapped_user_scripts ?? 0}/{capabilities?.summary.user_scripts_total ?? 0}</strong></div>
|
||
<div><span>数据集</span><strong>{capabilities?.summary.uploaded_datasets ?? 0}</strong></div>
|
||
<div><span>结果</span><strong>{capabilities?.summary.artifacts ?? 0}</strong></div>
|
||
<div><span>曲线</span><strong>{capabilities?.summary.curves ?? 0}</strong></div>
|
||
<div><span>权重</span><strong>{capabilities?.summary.weights ?? 0}</strong></div>
|
||
</div>
|
||
<div className="capabilityGrid">
|
||
{(capabilities?.domains ?? []).map((domain) => (
|
||
<div key={domain.id} className={`capCard ${domain.ready ? "ok" : "bad"}`}>
|
||
<div className="capHead">
|
||
<strong>{domain.label}</strong>
|
||
<span>{domain.ready ? "READY" : "CHECK"}</span>
|
||
</div>
|
||
<div className="capNumbers">
|
||
<span>{domain.tasks.required_ready}/{domain.tasks.required} required</span>
|
||
<span>{domain.evidence.count} evidence</span>
|
||
</div>
|
||
<div className="capTags">
|
||
{domain.tasks.examples.slice(0, 4).map((task) => <small key={task}>{task}</small>)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="requirementStrip">
|
||
{(capabilities?.requirements ?? []).map((item) => (
|
||
<span key={item.id} className={item.passed ? "ok" : "bad"} title={item.detail}>
|
||
{item.label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="grid two" id="jobs">
|
||
<div className="panel taskPanel">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Job Builder</p>
|
||
<h2>创建任务</h2>
|
||
</div>
|
||
<button className="primary" disabled={busy} onClick={createJob}>
|
||
<Play size={17} />启动
|
||
</button>
|
||
</div>
|
||
<div className="taskColumns">
|
||
{Object.entries(taskGroups).map(([group, values]) => (
|
||
<div key={group} className="taskGroup">
|
||
<span>{group}</span>
|
||
{values.map((task) => (
|
||
<button key={task} className={task === taskType ? "selected" : ""} onClick={() => pickTask(task)}>
|
||
{task}
|
||
</button>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="gpuSelector">
|
||
<div>
|
||
<span>GPU 设备</span>
|
||
<strong>{selectedGpuIds.length ? selectedGpuIds.map((item) => `GPU ${item}`).join(", ") : "CPU"}</strong>
|
||
</div>
|
||
<div className="gpuPicker">
|
||
<button type="button" className={!selectedGpuIds.length ? "selected" : ""} onClick={() => setSelectedGpuIds([])}>
|
||
<Cpu size={14} />
|
||
<span>CPU</span>
|
||
</button>
|
||
{availableGpus.map((gpu) => (
|
||
<button key={gpu.index} type="button" className={selectedGpuIds.includes(gpu.index) ? "selected" : ""} onClick={() => toggleGpu(gpu.index)}>
|
||
<Cpu size={14} />
|
||
<span>GPU {gpu.index}</span>
|
||
<small>{gpu.memory_free_mb} MB</small>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="runtimeSelector">
|
||
<label>
|
||
<span>Conda 环境</span>
|
||
<select value={selectedCondaEnv} onChange={(event) => setSelectedCondaEnv(event.target.value)} aria-label="conda environment">
|
||
{condaEnvOptions.map((name) => (
|
||
<option key={name} value={name}>
|
||
{name === "auto" ? `Auto (${taskType.startsWith("mmseg.") ? condaEnvs?.mmseg_default ?? "seg_mmcv" : condaEnvs?.task_default ?? "seg_smp"})` : name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<label className="field">
|
||
<span>参数 JSON</span>
|
||
<textarea value={params} onChange={(event) => setParams(event.target.value)} />
|
||
</label>
|
||
</div>
|
||
|
||
<div className="panel">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Queue</p>
|
||
<h2>最近任务</h2>
|
||
</div>
|
||
<Gauge size={22} />
|
||
</div>
|
||
<div className="jobList">
|
||
{jobs.slice(0, 12).map((job) => (
|
||
<button key={job.id} className="jobRow" onClick={() => inspectJob(job)}>
|
||
<div className="jobRowTop">
|
||
<span>{job.type}</span>
|
||
<StatusPill status={job.status} />
|
||
</div>
|
||
<small>{job.description || job.id.slice(0, 8)}</small>
|
||
<JobProgressBar progress={job.progress} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="grid two" id="datasets">
|
||
<div className="panel">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Dataset Bench</p>
|
||
<h2>数据集、Label、Mask 上传</h2>
|
||
</div>
|
||
<Database size={22} />
|
||
</div>
|
||
<div className="datasetForm">
|
||
<label className="field compact">
|
||
<span>数据集名称</span>
|
||
<input value={datasetName} onChange={(event) => setDatasetName(event.target.value)} />
|
||
</label>
|
||
<label className="field compact">
|
||
<span>说明</span>
|
||
<input value={datasetDescription} onChange={(event) => setDatasetDescription(event.target.value)} />
|
||
</label>
|
||
<div className="segmented">
|
||
{(["images", "labels", "masks"] as const).map((kind) => (
|
||
<button key={kind} className={uploadKind === kind ? "active" : ""} onClick={() => setUploadKind(kind)}>
|
||
{kind}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<label className="drop">
|
||
<UploadCloud size={24} />
|
||
<span>{uploadFiles?.length ? `${uploadFiles.length} files selected` : "选择图片、label 或 mask 文件"}</span>
|
||
<input multiple type="file" accept="image/*,.txt,.json,.yaml,.yml" onChange={(event) => setUploadFiles(event.target.files)} />
|
||
</label>
|
||
<div className="buttonRow">
|
||
<button className="primary" disabled={busy} 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)}>
|
||
<Wand2 size={16} />
|
||
<span>{taskLabels[task]}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Files</p>
|
||
<h2>数据集浏览</h2>
|
||
</div>
|
||
<div className="buttonRow compactButtons">
|
||
<button className="iconButton" disabled={busy || !selectedValidation?.ready.yolo} onClick={generateSelectedYoloYaml} title="生成 YOLO dataset.yaml">
|
||
<FileSearch size={18} />
|
||
</button>
|
||
<button className="iconButton" disabled={busy || !selectedValidation?.ready.yolo} onClick={startSelectedYoloTrain} title="启动自定义 YOLO 训练">
|
||
<Play size={18} />
|
||
</button>
|
||
<button className="iconButton" disabled={busy || !selectedDataset?.absolute_layout || !selectedYoloWeightReady} onClick={startSelectedYoloPredict} title={selectedYoloWeightReady ? "使用自定义 best.pt 预测" : "best.pt 尚未生成"}>
|
||
<FileImage size={18} />
|
||
</button>
|
||
<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} image · {dataset.counts.labels} label · {dataset.counts.masks} mask</span>
|
||
</div>
|
||
<div className="readinessLine">
|
||
<StatusPill status={datasetValidations[dataset.name]?.ready.yolo ? "success" : "queued"} />
|
||
<small>YOLO {datasetValidations[dataset.name]?.pairs.image_label ?? 0} pair · Mask {datasetValidations[dataset.name]?.pairs.image_mask ?? 0} pair</small>
|
||
</div>
|
||
<div className="sampleStrip">
|
||
{["images", "labels", "masks"].flatMap((kind) =>
|
||
(dataset.samples[kind] ?? []).slice(0, 4).map((sample) => (
|
||
<a key={`${kind}-${sample.relative_path}`} href={`${API_BASE}/api/artifacts/${sample.relative_path}`} target="_blank" rel="noreferrer">
|
||
<span>{kind}</span>
|
||
<small>{sample.name}</small>
|
||
</a>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{selectedValidation && <DatasetQuality validation={selectedValidation} />}
|
||
{selectedDataset && <DatasetYoloOutputs dataset={selectedDataset} outputs={selectedYoloOutputs} />}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="grid two" id="coverage">
|
||
<div className="panel">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Coverage</p>
|
||
<h2>Seg 功能覆盖</h2>
|
||
</div>
|
||
<div className="buttonRow compactButtons">
|
||
<button className="iconButton" disabled={busy} onClick={runAcceptanceSmoke} title="运行轻量验收">
|
||
<ClipboardCheck size={18} />
|
||
</button>
|
||
<button className="iconButton" disabled={busy} onClick={runRealAcceptance} title="运行真实数据验收">
|
||
<FileSearch size={18} />
|
||
</button>
|
||
<button className="iconButton" disabled={busy} onClick={runDeepAcceptance} title="运行深度训练验收">
|
||
<Activity size={18} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="coverageGrid">
|
||
<div>
|
||
<span>业务脚本</span>
|
||
<strong>{coverage?.mapped_user_scripts ?? 0}/{coverage?.user_scripts_total ?? 0}</strong>
|
||
</div>
|
||
<div>
|
||
<span>全部脚本</span>
|
||
<strong>{coverage?.scripts_total ?? 0}</strong>
|
||
</div>
|
||
<div>
|
||
<span>任务构建</span>
|
||
<strong>{coverage?.task_build_passed ? "OK" : "Check"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>轻量验收</span>
|
||
<strong>{acceptance?.available === false ? "New" : acceptance?.passed ? "OK" : "Check"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>模型族</span>
|
||
<strong>{acceptance?.model_family_readiness?.passed ? "OK" : "Check"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>真实数据</span>
|
||
<strong>{realAcceptance?.available === false ? "New" : realAcceptance?.passed ? "OK" : "Check"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>深度训练</span>
|
||
<strong>{deepAcceptance?.available === false ? "New" : deepAcceptance?.passed ? "OK" : "Check"}</strong>
|
||
</div>
|
||
</div>
|
||
<div className="coverageStatus">
|
||
{(coverage?.unmapped_user_scripts.length ?? 0) === 0 ? (
|
||
<>
|
||
<span>当前用户侧脚本已全部映射到网页任务。</span>
|
||
<span>最近验收:{acceptance?.created_at ?? "尚未运行"} {acceptance?.run_id ? `#${acceptance.run_id}` : ""}</span>
|
||
<span>真实数据:{realAcceptance?.created_at ?? "尚未运行"} {realAcceptance?.run_id ? `#${realAcceptance.run_id}` : ""},通过 {realAcceptance?.checks?.filter((item) => item.passed).length ?? 0}/{realAcceptance?.checks?.length ?? 0}</span>
|
||
<span>深度验收:{deepAcceptance?.created_at ?? "尚未运行"} {deepAcceptance?.run_id ? `#${deepAcceptance.run_id}` : ""},通过 {deepAcceptance?.checks?.filter((item) => item.passed).length ?? 0}/{deepAcceptance?.checks?.length ?? 0}</span>
|
||
<span>模型族 readiness:{acceptance?.model_family_readiness?.checks?.filter((item) => item.passed).length ?? 0}/{acceptance?.model_family_readiness?.checks?.length ?? 0},warnings {acceptance?.model_family_readiness?.warnings?.length ?? 0}</span>
|
||
</>
|
||
) : (
|
||
coverage?.unmapped_user_scripts.slice(0, 8).map((item) => <code key={item}>{item}</code>)
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Buildability</p>
|
||
<h2>任务可启动检查</h2>
|
||
</div>
|
||
<Terminal size={22} />
|
||
</div>
|
||
<div className="taskCheckList">
|
||
{(coverage?.task_build_checks ?? []).slice(0, 28).map((item) => (
|
||
<div key={item.task} className={item.passed ? "ok" : "bad"}>
|
||
<span>{item.task}</span>
|
||
<small>{item.passed ? "command ready" : item.error ?? "check failed"}</small>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="grid two" id="agents">
|
||
<div className="panel agentPanel">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Evaluation Agent</p>
|
||
<h2>评价建议</h2>
|
||
</div>
|
||
<StatusPill status={(agentEvaluation?.score ?? 0) >= 1 ? "success" : "queued"} />
|
||
</div>
|
||
<div className="agentScore">
|
||
<strong>{Math.round((agentEvaluation?.score ?? 0) * 100)}%</strong>
|
||
<span>{agentEvaluation?.checks.filter((item) => item.passed).length ?? 0}/{agentEvaluation?.checks.length ?? 0} checks passed</span>
|
||
</div>
|
||
<div className="suggestionList">
|
||
{(agentEvaluation?.suggestions ?? ["等待评价 agent 返回建议。"]).slice(0, 6).map((item, index) => (
|
||
<div key={`${index}-${item}`}>{item}</div>
|
||
))}
|
||
</div>
|
||
<AgentCheckList checks={agentEvaluation?.checks ?? []} limit={14} />
|
||
</div>
|
||
|
||
<div className="panel agentPanel">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Validation Agent</p>
|
||
<h2>运行验证</h2>
|
||
</div>
|
||
<button className="primary" disabled={agentBusy} onClick={runAgentValidation}>
|
||
<ClipboardCheck size={17} />验证
|
||
</button>
|
||
</div>
|
||
<div className="agentScore">
|
||
<strong>{agentValidation ? (agentValidation.passed ? "OK" : "Check") : "Ready"}</strong>
|
||
<span>{agentValidation ? `${agentValidation.checks.filter((item) => item.passed).length}/${agentValidation.checks.length} checks passed` : "轻量验证不会触发深度训练"}</span>
|
||
</div>
|
||
<AgentCheckList checks={agentValidation?.checks ?? []} limit={18} />
|
||
</div>
|
||
</section>
|
||
|
||
<section className="grid four">
|
||
<div className="panel" id="gpu">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Hardware</p>
|
||
<h2>GPU</h2>
|
||
</div>
|
||
<Cpu size={22} />
|
||
</div>
|
||
{(gpus?.gpus ?? []).map((gpu) => (
|
||
<div className="gpu" key={gpu.index}>
|
||
<div>
|
||
<strong>GPU {gpu.index}</strong>
|
||
<span>{gpu.name}</span>
|
||
</div>
|
||
<meter value={gpu.memory_used_mb} max={gpu.memory_total_mb} />
|
||
<small>{gpu.memory_free_mb} MB free · {gpu.utilization_gpu_percent}% · {gpu.temperature_c}C</small>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="panel" id="runtime">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Runtime</p>
|
||
<h2>环境就绪</h2>
|
||
</div>
|
||
<ShieldCheck size={22} />
|
||
</div>
|
||
<div className="envList">
|
||
{(runtimeReadiness?.envs ?? []).map((env) => (
|
||
<div key={env.role} className={`envCard ${env.passed ? "ok" : "bad"}`}>
|
||
<div className="envHead">
|
||
<div>
|
||
<strong>{env.name}</strong>
|
||
<small>{env.label}</small>
|
||
</div>
|
||
<span>{env.passed ? "READY" : env.exists ? "CHECK" : "MISSING"}</span>
|
||
</div>
|
||
<div className="envChecks">
|
||
{env.checks.slice(0, 8).map((check) => (
|
||
<span key={check.module} className={check.passed ? "ok" : "bad"} title={check.error ?? check.package}>
|
||
{check.module}{check.version ? ` ${check.version}` : ""}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p className="muted">{runtimeReadiness?.passed ? "runtime imports ready" : "run scripts/bootstrap_conda_envs.sh"} · {runtimeReadiness?.generated_at ?? "not checked"}</p>
|
||
</div>
|
||
|
||
<WeightPanel
|
||
catalog={catalog}
|
||
manifest={weightManifest}
|
||
verification={weightVerification}
|
||
busy={busy}
|
||
onSync={syncWeights}
|
||
onVerify={verifyWeights}
|
||
/>
|
||
|
||
<div className="panel">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Catalog</p>
|
||
<h2>模型</h2>
|
||
</div>
|
||
<FileSearch size={22} />
|
||
</div>
|
||
<p className="muted">SegModel {catalog?.segmodel_architectures.length ?? 0} · YOLO {catalog?.yolo_models.length ?? 0} · MMSeg {catalog?.mmseg_algorithms.length ?? 0}</p>
|
||
<div className="chips">
|
||
{(catalog?.segmodel_architectures ?? []).slice(0, 8).map((item) => <span key={item}>{item}</span>)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="grid three">
|
||
<div className="panel insight">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Segmentation</p>
|
||
<h2>分割结果</h2>
|
||
</div>
|
||
<Wand2 size={22} />
|
||
</div>
|
||
<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">
|
||
<div>
|
||
<p className="eyebrow">Heatmap</p>
|
||
<h2>YOLO 热度图</h2>
|
||
</div>
|
||
<Zap size={22} />
|
||
</div>
|
||
<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">
|
||
<div>
|
||
<p className="eyebrow">Curves</p>
|
||
<h2>Loss / 指标</h2>
|
||
</div>
|
||
<BarChart3 size={22} />
|
||
</div>
|
||
<div className="resultList tight">
|
||
<CurvePanel curves={curves} selected={selectedCurve} selectedPath={selectedCurvePath} onSelect={setSelectedCurvePath} />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="grid two">
|
||
<div className="panel logPanel">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Live Log</p>
|
||
<h2>{selectedJob?.type ?? "选择一个任务"}</h2>
|
||
</div>
|
||
<button className="iconButton" disabled={!selectedJob} onClick={cancelSelectedJob} title="取消任务">
|
||
<Square size={18} />
|
||
</button>
|
||
</div>
|
||
{selectedJob && <JobProgressBar progress={selectedJob.progress} />}
|
||
{selectedJob && <JobDiagnostics job={selectedJob} />}
|
||
<pre>{log || "No log selected."}</pre>
|
||
</div>
|
||
|
||
<div className="panel" id="results">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Artifacts</p>
|
||
<h2>最近结果</h2>
|
||
</div>
|
||
<BarChart3 size={22} />
|
||
</div>
|
||
<ResultBrowser
|
||
results={filteredResults}
|
||
total={results.length}
|
||
familyOptions={resultFamilyOptions}
|
||
roleOptions={resultRoleOptions}
|
||
familyFilter={resultFamilyFilter}
|
||
roleFilter={resultRoleFilter}
|
||
onFamilyFilter={setResultFamilyFilter}
|
||
onRoleFilter={setResultRoleFilter}
|
||
/>
|
||
</div>
|
||
</section>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function JobDiagnostics({ job }: { job: Job }) {
|
||
const command = job.command?.join(" ") ?? "";
|
||
return (
|
||
<div className="jobDiagnostics">
|
||
<div className="jobDetailGrid">
|
||
<div><span>ID</span><strong>{job.id.slice(0, 12)}</strong></div>
|
||
<div><span>PID</span><strong>{job.pid ?? "-"}</strong></div>
|
||
<div><span>Exit</span><strong>{job.exit_code ?? "-"}</strong></div>
|
||
<div><span>Log</span><strong>{formatBytes(job.log_size)}</strong></div>
|
||
</div>
|
||
{job.cwd && (
|
||
<div className="jobPath">
|
||
<span>CWD</span>
|
||
<code>{job.cwd}</code>
|
||
</div>
|
||
)}
|
||
{!!job.error && <div className="jobError">{job.error}</div>}
|
||
{!!command && (
|
||
<details className="jobDetailBlock" open>
|
||
<summary>Command</summary>
|
||
<pre>{command}</pre>
|
||
</details>
|
||
)}
|
||
<details className="jobDetailBlock">
|
||
<summary>Params</summary>
|
||
<pre>{JSON.stringify(job.params ?? {}, null, 2)}</pre>
|
||
</details>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WeightPanel({
|
||
catalog,
|
||
manifest,
|
||
verification,
|
||
busy,
|
||
onSync,
|
||
onVerify
|
||
}: {
|
||
catalog: Catalog | null;
|
||
manifest: WeightManifest | null;
|
||
verification: WeightVerifyPayload | null;
|
||
busy: boolean;
|
||
onSync: () => void;
|
||
onVerify: () => void;
|
||
}) {
|
||
const files = manifest?.files ?? [];
|
||
const familyStats = Array.from(files.reduce((counts, item) => counts.set(item.family, (counts.get(item.family) ?? 0) + 1), new Map<string, number>()).entries())
|
||
.sort((a, b) => b[1] - a[1]);
|
||
return (
|
||
<div className="panel" id="weights">
|
||
<div className="panelHead">
|
||
<div>
|
||
<p className="eyebrow">Assets</p>
|
||
<h2>权重</h2>
|
||
</div>
|
||
<div className="buttonRow compactButtons">
|
||
<button className="iconButton" disabled={busy || !files.length} onClick={onVerify} title="校验权重 manifest">
|
||
<ShieldCheck size={18} />
|
||
</button>
|
||
<button className="iconButton" disabled={busy} onClick={onSync} title="同步权重">
|
||
<UploadCloud size={18} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="bigNumber">{manifest?.count ?? catalog?.weights.count ?? 0}</div>
|
||
<p className="muted">{formatBytes(manifest?.total_bytes ?? catalog?.weights.total_bytes)} indexed</p>
|
||
<p className="muted">{manifest?.updated_at ?? catalog?.weights.updated_at ?? "manifest not generated"}</p>
|
||
{verification && (
|
||
<div className={verification.ok_count === verification.count ? "weightVerify ok" : "weightVerify bad"}>
|
||
{verification.ok_count}/{verification.count} verified
|
||
</div>
|
||
)}
|
||
<div className="weightFamilies">
|
||
{familyStats.slice(0, 4).map(([family, count]) => (
|
||
<span key={family}>{family} {count}</span>
|
||
))}
|
||
</div>
|
||
<div className="weightList">
|
||
{files.slice(0, 6).map((item) => (
|
||
<a key={item.stored_path} href={`${API_BASE}/api/artifacts/${item.stored_path}`} target="_blank" rel="noreferrer">
|
||
<span>{item.source_path}</span>
|
||
<small>{item.family} · {item.role} · {formatBytes(item.size)}</small>
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AgentCheckList({ checks, limit }: { checks: AgentCheck[]; limit: number }) {
|
||
if (!checks.length) {
|
||
return <p className="muted">尚未运行验证。</p>;
|
||
}
|
||
return (
|
||
<div className="agentCheckList">
|
||
{checks.slice(0, limit).map((check) => (
|
||
<div key={check.name} className={check.passed ? "ok" : "bad"} title={check.name}>
|
||
<span>{check.name}</span>
|
||
<small>{check.passed ? "passed" : "needs attention"}</small>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ResultPreview({ results }: { results: ResultItem[] }) {
|
||
if (!results.length) {
|
||
return <p className="muted">暂无结果</p>;
|
||
}
|
||
return (
|
||
<div className="previewGrid">
|
||
{results.map((item) => (
|
||
<a key={item.path} href={`${API_BASE}/api/artifacts/${item.relative_path}`} target="_blank" rel="noreferrer">
|
||
<img src={`${API_BASE}/api/artifacts/${item.relative_path}`} alt={item.name} />
|
||
<span>{item.name}</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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.length ? (
|
||
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>
|
||
))
|
||
) : (
|
||
<p className="muted">没有匹配产物</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DatasetQuality({ validation }: { validation: DatasetValidation }) {
|
||
return (
|
||
<div className="qualityBox">
|
||
<div className="qualityHead">
|
||
<strong>{validation.dataset}</strong>
|
||
<span>{validation.ready.yolo ? "YOLO READY" : validation.ready.mask ? "MASK READY" : "CHECK"}</span>
|
||
</div>
|
||
<div className="qualityStats">
|
||
<div><span>Image/Label</span><strong>{validation.pairs.image_label}</strong></div>
|
||
<div><span>Image/Mask</span><strong>{validation.pairs.image_mask}</strong></div>
|
||
<div><span>Classes</span><strong>{validation.classes.length || 0}</strong></div>
|
||
<div><span>Annotations</span><strong>{validation.counts.annotations}</strong></div>
|
||
</div>
|
||
<div className="qualityChecks">
|
||
{validation.checks.map((check) => (
|
||
<div key={check.name} className={check.passed ? "ok" : "bad"}>
|
||
<span>{check.name}</span>
|
||
<small>{check.passed ? "ok" : `${check.errors?.length ?? 0} issue`}</small>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DatasetYoloOutputs({ dataset, outputs }: { dataset: UploadedDataset; outputs: DatasetYoloOutputsPayload }) {
|
||
const previewItems = [...outputs.heatmaps.slice(0, 3), ...outputs.predictions.slice(0, 3)].slice(0, 6);
|
||
const primaryCurve = outputs.curves[0];
|
||
const curveSeries = primaryCurve?.series.slice(0, 4) ?? [];
|
||
return (
|
||
<div className="datasetOutputBox">
|
||
<div className="qualityHead">
|
||
<strong>{dataset.name} · YOLO</strong>
|
||
<span>{outputs.bestWeight ? "BEST.PT READY" : "BEST.PT MISSING"}</span>
|
||
</div>
|
||
<div className="qualityStats">
|
||
<div><span>Weights</span><strong>{outputs.bestWeight ? 1 : 0}</strong></div>
|
||
<div><span>Predict</span><strong>{outputs.predictions.length}</strong></div>
|
||
<div><span>Heatmap</span><strong>{outputs.heatmaps.length}</strong></div>
|
||
<div><span>Curves</span><strong>{outputs.curves.length}</strong></div>
|
||
</div>
|
||
<div className="datasetOutputLinks">
|
||
{outputs.bestWeight && (
|
||
<a href={`${API_BASE}/api/artifacts/${outputs.bestWeight.relative_path}`} target="_blank" rel="noreferrer">
|
||
<span>best.pt</span>
|
||
<small>{formatBytes(outputs.bestWeight.size)}</small>
|
||
</a>
|
||
)}
|
||
{outputs.curves.slice(0, 2).map((curve) => (
|
||
<a key={curve.relative_path} href={`${API_BASE}/api/artifacts/${curve.relative_path}`} target="_blank" rel="noreferrer">
|
||
<span>{curve.name}</span>
|
||
<small>{curve.row_count} epochs</small>
|
||
</a>
|
||
))}
|
||
</div>
|
||
{primaryCurve && !!curveSeries.length && (
|
||
<div className="datasetOutputCurve">
|
||
<div>
|
||
<strong>{primaryCurve.name}</strong>
|
||
<span>{primaryCurve.row_count} epochs · {primaryCurve.family}</span>
|
||
</div>
|
||
<MiniCurvePlot series={curveSeries} />
|
||
</div>
|
||
)}
|
||
{!!previewItems.length && (
|
||
<div className="datasetOutputPreview">
|
||
{previewItems.map((item) => (
|
||
<a key={item.relative_path} href={`${API_BASE}/api/artifacts/${item.relative_path}`} target="_blank" rel="noreferrer">
|
||
<img src={`${API_BASE}/api/artifacts/${item.relative_path}`} alt={item.name} />
|
||
<span>{item.role}</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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];
|
||
}
|
||
|
||
function resultFilterLabel(value: string) {
|
||
return value === "all" ? "All" : value;
|
||
}
|
||
|
||
createRoot(document.getElementById("root")!).render(
|
||
<React.StrictMode>
|
||
<App />
|
||
</React.StrictMode>
|
||
);
|