diff --git a/README.md b/README.md index 667e837..c4f7ef4 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,10 @@ Open the Vite URL shown in the terminal. The frontend expects the backend at The web UI includes a dataset bench for creating upload workspaces, uploading images/labels/masks, and jumping into the existing rename, PNG conversion, resize, pair-check, label rebuild, transparent overlay, stitch, and video-frame -jobs. Segmentation previews, YOLO heatmaps, and loss/metric artifacts are -grouped on the results dashboard. +jobs. Selecting an uploaded dataset fills task JSON with its images, labels, +and masks directories. Segmentation previews, YOLO heatmaps, and loss/metric +artifacts are grouped on the results dashboard, and YOLO-style `results.csv` +files are parsed into lightweight training curves. The coverage panel calls `GET /api/coverage` and verifies that the user-facing scripts from the existing `Seg/` workspace are mapped to web jobs. MMSeg @@ -121,6 +123,8 @@ Use `GET /api/catalog` to inspect supported models, algorithms, datasets, and task types discovered from the existing `Seg/` workspace. Use `GET /api/coverage` to inspect script-to-task coverage and task buildability. +Use `GET /api/results/curves` to inspect parsed training curves discovered +from YOLO, SegModel, MMSeg, visual-tool, and analysis output directories. ## Agents diff --git a/backend/app/agents/evaluation_agent.py b/backend/app/agents/evaluation_agent.py index 1310196..639639b 100644 --- a/backend/app/agents/evaluation_agent.py +++ b/backend/app/agents/evaluation_agent.py @@ -39,8 +39,9 @@ def evaluate_project() -> dict: expectations = { "left_nav_dataset": "数据集" in frontend_text and "#datasets" in frontend_text, "upload_ui": "uploadDatasetFiles" in frontend_text and "labels" in frontend_text and "masks" in frontend_text, - "loss_result_ui": "loss" in frontend_text.lower() and "heatmap" in frontend_text.lower(), + "loss_result_ui": "loss" in frontend_text.lower() and "heatmap" in frontend_text.lower() and "CurvePanel" in frontend_text, "dataset_api": "/api/datasets" in backend_text and "api_upload_dataset_files" in backend_text, + "curve_api": "/api/results/curves" in backend_text, "coverage_api": "/api/coverage" in backend_text and coverage["task_build_passed"], "visual_tools": "visual.yolo11_heatmap_v2" in catalog["task_types"] and "visual.fps" in catalog["task_types"], "yolo_dataset_tools": "dataset.yolo_txt_sort" in catalog["task_types"] and "dataset.yolo_resize" in catalog["task_types"], diff --git a/backend/app/agents/validation_agent.py b/backend/app/agents/validation_agent.py index 304bef8..9ef5e0e 100644 --- a/backend/app/agents/validation_agent.py +++ b/backend/app/agents/validation_agent.py @@ -12,6 +12,7 @@ from ..acceptance import run_live_acceptance from ..catalog import get_catalog from ..config import settings from ..coverage import get_coverage_report +from ..modules.results.service import scan_training_curves from ..modules.system.service import get_conda_envs, get_gpus from ..modules.weights.service import load_manifest @@ -53,6 +54,8 @@ def validate_project(run_build: bool = False) -> dict: checks.append({"name": "task_buildability", "passed": coverage["task_build_passed"], "detail": coverage["task_build_checks"]}) checks.append({"name": "script_coverage_user_facing", "passed": not coverage["unmapped_user_scripts"], "detail": coverage}) checks.append({"name": "weights_manifest_present", "passed": manifest.get("count", 0) >= 1}) + curves = scan_training_curves() + checks.append({"name": "training_curves_detected", "passed": len(curves) >= 1, "detail": {"count": len(curves), "examples": [item["relative_path"] for item in curves[:5]]}}) checks.append({"name": "gpus_query", "passed": bool(get_gpus().get("available"))}) env_names = [item["name"] for item in get_conda_envs().get("envs", [])] checks.append({"name": "task_env_exists", "passed": settings.task_conda_env in env_names, "detail": {"env": settings.task_conda_env}}) @@ -95,10 +98,12 @@ def validate_project(run_build: bool = False) -> dict: health = _fetch(f"{backend_url}/api/health") datasets = _fetch(f"{backend_url}/api/datasets") live_coverage = _fetch(f"{backend_url}/api/coverage") + live_curves = _fetch(f"{backend_url}/api/results/curves") frontend = _fetch(frontend_url) checks.append({"name": "live_backend_health", "passed": health["passed"] and '"ok":true' in health.get("body", "").replace(" ", ""), "detail": health}) checks.append({"name": "live_dataset_api", "passed": datasets["passed"] and datasets.get("body", "").lstrip().startswith("["), "detail": datasets}) checks.append({"name": "live_coverage_api", "passed": live_coverage["passed"] and '"task_build_passed":true' in live_coverage.get("body", "").replace(" ", ""), "detail": live_coverage}) + checks.append({"name": "live_training_curves_api", "passed": live_curves["passed"] and live_curves.get("body", "").lstrip().startswith("["), "detail": live_curves}) checks.append({"name": "live_frontend_index", "passed": frontend["passed"] and "Seg Data Server" in frontend.get("body", ""), "detail": frontend}) if os.getenv("SEG_VALIDATE_ACCEPTANCE", "1") == "1": acceptance = run_live_acceptance(backend_url) diff --git a/backend/app/main.py b/backend/app/main.py index c362717..847291a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,7 +14,8 @@ from .catalog import get_catalog from .config import settings from .coverage import get_coverage_report from .jobs import cancel_job, create_job -from .modules.system.service import disk_usage, get_conda_envs, get_gpus, scan_results +from .modules.results.service import scan_results, scan_training_curves +from .modules.system.service import disk_usage, get_conda_envs, get_gpus from .modules.dataset.service import create_dataset, list_uploaded_datasets, save_upload from .modules.weights.service import load_manifest, sync_weights, verify_weights from .agents.evaluation_agent import evaluate_project @@ -170,6 +171,11 @@ def api_results() -> list[dict]: return scan_results() +@app.get("/api/results/curves") +def api_result_curves() -> list[dict]: + return scan_training_curves() + + @app.get("/api/artifacts/{artifact_path:path}") def api_artifact(artifact_path: str): candidate = Path(artifact_path) diff --git a/backend/app/modules/dataset/service.py b/backend/app/modules/dataset/service.py index 754461f..93b0a8f 100644 --- a/backend/app/modules/dataset/service.py +++ b/backend/app/modules/dataset/service.py @@ -80,6 +80,7 @@ def describe_dataset(name: str) -> dict: safe_name = slugify(name) root = dataset_dir(safe_name) meta = _load_meta(safe_name) + absolute_layout = {kind: str((root / kind).resolve()) for kind in DATASET_KINDS} counts = {} samples = {} for kind in sorted(DATASET_KINDS): @@ -95,7 +96,7 @@ def describe_dataset(name: str) -> dict: } for path in files[:80] ] - return {**meta, "counts": counts, "samples": samples} + return {**meta, "absolute_layout": absolute_layout, "counts": counts, "samples": samples} def list_uploaded_datasets() -> list[dict]: diff --git a/backend/app/modules/results/__init__.py b/backend/app/modules/results/__init__.py new file mode 100644 index 0000000..2b8cac3 --- /dev/null +++ b/backend/app/modules/results/__init__.py @@ -0,0 +1 @@ +"""Result artifact discovery and lightweight metric parsing.""" diff --git a/backend/app/modules/results/service.py b/backend/app/modules/results/service.py new file mode 100644 index 0000000..12fb070 --- /dev/null +++ b/backend/app/modules/results/service.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import csv +import math +from pathlib import Path +from typing import Iterable + +from ...config import settings + +RESULT_EXTS = {".csv", ".png", ".jpg", ".jpeg", ".svg", ".log", ".pth", ".pt", ".mp4", ".avi"} +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".svg"} +CURVE_NAME_HINTS = ("results.csv", "loss", "metric", "miou", "iou") +SERIES_HINTS = ("loss", "miou", "iou", "map", "precision", "recall", "acc", "dice", "f1") +SKIP_SERIES_HINTS = ("time", "lr/") + + +def result_roots() -> list[Path]: + source = settings.source_root + project = settings.project_root + roots = [ + source / "DataSet_Public_outputs", + source / "BestMode_Predict_Results_DataSet_Public", + source / "Hardisk", + source / "Seg_All_In_One_Analysis", + source / "Seg_Predict_YoloModel", + source / "Seg_Predict_SegModel", + source / "Seg_Predict_Own_Video_V2", + source / "Tool-可视化" / "runs", + source / "Tool-可视化" / "Data" / "result", + source / "Tool-图片堆叠" / "result_0.3透明度", + ] + upload_root = project / "var" / "uploads" / "datasets" + if upload_root.exists(): + roots.extend(path for path in upload_root.glob("*/results") if path.is_dir()) + return roots + + +def _safe_relative(path: Path) -> str: + resolved = path.resolve() + for root in (settings.source_root, settings.project_root): + try: + return str(resolved.relative_to(root)) + except ValueError: + continue + return str(resolved) + + +def _family_for_path(path: Path) -> str: + lower = str(path).lower() + if "yolo" in lower: + return "yolo" + if "mmseg" in lower or "dataset_public_outputs" in lower or "bestmode_predict" in lower: + return "mmseg" + if "segmodel" in lower: + return "segmodel" + if "analysis" in lower: + return "analysis" + if "tool-" in lower or "tool_" in lower: + return "tool" + return "artifact" + + +def _role_for_path(path: Path) -> str: + lower = str(path).lower() + if any(key in lower for key in ("heat", "cam", "grad")): + return "heatmap" + if any(key in lower for key in ("predict", "pred", "mask", "comparison", "overlay", "result_")) and path.suffix.lower() in IMAGE_EXTS: + return "segmentation" + if any(key in lower for key in ("loss", "metric", "miou", "iou", "curve", "results.csv")): + return "curve" + if path.suffix.lower() in {".pt", ".pth"}: + return "weight" + if path.suffix.lower() in {".mp4", ".avi"}: + return "video" + return "artifact" + + +def _iter_result_files() -> Iterable[Path]: + seen: set[Path] = set() + for root in result_roots(): + if not root.exists(): + continue + for path in root.rglob("*"): + if not path.is_file() or path.suffix.lower() not in RESULT_EXTS: + continue + resolved = path.resolve() + if resolved in seen: + continue + seen.add(resolved) + yield resolved + + +def scan_results(limit: int = 1000) -> list[dict]: + results: list[dict] = [] + for path in _iter_result_files(): + try: + stat = path.stat() + except OSError: + continue + results.append( + { + "name": path.name, + "path": str(path), + "relative_path": _safe_relative(path), + "size": stat.st_size, + "modified": stat.st_mtime, + "kind": path.suffix.lower().lstrip("."), + "family": _family_for_path(path), + "role": _role_for_path(path), + "previewable": path.suffix.lower() in IMAGE_EXTS, + } + ) + results.sort(key=lambda item: item["modified"], reverse=True) + return results[:limit] + + +def _is_curve_candidate(path: Path) -> bool: + lower = path.name.lower() + whole = str(path).lower() + return path.suffix.lower() == ".csv" and (lower in CURVE_NAME_HINTS or any(hint in whole for hint in CURVE_NAME_HINTS)) + + +def _as_float(value: str | None) -> float | None: + if value is None: + return None + try: + number = float(value.strip()) + except (TypeError, ValueError): + return None + if not math.isfinite(number): + return None + return number + + +def _series_candidates(headers: list[str]) -> list[str]: + candidates = [] + for header in headers: + key = header.strip() + lower = key.lower() + if any(skip in lower for skip in SKIP_SERIES_HINTS): + continue + if any(hint in lower for hint in SERIES_HINTS): + candidates.append(key) + return candidates[:10] + + +def parse_training_curve(path: Path, max_points: int = 300) -> dict | None: + try: + with path.open("r", encoding="utf-8-sig", newline="") as handle: + reader = csv.DictReader(handle) + if not reader.fieldnames: + return None + headers = [field.strip() for field in reader.fieldnames] + x_key = "epoch" if "epoch" in headers else "iter" if "iter" in headers else "step" if "step" in headers else headers[0] + series_names = _series_candidates(headers) + if not series_names: + return None + rows = [] + for raw in reader: + normalized = {key.strip(): value for key, value in raw.items() if key is not None} + x = _as_float(normalized.get(x_key)) + if x is None: + continue + rows.append((x, normalized)) + except (OSError, UnicodeDecodeError, csv.Error): + return None + + if not rows: + return None + stride = max(1, math.ceil(len(rows) / max_points)) + sampled = rows[::stride] + series = [] + for name in series_names: + points = [] + for x, row in sampled: + y = _as_float(row.get(name)) + if y is not None: + points.append({"x": x, "y": y}) + if len(points) >= 2: + values = [point["y"] for point in points] + series.append({"name": name, "points": points, "last": values[-1], "min": min(values), "max": max(values)}) + if not series: + return None + stat = path.stat() + return { + "name": path.parent.name if path.name.lower() == "results.csv" else path.stem, + "file_name": path.name, + "path": str(path.resolve()), + "relative_path": _safe_relative(path), + "modified": stat.st_mtime, + "size": stat.st_size, + "family": _family_for_path(path), + "x_key": x_key, + "row_count": len(rows), + "series": series, + } + + +def scan_training_curves(limit: int = 20) -> list[dict]: + curves = [] + for path in _iter_result_files(): + if not _is_curve_candidate(path): + continue + curve = parse_training_curve(path) + if curve: + curves.append(curve) + curves.sort(key=lambda item: item["modified"], reverse=True) + return curves[:limit] diff --git a/backend/tests/test_results_service.py b/backend/tests/test_results_service.py new file mode 100644 index 0000000..882a3b7 --- /dev/null +++ b/backend/tests/test_results_service.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from app.modules.results.service import parse_training_curve + + +def test_parse_yolo_results_curve(tmp_path: Path): + csv_path = tmp_path / "results.csv" + csv_path.write_text( + "\n".join( + [ + "epoch,time,train/box_loss,train/seg_loss,metrics/mAP50(M),val/seg_loss,lr/pg0", + "1,1.0,1.5,3.0,0.12,2.8,0.001", + "2,2.0,1.2,2.5,0.18,2.1,0.001", + "3,3.0,0.9,2.0,0.25,1.9,0.001", + ] + ), + encoding="utf-8", + ) + + curve = parse_training_curve(csv_path) + + assert curve is not None + assert curve["x_key"] == "epoch" + names = [item["name"] for item in curve["series"]] + assert "train/seg_loss" in names + assert "metrics/mAP50(M)" in names + assert all("lr/" not in name for name in names) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 58c19e8..953baba 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -50,6 +50,8 @@ type Catalog = { type UploadedDataset = { name: string; description?: string; + absolute_layout?: Record<"images" | "labels" | "masks", string>; + layout?: Record<"images" | "labels" | "masks", string>; counts: { images: number; labels: number; masks: number }; samples: Record>; }; @@ -61,6 +63,28 @@ type ResultItem = { size: number; modified: number; kind: string; + family?: string; + role?: string; + previewable?: boolean; +}; + +type CurveSeries = { + name: string; + points: Array<{ x: number; y: number }>; + last: number; + min: number; + max: number; +}; + +type TrainingCurve = { + name: string; + file_name: string; + relative_path: string; + modified: number; + family: string; + x_key: string; + row_count: number; + series: CurveSeries[]; }; type CoveragePayload = { @@ -163,6 +187,7 @@ function useData() { const [gpus, setGpus] = useState(null); const [jobs, setJobs] = useState([]); const [results, setResults] = useState([]); + const [curves, setCurves] = useState([]); const [datasets, setDatasets] = useState([]); const [coverage, setCoverage] = useState(null); const [acceptance, setAcceptance] = useState(null); @@ -170,11 +195,12 @@ function useData() { async function refresh() { try { - const [catalogNext, gpusNext, jobsNext, resultsNext, datasetsNext, coverageNext, acceptanceNext] = await Promise.all([ + const [catalogNext, gpusNext, jobsNext, resultsNext, curvesNext, datasetsNext, coverageNext, acceptanceNext] = await Promise.all([ api("/api/catalog"), api("/api/system/gpus"), api("/api/jobs"), api("/api/results"), + api("/api/results/curves"), api("/api/datasets"), api("/api/coverage"), api("/api/acceptance/latest") @@ -183,6 +209,7 @@ function useData() { setGpus(gpusNext); setJobs(jobsNext); setResults(resultsNext.slice(0, 80)); + setCurves(curvesNext.slice(0, 12)); setDatasets(datasetsNext); setCoverage(coverageNext); setAcceptance(acceptanceNext); @@ -198,7 +225,7 @@ function useData() { return () => window.clearInterval(timer); }, []); - return { catalog, gpus, jobs, results, datasets, coverage, acceptance, error, refresh }; + return { catalog, gpus, jobs, results, curves, datasets, coverage, acceptance, error, refresh }; } function StatusPill({ status }: { status: string }) { @@ -206,7 +233,7 @@ function StatusPill({ status }: { status: string }) { } function App() { - const { catalog, gpus, jobs, results, datasets, coverage, acceptance, error, refresh } = useData(); + const { catalog, gpus, jobs, results, curves, datasets, coverage, acceptance, error, refresh } = useData(); const [taskType, setTaskType] = useState("mock.echo"); const [params, setParams] = useState(JSON.stringify(defaultParams["mock.echo"], null, 2)); const [selectedJob, setSelectedJob] = useState(null); @@ -214,6 +241,8 @@ function App() { const [busy, setBusy] = useState(false); const [datasetName, setDatasetName] = useState("demo_dataset"); const [datasetDescription, setDatasetDescription] = useState(""); + const [selectedDatasetName, setSelectedDatasetName] = useState(""); + const [selectedCurvePath, setSelectedCurvePath] = useState(""); const [uploadKind, setUploadKind] = useState<"images" | "labels" | "masks">("images"); const [uploadFiles, setUploadFiles] = useState(null); @@ -234,6 +263,11 @@ function App() { }, [catalog]); const datasetOps = taskGroups.dataset.filter((task) => task in taskLabels); + const selectedDataset = useMemo( + () => datasets.find((dataset) => dataset.name === selectedDatasetName) ?? datasets.find((dataset) => dataset.name === datasetName), + [datasetName, datasets, selectedDatasetName] + ); + const selectedCurve = curves.find((curve) => curve.relative_path === selectedCurvePath) ?? curves[0]; function pickTask(next: string) { setTaskType(next); @@ -241,10 +275,28 @@ function App() { } function pickDatasetTask(next: string) { - pickTask(next); + setTaskType(next); + setParams(JSON.stringify(datasetParamsForTask(next), null, 2)); window.location.hash = "jobs"; } + function datasetParamsForTask(next: string): Record { + const base = { ...(catalog?.task_defaults?.[next] ?? defaultParams[next] ?? {}) }; + const layout = selectedDataset?.absolute_layout; + if (!layout) return base; + const resultDir = `${layout.images.replace(/\/images$/, "")}/results/${next.replace(".", "_")}`; + if (["dataset.pair", "dataset.resize", "dataset.stack", "dataset.stitch", "dataset.yolo_check_pairs", "dataset.yolo_stack"].includes(next)) { + return { ...base, image_dir: layout.images, label_dir: layout.labels, result_dir: resultDir }; + } + if (["dataset.rebuild_labels", "dataset.yolo_rebuild_labels", "dataset.yolo_txt_sort"].includes(next)) { + return { ...base, label_dir: layout.labels, folder: layout.labels }; + } + if (["dataset.to_png", "dataset.yolo_convert_png", "dataset.yolo_resize"].includes(next)) { + return { ...base, input_dir: layout.images, output_dir: resultDir, folder: layout.images }; + } + return base; + } + async function createJob() { setBusy(true); try { @@ -288,6 +340,7 @@ function App() { method: "POST", body: JSON.stringify({ name: datasetName, description: datasetDescription }) }); + setSelectedDatasetName(datasetName); await refresh(); } finally { setBusy(false); @@ -495,7 +548,22 @@ function App() {
{datasets.map((dataset) => ( -
+
{ + setDatasetName(dataset.name); + setSelectedDatasetName(dataset.name); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + setDatasetName(dataset.name); + setSelectedDatasetName(dataset.name); + } + }} + >
{dataset.name} {dataset.counts.images} image · {dataset.counts.labels} label · {dataset.counts.masks} mask @@ -510,7 +578,7 @@ function App() { )) )}
-
+
))}
@@ -641,7 +709,7 @@ function App() { - /predict|mask|comparison|prediction/i.test(item.relative_path) && ["png", "jpg", "jpeg"].includes(item.kind)).slice(0, 6)} /> + item.role === "segmentation" && ["png", "jpg", "jpeg", "svg"].includes(item.kind)).slice(0, 6)} />
@@ -651,7 +719,7 @@ function App() {
- /heat|cam|grad/i.test(item.relative_path) && ["png", "jpg", "jpeg"].includes(item.kind)).slice(0, 6)} /> + item.role === "heatmap" && ["png", "jpg", "jpeg", "svg"].includes(item.kind)).slice(0, 6)} />
@@ -662,12 +730,7 @@ function App() {
- {results.filter((item) => /loss|metric|miou|iou|csv|curve/i.test(item.relative_path)).slice(0, 10).map((item) => ( - - {item.name} - {formatBytes(item.size)} - - ))} +
@@ -711,7 +774,7 @@ function App() { function ResultPreview({ results }: { results: ResultItem[] }) { if (!results.length) { - return

暂无结果,运行预测、热度图或分析任务后会自动出现在这里。

; + return

暂无结果

; } return (
@@ -725,6 +788,82 @@ function ResultPreview({ results }: { results: ResultItem[] }) { ); } +function CurvePanel({ + curves, + selected, + selectedPath, + onSelect +}: { + curves: TrainingCurve[]; + selected?: TrainingCurve; + selectedPath: string; + onSelect: (path: string) => void; +}) { + if (!curves.length || !selected) { + return

暂无曲线数据

; + } + const visibleSeries = selected.series.slice(0, 5); + return ( +
+ + +
+ {visibleSeries.map((item, index) => ( + + + {item.name} + {item.last.toFixed(4)} + + ))} +
+
+ ); +} + +function MiniCurvePlot({ series }: { series: CurveSeries[] }) { + const points = series.flatMap((item) => item.points); + if (!points.length) return
; + 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 ( + + + {[0.25, 0.5, 0.75].map((tick) => ( + + ))} + {series.map((item, index) => ( + `${scaleX(point.x).toFixed(2)},${scaleY(point.y).toFixed(2)}`).join(" ")} + fill="none" + stroke={curveColor(index)} + strokeWidth="2.4" + strokeLinejoin="round" + strokeLinecap="round" + /> + ))} + + ); +} + +function curveColor(index: number) { + return ["#9de26f", "#73d2de", "#d3b35b", "#7aa2ff", "#f07167"][index % 5]; +} + createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 2b47ec3..67a879c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -32,7 +32,7 @@ button, textarea, select { font: inherit; } -input { +input, select { font: inherit; color: var(--ink); } @@ -477,10 +477,20 @@ textarea { } .datasetCard { + width: 100%; + display: block; + text-align: left; padding: 12px; border: 1px solid var(--line); border-radius: 7px; background: #101310; + color: var(--ink); + cursor: pointer; +} + +.datasetCard.selected { + border-color: var(--green); + background: rgba(157, 226, 111, 0.08); } .datasetCardHead { @@ -619,6 +629,136 @@ meter { white-space: pre-wrap; } +.curvePanel { + display: grid; + gap: 10px; +} + +.curvePanel select { + width: 100%; + height: 38px; + padding: 0 10px; + border-radius: 6px; + border: 1px solid var(--line); + background: var(--field); +} + +.curveSvg, .curveEmpty { + width: 100%; + aspect-ratio: 2.7 / 1; + min-height: 160px; + border: 1px solid var(--line); + border-radius: 7px; + background: #080a08; +} + +.curveSvg .axis { + stroke: rgba(238, 242, 232, 0.28); + stroke-width: 1; +} + +.curveSvg .gridLine { + stroke: rgba(238, 242, 232, 0.08); + stroke-width: 1; +} + +.curveLegend { + display: grid; + gap: 7px; +} + +.curveLegend a { + display: grid; + grid-template-columns: 10px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: #101310; + text-decoration: none; + color: var(--ink); +} + +.curveLegend i { + width: 10px; + height: 10px; + border-radius: 999px; +} + +.curveLegend span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 980px) { + body { + min-width: 0; + } + + .shell { + grid-template-columns: 1fr; + } + + .rail { + position: static; + height: auto; + display: grid; + grid-template-columns: auto 1fr; + gap: 12px; + padding: 14px; + } + + .brand { + margin-bottom: 0; + } + + nav { + grid-template-columns: repeat(6, minmax(0, 1fr)); + align-items: center; + } + + nav a { + justify-content: center; + padding: 10px 6px; + } + + nav a svg { + flex: none; + } + + nav a { + font-size: 0; + } + + .workspace { + padding: 16px; + } + + .topbar, .panelHead { + align-items: flex-start; + gap: 12px; + } + + .metrics, + .grid.two, + .grid.three, + .taskColumns { + grid-template-columns: 1fr; + } + + .coverageGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .opGrid, + .sampleStrip, + .taskCheckList { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + .resultList a:hover, .jobRow:hover { border-color: var(--green); } @@ -665,7 +805,7 @@ meter { white-space: nowrap; } -@media (max-width: 1180px) { +@media (min-width: 981px) and (max-width: 1180px) { body { min-width: 960px; } .shell { grid-template-columns: 220px 1fr; } .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }