From 36a406ae49c5835a4d66dae61b60cd6af98b0c67 Mon Sep 17 00:00:00 2001 From: Harmon Date: Tue, 7 Jul 2026 01:04:47 +0800 Subject: [PATCH] Improve pairing planning and statistics visibility --- backend/app/models/schemas.py | 11 +- backend/app/routers/ops_maintenance.py | 7 +- backend/app/routers/pairing.py | 61 +++ backend/app/routers/stats.py | 273 ++++++++-- .../app/services/dinsar_production_service.py | 108 ++-- .../app/services/ops_maintenance_service.py | 322 +++-------- backend/app/services/spatial_service.py | 441 ++++++++++++++- ...OVINCE_COVERAGE_PAIRING_DESIGN_20260706.md | 63 +++ frontend/src/App.css | 508 +++++++++++++++++- frontend/src/DinsarProductionPanel.jsx | 14 +- frontend/src/HealthCheckPanel.jsx | 16 +- frontend/src/OpsTaskMaintenancePanel.jsx | 58 +- frontend/src/ProductionWorkspace.jsx | 1 + frontend/src/StatisticsDashboard.jsx | 160 +++++- frontend/src/api/pairing.js | 1 + frontend/src/api/stats.js | 4 +- frontend/src/components/app/AppSidePanel.jsx | 15 + .../src/components/panels/PairListRow.jsx | 8 + frontend/src/config/appConstants.js | 3 +- frontend/src/panels/PairPlanningPanel.jsx | 444 ++++++++++++++- frontend/src/panels/PairsListPanel.jsx | 22 +- frontend/src/utils/appUiHelpers.js | 4 + 22 files changed, 2085 insertions(+), 459 deletions(-) create mode 100644 docs/DINSAR_PROVINCE_COVERAGE_PAIRING_DESIGN_20260706.md diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index e628218..ca10290 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -284,7 +284,7 @@ class PairingRequest(BaseModel): slave_date_to: Optional[str] = Field(default=None, pattern=r'^\d{8}$|^$') # === 配对策略(新增) === - strategy: str = Field(default="dinsar_production", pattern=r'^dinsar_production$') + strategy: str = Field(default="dinsar_production", pattern=r'^(dinsar_production|dinsar_province_coverage)$') num_connections: int = Field(default=1, ge=1, le=10) reference_image_id: Optional[int] = None @@ -307,7 +307,8 @@ class PairingRequest(BaseModel): normalized['overlap_threshold'] = normalized['pair_footprint_overlap_min_ratio'] if normalized.get('footprint_center_distance_max_meters') not in (None, ''): normalized['spatial_baseline_max_meters'] = normalized['footprint_center_distance_max_meters'] - normalized['strategy'] = 'dinsar_production' + if normalized.get('strategy') not in {'dinsar_production', 'dinsar_province_coverage'}: + normalized['strategy'] = 'dinsar_production' return normalized @field_validator( @@ -390,6 +391,11 @@ class RadarPair(BaseModel): selection_strategy: Optional[str] = None selection_score: Optional[float] = None selection_reason: Optional[str] = None + coverage_rank: Optional[int] = None + aoi_new_area_ratio: Optional[float] = None + aoi_pair_area_ratio: Optional[float] = None + aoi_coverage_ratio_after: Optional[float] = None + effective_coverage_geojson: Optional[Dict[str, Any]] = None time_baseline_days: int spatial_baseline_meters: float scene_center_distance_meters: Optional[float] = None @@ -416,6 +422,7 @@ class PairingResponse(BaseModel): network_run_id: Optional[str] = None candidate_count: int = 0 selected_edge_count: int = 0 + coverage: Optional[Dict[str, Any]] = None class PsRequest(BaseModel): diff --git a/backend/app/routers/ops_maintenance.py b/backend/app/routers/ops_maintenance.py index 001a28d..160ea56 100644 --- a/backend/app/routers/ops_maintenance.py +++ b/backend/app/routers/ops_maintenance.py @@ -17,12 +17,7 @@ router = APIRouter(prefix="/ops-maintenance", tags=["ops-maintenance"]) class CleanupRequest(BaseModel): confirm: bool = False - delete_task_records: bool = True - delete_logs: bool = True - delete_production_records: bool = True - delete_result_products: bool = True - delete_production_dirs: bool = True - delete_task_pool_dir: bool = True + delete_landsar_work_dir: bool = True @router.get("/tasks") diff --git a/backend/app/routers/pairing.py b/backend/app/routers/pairing.py index bbf1739..c00e1e5 100644 --- a/backend/app/routers/pairing.py +++ b/backend/app/routers/pairing.py @@ -329,6 +329,7 @@ async def find_pairs_endpoint( network_run_id=pairing_metadata.get("network_run_id"), candidate_count=int(pairing_metadata.get("candidate_count") or 0), selected_edge_count=int(pairing_metadata.get("selected_edge_count") or 0), + coverage=None, ) except Exception as e: if isinstance(e, HTTPException): @@ -339,6 +340,66 @@ async def find_pairs_endpoint( raise HTTPException(status_code=500, detail="处理 AOI 或查找干涉对时发生错误,请查看后端日志") +@router.post("/pairing/coverage-plan", response_model=PairingResponse) +async def build_coverage_pairing_plan_endpoint( + params: PairingRequest = Depends(get_pairing_request_from_form), + target_date_from: str = Form(...), + target_date_to: str = Form(...), + extension_days: int = Form(15), + max_pairs: int = Form(200), + target_coverage_ratio: float = Form(0.98), + min_new_coverage_ratio: float = Form(0.0005), + files: Optional[List[UploadFile]] = File(None), + aoi_geojson: Optional[str] = Form(None), + require_orbit_data: bool = Form(True), + db: AsyncSession = Depends(get_db), +): + try: + resolved_aoi = await _parse_aoi_from_files(files) + if resolved_aoi is None: + resolved_aoi = _parse_aoi_geojson_form_value(aoi_geojson) + + if files and resolved_aoi is None: + raise HTTPException(status_code=400, detail="Uploaded files must include a valid SHP or GeoJSON AOI.") + + aoi_wkt = resolved_aoi[0] if resolved_aoi else None + response_aoi_geojson = resolved_aoi[1] if resolved_aoi else None + if not aoi_wkt: + raise HTTPException(status_code=400, detail="区域空间覆盖规划必须选择行政区或上传 AOI。") + coverage_params = params.model_copy(update={"strategy": "dinsar_province_coverage"}) + pairs, runtime_warnings, pairing_metadata = await spatial_service.find_dinsar_coverage_pairs( + db, + coverage_params, + target_date_from=target_date_from, + target_date_to=target_date_to, + extension_days=extension_days, + max_pairs=max_pairs, + target_coverage_ratio=target_coverage_ratio, + min_new_coverage_ratio=min_new_coverage_ratio, + aoi_wkt=aoi_wkt, + require_orbit_data=require_orbit_data, + ) + return PairingResponse( + pairs=pairs, + aoi_geojson=response_aoi_geojson, + warnings=runtime_warnings, + fallback_used=bool(pairing_metadata.get("fallback_used")), + degraded=bool(pairing_metadata.get("degraded")), + policy_version=pairing_metadata.get("policy_version"), + network_run_id=pairing_metadata.get("network_run_id"), + candidate_count=int(pairing_metadata.get("candidate_count") or 0), + selected_edge_count=int(pairing_metadata.get("selected_edge_count") or 0), + coverage=pairing_metadata.get("coverage"), + ) + except Exception as e: + if isinstance(e, HTTPException): + raise e + if isinstance(e, (RuntimeError, ValueError)): + raise HTTPException(status_code=409, detail=str(e)) + logger.exception("Failed to build D-InSAR coverage pairing plan") + raise HTTPException(status_code=500, detail="Failed to build D-InSAR coverage pairing plan.") + + @router.post("/find-ps-timeseries", response_model=Dict[str, List[RadarData]]) async def find_ps_timeseries_endpoint( params: PsRequest = Depends(get_ps_request_from_form), diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py index 4f915b7..1ac6514 100644 --- a/backend/app/routers/stats.py +++ b/backend/app/routers/stats.py @@ -11,6 +11,8 @@ from typing import Any, Dict, Optional logger = logging.getLogger(__name__) +SOURCE_METADATA_MANAGED_FORMATS = ("LT1_ARCHIVE", "S1_ZIP") + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import distinct, func, text from sqlalchemy.ext.asyncio import AsyncSession @@ -51,7 +53,6 @@ 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 @@ -80,6 +81,10 @@ def _ratio(numerator: int, denominator: int) -> float: return round(float(numerator) / float(denominator), 4) +def _is_source_metadata_managed_format(value: Any) -> bool: + return str(value or "").strip().upper() in SOURCE_METADATA_MANAGED_FORMATS + + def _family_label(value: Any) -> str: text = str(value or "").strip().upper() if text in {"LT1", "LT-1", "LUTAN", "LUTAN1"}: @@ -102,6 +107,14 @@ def _month_from_yyyymmdd(value: Any) -> Optional[str]: return None +def _compact_yyyymmdd(value: Any) -> Optional[str]: + text = str(value or "").strip() + digits = "".join(ch for ch in text if ch.isdigit()) + if len(digits) >= 8: + return digits[:8] + return None + + def _month_from_datetime(value: Any) -> Optional[str]: if not value: return None @@ -594,16 +607,36 @@ async def get_statistics_dashboard( db, select(func.count(SourceProductAssetORM.id)).where(SourceProductAssetORM.is_active == True), ) + metadata_source_total = await _scalar_count( + db, + select(func.count(SourceProductAssetORM.id)).where( + SourceProductAssetORM.is_active == True, + SourceProductAssetORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS), + ), + ) 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))), + select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))).where( + SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS), + ), + ) + metadata_doc_total = await _scalar_count( + db, + select(func.count(SourceMetadataDocumentORM.id)).where( + SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS), + ), + ) + geometry_total = await _scalar_count( + db, + select(func.count(SARSceneGeometryProfileORM.id)).where( + SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS), + ), ) - 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.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS), SARSceneGeometryProfileORM.metadata_quality == "READY", SARSceneGeometryProfileORM.production_readiness == "READY", ), @@ -633,6 +666,7 @@ async def get_statistics_dashboard( for family, source_format, parse_status, count in source_group_rows.all(): family_label = _family_label(family) status_label = _status_label(parse_status) + format_label = str(source_format or "UNKNOWN") count_int = _safe_int(count) family_bucket = source_by_family_map.setdefault( family_label, @@ -649,7 +683,6 @@ async def get_statistics_dashboard( 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( { @@ -673,19 +706,22 @@ async def get_statistics_dashboard( geometry_rows = await db.execute( select( SARSceneGeometryProfileORM.satellite_family, + SARSceneGeometryProfileORM.source_format, SARSceneGeometryProfileORM.metadata_quality, SARSceneGeometryProfileORM.production_readiness, func.count(SARSceneGeometryProfileORM.id), ) + .where(SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS)) .group_by( SARSceneGeometryProfileORM.satellite_family, + SARSceneGeometryProfileORM.source_format, 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(): + for family, source_format, 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( @@ -1075,6 +1111,82 @@ async def get_statistics_dashboard( 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 + missing_orbit_issue_rows = await db.execute( + select(AssetInventoryIssueORM.metadata_json) + .where(AssetInventoryIssueORM.status == "OPEN") + .where(AssetInventoryIssueORM.issue_code == "scene_missing_orbit") + ) + orbit_missing_satellite_map: dict[str, dict[str, Any]] = {} + orbit_missing_top_dates: dict[tuple[str, str], int] = {} + orbit_missing_unknown_date_scene_count = 0 + for (metadata_json,) in missing_orbit_issue_rows.all(): + metadata = metadata_json if isinstance(metadata_json, dict) else {} + satellite = str(metadata.get("satellite") or "UNKNOWN").strip().upper() or "UNKNOWN" + ymd = _compact_yyyymmdd(metadata.get("imaging_date") or metadata.get("acquisition_start_time_utc")) + month = _month_from_yyyymmdd(ymd) if ymd else None + satellite_bucket = orbit_missing_satellite_map.setdefault( + satellite, + { + "satellite": satellite, + "affected_scene_count": 0, + "dates": defaultdict(int), + "months": defaultdict(lambda: {"affected_scene_count": 0, "dates": set()}), + "unknown_date_scene_count": 0, + }, + ) + satellite_bucket["affected_scene_count"] += 1 + if not ymd: + satellite_bucket["unknown_date_scene_count"] += 1 + orbit_missing_unknown_date_scene_count += 1 + continue + satellite_bucket["dates"][ymd] += 1 + orbit_missing_top_dates[(satellite, ymd)] = orbit_missing_top_dates.get((satellite, ymd), 0) + 1 + if month: + month_bucket = satellite_bucket["months"][month] + month_bucket["affected_scene_count"] += 1 + month_bucket["dates"].add(ymd) + + orbit_missing_by_satellite: list[dict[str, Any]] = [] + for satellite, bucket in sorted(orbit_missing_satellite_map.items()): + date_items = [ + {"date": date_text, "affected_scene_count": _safe_int(count)} + for date_text, count in sorted(bucket["dates"].items()) + ] + month_items = [ + { + "month": month, + "affected_scene_count": _safe_int(month_bucket["affected_scene_count"]), + "missing_orbit_date_count": len(month_bucket["dates"]), + } + for month, month_bucket in sorted(bucket["months"].items()) + ] + orbit_missing_by_satellite.append( + { + "satellite": satellite, + "affected_scene_count": _safe_int(bucket["affected_scene_count"]), + "missing_orbit_date_count": len(date_items), + "unknown_date_scene_count": _safe_int(bucket["unknown_date_scene_count"]), + "first_missing_date": date_items[0]["date"] if date_items else None, + "last_missing_date": date_items[-1]["date"] if date_items else None, + "months": month_items, + "dates": date_items, + } + ) + + orbit_missing_summary = { + "affected_scene_count": sum(item["affected_scene_count"] for item in orbit_missing_by_satellite), + "missing_orbit_date_count": sum(item["missing_orbit_date_count"] for item in orbit_missing_by_satellite), + "unknown_date_scene_count": orbit_missing_unknown_date_scene_count, + "by_satellite": orbit_missing_by_satellite, + "top_dates": [ + {"satellite": satellite, "date": date_text, "affected_scene_count": _safe_int(count)} + for (satellite, date_text), count in sorted( + orbit_missing_top_dates.items(), + key=lambda item: (-item[1], item[0][0], item[0][1]), + )[:20] + ], + } + inventory_state_rows = await db.execute( select( AssetInventoryStateORM.inventory_type, @@ -1114,16 +1226,24 @@ async def get_statistics_dashboard( 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) + geometry_ready_rate = _ratio(geometry_ready, geometry_total) + metadata_ready_rate = _ratio(metadata_asset_total, metadata_source_total) result_ready_total = sum(item["ready_count"] for item in results_by_catalog) + metadata_missing_count = max(0, metadata_source_total - metadata_asset_total) + geometry_not_ready_count = max(0, geometry_total - geometry_ready) + orbit_missing_gap_count = max(0, orbit_required_total - selected_orbit_bindings) + non_orbit_open_issue_total = sum( + count + for code, count in issue_by_code.items() + if code != "scene_missing_orbit" + ) risk_count = ( - max(0, source_total - metadata_asset_total) - + max(0, source_total - geometry_ready) - + max(0, orbit_required_total - selected_orbit_bindings) + metadata_missing_count + + geometry_not_ready_count + + orbit_missing_gap_count + result_assets_missing - + open_issue_total + + non_orbit_open_issue_total ) kpis = [ @@ -1140,7 +1260,7 @@ async def get_statistics_dashboard( "label": "元数据入库率", "value": round(metadata_ready_rate * 100, 1), "unit": "%", - "note": f"{metadata_asset_total}/{source_total} 景已提取 XML/元数据", + "note": f"{metadata_asset_total}/{metadata_source_total} 景已提取 XML/元数据", "tone": "success" if metadata_ready_rate >= 0.98 else "warning", }, { @@ -1148,7 +1268,7 @@ async def get_statistics_dashboard( "label": "几何画像可用率", "value": round(geometry_ready_rate * 100, 1), "unit": "%", - "note": f"{geometry_ready}/{source_total} 景可用于覆盖统计", + "note": f"{geometry_ready}/{geometry_total} 景可用于覆盖统计", "tone": "success" if geometry_ready_rate >= 0.95 else "warning", }, { @@ -1172,7 +1292,7 @@ async def get_statistics_dashboard( "label": "待关注项", "value": risk_count, "unit": "项", - "note": f"开放问题 {open_issue_total},缺失成果资产 {result_assets_missing}", + "note": f"缺精轨 {orbit_missing_gap_count},开放问题 {open_issue_total},缺失成果资产 {result_assets_missing}", "tone": "danger" if risk_count else "success", }, ] @@ -1186,20 +1306,23 @@ async def get_statistics_dashboard( "source_by_family": source_by_family, "source_by_format": source_by_format, "source_by_month": source_by_month, + "metadata_source_total": metadata_source_total, "metadata_asset_total": metadata_asset_total, "metadata_doc_total": metadata_doc_total, + "metadata_missing_count": metadata_missing_count, "metadata_ready_rate": metadata_ready_rate, "geometry_total": geometry_total, "geometry_ready": geometry_ready, + "geometry_not_ready_count": geometry_not_ready_count, "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)}, + {"key": "source", "label": "源资产登记", "value": source_total, "denominator": source_total, "rate": 1.0}, + {"key": "metadata", "label": "元数据入库", "value": metadata_asset_total, "denominator": metadata_source_total, "rate": metadata_ready_rate}, + {"key": "geometry", "label": "几何画像", "value": geometry_total, "denominator": metadata_source_total, "rate": _ratio(geometry_total, metadata_source_total)}, + {"key": "ready", "label": "可生产画像", "value": geometry_ready, "denominator": geometry_total, "rate": geometry_ready_rate}, + {"key": "preview", "label": "预览缓存", "value": preview_ready, "denominator": radar_total, "rate": _ratio(preview_ready, radar_total)}, ], }, "orbit": { @@ -1208,6 +1331,8 @@ async def get_statistics_dashboard( "orbit_required_total": orbit_required_total, "selected_bindings": selected_orbit_bindings, "matched_bindings": matched_orbit_bindings, + "missing_scene_count": orbit_missing_gap_count, + "missing_summary": orbit_missing_summary, "selected_rate": selected_orbit_rate, }, "coverage": { @@ -1242,6 +1367,9 @@ async def get_statistics_dashboard( "issues": { "issue_total": issue_total, "open_issue_total": open_issue_total, + "risk_count": risk_count, + "non_orbit_open_issue_total": non_orbit_open_issue_total, + "orbit_missing_gap_count": orbit_missing_gap_count, "by_severity": [ {"severity": severity, "count": count} for severity, count in sorted(issue_by_severity.items(), key=lambda kv: (-kv[1], kv[0])) @@ -1361,12 +1489,16 @@ async def get_statistics( "db_ready_and_cache_exists_count": 0, "db_ready_but_cache_missing_count": 0, } - source_xml_consistency = { + source_metadata_consistency = { "total_records_count": 0, - "xml_detected_count": 0, - "xml_missing_count": 0, - "xml_parsed_ok_count": 0, - "xml_detected_but_unparsed_count": 0, + "metadata_document_asset_count": 0, + "metadata_document_count": 0, + "metadata_document_missing_count": 0, + "metadata_parsed_ok_count": 0, + "metadata_detected_but_unparsed_count": 0, + "geometry_ready_count": 0, + "geometry_missing_count": 0, + "geometry_not_ready_count": 0, } try: @@ -1399,7 +1531,6 @@ async def get_statistics( ) source_rows = source_rows_res.all() source_preview_consistency["total_records_count"] = len(source_rows) - source_xml_consistency["total_records_count"] = len(source_rows) for ( unique_id, @@ -1415,7 +1546,6 @@ async def get_statistics( ) in source_rows: if not file_path: source_preview_consistency["preview_missing_count"] += 1 - source_xml_consistency["xml_missing_count"] += 1 continue cache_key = unique_id or file_path @@ -1443,34 +1573,64 @@ async def get_statistics( else: source_preview_consistency["db_ready_but_cache_missing_count"] += 1 - scene_dir = file_path if os.path.isdir(file_path) else os.path.dirname(file_path) - xml_path = find_xml_file(scene_dir) if scene_dir else None - has_xml = bool(xml_path and os.path.exists(xml_path)) - if has_xml: - source_xml_consistency["xml_detected_count"] += 1 - parsed_ok = any( - value is not None and value != "" - for value in [ - scene_center_lon, - scene_center_lat, - acquisition_time_utc, - satellite_mode, - receiving_station, - product_level, - product_unique_id, - ] - ) - if parsed_ok: - source_xml_consistency["xml_parsed_ok_count"] += 1 - else: - source_xml_consistency["xml_detected_but_unparsed_count"] += 1 - else: - source_xml_consistency["xml_missing_count"] += 1 + active_source_count = await _scalar_count( + db, + select(func.count(SourceProductAssetORM.id)).where( + SourceProductAssetORM.is_active == True, + SourceProductAssetORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS), + ), + ) + metadata_asset_count = await _scalar_count( + db, + select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))).where( + SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS), + ), + ) + metadata_doc_count = await _scalar_count( + db, + select(func.count(SourceMetadataDocumentORM.id)).where( + SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS), + ), + ) + metadata_parse_issue_count = await _scalar_count( + db, + select(func.count(SourceMetadataDocumentORM.id)).where( + SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS), + SourceMetadataDocumentORM.parse_status != "OK", + ), + ) + geometry_ready_count = await _scalar_count( + db, + select(func.count(SARSceneGeometryProfileORM.id)).where( + SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS), + SARSceneGeometryProfileORM.metadata_quality == "READY", + SARSceneGeometryProfileORM.production_readiness == "READY", + ), + ) + geometry_total_count = await _scalar_count( + db, + select(func.count(SARSceneGeometryProfileORM.id)).where( + SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS), + ), + ) + source_metadata_consistency.update( + { + "total_records_count": active_source_count, + "metadata_document_asset_count": metadata_asset_count, + "metadata_document_count": metadata_doc_count, + "metadata_document_missing_count": max(0, active_source_count - metadata_asset_count), + "metadata_parsed_ok_count": max(0, metadata_doc_count - metadata_parse_issue_count), + "metadata_detected_but_unparsed_count": metadata_parse_issue_count, + "geometry_ready_count": geometry_ready_count, + "geometry_missing_count": max(0, active_source_count - geometry_total_count), + "geometry_not_ready_count": max(0, geometry_total_count - geometry_ready_count), + } + ) except Exception as e: logger.warning("统计源数据时发生错误 (可能是表不存在): %s", e) source_preview_consistency["error"] = str(e) - source_xml_consistency["error"] = str(e) + source_metadata_consistency["error"] = str(e) # 4. AI 质量统计 labeled_good_count = sum(1 for record in dinsar_records if record.product.user_label == 1) @@ -1756,7 +1916,16 @@ async def get_statistics( "with_orbit_data_count": with_orbit_data_count, }, "source_preview_consistency": source_preview_consistency, - "source_xml_consistency": source_xml_consistency, + "source_metadata_consistency": source_metadata_consistency, + "source_xml_consistency": { + "deprecated": True, + "replacement": "source_metadata_consistency", + "total_records_count": source_metadata_consistency.get("total_records_count", 0), + "xml_detected_count": source_metadata_consistency.get("metadata_document_asset_count", 0), + "xml_missing_count": 0, + "xml_parsed_ok_count": source_metadata_consistency.get("metadata_parsed_ok_count", 0), + "xml_detected_but_unparsed_count": 0, + }, "water_geo_consistency": water_geo_consistency, "pairing_consistency": pairing_consistency, "by_satellite": by_satellite, diff --git a/backend/app/services/dinsar_production_service.py b/backend/app/services/dinsar_production_service.py index 6e5cce6..27b50d0 100644 --- a/backend/app/services/dinsar_production_service.py +++ b/backend/app/services/dinsar_production_service.py @@ -80,6 +80,16 @@ TERMINAL_ITEM_STATUSES = { _SAFE_POINTER_RE = re.compile(r"[^0-9A-Za-z._-]+") +def _item_status_counts(items: List[DinsarProductionRunItemORM]) -> Dict[str, int]: + counts: Dict[str, int] = {} + for item in items: + status = str(item.status or "").strip().upper() + if not status: + continue + counts[status] = counts.get(status, 0) + 1 + return counts + + def _task_type_for_engine(engine_code: str) -> str: normalized = str(engine_code or "").strip().lower() if normalized == "sarscape": @@ -997,53 +1007,59 @@ class DinsarProductionService: ) for item in items_result.scalars().all(): items_by_run_id.setdefault(item.run_id, []).append(item) + + def serialize_run(run: DinsarProductionRunORM) -> Dict[str, Any]: + run_items = items_by_run_id.get(run.run_id, []) + item_counts = _item_status_counts(run_items) + return { + "run_id": run.run_id, + "product_family": run.product_family, + "engine": run.engine_code, + "profile_code": run.profile_code, + "status": _public_run_status(run.status), + "raw_status": run.status, + "started_at": _safe_epoch(run.started_at or run.created_at), + "ended_at": _safe_epoch(run.ended_at), + "task_id": run.task_id, + "workflow_run_id": run.workflow_run_id, + "root_dir": run.source_root, + "publish_root_dir": run.publish_root_dir, + "message": run.latest_message, + "summary_json": run.summary_json if isinstance(run.summary_json, dict) else {}, + "total_items": max(int(run.total_items or 0), len(run_items)), + "completed_items": item_counts.get(RUN_ITEM_STATUS_COMPLETED, int(run.completed_items or 0)), + "running_items": item_counts.get(RUN_ITEM_STATUS_RUNNING, 0), + "pending_items": item_counts.get(RUN_ITEM_STATUS_PENDING, 0), + "failed_items": item_counts.get(RUN_ITEM_STATUS_FAILED, int(run.failed_items or 0)), + "skipped_items": item_counts.get(RUN_ITEM_STATUS_SKIPPED, int(run.skipped_items or 0)), + "cancelled_items": item_counts.get(RUN_ITEM_STATUS_CANCELLED, 0), + "items": [ + { + "task_name": item.task_name, + "task_alias": item.task_alias, + "pair_key": item.pair_key, + "status": item.status, + "current_step": item.current_step, + "latest_run_key": item.latest_run_key, + "latest_output_dir": item.latest_output_dir, + "latest_manifest_path": item.latest_manifest_path, + "last_error": item.last_error, + "paths": _build_output_paths( + engine_code=run.engine_code, + item=item, + run_key=str(item.latest_run_key or ""), + output_dir=str(item.latest_output_dir or _execution_dir(item, str(item.latest_run_key or ""))), + manifest_path=item.latest_manifest_path, + ) + if item.latest_run_key + else {}, + } + for item in run_items[:5] + ], + } + return { - "runs": [ - { - "run_id": run.run_id, - "product_family": run.product_family, - "engine": run.engine_code, - "profile_code": run.profile_code, - "status": _public_run_status(run.status), - "raw_status": run.status, - "started_at": _safe_epoch(run.started_at or run.created_at), - "ended_at": _safe_epoch(run.ended_at), - "task_id": run.task_id, - "workflow_run_id": run.workflow_run_id, - "root_dir": run.source_root, - "publish_root_dir": run.publish_root_dir, - "message": run.latest_message, - "summary_json": run.summary_json if isinstance(run.summary_json, dict) else {}, - "total_items": run.total_items, - "completed_items": run.completed_items, - "failed_items": run.failed_items, - "skipped_items": run.skipped_items, - "items": [ - { - "task_name": item.task_name, - "task_alias": item.task_alias, - "pair_key": item.pair_key, - "status": item.status, - "current_step": item.current_step, - "latest_run_key": item.latest_run_key, - "latest_output_dir": item.latest_output_dir, - "latest_manifest_path": item.latest_manifest_path, - "last_error": item.last_error, - "paths": _build_output_paths( - engine_code=run.engine_code, - item=item, - run_key=str(item.latest_run_key or ""), - output_dir=str(item.latest_output_dir or _execution_dir(item, str(item.latest_run_key or ""))), - manifest_path=item.latest_manifest_path, - ) - if item.latest_run_key - else {}, - } - for item in items_by_run_id.get(run.run_id, [])[:5] - ], - } - for run in runs - ], + "runs": [serialize_run(run) for run in runs], "total": total, } diff --git a/backend/app/services/ops_maintenance_service.py b/backend/app/services/ops_maintenance_service.py index acfb1a0..68a525f 100644 --- a/backend/app/services/ops_maintenance_service.py +++ b/backend/app/services/ops_maintenance_service.py @@ -4,9 +4,9 @@ import os import shutil from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Dict, List, Optional -from sqlalchemy import delete, func, or_, select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from ..config import settings @@ -14,25 +14,18 @@ from ..models import ( DinsarProductionExecutionORM, DinsarProductionRunItemORM, DinsarProductionRunORM, - DinsarResultORM, - ResultProductORM, SystemJobORM, SystemTaskORM, TaskLogORM, - WorkflowArtifactORM, - WorkflowRunORM, - WorkflowStepORM, ) -from .dinsar_production_service import dinsar_production_service - -TERMINAL_TASK_STATUSES = {"COMPLETED", "FAILED", "PARTIAL_SUCCESS", "CANCELLED"} -MAINTENANCE_LIST_STATUSES = {"FAILED", "PARTIAL_SUCCESS", "CANCELLED", "PENDING", "RUNNING"} +MAINTENANCE_LIST_STATUSES = {"COMPLETED", "FAILED", "PARTIAL_SUCCESS", "CANCELLED", "PENDING", "RUNNING"} ACTIVE_TASK_STATUSES = {"PENDING", "RUNNING"} ACTIVE_JOB_STATUSES = {"READY", "RETRY", "RUNNING"} DINSAR_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN", "PYINT_RUN", "IDL_RUN_DINSAR"} -SUPPORTED_CLEANUP_TASK_TYPES = DINSAR_TASK_TYPES | {"COPY_DATA"} -DEFAULT_TASK_TYPES = SUPPORTED_CLEANUP_TASK_TYPES | {"PAIRING_CACHE_REBUILD"} +SUPPORTED_CLEANUP_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN"} +DEFAULT_TASK_TYPES = DINSAR_TASK_TYPES | {"COPY_DATA", "PAIRING_CACHE_REBUILD"} +LANDSAR_WORK_TERMINAL_EXECUTION_STATUSES = {"COMPLETED", "FAILED", "CANCELLED"} def _utcnow_naive() -> datetime: @@ -68,8 +61,15 @@ def _path_exists(path: str) -> bool: return bool(path) and os.path.exists(path) -def _safe_count(rows: Iterable[Any]) -> int: - return len(list(rows)) +def _is_within_path(path: str, parent: str) -> bool: + path_text = _normalize_path_text(path) + parent_text = _normalize_path_text(parent) + if not path_text or not parent_text: + return False + try: + return os.path.commonpath([os.path.abspath(path_text), os.path.abspath(parent_text)]) == os.path.abspath(parent_text) + except ValueError: + return False class OpsMaintenanceService: @@ -108,11 +108,12 @@ class OpsMaintenanceService: if item.get("issue_level") in {"warning", "danger"} or _norm_status(item.get("status")) in {"FAILED", "PARTIAL_SUCCESS", "CANCELLED"} ] + visible_items = items if status_filter else abnormal_items return { - "items": abnormal_items, + "items": visible_items, "limit": safe_limit, "offset": safe_offset, - "returned": len(abnormal_items), + "returned": len(visible_items), } async def diagnose_task(self, db: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]: @@ -124,19 +125,11 @@ class OpsMaintenanceService: item_counts: Dict[str, int] = {} execution_counts: Dict[str, int] = {} disk_paths: List[Dict[str, Any]] = [] - products: List[Dict[str, Any]] = [] if run is not None: item_counts = await self._status_counts(db, DinsarProductionRunItemORM, run.run_id) execution_counts = await self._status_counts(db, DinsarProductionExecutionORM, run.run_id) disk_paths.extend(await self._collect_run_disk_paths(db, run)) - products = await self._collect_result_products(db, run) - related_tasks = await self._related_copy_tasks_for_run(db, run) if run is not None else [] - - copy_dest = self._copy_task_dest_dir(task) - if copy_dest: - disk_paths.append(self._path_payload(copy_dest, "task_pool")) - recent_logs = await self._recent_logs(db, task.task_id) findings, cleanup_supported, cleanup_blockers = self._diagnose_findings( task=task, @@ -152,8 +145,6 @@ class OpsMaintenanceService: "production_run": self._run_payload(run) if run else None, "production_item_counts": item_counts, "production_execution_counts": execution_counts, - "result_products": products, - "related_tasks": [self._task_payload(item) for item in related_tasks], "recent_logs": recent_logs, "disk_paths": disk_paths, "diagnosis": { @@ -179,15 +170,17 @@ class OpsMaintenanceService: if task_status in ACTIVE_TASK_STATUSES: blockers.append("任务仍处于活动状态,不能清理。") - db_counts = await self._cleanup_db_counts(db, diagnosis) disk_deletes = self._cleanup_disk_targets(diagnosis) + has_existing_target = any(item.get("exists") and item.get("allowed") for item in disk_deletes) + if not has_existing_target: + blockers.append("未发现可清理的 LandSAR_WORK_ROOT/run_* 目录。") blocked = bool(blockers) or not cleanup_supported return { "task_id": task_id, "blocked": blocked, "blockers": blockers, "cleanup_supported": cleanup_supported and not blocked, - "database_deletes": db_counts, + "database_deletes": {}, "disk_deletes": disk_deletes, } @@ -204,82 +197,17 @@ class OpsMaintenanceService: if preview.get("blocked"): raise ValueError("; ".join(preview.get("blockers") or ["清理被阻止。"])) - deleted_db: Dict[str, int] = {} task = await self._get_task(db, task_id) if task is None: return None - jobs = await self._get_jobs(db, task_id) - run = await self._get_production_run_for_task(db, task, jobs) - - delete_logs = bool(options.get("delete_logs", True)) - delete_task_records = bool(options.get("delete_task_records", True)) - delete_production_records = bool(options.get("delete_production_records", True)) - delete_result_products = bool(options.get("delete_result_products", True)) - delete_task_pool_dir = bool(options.get("delete_task_pool_dir", True)) - related_copy_tasks = await self._related_copy_tasks_for_run(db, run) if run is not None else [] - - if delete_result_products and run is not None: - products = await self._result_product_orms_for_run(db, run) - product_ids = [item.product_id for item in products] - compat_ids = await self._compat_ids_for_products(db, product_ids) - if product_ids: - result = await db.execute(delete(ResultProductORM).where(ResultProductORM.product_id.in_(product_ids))) - deleted_db["result_products"] = int(result.rowcount or 0) - if compat_ids: - result = await db.execute(delete(DinsarResultORM).where(DinsarResultORM.id.in_(compat_ids))) - deleted_db["dinsar_results"] = int(result.rowcount or 0) - - if delete_production_records and run is not None: - deleted_run = await dinsar_production_service.delete_run_record(run.run_id, db=db) - deleted_db["dinsar_production_runs"] = 1 if deleted_run else 0 - deleted_db["dinsar_production_run_items"] = int(preview["database_deletes"].get("dinsar_production_run_items", 0)) - deleted_db["dinsar_production_executions"] = int(preview["database_deletes"].get("dinsar_production_executions", 0)) - deleted_db["system_jobs"] = int(preview["database_deletes"].get("system_jobs", 0)) - deleted_db["system_tasks"] = 1 - deleted_db["task_logs"] = int(preview["database_deletes"].get("task_logs", 0)) - if delete_task_pool_dir: - related_deleted = await self._delete_related_tasks(db, related_copy_tasks) - for key, value in related_deleted.items(): - deleted_db[key] = int(deleted_db.get(key, 0)) + int(value or 0) - else: - if delete_logs: - result = await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id == task_id)) - deleted_db["task_logs"] = int(result.rowcount or 0) - if delete_task_records: - result = await db.execute(delete(SystemJobORM).where(SystemJobORM.task_id == task_id)) - deleted_db["system_jobs"] = int(result.rowcount or 0) - result = await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id == task_id)) - deleted_db["system_tasks"] = int(result.rowcount or 0) - await db.commit() disk_result = await self._delete_disk_targets(preview, options) return { "task_id": task_id, - "deleted_database": deleted_db, + "deleted_database": {}, "deleted_disk": disk_result, } - async def _delete_related_tasks( - self, - db: AsyncSession, - tasks: List[SystemTaskORM], - ) -> Dict[str, int]: - task_ids = [task.task_id for task in tasks if task.task_id] - if not task_ids: - return {} - result = await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id.in_(task_ids))) - logs = int(result.rowcount or 0) - result = await db.execute(delete(SystemJobORM).where(SystemJobORM.task_id.in_(task_ids))) - jobs = int(result.rowcount or 0) - result = await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id.in_(task_ids))) - task_count = int(result.rowcount or 0) - await db.commit() - return { - "related_task_logs": logs, - "related_system_jobs": jobs, - "related_system_tasks": task_count, - } - async def _get_task(self, db: AsyncSession, task_id: str) -> Optional[SystemTaskORM]: result = await db.execute(select(SystemTaskORM).where(SystemTaskORM.task_id == str(task_id or "").strip())) return result.scalar_one_or_none() @@ -383,87 +311,33 @@ class OpsMaintenanceService: async def _collect_run_disk_paths(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]: paths: Dict[str, Dict[str, Any]] = {} - if run.source_root: - paths[_normalize_path_text(run.source_root)] = self._path_payload(run.source_root, "task_pool") result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id)) for execution in result.scalars().all(): - if execution.output_dir: - publish_dir = self._publish_package_dir(execution.output_dir) - paths[publish_dir] = self._path_payload(publish_dir, "production_result") - log_path = dinsar_production_service.read_run_log(run.run_id, max_bytes=1).get("path") - if log_path: - paths[_normalize_path_text(log_path)] = self._path_payload(log_path, "run_log") + landsar_work_dir = self._landsar_work_dir_for_execution(run, execution) + if landsar_work_dir: + payload = self._path_payload(landsar_work_dir, "landsar_work") + payload["run_key"] = execution.run_key + payload["execution_status"] = execution.status + paths[_normalize_path_text(landsar_work_dir).lower()] = payload return list(paths.values()) - async def _collect_result_products(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]: - products = await self._result_product_orms_for_run(db, run) - return [ - { - "product_id": item.product_id, - "display_name": item.display_name, - "status": item.status, - "health_status": item.health_status, - "publish_dir": item.publish_dir, - "manifest_path": item.manifest_path, - } - for item in products - ] - - async def _result_product_orms_for_run(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[ResultProductORM]: - result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id)) - dirs = [self._publish_package_dir(item.output_dir) for item in result.scalars().all() if item.output_dir] - clauses = [] - for path in dirs: - clauses.append(ResultProductORM.publish_dir == path) - clauses.append(ResultProductORM.native_output_dir.like(path + "%")) - clauses.append(ResultProductORM.manifest_path.like(path + "%")) - clauses.append(ResultProductORM.primary_asset_path.like(path + "%")) - if not clauses: - return [] - products = await db.execute(select(ResultProductORM).where(or_(*clauses))) - by_id: Dict[str, ResultProductORM] = {} - for product in products.scalars().all(): - by_id[product.product_id] = product - return list(by_id.values()) - - async def _compat_ids_for_products(self, db: AsyncSession, product_ids: List[str]) -> List[int]: - if not product_ids: - return [] - result = await db.execute(select(DinsarResultORM).where(DinsarResultORM.compat_product_id.in_(product_ids))) - return [int(item.id) for item in result.scalars().all()] - - def _publish_package_dir(self, output_dir: str) -> str: - normalized = _normalize_path_text(output_dir) - marker = os.sep + "runs" + os.sep - if marker.lower() in normalized.lower(): - lower = normalized.lower() - index = lower.index(marker.lower()) - return normalized[:index] - return normalized - - def _copy_task_dest_dir(self, task: SystemTaskORM) -> str: - params = task.params if isinstance(task.params, dict) else {} - return _normalize_path_text(params.get("dest_dir")) if params.get("dest_dir") else "" - - async def _related_copy_tasks_for_run( + def _landsar_work_dir_for_execution( self, - db: AsyncSession, - run: Optional[DinsarProductionRunORM], - ) -> List[SystemTaskORM]: - source_root = _normalize_path_text(run.source_root if run is not None else "") - if not source_root: - return [] - result = await db.execute( - select(SystemTaskORM) - .where(SystemTaskORM.task_type == "COPY_DATA") - .order_by(SystemTaskORM.updated_at.desc(), SystemTaskORM.id.desc()) - .limit(1000) - ) - tasks = [] - for task in result.scalars().all(): - if _normalize_path_text(self._copy_task_dest_dir(task)).lower() == source_root.lower(): - tasks.append(task) - return tasks + run: DinsarProductionRunORM, + execution: DinsarProductionExecutionORM, + ) -> str: + if _norm_status(run.engine_code) != "LANDSAR": + return "" + if _norm_status(run.status) in ACTIVE_TASK_STATUSES: + return "" + if _norm_status(execution.status) not in LANDSAR_WORK_TERMINAL_EXECUTION_STATUSES: + return "" + work_root = _normalize_path_text(settings.LANDSAR_WORK_ROOT) + run_key = str(execution.run_key or "").strip() + if not work_root or not run_key or Path(run_key).name != run_key or not run_key.startswith("run_"): + return "" + candidate = _normalize_path_text(os.path.join(work_root, run_key)) + return candidate if self._is_landsar_work_delete_path(candidate) else "" def _diagnose_findings( self, @@ -481,9 +355,9 @@ class OpsMaintenanceService: if status == "FAILED": findings.append("任务已失败,需要人工确认后清理。") elif status == "PARTIAL_SUCCESS": - findings.append("任务部分成功,清理前请确认保留策略。") + findings.append("任务部分成功,可按需清理 LandSAR 工作目录。") elif status == "CANCELLED": - findings.append("任务已取消,可按需清理残留记录和目录。") + findings.append("任务已取消,可按需清理 LandSAR 工作目录。") elif status in ACTIVE_TASK_STATUSES: blockers.append("任务仍处于活动状态。") @@ -507,58 +381,29 @@ class OpsMaintenanceService: missing_paths = [item for item in disk_paths if item.get("path") and not item.get("exists")] if missing_paths: findings.append(f"有 {len(missing_paths)} 个登记路径已不存在。") + landsar_work_paths = [ + item + for item in disk_paths + if item.get("kind") == "landsar_work" and item.get("exists") + ] + if landsar_work_paths: + findings.append(f"发现 {len(landsar_work_paths)} 个可清理的 LandSAR 工作目录。") - cleanup_supported = _norm_status(task.task_type) in SUPPORTED_CLEANUP_TASK_TYPES and not blockers + cleanup_supported = ( + _norm_status(task.task_type) in SUPPORTED_CLEANUP_TASK_TYPES + and not blockers + and bool(landsar_work_paths) + ) return findings, cleanup_supported, blockers - async def _cleanup_db_counts(self, db: AsyncSession, diagnosis: Dict[str, Any]) -> Dict[str, int]: - task_id = diagnosis["task"]["task_id"] - run = diagnosis.get("production_run") or {} - run_id = run.get("run_id") - workflow_run_id = run.get("workflow_run_id") - counts = { - "system_tasks": await self._count(db, select(func.count()).select_from(SystemTaskORM).where(SystemTaskORM.task_id == task_id)), - "system_jobs": await self._count(db, select(func.count()).select_from(SystemJobORM).where(SystemJobORM.task_id == task_id)), - "task_logs": await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id == task_id)), - "related_system_tasks": 0, - "related_system_jobs": 0, - "related_task_logs": 0, - "dinsar_production_runs": 0, - "dinsar_production_run_items": 0, - "dinsar_production_executions": 0, - "result_products": len(diagnosis.get("result_products") or []), - "dinsar_results": 0, - "workflow_runs": 0, - "workflow_steps": 0, - "workflow_artifacts": 0, - } - if run_id: - counts["dinsar_production_runs"] = await self._count(db, select(func.count()).select_from(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id == run_id)) - counts["dinsar_production_run_items"] = await self._count(db, select(func.count()).select_from(DinsarProductionRunItemORM).where(DinsarProductionRunItemORM.run_id == run_id)) - counts["dinsar_production_executions"] = await self._count(db, select(func.count()).select_from(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run_id)) - run_obj = await self._get_run_by_id(db, run_id) - related_tasks = await self._related_copy_tasks_for_run(db, run_obj) - related_task_ids = [item.task_id for item in related_tasks if item.task_id] - counts["related_system_tasks"] = len(related_task_ids) - if related_task_ids: - counts["related_system_jobs"] = await self._count(db, select(func.count()).select_from(SystemJobORM).where(SystemJobORM.task_id.in_(related_task_ids))) - counts["related_task_logs"] = await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id.in_(related_task_ids))) - if workflow_run_id: - counts["workflow_runs"] = await self._count(db, select(func.count()).select_from(WorkflowRunORM).where(WorkflowRunORM.run_id == workflow_run_id)) - counts["workflow_steps"] = await self._count(db, select(func.count()).select_from(WorkflowStepORM).where(WorkflowStepORM.run_id == workflow_run_id)) - counts["workflow_artifacts"] = await self._count(db, select(func.count()).select_from(WorkflowArtifactORM).where(WorkflowArtifactORM.run_id == workflow_run_id)) - return counts - - async def _get_run_by_id(self, db: AsyncSession, run_id: str) -> Optional[DinsarProductionRunORM]: - result = await db.execute(select(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id == run_id)) - return result.scalar_one_or_none() - async def _count(self, db: AsyncSession, stmt: Any) -> int: return int((await db.execute(stmt)).scalar_one() or 0) def _cleanup_disk_targets(self, diagnosis: Dict[str, Any]) -> List[Dict[str, Any]]: targets: Dict[str, Dict[str, Any]] = {} for item in diagnosis.get("disk_paths") or []: + if item.get("kind") != "landsar_work": + continue path = _normalize_path_text(item.get("path")) if not path: continue @@ -581,29 +426,26 @@ class OpsMaintenanceService: normalized = _normalize_path_text(path) if not normalized: return False - roots = [ - settings.DINSAR_TASK_POOL_ROOT, - settings.DINSAR_PRODUCT_DIR, - os.path.join(settings.PROJECT_ROOT, "backend", "runtime", "dinsar_production"), - ] + return self._is_landsar_work_delete_path(normalized) + + def _is_landsar_work_delete_path(self, path: str) -> bool: + work_root = _normalize_path_text(settings.LANDSAR_WORK_ROOT) + normalized = _normalize_path_text(path) + if not work_root or not normalized: + return False + root_full = os.path.abspath(work_root) full = os.path.abspath(normalized) - for root in roots: - root_text = _normalize_path_text(root) - if not root_text: - continue - root_full = os.path.abspath(root_text) - if full == root_full: - return False - try: - if os.path.commonpath([full, root_full]) == root_full: - return True - except ValueError: - continue - return False + if full == root_full or not _is_within_path(full, root_full): + return False + try: + relative = os.path.relpath(full, root_full) + except ValueError: + return False + parts = [part for part in relative.split(os.sep) if part] + return len(parts) == 1 and parts[0].startswith("run_") async def _delete_disk_targets(self, preview: Dict[str, Any], options: Dict[str, bool]) -> Dict[str, Any]: - delete_production_dirs = bool(options.get("delete_production_dirs", True)) - delete_task_pool_dir = bool(options.get("delete_task_pool_dir", True)) + delete_landsar_work_dir = bool(options.get("delete_landsar_work_dir", True)) deleted: List[str] = [] missing: List[str] = [] skipped: List[str] = [] @@ -611,13 +453,13 @@ class OpsMaintenanceService: for item in preview.get("disk_deletes") or []: kind = item.get("kind") path = _normalize_path_text(item.get("path")) + if kind != "landsar_work": + skipped.append(path) + continue if not item.get("allowed"): skipped.append(path) continue - if kind == "task_pool" and not delete_task_pool_dir: - skipped.append(path) - continue - if kind == "production_result" and not delete_production_dirs: + if not delete_landsar_work_dir: skipped.append(path) continue if not _path_exists(path): diff --git a/backend/app/services/spatial_service.py b/backend/app/services/spatial_service.py index fb44afe..ce5b8c4 100644 --- a/backend/app/services/spatial_service.py +++ b/backend/app/services/spatial_service.py @@ -7,8 +7,10 @@ import hashlib import json import logging import math +import re import uuid from collections import defaultdict +from datetime import datetime, timedelta from itertools import combinations from typing import Any, Dict, List, Optional, Tuple @@ -20,7 +22,7 @@ from sqlalchemy.orm import aliased from geoalchemy2 import Geography from geoalchemy2.shape import to_shape from geoalchemy2.functions import ST_Intersects, ST_Intersection, ST_Area, ST_Centroid, ST_Covers -from shapely.geometry import Polygon +from shapely.geometry import Polygon, mapping, shape from shapely.ops import unary_union from ..models import ( @@ -196,6 +198,112 @@ class SpatialService: } return result_pairs, warnings, metadata + async def find_dinsar_coverage_pairs( + self, + db: AsyncSession, + params: PairingRequest, + *, + target_date_from: str, + target_date_to: str, + extension_days: int = 15, + max_pairs: int = 200, + target_coverage_ratio: float = 0.98, + min_new_coverage_ratio: float = 0.0005, + aoi_wkt: Optional[str] = None, + require_orbit_data: bool = True, + ) -> Tuple[List[RadarPair], List[str], Dict[str, Any]]: + warnings: List[str] = [] + target_start = self._parse_yyyymmdd(target_date_from, field_name="target_date_from") + target_end = self._parse_yyyymmdd(target_date_to, field_name="target_date_to") + if target_end < target_start: + raise ValueError("target_date_to must be greater than or equal to target_date_from.") + + safe_extension_days = max(0, min(180, int(extension_days or 0))) + safe_max_pairs = max(1, min(5000, int(max_pairs or 200))) + safe_target_coverage_ratio = max(0.0, min(1.0, float(target_coverage_ratio or 0.98))) + safe_min_new_coverage_ratio = max(0.0, min(1.0, float(min_new_coverage_ratio or 0.0))) + query_start = target_start - timedelta(days=safe_extension_days) + query_end = target_end + timedelta(days=safe_extension_days) + + effective_params = self._normalize_pairing_request(params).model_copy( + update={ + "master_date_from": self._format_yyyymmdd(query_start), + "master_date_to": self._format_yyyymmdd(query_end), + "slave_date_from": self._format_yyyymmdd(query_start), + "slave_date_to": self._format_yyyymmdd(query_end), + "strategy": "dinsar_province_coverage", + } + ) + pairing_status = await pairing_state_service.get_pairing_system_status(db) + cache_status = str(pairing_status.get("status") or "UNINITIALIZED") + scene_count = int(pairing_status.get("scene_count") or 0) + pair_count = int(pairing_status.get("pair_count") or 0) + degraded = bool(pairing_status.get("needs_rebuild")) + + if cache_status in {"FAILED", "UNINITIALIZED", "ERROR"} or (scene_count > 1 and pair_count == 0): + raise RuntimeError( + "Pairing candidate cache is not available. Repair or rebuild the pairing foundation first." + ) + if degraded: + warnings.append( + f"Pairing foundation status is {cache_status}; coverage plan uses current cached candidates." + ) + + candidate_pool = await self._query_pairing_metric_cache( + db, + effective_params, + aoi_wkt=aoi_wkt, + require_orbit_data=require_orbit_data, + ) + selected_candidates, coverage_meta, strategy_warnings = self._apply_province_coverage_strategy( + candidate_pool, + target_start=target_start, + target_end=target_end, + query_start=query_start, + query_end=query_end, + max_pairs=safe_max_pairs, + target_coverage_ratio=safe_target_coverage_ratio, + min_new_coverage_ratio=safe_min_new_coverage_ratio, + aoi_wkt=aoi_wkt, + ) + warnings.extend(strategy_warnings) + if not selected_candidates: + warnings.extend( + await self._build_empty_pairing_diagnostics( + db, + effective_params, + aoi_wkt=aoi_wkt, + require_orbit_data=require_orbit_data, + ) + ) + + for candidate in selected_candidates: + candidate["selection_strategy"] = "dinsar_province_coverage" + self._ensure_candidate_identity(candidate) + + network_run_id = await self._persist_network_run( + db, + params=effective_params, + aoi_wkt=aoi_wkt, + require_orbit_data=require_orbit_data, + warnings=warnings, + candidate_pool=candidate_pool, + selected_candidates=selected_candidates, + ) + + await self._attach_dinsar_production_summaries(db, selected_candidates) + result_pairs = self._generate_task_names(self._build_radar_pairs(selected_candidates)) + metadata = { + "fallback_used": False, + "degraded": degraded, + "policy_version": PAIRING_POLICY_VERSION, + "network_run_id": network_run_id, + "candidate_count": len(candidate_pool), + "selected_edge_count": len(result_pairs), + "coverage": coverage_meta, + } + return result_pairs, warnings, metadata + def _normalize_pairing_request(self, params: PairingRequest) -> PairingRequest: updates: Dict[str, Any] = {} @@ -408,6 +516,27 @@ class SpatialService: selection_strategy=candidate.get("selection_strategy"), selection_score=float(selection_score) if selection_score is not None else None, selection_reason=candidate.get("selection_reason"), + coverage_rank=( + int(candidate["coverage_rank"]) + if candidate.get("coverage_rank") is not None + else None + ), + aoi_new_area_ratio=( + float(candidate["aoi_new_area_ratio"]) + if candidate.get("aoi_new_area_ratio") is not None + else None + ), + aoi_pair_area_ratio=( + float(candidate["aoi_pair_area_ratio"]) + if candidate.get("aoi_pair_area_ratio") is not None + else None + ), + aoi_coverage_ratio_after=( + float(candidate["aoi_coverage_ratio_after"]) + if candidate.get("aoi_coverage_ratio_after") is not None + else None + ), + effective_coverage_geojson=candidate.get("effective_coverage_geojson"), time_baseline_days=int(candidate["days"]), spatial_baseline_meters=float(candidate["dist"]), scene_center_distance_meters=float( @@ -701,6 +830,253 @@ class SpatialService: output.append(item) return output + def _apply_province_coverage_strategy( + self, + candidate_pool: List[dict], + *, + target_start: datetime, + target_end: datetime, + query_start: datetime, + query_end: datetime, + max_pairs: int, + target_coverage_ratio: float, + min_new_coverage_ratio: float, + aoi_wkt: Optional[str], + ) -> Tuple[List[dict], Dict[str, Any], List[str]]: + warnings: List[str] = [] + target_days = self._date_set(target_start, target_end) + covered_days: set[datetime] = set() + selected: List[dict] = [] + remaining = [candidate for candidate in candidate_pool if self._candidate_date_window(candidate) is not None] + seen_metric_ids: set[int] = set() + aoi_poly = self._parse_optional_aoi_polygon(aoi_wkt) + geometry_cache: Dict[int, Any] = {} + selected_coverage = Polygon() + aoi_area = float(aoi_poly.area or 0.0) if aoi_poly is not None else 0.0 + stop_reason = "no_more_gain" + max_pairs_reached = False + + while remaining and len(selected) < max_pairs: + best_candidate = None + best_score: Optional[Tuple[float, float, float, float, float, float, str]] = None + current_aoi_coverage_ratio = ( + float(selected_coverage.area or 0.0) / aoi_area + if aoi_poly is not None and aoi_area > 0 + else None + ) + if current_aoi_coverage_ratio is not None and current_aoi_coverage_ratio >= target_coverage_ratio: + stop_reason = "target_coverage_reached" + break + for candidate in remaining: + window = self._candidate_date_window(candidate) + if window is None: + continue + candidate_days = self._date_set(*window) & target_days + new_days = candidate_days - covered_days + new_area = 0.0 + candidate_area = 0.0 + if aoi_poly is not None: + candidate_geom = self._get_candidate_intersection_geom( + candidate, + aoi_poly=aoi_poly, + geometry_cache=geometry_cache, + ) + if candidate_geom is not None and not candidate_geom.is_empty: + candidate_area = float(candidate_geom.area or 0.0) + new_area = float(candidate_geom.difference(selected_coverage).area or 0.0) + if aoi_poly is not None: + new_area_ratio = (new_area / aoi_area) if aoi_area > 0 else 0.0 + if new_area_ratio <= 0: + continue + elif not new_days: + continue + quality_score = float(candidate.get("dinsar_quality_score") or 0.0) + overlap = float(candidate.get("overlap_ratio") or 0.0) + temporal_days = float(candidate.get("days") or 0.0) + distance = float(candidate.get("scene_center_distance_meters") or candidate.get("dist") or 0.0) + if aoi_poly is not None and aoi_area > 0: + score = ( + new_area_ratio, + candidate_area / aoi_area, + float(len(new_days)) / max(1, len(target_days)), + overlap, + quality_score, + -temporal_days - (distance / 1000000.0), + str(candidate.get("pair_uid") or ""), + ) + else: + score = ( + float(len(new_days)), + float(len(candidate_days)), + overlap, + quality_score, + 0.0, + -temporal_days - (distance / 1000000.0), + str(candidate.get("pair_uid") or ""), + ) + if best_score is None or score > best_score: + best_candidate = candidate + best_score = score + + if best_candidate is None: + stop_reason = "no_candidate_adds_coverage" + break + if aoi_poly is None and covered_days == target_days: + stop_reason = "target_time_reached" + break + + window = self._candidate_date_window(best_candidate) + candidate_days = self._date_set(*window) & target_days if window else set() + new_days = candidate_days - covered_days + candidate_geom = self._get_candidate_intersection_geom( + best_candidate, + aoi_poly=aoi_poly, + geometry_cache=geometry_cache, + ) if aoi_poly is not None else None + new_area = 0.0 + candidate_area = 0.0 + if candidate_geom is not None and not candidate_geom.is_empty: + candidate_area = float(candidate_geom.area or 0.0) + new_area = float(candidate_geom.difference(selected_coverage).area or 0.0) + new_area_ratio = (new_area / aoi_area) if aoi_area > 0 else 0.0 + if aoi_poly is not None and len(selected) > 0 and new_area_ratio < min_new_coverage_ratio: + stop_reason = "marginal_gain_below_threshold" + break + best_candidate["selection_reason"] = "province_coverage_new_days" + if aoi_poly is not None and new_area > 0: + best_candidate["selection_reason"] = "province_aoi_new_area" + best_candidate["selection_score"] = float(best_score[0] if best_score else len(new_days)) + best_candidate["coverage_rank"] = len(selected) + 1 + best_candidate["coverage_new_days"] = len(new_days) + best_candidate["coverage_total_days"] = len(candidate_days) + best_candidate["aoi_new_area_ratio"] = new_area_ratio if aoi_area > 0 else None + best_candidate["aoi_pair_area_ratio"] = (candidate_area / aoi_area) if aoi_area > 0 else None + next_selected_coverage = selected_coverage + if candidate_geom is not None and not candidate_geom.is_empty: + next_selected_coverage = unary_union([selected_coverage, candidate_geom]) + best_candidate["aoi_coverage_ratio_after"] = ( + float(next_selected_coverage.area or 0.0) / aoi_area + if aoi_area > 0 else None + ) + best_candidate["effective_coverage_geojson"] = self._geometry_to_geojson(candidate_geom) + best_candidate["target_coverage_ratio_after"] = ( + len(covered_days | candidate_days) / max(1, len(target_days)) + ) + selected.append(best_candidate) + covered_days |= candidate_days + selected_coverage = next_selected_coverage + seen_metric_ids.add(int(best_candidate.get("metric_cache_ref_id") or 0)) + remaining = [ + candidate for candidate in remaining + if int(candidate.get("metric_cache_ref_id") or 0) not in seen_metric_ids + ] + + if remaining and len(selected) >= max_pairs: + max_pairs_reached = True + stop_reason = "max_pairs_reached" + uncovered_ranges = self._date_ranges_from_days(target_days - covered_days) + if uncovered_ranges: + warnings.append( + "Coverage plan did not fully cover the requested time range. " + f"Uncovered ranges: {', '.join(f'{item[0]}~{item[1]}' for item in uncovered_ranges[:6])}" + ) + if max_pairs_reached: + warnings.append( + f"Coverage planning reached task limit max_pairs={max_pairs}; increase the limit for better spatial coverage." + ) + selected_area = float(selected_coverage.area or 0.0) if aoi_poly is not None else 0.0 + aoi_coverage_ratio = (selected_area / aoi_area) if aoi_area > 0 else None + if aoi_coverage_ratio is not None and aoi_coverage_ratio < target_coverage_ratio: + warnings.append( + f"AOI spatial coverage is {aoi_coverage_ratio:.1%}; target is {target_coverage_ratio:.1%}." + ) + + temporal_ratio = len(covered_days) / max(1, len(target_days)) + coverage_meta = { + "strategy": "dinsar_province_coverage", + "optimization_goal": "minimize_pair_count_for_aoi_spatial_coverage", + "greedy_rule": "select_pair_with_largest_new_aoi_intersection_area_each_step", + "stop_reason": stop_reason, + "max_pairs": max_pairs, + "target_coverage_ratio": round(target_coverage_ratio, 6), + "min_new_coverage_ratio": round(min_new_coverage_ratio, 6), + "coverage_basis": "aoi_spatial" if aoi_coverage_ratio is not None else "temporal", + "target_date_from": self._format_yyyymmdd(target_start), + "target_date_to": self._format_yyyymmdd(target_end), + "query_date_from": self._format_yyyymmdd(query_start), + "query_date_to": self._format_yyyymmdd(query_end), + "target_day_count": len(target_days), + "covered_day_count": len(covered_days), + "temporal_coverage_ratio": round(temporal_ratio, 6), + "aoi_coverage_ratio": round(aoi_coverage_ratio, 6) if aoi_coverage_ratio is not None else None, + "coverage_ratio": round(aoi_coverage_ratio if aoi_coverage_ratio is not None else temporal_ratio, 6), + "uncovered_ranges": [ + {"date_from": start, "date_to": end} + for start, end in uncovered_ranges + ], + "selected_pair_count": len(selected), + "candidate_count": len(candidate_pool), + } + return selected, coverage_meta, warnings + + def _candidate_date_window(self, candidate: dict) -> Optional[Tuple[datetime, datetime]]: + master = candidate.get("master") + slave = candidate.get("slave") + master_date = self._try_parse_yyyymmdd(getattr(master, "imaging_date", None)) + slave_date = self._try_parse_yyyymmdd(getattr(slave, "imaging_date", None)) + if master_date is None or slave_date is None: + return None + return (master_date, slave_date) if master_date <= slave_date else (slave_date, master_date) + + def _sort_coverage_candidates(self, candidates: List[dict]) -> List[dict]: + return sorted( + candidates, + key=lambda item: ( + str(getattr(item.get("master"), "imaging_date", "") or ""), + str(getattr(item.get("slave"), "imaging_date", "") or ""), + -float(item.get("selection_score") or 0.0), + str(item.get("pair_uid") or ""), + ), + ) + + def _date_set(self, start: datetime, end: datetime) -> set[datetime]: + if end < start: + return set() + return {start + timedelta(days=offset) for offset in range((end - start).days + 1)} + + def _date_ranges_from_days(self, days: set[datetime]) -> List[Tuple[str, str]]: + if not days: + return [] + ordered = sorted(days) + ranges: List[Tuple[datetime, datetime]] = [] + start = previous = ordered[0] + for day in ordered[1:]: + if day == previous + timedelta(days=1): + previous = day + continue + ranges.append((start, previous)) + start = previous = day + ranges.append((start, previous)) + return [(self._format_yyyymmdd(start), self._format_yyyymmdd(end)) for start, end in ranges] + + def _parse_yyyymmdd(self, value: str, *, field_name: str) -> datetime: + parsed = self._try_parse_yyyymmdd(value) + if parsed is None: + raise ValueError(f"{field_name} must be YYYYMMDD.") + return parsed + + def _try_parse_yyyymmdd(self, value: Any) -> Optional[datetime]: + text_value = str(value or "").strip() + if not re.match(r"^\d{8}$", text_value): + return None + try: + return datetime.strptime(text_value, "%Y%m%d") + except ValueError: + return None + + def _format_yyyymmdd(self, value: datetime) -> str: + return value.strftime("%Y%m%d") + async def _persist_network_run( self, db: AsyncSession, @@ -797,6 +1173,13 @@ class SpatialService: if candidate.get("pair_aoi_overlap_ratio") is not None else None ), + "coverage_rank": candidate.get("coverage_rank"), + "coverage_new_days": candidate.get("coverage_new_days"), + "coverage_total_days": candidate.get("coverage_total_days"), + "aoi_new_area_ratio": candidate.get("aoi_new_area_ratio"), + "aoi_pair_area_ratio": candidate.get("aoi_pair_area_ratio"), + "aoi_coverage_ratio_after": candidate.get("aoi_coverage_ratio_after"), + "target_coverage_ratio_after": candidate.get("target_coverage_ratio_after"), } def _stable_sha1(self, value: Any) -> str: @@ -949,7 +1332,12 @@ class SpatialService: warnings.extend(strategy_warnings) edges: List[Dict[str, Any]] = [] - for edge_rank, candidate in enumerate(self._sorted_candidates(selected_candidates), start=1): + ordered_candidates = ( + selected_candidates + if params.strategy == "dinsar_province_coverage" + else self._sorted_candidates(selected_candidates) + ) + for edge_rank, candidate in enumerate(ordered_candidates, start=1): master = candidate["master"] slave = candidate["slave"] edges.append( @@ -1820,9 +2208,14 @@ class SpatialService: return geometry_cache[cache_key] try: - master_poly = Polygon(candidate["master"].coverage_polygon) - slave_poly = Polygon(candidate["slave"].coverage_polygon) - if master_poly.is_empty or slave_poly.is_empty: + master_poly = self._coverage_polygon_to_shape(getattr(candidate["master"], "coverage_polygon", None)) + slave_poly = self._coverage_polygon_to_shape(getattr(candidate["slave"], "coverage_polygon", None)) + if ( + master_poly is None + or slave_poly is None + or master_poly.is_empty + or slave_poly.is_empty + ): geometry_cache[cache_key] = None return None pair_geom = master_poly.intersection(slave_poly) @@ -1834,6 +2227,44 @@ class SpatialService: geometry_cache[cache_key] = None return None + def _coverage_polygon_to_shape(self, coverage_polygon: Any): + if not coverage_polygon: + return None + try: + if isinstance(coverage_polygon, dict): + if coverage_polygon.get("type") == "Feature": + geometry = coverage_polygon.get("geometry") + if not geometry: + return None + geom = shape(geometry) + else: + geom = shape(coverage_polygon) + elif isinstance(coverage_polygon, list): + points = [] + for point in coverage_polygon: + if isinstance(point, (list, tuple)) and len(point) >= 2: + lon = float(point[0]) + lat = float(point[1]) + points.append((lon, lat)) + if len(points) < 3: + return None + geom = Polygon(points) + else: + return None + if geom.is_empty or not geom.is_valid: + return None + return geom + except Exception: + return None + + def _geometry_to_geojson(self, geometry: Any) -> Optional[Dict[str, Any]]: + try: + if geometry is None or geometry.is_empty: + return None + return mapping(geometry) + except Exception: + return None + def _score_sbas_candidate( self, candidate: dict, diff --git a/docs/DINSAR_PROVINCE_COVERAGE_PAIRING_DESIGN_20260706.md b/docs/DINSAR_PROVINCE_COVERAGE_PAIRING_DESIGN_20260706.md new file mode 100644 index 0000000..56960b7 --- /dev/null +++ b/docs/DINSAR_PROVINCE_COVERAGE_PAIRING_DESIGN_20260706.md @@ -0,0 +1,63 @@ +# D-InSAR Province Coverage Pairing Design + +Date: 2026-07-06 + +## Goal + +Provide a planning mode for near-term province-wide D-InSAR delivery. The user enters a target production time range as the scene pool and selects a province/AOI. The system builds standard D-InSAR candidate pairs, then greedily selects pairs that add the most new AOI spatial coverage. The time range is a freshness/pool constraint, not the primary coverage objective. + +## Scope + +- Reuse `pairing_metric_cache` as the only candidate source. +- Keep the existing standard D-InSAR thresholds: temporal baseline, overlap, center distance, same mode, same polarization, same family, orbit availability. +- Add a planning endpoint that returns normal `RadarPair` rows, so existing pair list, batch save, copy, and production flow remain unchanged. +- Store the selected network through `pairing_network_runs` / `pairing_network_edges` for traceability. + +## Greedy Policy + +1. Build candidate windows from `master_imaging_date` to `slave_imaging_date`. +2. Start with the requested target window. +3. Query candidates in the target pool plus `extension_days`. +4. When an AOI is available, prefer candidates that add new uncovered AOI area. +5. Use target-window temporal coverage as a secondary score and diagnostic. +6. Score each candidate: + - new AOI area covered + - total AOI area covered by the pair + - new target days covered + - existing target coverage overlap + - scene overlap ratio + - D-InSAR quality score + - shorter temporal baseline and smaller center distance as tie-breakers +7. Select the best edge, mark covered AOI area and days, then repeat until: + - AOI area has no meaningful new coverage candidates + - no candidate adds coverage + - `max_pairs` is reached + +## Coverage Semantics + +Coverage here means AOI spatial coverage when an AOI is supplied. Temporal coverage is reported separately to show whether the selected pairs span the requested production window. For province-wide use, the user must select the province AOI and set an AOI overlap threshold appropriate for production planning. + +## API + +`POST /pairing/coverage-plan` + +Form fields: + +- `target_date_from`, `target_date_to`: required `YYYYMMDD` +- standard pairing fields: `time_baseline_min`, `time_baseline_max`, `overlap_threshold`, `spatial_baseline_max_meters`, `aoi_overlap_threshold`, `allowed_satellites` +- `extension_days`: default 15 +- `max_pairs`: default 200 +- `require_orbit_data`: default true +- `files` or `aoi_geojson`: optional AOI, same as `/find-pairs` + +Response: + +- `pairs`: selected `RadarPair` rows +- `coverage`: requested range, effective query range, covered days, coverage ratio, uncovered ranges +- `warnings`, `network_run_id`, `candidate_count`, `selected_edge_count` + +## Notes + +- This is a planning tool, not a new production engine. +- Existing batch splitting remains the operational control for producing 100-by-100 or any user-selected batch size. +- Produced pairs should still be filtered in the pair list by existing result status indicators before batch creation. diff --git a/frontend/src/App.css b/frontend/src/App.css index 1f16332..dec60b7 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1265,6 +1265,7 @@ input[type="checkbox"] { .list-toolbar { display: flex; align-items: center; + justify-content: space-between; gap: 8px; padding: 8px 15px; border-bottom: 1px solid var(--color-border); @@ -1272,6 +1273,31 @@ input[type="checkbox"] { font-size: 12px; color: var(--color-text-secondary); } +.list-toolbar-main { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.list-toolbar-main strong { + color: var(--color-text-primary); + font-size: 13px; +} +.list-toolbar-main span { + color: var(--color-text-muted); + line-height: 1.4; +} +.select-all-pairs-control { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + color: var(--color-text-primary); + font-weight: 650; +} +.select-all-pairs-control input { + margin: 0; +} .list-toolbar.column-layout { flex-direction: column; align-items: stretch; @@ -2007,6 +2033,10 @@ input[type="checkbox"] { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.statistics-section--orbit { + grid-column: span 2; +} + .statistics-bottom-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2107,6 +2137,18 @@ input[type="checkbox"] { font-size: 17px; } +.statistics-orbit-layout { + display: grid; + grid-template-columns: minmax(260px, 0.72fr) minmax(360px, 1.28fr); + gap: 12px; + align-items: start; +} + +.statistics-orbit-overview { + display: grid; + gap: 10px; +} + .statistics-mini-bars { display: grid; gap: 8px; @@ -2130,6 +2172,180 @@ input[type="checkbox"] { background: #16a34a; } +.statistics-orbit-gap { + border: 1px solid #fed7aa; + border-radius: 6px; + background: #fff7ed; + padding: 10px; + min-width: 0; +} + +.statistics-orbit-gap--ok { + border-color: #bbf7d0; + background: #f0fdf4; + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.statistics-orbit-gap--ok strong { + color: #166534; + font-size: 13px; +} + +.statistics-orbit-gap--ok span { + color: #166534; + font-size: 12px; + font-weight: 700; +} + +.statistics-orbit-gap-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: start; + padding-bottom: 9px; + border-bottom: 1px solid #fed7aa; +} + +.statistics-orbit-gap-head strong { + display: block; + color: #7c2d12; + font-size: 13px; + line-height: 1.35; +} + +.statistics-orbit-gap-head span { + display: block; + margin-top: 3px; + color: #9a3412; + font-size: 12px; + line-height: 1.45; + font-weight: 700; +} + +.statistics-orbit-gap-metrics { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.statistics-orbit-gap-metrics div { + min-width: 94px; + border: 1px solid #fdba74; + border-radius: 6px; + background: #ffffff; + padding: 6px 8px; + text-align: right; +} + +.statistics-orbit-gap-metrics span { + margin: 0; + color: #9a3412; + font-size: 11px; +} + +.statistics-orbit-gap-metrics b { + display: block; + margin-top: 2px; + color: #7c2d12; + font-size: 16px; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +.statistics-orbit-gap-list { + display: grid; + gap: 9px; + max-height: 310px; + overflow: auto; + padding: 9px 2px 0 0; +} + +.statistics-orbit-gap-satellite { + border: 1px solid #fde68a; + border-radius: 6px; + background: #ffffff; + padding: 8px; + min-width: 0; +} + +.statistics-orbit-gap-satellite-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: baseline; + margin-bottom: 7px; +} + +.statistics-orbit-gap-satellite-head strong { + color: #0f172a; + font-size: 13px; +} + +.statistics-orbit-gap-satellite-head span { + color: #475569; + font-size: 12px; + font-weight: 700; + text-align: right; +} + +.statistics-orbit-month-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); + gap: 6px; + margin-bottom: 8px; +} + +.statistics-orbit-month-grid span { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 5px; + align-items: center; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #f8fafc; + padding: 5px 6px; + color: #334155; + font-size: 11px; + font-weight: 800; +} + +.statistics-orbit-month-grid b, +.statistics-orbit-month-grid em { + color: #7c2d12; + font-style: normal; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.statistics-orbit-date-row { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.statistics-orbit-date-row span { + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid #fed7aa; + border-radius: 999px; + background: #fff7ed; + color: #7c2d12; + font-size: 11px; + line-height: 1; + font-weight: 800; + padding: 5px 7px; +} + +.statistics-orbit-date-row b { + color: #9a3412; + font-variant-numeric: tabular-nums; +} + .statistics-run-list, .statistics-inventory-list { display: grid; @@ -2476,11 +2692,17 @@ input[type="checkbox"] { @media (max-width: 980px) { .statistics-kpi-grid, - .statistics-grid, - .statistics-plan-grid { + .statistics-command-grid, + .statistics-dashboard-grid, + .statistics-bottom-grid, + .statistics-orbit-layout { grid-template-columns: 1fr; } + .statistics-section--orbit { + grid-column: auto; + } + .statistics-header { flex-direction: column; } @@ -4011,6 +4233,257 @@ input[type="checkbox"] { margin-top: 6px; } +.province-coverage-card { + display: flex; + flex-direction: column; + gap: 10px; +} + +.coverage-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 10px; +} + +.coverage-form-grid label { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + font-size: 12px; + font-weight: 600; + color: var(--color-text-secondary); +} + +.coverage-form-grid input { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 7px 8px; + color: var(--color-text-primary); + background: #fff; +} + +.coverage-aoi-panel { + display: flex; + flex-direction: column; + gap: 9px; + padding: 10px 12px; + border: 1px solid #d8dee8; + border-radius: 8px; + background: #f8fafc; +} + +.coverage-aoi-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + color: var(--color-text-primary); +} + +.coverage-aoi-header strong { + font-size: 13px; + line-height: 1.4; +} + +.coverage-aoi-header span { + max-width: 56ch; + color: var(--color-text-muted); + font-size: 12px; + line-height: 1.45; + text-align: right; +} + +.coverage-aoi-mode-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.coverage-aoi-mode { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 5px 9px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + color: #334155; + font-size: 12px; + font-weight: 650; + cursor: pointer; +} + +.coverage-aoi-mode input { + margin: 0; +} + +.coverage-aoi-mode.active { + border-color: #2563eb; + background: #eff6ff; + color: #1d4ed8; +} + +.coverage-aoi-selects { + display: grid; + grid-template-columns: repeat(2, minmax(160px, 1fr)); + gap: 10px; +} + +.coverage-aoi-selects label, +.coverage-aoi-file { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + font-size: 12px; + font-weight: 600; + color: var(--color-text-secondary); +} + +.coverage-aoi-selects select, +.coverage-aoi-file input { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 7px 8px; + color: var(--color-text-primary); + background: #fff; +} + +.coverage-aoi-note { + color: #64748b; + font-size: 12px; + line-height: 1.45; +} + +.coverage-aoi-error { + padding: 7px 9px; + border: 1px solid #fecaca; + border-radius: 6px; + background: #fef2f2; + color: #991b1b; + font-size: 12px; + line-height: 1.45; +} + +.coverage-action-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.coverage-action-row button { + min-width: 132px; +} + +.coverage-action-row span { + font-size: 12px; + color: var(--color-text-muted); +} + +.coverage-result { + display: flex; + flex-direction: column; + gap: 3px; + border-radius: 8px; + padding: 10px 12px; + font-size: 12px; + line-height: 1.45; +} + +.coverage-result.ready { + border: 1px solid #bae6fd; + background: #f0f9ff; + color: #075985; +} + +.coverage-result.error { + border: 1px solid #fecaca; + background: #fef2f2; + color: #991b1b; +} + +.coverage-result-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 7px; +} + +.coverage-result-actions button { + border-color: #2563eb; + background: #2563eb; + color: #fff; + font-size: 12px; + font-weight: 700; + box-shadow: none; +} + +.coverage-result-actions button:hover:not(:disabled) { + border-color: #1d4ed8; + background: #1d4ed8; + box-shadow: none; +} + +.coverage-map-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + padding: 8px 10px; + border: 1px solid #d8dee8; + border-radius: 8px; + background: #fff; + color: var(--color-text-secondary); + font-size: 12px; +} + +.coverage-map-toolbar strong { + color: var(--color-text-primary); + font-size: 13px; +} + +.coverage-map-toolbar span { + flex: 1 1 260px; + min-width: 0; +} + +.coverage-map-toolbar button { + padding: 5px 9px; + font-size: 12px; + box-shadow: none; +} + +.coverage-map-toolbar button.active { + border-color: #2563eb; + background: #eff6ff; + color: #1d4ed8; +} + +@media (max-width: 760px) { + .coverage-form-grid, + .coverage-aoi-selects { + grid-template-columns: 1fr; + } + + .coverage-aoi-header { + flex-direction: column; + gap: 4px; + } + + .coverage-aoi-header span { + max-width: none; + text-align: left; + } +} + .asset-inventory-panel { width: 100%; max-width: 1280px; @@ -6177,6 +6650,17 @@ input[type="checkbox"] { padding: 0; } +.production-workspace-shell .dinsar-products-hero, +.production-workspace-shell .dinsar-products-section-head, +.production-workspace-shell .dinsar-products-top-grid, +.production-workspace-shell .dinsar-products-catalog-section, +.production-workspace-shell .dinsar-products-catalog-section .dinsar-catalog-shell, +.production-workspace-shell .dinsar-products-catalog-section .dinsar-catalog-workspace { + width: 100%; + max-width: none; + box-sizing: border-box; +} + .dinsar-production-header { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(420px, 0.95fr); @@ -6877,7 +7361,7 @@ input[type="checkbox"] { .dinsar-products-page { width: 100%; - max-width: 1480px; + max-width: none; margin: 0 auto; padding: 6px 0 24px; display: grid; @@ -6885,12 +7369,12 @@ input[type="checkbox"] { } .panel--standalone .dinsar-products-page { - max-width: 1480px; + max-width: none; } .dinsar-products-hero { - display: flex; - justify-content: space-between; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 0.42fr); gap: 18px; align-items: flex-start; padding: 16px 18px; @@ -6907,7 +7391,7 @@ input[type="checkbox"] { } .dinsar-products-hero p { - max-width: 72ch; + max-width: 96ch; margin: 8px 0 0; color: var(--color-text-secondary); font-size: 13px; @@ -6916,9 +7400,9 @@ input[type="checkbox"] { .dinsar-products-signals { display: grid; - grid-template-columns: repeat(2, minmax(116px, 1fr)); + grid-template-columns: repeat(4, minmax(112px, 1fr)); gap: 8px; - min-width: 280px; + min-width: 0; } .dinsar-products-section-head { @@ -6946,7 +7430,7 @@ input[type="checkbox"] { .dinsar-products-top-grid { display: grid; - grid-template-columns: minmax(420px, 0.95fr) minmax(460px, 1.05fr); + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; } @@ -7064,7 +7548,7 @@ input[type="checkbox"] { } .dinsar-products-catalog-section .dinsar-catalog-workspace { - grid-template-columns: minmax(380px, 0.36fr) minmax(0, 1fr); + grid-template-columns: minmax(420px, 0.34fr) minmax(0, 1fr); } .sbas-products-page { @@ -7848,7 +8332,7 @@ input[type="checkbox"] { } } -@media (max-width: 1500px) { +@media (max-width: 1100px) { .panel--standalone .dinsar-products-top-grid { grid-template-columns: 1fr; } diff --git a/frontend/src/DinsarProductionPanel.jsx b/frontend/src/DinsarProductionPanel.jsx index 8dad37e..7b26ec4 100644 --- a/frontend/src/DinsarProductionPanel.jsx +++ b/frontend/src/DinsarProductionPanel.jsx @@ -299,15 +299,18 @@ function safeCount(value) { function getRunCounts(run) { const completed = safeCount(run?.completed_items); + const running = safeCount(run?.running_items); + const pending = safeCount(run?.pending_items); const failed = safeCount(run?.failed_items); const skipped = safeCount(run?.skipped_items); + const cancelled = safeCount(run?.cancelled_items); const total = safeCount(run?.total_items); - return { completed, failed, skipped, total }; + return { completed, running, pending, failed, skipped, cancelled, total }; } function hasRunCounts(run) { - const { completed, failed, skipped, total } = getRunCounts(run); - return [completed, failed, skipped, total].some(value => value != null); + const { completed, running, pending, failed, skipped, cancelled, total } = getRunCounts(run); + return [completed, running, pending, failed, skipped, cancelled, total].some(value => value != null); } function hasMixedRunOutcome(run) { @@ -345,11 +348,14 @@ function getDisplayTaskStatus(task, relatedRun = null) { } function formatRunCounts(run) { - const { completed, failed, skipped, total } = getRunCounts(run); + const { completed, running, pending, failed, skipped, cancelled, total } = getRunCounts(run); const parts = []; + if (running != null && running > 0) parts.push(`运行中 ${running}`); if (completed != null) parts.push(`成功 ${completed}`); if (failed != null) parts.push(`失败 ${failed}`); if (skipped != null && skipped > 0) parts.push(`跳过 ${skipped}`); + if (cancelled != null && cancelled > 0) parts.push(`取消 ${cancelled}`); + if (pending != null && pending > 0) parts.push(`待处理 ${pending}`); if (total != null) parts.push(`总数 ${total}`); return parts.join(' / '); } diff --git a/frontend/src/HealthCheckPanel.jsx b/frontend/src/HealthCheckPanel.jsx index cf9d48c..cbfedeb 100644 --- a/frontend/src/HealthCheckPanel.jsx +++ b/frontend/src/HealthCheckPanel.jsx @@ -7,7 +7,6 @@ import { syncWaterScenesFromDisk } from './api/water'; import { listEngines, runWslCheck } from './api/dinsarProduction'; import { getOrbitStatus, syncOrbitPools } from './api/orbit'; import LogManagementPanel from './LogManagementPanel'; -import OpsTaskMaintenancePanel from './OpsTaskMaintenancePanel'; const toNumber = (value) => { const parsed = Number(value); @@ -129,7 +128,7 @@ const formatSourceRootRole = (role, en = false) => { const buildConsistencySummary = (stats, en = false) => { const dinsar = stats?.dinsar_cache_consistency || {}; const preview = stats?.source_preview_consistency || {}; - const xml = stats?.source_xml_consistency || {}; + const metadata = stats?.source_metadata_consistency || {}; const water = stats?.water_geo_consistency || {}; const issues = [ @@ -164,15 +163,15 @@ const buildConsistencySummary = (stats, en = false) => { level: 'warn', }, { - key: 'xml_unparsed', - label: en ? 'Source: XML detected but key fields not imported' : '源影像:检测到 XML 但关键字段未入库', - count: toNumber(xml.xml_detected_but_unparsed_count), + key: 'metadata_parse_issue', + label: en ? 'Source: metadata document parse issues' : '源影像:元数据文档解析异常', + count: toNumber(metadata.metadata_detected_but_unparsed_count), level: 'warn', }, { - key: 'xml_missing', - label: en ? 'Source: XML not detected' : '源影像:未检测到 XML', - count: toNumber(xml.xml_missing_count), + key: 'metadata_missing', + label: en ? 'Source: metadata document not registered' : '源影像:元数据文档未入库', + count: toNumber(metadata.metadata_document_missing_count), level: 'warn', }, { @@ -1508,7 +1507,6 @@ const HealthCheckPanel = ({ currentUser }) => { )} {/* 日志管理 */} - diff --git a/frontend/src/OpsTaskMaintenancePanel.jsx b/frontend/src/OpsTaskMaintenancePanel.jsx index a3f2f35..8559d3b 100644 --- a/frontend/src/OpsTaskMaintenancePanel.jsx +++ b/frontend/src/OpsTaskMaintenancePanel.jsx @@ -18,6 +18,7 @@ const TASK_TYPES = [ const STATUSES = [ ['', '全部状态'], + ['COMPLETED', '已完成'], ['FAILED', '失败'], ['PARTIAL_SUCCESS', '部分成功'], ['CANCELLED', '已取消'], @@ -93,7 +94,7 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => { const [diagnosisLoading, setDiagnosisLoading] = useState(false); const [preview, setPreview] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); - const [cleanupLoading, setCleanupLoading] = useState(false); + const [workCleanupLoading, setWorkCleanupLoading] = useState(false); const [cleanupResult, setCleanupResult] = useState(null); const [panelMessage, setPanelMessage] = useState(null); @@ -154,39 +155,30 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => { } }, [selectedTaskId]); - const executeCleanup = useCallback(async () => { + const executeLandsarWorkCleanup = useCallback(async () => { if (!selectedTaskId || !preview || preview.blocked || !isAdmin) return; - setCleanupLoading(true); + setWorkCleanupLoading(true); setCleanupResult(null); try { const data = await cleanupMaintenanceTask(selectedTaskId, { confirm: true, - delete_task_records: true, - delete_logs: true, - delete_production_records: true, - delete_result_products: true, - delete_production_dirs: true, - delete_task_pool_dir: true, - }); - setCleanupResult({ - ok: true, - message: `清理完成:数据库 ${Object.values(data.deleted_database || {}).reduce((sum, value) => sum + Number(value || 0), 0)} 条,目录 ${(data.deleted_disk?.deleted || []).length} 个。`, - }); - setPanelMessage({ - ok: true, - message: `清理完成:数据库 ${Object.values(data.deleted_database || {}).reduce((sum, value) => sum + Number(value || 0), 0)} 条,目录 ${(data.deleted_disk?.deleted || []).length} 个。`, + delete_landsar_work_dir: true, }); + const deletedCount = (data.deleted_disk?.deleted || []).length; + const message = `LandSAR 工作目录清理完成:目录 ${deletedCount} 个。`; + setCleanupResult({ ok: true, message }); + setPanelMessage({ ok: true, message }); setPreview(null); - setDiagnosis(null); - setSelectedTaskId(''); + await loadDiagnosis(selectedTaskId); await loadTasks(); } catch (err) { - setCleanupResult({ ok: false, message: err.response?.data?.detail || err.message || '清理失败' }); - setPanelMessage({ ok: false, message: err.response?.data?.detail || err.message || '清理失败' }); + const message = err.response?.data?.detail || err.message || 'LandSAR 工作目录清理失败'; + setCleanupResult({ ok: false, message }); + setPanelMessage({ ok: false, message }); } finally { - setCleanupLoading(false); + setWorkCleanupLoading(false); } - }, [isAdmin, loadTasks, preview, selectedTaskId]); + }, [isAdmin, loadDiagnosis, loadTasks, preview, selectedTaskId]); const abnormalCount = tasks.length; const cleanableCount = tasks.filter(item => item.cleanup_supported).length; @@ -331,24 +323,18 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => { {preview.blockers?.join(';') || '当前任务不允许清理。'} )} -
-
-
数据库记录
- -
-
-
磁盘路径
- -
+
+
LANDSAR_WORK_ROOT
+
{!isAdmin && 仅管理员可执行清理。}
diff --git a/frontend/src/ProductionWorkspace.jsx b/frontend/src/ProductionWorkspace.jsx index 68f1186..bd54487 100644 --- a/frontend/src/ProductionWorkspace.jsx +++ b/frontend/src/ProductionWorkspace.jsx @@ -217,6 +217,7 @@ export default function ProductionWorkspace({ onRefreshRadarSearch={pairingPanel?.onRefreshRadarSearch} onSearchAll={radarPanel?.onSearchAll} onRefreshDinsar={pairingPanel?.onRefreshDinsar} + onOpenPairsView={() => setActiveView('dinsar_pairs')} language={language} /> ); diff --git a/frontend/src/StatisticsDashboard.jsx b/frontend/src/StatisticsDashboard.jsx index 57b2823..ae5f98e 100644 --- a/frontend/src/StatisticsDashboard.jsx +++ b/frontend/src/StatisticsDashboard.jsx @@ -295,7 +295,11 @@ function buildPipelineOption(rows) { axisPointer: { type: 'shadow' }, formatter: (params) => { const item = params?.[0]?.data || {}; - return `${item.label}
数量:${formatNumber(item.value)}
比例:${formatPercent(item.rate)}`; + const denominator = Number(item.denominator); + const denominatorText = Number.isFinite(denominator) && denominator > 0 + ? `
适用总数:${formatNumber(denominator)}` + : ''; + return `${item.label}
数量:${formatNumber(item.value)}${denominatorText}
比例:${formatPercent(item.rate)}`; }, }, xAxis: { @@ -327,7 +331,13 @@ function buildPipelineOption(rows) { show: true, position: 'right', color: '#475569', - formatter: (params) => `${formatNumber(params.data.value)} / ${formatPercent(params.data.rate)}`, + formatter: (params) => { + const denominator = Number(params.data.denominator); + const totalText = Number.isFinite(denominator) && denominator > 0 + ? `/${formatNumber(denominator)}` + : ''; + return `${formatNumber(params.data.value)}${totalText} / ${formatPercent(params.data.rate)}`; + }, }, }, ], @@ -743,6 +753,87 @@ function FamilyLegend({ rows }) { ); } +function formatYmd(value) { + const text = String(value || '').trim(); + if (/^\d{8}$/.test(text)) { + return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`; + } + return text || '-'; +} + +function MissingOrbitSummary({ summary }) { + const affectedSceneCount = Number(summary?.affected_scene_count || 0); + const missingDateCount = Number(summary?.missing_orbit_date_count || 0); + const bySatellite = Array.isArray(summary?.by_satellite) ? summary.by_satellite : []; + + if (!affectedSceneCount) { + return ( +
+ 精轨缺口 + 当前没有开放的缺精轨场景。 +
+ ); + } + + return ( +
+
+
+ 精轨缺口说明 + 缺轨按受影响影像景数统计;补齐时按卫星日期准备 LT-1 TXT 精轨。 +
+
+
+ 受影响景数 + {formatNumber(affectedSceneCount)} +
+
+ 缺失卫星日期 + {formatNumber(missingDateCount)} +
+
+
+ +
+ {bySatellite.map((item) => { + const months = Array.isArray(item.months) ? item.months : []; + const dates = Array.isArray(item.dates) ? item.dates : []; + return ( +
+
+ {item.satellite || 'UNKNOWN'} + + {formatNumber(item.affected_scene_count)} 景 / {formatNumber(item.missing_orbit_date_count)} 个日期 + {item.first_missing_date && item.last_missing_date + ? ` / ${formatYmd(item.first_missing_date)} 至 ${formatYmd(item.last_missing_date)}` + : ''} + +
+
+ {months.map((month) => ( + + {month.month} + {formatNumber(month.affected_scene_count)}景 + {formatNumber(month.missing_orbit_date_count)}日 + + ))} +
+
+ {dates.map((date) => ( + + {formatYmd(date.date)} + {formatNumber(date.affected_scene_count)} + + ))} +
+
+ ); + })} +
+
+ ); +} + function ProductionRunList({ rows }) { const data = Array.isArray(rows) ? rows : []; if (!data.length) { @@ -792,11 +883,17 @@ export default function StatisticsDashboard() { const [lastLoadedAt, setLastLoadedAt] = useState(null); const [coverageMode, setCoverageMode] = useState('source'); - const loadDashboard = async () => { + const loadDashboard = async ({ fresh = false } = {}) => { setLoading(true); setError(''); try { - const payload = await getStatisticsDashboard(); + let payload; + try { + payload = await getStatisticsDashboard(fresh); + } catch (err) { + if (!fresh) throw err; + payload = await getStatisticsDashboard(false); + } setData(payload); setLastLoadedAt(new Date()); } catch (err) { @@ -875,7 +972,7 @@ export default function StatisticsDashboard() { {data.cache_meta.ttl_seconds ? ` · ${data.cache_meta.ttl_seconds}秒缓存` : ''} )} - @@ -948,29 +1045,38 @@ export default function StatisticsDashboard() { -
-
-
- 精轨资产 - {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)} - +
+
+
+
+
+ 精轨资产 + {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)} + +
+ ))} +
+
+
diff --git a/frontend/src/api/pairing.js b/frontend/src/api/pairing.js index cf4bbac..fc94699 100644 --- a/frontend/src/api/pairing.js +++ b/frontend/src/api/pairing.js @@ -1,6 +1,7 @@ import apiClient from './client'; export const findPairs = (formData) => apiClient.post('/find-pairs', formData).then(r => r.data); +export const buildCoveragePlan = (formData) => apiClient.post('/pairing/coverage-plan', formData).then(r => r.data); export const findPsTimeseries = (formData) => apiClient.post('/find-ps-timeseries', formData).then(r => r.data); export const getPairingHealth = () => apiClient.get('/pairing/health').then(r => r.data); export const rebuildPairingCache = () => apiClient.post('/pairing/rebuild-cache').then(r => r.data); diff --git a/frontend/src/api/stats.js b/frontend/src/api/stats.js index 9fee6f2..5f746fb 100644 --- a/frontend/src/api/stats.js +++ b/frontend/src/api/stats.js @@ -3,5 +3,5 @@ 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); +export const getStatisticsDashboard = (fresh = false) => + apiClient.get('/statistics/dashboard', { params: fresh ? { fresh: true } : undefined }).then(r => r.data); diff --git a/frontend/src/components/app/AppSidePanel.jsx b/frontend/src/components/app/AppSidePanel.jsx index ec7812f..34eff28 100644 --- a/frontend/src/components/app/AppSidePanel.jsx +++ b/frontend/src/components/app/AppSidePanel.jsx @@ -18,6 +18,7 @@ const LazyAssetInventoryPanel = lazy(() => import('../../AssetInventoryPanel')); const LazyIDLAutomationPanel = lazy(() => import('../../IDLAutomationPanel')); const LazyHazardPointPanel = lazy(() => import('../../HazardPointPanel')); const LazyHealthCheckPanel = lazy(() => import('../../HealthCheckPanel')); +const LazyOpsTaskMaintenancePanel = lazy(() => import('../../OpsTaskMaintenancePanel')); const LazyFloodAnalysisWorkspace = lazy(() => import('../../FloodAnalysisWorkspace')); const LazyUserAdminPanel = lazy(() => import('../../UserAdminPanel')); const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel')); @@ -329,6 +330,20 @@ export default function AppSidePanel({
)} + {leftPanelTab === 'ops_tasks' && ( +
+ {isAdmin ? ( + }> + + + ) : ( +
+

仅管理员可访问任务维护。

+
+ )} +
+ )} + {leftPanelTab === 'users' && (
{isAdmin ? ( diff --git a/frontend/src/components/panels/PairListRow.jsx b/frontend/src/components/panels/PairListRow.jsx index 20000ad..56d3979 100644 --- a/frontend/src/components/panels/PairListRow.jsx +++ b/frontend/src/components/panels/PairListRow.jsx @@ -82,6 +82,7 @@ function PairListRow({ const overlapLabel = pair.pair_aoi_overlap_ratio != null ? 'AOI覆盖' : '影像重叠'; const production = productionLabel(pair.production_summary); const quality = qualityLabel(pair); + const hasCoverageContribution = pair.aoi_new_area_ratio != null || pair.aoi_coverage_ratio_after != null; const engines = Array.isArray(pair.production_summary?.engine_codes) ? pair.production_summary.engine_codes.filter(Boolean).join('/') : ''; @@ -104,6 +105,13 @@ function PairListRow({ 时基: {pair.time_baseline_days}d 中心距: {formatDistance(centerDistance)} {overlapLabel}: {formatPercent(overlap)} + {hasCoverageContribution && ( + + 覆盖贡献: {pair.coverage_rank ? `#${pair.coverage_rank} ` : ''} + 新增 {formatPercent(pair.aoi_new_area_ratio)} + {pair.aoi_coverage_ratio_after != null ? ` / 累计 ${formatPercent(pair.aoi_coverage_ratio_after)}` : ''} + + )} {quality && D-InSAR: {quality}}
diff --git a/frontend/src/config/appConstants.js b/frontend/src/config/appConstants.js index fe2c13c..1beda02 100644 --- a/frontend/src/config/appConstants.js +++ b/frontend/src/config/appConstants.js @@ -221,7 +221,7 @@ export const LEFT_GROUP_TABS = { insar_analysis: LEFT_GROUP_SECTIONS.insar_analysis.flatMap(section => section.tabs), statistics: ['statistics'], flood_analysis: ['flood_analysis'], - ops: ['health', 'users', 'audit'], + ops: ['health', 'ops_tasks', 'users', 'audit'], }; export const LEFT_TAB_GROUP = Object.entries(LEFT_GROUP_TABS).reduce((acc, [group, tabs]) => { @@ -262,6 +262,7 @@ export const ADMIN_ONLY_TABS = new Set([ ...PRODUCTION_WORKSPACE_LEGACY_TABS, 'users', 'audit', + 'ops_tasks', ]); export const DEFAULT_LIST_PAGE_SIZE = 200; diff --git a/frontend/src/panels/PairPlanningPanel.jsx b/frontend/src/panels/PairPlanningPanel.jsx index 7c99107..c59c65b 100644 --- a/frontend/src/panels/PairPlanningPanel.jsx +++ b/frontend/src/panels/PairPlanningPanel.jsx @@ -1,10 +1,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { + buildCoveragePlan, getPairingHealth, rebuildPairingCache, reconcileDirtyPairingCache, } from '../api/pairing'; +import { getRegionChildren, getRegionGeometry } from '../api/aoi'; import MiniCoverageMap from '../components/MiniCoverageMap'; +import { usePairingStore } from '../store'; const formatIso = (value, en = false) => { if (!value) return en ? 'Never' : '未执行'; @@ -41,6 +44,7 @@ export default function PairPlanningPanel({ onRefreshRadarSearch, onSearchAll, onRefreshDinsar, + onOpenPairsView, language, }) { const en = language === 'en'; @@ -50,7 +54,60 @@ export default function PairPlanningPanel({ const [pairingRepairing, setPairingRepairing] = useState(false); const [pairingFullRebuilding, setPairingFullRebuilding] = useState(false); const [pairingActionResult, setPairingActionResult] = useState(null); - const previewPairs = useMemo(() => foundPairs.slice(0, 20), [foundPairs]); + const [coveragePlanning, setCoveragePlanning] = useState(false); + const [coverageResult, setCoverageResult] = useState(null); + const [coverageAoiMode, setCoverageAoiMode] = useState('region'); + const [coverageAoiFiles, setCoverageAoiFiles] = useState(null); + const [coverageAoiGeojson, setCoverageAoiGeojson] = useState(null); + const [coverageAoiError, setCoverageAoiError] = useState(''); + const [coverageRegionLoading, setCoverageRegionLoading] = useState(false); + const [coverageRegionLoaded, setCoverageRegionLoaded] = useState(false); + const [coverageRegionOptions, setCoverageRegionOptions] = useState({ provinces: [], cities: [] }); + const [coverageRegionSelection, setCoverageRegionSelection] = useState({ province: '', city: '' }); + const [coverageMapMode, setCoverageMapMode] = useState('effective'); + const [coverageForm, setCoverageForm] = useState({ + target_date_from: '', + target_date_to: '', + extension_days: '15', + max_pairs: '200', + target_coverage_ratio: '0.98', + min_new_coverage_ratio: '0.0005', + time_baseline_min: '1', + time_baseline_max: '30', + overlap_threshold: '0.5', + spatial_baseline_max_meters: '5000', + aoi_overlap_threshold: '0', + allowed_satellites: 'LT1', + }); + const { setFoundPairs, setPairingAlert } = usePairingStore(); + const selectedCoverageRegionTreeId = coverageRegionSelection.city || coverageRegionSelection.province || ''; + const selectedCoverageProvince = useMemo( + () => coverageRegionOptions.provinces.find(item => String(item.tree_id) === String(coverageRegionSelection.province)), + [coverageRegionOptions.provinces, coverageRegionSelection.province], + ); + const selectedCoverageCity = useMemo( + () => coverageRegionOptions.cities.find(item => String(item.tree_id) === String(coverageRegionSelection.city)), + [coverageRegionOptions.cities, coverageRegionSelection.city], + ); + const coverageAoiSummary = useMemo(() => { + if (coverageAoiMode === 'region') { + if (!selectedCoverageRegionTreeId) return '请选择行政区作为空间覆盖范围'; + const name = selectedCoverageCity?.name || selectedCoverageProvince?.name || selectedCoverageRegionTreeId; + return `行政区 AOI:${name}${coverageRegionSelection.city ? '' : '(全域)'}`; + } + const files = Array.from(coverageAoiFiles || []); + if (files.length === 0) return '请上传 SHP/GeoJSON 作为空间覆盖范围'; + const names = files.slice(0, 2).map(file => file.name).join('、'); + return `上传 AOI:${names}${files.length > 2 ? ` 等 ${files.length} 个文件` : ''}`; + }, [ + coverageAoiFiles, + coverageAoiMode, + coverageRegionSelection.city, + selectedCoverageCity, + selectedCoverageProvince, + selectedCoverageRegionTreeId, + ]); + const previewPairs = useMemo(() => foundPairs, [foundPairs]); const previewPolygons = useMemo(() => ( previewPairs.flatMap((pair, index) => { const taskLabel = pair.task_alias || pair.task_name || `Pair ${index + 1}`; @@ -70,6 +127,22 @@ export default function PairPlanningPanel({ ]; }) ), [previewPairs]); + const previewEffectiveGeojson = useMemo(() => ({ + type: 'FeatureCollection', + features: previewPairs + .filter(pair => pair.effective_coverage_geojson) + .map((pair, index) => ({ + type: 'Feature', + properties: { + name: `${pair.coverage_rank || index + 1}. ${pair.task_alias || pair.task_name || `Pair ${index + 1}`}`, + label: `${pair.coverage_rank || index + 1}. 新增 ${((Number(pair.aoi_new_area_ratio || 0)) * 100).toFixed(2)}% / 累计 ${((Number(pair.aoi_coverage_ratio_after || 0)) * 100).toFixed(1)}%`, + color: '#2563eb', + fillColor: '#3b82f6', + }, + geometry: pair.effective_coverage_geojson, + })), + }), [previewPairs]); + const mapShowsEffectiveCoverage = coverageMapMode === 'effective' && previewEffectiveGeojson.features.length > 0; const refreshPairingStatus = useCallback(async () => { if (isReadOnlyUser) { @@ -122,6 +195,169 @@ export default function PairPlanningPanel({ }, [en, refreshPairingStatus]); const pairingActionBusy = pairingRepairing || pairingFullRebuilding; + const handleCoverageFieldChange = useCallback((field, value) => { + setCoverageForm(current => ({ ...current, [field]: value })); + }, []); + + const loadCoverageProvinces = useCallback(async () => { + setCoverageRegionLoading(true); + setCoverageAoiError(''); + try { + const result = await getRegionChildren('1'); + setCoverageRegionOptions({ + provinces: Array.isArray(result?.children) ? result.children : [], + cities: [], + }); + setCoverageRegionLoaded(true); + } catch (error) { + setCoverageAoiError(error.response?.data?.detail || error.message || '行政区加载失败。'); + setCoverageRegionOptions({ provinces: [], cities: [] }); + setCoverageRegionLoaded(true); + } finally { + setCoverageRegionLoading(false); + } + }, []); + + const loadCoverageCities = useCallback(async (provinceId) => { + if (!provinceId) { + setCoverageRegionOptions(current => ({ ...current, cities: [] })); + return; + } + setCoverageRegionLoading(true); + setCoverageAoiError(''); + try { + const result = await getRegionChildren(provinceId); + setCoverageRegionOptions(current => ({ + ...current, + cities: Array.isArray(result?.children) ? result.children : [], + })); + } catch (error) { + setCoverageAoiError(error.response?.data?.detail || error.message || '地市加载失败。'); + setCoverageRegionOptions(current => ({ ...current, cities: [] })); + } finally { + setCoverageRegionLoading(false); + } + }, []); + + useEffect(() => { + if (isReadOnlyUser || coverageAoiMode !== 'region' || coverageRegionLoaded || coverageRegionLoading) return; + void loadCoverageProvinces(); + }, [ + coverageAoiMode, + coverageRegionLoaded, + coverageRegionLoading, + isReadOnlyUser, + loadCoverageProvinces, + ]); + + const handleCoverageAoiModeChange = useCallback(async (mode) => { + setCoverageAoiMode(mode); + setCoverageAoiError(''); + setCoverageResult(null); + setCoverageAoiGeojson(null); + if (mode === 'region' && !coverageRegionLoaded) { + await loadCoverageProvinces(); + } + }, [coverageRegionLoaded, loadCoverageProvinces]); + + const handleCoverageProvinceChange = useCallback(async (value) => { + setCoverageRegionSelection({ province: value, city: '' }); + setCoverageAoiGeojson(null); + setCoverageResult(null); + if (value) await loadCoverageCities(value); + else setCoverageRegionOptions(current => ({ ...current, cities: [] })); + }, [loadCoverageCities]); + + const handleCoverageCityChange = useCallback((value) => { + setCoverageRegionSelection(current => ({ ...current, city: value })); + setCoverageAoiGeojson(null); + setCoverageResult(null); + }, []); + + const handleCoverageFilesChange = useCallback((files) => { + setCoverageAoiFiles(files); + setCoverageAoiGeojson(null); + setCoverageAoiError(''); + setCoverageResult(null); + }, []); + + const handleCoveragePlan = useCallback(async () => { + if (isReadOnlyUser || coveragePlanning) return; + const targetFrom = String(coverageForm.target_date_from || '').trim(); + const targetTo = String(coverageForm.target_date_to || '').trim(); + if (!/^\d{8}$/.test(targetFrom) || !/^\d{8}$/.test(targetTo)) { + setCoverageResult({ error: '请输入 YYYYMMDD 格式的目标起止日期。' }); + return; + } + setCoveragePlanning(true); + setCoverageResult(null); + setCoverageAoiError(''); + try { + const formData = new FormData(); + Object.entries(coverageForm).forEach(([key, value]) => { + const text = String(value ?? '').trim(); + if (!text) return; + if (key === 'allowed_satellites') { + const families = text.split(',').map(item => item.trim()).filter(Boolean); + if (families.length > 0) formData.append(key, JSON.stringify(families)); + return; + } + formData.append(key, text); + }); + formData.append('require_orbit_data', 'true'); + formData.append('require_same_imaging_mode', 'true'); + formData.append('require_same_polarization', 'true'); + formData.append('limit_footprint_center_distance', 'true'); + + let selectedAoiGeojson = null; + if (coverageAoiMode === 'file') { + const files = Array.from(coverageAoiFiles || []); + if (files.length === 0) { + throw new Error('请选择 AOI 文件。'); + } + files.forEach(file => formData.append('files', file)); + } else { + if (!selectedCoverageRegionTreeId) { + throw new Error('请选择行政区 AOI。'); + } + const result = await getRegionGeometry(selectedCoverageRegionTreeId); + selectedAoiGeojson = result?.aoi_geojson || null; + if (!selectedAoiGeojson) { + throw new Error('行政区边界为空,无法进行空间覆盖规划。'); + } + formData.append('aoi_geojson', JSON.stringify(selectedAoiGeojson)); + } + + const response = await buildCoveragePlan(formData); + const pairs = Array.isArray(response?.pairs) ? response.pairs : []; + const warnings = Array.isArray(response?.warnings) ? response.warnings : []; + const responseAoi = response?.aoi_geojson || selectedAoiGeojson || null; + setFoundPairs(pairs.map(pair => ({ ...pair, isSelected: true, isVis: false }))); + setPairingAlert({ warnings, fallbackUsed: Boolean(response?.fallback_used) }); + setCoverageAoiGeojson(responseAoi); + setCoverageResult({ + ...response, + aoi_geojson: responseAoi, + pairCount: pairs.length, + warningCount: warnings.length, + }); + } catch (error) { + const message = error.response?.data?.detail || error.message || '区域空间覆盖配对规划失败。'; + setCoverageAoiError(message); + setCoverageResult({ error: message }); + } finally { + setCoveragePlanning(false); + } + }, [ + coverageAoiFiles, + coverageAoiMode, + coverageForm, + coveragePlanning, + isReadOnlyUser, + selectedCoverageRegionTreeId, + setFoundPairs, + setPairingAlert, + ]); return (
@@ -139,16 +375,208 @@ export default function PairPlanningPanel({
+
+
区域空间覆盖配对规划
+

+ 以目标生产时段限定影像池,按标准 D-InSAR 约束生成候选对,并优先选择能增加 AOI 空间覆盖范围的配对;扩展天数用于补充边界时段的可用影像。 +

+
+ + + + + + + + + + +
+
+
+ 空间覆盖范围 + {coverageAoiSummary} +
+
+ + +
+ {coverageAoiMode === 'region' ? ( +
+ + +
+ ) : ( +
+ handleCoverageFilesChange(event.target.files)} + /> +
+ )} +
+ 覆盖目标:优先增加所选 AOI 的空间面覆盖;时间范围用于限定候选影像池。 +
+ {coverageAoiError && ( +
{coverageAoiError}
+ )} +
+
+ + {coverageAoiSummary} +
+ {coverageResult && ( +
+ {coverageResult.error ? ( + {coverageResult.error} + ) : ( + <> + + 已生成 {coverageResult.pairCount} 对,AOI 空间覆盖率 {Math.round(Number(coverageResult.coverage?.aoi_coverage_ratio ?? coverageResult.coverage?.coverage_ratio ?? 0) * 100)}% + + + 候选 {Number(coverageResult.candidate_count || 0)},网络 {coverageResult.network_run_id || '-'}, + 影像池范围 {coverageResult.coverage?.query_date_from || '-'} 至 {coverageResult.coverage?.query_date_to || '-'}。 + + + 时间覆盖率 {Math.round(Number(coverageResult.coverage?.temporal_coverage_ratio || 0) * 100)}%; + 任务上限 {coverageResult.coverage?.max_pairs || coverageForm.max_pairs}; + 停止原因:{coverageResult.coverage?.stop_reason || '-'}。 + + + 贪心规则:每一步选择新增 AOI 有效覆盖面积最大的候选对;达到目标覆盖率或新增收益低于阈值后停止。 + + {coverageResult.coverage?.uncovered_ranges?.length > 0 && ( + + 时间缺口:{coverageResult.coverage.uncovered_ranges.slice(0, 3).map(item => `${item.date_from}-${item.date_to}`).join(',')} + + )} +
+ +
+ + )} +
+ )} +
+
+ {foundPairs.length > 0 && ( +
+ 覆盖检视 + + {mapShowsEffectiveCoverage + ? `显示 ${previewEffectiveGeojson.features.length} 个有效覆盖面(master ∩ slave ∩ AOI)` + : `显示 ${previewPairs.length} 对主辅影像 footprint`} + + + +
+ )} previewPairs.length - ? `${previewPairs.length}/${foundPairs.length} 对` - : `${foundPairs.length} 对` - } - polygons={previewPolygons} - height={260} + subtitle={mapShowsEffectiveCoverage ? `${previewEffectiveGeojson.features.length} 个有效覆盖面` : `${previewPairs.length} 对`} + polygons={mapShowsEffectiveCoverage ? [] : previewPolygons} + geojson={mapShowsEffectiveCoverage ? previewEffectiveGeojson : (coverageAoiGeojson || coverageResult?.aoi_geojson)} + height={420} emptyText={en ? 'Run pair planning to preview pair footprints.' : '生成配对后显示候选范围。'} />
diff --git a/frontend/src/panels/PairsListPanel.jsx b/frontend/src/panels/PairsListPanel.jsx index c6bd7f6..26217af 100644 --- a/frontend/src/panels/PairsListPanel.jsx +++ b/frontend/src/panels/PairsListPanel.jsx @@ -112,14 +112,18 @@ function PairsListPanel({ ) : ( <>
- -
- 保存批次 ({selectedPairsCount}) + 保存选中配对为批次 ({selectedPairsCount}) diff --git a/frontend/src/utils/appUiHelpers.js b/frontend/src/utils/appUiHelpers.js index 0ca80a9..471fb5b 100644 --- a/frontend/src/utils/appUiHelpers.js +++ b/frontend/src/utils/appUiHelpers.js @@ -87,6 +87,8 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => { return '洪涝灾害分析'; case 'health': return '运行维护'; + case 'ops_tasks': + return '任务维护'; case 'users': return '用户管理'; case 'audit': @@ -120,6 +122,8 @@ export const getLeftTabDescription = (tabKey) => { return '围绕洪涝场景开展水体提取、过程分析和专题制图。'; case 'health': return '检查核心服务、数据目录、生产索引和运行环境,定位影响生产的阻断项。'; + case 'ops_tasks': + return '查看失败、部分成功、取消和残留运行的任务,按诊断预览清理数据库记录和生产目录。'; case 'users': return '维护系统用户、角色与访问权限。'; case 'audit':