Add result curve discovery dashboard
This commit is contained in:
@@ -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
|
The web UI includes a dataset bench for creating upload workspaces, uploading
|
||||||
images/labels/masks, and jumping into the existing rename, PNG conversion,
|
images/labels/masks, and jumping into the existing rename, PNG conversion,
|
||||||
resize, pair-check, label rebuild, transparent overlay, stitch, and video-frame
|
resize, pair-check, label rebuild, transparent overlay, stitch, and video-frame
|
||||||
jobs. Segmentation previews, YOLO heatmaps, and loss/metric artifacts are
|
jobs. Selecting an uploaded dataset fills task JSON with its images, labels,
|
||||||
grouped on the results dashboard.
|
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
|
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
|
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.
|
task types discovered from the existing `Seg/` workspace.
|
||||||
Use `GET /api/coverage` to inspect script-to-task coverage and task
|
Use `GET /api/coverage` to inspect script-to-task coverage and task
|
||||||
buildability.
|
buildability.
|
||||||
|
Use `GET /api/results/curves` to inspect parsed training curves discovered
|
||||||
|
from YOLO, SegModel, MMSeg, visual-tool, and analysis output directories.
|
||||||
|
|
||||||
## Agents
|
## Agents
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ def evaluate_project() -> dict:
|
|||||||
expectations = {
|
expectations = {
|
||||||
"left_nav_dataset": "数据集" in frontend_text and "#datasets" in frontend_text,
|
"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,
|
"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,
|
"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"],
|
"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"],
|
"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"],
|
"yolo_dataset_tools": "dataset.yolo_txt_sort" in catalog["task_types"] and "dataset.yolo_resize" in catalog["task_types"],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from ..acceptance import run_live_acceptance
|
|||||||
from ..catalog import get_catalog
|
from ..catalog import get_catalog
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..coverage import get_coverage_report
|
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.system.service import get_conda_envs, get_gpus
|
||||||
from ..modules.weights.service import load_manifest
|
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": "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": "script_coverage_user_facing", "passed": not coverage["unmapped_user_scripts"], "detail": coverage})
|
||||||
checks.append({"name": "weights_manifest_present", "passed": manifest.get("count", 0) >= 1})
|
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"))})
|
checks.append({"name": "gpus_query", "passed": bool(get_gpus().get("available"))})
|
||||||
env_names = [item["name"] for item in get_conda_envs().get("envs", [])]
|
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}})
|
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")
|
health = _fetch(f"{backend_url}/api/health")
|
||||||
datasets = _fetch(f"{backend_url}/api/datasets")
|
datasets = _fetch(f"{backend_url}/api/datasets")
|
||||||
live_coverage = _fetch(f"{backend_url}/api/coverage")
|
live_coverage = _fetch(f"{backend_url}/api/coverage")
|
||||||
|
live_curves = _fetch(f"{backend_url}/api/results/curves")
|
||||||
frontend = _fetch(frontend_url)
|
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_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_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_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})
|
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":
|
if os.getenv("SEG_VALIDATE_ACCEPTANCE", "1") == "1":
|
||||||
acceptance = run_live_acceptance(backend_url)
|
acceptance = run_live_acceptance(backend_url)
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from .catalog import get_catalog
|
|||||||
from .config import settings
|
from .config import settings
|
||||||
from .coverage import get_coverage_report
|
from .coverage import get_coverage_report
|
||||||
from .jobs import cancel_job, create_job
|
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.dataset.service import create_dataset, list_uploaded_datasets, save_upload
|
||||||
from .modules.weights.service import load_manifest, sync_weights, verify_weights
|
from .modules.weights.service import load_manifest, sync_weights, verify_weights
|
||||||
from .agents.evaluation_agent import evaluate_project
|
from .agents.evaluation_agent import evaluate_project
|
||||||
@@ -170,6 +171,11 @@ def api_results() -> list[dict]:
|
|||||||
return scan_results()
|
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}")
|
@app.get("/api/artifacts/{artifact_path:path}")
|
||||||
def api_artifact(artifact_path: str):
|
def api_artifact(artifact_path: str):
|
||||||
candidate = Path(artifact_path)
|
candidate = Path(artifact_path)
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ def describe_dataset(name: str) -> dict:
|
|||||||
safe_name = slugify(name)
|
safe_name = slugify(name)
|
||||||
root = dataset_dir(safe_name)
|
root = dataset_dir(safe_name)
|
||||||
meta = _load_meta(safe_name)
|
meta = _load_meta(safe_name)
|
||||||
|
absolute_layout = {kind: str((root / kind).resolve()) for kind in DATASET_KINDS}
|
||||||
counts = {}
|
counts = {}
|
||||||
samples = {}
|
samples = {}
|
||||||
for kind in sorted(DATASET_KINDS):
|
for kind in sorted(DATASET_KINDS):
|
||||||
@@ -95,7 +96,7 @@ def describe_dataset(name: str) -> dict:
|
|||||||
}
|
}
|
||||||
for path in files[:80]
|
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]:
|
def list_uploaded_datasets() -> list[dict]:
|
||||||
|
|||||||
1
backend/app/modules/results/__init__.py
Normal file
1
backend/app/modules/results/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Result artifact discovery and lightweight metric parsing."""
|
||||||
208
backend/app/modules/results/service.py
Normal file
208
backend/app/modules/results/service.py
Normal file
@@ -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]
|
||||||
27
backend/tests/test_results_service.py
Normal file
27
backend/tests/test_results_service.py
Normal file
@@ -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)
|
||||||
@@ -50,6 +50,8 @@ type Catalog = {
|
|||||||
type UploadedDataset = {
|
type UploadedDataset = {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
absolute_layout?: Record<"images" | "labels" | "masks", string>;
|
||||||
|
layout?: Record<"images" | "labels" | "masks", string>;
|
||||||
counts: { images: number; labels: number; masks: number };
|
counts: { images: number; labels: number; masks: number };
|
||||||
samples: Record<string, Array<{ name: string; relative_path: string; size: number; previewable: boolean }>>;
|
samples: Record<string, Array<{ name: string; relative_path: string; size: number; previewable: boolean }>>;
|
||||||
};
|
};
|
||||||
@@ -61,6 +63,28 @@ type ResultItem = {
|
|||||||
size: number;
|
size: number;
|
||||||
modified: number;
|
modified: number;
|
||||||
kind: string;
|
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 = {
|
type CoveragePayload = {
|
||||||
@@ -163,6 +187,7 @@ function useData() {
|
|||||||
const [gpus, setGpus] = useState<GpuPayload | null>(null);
|
const [gpus, setGpus] = useState<GpuPayload | null>(null);
|
||||||
const [jobs, setJobs] = useState<Job[]>([]);
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
const [results, setResults] = useState<ResultItem[]>([]);
|
const [results, setResults] = useState<ResultItem[]>([]);
|
||||||
|
const [curves, setCurves] = useState<TrainingCurve[]>([]);
|
||||||
const [datasets, setDatasets] = useState<UploadedDataset[]>([]);
|
const [datasets, setDatasets] = useState<UploadedDataset[]>([]);
|
||||||
const [coverage, setCoverage] = useState<CoveragePayload | null>(null);
|
const [coverage, setCoverage] = useState<CoveragePayload | null>(null);
|
||||||
const [acceptance, setAcceptance] = useState<AcceptancePayload | null>(null);
|
const [acceptance, setAcceptance] = useState<AcceptancePayload | null>(null);
|
||||||
@@ -170,11 +195,12 @@ function useData() {
|
|||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
const [catalogNext, gpusNext, jobsNext, resultsNext, datasetsNext, coverageNext, acceptanceNext] = await Promise.all([
|
const [catalogNext, gpusNext, jobsNext, resultsNext, curvesNext, datasetsNext, coverageNext, acceptanceNext] = await Promise.all([
|
||||||
api<Catalog>("/api/catalog"),
|
api<Catalog>("/api/catalog"),
|
||||||
api<GpuPayload>("/api/system/gpus"),
|
api<GpuPayload>("/api/system/gpus"),
|
||||||
api<Job[]>("/api/jobs"),
|
api<Job[]>("/api/jobs"),
|
||||||
api<ResultItem[]>("/api/results"),
|
api<ResultItem[]>("/api/results"),
|
||||||
|
api<TrainingCurve[]>("/api/results/curves"),
|
||||||
api<UploadedDataset[]>("/api/datasets"),
|
api<UploadedDataset[]>("/api/datasets"),
|
||||||
api<CoveragePayload>("/api/coverage"),
|
api<CoveragePayload>("/api/coverage"),
|
||||||
api<AcceptancePayload>("/api/acceptance/latest")
|
api<AcceptancePayload>("/api/acceptance/latest")
|
||||||
@@ -183,6 +209,7 @@ function useData() {
|
|||||||
setGpus(gpusNext);
|
setGpus(gpusNext);
|
||||||
setJobs(jobsNext);
|
setJobs(jobsNext);
|
||||||
setResults(resultsNext.slice(0, 80));
|
setResults(resultsNext.slice(0, 80));
|
||||||
|
setCurves(curvesNext.slice(0, 12));
|
||||||
setDatasets(datasetsNext);
|
setDatasets(datasetsNext);
|
||||||
setCoverage(coverageNext);
|
setCoverage(coverageNext);
|
||||||
setAcceptance(acceptanceNext);
|
setAcceptance(acceptanceNext);
|
||||||
@@ -198,7 +225,7 @@ function useData() {
|
|||||||
return () => window.clearInterval(timer);
|
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 }) {
|
function StatusPill({ status }: { status: string }) {
|
||||||
@@ -206,7 +233,7 @@ function StatusPill({ status }: { status: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
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 [taskType, setTaskType] = useState("mock.echo");
|
||||||
const [params, setParams] = useState(JSON.stringify(defaultParams["mock.echo"], null, 2));
|
const [params, setParams] = useState(JSON.stringify(defaultParams["mock.echo"], null, 2));
|
||||||
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
||||||
@@ -214,6 +241,8 @@ function App() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [datasetName, setDatasetName] = useState("demo_dataset");
|
const [datasetName, setDatasetName] = useState("demo_dataset");
|
||||||
const [datasetDescription, setDatasetDescription] = useState("");
|
const [datasetDescription, setDatasetDescription] = useState("");
|
||||||
|
const [selectedDatasetName, setSelectedDatasetName] = useState("");
|
||||||
|
const [selectedCurvePath, setSelectedCurvePath] = useState("");
|
||||||
const [uploadKind, setUploadKind] = useState<"images" | "labels" | "masks">("images");
|
const [uploadKind, setUploadKind] = useState<"images" | "labels" | "masks">("images");
|
||||||
const [uploadFiles, setUploadFiles] = useState<FileList | null>(null);
|
const [uploadFiles, setUploadFiles] = useState<FileList | null>(null);
|
||||||
|
|
||||||
@@ -234,6 +263,11 @@ function App() {
|
|||||||
}, [catalog]);
|
}, [catalog]);
|
||||||
|
|
||||||
const datasetOps = taskGroups.dataset.filter((task) => task in taskLabels);
|
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) {
|
function pickTask(next: string) {
|
||||||
setTaskType(next);
|
setTaskType(next);
|
||||||
@@ -241,10 +275,28 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pickDatasetTask(next: string) {
|
function pickDatasetTask(next: string) {
|
||||||
pickTask(next);
|
setTaskType(next);
|
||||||
|
setParams(JSON.stringify(datasetParamsForTask(next), null, 2));
|
||||||
window.location.hash = "jobs";
|
window.location.hash = "jobs";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function datasetParamsForTask(next: string): Record<string, unknown> {
|
||||||
|
const base = { ...(catalog?.task_defaults?.[next] ?? defaultParams[next] ?? {}) };
|
||||||
|
const layout = selectedDataset?.absolute_layout;
|
||||||
|
if (!layout) return base;
|
||||||
|
const resultDir = `${layout.images.replace(/\/images$/, "")}/results/${next.replace(".", "_")}`;
|
||||||
|
if (["dataset.pair", "dataset.resize", "dataset.stack", "dataset.stitch", "dataset.yolo_check_pairs", "dataset.yolo_stack"].includes(next)) {
|
||||||
|
return { ...base, image_dir: layout.images, label_dir: layout.labels, result_dir: resultDir };
|
||||||
|
}
|
||||||
|
if (["dataset.rebuild_labels", "dataset.yolo_rebuild_labels", "dataset.yolo_txt_sort"].includes(next)) {
|
||||||
|
return { ...base, label_dir: layout.labels, folder: layout.labels };
|
||||||
|
}
|
||||||
|
if (["dataset.to_png", "dataset.yolo_convert_png", "dataset.yolo_resize"].includes(next)) {
|
||||||
|
return { ...base, input_dir: layout.images, output_dir: resultDir, folder: layout.images };
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
async function createJob() {
|
async function createJob() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -288,6 +340,7 @@ function App() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ name: datasetName, description: datasetDescription })
|
body: JSON.stringify({ name: datasetName, description: datasetDescription })
|
||||||
});
|
});
|
||||||
|
setSelectedDatasetName(datasetName);
|
||||||
await refresh();
|
await refresh();
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -495,7 +548,22 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="datasetList">
|
<div className="datasetList">
|
||||||
{datasets.map((dataset) => (
|
{datasets.map((dataset) => (
|
||||||
<div className="datasetCard" key={dataset.name}>
|
<div
|
||||||
|
className={`datasetCard ${selectedDataset?.name === dataset.name ? "selected" : ""}`}
|
||||||
|
key={dataset.name}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
setDatasetName(dataset.name);
|
||||||
|
setSelectedDatasetName(dataset.name);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
setDatasetName(dataset.name);
|
||||||
|
setSelectedDatasetName(dataset.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="datasetCardHead">
|
<div className="datasetCardHead">
|
||||||
<strong>{dataset.name}</strong>
|
<strong>{dataset.name}</strong>
|
||||||
<span>{dataset.counts.images} image · {dataset.counts.labels} label · {dataset.counts.masks} mask</span>
|
<span>{dataset.counts.images} image · {dataset.counts.labels} label · {dataset.counts.masks} mask</span>
|
||||||
@@ -510,7 +578,7 @@ function App() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -641,7 +709,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<Wand2 size={22} />
|
<Wand2 size={22} />
|
||||||
</div>
|
</div>
|
||||||
<ResultPreview results={results.filter((item) => /predict|mask|comparison|prediction/i.test(item.relative_path) && ["png", "jpg", "jpeg"].includes(item.kind)).slice(0, 6)} />
|
<ResultPreview results={results.filter((item) => item.role === "segmentation" && ["png", "jpg", "jpeg", "svg"].includes(item.kind)).slice(0, 6)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="panel insight">
|
<div className="panel insight">
|
||||||
<div className="panelHead">
|
<div className="panelHead">
|
||||||
@@ -651,7 +719,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<Zap size={22} />
|
<Zap size={22} />
|
||||||
</div>
|
</div>
|
||||||
<ResultPreview results={results.filter((item) => /heat|cam|grad/i.test(item.relative_path) && ["png", "jpg", "jpeg"].includes(item.kind)).slice(0, 6)} />
|
<ResultPreview results={results.filter((item) => item.role === "heatmap" && ["png", "jpg", "jpeg", "svg"].includes(item.kind)).slice(0, 6)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="panel insight">
|
<div className="panel insight">
|
||||||
<div className="panelHead">
|
<div className="panelHead">
|
||||||
@@ -662,12 +730,7 @@ function App() {
|
|||||||
<BarChart3 size={22} />
|
<BarChart3 size={22} />
|
||||||
</div>
|
</div>
|
||||||
<div className="resultList tight">
|
<div className="resultList tight">
|
||||||
{results.filter((item) => /loss|metric|miou|iou|csv|curve/i.test(item.relative_path)).slice(0, 10).map((item) => (
|
<CurvePanel curves={curves} selected={selectedCurve} selectedPath={selectedCurvePath} onSelect={setSelectedCurvePath} />
|
||||||
<a key={item.path} href={`${API_BASE}/api/artifacts/${item.relative_path}`} target="_blank" rel="noreferrer">
|
|
||||||
<span>{item.name}</span>
|
|
||||||
<small>{formatBytes(item.size)}</small>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -711,7 +774,7 @@ function App() {
|
|||||||
|
|
||||||
function ResultPreview({ results }: { results: ResultItem[] }) {
|
function ResultPreview({ results }: { results: ResultItem[] }) {
|
||||||
if (!results.length) {
|
if (!results.length) {
|
||||||
return <p className="muted">暂无结果,运行预测、热度图或分析任务后会自动出现在这里。</p>;
|
return <p className="muted">暂无结果</p>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="previewGrid">
|
<div className="previewGrid">
|
||||||
@@ -725,6 +788,82 @@ function ResultPreview({ results }: { results: ResultItem[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CurvePanel({
|
||||||
|
curves,
|
||||||
|
selected,
|
||||||
|
selectedPath,
|
||||||
|
onSelect
|
||||||
|
}: {
|
||||||
|
curves: TrainingCurve[];
|
||||||
|
selected?: TrainingCurve;
|
||||||
|
selectedPath: string;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
if (!curves.length || !selected) {
|
||||||
|
return <p className="muted">暂无曲线数据</p>;
|
||||||
|
}
|
||||||
|
const visibleSeries = selected.series.slice(0, 5);
|
||||||
|
return (
|
||||||
|
<div className="curvePanel">
|
||||||
|
<select value={selectedPath || selected.relative_path} onChange={(event) => onSelect(event.target.value)} aria-label="curve">
|
||||||
|
{curves.map((curve) => (
|
||||||
|
<option key={curve.relative_path} value={curve.relative_path}>
|
||||||
|
{curve.family} · {curve.name} · {curve.row_count} epochs
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<MiniCurvePlot series={visibleSeries} />
|
||||||
|
<div className="curveLegend">
|
||||||
|
{visibleSeries.map((item, index) => (
|
||||||
|
<a key={item.name} href={`${API_BASE}/api/artifacts/${selected.relative_path}`} target="_blank" rel="noreferrer">
|
||||||
|
<i style={{ background: curveColor(index) }} />
|
||||||
|
<span>{item.name}</span>
|
||||||
|
<small>{item.last.toFixed(4)}</small>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniCurvePlot({ series }: { series: CurveSeries[] }) {
|
||||||
|
const points = series.flatMap((item) => item.points);
|
||||||
|
if (!points.length) return <div className="curveEmpty" />;
|
||||||
|
const minX = Math.min(...points.map((point) => point.x));
|
||||||
|
const maxX = Math.max(...points.map((point) => point.x));
|
||||||
|
const minY = Math.min(...points.map((point) => point.y));
|
||||||
|
const maxY = Math.max(...points.map((point) => point.y));
|
||||||
|
const width = 520;
|
||||||
|
const height = 190;
|
||||||
|
const pad = 16;
|
||||||
|
const scaleX = (x: number) => pad + ((x - minX) / Math.max(maxX - minX, 1)) * (width - pad * 2);
|
||||||
|
const scaleY = (y: number) => height - pad - ((y - minY) / Math.max(maxY - minY, 1)) * (height - pad * 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg className="curveSvg" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="training curve">
|
||||||
|
<path d={`M ${pad} ${height - pad} H ${width - pad} M ${pad} ${pad} V ${height - pad}`} className="axis" />
|
||||||
|
{[0.25, 0.5, 0.75].map((tick) => (
|
||||||
|
<path key={tick} d={`M ${pad} ${pad + tick * (height - pad * 2)} H ${width - pad}`} className="gridLine" />
|
||||||
|
))}
|
||||||
|
{series.map((item, index) => (
|
||||||
|
<polyline
|
||||||
|
key={item.name}
|
||||||
|
points={item.points.map((point) => `${scaleX(point.x).toFixed(2)},${scaleY(point.y).toFixed(2)}`).join(" ")}
|
||||||
|
fill="none"
|
||||||
|
stroke={curveColor(index)}
|
||||||
|
strokeWidth="2.4"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function curveColor(index: number) {
|
||||||
|
return ["#9de26f", "#73d2de", "#d3b35b", "#7aa2ff", "#f07167"][index % 5];
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ button, textarea, select {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input, select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
@@ -477,10 +477,20 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.datasetCard {
|
.datasetCard {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
text-align: left;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: #101310;
|
background: #101310;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasetCard.selected {
|
||||||
|
border-color: var(--green);
|
||||||
|
background: rgba(157, 226, 111, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.datasetCardHead {
|
.datasetCardHead {
|
||||||
@@ -619,6 +629,136 @@ meter {
|
|||||||
white-space: pre-wrap;
|
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 {
|
.resultList a:hover, .jobRow:hover {
|
||||||
border-color: var(--green);
|
border-color: var(--green);
|
||||||
}
|
}
|
||||||
@@ -665,7 +805,7 @@ meter {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (min-width: 981px) and (max-width: 1180px) {
|
||||||
body { min-width: 960px; }
|
body { min-width: 960px; }
|
||||||
.shell { grid-template-columns: 220px 1fr; }
|
.shell { grid-template-columns: 220px 1fr; }
|
||||||
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
|||||||
Reference in New Issue
Block a user