From dd7b7384ecc8636a255a29e5a9cd5292bc012ea8 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Tue, 30 Jun 2026 12:38:25 +0800 Subject: [PATCH] Add dataset bench and validation agents --- .env.example | 4 +- README.md | 25 ++- backend/app/agents/__init__.py | 2 + backend/app/agents/evaluation_agent.py | 61 +++++++ backend/app/agents/validation_agent.py | 98 ++++++++++ backend/app/catalog.py | 6 +- backend/app/config.py | 2 +- backend/app/main.py | 34 +++- backend/app/modules/dataset/service.py | 132 ++++++++++++++ backend/app/schemas.py | 4 + backend/tests/test_agents.py | 14 ++ backend/tests/test_dataset_service.py | 13 ++ frontend/src/main.tsx | 236 +++++++++++++++++++++++-- frontend/src/styles.css | 206 ++++++++++++++++++++- scripts/run_agents.py | 35 ++++ scripts/run_backend.sh | 5 +- 16 files changed, 853 insertions(+), 24 deletions(-) create mode 100644 backend/app/agents/__init__.py create mode 100644 backend/app/agents/evaluation_agent.py create mode 100644 backend/app/agents/validation_agent.py create mode 100644 backend/app/modules/dataset/service.py create mode 100644 backend/tests/test_agents.py create mode 100644 backend/tests/test_dataset_service.py create mode 100644 scripts/run_agents.py diff --git a/.env.example b/.env.example index 25bc061..22206cd 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ SEG_DATA_SERVER_ROOT=. SEG_BACKEND_DB=var/seg_data_server.sqlite3 SEG_BACKEND_LOG_DIR=var/job_logs SEG_TASK_CONDA_ENV=seg_smp -SEG_BACKEND_CONDA_ENV=seg_server +SEG_BACKEND_CONDA_ENV=seg_smp SEG_WEIGHT_MODE=copy SEG_ENABLE_SHELL_TASKS=1 -VITE_API_BASE=http://localhost:8000 +VITE_API_BASE=http://localhost:8010 diff --git a/README.md b/README.md index 5cd8ea5..3aebbce 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,9 @@ Seg_Data_Server_Net/ cd Seg_Data_Server_Net cp .env.example .env -# Backend. The existing machine already has a seg_server env with FastAPI. -conda run -n seg_server uvicorn app.main:app --app-dir backend --host 0.0.0.0 --port 8000 +# Backend. The deployment env is seg_smp so the API and task wrappers share +# the same segmentation dependency stack. +conda run -n seg_smp uvicorn app.main:app --app-dir backend --host 0.0.0.0 --port 8010 # Frontend. cd frontend @@ -37,7 +38,13 @@ npm run dev -- --host 0.0.0.0 ``` Open the Vite URL shown in the terminal. The frontend expects the backend at -`http://localhost:8000` by default. +`http://localhost:8010` by default. + +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. ## Weight Sync @@ -77,3 +84,15 @@ The backend exposes all current Seg capabilities as job types. Examples: Use `GET /api/catalog` to inspect supported models, algorithms, datasets, and task types discovered from the existing `Seg/` workspace. + +## Agents + +Run the local evaluation and validation agents before publishing changes: + +```bash +PYTHONPATH=backend conda run -n seg_smp python scripts/run_agents.py --build +``` + +The validation agent checks catalog coverage, the new `seg_smp` env, GPU +visibility, no-weight Git safety, backend tests, frontend build, and live +backend/frontend endpoints when the services are running. diff --git a/backend/app/agents/__init__.py b/backend/app/agents/__init__.py new file mode 100644 index 0000000..90a8d9d --- /dev/null +++ b/backend/app/agents/__init__.py @@ -0,0 +1,2 @@ +"""Local deterministic agents for app evaluation and validation.""" + diff --git a/backend/app/agents/evaluation_agent.py b/backend/app/agents/evaluation_agent.py new file mode 100644 index 0000000..03b6a1e --- /dev/null +++ b/backend/app/agents/evaluation_agent.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from pathlib import Path + +from ..catalog import get_catalog +from ..config import settings + + +REQUIRED_TASKS = { + "dataset.upload": "covered_by_api", + "dataset.video_frames": "job", + "segmodel.train": "job", + "segmodel.predict": "job", + "yolo.heatmap": "job", + "mmseg.flops_fps": "job", + "analysis.all": "job", +} + + +def evaluate_project() -> dict: + """Return product/implementation suggestions for the current web platform.""" + frontend = settings.project_root / "frontend" / "src" / "main.tsx" + backend = settings.project_root / "backend" / "app" / "main.py" + readme = settings.project_root / "README.md" + catalog = get_catalog() + checks = [] + suggestions = [] + + frontend_text = frontend.read_text(encoding="utf-8") if frontend.exists() else "" + backend_text = backend.read_text(encoding="utf-8") if backend.exists() else "" + readme_text = readme.read_text(encoding="utf-8") if readme.exists() else "" + + 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(), + "dataset_api": "/api/datasets" in backend_text and "api_upload_dataset_files" in backend_text, + "no_weight_to_gitea": "Do not push" in readme_text and "check_no_weight_git" in readme_text, + "all_core_tasks": all(task in catalog["task_types"] for task in REQUIRED_TASKS if REQUIRED_TASKS[task] == "job"), + } + + for name, passed in expectations.items(): + checks.append({"name": name, "passed": bool(passed)}) + if not passed: + suggestions.append(f"Improve missing capability: {name}") + + if len(catalog["mmseg_algorithms"]) < 31: + suggestions.append("MMSeg algorithm catalog should expose all 31 algorithm generators.") + if len(catalog["segmodel_architectures"]) < 12: + suggestions.append("SegModel catalog should expose all 12 supported architectures.") + if not suggestions: + suggestions.append("Current platform covers the requested control-plane features; next focus is real dataset/training acceptance tests.") + + score = sum(1 for item in checks if item["passed"]) / max(len(checks), 1) + return { + "agent": "evaluation_suggestion_agent", + "score": round(score, 3), + "checks": checks, + "suggestions": suggestions, + } + diff --git a/backend/app/agents/validation_agent.py b/backend/app/agents/validation_agent.py new file mode 100644 index 0000000..9b1b5e9 --- /dev/null +++ b/backend/app/agents/validation_agent.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import urllib.error +import urllib.request +from pathlib import Path + +from ..catalog import get_catalog +from ..config import settings +from ..modules.system.service import get_conda_envs, get_gpus +from ..modules.weights.service import load_manifest + + +def _run(command: list[str], cwd: Path | None = None, timeout: int = 60) -> dict: + result = subprocess.run(command, cwd=cwd, capture_output=True, text=True, timeout=timeout) + return { + "command": command, + "returncode": result.returncode, + "stdout": result.stdout[-4000:], + "stderr": result.stderr[-4000:], + "passed": result.returncode == 0, + } + + +def _fetch(url: str, timeout: int = 5) -> dict: + try: + with urllib.request.urlopen(url, timeout=timeout) as response: + body = response.read(20000).decode("utf-8", errors="replace") + return {"url": url, "status": response.status, "body": body, "passed": 200 <= response.status < 300} + except urllib.error.HTTPError as exc: + body = exc.read(20000).decode("utf-8", errors="replace") + return {"url": url, "status": exc.code, "body": body, "passed": False} + except Exception as exc: + return {"url": url, "error": str(exc), "passed": False} + + +def validate_project(run_build: bool = False) -> dict: + """Validate current runtime readiness without launching heavy training.""" + checks = [] + catalog = get_catalog() + manifest = load_manifest() + + checks.append({"name": "catalog_has_yolo_heatmap", "passed": "yolo.heatmap" in catalog["task_types"]}) + checks.append({"name": "catalog_has_mmseg_31_algs", "passed": len(catalog["mmseg_algorithms"]) >= 31}) + checks.append({"name": "weights_manifest_present", "passed": manifest.get("count", 0) >= 1}) + 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": "seg_smp_env_exists", "passed": "seg_smp" in env_names}) + + smoke = _run( + [ + "conda", + "run", + "-n", + "seg_smp", + "python", + "-c", + "import fastapi, uvicorn, torch, cv2, segmentation_models_pytorch, ultralytics, albumentations, mmengine, mmseg, mmcv; print(torch.__version__, torch.cuda.is_available())", + ], + cwd=settings.project_root, + ) + checks.append({"name": "seg_smp_backend_smoke", "passed": smoke["passed"], "detail": smoke}) + + no_weight = _run(["bash", "scripts/check_no_weight_git.sh"], cwd=settings.project_root) + checks.append({"name": "no_weight_in_git", "passed": no_weight["passed"], "detail": no_weight}) + + if os.getenv("SEG_VALIDATE_LIVE", "1") == "1": + backend_url = os.getenv("SEG_VALIDATE_BACKEND_URL", "http://127.0.0.1:8010") + frontend_url = os.getenv("SEG_VALIDATE_FRONTEND_URL", "http://127.0.0.1:5173") + health = _fetch(f"{backend_url}/api/health") + datasets = _fetch(f"{backend_url}/api/datasets") + 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_frontend_index", "passed": frontend["passed"] and "Seg Data Server" in frontend.get("body", ""), "detail": frontend}) + + if run_build: + tests = _run(["conda", "run", "-n", settings.backend_conda_env, "python", "-m", "pytest", "-q"], cwd=settings.project_root, timeout=120) + checks.append({"name": "backend_tests", "passed": tests["passed"], "detail": tests}) + build = _run(["npm", "run", "build"], cwd=settings.project_root / "frontend", timeout=120) + checks.append({"name": "frontend_build", "passed": build["passed"], "detail": build}) + + passed = all(item["passed"] for item in checks) + return { + "agent": "validation_agent", + "passed": passed, + "checks": checks, + } + + +def write_validation_report(path: Path, run_build: bool = False) -> dict: + result = validate_project(run_build=run_build) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + return result diff --git a/backend/app/catalog.py b/backend/app/catalog.py index a6ee4e6..803f156 100644 --- a/backend/app/catalog.py +++ b/backend/app/catalog.py @@ -103,6 +103,11 @@ def discover_datasets() -> list[dict[str, Any]]: if item.name == "All_Data_Record.json" or not data: continue candidates.append({"name": item.stem, "path": rel(item, root), "source": "mmseg_parameter"}) + uploaded_root = settings.project_root / "var" / "uploads" / "datasets" + if uploaded_root.exists(): + for item in sorted(uploaded_root.iterdir()): + if item.is_dir(): + candidates.append({"name": item.name, "path": rel(item, settings.project_root), "source": "uploaded"}) return candidates @@ -137,4 +142,3 @@ def get_catalog() -> dict[str, Any]: "datasets": discover_datasets(), "weights": discover_weights_summary(), } - diff --git a/backend/app/config.py b/backend/app/config.py index c12ece5..bec87f7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -46,7 +46,7 @@ def get_settings() -> Settings: log_dir=log_dir, weights_root=weights_root, task_conda_env=os.getenv("SEG_TASK_CONDA_ENV", "seg_smp"), - backend_conda_env=os.getenv("SEG_BACKEND_CONDA_ENV", "seg_server"), + backend_conda_env=os.getenv("SEG_BACKEND_CONDA_ENV", "seg_smp"), weight_mode=os.getenv("SEG_WEIGHT_MODE", "copy"), enable_shell_tasks=os.getenv("SEG_ENABLE_SHELL_TASKS", "1") == "1", ) diff --git a/backend/app/main.py b/backend/app/main.py index 4f103ee..075b1c6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ import asyncio import json from pathlib import Path -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse @@ -13,9 +13,12 @@ from .catalog import get_catalog from .config import settings from .jobs import cancel_job, create_job from .modules.system.service import disk_usage, get_conda_envs, get_gpus, scan_results +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 +from .agents.validation_agent import validate_project from .paths import ensure_inside -from .schemas import JobCreate, ProfileCreate, WeightSyncRequest +from .schemas import DatasetCreate, JobCreate, ProfileCreate, WeightSyncRequest app = FastAPI(title="Seg Data Server Net", version="0.1.0") @@ -60,6 +63,24 @@ def api_catalog() -> dict: return get_catalog() +@app.get("/api/datasets") +def api_datasets() -> list[dict]: + return list_uploaded_datasets() + + +@app.post("/api/datasets") +def api_create_dataset(dataset: DatasetCreate) -> dict: + return create_dataset(dataset.name, dataset.description) + + +@app.post("/api/datasets/{dataset_name}/upload/{kind}") +async def api_upload_dataset_files(dataset_name: str, kind: str, files: list[UploadFile] = File(...)) -> dict: + try: + return await save_upload(dataset_name, kind, files) + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/profiles") def api_profiles(kind: str | None = None) -> list[dict]: return db.list_profiles(kind) @@ -170,3 +191,12 @@ def api_weight_sync(request: WeightSyncRequest) -> dict: def api_weight_verify() -> dict: return verify_weights() + +@app.get("/api/agents/evaluate") +def api_agent_evaluate() -> dict: + return evaluate_project() + + +@app.get("/api/agents/validate") +def api_agent_validate(run_build: bool = False) -> dict: + return validate_project(run_build=run_build) diff --git a/backend/app/modules/dataset/service.py b/backend/app/modules/dataset/service.py new file mode 100644 index 0000000..754461f --- /dev/null +++ b/backend/app/modules/dataset/service.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import json +import re +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable + +from fastapi import UploadFile + +from ...config import settings + +DATASET_KINDS = ("images", "labels", "masks") +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"} + + +def uploads_root() -> Path: + root = settings.project_root / "var" / "uploads" / "datasets" + root.mkdir(parents=True, exist_ok=True) + return root + + +def slugify(value: str) -> str: + text = re.sub(r"[^A-Za-z0-9_.\-\u4e00-\u9fff]+", "_", value.strip()) + return text.strip("._") or "dataset" + + +def safe_filename(value: str | None) -> str: + original = Path(value or "upload.bin").name + suffix = Path(original).suffix.lower() + stem = slugify(Path(original).stem or "upload") + if suffix and re.fullmatch(r"\.[A-Za-z0-9]{1,12}", suffix): + return f"{stem}{suffix}" + return stem + + +def dataset_dir(name: str) -> Path: + return uploads_root() / slugify(name) + + +def metadata_path(name: str) -> Path: + return dataset_dir(name) / "dataset.json" + + +def create_dataset(name: str, description: str = "") -> dict: + safe_name = slugify(name) + root = dataset_dir(safe_name) + for kind in DATASET_KINDS: + (root / kind).mkdir(parents=True, exist_ok=True) + meta = { + "name": safe_name, + "description": description, + "created_at": datetime.now(timezone.utc).isoformat(), + "root": str(root.relative_to(settings.project_root)), + "layout": { + "images": str((root / "images").relative_to(settings.project_root)), + "labels": str((root / "labels").relative_to(settings.project_root)), + "masks": str((root / "masks").relative_to(settings.project_root)), + }, + } + metadata_path(safe_name).write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + return describe_dataset(safe_name) + + +def _load_meta(name: str) -> dict: + path = metadata_path(name) + if path.exists(): + return json.loads(path.read_text(encoding="utf-8")) + return {"name": slugify(name), "description": "", "root": str(dataset_dir(name).relative_to(settings.project_root))} + + +def _iter_files(root: Path) -> Iterable[Path]: + if not root.exists(): + return [] + return sorted(path for path in root.rglob("*") if path.is_file()) + + +def describe_dataset(name: str) -> dict: + safe_name = slugify(name) + root = dataset_dir(safe_name) + meta = _load_meta(safe_name) + counts = {} + samples = {} + for kind in sorted(DATASET_KINDS): + files = list(_iter_files(root / kind)) + counts[kind] = len(files) + samples[kind] = [ + { + "name": path.name, + "path": str(path.resolve()), + "relative_path": str(path.resolve().relative_to(settings.project_root)), + "size": path.stat().st_size, + "previewable": path.suffix.lower() in IMAGE_EXTS, + } + for path in files[:80] + ] + return {**meta, "counts": counts, "samples": samples} + + +def list_uploaded_datasets() -> list[dict]: + root = uploads_root() + datasets = [] + for item in sorted(root.iterdir()): + if item.is_dir(): + datasets.append(describe_dataset(item.name)) + return datasets + + +async def save_upload(dataset: str, kind: str, files: list[UploadFile]) -> dict: + if kind not in DATASET_KINDS: + raise ValueError(f"unsupported dataset file kind: {kind}") + safe_name = slugify(dataset) + if not metadata_path(safe_name).exists(): + create_dataset(safe_name) + target = dataset_dir(safe_name) / kind + target.mkdir(parents=True, exist_ok=True) + saved = [] + for upload in files: + filename = safe_filename(upload.filename) + dst = target / filename + if dst.exists(): + stem = dst.stem + suffix = dst.suffix + counter = 1 + while dst.exists(): + dst = target / f"{stem}_{counter}{suffix}" + counter += 1 + with dst.open("wb") as handle: + shutil.copyfileobj(upload.file, handle) + saved.append({"name": dst.name, "relative_path": str(dst.relative_to(settings.project_root)), "size": dst.stat().st_size}) + return {"dataset": describe_dataset(safe_name), "saved": saved} diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b259a6e..b1cbfdc 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -43,3 +43,7 @@ class WeightSyncRequest(BaseModel): hash_files: bool = True skip_existing: bool = True + +class DatasetCreate(BaseModel): + name: str + description: str = "" diff --git a/backend/tests/test_agents.py b/backend/tests/test_agents.py new file mode 100644 index 0000000..d8982d0 --- /dev/null +++ b/backend/tests/test_agents.py @@ -0,0 +1,14 @@ +from app.agents.evaluation_agent import evaluate_project +from app.agents.validation_agent import validate_project + + +def test_evaluation_agent_returns_checks(): + result = evaluate_project() + assert result["agent"] == "evaluation_suggestion_agent" + assert result["checks"] + + +def test_validation_agent_lightweight(): + result = validate_project(run_build=False) + assert result["agent"] == "validation_agent" + assert any(item["name"] == "catalog_has_yolo_heatmap" for item in result["checks"]) diff --git a/backend/tests/test_dataset_service.py b/backend/tests/test_dataset_service.py new file mode 100644 index 0000000..88f7435 --- /dev/null +++ b/backend/tests/test_dataset_service.py @@ -0,0 +1,13 @@ +from app.modules.dataset.service import create_dataset, describe_dataset + + +def test_create_dataset_layout(tmp_path, monkeypatch): + from types import SimpleNamespace + from app.modules.dataset import service + + monkeypatch.setattr(service, "settings", SimpleNamespace(project_root=tmp_path)) + created = create_dataset("case 01", "demo") + assert created["name"] == "case_01" + assert created["counts"] == {"images": 0, "labels": 0, "masks": 0} + described = describe_dataset("case_01") + assert described["layout"]["images"].endswith("images") diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 7218dc2..457842b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,8 +3,10 @@ import { createRoot } from "react-dom/client"; import { Activity, BarChart3, + Boxes, Cpu, Database, + FileImage, FileSearch, Gauge, HardDrive, @@ -15,11 +17,12 @@ import { Square, Terminal, UploadCloud, + Wand2, Zap } from "lucide-react"; import "./styles.css"; -const API_BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:8000"; +const API_BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:8010"; type Job = { id: string; @@ -42,6 +45,22 @@ type Catalog = { weights: { count: number; total_bytes: number; updated_at?: string }; }; +type UploadedDataset = { + name: string; + description?: string; + counts: { images: number; labels: number; masks: number }; + samples: Record>; +}; + +type ResultItem = { + name: string; + path: string; + relative_path: string; + size: number; + modified: number; + kind: string; +}; + type GpuPayload = { available: boolean; gpus: Array<{ @@ -66,6 +85,13 @@ async function api(path: string, init?: RequestInit): Promise { const defaultParams: Record> = { "mock.echo": { message: "hello from Seg Data Server" }, + "dataset.rename": { input_dir: "../DataSet_Own", prefix: "image" }, + "dataset.to_png": { input_dir: "../DataSet_Own", output_dir: "../DataSet_Own_png" }, + "dataset.resize": { input_dir: "../DataSet_Own", output_dir: "../DataSet_Own_resize", size: "512x512" }, + "dataset.pair": { image_dir: "../DataSet_Own/images", label_dir: "../DataSet_Own/labels" }, + "dataset.rebuild_labels": { label_dir: "../DataSet_Own/labels", output_dir: "../DataSet_Own/rebuilt_labels" }, + "dataset.stack": { image_dir: "../DataSet_Own/images", mask_dir: "../DataSet_Own/masks", output_dir: "../DataSet_Own/stacked" }, + "dataset.stitch": { input_dir: "../DataSet_Own/stacked", output_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 }, @@ -79,6 +105,17 @@ const defaultParams: Record> = { "analysis.all": { input_dir: "../BestMode_Predict_Results_DataSet_Public", output_dir: "./", dataset_choice: 1 } }; +const taskLabels: Record = { + "dataset.rename": "重命名", + "dataset.to_png": "转 PNG", + "dataset.resize": "Resize", + "dataset.pair": "图片/Label 配对", + "dataset.rebuild_labels": "重建 Label", + "dataset.stack": "透明叠加", + "dataset.stitch": "拼接检查", + "dataset.video_frames": "视频抽帧" +}; + function formatBytes(value?: number) { if (!value) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; @@ -95,21 +132,24 @@ function useData() { const [catalog, setCatalog] = useState(null); const [gpus, setGpus] = useState(null); const [jobs, setJobs] = useState([]); - const [results, setResults] = useState>>([]); + const [results, setResults] = useState([]); + const [datasets, setDatasets] = useState([]); const [error, setError] = useState(""); async function refresh() { try { - const [catalogNext, gpusNext, jobsNext, resultsNext] = await Promise.all([ + const [catalogNext, gpusNext, jobsNext, resultsNext, datasetsNext] = await Promise.all([ api("/api/catalog"), api("/api/system/gpus"), api("/api/jobs"), - api>>("/api/results") + api("/api/results"), + api("/api/datasets") ]); setCatalog(catalogNext); setGpus(gpusNext); setJobs(jobsNext); setResults(resultsNext.slice(0, 80)); + setDatasets(datasetsNext); setError(""); } catch (err) { setError(String(err)); @@ -122,7 +162,7 @@ function useData() { return () => window.clearInterval(timer); }, []); - return { catalog, gpus, jobs, results, error, refresh }; + return { catalog, gpus, jobs, results, datasets, error, refresh }; } function StatusPill({ status }: { status: string }) { @@ -130,12 +170,16 @@ function StatusPill({ status }: { status: string }) { } function App() { - const { catalog, gpus, jobs, results, error, refresh } = useData(); + const { catalog, gpus, jobs, results, datasets, 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); const [log, setLog] = useState(""); const [busy, setBusy] = useState(false); + const [datasetName, setDatasetName] = useState("demo_dataset"); + const [datasetDescription, setDatasetDescription] = useState(""); + const [uploadKind, setUploadKind] = useState<"images" | "labels" | "masks">("images"); + const [uploadFiles, setUploadFiles] = useState(null); const runningCount = jobs.filter((job) => job.status === "running").length; const successCount = jobs.filter((job) => job.status === "success").length; @@ -152,11 +196,18 @@ function App() { }; }, [catalog]); + const datasetOps = taskGroups.dataset.filter((task) => task in taskLabels); + function pickTask(next: string) { setTaskType(next); setParams(JSON.stringify(defaultParams[next] ?? {}, null, 2)); } + function pickDatasetTask(next: string) { + pickTask(next); + window.location.hash = "jobs"; + } + async function createJob() { setBusy(true); try { @@ -183,6 +234,36 @@ function App() { } } + async function createDataset() { + setBusy(true); + try { + await api("/api/datasets", { + method: "POST", + body: JSON.stringify({ name: datasetName, description: datasetDescription }) + }); + 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); + } + } + async function inspectJob(job: Job) { const detail = await api(`/api/jobs/${job.id}`); setSelectedJob(detail); @@ -214,6 +295,7 @@ function App() {