diff --git a/.env.example b/.env.example index a675db8..fb4824a 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,7 @@ TRUSTED_PROXY_IPS=127.0.0.1 PORT=18000 BACKEND_BIND_HOST=127.0.0.1 UVICORN_LOG_LEVEL=info +DASHBOARD_STATS_CACHE_TTL_SECONDS=120 PYTHON_PATH=C:\ProgramData\anaconda3\envs\InSAR\python.exe CONDA_EXE= @@ -65,6 +66,10 @@ SBAS_TASK_POOL_ROOT=D:\Task_Pool\SBAS DATA_DISTRIBUTION_ROOT=D:\Task_Pool\Data_Distribution GF3_TASK_POOL_ROOT=D:\GaoFen3_Pool\task_pool SOURCE_PRODUCT_DIRS=D:\LuTan1_Image_Pool_Zip;D:\Sentinel1_Image_Pool_ZIP +ASSET_SCAN_PARSE_WORKERS=4 +ASSET_SCAN_PARSE_INFLIGHT=64 +ASSET_SCAN_SKIP_UNCHANGED_FAILURES=true +ASSET_SCAN_DB_BATCH_SIZE=50 SENTINEL1_STORAGE_DIRS= INSAR_STORAGE_DIRS= MONITOR_RADAR_DIRS= @@ -333,6 +338,12 @@ JOB_WORKER_JOB_HEARTBEAT_INTERVAL=5 JOB_WORKER_STALE_RECOVER_INTERVAL=15 JOB_WORKER_STALE_RUNNING_SECONDS=7200 JOB_WORKER_HEARTBEAT_INTERVAL=5 +# Main server only. Comma/semicolon-separated IPv4 addresses or CIDR blocks allowed +# to run LandSAR cluster workers against this PostgreSQL server. +LANDSAR_CLUSTER_ALLOWED_WORKER_IPS=192.168.1.6 +# Empty means the worker can claim all job types. Remote LandSAR nodes should set: +# JOB_WORKER_ALLOWED_TYPES=LANDSAR_CLUSTER_ITEM +JOB_WORKER_ALLOWED_TYPES= # ----------------------------------------------------------------------------- @@ -394,6 +405,11 @@ DEFAULT_VLM_MODEL=qwen3-vl:30b # Empty means same-origin /tiles, routed by the main nginx proxy. VITE_TILE_SERVER_URL= VITE_TILE_SERVER_TOKEN=change_me +VITE_APP_ORG_NAME=黑龙江省自然资源卫星应用技术中心 +VITE_APP_SYSTEM_NAME=InSAR 自动化管理系统 +VITE_APP_SYSTEM_TAGLINE=科研工程生产平台 +# Optional override for deployed builds. Empty uses frontend/src/logo.jpg. +VITE_APP_LOGO_URL= TILE_SERVER_AUTO_START=true TILE_SERVER_AUTO_STOP=true TILE_SERVER_ROOT=D:\Code\tile-server diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..7a03b34 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,33 @@ +# Product + +## Register + +product + +## Users + +黑龙江省自然资源卫星应用技术中心及同类科研工程单位的业务人员、科研人员、生产管理员和运维人员。用户在内网或受控生产环境中长期、高频使用系统,围绕 SAR/InSAR 数据接入、生产编排、结果管理、地图分析和运维自检完成可追溯的工程流程。 + +## Product Purpose + +本系统用于管理 InSAR 自动化生产与分析流程,统一承载数据入库、D-InSAR/SBAS-InSAR 规划、生产运行、结果 catalog、地图分析和系统维护。成功标准是稳定、清晰、可审计:用户能准确判断当前数据、任务、结果和运行环境状态,并能以最少干扰完成生产操作。 + +## Brand Personality + +科研、专业、克制;同时体现严谨、稳定、工程化。界面应像生产控制台,而不是营销展示页或通用 SaaS 后台。 + +## Anti-references + +不做大面积装饰渐变、营销式 hero、过度圆角卡片、花哨动效、以英文为主的国际化界面、只靠红绿状态点表达系统健康的页眉。运行环境检查归入运行维护,不在全局页眉堆叠 DB、Worker、IDL、Ollama、Nginx 等状态灯。 + +## Design Principles + +- 任务优先:界面结构服务于数据、任务、结果、运维等真实工作流。 +- 中文清晰:操作、状态、错误和提示默认使用中文;InSAR、D-InSAR、SBAS、Gamma、LandSAR、IDL 等专业术语保留英文。 +- 克制可信:用稳定的层级、对齐、密度和状态说明建立信任,不依赖装饰。 +- 工程可审计:关键生产动作、只读权限、授权状态、任务状态和结果状态必须明确可见。 +- 可分发部署:单位名、系统名、logo 等组织身份信息应可通过环境变量配置。 + +## Accessibility & Inclusion + +默认遵循生产系统可读性要求:正文对比度充足,键盘焦点可见,动效克制并尊重 reduced motion,状态表达不只依赖颜色,中文文案短句明确。 diff --git a/backend/app/config.py b/backend/app/config.py index 6ea2e9a..7237aae 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -235,9 +235,13 @@ class Settings(BaseSettings): RADAR_THUMBNAIL_MAX_SIZE: int = 1600 RADAR_CACHE_WORKERS: int = 2 RADAR_GEO_CACHE_WORKERS: int = 2 - RADAR_GEO_CACHE_VERSION: str = "b1" + RADAR_GEO_CACHE_VERSION: str = "b2" RADAR_GEO_CACHE_QUALITY: int = 84 RADAR_PREVIEW_BUILD_ON_DEMAND: bool = True + ASSET_SCAN_PARSE_WORKERS: int = 4 + ASSET_SCAN_PARSE_INFLIGHT: int = 64 + ASSET_SCAN_SKIP_UNCHANGED_FAILURES: bool = True + ASSET_SCAN_DB_BATCH_SIZE: int = 50 WATER_RESULTS_DIR: str = "" GF3_WATER_DEM_PATH: str = "" @@ -417,6 +421,7 @@ class Settings(BaseSettings): JOB_WORKER_STALE_RECOVER_INTERVAL: float = 15.0 JOB_WORKER_STALE_RUNNING_SECONDS: int = 300 JOB_WORKER_HEARTBEAT_INTERVAL: float = 5.0 + JOB_WORKER_ALLOWED_TYPES: str = "" TIMESERIES_ENABLED: bool = False TIMESERIES_WSL_DISTRO: str = "" @@ -495,6 +500,13 @@ class Settings(BaseSettings): ) if not self.SRTM_DEM_DIR: object.__setattr__(self, "SRTM_DEM_DIR", os.path.join(backend_dir, "dem_data")) + object.__setattr__(self, "ASSET_SCAN_PARSE_WORKERS", max(1, int(self.ASSET_SCAN_PARSE_WORKERS or 1))) + object.__setattr__( + self, + "ASSET_SCAN_PARSE_INFLIGHT", + max(self.ASSET_SCAN_PARSE_WORKERS, int(self.ASSET_SCAN_PARSE_INFLIGHT or self.ASSET_SCAN_PARSE_WORKERS)), + ) + object.__setattr__(self, "ASSET_SCAN_DB_BATCH_SIZE", max(1, int(self.ASSET_SCAN_DB_BATCH_SIZE or 1))) if not self.GF3_ARCHIVE_SOURCE_DIRS: object.__setattr__( self, diff --git a/backend/app/license_service.py b/backend/app/license_service.py index ddb5d9e..cec2421 100644 --- a/backend/app/license_service.py +++ b/backend/app/license_service.py @@ -17,6 +17,7 @@ import hashlib import json import os import subprocess +import time import uuid from datetime import datetime, timezone from typing import Any, Dict, Optional @@ -32,6 +33,13 @@ _PUBLIC_KEY_B64 = "QOpR1c3bONDwOzrj3IVTogE1ZHIphpwxJY8nhWa09yw=" _APP_DIR = os.path.dirname(os.path.abspath(__file__)) _BACKEND_DIR = os.path.dirname(_APP_DIR) LICENSE_PATH_DEFAULT = os.path.join(_BACKEND_DIR, "license", "license.lic") +LICENSE_STATUS_CACHE_SECONDS = int(os.getenv("LICENSE_STATUS_CACHE_SECONDS", "30")) +_LICENSE_STATUS_CACHE: Dict[str, Any] = { + "path": None, + "mtime": None, + "checked_at": 0.0, + "payload": None, +} def _now_utc() -> datetime: @@ -86,47 +94,71 @@ def _get_machine_fingerprint() -> str: # ── 授权验证 ────────────────────────────────────────────────────────────────── +def _license_result(result: Dict[str, Any], *, use_cache: bool, path: str, mtime: Optional[float]) -> Dict[str, Any]: + if use_cache: + _LICENSE_STATUS_CACHE.update({ + "path": path, + "mtime": mtime, + "checked_at": time.monotonic(), + "payload": dict(result), + }) + return result + + def check_license(license_path: Optional[str] = None) -> Dict[str, Any]: + use_cache = license_path is None license_path = license_path or settings.LICENSE_PATH or LICENSE_PATH_DEFAULT + mtime = os.path.getmtime(license_path) if os.path.exists(license_path) else None + + if use_cache: + cached = _LICENSE_STATUS_CACHE.get("payload") + cache_age = time.monotonic() - float(_LICENSE_STATUS_CACHE.get("checked_at") or 0.0) + if ( + cached is not None + and _LICENSE_STATUS_CACHE.get("path") == license_path + and _LICENSE_STATUS_CACHE.get("mtime") == mtime + and cache_age <= LICENSE_STATUS_CACHE_SECONDS + ): + return dict(cached) if not os.path.exists(license_path): - return {"ok": False, "reason": "未找到授权文件"} + return _license_result({"ok": False, "reason": "未找到授权文件"}, use_cache=use_cache, path=license_path, mtime=mtime) try: blob = open(license_path, "rb").read().strip() parts = blob.split(b"|", 2) if len(parts) != 3 or parts[0] != b"LIC2": - return {"ok": False, "reason": "授权文件格式无效"} + return _license_result({"ok": False, "reason": "授权文件格式无效"}, use_cache=use_cache, path=license_path, mtime=mtime) _, sig_b64, payload_b64 = parts pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(_PUBLIC_KEY_B64)) pub.verify(base64.b64decode(sig_b64), payload_b64) payload = json.loads(base64.b64decode(payload_b64)) except InvalidSignature: - return {"ok": False, "reason": "授权文件签名无效(可能已被篡改)"} + return _license_result({"ok": False, "reason": "授权文件签名无效(可能已被篡改)"}, use_cache=use_cache, path=license_path, mtime=mtime) except Exception as e: - return {"ok": False, "reason": f"授权文件解析失败: {e}"} + return _license_result({"ok": False, "reason": f"授权文件解析失败: {e}"}, use_cache=use_cache, path=license_path, mtime=mtime) fp_expected = payload.get("fingerprint") fp_actual = _get_machine_fingerprint() if not fp_expected or fp_expected != fp_actual: - return {"ok": False, "reason": "机器指纹不匹配"} + return _license_result({"ok": False, "reason": "机器指纹不匹配"}, use_cache=use_cache, path=license_path, mtime=mtime) expires_at = payload.get("expires_at") if not expires_at: - return {"ok": False, "reason": "授权文件缺少有效期"} + return _license_result({"ok": False, "reason": "授权文件缺少有效期"}, use_cache=use_cache, path=license_path, mtime=mtime) try: expires_dt = datetime.fromisoformat(expires_at) except Exception: - return {"ok": False, "reason": "有效期格式错误"} + return _license_result({"ok": False, "reason": "有效期格式错误"}, use_cache=use_cache, path=license_path, mtime=mtime) if _now_utc() > expires_dt: - return {"ok": False, "reason": "授权已过期"} + return _license_result({"ok": False, "reason": "授权已过期"}, use_cache=use_cache, path=license_path, mtime=mtime) - return { + return _license_result({ "ok": True, "issued_to": payload.get("issued_to"), "expires_at": expires_at, "fingerprint": fp_actual, "license_path": license_path, - } + }, use_cache=use_cache, path=license_path, mtime=mtime) diff --git a/backend/app/routers/dependencies.py b/backend/app/routers/dependencies.py index e2b2eba..df8b142 100644 --- a/backend/app/routers/dependencies.py +++ b/backend/app/routers/dependencies.py @@ -126,6 +126,17 @@ _STATS_CACHE_DATA: Optional[Dict[str, Any]] = None _STATS_CACHE_EXPIRES_AT = 0.0 _STATS_CACHE_GENERATED_AT_UTC: Optional[str] = None +DASHBOARD_STATS_CACHE_TTL_SECONDS = read_int_env( + "DASHBOARD_STATS_CACHE_TTL_SECONDS", + STATS_CACHE_TTL_SECONDS, + minimum=0, + maximum=3600, +) +_DASHBOARD_STATS_CACHE_LOCK = asyncio.Lock() +_DASHBOARD_STATS_CACHE_DATA: Optional[Dict[str, Any]] = None +_DASHBOARD_STATS_CACHE_EXPIRES_AT = 0.0 +_DASHBOARD_STATS_CACHE_GENERATED_AT_UTC: Optional[str] = None + # --------------------------------------------------------------------------- # AOI token store # --------------------------------------------------------------------------- diff --git a/backend/app/routers/dinsar_production.py b/backend/app/routers/dinsar_production.py index f266a4a..0e81c66 100644 --- a/backend/app/routers/dinsar_production.py +++ b/backend/app/routers/dinsar_production.py @@ -42,6 +42,12 @@ LANDSAR_PRODUCTION_JOB_MAX_ATTEMPTS = read_int_env( minimum=1, maximum=10, ) +LANDSAR_CLUSTER_ITEM_JOB_MAX_ATTEMPTS = read_int_env( + "LANDSAR_CLUSTER_ITEM_JOB_MAX_ATTEMPTS", + 1, + minimum=1, + maximum=10, +) class RunJobRequest(BaseModel): @@ -441,6 +447,114 @@ async def submit_run( } +@router.post("/landsar-cluster/run") +async def submit_landsar_cluster_run( + req: RunJobRequest, + current_user: AuthUserORM = Depends(_get_current_user), +): + if str(req.engine_code or "").strip().lower() != "landsar": + raise HTTPException(status_code=400, detail="LandSAR cluster only accepts engine_code='landsar'.") + + registry = _get_registry() + engine = registry.get_engine("landsar") + if not engine: + raise HTTPException(status_code=400, detail="Engine 'landsar' not found.") + + valid_profiles = {profile.code for profile in engine.get_profiles()} + if req.profile not in valid_profiles: + raise HTTPException( + status_code=400, + detail=( + f"Engine 'landsar' does not support profile '{req.profile}'. " + f"Available profiles: {sorted(valid_profiles)}" + ), + ) + + validation_summary = None + if hasattr(engine, "validate_root_dir"): + try: + validation_summary = await asyncio.to_thread( + engine.validate_root_dir, + req.root_dir, + req.num_to_process, + req.rerun_mode, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if int(validation_summary.get("task_count", 0) or 0) <= 0: + if ( + req.rerun_mode == "unfinished_only" + and int(validation_summary.get("skipped_completed_count", 0) or 0) > 0 + ): + raise HTTPException( + status_code=400, + detail=( + f"All discovered Task_* directories already have completed " + f"landsar/{req.profile} results under: {req.root_dir}" + ), + ) + raise HTTPException(status_code=400, detail="No valid LandSAR task directories selected.") + + normalized_extra = dict(req.extra or {}) + if hasattr(engine, "normalize_extra"): + try: + normalized_extra = engine.normalize_extra(normalized_extra) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if validation_summary is not None: + validated_task_count = validation_summary.get("task_count", 0) + normalized_extra.update( + { + "__validated_task_count": validated_task_count, + "__validated_mode": validation_summary.get("mode", ""), + "__rerun_mode": req.rerun_mode, + "__discovered_task_count": int(validation_summary.get("discovered_task_count", validated_task_count) or 0), + "__skipped_completed_count": int(validation_summary.get("skipped_completed_count", 0) or 0), + "__cluster": True, + } + ) + + effective_timeout_seconds = req.timeout_seconds + if effective_timeout_seconds is None: + engine_default_timeout = getattr(engine, "default_timeout_seconds", None) + if engine_default_timeout: + effective_timeout_seconds = int(engine_default_timeout) + + try: + async with _new_session() as db: + result = await dinsar_production_service.create_landsar_cluster_run( + profile_code=req.profile, + root_dir=req.root_dir, + num_to_process=req.num_to_process, + rerun_mode=req.rerun_mode, + timeout_seconds=effective_timeout_seconds, + extra=normalized_extra, + created_by=getattr(current_user, "username", None), + max_attempts=LANDSAR_CLUSTER_ITEM_JOB_MAX_ATTEMPTS, + db=db, + ) + except ValueError as exc: + message = str(exc) + status_code = 409 if "浠诲姟鍐茬獊" in message else 400 + raise HTTPException(status_code=status_code, detail=message) from exc + + return { + "task_id": result["task_id"], + "job_id": None, + "run_id": result["run_id"], + "workflow_run_id": None, + "job_type": "LANDSAR_CLUSTER_ITEM", + "engine_code": "landsar", + "profile": req.profile, + "selected_task_count": result.get("selected_task_count", 0), + "discovered_task_count": result.get("discovered_task_count", result.get("selected_task_count", 0)), + "skipped_completed_count": result.get("skipped_completed_count", 0), + "rerun_mode": result.get("rerun_mode", req.rerun_mode), + "message": "LandSAR cluster items queued.", + } + + @router.get("/runs") async def list_runs(limit: int = 20, offset: int = 0): async with _new_session() as db: diff --git a/backend/app/routers/radar.py b/backend/app/routers/radar.py index df559a4..9e4b801 100644 --- a/backend/app/routers/radar.py +++ b/backend/app/routers/radar.py @@ -318,10 +318,7 @@ async def _build_radar_preview_cache( geo_error = "invalid_bbox" has_geo_cache = False else: - source_corner_mapping = await asyncio.to_thread( - data_service.get_radar_source_corner_mapping, - record.file_path, - ) + source_corner_mapping = data_service.get_radar_record_corner_mapping(record) ok_geo, geo_error = await asyncio.to_thread( image_service.create_geocorrected_radar_cached_image, preview_source, @@ -408,6 +405,7 @@ async def _get_cached_radar_preview(data_id: int, db: AsyncSession): raw_cache_path, geo_cache_path = _radar_preview_paths(record) if ( (record.preview_cache_status or "NONE") == "READY" + and (record.preview_cache_version or "") == settings.RADAR_GEO_CACHE_VERSION and record.preview_cache_path and str(record.preview_cache_path).lower().endswith(".webp") and os.path.exists(record.preview_cache_path) @@ -418,7 +416,7 @@ async def _get_cached_radar_preview(data_id: int, db: AsyncSession): headers={"Cache-Control": "public, max-age=31536000"}, ) - if os.path.exists(geo_cache_path): + if os.path.exists(geo_cache_path) and (record.preview_cache_version or "") == settings.RADAR_GEO_CACHE_VERSION: return FileResponse( geo_cache_path, media_type="image/webp", @@ -523,7 +521,6 @@ async def search_radar_data_endpoint( product_unique_id: Optional[str] = Form(None), orbit_direction: Optional[str] = Form(None), has_orbit_data: Optional[bool] = Form(None), - is_envi_processed: Optional[bool] = Form(None), imaging_date_from: Optional[str] = Form(None), imaging_date_to: Optional[str] = Form(None), region_tree_id: Optional[str] = Form(None), @@ -609,8 +606,6 @@ async def search_radar_data_endpoint( filters.append(RadarDataORM.orbit_direction.ilike(f"%{n_orbit_direction}%")) if has_orbit_data is not None: filters.append(RadarDataORM.has_orbit_data == has_orbit_data) - if is_envi_processed is not None: - filters.append(RadarDataORM.is_envi_processed == is_envi_processed) if n_date_from: filters.append(RadarDataORM.imaging_date >= n_date_from) if n_date_to: diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py index e1e8977..4f915b7 100644 --- a/backend/app/routers/stats.py +++ b/backend/app/routers/stats.py @@ -12,21 +12,45 @@ from typing import Any, Dict, Optional logger = logging.getLogger(__name__) from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import func, text +from sqlalchemy import distinct, func, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select +try: + from shapely.geometry import Point, Polygon, box, mapping, shape +except Exception: # pragma: no cover - production dependency is optional for stats fallback + Point = None + Polygon = None + box = None + mapping = None + shape = None + from ..auth_service import ROLE_ADMIN from ..config import settings from ..database import get_db from ..models import ( + AssetInventoryIssueORM, + AssetInventoryStateORM, AuthUserORM, + DinsarProductionRunORM, + DinsarTaskBatchORM, + DinsarTaskItemORM, + OrbitAssetORM, RadarDataORM, + ResultAssetORM, + ResultIssueORM, + ResultProductORM, SARSceneGeoORM, + SARSceneGeometryProfileORM, + SceneOrbitBindingORM, + SourceMetadataDocumentORM, + SourceProductAssetORM, + WorkflowRunORM, ) from ..services.data_service import data_service from ..services.dinsar_read_service import dinsar_read_service from ..services.pairing_state_service import pairing_state_service +from ..services.admin_region_lookup_service import _build_region_path, _load_region_records from ..utils import find_xml_file from . import dependencies as _deps from .dependencies import _get_current_user @@ -34,6 +58,1226 @@ from .dependencies import _get_current_user router = APIRouter() +def _safe_int(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _safe_float(value: Any) -> Optional[float]: + try: + if value is None: + return None + return float(value) + except (TypeError, ValueError): + return None + + +def _ratio(numerator: int, denominator: int) -> float: + if denominator <= 0: + return 0.0 + return round(float(numerator) / float(denominator), 4) + + +def _family_label(value: Any) -> str: + text = str(value or "").strip().upper() + if text in {"LT1", "LT-1", "LUTAN", "LUTAN1"}: + return "LT-1" + if text in {"S1", "SENTINEL1", "SENTINEL-1"}: + return "Sentinel-1" + if text in {"GF3", "GAOFEN3", "GAOFEN-3"}: + return "GF3" + return text or "未分类" + + +def _status_label(value: Any) -> str: + return str(value or "UNKNOWN").strip().upper() or "UNKNOWN" + + +def _month_from_yyyymmdd(value: Any) -> Optional[str]: + text = str(value or "").strip() + if len(text) >= 6 and text[:6].isdigit(): + return f"{text[:4]}-{text[4:6]}" + return None + + +def _month_from_datetime(value: Any) -> Optional[str]: + if not value: + return None + try: + return value.strftime("%Y-%m") + except AttributeError: + text = str(value) + if len(text) >= 7: + return text[:7] + return None + + +def _percent_text(value: float) -> str: + return f"{round(value * 100, 1)}%" + + +def _point_bbox(lon: Any, lat: Any) -> Optional[tuple[float, float, float, float]]: + lon_value = _safe_float(lon) + lat_value = _safe_float(lat) + if lon_value is None or lat_value is None: + return None + return (lon_value, lat_value, lon_value, lat_value) + + +def _polygon_points(value: Any) -> list[tuple[float, float]]: + if not value: + return [] + if isinstance(value, str): + try: + value = json.loads(value) + except Exception: + return [] + if isinstance(value, dict): + coordinates = value.get("coordinates") + if value.get("type") == "Feature": + return _polygon_points(value.get("geometry")) + if value.get("type") == "Polygon" and coordinates: + value = coordinates[0] if coordinates else [] + elif value.get("type") == "MultiPolygon" and coordinates: + value = coordinates[0][0] if coordinates and coordinates[0] else [] + else: + return [] + points: list[tuple[float, float]] = [] + if isinstance(value, list): + for item in value: + if isinstance(item, dict): + lon = _safe_float(item.get("lon", item.get("longitude"))) + lat = _safe_float(item.get("lat", item.get("latitude"))) + elif isinstance(item, (list, tuple)) and len(item) >= 2: + lon = _safe_float(item[0]) + lat = _safe_float(item[1]) + else: + continue + if lon is not None and lat is not None: + points.append((lon, lat)) + return points + + +def _bbox_from_polygon_or_values( + polygon_value: Any, + min_lon: Any = None, + min_lat: Any = None, + max_lon: Any = None, + max_lat: Any = None, +) -> Optional[tuple[float, float, float, float]]: + values = [_safe_float(min_lon), _safe_float(min_lat), _safe_float(max_lon), _safe_float(max_lat)] + if all(value is not None for value in values): + left, bottom, right, top = values + if left > right: + left, right = right, left + if bottom > top: + bottom, top = top, bottom + return (left, bottom, right, top) + points = _polygon_points(polygon_value) + if not points: + return None + lons = [point[0] for point in points] + lats = [point[1] for point in points] + return (min(lons), min(lats), max(lons), max(lats)) + + +def _shape_from_polygon_or_bbox(polygon_value: Any, bbox_value: tuple[float, float, float, float]): + if Polygon is not None and shape is not None: + if polygon_value: + try: + if isinstance(polygon_value, str): + polygon_value = json.loads(polygon_value) + if isinstance(polygon_value, dict): + geom = shape(polygon_value.get("geometry") if polygon_value.get("type") == "Feature" else polygon_value) + if not geom.is_empty: + return geom + points = _polygon_points(polygon_value) + if len(points) >= 3: + geom = Polygon(points) + if geom.is_valid and not geom.is_empty: + return geom + except Exception: + pass + if box is not None: + left, bottom, right, top = bbox_value + if left != right and bottom != top: + return box(left, bottom, right, top) + return None + + +def _build_heatmap_grid(items: list[dict[str, Any]], *, columns: int = 48) -> dict[str, Any]: + valid_items = [ + item for item in items + if item.get("bbox") is not None + ] + if not valid_items: + return { + "total": len(items), + "covered_count": 0, + "cell_count": 0, + "max_count": 0, + "extent": {"min_lon": None, "min_lat": None, "max_lon": None, "max_lat": None}, + "cells": [], + } + + min_lon = min(item["bbox"][0] for item in valid_items) + min_lat = min(item["bbox"][1] for item in valid_items) + max_lon = max(item["bbox"][2] for item in valid_items) + max_lat = max(item["bbox"][3] for item in valid_items) + lon_span = max(max_lon - min_lon, 0.01) + lat_span = max(max_lat - min_lat, 0.01) + rows = max(16, min(40, round((columns * lat_span) / lon_span))) + cell_lon = lon_span / columns + cell_lat = lat_span / rows + buckets: dict[tuple[int, int], dict[str, Any]] = {} + + def add_to_bucket(col: int, row: int, item: dict[str, Any]) -> None: + key = (col, row) + bucket = buckets.setdefault( + key, + { + "col": col, + "row": row, + "count": 0, + "families": {}, + "catalogs": {}, + "examples": [], + }, + ) + bucket["count"] += 1 + family = str(item.get("family") or "").strip() + catalog = str(item.get("catalog") or "").strip() + if family: + bucket["families"][family] = bucket["families"].get(family, 0) + 1 + if catalog: + bucket["catalogs"][catalog] = bucket["catalogs"].get(catalog, 0) + 1 + if len(bucket["examples"]) < 4: + bucket["examples"].append({ + "label": item.get("label"), + "family": family or None, + "catalog": catalog or None, + "date": item.get("date"), + }) + + for item in valid_items: + left, bottom, right, top = item["bbox"] + col_start = max(0, min(columns - 1, int((left - min_lon) / cell_lon))) + col_end = max(0, min(columns - 1, int((right - min_lon) / cell_lon))) + row_start = max(0, min(rows - 1, int((bottom - min_lat) / cell_lat))) + row_end = max(0, min(rows - 1, int((top - min_lat) / cell_lat))) + geom = _shape_from_polygon_or_bbox(item.get("polygon"), item["bbox"]) + for col in range(col_start, col_end + 1): + for row in range(row_start, row_end + 1): + if geom is not None and box is not None: + cell = box( + min_lon + col * cell_lon, + min_lat + row * cell_lat, + min_lon + (col + 1) * cell_lon, + min_lat + (row + 1) * cell_lat, + ) + try: + if not geom.intersects(cell): + continue + except Exception: + pass + add_to_bucket(col, row, item) + + cells = [] + for bucket in buckets.values(): + col = bucket["col"] + row = bucket["row"] + dominant_family = sorted(bucket["families"].items(), key=lambda kv: (-kv[1], kv[0]))[0][0] if bucket["families"] else None + dominant_catalog = sorted(bucket["catalogs"].items(), key=lambda kv: (-kv[1], kv[0]))[0][0] if bucket["catalogs"] else None + cells.append( + { + "col": col, + "row": row, + "count": bucket["count"], + "lon_min": round(min_lon + col * cell_lon, 6), + "lon_max": round(min_lon + (col + 1) * cell_lon, 6), + "lat_min": round(min_lat + row * cell_lat, 6), + "lat_max": round(min_lat + (row + 1) * cell_lat, 6), + "lon": round(min_lon + (col + 0.5) * cell_lon, 6), + "lat": round(min_lat + (row + 0.5) * cell_lat, 6), + "dominant_family": dominant_family, + "dominant_catalog": dominant_catalog, + "families": [ + {"name": name, "count": count} + for name, count in sorted(bucket["families"].items(), key=lambda kv: (-kv[1], kv[0])) + ], + "catalogs": [ + {"name": name, "count": count} + for name, count in sorted(bucket["catalogs"].items(), key=lambda kv: (-kv[1], kv[0])) + ], + "examples": bucket["examples"], + } + ) + cells.sort(key=lambda item: (-item["count"], item["row"], item["col"])) + + return { + "total": len(items), + "covered_count": len(valid_items), + "cell_count": len(cells), + "max_count": max((cell["count"] for cell in cells), default=0), + "columns": columns, + "rows": rows, + "extent": { + "min_lon": round(min_lon, 6), + "min_lat": round(min_lat, 6), + "max_lon": round(max_lon, 6), + "max_lat": round(max_lat, 6), + }, + "cells": cells, + } + + +def _build_region_match_candidates(records: list[Any]) -> list[tuple[Any, tuple[float, float, float, float]]]: + candidates = [] + for record in records: + try: + bounds = tuple(float(value) for value in record.geometry.bounds) + except Exception: + continue + if len(bounds) == 4: + candidates.append((record, bounds)) + return candidates + + +def _match_city_region( + lon: Any, + lat: Any, + region_candidates: list[tuple[Any, tuple[float, float, float, float]]], + region_by_id: dict[str, dict[str, Any]], +) -> dict[str, Any] | None: + lon_value = _safe_float(lon) + lat_value = _safe_float(lat) + if lon_value is None or lat_value is None: + return None + try: + point = Point(lon_value, lat_value) + except Exception: + return None + + matched = None + for record, bounds in region_candidates: + min_lon, min_lat, max_lon, max_lat = bounds + if lon_value < min_lon or lon_value > max_lon or lat_value < min_lat or lat_value > max_lat: + continue + try: + if record.geometry.covers(point): + matched = record + break + except Exception: + continue + if matched is None: + return None + + path_names, path_tree_ids = _build_region_path(matched.tree_id, region_by_id) + city_tree_id = None + city_name = None + province_name = None + for tree_id, name in zip(path_tree_ids, path_names): + node_level = str((region_by_id.get(tree_id) or {}).get("level") or "").strip().lower() + if node_level == "province": + province_name = name + if node_level == "city": + city_tree_id = tree_id + city_name = name + break + + if not city_tree_id: + level = str(getattr(matched, "level", "") or "").lower() + if level == "city": + city_tree_id = matched.tree_id + city_name = matched.name + else: + parts = str(matched.tree_id).split("-") + if len(parts) >= 3: + city_tree_id = "-".join(parts[:3]) + city_name = (region_by_id.get(city_tree_id) or {}).get("name") or matched.name + + if not city_tree_id: + return None + + return { + "tree_id": city_tree_id, + "name": str(city_name or city_tree_id), + "province": province_name, + "matched_tree_id": matched.tree_id, + } + + +def _echarts_map_geometry(geometry: Any) -> dict[str, Any] | None: + if mapping is None or geometry is None or getattr(geometry, "is_empty", True): + return None + try: + simplified = geometry.simplify(0.015, preserve_topology=True) + if simplified is not None and not simplified.is_empty: + geometry = simplified + except Exception: + pass + try: + geometry_json = mapping(geometry) + except Exception: + return None + if geometry_json.get("type") not in {"Polygon", "MultiPolygon"}: + return None + coordinates = geometry_json.get("coordinates") + if not coordinates: + return None + return geometry_json + + +def _build_city_region_coverage( + source_points: list[dict[str, Any]], + result_points: list[dict[str, Any]], +) -> dict[str, Any]: + records, region_by_id, error = _load_region_records() + if error: + return { + "status": "unavailable", + "message": error, + "features": {"type": "FeatureCollection", "features": []}, + "source": {"total": len(source_points), "matched_count": 0, "max_count": 0, "regions": []}, + "results": {"total": len(result_points), "matched_count": 0, "max_count": 0, "regions": []}, + } + + by_tree: dict[str, dict[str, Any]] = {} + region_candidates = _build_region_match_candidates(records) + + def ensure_bucket(region: dict[str, Any]) -> dict[str, Any]: + tree_id = region["tree_id"] + return by_tree.setdefault( + tree_id, + { + "tree_id": tree_id, + "name": region.get("name") or tree_id, + "province": region.get("province"), + "source_count": 0, + "result_count": 0, + "families": {}, + "catalogs": {}, + }, + ) + + for item in source_points: + region = _match_city_region(item.get("lon"), item.get("lat"), region_candidates, region_by_id) + if not region: + continue + bucket = ensure_bucket(region) + bucket["source_count"] += 1 + family = str(item.get("family") or "").strip() + if family: + bucket["families"][family] = bucket["families"].get(family, 0) + 1 + + for item in result_points: + region = _match_city_region(item.get("lon"), item.get("lat"), region_candidates, region_by_id) + if not region: + continue + bucket = ensure_bucket(region) + bucket["result_count"] += 1 + catalog = str(item.get("catalog") or "").strip() + if catalog: + bucket["catalogs"][catalog] = bucket["catalogs"].get(catalog, 0) + 1 + + city_records = {record.tree_id: record for record in records if str(record.level or "").lower() == "city"} + features = [] + for tree_id, bucket in by_tree.items(): + record = city_records.get(tree_id) + if record is None: + continue + geometry_json = _echarts_map_geometry(record.geometry) + if geometry_json is None: + continue + features.append( + { + "type": "Feature", + "properties": { + "tree_id": tree_id, + "name": bucket["name"], + "province": bucket.get("province"), + "source_count": bucket["source_count"], + "result_count": bucket["result_count"], + }, + "geometry": geometry_json, + } + ) + try: + point = record.geometry.representative_point() + bucket["center_lon"] = float(point.x) + bucket["center_lat"] = float(point.y) + except Exception: + pass + + def rows_for(kind: str) -> list[dict[str, Any]]: + count_key = "source_count" if kind == "source" else "result_count" + detail_key = "families" if kind == "source" else "catalogs" + return [ + { + "tree_id": bucket["tree_id"], + "name": bucket["name"], + "province": bucket.get("province"), + "count": bucket[count_key], + "lon": bucket.get("center_lon"), + "lat": bucket.get("center_lat"), + "breakdown": [ + {"name": name, "count": count} + for name, count in sorted(bucket[detail_key].items(), key=lambda kv: (-kv[1], kv[0])) + ], + } + for bucket in sorted(by_tree.values(), key=lambda item: (-item[count_key], item["name"])) + if bucket[count_key] > 0 + ] + + source_rows = rows_for("source") + result_rows = rows_for("results") + return { + "status": "ok", + "features": {"type": "FeatureCollection", "features": features}, + "source": { + "total": len(source_points), + "matched_count": sum(item["count"] for item in source_rows), + "max_count": max((item["count"] for item in source_rows), default=0), + "regions": source_rows, + }, + "results": { + "total": len(result_points), + "matched_count": sum(item["count"] for item in result_rows), + "max_count": max((item["count"] for item in result_rows), default=0), + "regions": result_rows, + }, + } + + +async def _scalar_count(db: AsyncSession, stmt) -> int: + result = await db.execute(stmt) + return _safe_int(result.scalar_one()) + + +@router.get("/statistics/dashboard") +async def get_statistics_dashboard( + fresh: bool = False, + current_user: AuthUserORM = Depends(_get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Business-facing dashboard statistics for the production overview page. + + This endpoint keeps the leadership/statistics dashboard separate from the + legacy /statistics health-consistency payload. + """ + if fresh and current_user.role != ROLE_ADMIN: + raise HTTPException(status_code=403, detail="Only admin can force refresh dashboard statistics.") + + now_mono = time.monotonic() + if _deps.DASHBOARD_STATS_CACHE_TTL_SECONDS > 0 and not fresh: + async with _deps._DASHBOARD_STATS_CACHE_LOCK: + if ( + _deps._DASHBOARD_STATS_CACHE_DATA is not None + and now_mono < _deps._DASHBOARD_STATS_CACHE_EXPIRES_AT + ): + return { + **_deps._DASHBOARD_STATS_CACHE_DATA, + "cache_meta": { + "enabled": True, + "hit": True, + "ttl_seconds": _deps.DASHBOARD_STATS_CACHE_TTL_SECONDS, + "generated_at": _deps._DASHBOARD_STATS_CACHE_GENERATED_AT_UTC, + }, + } + + generated_at = datetime.utcnow().isoformat(timespec="seconds") + "Z" + + source_total = await _scalar_count( + db, + select(func.count(SourceProductAssetORM.id)).where(SourceProductAssetORM.is_active == True), + ) + radar_total = await _scalar_count(db, select(func.count(RadarDataORM.id))) + metadata_asset_total = await _scalar_count( + db, + select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))), + ) + metadata_doc_total = await _scalar_count(db, select(func.count(SourceMetadataDocumentORM.id))) + geometry_total = await _scalar_count(db, select(func.count(SARSceneGeometryProfileORM.id))) + geometry_ready = await _scalar_count( + db, + select(func.count(SARSceneGeometryProfileORM.id)).where( + SARSceneGeometryProfileORM.metadata_quality == "READY", + SARSceneGeometryProfileORM.production_readiness == "READY", + ), + ) + preview_ready = await _scalar_count( + db, + select(func.count(RadarDataORM.id)).where(RadarDataORM.preview_cache_status == "READY"), + ) + + source_group_rows = await db.execute( + select( + SourceProductAssetORM.satellite_family, + SourceProductAssetORM.source_format, + SourceProductAssetORM.parse_status, + func.count(SourceProductAssetORM.id), + ) + .where(SourceProductAssetORM.is_active == True) + .group_by( + SourceProductAssetORM.satellite_family, + SourceProductAssetORM.source_format, + SourceProductAssetORM.parse_status, + ) + .order_by(SourceProductAssetORM.satellite_family, SourceProductAssetORM.source_format) + ) + source_by_family_map: dict[str, dict[str, Any]] = {} + source_by_format: list[dict[str, Any]] = [] + for family, source_format, parse_status, count in source_group_rows.all(): + family_label = _family_label(family) + status_label = _status_label(parse_status) + count_int = _safe_int(count) + family_bucket = source_by_family_map.setdefault( + family_label, + { + "family": family_label, + "count": 0, + "ready_count": 0, + "issue_count": 0, + "formats": {}, + }, + ) + family_bucket["count"] += count_int + if status_label in {"OK", "READY", "NATIVE_READY"}: + family_bucket["ready_count"] += count_int + else: + family_bucket["issue_count"] += count_int + format_label = str(source_format or "UNKNOWN") + family_bucket["formats"][format_label] = family_bucket["formats"].get(format_label, 0) + count_int + source_by_format.append( + { + "family": family_label, + "source_format": format_label, + "parse_status": status_label, + "count": count_int, + } + ) + + source_by_family = [] + for item in source_by_family_map.values(): + item["ready_rate"] = _ratio(item["ready_count"], item["count"]) + item["formats"] = [ + {"name": name, "count": count} + for name, count in sorted(item["formats"].items(), key=lambda kv: (-kv[1], kv[0])) + ] + source_by_family.append(item) + source_by_family.sort(key=lambda row: (-row["count"], row["family"])) + + geometry_rows = await db.execute( + select( + SARSceneGeometryProfileORM.satellite_family, + SARSceneGeometryProfileORM.metadata_quality, + SARSceneGeometryProfileORM.production_readiness, + func.count(SARSceneGeometryProfileORM.id), + ) + .group_by( + SARSceneGeometryProfileORM.satellite_family, + SARSceneGeometryProfileORM.metadata_quality, + SARSceneGeometryProfileORM.production_readiness, + ) + .order_by(SARSceneGeometryProfileORM.satellite_family) + ) + geometry_by_family_map: dict[str, dict[str, Any]] = {} + for family, metadata_quality, production_readiness, count in geometry_rows.all(): + family_label = _family_label(family) + count_int = _safe_int(count) + bucket = geometry_by_family_map.setdefault( + family_label, + {"family": family_label, "count": 0, "ready_count": 0, "issue_count": 0, "statuses": {}}, + ) + bucket["count"] += count_int + key = f"{_status_label(metadata_quality)} / {_status_label(production_readiness)}" + bucket["statuses"][key] = bucket["statuses"].get(key, 0) + count_int + if _status_label(metadata_quality) == "READY" and _status_label(production_readiness) == "READY": + bucket["ready_count"] += count_int + else: + bucket["issue_count"] += count_int + geometry_by_family = [] + for item in geometry_by_family_map.values(): + item["ready_rate"] = _ratio(item["ready_count"], item["count"]) + item["statuses"] = [ + {"name": name, "count": count} + for name, count in sorted(item["statuses"].items(), key=lambda kv: (-kv[1], kv[0])) + ] + geometry_by_family.append(item) + geometry_by_family.sort(key=lambda row: (-row["count"], row["family"])) + + source_month_rows = await db.execute( + select(SourceProductAssetORM.imaging_date, SourceProductAssetORM.satellite_family) + .where(SourceProductAssetORM.is_active == True) + .where(SourceProductAssetORM.imaging_date.isnot(None)) + ) + source_month_map: dict[str, dict[str, Any]] = {} + for imaging_date, family in source_month_rows.all(): + month = _month_from_yyyymmdd(imaging_date) + if not month: + continue + family_label = _family_label(family) + bucket = source_month_map.setdefault(month, {"month": month, "total": 0, "by_family": {}}) + bucket["total"] += 1 + bucket["by_family"][family_label] = bucket["by_family"].get(family_label, 0) + 1 + source_by_month = [source_month_map[key] for key in sorted(source_month_map)] + + orbit_total = await _scalar_count( + db, + select(func.count(OrbitAssetORM.id)).where(OrbitAssetORM.is_active == True), + ) + orbit_group_rows = await db.execute( + select(OrbitAssetORM.satellite_family, OrbitAssetORM.parse_status, func.count(OrbitAssetORM.id)) + .where(OrbitAssetORM.is_active == True) + .group_by(OrbitAssetORM.satellite_family, OrbitAssetORM.parse_status) + .order_by(OrbitAssetORM.satellite_family) + ) + orbit_by_family_map: dict[str, dict[str, Any]] = {} + for family, parse_status, count in orbit_group_rows.all(): + family_label = _family_label(family) + status_label = _status_label(parse_status) + count_int = _safe_int(count) + bucket = orbit_by_family_map.setdefault( + family_label, + {"family": family_label, "count": 0, "ok_count": 0, "issue_count": 0, "statuses": {}}, + ) + bucket["count"] += count_int + bucket["statuses"][status_label] = bucket["statuses"].get(status_label, 0) + count_int + if status_label == "OK": + bucket["ok_count"] += count_int + else: + bucket["issue_count"] += count_int + orbit_by_family = [] + for item in orbit_by_family_map.values(): + item["ok_rate"] = _ratio(item["ok_count"], item["count"]) + item["statuses"] = [ + {"name": name, "count": count} + for name, count in sorted(item["statuses"].items(), key=lambda kv: (-kv[1], kv[0])) + ] + orbit_by_family.append(item) + orbit_by_family.sort(key=lambda row: (-row["count"], row["family"])) + + orbit_required_total = sum( + item["count"] + for item in source_by_family + if item["family"] in {"LT-1", "Sentinel-1"} + ) + selected_orbit_bindings = await _scalar_count( + db, + select(func.count(SceneOrbitBindingORM.id)).where(SceneOrbitBindingORM.selection_status == "SELECTED"), + ) + matched_orbit_bindings = await _scalar_count( + db, + select(func.count(SceneOrbitBindingORM.id)).where(SceneOrbitBindingORM.match_status == "MATCHED"), + ) + + empty_legacy_coverage_grid = { + "total": 0, + "covered_count": 0, + "cell_count": 0, + "max_count": 0, + "extent": {"min_lon": None, "min_lat": None, "max_lon": None, "max_lat": None}, + "cells": [], + } + + coverage_rows = await db.execute( + select( + SARSceneGeometryProfileORM.id, + SARSceneGeometryProfileORM.satellite_family, + SARSceneGeometryProfileORM.acquisition_start_time_utc, + SARSceneGeometryProfileORM.scene_center_lon, + SARSceneGeometryProfileORM.scene_center_lat, + ) + .where( + SARSceneGeometryProfileORM.scene_center_lon.isnot(None), + SARSceneGeometryProfileORM.scene_center_lat.isnot(None), + ) + .order_by(SARSceneGeometryProfileORM.acquisition_start_time_utc.desc().nullslast()) + ) + source_region_points: list[dict[str, Any]] = [] + for ( + row_id, + family, + acquisition_start, + lon, + lat, + ) in coverage_rows.all(): + family_label = _family_label(family) + lon_float = _safe_float(lon) + lat_float = _safe_float(lat) + if lon_float is not None and lat_float is not None: + source_region_points.append( + { + "id": row_id, + "family": family_label, + "lon": lon_float, + "lat": lat_float, + "date": acquisition_start.date().isoformat() if acquisition_start else None, + } + ) + source_coverage_grid = {**empty_legacy_coverage_grid, "total": len(source_region_points), "covered_count": len(source_region_points)} + + result_total = await _scalar_count(db, select(func.count(ResultProductORM.id))) + result_rows = await db.execute( + select( + ResultProductORM.catalog_name, + ResultProductORM.status, + ResultProductORM.health_status, + func.count(ResultProductORM.id), + ) + .group_by(ResultProductORM.catalog_name, ResultProductORM.status, ResultProductORM.health_status) + .order_by(ResultProductORM.catalog_name) + ) + results_by_catalog_map: dict[str, dict[str, Any]] = {} + for catalog_name, status, health_status, count in result_rows.all(): + catalog = str(catalog_name or "unknown") + count_int = _safe_int(count) + bucket = results_by_catalog_map.setdefault( + catalog, + {"catalog": catalog, "count": 0, "ready_count": 0, "issue_count": 0, "statuses": {}, "health": {}}, + ) + bucket["count"] += count_int + status_label = _status_label(status) + health_label = _status_label(health_status) + bucket["statuses"][status_label] = bucket["statuses"].get(status_label, 0) + count_int + bucket["health"][health_label] = bucket["health"].get(health_label, 0) + count_int + if status_label == "READY" and health_label == "OK": + bucket["ready_count"] += count_int + else: + bucket["issue_count"] += count_int + results_by_catalog = [] + for item in results_by_catalog_map.values(): + item["ready_rate"] = _ratio(item["ready_count"], item["count"]) + item["statuses"] = [ + {"name": name, "count": count} + for name, count in sorted(item["statuses"].items(), key=lambda kv: (-kv[1], kv[0])) + ] + item["health"] = [ + {"name": name, "count": count} + for name, count in sorted(item["health"].items(), key=lambda kv: (-kv[1], kv[0])) + ] + results_by_catalog.append(item) + results_by_catalog.sort(key=lambda row: (-row["count"], row["catalog"])) + + result_assets_total = await _scalar_count(db, select(func.count(ResultAssetORM.id))) + result_assets_missing = await _scalar_count( + db, + select(func.count(ResultAssetORM.id)).where(ResultAssetORM.exists_flag == False), + ) + result_preview_count = await _scalar_count( + db, + select(func.count(ResultProductORM.id)).where(ResultProductORM.preview_path.isnot(None)), + ) + + result_month_rows = await db.execute( + select( + ResultProductORM.catalog_name, + ResultProductORM.published_at, + ResultProductORM.produced_at, + ResultProductORM.registered_at, + ) + ) + result_month_map: dict[str, dict[str, Any]] = {} + for catalog, published_at, produced_at, registered_at in result_month_rows.all(): + month = _month_from_datetime(published_at or produced_at or registered_at) + if not month: + continue + bucket = result_month_map.setdefault(month, {"month": month, "total": 0, "by_catalog": {}}) + bucket["total"] += 1 + catalog_label = str(catalog or "unknown") + bucket["by_catalog"][catalog_label] = bucket["by_catalog"].get(catalog_label, 0) + 1 + results_by_month = [result_month_map[key] for key in sorted(result_month_map)] + + result_coverage_rows = await db.execute( + select( + ResultProductORM.id, + ResultProductORM.catalog_name, + ResultProductORM.product_type, + ResultProductORM.produced_at, + ResultProductORM.published_at, + ResultProductORM.registered_at, + ResultProductORM.min_lon, + ResultProductORM.min_lat, + ResultProductORM.max_lon, + ResultProductORM.max_lat, + ResultProductORM.coverage_polygon, + ) + .where( + (ResultProductORM.coverage_polygon.isnot(None)) + | ( + ResultProductORM.min_lon.isnot(None) + & ResultProductORM.min_lat.isnot(None) + & ResultProductORM.max_lon.isnot(None) + & ResultProductORM.max_lat.isnot(None) + ) + ) + ) + result_region_points: list[dict[str, Any]] = [] + for ( + product_id, + catalog_name, + product_type, + produced_at, + published_at, + registered_at, + min_lon_value, + min_lat_value, + max_lon_value, + max_lat_value, + coverage_polygon, + ) in result_coverage_rows.all(): + bbox = _bbox_from_polygon_or_values( + coverage_polygon, + min_lon_value, + min_lat_value, + max_lon_value, + max_lat_value, + ) + center_lon = center_lat = None + if bbox: + center_lon = (bbox[0] + bbox[2]) / 2 + center_lat = (bbox[1] + bbox[3]) / 2 + result_region_points.append( + { + "id": product_id, + "catalog": str(catalog_name or product_type or "unknown"), + "lon": center_lon, + "lat": center_lat, + "date": _month_from_datetime(published_at or produced_at or registered_at), + } + ) + result_coverage_grid = {**empty_legacy_coverage_grid, "total": len(result_region_points), "covered_count": len(result_region_points)} + city_region_coverage = _build_city_region_coverage(source_region_points, result_region_points) + + dinsar_batch_count = await _scalar_count(db, select(func.count(DinsarTaskBatchORM.id))) + dinsar_task_count = await _scalar_count(db, select(func.count(DinsarTaskItemORM.id))) + dinsar_task_status_rows = await db.execute( + select(DinsarTaskItemORM.status, func.count(DinsarTaskItemORM.id)) + .group_by(DinsarTaskItemORM.status) + .order_by(DinsarTaskItemORM.status) + ) + dinsar_task_status = [ + {"status": _status_label(status), "count": _safe_int(count)} + for status, count in dinsar_task_status_rows.all() + ] + + production_run_rows = await db.execute( + select( + DinsarProductionRunORM.run_id, + DinsarProductionRunORM.product_family, + DinsarProductionRunORM.engine_code, + DinsarProductionRunORM.status, + DinsarProductionRunORM.total_items, + DinsarProductionRunORM.completed_items, + DinsarProductionRunORM.failed_items, + DinsarProductionRunORM.started_at, + DinsarProductionRunORM.ended_at, + DinsarProductionRunORM.created_at, + DinsarProductionRunORM.latest_message, + ) + .order_by(DinsarProductionRunORM.created_at.desc().nullslast(), DinsarProductionRunORM.id.desc()) + ) + production_status_map: dict[str, int] = {} + production_engine_map: dict[str, dict[str, Any]] = {} + recent_production_runs: list[dict[str, Any]] = [] + duration_seconds: list[float] = [] + production_run_count = 0 + for ( + run_id, + product_family, + engine_code, + status, + total_items, + completed_items, + failed_items, + started_at, + ended_at, + created_at, + latest_message, + ) in production_run_rows.all(): + production_run_count += 1 + status_label = _status_label(status) + production_status_map[status_label] = production_status_map.get(status_label, 0) + 1 + engine_label = str(engine_code or "unknown") + engine_bucket = production_engine_map.setdefault( + engine_label, + {"engine": engine_label, "count": 0, "completed": 0, "failed": 0, "running": 0}, + ) + engine_bucket["count"] += 1 + if status_label in {"COMPLETED", "SUCCESS", "DONE"}: + engine_bucket["completed"] += 1 + elif status_label in {"FAILED", "ERROR"}: + engine_bucket["failed"] += 1 + elif status_label in {"RUNNING", "PENDING", "QUEUED"}: + engine_bucket["running"] += 1 + if started_at and ended_at: + try: + duration_seconds.append((ended_at - started_at).total_seconds()) + except Exception: + pass + if len(recent_production_runs) < 8: + recent_production_runs.append( + { + "run_id": run_id, + "product_family": product_family, + "engine_code": engine_label, + "status": status_label, + "total_items": _safe_int(total_items), + "completed_items": _safe_int(completed_items), + "failed_items": _safe_int(failed_items), + "created_at": created_at.isoformat() if created_at else None, + "started_at": started_at.isoformat() if started_at else None, + "ended_at": ended_at.isoformat() if ended_at else None, + "latest_message": latest_message, + } + ) + + workflow_rows = await db.execute( + select(WorkflowRunORM.workflow_name, WorkflowRunORM.status, func.count(WorkflowRunORM.id)) + .group_by(WorkflowRunORM.workflow_name, WorkflowRunORM.status) + .order_by(WorkflowRunORM.workflow_name, WorkflowRunORM.status) + ) + workflow_status = [ + {"workflow": str(workflow or "unknown"), "status": _status_label(status), "count": _safe_int(count)} + for workflow, status, count in workflow_rows.all() + ] + + result_issue_rows = await db.execute( + select(ResultIssueORM.severity, ResultIssueORM.issue_code, ResultIssueORM.status, func.count(ResultIssueORM.id)) + .group_by(ResultIssueORM.severity, ResultIssueORM.issue_code, ResultIssueORM.status) + .order_by(ResultIssueORM.severity, ResultIssueORM.issue_code) + ) + inventory_issue_rows = await db.execute( + select( + AssetInventoryIssueORM.severity, + AssetInventoryIssueORM.issue_code, + AssetInventoryIssueORM.status, + func.count(AssetInventoryIssueORM.id), + ) + .group_by(AssetInventoryIssueORM.severity, AssetInventoryIssueORM.issue_code, AssetInventoryIssueORM.status) + .order_by(AssetInventoryIssueORM.severity, AssetInventoryIssueORM.issue_code) + ) + issue_total = 0 + open_issue_total = 0 + issue_by_severity: dict[str, int] = {} + issue_by_code: dict[str, int] = {} + for severity, issue_code, status, count in list(result_issue_rows.all()) + list(inventory_issue_rows.all()): + count_int = _safe_int(count) + status_label = _status_label(status) + severity_label = _status_label(severity) + code_label = str(issue_code or "UNKNOWN") + issue_total += count_int + if status_label == "OPEN": + open_issue_total += count_int + issue_by_severity[severity_label] = issue_by_severity.get(severity_label, 0) + count_int + issue_by_code[code_label] = issue_by_code.get(code_label, 0) + count_int + + inventory_state_rows = await db.execute( + select( + AssetInventoryStateORM.inventory_type, + AssetInventoryStateORM.status, + AssetInventoryStateORM.last_seen_entry_count, + AssetInventoryStateORM.last_asset_count, + AssetInventoryStateORM.last_issue_count, + AssetInventoryStateORM.last_scan_started_at, + AssetInventoryStateORM.last_scan_finished_at, + AssetInventoryStateORM.needs_rescan, + ) + .order_by(AssetInventoryStateORM.updated_at.desc().nullslast(), AssetInventoryStateORM.id.desc()) + .limit(12) + ) + inventory_states = [ + { + "inventory_type": inventory_type, + "status": _status_label(status), + "last_seen_entry_count": _safe_int(last_seen_entry_count), + "last_asset_count": _safe_int(last_asset_count), + "last_issue_count": _safe_int(last_issue_count), + "last_scan_started_at": last_scan_started_at.isoformat() if last_scan_started_at else None, + "last_scan_finished_at": last_scan_finished_at.isoformat() if last_scan_finished_at else None, + "needs_rescan": bool(needs_rescan), + } + for ( + inventory_type, + status, + last_seen_entry_count, + last_asset_count, + last_issue_count, + last_scan_started_at, + last_scan_finished_at, + needs_rescan, + ) in inventory_state_rows.all() + ] + + avg_duration_seconds = round(sum(duration_seconds) / len(duration_seconds), 1) if duration_seconds else None + selected_orbit_rate = _ratio(selected_orbit_bindings, orbit_required_total) + geometry_ready_rate = _ratio(geometry_ready, source_total) + metadata_ready_rate = _ratio(metadata_asset_total, source_total) + result_ready_total = sum(item["ready_count"] for item in results_by_catalog) + + risk_count = ( + max(0, source_total - metadata_asset_total) + + max(0, source_total - geometry_ready) + + max(0, orbit_required_total - selected_orbit_bindings) + + result_assets_missing + + open_issue_total + ) + + kpis = [ + { + "key": "source_total", + "label": "源数据资产", + "value": source_total, + "unit": "景", + "note": f"兼容台账 {radar_total} 条", + "tone": "primary", + }, + { + "key": "metadata_ready", + "label": "元数据入库率", + "value": round(metadata_ready_rate * 100, 1), + "unit": "%", + "note": f"{metadata_asset_total}/{source_total} 景已提取 XML/元数据", + "tone": "success" if metadata_ready_rate >= 0.98 else "warning", + }, + { + "key": "geometry_ready", + "label": "几何画像可用率", + "value": round(geometry_ready_rate * 100, 1), + "unit": "%", + "note": f"{geometry_ready}/{source_total} 景可用于覆盖统计", + "tone": "success" if geometry_ready_rate >= 0.95 else "warning", + }, + { + "key": "orbit_selected", + "label": "精轨绑定率", + "value": round(selected_orbit_rate * 100, 1), + "unit": "%", + "note": f"{selected_orbit_bindings}/{orbit_required_total} 景已选中精轨", + "tone": "success" if selected_orbit_rate >= 0.95 else "warning", + }, + { + "key": "result_total", + "label": "形变成果", + "value": result_total, + "unit": "项", + "note": f"健康成果 {result_ready_total} 项,预览 {result_preview_count} 项", + "tone": "primary", + }, + { + "key": "risk_total", + "label": "待关注项", + "value": risk_count, + "unit": "项", + "note": f"开放问题 {open_issue_total},缺失成果资产 {result_assets_missing}", + "tone": "danger" if risk_count else "success", + }, + ] + + dashboard_payload = { + "generated_at": generated_at, + "kpis": kpis, + "asset": { + "source_total": source_total, + "radar_total": radar_total, + "source_by_family": source_by_family, + "source_by_format": source_by_format, + "source_by_month": source_by_month, + "metadata_asset_total": metadata_asset_total, + "metadata_doc_total": metadata_doc_total, + "metadata_ready_rate": metadata_ready_rate, + "geometry_total": geometry_total, + "geometry_ready": geometry_ready, + "geometry_ready_rate": geometry_ready_rate, + "geometry_by_family": geometry_by_family, + "preview_ready": preview_ready, + "pipeline": [ + {"key": "source", "label": "源资产登记", "value": source_total, "rate": 1.0}, + {"key": "metadata", "label": "元数据入库", "value": metadata_asset_total, "rate": metadata_ready_rate}, + {"key": "geometry", "label": "几何画像", "value": geometry_total, "rate": _ratio(geometry_total, source_total)}, + {"key": "ready", "label": "可生产画像", "value": geometry_ready, "rate": geometry_ready_rate}, + {"key": "preview", "label": "预览缓存", "value": preview_ready, "rate": _ratio(preview_ready, radar_total)}, + ], + }, + "orbit": { + "orbit_total": orbit_total, + "orbit_by_family": orbit_by_family, + "orbit_required_total": orbit_required_total, + "selected_bindings": selected_orbit_bindings, + "matched_bindings": matched_orbit_bindings, + "selected_rate": selected_orbit_rate, + }, + "coverage": { + "point_total": geometry_total, + "source": source_coverage_grid, + "results": result_coverage_grid, + "city_regions": city_region_coverage, + }, + "production": { + "dinsar_batch_count": dinsar_batch_count, + "dinsar_task_count": dinsar_task_count, + "dinsar_task_status": dinsar_task_status, + "run_count": production_run_count, + "run_status": [ + {"status": status, "count": count} + for status, count in sorted(production_status_map.items(), key=lambda kv: (-kv[1], kv[0])) + ], + "engine_status": sorted(production_engine_map.values(), key=lambda row: (-row["count"], row["engine"])), + "avg_duration_seconds": avg_duration_seconds, + "recent_runs": recent_production_runs, + "workflow_status": workflow_status, + }, + "results": { + "result_total": result_total, + "result_ready_total": result_ready_total, + "result_preview_count": result_preview_count, + "result_assets_total": result_assets_total, + "result_assets_missing": result_assets_missing, + "results_by_catalog": results_by_catalog, + "results_by_month": results_by_month, + }, + "issues": { + "issue_total": issue_total, + "open_issue_total": open_issue_total, + "by_severity": [ + {"severity": severity, "count": count} + for severity, count in sorted(issue_by_severity.items(), key=lambda kv: (-kv[1], kv[0])) + ], + "by_code": [ + {"code": code, "count": count} + for code, count in sorted(issue_by_code.items(), key=lambda kv: (-kv[1], kv[0]))[:12] + ], + }, + "inventory": { + "states": inventory_states, + }, + "summary": { + "metadata_ready_text": _percent_text(metadata_ready_rate), + "geometry_ready_text": _percent_text(geometry_ready_rate), + "orbit_selected_text": _percent_text(selected_orbit_rate), + }, + } + + if _deps.DASHBOARD_STATS_CACHE_TTL_SECONDS > 0: + async with _deps._DASHBOARD_STATS_CACHE_LOCK: + _deps._DASHBOARD_STATS_CACHE_DATA = dashboard_payload + _deps._DASHBOARD_STATS_CACHE_EXPIRES_AT = time.monotonic() + _deps.DASHBOARD_STATS_CACHE_TTL_SECONDS + _deps._DASHBOARD_STATS_CACHE_GENERATED_AT_UTC = generated_at + + return { + **dashboard_payload, + "cache_meta": { + "enabled": _deps.DASHBOARD_STATS_CACHE_TTL_SECONDS > 0, + "hit": False, + "ttl_seconds": _deps.DASHBOARD_STATS_CACHE_TTL_SECONDS, + "generated_at": generated_at, + }, + } + + @router.get("/statistics") async def get_statistics( fresh: bool = False, diff --git a/backend/app/services/admin_region_lookup_service.py b/backend/app/services/admin_region_lookup_service.py index eba1f5f..9a1303c 100644 --- a/backend/app/services/admin_region_lookup_service.py +++ b/backend/app/services/admin_region_lookup_service.py @@ -5,7 +5,7 @@ import json from pathlib import Path from typing import Any -from shapely.geometry import Point, shape +from shapely.geometry import MultiPolygon, Point, shape from shapely.ops import unary_union try: @@ -36,6 +36,7 @@ class _RegionGeometryRecord: _REGION_GEOMETRY_CACHE: list[_RegionGeometryRecord] | None = None _REGION_BY_ID_LOOKUP_CACHE: dict[str, dict[str, Any]] | None = None _REGION_LOAD_ERROR: str | None = None +_POLYGONAL_GEOMETRY_TYPES = {"Polygon", "MultiPolygon"} def _backend_geojson_dir() -> Path: @@ -190,6 +191,40 @@ def _build_region_path(tree_id: str, region_by_id: dict[str, dict[str, Any]]) -> return names, tree_ids +def _as_polygonal_geometry(geometry): + if geometry is None or geometry.is_empty: + return None + if getattr(geometry, "geom_type", None) in _POLYGONAL_GEOMETRY_TYPES: + return geometry + + polygon_parts = [] + for part in getattr(geometry, "geoms", []) or []: + polygonal = _as_polygonal_geometry(part) + if polygonal is None or polygonal.is_empty: + continue + if getattr(polygonal, "geom_type", None) == "Polygon": + polygon_parts.append(polygonal) + else: + polygon_parts.extend([item for item in getattr(polygonal, "geoms", []) if not item.is_empty]) + + if not polygon_parts: + return None + if len(polygon_parts) == 1: + return polygon_parts[0] + try: + merged = unary_union(polygon_parts) + if merged is not None and not merged.is_empty: + if getattr(merged, "geom_type", None) in _POLYGONAL_GEOMETRY_TYPES: + return merged + return _as_polygonal_geometry(merged) + except Exception: + pass + try: + return MultiPolygon(polygon_parts) + except Exception: + return polygon_parts[0] + + def _load_region_records() -> tuple[list[_RegionGeometryRecord], dict[str, dict[str, Any]], str | None]: global _REGION_BY_ID_LOOKUP_CACHE, _REGION_GEOMETRY_CACHE, _REGION_LOAD_ERROR if _REGION_GEOMETRY_CACHE is not None: @@ -228,6 +263,7 @@ def _load_region_records() -> tuple[list[_RegionGeometryRecord], dict[str, dict[ if not geometries: continue geometry = _merge_region_geometries(geometries) + geometry = _as_polygonal_geometry(geometry) if geometry is None or geometry.is_empty: continue node = region_by_id.get(tree_id) or {} diff --git a/backend/app/services/asset_inventory_service.py b/backend/app/services/asset_inventory_service.py index 8514529..3f9e21f 100644 --- a/backend/app/services/asset_inventory_service.py +++ b/backend/app/services/asset_inventory_service.py @@ -9,8 +9,10 @@ import re import shutil import tarfile import zipfile +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait from datetime import datetime, timedelta from pathlib import PurePosixPath +from types import SimpleNamespace from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Sequence, Tuple from geoalchemy2.shape import from_shape @@ -35,6 +37,7 @@ from ..models import ( SourceProductAssetORM, ) from ..utils import ( + build_corner_pixel_mapping, find_xml_file, normalize_satellite_family, parse_gf3_l2_dirname, @@ -55,6 +58,9 @@ LT1_ORBIT_MATCH_RULE_VERSION = "lt1_orbit_day_v1" ASSET_SCAN_LOG_INTERVAL = 100 ASSET_SCAN_DETAILED_PARSE_LOG_LIMIT = 200 ARCHIVE_INTEGRITY_LOG_INTERVAL = 10 +DEFAULT_ASSET_SCAN_PARSE_WORKERS = 4 +DEFAULT_ASSET_SCAN_PARSE_INFLIGHT = 64 +DEFAULT_ASSET_SCAN_DB_BATCH_SIZE = 50 _WINDOWS_DRIVE_RE = re.compile(r"^[a-zA-Z]:[\\/]") _S1_SOURCE_RE = re.compile( @@ -458,32 +464,60 @@ def _archive_read_first_matching(path: str, predicate: Callable[[str], bool]) -> return None, None, members -def _archive_list_matching(path: str, predicate: Callable[[str], bool], *, limit: int = 20) -> List[str]: - matches: List[str] = [] +def _archive_collect_members( + path: str, + *, + content_predicate: Callable[[str], bool], + list_predicate: Callable[[str], bool], + list_limit: int = 20, +) -> Tuple[Optional[str], Optional[bytes], List[str], List[str]]: + content_name: Optional[str] = None + content_data: Optional[bytes] = None + listed: List[str] = [] + scanned: List[str] = [] + target_list_count = max(0, int(list_limit)) - def _visit(name: str) -> None: - if predicate(name): - matches.append(name) + def _visit(name: str, reader: Callable[[], bytes]) -> None: + nonlocal content_name, content_data + scanned.append(name) + if list_predicate(name) and len(listed) < target_list_count: + listed.append(name) + if content_name is None and content_predicate(name): + content_name = name + content_data = reader() + + def _done() -> bool: + return content_name is not None and len(listed) >= target_list_count if zipfile.is_zipfile(path): with zipfile.ZipFile(path) as archive: for info in archive.infolist(): if info.is_dir(): continue - _visit(info.filename) - if len(matches) >= limit: + _visit(info.filename, lambda info=info: archive.read(info)) + if _done(): break - return matches + return content_name, content_data, listed, scanned if tarfile.is_tarfile(path): with tarfile.open(path, "r:*") as archive: for member in archive: if not member.isfile(): continue - _visit(member.name) - if len(matches) >= limit: + + def _read(member=member) -> bytes: + source = archive.extractfile(member) + if source is None: + return b"" + with source: + return source.read() + + _visit(member.name, _read) + if _done(): break - return matches + return content_name, content_data, listed, scanned + + return None, None, listed, scanned def _safe_archive_member_name(member_name: str, archive_path: str) -> str: @@ -783,8 +817,19 @@ def _parse_radar_xml_metadata_bytes(data: bytes) -> Tuple[Optional[List[Tuple[fl ) coverage_polygon: Optional[List[Tuple[float, float]]] = None + corner_details: Dict[str, Dict[str, Any]] = {} if len(corners) >= 4: coverage_polygon = _ordered_closed_polygon_from_corners(corners[:4]) + corner_details = { + str(item.get("name")): { + "lon": item.get("lon"), + "lat": item.get("lat"), + "ref_row": item.get("ref_row"), + "ref_col": item.get("ref_col"), + } + for item in corners + if item.get("name") + } start_time = ( _xml_text_under_local_path(root, "start", "timeUTC") @@ -834,6 +879,7 @@ def _parse_radar_xml_metadata_bytes(data: bytes) -> Tuple[Optional[List[Tuple[fl for item in corners if item.get("name") }, + "corner_pixel_mapping": build_corner_pixel_mapping(corner_details), "coverage_polygon": coverage_polygon, } return coverage_polygon, {key: value for key, value in metadata.items() if value not in (None, "", [])} @@ -890,6 +936,58 @@ def _metadata_document( } +def _s1_annotation_sort_key(path: str) -> Tuple[int, str]: + normalized = str(path or "").replace("\\", "/") + lower = normalized.lower() + base = PurePosixPath(normalized).name.lower() + is_direct_annotation = "/annotation/" in lower and lower.count("/annotation/") == 1 + is_measurement_annotation = is_direct_annotation and base.startswith("s1") and base.endswith(".xml") + if is_measurement_annotation: + rank = 0 + elif "/annotation/calibration/" in lower: + rank = 2 + elif "/annotation/noise/" in lower: + rank = 3 + elif "/annotation/rfi/" in lower: + rank = 4 + else: + rank = 1 + return rank, lower + + +def _parse_s1_preview_kml_bytes(data: bytes) -> Dict[str, Any]: + try: + root = etree.fromstring(data, parser=_xml_parser()) + except Exception: + return {} + coordinate_texts = root.xpath("//*[local-name()='LatLonQuad']/*[local-name()='coordinates']/text()") + if not coordinate_texts: + return {} + points: List[Tuple[float, float]] = [] + for token in str(coordinate_texts[0] or "").strip().split(): + parts = token.split(",") + if len(parts) < 2: + continue + try: + points.append((float(parts[0]), float(parts[1]))) + except ValueError: + continue + if len(points) != 4: + return {} + mapping = { + "bottom_left": [points[0][0], points[0][1]], + "bottom_right": [points[1][0], points[1][1]], + "top_right": [points[2][0], points[2][1]], + "top_left": [points[3][0], points[3][1]], + "source": "s1_preview_map_overlay_kml", + } + polygon = [points[0], points[1], points[2], points[3], points[0]] + return { + "preview_map_overlay_polygon": polygon, + "corner_pixel_mapping": mapping, + } + + def _extract_s1_annotation_documents(source_path: str, *, limit: int = 16) -> List[Dict[str, Any]]: docs: List[Dict[str, Any]] = [] stat = _stat_path(source_path) @@ -902,7 +1000,7 @@ def _extract_s1_annotation_documents(source_path: str, *, limit: int = 16) -> Li for file_name in files: if file_name.lower().endswith(".xml"): candidates.append(os.path.join(current_root, file_name)) - for path in sorted(candidates)[: max(0, limit)]: + for path in sorted(candidates, key=_s1_annotation_sort_key)[: max(0, limit)]: try: with open(path, "rb") as stream: data = stream.read() @@ -928,7 +1026,7 @@ def _extract_s1_annotation_documents(source_path: str, *, limit: int = 16) -> Li for name in archive.namelist() if "/annotation/" in name.lower() and name.lower().endswith(".xml") ] - for name in sorted(names)[: max(0, limit)]: + for name in sorted(names, key=_s1_annotation_sort_key)[: max(0, limit)]: docs.append( _metadata_document( document_type="S1_ANNOTATION", @@ -1099,13 +1197,48 @@ def _parse_s1_manifest_bytes(data: bytes) -> Dict[str, Any]: def _parse_s1_zip_manifest(path: str) -> Dict[str, Any]: stat = _stat_path(path) with zipfile.ZipFile(path) as archive: - manifest_name = next( - (name for name in archive.namelist() if name.lower().endswith("/manifest.safe") or name.lower() == "manifest.safe"), - None, - ) + manifest_name = None + preview_kml_name = None + annotation_names: List[str] = [] + for info in archive.infolist(): + if info.is_dir(): + continue + name = info.filename + lower = name.lower() + if manifest_name is None and (lower.endswith("/manifest.safe") or lower == "manifest.safe"): + manifest_name = name + if preview_kml_name is None and lower.endswith("/preview/map-overlay.kml"): + preview_kml_name = name + if "/annotation/" in lower and lower.endswith(".xml"): + annotation_names.append(name) if not manifest_name: return {"manifest_parse_status": "MISSING"} manifest_bytes = archive.read(manifest_name) + annotation_documents = [ + _metadata_document( + document_type="S1_ANNOTATION", + member_path=name, + content=archive.read(name), + source_format="S1_ZIP", + satellite_family="S1", + archive_path=path, + archive_mtime=stat.get("mtime_epoch"), + ) + for name in sorted(annotation_names, key=_s1_annotation_sort_key)[:16] + ] + preview_kml_bytes = archive.read(preview_kml_name) if preview_kml_name else None + preview_kml_meta = _parse_s1_preview_kml_bytes(preview_kml_bytes) if preview_kml_bytes else {} + preview_kml_documents = [ + _metadata_document( + document_type="S1_PREVIEW_KML", + member_path=preview_kml_name or "preview/map-overlay.kml", + content=preview_kml_bytes, + source_format="S1_ZIP", + satellite_family="S1", + archive_path=path, + archive_mtime=stat.get("mtime_epoch"), + ) + ] if preview_kml_bytes else [] return { "manifest_parse_status": "OK", "manifest_path": manifest_name, @@ -1119,8 +1252,10 @@ def _parse_s1_zip_manifest(path: str) -> Dict[str, Any]: archive_path=path, archive_mtime=stat.get("mtime_epoch"), ), - *_extract_s1_annotation_documents(path), + *preview_kml_documents, + *annotation_documents, ], + **preview_kml_meta, **_parse_s1_manifest_bytes(manifest_bytes), } @@ -1132,6 +1267,23 @@ def _parse_s1_safe_manifest(path: str) -> Dict[str, Any]: stat = _stat_path(path) with open(manifest_path, "rb") as stream: manifest_bytes = stream.read() + preview_kml_path = os.path.join(path, "preview", "map-overlay.kml") + preview_kml_bytes = None + if os.path.isfile(preview_kml_path): + with open(preview_kml_path, "rb") as preview_stream: + preview_kml_bytes = preview_stream.read() + preview_kml_meta = _parse_s1_preview_kml_bytes(preview_kml_bytes) if preview_kml_bytes else {} + preview_kml_documents = [ + _metadata_document( + document_type="S1_PREVIEW_KML", + member_path="preview/map-overlay.kml", + content=preview_kml_bytes, + source_format="S1_SAFE_DIR", + satellite_family="S1", + archive_path=path, + archive_mtime=stat.get("mtime_epoch"), + ) + ] if preview_kml_bytes else [] return { "manifest_parse_status": "OK", "manifest_path": manifest_path, @@ -1145,8 +1297,10 @@ def _parse_s1_safe_manifest(path: str) -> Dict[str, Any]: archive_path=path, archive_mtime=stat.get("mtime_epoch"), ), + *preview_kml_documents, *_extract_s1_annotation_documents(path), ], + **preview_kml_meta, **_parse_s1_manifest_bytes(manifest_bytes), } @@ -1154,14 +1308,11 @@ def _parse_s1_safe_manifest(path: str) -> Dict[str, Any]: def _parse_lt1_archive_metadata(path: str) -> Dict[str, Any]: archive_stem = _strip_known_suffix(os.path.basename(path)) stat = _stat_path(path) - xml_member, xml_data, members = _archive_read_first_matching( + xml_member, xml_data, tiff_members, members = _archive_collect_members( path, - lambda name: _archive_member_base_name(name).lower().endswith(".meta.xml"), - ) - tiff_members = _archive_list_matching( - path, - lambda name: _archive_member_base_name(name).lower().endswith((".tiff", ".tif")), - limit=8, + content_predicate=lambda name: _archive_member_base_name(name).lower().endswith(".meta.xml"), + list_predicate=lambda name: _archive_member_base_name(name).lower().endswith((".tiff", ".tif")), + list_limit=8, ) if not xml_member or not xml_data: return { @@ -1193,14 +1344,11 @@ def _parse_lt1_archive_metadata(path: str) -> Dict[str, Any]: def _parse_gf3_archive_metadata(path: str) -> Dict[str, Any]: archive_stem = _strip_known_suffix(os.path.basename(path)) - xml_member, xml_data, members = _archive_read_first_matching( + xml_member, xml_data, quicklooks, members = _archive_collect_members( path, - lambda name: _archive_member_base_name(name).lower().endswith(".xml"), - ) - quicklooks = _archive_list_matching( - path, - lambda name: _archive_member_base_name(name).lower().endswith((".jpg", ".jpeg", ".png", ".bmp", "_ql.tif", "_ql.tiff")), - limit=8, + content_predicate=lambda name: _archive_member_base_name(name).lower().endswith(".xml"), + list_predicate=lambda name: _archive_member_base_name(name).lower().endswith((".jpg", ".jpeg", ".png", ".bmp", "_ql.tif", "_ql.tiff")), + list_limit=8, ) if not xml_member or not xml_data: return { @@ -1782,6 +1930,8 @@ def _cached_source_asset_is_unchanged( cached: Optional[Dict[str, Any]], stat: Dict[str, Optional[float]], root: ManagedRootORM, + *, + skip_unchanged_failures: bool = True, ) -> bool: if not cached: return False @@ -1797,7 +1947,11 @@ def _cached_source_asset_is_unchanged( return False if str(cached.get("parser_version") or "") != PARSER_VERSION: return False - if str(cached.get("parse_status") or "").upper() != "OK": + parse_status = str(cached.get("parse_status") or "").upper() + allowed_statuses = {"OK"} + if skip_unchanged_failures: + allowed_statuses.update({"PARTIAL", "FAILED"}) + if parse_status not in allowed_statuses: return False return _same_size(cached.get("size_bytes"), stat.get("size_bytes")) and _same_mtime( cached.get("mtime_epoch"), @@ -1840,14 +1994,27 @@ def _collect_source_assets_incremental( log_callback: Optional[Callable[[str, str], None]] = None, progress_start: int = 0, progress_end: int = 100, + parse_workers: int = DEFAULT_ASSET_SCAN_PARSE_WORKERS, + parse_inflight: int = DEFAULT_ASSET_SCAN_PARSE_INFLIGHT, + skip_unchanged_failures: bool = True, + row_batch_callback: Optional[Callable[[List[Dict[str, Any]]], None]] = None, + row_batch_size: int = DEFAULT_ASSET_SCAN_DB_BATCH_SIZE, ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], int, int, List[str]]: rows: List[Dict[str, Any]] = [] + pending_rows: List[Dict[str, Any]] = [] issues: List[Dict[str, Any]] = [] seen_paths: List[str] = [] + seen_path_set: set[str] = set() entry_count = 0 skipped_unchanged = 0 + skipped_unchanged_ok = 0 + skipped_unchanged_failed = 0 parse_attempts = 0 + parse_completed = 0 last_progress_count = 0 + parse_workers = max(1, int(parse_workers or 1)) + parse_inflight = max(parse_workers, int(parse_inflight or parse_workers)) + row_batch_size = max(1, int(row_batch_size or 1)) def _log(level: str, message: str) -> None: if log_callback: @@ -1857,39 +2024,37 @@ def _collect_source_assets_incremental( if progress_callback: progress_callback(_activity_progress(progress_start, progress_end, entry_count), message) - _log("INFO", f"Source root discovery started: {root.path}") - for path in _iter_source_candidates(root.path): - entry_count += 1 - normalized_path = _normalize_path(path) - stat = _stat_path(normalized_path) - if _cached_source_asset_is_unchanged(existing_by_path.get(normalized_path), stat, root): - seen_paths.append(normalized_path) - skipped_unchanged += 1 - if skipped_unchanged % ASSET_SCAN_LOG_INTERVAL == 0: - _log( - "INFO", - f"Skipped unchanged source archives: {skipped_unchanged} (candidates={entry_count})", - ) - if entry_count - last_progress_count >= ASSET_SCAN_LOG_INTERVAL: - _progress( - "Scanning source archives: " - f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, issues={len(issues)}" - ) - last_progress_count = entry_count - continue - parse_attempts += 1 + def _mark_seen(path: str) -> None: + normalized = _normalize_path(path) + if normalized and normalized not in seen_path_set: + seen_path_set.add(normalized) + seen_paths.append(normalized) + + def _emit_row_batch(*, force: bool = False) -> None: + if not row_batch_callback or not pending_rows: + return + if not force and len(pending_rows) < row_batch_size: + return + batch = pending_rows[:] + pending_rows.clear() + row_batch_callback(batch) + + def _parse_one(index: int, normalized_path: str) -> Dict[str, Any]: file_name = os.path.basename(normalized_path) - if parse_attempts <= ASSET_SCAN_DETAILED_PARSE_LOG_LIMIT or parse_attempts % ASSET_SCAN_LOG_INTERVAL == 0: - _log("INFO", f"Extracting source archive metadata {parse_attempts}: {file_name}") - if parse_attempts <= 50 or parse_attempts % 25 == 0: - _progress( - "Extracting source archive metadata: " - f"{file_name} (changed/new={parse_attempts}, skipped={skipped_unchanged})" - ) try: - row = _parse_source_entry(normalized_path, root) + row = _parse_source_entry(normalized_path, parse_root) + return {"index": index, "path": normalized_path, "file_name": file_name, "row": row, "error": None} except Exception as exc: - row = None + return {"index": index, "path": normalized_path, "file_name": file_name, "row": None, "error": exc} + + def _handle_parse_result(result: Dict[str, Any]) -> None: + nonlocal parse_completed, last_progress_count + parse_completed += 1 + normalized_path = str(result.get("path") or "") + file_name = str(result.get("file_name") or os.path.basename(normalized_path)) + exc = result.get("error") + if exc is not None: + _mark_seen(normalized_path) _log("WARNING", f"Source archive metadata parse failed: {file_name}: {exc}") issues.append( { @@ -1899,10 +2064,14 @@ def _collect_source_assets_incremental( "source_path": normalized_path, } ) + return + row = result.get("row") if row is None: - continue + _mark_seen(normalized_path) + return rows.append(row) - seen_paths.append(str(row["file_path"])) + pending_rows.append(row) + _mark_seen(str(row["file_path"])) if row.get("parse_status") in {"FAILED", "PARTIAL"}: _log( "WARNING", @@ -1920,17 +2089,91 @@ def _collect_source_assets_incremental( if entry_count - last_progress_count >= ASSET_SCAN_LOG_INTERVAL: _progress( "Scanning source archives: " - f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, issues={len(issues)}" + f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, " + f"completed={parse_completed}/{parse_attempts}, issues={len(issues)}" ) last_progress_count = entry_count + _emit_row_batch() + + def _drain_completed(pending: set, *, wait_for_one: bool = False) -> set: + if not pending: + return pending + timeout = None if wait_for_one else 0 + done, remaining = wait(pending, timeout=timeout, return_when=FIRST_COMPLETED) + for future in done: + _handle_parse_result(future.result()) + return remaining + + _log( + "INFO", + "Source root discovery started: " + f"{root.path} (workers={parse_workers}, inflight={parse_inflight}, " + f"db_batch_size={row_batch_size}, skip_unchanged_failures={skip_unchanged_failures})", + ) + parse_root = SimpleNamespace(id=root.id, path=root.path) + with ThreadPoolExecutor(max_workers=parse_workers, thread_name_prefix="asset-parse") as executor: + pending = set() + for path in _iter_source_candidates(root.path): + entry_count += 1 + normalized_path = _normalize_path(path) + stat = _stat_path(normalized_path) + cached = existing_by_path.get(normalized_path) + if _cached_source_asset_is_unchanged( + cached, + stat, + root, + skip_unchanged_failures=skip_unchanged_failures, + ): + _mark_seen(normalized_path) + skipped_unchanged += 1 + cached_status = str((cached or {}).get("parse_status") or "").upper() + if cached_status == "OK": + skipped_unchanged_ok += 1 + else: + skipped_unchanged_failed += 1 + if skipped_unchanged % ASSET_SCAN_LOG_INTERVAL == 0: + _log( + "INFO", + "Skipped unchanged source archives: " + f"{skipped_unchanged} (ok={skipped_unchanged_ok}, failed_cached={skipped_unchanged_failed}, " + f"candidates={entry_count})", + ) + if entry_count - last_progress_count >= ASSET_SCAN_LOG_INTERVAL: + _progress( + "Scanning source archives: " + f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, " + f"completed={parse_completed}/{parse_attempts}, issues={len(issues)}" + ) + last_progress_count = entry_count + continue + parse_attempts += 1 + file_name = os.path.basename(normalized_path) + if parse_attempts <= ASSET_SCAN_DETAILED_PARSE_LOG_LIMIT or parse_attempts % ASSET_SCAN_LOG_INTERVAL == 0: + _log("INFO", f"Extracting source archive metadata {parse_attempts}: {file_name}") + if parse_attempts <= 50 or parse_attempts % 25 == 0: + _progress( + "Extracting source archive metadata: " + f"{file_name} (changed/new={parse_attempts}, completed={parse_completed}, " + f"workers={parse_workers}, skipped={skipped_unchanged}, issue={len(issues)})" + ) + pending.add(executor.submit(_parse_one, parse_attempts, normalized_path)) + while len(pending) >= parse_inflight: + pending = _drain_completed(pending, wait_for_one=True) + pending = _drain_completed(pending, wait_for_one=False) + while pending: + pending = _drain_completed(pending, wait_for_one=True) + _emit_row_batch(force=True) _log( "INFO", "Source root discovery finished: " - f"candidates={entry_count}, skipped={skipped_unchanged}, changed_or_new={len(rows)}, issues={len(issues)}", + f"candidates={entry_count}, skipped={skipped_unchanged}, " + f"skipped_ok={skipped_unchanged_ok}, skipped_failed_cached={skipped_unchanged_failed}, " + f"changed_or_new={len(rows)}, parse_attempts={parse_attempts}, issues={len(issues)}", ) _progress( "Source archive discovery finished: " - f"candidates={entry_count}, skipped={skipped_unchanged}, changed_or_new={len(rows)}" + f"candidates={entry_count}, skipped={skipped_unchanged}, changed_or_new={len(rows)}, " + f"workers={parse_workers}" ) return rows, issues, entry_count, skipped_unchanged, seen_paths @@ -2152,6 +2395,8 @@ class AssetInventoryService: task_id: Optional[str] = None, progress_start: int = 84, progress_end: int = 96, + progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None, + progress_interval: int = 1, ) -> Dict[str, Any]: generated_session = db is None if generated_session: @@ -2218,6 +2463,16 @@ class AssetInventoryService: if limit and limit > 0: candidates = candidates[: int(limit)] summary["candidate_count"] = len(candidates) + if progress_callback: + progress_callback( + { + "event": "planned", + "records_seen": summary["records_seen"], + "candidate_count": summary["candidate_count"], + "skipped_ready": summary["skipped_ready"], + "families": target_families, + } + ) if not apply: return summary if not candidates: @@ -2226,6 +2481,8 @@ class AssetInventoryService: f"Archive preview cache already ready: skipped={summary['skipped_ready']}", progress_end, ) + if progress_callback: + progress_callback({"event": "completed", **summary}) return summary await self._progress( @@ -2235,6 +2492,7 @@ class AssetInventoryService: ) thumb_size = (settings.RADAR_THUMBNAIL_MAX_SIZE, settings.RADAR_THUMBNAIL_MAX_SIZE) total = len(candidates) + progress_interval = max(1, int(progress_interval or 1)) for index, record in enumerate(candidates, start=1): unique_id = record.unique_id or record.file_path raw_cache_path = DataService.get_radar_raw_cache_path(unique_id, record.file_path) @@ -2277,10 +2535,7 @@ class AssetInventoryService: await task_service.add_log(task_id, "ERROR", f"Preview geometry invalid: {product_name}: {record.preview_cache_error}") db.add(record) else: - source_corner_mapping = await asyncio.to_thread( - DataService.get_radar_source_corner_mapping, - record.file_path, - ) + source_corner_mapping = DataService.get_radar_record_corner_mapping(record) ok_geo, geo_error = await asyncio.to_thread( image_service.create_geocorrected_radar_cached_image, preview_source, @@ -2327,6 +2582,31 @@ class AssetInventoryService: f"Building archive preview cache ({index}/{total}): ready={summary['ready']}, failed={summary['failed']}, missing={summary['missing_source']}", min(progress_end, progress), ) + if progress_callback and ( + index == 1 + or index == total + or index % progress_interval == 0 + or (record.preview_cache_status or "").upper() == "FAILED" + ): + progress_callback( + { + "event": "item", + "processed": index, + "total": total, + "records_seen": summary["records_seen"], + "candidate_count": summary["candidate_count"], + "skipped_ready": summary["skipped_ready"], + "ready": summary["ready"], + "cached": summary["cached"], + "failed": summary["failed"], + "missing_source": summary["missing_source"], + "raw_cached": summary["raw_cached"], + "raw_failed": summary["raw_failed"], + "product_name": product_name, + "status": record.preview_cache_status, + "error": record.preview_cache_error, + } + ) await db.commit() await self._progress( @@ -2338,6 +2618,8 @@ class AssetInventoryService: ), progress_end, ) + if progress_callback: + progress_callback({"event": "completed", **summary}) return summary except Exception: if db is not None: @@ -2748,6 +3030,109 @@ class AssetInventoryService: loop = asyncio.get_running_loop() progress_callback, log_callback, drain_thread_events = self._thread_callbacks(task_id, loop) + batch_write_start = max(progress_start, progress_end - 10) + batch_write_end = max(batch_write_start, progress_end - 3) + persisted_changed_count = 0 + + async def _upsert_source_asset_batch(rows_batch: Sequence[Dict[str, Any]], *, batch_index: int) -> int: + if not rows_batch: + return 0 + now = _utcnow() + db_rows = [ + {key: value for key, value in row.items() if not str(key).startswith("_")} + for row in rows_batch + ] + for row in db_rows: + stmt = pg_insert(SourceProductAssetORM).values(row) + excluded = stmt.excluded + stmt = stmt.on_conflict_do_update( + index_elements=["file_path"], + set_={ + "asset_uid": excluded.asset_uid, + "logical_product_uid": excluded.logical_product_uid, + "satellite_family": excluded.satellite_family, + "satellite": excluded.satellite, + "source_format": excluded.source_format, + "product_type": excluded.product_type, + "product_level": excluded.product_level, + "imaging_mode": excluded.imaging_mode, + "polarization": excluded.polarization, + "absolute_orbit": excluded.absolute_orbit, + "relative_orbit": excluded.relative_orbit, + "orbit_direction": excluded.orbit_direction, + "acquisition_start_time_utc": excluded.acquisition_start_time_utc, + "acquisition_stop_time_utc": excluded.acquisition_stop_time_utc, + "imaging_date": excluded.imaging_date, + "root_ref_id": excluded.root_ref_id, + "root_path": excluded.root_path, + "archive_path": excluded.archive_path, + "path_kind": excluded.path_kind, + "file_name": excluded.file_name, + "file_stem": excluded.file_stem, + "file_ext": excluded.file_ext, + "size_bytes": excluded.size_bytes, + "mtime_epoch": excluded.mtime_epoch, + "checksum_status": excluded.checksum_status, + "archive_integrity_status": "NOT_CHECKED", + "archive_integrity_method": None, + "archive_integrity_checked_at": None, + "archive_integrity_error": None, + "archive_integrity_version": None, + "archive_integrity_member_count": None, + "parser_name": excluded.parser_name, + "parser_version": excluded.parser_version, + "parse_status": excluded.parse_status, + "parse_error": excluded.parse_error, + "parsed_at": excluded.parsed_at, + "metadata_json": excluded.metadata_json, + "is_active": True, + "missing_since": None, + "updated_at": now, + }, + ) + await db.execute(stmt) + await db.flush() + + changed_paths = [str(row["file_path"]) for row in rows_batch if row.get("file_path")] + asset_ids_by_path: Dict[str, int] = {} + if changed_paths: + result = await db.execute( + select(SourceProductAssetORM.file_path, SourceProductAssetORM.id).where( + SourceProductAssetORM.file_path.in_(changed_paths) + ) + ) + asset_ids_by_path = {str(path): int(asset_id) for path, asset_id in result.all()} + await self._upsert_metadata_documents_for_source_assets(db, rows_batch, asset_ids_by_path) + await self._upsert_radar_records_for_source_assets(db, rows_batch, asset_ids_by_path) + + await db.commit() + if task_id: + await task_service.add_log( + task_id, + "INFO", + f"Source asset DB batch committed: batch={batch_index}, rows={len(rows_batch)}", + ) + await self._progress( + task_id, + f"Committed source asset DB batch {batch_index}: rows={len(rows_batch)}", + batch_write_start, + ) + return len(rows_batch) + + batch_index = 0 + + def _persist_row_batch(rows_batch: List[Dict[str, Any]]) -> None: + nonlocal batch_index, persisted_changed_count + if not rows_batch: + return + batch_index += 1 + future = asyncio.run_coroutine_threadsafe( + _upsert_source_asset_batch(rows_batch, batch_index=batch_index), + loop, + ) + persisted = int(future.result()) + persisted_changed_count += persisted + rows, issues, entry_count, skipped_unchanged, seen_paths = await asyncio.to_thread( _collect_source_assets_incremental, root, @@ -2756,104 +3141,24 @@ class AssetInventoryService: log_callback=log_callback, progress_start=progress_start, progress_end=max(progress_start + 1, progress_end - 8), + parse_workers=settings.ASSET_SCAN_PARSE_WORKERS, + parse_inflight=settings.ASSET_SCAN_PARSE_INFLIGHT, + skip_unchanged_failures=settings.ASSET_SCAN_SKIP_UNCHANGED_FAILURES, + row_batch_callback=_persist_row_batch if task_id else None, + row_batch_size=settings.ASSET_SCAN_DB_BATCH_SIZE, ) await drain_thread_events() - changed_paths = [row["file_path"] for row in rows] + if not task_id and rows: + persisted_changed_count += await _upsert_source_asset_batch(rows, batch_index=1) now = _utcnow() - write_start = max(progress_start, progress_end - 7) - write_end = max(write_start, progress_end - 3) - await self._progress( - task_id, - f"Writing source asset index: changed_or_new={len(rows)}, skipped={skipped_unchanged}", - write_start, - ) - db_rows = [ - {key: value for key, value in row.items() if not str(key).startswith("_")} - for row in rows - ] if task_id: await task_service.add_log( task_id, "INFO", - f"Source asset DB upsert started: changed_or_new={len(rows)}, skipped={skipped_unchanged}, seen={len(seen_paths)}", + "Source asset DB batch upsert finished: " + f"changed_or_new={len(rows)}, persisted={persisted_changed_count}, " + f"batches={batch_index}, skipped={skipped_unchanged}, seen={len(seen_paths)}", ) - for index, row in enumerate(db_rows, start=1): - stmt = pg_insert(SourceProductAssetORM).values(row) - excluded = stmt.excluded - stmt = stmt.on_conflict_do_update( - index_elements=["file_path"], - set_={ - "asset_uid": excluded.asset_uid, - "logical_product_uid": excluded.logical_product_uid, - "satellite_family": excluded.satellite_family, - "satellite": excluded.satellite, - "source_format": excluded.source_format, - "product_type": excluded.product_type, - "product_level": excluded.product_level, - "imaging_mode": excluded.imaging_mode, - "polarization": excluded.polarization, - "absolute_orbit": excluded.absolute_orbit, - "relative_orbit": excluded.relative_orbit, - "orbit_direction": excluded.orbit_direction, - "acquisition_start_time_utc": excluded.acquisition_start_time_utc, - "acquisition_stop_time_utc": excluded.acquisition_stop_time_utc, - "imaging_date": excluded.imaging_date, - "root_ref_id": excluded.root_ref_id, - "root_path": excluded.root_path, - "archive_path": excluded.archive_path, - "path_kind": excluded.path_kind, - "file_name": excluded.file_name, - "file_stem": excluded.file_stem, - "file_ext": excluded.file_ext, - "size_bytes": excluded.size_bytes, - "mtime_epoch": excluded.mtime_epoch, - "checksum_status": excluded.checksum_status, - "archive_integrity_status": "NOT_CHECKED", - "archive_integrity_method": None, - "archive_integrity_checked_at": None, - "archive_integrity_error": None, - "archive_integrity_version": None, - "archive_integrity_member_count": None, - "parser_name": excluded.parser_name, - "parser_version": excluded.parser_version, - "parse_status": excluded.parse_status, - "parse_error": excluded.parse_error, - "parsed_at": excluded.parsed_at, - "metadata_json": excluded.metadata_json, - "is_active": True, - "missing_since": None, - "updated_at": now, - }, - ) - await db.execute(stmt) - if index % ASSET_SCAN_LOG_INTERVAL == 0 or index == len(rows): - progress_value = write_start - if rows and write_end > write_start: - progress_value = write_start + int(index / max(1, len(rows)) * (write_end - write_start)) - await self._progress( - task_id, - f"Writing source asset index: {index}/{len(rows)} changed_or_new, skipped={skipped_unchanged}", - progress_value, - ) - await db.flush() - - asset_ids_by_path: Dict[str, int] = {} - if changed_paths: - await self._progress( - task_id, - f"Updating radar scene records for changed source assets: {len(changed_paths)}", - max(write_end, progress_end - 2), - ) - result = await db.execute( - select(SourceProductAssetORM.file_path, SourceProductAssetORM.id).where( - SourceProductAssetORM.file_path.in_(changed_paths) - ) - ) - asset_ids_by_path = {str(path): int(asset_id) for path, asset_id in result.all()} - await self._upsert_metadata_documents_for_source_assets(db, rows, asset_ids_by_path) - await self._upsert_radar_records_for_source_assets(db, rows, asset_ids_by_path) - elif task_id: - await task_service.add_log(task_id, "INFO", "No changed source assets; radar scene record update skipped.") await self._progress(task_id, "Marking missing source assets and refreshing scan issues...", max(progress_end - 2, progress_start)) await self._mark_missing_source_assets(db, root, seen_paths, now) diff --git a/backend/app/services/data_service.py b/backend/app/services/data_service.py index 7afa8ab..37eb01a 100644 --- a/backend/app/services/data_service.py +++ b/backend/app/services/data_service.py @@ -644,6 +644,21 @@ class DataService: return corner_mapping return None + @staticmethod + def get_radar_record_corner_mapping(record: Any) -> Optional[Dict[str, Any]]: + record_state = getattr(record, "__dict__", {}) if record is not None else {} + for metadata in ( + getattr(record, "metadata_json", None), + getattr(record_state.get("source_product_asset"), "metadata_json", None), + getattr(record_state.get("source_archive_asset"), "metadata_json", None), + ): + if not isinstance(metadata, dict): + continue + corner_mapping = metadata.get("corner_pixel_mapping") + if isinstance(corner_mapping, dict): + return corner_mapping + return DataService.get_radar_source_corner_mapping(str(getattr(record, "file_path", "") or "")) + @staticmethod def _normalize_coverage_polygon(coverage_polygon: Any) -> Optional[List[Tuple[float, float]]]: if isinstance(coverage_polygon, list): @@ -1102,6 +1117,7 @@ class DataService: "max_lon": record.max_lon, "max_lat": record.max_lat, "preview_cache_version": record.preview_cache_version, + "source_corner_mapping": DataService.get_radar_record_corner_mapping(record), } ) @@ -1164,10 +1180,7 @@ class DataService: except (TypeError, ValueError): bbox = None - source_corner_mapping = await asyncio.to_thread( - DataService.get_radar_source_corner_mapping, - file_path, - ) + source_corner_mapping = item.get("source_corner_mapping") need_geo_rebuild = ( (not geo_cache_mtime) diff --git a/backend/app/services/dinsar_production_service.py b/backend/app/services/dinsar_production_service.py index b86d082..f4c92e9 100644 --- a/backend/app/services/dinsar_production_service.py +++ b/backend/app/services/dinsar_production_service.py @@ -35,6 +35,7 @@ from .workflow_service import workflow_service TASK_TYPE_DINSAR_PRODUCTION = "IDL_RUN_DINSAR" TASK_TYPE_PYINT_DINSAR_PRODUCTION = "PYINT_RUN" TASK_TYPE_LANDSAR_DINSAR_PRODUCTION = "LANDSAR_RUN" +TASK_TYPE_LANDSAR_CLUSTER_PRODUCTION = "LANDSAR_CLUSTER_RUN" RUN_STATUS_PENDING = "PENDING" RUN_STATUS_RUNNING = "RUNNING" RUN_STATUS_COMPLETED = "COMPLETED" @@ -775,6 +776,172 @@ class DinsarProductionService: "rerun_mode": selection["rerun_mode"], } + async def create_landsar_cluster_run( + self, + *, + profile_code: str, + root_dir: str, + num_to_process: int, + rerun_mode: Optional[str], + timeout_seconds: Optional[int], + extra: Optional[Dict[str, Any]], + created_by: Optional[str], + max_attempts: int, + db: AsyncSession, + ) -> Dict[str, Any]: + normalized_engine = "landsar" + normalized_profile = str(profile_code or "").strip() + normalized_root = _normalize_dir(root_dir, "root_dir") + selection = await asyncio.to_thread( + _select_run_items, + normalized_root, + engine_code=normalized_engine, + profile_code=normalized_profile, + num_to_process=max(0, int(num_to_process or 0)), + rerun_mode=rerun_mode, + ) + item_payloads = selection["items"] + if not item_payloads: + if ( + selection["discovered_task_count"] > 0 + and selection["skipped_completed_count"] > 0 + and selection["rerun_mode"] == RERUN_MODE_UNFINISHED_ONLY + ): + raise ValueError( + f"All discovered Task_* directories already have completed " + f"landsar/{normalized_profile} results under: {normalized_root}" + ) + raise ValueError(f"No Task_* directories found under: {normalized_root}") + + run_id = str(uuid.uuid4()) + mode = "cluster" + task_name = f"D-InSAR production: landsar/{normalized_profile} cluster" + task_params = { + "engine_code": normalized_engine, + "profile": normalized_profile, + "root_dir": normalized_root, + "num_to_process": int(num_to_process or 0), + "rerun_mode": selection["rerun_mode"], + "timeout_seconds": timeout_seconds, + "extra": dict(extra or {}), + "mode": mode, + "production_run_id": run_id, + "cluster_job_type": "LANDSAR_CLUSTER_ITEM", + } + + task_id: Optional[str] = None + created_items: List[DinsarProductionRunItemORM] = [] + try: + task_id = await task_service.create_task( + task_type=TASK_TYPE_LANDSAR_CLUSTER_PRODUCTION, + task_name=task_name, + params=task_params, + db=db, + ) + + run = DinsarProductionRunORM( + run_id=run_id, + task_id=task_id, + product_family="dinsar", + engine_code=normalized_engine, + profile_code=normalized_profile, + mode=mode, + source_root=normalized_root, + publish_root_dir=settings.DINSAR_PRODUCT_DIR, + status=RUN_STATUS_PENDING, + cancel_requested=False, + total_items=len(item_payloads), + completed_items=0, + failed_items=0, + skipped_items=0, + latest_message="Queued LandSAR cluster items", + params_json=task_params, + summary_json={ + "phase": "queued", + "selected_task_count": len(item_payloads), + "discovered_task_count": selection["discovered_task_count"], + "skipped_completed_count": selection["skipped_completed_count"], + "rerun_mode": selection["rerun_mode"], + "product_family": "dinsar", + "publish_root_dir": settings.DINSAR_PRODUCT_DIR, + "cluster_job_type": "LANDSAR_CLUSTER_ITEM", + }, + created_by=created_by, + ) + db.add(run) + await db.flush() + + for item_payload in item_payloads: + item = DinsarProductionRunItemORM( + run_id=run_id, + order_index=item_payload["order_index"], + task_name=item_payload["task_name"], + task_alias=item_payload["task_alias"], + pair_key=item_payload["pair_key"], + pair_uid=item_payload["pair_uid"], + network_run_id=item_payload["network_run_id"], + network_edge_id=item_payload["network_edge_id"], + policy_version=item_payload["policy_version"], + selection_strategy=item_payload["selection_strategy"], + source_task_dir=item_payload["source_task_dir"], + results_root_dir=item_payload["results_root_dir"], + status=RUN_ITEM_STATUS_PENDING, + ) + db.add(item) + created_items.append(item) + + await db.flush() + + from .job_queue_service import job_queue_service + + for item in created_items: + await job_queue_service.create_job( + "LANDSAR_CLUSTER_ITEM", + payload={ + "production_run_id": run_id, + "item_id": item.id, + }, + task_id=task_id, + priority=0, + max_attempts=max(1, int(max_attempts or 1)), + db=db, + ) + + await db.commit() + await db.refresh(run) + except Exception as exc: + await db.rollback() + if task_id: + try: + await task_service.update_task( + task_id, + status="FAILED", + message=f"Failed to create LandSAR cluster run: {exc}", + ) + except Exception: + pass + raise + + await asyncio.to_thread( + _append_run_log_sync, + run_id, + ( + f"[cluster-queued] run_id={run_id} profile={normalized_profile} root={normalized_root} " + f"items={len(item_payloads)} rerun_mode={selection['rerun_mode']} " + f"skipped_completed={selection['skipped_completed_count']}" + ), + ) + return { + "run_id": run_id, + "task_id": task_id, + "workflow_run_id": None, + "status": run.status, + "selected_task_count": len(item_payloads), + "discovered_task_count": selection["discovered_task_count"], + "skipped_completed_count": selection["skipped_completed_count"], + "rerun_mode": selection["rerun_mode"], + } + async def list_runs( self, db: AsyncSession, @@ -923,6 +1090,80 @@ class DinsarProductionService: run.latest_message = latest_message return run + async def finalize_cluster_run_if_complete( + self, + run: DinsarProductionRunORM, + *, + db: AsyncSession, + publish_error: Optional[str] = None, + ) -> Optional[str]: + rows = await db.execute( + select(DinsarProductionRunItemORM.status, func.count(DinsarProductionRunItemORM.id)) + .where(DinsarProductionRunItemORM.run_id == run.run_id) + .group_by(DinsarProductionRunItemORM.status) + ) + counts = {str(status or "").upper(): int(count or 0) for status, count in rows.fetchall()} + total_items = int(run.total_items or 0) + completed = counts.get(RUN_ITEM_STATUS_COMPLETED, 0) + failed = counts.get(RUN_ITEM_STATUS_FAILED, 0) + skipped = counts.get(RUN_ITEM_STATUS_SKIPPED, 0) + cancelled = counts.get(RUN_ITEM_STATUS_CANCELLED, 0) + terminal_count = completed + failed + skipped + cancelled + + run.completed_items = completed + run.failed_items = failed + run.skipped_items = skipped + if total_items <= 0 or terminal_count < total_items: + run.status = RUN_STATUS_RUNNING + run.latest_message = ( + f"LandSAR cluster running: completed={completed} failed={failed} " + f"skipped={skipped} cancelled={cancelled} total={total_items}" + ) + await db.commit() + return None + + if publish_error: + final_status = RUN_STATUS_FAILED + latest_message = f"LandSAR cluster publish failed: {publish_error}" + elif cancelled > 0 or bool(run.cancel_requested): + final_status = RUN_STATUS_CANCELLED + latest_message = ( + f"LandSAR cluster cancelled. completed={completed} failed={failed} " + f"cancelled={cancelled} total={total_items}" + ) + elif failed > 0: + final_status = RUN_STATUS_FAILED + latest_message = ( + f"LandSAR cluster finished with failures. completed={completed} " + f"failed={failed} total={total_items}" + ) + else: + final_status = RUN_STATUS_COMPLETED + latest_message = ( + f"LandSAR cluster completed. completed={completed} failed={failed} total={total_items}" + ) + + summary_payload = dict(run.summary_json or {}) + summary_payload.update( + { + "phase": "completed", + "cluster": True, + "total_items": total_items, + "completed_items": completed, + "failed_items": failed, + "skipped_items": skipped, + "cancelled_items": cancelled, + "publish_error": publish_error, + } + ) + run.status = final_status + run.summary_json = summary_payload + run.latest_message = latest_message + run.ended_at = _utcnow() + await db.commit() + await asyncio.to_thread(_append_run_log_sync, run.run_id, f"[cluster-finish] status={final_status} message={latest_message}") + return final_status + async def mark_run_started( self, run: DinsarProductionRunORM, diff --git a/backend/app/services/health_service.py b/backend/app/services/health_service.py index 5c7753e..fa2670d 100644 --- a/backend/app/services/health_service.py +++ b/backend/app/services/health_service.py @@ -1136,7 +1136,7 @@ async def _check_product_packages() -> Dict[str, Any]: status["missing_publish_dir_count"] += 1 if not str(processor_code or "").strip(): status["missing_processor_count"] += 1 - if engine_key in {"isce2", "pyint", "gamma"} and not str(runtime_id or "").strip(): + if engine_key in {"pyint", "gamma"} and not str(runtime_id or "").strip(): status["missing_runtime_count"] += 1 if not str(native_output_dir or "").strip(): status["missing_native_output_count"] += 1 @@ -1386,7 +1386,7 @@ async def _check_wsl_runtime() -> Dict[str, Any]: } try: required_by_engine = { - "isce2": bool(settings.ISCE2_ENABLED or settings.TIMESERIES_ENABLED), + "isce2": bool(settings.ISCE2_ENABLED), "pyint": bool(settings.PYINT_ENABLED), "gamma": bool(settings.GAMMA_SBAS_ENABLED), } diff --git a/backend/app/services/job_handlers.py b/backend/app/services/job_handlers.py index 4e0e6b1..cb0fc2a 100644 --- a/backend/app/services/job_handlers.py +++ b/backend/app/services/job_handlers.py @@ -19,7 +19,7 @@ from sqlalchemy import select from .. import database from ..config import settings, split_env_paths -from ..models import SystemJobORM, DinsarResultORM, HazardPointORM, DinsarTaskItemORM, PsTaskItemORM, RadarDataORM, SARSceneGeoORM, FloodDetectionORM, WaterDetectionORM, WaterExtractionORM, GF3ProcessingORM, AiDiagnosisORM +from ..models import SystemJobORM, DinsarResultORM, HazardPointORM, DinsarTaskItemORM, PsTaskItemORM, RadarDataORM, SARSceneGeoORM, FloodDetectionORM, WaterDetectionORM, WaterExtractionORM, GF3ProcessingORM, AiDiagnosisORM, DinsarProductionRunItemORM from ..scheduler import scan_data_job from .data_service import data_service from .asset_inventory_service import asset_inventory_service @@ -93,6 +93,7 @@ JOB_TYPE_GF3_SARSCAPE_CLEAN = "GF3_SARSCAPE_CLEAN" JOB_TYPE_ISCE2_RUN = "ISCE2_RUN" JOB_TYPE_PYINT_RUN = "PYINT_RUN" JOB_TYPE_LANDSAR_RUN = "LANDSAR_RUN" +JOB_TYPE_LANDSAR_CLUSTER_ITEM = "LANDSAR_CLUSTER_ITEM" JOB_TYPE_PUBLISH_DINSAR_PRODUCTS = "PUBLISH_DINSAR_PRODUCTS" JOB_TYPE_REBUILD_DINSAR_CATALOG = "REBUILD_DINSAR_CATALOG" JOB_TYPE_REBUILD_PSINSAR_CATALOG = "REBUILD_PSINSAR_CATALOG" @@ -107,6 +108,15 @@ JOB_TYPE_SBAS_GAMMA_WORKFLOW = "SBAS_GAMMA_WORKFLOW" JOB_TYPE_SBAS_LANDSAR_WORKFLOW = "SBAS_LANDSAR_WORKFLOW" COPY_ALLOWED_STATUSES = {"PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"} +_LOCAL_ENGINE_LOCKS: Dict[str, asyncio.Lock] = {} + + +def _local_engine_lock(name: str) -> asyncio.Lock: + lock = _LOCAL_ENGINE_LOCKS.get(name) + if lock is None: + lock = asyncio.Lock() + _LOCAL_ENGINE_LOCKS[name] = lock + return lock def AsyncSessionLocal(): @@ -1997,6 +2007,15 @@ async def _run_dinsar_production_controller(job: SystemJobORM) -> None: ) except Exception as exc: publish_error = str(exc) + item.status = "FAILED" + item.current_step = "publish_failed" + item.last_error = publish_error + await dinsar_production_service.refresh_run_counters( + run, + db=db, + latest_message=f"Publish failed {item_label}: {publish_error}", + ) + await db.commit() await task_service.add_log( job.task_id, "WARNING", @@ -3209,6 +3228,357 @@ async def _handle_landsar_run(job: SystemJobORM) -> None: ) +async def _handle_landsar_cluster_item(job: SystemJobORM) -> None: + payload = job.payload or {} + production_run_id = str(payload.get("production_run_id") or "").strip() + item_id = _normalize_positive_int(payload.get("item_id")) + if not production_run_id or not item_id: + raise ValueError("LANDSAR_CLUSTER_ITEM requires production_run_id and item_id.") + + from ..dinsar_engines import registry + from ..dinsar_engines.base import RunRequest + + engine = registry.get_engine("landsar") + if engine is None: + raise RuntimeError("LandSAR engine is not registered on this worker.") + engine_title = "LandSAR" + per_task_timeout = int(getattr(settings, "LANDSAR_DINSAR_TIMEOUT_SECONDS", 0) or 43200) + + async with AsyncSessionLocal() as db: + run = await dinsar_production_service.get_run(production_run_id, db) + if run is None: + raise ValueError(f"LandSAR cluster run not found: {production_run_id}") + + item = await db.get(DinsarProductionRunItemORM, int(item_id)) + if item is None or item.run_id != run.run_id: + raise ValueError(f"LandSAR cluster item not found: {item_id}") + + await db.refresh(item) + item_status = str(item.status or "").strip().upper() + if item_status in {"COMPLETED", "FAILED", "SKIPPED", "CANCELLED"}: + await dinsar_production_service.finalize_cluster_run_if_complete(run, db=db) + return + + current_task = await task_service.get_task(job.task_id) + task_cancelled = bool(current_task and current_task.status == "CANCELLED") + if bool(run.cancel_requested) or task_cancelled: + run.cancel_requested = True + item.status = "CANCELLED" + item.current_step = "cancelled" + item.last_error = "Cancelled before worker execution." + await dinsar_production_service.refresh_run_counters(run, db=db, latest_message=item.last_error) + await db.commit() + await dinsar_production_service.finalize_cluster_run_if_complete(run, db=db) + return + + if str(run.status or "").strip().upper() == "PENDING": + await task_service.start_task( + job.task_id, + message=f"Starting LandSAR cluster run {run.run_id} ({run.total_items} items)...", + ) + await dinsar_production_service.mark_run_started( + run, + db=db, + message=f"LandSAR cluster started. total={run.total_items}", + ) + else: + await task_service.update_task( + job.task_id, + status="RUNNING", + message=f"LandSAR cluster running. item={item.task_alias or item.task_name}", + ) + + params = run.params_json or {} + user_extra = dict(params.get("extra") or {}) + timeout_seconds_raw = params.get("timeout_seconds") + if timeout_seconds_raw not in (None, ""): + per_task_timeout = int(timeout_seconds_raw) + + total_items = max(1, int(run.total_items or 1)) + item_index = max(1, int(item.order_index or 1)) + item_label = item.task_alias or item.task_name + run_key = f"{build_run_key('landsar', run.profile_code, started_at=datetime.utcnow())}_{item.id}_{uuid.uuid4().hex[:6]}" + execution = await dinsar_production_service.begin_item_execution( + run=run, + item=item, + run_key=run_key, + db=db, + ) + + managed_run_dir = os.path.normpath(execution.output_dir) + landsar_work_root = str(getattr(settings, "LANDSAR_WORK_ROOT", "") or "").strip() + if landsar_work_root: + managed_native_output_dir = os.path.normpath(os.path.join(landsar_work_root, run_key, "native")) + else: + managed_native_output_dir = os.path.join(managed_run_dir, "native") + managed_work_dir = os.path.join(managed_native_output_dir, "workflow") + managed_export_dir = os.path.join(managed_native_output_dir, "export") + managed_orbit_output_dir = os.path.join(managed_work_dir, "orbits") + base_progress = min(95, 5 + int(((item_index - 1) / total_items) * 90)) + progress_state: Dict[str, Any] = { + "progress": base_progress, + "message": f"[landsar/cluster] Running {item_index}/{total_items}: {item_label}", + "started_monotonic": time.monotonic(), + } + progress_queue: asyncio.Queue[Optional[Dict[str, Any]]] = asyncio.Queue() + loop = asyncio.get_running_loop() + + def _emit_progress(event: Dict[str, Any]) -> None: + if not event: + return + try: + loop.call_soon_threadsafe(progress_queue.put_nowait, dict(event)) + except RuntimeError: + return + + async def _consume_progress() -> None: + while True: + event = await progress_queue.get() + if event is None: + return + event_type = str(event.get("event") or "").strip().lower() + if event_type == "log": + level = str(event.get("level") or "INFO").strip().upper() + if level not in {"DEBUG", "INFO", "WARNING", "ERROR"}: + level = "INFO" + source = str(event.get("source") or "").strip() + message = str(event.get("message") or "").strip() + if message: + prefix = f"[cluster {item_index}/{total_items}] {engine_title} {item_label}" + if source: + prefix = f"{prefix} {source}" + await task_service.add_log(job.task_id, level, f"{prefix}: {message}") + elif event_type == "pair_started": + progress_state["message"] = f"[landsar/cluster] Running {item_index}/{total_items}: {item_label}" + progress_state["started_monotonic"] = time.monotonic() + await task_service.add_log( + job.task_id, + "INFO", + f"[cluster {item_index}/{total_items}] {engine_title} started {item_label}", + ) + elif event_type == "pair_finished": + if bool(event.get("success")): + progress_state["progress"] = min(98, 5 + int((item_index / total_items) * 90)) + progress_state["message"] = f"[landsar/cluster] Finished {item_index}/{total_items}: {item_label}" + else: + progress_state["message"] = f"[landsar/cluster] Failed {item_index}/{total_items}: {item_label}" + + async def _task_keepalive() -> None: + while True: + await asyncio.sleep(30) + try: + message = str(progress_state.get("message") or "") + started_monotonic = progress_state.get("started_monotonic") + if isinstance(started_monotonic, (int, float)): + elapsed_seconds = max(0, int(time.monotonic() - float(started_monotonic))) + message = f"{message} (elapsed={elapsed_seconds}s)" + await task_service.update_task( + job.task_id, + status="RUNNING", + progress=int(progress_state.get("progress") or base_progress), + message=message, + ) + except Exception as exc: + logger.warning("LandSAR cluster keepalive failed for item %s: %s", item_label, exc) + + progress_task = asyncio.create_task(_consume_progress()) + keepalive_task = asyncio.create_task(_task_keepalive()) + task_result: Dict[str, Any] = {} + result = None + run_exception_text = "" + + await task_service.add_log( + job.task_id, + "INFO", + f"[cluster {item_index}/{total_items}] Launching {item_label} -> {managed_run_dir}", + ) + dinsar_production_service.append_run_log( + run.run_id, + f"[cluster-item-start] {item_index}/{total_items} {item_label} run_key={run_key} output={managed_run_dir}", + ) + + request = RunRequest( + engine_code="landsar", + profile=run.profile_code, + root_dir=str(item.source_task_dir), + job_id=job.job_id, + num_to_process=1, + timeout_seconds=per_task_timeout or None, + extra={ + **user_extra, + "__managed_run_dir": managed_run_dir, + "__managed_native_output_dir": managed_native_output_dir, + "__managed_work_dir": managed_work_dir, + "__managed_export_dir": managed_export_dir, + "__managed_orbit_output_dir": managed_orbit_output_dir, + "__managed_run_key": run_key, + "__source_root_override": run.source_root, + "__rerun_mode": "rerun_all", + "__cluster_item": True, + }, + progress_callback=_emit_progress, + ) + + publish_error: Optional[str] = None + item_error: Optional[str] = None + try: + availability = await asyncio.to_thread(engine.check_available) + if not availability.available: + raise RuntimeError(f"LandSAR engine is unavailable on worker: {availability.message}") + + async with _local_engine_lock("landsar"): + result = await asyncio.to_thread(engine.run, request) + + detail = result.detail or {} if result else {} + task_result = ((detail.get("task_results") or [{}])[0]) if result else {} + result_error = str(result.error or "").strip() if result else "" + result_success = bool(result.success) if result else False + if not result or not result_success or not bool(task_result.get("success", result_success)): + error_message = ( + str(task_result.get("error") or "").strip() + or result_error + or run_exception_text + or str(task_result.get("stderr_tail") or "").strip() + or "LandSAR cluster item failed." + ) + raise RuntimeError(error_message) + + run_dir = os.path.normpath( + str(task_result.get("run_dir") or task_result.get("output_dir") or execution.output_dir) + ) + if run_dir != managed_run_dir: + raise RuntimeError(f"LandSAR managed run dir mismatch: expected {managed_run_dir}, got {run_dir}") + + primary_file = str(task_result.get("primary_file") or "").strip() + source_files = [ + str(path) + for path in (task_result.get("source_files") or []) + if str(path or "").strip() + ] + native_output_dir = str(task_result.get("native_output_dir") or managed_native_output_dir).strip() or managed_native_output_dir + if not primary_file or not os.path.isfile(primary_file): + raise RuntimeError(f"LandSAR primary output is missing: {primary_file or ''}") + if not source_files: + source_files = [primary_file] + + metrics = {"result_detail": detail, "task_result": task_result, "cluster_item": True} + manifest_path = await asyncio.to_thread( + dinsar_production_service.build_execution_manifest, + run=run, + item=item, + execution=execution, + primary_file=primary_file, + source_files=source_files, + native_output_dir=native_output_dir, + metrics=metrics, + ) + await asyncio.to_thread( + dinsar_production_service.write_current_pointer, + run=run, + item=item, + execution=execution, + manifest_path=manifest_path, + primary_file=primary_file, + source_files=source_files, + native_output_dir=native_output_dir, + ) + await dinsar_production_service.mark_item_completed( + run=run, + item=item, + execution=execution, + manifest_path=manifest_path, + metrics=metrics, + db=db, + ) + + try: + publish_result = await result_catalog_service.publish_from_sources(db, [managed_run_dir]) + processed_count = int(publish_result.get("processed", 0) or 0) + failed_count = int(publish_result.get("failed", 0) or 0) + if processed_count > 0: + await result_catalog_service.rebuild_catalog(db, full_rebuild=True) + if processed_count != 1 or failed_count != 0: + raise RuntimeError(f"expected processed=1 failed=0, got processed={processed_count} failed={failed_count}") + await task_service.add_log( + job.task_id, + "INFO", + f"[cluster {item_index}/{total_items}] Published {item_label}", + ) + except Exception as exc: + publish_error = str(exc) + await task_service.add_log( + job.task_id, + "WARNING", + f"[cluster {item_index}/{total_items}] Result catalog publish failed for {item_label}: {publish_error}", + ) + + await task_service.add_log( + job.task_id, + "INFO", + f"[cluster {item_index}/{total_items}] Completed {item_label}", + ) + dinsar_production_service.append_run_log(run.run_id, f"[cluster-item-ok] {item_index}/{total_items} {item_label}") + except Exception as exc: + run_exception_text = str(exc) + item_error = run_exception_text + await dinsar_production_service.mark_item_failed( + run=run, + item=item, + execution=execution, + error_message=item_error, + db=db, + ) + await task_service.add_log( + job.task_id, + "WARNING", + f"[cluster {item_index}/{total_items}] Failed {item_label}: {item_error}", + ) + dinsar_production_service.append_run_log( + run.run_id, + f"[cluster-item-failed] {item_index}/{total_items} {item_label}: {item_error}", + ) + if task_result.get("command"): + await task_service.add_log(job.task_id, "INFO", f"LandSAR command [{item_label}]: {task_result.get('command')}") + if task_result.get("stdout_tail"): + await task_service.add_log(job.task_id, "INFO", f"LandSAR stdout tail [{item_label}]:\n{task_result.get('stdout_tail')}") + if task_result.get("stderr_tail"): + await task_service.add_log(job.task_id, "WARNING", f"LandSAR stderr tail [{item_label}]:\n{task_result.get('stderr_tail')}") + finally: + keepalive_task.cancel() + try: + await keepalive_task + except asyncio.CancelledError: + pass + await progress_queue.put(None) + await progress_task + + await db.refresh(run) + await dinsar_production_service.refresh_run_counters(run, db=db) + done_items = int(run.completed_items or 0) + int(run.failed_items or 0) + int(run.skipped_items or 0) + progress = min(99, 5 + int((done_items / max(1, int(run.total_items or 1))) * 90)) + final_status = await dinsar_production_service.finalize_cluster_run_if_complete( + run, + db=db, + publish_error=publish_error, + ) + + if final_status: + task_status = "COMPLETED" if final_status == "COMPLETED" else ("CANCELLED" if final_status == "CANCELLED" else "FAILED") + await task_service.update_task( + job.task_id, + status=task_status, + progress=100, + message=run.latest_message, + ) + else: + await task_service.update_task( + job.task_id, + status="RUNNING", + progress=progress, + message=f"LandSAR cluster progress: completed={run.completed_items} failed={run.failed_items} total={run.total_items}", + ) + + async def _handle_water_geocode(job: SystemJobORM) -> None: """单景 SAR 地理编码 job handler(多视 + 地理编码 + 辐射定标)。""" from .water_service import run_geocoding_workflow, WATER_RESULTS_DIR @@ -5259,6 +5629,7 @@ _HANDLERS = { JOB_TYPE_ISCE2_RUN: _handle_isce2_run, JOB_TYPE_PYINT_RUN: _handle_pyint_run, JOB_TYPE_LANDSAR_RUN: _handle_landsar_run, + JOB_TYPE_LANDSAR_CLUSTER_ITEM: _handle_landsar_cluster_item, JOB_TYPE_WATER_GEOCODE: _handle_water_geocode, JOB_TYPE_SAR_SCENE_PREPROCESS: _handle_sar_scene_preprocess, JOB_TYPE_WATER_FLOOD: _handle_water_flood, diff --git a/backend/app/services/job_queue_service.py b/backend/app/services/job_queue_service.py index 5c24c43..86352d8 100644 --- a/backend/app/services/job_queue_service.py +++ b/backend/app/services/job_queue_service.py @@ -2,7 +2,7 @@ import asyncio import json import uuid from datetime import datetime, timedelta, timezone -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Sequence from sqlalchemy import and_, or_, select, update, text from sqlalchemy.ext.asyncio import AsyncSession @@ -183,6 +183,7 @@ class JobQueueService: self, worker_id: str, lock_timeout_seconds: int = 1800, + allowed_job_types: Optional[Sequence[str]] = None, db: Optional[AsyncSession] = None, ) -> Optional[SystemJobORM]: gen_db = db is None @@ -191,14 +192,30 @@ class JobQueueService: try: lock_timeout_seconds = int(lock_timeout_seconds) + normalized_allowed = [ + _normalize_job_type(job_type) + for job_type in (allowed_job_types or []) + if str(job_type or "").strip() + ] + normalized_allowed = list(dict.fromkeys(normalized_allowed)) + allowed_clause = "" + params: Dict[str, Any] = {"lock_timeout_seconds": lock_timeout_seconds} + if normalized_allowed: + allowed_params = [] + for index, job_type in enumerate(normalized_allowed): + key = f"allowed_job_type_{index}" + allowed_params.append(f":{key}") + params[key] = job_type + allowed_clause = f"AND job_type IN ({', '.join(allowed_params)})" async with db.begin(): result = await db.execute( text( - """ + f""" SELECT id FROM system_jobs WHERE status IN ('READY', 'RETRY') AND (next_run_at IS NULL OR next_run_at <= NOW()) + {allowed_clause} AND ( locked_by IS NULL OR locked_at IS NULL OR @@ -209,7 +226,7 @@ class JobQueueService: LIMIT 1 """ ), - {"lock_timeout_seconds": lock_timeout_seconds}, + params, ) row = result.first() if not row: diff --git a/backend/app/services/job_worker.py b/backend/app/services/job_worker.py index e24b28b..57ac57e 100644 --- a/backend/app/services/job_worker.py +++ b/backend/app/services/job_worker.py @@ -3,7 +3,7 @@ import os import socket import uuid import time -from typing import Set +from typing import Optional, Set from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy import func @@ -26,6 +26,15 @@ IDL_JOB_TYPES = { } +def _parse_allowed_job_types(raw_value: str) -> Optional[Set[str]]: + values = { + part.strip().upper() + for part in str(raw_value or "").replace(";", ",").split(",") + if part.strip() + } + return values or None + + def _default_worker_id() -> str: host = socket.gethostname() pid = os.getpid() @@ -129,6 +138,7 @@ async def run_worker_loop( concurrency = max(1, int(concurrency)) sem = asyncio.Semaphore(concurrency) active: Set[asyncio.Task] = set() + allowed_job_types = _parse_allowed_job_types(getattr(settings, "JOB_WORKER_ALLOWED_TYPES", "")) job_heartbeat_interval = float(settings.JOB_WORKER_JOB_HEARTBEAT_INTERVAL) stale_recover_interval = float(settings.JOB_WORKER_STALE_RECOVER_INTERVAL) stale_running_seconds = int(settings.JOB_WORKER_STALE_RUNNING_SECONDS) @@ -187,7 +197,10 @@ async def run_worker_loop( print(f"[WARN] worker poll: {exc}") if len(active) < concurrency: - job = await job_queue_service.claim_next_job(worker_id) + job = await job_queue_service.claim_next_job( + worker_id, + allowed_job_types=allowed_job_types, + ) if job: task = asyncio.create_task(_wrap(job)) active.add(task) diff --git a/backend/app/utils.py b/backend/app/utils.py index ba7f7ed..a8c664e 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -95,6 +95,68 @@ def _ordered_closed_polygon_from_corner_details(corner_details: Dict[str, Dict[s return _ordered_closed_polygon([(value["lon"], value["lat"]) for value in (corner_details or {}).values()]) + +def build_corner_pixel_mapping(corner_details: Dict[str, Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if len(corner_details or {}) < 4: + return None + + entries: List[Tuple[str, Dict[str, Any]]] = [] + ref_rows = [] + ref_cols = [] + for name, info in (corner_details or {}).items(): + if info.get("lon") is None or info.get("lat") is None: + return None + if info.get("ref_row") is None or info.get("ref_col") is None: + return None + try: + ref_row = int(float(info["ref_row"])) + ref_col = int(float(info["ref_col"])) + except (TypeError, ValueError): + return None + normalized = dict(info) + normalized["ref_row"] = ref_row + normalized["ref_col"] = ref_col + entries.append((str(name), normalized)) + ref_rows.append(ref_row) + ref_cols.append(ref_col) + + min_row, max_row = min(ref_rows), max(ref_rows) + min_col, max_col = min(ref_cols), max(ref_cols) + remaining = entries[:] + + def pick(target_row: int, target_col: int) -> Optional[Tuple[float, float]]: + if not remaining: + return None + ranked = [] + for index, (name, info) in enumerate(remaining): + row = int(info["ref_row"]) + col = int(info["ref_col"]) + score = abs(row - target_row) + abs(col - target_col) + ranked.append((score, abs(row - target_row), abs(col - target_col), name, index)) + ranked.sort() + chosen_index = ranked[0][4] + _, chosen = remaining.pop(chosen_index) + try: + return float(chosen["lon"]), float(chosen["lat"]) + except (TypeError, ValueError): + return None + + top_left = pick(min_row, min_col) + top_right = pick(min_row, max_col) + bottom_left = pick(max_row, min_col) + bottom_right = pick(max_row, max_col) + + if not all([top_left, top_right, bottom_left, bottom_right]): + return None + + return { + "top_left": [top_left[0], top_left[1]], + "top_right": [top_right[0], top_right[1]], + "bottom_left": [bottom_left[0], bottom_left[1]], + "bottom_right": [bottom_right[0], bottom_right[1]], + "source": "xml_ref_row_col", + } + # --- Sentinel-1 (S1A/S1B/S1C) Parsers --- def _radar_meta_base() -> Dict[str, Any]: @@ -446,54 +508,6 @@ def parse_xml_metadata( except (TypeError, ValueError): return None - def _build_corner_pixel_mapping(corner_details: Dict[str, Dict[str, Any]]) -> Optional[Dict[str, Any]]: - if len(corner_details) < 4: - return None - - ref_rows = [] - ref_cols = [] - for info in corner_details.values(): - if info.get("ref_row") is None or info.get("ref_col") is None: - return None - ref_rows.append(int(info["ref_row"])) - ref_cols.append(int(info["ref_col"])) - - min_row, max_row = min(ref_rows), max(ref_rows) - min_col, max_col = min(ref_cols), max(ref_cols) - remaining = set(corner_details.keys()) - - def pick(target_row: int, target_col: int) -> Optional[Tuple[float, float]]: - if not remaining: - return None - ranked = [] - for name in remaining: - info = corner_details[name] - row = int(info["ref_row"]) - col = int(info["ref_col"]) - score = abs(row - target_row) + abs(col - target_col) - ranked.append((score, abs(row - target_row), abs(col - target_col), name)) - ranked.sort() - chosen_name = ranked[0][3] - remaining.remove(chosen_name) - chosen = corner_details[chosen_name] - return float(chosen["lon"]), float(chosen["lat"]) - - top_left = pick(min_row, min_col) - top_right = pick(min_row, max_col) - bottom_left = pick(max_row, min_col) - bottom_right = pick(max_row, max_col) - - if not all([top_left, top_right, bottom_left, bottom_right]): - return None - - return { - "top_left": [top_left[0], top_left[1]], - "top_right": [top_right[0], top_right[1]], - "bottom_left": [bottom_left[0], bottom_left[1]], - "bottom_right": [bottom_right[0], bottom_right[1]], - "source": "xml_ref_row_col", - } - corners = ['bottomLeft', 'bottomRight', 'topRight', 'topLeft'] polygon = [] @@ -652,7 +666,7 @@ def parse_xml_metadata( if not ordered_polygon: return None, None polygon = ordered_polygon - corner_pixel_mapping = _build_corner_pixel_mapping(corner_details) + corner_pixel_mapping = build_corner_pixel_mapping(corner_details) meta = { "orbit_direction": orbit_direction, "imaging_mode": imaging_mode, diff --git a/config/landsar_cluster_worker.env.example b/config/landsar_cluster_worker.env.example new file mode 100644 index 0000000..7c2f091 --- /dev/null +++ b/config/landsar_cluster_worker.env.example @@ -0,0 +1,7 @@ +# Copy this file to .env on the remote LandSAR worker host 192.168.1.6. +# DATABASE_URL points to the main InSAR management server/PostgreSQL host 192.168.1.62. +DATABASE_URL=postgresql+asyncpg://postgres:WXZXzhb123456@192.168.1.62:5432/insar_management +JOB_WORKER_ALLOWED_TYPES=LANDSAR_CLUSTER_ITEM +JOB_WORKER_CONCURRENCY=1 +JOB_WORKER_POLL_INTERVAL=1.0 +LANDSAR_CLUSTER_WORKER_ID= diff --git a/docs/FRONTEND_PRODUCTION_UI_REFINEMENT.md b/docs/FRONTEND_PRODUCTION_UI_REFINEMENT.md new file mode 100644 index 0000000..3a4a1c7 --- /dev/null +++ b/docs/FRONTEND_PRODUCTION_UI_REFINEMENT.md @@ -0,0 +1,135 @@ +# Frontend Production UI Refinement + +## 定位 + +本前端面向科研工程单位的 InSAR 生产、分析和运维场景。界面设计服务于长期、高频、可审计的工程工作流,不做营销化展示页。 + +## 语言策略 + +- 默认并长期采用中文界面。 +- 不再维护完整英文界面和运行时 DOM 翻译。 +- 专业术语按行业习惯保留英文或中英混写,例如 InSAR、D-InSAR、SBAS、Gamma、LandSAR、ENVI、SARscape、IDL、Ollama、DB、Worker、Task、Run、Catalog、AOI、DEM、GeoTIFF、WebP。 +- 操作、状态、错误、提示使用中文,要求准确、短句、可执行。 +- 后续新增前端文案直接写中文,不再添加 `language === 'en'` 分支。 + +## 视觉原则 + +- 气质:冷静、精密、可审计,像科研生产控制台,而不是通用 SaaS 后台。 +- 色彩:以蓝灰中性色为基础,蓝色只用于主操作、选中态、地图覆盖层和关键状态,不做装饰性大面积渐变。 +- 信息密度:允许密集,但必须靠分组、标题、状态标签和对齐建立秩序。 +- 控件:同类按钮、标签、面板、表单行使用一致样式。避免在 JSX 中散落大段 inline style。 +- 动效:只表达状态变化,不做页面级展示动画。进度条可即时更新,避免无意义的 width 动画。 + +## 优先修整项 + +1. 固定中文运行时,移除顶部语言切换。 +2. 修复已损坏或乱码的核心文案,优先顺序:全局顶栏、登录页、导航常量、生产工作台、雷达数据面板、运维自检。 +3. 收敛视觉反模式:粗侧边强调线、装饰渐变、过宽阴影、过圆卡片。 +4. 抽取基础 UI 词汇:按钮、状态标签、面板段落、工具栏、字段行。 +5. 重塑生产工作台为流程导向:数据准备 -> 配对/栈规划 -> 生产运行 -> 质量检查 -> 成果发布。 + +## 当前决策 + +- `I18nProvider` 保留兼容 API,但固定返回 `language: 'zh'`,`t(text)` 原样返回。 +- 暂不一次性删除所有英文分支,避免大面积回归风险;后续按面板逐步清理。 +- 后端服务无需停止。只修改前端文件时,Vite 可热更新;必要时刷新浏览器即可。 + +## 2026-06-21 修整记录 + +- 固定中文运行时,移除顶部语言切换入口,保留 `useI18n` 兼容层以降低改动范围。 +- 修复登录页、全局状态栏、地图底图常量、生产管理常量、雷达数据面板和共享加载态的中文文案。 +- 将生产管理入口重塑为流程型工作台,突出数据准备、规划、运行、质量检查与成果发布,弱化营销式 hero 和装饰性渐变。 +- 调整基础视觉规则:减少大阴影、过圆卡片、装饰渐变和无意义进度动画;保留更克制的工程控制台气质。 +- 已通过 `npm run build`,并对本轮触碰文件做了乱码与视觉反模式扫描。 + +## 2026-06-21 页眉与生产面板补充 + +- 页眉移除 DB、Worker、IDL、Ollama、Nginx 等运行状态灯,系统健康状态集中放在“运行维护”模块。 +- 页眉改为单位 logo、单位名称、系统名称、授权摘要、任务摘要和用户操作。 +- 单位名、系统名、页眉标语和 logo URL 支持通过 `VITE_APP_*` 环境变量配置,默认使用 `frontend/src/logo.jpg`。 +- 新增 `PRODUCT.md`,记录系统面向科研工程单位的产品定位:科研、专业、克制,同时要求严谨、稳定、工程化。 +- SBAS 生产面板中高曝光的 Runtime Status、Task queue、Workflow 说明文案改为中文表达,保留必要英文术语。 + +## 2026-06-21 运行维护页设计 + +- 将“运行维护”定位为页眉移除健康灯后的主健康入口,顶部摘要直接回答生产是否就绪、阻断项数量、最近检查和一致性异常。 +- 保留所有现有检查项,但按职责分组:核心服务、结果目录与生产索引、数据资产与运行时、一致性与精轨、维护操作。 +- 固定中文运行路径,`HealthCheckPanel` 不再依赖 `language === 'en'` 进入英文界面。 +- 视觉上减少阴影和卡片堆叠感,改为分组式生产控制台;状态不只靠颜色表达,同时显示“正常/异常”和具体数量。 + +## 2026-06-21 面板一致性策略 + +- 当前前端的主要观感问题来自多个面板各自生长:宽度、卡片高度、标题层级和内边距不一致,造成“碎”和“忽宽忽高”的感觉。 +- 后续按页处理,不先做大规模重构:每轮选择一个高频页面,统一壳层、摘要、分区标题、最大宽度和关键文案;全量走完后再做一次整体审阅。 +- D-InSAR 生产页先完成第一轮结构归整:增加生产摘要,按“引擎与能力 / 任务准备与提交 / 运行监控与审计记录”分区,页面最大宽度收敛到生产控制台尺度。 +- 独立面板轻量收敛:去掉装饰背景,降低独立页头部高度和标题字号,让页面主体而不是壳层成为视觉中心。 + +## 2026-06-21 D-InSAR 结果目录第一轮 + +- D-InSAR 结果页从“结果提取与标准目录”的说明块,调整为成果归档控制台:顶部直接显示操作模式、产物任务、提取源和日志策略。 +- 提取、重扫和任务日志归为“成果提取与任务监控”,下方标准目录归为“标准目录与资产详情”,降低页面碎片感。 +- 页面最大宽度与 D-InSAR 生产运行页统一到 1280px,减少超宽屏下横向拉伸;卡片圆角、边框、内边距跟随当前生产控制台节奏。 +- 保留现有后端接口、任务监控和目录组件行为,本轮只做前端结构、文案和壳层修整。 + +## 2026-06-21 SBAS-InSAR 结果目录第一轮 + +- SBAS 成果页顶部从普通 section 调整为结果目录控制台:显示操作模式、目录状态、登记产品数和问题数。 +- 保留原有刷新、目录重建、检索、预览、时序曲线、资产下载等业务能力,只收敛外层壳、标题和主工作区比例。 +- 主工作区增加“结果检索与资产复核”分区说明,左侧列表宽度从 380px 收敛到 360px,避免与详情区争抢空间。 +- 与 D-InSAR 成果页保持 1280px 最大宽度和同一套状态摘要视觉词汇,形成 D-InSAR / SBAS 成果发布链路的一致入口。 + +## 2026-06-21 工程痕迹文案清理第一轮 + +- 前端不再把已经退出主流程的 ISCE2/MintPy 能力作为生产入口说明、下拉选项或运维自检对象展示。 +- 运行维护页的精轨检查聚焦当前生产 TXT 池;前端不再把 ISCE2 XML 池纳入健康判断、计数摘要、修复按钮或结果说明。 +- 任务中心与导航文案去掉“旧 / legacy / 停用”等工程开发痕迹,历史任务使用中性中文标签表达。 +- SBAS / D-InSAR 高曝光结果文案改为面向业务人员的表达,避免把兼容层、桥接层和弃用路径暴露成用户概念。 + +## 2026-06-21 资产库存页第一轮 + +- 资产库存页定位为台账查看与质量复核入口,不再在顶部暴露“全部扫描 / LT-1 扫描 / S1 扫描 / 精轨扫描”等运维扫描按钮。 +- 资产扫描能力仍保留在数据接入与运维流程中;资产库存页保留刷新和压缩包完整性审计,避免普通台账页面变成操作面板。 +- 页面壳层收敛到 1280px,标题说明改为“查看源产品、精密轨道、绑定状态和开放问题”,指标区改为五列台账摘要。 + +## 2026-06-21 应用壳与导航第一轮 + +- 左侧一级任务线调整为“数据资产 / 生产管理 / 形变分析 / 灾害分析 / 运行维护”,减少泛化后台感。 +- 数据域入口改为“数据接入 / 资产台账 / 影像检索 / 灾害点库”,对应接入、台账、检索和空间对象管理四类任务。 +- 形变分析入口改为“D-InSAR 结果判读 / D-InSAR 专题分析 / SBAS 形变分析”,让分析链路更贴近科研业务表达。 +- 独立工作区页头不再使用统一兜底说明,按模块显示专业描述,帮助用户理解当前页面在任务线中的位置。 +- 左侧导航视觉从胶囊按钮堆叠调整为更克制的分层按钮,减少碎片感并强化一级任务线。 + +## 2026-06-21 数据接入页第一轮 + +- 数据接入页从“数据监控面板”调整为接入控制台:顶部显示配置状态、接入任务、源数据池和可用存储。 +- 页面任务线梳理为接入路径与生产目录、本机存储状态、LT-1/Sentinel-1 源数据与精轨登记、GF3 回传成果登记、接入任务记录。 +- 将“扫描压缩包 / 扫描精轨”等工程按钮文案改为“登记源压缩包 / 登记精轨”,更贴合生产数据接入语义。 +- 移除接入页顶部装饰渐变提示,改为状态提示条;保留现有后端接口和任务调用路径。 + +## 2026-06-22 应用壳与综合统计调整 + +- 右侧全局日志栏不再作为主界面常驻区域展示,地图和左侧任务栏获得更完整的横向空间;各业务页内部的任务记录、审计记录和运行维护能力继续保留。 +- “统计”从影像检索页按钮提升为一级导航“综合统计”,与数据资产、生产管理、形变分析、灾害分析同级,避免把生产统计能力误放在单一检索任务里。 +- 综合统计页从遮罩弹窗改为全宽工作页,按源影像、D-InSAR 成果、质量判读、缓存一致性四类信息组织;图表使用克制的科研控制台风格,减少装饰色和弹窗遮挡。 +- 影像检索页顶部仅保留检索相关动作,统计入口由导航承担,任务线更清晰:先接入和检索数据,再进入生产和成果分析,最终在综合统计中复核总体状态。 + +## 2026-06-22 接入页与统计口径回收 + +- 左侧主栏固定到 620px,不再保留拖拽宽度;右侧日志栏移除后,主界面直接形成“宽任务栏 + 地图工作区”的稳定布局。 +- 数据接入页的“接入路径与生产目录”默认折叠。该区主要是服务器部署目录核对,不应占据日常接入操作的首屏。 +- 接入页补充“开放问题复核”区,直接读取资产台账开放 issue;底部日志明确为任务执行日志,避免把 “Extracting source archive metadata...” 这类过程日志误认为问题结论。 +- 综合统计暂时改为一级入口占位页,不再接临时 `/statistics` 图表。具体统计口径和图表方案记录在 `docs/STATISTICS_DASHBOARD_DESIGN.md`,后续先设计再实现。 + +## 2026-06-23 综合统计命名与覆盖图修正 + +- 综合统计页标题调整为“InSAR 数据与生产统计”,不再使用“生产态势驾驶舱”这类展示大屏语义。 +- 覆盖图从经纬度散点图调整为“源数据空间覆盖密度”,按场景中心点聚合成网格热力图,不再显示笛卡尔经纬度坐标轴。 +- 当前覆盖密度图只表达中心点密度,不等同于真实 footprint 面状覆盖;正式面积覆盖、行政区覆盖率和空白区判断需要后续基于有序角点/footprint 的后端聚合。 +- 综合统计继续使用手动刷新,不做自动轮询,避免统计页持续打生产服务。 + +## 2026-06-23 综合统计热力格网升级 + +- 覆盖热力不再由前端临时聚合点位,而是由 `/api/statistics/dashboard` 返回后端格网单元,前端只负责渲染。 +- 覆盖统计分成“源数据”和“成果”两个对象:源数据统计 LT-1 / Sentinel-1 / GF3,成果统计 `result_products` 中 D-InSAR / SBAS 等已登记产品。 +- 源数据优先用 `sar_scene_geometry_profiles.footprint_polygon`,缺失时退回中心点;成果优先用 `result_products.coverage_polygon`,缺失时退回 bbox。 +- 当前热力图仍是工程态势图,不作为正式面积统计或行政区覆盖率结论。 diff --git a/docs/INDEX.md b/docs/INDEX.md index ed2f48f..d1829f7 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,106 +1,83 @@ -# 文档索引 +# 鏂囨。绱㈠紩 -最后更新:2026-06-16 +鏈€鍚庢洿鏂帮細2026-06-23 -本页是当前有效文档入口。没有列在本页的历史设计、实验记录和过程文档不再作为当前系统事实依据。 - -## 总览与部署 +鏈〉鏄綋鍓嶆湁鏁堟枃妗e叆鍙c€傛病鏈夊垪鍦ㄦ湰椤碘€滃綋鍓嶆湁鏁堟枃妗b€濅腑鐨勫巻鍙茶璁°€佸疄楠岃褰曞拰杩囩▼鏂囨。锛屼笉鍐嶄綔涓哄綋鍓嶇郴缁熶簨瀹炰緷鎹€? +## 褰撳墠鏈夋晥鏂囨。 +### 鎬昏涓庨儴缃? - [../README.md](../README.md) - 项目总览、当前生产入口、启动链路和文档入口。 - + 椤圭洰鎬昏銆佸綋鍓嶇敓浜у叆鍙c€佸惎鍔ㄩ摼璺拰鏂囨。鍏ュ彛銆? - [DEPLOYMENT.md](DEPLOYMENT.md) - Windows + PostgreSQL + WSL2 + Gamma/ISCE2/ENVI 的部署与运行说明。 - + Windows + PostgreSQL + WSL2 + Gamma/ISCE2/ENVI 鐨勯儴缃蹭笌杩愯璇存槑銆? - [BASEMAP_TILESERVER_PROXY_AND_ACCESS_20260613.md](BASEMAP_TILESERVER_PROXY_AND_ACCESS_20260613.md) - Tile-server proxy, LAN access, token configuration, and Nginx IP whitelist. + Tile-server proxy銆丩AN access銆乼oken configuration 鍜?Nginx IP whitelist銆? - [DOCUMENTATION_GOVERNANCE.md](DOCUMENTATION_GOVERNANCE.md) - 文档治理规则、事实来源优先级和清理约定。 - + 鏂囨。娌荤悊瑙勫垯銆佷簨瀹炴潵婧愪紭鍏堢骇鍜屾竻鐞嗙害瀹氥€? - [FRONTEND_NAVIGATION_ARCHITECTURE.md](FRONTEND_NAVIGATION_ARCHITECTURE.md) - 当前左侧导航和生产管理工作台视图模型。 - + 褰撳墠宸︿晶瀵艰埅鍜岀敓浜х鐞嗗伐浣滃彴瑙嗗浘妯″瀷銆? +- [FRONTEND_PRODUCTION_UI_REFINEMENT.md](FRONTEND_PRODUCTION_UI_REFINEMENT.md) + 鍓嶇瀵艰埅銆佹暟鎹帴鍏ャ€佺敓浜х鐞嗐€佺患鍚堢粺璁$瓑椤甸潰鐨勮繎鏈熷伐绋嬪寲璋冩暣璁板綍銆? +- [STATISTICS_DASHBOARD_DESIGN.md](STATISTICS_DASHBOARD_DESIGN.md) + 缁煎悎缁熻涓€绾ч〉闈㈢殑宸ョ▼瀹氫綅銆佺粺璁″彛寰勩€佹簮鏁版嵁/鎴愭灉绌洪棿瑕嗙洊瀵嗗害鍥鹃檺鍒跺拰鍚庣画 footprint 鐑姏鍗囩骇璺嚎銆? - [GLOBAL_TASK_STATUS_LOCK_REDESIGN_20260612.md](GLOBAL_TASK_STATUS_LOCK_REDESIGN_20260612.md) - 全局界面锁降级为任务状态中心、功能级任务面板和后端资源锁的重构设计。 - -- [OLLAMA_DINSAR_DIAGNOSIS_DEPLOYMENT_20260620.md](OLLAMA_DINSAR_DIAGNOSIS_DEPLOYMENT_20260620.md) - D-InSAR 分析中 Ollama 本机 VLM 诊断的部署配置、模型选择和运行约定。 - -## 生产与结果 - -- [THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md](THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md) - 当前陆探一号、Sentinel-1、高分三本机生产、按需解包、GF3 外部生产登记、结果管理和 UNC 退出约定。 - + 鍏ㄥ眬鐣岄潰閿侀檷绾т负浠诲姟鐘舵€佷腑蹇冦€佸姛鑳界骇浠诲姟闈㈡澘鍜屽悗绔祫婧愰攣鐨勯噸鏋勮璁°€? +- [OLLAMA_DINSAR_DIAGNOSIS_DEPLOYMENT_20260620.md](OLLAMA_DINSAR_DIAGNOSIS_DEPLOYMENT_20260620.md) + D-InSAR 鍒嗘瀽涓?Ollama 鏈満 VLM 璇婃柇鐨勯儴缃查厤缃€佹ā鍨嬮€夋嫨鍜岃繍琛岀害瀹氥€? +- [STORAGE_PRESSURE_CLEANUP_GOVERNANCE_20260623.md](STORAGE_PRESSURE_CLEANUP_GOVERNANCE_20260623.md) + 瀛樺偍鍘嬪姏娓呯悊涓庢不鐞嗚竟鐣岋細婧愭暟鎹€丟F3 `_geo`銆丏EM銆佺簿杞ㄥ拰姝e紡鎴愭灉涓嶈繘鍏ユ櫘閫氭竻鐞嗭紝缂撳瓨銆佹棩蹇椼€佽繍琛屾椂鍜?Task_Pool materialize 鍒嗛樁娈垫不鐞嗐€? +### 鐢熶骇涓庣粨鏋? +- [THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md](THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md) + 褰撳墠闄嗘帰涓€鍙枫€丼entinel-1銆侀珮鍒嗕笁鏈満鐢熶骇銆佹寜闇€瑙e寘銆丟F3 澶栭儴鐢熶骇鐧昏銆佺粨鏋滅鐞嗗拰 UNC 閫€鍑虹害瀹氥€? - [PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md](PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md) - 统一结果目录、标准产品包、catalog 与多引擎结果共存约定。D-InSAR 当前引擎集合以 2026-06-14 三引擎 Task_Pool 重构设计为准。 - + 缁熶竴缁撴灉鐩綍銆佹爣鍑嗕骇鍝佸寘銆乧atalog 涓庡寮曟搸缁撴灉鍏卞瓨绾﹀畾銆? - [DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md](DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md) - D-InSAR 保留 ENVI/SARscape、LandSAR、Gamma/PyINT 三引擎,退出 ISCE2,统一 Task_Pool、结果聚合和中间文件清理的当前设计。 -- [LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md](LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md) - LandSAR D-InSAR/SBAS 的全球 DEM 一次性 Int16 标准化、区域裁剪 tif、生产配置和 guardrail 约定。 -- [UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md](UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md) - LT-1/Sentinel-1 本地源压缩包管理、包内 XML/manifest 资产化、本地 Task_Pool materialize,以及 UNC 退出运行链路后的本机部署边界。 -- [SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md](SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md) - LT-1/Sentinel-1 源压缩包完整性审计的独立任务、增量语义、数据库字段和问题登记规则。 - + D-InSAR 淇濈暀 ENVI/SARscape銆丩andSAR銆丟amma/PyINT 涓夊紩鎿庯紝閫€鍑?ISCE2锛岀粺涓€ Task_Pool銆佺粨鏋滆仛鍚堝拰涓棿鏂囦欢娓呯悊鐨勫綋鍓嶈璁°€? +- [LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md](LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md) + LandSAR D-InSAR/SBAS 鐨勫叏鐞?DEM 涓€娆℃€?Int16 鏍囧噯鍖栥€佸尯鍩熻鍓?tif銆佺敓浜ч厤缃拰 guardrail 绾﹀畾銆? +- [LANDSAR_CLUSTER_WORKER_DEPLOYMENT_20260624.md](LANDSAR_CLUSTER_WORKER_DEPLOYMENT_20260624.md) + LandSAR D-InSAR 集群 worker 的队列分片设计、主服务器 IP 白名单、远端 Windows 节点 192.168.1.6 部署和运行约束。 +- [UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md](UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md) + LT-1/Sentinel-1 鏈湴婧愬帇缂╁寘绠$悊銆佸寘鍐?XML/manifest 璧勪骇鍖栥€佹湰鍦?Task_Pool materialize锛屼互鍙?UNC 閫€鍑哄悗鐨勬湰鏈洪儴缃茶竟鐣屻€? +- [SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md](SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md) + LT-1/Sentinel-1 婧愬帇缂╁寘瀹屾暣鎬у璁$殑鐙珛浠诲姟銆佸閲忚涔夈€佹暟鎹簱瀛楁鍜岄棶棰樼櫥璁拌鍒欍€? - [DINSAR_PRODUCTION_CORES_OVERVIEW.md](DINSAR_PRODUCTION_CORES_OVERVIEW.md) - 旧版 ENVI/SARscape、ISCE2、Gamma/PyINT D-InSAR 生产核心说明。ISCE2 相关内容仅作历史背景。 - + 鏃х増 ENVI/SARscape銆両SCE2銆丟amma/PyINT D-InSAR 鐢熶骇鏍稿績璇存槑銆侷SCE2 鐩稿叧鍐呭浠呬綔鍘嗗彶鑳屾櫙銆? - [SBAS_INSAR_CURRENT_WORKFLOW.md](SBAS_INSAR_CURRENT_WORKFLOW.md) - 当前 Gamma SBAS-InSAR 生产、AOI 选栈、结果 catalog、产物和 LOS 符号约定。 - -## 运行时与专项配置 + 褰撳墠 Gamma SBAS-InSAR 鐢熶骇銆丄OI 閫夋嫨銆佺粨鏋?catalog銆佷骇鍝佸拰 LOS 绗﹀彿绾﹀畾銆? +### 杩愯鏃朵笌涓撻」閰嶇疆 - [WSL_RUNTIME_REFACTOR_DESIGN_20260422.md](WSL_RUNTIME_REFACTOR_DESIGN_20260422.md) - WSL 共享运行时和 Broker 设计。 - + WSL 鍏变韩杩愯鏃跺拰 Broker 璁捐銆? - [PROJ_CONFIGURATION.md](PROJ_CONFIGURATION.md) - PROJ / GDAL 配置说明。 - + PROJ / GDAL 閰嶇疆璇存槑銆? - [ISCE2_MANAGED_DINSAR_IMPLEMENTATION_20260424.md](ISCE2_MANAGED_DINSAR_IMPLEMENTATION_20260424.md) - ISCE2 托管 D-InSAR 历史落地说明。新 D-InSAR 生产不再采用。 - + ISCE2 鎵樼 D-InSAR 鍘嗗彶钀藉湴璇存槑銆傛柊 D-InSAR 鐢熶骇涓嶅啀閲囩敤銆? - [ISCE2_PRODUCTION_RELIABILITY_HARDENING_DESIGN_20260424.md](ISCE2_PRODUCTION_RELIABILITY_HARDENING_DESIGN_20260424.md) - ISCE2 生产链路历史稳定性约束。新 D-InSAR 生产不再采用。 - -## 数据与业务模块 - + ISCE2 鐢熶骇閾捐矾鍘嗗彶绋冲畾鎬х害鏉熴€傛柊 D-InSAR 鐢熶骇涓嶅啀閲囩敤銆? +### 鏁版嵁涓庝笟鍔℃ā鍧? - [SENTINEL1_SOURCE_ORBIT_ASSET_DESIGN_20260512.md](SENTINEL1_SOURCE_ORBIT_ASSET_DESIGN_20260512.md) - Sentinel-1 / LT-1 源数据与精密轨道资产层设计。 -- [PRECISE_ORBIT_PRODUCTION_CONTRACT_20260617.md](PRECISE_ORBIT_PRODUCTION_CONTRACT_20260617.md) - Current LT-1/Sentinel-1 precise-orbit source assets, LT-1 production TXT pools, Gamma/PyINT, LandSAR, and retired ISCE2 orbit boundaries. - + Sentinel-1 / LT-1 婧愭暟鎹笌绮惧瘑杞ㄩ亾璧勪骇灞傝璁°€? +- [PRECISE_ORBIT_PRODUCTION_CONTRACT_20260617.md](PRECISE_ORBIT_PRODUCTION_CONTRACT_20260617.md) + 褰撳墠 LT-1/Sentinel-1 绮捐建婧愯祫浜с€丩T-1 鐢熶骇 TXT 姹犮€丟amma/PyINT銆丩andSAR 鍜岄€€褰?ISCE2 绮捐建杈圭晫銆? - [FLOOD_GEOTIFF_GAMMA_PREPROCESS_DESIGN_20260515.md](FLOOD_GEOTIFF_GAMMA_PREPROCESS_DESIGN_20260515.md) - 洪涝模块 GeoTIFF 化与 Gamma 前处理方向。 - + 娲稘妯″潡 GeoTIFF 鍖栦笌 Gamma 鍓嶅鐞嗘柟鍚戙€? - [GF3_SARSCAPE_NATIVE_TO_GEOTIFF_DESIGN_20260530.md](GF3_SARSCAPE_NATIVE_TO_GEOTIFF_DESIGN_20260530.md) - GF3 SARscape 原生 `_geo` 二进制池、GeoTIFF 标准化、入库和洪涝接入设计。 - + GF3 SARscape 鍘熺敓 `_geo` 浜岃繘鍒舵睜銆丟eoTIFF 鏍囧噯鍖栥€佸叆搴撳拰娲稘鎺ュ叆璁捐銆? - [FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md](FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md) - 洪涝灾害分析工作台、产品包和矢量套合边界。 - + 娲稘鐏惧鍒嗘瀽宸ヤ綔鍙般€佷骇鍝佸寘鍜岀煝閲忓鍚堣竟鐣屻€? - [FLOOD_WATER_ALGORITHM_ENGINEERING_HANDOFF_20260602.md](FLOOD_WATER_ALGORITHM_ENGINEERING_HANDOFF_20260602.md) - 洪涝/水体算法接入现状、processor 输出契约和工程交接路线。 - -## 安全 + 娲稘/姘翠綋绠楁硶鎺ュ叆鐜扮姸銆乸rocessor 杈撳嚭濂戠害鍜屽伐绋嬩氦鎺ヨ矾绾裤€? +### 瀹夊叏 - [SECURITY_AUDIT_2026-03-12.md](SECURITY_AUDIT_2026-03-12.md) - 安全审计记录。 - -## 工作笔记 + 瀹夊叏瀹¤璁板綍銆? +## 宸ヤ綔绗旇 - [../INIT.md](../INIT.md) - 工作笔记。只用于辅助理解现场状态,不替代正式文档。 + 宸ヤ綔绗旇锛屽彧鐢ㄤ簬杈呭姪鐞嗚В鐜板満鐘舵€侊紝涓嶆浛浠f寮忔枃妗c€? +## 鍘嗗彶褰掓。 -## 已删除的历史材料 - -以下材料已从仓库文档树删除,不再维护: - -- 旧 `docs/archive/` 历史堆积目录; -- 旧 SBAS 过程文档和试验 runbook; -- 旧 ISCE2/MintPy/SARscape 时序生产设计; -- 过期的 PyINT/Gamma 实验记录; -- 过期的 Sentinel-1 阶段计划; -- 过期的配对增强计划和阶段性审计快照。 - -需要判断当前实现时,优先看代码入口和本索引列出的文档。 +- [archived_20260623/README.md](archived_20260623/README.md) + 2026-06-23 浠?`docs/` 鏍圭洰褰曠Щ鍑虹殑鍘嗗彶銆佸疄楠屻€佽繃绋嬪拰闃舵鎬ф枃妗f竻鍗曘€傝繖浜涙枃妗d笉鍐嶄綔涓哄綋鍓嶇郴缁熶簨瀹炰緷鎹€? +闇€瑕佸垽鏂綋鍓嶅疄鐜版椂锛屼紭鍏堢湅浠g爜鍏ュ彛銆乣README.md` 鍜屾湰绱㈠紩鍒楀嚭鐨勫綋鍓嶆湁鏁堟枃妗c€? diff --git a/docs/LANDSAR_CLUSTER_WORKER_DEPLOYMENT_20260624.md b/docs/LANDSAR_CLUSTER_WORKER_DEPLOYMENT_20260624.md new file mode 100644 index 0000000..73c42ca --- /dev/null +++ b/docs/LANDSAR_CLUSTER_WORKER_DEPLOYMENT_20260624.md @@ -0,0 +1,333 @@ +# LandSAR 集群 Worker 设计与部署记录(2026-06-24) + +## 结论 + +本次改造是在保留本机 LandSAR 生产链路的前提下,新增 LandSAR 集群执行入口。 + +本机旧入口仍然是 `LANDSAR_RUN`:由主服务器上的一个控制器串行处理一个批次内的 pair。新入口是 `LANDSAR_CLUSTER_ITEM`:主服务器提交集群任务后,系统按 pair 拆成多条队列任务,由本机或远端 Windows worker 领取执行。 + +当前规划的远端计算服务器是: + +- 主服务器 / PostgreSQL / Web 系统:`192.168.1.62` +- 远端 LandSAR worker:`192.168.1.6` + +远端 worker 的 `.env` 里 `DATABASE_URL` 必须指向主服务器 `192.168.1.62`,不是写它自己 `192.168.1.6`。 + +## 已落地代码 + +后端集群入口: + +- `backend/app/routers/dinsar_production.py` + - 新增 `POST /dinsar-production/landsar-cluster/run` + - 只接受 `engine_code=landsar` + - 提交后按 Task/pair 拆分为多个 `LANDSAR_CLUSTER_ITEM` + +生产服务: + +- `backend/app/services/dinsar_production_service.py` + - 新增 `create_landsar_cluster_run` + - 复用现有 `DinsarProductionRunORM`、`DinsarProductionRunItemORM`、`DinsarProductionExecutionORM` + - 不新增 PG 表结构 + - 新增 `LANDSAR_CLUSTER_RUN` 父任务类型 + +队列与 worker: + +- `backend/app/services/job_queue_service.py` + - `claim_next_job` 支持 `allowed_job_types` +- `backend/app/services/job_worker.py` + - 新增 `JOB_WORKER_ALLOWED_TYPES` 过滤 + - 远端 worker 可配置为只领取 `LANDSAR_CLUSTER_ITEM` +- `backend/app/services/job_handlers.py` + - 新增 `LANDSAR_CLUSTER_ITEM` handler + - 每个 handler 只处理一个 pair + - 使用本进程本地锁避免单台机器上多个 LandSAR 任务并发抢授权 + - 不使用旧 `wsl_dinsar_landsar` 全局数据库锁,因此多台服务器可以并行处理不同 pair + +远端专用入口: + +- `run_landsar_cluster_worker.py` + - Windows 远端直接运行此脚本即可 + - 默认只领取 `LANDSAR_CLUSTER_ITEM` + - 默认并发为 1 +- `scripts/start_landsar_cluster_worker.ps1` + - 远端 Windows 推荐启动器 + - 检查 `.env` + - 自动定位 Python + - 创建 `logs\landsar_cluster_worker` + - 支持前台运行和 `-Background` 后台运行 +- `scripts/start_landsar_cluster_worker.bat` + - 远端双击启动入口,内部调用 PowerShell 启动器 + +主服务器网络准入脚本: + +- `scripts/sync_landsar_cluster_network_access.ps1` + - 从主服务器 `.env` 读取 `LANDSAR_CLUSTER_ALLOWED_WORKER_IPS` + - 同步 PostgreSQL `pg_hba.conf` + - 同步 Windows 防火墙 TCP `5432` + - reload PostgreSQL + +前端入口: + +- `frontend/src/DinsarProductionPanel.jsx` + - LandSAR 引擎下新增“提交 LandSAR 集群”按钮 + - 原“提交任务”按钮仍走本机旧链路 + +## 主服务器配置 + +主服务器 `.env` 增加: + +```env +LANDSAR_CLUSTER_ALLOWED_WORKER_IPS=192.168.1.6 +``` + +如果后续增加更多 worker,用逗号或分号分隔: + +```env +LANDSAR_CLUSTER_ALLOWED_WORKER_IPS=192.168.1.6,192.168.1.7,192.168.1.8 +``` + +每次修改后在主服务器执行: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\sync_landsar_cluster_network_access.ps1 +``` + +脚本会把允许的 worker IP 写入 `D:\PostgreSQLData\pg_hba.conf` 的受管控区块: + +```text +# BEGIN InSAR LandSAR cluster workers +host insar_management all 192.168.1.6/32 scram-sha-256 +# END InSAR LandSAR cluster workers +``` + +同时维护 Windows 防火墙规则: + +```text +InSAR PostgreSQL 5432 LandSAR Cluster +``` + +当前主服务器已完成配置: + +- PostgreSQL 监听 `0.0.0.0:5432` +- `pg_hba.conf` 只允许 `192.168.1.6/32` 访问 `insar_management` +- 防火墙 TCP `5432` 只允许 `192.168.1.6` + +## 远端 192.168.1.6 需要复制什么 + +推荐复制整个当前仓库,而不是只挑脚本。 + +原因是远端 worker 虽然只运行 `run_landsar_cluster_worker.py`,但它会 import 后端配置、ORM、队列服务、LandSAR engine、结果发布服务、任务服务等模块。只复制单个脚本会缺依赖。 + +建议远端目录保持一致: + +```text +D:\Code\Insar_management_system_v2 +``` + +至少要确保这些内容在远端存在并与主服务器代码版本一致: + +- `run_landsar_cluster_worker.py` +- `backend/` +- `scripts/` +- `config/` +- `.env` +- Python 依赖环境 +- LandSAR 安装目录 +- DEM 文件 +- 任务输入目录或可访问的任务输入路径 +- 结果返回目录或可访问的结果目录 + +前端 `frontend/` 对远端 worker 不是运行必需,但为了版本一致,建议整仓同步。 + +## 远端 192.168.1.6 的 .env + +远端 `.env` 模板已维护在: + +```text +config\landsar_cluster_worker.env.example +``` + +复制为远端项目根目录 `.env`: + +```powershell +Copy-Item config\landsar_cluster_worker.env.example .env +``` + +远端 `.env` 的核心配置: + +```env +DATABASE_URL=postgresql+asyncpg://postgres:WXZXzhb123456@192.168.1.62:5432/insar_management +JOB_WORKER_ALLOWED_TYPES=LANDSAR_CLUSTER_ITEM +JOB_WORKER_CONCURRENCY=1 +JOB_WORKER_POLL_INTERVAL=1.0 +LANDSAR_CLUSTER_WORKER_ID= +``` + +还需要按远端实际 LandSAR 环境配置这些项: + +```env +LANDSAR_ENABLED=true +LANDSAR_HOME=D:\LandSAR +LANDSAR_CONSOLE_EXE=D:\LandSAR\InSAR_Console.exe +LANDSAR_WORK_ROOT=D:\LandSAR_Work +LANDSAR_RUNTIME_PATHS=D:\LandSAR +LANDSAR_LICENSE_MODE=netVersion +LANDSAR_LICENSE_HOST=127.0.0.1 +LANDSAR_LICENSE_PORT=6666 +LANDSAR_CONFIG_ROW=netVersion,zh,127.0.0.1,6666 +LANDSAR_CONFIG_AUTO_WRITE=true +LANDSAR_AUTH_SERVER_EXE=D:\Code\Insar_management_system_v2\third_party\LandSAR\tools\_portable_release\LandSAR_auth_tools_win64\landsar_net_auth_server.exe +LANDSAR_AUTH_SERVER_AUTO_START=true +LANDSAR_AUTH_SERVER_HOST=127.0.0.1 +LANDSAR_AUTH_SERVER_PORT=6666 +LANDSAR_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape_global_int16.tif +LANDSAR_DINSAR_TIMEOUT_SECONDS=43200 +``` + +如果远端的 LandSAR 授权服务器、安装路径、DEM 路径不同,按远端实际路径填写。 + +建议给 `LANDSAR_CLUSTER_WORKER_ID` 一个稳定值,方便主服务器健康检查区分节点: + +```env +LANDSAR_CLUSTER_WORKER_ID=landsar-worker-192-168-1-6 +``` + +## 远端 Windows 启动命令 + +在 `192.168.1.6` 上进入项目目录: + +```powershell +Set-Location D:\Code\Insar_management_system_v2 +``` + +启动 worker: + +```powershell +.\scripts\start_landsar_cluster_worker.ps1 +``` + +看到类似输出即表示监听程序已启动: + +```text +[*] Starting LandSAR cluster worker... +[*] Allowed job types: LANDSAR_CLUSTER_ITEM +[*] Poll interval: 1s +[*] Concurrency: 1 +``` + +需要双击启动时,运行: + +```text +D:\Code\Insar_management_system_v2\scripts\start_landsar_cluster_worker.bat +``` + +需要后台启动时,运行: + +```powershell +.\scripts\start_landsar_cluster_worker.ps1 -Background +``` + +启动日志在: + +```text +D:\Code\Insar_management_system_v2\logs\landsar_cluster_worker +``` + +## 远端连通性检查 + +在 `192.168.1.6` 上检查能否连主服务器数据库: + +```powershell +Test-NetConnection 192.168.1.62 -Port 5432 +``` + +应看到: + +```text +TcpTestSucceeded : True +``` + +再用 Python 检查数据库认证: + +```powershell +$env:DATABASE_URL='postgresql+asyncpg://postgres:WXZXzhb123456@192.168.1.62:5432/insar_management' +@' +import asyncio, os +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +async def main(): + engine = create_async_engine(os.environ["DATABASE_URL"], pool_pre_ping=True) + async with engine.connect() as conn: + print((await conn.execute(text("select 1"))).scalar_one()) + await engine.dispose() + +asyncio.run(main()) +'@ | C:\ProgramData\anaconda3\envs\InSAR\python.exe - +``` + +应输出: + +```text +1 +``` + +## 生产使用流程 + +1. 主服务器前端进入 D-InSAR 生产管理。 +2. 选择 LandSAR 引擎。 +3. 选择生产根目录,例如 `D:\Task_Pool\DInSAR` 或某个具体 `Task_*` 父目录。 +4. 点击“提交 LandSAR 集群”。 +5. 后端创建一个 `LANDSAR_CLUSTER_RUN` 父任务。 +6. 每个 pair 生成一条 `LANDSAR_CLUSTER_ITEM` 队列任务。 +7. 本机或远端 worker 抢占 item。 +8. worker 调用本机 LandSAR 环境处理该 pair。 +9. item 完成后写 execution manifest,并尝试发布到 D-InSAR 结果 catalog。 +10. 所有 item 进入终态后,父 run 标记为完成、失败或取消。 + +## 当前重要约束 + +这次改造解决的是“按 pair 分片调度和多 worker 领取”的问题,不是完整的数据自动搬运系统。 + +因此远端 `192.168.1.6` 必须满足以下路径条件之一: + +1. 与主服务器保持相同盘符和目录结构,并能看到同样的 `Task_Pool` 输入数据。 +2. 或者远端通过映射盘、同步工具、计划任务等方式,把所需 pair 的输入数据准备到相同路径。 +3. 结果输出目录也必须让主服务器可以扫描或访问,否则 worker 虽然能计算,结果不会自然回到主服务器 catalog。 + +当前代码中的 LandSAR cluster item 使用数据库里已有的 `source_task_dir` 作为 LandSAR 输入目录;它不会自动从源压缩包解包到远端,也不会自动把远端本地结果复制回主服务器。 + +后续若要彻底工程化,应新增两个能力: + +- 集群 item 开始前:按 pair 从源压缩包或主服务器 Task_Pool materialize 到远端本地工作目录。 +- 集群 item 完成后:把标准产品包从远端回传到主服务器结果目录,再由主服务器统一入库。 + +## 不要混淆的 IP + +`192.168.1.62` 是主服务器 IP,负责: + +- Web 后端 +- PostgreSQL +- 任务队列 +- 数据库自维护 +- 前端操作入口 + +`192.168.1.6` 是远端 LandSAR worker IP,负责: + +- 常驻监听 `LANDSAR_CLUSTER_ITEM` +- 调用本机 LandSAR +- 写 item 执行状态 + +所以远端 `.env` 中: + +```env +DATABASE_URL=...@192.168.1.62:5432/insar_management +``` + +而主服务器 `.env` 中: + +```env +LANDSAR_CLUSTER_ALLOWED_WORKER_IPS=192.168.1.6 +``` + +这两个配置方向不同,不能互换。 diff --git a/docs/STATISTICS_DASHBOARD_DESIGN.md b/docs/STATISTICS_DASHBOARD_DESIGN.md new file mode 100644 index 0000000..c458bd9 --- /dev/null +++ b/docs/STATISTICS_DASHBOARD_DESIGN.md @@ -0,0 +1,163 @@ +# 综合统计界面设计说明 + +## 定位 + +综合统计页定位为 **InSAR 数据与生产统计**,不是营销式展示大屏,也不再使用“生产态势驾驶舱”命名。 + +页面面向系统运维、生产管理和汇报前复核,核心目标是用可追溯统计口径回答: + +- 当前 LT-1、Sentinel-1、GF3 源数据底座是否齐备。 +- 元数据解析、几何画像、预览缓存和精轨保障是否支撑生产。 +- D-InSAR / SBAS 任务规划、生产运行和成果登记是否稳定。 +- 当前是否存在需要处理的资产、生产或成果问题。 + +综合统计必须以后端聚合口径为准,不能复用影像检索页的分页结果。影像检索或资产列表中的“一键显示”只代表当前检索结果或当前页可见数据,不代表全库统计。 + +## 当前实现 + +前端页面:`frontend/src/StatisticsDashboard.jsx` + +前端样式:`frontend/src/App.css` + +后端接口:`GET /api/statistics/dashboard` + +页面不自动轮询,只提供手动刷新,避免统计聚合影响生产服务。 + +## 页面结构 + +1. 顶部 KPI:源数据、几何画像、精轨绑定、生产任务、成果和问题的核心计数。 +2. 市级覆盖热力:分为源数据覆盖和成果覆盖两个视图,按场景或成果中心点落入的市级行政区统计数量。 +3. 数据底座与生产链路:展示源资产登记、元数据、几何画像、可生产画像、预览缓存等链路状态。 +4. 时间分布与精轨保障:展示源数据月份分布、精轨资产和精轨绑定覆盖率。 +5. 生产与成果:展示 D-InSAR / SBAS 运行状态、成果趋势、开放问题和最近任务状态。 + +## 统计口径 + +### 源数据资产 + +主口径使用 `source_product_assets`。 + +当前系统约定: + +- LT-1 管理源压缩包,扫描时从压缩包内提取 XML、预览和几何信息,参与生产时再按需解包到 `Task_Pool`。 +- Sentinel-1 管理源压缩包,扫描时从 SAFE zip 内提取 manifest、annotation、preview 和几何信息,参与生产时再按需解包。 +- GF3 管理外部服务器生产后的 `native_geo` 成果和快视图资产,本机负责登记、WebP 缓存和后续成果管理。 + +展示内容: + +- 源数据资产总量。 +- LT-1 / Sentinel-1 / GF3 分类数量。 +- 元数据文档提取率。 +- 几何画像可用率。 +- 源资产按月份分布。 +- 入库到可生产链路:源资产登记、元数据入库、几何画像、可生产画像、预览缓存。 + +### 市级覆盖热力 + +空间覆盖不能只统计三类源数据,也必须统计生产后的结果产品。两类对象分开展示,不混算。 + +#### 源数据覆盖 + +主口径使用 `sar_scene_geometry_profiles`: + +- 使用 `scene_center_lon` / `scene_center_lat` 落入市级行政区的数量。 +- 统计对象包括 LT-1、Sentinel-1、GF3 三类源数据。 +- tooltip 展示市级单位、源数据总数和数据源构成。 + +#### 成果覆盖 + +主口径使用 `result_products`: + +- 使用 `coverage_polygon` 或 bbox 中心点落入市级行政区的数量。 +- 统计对象包括已登记的 D-InSAR、SBAS 等结果产品,按 `catalog_name` 区分。 +- tooltip 展示市级单位、成果总数和成果类型构成。 + +当前实现不是经纬度散点图,也不是经纬度格网,而是后端按行政区聚合,前端用 ECharts Map 分级设色: + +- 页面在“源数据市级覆盖热力”和“成果市级覆盖热力”之间切换。 +- 不显示笛卡尔经纬度坐标轴。 +- 每个市级行政区按源数据景数或成果项数确定填充颜色。 +- 行政区边界来自系统现有全国行政区 GeoJSON/AOI 服务。 + +必须明确的限制: + +- 当前热力图统计的是中心点落区数量,不等同于真实场景 footprint 覆盖面积。 +- 结果产品当前按 coverage polygon 或 bbox 中心点落区,不等同于真实有效像元面积。 +- 当前热力图用于工程态势判断,不能直接作为正式面积统计、行政区覆盖率或成果质量评价结论。 +- 正式覆盖率需要进一步接入行政边界、AOI 或真实有效像元 mask。 + +### 精轨保障 + +主口径使用 `orbit_assets` 和 `scene_orbit_bindings`。 + +展示内容: + +- 精轨资产总数。 +- LT-1 / Sentinel-1 精轨分类。 +- 已选中精轨绑定数。 +- 精轨绑定覆盖率。 + +生产约束: + +- 统计页只展示精轨资产和绑定情况,不负责推断具体生产引擎如何取轨。 +- 具体生产取轨逻辑以 `PRECISE_ORBIT_PRODUCTION_CONTRACT_20260617.md` 为准。 + +### 生产运行 + +主口径使用: + +- `dinsar_task_batches` +- `dinsar_task_items` +- `dinsar_production_runs` +- `workflow_runs` + +展示内容: + +- D-InSAR 任务规划状态。 +- D-InSAR 生产运行状态。 +- 最近生产批次。 +- 平均运行耗时。 + +### 成果与问题 + +主口径使用: + +- `result_products` +- `result_assets` +- `result_issues` +- `asset_inventory_issues` +- `asset_inventory_states` + +展示内容: + +- D-InSAR / SBAS 成果数量。 +- 成果按月份趋势。 +- 成果预览和缺失资产统计。 +- 开放问题按类型分布。 +- 最近扫描状态。 + +## 视觉原则 + +- 使用工程统计页面语义,不使用“驾驶舱”作为默认标题。 +- 页面保持浅色工程管理风格,避免深色大屏、装饰化背景和无法解释的数据可视化。 +- 市级覆盖热力图可以作为首屏主要图表,但必须服务于生产判断:哪些市源数据集中、哪些市已有成果、哪些市缺数据或缺成果。 +- 图表文案必须写清统计口径,避免把中心点落区数量误读为正式面积覆盖。 +- 不引入 Three.js;统计页优先保证真实数据、稳定性能和可追溯口径。 + +## 后续增强 + +优先级从高到低: + +1. 扫描阶段固化 `corner_pixel_mapping`、有序角点和 footprint 质量标识,保证覆盖边界可追溯。 +2. 结果登记阶段固化成果有效像元范围,区分产品 bbox、footprint 和有效数据 mask。 +3. 增加省份/项目区筛选,支持只看黑龙江省或指定业务区。 +4. 增加按数据源、结果类型、时间窗口、产品可生产性、精轨绑定状态的筛选。 +5. 增加图表 drill-down,点击统计单元可跳转到资产台账、影像检索或成果列表。 +6. 如果后续需要汇报模式,只做布局适配,不改变统计口径。 + +## 变更记录 + +- 2026-06-23:覆盖图升级为市级行政区热力统计,按中心点落入市级单位计数,分离“源数据覆盖”和“成果覆盖”。 +- 2026-06-23:覆盖图曾短暂使用后端格网热力统计,因展示效果不适合综合统计页,调整为市级行政区分级设色。 +- 2026-06-23:页面命名调整为“InSAR 数据与生产统计”;覆盖图从经纬度散点调整为空间覆盖密度图;文档明确当前图不等同于正式面积统计。 +- 2026-06-22:综合统计从影像检索弹窗提升为一级页面,统计口径独立于检索分页结果。 diff --git a/docs/STORAGE_PRESSURE_CLEANUP_GOVERNANCE_20260623.md b/docs/STORAGE_PRESSURE_CLEANUP_GOVERNANCE_20260623.md new file mode 100644 index 0000000..df8a5f9 --- /dev/null +++ b/docs/STORAGE_PRESSURE_CLEANUP_GOVERNANCE_20260623.md @@ -0,0 +1,346 @@ +# 存储压力清理与治理设计 + +最后更新:2026-06-23 + +本文档记录系统后续增加“释放存储压力”能力的设计边界。当前阶段仅作为维护和设计依据,不代表已经实现清理按钮、接口或数据库表。 + +## 1. 背景 + +当前系统已经明确退出 UNC 活动生产链路,LT-1、Sentinel-1、高分三、精密轨道、DEM、Task_Pool、运行时和结果发布目录均要求走本机路径。 + +本机化以后,磁盘压力主要来自: + +- LT-1 / Sentinel-1 源压缩包持续增长; +- WebP、预览图源缓存和雷达缩略缓存持续增长; +- D-InSAR / SBAS-InSAR 的 Task_Pool materialize 目录持续增长; +- LandSAR、ENVI/SARscape、Gamma/PyINT、IDL、WSL broker 等运行时临时目录持续增长; +- `system_tasks` / `task_logs`、生产运行日志、诊断日志等数据库记录持续增长; +- 失败任务、调试任务和隔离区残留。 + +清理能力必须服务于生产稳定性,不能把“释放空间”做成粗暴删除目录。系统需要先判断数据角色、数据库引用、任务状态和可重建性,再生成清理计划。 + +## 2. 总原则 + +1. 源数据不清理。 + LT-1 / Sentinel-1 源压缩包、高分三 `_geo` 原生成果、DEM、精密轨道池是生产输入或登记对象,普通存储清理不得删除。 + +2. 先 dry-run,后执行。 + 所有清理动作必须先生成计划,列出路径、大小、数据库影响、风险等级和预计释放空间。用户确认后才执行。 + +3. 清理动作必须可审计。 + 系统必须记录谁在什么时候按什么规则清理了哪些文件、释放了多少空间、哪些失败、哪些数据库记录被更新。 + +4. 优先清理可重建派生物。 + 日志、过期缓存、旧版本缓存、失败任务临时目录、运行时临时目录、过期隔离区优先进入第一阶段。 + +5. 正式成果不走普通清理。 + `D:\production_results` 及其 catalog 注册成果不能被“一键清理”删除。成果删除或归档应走单独的结果管理流程。 + +6. Task_Pool 清理必须依赖生产状态。 + `D:\Task_Pool` 下 materialize 出来的生产输入理论上可从源压缩包重建,但只有在任务已结束、无活动执行、结果已登记或用户明确确认后才能清理。 + +7. 文件状态和数据库状态必须同步。 + 如果删除了数据库引用的 WebP 缓存、运行记录、结果资产或任务日志,必须同步更新对应表,避免前端显示“可用”但文件已不存在。 + +## 3. 永不进入普通清理的对象 + +以下对象默认不可被普通“释放存储压力”功能删除: + +| 对象 | 典型路径 / 配置 | 原因 | +| --- | --- | --- | +| LT-1 源压缩包 | `SOURCE_PRODUCT_DIRS` 中的 `D:\LuTan1_Image_Pool_Zip` | 源数据,是按需解包和重新生产的根 | +| Sentinel-1 源压缩包 | `SOURCE_PRODUCT_DIRS` 中的 `D:\Sentinel1_Image_Pool_ZIP` | 源数据,是按需解包和重新生产的根 | +| 高分三 `_geo` 成果池 | `GF3_SARSCAPE_NATIVE_DIRS=D:\GaoFen3_Pool\native_geo` | 本机登记对象,WebP 从这里生成 | +| 高分三 catalog | `GF3_STORAGE_DIRS=D:\GaoFen3_Pool\catalog` | 平台登记 manifest 和追踪材料 | +| LT-1 / S1 原生精轨源池 | `ORBIT_SOURCE_DIRS` | 轨道源资产 | +| LT-1 生产精轨池 | `ORBIT_POOL_ENVI` / `PYINT_ORBIT_POOL_TXT` / `GAMMA_SBAS_ORBIT_ROOTS` | ENVI、LandSAR、Gamma/PyINT 生产依赖 | +| DEM | `D:\DEM` 及相关 DEM 配置 | D-InSAR / SBAS / GF3 生产依赖 | +| 正式发布成果 | `RESULT_PUBLISH_ROOT`、`DINSAR_PRODUCT_DIR`、`TIMESERIES_PRODUCT_DIR` | 结果 catalog 管理对象,不走普通清理 | + +如确需删除以上对象,必须另设“源数据归档/删除”或“成果归档/删除”专项流程,不能复用普通清理按钮。 + +## 4. 可清理对象分级 + +### 4.1 低风险:第一阶段优先实现 + +| 类别 | 规则 | 数据库动作 | +| --- | --- | --- | +| 任务日志 | 清理已结束任务的旧日志,保留最近 N 天或每任务最后 N 条 | 删除 `task_logs`,可保留任务摘要 | +| 已结束旧任务记录 | 只清 `COMPLETED` / `FAILED` / `CANCELLED`,不得清 `PENDING` / `RUNNING` | 删除 `system_tasks` 及日志,或仅压缩日志 | +| 无引用缓存文件 | `backend\image_cache` 下没有数据库引用、文件不存在于当前版本策略的缓存 | 文件删除即可,记录清理项 | +| 旧版本 WebP 缓存 | `RADAR_GEO_CACHE_VERSION` 已变化且数据库不再引用 | 删除文件;如仍被引用,先更新数据库 | +| 过期隔离区 | 隔离超过保留期的文件 | 删除隔离记录或更新清理项 | + +### 4.2 中风险:第二阶段实现 + +| 类别 | 规则 | 数据库动作 | +| --- | --- | --- | +| 雷达 WebP 缓存 | 可从源压缩包或 GF3 `_geo` 重建;默认只清旧版本、孤立文件 | 若删除当前引用缓存,`radar_data.preview_cache_status` 改为 `NONE`,写入 `preview_cache_error=storage_cleanup_removed` | +| 预览图源缓存 | `radar_archive_preview_sources` 等从压缩包提取的中间缓存 | 可删除,后续扫描或预览重建 | +| 运行时临时目录 | `production_runtime`、IDL runtime、WSL jobs、PyINT work、临时 DEM 裁剪 | 仅清无活动任务、超过保留期的目录 | +| GF3 SARscape runtime | `GF3_TASK_POOL_ROOT` / `GF3_SARSCAPE_RUNTIME_DIR` | 当前本机 GF3 不生产,原则上仅清失败/过期 runtime,不清 `_geo` | + +### 4.3 高风险:第三阶段谨慎实现 + +| 类别 | 规则 | 数据库动作 | +| --- | --- | --- | +| D-InSAR Task_Pool materialize 目录 | 任务结束、无活动执行、可由源压缩包重建、用户确认 | 更新批次/任务的 materialize 状态 | +| SBAS Task_Pool materialize 目录 | 任务结束、无活动执行、结果或失败状态明确 | 更新 SBAS 生产运行状态 | +| D-InSAR / SBAS 中间文件 | 只清已发布结果之外的中间产物 | 必须依赖 result catalog 和 run manifest | + +### 4.4 不在本功能处理 + +- 源压缩包去重、归档、外发; +- 正式结果删除; +- DEM 版本删除; +- 精轨池删除; +- PostgreSQL VACUUM / 备份压缩; +- 洪水检测专项数据清理。 + +## 5. 清理计划模型 + +后续实现时,清理流程应分为两个动作: + +1. `PLAN` + 只扫描并估算,不删除文件,不修改业务表。 + +2. `APPLY` + 按用户确认的计划执行,逐项记录结果,必要时同步更新数据库。 + +清理计划每一项至少包含: + +```json +{ + "category": "radar_preview_cache", + "action": "delete_file", + "path": "D:\\Code\\Insar_management_system_v2\\backend\\image_cache\\radar_geo\\xxx.webp", + "size_bytes": 123456, + "risk_level": "low", + "reason": "old_cache_version", + "db_table": "radar_data", + "db_pk": 123, + "db_update": { + "preview_cache_status": "NONE", + "preview_cache_error": "storage_cleanup_removed" + } +} +``` + +## 6. 建议新增数据库表 + +为了审计和可追溯,建议新增两张表。 + +### 6.1 `storage_cleanup_runs` + +| 字段 | 含义 | +| --- | --- | +| `run_id` | 清理任务 ID | +| `status` | `PLANNED` / `RUNNING` / `COMPLETED` / `FAILED` / `CANCELLED` | +| `dry_run` | 是否只生成计划 | +| `categories` | 本次涉及类别 | +| `planned_bytes` | 计划释放空间 | +| `released_bytes` | 实际释放空间 | +| `planned_count` | 计划项数量 | +| `succeeded_count` | 成功项数量 | +| `failed_count` | 失败项数量 | +| `started_at` / `ended_at` | 执行时间 | +| `operator_user_id` | 操作用户 | +| `report_json` | 汇总报告 | + +### 6.2 `storage_cleanup_items` + +| 字段 | 含义 | +| --- | --- | +| `run_id` | 所属清理任务 | +| `category` | 清理类别 | +| `action` | `delete_file` / `delete_dir` / `quarantine` / `delete_db_rows` / `update_db_rows` | +| `path` | 文件或目录路径 | +| `size_bytes` | 大小 | +| `risk_level` | `low` / `medium` / `high` | +| `reason` | 命中规则 | +| `db_table` / `db_pk` | 关联数据库对象 | +| `before_json` / `after_json` | 数据库变更前后摘要 | +| `quarantine_path` | 隔离路径 | +| `status` | 单项执行状态 | +| `error` | 失败原因 | + +第一阶段也可以先不建表,使用 `system_tasks` + JSON 报告落地,但正式实现建议单独建表。 + +## 7. 路径安全规则 + +所有文件清理必须满足以下规则: + +1. 路径必须位于白名单根目录下。 +2. 禁止删除盘符根目录,例如 `D:\`。 +3. 禁止删除项目根目录、数据库目录、Python 环境目录、Nginx 目录。 +4. 禁止处理 UNC 路径。 +5. 禁止跟随符号链接逃逸白名单根目录。 +6. 删除目录前必须重新计算 resolved path 并确认仍在白名单内。 +7. 默认先移动到隔离区,隔离区过期后再永久删除。 +8. 单次执行应有最大删除数量和最大删除字节数上限。 + +建议白名单根目录来自配置和系统常量: + +- `backend\image_cache` +- `TASK_POOL_ROOT` +- `DINSAR_TASK_POOL_ROOT` +- `SBAS_TASK_POOL_ROOT` +- `GF3_TASK_POOL_ROOT` +- `DATA_DISTRIBUTION_ROOT` +- `IDL_WORKER_RUNTIME_DIR` +- `SAR_ANALYSIS_WORK_ROOT` +- `WSL_BROKER_JOB_ROOT` +- `PYINT_WORK_ROOT` +- `PYINT_DEM_ROOT` +- `GAMMA_SBAS_TRIAL_ROOT` +- `RESULT_QUARANTINE_ROOT` + +其中 `RESULT_PUBLISH_ROOT` 只允许扫描统计,不允许普通清理删除。 + +## 8. 任务互斥和运行保护 + +执行清理前必须检查活动任务: + +- 存在 WebP 构建任务时,禁止清理 `backend\image_cache`; +- 存在资产扫描任务时,禁止清理预览图源缓存; +- 存在 D-InSAR 生产任务时,禁止清理 `DINSAR_TASK_POOL_ROOT` 和 D-InSAR runtime; +- 存在 SBAS 生产任务时,禁止清理 `SBAS_TASK_POOL_ROOT`、`GAMMA_SBAS_WORK_ROOT` 和 SBAS runtime; +- 存在 GF3 标准化或 WebP 生成任务时,禁止清理 `GF3_TASK_POOL_ROOT`; +- 禁止清理任何 `PENDING` / `RUNNING` 任务关联的目录。 + +后端实现应使用任务类型锁或 PostgreSQL advisory lock,避免多个清理任务并发执行。 + +## 9. 建议默认保留策略 + +以下值是初始建议,后续可放入 `.env`: + +| 配置 | 建议默认值 | 含义 | +| --- | --- | --- | +| `RUNTIME_CLEANUP_TASK_LOG_RETENTION_DAYS` | 30 | 已结束任务日志保留天数 | +| `RUNTIME_CLEANUP_TASK_RECORD_RETENTION_DAYS` | 90 | 已结束任务记录保留天数 | +| `RUNTIME_CLEANUP_IMAGE_CACHE_RETENTION_DAYS` | 60 | 无引用缓存保留天数 | +| `RUNTIME_CLEANUP_RUNTIME_RETENTION_DAYS` | 30 | 运行时临时目录保留天数 | +| `RUNTIME_CLEANUP_FAILED_RUNTIME_RETENTION_DAYS` | 7 | 失败任务临时目录保留天数 | +| `RUNTIME_CLEANUP_TASK_POOL_RETENTION_DAYS` | 30 | 可重建 Task_Pool materialize 目录保留天数 | +| `RUNTIME_CLEANUP_QUARANTINE_RETENTION_DAYS` | 14 | 隔离区永久删除前保留天数 | + +默认只启用低风险类别。Task_Pool 和中间文件清理应默认关闭,需要用户显式勾选。 + +## 10. 前端工作台设计方向 + +入口建议放在“运行维护 / 存储治理”,而不是放在资产扫描、生产准备或数据分发按钮旁边。 + +页面结构建议: + +1. 存储概览 + - 按磁盘卷展示总容量、已用、剩余、压力等级; + - 展示系统可治理目录的估算占用; + - 单独标注“受保护源数据”和“可清理派生数据”。 + +2. 清理类别 + - 任务日志; + - 图像缓存; + - 运行时临时目录; + - Task_Pool materialize; + - 隔离区。 + +3. 清理计划 + - 预计释放空间; + - 文件数量; + - 数据库记录数量; + - 风险等级; + - 样例路径; + - 受保护跳过项。 + +4. 执行与审计 + - 后台任务进度; + - 单项失败列表; + - 释放空间统计; + - 可下载 JSON 报告。 + +界面文案必须明确区分: + +- “源数据,不会删除”; +- “缓存,可重建”; +- “运行临时目录,任务结束后可清”; +- “正式成果,不在本功能删除”。 + +## 11. 建议接口 + +后续实现可采用以下接口: + +```text +GET /maintenance/storage/overview +POST /maintenance/storage-cleanup/plan +POST /maintenance/storage-cleanup/runs +GET /maintenance/storage-cleanup/runs/{run_id} +GET /maintenance/storage-cleanup/runs/{run_id}/items +POST /maintenance/storage-cleanup/quarantine/purge-plan +POST /maintenance/storage-cleanup/quarantine/purge +``` + +`plan` 接口只返回计划,不创建删除动作。`runs` 接口基于某次计划执行,并创建后台任务。 + +## 12. 实施阶段 + +### 阶段 0:文档维护 + +- 固化清理边界; +- 确认不清源压缩包、不清 GF3 `_geo`、不清 DEM、精轨和正式成果; +- 后续设计和编码必须引用本文档。 + +### 阶段 1:低风险清理 + +- 存储概览; +- 任务日志清理; +- 旧任务记录清理; +- 无引用缓存 dry-run; +- 清理任务审计报告。 + +### 阶段 2:缓存治理 + +- WebP 缓存计划; +- 旧版本缓存清理; +- 数据库 `preview_cache_*` 同步; +- 缓存按需重建入口。 + +### 阶段 3:运行时治理 + +- `production_runtime`、IDL、PyINT、WSL job、GF3 runtime 清理; +- 运行任务保护; +- 隔离区机制。 + +### 阶段 4:Task_Pool materialize 治理 + +- D-InSAR / SBAS Task_Pool 目录识别; +- 与生产批次、生产运行、结果 catalog 关联; +- 可重建性验证; +- 用户确认后清理或隔离。 + +### 阶段 5:成果归档专项 + +- 不纳入普通清理; +- 单独设计结果产品归档、下线、删除和恢复流程。 + +## 13. 验收标准 + +后续实现完成后,至少满足: + +1. dry-run 不改文件、不改业务表; +2. 清理计划能解释每一项为什么可清; +3. 源压缩包、GF3 `_geo`、DEM、精轨池和正式成果不会出现在普通清理执行项中; +4. 删除 WebP 缓存后,数据库状态不会继续显示 `READY`; +5. 活动任务相关目录不会被清理; +6. 所有删除动作有审计记录; +7. 清理失败不会导致整批数据库状态不一致; +8. 前端能展示释放空间、失败项和跳过原因。 + +## 14. 与现有文档的关系 + +- 三类数据本机生产边界以 `THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md` 为准。 +- 源压缩包管理和按需 materialize 以 `UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md` 为准。 +- 源压缩包完整性审计以 `SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md` 为准。 +- D-InSAR Task_Pool 和中间文件治理参考 `DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md`。 +- 正式成果包和 result catalog 以 `PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md` 为准。 + +本文档只定义存储压力治理边界,不替代源数据、生产准备、结果管理或完整性审计文档。 diff --git a/docs/GAMMA_SBAS_EXPERT_CORRECT_IMPLEMENTATION_ROUTE_20260607.md b/docs/archived_20260623/GAMMA_SBAS_EXPERT_CORRECT_IMPLEMENTATION_ROUTE_20260607.md similarity index 100% rename from docs/GAMMA_SBAS_EXPERT_CORRECT_IMPLEMENTATION_ROUTE_20260607.md rename to docs/archived_20260623/GAMMA_SBAS_EXPERT_CORRECT_IMPLEMENTATION_ROUTE_20260607.md diff --git a/docs/GAMMA_SBAS_EXPERT_WORKFLOW_AUDIT_20260608.md b/docs/archived_20260623/GAMMA_SBAS_EXPERT_WORKFLOW_AUDIT_20260608.md similarity index 100% rename from docs/GAMMA_SBAS_EXPERT_WORKFLOW_AUDIT_20260608.md rename to docs/archived_20260623/GAMMA_SBAS_EXPERT_WORKFLOW_AUDIT_20260608.md diff --git a/docs/GAMMA_SBAS_RUNTIME_OBSERVATIONS_20260611.md b/docs/archived_20260623/GAMMA_SBAS_RUNTIME_OBSERVATIONS_20260611.md similarity index 100% rename from docs/GAMMA_SBAS_RUNTIME_OBSERVATIONS_20260611.md rename to docs/archived_20260623/GAMMA_SBAS_RUNTIME_OBSERVATIONS_20260611.md diff --git a/docs/GAMMA_SBAS_ZERO_RATE_VALID_VISUALIZATION_DESIGN_20260608.md b/docs/archived_20260623/GAMMA_SBAS_ZERO_RATE_VALID_VISUALIZATION_DESIGN_20260608.md similarity index 100% rename from docs/GAMMA_SBAS_ZERO_RATE_VALID_VISUALIZATION_DESIGN_20260608.md rename to docs/archived_20260623/GAMMA_SBAS_ZERO_RATE_VALID_VISUALIZATION_DESIGN_20260608.md diff --git a/docs/GF3_WATER_EXTRACTION_INTEGRATION_20260615.md b/docs/archived_20260623/GF3_WATER_EXTRACTION_INTEGRATION_20260615.md similarity index 100% rename from docs/GF3_WATER_EXTRACTION_INTEGRATION_20260615.md rename to docs/archived_20260623/GF3_WATER_EXTRACTION_INTEGRATION_20260615.md diff --git a/docs/LANDSAR_SBAS_ARCHIVE_20260606.md b/docs/archived_20260623/LANDSAR_SBAS_ARCHIVE_20260606.md similarity index 100% rename from docs/LANDSAR_SBAS_ARCHIVE_20260606.md rename to docs/archived_20260623/LANDSAR_SBAS_ARCHIVE_20260606.md diff --git a/docs/LANDSAR_SBAS_INSAR_INTEGRATION_DESIGN.md b/docs/archived_20260623/LANDSAR_SBAS_INSAR_INTEGRATION_DESIGN.md similarity index 100% rename from docs/LANDSAR_SBAS_INSAR_INTEGRATION_DESIGN.md rename to docs/archived_20260623/LANDSAR_SBAS_INSAR_INTEGRATION_DESIGN.md diff --git a/docs/LandSAR_API服务接入采购需求说明书_20260604.md b/docs/archived_20260623/LandSAR_API服务接入采购需求说明书_20260604.md similarity index 100% rename from docs/LandSAR_API服务接入采购需求说明书_20260604.md rename to docs/archived_20260623/LandSAR_API服务接入采购需求说明书_20260604.md diff --git a/docs/archived_20260623/README.md b/docs/archived_20260623/README.md new file mode 100644 index 0000000..1c369ba --- /dev/null +++ b/docs/archived_20260623/README.md @@ -0,0 +1,22 @@ +# 2026-06-23 文档归档说明 + +本目录保存 2026-06-23 文档治理时从 `docs/` 根目录移出的历史、实验、过程和阶段性文档。 + +归档原则: + +- `docs/INDEX.md` 列出的文档继续作为当前有效事实入口。 +- 本目录文档只作为历史参考,不再直接指导当前系统设计、部署或生产操作。 +- 如果需要重新启用某份归档文档,必须先复核其内容与当前代码、数据库结构、生产流程是否一致,再移回 `docs/` 并加入 `docs/INDEX.md`。 + +本次归档文件: + +- `GAMMA_SBAS_EXPERT_CORRECT_IMPLEMENTATION_ROUTE_20260607.md` +- `GAMMA_SBAS_EXPERT_WORKFLOW_AUDIT_20260608.md` +- `GAMMA_SBAS_RUNTIME_OBSERVATIONS_20260611.md` +- `GAMMA_SBAS_ZERO_RATE_VALID_VISUALIZATION_DESIGN_20260608.md` +- `GF3_WATER_EXTRACTION_INTEGRATION_20260615.md` +- `LANDSAR_SBAS_ARCHIVE_20260606.md` +- `LANDSAR_SBAS_INSAR_INTEGRATION_DESIGN.md` +- `LandSAR_API服务接入采购需求说明书_20260604.md` +- `SBAS_INSAR_GAMMA_EXPERT_WORKFLOW_REVIEW_20260603.md` +- `SENTINEL1_GAMMA_SBAS_NO_STITCH_DESIGN.md` diff --git a/docs/SBAS_INSAR_GAMMA_EXPERT_WORKFLOW_REVIEW_20260603.md b/docs/archived_20260623/SBAS_INSAR_GAMMA_EXPERT_WORKFLOW_REVIEW_20260603.md similarity index 100% rename from docs/SBAS_INSAR_GAMMA_EXPERT_WORKFLOW_REVIEW_20260603.md rename to docs/archived_20260623/SBAS_INSAR_GAMMA_EXPERT_WORKFLOW_REVIEW_20260603.md diff --git a/docs/SENTINEL1_GAMMA_SBAS_NO_STITCH_DESIGN.md b/docs/archived_20260623/SENTINEL1_GAMMA_SBAS_NO_STITCH_DESIGN.md similarity index 100% rename from docs/SENTINEL1_GAMMA_SBAS_NO_STITCH_DESIGN.md rename to docs/archived_20260623/SENTINEL1_GAMMA_SBAS_NO_STITCH_DESIGN.md diff --git a/frontend/src/AiAnalysisPanel.jsx b/frontend/src/AiAnalysisPanel.jsx index 2b926c1..1e9646d 100644 --- a/frontend/src/AiAnalysisPanel.jsx +++ b/frontend/src/AiAnalysisPanel.jsx @@ -26,7 +26,6 @@ export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) { taskTypes: ['AI_DIAGNOSIS'], showRecent: true, recentLimit: 1, - pollRecentMs: 10000, }); // 状态 diff --git a/frontend/src/App.css b/frontend/src/App.css index d66a95b..ea1327a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -18,8 +18,8 @@ --color-warning: #f59e0b; --color-info: #0ea5e9; --color-envi: #0ea5a4; - --shadow-panel: 0 16px 36px rgba(15, 23, 42, 0.1); - --shadow-soft: 0 4px 14px rgba(15, 23, 42, 0.08); + --shadow-panel: 0 8px 18px rgba(15, 23, 42, 0.08); + --shadow-soft: 0 2px 8px rgba(15, 23, 42, 0.07); } *, *::before, *::after { @@ -35,9 +35,7 @@ html, body { font-family: var(--font-sans); font-size: 14px; background-color: var(--color-bg); - background-image: radial-gradient(circle at 15% 20%, #f8fbff 0%, transparent 35%), - radial-gradient(circle at 85% 0%, #eef6ff 0%, transparent 45%), - linear-gradient(180deg, #eef2f7 0%, #f7f9fc 100%); + background-image: linear-gradient(180deg, #eef2f7 0%, #f7f9fc 100%); color: var(--color-text-primary); } @@ -57,24 +55,9 @@ html, body { } .main-layout--standalone { - padding: 14px 16px 18px; + padding: 12px 14px 16px; align-items: stretch; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0)) no-repeat, - radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 36%); -} - -.panel-resizer { - flex: 0 0 6px; - cursor: col-resize; - background: linear-gradient(180deg, rgba(15, 23, 42, 0.05), rgba(15, 23, 42, 0.02)); - border-left: 1px solid rgba(15, 23, 42, 0.06); - border-right: 1px solid rgba(15, 23, 42, 0.06); - transition: background 0.2s ease; -} - -.panel-resizer:hover { - background: rgba(37, 99, 235, 0.12); + background: #f8fafc; } .center-container { @@ -90,18 +73,12 @@ html, body { width: 300px; flex-shrink: 0; background-color: var(--color-panel-bg); - background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 255, 255, 1)); border-right: 1px solid var(--color-border-strong); box-shadow: var(--shadow-panel); display: flex; flex-direction: column; overflow: hidden; } -.right-panel { - border-right: none; - border-left: 1px solid var(--color-border-strong); -} - .panel header { padding: 10px 15px; border-bottom: 1px solid var(--color-border); @@ -706,7 +683,6 @@ button:focus-visible { overflow: hidden; width: 100%; box-shadow: var(--shadow-soft); - border-left: 4px solid var(--color-accent); } .analysis-header { @@ -945,17 +921,6 @@ button:focus-visible { color: var(--color-success); } -.envi-status { - font-family: var(--font-mono); - font-size: 10px; - font-weight: bold; - color: var(--color-envi); - border: 1px solid var(--color-envi); - border-radius: 3px; - padding: 0 2px; - line-height: 1; -} - input[type="checkbox"] { margin: 0; } @@ -977,9 +942,9 @@ input[type="checkbox"] { .panel-tabs { display: flex; flex-direction: column; - gap: 6px; - padding: 8px 10px 10px; - background-color: var(--color-panel-muted); + gap: 8px; + padding: 10px 12px 12px; + background: #f8fafc; border-bottom: 1px solid var(--color-border); } @@ -990,34 +955,55 @@ input[type="checkbox"] { scrollbar-gutter: stable both-edges; } +.panel-tabs .group-tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + overflow: visible; +} + .panel-tabs .group-tabs button { - font-size: 0.86em; - padding: 8px 12px; + width: 100%; + font-size: 12px; + padding: 8px 8px; font-weight: 700; + background: #fff; + border: 1px solid var(--color-border); + border-radius: 6px; + box-shadow: none; } .panel-tabs .group-tabs button.active-tab { - background-color: var(--color-accent); + background-color: #172033; + border-color: #172033; color: #fff; - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35); + box-shadow: none; } .panel-tabs .section-tabs button { - font-size: 0.78em; - padding: 5px 10px; + font-size: 12px; + padding: 6px 10px; font-weight: 700; - background-color: rgba(255, 255, 255, 0.78); + background-color: #fff; + border: 1px solid var(--color-border); + border-radius: 6px; + box-shadow: none; } .panel-tabs .section-tabs button.active-tab { - background-color: #dbeafe; + background-color: #eaf1ff; color: var(--color-accent-strong); - box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.18); + border-color: rgba(37, 99, 235, 0.28); + box-shadow: none; } .panel-tabs .sub-tabs button { - font-size: 0.8em; - padding: 6px 10px; + font-size: 12px; + padding: 7px 10px; + background: #fff; + border: 1px solid transparent; + border-radius: 6px; + box-shadow: none; } .tabs-header button { flex: 0 0 auto; @@ -1027,21 +1013,22 @@ input[type="checkbox"] { font-size: 0.85em; font-weight: 600; cursor: pointer; - transition: all 0.2s; - border-radius: 999px; + transition: background-color 0.2s, color 0.2s, border-color 0.2s; + border-radius: 6px; color: var(--color-text-secondary); min-height: 34px; line-height: 1.1; white-space: nowrap; } .tabs-header button:hover { - background-color: #e9eff8; + background-color: #eef3fb; color: var(--color-text-primary); } .tabs-header button.active-tab { color: var(--color-accent-strong); - background-color: var(--color-accent-soft); - box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.18); + background-color: #eaf1ff; + border-color: rgba(37, 99, 235, 0.28); + box-shadow: none; } .left-tabs button { @@ -1687,139 +1674,536 @@ input[type="checkbox"] { word-break: break-all; } -/* Statistics Dashboard Modal Styles */ -.statistics-modal { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background-color: rgba(15, 23, 42, 0.55); - backdrop-filter: blur(6px); - z-index: 1050; - display: flex; - justify-content: center; - align-items: center; +.statistics-workspace { + min-height: 100%; + background: #eef2f7; + padding: 16px; } .statistics-content { - background: white; - border-radius: 8px; - padding: 20px; - width: 90vw; - height: 90vh; - max-width: 1400px; - box-shadow: var(--shadow-panel); - border: 1px solid var(--color-border); + width: min(100%, 1480px); + margin: 0 auto; display: flex; flex-direction: column; + gap: 12px; } -.statistics-header { +.statistics-hero { display: flex; justify-content: space-between; - align-items: center; - border-bottom: 1px solid var(--color-border); - padding-bottom: 15px; - margin-bottom: 20px; + align-items: flex-start; + gap: 18px; + background: #ffffff; + border: 1px solid #d5dfeb; + border-radius: 8px; + padding: 16px 18px; } -.statistics-header h1 { +.statistics-hero h1 { margin: 0; - font-size: 1.5em; + color: #0f172a; + font-size: 23px; + line-height: 1.25; + letter-spacing: 0; +} + +.statistics-hero p { + margin: 6px 0 0; + color: #475569; + font-size: 13px; + line-height: 1.6; + max-width: 78ch; +} + +.statistics-hero-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; + flex: 0 0 auto; +} + +.statistics-hero-actions span { + color: #475569; + font-size: 12px; + font-weight: 700; +} + +.statistics-hero-actions button { + border: 1px solid #1d4ed8; + border-radius: 6px; + background: #1d4ed8; + color: #ffffff; + cursor: pointer; + font-size: 12px; + font-weight: 800; + padding: 7px 12px; +} + +.statistics-hero-actions button:disabled { + border-color: #94a3b8; + background: #94a3b8; + cursor: wait; +} + +.statistics-state { + background: #ffffff; + border: 1px solid #d5dfeb; + border-radius: 8px; + color: #475569; + padding: 14px; + text-align: center; +} + +.statistics-state--error { + color: #991b1b; + background: #fef2f2; + border-color: #fecaca; +} + +.statistics-skeleton-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.statistics-skeleton { + min-height: 130px; + border: 1px solid #d5dfeb; + border-radius: 8px; + background: #ffffff; + position: relative; + overflow: hidden; +} + +.statistics-skeleton::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(37, 99, 235, 0.08), transparent); + transform: translateX(-100%); + animation: statistics-skeleton 1.3s ease-out infinite; +} + +@keyframes statistics-skeleton { + to { + transform: translateX(100%); + } } .statistics-kpi-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; - margin-bottom: 12px; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 10px; } .statistics-kpi-card { - background: #f8fafc; - border: 1px solid var(--color-border); - border-radius: 6px; - padding: 10px 12px; + background: #ffffff; + border: 1px solid #d5dfeb; + border-radius: 8px; + padding: 12px; + min-width: 0; +} + +.statistics-kpi-card--success { + background: #f0fdf4; + border-color: #bbf7d0; +} + +.statistics-kpi-card--warning { + background: #fffbeb; + border-color: #fde68a; +} + +.statistics-kpi-card--danger { + background: #fef2f2; + border-color: #fecaca; } .statistics-kpi-label { + color: #475569; font-size: 12px; - color: var(--color-text-secondary); - margin-bottom: 4px; + font-weight: 800; + margin-bottom: 6px; } -.statistics-kpi-value { - font-size: 22px; - font-weight: 700; - color: var(--color-text-primary); +.statistics-kpi-main { + display: flex; + align-items: baseline; + gap: 5px; + min-width: 0; } -.statistics-alerts { +.statistics-kpi-main strong { + color: #0f172a; + font-size: 25px; + line-height: 1.05; + font-weight: 850; +} + +.statistics-kpi-main span { + color: #475569; + font-size: 12px; + font-weight: 800; +} + +.statistics-kpi-note { + margin-top: 7px; + color: #475569; + font-size: 12px; + line-height: 1.45; +} + +.statistics-command-grid { + display: grid; + grid-template-columns: minmax(0, 2.05fr) minmax(360px, 0.95fr); + gap: 12px; + align-items: stretch; +} + +.statistics-map-panel { + min-height: 520px; + background: #ffffff; + border: 1px solid #d5dfeb; + border-radius: 8px; + padding: 14px; display: flex; flex-direction: column; - gap: 8px; - margin-bottom: 12px; + gap: 12px; + overflow: hidden; } -.statistics-alert-item { - border-radius: 6px; - padding: 8px 10px; - font-size: 13px; - border: 1px solid; -} - -.statistics-alert-item.ok { - background: #ecfdf3; - border-color: #86efac; - color: #166534; -} - -.statistics-alert-item.warn { - background: #fffbeb; - border-color: #fcd34d; - color: #92400e; -} - -.statistics-alert-item.error { - background: #fef2f2; - border-color: #fca5a5; - color: #991b1b; -} - -.close-button { - background: none; - border: none; - font-size: 2rem; - font-weight: bold; - line-height: 1; - color: var(--color-text-primary); - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); - opacity: .5; - cursor: pointer; -} -.close-button:hover { - opacity: .8; -} - -.statistics-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); - gap: 20px; - overflow-y: auto; - flex-grow: 1; - padding: 10px; -} - -.chart-container { - background: var(--color-panel-muted); - border: 1px solid var(--color-border); - border-radius: 6px; - padding: 15px; +.statistics-map-header { display: flex; - justify-content: center; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} + +.statistics-map-tools { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; +} + +.statistics-segmented-control { + display: inline-flex; + padding: 2px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #f8fafc; +} + +.statistics-segmented-control button { + border: 0; + border-radius: 4px; + background: transparent; + color: #475569; + cursor: pointer; + font-size: 12px; + font-weight: 800; + line-height: 1; + padding: 7px 10px; +} + +.statistics-segmented-control button:hover { + color: #0f172a; +} + +.statistics-segmented-control button.is-active { + background: #1d4ed8; + color: #ffffff; +} + +.statistics-map-header h2 { + margin: 0; + color: #0f172a; + font-size: 17px; + line-height: 1.35; +} + +.statistics-map-header span { + display: inline-block; + margin-top: 4px; + color: #475569; + font-size: 12px; + font-weight: 700; +} + +.statistics-map-chart { + flex: 1 1 auto; + min-height: 440px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: + linear-gradient(#edf2f7 1px, transparent 1px), + linear-gradient(90deg, #edf2f7 1px, transparent 1px), + #f8fafc; + background-size: 34px 34px; + overflow: hidden; +} + +.statistics-family-list { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; + align-items: flex-start; +} + +.statistics-family-item { + display: grid; + grid-template-columns: 10px minmax(72px, 1fr) minmax(42px, auto); + column-gap: 6px; + row-gap: 3px; align-items: center; - min-height: 350px; + color: #334155; + border: 1px solid #d5dfeb; + border-radius: 6px; + background: #f8fafc; + min-width: 144px; + padding: 6px 8px; + font-size: 11px; + font-weight: 800; +} + +.statistics-family-item strong { + color: #0f172a; + justify-self: end; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.statistics-family-item em { + grid-column: 2 / 4; + justify-self: end; + color: #64748b; + font-style: normal; + font-weight: 700; + white-space: nowrap; +} + +.statistics-family-swatch { + width: 8px; + height: 8px; + border-radius: 999px; + justify-self: center; +} + +.statistics-side-stack, +.statistics-dashboard-grid, +.statistics-bottom-grid { + display: grid; + gap: 12px; +} + +.statistics-side-stack { + grid-template-rows: repeat(2, minmax(0, 1fr)); +} + +.statistics-dashboard-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.statistics-bottom-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.statistics-section { + background: #ffffff; + border: 1px solid #d5dfeb; + border-radius: 8px; + padding: 12px; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.statistics-section-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + border-bottom: 1px solid #e2e8f0; + padding-bottom: 8px; +} + +.statistics-section-header h2 { + margin: 0; + color: #0f172a; + font-size: 15px; + line-height: 1.35; +} + +.statistics-section-header span { + color: #64748b; + font-size: 12px; + font-weight: 700; + text-align: right; +} + +.statistics-echart { + position: relative; + width: 100%; + min-height: 260px; +} + +.statistics-echart-canvas { + width: 100%; + height: 100%; + min-height: inherit; +} + +.statistics-chart-sm { + min-height: 205px; +} + +.statistics-chart-md { + min-height: 245px; +} + +.statistics-chart-empty { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: #64748b; + font-size: 13px; +} + +.statistics-chart-empty--error { + color: #b91c1c; + background: rgba(255, 255, 255, 0.78); + text-align: center; +} + +.statistics-orbit-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.statistics-orbit-summary div { + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #f8fafc; + padding: 8px; +} + +.statistics-orbit-summary span { + display: block; + color: #64748b; + font-size: 12px; +} + +.statistics-orbit-summary strong { + display: block; + margin-top: 3px; + color: #0f172a; + font-size: 17px; +} + +.statistics-mini-bars { + display: grid; + gap: 8px; +} + +.statistics-mini-bars div { + position: relative; + display: grid; + grid-template-columns: 82px 56px 1fr; + gap: 8px; + align-items: center; + color: #334155; + font-size: 12px; + font-weight: 800; +} + +.statistics-mini-bars i { + display: block; + height: 9px; + border-radius: 999px; + background: #16a34a; +} + +.statistics-run-list, +.statistics-inventory-list { + display: grid; + gap: 8px; + max-height: 310px; + overflow: auto; + padding-right: 2px; +} + +.statistics-run-row, +.statistics-inventory-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 10px; + align-items: center; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #f8fafc; + padding: 8px; + min-width: 0; +} + +.statistics-run-row strong, +.statistics-inventory-row strong { + display: block; + color: #0f172a; + font-size: 12px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.statistics-run-row span, +.statistics-inventory-row span { + display: block; + color: #64748b; + font-size: 11px; + margin-top: 2px; +} + +.statistics-run-row b, +.statistics-inventory-row b { + font-size: 11px; +} + +.statistics-run-row em, +.statistics-inventory-row em { + color: #475569; + font-style: normal; + font-size: 12px; + font-weight: 800; +} + +.statistics-empty-line { + color: #64748b; + font-size: 13px; + padding: 16px 4px; + text-align: center; +} + +.statistics-footer-note { + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + color: #64748b; + font-size: 12px; + font-weight: 700; + padding: 2px 2px 6px; +} + +@media (prefers-reduced-motion: reduce) { + .statistics-skeleton::after { + animation: none; + } } .chart-container-full { @@ -2090,6 +2474,22 @@ input[type="checkbox"] { pointer-events: none; } +@media (max-width: 980px) { + .statistics-kpi-grid, + .statistics-grid, + .statistics-plan-grid { + grid-template-columns: 1fr; + } + + .statistics-header { + flex-direction: column; + } + + .statistics-header-meta { + justify-content: flex-start; + } +} + .task-center-button { pointer-events: auto; display: inline-flex; @@ -2142,7 +2542,7 @@ input[type="checkbox"] { width: 60px; height: 60px; border: 4px solid rgba(255, 255, 255, 0.1); - border-top: 4px solid var(--color-accent); + border-top-color: rgba(37, 99, 235, 0.9); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 20px; @@ -2225,7 +2625,6 @@ input[type="checkbox"] { .task-progress-fill { height: 100%; background-color: var(--color-accent); - transition: width 0.3s ease-out; box-shadow: 0 0 14px rgba(37, 99, 235, 0.6); } @@ -2334,12 +2733,13 @@ input[type="checkbox"] { /* Health Check Panel */ .health-panel { padding: 16px; - background: var(--color-panel-bg); + background: #f8fafc; border-top: 1px solid var(--color-border); height: 100%; display: flex; flex-direction: column; - gap: 12px; + gap: 14px; + overflow: auto; } .health-header { @@ -2347,21 +2747,24 @@ input[type="checkbox"] { justify-content: space-between; align-items: center; gap: 12px; + padding: 2px 0 4px; } .health-title { - font-size: 1.1em; - font-weight: 600; + font-size: 1.15em; + font-weight: 700; color: var(--color-text-primary); + line-height: 1.3; } .health-subtitle { font-size: 0.85em; - color: var(--color-text-muted); + color: var(--color-text-secondary); + margin-top: 2px; } .health-refresh { - padding: 6px 12px; + padding: 7px 12px; background: var(--color-accent); color: #fff; border: none; @@ -2377,10 +2780,11 @@ input[type="checkbox"] { .health-summary { display: grid; - gap: 6px; - padding: 10px 12px; - border-radius: 10px; - background: var(--color-panel-muted); + grid-template-columns: repeat(3, minmax(150px, 1fr)); + gap: 10px; + padding: 12px; + border-radius: 8px; + background: #ffffff; border: 1px solid var(--color-border); } @@ -2388,40 +2792,126 @@ input[type="checkbox"] { display: flex; justify-content: space-between; align-items: center; + gap: 10px; font-size: 0.9em; color: var(--color-text-secondary); + min-width: 0; +} + +.health-summary-item > span:last-child { + text-align: right; +} + +.health-signal-row { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 8px; + padding-top: 10px; + border-top: 1px solid var(--color-border); +} + +.health-signal { + display: grid; + gap: 2px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--color-border); + background: #f8fafc; +} + +.health-signal strong { + font-size: 0.9em; + color: var(--color-text-primary); +} + +.health-signal-label, +.health-signal-detail { + color: var(--color-text-muted); + font-size: 0.76em; +} + +.health-signal.ok { + border-color: rgba(22, 163, 74, 0.26); + background: rgba(22, 163, 74, 0.06); +} + +.health-signal.fail { + border-color: rgba(220, 38, 38, 0.26); + background: rgba(220, 38, 38, 0.06); +} + +.health-sections { + display: grid; + gap: 14px; +} + +.health-section { + display: grid; + gap: 10px; +} + +.health-section-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-end; + padding: 0 2px; +} + +.health-section-header h3 { + margin: 0; + font-size: 0.98em; + color: var(--color-text-primary); +} + +.health-section-header p { + margin: 3px 0 0; + color: var(--color-text-secondary); + font-size: 0.82em; + line-height: 1.5; } .health-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; } +.health-grid--ops { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + .health-card { padding: 12px; - border-radius: 10px; + border-radius: 8px; border: 1px solid var(--color-border); background: #fff; - box-shadow: var(--shadow-soft); display: flex; flex-direction: column; gap: 6px; } .health-card-title { - font-weight: 600; + font-weight: 700; color: var(--color-text-primary); margin-bottom: 2px; + font-size: 0.95em; } .health-card-row { display: flex; justify-content: space-between; + gap: 12px; font-size: 0.85em; color: var(--color-text-secondary); } +.health-card-row > span:last-child { + text-align: right; + word-break: break-word; +} + .health-card-note { font-size: 0.8em; color: var(--color-text-muted); @@ -2440,6 +2930,391 @@ input[type="checkbox"] { color: var(--color-success); } +.health-legacy-diagnostic { + background: #f8fafc; +} + +.health-legacy-diagnostic summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + cursor: pointer; + font-weight: 700; + color: var(--color-text-primary); + list-style: none; +} + +.health-legacy-diagnostic summary::-webkit-details-marker { + display: none; +} + +.health-legacy-diagnostic[open] summary { + padding-bottom: 8px; + border-bottom: 1px solid var(--color-border); +} + +.health-inline-button { + padding: 4px 9px; + border-radius: 6px; + border: 1px solid var(--color-border); + background: #ffffff; + color: var(--color-text-secondary); + font-size: 0.78em; + cursor: pointer; +} + +.health-inline-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.health-maintenance-stack { + display: grid; + gap: 10px; +} + +.health-action-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 12px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: #fff; +} + +.health-action-card h4 { + margin: 0; + font-size: 0.92em; + color: var(--color-text-primary); +} + +.health-action-card p { + margin: 4px 0 0; + color: var(--color-text-secondary); + font-size: 0.82em; + line-height: 1.5; +} + +.health-action-button { + flex: 0 0 auto; + padding: 7px 12px; + border: 1px solid #1d4ed8; + border-radius: 6px; + background: #1d4ed8; + color: #fff; + font-size: 0.82em; + font-weight: 700; + cursor: pointer; +} + +.health-action-button:disabled { + opacity: 0.62; + cursor: not-allowed; +} + +.log-management-panel { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: #fff; +} + +.log-management-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; +} + +.log-management-header h3, +.log-modal-header h3 { + margin: 0; + color: var(--color-text-primary); + font-size: 0.95em; +} + +.log-management-header p, +.log-modal-header p { + margin: 4px 0 0; + color: var(--color-text-secondary); + font-size: 0.82em; + line-height: 1.5; +} + +.log-management-controls { + display: flex; + align-items: flex-end; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.ops-field { + display: grid; + gap: 4px; + color: var(--color-text-secondary); + font-size: 0.78em; +} + +.ops-field select, +.log-modal-tools input { + min-height: 32px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: #fff; + color: var(--color-text-primary); + font-size: 0.86em; +} + +.ops-field select { + min-width: 120px; + padding: 5px 28px 5px 9px; +} + +.log-modal-tools input { + min-width: 220px; + flex: 1 1 260px; + padding: 6px 10px; +} + +.ops-button { + min-height: 32px; + padding: 6px 11px; + border-radius: 6px; + border: 1px solid var(--color-border); + background: #fff; + color: var(--color-text-primary); + font-size: 0.82em; + font-weight: 700; + cursor: pointer; + white-space: nowrap; +} + +.ops-button--primary { + border-color: #1d4ed8; + background: #1d4ed8; + color: #fff; +} + +.ops-button--secondary { + border-color: var(--color-border); + background: #f8fafc; +} + +.ops-button--danger { + border-color: rgba(220, 38, 38, 0.28); + background: rgba(220, 38, 38, 0.08); + color: var(--color-danger); +} + +.ops-button--sm { + min-height: 28px; + padding: 4px 9px; + font-size: 0.78em; +} + +.ops-button:disabled { + opacity: 0.58; + cursor: not-allowed; +} + +.ops-message { + padding: 8px 10px; + border-radius: 6px; + font-size: 0.82em; +} + +.ops-message--success { + border: 1px solid rgba(22, 163, 74, 0.25); + background: rgba(22, 163, 74, 0.08); + color: var(--color-success); +} + +.ops-message--error { + border: 1px solid rgba(220, 38, 38, 0.25); + background: rgba(220, 38, 38, 0.08); + color: var(--color-danger); +} + +.log-empty-state { + display: flex; + min-height: 96px; + align-items: center; + justify-content: center; + border: 1px dashed var(--color-border); + border-radius: 8px; + color: var(--color-text-secondary); + font-size: 0.86em; + background: #f8fafc; +} + +.log-table-wrap { + overflow-x: auto; + border: 1px solid var(--color-border); + border-radius: 8px; +} + +.log-table { + width: 100%; + border-collapse: collapse; + min-width: 760px; + background: #fff; +} + +.log-table th { + padding: 9px 10px; + background: #f8fafc; + border-bottom: 1px solid var(--color-border); + color: var(--color-text-secondary); + font-size: 0.78em; + font-weight: 700; + text-align: left; +} + +.log-table td { + padding: 9px 10px; + border-bottom: 1px solid #eef2f7; + color: var(--color-text-primary); + font-size: 0.82em; + vertical-align: middle; +} + +.log-table tbody tr:last-child td { + border-bottom: 0; +} + +.log-file-name, +.log-number-cell { + font-family: Consolas, Monaco, "Courier New", monospace; +} + +.log-file-name { + max-width: 420px; + word-break: break-all; + color: #0f172a; +} + +.log-number-cell { + text-align: right; + white-space: nowrap; +} + +.log-type-badge { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.75em; + font-weight: 700; + border: 1px solid transparent; + white-space: nowrap; +} + +.log-type-badge--app { + color: #1d4ed8; + background: rgba(37, 99, 235, 0.1); + border-color: rgba(37, 99, 235, 0.22); +} + +.log-type-badge--task { + color: #047857; + background: rgba(5, 150, 105, 0.1); + border-color: rgba(5, 150, 105, 0.22); +} + +.log-type-badge--error { + color: var(--color-danger); + background: rgba(220, 38, 38, 0.1); + border-color: rgba(220, 38, 38, 0.22); +} + +.log-type-badge--other { + color: #475569; + background: #f1f5f9; + border-color: var(--color-border); +} + +.log-row-actions { + display: flex; + justify-content: center; + gap: 6px; +} + +.log-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(15, 23, 42, 0.48); +} + +.log-modal { + width: min(1180px, 100%); + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 8px; + background: #fff; + border: 1px solid var(--color-border); +} + +.log-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border-bottom: 1px solid var(--color-border); +} + +.log-modal-header h3 { + font-family: Consolas, Monaco, "Courier New", monospace; + word-break: break-all; +} + +.log-modal-tools { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 10px 16px; + border-bottom: 1px solid var(--color-border); + background: #f8fafc; +} + +.log-page-info { + color: var(--color-text-secondary); + font-size: 0.8em; + white-space: nowrap; +} + +.log-content-view { + flex: 1; + overflow: auto; + background: #111827; + padding: 14px 16px; +} + +.log-content-view pre { + margin: 0; + color: #d1d5db; + font-family: Consolas, Monaco, "Courier New", monospace; + font-size: 0.76em; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + .health-badge { display: inline-flex; align-items: center; @@ -2477,13 +3352,29 @@ input[type="checkbox"] { color: var(--color-text-muted); } +@media (max-width: 760px) { + .health-summary { + grid-template-columns: 1fr; + } + + .health-header, + .health-section-header { + align-items: flex-start; + flex-direction: column; + } + + .health-grid { + grid-template-columns: 1fr; + } +} + /* Top Status Bar */ .top-status-bar { display: grid; - grid-template-columns: 1.2fr 2fr 1.2fr; + grid-template-columns: minmax(320px, 1.4fr) minmax(260px, 0.9fr) minmax(320px, 1.2fr); align-items: center; gap: 16px; - padding: 10px 16px; + padding: 9px 16px; background: rgba(255, 255, 255, 0.95); border-bottom: 1px solid var(--color-border); box-shadow: var(--shadow-soft); @@ -2491,54 +3382,73 @@ input[type="checkbox"] { .status-brand { display: flex; - flex-direction: column; - gap: 2px; + align-items: center; + gap: 10px; + min-width: 0; +} + +.status-brand-logo { + width: 38px; + height: 38px; + object-fit: contain; + flex: 0 0 auto; +} + +.status-brand-copy { + min-width: 0; +} + +.brand-org { + font-size: 0.82em; + color: var(--color-text-secondary); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .brand-title { font-weight: 700; color: var(--color-text-primary); font-size: 1.05em; + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.brand-subtitle { - font-size: 0.8em; - color: var(--color-text-muted); -} - -.status-items { +.status-system-meta { display: flex; flex-wrap: wrap; - gap: 10px 14px; align-items: center; justify-content: center; + gap: 8px; + color: var(--color-text-secondary); + font-size: 0.8em; + min-width: 0; } -.status-item { +.status-license-chip { display: inline-flex; align-items: center; - gap: 6px; - font-size: 0.85em; + padding: 3px 7px; + border-radius: 999px; + border: 1px solid var(--color-border); + background: var(--color-panel-muted); color: var(--color-text-secondary); + line-height: 1.2; } -.status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--color-warning); +.status-license-chip.ok { + border-color: rgba(22, 163, 74, 0.24); + background: rgba(22, 163, 74, 0.08); + color: #166534; } -.status-dot.ok { - background: var(--color-success); -} - -.status-dot.fail { - background: var(--color-danger); -} - -.status-dot.warn { - background: var(--color-warning); +.status-license-chip.fail { + border-color: rgba(220, 38, 38, 0.24); + background: rgba(220, 38, 38, 0.08); + color: #991b1b; } .status-actions { @@ -2546,6 +3456,7 @@ input[type="checkbox"] { justify-content: flex-end; align-items: center; gap: 12px; + min-width: 0; } .status-lang-switch { @@ -2591,22 +3502,11 @@ input[type="checkbox"] { color: var(--color-text-secondary); padding: 4px 8px; border-radius: 6px; - transition: all 0.3s ease; } .status-task.has-active-tasks { background: rgba(24, 144, 255, 0.1); color: #1890ff; - animation: pulse-glow 2s ease-in-out infinite; -} - -@keyframes pulse-glow { - 0%, 100% { - box-shadow: 0 0 0 0 rgba(24, 144, 255, 0.4); - } - 50% { - box-shadow: 0 0 8px 2px rgba(24, 144, 255, 0.2); - } } .status-license { @@ -2626,7 +3526,6 @@ input[type="checkbox"] { .status-task-fill { height: 100%; background: var(--color-accent); - transition: width 0.3s ease; } .status-refresh { @@ -2644,6 +3543,22 @@ input[type="checkbox"] { cursor: not-allowed; } +@media (max-width: 1080px) { + .top-status-bar { + grid-template-columns: 1fr; + align-items: stretch; + } + + .status-system-meta { + justify-content: flex-start; + } + + .status-actions { + justify-content: flex-start; + flex-wrap: wrap; + } +} + /* Workflow Sidebar */ .workflow-header { padding: 12px 12px 8px; @@ -2744,29 +3659,40 @@ input[type="checkbox"] { } .asset-inventory-panel { - padding: 12px; + width: 100%; + max-width: 1280px; + margin: 0 auto; + padding: 6px 0 24px; display: flex; flex-direction: column; - gap: 12px; + gap: 14px; min-width: 0; } .asset-toolbar { display: flex; justify-content: space-between; - gap: 10px; + gap: 18px; align-items: flex-start; + padding: 16px 18px; + border-radius: 12px; + border: 1px solid var(--color-border); + background: #fff; } .asset-toolbar h3 { margin: 0; - font-size: 16px; + font-size: 18px; + line-height: 1.35; + color: var(--color-text-primary); } .asset-toolbar p { - margin: 4px 0 0; - color: var(--color-text-muted); - font-size: 12px; + max-width: 76ch; + margin: 8px 0 0; + color: var(--color-text-secondary); + font-size: 13px; + line-height: 1.7; } .asset-actions { @@ -2800,8 +3726,8 @@ input[type="checkbox"] { .asset-metrics { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; } .asset-metric { @@ -2974,7 +3900,93 @@ input[type="checkbox"] { font-size: 12px; } +.data-ingest-panel { + width: 100%; + max-width: 1280px; + margin: 0 auto; + padding: 6px 0 24px; + background: #f8fafc; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.data-ingest-header { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; + margin-bottom: 14px; + padding: 16px 18px; + border-radius: 12px; + border: 1px solid var(--color-border); + background: #fff; +} + +.data-ingest-header h3 { + margin: 0; + color: var(--color-text-primary); + font-size: 18px; + line-height: 1.35; +} + +.data-ingest-header p { + max-width: 76ch; + margin: 8px 0 0; + color: var(--color-text-secondary); + font-size: 13px; + line-height: 1.7; +} + +.data-ingest-signals { + display: grid; + grid-template-columns: repeat(2, minmax(116px, 1fr)); + gap: 8px; + min-width: 280px; +} + +.data-ingest-notice { + margin: 0 0 12px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--color-border); + background: #fff; + color: var(--color-text-secondary); + font-size: 13px; + line-height: 1.6; +} + +.data-ingest-notice.ok { + border-color: rgba(22, 163, 74, 0.22); + background: rgba(240, 253, 244, 0.82); + color: #166534; +} + +.data-ingest-notice.warn { + border-color: rgba(245, 158, 11, 0.28); + background: #fffbeb; + color: #92400e; +} + +.data-ingest-directory summary { + cursor: pointer; + font-weight: 700; + color: var(--color-text-primary); +} + +.data-ingest-directory p { + margin: 8px 0 10px; + color: var(--color-text-muted); + font-size: 12px; + line-height: 1.6; +} + @media (max-width: 1200px) { + .asset-metrics { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .top-status-bar { grid-template-columns: 1fr; text-align: left; @@ -2985,6 +3997,30 @@ input[type="checkbox"] { } } +@media (max-width: 760px) { + .data-ingest-header { + flex-direction: column; + } + + .data-ingest-signals { + grid-template-columns: 1fr; + min-width: 0; + width: 100%; + } + + .asset-toolbar { + flex-direction: column; + } + + .asset-actions { + justify-content: flex-start; + } + + .asset-metrics { + grid-template-columns: 1fr; + } +} + .login-page-wrapper { min-height: 100vh; background: radial-gradient(circle at 12% 10%, rgba(37, 99, 235, 0.15) 0%, transparent 42%), @@ -4082,9 +5118,11 @@ input[type="checkbox"] { } .ai-diagnosis-markdown blockquote { - border-left: 4px solid var(--color-accent); - padding-left: 16px; + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 10px 12px; margin: 12px 0; + background: rgba(248, 250, 252, 0.9); color: var(--color-text-secondary); font-style: italic; } @@ -4190,7 +5228,7 @@ input[type="checkbox"] { padding: 12px; background: var(--color-accent-soft); border-radius: 6px; - border-left: 3px solid var(--color-accent); + border: 1px solid rgba(37, 99, 235, 0.22); } .strategy-params strong { @@ -4243,167 +5281,6 @@ input[type="checkbox"] { } } -/* ============ Result Export Modal ============ */ -.result-export-modal { - width: min(620px, calc(100vw - 24px)); - display: flex; - flex-direction: column; -} -.result-export-modal .modal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; -} -.result-export-modal .modal-header h3 { - margin: 0; - font-size: 16px; -} -.result-export-modal .modal-close-btn { - background: none; - border: none; - font-size: 22px; - cursor: pointer; - color: var(--color-text-secondary); - padding: 0 4px; -} -.result-export-modal .modal-body { - flex: 1; - overflow-y: auto; -} -.export-path-section { - margin-bottom: 14px; -} -.export-path-section label { - display: block; - font-size: 13px; - color: var(--color-text-secondary); - margin-bottom: 6px; -} -.export-path-input { - width: 100%; - padding: 8px 10px; - border: 1px solid var(--color-border); - border-radius: 4px; - font-size: 13px; - box-sizing: border-box; -} -.export-select-section { - margin-bottom: 14px; -} -.export-select-header { - margin-bottom: 6px; - font-size: 13px; -} -.export-select-header label { - display: flex; - align-items: center; - gap: 6px; - cursor: pointer; -} -.export-select-hint { - margin-bottom: 8px; - font-size: 12px; - color: var(--color-text-secondary); - line-height: 1.5; -} -.export-result-list { - list-style: none; - padding: 0; - margin: 0; - max-height: 260px; - overflow-y: auto; - border: 1px solid var(--color-border); - border-radius: 4px; -} -.export-result-item { - padding: 6px 10px; - border-bottom: 1px solid #f0f0f0; - font-size: 12px; -} -.export-result-item:last-child { - border-bottom: none; -} -.export-result-item label { - display: flex; - align-items: center; - gap: 6px; - cursor: pointer; -} -.export-result-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.export-error { - color: #e53e3e; - font-size: 13px; - padding: 8px 10px; - background: #fff5f5; - border-radius: 4px; - margin-bottom: 10px; -} -.export-summary { - background: #f0fff4; - border: 1px solid #c6f6d5; - border-radius: 4px; - padding: 10px 12px; - margin-bottom: 10px; -} -.export-summary-title { - font-weight: 600; - font-size: 14px; - color: #276749; - margin-bottom: 6px; -} -.export-summary-stats { - display: flex; - gap: 14px; - font-size: 13px; - margin-bottom: 4px; -} -.export-summary-stats .stat-ok { color: #276749; } -.export-summary-stats .stat-skip { color: #975a16; } -.export-summary-stats .stat-fail { color: #e53e3e; } -.export-summary-dir { - font-size: 12px; - color: var(--color-text-secondary); - word-break: break-all; -} -.result-export-modal .modal-footer { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 16px; - padding-top: 12px; - border-top: 1px solid var(--color-border); -} -.result-export-modal .modal-footer button { - min-width: 108px; - padding: 6px 16px; - border-radius: 4px; - border: 1px solid var(--color-border); - background: #fff; - color: var(--color-text-primary); - cursor: pointer; -} -.result-export-modal .modal-footer .btn-secondary:hover:not(:disabled) { - background: var(--color-panel-muted); -} -.result-export-modal .modal-footer .btn-primary { - background: var(--color-accent); - color: #fff; - border: none; - font-weight: 600; -} -.result-export-modal .modal-footer .btn-primary:hover:not(:disabled) { - background: var(--color-accent-strong); -} -.result-export-modal .modal-footer button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - /* ============ D-InSAR Results Workspace ============ */ .dinsar-results-toolbar { @@ -4726,6 +5603,115 @@ input[type="checkbox"] { accent-color: var(--color-accent); } +/* ============ D-InSAR Production ============ */ + +.dinsar-production-shell { + width: 100%; + max-width: 1280px; + margin: 0 auto; + padding: 16px; + display: grid; + gap: 14px; + box-sizing: border-box; +} + +.dinsar-production-header { + display: grid; + grid-template-columns: minmax(280px, 1fr) minmax(420px, 0.95fr); + gap: 14px; + align-items: stretch; + padding: 14px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: #fff; +} + +.dinsar-production-header h3, +.dinsar-production-section-header h4 { + margin: 0; + color: var(--color-text-primary); + line-height: 1.35; +} + +.dinsar-production-header h3 { + font-size: 1.08em; +} + +.dinsar-production-header p, +.dinsar-production-section-header p { + margin: 4px 0 0; + color: var(--color-text-secondary); + font-size: 0.84em; + line-height: 1.55; +} + +.dinsar-production-summary { + display: grid; + grid-template-columns: repeat(4, minmax(92px, 1fr)); + gap: 8px; +} + +.dinsar-production-signal { + display: grid; + gap: 3px; + min-height: 54px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--color-border); + background: #f8fafc; + align-content: center; +} + +.dinsar-production-signal span { + color: var(--color-text-muted); + font-size: 0.76em; +} + +.dinsar-production-signal strong { + color: var(--color-text-primary); + font-size: 0.9em; + word-break: break-word; +} + +.dinsar-production-signal.ok { + border-color: rgba(22, 163, 74, 0.26); + background: rgba(22, 163, 74, 0.06); +} + +.dinsar-production-signal.warn { + border-color: rgba(245, 158, 11, 0.28); + background: rgba(245, 158, 11, 0.08); +} + +.dinsar-production-section { + display: grid; + gap: 8px; +} + +.dinsar-production-section-header { + padding: 0 2px; +} + +.dinsar-production-section-header h4 { + font-size: 0.96em; +} + +@media (max-width: 1180px) { + .dinsar-production-header { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .dinsar-production-shell { + padding: 12px; + } + + .dinsar-production-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + /* ============ D-InSAR Catalog ============ */ .dinsar-status-pill { @@ -4937,14 +5923,16 @@ input[type="checkbox"] { .dinsar-catalog-workspace { display: grid; - grid-template-columns: minmax(360px, 420px) minmax(0, 1fr); + grid-template-columns: minmax(300px, 340px) minmax(0, 1fr); gap: 14px; min-height: 0; - align-items: start; + align-items: stretch; + height: min(760px, calc(100vh - 180px)); } .panel--standalone .dinsar-catalog-workspace { - grid-template-columns: minmax(380px, 440px) minmax(0, 1fr); + grid-template-columns: minmax(300px, 340px) minmax(0, 1fr); + height: min(820px, calc(100vh - 148px)); } .dinsar-catalog-list-card, @@ -4959,13 +5947,7 @@ input[type="checkbox"] { } .dinsar-catalog-list-card { - position: sticky; - top: 16px; - max-height: min(760px, calc(100vh - 140px)); -} - -.panel--standalone .dinsar-catalog-list-card { - max-height: min(820px, calc(100vh - 112px)); + min-height: 0; } .panel--standalone { @@ -4973,7 +5955,7 @@ input[type="checkbox"] { border-right: none; border-left: none; box-shadow: none; - background: transparent; + background: #f8fafc; } .panel--standalone .panel-tabs { @@ -4985,32 +5967,32 @@ input[type="checkbox"] { .panel--standalone .panel-content { overflow-y: auto; + background: #f8fafc; } .panel-standalone-header { display: flex; justify-content: space-between; align-items: flex-start; - gap: 18px; - padding: 4px 0 18px; + gap: 14px; + padding: 4px 0 14px; } .panel-standalone-header-main { display: grid; - gap: 8px; + gap: 5px; min-width: 0; } .panel-standalone-eyebrow { font-size: 11px; font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; + letter-spacing: 0; color: var(--color-text-muted); } .panel-standalone-header-main strong { - font-size: 24px; + font-size: 20px; line-height: 1.2; color: var(--color-text-primary); } @@ -5032,7 +6014,7 @@ input[type="checkbox"] { .panel-standalone-actions button { padding: 8px 14px; - border-radius: 999px; + border-radius: 6px; background: rgba(255, 255, 255, 0.92); border: 1px solid var(--color-border); color: var(--color-text-secondary); @@ -5185,12 +6167,14 @@ input[type="checkbox"] { padding: 14px; display: grid; gap: 14px; + min-height: 0; + flex: 1 1 auto; overflow-y: auto; } .dinsar-catalog-hero { display: grid; - grid-template-columns: minmax(200px, 280px) minmax(0, 1fr); + grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); gap: 14px; } @@ -5237,7 +6221,7 @@ input[type="checkbox"] { .dinsar-catalog-kv-grid, .dinsar-catalog-detail-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } @@ -5325,19 +6309,71 @@ input[type="checkbox"] { .dinsar-products-page { width: 100%; - max-width: 1680px; + max-width: 1480px; margin: 0 auto; padding: 6px 0 24px; display: grid; - gap: 18px; + gap: 14px; } .panel--standalone .dinsar-products-page { - max-width: none; + max-width: 1480px; } .dinsar-products-hero { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; padding: 16px 18px; + border-radius: 12px; + border: 1px solid var(--color-border); + background: #fff; +} + +.dinsar-products-hero strong { + display: block; + font-size: 18px; + line-height: 1.35; + color: var(--color-text-primary); +} + +.dinsar-products-hero p { + max-width: 72ch; + margin: 8px 0 0; + color: var(--color-text-secondary); + font-size: 13px; + line-height: 1.7; +} + +.dinsar-products-signals { + display: grid; + grid-template-columns: repeat(2, minmax(116px, 1fr)); + gap: 8px; + min-width: 280px; +} + +.dinsar-products-section-head { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: flex-end; + padding: 2px 2px 0; +} + +.dinsar-products-section-head strong { + display: block; + font-size: 14px; + line-height: 1.45; + color: var(--color-text-primary); +} + +.dinsar-products-section-head span { + display: block; + margin-top: 3px; + font-size: 12px; + line-height: 1.55; + color: var(--color-text-muted); } .dinsar-products-top-grid { @@ -5351,6 +6387,9 @@ input[type="checkbox"] { padding: 14px; display: grid; gap: 12px; + border-radius: 12px; + border: 1px solid var(--color-border); + background: #fff; } .dinsar-products-card.monitor.tone-warn { @@ -5382,6 +6421,34 @@ input[type="checkbox"] { gap: 10px; } +.dinsar-products-controlled-source { + display: grid; + gap: 6px; + padding: 12px; + border-radius: 8px; + border: 1px solid var(--color-border); + background: rgba(248, 250, 252, 0.96); +} + +.dinsar-products-controlled-source span { + font-size: 11px; + font-weight: 700; + color: var(--color-text-muted); +} + +.dinsar-products-controlled-source strong { + font-size: 14px; + color: var(--color-text-primary); +} + +.dinsar-products-controlled-source p, +.dinsar-products-action-hint { + margin: 0; + font-size: 12px; + line-height: 1.6; + color: var(--color-text-secondary); +} + .dinsar-products-field { display: grid; gap: 6px; @@ -5415,6 +6482,95 @@ input[type="checkbox"] { font-size: 13px; } +.dinsar-products-catalog-section { + display: grid; + gap: 10px; +} + +.sbas-products-page { + width: 100%; + max-width: 1280px; + margin: 0 auto; + padding: 6px 0 24px; + display: grid; + gap: 14px; +} + +.sbas-products-header { + display: grid; + gap: 12px; + padding: 16px 18px; + border-radius: 12px; + border: 1px solid var(--color-border); + background: #fff; +} + +.sbas-products-header-main { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; +} + +.sbas-products-header h3 { + margin: 0; + color: var(--color-text-primary); + font-size: 18px; + line-height: 1.35; +} + +.sbas-products-header p { + max-width: 76ch; + margin: 8px 0 0; + color: var(--color-text-secondary); + font-size: 13px; + line-height: 1.7; +} + +.sbas-products-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.sbas-products-signals, +.sbas-products-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.sbas-products-section-head { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: flex-end; + padding: 2px 2px 0; +} + +.sbas-products-section-head strong { + display: block; + font-size: 14px; + line-height: 1.45; + color: var(--color-text-primary); +} + +.sbas-products-section-head span { + display: block; + margin-top: 3px; + font-size: 12px; + line-height: 1.55; + color: var(--color-text-muted); +} + +.sbas-products-workspace { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + .dinsar-monitor-card { display: grid; gap: 12px; @@ -5499,6 +6655,476 @@ input[type="checkbox"] { margin-left: auto; } +/* ============ Unified Result Extraction Workspace ============ */ +.result-extraction-page { + min-height: 100%; + padding: 16px; + display: grid; + gap: 14px; + background: #f8fafc; +} + +.result-extraction-hero { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + padding: 18px 20px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: #ffffff; +} + +.result-extraction-hero div:first-child { + display: grid; + gap: 6px; + min-width: 0; +} + +.result-extraction-hero span { + font-size: 12px; + font-weight: 800; + color: var(--color-text-muted); +} + +.result-extraction-hero strong { + font-size: 22px; + line-height: 1.2; + color: var(--color-text-primary); +} + +.result-extraction-hero p { + max-width: 900px; + margin: 0; + color: var(--color-text-secondary); + font-size: 13px; + line-height: 1.7; +} + +.result-extraction-hero-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + min-width: 240px; +} + +.result-extraction-hero-meta span { + padding: 7px 10px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: #f8fafc; + color: var(--color-text-secondary); +} + +.result-extraction-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.result-extraction-metric { + padding: 14px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: #ffffff; + border-left-width: 4px; +} + +.result-extraction-metric.tone-primary { + border-left-color: #2563eb; +} + +.result-extraction-metric.tone-neutral { + border-left-color: #64748b; +} + +.result-extraction-metric.tone-warning { + border-left-color: #d97706; +} + +.result-extraction-metric span { + display: block; + font-size: 12px; + font-weight: 800; + color: var(--color-text-muted); +} + +.result-extraction-metric strong { + display: block; + margin-top: 8px; + font-size: 24px; + line-height: 1; + color: var(--color-text-primary); +} + +.result-extraction-metric p { + margin: 8px 0 0; + font-size: 12px; + color: var(--color-text-secondary); +} + +.result-extraction-layout { + display: grid; + grid-template-columns: minmax(260px, 310px) minmax(0, 1fr); + gap: 14px; + min-height: min(720px, calc(100vh - 270px)); +} + +.result-extraction-channel-list { + display: grid; + align-content: start; + gap: 10px; +} + +.result-extraction-channel-list button { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 10px; + padding: 13px; + text-align: left; + border: 1px solid var(--color-border); + border-radius: 8px; + background: #ffffff; + color: var(--color-text-primary); +} + +.result-extraction-channel-list button.active { + border-color: rgba(37, 99, 235, 0.45); + background: #eff6ff; + box-shadow: inset 3px 0 0 #2563eb; +} + +.result-extraction-channel-list span { + grid-column: 1 / -1; + font-size: 11px; + font-weight: 800; + color: var(--color-text-muted); +} + +.result-extraction-channel-list strong { + min-width: 0; + font-size: 14px; + line-height: 1.25; +} + +.result-extraction-channel-list em, +.result-extraction-state { + justify-self: end; + align-self: center; + padding: 4px 8px; + border-radius: 999px; + font-size: 11px; + font-style: normal; + font-weight: 800; +} + +.result-extraction-channel-list em.ready, +.result-extraction-state.ready { + color: #166534; + background: #dcfce7; +} + +.result-extraction-channel-list em.planned, +.result-extraction-state.planned { + color: #0f766e; + background: #ccfbf1; +} + +.result-extraction-channel-list em.pending, +.result-extraction-state.pending { + color: #92400e; + background: #fef3c7; +} + +.result-extraction-main-card { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--color-border); + border-radius: 8px; + background: #ffffff; + overflow: hidden; +} + +.result-extraction-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--color-border); + background: #f8fafc; +} + +.result-extraction-card-head div { + display: grid; + gap: 4px; + min-width: 0; +} + +.result-extraction-card-head span, +.result-extraction-list-head span { + font-size: 11px; + font-weight: 800; + color: var(--color-text-muted); +} + +.result-extraction-card-head strong { + font-size: 15px; + color: var(--color-text-primary); +} + +.result-extraction-card-head button, +.result-extraction-action-stack button { + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: #ffffff; + color: var(--color-text-secondary); +} + +.result-extraction-action-stack button.primary { + border-color: #2563eb; + background: #2563eb; + color: #ffffff; + font-weight: 800; +} + +.result-extraction-card-head button:disabled, +.result-extraction-action-stack button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.result-extraction-controls { + display: grid; + grid-template-columns: minmax(180px, 0.75fr) minmax(260px, 1.4fr) auto; + gap: 12px; + padding: 14px 16px 10px; +} + +.result-extraction-field { + display: grid; + gap: 6px; + min-width: 0; +} + +.result-extraction-field span { + font-size: 11px; + font-weight: 800; + color: var(--color-text-muted); +} + +.result-extraction-field input { + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 9px 10px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: #ffffff; + color: var(--color-text-primary); +} + +.result-extraction-action-stack { + display: flex; + align-items: end; + gap: 8px; +} + +.result-extraction-hint { + margin: 0 16px 12px; + padding: 9px 10px; + border: 1px solid rgba(37, 99, 235, 0.16); + border-radius: 6px; + background: #eff6ff; + color: #1e3a8a; + font-size: 12px; + line-height: 1.5; +} + +.result-extraction-message { + display: grid; + gap: 5px; + margin: 0 16px 12px; + padding: 10px 12px; + border-radius: 6px; + font-size: 12px; + line-height: 1.5; +} + +.result-extraction-message.error { + border: 1px solid #fecaca; + background: #fef2f2; + color: #b91c1c; +} + +.result-extraction-message.success { + border: 1px solid #bbf7d0; + background: #f0fdf4; + color: #166534; +} + +.result-extraction-message code { + color: var(--color-text-primary); + word-break: break-all; +} + +.result-extraction-list-head { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 0 16px 10px; + border-bottom: 1px solid var(--color-border); +} + +.result-extraction-list-head strong { + font-size: 12px; + color: var(--color-text-secondary); +} + +.result-extraction-result-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + scrollbar-gutter: stable; + display: grid; + align-content: start; +} + +.result-extraction-result-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + gap: 10px; + align-items: center; + padding: 11px 16px; + border-bottom: 1px solid var(--color-border); +} + +.result-extraction-result-main { + min-width: 0; + display: grid; + gap: 4px; +} + +.result-extraction-result-main strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + color: var(--color-text-primary); +} + +.result-extraction-result-main span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--color-text-muted); +} + +.result-extraction-status-chip { + padding: 4px 7px; + border-radius: 999px; + background: #f1f5f9; + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} + +.result-extraction-empty { + padding: 22px 16px; + color: var(--color-text-muted); + font-size: 13px; +} + +.result-extraction-placeholder { + display: grid; + gap: 14px; + align-content: start; + padding: 22px; +} + +.result-extraction-placeholder > strong { + font-size: 20px; + color: var(--color-text-primary); +} + +.result-extraction-placeholder > p { + max-width: 720px; + margin: 0; + color: var(--color-text-secondary); + line-height: 1.7; + font-size: 13px; +} + +.result-extraction-state { + justify-self: start; +} + +.result-extraction-contract { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.result-extraction-contract div, +.result-extraction-sbas-sample { + padding: 12px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: #f8fafc; +} + +.result-extraction-contract span, +.result-extraction-sbas-sample > span { + display: block; + margin-bottom: 7px; + font-size: 11px; + font-weight: 800; + color: var(--color-text-muted); +} + +.result-extraction-contract strong { + font-size: 13px; + color: var(--color-text-primary); +} + +.result-extraction-sbas-sample { + display: grid; + gap: 8px; +} + +.result-extraction-sbas-sample p { + margin: 0; + color: var(--color-text-muted); + font-size: 13px; +} + +.result-extraction-sbas-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 0; + border-top: 1px solid var(--color-border); +} + +.result-extraction-sbas-row strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; +} + +.result-extraction-sbas-row span { + font-size: 12px; + color: var(--color-text-muted); +} + @media (max-width: 1200px) { .dinsar-toolbar-grid, .dinsar-catalog-summary, @@ -5510,6 +7136,14 @@ input[type="checkbox"] { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .result-extraction-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .result-extraction-layout { + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + } + .dinsar-filter-layout { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -5583,6 +7217,20 @@ input[type="checkbox"] { .dinsar-catalog-list { max-height: 48vh; } + + .result-extraction-layout, + .result-extraction-controls, + .result-extraction-contract { + grid-template-columns: 1fr; + } + + .result-extraction-channel-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .result-extraction-action-stack { + align-items: stretch; + } } @media (max-width: 640px) { @@ -5611,4 +7259,30 @@ input[type="checkbox"] { .dinsar-products-actions button { width: 100%; } + + .result-extraction-page { + padding: 12px; + } + + .result-extraction-hero, + .result-extraction-card-head { + flex-direction: column; + align-items: flex-start; + } + + .result-extraction-hero-meta, + .result-extraction-channel-list, + .result-extraction-metrics { + grid-template-columns: 1fr; + } + + .result-extraction-result-row { + grid-template-columns: auto minmax(0, 1fr); + } + + .result-extraction-result-row .dinsar-engine-badge, + .result-extraction-status-chip { + grid-column: 2; + justify-self: start; + } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index be8b033..a7e0636 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,7 +4,6 @@ import { useShallow } from 'zustand/react/shallow'; import 'leaflet/dist/leaflet.css'; import './App.css'; import LoginPage from './LoginPage'; -import AppLogPanel from './components/app/AppLogPanel'; import AppMapWorkspace from './components/app/AppMapWorkspace'; import AppOverlays from './components/app/AppOverlays'; import AppSidePanel from './components/app/AppSidePanel'; @@ -18,7 +17,6 @@ import { } from './store'; import useAppAuthLifecycle from './hooks/useAppAuthLifecycle'; import useGlobalTaskControl from './hooks/useGlobalTaskControl'; -import usePanelResize from './hooks/usePanelResize'; import useRegionAoiHandlers from './hooks/useRegionAoiHandlers'; import usePaginationControls from './hooks/usePaginationControls'; import useRadarSearch from './hooks/useRadarSearch'; @@ -222,20 +220,13 @@ function App() { setPendingTaskIds: state.setPendingTaskIds, }))); const { - leftPanelTab, setLeftPanelTab, leftPanelWidth, setLeftPanelWidth, - rightPanelWidth, setRightPanelWidth, isResizing, setIsResizing, - setShowStats, setShowDataInfo, setSelectedDataInfo, showDates, + leftPanelTab, setLeftPanelTab, leftPanelWidth, + setShowDataInfo, setSelectedDataInfo, showDates, baseLayerKey, setBaseLayerKey, isLoading, setIsLoading, addLog, } = useUiStore(useShallow((state) => ({ leftPanelTab: state.leftPanelTab, setLeftPanelTab: state.setLeftPanelTab, leftPanelWidth: state.leftPanelWidth, - setLeftPanelWidth: state.setLeftPanelWidth, - rightPanelWidth: state.rightPanelWidth, - setRightPanelWidth: state.setRightPanelWidth, - isResizing: state.isResizing, - setIsResizing: state.setIsResizing, - setShowStats: state.setShowStats, setShowDataInfo: state.setShowDataInfo, setSelectedDataInfo: state.setSelectedDataInfo, showDates: state.showDates, @@ -423,7 +414,6 @@ function App() { const hazardLayersRef = useRef({}); const dinsarResultLayersRef = useRef({}); const sbasAnalysisLayersRef = useRef({}); - const resizeStateRef = useRef({ side: null, startX: 0, startLeft: 0, startRight: 0 }); const allDataRef = useRef(allData); const dinsarResultsRef = useRef(dinsarResults); const mapBatchRef = useRef({ frameId: null, token: 0 }); @@ -446,7 +436,7 @@ function App() { cancelAnimationFrame(frameId); window.clearTimeout(timeoutId); }; - }, [isStandaloneLeftPage, leftPanelWidth, rightPanelWidth]); + }, [isStandaloneLeftPage, leftPanelWidth]); const getVisibleLayerRefs = useCallback(() => ({ activeLayersRef: activeLayersRef.current, @@ -717,16 +707,6 @@ function App() { setShowPsModal, }); - const { startResize } = usePanelResize({ - isResizing, - setIsResizing, - leftPanelWidth, - rightPanelWidth, - setLeftPanelWidth, - setRightPanelWidth, - resizeStateRef, - }); - const { handleLoginSuccess, handleLogout, @@ -1238,7 +1218,7 @@ function App() { ? '-' : `${(Number(result.ai_score) * 100).toFixed(0)}%`; const engine = getDinsarEngineMeta(result.engine_code); - const strategy = escapeHtml(result.selection_strategy || 'legacy'); + const strategy = escapeHtml(result.selection_strategy || '标准选择'); const taskAlias = escapeHtml(result.task_alias || result.task_name || result.name || '-'); const pairKey = escapeHtml(result.pair_key || '-'); const pairUid = escapeHtml(result.pair_uid || '-'); @@ -2027,10 +2007,6 @@ function App() { fetchHealthStatus({ refresh: true }); }, [fetchHealthStatus]); - const openStatisticsDashboard = useCallback(() => { - setShowStats(true); - }, [setShowStats]); - const refreshDinsarResults = useCallback(() => { fetchDinsarResults({ offset: 0 }); }, [fetchDinsarResults]); @@ -2046,7 +2022,6 @@ function App() { showRadarPageInputError, radarPageInputValidationError, onSearchAll: searchAllRadarData, - onShowStats: openStatisticsDashboard, onSearch: applyRadarSearch, onReset: resetRadarSearch, onAoiModeChange: handleRadarSearchAoiModeChange, @@ -2178,12 +2153,6 @@ function App() { sbasAnalysisPanel={sbasAnalysisPanel} /> -
startResize('left', event)} - style={{ display: isStandaloneLeftPage ? 'none' : undefined }} - /> -
-
startResize('right', event)} - style={{ display: isStandaloneLeftPage ? 'none' : undefined }} - /> - -
- -
{ - if (readOnly || scanLoading) return; - setScanLoading(true); - setMessage(''); - setError(''); - const requestPayload = - scanPayload && typeof scanPayload === 'object' && scanPayload.nativeEvent - ? {} - : scanPayload; - try { - const result = await scanAssetInventory({ - inventory_types: [], - root_ids: [], - bind_orbits: true, - families: INVENTORY_FAMILIES, - ...requestPayload, - }); - setMessage(`资产扫描任务已入队: ${result.task_id}`); - onTaskStart?.(result.task_id, '源数据/精轨资产扫描已入队', { - taskType: 'SCAN_ASSET_INVENTORY', - nonBlocking: true, - }); - } catch (err) { - setError(err?.response?.data?.detail || err.message || '启动资产扫描失败'); - } finally { - setScanLoading(false); - } - }; - const handleArchiveIntegrityAudit = async (auditPayload = {}, label = '压缩包完整性审计') => { if (readOnly || auditLoading) return; setAuditLoading(true); @@ -169,7 +138,7 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {

源数据与精轨资产

-

Sentinel-1 与 LT-1 的源产品、精密轨道和绑定状态

+

查看 Sentinel-1 与 LT-1 的源产品、精密轨道、绑定状态和开放问题;资产登记由数据接入与运维流程维护。

- - - - - - diff --git a/frontend/src/DataCopierPanel.jsx b/frontend/src/DataCopierPanel.jsx index 36fb030..df88c0d 100644 --- a/frontend/src/DataCopierPanel.jsx +++ b/frontend/src/DataCopierPanel.jsx @@ -53,7 +53,7 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => { if (taskId && status === 'RUNNING') { logIntervalRef.current = setInterval(() => { fetchLogsRef.current?.(); - }, 1000); + }, 3000); } else if (logIntervalRef.current) { clearInterval(logIntervalRef.current); if (taskId) fetchLogsRef.current?.(); diff --git a/frontend/src/DataMonitorPanel.jsx b/frontend/src/DataMonitorPanel.jsx index 861d5c1..cfd16ef 100644 --- a/frontend/src/DataMonitorPanel.jsx +++ b/frontend/src/DataMonitorPanel.jsx @@ -85,6 +85,8 @@ const SCAN_TASK_TYPES = new Set([ 'GF3_SARSCAPE_SYNC', 'GF3_QUICKLOOK_WEBP', ]); +const MONITOR_REFRESH_MS = 10000; +const ACTIVE_TASK_LOG_REFRESH_MS = 3000; const TASK_TYPE_LABELS = { SCAN_ASSET_INVENTORY: 'LT/S1 资产索引', @@ -270,7 +272,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled }; fetchLogsAndTasks(); - const intervalId = setInterval(fetchLogsAndTasks, 2000); + const intervalId = setInterval(fetchLogsAndTasks, MONITOR_REFRESH_MS); return () => { canceled = true; @@ -282,6 +284,8 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled displayActiveTasks.filter((task) => SCAN_TASK_TYPES.has(task.task_type)), recentScanTasks ).slice(0, 8); + const selectedTask = displayedScanTasks.find((task) => task.task_id === selectedTaskId) || displayedScanTasks[0] || null; + const selectedTaskActive = ['PENDING', 'RUNNING'].includes(String(selectedTask?.status || '').toUpperCase()); useEffect(() => { if (!enabled) { @@ -315,12 +319,17 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled } }; fetchTaskLogs(); - const intervalId = setInterval(fetchTaskLogs, 2000); + if (!selectedTaskActive) { + return () => { + canceled = true; + }; + } + const intervalId = setInterval(fetchTaskLogs, ACTIVE_TASK_LOG_REFRESH_MS); return () => { canceled = true; clearInterval(intervalId); }; - }, [apiEndpoint, enabled, selectedTaskId]); + }, [apiEndpoint, enabled, selectedTaskActive, selectedTaskId]); useEffect(() => { if (logEndRef.current) { @@ -328,8 +337,6 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled } }, [logs, selectedTaskLogs]); - const selectedTask = displayedScanTasks.find((task) => task.task_id === selectedTaskId) || displayedScanTasks[0] || null; - const sourceInventoryDirs = uniquePaths([...config.s1_source_dirs, ...config.s1_storage_dirs]); const orbitInventoryDirs = uniquePaths([ ...config.orbit_source_dirs, @@ -344,6 +351,10 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled const canRunSourceProductScan = !readOnly && configLoaded && hasSourceProductDirs; const canRunOrbitAssetScan = !readOnly && configLoaded && hasOrbitAssetDirs; const canRunGf3SarscapeProduce = !readOnly && configLoaded && hasGf3SarscapeNativeDirs && hasGf3StorageDirs; + const activeIngestTaskCount = displayedScanTasks.filter((task) => + ['PENDING', 'RUNNING'].includes(String(task.status || '').toUpperCase()) + ).length; + const availableStorageCount = config.storage_roots.filter((item) => ['ok', 'warning'].includes(String(item.status || '').toLowerCase())).length; const handleClearScanTaskHistory = async () => { if (readOnly) { @@ -511,10 +522,9 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled const sectionStyle = { marginBottom: '12px', padding: '10px 12px', - borderRadius: '10px', + borderRadius: '8px', background: 'var(--color-panel-bg)', border: '1px solid var(--color-border)', - boxShadow: 'var(--shadow-soft)', }; const labelStyle = { minWidth: '100px', color: 'var(--color-text-muted)', flexShrink: 0 }; const rowStyle = { display: 'flex', gap: '8px' }; @@ -532,38 +542,43 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled return (
-

数据监控面板

+
+
+

数据接入

+

集中管理源数据、精密轨道、GF3 回传成果和接入任务记录,确保生产前数据资产可追溯。

+
+
+
+ 配置状态 + {configLoaded ? '已加载' : '未加载'} +
+
0 ? 'warn' : 'ready'}`}> + 接入任务 + {activeIngestTaskCount > 0 ? `${activeIngestTaskCount} 个运行中` : '空闲'} +
+
+ 源数据池 + {sourceInventoryDirs.length} +
+
0 ? 'ready' : 'neutral'}`}> + 可用存储 + {availableStorageCount}/{config.storage_roots.length || 0} +
+
+
-
+
{configLoaded - ? '仅手动模式。路径从 .env 读取;如需修改请更新 .env 并重启后端。' - : '未加载到监控状态,请检查后端 /api/monitor/status。'} + ? '接入路径由环境配置统一管理;本页仅触发登记、审计和任务复核,不直接修改生产目录。' + : '未加载到接入状态,请检查后端运行维护接口。'}
-
-
路径摘要
+
+ 接入路径与生产目录 +

以下为服务器部署目录,仅用于核对环境配置;日常接入操作不需要展开。

精轨源资产{formatList(orbitInventoryDirs)}
LT-1 生产 TXT 池{config.orbit_production_txt_pool || '未配置'}
@@ -576,10 +591,10 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
D-InSAR 结果{config.dinsar_product_dir || '未配置'}
SBAS 结果{config.sbas_product_root || '未配置'}
-
+
-
本机存储感知
+
本机存储状态
{config.storage_roots.length ? config.storage_roots.map((item, index) => (
-
LT-1 / Sentinel-1 压缩包资产索引
+
LT-1 / Sentinel-1 源数据与精轨登记
压缩包源池{formatList(sourceInventoryDirs)}
精轨源资产{formatList(orbitInventoryDirs)}
@@ -619,42 +634,42 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
{clearScanHistoryMessage && ( @@ -761,7 +776,6 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled width: `${progress}%`, height: '100%', background: statusColor(task.status), - transition: 'width 0.2s ease', }} />
@@ -784,7 +798,10 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
- {selectedTask ? `${taskTitle(selectedTask)} 日志` : '实时日志'} + {selectedTask ? `${taskTitle(selectedTask)} 任务日志` : '实时执行日志'} + + 日志用于追踪执行步骤,issue 为本次解析已发现的问题计数。 +
['running', 'pending'].includes(String(run.status || '').toLowerCase())).length; + const failedRunCount = runs.filter(run => ['failed', 'FAILED'].includes(String(run.status || ''))).length; + const productionReady = !!currentEngineObj?.available && !!rootDir.trim() && !pyintPreviewBlocksSubmit && !readOnly; const loadEngines = useCallback(async () => { setEnginesLoading(true); @@ -892,6 +896,48 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) } }; + const handleSubmitCluster = async () => { + if (readOnly || clusterSubmitting) return; + if (selectedEngine !== 'landsar') { + setSubmitError(true); + setSubmitMsg('LandSAR 集群只支持 LandSAR 引擎。'); + return; + } + if (!rootDir.trim()) { + setSubmitError(true); + setSubmitMsg('请输入或选择 D-InSAR 生产根目录。'); + return; + } + + setClusterSubmitting(true); + setSubmitMsg(''); + setSubmitError(false); + try { + const extra = buildExtraPayload(currentParamSchema, engineExtraParams); + const result = await submitLandsarClusterRun({ + engine_code: 'landsar', + profile: selectedProfile, + root_dir: rootDir.trim(), + num_to_process: Number(numToProcess) || 0, + rerun_mode: rerunMode, + timeout_seconds: timeoutSec ? Number(timeoutSec) : null, + extra, + }); + const taskCount = result?.selected_task_count ? `,选中 ${result.selected_task_count} 个 pair` : ''; + const skippedCompleted = Number(result?.skipped_completed_count || 0); + const skippedText = skippedCompleted > 0 ? `,跳过 ${skippedCompleted} 个已完成 pair` : ''; + setSubmitError(false); + setSubmitMsg(`LandSAR 集群任务已入队:${result.task_id}${taskCount}${skippedText}`); + if (onJobQueued) onJobQueued(result.task_id); + await refreshMonitor(); + } catch (err) { + setSubmitError(true); + setSubmitMsg(`LandSAR 集群提交失败:${err?.response?.data?.detail || err.message}`); + } finally { + setClusterSubmitting(false); + } + }; + const handleViewLog = async run => { const runId = typeof run === 'string' ? run : (run?.run_id || run?.task_id || ''); const source = typeof run !== 'string' && run?.log_source === 'task' ? 'task' : 'run'; @@ -1001,9 +1047,10 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) }, [logModal.open, logModal.runId, logTaskId, readOnly, refreshMonitor, runLogDeletingId]); const isSubmitDisabled = readOnly || submitting || !currentEngineObj?.available || !rootDir.trim() || pyintPreviewBlocksSubmit; + const isClusterSubmitDisabled = readOnly || clusterSubmitting || selectedEngine !== 'landsar' || !rootDir.trim(); return ( -
+
{logModal.open && (
)} -
+
+
+

D-InSAR 生产运行

+

选择生产引擎、任务根目录和处理模板,完成预检后提交运行;监控区用于查看近期 Task、日志和审计记录。

+
+
+
+ 提交状态 + {productionReady ? '可提交' : readOnly ? '只读' : '待检查'} +
+
+ 当前引擎 + {formatEngineLabel(selectedEngine, currentEngineObj?.engine_label)} +
+
+ 运行中 + {activeRunCount} +
+
0 ? 'warn' : ''}`}> + 失败记录 + {failedRunCount} +
+
+
+ +
+
+
+

引擎与能力

+

先确认生产引擎可用,再选择对应处理模板。

+
+
+
引擎状态
+
-
+
+
+
+

任务准备与提交

+

生产任务根目录、参数模板和预检结果共同决定是否允许提交。

+
+
+
提交生产任务
@@ -1635,15 +1722,48 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) > {submitting ? '提交中...' : '提交任务'} + {selectedEngine === 'landsar' && ( + + )} {submitMsg && ( {submitMsg} )}
+ {selectedEngine === 'landsar' && ( +
+ 集群模式按 pair 拆分队列任务。远端服务器需启动只领取 LANDSAR_CLUSTER_ITEM 的 worker; + 本地 LandSAR 提交流程保持不变。 +
+ )}
+
-
+
+
+
+

运行监控与审计记录

+

监控近期 Task、查看日志,并保留运行记录删除等审计类操作。

+
+
+
运行监控
@@ -1912,6 +2031,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
)}
+
); } diff --git a/frontend/src/DinsarProductsPanel.jsx b/frontend/src/DinsarProductsPanel.jsx index 096b425..0427100 100644 --- a/frontend/src/DinsarProductsPanel.jsx +++ b/frontend/src/DinsarProductsPanel.jsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { scanDinsarResults } from './api/dinsar'; +import { listTaskRoots } from './api/dinsarProduction'; import { extractDispResults } from './api/idl'; import { clearTaskLogs, deleteTaskLog, getTaskLogs } from './api/tasks'; import DinsarCatalogPanel from './components/DinsarCatalogPanel'; @@ -8,14 +9,10 @@ import useTaskMonitor from './hooks/useTaskMonitor'; const PRODUCT_TASK_TYPES = [ 'SCAN_DINSAR', - 'PUBLISH_DINSAR_PRODUCTS', - 'REBUILD_DINSAR_CATALOG', ]; const TASK_TYPE_LABEL = { SCAN_DINSAR: 'D-InSAR 结果扫描', - PUBLISH_DINSAR_PRODUCTS: 'D-InSAR 产物发布', - REBUILD_DINSAR_CATALOG: 'D-InSAR 目录重建', }; const STATUS_LABEL = { @@ -48,13 +45,12 @@ function getLogTone(level) { } export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) { - const [extractRootDir, setExtractRootDir] = useState(''); - const [extractDestDir, setExtractDestDir] = useState(''); + const [productionRoot, setProductionRoot] = useState(''); + const [productionRootReady, setProductionRootReady] = useState(false); const [extractResult, setExtractResult] = useState(null); - const [extracting, setExtracting] = useState(false); + const [syncing, setSyncing] = useState(false); const [actionMessage, setActionMessage] = useState(''); const [actionError, setActionError] = useState(false); - const [scanning, setScanning] = useState(false); const [taskLogs, setTaskLogs] = useState([]); const [taskLogsLoading, setTaskLogsLoading] = useState(false); @@ -62,13 +58,17 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) { const [taskLogDeletingId, setTaskLogDeletingId] = useState(null); const taskMonitor = useTaskMonitor({ taskTypes: PRODUCT_TASK_TYPES, - showRecent: true, - recentLimit: 1, + showRecent: false, }); const monitoredTask = taskMonitor.latestTask; const logTaskId = monitoredTask?.task_id || ''; - const showingRecentTask = !taskMonitor.isBusy && !!monitoredTask; const actionTone = getMessageTone(actionMessage, actionError); + const activeTaskCount = taskMonitor.activeTasks?.length || 0; + const catalogSourceState = productionRootReady ? '已配置' : '未配置'; + const taskStateLabel = activeTaskCount > 0 + ? `${activeTaskCount} 个运行中` + : '空闲'; + const taskStateTone = activeTaskCount > 0 ? 'warn' : 'ready'; const loadTaskLogs = useCallback(async (taskId) => { if (!taskId) { @@ -87,8 +87,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) { }, []); const refreshMonitor = useCallback(async () => { - const nextRecentTasks = await taskMonitor.refreshRecentTasks(); - const nextTaskId = taskMonitor.activeTasks[0]?.task_id || nextRecentTasks[0]?.task_id || logTaskId; + const nextTaskId = taskMonitor.activeTasks[0]?.task_id || logTaskId; await loadTaskLogs(nextTaskId); }, [loadTaskLogs, logTaskId, taskMonitor]); @@ -96,6 +95,33 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) { loadTaskLogs(logTaskId); }, [loadTaskLogs, logTaskId]); + useEffect(() => { + if (!taskMonitor.isBusy || !logTaskId) return undefined; + const timer = window.setInterval(() => { + void loadTaskLogs(logTaskId); + }, 3000); + return () => window.clearInterval(timer); + }, [loadTaskLogs, logTaskId, taskMonitor.isBusy]); + + useEffect(() => { + let canceled = false; + listTaskRoots() + .then((data) => { + if (canceled) return; + const root = String(data?.root || '').trim(); + setProductionRoot(root); + setProductionRootReady(Boolean(root && data?.root_exists)); + }) + .catch(() => { + if (canceled) return; + setProductionRoot(''); + setProductionRootReady(false); + }); + return () => { + canceled = true; + }; + }, []); + const handleDeleteTaskLog = useCallback(async (logId) => { const taskId = logTaskId; if (!taskId || !logId || taskLogActionLoading) return; @@ -132,63 +158,70 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) { } }, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]); - const handleExtract = async () => { - if (!extractRootDir.trim()) return; - setExtracting(true); + const handleExtractAndScan = async () => { + if (readOnly || !productionRootReady || !productionRoot.trim()) return; + setSyncing(true); setExtractResult(null); setActionMessage(''); setActionError(false); try { - const result = await extractDispResults(extractRootDir.trim(), extractDestDir.trim() || null); + const result = await extractDispResults(productionRoot.trim(), null); setExtractResult(result); - } catch (err) { - setExtractResult({ error: err?.response?.data?.detail || err.message }); - } finally { - setExtracting(false); - } - }; - - const handleScan = async () => { - if (readOnly) return; - setScanning(true); - setActionMessage(''); - setActionError(false); - try { - const result = await scanDinsarResults(); - setActionMessage(result?.message || `D-InSAR 结果扫描任务已入队:${result?.task_id || '-'}`); - if (result?.task_id) { - onJobQueued?.(result.task_id); + const scanResult = await scanDinsarResults(); + setActionMessage(scanResult?.message || `D-InSAR 结果登记任务已提交:${scanResult?.task_id || '-'}`); + if (scanResult?.task_id) { + onJobQueued?.(scanResult.task_id); } await refreshMonitor(); } catch (err) { setActionError(true); - setActionMessage(err?.response?.data?.detail || err.message || 'D-InSAR 结果扫描失败'); + const message = err?.response?.data?.detail || err.message || 'D-InSAR 结果提取与登记失败'; + setActionMessage(message); + setExtractResult((current) => current || { error: message }); } finally { - setScanning(false); + setSyncing(false); } }; const monitorTone = useMemo(() => { if (!monitoredTask) return 'neutral'; - if (showingRecentTask) return 'info'; return String(monitoredTask.status || '').toUpperCase() === 'RUNNING' ? 'warn' : 'neutral'; - }, [monitoredTask, showingRecentTask]); + }, [monitoredTask]); return (
- D-InSAR 结果提取与标准目录 + D-InSAR 结果目录

- 这里负责把生产目录中的位移结果提取为标准成果包,并触发统一扫描、发布和编目。 - 生产运行与参数配置现已收口到“生产管理”工作台中的 “D-InSAR 运行” 子视图。 + 将生产目录中的位移结果提取为标准成果包,并触发结果登记和目录编目。 + 生产参数与运行提交已归入“D-InSAR 运行”,这里专注成果归档与资产登记。

-
- - {readOnly ? '只读模式' : '可执行写操作'} - - 日志改为手动刷新 +
+
+ 操作模式 + {readOnly ? '只读' : '可维护'} +
+
+ 产物任务 + {taskStateLabel} +
+
+ 提取源 + {catalogSourceState} +
+
+ 日志 + 手动刷新 +
+
+
+ +
+
+ 成果提取与任务监控 + 左侧执行受控提取与登记,右侧核对后台任务与日志。
@@ -196,46 +229,27 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
- 结果提取与重扫 - 先提取标准结果包,再按统一目录登记 + D-InSAR 结果提取与登记 + 将已完成的生产成果归入标准结果目录
-
- - +
+ 成果来源 + {productionRootReady ? '生产目录已就绪' : '生产目录待完善'} +

{productionRootReady ? '可将当前生产成果提取并登记为标准结果包。' : '请先完成 D-InSAR 生产目录配置。'}

- + {!productionRootReady && 请先在后端配置 D-InSAR 生产根目录。}
{actionMessage && ( @@ -270,7 +284,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
产物任务监控 - 当前不轮询,按需手动刷新 + 任务运行时自动更新,空闲时按需刷新
@@ -281,7 +295,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
- {showingRecentTask ? '最近一次任务' : '当前任务'} + 当前任务 {formatTaskType(monitoredTask.task_type)}
@@ -303,7 +317,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) { )}
- {showingRecentTask ? '最近一次任务日志' : '当前任务日志'} + 当前任务日志 {!readOnly && (
- +
+
+
+ 标准目录与资产详情 + 核对 AOI、时间范围、资产文件、发布状态和目录一致性。 +
+
+ +
); } diff --git a/frontend/src/FloodAnalysisWorkspace.jsx b/frontend/src/FloodAnalysisWorkspace.jsx index b201198..865e232 100644 --- a/frontend/src/FloodAnalysisWorkspace.jsx +++ b/frontend/src/FloodAnalysisWorkspace.jsx @@ -31,6 +31,7 @@ import { RADAR_SEARCH_DEFAULTS } from './config/appConstants'; const SEARCH_PAGE_SIZE = 30; const LIST_PAGE_SIZE = 20; +const ACTIVE_WORKSPACE_REFRESH_INTERVAL_MS = 15000; const VIEWS = [ { key: 'extract', label: '水体提取' }, @@ -649,7 +650,7 @@ export default function FloodAnalysisWorkspace({ if (!runningCount) return undefined; const timer = window.setInterval(() => { refreshAll(); - }, 6000); + }, ACTIVE_WORKSPACE_REFRESH_INTERVAL_MS); return () => window.clearInterval(timer); }, [refreshAll, runningCount]); diff --git a/frontend/src/HazardPointPanel.jsx b/frontend/src/HazardPointPanel.jsx index ceeef62..6e60ceb 100644 --- a/frontend/src/HazardPointPanel.jsx +++ b/frontend/src/HazardPointPanel.jsx @@ -13,7 +13,6 @@ const HazardPointPanel = ({ onPointClick, onToggleVisibility, isVisible, onScanC taskTypes: ['SCAN_HAZARD'], showRecent: true, recentLimit: 1, - pollRecentMs: 10000, }); const scanBusy = isLoading || scanTaskMonitor.isBusy; diff --git a/frontend/src/HealthCheckPanel.jsx b/frontend/src/HealthCheckPanel.jsx index cbdcfaf..146d315 100644 --- a/frontend/src/HealthCheckPanel.jsx +++ b/frontend/src/HealthCheckPanel.jsx @@ -96,11 +96,6 @@ const renderOrbitSourceIssueDetails = (item, en, formatPathText) => { {en ? 'ENVI: ' : 'ENVI:'}{formatPathText(item.envi_path)}
)} - {item?.isce2_path && ( -
- {en ? 'ISCE2: ' : 'ISCE2:'}{formatPathText(item.isce2_path)} -
- )} {hasNulDetails && (
{en ? 'NUL bytes: ' : 'NUL 字节:'}{formatIntegerText(nulCount)} @@ -201,10 +196,10 @@ const buildConsistencySummary = (stats, en = false) => { }; }; -const HEALTH_PANEL_POLL_INTERVAL_MS = 30000; +const HEALTH_PANEL_POLL_INTERVAL_MS = 5 * 60 * 1000; -const HealthCheckPanel = ({ language = 'zh', currentUser }) => { - const en = language === 'en'; +const HealthCheckPanel = ({ currentUser }) => { + const en = false; const isAdmin = currentUser?.role === 'admin'; const [status, setStatus] = useState(null); const [loading, setLoading] = useState(false); @@ -222,8 +217,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { const [wslChecking, setWslChecking] = useState(false); const [orbitStatus, setOrbitStatus] = useState(null); const [orbitSyncing, setOrbitSyncing] = useState(false); - const [orbitRepairing, setOrbitRepairing] = useState(false); - const [orbitQuarantining, setOrbitQuarantining] = useState(false); const [orbitSyncResult, setOrbitSyncResult] = useState(null); const statusFetchInFlightRef = useRef(false); @@ -233,7 +226,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { setOrbitStatus(data); } catch (err) { setOrbitStatus(null); - setOrbitSyncResult({ error: err.response?.data?.detail || err.message || (en ? 'Failed to fetch orbit status' : '精轨状态获取失败') }); + setOrbitSyncResult({ error: err.response?.data?.detail || err.message || '精轨状态获取失败' }); } }, [en]); @@ -266,7 +259,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { setStatus(healthData); setLastChecked(new Date()); } catch (err) { - setError(err.response?.data?.detail || err.message || (en ? 'Health check failed' : '运维自检失败')); + setError(err.response?.data?.detail || err.message || '运维自检失败'); setStatus(null); } @@ -276,19 +269,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { setConsistencyError(''); } catch (err) { setConsistencySummary(null); - setConsistencyError(err.response?.data?.detail || err.message || (en ? 'Failed to fetch consistency stats' : '一致性统计获取失败')); + setConsistencyError(err.response?.data?.detail || err.message || '一致性统计获取失败'); } // 引擎状态独立加载,不影响主健康检查。 await refreshEngineStatus(); - // 轨道目录状态由专门接口维护,失败时由 refreshOrbitStatus 自己写回 UI。 - await refreshOrbitStatus(); } finally { setLoading(false); statusFetchInFlightRef.current = false; } - }, [en, refreshEngineStatus, refreshOrbitStatus]); + }, [en, refreshEngineStatus]); useEffect(() => { void fetchStatus(); @@ -297,9 +288,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { syncLoading || cleanupLoading || wslChecking || - orbitSyncing || - orbitRepairing || - orbitQuarantining + orbitSyncing ) { return; } @@ -309,8 +298,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { }, [ cleanupLoading, fetchStatus, - orbitQuarantining, - orbitRepairing, orbitSyncing, syncLoading, wslChecking, @@ -322,6 +309,14 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { ); + const renderSignal = (ok, label, detail = '') => ( +
+ {label} + {ok ? '正常' : '异常'} + {detail && {detail}} +
+ ); + const formatIso = (iso) => { if (!iso) return en ? 'Unknown' : '未知'; try { @@ -342,6 +337,14 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { typeof timeseriesResultCatalog.needs_rebuild === 'boolean' ? timeseriesResultCatalog.needs_rebuild : null; const sourceRoots = asObject(status?.source_roots); const productPackages = asObject(status?.product_packages); + const assetInventory = asObject(status?.asset_inventory); + const hasAssetInventory = Boolean(status?.asset_inventory); + const assetSourceRoots = asObject(assetInventory.source_roots); + const assetOrbitRoots = asObject(assetInventory.orbit_roots); + const sourceAssets = asObject(assetInventory.source_assets); + const orbitAssets = asObject(assetInventory.orbit_assets); + const orbitBindings = asObject(assetInventory.bindings); + const assetIssues = asObject(assetInventory.issues); const wslRuntime = asObject(status?.wsl_runtime); const wslRuntimeItems = asArray(wslRuntime.runtimes); const pairingSystem = asObject(status?.pairing_system); @@ -374,58 +377,60 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { const orbitPools = orbitStatus?.pools || {}; const orbitConsistency = orbitStatus?.consistency || {}; const orbitDatabase = orbitStatus?.database || {}; - const orbitIsce2Enabled = Boolean(orbitPools.isce2?.enabled || orbitConsistency.isce2?.enabled || orbitDatabase.isce2_enabled); const orbitMismatchCount = toNumber(orbitConsistency.mismatch_count); const orbitDbMissingEnviCount = toNumber(orbitDatabase.stems_missing_in_envi_count); - const orbitDbMissingIsce2Count = orbitIsce2Enabled ? toNumber(orbitDatabase.stems_missing_in_isce2_count) : 0; const orbitDbMissingPathCount = toNumber(orbitDatabase.db_missing_path_count); const orbitDbFlagIssueCount = toNumber(orbitDatabase.has_orbit_but_missing_path_count) + toNumber(orbitDatabase.without_orbit_but_path_present_count); const orbitScanErrorCount = (orbitSource.errors?.length || 0) + - (orbitPools.envi?.errors?.length || 0) + - (orbitIsce2Enabled ? (orbitPools.isce2?.errors?.length || 0) : 0); + (orbitPools.envi?.errors?.length || 0); const orbitDuplicateCount = toNumber(orbitSource.duplicate_count) + - toNumber(orbitPools.envi?.duplicate_count) + - (orbitIsce2Enabled ? toNumber(orbitPools.isce2?.duplicate_count) : 0); + toNumber(orbitPools.envi?.duplicate_count); const orbitSuspectBadCount = toNumber(orbitSource.suspect_bad_count); const orbitSourceWithoutEnviCount = toNumber(orbitSource.source_without_envi_count); const orbitEnviWithoutSourceCount = toNumber(orbitSource.envi_without_source_count); - const orbitIsce2WithoutSourceCount = orbitIsce2Enabled ? toNumber(orbitSource.isce2_without_source_count) : 0; - const orbitQuarantinePath = orbitSource.quarantine_path || orbitStatus?.source_gaps?.quarantine_path; const orbitBadSourceSamples = asArray(orbitSource.bad_source_samples).filter(hasOrbitCorruptionSignal); - const orbitSuspectBadSamples = asArray(orbitSource.suspect_bad_samples); - const orbitSuspectWithoutCorruptionSamples = orbitSuspectBadSamples.filter( - (item) => !orbitBadSourceSamples.some((badItem) => badItem.name === item.name) - ); const orbitBadSourceSampleCount = toNumber(orbitSource.bad_source_sample_count || orbitBadSourceSamples.length); const orbitOverallHealthy = Boolean( orbitStatus && orbitMismatchCount === 0 && orbitDbMissingEnviCount === 0 && - orbitDbMissingIsce2Count === 0 && orbitDbMissingPathCount === 0 && orbitDbFlagIssueCount === 0 && orbitScanErrorCount === 0 && orbitSuspectBadCount === 0 && orbitSourceWithoutEnviCount === 0 && - orbitEnviWithoutSourceCount === 0 && - orbitIsce2WithoutSourceCount === 0 + orbitEnviWithoutSourceCount === 0 ); + const assetInventoryHealthy = Boolean(hasAssetInventory && assetInventory.ok); + const orbitAssetRiskCount = + toNumber(orbitAssets.parse_failed_count) + + toNumber(orbitBindings.missing_count) + + toNumber(orbitBindings.ambiguous_count); + + const productionBlockingCount = [ + !status?.ok, + !status?.database?.ok, + !status?.database?.schema_ok, + !status?.worker?.ok, + consistencySummary && consistencySummary.critical > 0, + hasAssetInventory && !assetInventoryHealthy, + ].filter(Boolean).length; return (
-
{en ? 'System Health' : '运维自检'}
+
运行维护
- {lastChecked ? `${en ? 'Last check: ' : '上次检查:'}${lastChecked.toLocaleString()}` : (en ? 'Not checked yet' : '尚未检查')} + 面向生产环境的系统健康、数据一致性和维护操作入口
@@ -439,23 +444,37 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { <>
- {en ? 'Overall Status' : '总体状态'} - {renderBadge(status.ok)} + 生产就绪 + {renderBadge(status.ok && productionBlockingCount === 0, productionBlockingCount > 0 ? `${productionBlockingCount} 个阻断项` : '可运行')}
- {en ? 'Timestamp' : '时间戳'} - {formatIso(status.timestamp)} + 最近检查 + {lastChecked ? lastChecked.toLocaleString() : formatIso(status.timestamp)}
- {en ? 'Consistency Issues' : '一致性异常'} + 一致性异常 {renderBadge( !consistencySummary || consistencySummary.total === 0, - consistencySummary ? `${consistencySummary.total} ${en ? 'items' : '项'}` : (en ? 'Unknown' : '未知') + consistencySummary ? `${consistencySummary.total} 项` : '未知' )}
+
+ {renderSignal(!!status.database?.ok && !!status.database?.schema_ok, '数据库')} + {renderSignal(!!status.worker?.ok, 'Worker', `${status.worker?.worker_count ?? 0} 个`)} + {renderSignal(!!status.idl?.ok, 'IDL/ENVI')} + {renderSignal(!!status.nginx?.ok, 'Nginx')} +
-
+
+
+
+
+

核心服务

+

判断系统是否具备基础生产能力:数据库、PostGIS、schema 和任务执行器。

+
+
+
{en ? 'Database' : '数据库'}
@@ -587,7 +606,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
)}
+
+
+
+
+
+

结果目录与生产索引

+

检查 D-InSAR、时序 InSAR、配对基础表和兼容视图是否能支撑结果查询与生产流转。

+
+
+
{en ? 'D-InSAR Result Catalog' : 'D-InSAR 结果目录'}
@@ -758,7 +787,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
{bridgeIssueCount === 0 ? (
- {en ? 'Catalog-first reads and legacy compat rows are aligned.' : '目录事实源与旧兼容视图当前一致。'} + {en ? 'Catalog reads and compatibility rows are aligned.' : '目录事实源与兼容视图当前一致。'}
) : ( <> @@ -805,7 +834,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
)}
+
+
+
+
+
+

数据资产与运行时

+

检查受管源路径、标准结果包、WSL runtime、IDL/ENVI、D-InSAR 引擎、Ollama 和 Nginx。

+
+
+
{en ? 'Managed Source Roots' : '受管源路径'}
@@ -1037,7 +1076,115 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
)}
+
+
+
+
+
+

数据资产与精轨

+

当前生产以 XML 抽取后的源产品、精轨资产和场景绑定为准;旧精轨池核对仅作为过渡诊断。

+
+
+
+
+
源产品资产
+
+ 总体状态 + {renderBadge( + assetInventoryHealthy, + assetInventory ? `${toNumber(sourceAssets.total_count)} 项` : '未知' + )} +
+
+ LT-1 / Sentinel-1 + {toNumber(sourceAssets.lt1_count)} / {toNumber(sourceAssets.s1_count)} +
+
+ 解析异常 + {toNumber(sourceAssets.parse_failed_count)} +
+
+ 源数据根 + {toNumber(assetSourceRoots.accessible_count)} / {toNumber(assetSourceRoots.configured_count)} +
+
+ 需复扫 + {toNumber(assetSourceRoots.needs_rescan_count)} +
+ {toNumber(sourceAssets.parse_failed_count) > 0 ? ( +
+ 存在源产品解析异常,请在“数据资产”中查看开放问题并复扫相关目录。 +
+ ) : ( +
+ 源产品资产已按 XML/元数据登记。 +
+ )} +
+ +
+
精轨资产状态
+
+ 总体状态 + {renderBadge( + orbitAssetRiskCount === 0 && assetInventoryHealthy, + orbitAssetRiskCount > 0 ? `${orbitAssetRiskCount} 个风险项` : `${toNumber(orbitAssets.total_count)} 项` + )} +
+
+ LT-1 / Sentinel-1 + {toNumber(orbitAssets.lt1_count)} / {toNumber(orbitAssets.s1_count)} +
+
+ 解析异常 + {toNumber(orbitAssets.parse_failed_count)} +
+
+ 精轨根 + {toNumber(assetOrbitRoots.accessible_count)} / {toNumber(assetOrbitRoots.configured_count)} +
+
+ 需复扫 + {toNumber(assetOrbitRoots.needs_rescan_count)} +
+
+ 精轨可用性以资产登记和时间窗绑定结果为准,不再以 ISCE2 XML 池作为生产判断。 +
+
+ +
+
场景精轨绑定
+
+ 已绑定场景 + {toNumber(orbitBindings.matched_count)} / {toNumber(orbitBindings.scene_count)} +
+
+ 缺失精轨 + {toNumber(orbitBindings.missing_count)} +
+
+ 候选歧义 + {toNumber(orbitBindings.ambiguous_count)} +
+
+ 开放问题 + {toNumber(assetIssues.open_count)} +
+
+ 错误 / 警告 + {toNumber(assetIssues.error_count)} / {toNumber(assetIssues.warning_count)} +
+ {orbitAssetRiskCount > 0 ? ( +
+ 请在“数据资产”中复核缺失或歧义精轨;生产配对会优先使用已选定的精轨资产。 +
+ ) : ( +
+ 当前开放问题未显示精轨绑定风险。 +
+ )} +
{en ? 'Consistency Check' : '一致性检测'}
@@ -1103,12 +1250,30 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
)}
-
- {/* 精轨管理 */} {isAdmin && ( -
-
{en ? 'Precise Orbit Management' : '精轨管理'}
+
+ + 旧精轨池核对 + + +
+ 该诊断仅核对源精轨目录与 ENVI/Gamma 生产 TXT 池,用于排查历史目录;生产判断以资产登记与场景绑定为准。 +
+ {orbitStatus ? ( + <> +
{en ? 'Precise Orbit Management' : '文件池状态'}
{en ? 'Overall' : '总体状态'} {renderBadge( @@ -1120,14 +1285,10 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
- {orbitIsce2Enabled - ? (en ? 'Source / ENVI-Gamma TXT / ISCE2 XML' : '源目录 / ENVI-Gamma TXT / ISCE2 XML') - : (en ? 'Source / ENVI-Gamma TXT' : '源目录 / ENVI-Gamma TXT')} + {en ? 'Source / production TXT' : '源目录 / 生产 TXT'} - {orbitIsce2Enabled - ? `${toNumber(orbitSource.total_source)} / ${toNumber(orbitPools.envi?.total)} / ${toNumber(orbitPools.isce2?.total)}` - : `${toNumber(orbitSource.total_source)} / ${toNumber(orbitPools.envi?.total)}`} + {`${toNumber(orbitSource.total_source)} / ${toNumber(orbitPools.envi?.total)}`}
@@ -1140,24 +1301,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { {en ? 'Pool mismatches' : '池不一致'} {orbitMismatchCount}
- {orbitIsce2Enabled ? ( -
- {en ? 'Suspect bad TXT / source-only' : '疑似坏 TXT / 仅源存在'} - {orbitSuspectBadCount} / {orbitSourceWithoutEnviCount} -
- ) : ( -
- {en ? 'Source-only' : '仅源存在'} - {orbitSourceWithoutEnviCount} -
- )}
- {orbitIsce2Enabled ? (en ? 'TXT-only / ISCE2-only' : '仅 TXT / 仅 ISCE2') : (en ? 'TXT-only' : '仅 TXT')} - {orbitIsce2Enabled ? `${orbitEnviWithoutSourceCount} / ${orbitIsce2WithoutSourceCount}` : orbitEnviWithoutSourceCount} + {en ? 'Source-only' : '仅源存在'} + {orbitSourceWithoutEnviCount}
- {orbitIsce2Enabled ? (en ? 'DB missing in TXT / ISCE2' : '数据库在 TXT / ISCE2 缺失') : (en ? 'DB missing in TXT' : '数据库在 TXT 缺失')} - {orbitIsce2Enabled ? `${orbitDbMissingEnviCount} / ${orbitDbMissingIsce2Count}` : orbitDbMissingEnviCount} + {en ? 'Production TXT only' : '仅生产 TXT 存在'} + {orbitEnviWithoutSourceCount} +
+
+ {en ? 'DB missing in production TXT' : '数据库在生产 TXT 缺失'} + {orbitDbMissingEnviCount}
{en ? 'Duplicate stems / scan errors' : '重复 stem / 扫描异常'} @@ -1166,21 +1320,11 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
{en - ? (orbitIsce2Enabled - ? 'LT-1 orbit scans synchronize the production TXT pool and the legacy ISCE2 XML pool. S1 EOF files remain registered as source orbit assets.' - : 'LT-1 orbit scans synchronize the production TXT pool for ENVI/SARscape and Gamma. S1 EOF files remain registered as source orbit assets; ISCE2 XML is disabled.') - : (orbitIsce2Enabled - ? 'LT-1 精轨扫描会同步生产 TXT 池和 legacy ISCE2 XML 池;S1 EOF 只登记为源精轨资产。' - : 'LT-1 精轨扫描会同步 ENVI/SARscape 与 Gamma 共用的生产 TXT 池;S1 EOF 只登记为源精轨资产,ISCE2 XML 已停用。')} + ? 'LT-1 orbit scans synchronize the production TXT pool for ENVI/SARscape and Gamma. S1 EOF files remain registered as source orbit assets.' + : 'LT-1 精轨扫描会同步 ENVI/SARscape 与 Gamma 共用的生产 TXT 池;S1 EOF 只登记为源精轨资产。'}
{en ? 'Source path: ' : '源目录路径:'}{formatPathText(orbitSource.path)}
{en ? 'Production TXT pool: ' : '生产 TXT 池:'}{formatPathText(orbitPools.envi?.path)}
- {orbitIsce2Enabled && ( - <> -
{en ? 'Legacy ISCE2 pool: ' : 'Legacy ISCE2 池:'}{formatPathText(orbitPools.isce2?.path)}
-
{en ? 'Quarantine path: ' : '隔离目录:'}{formatPathText(orbitQuarantinePath)}
- - )} {orbitDuplicateCount > 0 && (
@@ -1203,13 +1347,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { : `数据库 orbit_file_path 指向不存在文件:${orbitDbMissingPathCount} / ${toNumber(orbitDatabase.distinct_orbit_path_count)}`}
)} - {orbitIsce2Enabled && orbitSuspectBadCount > 0 && ( -
- {en - ? `Suspect bad source TXT (source exists but ISCE2 XML missing): ${orbitSuspectBadCount}` - : `疑似坏源 TXT(源文件存在但 ISCE2 XML 缺失):${orbitSuspectBadCount}`} -
- )} {orbitBadSourceSampleCount > 0 && (
{en @@ -1223,24 +1360,12 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { {orbitDatabase.sample_missing_in_envi.slice(0, 5).join(', ')}
)} - {orbitIsce2Enabled && orbitDatabase.sample_missing_in_isce2?.length > 0 && ( -
- {en ? 'DB expected but ISCE2 pool missing: ' : '数据库期望但 ISCE2 池缺失:'} - {orbitDatabase.sample_missing_in_isce2.slice(0, 5).join(', ')} -
- )} {orbitBadSourceSamples.slice(0, 5).map((item) => (
{item.name} - {item.error || (en ? 'Corruption signal detected' : '检测到损坏信号')} {renderOrbitSourceIssueDetails(item, en, formatPathText)}
))} - {orbitIsce2Enabled && orbitSuspectWithoutCorruptionSamples.slice(0, 5).map((item) => ( -
- {item.name} - {renderOrbitSourceIssueDetails(item, en, formatPathText)} -
- ))} {(orbitConsistency.mismatches || []).slice(0, 5).map((item) => (
{item.name} - {item.issue} @@ -1249,11 +1374,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { {en ? 'ENVI: ' : 'ENVI:'}{formatPathText(item.envi_path)}
)} - {item.isce2_path && ( -
- {en ? 'ISCE2: ' : 'ISCE2:'}{formatPathText(item.isce2_path)} -
- )}
))} {orbitConsistency.mismatch_count > 5 && ( @@ -1278,11 +1398,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { {en ? 'ENVI pool scan error: ' : 'ENVI 池扫描异常:'}{item}
))} - {orbitIsce2Enabled && (orbitPools.isce2?.errors || []).slice(0, 3).map((item, index) => ( -
- {en ? 'ISCE2 pool scan error: ' : 'ISCE2 池扫描异常:'}{item} -
- ))}
- {orbitIsce2Enabled && ( - <> - - - - )}
{orbitSyncResult && ( @@ -1354,176 +1427,89 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
{orbitSyncResult.error}
) : ( <> - {'confirmed_bad_count' in orbitSyncResult ? ( - <> -
- {en - ? `Quarantine finished. Confirmed bad TXT: ${toNumber(orbitSyncResult.confirmed_bad_count)} / validated ${toNumber(orbitSyncResult.validated_count)}` - : `隔离完成。已确认坏 TXT ${toNumber(orbitSyncResult.confirmed_bad_count)} 项 / 已校验 ${toNumber(orbitSyncResult.validated_count)} 项`} -
-
- {en - ? `Quarantine root: ${formatPathText(orbitSyncResult.quarantine_root)}` - : `隔离目录:${formatPathText(orbitSyncResult.quarantine_root)}`} -
- {(orbitSyncResult.confirmed_bad || []).slice(0, 5).map((item, index) => ( -
- {item.name} - {item.error} - {renderOrbitSourceIssueDetails(item, en, formatPathText)} - {item.quarantined_source && ( -
- {en ? 'Moved source to: ' : '源文件已移至:'}{formatPathText(item.quarantined_source)} -
- )} - {item.quarantined_envi && ( -
- {en ? 'Moved ENVI to: ' : 'ENVI 已移至:'}{formatPathText(item.quarantined_envi)} -
- )} - {item.quarantined_isce2 && ( -
- {en ? 'Moved ISCE2 to: ' : 'ISCE2 已移至:'}{formatPathText(item.quarantined_isce2)} -
- )} -
- ))} - {(orbitSyncResult.skipped_valid || []).slice(0, 3).map((item, index) => ( -
- {item.name} - {item.reason} -
- ))} - {(orbitSyncResult.errors || []).slice(0, 5).map((item, index) => ( -
- {item.name} - {item.scope} - {item.error} -
- ))} - - ) : 'before' in orbitSyncResult ? ( - <> -
- {en - ? `Repair finished. Before mismatches: ${toNumber(orbitSyncResult.before?.mismatch_count)}, after mismatches: ${toNumber(orbitSyncResult.after?.mismatch_count)}` - : `修复完成。修复前不一致 ${toNumber(orbitSyncResult.before?.mismatch_count)} 项,修复后不一致 ${toNumber(orbitSyncResult.after?.mismatch_count)} 项`} -
-
- {en - ? `Recovered from ENVI TXT: ${(orbitSyncResult.repaired_from_envi || []).length}, repair errors: ${toNumber(orbitSyncResult.repair_error_count)}` - : `从 ENVI TXT 补转成功 ${(orbitSyncResult.repaired_from_envi || []).length} 项,修复失败 ${toNumber(orbitSyncResult.repair_error_count)} 项`} -
-
- {en - ? `Source scan ${toNumber(orbitSyncResult.sync_result?.total_source)}, TXT copied ${(orbitSyncResult.sync_result?.envi?.copied || []).length}, TXT refreshed ${(orbitSyncResult.sync_result?.envi?.updated || []).length}${orbitSyncResult.isce2_enabled ? `, ISCE2 converted ${(orbitSyncResult.sync_result?.isce2?.converted || []).length}, ISCE2 refreshed ${(orbitSyncResult.sync_result?.isce2?.reconverted || []).length}` : ', ISCE2 disabled'}` - : `源目录扫描 ${toNumber(orbitSyncResult.sync_result?.total_source)} 项,TXT 新增 ${(orbitSyncResult.sync_result?.envi?.copied || []).length} 项、刷新 ${(orbitSyncResult.sync_result?.envi?.updated || []).length} 项${orbitSyncResult.isce2_enabled ? `,ISCE2 新增转换 ${(orbitSyncResult.sync_result?.isce2?.converted || []).length} 项、重转 ${(orbitSyncResult.sync_result?.isce2?.reconverted || []).length} 项` : ',ISCE2 已停用'}`} -
- {(orbitSyncResult.repaired_from_envi || []).slice(0, 5).length > 0 && ( -
- {en ? 'Recovered stems: ' : '已补转 stem:'} - {(orbitSyncResult.repaired_from_envi || []).slice(0, 5).join(', ')} +
+ {orbitSyncResult.healthy + ? (en ? 'Pools are consistent.' : '本地池一致。') + : (en ? `Detected ${toNumber(orbitSyncResult.mismatch_count)} mismatches.` : `检测到 ${toNumber(orbitSyncResult.mismatch_count)} 项不一致。`)} +
+
+ {en + ? `TXT ${toNumber(orbitSyncResult.envi?.total)}, scan errors ${toNumber(orbitSyncResult.error_count)}` + : `TXT ${toNumber(orbitSyncResult.envi?.total)} 项,扫描异常 ${toNumber(orbitSyncResult.error_count)} 项`} +
+ {(orbitSyncResult.mismatches || []).slice(0, 5).map((item, index) => ( +
+ {item.name} - {item.issue} + {item.envi_path && ( +
+ {en ? 'ENVI: ' : 'ENVI:'}{formatPathText(item.envi_path)}
)} - {(orbitSyncResult.repair_errors || []).slice(0, 3).map((item, index) => ( -
- {item.name} - {item.error} - {renderOrbitSourceIssueDetails(item, en, formatPathText)} -
- ))} - {(orbitSyncResult.sync_result?.source?.errors || []).slice(0, 3).map((item, index) => ( -
- {en ? 'Source scan error: ' : '源目录扫描异常:'}{item} -
- ))} - {(orbitSyncResult.sync_result?.invalid_sources || []).slice(0, 5).map((item, index) => ( -
- {item.name} - {item.error} - {renderOrbitSourceIssueDetails(item, en, formatPathText)} -
- ))} - {(orbitSyncResult.sync_result?.isce2?.errors || []).slice(0, 3).map((item, index) => ( -
- {item.file} - {item.error} -
- ))} - - ) : ( - <> -
- {orbitSyncResult.healthy - ? (en ? 'Pools are consistent.' : '本地池一致。') - : (en ? `Detected ${toNumber(orbitSyncResult.mismatch_count)} mismatches.` : `检测到 ${toNumber(orbitSyncResult.mismatch_count)} 项不一致。`)} -
-
- {en - ? `TXT ${toNumber(orbitSyncResult.envi?.total)}${orbitSyncResult.isce2?.enabled ? `, ISCE2 ${toNumber(orbitSyncResult.isce2?.total)}` : ', ISCE2 disabled'}, scan errors ${toNumber(orbitSyncResult.error_count)}` - : `TXT ${toNumber(orbitSyncResult.envi?.total)} 项${orbitSyncResult.isce2?.enabled ? `,ISCE2 ${toNumber(orbitSyncResult.isce2?.total)} 项` : ',ISCE2 已停用'},扫描异常 ${toNumber(orbitSyncResult.error_count)} 项`} -
- {(orbitSyncResult.mismatches || []).slice(0, 5).map((item, index) => ( -
- {item.name} - {item.issue} - {item.envi_path && ( -
- {en ? 'ENVI: ' : 'ENVI:'}{formatPathText(item.envi_path)} -
- )} - {item.isce2_path && ( -
- {en ? 'ISCE2: ' : 'ISCE2:'}{formatPathText(item.isce2_path)} -
- )} -
- ))} - {(orbitSyncResult.errors || []).slice(0, 3).map((item, index) => ( -
- {item} -
- ))} - - )} +
+ ))} + {(orbitSyncResult.errors || []).slice(0, 3).map((item, index) => ( +
+ {item} +
+ ))} )}
)} -
+ + ) : ( +
展开后点击“加载诊断”获取旧精轨池状态。
+ )} + )} +
+ {/* 系统维护 */} - {isAdmin && ( -
-
{en ? 'System Maintenance' : '系统维护'}
-
- {en ? 'Expired Sessions' : '过期会话清理'} - -
- {cleanupResult && ( -
- {cleanupResult.message} +
+
+
+

维护与审计

+

低频维护动作与日志审计集中管理,避免与实时健康状态混杂。

- )} -
- {en ? 'Delete expired and revoked session records from the database.' : '删除数据库中已过期和已撤销的会话记录,释放空间。'}
-
- )} +
+ {isAdmin && ( +
+
+

会话记录维护

+

清理已过期或已撤销的登录会话,不影响当前有效登录。

+ {cleanupResult && ( +
+ {cleanupResult.message} +
+ )} +
+ +
+ )} - {/* 日志管理 */} -
- + {/* 日志管理 */} + +
+
)} diff --git a/frontend/src/IDLAutomationPanel.jsx b/frontend/src/IDLAutomationPanel.jsx index 3c9b72e..2e4d5ba 100644 --- a/frontend/src/IDLAutomationPanel.jsx +++ b/frontend/src/IDLAutomationPanel.jsx @@ -41,7 +41,6 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) { taskTypes: ['IDL_IMPORT', 'IDL_DINSAR'], showRecent: true, recentLimit: 1, - pollRecentMs: 10000, }); const runningTask = idlTaskMonitor.activeTasks[0] || null; @@ -75,9 +74,12 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) { useEffect(() => { refreshData().catch(() => {}); - const timer = setInterval(() => refreshData().catch(() => {}), 10000); + if (!runningTask) { + return undefined; + } + const timer = setInterval(() => refreshData().catch(() => {}), 15000); return () => clearInterval(timer); - }, [refreshData]); + }, [refreshData, runningTask]); const runAction = async (action) => { setIsBusy(true); diff --git a/frontend/src/LogManagementPanel.jsx b/frontend/src/LogManagementPanel.jsx index 5433242..931699e 100644 --- a/frontend/src/LogManagementPanel.jsx +++ b/frontend/src/LogManagementPanel.jsx @@ -13,21 +13,27 @@ const LogManagementPanel = ({ isAdmin }) => { const [totalLines, setTotalLines] = useState(0); const [currentOffset, setCurrentOffset] = useState(0); const [searchTerm, setSearchTerm] = useState(''); + const [message, setMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + + const getErrorText = error => error.response?.data?.detail || error.message || '操作失败'; const loadLogs = useCallback(async () => { setLoading(true); + setErrorMessage(''); try { const data = await listLogs(filterType || null); - setLogs(data); + setLogs(Array.isArray(data) ? data : []); } catch (error) { console.error('加载日志列表失败:', error); - alert(`加载日志列表失败:${error.response?.data?.detail || error.message}`); + setErrorMessage(`日志列表加载失败:${getErrorText(error)}`); } finally { setLoading(false); } }, [filterType]); const loadLogContent = useCallback(async (logPath, offset = 0) => { + setErrorMessage(''); try { const data = await getLogContent(logPath, offset, PAGE_SIZE); setLogContent(data.content || ''); @@ -35,7 +41,7 @@ const LogManagementPanel = ({ isAdmin }) => { setCurrentOffset(offset); } catch (error) { console.error('加载日志内容失败:', error); - alert(`加载日志内容失败:${error.response?.data?.detail || error.message}`); + setErrorMessage(`日志内容加载失败:${getErrorText(error)}`); } }, []); @@ -48,12 +54,13 @@ const LogManagementPanel = ({ isAdmin }) => { setShowModal(true); setCurrentOffset(0); setSearchTerm(''); + setMessage(''); await loadLogContent(log.path, 0); }; const handleDeleteLog = async log => { if (!isAdmin) { - alert('只有管理员可以删除日志。'); + setErrorMessage('当前账号没有日志删除权限。'); return; } @@ -61,9 +68,11 @@ const LogManagementPanel = ({ isAdmin }) => { return; } + setMessage(''); + setErrorMessage(''); try { await deleteLog(log.path); - alert('日志文件已删除。'); + setMessage('日志文件已删除。'); await loadLogs(); if (selectedLog && selectedLog.path === log.path) { setShowModal(false); @@ -74,28 +83,27 @@ const LogManagementPanel = ({ isAdmin }) => { } } catch (error) { console.error('删除日志失败:', error); - alert(`删除日志失败:${error.response?.data?.detail || error.message}`); + setErrorMessage(`日志删除失败:${getErrorText(error)}`); } }; const handlePrevPage = () => { if (selectedLog && currentOffset > 0) { - const newOffset = Math.max(0, currentOffset - PAGE_SIZE); - loadLogContent(selectedLog.path, newOffset); + loadLogContent(selectedLog.path, Math.max(0, currentOffset - PAGE_SIZE)); } }; const handleNextPage = () => { if (selectedLog && currentOffset + PAGE_SIZE < totalLines) { - const newOffset = currentOffset + PAGE_SIZE; - loadLogContent(selectedLog.path, newOffset); + loadLogContent(selectedLog.path, currentOffset + PAGE_SIZE); } }; const formatSize = bytes => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + const size = Number(bytes) || 0; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; }; const getTypeLabel = type => { @@ -105,17 +113,7 @@ const LogManagementPanel = ({ isAdmin }) => { error: '错误日志', other: '其他', }; - return labels[type] || type; - }; - - const getTypeColor = type => { - const colors = { - app: '#3b82f6', - task: '#10b981', - error: '#ef4444', - other: '#6b7280', - }; - return colors[type] || '#6b7280'; + return labels[type] || type || '其他'; }; const filteredContent = searchTerm @@ -125,242 +123,118 @@ const LogManagementPanel = ({ isAdmin }) => { .join('\n') : logContent; + const pageStart = totalLines === 0 ? 0 : currentOffset + 1; + const pageEnd = Math.min(currentOffset + PAGE_SIZE, totalLines); + return ( -
-
-

日志管理

-
- - -
+ {errorMessage &&
{errorMessage}
} + {message &&
{message}
} + {logs.length === 0 ? ( -
暂无日志文件
+
{loading ? '正在读取日志目录...' : '暂无可展示的日志文件'}
) : ( - - - - - - - - - - - - {logs.map((log, index) => ( - - - - - - +
+
文件名类型大小修改时间操作
{log.name} - - {getTypeLabel(log.type)} - - - {formatSize(log.size)} - {log.modified_at} -
- - {isAdmin && ( - - )} -
-
+ + + + + + + - ))} - -
文件名类型大小修改时间操作
+ + + {logs.map(log => ( + + {log.name} + + + {getTypeLabel(log.type)} + + + {formatSize(log.size)} + {log.modified_at || '-'} + +
+ + {isAdmin && ( + + )} +
+ + + ))} + + +
)} {showModal && selectedLog && ( -
-
-
+
+
+
-

{selectedLog.name}

-
- 大小:{formatSize(selectedLog.size)} | 修改时间:{selectedLog.modified_at} | 总行数:{totalLines} -
+

{selectedLog.name}

+

+ 大小:{formatSize(selectedLog.size)} · 修改时间:{selectedLog.modified_at || '-'} · 总行数:{totalLines} +

-
-
+
setSearchTerm(event.target.value)} - style={{ - flex: 1, - padding: '6px 12px', - border: '1px solid #d1d5db', - borderRadius: '4px', - fontSize: '13px', - }} /> -
- 显示行 {totalLines === 0 ? 0 : currentOffset + 1} - {Math.min(currentOffset + PAGE_SIZE, totalLines)} +
+ 显示行 {pageStart} - {pageEnd}
-
-
-
-                {filteredContent || '(空日志)'}
-              
+
+
{filteredContent || '(空日志)'}
diff --git a/frontend/src/LoginPage.jsx b/frontend/src/LoginPage.jsx index 4a2c3ef..e1c42c1 100644 --- a/frontend/src/LoginPage.jsx +++ b/frontend/src/LoginPage.jsx @@ -1,7 +1,6 @@ import React, { useEffect, useState } from 'react'; import apiClient from './api/client'; - const LoginPage = ({ onLoginSuccess }) => { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -105,5 +104,4 @@ const LoginPage = ({ onLoginSuccess }) => { ); }; - export default LoginPage; diff --git a/frontend/src/ProductionWorkspace.jsx b/frontend/src/ProductionWorkspace.jsx index 4dccc55..a6f6906 100644 --- a/frontend/src/ProductionWorkspace.jsx +++ b/frontend/src/ProductionWorkspace.jsx @@ -1,5 +1,4 @@ import { Suspense, lazy, useEffect, useMemo, useState } from 'react'; - import { PRODUCTION_WORKSPACE_ENTRY_TO_VIEW, PRODUCTION_WORKSPACE_TAB, @@ -17,133 +16,151 @@ const LazyPairsListPanel = lazy(() => import('./panels/PairsListPanel')); const LazyBatchPanel = lazy(() => import('./panels/BatchPanel')); const LazyDataCopierPanel = lazy(() => import('./DataCopierPanel')); -const shellStyle = { - minHeight: '100%', - padding: '20px 24px 28px', - boxSizing: 'border-box', - background: 'linear-gradient(180deg, #f5f7fb 0%, #eef4ff 52%, #f8fafc 100%)', -}; - -const heroStyle = { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', - gap: 16, - marginBottom: 18, -}; - -const heroCardStyle = { - borderRadius: 24, - border: '1px solid #d7e0eb', - background: 'linear-gradient(135deg, #ffffff 0%, #f8fbff 56%, #eef6ff 100%)', - boxShadow: '0 16px 40px rgba(15, 23, 42, 0.06)', -}; - -const summaryCardStyle = { - padding: '12px 14px', - borderRadius: 18, - border: '1px solid #e2e8f0', - background: 'rgba(255, 255, 255, 0.82)', -}; +const WORKFLOW_STEPS = [ + '数据准备', + '配对/栈规划', + '生产运行', + '质量检查', + '成果发布', +]; const SENSOR_PRODUCTION_PLACEHOLDERS = { lt1_production: { - title: '陆探一号生产模块', - subtitle: '当前先占位纳入生产管理,执行链路保留 LandSAR、ENVI+SARscape、Gamma/PyINT。', + title: '陆探一生产占位', + note: '当前保留 LT-1 源压缩包本机登记与按需 materialize 入口。', rows: [ - ['源压缩包', 'D:\\LuTan1_Image_Pool_Zip,只索引包内 XML/元数据,不做全量解包。'], - ['精密轨道', 'D:\\LT1_data_lsarorbit,本机部署并绑定到源资产。'], - ['按需解包', '生产任务需要时才 materialize 到 D:\\Task_Pool\\DInSAR 或 D:\\Task_Pool\\SBAS。'], - ['生产边界', 'D-InSAR 与 SBAS-InSAR 均使用本机 Task_Pool,不允许 UNC 参与运行。'], - ['结果管理', '生成结果进入 D-InSAR/SBAS 产物目录,由生产管理结果页统一重建 catalog。'], + ['数据来源', '本机源压缩包 archive'], + ['精轨策略', '按生产任务关联 orbit 资产'], + ['准备方式', '按需 materialize 到 Task_Pool'], + ['生产边界', 'D-InSAR/SBAS 不走 UNC'], + ['结果管理', '进入统一产品 catalog'], ], }, sentinel1_production: { - title: 'Sentinel-1 生产模块', - subtitle: '当前先占位纳入生产管理,D-InSAR 保留 Gamma/PyINT 路径,SBAS 仍为规划态。', + title: 'Sentinel-1 生产占位', + note: '当前主要沉淀数据与精轨管理约束,SBAS 仅保留规划能力。', rows: [ - ['源压缩包', 'D:\\Sentinel1_Image_Pool_ZIP,本机登记 ZIP/SAFE 元数据。'], - ['精密轨道', 'D:\\Sentinel1_EOF_Pool,本机保存 AUX_POEORB/RESORB。'], - ['按需解包', '需要运行时才将 ZIP 解包到本机 Task_Pool,界面不提供全量解包按钮。'], - ['D-InSAR', 'Gamma/PyINT 可作为生产方向,运行材料必须来自本机路径。'], - ['SBAS', '当前仅做堆栈发现和规划,执行链路未启用。'], + ['数据来源', 'ZIP/SAFE 本机 archive'], + ['精轨策略', 'EOF 精轨本机管理'], + ['准备方式', '按需解包到工作目录'], + ['D-InSAR', '走统一生产任务队列'], + ['SBAS', '保留序列规划能力'], ], }, gf3_native_registration: { title: '高分三结果登记', - subtitle: 'GF3 不在本机生产;另一台 SARscape 服务器完成 _geo 后复制到本机登记。', + note: 'GF3 由外部 SARscape 服务生产,本系统登记回传成果并生成预览。', rows: [ - ['外部生产', '外部机器按 YYYYMMDD_geo/场景目录输出 SARscape 原生 _geo 二进制。'], - ['本机落盘', '复制到 D:\\GaoFen3_Pool\\native_geo 后递归扫描登记。'], - ['预览生成', 'WebP 从 *_geo 主二进制读取生成,不使用 *_geo_ql.tif 作为正式预览源。'], - ['精轨', 'GF3 本链路无精密轨道管理。'], - ['结果管理', '登记后的 GF3 资产进入数据管理,后续需要全影像时再提取/标准化。'], + ['生产方式', '外部 SARscape 服务'], + ['落地路径', '本机登记 _geo 二进制'], + ['预览生成', '转换 WebP 供地图使用'], + ['精轨策略', '按外部生产结果留痕'], + ['结果管理', '进入统一产品 catalog'], ], }, }; -function SensorProductionPlaceholder({ viewKey }) { - const data = SENSOR_PRODUCTION_PLACEHOLDERS[viewKey]; - if (!data) { - return null; - } - return ( -
-
当前设计约定
-

{data.title}

-

- {data.subtitle} -

-
- {data.rows.map(([label, value]) => ( -
- {label} - {value} -
- ))} -
-
- ); -} +const shellStyle = { + minHeight: '100%', + background: '#f8fafc', + color: '#0f172a', +}; -function resolveView(entry) { - return PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[entry] || PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[PRODUCTION_WORKSPACE_TAB]; +const headerStyle = { + padding: '18px 20px 14px', + borderBottom: '1px solid #e2e8f0', + background: '#ffffff', +}; + +const sectionStyle = { + padding: '16px 20px 22px', +}; + +const compactPanelStyle = { + border: '1px solid #e2e8f0', + borderRadius: 8, + background: '#ffffff', +}; + +const mutedTextStyle = { + color: '#64748b', + fontSize: 13, + lineHeight: 1.6, +}; + +function resolveView(activeEntry) { + return PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[activeEntry] || PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[PRODUCTION_WORKSPACE_TAB]; } function resolveWorkbenchKey(viewKey) { - const workbench = PRODUCTION_WORKSPACE_WORKBENCHES.find(item => ( - item.views.some(view => view.key === viewKey) - )); - return workbench?.key || PRODUCTION_WORKSPACE_WORKBENCHES[0]?.key || 'dinsar_workbench'; + const workbench = PRODUCTION_WORKSPACE_WORKBENCHES.find(item => + item.views.some(view => view.key === viewKey), + ); + return workbench?.key || PRODUCTION_WORKSPACE_WORKBENCHES[0]?.key; +} + +function buttonStyle(active) { + return { + border: `1px solid ${active ? '#2563eb' : '#cbd5e1'}`, + background: active ? '#eff6ff' : '#ffffff', + color: active ? '#1d4ed8' : '#334155', + borderRadius: 6, + padding: '7px 10px', + fontSize: 13, + fontWeight: 600, + cursor: 'pointer', + lineHeight: 1.3, + }; +} + +function PlaceholderView({ config }) { + if (!config) { + return ( +
+

生产入口未配置

+

当前视图尚未接入生产面板。

+
+ ); + } + + return ( +
+

{config.title}

+

{config.note}

+
+ {config.rows.map(([label, value]) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
+
+ ); } export default function ProductionWorkspace({ - activeEntry = PRODUCTION_WORKSPACE_TAB, - readOnly = false, + activeEntry, + readOnly, onTaskStart, apiEndpoint, language, - foundPairs = [], - selectedPairsCount = 0, - isLoading = false, - hasEnoughRadarScenesForPlanning = false, - hasRadarSearched = false, - pairingPanel = {}, - radarPanel = {}, - pairsPanel = {}, + foundPairs, + selectedPairsCount, + isLoading, + hasEnoughRadarScenesForPlanning, + hasRadarSearched, + pairingPanel, + radarPanel, + pairsPanel, }) { - const [activeView, setActiveView] = useState(() => resolveView(activeEntry)); - const [activeWorkbench, setActiveWorkbench] = useState(() => resolveWorkbenchKey(resolveView(activeEntry))); + const initialView = resolveView(activeEntry); + const [activeView, setActiveView] = useState(initialView); + const [activeWorkbench, setActiveWorkbench] = useState(resolveWorkbenchKey(initialView)); useEffect(() => { const nextView = resolveView(activeEntry); @@ -151,17 +168,16 @@ export default function ProductionWorkspace({ setActiveWorkbench(resolveWorkbenchKey(nextView)); }, [activeEntry]); - const activeViewMeta = useMemo( - () => PRODUCTION_WORKSPACE_VIEWS.find(view => view.key === activeView) || PRODUCTION_WORKSPACE_VIEWS[0], - [activeView] - ); - const activeWorkbenchMeta = useMemo( + const currentWorkbench = useMemo( () => PRODUCTION_WORKSPACE_WORKBENCHES.find(item => item.key === activeWorkbench) || PRODUCTION_WORKSPACE_WORKBENCHES[0], - [activeWorkbench] + [activeWorkbench], + ); + const currentView = useMemo( + () => PRODUCTION_WORKSPACE_VIEWS.find(view => view.key === activeView), + [activeView], ); - const activeSubViews = activeWorkbenchMeta?.views || []; - const switchWorkbench = (workbench) => { + const switchWorkbench = workbench => { setActiveWorkbench(workbench.key); if (!workbench.views.some(view => view.key === activeView)) { setActiveView(workbench.defaultView); @@ -177,224 +193,176 @@ export default function ProductionWorkspace({ }; const handleDinsarPrepareQueued = taskId => { - onTaskStart?.(taskId, 'D-InSAR生产准备任务已入队,正在处理...', { + onTaskStart?.(taskId, 'D-InSAR 生产准备任务已入队,正在处理...', { taskType: 'COPY_DATA', nonBlocking: true, }); }; const handleSbasProductQueued = taskId => { - onTaskStart?.(taskId, 'SBAS-InSAR result catalog task queued.', { + onTaskStart?.(taskId, 'SBAS-InSAR 结果 catalog 任务已入队。', { taskType: 'REBUILD_SBAS_INSAR_CATALOG', nonBlocking: true, }); }; + const renderContent = () => { + if (activeView === 'dinsar_pairing') { + return ( + + ); + } + + if (activeView === 'dinsar_pairs') { + return ( +
+ + +
+ ); + } + + if (activeView === 'dinsar_prepare') { + return ( + + ); + } + + if (activeView === 'dinsar_runs') { + return ; + } + + if (activeView === 'dinsar_products') { + return ; + } + + if (['sbas_insar_planning', 'sbas_insar_batches', 'sbas_insar_prepare', 'sbas_insar_runs'].includes(activeView)) { + const focusMap = { + sbas_insar_planning: 'planning', + sbas_insar_batches: 'batches', + sbas_insar_prepare: 'prepare', + sbas_insar_runs: 'runs', + }; + return ( + + ); + } + + if (activeView === 'sbas_insar_products') { + return ; + } + + return ; + }; + return (
-
-
-
- Production Management +
+
+
+
生产工作台
+

InSAR 生产管理

+

+ 面向科研工程生产的任务编排入口,统一组织数据准备、规划、运行、质量检查与成果发布。 +

-

生产管理

-

- 这里统一承载 D-InSAR 配对、批次、生产准备、运行和产物管理,以及 Gamma SBAS-InSAR 生产链。 - 陆探与哨兵源数据按压缩包登记,生产时再解包到本机 Task_Pool;高分三只登记外部 SARscape 服务器复制回来的 _geo 结果。 -

-
+ {readOnly && ( +
+ 当前为只读账号,生产提交操作已禁用。 +
+ )} +
-
-
-
主生产链
-
D-InSAR / SBAS
-
- D-InSAR 使用配对批次驱动;SBAS 使用 Gamma IPTA 工作流驱动。PS/旧时序入口不再作为主流程展示。 +
+ {WORKFLOW_STEPS.map((step, index) => ( +
+ {index + 1}. {step}
-
-
-
运行边界
-
本机 Task_Pool
-
- 源压缩包先登记元数据,生产需要时再按需解包;D-InSAR/SBAS 不走 UNC。 -
-
-
-
结果管理
-
产物 catalog
-
- 生产结果进入 D-InSAR、SBAS 或 GF3 数据目录,后续分析从结果 catalog 读取。 -
-
-
+ ))} +
-
-
- {PRODUCTION_WORKSPACE_WORKBENCHES.map(workbench => { - const isActive = workbench.key === activeWorkbench; - return ( -
-
{workbench.description}
- - ); - })} -
- - -
-
- {activeSubViews.map(view => { - const isActive = view.key === activeView; - return ( - - ); - })} -
-
- -
-
- {activeWorkbenchMeta?.label} / {activeViewMeta.label} -
- }> - {SENSOR_PRODUCTION_PLACEHOLDERS[activeView] && ( - - )} - {activeView === 'dinsar_pairing' && ( - - )} - {activeView === 'dinsar_pairs' && ( -
- - + + ))}
- )} - {activeView === 'dinsar_prepare' && ( - - )} - {activeView === 'dinsar_runs' && ( - - )} - {['sbas_insar_planning', 'sbas_insar_batches', 'sbas_insar_prepare', 'sbas_insar_runs'].includes(activeView) && ( - - )} - {activeView === 'sbas_insar_products' && ( - - )} - {activeView === 'dinsar_products' && ( - - )} -
-
+ + +
+
+
+
+
{currentWorkbench?.label}
+

{currentView?.label || '生产视图'}

+
+
{currentView?.description}
+
+
+ {currentWorkbench?.views.map(view => ( + + ))} +
+
+ + }> + {renderContent()} + +
+
+
); } diff --git a/frontend/src/ResultExtractionPanel.jsx b/frontend/src/ResultExtractionPanel.jsx new file mode 100644 index 0000000..ba19399 --- /dev/null +++ b/frontend/src/ResultExtractionPanel.jsx @@ -0,0 +1,418 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { exportDinsarResults, getDinsarResults } from './api/dinsar'; +import { listSbasInsarProducts } from './api/sbasInsarProducts'; +import { getDinsarEngineMeta } from './utils/dinsarEngines'; + +const DEFAULT_TARGET_DIR = String.raw`D:\Result_Export\DInSAR`; +const PAGE_SIZE = 100; + +const PRODUCT_CHANNELS = [ + { + key: 'dinsar', + group: 'InSAR 成果', + label: 'D-InSAR 结果', + state: 'ready', + stateText: '可提取', + description: '从已登记的 D-InSAR 成果中选择位移结果,复制到服务器指定交付目录。', + }, + { + key: 'sbas', + group: 'InSAR 成果', + label: 'SBAS-InSAR 结果', + state: 'planned', + stateText: '目录可查', + description: '成果目录和预览已接入,统一提取接口待补齐。', + }, + { + key: 'lt1_ortho', + group: '正射成果', + label: 'LT-1 正射结果', + state: 'placeholder', + stateText: '待接入', + description: '陆探一正射生产结果后续接入标准成果目录,并开放提取。', + }, + { + key: 's1_ortho', + group: '正射成果', + label: 'Sentinel-1 正射结果', + state: 'placeholder', + stateText: '待接入', + description: 'Sentinel-1 正射生产占位,后续登记后统一提取。', + }, + { + key: 'gf3_ortho', + group: '正射成果', + label: 'GF3 SARscape _geo', + state: 'placeholder', + stateText: '待接入', + description: 'GF3 外部生产后的 _geo 二进制和 WebP 已按本机登记思路设计,统一导出接口待接入。', + }, +]; + +function formatNumber(value) { + const number = Number(value); + if (!Number.isFinite(number)) return '-'; + return new Intl.NumberFormat('zh-CN').format(number); +} + +function normalizeItems(payload) { + return Array.isArray(payload?.items) ? payload.items : []; +} + +function extractTotal(payload, fallback = 0) { + const total = Number(payload?.total); + return Number.isFinite(total) ? total : fallback; +} + +function resultDisplayName(result) { + return String(result?.name || result?.task_alias || result?.task_name || result?.product_id || `#${result?.id || ''}`).trim(); +} + +function resultDateText(result) { + const name = resultDisplayName(result); + const matches = name.match(/(\d{8})/g); + if (matches?.length >= 2) return `${matches[0]} / ${matches[1]}`; + if (matches?.length === 1) return matches[0]; + return '-'; +} + +function stateClass(state) { + if (state === 'ready') return 'ready'; + if (state === 'planned') return 'planned'; + return 'pending'; +} + +export default function ResultExtractionPanel({ readOnly = false }) { + const [activeChannel, setActiveChannel] = useState('dinsar'); + const [dinsarPayload, setDinsarPayload] = useState({ items: [], total: 0 }); + const [sbasPayload, setSbasPayload] = useState({ items: [], total: 0 }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [query, setQuery] = useState(''); + const [targetDir, setTargetDir] = useState(DEFAULT_TARGET_DIR); + const [selectedIds, setSelectedIds] = useState(() => new Set()); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(''); + const [exportResult, setExportResult] = useState(null); + + const selectedChannel = PRODUCT_CHANNELS.find(channel => channel.key === activeChannel) || PRODUCT_CHANNELS[0]; + + const loadCatalogs = async () => { + setLoading(true); + setError(''); + try { + const [dinsarData, sbasData] = await Promise.all([ + getDinsarResults({ limit: PAGE_SIZE, offset: 0 }), + listSbasInsarProducts({ limit: 30, offset: 0 }), + ]); + const dinsarItems = normalizeItems(dinsarData); + setDinsarPayload({ ...dinsarData, items: dinsarItems, total: extractTotal(dinsarData, dinsarItems.length) }); + const sbasItems = normalizeItems(sbasData); + setSbasPayload({ ...sbasData, items: sbasItems, total: extractTotal(sbasData, sbasItems.length) }); + setSelectedIds(new Set(dinsarItems.map(item => item.id).filter(id => id !== undefined && id !== null))); + } catch (err) { + setError(err?.response?.data?.detail || err.message || '结果目录加载失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadCatalogs(); + }, []); + + const filteredDinsar = useMemo(() => { + const value = query.trim().toLowerCase(); + const items = dinsarPayload.items || []; + if (!value) return items; + return items.filter(item => { + const haystack = [ + item.name, + item.task_name, + item.task_alias, + item.pair_key, + item.product_id, + item.engine_code, + item.file_path, + ].filter(Boolean).join(' ').toLowerCase(); + return haystack.includes(value); + }); + }, [dinsarPayload.items, query]); + + const filteredIds = useMemo( + () => filteredDinsar.map(item => item.id).filter(id => id !== undefined && id !== null), + [filteredDinsar], + ); + + const selectedCountInView = filteredIds.filter(id => selectedIds.has(id)).length; + const allVisibleSelected = filteredIds.length > 0 && selectedCountInView === filteredIds.length; + + const orthoPlaceholderCount = PRODUCT_CHANNELS.filter(channel => channel.group === '正射成果').length; + const currentCatalogTotal = Number(dinsarPayload.total || 0) + Number(sbasPayload.total || 0); + + const metrics = [ + { + label: 'D-InSAR 可提取', + value: dinsarPayload.total, + note: `当前载入 ${filteredDinsar.length}/${dinsarPayload.items.length} 条`, + tone: 'primary', + }, + { + label: 'SBAS 目录', + value: sbasPayload.total, + note: '统一提取接口待接入', + tone: 'neutral', + }, + { + label: '当前接入目录', + value: currentCatalogTotal, + note: 'D-InSAR + SBAS 已接入清单', + tone: 'neutral', + }, + { + label: '正射通道', + value: orthoPlaceholderCount, + note: 'LT-1 / S1 / GF3 占位', + tone: 'warning', + }, + ]; + + const toggleOne = (id) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const toggleVisible = () => { + setSelectedIds(prev => { + const next = new Set(prev); + if (allVisibleSelected) { + filteredIds.forEach(id => next.delete(id)); + } else { + filteredIds.forEach(id => next.add(id)); + } + return next; + }); + }; + + const handleExport = async () => { + const dir = targetDir.trim(); + if (!dir) { + setExportError('请输入服务器目标目录。'); + return; + } + const ids = [...selectedIds].filter(id => filteredIds.includes(id)); + if (ids.length === 0) { + setExportError('请至少选择一条 D-InSAR 结果。'); + return; + } + setExporting(true); + setExportError(''); + setExportResult(null); + try { + const response = await exportDinsarResults(ids, dir); + setExportResult(response); + } catch (err) { + setExportError(err?.response?.data?.detail || err.message || 'D-InSAR 结果提取失败'); + } finally { + setExporting(false); + } + }; + + const renderDinsarWorkspace = () => ( +
+
+
+ D-InSAR 交付提取 + 选择已登记结果并复制到服务器目录 +
+ +
+ +
+ + +
+ + +
+
+ +
+ 目标目录是服务器可访问路径,后端会按任务名或成果名创建子目录,避免直接覆盖同名成果。 +
+ + {error &&
{error}
} + {exportError &&
{exportError}
} + {exportResult && ( +
+ 提取完成 + 复制 {formatNumber(exportResult.copied)} 项,跳过 {formatNumber(exportResult.skipped)} 项,失败 {formatNumber(exportResult.failed)} 项。 + {exportResult.target_dir} +
+ )} + +
+ 结果列表 + {selectedCountInView}/{filteredDinsar.length} +
+
+ {loading ? ( +
正在加载成果目录...
+ ) : filteredDinsar.length === 0 ? ( +
当前条件下没有可提取的 D-InSAR 结果。
+ ) : ( + filteredDinsar.map(result => { + const id = result.id; + const engineMeta = getDinsarEngineMeta(result.engine_code); + return ( + + ); + }) + )} +
+
+ ); + + const renderPlaceholderWorkspace = () => ( +
+
+ + {selectedChannel.stateText} + + {selectedChannel.label} +

{selectedChannel.description}

+
+
+ 登记入口 + {selectedChannel.key === 'sbas' ? 'SBAS-InSAR 成果目录' : '生产管理成果登记'} +
+
+ 提取接口 + 待实现 +
+
+ 交付目录 + 服务器固定/指定路径 +
+
+ {selectedChannel.key === 'sbas' && ( +
+ 当前 SBAS 目录样例 + {sbasPayload.items.length === 0 ? ( +

暂无可展示的 SBAS-InSAR 成果。

+ ) : ( + sbasPayload.items.slice(0, 5).map(item => ( +
+ {item.product_id || item.name || `#${item.id}`} + {item.status || 'UNKNOWN'} +
+ )) + )} +
+ )} +
+
+ ); + + return ( +
+
+
+ 成果交付出口 + 结果提取工作台 +

+ 将三类正射生产成果、D-InSAR 成果和 SBAS-InSAR 成果集中管理。当前 D-InSAR 已接入真实提取, + 其余链路先保留清晰占位,避免把未完成流程误当成可执行功能。 +

+
+
+ D-InSAR {formatNumber(dinsarPayload.total)} + SBAS {formatNumber(sbasPayload.total)} + {readOnly ? '只读账号' : '可执行账号'} +
+
+ +
+ {metrics.map(metric => ( +
+ {metric.label} + {formatNumber(metric.value)} +

{metric.note}

+
+ ))} +
+ +
+ + {activeChannel === 'dinsar' ? renderDinsarWorkspace() : renderPlaceholderWorkspace()} +
+
+ ); +} diff --git a/frontend/src/SbasInsarProductionPanel.jsx b/frontend/src/SbasInsarProductionPanel.jsx index 50fd6fe..8983496 100644 --- a/frontend/src/SbasInsarProductionPanel.jsx +++ b/frontend/src/SbasInsarProductionPanel.jsx @@ -285,33 +285,33 @@ function RuntimeStatusPanel({ status }) { return (
-
Runtime Status
+
运行状态
- - - + + +
{(currentTask || currentJob) && (
- Task: {currentTask ? `${currentTask.task_type || '-'} ${currentTask.status || '-'} ${currentTask.progress ?? 0}%` : '-'} + Task:{currentTask ? `${currentTask.task_type || '-'} ${currentTask.status || '-'} ${currentTask.progress ?? 0}%` : '-'} {'; '} - Job: {currentJob ? `${currentJob.job_type || '-'} ${currentJob.status || '-'}` : '-'} + Job:{currentJob ? `${currentJob.job_type || '-'} ${currentJob.status || '-'}` : '-'}
)} {latestTaskLog && (
- DB log: [{latestTaskLog.level || 'INFO'}] {latestTaskLog.message} + DB 日志:[{latestTaskLog.level || 'INFO'}] {latestTaskLog.message}
)} {latestFileLog?.tail && (
- {latestFileLog.name || 'latest log'} + {latestFileLog.name || '最近日志'}
       )}
       
- WSL processes ({wslProcesses.length}) + WSL 进程({wslProcesses.length})
{wslProcesses.length === 0 && ( -
{status.wsl_processes?.error || 'No matching WSL process reported.'}
+
{status.wsl_processes?.error || '未发现匹配的 WSL 进程。'}
)} {wslProcesses.map(item => (
@@ -1021,7 +1021,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart setLandsarRunDetail(detailData); } if (data?.task_id) { - onTaskStart?.(data.task_id, 'LandSAR SBAS workflow queued.', { + onTaskStart?.(data.task_id, 'LandSAR SBAS Workflow 已入队。', { taskType: data.job_type || 'SBAS_LANDSAR_WORKFLOW', nonBlocking: true, }); @@ -1070,7 +1070,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart const data = await submitSbasInsarWorkflowJob(selectedRunId, workflowPayload); setWorkflowJob(data); if (data?.task_id) { - onTaskStart?.(data.task_id, 'Gamma SBAS workflow queued.', { + onTaskStart?.(data.task_id, 'Gamma SBAS Workflow 已入队。', { taskType: data.job_type || 'SBAS_GAMMA_WORKFLOW', nonBlocking: true, }); @@ -1163,7 +1163,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart }); setCoregistrationJob(data); if (data?.task_id) { - onTaskStart?.(data.task_id, 'SBAS coregistration task queued.', { + onTaskStart?.(data.task_id, 'SBAS 共参考配准 Task 已入队。', { taskType: data.job_type || 'SBAS_COREGISTRATION', nonBlocking: true, }); @@ -1210,7 +1210,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart }); setRdcDemJob(data); if (data?.task_id) { - onTaskStart?.(data.task_id, 'SBAS RDC DEM task queued.', { + onTaskStart?.(data.task_id, 'SBAS RDC DEM Task 已入队。', { taskType: data.job_type || 'SBAS_RDC_DEM', nonBlocking: true, }); @@ -1261,7 +1261,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart }); setInterferogramJob(data); if (data?.task_id) { - onTaskStart?.(data.task_id, 'SBAS interferogram task queued.', { + onTaskStart?.(data.task_id, 'SBAS 干涉图 Task 已入队。', { taskType: data.job_type || 'SBAS_INTERFEROGRAMS', nonBlocking: true, }); @@ -1310,7 +1310,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart }); setIptaTimeseriesJob(data); if (data?.task_id) { - onTaskStart?.(data.task_id, 'SBAS IPTA timeseries task queued.', { + onTaskStart?.(data.task_id, 'SBAS IPTA 时序 Task 已入队。', { taskType: data.job_type || 'SBAS_IPTA_TIMESERIES', nonBlocking: true, }); @@ -1379,7 +1379,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart }} style={{ border: '1px solid #0369a1', borderRadius: 8, background: '#e0f2fe', color: '#0369a1', padding: '7px 11px', fontWeight: 750 }} > - Open Runtime Status + 打开运行状态
) : null; @@ -2058,7 +2058,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
{running && (
- 正在运行,已自动打开右侧 Runtime Status + 正在运行,已自动打开右侧运行状态
)} @@ -2153,11 +2153,11 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart color: '#9a3412', fontSize: 12, }}> - Sentinel-1 Gamma SBAS is planning-only. Stack discovery, audit manifest and run record are enabled; Gamma execution is disabled until the S1 TOPS/SBAS scripts are verified. + Sentinel-1 Gamma SBAS 当前仅开放规划能力:可进行栈发现、审计 Manifest 和 Run 记录管理;Gamma 执行需等待 S1 TOPS/SBAS 脚本验证完成后启用。
)}
- 专家文档目录 + manifest + WSL runner 主路径。旧分阶段执行仅作为兼容桥接。 + 专家文档目录 + manifest + WSL runner 主路径,生产执行以当前统一工作流为准。
@@ -1558,7 +1570,26 @@ export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued }
-
+
+
+ 操作模式 + {readOnly ? '只读' : '可维护'} +
+
+ 目录状态 + {catalogStatusValue} +
+
0 ? 'ready' : 'neutral'}`}> + 登记产品 + {productCount} +
+
0 ? 'warn' : 'ready'}`}> + 问题数 + {catalogIssueCount} +
+
+ +
} accent={catalogColor} /> @@ -1575,7 +1606,14 @@ export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued } )} -
+
+
+ 结果检索与资产复核 + 左侧筛选已登记结果,右侧查看预览图、位置摘要、统计、下载资产和目录问题。 +
+
+ +
{ - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; +const STATUS_COLORS = { + READY: '#16a34a', + OK: '#16a34a', + COMPLETED: '#16a34a', + SUCCESS: '#16a34a', + RUNNING: '#0ea5e9', + PENDING: '#f59e0b', + FAILED: '#dc2626', + ERROR: '#dc2626', + WARNING: '#f59e0b', + WARN: '#f59e0b', + UNKNOWN: '#64748b', }; -const StatisticsDashboard = ({ onClose }) => { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); +function formatNumber(value) { + const number = Number(value); + if (!Number.isFinite(number)) return '-'; + return new Intl.NumberFormat('zh-CN').format(number); +} + +function formatPercent(rate) { + const number = Number(rate); + if (!Number.isFinite(number)) return '-'; + return `${(number * 100).toFixed(1)}%`; +} + +function formatDuration(seconds) { + const number = Number(seconds); + if (!Number.isFinite(number) || number <= 0) return '-'; + if (number < 60) return `${Math.round(number)} 秒`; + if (number < 3600) return `${Math.round(number / 60)} 分钟`; + return `${(number / 3600).toFixed(1)} 小时`; +} + +function formatDateTime(value) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +function colorForStatus(status) { + return STATUS_COLORS[String(status || '').toUpperCase()] || '#64748b'; +} + +function colorForFamily(family) { + return FAMILY_COLORS[family] || '#64748b'; +} + +function colorForCoverageCount(count, maxCount) { + const value = Number(count) || 0; + const max = Math.max(Number(maxCount) || 1, 1); + const rate = value / max; + if (rate >= 0.82) return '#08306b'; + if (rate >= 0.62) return '#1261b4'; + if (rate >= 0.42) return '#2f7ed8'; + if (rate >= 0.24) return '#5aa9f4'; + if (rate >= 0.1) return '#9bd3ff'; + return '#dbeeff'; +} + +function isFeatureCollection(features) { + return features?.type === 'FeatureCollection' + && Array.isArray(features.features) + && features.features.length > 0; +} + +function registerCityCoverageMap(features) { + if (!isFeatureCollection(features)) return false; + echarts.registerMap(CITY_COVERAGE_MAP_NAME, features); + return true; +} + +function buildCityCoverageGrid(cityCoverage, mode = 'source') { + const payload = cityCoverage || {}; + const scope = mode === 'results' ? payload.results : payload.source; + const features = Array.isArray(payload.features?.features) ? payload.features.features : []; + const regions = Array.isArray(scope?.regions) ? scope.regions : []; + if (!features.length || !regions.length) return null; + const countByTree = new Map(regions.map((item) => [String(item.tree_id), item])); + return { + source_type: 'city_regions', + total: scope.total, + covered_count: scope.matched_count, + cell_count: regions.length, + max_count: scope.max_count, + extent: {}, + features: payload.features, + cells: regions.map((region) => ({ + tree_id: region.tree_id, + name: region.name, + count: region.count, + lon: region.lon, + lat: region.lat, + breakdown: region.breakdown || [], + meta: countByTree.get(String(region.tree_id)), + })), + }; +} + +function Chart({ option, className = '', emptyText = '暂无数据' }) { + const containerRef = useRef(null); + const chartRef = useRef(null); + const [renderError, setRenderError] = useState(''); useEffect(() => { - const fetchStatistics = async () => { - try { - setLoading(true); - const data = await statsApi.getStatistics(); - setStats(data); - setError(''); - } catch (err) { - setError('无法加载统计数据,请确认后端服务正常。'); - console.error(err); - } finally { - setLoading(false); - } + if (!containerRef.current) return undefined; + const chart = echarts.init(containerRef.current, null, { renderer: 'canvas' }); + chartRef.current = chart; + + let resizeObserver = null; + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(() => chart.resize()); + resizeObserver.observe(containerRef.current); + } + const onResize = () => chart.resize(); + window.addEventListener('resize', onResize); + + return () => { + window.removeEventListener('resize', onResize); + resizeObserver?.disconnect(); + chart.dispose(); + chartRef.current = null; }; - fetchStatistics(); }, []); - const generateChartData = (title, data, colors = null) => { - const labels = Object.keys(data || {}); - const values = Object.values(data || {}).map(num); - const defaultBg = [ - 'rgba(255, 99, 132, 0.7)', - 'rgba(54, 162, 235, 0.7)', - 'rgba(255, 206, 86, 0.7)', - 'rgba(75, 192, 192, 0.7)', - 'rgba(153, 102, 255, 0.7)', - 'rgba(255, 159, 64, 0.7)', - ]; - const defaultBorder = [ - 'rgba(255, 99, 132, 1)', - 'rgba(54, 162, 235, 1)', - 'rgba(255, 206, 86, 1)', - 'rgba(75, 192, 192, 1)', - 'rgba(153, 102, 255, 1)', - 'rgba(255, 159, 64, 1)', - ]; + useEffect(() => { + if (!chartRef.current) return; + if (option) { + try { + chartRef.current.setOption(option, true); + setRenderError(''); + } catch (err) { + console.error('Statistics chart render failed', err); + chartRef.current.clear(); + setRenderError(err?.message || 'chart render failed'); + } + } else { + setRenderError(''); + chartRef.current.clear(); + } + }, [option]); + + return ( +
+
+ {!option &&
{emptyText}
} + {renderError &&
图表渲染失败,请刷新或检查统计数据
} +
+ ); +} + +function buildSourceOption(rows) { + const data = Array.isArray(rows) ? rows : []; + if (!data.length) return null; + return { + animation: false, + color: data.map((item) => colorForFamily(item.family)), + grid: { left: 36, right: 12, top: 26, bottom: 28 }, + tooltip: { + trigger: 'axis', + confine: true, + axisPointer: { type: 'shadow' }, + formatter: (params) => { + const item = params?.[0]?.data || {}; + return `${item.family}
资产:${formatNumber(item.count)} 景
解析可用:${formatPercent(item.ready_rate)}`; + }, + }, + xAxis: { + type: 'category', + data: data.map((item) => item.family), + axisTick: { show: false }, + axisLine: { lineStyle: { color: '#cbd5e1' } }, + axisLabel: { color: '#475569', fontWeight: 700 }, + }, + yAxis: { + type: 'value', + splitLine: { lineStyle: { color: '#edf2f7' } }, + axisLabel: { color: '#64748b' }, + }, + series: [ + { + type: 'bar', + barMaxWidth: 42, + data: data.map((item) => ({ + ...item, + value: item.count, + itemStyle: { color: colorForFamily(item.family), borderRadius: [4, 4, 0, 0] }, + })), + }, + ], + }; +} + +function buildTrendOption(rows, familyNames) { + const data = Array.isArray(rows) ? rows : []; + if (!data.length) return null; + const families = familyNames?.length + ? familyNames + : Array.from(new Set(data.flatMap((item) => Object.keys(item.by_family || {})))); + return { + animation: false, + color: families.map(colorForFamily), + legend: { + top: 0, + right: 0, + itemWidth: 12, + itemHeight: 8, + textStyle: { color: '#475569', fontSize: 11 }, + }, + grid: { left: 42, right: 12, top: 34, bottom: 28 }, + tooltip: { trigger: 'axis', confine: true }, + xAxis: { + type: 'category', + boundaryGap: false, + data: data.map((item) => item.month), + axisTick: { show: false }, + axisLine: { lineStyle: { color: '#cbd5e1' } }, + axisLabel: { color: '#64748b' }, + }, + yAxis: { + type: 'value', + splitLine: { lineStyle: { color: '#edf2f7' } }, + axisLabel: { color: '#64748b' }, + }, + series: families.map((family) => ({ + name: family, + type: 'line', + smooth: true, + symbolSize: 5, + lineStyle: { width: 2 }, + areaStyle: { opacity: 0.08 }, + data: data.map((item) => item.by_family?.[family] || 0), + })), + }; +} + +function buildPipelineOption(rows) { + const data = Array.isArray(rows) ? rows : []; + if (!data.length) return null; + return { + animation: false, + grid: { left: 82, right: 38, top: 8, bottom: 10 }, + tooltip: { + trigger: 'axis', + confine: true, + axisPointer: { type: 'shadow' }, + formatter: (params) => { + const item = params?.[0]?.data || {}; + return `${item.label}
数量:${formatNumber(item.value)}
比例:${formatPercent(item.rate)}`; + }, + }, + xAxis: { + type: 'value', + max: Math.max(...data.map((item) => Number(item.value) || 0), 1), + splitLine: { lineStyle: { color: '#edf2f7' } }, + axisLabel: { color: '#64748b' }, + }, + yAxis: { + type: 'category', + inverse: true, + data: data.map((item) => item.label), + axisTick: { show: false }, + axisLine: { show: false }, + axisLabel: { color: '#334155', fontWeight: 700 }, + }, + series: [ + { + type: 'bar', + barWidth: 14, + data: data.map((item, index) => ({ + ...item, + itemStyle: { + color: ['#2563eb', '#0f766e', '#0891b2', '#16a34a', '#d97706'][index % 5], + borderRadius: [0, 5, 5, 0], + }, + })), + label: { + show: true, + position: 'right', + color: '#475569', + formatter: (params) => `${formatNumber(params.data.value)} / ${formatPercent(params.data.rate)}`, + }, + }, + ], + }; +} + +function buildLegacyPointCoverageGrid(points, extent) { + const source = Array.isArray(points) + ? points + .map((item) => ({ + ...item, + lon: Number(item.lon), + lat: Number(item.lat), + })) + .filter((item) => Number.isFinite(item.lon) && Number.isFinite(item.lat)) + : []; + if (!source.length) return null; + + const extentMinLon = Number(extent?.min_lon); + const extentMaxLon = Number(extent?.max_lon); + const extentMinLat = Number(extent?.min_lat); + const extentMaxLat = Number(extent?.max_lat); + const minLon = Number.isFinite(extentMinLon) ? extentMinLon : Math.min(...source.map((item) => item.lon)); + const maxLon = Number.isFinite(extentMaxLon) ? extentMaxLon : Math.max(...source.map((item) => item.lon)); + const minLat = Number.isFinite(extentMinLat) ? extentMinLat : Math.min(...source.map((item) => item.lat)); + const maxLat = Number.isFinite(extentMaxLat) ? extentMaxLat : Math.max(...source.map((item) => item.lat)); + const lonSpan = Math.max(maxLon - minLon, 0.01); + const latSpan = Math.max(maxLat - minLat, 0.01); + const columns = 42; + const rows = Math.max(14, Math.min(34, Math.round((columns * latSpan) / lonSpan))); + const cellLon = lonSpan / columns; + const cellLat = latSpan / rows; + const buckets = new Map(); + + source.forEach((item) => { + const col = Math.min(columns - 1, Math.max(0, Math.floor(((item.lon - minLon) / lonSpan) * columns))); + const row = Math.min(rows - 1, Math.max(0, Math.floor(((item.lat - minLat) / latSpan) * rows))); + const key = `${col}:${row}`; + const family = item.family || '未分类'; + const bucket = buckets.get(key) || { col, row, count: 0, families: {}, examples: [] }; + bucket.count += 1; + bucket.families[family] = (bucket.families[family] || 0) + 1; + if (bucket.examples.length < 4) { + bucket.examples.push({ + family, + label: item.satellite || item.source_format, + date: item.date, + }); + } + buckets.set(key, bucket); + }); + + const cells = Array.from(buckets.values()).map((bucket) => { + const dominantFamily = Object.entries(bucket.families).sort((a, b) => b[1] - a[1])[0]?.[0] || '未分类'; return { - labels, - datasets: [ + col: bucket.col, + row: bucket.row, + count: bucket.count, + lon_min: minLon + bucket.col * cellLon, + lon_max: minLon + (bucket.col + 1) * cellLon, + lat_min: minLat + bucket.row * cellLat, + lat_max: minLat + (bucket.row + 1) * cellLat, + lon: minLon + (bucket.col + 0.5) * cellLon, + lat: minLat + (bucket.row + 0.5) * cellLat, + dominant_family: dominantFamily, + families: Object.entries(bucket.families).map(([name, count]) => ({ name, count })), + examples: bucket.examples, + }; + }); + + return { + source_type: 'legacy_points', + total: source.length, + covered_count: source.length, + cell_count: cells.length, + max_count: Math.max(...cells.map((cell) => cell.count), 1), + columns, + rows, + extent: { min_lon: minLon, min_lat: minLat, max_lon: maxLon, max_lat: maxLat }, + cells, + }; +} + +function buildCoverageOption(grid, mode = 'source') { + if (grid?.source_type === 'city_regions') { + if (!registerCityCoverageMap(grid.features)) return null; + const unit = mode === 'results' ? '项成果' : '景源数据'; + const ownerLabel = mode === 'results' ? '成果类型' : '数据源'; + const maxCount = Math.max(Number(grid?.max_count) || 0, ...grid.cells.map((item) => Number(item.count) || 0), 1); + const heatData = grid.cells + .map((region) => { + const lon = Number(region.lon); + const lat = Number(region.lat); + const count = Number(region.count) || 0; + if (!Number.isFinite(lon) || !Number.isFinite(lat) || count <= 0) return null; + return { + name: region.name, + value: [lon, lat, count], + tree_id: region.tree_id, + breakdown: region.breakdown || [], + }; + }) + .filter(Boolean); + return { + animation: false, + tooltip: { + trigger: 'item', + confine: true, + formatter: ({ data }) => { + const value = Array.isArray(data?.value) ? Number(data.value[2]) : Number(data?.value); + if (!data || !Number.isFinite(value) || value <= 0) { + return `${data?.name || '未命中行政区'}
暂无${unit}`; + } + const breakdown = Array.isArray(data.breakdown) && data.breakdown.length + ? `
${ownerLabel}:${data.breakdown.map((item) => `${item.name} ${formatNumber(item.count)}`).join(',')}` + : ''; + return `${data.name}
${formatNumber(value)} ${unit}${breakdown}`; + }, + }, + visualMap: { + show: true, + type: 'continuous', + min: 0, + max: maxCount, + orient: 'vertical', + right: 8, + bottom: 10, + calculable: false, + itemWidth: 12, + itemHeight: 78, + textStyle: { color: '#475569', fontSize: 11 }, + inRange: { + color: ['#dbeafe', '#93c5fd', '#38bdf8', '#22c55e', '#facc15', '#f97316', '#dc2626'], + }, + }, + geo: { + map: CITY_COVERAGE_MAP_NAME, + roam: false, + silent: true, + layoutCenter: ['50%', '50%'], + layoutSize: '96%', + itemStyle: { + areaColor: '#f8fafc', + borderColor: '#cbd5e1', + borderWidth: 0.8, + }, + emphasis: { + disabled: true, + }, + }, + series: [ { - label: title, - data: values, - backgroundColor: colors?.backgroundColor || defaultBg, - borderColor: colors?.borderColor || defaultBorder, - borderWidth: 1, + name: mode === 'results' ? '成果热度' : '源数据热度', + type: 'heatmap', + coordinateSystem: 'geo', + pointSize: mode === 'results' ? 34 : 28, + blurSize: mode === 'results' ? 42 : 36, + minOpacity: 0.18, + maxOpacity: 0.92, + data: heatData, + }, + { + name: mode === 'results' ? '成果命中市' : '源数据命中市', + type: 'scatter', + coordinateSystem: 'geo', + symbolSize: (value) => { + const count = Number(value?.[2]) || 0; + return Math.max(5, Math.min(15, 5 + (count / maxCount) * 10)); + }, + itemStyle: { + color: 'rgba(15, 23, 42, 0.72)', + borderColor: '#ffffff', + borderWidth: 1, + }, + label: { + show: true, + position: 'right', + color: '#0f172a', + fontSize: 11, + fontWeight: 800, + formatter: ({ data }) => (Number(data?.value?.[2]) >= maxCount * 0.28 ? data.name : ''), + }, + emphasis: { + label: { show: true }, + itemStyle: { color: '#0f172a' }, + }, + data: heatData, }, ], }; + } + + const cells = Array.isArray(grid?.cells) ? grid.cells : []; + const extent = grid?.extent || {}; + if (!cells.length) return null; + const minLon = Number(extent.min_lon); + const maxLon = Number(extent.max_lon); + const minLat = Number(extent.min_lat); + const maxLat = Number(extent.max_lat); + if (![minLon, maxLon, minLat, maxLat].every(Number.isFinite)) return null; + const lonSpan = Math.max(maxLon - minLon, 0.01); + const latSpan = Math.max(maxLat - minLat, 0.01); + const lonPadding = lonSpan * 0.04; + const latPadding = latSpan * 0.04; + const maxCount = Math.max(Number(grid?.max_count) || 0, ...cells.map((item) => Number(item.count) || 0), 1); + const unit = mode === 'results' ? '项成果' : '景源数据'; + const ownerLabel = mode === 'results' ? '主成果类型' : '主数据源'; + const data = cells.map((cell) => ({ + value: [ + Number(cell.lon_min), + Number(cell.lat_min), + Number(cell.lon_max), + Number(cell.lat_max), + Number(cell.count) || 0, + ], + meta: cell, + })); + + return { + animation: false, + grid: { left: 8, right: 8, top: 8, bottom: 8 }, + tooltip: { + trigger: 'item', + confine: true, + formatter: ({ data: item }) => { + const meta = item?.meta; + if (!meta) return ''; + const owner = mode === 'results' ? meta.dominant_catalog : meta.dominant_family; + return [ + `${formatNumber(meta.count)} ${unit}`, + owner ? `${ownerLabel}:${owner}` : '', + `经度:${Number(meta.lon_min).toFixed(3)} - ${Number(meta.lon_max).toFixed(3)}`, + `纬度:${Number(meta.lat_min).toFixed(3)} - ${Number(meta.lat_max).toFixed(3)}`, + meta.examples.length + ? `样例:${meta.examples.map((example) => `${example.family || example.catalog || ''} ${example.date || ''}`.trim()).filter(Boolean).join(',')}` + : '', + ].filter(Boolean).join('
'); + }, + }, + xAxis: { + type: 'value', + min: minLon - lonPadding, + max: maxLon + lonPadding, + show: false, + }, + yAxis: { + type: 'value', + min: minLat - latPadding, + max: maxLat + latPadding, + show: false, + scale: true, + }, + series: [ + { + name: '覆盖密度', + type: 'custom', + renderItem: (params, api) => { + const lonMin = api.value(0); + const latMin = api.value(1); + const lonMax = api.value(2); + const latMax = api.value(3); + const count = api.value(4); + const topLeft = api.coord([lonMin, latMax]); + const bottomRight = api.coord([lonMax, latMin]); + const rect = echarts.graphic.clipRectByRect( + { + x: topLeft[0], + y: topLeft[1], + width: Math.max(bottomRight[0] - topLeft[0], 1.5), + height: Math.max(bottomRight[1] - topLeft[1], 1.5), + }, + { + x: params.coordSys.x, + y: params.coordSys.y, + width: params.coordSys.width, + height: params.coordSys.height, + }, + ); + if (!rect) return null; + return { + type: 'rect', + shape: rect, + style: { + fill: colorForCoverageCount(count, maxCount), + stroke: 'rgba(255,255,255,0.72)', + lineWidth: 0.6, + }, + emphasis: { + style: { + stroke: '#0f172a', + lineWidth: 1.2, + }, + }, + }; + }, + data, + }, + ], + }; +} + +function buildStatusOption(rows, key = 'status') { + const data = Array.isArray(rows) ? rows : []; + if (!data.length) return null; + return { + animation: false, + grid: { left: 80, right: 26, top: 8, bottom: 12 }, + tooltip: { trigger: 'axis', confine: true, axisPointer: { type: 'shadow' } }, + xAxis: { + type: 'value', + splitLine: { lineStyle: { color: '#edf2f7' } }, + axisLabel: { color: '#64748b' }, + }, + yAxis: { + type: 'category', + inverse: true, + data: data.map((item) => item[key]), + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: '#334155', fontWeight: 700 }, + }, + series: [ + { + type: 'bar', + barWidth: 14, + data: data.map((item) => ({ + value: item.count, + itemStyle: { color: colorForStatus(item[key]), borderRadius: [0, 5, 5, 0] }, + })), + label: { show: true, position: 'right', color: '#475569' }, + }, + ], + }; +} + +function buildResultTrendOption(rows) { + const data = Array.isArray(rows) ? rows : []; + if (!data.length) return null; + const catalogs = Array.from(new Set(data.flatMap((item) => Object.keys(item.by_catalog || {})))); + const colors = ['#2563eb', '#16a34a', '#d97706', '#7c3aed']; + return { + animation: false, + color: colors, + legend: { + top: 0, + right: 0, + itemWidth: 12, + itemHeight: 8, + textStyle: { color: '#475569', fontSize: 11 }, + }, + grid: { left: 42, right: 12, top: 34, bottom: 28 }, + tooltip: { trigger: 'axis', confine: true }, + xAxis: { + type: 'category', + data: data.map((item) => item.month), + axisTick: { show: false }, + axisLine: { lineStyle: { color: '#cbd5e1' } }, + axisLabel: { color: '#64748b' }, + }, + yAxis: { + type: 'value', + splitLine: { lineStyle: { color: '#edf2f7' } }, + axisLabel: { color: '#64748b' }, + }, + series: catalogs.map((catalog) => ({ + name: catalog === 'sbas_insar' ? 'SBAS' : catalog.toUpperCase(), + type: 'bar', + stack: 'result', + barMaxWidth: 28, + data: data.map((item) => item.by_catalog?.[catalog] || 0), + })), + }; +} + +function KpiCard({ item }) { + return ( +
+
{item.label}
+
+ {formatNumber(item.value)} + {item.unit} +
+
{item.note}
+
+ ); +} + +function Section({ title, subtitle, className = '', children }) { + return ( +
+
+

{title}

+ {subtitle && {subtitle}} +
+ {children} +
+ ); +} + +function FamilyLegend({ rows }) { + const data = Array.isArray(rows) ? rows : []; + return ( +
+ {data.map((item) => ( +
+ + {item.family} + {formatNumber(item.count)} + {formatPercent(item.ready_rate)} 可用 +
+ ))} +
+ ); +} + +function ProductionRunList({ rows }) { + const data = Array.isArray(rows) ? rows : []; + if (!data.length) { + return
暂无生产运行记录
; + } + return ( +
+ {data.map((item) => ( +
+
+ {item.run_id} + {item.engine_code} · {formatDateTime(item.created_at)} +
+ {item.status} + {formatNumber(item.completed_items)}/{formatNumber(item.total_items)} +
+ ))} +
+ ); +} + +function InventoryStateList({ rows }) { + const data = Array.isArray(rows) ? rows : []; + if (!data.length) { + return
暂无扫描状态记录
; + } + return ( +
+ {data.slice(0, 6).map((item) => ( +
+
+ {item.inventory_type || '未分类扫描'} + {formatDateTime(item.last_scan_finished_at || item.last_scan_started_at)} +
+ {item.status} + {formatNumber(item.last_asset_count)} 项 +
+ ))} +
+ ); +} + +export default function StatisticsDashboard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [lastLoadedAt, setLastLoadedAt] = useState(null); + const [coverageMode, setCoverageMode] = useState('source'); + + const loadDashboard = async () => { + setLoading(true); + setError(''); + try { + const payload = await getStatisticsDashboard(); + setData(payload); + setLastLoadedAt(new Date()); + } catch (err) { + setError(err.response?.data?.detail || err.message || '统计数据获取失败'); + } finally { + setLoading(false); + } }; - if (loading) { - return ( -
-
-

正在加载统计数据...

- -
-
- ); - } + useEffect(() => { + void loadDashboard(); + }, []); - if (error) { - return ( -
-
-

{error}

- -
-
- ); - } - - if (!stats) return null; - - const { - dinsar_results_overview = {}, - source_data_overview = {}, - by_satellite = {}, - ai_quality_overview = {}, - ai_prediction_overview = {}, - dinsar_cache_consistency = {}, - source_preview_consistency = {}, - source_xml_consistency = {}, - } = stats; - - const sourceDataOrbit = generateChartData( - '源数据精轨状态', - { - 有精轨数据: num(source_data_overview.with_orbit_data_count), - 无精轨数据: Math.max(0, num(source_data_overview.total_count) - num(source_data_overview.with_orbit_data_count)), - }, - { - backgroundColor: ['rgba(75, 192, 192, 0.7)', 'rgba(201, 203, 207, 0.7)'], - borderColor: ['rgba(75, 192, 192, 1)', 'rgba(201, 203, 207, 1)'], - } + const families = useMemo( + () => (data?.asset?.source_by_family || []).map((item) => item.family), + [data], ); - const dinsarData = generateChartData( - 'D-InSAR 缓存状态', - { - 已缓存: num(dinsar_results_overview.cached_count), - 未缓存: num(dinsar_results_overview.uncached_count), - }, - { - backgroundColor: ['rgba(75, 192, 192, 0.7)', 'rgba(255, 99, 132, 0.7)'], - borderColor: ['rgba(75, 192, 192, 1)', 'rgba(255, 99, 132, 1)'], - } + const sourceOption = useMemo( + () => buildSourceOption(data?.asset?.source_by_family), + [data], ); - - const sourceDataEnvi = generateChartData( - '源数据 ENVI 处理状态', - { - 有ENVI结果: num(source_data_overview.envi_processed_count), - 无ENVI结果: Math.max(0, num(source_data_overview.total_count) - num(source_data_overview.envi_processed_count)), - }, - { - backgroundColor: ['rgba(54, 162, 235, 0.7)', 'rgba(201, 203, 207, 0.7)'], - borderColor: ['rgba(54, 162, 235, 1)', 'rgba(201, 203, 207, 1)'], - } + const sourceTrendOption = useMemo( + () => buildTrendOption(data?.asset?.source_by_month, families), + [data, families], ); - - const aiQualityData = generateChartData( - '人工标记质量分布', - { - 人工标记良好: num(ai_quality_overview.good_count), - 人工标记欠佳: num(ai_quality_overview.bad_count), - 未人工标记: num(ai_quality_overview.unlabeled_count), - }, - { - backgroundColor: ['rgba(40, 167, 69, 0.7)', 'rgba(220, 53, 69, 0.7)', 'rgba(108, 117, 125, 0.7)'], - borderColor: ['rgba(40, 167, 69, 1)', 'rgba(220, 53, 69, 1)', 'rgba(108, 117, 125, 1)'], - } + const pipelineOption = useMemo( + () => buildPipelineOption(data?.asset?.pipeline), + [data], ); - - const aiPredictionData = generateChartData( - 'AI 预测质量分布', - { - 'AI预测良好 (>=0.7)': num(ai_prediction_overview.good_count), - 'AI预测欠佳 (<0.4)': num(ai_prediction_overview.bad_count), - AI预测中等: num(ai_prediction_overview.medium_count), - 未预测: num(ai_prediction_overview.unpredicted_count), - }, - { - backgroundColor: ['rgba(40, 167, 69, 0.7)', 'rgba(220, 53, 69, 0.7)', 'rgba(255, 193, 7, 0.7)', 'rgba(108, 117, 125, 0.7)'], - borderColor: ['rgba(40, 167, 69, 1)', 'rgba(220, 53, 69, 1)', 'rgba(255, 193, 7, 1)', 'rgba(108, 117, 125, 1)'], - } + const coverageOption = useMemo( + () => buildCoverageOption( + buildCityCoverageGrid(data?.coverage?.city_regions, coverageMode) + || (coverageMode === 'results' ? data?.coverage?.results : data?.coverage?.source), + coverageMode, + ), + [data, coverageMode], ); - - const dinsarConsistencyData = generateChartData( - 'D-InSAR 缓存一致性', - { - '库缓存且文件存在': num(dinsar_cache_consistency.db_cached_and_file_exists_count), - '库缓存但文件缺失': num(dinsar_cache_consistency.db_cached_but_file_missing_count), - '库未缓存但文件存在': num(dinsar_cache_consistency.db_uncached_but_file_exists_count), - '库未缓存且文件缺失': num(dinsar_cache_consistency.db_uncached_and_file_missing_count), - }, - { - backgroundColor: ['rgba(40, 167, 69, 0.7)', 'rgba(220, 53, 69, 0.7)', 'rgba(255, 193, 7, 0.7)', 'rgba(108, 117, 125, 0.7)'], - borderColor: ['rgba(40, 167, 69, 1)', 'rgba(220, 53, 69, 1)', 'rgba(255, 193, 7, 1)', 'rgba(108, 117, 125, 1)'], - } + const productionStatusOption = useMemo( + () => buildStatusOption(data?.production?.run_status), + [data], ); - - const sourcePreviewConsistencyData = generateChartData( - '源影像预览一致性', - { - '存在预览缓存': num(source_preview_consistency.preview_exists_count), - '预览缓存缺失': num(source_preview_consistency.preview_missing_count), - 'DB READY且有缓存': num(source_preview_consistency.db_ready_and_cache_exists_count), - 'DB READY但缺缓存': num(source_preview_consistency.db_ready_but_cache_missing_count), - }, - { - backgroundColor: ['rgba(40, 167, 69, 0.7)', 'rgba(220, 53, 69, 0.7)', 'rgba(54, 162, 235, 0.7)', 'rgba(255, 159, 64, 0.7)'], - borderColor: ['rgba(40, 167, 69, 1)', 'rgba(220, 53, 69, 1)', 'rgba(54, 162, 235, 1)', 'rgba(255, 159, 64, 1)'], - } + const taskStatusOption = useMemo( + () => buildStatusOption(data?.production?.dinsar_task_status), + [data], ); - - const sourceXmlConsistencyData = generateChartData( - '源影像 XML 读取一致性', - { - '检测到XML并解析': num(source_xml_consistency.xml_parsed_ok_count), - '检测到XML未解析': num(source_xml_consistency.xml_detected_but_unparsed_count), - '缺少XML': num(source_xml_consistency.xml_missing_count), - }, - { - backgroundColor: ['rgba(40, 167, 69, 0.7)', 'rgba(255, 193, 7, 0.7)', 'rgba(220, 53, 69, 0.7)'], - borderColor: ['rgba(40, 167, 69, 1)', 'rgba(255, 193, 7, 1)', 'rgba(220, 53, 69, 1)'], - } + const resultTrendOption = useMemo( + () => buildResultTrendOption(data?.results?.results_by_month), + [data], ); - - const issues = []; - if (num(dinsar_cache_consistency.db_cached_but_file_missing_count) > 0) { - issues.push({ level: 'error', text: `D-InSAR: 数据库标记已缓存但文件缺失 ${num(dinsar_cache_consistency.db_cached_but_file_missing_count)} 条` }); - } - if (num(dinsar_cache_consistency.db_uncached_but_file_exists_count) > 0) { - issues.push({ level: 'warn', text: `D-InSAR: 数据库未标记缓存但文件已存在 ${num(dinsar_cache_consistency.db_uncached_but_file_exists_count)} 条` }); - } - if (num(dinsar_cache_consistency.manifest_missing_file_count) > 0) { - issues.push({ level: 'warn', text: `D-InSAR: manifest 引用缺失文件 ${num(dinsar_cache_consistency.manifest_missing_file_count)} 条` }); - } - if (num(source_preview_consistency.db_ready_but_cache_missing_count) > 0) { - issues.push({ level: 'error', text: `源影像: DB READY 但预览缓存缺失 ${num(source_preview_consistency.db_ready_but_cache_missing_count)} 条` }); - } - if (num(source_xml_consistency.xml_detected_but_unparsed_count) > 0) { - issues.push({ level: 'warn', text: `源影像: 检测到 XML 但关键字段未入库 ${num(source_xml_consistency.xml_detected_but_unparsed_count)} 条` }); - } - if (num(source_xml_consistency.xml_missing_count) > 0) { - issues.push({ level: 'warn', text: `源影像: 未检测到 XML ${num(source_xml_consistency.xml_missing_count)} 条` }); - } + const issueCodeOption = useMemo( + () => buildStatusOption(data?.issues?.by_code, 'code'), + [data], + ); + const activeCoverage = buildCityCoverageGrid(data?.coverage?.city_regions, coverageMode) + || (coverageMode === 'results' ? data?.coverage?.results : data?.coverage?.source); + const coverageSubtitle = coverageMode === 'results' + ? `按市级行政区统计,命中 ${formatNumber(activeCoverage?.cell_count)} 个市,已定位 ${formatNumber(activeCoverage?.covered_count)} / ${formatNumber(activeCoverage?.total)} 项` + : `按市级行政区统计,命中 ${formatNumber(activeCoverage?.cell_count)} 个市,已定位 ${formatNumber(activeCoverage?.covered_count)} / ${formatNumber(activeCoverage?.total || data?.coverage?.point_total)} 景`; return ( -
+
-
-

数据统计仪表盘

- -
+
+
+

InSAR 数据与生产统计

+

+ 汇总源数据接入、元数据解析、精轨保障、生产运行和成果健康状态,用于判断系统工程能力和待处理风险。 +

+
+
+ {data?.generated_at ? `统计时间 ${formatDateTime(data.generated_at)}` : '等待统计数据'} + {data?.cache_meta?.enabled && ( + + {data.cache_meta.hit ? '缓存命中' : '已重算'} + {data.cache_meta.ttl_seconds ? ` · ${data.cache_meta.ttl_seconds}秒缓存` : ''} + + )} + +
+
-
-
-
D-InSAR总数
-
{num(dinsar_results_overview.total_count)}
+ {error && ( +
+ {error}
-
-
D-InSAR缓存文件存在
-
{num(dinsar_cache_consistency.cache_file_exists_count)}
-
-
-
源影像预览存在
-
{num(source_preview_consistency.preview_exists_count)}
-
-
-
XML解析成功
-
{num(source_xml_consistency.xml_parsed_ok_count)}
-
-
+ )} -
- {issues.length === 0 ? ( -
一致性检查通过,当前未发现异常项。
- ) : ( - issues.map((item, index) => ( -
- {item.text} + {loading && !data ? ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+ ))} +
+ ) : ( + <> +
+ {(data?.kpis || []).map((item) => ( + + ))} +
+ +
+
+
+
+

{coverageMode === 'results' ? '成果市级覆盖热力' : '源数据市级覆盖热力'}

+ + {coverageSubtitle} + +
+
+
+ + +
+ {coverageMode === 'source' && } +
+
+
- )) - )} -
-
-
- -
+
+
+ +
+
+ +
+
+
-
- -
+
+
+ +
-
- -
+
+
+
+ 精轨资产 + {formatNumber(data?.orbit?.orbit_total)} +
+
+ 绑定覆盖率 + {formatPercent(data?.orbit?.selected_rate)} +
+
+ 匹配记录 + {formatNumber(data?.orbit?.matched_bindings)} +
+
+
+ {(data?.orbit?.orbit_by_family || []).map((item) => ( +
+ {item.family} + {formatNumber(item.count)} + +
+ ))} +
+
-
- -
+
+ +
-
- -
+
+ +
-
- -
+
+ +
-
- {num(source_data_overview.total_count) > 0 && } -
+
+ +
+
-
- {Object.keys(by_satellite).length > 0 && } -
+
+
+ +
+
+ +
+
-
- {num(source_data_overview.total_count) > 0 && } -
-
+
+ 页面不自动轮询,避免统计聚合影响生产服务。 + {lastLoadedAt && 本地刷新时间 {formatDateTime(lastLoadedAt.toISOString())}} +
+ + )}
); -}; - -export default StatisticsDashboard; +} diff --git a/frontend/src/TimeseriesProductionPanel.jsx b/frontend/src/TimeseriesProductionPanel.jsx index 71fe63c..2e3ca70 100644 --- a/frontend/src/TimeseriesProductionPanel.jsx +++ b/frontend/src/TimeseriesProductionPanel.jsx @@ -42,6 +42,20 @@ const STATUS_COLOR = { PUBLISHED: '#16a34a', }; +const ACTIVE_RUN_STATUSES = new Set([ + 'PENDING', + 'RUNNING', + 'PREPARING', + 'STACK_PREPARING', + 'MATERIALIZING', + 'STACK_RUNNING', + 'MINTPY_RUNNING', + 'EXPORTING', + 'REGISTERING', +]); + +const ACTIVE_RUN_REFRESH_INTERVAL_MS = 30000; + const PREPARED_STACK_STATE = { not_prepared: { label: 'Not prepared', color: '#64748b' }, manifest_unreadable: { label: 'Manifest unreadable', color: '#dc2626' }, @@ -209,6 +223,9 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue const [wslReport, setWslReport] = useState(null); const [preflightLoading, setPreflightLoading] = useState(false); const [preflightReport, setPreflightReport] = useState(null); + const hasActiveRun = useMemo(() => ( + runs.some(item => ACTIVE_RUN_STATUSES.has(String(item.status || '').toUpperCase())) + ), [runs]); const [retryingStepId, setRetryingStepId] = useState(''); const selectedBatch = useMemo( @@ -355,9 +372,10 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue }, [loadBatches, loadRuns]); useEffect(() => { - const timer = setInterval(loadRuns, 10000); + if (!hasActiveRun) return undefined; + const timer = setInterval(loadRuns, ACTIVE_RUN_REFRESH_INTERVAL_MS); return () => clearInterval(timer); - }, [loadRuns]); + }, [hasActiveRun, loadRuns]); useEffect(() => { loadRunDetail(selectedRunId); @@ -465,8 +483,7 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue }} > 当前生产入口采用分层 SBAS 模型:时序配对先形成候选大池,提交 run 后由 prepare 冻结 prepared SBAS 小栈。 - ENVI/SARscape SBAS 后续只读取 prepared manifest 和 selected_network_edges 审计图,不再重新扫描全量数据。 - ISCE2 + MintPy 路径仍沿用 stack_prep、materialize、stack、MintPy、publish、register 链路。 + ENVI/SARscape SBAS 后续只读取 prepared manifest 和 selected_network_edges 审计图,确保生产输入可复核、可追溯。
{wslReport && (
-
diff --git a/frontend/src/WaterMonitorPanel.jsx b/frontend/src/WaterMonitorPanel.jsx index 365e8bd..a9a303e 100644 --- a/frontend/src/WaterMonitorPanel.jsx +++ b/frontend/src/WaterMonitorPanel.jsx @@ -10,6 +10,7 @@ const getStatusLabel = (lang) => lang === 'en' ? { PENDING: 'Pending', RUNNING: 'Processing', DONE: 'Done', FAILED: 'Failed' } : { PENDING: '等待中', RUNNING: '处理中', DONE: '完成', FAILED: '失败' }; const STATUS_COLOR = { PENDING: '#64748b', RUNNING: '#2563eb', DONE: '#16a34a', FAILED: '#dc2626' }; +const ACTIVE_SCENE_REFRESH_INTERVAL_MS = 15000; const UI_COLORS = { pageText: '#0f172a', @@ -468,7 +469,7 @@ export default function WaterMonitorPanel({ readOnly, onShowOnMap, onShowFloodOn const timer = setInterval(() => { loadScenes(scenesPageRef.current); loadStatusIds(); - }, 5000); + }, ACTIVE_SCENE_REFRESH_INTERVAL_MS); return () => clearInterval(timer); }, [activeRadarIds, tab, loadScenes, loadStatusIds]); diff --git a/frontend/src/api/dinsarProduction.js b/frontend/src/api/dinsarProduction.js index e7f1fee..9770297 100644 --- a/frontend/src/api/dinsarProduction.js +++ b/frontend/src/api/dinsarProduction.js @@ -18,6 +18,9 @@ export const runWslCheck = (payload = {}) => export const submitRun = (payload) => apiClient.post('/dinsar-production/run', payload).then(r => r.data); +export const submitLandsarClusterRun = (payload) => + apiClient.post('/dinsar-production/landsar-cluster/run', payload).then(r => r.data); + // 运行历史 export const listRuns = (limit = 20, offset = 0) => apiClient.get( diff --git a/frontend/src/api/stats.js b/frontend/src/api/stats.js index 4fa6a2f..9fee6f2 100644 --- a/frontend/src/api/stats.js +++ b/frontend/src/api/stats.js @@ -2,3 +2,6 @@ import apiClient from './client'; export const getStatistics = (fresh = false) => apiClient.get('/statistics', { params: fresh ? { fresh: true } : undefined }).then(r => r.data); + +export const getStatisticsDashboard = () => + apiClient.get('/statistics/dashboard').then(r => r.data); diff --git a/frontend/src/components/DataInfoModal.jsx b/frontend/src/components/DataInfoModal.jsx index 742fa75..ae0fc47 100644 --- a/frontend/src/components/DataInfoModal.jsx +++ b/frontend/src/components/DataInfoModal.jsx @@ -54,7 +54,6 @@ const createSentinelRows = (dataInfo, language, formatYmd) => { field(language === 'en' ? 'Orbit File:' : '轨道文件:', dataInfo.orbit_file_path, { valueStyle: { wordBreak: 'break-all' }, }), - field(language === 'en' ? 'ENVI Processed:' : 'ENVI已处理:', yesNo(dataInfo.is_envi_processed, language)), ]; }; @@ -77,7 +76,6 @@ const createDefaultRows = (dataInfo, language, formatYmd) => [ field(language === 'en' ? 'Orbit File:' : '轨道文件:', dataInfo.orbit_file_path, { valueStyle: { wordBreak: 'break-all' }, }), - field(language === 'en' ? 'ENVI Processed:' : 'ENVI已处理:', yesNo(dataInfo.is_envi_processed, language)), ]; const createRows = (dataInfo, language, formatYmd) => { diff --git a/frontend/src/components/DinsarCatalogPanel.jsx b/frontend/src/components/DinsarCatalogPanel.jsx index 3783a7d..1bc3e30 100644 --- a/frontend/src/components/DinsarCatalogPanel.jsx +++ b/frontend/src/components/DinsarCatalogPanel.jsx @@ -6,8 +6,6 @@ import { getDinsarProductDetail, getDinsarProductCleanupPlan, listDinsarProductPairs, - queueDinsarCatalogRebuild, - queueDinsarProductPublish, } from '../api/dinsarProducts'; import { DINSAR_ENGINE_ALL, @@ -52,15 +50,6 @@ function formatBytes(value) { return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`; } -function parseDirectoryList(value) { - return [...new Set( - String(value || '') - .split(/[\r\n,;]+/) - .map((item) => item.trim()) - .filter(Boolean) - )]; -} - function getMessageTone(message) { return /失败|error|Error|ERROR/.test(String(message || '')) ? 'error' : 'success'; } @@ -91,8 +80,6 @@ function MetaField({ label, value, multiline = false }) { export default function DinsarCatalogPanel({ readOnly = false, compact = false, - initialSourceDir = '', - onTaskQueued, }) { const [catalogStatus, setCatalogStatus] = useState(null); const [products, setProducts] = useState([]); @@ -104,27 +91,14 @@ export default function DinsarCatalogPanel({ const [cleanupPlanLoading, setCleanupPlanLoading] = useState(false); const [loading, setLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false); - const [actionLoading, setActionLoading] = useState(false); const [actionMessage, setActionMessage] = useState(''); - const [sourceDirectoriesText, setSourceDirectoriesText] = useState(initialSourceDir || ''); - const [publishRoot, setPublishRoot] = useState(''); const [engineFilter, setEngineFilter] = useState(DINSAR_ENGINE_ALL); const [queryDraft, setQueryDraft] = useState(''); const [queryApplied, setQueryApplied] = useState(''); - const listLimit = compact ? 8 : 24; + const listLimit = compact ? 8 : 18; const previewBaseUrl = apiClient.defaults.baseURL || '/api'; - useEffect(() => { - if (!initialSourceDir) return; - setSourceDirectoriesText((current) => (current.trim() ? current : initialSourceDir)); - }, [initialSourceDir]); - - const sourceDirectories = useMemo( - () => parseDirectoryList(sourceDirectoriesText), - [sourceDirectoriesText] - ); - const engineOptions = useMemo( () => buildDinsarEngineOptions([], { includeKnown: true }), [] @@ -210,6 +184,11 @@ export default function DinsarCatalogPanel({ } }, []); + const handleSelectProduct = useCallback((pairKey, productId) => { + setSelectedPairKey(pairKey); + setSelectedProductId((current) => (current === productId ? current : productId || null)); + }, []); + const loadCleanupPlan = useCallback(async () => { if (!selectedProductId) return; setCleanupPlanLoading(true); @@ -243,45 +222,6 @@ export default function DinsarCatalogPanel({ setQueryApplied(''); }, []); - const handleQueuePublish = async () => { - if (readOnly || sourceDirectories.length === 0) return; - setActionLoading(true); - setActionMessage(''); - try { - const result = await queueDinsarProductPublish({ - source_directories: sourceDirectories, - publish_root: publishRoot.trim() || null, - rebuild_catalog: true, - }); - setActionMessage(`结果包发布任务已入队:${result.task_id}`); - onTaskQueued?.(result.task_id); - await loadCatalog(); - } catch (error) { - setActionMessage(`结果包发布失败:${error?.response?.data?.detail || error.message}`); - } finally { - setActionLoading(false); - } - }; - - const handleQueueRebuild = async () => { - if (readOnly) return; - setActionLoading(true); - setActionMessage(''); - try { - const result = await queueDinsarCatalogRebuild({ - publish_root: publishRoot.trim() || null, - full_rebuild: true, - }); - setActionMessage(`结果目录重建任务已入队:${result.task_id}`); - onTaskQueued?.(result.task_id); - await loadCatalog(); - } catch (error) { - setActionMessage(`结果目录重建失败:${error?.response?.data?.detail || error.message}`); - } finally { - setActionLoading(false); - } - }; - const catalogTone = STATUS_TONE_MAP[catalogStatus?.status] || 'neutral'; const actionTone = getMessageTone(actionMessage); const selectedIssues = Array.isArray(selectedProduct?.issues) ? selectedProduct.issues : []; @@ -311,7 +251,7 @@ export default function DinsarCatalogPanel({ {selectedEngineMeta.shortLabel} )} -
@@ -352,49 +292,6 @@ export default function DinsarCatalogPanel({
)} - {!compact && ( -
-
- 手动发布与目录重建 -

- 这里用于把既有结果目录重新发布为标准结果包,并按最新规则重建目录索引。 - 同一对影像的 ENVI/SARscape、LandSAR、Gamma/PyINT 结果会按任务聚合展示,底层仍依赖 `engine_code` 与 `run_key` 分别登记。 -

-
-
-