Add LandSAR cluster worker deployment

This commit is contained in:
2026-06-24 14:10:58 +08:00
parent 71c524967c
commit da09ba05cb
88 changed files with 9300 additions and 3011 deletions
+16
View File
@@ -36,6 +36,7 @@ TRUSTED_PROXY_IPS=127.0.0.1
PORT=18000 PORT=18000
BACKEND_BIND_HOST=127.0.0.1 BACKEND_BIND_HOST=127.0.0.1
UVICORN_LOG_LEVEL=info UVICORN_LOG_LEVEL=info
DASHBOARD_STATS_CACHE_TTL_SECONDS=120
PYTHON_PATH=C:\ProgramData\anaconda3\envs\InSAR\python.exe PYTHON_PATH=C:\ProgramData\anaconda3\envs\InSAR\python.exe
CONDA_EXE= CONDA_EXE=
@@ -65,6 +66,10 @@ SBAS_TASK_POOL_ROOT=D:\Task_Pool\SBAS
DATA_DISTRIBUTION_ROOT=D:\Task_Pool\Data_Distribution DATA_DISTRIBUTION_ROOT=D:\Task_Pool\Data_Distribution
GF3_TASK_POOL_ROOT=D:\GaoFen3_Pool\task_pool GF3_TASK_POOL_ROOT=D:\GaoFen3_Pool\task_pool
SOURCE_PRODUCT_DIRS=D:\LuTan1_Image_Pool_Zip;D:\Sentinel1_Image_Pool_ZIP SOURCE_PRODUCT_DIRS=D:\LuTan1_Image_Pool_Zip;D:\Sentinel1_Image_Pool_ZIP
ASSET_SCAN_PARSE_WORKERS=4
ASSET_SCAN_PARSE_INFLIGHT=64
ASSET_SCAN_SKIP_UNCHANGED_FAILURES=true
ASSET_SCAN_DB_BATCH_SIZE=50
SENTINEL1_STORAGE_DIRS= SENTINEL1_STORAGE_DIRS=
INSAR_STORAGE_DIRS= INSAR_STORAGE_DIRS=
MONITOR_RADAR_DIRS= MONITOR_RADAR_DIRS=
@@ -333,6 +338,12 @@ JOB_WORKER_JOB_HEARTBEAT_INTERVAL=5
JOB_WORKER_STALE_RECOVER_INTERVAL=15 JOB_WORKER_STALE_RECOVER_INTERVAL=15
JOB_WORKER_STALE_RUNNING_SECONDS=7200 JOB_WORKER_STALE_RUNNING_SECONDS=7200
JOB_WORKER_HEARTBEAT_INTERVAL=5 JOB_WORKER_HEARTBEAT_INTERVAL=5
# Main server only. Comma/semicolon-separated IPv4 addresses or CIDR blocks allowed
# to run LandSAR cluster workers against this PostgreSQL server.
LANDSAR_CLUSTER_ALLOWED_WORKER_IPS=192.168.1.6
# Empty means the worker can claim all job types. Remote LandSAR nodes should set:
# JOB_WORKER_ALLOWED_TYPES=LANDSAR_CLUSTER_ITEM
JOB_WORKER_ALLOWED_TYPES=
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -394,6 +405,11 @@ DEFAULT_VLM_MODEL=qwen3-vl:30b
# Empty means same-origin /tiles, routed by the main nginx proxy. # Empty means same-origin /tiles, routed by the main nginx proxy.
VITE_TILE_SERVER_URL= VITE_TILE_SERVER_URL=
VITE_TILE_SERVER_TOKEN=change_me VITE_TILE_SERVER_TOKEN=change_me
VITE_APP_ORG_NAME=黑龙江省自然资源卫星应用技术中心
VITE_APP_SYSTEM_NAME=InSAR 自动化管理系统
VITE_APP_SYSTEM_TAGLINE=科研工程生产平台
# Optional override for deployed builds. Empty uses frontend/src/logo.jpg.
VITE_APP_LOGO_URL=
TILE_SERVER_AUTO_START=true TILE_SERVER_AUTO_START=true
TILE_SERVER_AUTO_STOP=true TILE_SERVER_AUTO_STOP=true
TILE_SERVER_ROOT=D:\Code\tile-server TILE_SERVER_ROOT=D:\Code\tile-server
+33
View File
@@ -0,0 +1,33 @@
# Product
## Register
product
## Users
黑龙江省自然资源卫星应用技术中心及同类科研工程单位的业务人员、科研人员、生产管理员和运维人员。用户在内网或受控生产环境中长期、高频使用系统,围绕 SAR/InSAR 数据接入、生产编排、结果管理、地图分析和运维自检完成可追溯的工程流程。
## Product Purpose
本系统用于管理 InSAR 自动化生产与分析流程,统一承载数据入库、D-InSAR/SBAS-InSAR 规划、生产运行、结果 catalog、地图分析和系统维护。成功标准是稳定、清晰、可审计:用户能准确判断当前数据、任务、结果和运行环境状态,并能以最少干扰完成生产操作。
## Brand Personality
科研、专业、克制;同时体现严谨、稳定、工程化。界面应像生产控制台,而不是营销展示页或通用 SaaS 后台。
## Anti-references
不做大面积装饰渐变、营销式 hero、过度圆角卡片、花哨动效、以英文为主的国际化界面、只靠红绿状态点表达系统健康的页眉。运行环境检查归入运行维护,不在全局页眉堆叠 DB、Worker、IDL、Ollama、Nginx 等状态灯。
## Design Principles
- 任务优先:界面结构服务于数据、任务、结果、运维等真实工作流。
- 中文清晰:操作、状态、错误和提示默认使用中文;InSAR、D-InSAR、SBAS、Gamma、LandSAR、IDL 等专业术语保留英文。
- 克制可信:用稳定的层级、对齐、密度和状态说明建立信任,不依赖装饰。
- 工程可审计:关键生产动作、只读权限、授权状态、任务状态和结果状态必须明确可见。
- 可分发部署:单位名、系统名、logo 等组织身份信息应可通过环境变量配置。
## Accessibility & Inclusion
默认遵循生产系统可读性要求:正文对比度充足,键盘焦点可见,动效克制并尊重 reduced motion,状态表达不只依赖颜色,中文文案短句明确。
+13 -1
View File
@@ -235,9 +235,13 @@ class Settings(BaseSettings):
RADAR_THUMBNAIL_MAX_SIZE: int = 1600 RADAR_THUMBNAIL_MAX_SIZE: int = 1600
RADAR_CACHE_WORKERS: int = 2 RADAR_CACHE_WORKERS: int = 2
RADAR_GEO_CACHE_WORKERS: int = 2 RADAR_GEO_CACHE_WORKERS: int = 2
RADAR_GEO_CACHE_VERSION: str = "b1" RADAR_GEO_CACHE_VERSION: str = "b2"
RADAR_GEO_CACHE_QUALITY: int = 84 RADAR_GEO_CACHE_QUALITY: int = 84
RADAR_PREVIEW_BUILD_ON_DEMAND: bool = True RADAR_PREVIEW_BUILD_ON_DEMAND: bool = True
ASSET_SCAN_PARSE_WORKERS: int = 4
ASSET_SCAN_PARSE_INFLIGHT: int = 64
ASSET_SCAN_SKIP_UNCHANGED_FAILURES: bool = True
ASSET_SCAN_DB_BATCH_SIZE: int = 50
WATER_RESULTS_DIR: str = "" WATER_RESULTS_DIR: str = ""
GF3_WATER_DEM_PATH: str = "" GF3_WATER_DEM_PATH: str = ""
@@ -417,6 +421,7 @@ class Settings(BaseSettings):
JOB_WORKER_STALE_RECOVER_INTERVAL: float = 15.0 JOB_WORKER_STALE_RECOVER_INTERVAL: float = 15.0
JOB_WORKER_STALE_RUNNING_SECONDS: int = 300 JOB_WORKER_STALE_RUNNING_SECONDS: int = 300
JOB_WORKER_HEARTBEAT_INTERVAL: float = 5.0 JOB_WORKER_HEARTBEAT_INTERVAL: float = 5.0
JOB_WORKER_ALLOWED_TYPES: str = ""
TIMESERIES_ENABLED: bool = False TIMESERIES_ENABLED: bool = False
TIMESERIES_WSL_DISTRO: str = "" TIMESERIES_WSL_DISTRO: str = ""
@@ -495,6 +500,13 @@ class Settings(BaseSettings):
) )
if not self.SRTM_DEM_DIR: if not self.SRTM_DEM_DIR:
object.__setattr__(self, "SRTM_DEM_DIR", os.path.join(backend_dir, "dem_data")) object.__setattr__(self, "SRTM_DEM_DIR", os.path.join(backend_dir, "dem_data"))
object.__setattr__(self, "ASSET_SCAN_PARSE_WORKERS", max(1, int(self.ASSET_SCAN_PARSE_WORKERS or 1)))
object.__setattr__(
self,
"ASSET_SCAN_PARSE_INFLIGHT",
max(self.ASSET_SCAN_PARSE_WORKERS, int(self.ASSET_SCAN_PARSE_INFLIGHT or self.ASSET_SCAN_PARSE_WORKERS)),
)
object.__setattr__(self, "ASSET_SCAN_DB_BATCH_SIZE", max(1, int(self.ASSET_SCAN_DB_BATCH_SIZE or 1)))
if not self.GF3_ARCHIVE_SOURCE_DIRS: if not self.GF3_ARCHIVE_SOURCE_DIRS:
object.__setattr__( object.__setattr__(
self, self,
+42 -10
View File
@@ -17,6 +17,7 @@ import hashlib
import json import json
import os import os
import subprocess import subprocess
import time
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@@ -32,6 +33,13 @@ _PUBLIC_KEY_B64 = "QOpR1c3bONDwOzrj3IVTogE1ZHIphpwxJY8nhWa09yw="
_APP_DIR = os.path.dirname(os.path.abspath(__file__)) _APP_DIR = os.path.dirname(os.path.abspath(__file__))
_BACKEND_DIR = os.path.dirname(_APP_DIR) _BACKEND_DIR = os.path.dirname(_APP_DIR)
LICENSE_PATH_DEFAULT = os.path.join(_BACKEND_DIR, "license", "license.lic") LICENSE_PATH_DEFAULT = os.path.join(_BACKEND_DIR, "license", "license.lic")
LICENSE_STATUS_CACHE_SECONDS = int(os.getenv("LICENSE_STATUS_CACHE_SECONDS", "30"))
_LICENSE_STATUS_CACHE: Dict[str, Any] = {
"path": None,
"mtime": None,
"checked_at": 0.0,
"payload": None,
}
def _now_utc() -> datetime: def _now_utc() -> datetime:
@@ -86,47 +94,71 @@ def _get_machine_fingerprint() -> str:
# ── 授权验证 ────────────────────────────────────────────────────────────────── # ── 授权验证 ──────────────────────────────────────────────────────────────────
def _license_result(result: Dict[str, Any], *, use_cache: bool, path: str, mtime: Optional[float]) -> Dict[str, Any]:
if use_cache:
_LICENSE_STATUS_CACHE.update({
"path": path,
"mtime": mtime,
"checked_at": time.monotonic(),
"payload": dict(result),
})
return result
def check_license(license_path: Optional[str] = None) -> Dict[str, Any]: def check_license(license_path: Optional[str] = None) -> Dict[str, Any]:
use_cache = license_path is None
license_path = license_path or settings.LICENSE_PATH or LICENSE_PATH_DEFAULT license_path = license_path or settings.LICENSE_PATH or LICENSE_PATH_DEFAULT
mtime = os.path.getmtime(license_path) if os.path.exists(license_path) else None
if use_cache:
cached = _LICENSE_STATUS_CACHE.get("payload")
cache_age = time.monotonic() - float(_LICENSE_STATUS_CACHE.get("checked_at") or 0.0)
if (
cached is not None
and _LICENSE_STATUS_CACHE.get("path") == license_path
and _LICENSE_STATUS_CACHE.get("mtime") == mtime
and cache_age <= LICENSE_STATUS_CACHE_SECONDS
):
return dict(cached)
if not os.path.exists(license_path): if not os.path.exists(license_path):
return {"ok": False, "reason": "未找到授权文件"} return _license_result({"ok": False, "reason": "未找到授权文件"}, use_cache=use_cache, path=license_path, mtime=mtime)
try: try:
blob = open(license_path, "rb").read().strip() blob = open(license_path, "rb").read().strip()
parts = blob.split(b"|", 2) parts = blob.split(b"|", 2)
if len(parts) != 3 or parts[0] != b"LIC2": if len(parts) != 3 or parts[0] != b"LIC2":
return {"ok": False, "reason": "授权文件格式无效"} return _license_result({"ok": False, "reason": "授权文件格式无效"}, use_cache=use_cache, path=license_path, mtime=mtime)
_, sig_b64, payload_b64 = parts _, sig_b64, payload_b64 = parts
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(_PUBLIC_KEY_B64)) pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(_PUBLIC_KEY_B64))
pub.verify(base64.b64decode(sig_b64), payload_b64) pub.verify(base64.b64decode(sig_b64), payload_b64)
payload = json.loads(base64.b64decode(payload_b64)) payload = json.loads(base64.b64decode(payload_b64))
except InvalidSignature: except InvalidSignature:
return {"ok": False, "reason": "授权文件签名无效(可能已被篡改)"} return _license_result({"ok": False, "reason": "授权文件签名无效(可能已被篡改)"}, use_cache=use_cache, path=license_path, mtime=mtime)
except Exception as e: except Exception as e:
return {"ok": False, "reason": f"授权文件解析失败: {e}"} return _license_result({"ok": False, "reason": f"授权文件解析失败: {e}"}, use_cache=use_cache, path=license_path, mtime=mtime)
fp_expected = payload.get("fingerprint") fp_expected = payload.get("fingerprint")
fp_actual = _get_machine_fingerprint() fp_actual = _get_machine_fingerprint()
if not fp_expected or fp_expected != fp_actual: if not fp_expected or fp_expected != fp_actual:
return {"ok": False, "reason": "机器指纹不匹配"} return _license_result({"ok": False, "reason": "机器指纹不匹配"}, use_cache=use_cache, path=license_path, mtime=mtime)
expires_at = payload.get("expires_at") expires_at = payload.get("expires_at")
if not expires_at: if not expires_at:
return {"ok": False, "reason": "授权文件缺少有效期"} return _license_result({"ok": False, "reason": "授权文件缺少有效期"}, use_cache=use_cache, path=license_path, mtime=mtime)
try: try:
expires_dt = datetime.fromisoformat(expires_at) expires_dt = datetime.fromisoformat(expires_at)
except Exception: except Exception:
return {"ok": False, "reason": "有效期格式错误"} return _license_result({"ok": False, "reason": "有效期格式错误"}, use_cache=use_cache, path=license_path, mtime=mtime)
if _now_utc() > expires_dt: if _now_utc() > expires_dt:
return {"ok": False, "reason": "授权已过期"} return _license_result({"ok": False, "reason": "授权已过期"}, use_cache=use_cache, path=license_path, mtime=mtime)
return { return _license_result({
"ok": True, "ok": True,
"issued_to": payload.get("issued_to"), "issued_to": payload.get("issued_to"),
"expires_at": expires_at, "expires_at": expires_at,
"fingerprint": fp_actual, "fingerprint": fp_actual,
"license_path": license_path, "license_path": license_path,
} }, use_cache=use_cache, path=license_path, mtime=mtime)
+11
View File
@@ -126,6 +126,17 @@ _STATS_CACHE_DATA: Optional[Dict[str, Any]] = None
_STATS_CACHE_EXPIRES_AT = 0.0 _STATS_CACHE_EXPIRES_AT = 0.0
_STATS_CACHE_GENERATED_AT_UTC: Optional[str] = None _STATS_CACHE_GENERATED_AT_UTC: Optional[str] = None
DASHBOARD_STATS_CACHE_TTL_SECONDS = read_int_env(
"DASHBOARD_STATS_CACHE_TTL_SECONDS",
STATS_CACHE_TTL_SECONDS,
minimum=0,
maximum=3600,
)
_DASHBOARD_STATS_CACHE_LOCK = asyncio.Lock()
_DASHBOARD_STATS_CACHE_DATA: Optional[Dict[str, Any]] = None
_DASHBOARD_STATS_CACHE_EXPIRES_AT = 0.0
_DASHBOARD_STATS_CACHE_GENERATED_AT_UTC: Optional[str] = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# AOI token store # AOI token store
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+114
View File
@@ -42,6 +42,12 @@ LANDSAR_PRODUCTION_JOB_MAX_ATTEMPTS = read_int_env(
minimum=1, minimum=1,
maximum=10, maximum=10,
) )
LANDSAR_CLUSTER_ITEM_JOB_MAX_ATTEMPTS = read_int_env(
"LANDSAR_CLUSTER_ITEM_JOB_MAX_ATTEMPTS",
1,
minimum=1,
maximum=10,
)
class RunJobRequest(BaseModel): class RunJobRequest(BaseModel):
@@ -441,6 +447,114 @@ async def submit_run(
} }
@router.post("/landsar-cluster/run")
async def submit_landsar_cluster_run(
req: RunJobRequest,
current_user: AuthUserORM = Depends(_get_current_user),
):
if str(req.engine_code or "").strip().lower() != "landsar":
raise HTTPException(status_code=400, detail="LandSAR cluster only accepts engine_code='landsar'.")
registry = _get_registry()
engine = registry.get_engine("landsar")
if not engine:
raise HTTPException(status_code=400, detail="Engine 'landsar' not found.")
valid_profiles = {profile.code for profile in engine.get_profiles()}
if req.profile not in valid_profiles:
raise HTTPException(
status_code=400,
detail=(
f"Engine 'landsar' does not support profile '{req.profile}'. "
f"Available profiles: {sorted(valid_profiles)}"
),
)
validation_summary = None
if hasattr(engine, "validate_root_dir"):
try:
validation_summary = await asyncio.to_thread(
engine.validate_root_dir,
req.root_dir,
req.num_to_process,
req.rerun_mode,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if int(validation_summary.get("task_count", 0) or 0) <= 0:
if (
req.rerun_mode == "unfinished_only"
and int(validation_summary.get("skipped_completed_count", 0) or 0) > 0
):
raise HTTPException(
status_code=400,
detail=(
f"All discovered Task_* directories already have completed "
f"landsar/{req.profile} results under: {req.root_dir}"
),
)
raise HTTPException(status_code=400, detail="No valid LandSAR task directories selected.")
normalized_extra = dict(req.extra or {})
if hasattr(engine, "normalize_extra"):
try:
normalized_extra = engine.normalize_extra(normalized_extra)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if validation_summary is not None:
validated_task_count = validation_summary.get("task_count", 0)
normalized_extra.update(
{
"__validated_task_count": validated_task_count,
"__validated_mode": validation_summary.get("mode", ""),
"__rerun_mode": req.rerun_mode,
"__discovered_task_count": int(validation_summary.get("discovered_task_count", validated_task_count) or 0),
"__skipped_completed_count": int(validation_summary.get("skipped_completed_count", 0) or 0),
"__cluster": True,
}
)
effective_timeout_seconds = req.timeout_seconds
if effective_timeout_seconds is None:
engine_default_timeout = getattr(engine, "default_timeout_seconds", None)
if engine_default_timeout:
effective_timeout_seconds = int(engine_default_timeout)
try:
async with _new_session() as db:
result = await dinsar_production_service.create_landsar_cluster_run(
profile_code=req.profile,
root_dir=req.root_dir,
num_to_process=req.num_to_process,
rerun_mode=req.rerun_mode,
timeout_seconds=effective_timeout_seconds,
extra=normalized_extra,
created_by=getattr(current_user, "username", None),
max_attempts=LANDSAR_CLUSTER_ITEM_JOB_MAX_ATTEMPTS,
db=db,
)
except ValueError as exc:
message = str(exc)
status_code = 409 if "浠诲姟鍐茬獊" in message else 400
raise HTTPException(status_code=status_code, detail=message) from exc
return {
"task_id": result["task_id"],
"job_id": None,
"run_id": result["run_id"],
"workflow_run_id": None,
"job_type": "LANDSAR_CLUSTER_ITEM",
"engine_code": "landsar",
"profile": req.profile,
"selected_task_count": result.get("selected_task_count", 0),
"discovered_task_count": result.get("discovered_task_count", result.get("selected_task_count", 0)),
"skipped_completed_count": result.get("skipped_completed_count", 0),
"rerun_mode": result.get("rerun_mode", req.rerun_mode),
"message": "LandSAR cluster items queued.",
}
@router.get("/runs") @router.get("/runs")
async def list_runs(limit: int = 20, offset: int = 0): async def list_runs(limit: int = 20, offset: int = 0):
async with _new_session() as db: async with _new_session() as db:
+3 -8
View File
@@ -318,10 +318,7 @@ async def _build_radar_preview_cache(
geo_error = "invalid_bbox" geo_error = "invalid_bbox"
has_geo_cache = False has_geo_cache = False
else: else:
source_corner_mapping = await asyncio.to_thread( source_corner_mapping = data_service.get_radar_record_corner_mapping(record)
data_service.get_radar_source_corner_mapping,
record.file_path,
)
ok_geo, geo_error = await asyncio.to_thread( ok_geo, geo_error = await asyncio.to_thread(
image_service.create_geocorrected_radar_cached_image, image_service.create_geocorrected_radar_cached_image,
preview_source, preview_source,
@@ -408,6 +405,7 @@ async def _get_cached_radar_preview(data_id: int, db: AsyncSession):
raw_cache_path, geo_cache_path = _radar_preview_paths(record) raw_cache_path, geo_cache_path = _radar_preview_paths(record)
if ( if (
(record.preview_cache_status or "NONE") == "READY" (record.preview_cache_status or "NONE") == "READY"
and (record.preview_cache_version or "") == settings.RADAR_GEO_CACHE_VERSION
and record.preview_cache_path and record.preview_cache_path
and str(record.preview_cache_path).lower().endswith(".webp") and str(record.preview_cache_path).lower().endswith(".webp")
and os.path.exists(record.preview_cache_path) and os.path.exists(record.preview_cache_path)
@@ -418,7 +416,7 @@ async def _get_cached_radar_preview(data_id: int, db: AsyncSession):
headers={"Cache-Control": "public, max-age=31536000"}, headers={"Cache-Control": "public, max-age=31536000"},
) )
if os.path.exists(geo_cache_path): if os.path.exists(geo_cache_path) and (record.preview_cache_version or "") == settings.RADAR_GEO_CACHE_VERSION:
return FileResponse( return FileResponse(
geo_cache_path, geo_cache_path,
media_type="image/webp", media_type="image/webp",
@@ -523,7 +521,6 @@ async def search_radar_data_endpoint(
product_unique_id: Optional[str] = Form(None), product_unique_id: Optional[str] = Form(None),
orbit_direction: Optional[str] = Form(None), orbit_direction: Optional[str] = Form(None),
has_orbit_data: Optional[bool] = Form(None), has_orbit_data: Optional[bool] = Form(None),
is_envi_processed: Optional[bool] = Form(None),
imaging_date_from: Optional[str] = Form(None), imaging_date_from: Optional[str] = Form(None),
imaging_date_to: Optional[str] = Form(None), imaging_date_to: Optional[str] = Form(None),
region_tree_id: Optional[str] = Form(None), region_tree_id: Optional[str] = Form(None),
@@ -609,8 +606,6 @@ async def search_radar_data_endpoint(
filters.append(RadarDataORM.orbit_direction.ilike(f"%{n_orbit_direction}%")) filters.append(RadarDataORM.orbit_direction.ilike(f"%{n_orbit_direction}%"))
if has_orbit_data is not None: if has_orbit_data is not None:
filters.append(RadarDataORM.has_orbit_data == has_orbit_data) filters.append(RadarDataORM.has_orbit_data == has_orbit_data)
if is_envi_processed is not None:
filters.append(RadarDataORM.is_envi_processed == is_envi_processed)
if n_date_from: if n_date_from:
filters.append(RadarDataORM.imaging_date >= n_date_from) filters.append(RadarDataORM.imaging_date >= n_date_from)
if n_date_to: if n_date_to:
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@ import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from shapely.geometry import Point, shape from shapely.geometry import MultiPolygon, Point, shape
from shapely.ops import unary_union from shapely.ops import unary_union
try: try:
@@ -36,6 +36,7 @@ class _RegionGeometryRecord:
_REGION_GEOMETRY_CACHE: list[_RegionGeometryRecord] | None = None _REGION_GEOMETRY_CACHE: list[_RegionGeometryRecord] | None = None
_REGION_BY_ID_LOOKUP_CACHE: dict[str, dict[str, Any]] | None = None _REGION_BY_ID_LOOKUP_CACHE: dict[str, dict[str, Any]] | None = None
_REGION_LOAD_ERROR: str | None = None _REGION_LOAD_ERROR: str | None = None
_POLYGONAL_GEOMETRY_TYPES = {"Polygon", "MultiPolygon"}
def _backend_geojson_dir() -> Path: def _backend_geojson_dir() -> Path:
@@ -190,6 +191,40 @@ def _build_region_path(tree_id: str, region_by_id: dict[str, dict[str, Any]]) ->
return names, tree_ids return names, tree_ids
def _as_polygonal_geometry(geometry):
if geometry is None or geometry.is_empty:
return None
if getattr(geometry, "geom_type", None) in _POLYGONAL_GEOMETRY_TYPES:
return geometry
polygon_parts = []
for part in getattr(geometry, "geoms", []) or []:
polygonal = _as_polygonal_geometry(part)
if polygonal is None or polygonal.is_empty:
continue
if getattr(polygonal, "geom_type", None) == "Polygon":
polygon_parts.append(polygonal)
else:
polygon_parts.extend([item for item in getattr(polygonal, "geoms", []) if not item.is_empty])
if not polygon_parts:
return None
if len(polygon_parts) == 1:
return polygon_parts[0]
try:
merged = unary_union(polygon_parts)
if merged is not None and not merged.is_empty:
if getattr(merged, "geom_type", None) in _POLYGONAL_GEOMETRY_TYPES:
return merged
return _as_polygonal_geometry(merged)
except Exception:
pass
try:
return MultiPolygon(polygon_parts)
except Exception:
return polygon_parts[0]
def _load_region_records() -> tuple[list[_RegionGeometryRecord], dict[str, dict[str, Any]], str | None]: def _load_region_records() -> tuple[list[_RegionGeometryRecord], dict[str, dict[str, Any]], str | None]:
global _REGION_BY_ID_LOOKUP_CACHE, _REGION_GEOMETRY_CACHE, _REGION_LOAD_ERROR global _REGION_BY_ID_LOOKUP_CACHE, _REGION_GEOMETRY_CACHE, _REGION_LOAD_ERROR
if _REGION_GEOMETRY_CACHE is not None: if _REGION_GEOMETRY_CACHE is not None:
@@ -228,6 +263,7 @@ def _load_region_records() -> tuple[list[_RegionGeometryRecord], dict[str, dict[
if not geometries: if not geometries:
continue continue
geometry = _merge_region_geometries(geometries) geometry = _merge_region_geometries(geometries)
geometry = _as_polygonal_geometry(geometry)
if geometry is None or geometry.is_empty: if geometry is None or geometry.is_empty:
continue continue
node = region_by_id.get(tree_id) or {} node = region_by_id.get(tree_id) or {}
+467 -162
View File
@@ -9,8 +9,10 @@ import re
import shutil import shutil
import tarfile import tarfile
import zipfile import zipfile
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import PurePosixPath from pathlib import PurePosixPath
from types import SimpleNamespace
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Sequence, Tuple from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
from geoalchemy2.shape import from_shape from geoalchemy2.shape import from_shape
@@ -35,6 +37,7 @@ from ..models import (
SourceProductAssetORM, SourceProductAssetORM,
) )
from ..utils import ( from ..utils import (
build_corner_pixel_mapping,
find_xml_file, find_xml_file,
normalize_satellite_family, normalize_satellite_family,
parse_gf3_l2_dirname, parse_gf3_l2_dirname,
@@ -55,6 +58,9 @@ LT1_ORBIT_MATCH_RULE_VERSION = "lt1_orbit_day_v1"
ASSET_SCAN_LOG_INTERVAL = 100 ASSET_SCAN_LOG_INTERVAL = 100
ASSET_SCAN_DETAILED_PARSE_LOG_LIMIT = 200 ASSET_SCAN_DETAILED_PARSE_LOG_LIMIT = 200
ARCHIVE_INTEGRITY_LOG_INTERVAL = 10 ARCHIVE_INTEGRITY_LOG_INTERVAL = 10
DEFAULT_ASSET_SCAN_PARSE_WORKERS = 4
DEFAULT_ASSET_SCAN_PARSE_INFLIGHT = 64
DEFAULT_ASSET_SCAN_DB_BATCH_SIZE = 50
_WINDOWS_DRIVE_RE = re.compile(r"^[a-zA-Z]:[\\/]") _WINDOWS_DRIVE_RE = re.compile(r"^[a-zA-Z]:[\\/]")
_S1_SOURCE_RE = re.compile( _S1_SOURCE_RE = re.compile(
@@ -458,32 +464,60 @@ def _archive_read_first_matching(path: str, predicate: Callable[[str], bool]) ->
return None, None, members return None, None, members
def _archive_list_matching(path: str, predicate: Callable[[str], bool], *, limit: int = 20) -> List[str]: def _archive_collect_members(
matches: List[str] = [] path: str,
*,
content_predicate: Callable[[str], bool],
list_predicate: Callable[[str], bool],
list_limit: int = 20,
) -> Tuple[Optional[str], Optional[bytes], List[str], List[str]]:
content_name: Optional[str] = None
content_data: Optional[bytes] = None
listed: List[str] = []
scanned: List[str] = []
target_list_count = max(0, int(list_limit))
def _visit(name: str) -> None: def _visit(name: str, reader: Callable[[], bytes]) -> None:
if predicate(name): nonlocal content_name, content_data
matches.append(name) scanned.append(name)
if list_predicate(name) and len(listed) < target_list_count:
listed.append(name)
if content_name is None and content_predicate(name):
content_name = name
content_data = reader()
def _done() -> bool:
return content_name is not None and len(listed) >= target_list_count
if zipfile.is_zipfile(path): if zipfile.is_zipfile(path):
with zipfile.ZipFile(path) as archive: with zipfile.ZipFile(path) as archive:
for info in archive.infolist(): for info in archive.infolist():
if info.is_dir(): if info.is_dir():
continue continue
_visit(info.filename) _visit(info.filename, lambda info=info: archive.read(info))
if len(matches) >= limit: if _done():
break break
return matches return content_name, content_data, listed, scanned
if tarfile.is_tarfile(path): if tarfile.is_tarfile(path):
with tarfile.open(path, "r:*") as archive: with tarfile.open(path, "r:*") as archive:
for member in archive: for member in archive:
if not member.isfile(): if not member.isfile():
continue continue
_visit(member.name)
if len(matches) >= limit: def _read(member=member) -> bytes:
source = archive.extractfile(member)
if source is None:
return b""
with source:
return source.read()
_visit(member.name, _read)
if _done():
break break
return matches return content_name, content_data, listed, scanned
return None, None, listed, scanned
def _safe_archive_member_name(member_name: str, archive_path: str) -> str: def _safe_archive_member_name(member_name: str, archive_path: str) -> str:
@@ -783,8 +817,19 @@ def _parse_radar_xml_metadata_bytes(data: bytes) -> Tuple[Optional[List[Tuple[fl
) )
coverage_polygon: Optional[List[Tuple[float, float]]] = None coverage_polygon: Optional[List[Tuple[float, float]]] = None
corner_details: Dict[str, Dict[str, Any]] = {}
if len(corners) >= 4: if len(corners) >= 4:
coverage_polygon = _ordered_closed_polygon_from_corners(corners[:4]) coverage_polygon = _ordered_closed_polygon_from_corners(corners[:4])
corner_details = {
str(item.get("name")): {
"lon": item.get("lon"),
"lat": item.get("lat"),
"ref_row": item.get("ref_row"),
"ref_col": item.get("ref_col"),
}
for item in corners
if item.get("name")
}
start_time = ( start_time = (
_xml_text_under_local_path(root, "start", "timeUTC") _xml_text_under_local_path(root, "start", "timeUTC")
@@ -834,6 +879,7 @@ def _parse_radar_xml_metadata_bytes(data: bytes) -> Tuple[Optional[List[Tuple[fl
for item in corners for item in corners
if item.get("name") if item.get("name")
}, },
"corner_pixel_mapping": build_corner_pixel_mapping(corner_details),
"coverage_polygon": coverage_polygon, "coverage_polygon": coverage_polygon,
} }
return coverage_polygon, {key: value for key, value in metadata.items() if value not in (None, "", [])} return coverage_polygon, {key: value for key, value in metadata.items() if value not in (None, "", [])}
@@ -890,6 +936,58 @@ def _metadata_document(
} }
def _s1_annotation_sort_key(path: str) -> Tuple[int, str]:
normalized = str(path or "").replace("\\", "/")
lower = normalized.lower()
base = PurePosixPath(normalized).name.lower()
is_direct_annotation = "/annotation/" in lower and lower.count("/annotation/") == 1
is_measurement_annotation = is_direct_annotation and base.startswith("s1") and base.endswith(".xml")
if is_measurement_annotation:
rank = 0
elif "/annotation/calibration/" in lower:
rank = 2
elif "/annotation/noise/" in lower:
rank = 3
elif "/annotation/rfi/" in lower:
rank = 4
else:
rank = 1
return rank, lower
def _parse_s1_preview_kml_bytes(data: bytes) -> Dict[str, Any]:
try:
root = etree.fromstring(data, parser=_xml_parser())
except Exception:
return {}
coordinate_texts = root.xpath("//*[local-name()='LatLonQuad']/*[local-name()='coordinates']/text()")
if not coordinate_texts:
return {}
points: List[Tuple[float, float]] = []
for token in str(coordinate_texts[0] or "").strip().split():
parts = token.split(",")
if len(parts) < 2:
continue
try:
points.append((float(parts[0]), float(parts[1])))
except ValueError:
continue
if len(points) != 4:
return {}
mapping = {
"bottom_left": [points[0][0], points[0][1]],
"bottom_right": [points[1][0], points[1][1]],
"top_right": [points[2][0], points[2][1]],
"top_left": [points[3][0], points[3][1]],
"source": "s1_preview_map_overlay_kml",
}
polygon = [points[0], points[1], points[2], points[3], points[0]]
return {
"preview_map_overlay_polygon": polygon,
"corner_pixel_mapping": mapping,
}
def _extract_s1_annotation_documents(source_path: str, *, limit: int = 16) -> List[Dict[str, Any]]: def _extract_s1_annotation_documents(source_path: str, *, limit: int = 16) -> List[Dict[str, Any]]:
docs: List[Dict[str, Any]] = [] docs: List[Dict[str, Any]] = []
stat = _stat_path(source_path) stat = _stat_path(source_path)
@@ -902,7 +1000,7 @@ def _extract_s1_annotation_documents(source_path: str, *, limit: int = 16) -> Li
for file_name in files: for file_name in files:
if file_name.lower().endswith(".xml"): if file_name.lower().endswith(".xml"):
candidates.append(os.path.join(current_root, file_name)) candidates.append(os.path.join(current_root, file_name))
for path in sorted(candidates)[: max(0, limit)]: for path in sorted(candidates, key=_s1_annotation_sort_key)[: max(0, limit)]:
try: try:
with open(path, "rb") as stream: with open(path, "rb") as stream:
data = stream.read() data = stream.read()
@@ -928,7 +1026,7 @@ def _extract_s1_annotation_documents(source_path: str, *, limit: int = 16) -> Li
for name in archive.namelist() for name in archive.namelist()
if "/annotation/" in name.lower() and name.lower().endswith(".xml") if "/annotation/" in name.lower() and name.lower().endswith(".xml")
] ]
for name in sorted(names)[: max(0, limit)]: for name in sorted(names, key=_s1_annotation_sort_key)[: max(0, limit)]:
docs.append( docs.append(
_metadata_document( _metadata_document(
document_type="S1_ANNOTATION", document_type="S1_ANNOTATION",
@@ -1099,13 +1197,48 @@ def _parse_s1_manifest_bytes(data: bytes) -> Dict[str, Any]:
def _parse_s1_zip_manifest(path: str) -> Dict[str, Any]: def _parse_s1_zip_manifest(path: str) -> Dict[str, Any]:
stat = _stat_path(path) stat = _stat_path(path)
with zipfile.ZipFile(path) as archive: with zipfile.ZipFile(path) as archive:
manifest_name = next( manifest_name = None
(name for name in archive.namelist() if name.lower().endswith("/manifest.safe") or name.lower() == "manifest.safe"), preview_kml_name = None
None, annotation_names: List[str] = []
) for info in archive.infolist():
if info.is_dir():
continue
name = info.filename
lower = name.lower()
if manifest_name is None and (lower.endswith("/manifest.safe") or lower == "manifest.safe"):
manifest_name = name
if preview_kml_name is None and lower.endswith("/preview/map-overlay.kml"):
preview_kml_name = name
if "/annotation/" in lower and lower.endswith(".xml"):
annotation_names.append(name)
if not manifest_name: if not manifest_name:
return {"manifest_parse_status": "MISSING"} return {"manifest_parse_status": "MISSING"}
manifest_bytes = archive.read(manifest_name) manifest_bytes = archive.read(manifest_name)
annotation_documents = [
_metadata_document(
document_type="S1_ANNOTATION",
member_path=name,
content=archive.read(name),
source_format="S1_ZIP",
satellite_family="S1",
archive_path=path,
archive_mtime=stat.get("mtime_epoch"),
)
for name in sorted(annotation_names, key=_s1_annotation_sort_key)[:16]
]
preview_kml_bytes = archive.read(preview_kml_name) if preview_kml_name else None
preview_kml_meta = _parse_s1_preview_kml_bytes(preview_kml_bytes) if preview_kml_bytes else {}
preview_kml_documents = [
_metadata_document(
document_type="S1_PREVIEW_KML",
member_path=preview_kml_name or "preview/map-overlay.kml",
content=preview_kml_bytes,
source_format="S1_ZIP",
satellite_family="S1",
archive_path=path,
archive_mtime=stat.get("mtime_epoch"),
)
] if preview_kml_bytes else []
return { return {
"manifest_parse_status": "OK", "manifest_parse_status": "OK",
"manifest_path": manifest_name, "manifest_path": manifest_name,
@@ -1119,8 +1252,10 @@ def _parse_s1_zip_manifest(path: str) -> Dict[str, Any]:
archive_path=path, archive_path=path,
archive_mtime=stat.get("mtime_epoch"), archive_mtime=stat.get("mtime_epoch"),
), ),
*_extract_s1_annotation_documents(path), *preview_kml_documents,
*annotation_documents,
], ],
**preview_kml_meta,
**_parse_s1_manifest_bytes(manifest_bytes), **_parse_s1_manifest_bytes(manifest_bytes),
} }
@@ -1132,6 +1267,23 @@ def _parse_s1_safe_manifest(path: str) -> Dict[str, Any]:
stat = _stat_path(path) stat = _stat_path(path)
with open(manifest_path, "rb") as stream: with open(manifest_path, "rb") as stream:
manifest_bytes = stream.read() manifest_bytes = stream.read()
preview_kml_path = os.path.join(path, "preview", "map-overlay.kml")
preview_kml_bytes = None
if os.path.isfile(preview_kml_path):
with open(preview_kml_path, "rb") as preview_stream:
preview_kml_bytes = preview_stream.read()
preview_kml_meta = _parse_s1_preview_kml_bytes(preview_kml_bytes) if preview_kml_bytes else {}
preview_kml_documents = [
_metadata_document(
document_type="S1_PREVIEW_KML",
member_path="preview/map-overlay.kml",
content=preview_kml_bytes,
source_format="S1_SAFE_DIR",
satellite_family="S1",
archive_path=path,
archive_mtime=stat.get("mtime_epoch"),
)
] if preview_kml_bytes else []
return { return {
"manifest_parse_status": "OK", "manifest_parse_status": "OK",
"manifest_path": manifest_path, "manifest_path": manifest_path,
@@ -1145,8 +1297,10 @@ def _parse_s1_safe_manifest(path: str) -> Dict[str, Any]:
archive_path=path, archive_path=path,
archive_mtime=stat.get("mtime_epoch"), archive_mtime=stat.get("mtime_epoch"),
), ),
*preview_kml_documents,
*_extract_s1_annotation_documents(path), *_extract_s1_annotation_documents(path),
], ],
**preview_kml_meta,
**_parse_s1_manifest_bytes(manifest_bytes), **_parse_s1_manifest_bytes(manifest_bytes),
} }
@@ -1154,14 +1308,11 @@ def _parse_s1_safe_manifest(path: str) -> Dict[str, Any]:
def _parse_lt1_archive_metadata(path: str) -> Dict[str, Any]: def _parse_lt1_archive_metadata(path: str) -> Dict[str, Any]:
archive_stem = _strip_known_suffix(os.path.basename(path)) archive_stem = _strip_known_suffix(os.path.basename(path))
stat = _stat_path(path) stat = _stat_path(path)
xml_member, xml_data, members = _archive_read_first_matching( xml_member, xml_data, tiff_members, members = _archive_collect_members(
path, path,
lambda name: _archive_member_base_name(name).lower().endswith(".meta.xml"), content_predicate=lambda name: _archive_member_base_name(name).lower().endswith(".meta.xml"),
) list_predicate=lambda name: _archive_member_base_name(name).lower().endswith((".tiff", ".tif")),
tiff_members = _archive_list_matching( list_limit=8,
path,
lambda name: _archive_member_base_name(name).lower().endswith((".tiff", ".tif")),
limit=8,
) )
if not xml_member or not xml_data: if not xml_member or not xml_data:
return { return {
@@ -1193,14 +1344,11 @@ def _parse_lt1_archive_metadata(path: str) -> Dict[str, Any]:
def _parse_gf3_archive_metadata(path: str) -> Dict[str, Any]: def _parse_gf3_archive_metadata(path: str) -> Dict[str, Any]:
archive_stem = _strip_known_suffix(os.path.basename(path)) archive_stem = _strip_known_suffix(os.path.basename(path))
xml_member, xml_data, members = _archive_read_first_matching( xml_member, xml_data, quicklooks, members = _archive_collect_members(
path, path,
lambda name: _archive_member_base_name(name).lower().endswith(".xml"), content_predicate=lambda name: _archive_member_base_name(name).lower().endswith(".xml"),
) list_predicate=lambda name: _archive_member_base_name(name).lower().endswith((".jpg", ".jpeg", ".png", ".bmp", "_ql.tif", "_ql.tiff")),
quicklooks = _archive_list_matching( list_limit=8,
path,
lambda name: _archive_member_base_name(name).lower().endswith((".jpg", ".jpeg", ".png", ".bmp", "_ql.tif", "_ql.tiff")),
limit=8,
) )
if not xml_member or not xml_data: if not xml_member or not xml_data:
return { return {
@@ -1782,6 +1930,8 @@ def _cached_source_asset_is_unchanged(
cached: Optional[Dict[str, Any]], cached: Optional[Dict[str, Any]],
stat: Dict[str, Optional[float]], stat: Dict[str, Optional[float]],
root: ManagedRootORM, root: ManagedRootORM,
*,
skip_unchanged_failures: bool = True,
) -> bool: ) -> bool:
if not cached: if not cached:
return False return False
@@ -1797,7 +1947,11 @@ def _cached_source_asset_is_unchanged(
return False return False
if str(cached.get("parser_version") or "") != PARSER_VERSION: if str(cached.get("parser_version") or "") != PARSER_VERSION:
return False return False
if str(cached.get("parse_status") or "").upper() != "OK": parse_status = str(cached.get("parse_status") or "").upper()
allowed_statuses = {"OK"}
if skip_unchanged_failures:
allowed_statuses.update({"PARTIAL", "FAILED"})
if parse_status not in allowed_statuses:
return False return False
return _same_size(cached.get("size_bytes"), stat.get("size_bytes")) and _same_mtime( return _same_size(cached.get("size_bytes"), stat.get("size_bytes")) and _same_mtime(
cached.get("mtime_epoch"), cached.get("mtime_epoch"),
@@ -1840,14 +1994,27 @@ def _collect_source_assets_incremental(
log_callback: Optional[Callable[[str, str], None]] = None, log_callback: Optional[Callable[[str, str], None]] = None,
progress_start: int = 0, progress_start: int = 0,
progress_end: int = 100, progress_end: int = 100,
parse_workers: int = DEFAULT_ASSET_SCAN_PARSE_WORKERS,
parse_inflight: int = DEFAULT_ASSET_SCAN_PARSE_INFLIGHT,
skip_unchanged_failures: bool = True,
row_batch_callback: Optional[Callable[[List[Dict[str, Any]]], None]] = None,
row_batch_size: int = DEFAULT_ASSET_SCAN_DB_BATCH_SIZE,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], int, int, List[str]]: ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], int, int, List[str]]:
rows: List[Dict[str, Any]] = [] rows: List[Dict[str, Any]] = []
pending_rows: List[Dict[str, Any]] = []
issues: List[Dict[str, Any]] = [] issues: List[Dict[str, Any]] = []
seen_paths: List[str] = [] seen_paths: List[str] = []
seen_path_set: set[str] = set()
entry_count = 0 entry_count = 0
skipped_unchanged = 0 skipped_unchanged = 0
skipped_unchanged_ok = 0
skipped_unchanged_failed = 0
parse_attempts = 0 parse_attempts = 0
parse_completed = 0
last_progress_count = 0 last_progress_count = 0
parse_workers = max(1, int(parse_workers or 1))
parse_inflight = max(parse_workers, int(parse_inflight or parse_workers))
row_batch_size = max(1, int(row_batch_size or 1))
def _log(level: str, message: str) -> None: def _log(level: str, message: str) -> None:
if log_callback: if log_callback:
@@ -1857,39 +2024,37 @@ def _collect_source_assets_incremental(
if progress_callback: if progress_callback:
progress_callback(_activity_progress(progress_start, progress_end, entry_count), message) progress_callback(_activity_progress(progress_start, progress_end, entry_count), message)
_log("INFO", f"Source root discovery started: {root.path}") def _mark_seen(path: str) -> None:
for path in _iter_source_candidates(root.path): normalized = _normalize_path(path)
entry_count += 1 if normalized and normalized not in seen_path_set:
normalized_path = _normalize_path(path) seen_path_set.add(normalized)
stat = _stat_path(normalized_path) seen_paths.append(normalized)
if _cached_source_asset_is_unchanged(existing_by_path.get(normalized_path), stat, root):
seen_paths.append(normalized_path) def _emit_row_batch(*, force: bool = False) -> None:
skipped_unchanged += 1 if not row_batch_callback or not pending_rows:
if skipped_unchanged % ASSET_SCAN_LOG_INTERVAL == 0: return
_log( if not force and len(pending_rows) < row_batch_size:
"INFO", return
f"Skipped unchanged source archives: {skipped_unchanged} (candidates={entry_count})", batch = pending_rows[:]
) pending_rows.clear()
if entry_count - last_progress_count >= ASSET_SCAN_LOG_INTERVAL: row_batch_callback(batch)
_progress(
"Scanning source archives: " def _parse_one(index: int, normalized_path: str) -> Dict[str, Any]:
f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, issues={len(issues)}"
)
last_progress_count = entry_count
continue
parse_attempts += 1
file_name = os.path.basename(normalized_path) file_name = os.path.basename(normalized_path)
if parse_attempts <= ASSET_SCAN_DETAILED_PARSE_LOG_LIMIT or parse_attempts % ASSET_SCAN_LOG_INTERVAL == 0:
_log("INFO", f"Extracting source archive metadata {parse_attempts}: {file_name}")
if parse_attempts <= 50 or parse_attempts % 25 == 0:
_progress(
"Extracting source archive metadata: "
f"{file_name} (changed/new={parse_attempts}, skipped={skipped_unchanged})"
)
try: try:
row = _parse_source_entry(normalized_path, root) row = _parse_source_entry(normalized_path, parse_root)
return {"index": index, "path": normalized_path, "file_name": file_name, "row": row, "error": None}
except Exception as exc: except Exception as exc:
row = None return {"index": index, "path": normalized_path, "file_name": file_name, "row": None, "error": exc}
def _handle_parse_result(result: Dict[str, Any]) -> None:
nonlocal parse_completed, last_progress_count
parse_completed += 1
normalized_path = str(result.get("path") or "")
file_name = str(result.get("file_name") or os.path.basename(normalized_path))
exc = result.get("error")
if exc is not None:
_mark_seen(normalized_path)
_log("WARNING", f"Source archive metadata parse failed: {file_name}: {exc}") _log("WARNING", f"Source archive metadata parse failed: {file_name}: {exc}")
issues.append( issues.append(
{ {
@@ -1899,10 +2064,14 @@ def _collect_source_assets_incremental(
"source_path": normalized_path, "source_path": normalized_path,
} }
) )
return
row = result.get("row")
if row is None: if row is None:
continue _mark_seen(normalized_path)
return
rows.append(row) rows.append(row)
seen_paths.append(str(row["file_path"])) pending_rows.append(row)
_mark_seen(str(row["file_path"]))
if row.get("parse_status") in {"FAILED", "PARTIAL"}: if row.get("parse_status") in {"FAILED", "PARTIAL"}:
_log( _log(
"WARNING", "WARNING",
@@ -1920,17 +2089,91 @@ def _collect_source_assets_incremental(
if entry_count - last_progress_count >= ASSET_SCAN_LOG_INTERVAL: if entry_count - last_progress_count >= ASSET_SCAN_LOG_INTERVAL:
_progress( _progress(
"Scanning source archives: " "Scanning source archives: "
f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, issues={len(issues)}" f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, "
f"completed={parse_completed}/{parse_attempts}, issues={len(issues)}"
) )
last_progress_count = entry_count last_progress_count = entry_count
_emit_row_batch()
def _drain_completed(pending: set, *, wait_for_one: bool = False) -> set:
if not pending:
return pending
timeout = None if wait_for_one else 0
done, remaining = wait(pending, timeout=timeout, return_when=FIRST_COMPLETED)
for future in done:
_handle_parse_result(future.result())
return remaining
_log(
"INFO",
"Source root discovery started: "
f"{root.path} (workers={parse_workers}, inflight={parse_inflight}, "
f"db_batch_size={row_batch_size}, skip_unchanged_failures={skip_unchanged_failures})",
)
parse_root = SimpleNamespace(id=root.id, path=root.path)
with ThreadPoolExecutor(max_workers=parse_workers, thread_name_prefix="asset-parse") as executor:
pending = set()
for path in _iter_source_candidates(root.path):
entry_count += 1
normalized_path = _normalize_path(path)
stat = _stat_path(normalized_path)
cached = existing_by_path.get(normalized_path)
if _cached_source_asset_is_unchanged(
cached,
stat,
root,
skip_unchanged_failures=skip_unchanged_failures,
):
_mark_seen(normalized_path)
skipped_unchanged += 1
cached_status = str((cached or {}).get("parse_status") or "").upper()
if cached_status == "OK":
skipped_unchanged_ok += 1
else:
skipped_unchanged_failed += 1
if skipped_unchanged % ASSET_SCAN_LOG_INTERVAL == 0:
_log(
"INFO",
"Skipped unchanged source archives: "
f"{skipped_unchanged} (ok={skipped_unchanged_ok}, failed_cached={skipped_unchanged_failed}, "
f"candidates={entry_count})",
)
if entry_count - last_progress_count >= ASSET_SCAN_LOG_INTERVAL:
_progress(
"Scanning source archives: "
f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, "
f"completed={parse_completed}/{parse_attempts}, issues={len(issues)}"
)
last_progress_count = entry_count
continue
parse_attempts += 1
file_name = os.path.basename(normalized_path)
if parse_attempts <= ASSET_SCAN_DETAILED_PARSE_LOG_LIMIT or parse_attempts % ASSET_SCAN_LOG_INTERVAL == 0:
_log("INFO", f"Extracting source archive metadata {parse_attempts}: {file_name}")
if parse_attempts <= 50 or parse_attempts % 25 == 0:
_progress(
"Extracting source archive metadata: "
f"{file_name} (changed/new={parse_attempts}, completed={parse_completed}, "
f"workers={parse_workers}, skipped={skipped_unchanged}, issue={len(issues)})"
)
pending.add(executor.submit(_parse_one, parse_attempts, normalized_path))
while len(pending) >= parse_inflight:
pending = _drain_completed(pending, wait_for_one=True)
pending = _drain_completed(pending, wait_for_one=False)
while pending:
pending = _drain_completed(pending, wait_for_one=True)
_emit_row_batch(force=True)
_log( _log(
"INFO", "INFO",
"Source root discovery finished: " "Source root discovery finished: "
f"candidates={entry_count}, skipped={skipped_unchanged}, changed_or_new={len(rows)}, issues={len(issues)}", f"candidates={entry_count}, skipped={skipped_unchanged}, "
f"skipped_ok={skipped_unchanged_ok}, skipped_failed_cached={skipped_unchanged_failed}, "
f"changed_or_new={len(rows)}, parse_attempts={parse_attempts}, issues={len(issues)}",
) )
_progress( _progress(
"Source archive discovery finished: " "Source archive discovery finished: "
f"candidates={entry_count}, skipped={skipped_unchanged}, changed_or_new={len(rows)}" f"candidates={entry_count}, skipped={skipped_unchanged}, changed_or_new={len(rows)}, "
f"workers={parse_workers}"
) )
return rows, issues, entry_count, skipped_unchanged, seen_paths return rows, issues, entry_count, skipped_unchanged, seen_paths
@@ -2152,6 +2395,8 @@ class AssetInventoryService:
task_id: Optional[str] = None, task_id: Optional[str] = None,
progress_start: int = 84, progress_start: int = 84,
progress_end: int = 96, progress_end: int = 96,
progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
progress_interval: int = 1,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
generated_session = db is None generated_session = db is None
if generated_session: if generated_session:
@@ -2218,6 +2463,16 @@ class AssetInventoryService:
if limit and limit > 0: if limit and limit > 0:
candidates = candidates[: int(limit)] candidates = candidates[: int(limit)]
summary["candidate_count"] = len(candidates) summary["candidate_count"] = len(candidates)
if progress_callback:
progress_callback(
{
"event": "planned",
"records_seen": summary["records_seen"],
"candidate_count": summary["candidate_count"],
"skipped_ready": summary["skipped_ready"],
"families": target_families,
}
)
if not apply: if not apply:
return summary return summary
if not candidates: if not candidates:
@@ -2226,6 +2481,8 @@ class AssetInventoryService:
f"Archive preview cache already ready: skipped={summary['skipped_ready']}", f"Archive preview cache already ready: skipped={summary['skipped_ready']}",
progress_end, progress_end,
) )
if progress_callback:
progress_callback({"event": "completed", **summary})
return summary return summary
await self._progress( await self._progress(
@@ -2235,6 +2492,7 @@ class AssetInventoryService:
) )
thumb_size = (settings.RADAR_THUMBNAIL_MAX_SIZE, settings.RADAR_THUMBNAIL_MAX_SIZE) thumb_size = (settings.RADAR_THUMBNAIL_MAX_SIZE, settings.RADAR_THUMBNAIL_MAX_SIZE)
total = len(candidates) total = len(candidates)
progress_interval = max(1, int(progress_interval or 1))
for index, record in enumerate(candidates, start=1): for index, record in enumerate(candidates, start=1):
unique_id = record.unique_id or record.file_path unique_id = record.unique_id or record.file_path
raw_cache_path = DataService.get_radar_raw_cache_path(unique_id, record.file_path) raw_cache_path = DataService.get_radar_raw_cache_path(unique_id, record.file_path)
@@ -2277,10 +2535,7 @@ class AssetInventoryService:
await task_service.add_log(task_id, "ERROR", f"Preview geometry invalid: {product_name}: {record.preview_cache_error}") await task_service.add_log(task_id, "ERROR", f"Preview geometry invalid: {product_name}: {record.preview_cache_error}")
db.add(record) db.add(record)
else: else:
source_corner_mapping = await asyncio.to_thread( source_corner_mapping = DataService.get_radar_record_corner_mapping(record)
DataService.get_radar_source_corner_mapping,
record.file_path,
)
ok_geo, geo_error = await asyncio.to_thread( ok_geo, geo_error = await asyncio.to_thread(
image_service.create_geocorrected_radar_cached_image, image_service.create_geocorrected_radar_cached_image,
preview_source, preview_source,
@@ -2327,6 +2582,31 @@ class AssetInventoryService:
f"Building archive preview cache ({index}/{total}): ready={summary['ready']}, failed={summary['failed']}, missing={summary['missing_source']}", f"Building archive preview cache ({index}/{total}): ready={summary['ready']}, failed={summary['failed']}, missing={summary['missing_source']}",
min(progress_end, progress), min(progress_end, progress),
) )
if progress_callback and (
index == 1
or index == total
or index % progress_interval == 0
or (record.preview_cache_status or "").upper() == "FAILED"
):
progress_callback(
{
"event": "item",
"processed": index,
"total": total,
"records_seen": summary["records_seen"],
"candidate_count": summary["candidate_count"],
"skipped_ready": summary["skipped_ready"],
"ready": summary["ready"],
"cached": summary["cached"],
"failed": summary["failed"],
"missing_source": summary["missing_source"],
"raw_cached": summary["raw_cached"],
"raw_failed": summary["raw_failed"],
"product_name": product_name,
"status": record.preview_cache_status,
"error": record.preview_cache_error,
}
)
await db.commit() await db.commit()
await self._progress( await self._progress(
@@ -2338,6 +2618,8 @@ class AssetInventoryService:
), ),
progress_end, progress_end,
) )
if progress_callback:
progress_callback({"event": "completed", **summary})
return summary return summary
except Exception: except Exception:
if db is not None: if db is not None:
@@ -2748,6 +3030,109 @@ class AssetInventoryService:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
progress_callback, log_callback, drain_thread_events = self._thread_callbacks(task_id, loop) progress_callback, log_callback, drain_thread_events = self._thread_callbacks(task_id, loop)
batch_write_start = max(progress_start, progress_end - 10)
batch_write_end = max(batch_write_start, progress_end - 3)
persisted_changed_count = 0
async def _upsert_source_asset_batch(rows_batch: Sequence[Dict[str, Any]], *, batch_index: int) -> int:
if not rows_batch:
return 0
now = _utcnow()
db_rows = [
{key: value for key, value in row.items() if not str(key).startswith("_")}
for row in rows_batch
]
for row in db_rows:
stmt = pg_insert(SourceProductAssetORM).values(row)
excluded = stmt.excluded
stmt = stmt.on_conflict_do_update(
index_elements=["file_path"],
set_={
"asset_uid": excluded.asset_uid,
"logical_product_uid": excluded.logical_product_uid,
"satellite_family": excluded.satellite_family,
"satellite": excluded.satellite,
"source_format": excluded.source_format,
"product_type": excluded.product_type,
"product_level": excluded.product_level,
"imaging_mode": excluded.imaging_mode,
"polarization": excluded.polarization,
"absolute_orbit": excluded.absolute_orbit,
"relative_orbit": excluded.relative_orbit,
"orbit_direction": excluded.orbit_direction,
"acquisition_start_time_utc": excluded.acquisition_start_time_utc,
"acquisition_stop_time_utc": excluded.acquisition_stop_time_utc,
"imaging_date": excluded.imaging_date,
"root_ref_id": excluded.root_ref_id,
"root_path": excluded.root_path,
"archive_path": excluded.archive_path,
"path_kind": excluded.path_kind,
"file_name": excluded.file_name,
"file_stem": excluded.file_stem,
"file_ext": excluded.file_ext,
"size_bytes": excluded.size_bytes,
"mtime_epoch": excluded.mtime_epoch,
"checksum_status": excluded.checksum_status,
"archive_integrity_status": "NOT_CHECKED",
"archive_integrity_method": None,
"archive_integrity_checked_at": None,
"archive_integrity_error": None,
"archive_integrity_version": None,
"archive_integrity_member_count": None,
"parser_name": excluded.parser_name,
"parser_version": excluded.parser_version,
"parse_status": excluded.parse_status,
"parse_error": excluded.parse_error,
"parsed_at": excluded.parsed_at,
"metadata_json": excluded.metadata_json,
"is_active": True,
"missing_since": None,
"updated_at": now,
},
)
await db.execute(stmt)
await db.flush()
changed_paths = [str(row["file_path"]) for row in rows_batch if row.get("file_path")]
asset_ids_by_path: Dict[str, int] = {}
if changed_paths:
result = await db.execute(
select(SourceProductAssetORM.file_path, SourceProductAssetORM.id).where(
SourceProductAssetORM.file_path.in_(changed_paths)
)
)
asset_ids_by_path = {str(path): int(asset_id) for path, asset_id in result.all()}
await self._upsert_metadata_documents_for_source_assets(db, rows_batch, asset_ids_by_path)
await self._upsert_radar_records_for_source_assets(db, rows_batch, asset_ids_by_path)
await db.commit()
if task_id:
await task_service.add_log(
task_id,
"INFO",
f"Source asset DB batch committed: batch={batch_index}, rows={len(rows_batch)}",
)
await self._progress(
task_id,
f"Committed source asset DB batch {batch_index}: rows={len(rows_batch)}",
batch_write_start,
)
return len(rows_batch)
batch_index = 0
def _persist_row_batch(rows_batch: List[Dict[str, Any]]) -> None:
nonlocal batch_index, persisted_changed_count
if not rows_batch:
return
batch_index += 1
future = asyncio.run_coroutine_threadsafe(
_upsert_source_asset_batch(rows_batch, batch_index=batch_index),
loop,
)
persisted = int(future.result())
persisted_changed_count += persisted
rows, issues, entry_count, skipped_unchanged, seen_paths = await asyncio.to_thread( rows, issues, entry_count, skipped_unchanged, seen_paths = await asyncio.to_thread(
_collect_source_assets_incremental, _collect_source_assets_incremental,
root, root,
@@ -2756,104 +3141,24 @@ class AssetInventoryService:
log_callback=log_callback, log_callback=log_callback,
progress_start=progress_start, progress_start=progress_start,
progress_end=max(progress_start + 1, progress_end - 8), progress_end=max(progress_start + 1, progress_end - 8),
parse_workers=settings.ASSET_SCAN_PARSE_WORKERS,
parse_inflight=settings.ASSET_SCAN_PARSE_INFLIGHT,
skip_unchanged_failures=settings.ASSET_SCAN_SKIP_UNCHANGED_FAILURES,
row_batch_callback=_persist_row_batch if task_id else None,
row_batch_size=settings.ASSET_SCAN_DB_BATCH_SIZE,
) )
await drain_thread_events() await drain_thread_events()
changed_paths = [row["file_path"] for row in rows] if not task_id and rows:
persisted_changed_count += await _upsert_source_asset_batch(rows, batch_index=1)
now = _utcnow() now = _utcnow()
write_start = max(progress_start, progress_end - 7)
write_end = max(write_start, progress_end - 3)
await self._progress(
task_id,
f"Writing source asset index: changed_or_new={len(rows)}, skipped={skipped_unchanged}",
write_start,
)
db_rows = [
{key: value for key, value in row.items() if not str(key).startswith("_")}
for row in rows
]
if task_id: if task_id:
await task_service.add_log( await task_service.add_log(
task_id, task_id,
"INFO", "INFO",
f"Source asset DB upsert started: changed_or_new={len(rows)}, skipped={skipped_unchanged}, seen={len(seen_paths)}", "Source asset DB batch upsert finished: "
f"changed_or_new={len(rows)}, persisted={persisted_changed_count}, "
f"batches={batch_index}, skipped={skipped_unchanged}, seen={len(seen_paths)}",
) )
for index, row in enumerate(db_rows, start=1):
stmt = pg_insert(SourceProductAssetORM).values(row)
excluded = stmt.excluded
stmt = stmt.on_conflict_do_update(
index_elements=["file_path"],
set_={
"asset_uid": excluded.asset_uid,
"logical_product_uid": excluded.logical_product_uid,
"satellite_family": excluded.satellite_family,
"satellite": excluded.satellite,
"source_format": excluded.source_format,
"product_type": excluded.product_type,
"product_level": excluded.product_level,
"imaging_mode": excluded.imaging_mode,
"polarization": excluded.polarization,
"absolute_orbit": excluded.absolute_orbit,
"relative_orbit": excluded.relative_orbit,
"orbit_direction": excluded.orbit_direction,
"acquisition_start_time_utc": excluded.acquisition_start_time_utc,
"acquisition_stop_time_utc": excluded.acquisition_stop_time_utc,
"imaging_date": excluded.imaging_date,
"root_ref_id": excluded.root_ref_id,
"root_path": excluded.root_path,
"archive_path": excluded.archive_path,
"path_kind": excluded.path_kind,
"file_name": excluded.file_name,
"file_stem": excluded.file_stem,
"file_ext": excluded.file_ext,
"size_bytes": excluded.size_bytes,
"mtime_epoch": excluded.mtime_epoch,
"checksum_status": excluded.checksum_status,
"archive_integrity_status": "NOT_CHECKED",
"archive_integrity_method": None,
"archive_integrity_checked_at": None,
"archive_integrity_error": None,
"archive_integrity_version": None,
"archive_integrity_member_count": None,
"parser_name": excluded.parser_name,
"parser_version": excluded.parser_version,
"parse_status": excluded.parse_status,
"parse_error": excluded.parse_error,
"parsed_at": excluded.parsed_at,
"metadata_json": excluded.metadata_json,
"is_active": True,
"missing_since": None,
"updated_at": now,
},
)
await db.execute(stmt)
if index % ASSET_SCAN_LOG_INTERVAL == 0 or index == len(rows):
progress_value = write_start
if rows and write_end > write_start:
progress_value = write_start + int(index / max(1, len(rows)) * (write_end - write_start))
await self._progress(
task_id,
f"Writing source asset index: {index}/{len(rows)} changed_or_new, skipped={skipped_unchanged}",
progress_value,
)
await db.flush()
asset_ids_by_path: Dict[str, int] = {}
if changed_paths:
await self._progress(
task_id,
f"Updating radar scene records for changed source assets: {len(changed_paths)}",
max(write_end, progress_end - 2),
)
result = await db.execute(
select(SourceProductAssetORM.file_path, SourceProductAssetORM.id).where(
SourceProductAssetORM.file_path.in_(changed_paths)
)
)
asset_ids_by_path = {str(path): int(asset_id) for path, asset_id in result.all()}
await self._upsert_metadata_documents_for_source_assets(db, rows, asset_ids_by_path)
await self._upsert_radar_records_for_source_assets(db, rows, asset_ids_by_path)
elif task_id:
await task_service.add_log(task_id, "INFO", "No changed source assets; radar scene record update skipped.")
await self._progress(task_id, "Marking missing source assets and refreshing scan issues...", max(progress_end - 2, progress_start)) await self._progress(task_id, "Marking missing source assets and refreshing scan issues...", max(progress_end - 2, progress_start))
await self._mark_missing_source_assets(db, root, seen_paths, now) await self._mark_missing_source_assets(db, root, seen_paths, now)
+17 -4
View File
@@ -644,6 +644,21 @@ class DataService:
return corner_mapping return corner_mapping
return None return None
@staticmethod
def get_radar_record_corner_mapping(record: Any) -> Optional[Dict[str, Any]]:
record_state = getattr(record, "__dict__", {}) if record is not None else {}
for metadata in (
getattr(record, "metadata_json", None),
getattr(record_state.get("source_product_asset"), "metadata_json", None),
getattr(record_state.get("source_archive_asset"), "metadata_json", None),
):
if not isinstance(metadata, dict):
continue
corner_mapping = metadata.get("corner_pixel_mapping")
if isinstance(corner_mapping, dict):
return corner_mapping
return DataService.get_radar_source_corner_mapping(str(getattr(record, "file_path", "") or ""))
@staticmethod @staticmethod
def _normalize_coverage_polygon(coverage_polygon: Any) -> Optional[List[Tuple[float, float]]]: def _normalize_coverage_polygon(coverage_polygon: Any) -> Optional[List[Tuple[float, float]]]:
if isinstance(coverage_polygon, list): if isinstance(coverage_polygon, list):
@@ -1102,6 +1117,7 @@ class DataService:
"max_lon": record.max_lon, "max_lon": record.max_lon,
"max_lat": record.max_lat, "max_lat": record.max_lat,
"preview_cache_version": record.preview_cache_version, "preview_cache_version": record.preview_cache_version,
"source_corner_mapping": DataService.get_radar_record_corner_mapping(record),
} }
) )
@@ -1164,10 +1180,7 @@ class DataService:
except (TypeError, ValueError): except (TypeError, ValueError):
bbox = None bbox = None
source_corner_mapping = await asyncio.to_thread( source_corner_mapping = item.get("source_corner_mapping")
DataService.get_radar_source_corner_mapping,
file_path,
)
need_geo_rebuild = ( need_geo_rebuild = (
(not geo_cache_mtime) (not geo_cache_mtime)
@@ -35,6 +35,7 @@ from .workflow_service import workflow_service
TASK_TYPE_DINSAR_PRODUCTION = "IDL_RUN_DINSAR" TASK_TYPE_DINSAR_PRODUCTION = "IDL_RUN_DINSAR"
TASK_TYPE_PYINT_DINSAR_PRODUCTION = "PYINT_RUN" TASK_TYPE_PYINT_DINSAR_PRODUCTION = "PYINT_RUN"
TASK_TYPE_LANDSAR_DINSAR_PRODUCTION = "LANDSAR_RUN" TASK_TYPE_LANDSAR_DINSAR_PRODUCTION = "LANDSAR_RUN"
TASK_TYPE_LANDSAR_CLUSTER_PRODUCTION = "LANDSAR_CLUSTER_RUN"
RUN_STATUS_PENDING = "PENDING" RUN_STATUS_PENDING = "PENDING"
RUN_STATUS_RUNNING = "RUNNING" RUN_STATUS_RUNNING = "RUNNING"
RUN_STATUS_COMPLETED = "COMPLETED" RUN_STATUS_COMPLETED = "COMPLETED"
@@ -775,6 +776,172 @@ class DinsarProductionService:
"rerun_mode": selection["rerun_mode"], "rerun_mode": selection["rerun_mode"],
} }
async def create_landsar_cluster_run(
self,
*,
profile_code: str,
root_dir: str,
num_to_process: int,
rerun_mode: Optional[str],
timeout_seconds: Optional[int],
extra: Optional[Dict[str, Any]],
created_by: Optional[str],
max_attempts: int,
db: AsyncSession,
) -> Dict[str, Any]:
normalized_engine = "landsar"
normalized_profile = str(profile_code or "").strip()
normalized_root = _normalize_dir(root_dir, "root_dir")
selection = await asyncio.to_thread(
_select_run_items,
normalized_root,
engine_code=normalized_engine,
profile_code=normalized_profile,
num_to_process=max(0, int(num_to_process or 0)),
rerun_mode=rerun_mode,
)
item_payloads = selection["items"]
if not item_payloads:
if (
selection["discovered_task_count"] > 0
and selection["skipped_completed_count"] > 0
and selection["rerun_mode"] == RERUN_MODE_UNFINISHED_ONLY
):
raise ValueError(
f"All discovered Task_* directories already have completed "
f"landsar/{normalized_profile} results under: {normalized_root}"
)
raise ValueError(f"No Task_* directories found under: {normalized_root}")
run_id = str(uuid.uuid4())
mode = "cluster"
task_name = f"D-InSAR production: landsar/{normalized_profile} cluster"
task_params = {
"engine_code": normalized_engine,
"profile": normalized_profile,
"root_dir": normalized_root,
"num_to_process": int(num_to_process or 0),
"rerun_mode": selection["rerun_mode"],
"timeout_seconds": timeout_seconds,
"extra": dict(extra or {}),
"mode": mode,
"production_run_id": run_id,
"cluster_job_type": "LANDSAR_CLUSTER_ITEM",
}
task_id: Optional[str] = None
created_items: List[DinsarProductionRunItemORM] = []
try:
task_id = await task_service.create_task(
task_type=TASK_TYPE_LANDSAR_CLUSTER_PRODUCTION,
task_name=task_name,
params=task_params,
db=db,
)
run = DinsarProductionRunORM(
run_id=run_id,
task_id=task_id,
product_family="dinsar",
engine_code=normalized_engine,
profile_code=normalized_profile,
mode=mode,
source_root=normalized_root,
publish_root_dir=settings.DINSAR_PRODUCT_DIR,
status=RUN_STATUS_PENDING,
cancel_requested=False,
total_items=len(item_payloads),
completed_items=0,
failed_items=0,
skipped_items=0,
latest_message="Queued LandSAR cluster items",
params_json=task_params,
summary_json={
"phase": "queued",
"selected_task_count": len(item_payloads),
"discovered_task_count": selection["discovered_task_count"],
"skipped_completed_count": selection["skipped_completed_count"],
"rerun_mode": selection["rerun_mode"],
"product_family": "dinsar",
"publish_root_dir": settings.DINSAR_PRODUCT_DIR,
"cluster_job_type": "LANDSAR_CLUSTER_ITEM",
},
created_by=created_by,
)
db.add(run)
await db.flush()
for item_payload in item_payloads:
item = DinsarProductionRunItemORM(
run_id=run_id,
order_index=item_payload["order_index"],
task_name=item_payload["task_name"],
task_alias=item_payload["task_alias"],
pair_key=item_payload["pair_key"],
pair_uid=item_payload["pair_uid"],
network_run_id=item_payload["network_run_id"],
network_edge_id=item_payload["network_edge_id"],
policy_version=item_payload["policy_version"],
selection_strategy=item_payload["selection_strategy"],
source_task_dir=item_payload["source_task_dir"],
results_root_dir=item_payload["results_root_dir"],
status=RUN_ITEM_STATUS_PENDING,
)
db.add(item)
created_items.append(item)
await db.flush()
from .job_queue_service import job_queue_service
for item in created_items:
await job_queue_service.create_job(
"LANDSAR_CLUSTER_ITEM",
payload={
"production_run_id": run_id,
"item_id": item.id,
},
task_id=task_id,
priority=0,
max_attempts=max(1, int(max_attempts or 1)),
db=db,
)
await db.commit()
await db.refresh(run)
except Exception as exc:
await db.rollback()
if task_id:
try:
await task_service.update_task(
task_id,
status="FAILED",
message=f"Failed to create LandSAR cluster run: {exc}",
)
except Exception:
pass
raise
await asyncio.to_thread(
_append_run_log_sync,
run_id,
(
f"[cluster-queued] run_id={run_id} profile={normalized_profile} root={normalized_root} "
f"items={len(item_payloads)} rerun_mode={selection['rerun_mode']} "
f"skipped_completed={selection['skipped_completed_count']}"
),
)
return {
"run_id": run_id,
"task_id": task_id,
"workflow_run_id": None,
"status": run.status,
"selected_task_count": len(item_payloads),
"discovered_task_count": selection["discovered_task_count"],
"skipped_completed_count": selection["skipped_completed_count"],
"rerun_mode": selection["rerun_mode"],
}
async def list_runs( async def list_runs(
self, self,
db: AsyncSession, db: AsyncSession,
@@ -923,6 +1090,80 @@ class DinsarProductionService:
run.latest_message = latest_message run.latest_message = latest_message
return run return run
async def finalize_cluster_run_if_complete(
self,
run: DinsarProductionRunORM,
*,
db: AsyncSession,
publish_error: Optional[str] = None,
) -> Optional[str]:
rows = await db.execute(
select(DinsarProductionRunItemORM.status, func.count(DinsarProductionRunItemORM.id))
.where(DinsarProductionRunItemORM.run_id == run.run_id)
.group_by(DinsarProductionRunItemORM.status)
)
counts = {str(status or "").upper(): int(count or 0) for status, count in rows.fetchall()}
total_items = int(run.total_items or 0)
completed = counts.get(RUN_ITEM_STATUS_COMPLETED, 0)
failed = counts.get(RUN_ITEM_STATUS_FAILED, 0)
skipped = counts.get(RUN_ITEM_STATUS_SKIPPED, 0)
cancelled = counts.get(RUN_ITEM_STATUS_CANCELLED, 0)
terminal_count = completed + failed + skipped + cancelled
run.completed_items = completed
run.failed_items = failed
run.skipped_items = skipped
if total_items <= 0 or terminal_count < total_items:
run.status = RUN_STATUS_RUNNING
run.latest_message = (
f"LandSAR cluster running: completed={completed} failed={failed} "
f"skipped={skipped} cancelled={cancelled} total={total_items}"
)
await db.commit()
return None
if publish_error:
final_status = RUN_STATUS_FAILED
latest_message = f"LandSAR cluster publish failed: {publish_error}"
elif cancelled > 0 or bool(run.cancel_requested):
final_status = RUN_STATUS_CANCELLED
latest_message = (
f"LandSAR cluster cancelled. completed={completed} failed={failed} "
f"cancelled={cancelled} total={total_items}"
)
elif failed > 0:
final_status = RUN_STATUS_FAILED
latest_message = (
f"LandSAR cluster finished with failures. completed={completed} "
f"failed={failed} total={total_items}"
)
else:
final_status = RUN_STATUS_COMPLETED
latest_message = (
f"LandSAR cluster completed. completed={completed} failed={failed} total={total_items}"
)
summary_payload = dict(run.summary_json or {})
summary_payload.update(
{
"phase": "completed",
"cluster": True,
"total_items": total_items,
"completed_items": completed,
"failed_items": failed,
"skipped_items": skipped,
"cancelled_items": cancelled,
"publish_error": publish_error,
}
)
run.status = final_status
run.summary_json = summary_payload
run.latest_message = latest_message
run.ended_at = _utcnow()
await db.commit()
await asyncio.to_thread(_append_run_log_sync, run.run_id, f"[cluster-finish] status={final_status} message={latest_message}")
return final_status
async def mark_run_started( async def mark_run_started(
self, self,
run: DinsarProductionRunORM, run: DinsarProductionRunORM,
+2 -2
View File
@@ -1136,7 +1136,7 @@ async def _check_product_packages() -> Dict[str, Any]:
status["missing_publish_dir_count"] += 1 status["missing_publish_dir_count"] += 1
if not str(processor_code or "").strip(): if not str(processor_code or "").strip():
status["missing_processor_count"] += 1 status["missing_processor_count"] += 1
if engine_key in {"isce2", "pyint", "gamma"} and not str(runtime_id or "").strip(): if engine_key in {"pyint", "gamma"} and not str(runtime_id or "").strip():
status["missing_runtime_count"] += 1 status["missing_runtime_count"] += 1
if not str(native_output_dir or "").strip(): if not str(native_output_dir or "").strip():
status["missing_native_output_count"] += 1 status["missing_native_output_count"] += 1
@@ -1386,7 +1386,7 @@ async def _check_wsl_runtime() -> Dict[str, Any]:
} }
try: try:
required_by_engine = { required_by_engine = {
"isce2": bool(settings.ISCE2_ENABLED or settings.TIMESERIES_ENABLED), "isce2": bool(settings.ISCE2_ENABLED),
"pyint": bool(settings.PYINT_ENABLED), "pyint": bool(settings.PYINT_ENABLED),
"gamma": bool(settings.GAMMA_SBAS_ENABLED), "gamma": bool(settings.GAMMA_SBAS_ENABLED),
} }
+372 -1
View File
@@ -19,7 +19,7 @@ from sqlalchemy import select
from .. import database from .. import database
from ..config import settings, split_env_paths from ..config import settings, split_env_paths
from ..models import SystemJobORM, DinsarResultORM, HazardPointORM, DinsarTaskItemORM, PsTaskItemORM, RadarDataORM, SARSceneGeoORM, FloodDetectionORM, WaterDetectionORM, WaterExtractionORM, GF3ProcessingORM, AiDiagnosisORM from ..models import SystemJobORM, DinsarResultORM, HazardPointORM, DinsarTaskItemORM, PsTaskItemORM, RadarDataORM, SARSceneGeoORM, FloodDetectionORM, WaterDetectionORM, WaterExtractionORM, GF3ProcessingORM, AiDiagnosisORM, DinsarProductionRunItemORM
from ..scheduler import scan_data_job from ..scheduler import scan_data_job
from .data_service import data_service from .data_service import data_service
from .asset_inventory_service import asset_inventory_service from .asset_inventory_service import asset_inventory_service
@@ -93,6 +93,7 @@ JOB_TYPE_GF3_SARSCAPE_CLEAN = "GF3_SARSCAPE_CLEAN"
JOB_TYPE_ISCE2_RUN = "ISCE2_RUN" JOB_TYPE_ISCE2_RUN = "ISCE2_RUN"
JOB_TYPE_PYINT_RUN = "PYINT_RUN" JOB_TYPE_PYINT_RUN = "PYINT_RUN"
JOB_TYPE_LANDSAR_RUN = "LANDSAR_RUN" JOB_TYPE_LANDSAR_RUN = "LANDSAR_RUN"
JOB_TYPE_LANDSAR_CLUSTER_ITEM = "LANDSAR_CLUSTER_ITEM"
JOB_TYPE_PUBLISH_DINSAR_PRODUCTS = "PUBLISH_DINSAR_PRODUCTS" JOB_TYPE_PUBLISH_DINSAR_PRODUCTS = "PUBLISH_DINSAR_PRODUCTS"
JOB_TYPE_REBUILD_DINSAR_CATALOG = "REBUILD_DINSAR_CATALOG" JOB_TYPE_REBUILD_DINSAR_CATALOG = "REBUILD_DINSAR_CATALOG"
JOB_TYPE_REBUILD_PSINSAR_CATALOG = "REBUILD_PSINSAR_CATALOG" JOB_TYPE_REBUILD_PSINSAR_CATALOG = "REBUILD_PSINSAR_CATALOG"
@@ -107,6 +108,15 @@ JOB_TYPE_SBAS_GAMMA_WORKFLOW = "SBAS_GAMMA_WORKFLOW"
JOB_TYPE_SBAS_LANDSAR_WORKFLOW = "SBAS_LANDSAR_WORKFLOW" JOB_TYPE_SBAS_LANDSAR_WORKFLOW = "SBAS_LANDSAR_WORKFLOW"
COPY_ALLOWED_STATUSES = {"PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"} COPY_ALLOWED_STATUSES = {"PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"}
_LOCAL_ENGINE_LOCKS: Dict[str, asyncio.Lock] = {}
def _local_engine_lock(name: str) -> asyncio.Lock:
lock = _LOCAL_ENGINE_LOCKS.get(name)
if lock is None:
lock = asyncio.Lock()
_LOCAL_ENGINE_LOCKS[name] = lock
return lock
def AsyncSessionLocal(): def AsyncSessionLocal():
@@ -1997,6 +2007,15 @@ async def _run_dinsar_production_controller(job: SystemJobORM) -> None:
) )
except Exception as exc: except Exception as exc:
publish_error = str(exc) publish_error = str(exc)
item.status = "FAILED"
item.current_step = "publish_failed"
item.last_error = publish_error
await dinsar_production_service.refresh_run_counters(
run,
db=db,
latest_message=f"Publish failed {item_label}: {publish_error}",
)
await db.commit()
await task_service.add_log( await task_service.add_log(
job.task_id, job.task_id,
"WARNING", "WARNING",
@@ -3209,6 +3228,357 @@ async def _handle_landsar_run(job: SystemJobORM) -> None:
) )
async def _handle_landsar_cluster_item(job: SystemJobORM) -> None:
payload = job.payload or {}
production_run_id = str(payload.get("production_run_id") or "").strip()
item_id = _normalize_positive_int(payload.get("item_id"))
if not production_run_id or not item_id:
raise ValueError("LANDSAR_CLUSTER_ITEM requires production_run_id and item_id.")
from ..dinsar_engines import registry
from ..dinsar_engines.base import RunRequest
engine = registry.get_engine("landsar")
if engine is None:
raise RuntimeError("LandSAR engine is not registered on this worker.")
engine_title = "LandSAR"
per_task_timeout = int(getattr(settings, "LANDSAR_DINSAR_TIMEOUT_SECONDS", 0) or 43200)
async with AsyncSessionLocal() as db:
run = await dinsar_production_service.get_run(production_run_id, db)
if run is None:
raise ValueError(f"LandSAR cluster run not found: {production_run_id}")
item = await db.get(DinsarProductionRunItemORM, int(item_id))
if item is None or item.run_id != run.run_id:
raise ValueError(f"LandSAR cluster item not found: {item_id}")
await db.refresh(item)
item_status = str(item.status or "").strip().upper()
if item_status in {"COMPLETED", "FAILED", "SKIPPED", "CANCELLED"}:
await dinsar_production_service.finalize_cluster_run_if_complete(run, db=db)
return
current_task = await task_service.get_task(job.task_id)
task_cancelled = bool(current_task and current_task.status == "CANCELLED")
if bool(run.cancel_requested) or task_cancelled:
run.cancel_requested = True
item.status = "CANCELLED"
item.current_step = "cancelled"
item.last_error = "Cancelled before worker execution."
await dinsar_production_service.refresh_run_counters(run, db=db, latest_message=item.last_error)
await db.commit()
await dinsar_production_service.finalize_cluster_run_if_complete(run, db=db)
return
if str(run.status or "").strip().upper() == "PENDING":
await task_service.start_task(
job.task_id,
message=f"Starting LandSAR cluster run {run.run_id} ({run.total_items} items)...",
)
await dinsar_production_service.mark_run_started(
run,
db=db,
message=f"LandSAR cluster started. total={run.total_items}",
)
else:
await task_service.update_task(
job.task_id,
status="RUNNING",
message=f"LandSAR cluster running. item={item.task_alias or item.task_name}",
)
params = run.params_json or {}
user_extra = dict(params.get("extra") or {})
timeout_seconds_raw = params.get("timeout_seconds")
if timeout_seconds_raw not in (None, ""):
per_task_timeout = int(timeout_seconds_raw)
total_items = max(1, int(run.total_items or 1))
item_index = max(1, int(item.order_index or 1))
item_label = item.task_alias or item.task_name
run_key = f"{build_run_key('landsar', run.profile_code, started_at=datetime.utcnow())}_{item.id}_{uuid.uuid4().hex[:6]}"
execution = await dinsar_production_service.begin_item_execution(
run=run,
item=item,
run_key=run_key,
db=db,
)
managed_run_dir = os.path.normpath(execution.output_dir)
landsar_work_root = str(getattr(settings, "LANDSAR_WORK_ROOT", "") or "").strip()
if landsar_work_root:
managed_native_output_dir = os.path.normpath(os.path.join(landsar_work_root, run_key, "native"))
else:
managed_native_output_dir = os.path.join(managed_run_dir, "native")
managed_work_dir = os.path.join(managed_native_output_dir, "workflow")
managed_export_dir = os.path.join(managed_native_output_dir, "export")
managed_orbit_output_dir = os.path.join(managed_work_dir, "orbits")
base_progress = min(95, 5 + int(((item_index - 1) / total_items) * 90))
progress_state: Dict[str, Any] = {
"progress": base_progress,
"message": f"[landsar/cluster] Running {item_index}/{total_items}: {item_label}",
"started_monotonic": time.monotonic(),
}
progress_queue: asyncio.Queue[Optional[Dict[str, Any]]] = asyncio.Queue()
loop = asyncio.get_running_loop()
def _emit_progress(event: Dict[str, Any]) -> None:
if not event:
return
try:
loop.call_soon_threadsafe(progress_queue.put_nowait, dict(event))
except RuntimeError:
return
async def _consume_progress() -> None:
while True:
event = await progress_queue.get()
if event is None:
return
event_type = str(event.get("event") or "").strip().lower()
if event_type == "log":
level = str(event.get("level") or "INFO").strip().upper()
if level not in {"DEBUG", "INFO", "WARNING", "ERROR"}:
level = "INFO"
source = str(event.get("source") or "").strip()
message = str(event.get("message") or "").strip()
if message:
prefix = f"[cluster {item_index}/{total_items}] {engine_title} {item_label}"
if source:
prefix = f"{prefix} {source}"
await task_service.add_log(job.task_id, level, f"{prefix}: {message}")
elif event_type == "pair_started":
progress_state["message"] = f"[landsar/cluster] Running {item_index}/{total_items}: {item_label}"
progress_state["started_monotonic"] = time.monotonic()
await task_service.add_log(
job.task_id,
"INFO",
f"[cluster {item_index}/{total_items}] {engine_title} started {item_label}",
)
elif event_type == "pair_finished":
if bool(event.get("success")):
progress_state["progress"] = min(98, 5 + int((item_index / total_items) * 90))
progress_state["message"] = f"[landsar/cluster] Finished {item_index}/{total_items}: {item_label}"
else:
progress_state["message"] = f"[landsar/cluster] Failed {item_index}/{total_items}: {item_label}"
async def _task_keepalive() -> None:
while True:
await asyncio.sleep(30)
try:
message = str(progress_state.get("message") or "")
started_monotonic = progress_state.get("started_monotonic")
if isinstance(started_monotonic, (int, float)):
elapsed_seconds = max(0, int(time.monotonic() - float(started_monotonic)))
message = f"{message} (elapsed={elapsed_seconds}s)"
await task_service.update_task(
job.task_id,
status="RUNNING",
progress=int(progress_state.get("progress") or base_progress),
message=message,
)
except Exception as exc:
logger.warning("LandSAR cluster keepalive failed for item %s: %s", item_label, exc)
progress_task = asyncio.create_task(_consume_progress())
keepalive_task = asyncio.create_task(_task_keepalive())
task_result: Dict[str, Any] = {}
result = None
run_exception_text = ""
await task_service.add_log(
job.task_id,
"INFO",
f"[cluster {item_index}/{total_items}] Launching {item_label} -> {managed_run_dir}",
)
dinsar_production_service.append_run_log(
run.run_id,
f"[cluster-item-start] {item_index}/{total_items} {item_label} run_key={run_key} output={managed_run_dir}",
)
request = RunRequest(
engine_code="landsar",
profile=run.profile_code,
root_dir=str(item.source_task_dir),
job_id=job.job_id,
num_to_process=1,
timeout_seconds=per_task_timeout or None,
extra={
**user_extra,
"__managed_run_dir": managed_run_dir,
"__managed_native_output_dir": managed_native_output_dir,
"__managed_work_dir": managed_work_dir,
"__managed_export_dir": managed_export_dir,
"__managed_orbit_output_dir": managed_orbit_output_dir,
"__managed_run_key": run_key,
"__source_root_override": run.source_root,
"__rerun_mode": "rerun_all",
"__cluster_item": True,
},
progress_callback=_emit_progress,
)
publish_error: Optional[str] = None
item_error: Optional[str] = None
try:
availability = await asyncio.to_thread(engine.check_available)
if not availability.available:
raise RuntimeError(f"LandSAR engine is unavailable on worker: {availability.message}")
async with _local_engine_lock("landsar"):
result = await asyncio.to_thread(engine.run, request)
detail = result.detail or {} if result else {}
task_result = ((detail.get("task_results") or [{}])[0]) if result else {}
result_error = str(result.error or "").strip() if result else ""
result_success = bool(result.success) if result else False
if not result or not result_success or not bool(task_result.get("success", result_success)):
error_message = (
str(task_result.get("error") or "").strip()
or result_error
or run_exception_text
or str(task_result.get("stderr_tail") or "").strip()
or "LandSAR cluster item failed."
)
raise RuntimeError(error_message)
run_dir = os.path.normpath(
str(task_result.get("run_dir") or task_result.get("output_dir") or execution.output_dir)
)
if run_dir != managed_run_dir:
raise RuntimeError(f"LandSAR managed run dir mismatch: expected {managed_run_dir}, got {run_dir}")
primary_file = str(task_result.get("primary_file") or "").strip()
source_files = [
str(path)
for path in (task_result.get("source_files") or [])
if str(path or "").strip()
]
native_output_dir = str(task_result.get("native_output_dir") or managed_native_output_dir).strip() or managed_native_output_dir
if not primary_file or not os.path.isfile(primary_file):
raise RuntimeError(f"LandSAR primary output is missing: {primary_file or '<empty>'}")
if not source_files:
source_files = [primary_file]
metrics = {"result_detail": detail, "task_result": task_result, "cluster_item": True}
manifest_path = await asyncio.to_thread(
dinsar_production_service.build_execution_manifest,
run=run,
item=item,
execution=execution,
primary_file=primary_file,
source_files=source_files,
native_output_dir=native_output_dir,
metrics=metrics,
)
await asyncio.to_thread(
dinsar_production_service.write_current_pointer,
run=run,
item=item,
execution=execution,
manifest_path=manifest_path,
primary_file=primary_file,
source_files=source_files,
native_output_dir=native_output_dir,
)
await dinsar_production_service.mark_item_completed(
run=run,
item=item,
execution=execution,
manifest_path=manifest_path,
metrics=metrics,
db=db,
)
try:
publish_result = await result_catalog_service.publish_from_sources(db, [managed_run_dir])
processed_count = int(publish_result.get("processed", 0) or 0)
failed_count = int(publish_result.get("failed", 0) or 0)
if processed_count > 0:
await result_catalog_service.rebuild_catalog(db, full_rebuild=True)
if processed_count != 1 or failed_count != 0:
raise RuntimeError(f"expected processed=1 failed=0, got processed={processed_count} failed={failed_count}")
await task_service.add_log(
job.task_id,
"INFO",
f"[cluster {item_index}/{total_items}] Published {item_label}",
)
except Exception as exc:
publish_error = str(exc)
await task_service.add_log(
job.task_id,
"WARNING",
f"[cluster {item_index}/{total_items}] Result catalog publish failed for {item_label}: {publish_error}",
)
await task_service.add_log(
job.task_id,
"INFO",
f"[cluster {item_index}/{total_items}] Completed {item_label}",
)
dinsar_production_service.append_run_log(run.run_id, f"[cluster-item-ok] {item_index}/{total_items} {item_label}")
except Exception as exc:
run_exception_text = str(exc)
item_error = run_exception_text
await dinsar_production_service.mark_item_failed(
run=run,
item=item,
execution=execution,
error_message=item_error,
db=db,
)
await task_service.add_log(
job.task_id,
"WARNING",
f"[cluster {item_index}/{total_items}] Failed {item_label}: {item_error}",
)
dinsar_production_service.append_run_log(
run.run_id,
f"[cluster-item-failed] {item_index}/{total_items} {item_label}: {item_error}",
)
if task_result.get("command"):
await task_service.add_log(job.task_id, "INFO", f"LandSAR command [{item_label}]: {task_result.get('command')}")
if task_result.get("stdout_tail"):
await task_service.add_log(job.task_id, "INFO", f"LandSAR stdout tail [{item_label}]:\n{task_result.get('stdout_tail')}")
if task_result.get("stderr_tail"):
await task_service.add_log(job.task_id, "WARNING", f"LandSAR stderr tail [{item_label}]:\n{task_result.get('stderr_tail')}")
finally:
keepalive_task.cancel()
try:
await keepalive_task
except asyncio.CancelledError:
pass
await progress_queue.put(None)
await progress_task
await db.refresh(run)
await dinsar_production_service.refresh_run_counters(run, db=db)
done_items = int(run.completed_items or 0) + int(run.failed_items or 0) + int(run.skipped_items or 0)
progress = min(99, 5 + int((done_items / max(1, int(run.total_items or 1))) * 90))
final_status = await dinsar_production_service.finalize_cluster_run_if_complete(
run,
db=db,
publish_error=publish_error,
)
if final_status:
task_status = "COMPLETED" if final_status == "COMPLETED" else ("CANCELLED" if final_status == "CANCELLED" else "FAILED")
await task_service.update_task(
job.task_id,
status=task_status,
progress=100,
message=run.latest_message,
)
else:
await task_service.update_task(
job.task_id,
status="RUNNING",
progress=progress,
message=f"LandSAR cluster progress: completed={run.completed_items} failed={run.failed_items} total={run.total_items}",
)
async def _handle_water_geocode(job: SystemJobORM) -> None: async def _handle_water_geocode(job: SystemJobORM) -> None:
"""单景 SAR 地理编码 job handler(多视 + 地理编码 + 辐射定标)。""" """单景 SAR 地理编码 job handler(多视 + 地理编码 + 辐射定标)。"""
from .water_service import run_geocoding_workflow, WATER_RESULTS_DIR from .water_service import run_geocoding_workflow, WATER_RESULTS_DIR
@@ -5259,6 +5629,7 @@ _HANDLERS = {
JOB_TYPE_ISCE2_RUN: _handle_isce2_run, JOB_TYPE_ISCE2_RUN: _handle_isce2_run,
JOB_TYPE_PYINT_RUN: _handle_pyint_run, JOB_TYPE_PYINT_RUN: _handle_pyint_run,
JOB_TYPE_LANDSAR_RUN: _handle_landsar_run, JOB_TYPE_LANDSAR_RUN: _handle_landsar_run,
JOB_TYPE_LANDSAR_CLUSTER_ITEM: _handle_landsar_cluster_item,
JOB_TYPE_WATER_GEOCODE: _handle_water_geocode, JOB_TYPE_WATER_GEOCODE: _handle_water_geocode,
JOB_TYPE_SAR_SCENE_PREPROCESS: _handle_sar_scene_preprocess, JOB_TYPE_SAR_SCENE_PREPROCESS: _handle_sar_scene_preprocess,
JOB_TYPE_WATER_FLOOD: _handle_water_flood, JOB_TYPE_WATER_FLOOD: _handle_water_flood,
+20 -3
View File
@@ -2,7 +2,7 @@ import asyncio
import json import json
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional from typing import Any, Dict, Optional, Sequence
from sqlalchemy import and_, or_, select, update, text from sqlalchemy import and_, or_, select, update, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -183,6 +183,7 @@ class JobQueueService:
self, self,
worker_id: str, worker_id: str,
lock_timeout_seconds: int = 1800, lock_timeout_seconds: int = 1800,
allowed_job_types: Optional[Sequence[str]] = None,
db: Optional[AsyncSession] = None, db: Optional[AsyncSession] = None,
) -> Optional[SystemJobORM]: ) -> Optional[SystemJobORM]:
gen_db = db is None gen_db = db is None
@@ -191,14 +192,30 @@ class JobQueueService:
try: try:
lock_timeout_seconds = int(lock_timeout_seconds) lock_timeout_seconds = int(lock_timeout_seconds)
normalized_allowed = [
_normalize_job_type(job_type)
for job_type in (allowed_job_types or [])
if str(job_type or "").strip()
]
normalized_allowed = list(dict.fromkeys(normalized_allowed))
allowed_clause = ""
params: Dict[str, Any] = {"lock_timeout_seconds": lock_timeout_seconds}
if normalized_allowed:
allowed_params = []
for index, job_type in enumerate(normalized_allowed):
key = f"allowed_job_type_{index}"
allowed_params.append(f":{key}")
params[key] = job_type
allowed_clause = f"AND job_type IN ({', '.join(allowed_params)})"
async with db.begin(): async with db.begin():
result = await db.execute( result = await db.execute(
text( text(
""" f"""
SELECT id SELECT id
FROM system_jobs FROM system_jobs
WHERE status IN ('READY', 'RETRY') WHERE status IN ('READY', 'RETRY')
AND (next_run_at IS NULL OR next_run_at <= NOW()) AND (next_run_at IS NULL OR next_run_at <= NOW())
{allowed_clause}
AND ( AND (
locked_by IS NULL OR locked_by IS NULL OR
locked_at IS NULL OR locked_at IS NULL OR
@@ -209,7 +226,7 @@ class JobQueueService:
LIMIT 1 LIMIT 1
""" """
), ),
{"lock_timeout_seconds": lock_timeout_seconds}, params,
) )
row = result.first() row = result.first()
if not row: if not row:
+15 -2
View File
@@ -3,7 +3,7 @@ import os
import socket import socket
import uuid import uuid
import time import time
from typing import Set from typing import Optional, Set
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy import func from sqlalchemy import func
@@ -26,6 +26,15 @@ IDL_JOB_TYPES = {
} }
def _parse_allowed_job_types(raw_value: str) -> Optional[Set[str]]:
values = {
part.strip().upper()
for part in str(raw_value or "").replace(";", ",").split(",")
if part.strip()
}
return values or None
def _default_worker_id() -> str: def _default_worker_id() -> str:
host = socket.gethostname() host = socket.gethostname()
pid = os.getpid() pid = os.getpid()
@@ -129,6 +138,7 @@ async def run_worker_loop(
concurrency = max(1, int(concurrency)) concurrency = max(1, int(concurrency))
sem = asyncio.Semaphore(concurrency) sem = asyncio.Semaphore(concurrency)
active: Set[asyncio.Task] = set() active: Set[asyncio.Task] = set()
allowed_job_types = _parse_allowed_job_types(getattr(settings, "JOB_WORKER_ALLOWED_TYPES", ""))
job_heartbeat_interval = float(settings.JOB_WORKER_JOB_HEARTBEAT_INTERVAL) job_heartbeat_interval = float(settings.JOB_WORKER_JOB_HEARTBEAT_INTERVAL)
stale_recover_interval = float(settings.JOB_WORKER_STALE_RECOVER_INTERVAL) stale_recover_interval = float(settings.JOB_WORKER_STALE_RECOVER_INTERVAL)
stale_running_seconds = int(settings.JOB_WORKER_STALE_RUNNING_SECONDS) stale_running_seconds = int(settings.JOB_WORKER_STALE_RUNNING_SECONDS)
@@ -187,7 +197,10 @@ async def run_worker_loop(
print(f"[WARN] worker poll: {exc}") print(f"[WARN] worker poll: {exc}")
if len(active) < concurrency: if len(active) < concurrency:
job = await job_queue_service.claim_next_job(worker_id) job = await job_queue_service.claim_next_job(
worker_id,
allowed_job_types=allowed_job_types,
)
if job: if job:
task = asyncio.create_task(_wrap(job)) task = asyncio.create_task(_wrap(job))
active.add(task) active.add(task)
+63 -49
View File
@@ -95,6 +95,68 @@ def _ordered_closed_polygon_from_corner_details(corner_details: Dict[str, Dict[s
return _ordered_closed_polygon([(value["lon"], value["lat"]) for value in (corner_details or {}).values()]) return _ordered_closed_polygon([(value["lon"], value["lat"]) for value in (corner_details or {}).values()])
def build_corner_pixel_mapping(corner_details: Dict[str, Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if len(corner_details or {}) < 4:
return None
entries: List[Tuple[str, Dict[str, Any]]] = []
ref_rows = []
ref_cols = []
for name, info in (corner_details or {}).items():
if info.get("lon") is None or info.get("lat") is None:
return None
if info.get("ref_row") is None or info.get("ref_col") is None:
return None
try:
ref_row = int(float(info["ref_row"]))
ref_col = int(float(info["ref_col"]))
except (TypeError, ValueError):
return None
normalized = dict(info)
normalized["ref_row"] = ref_row
normalized["ref_col"] = ref_col
entries.append((str(name), normalized))
ref_rows.append(ref_row)
ref_cols.append(ref_col)
min_row, max_row = min(ref_rows), max(ref_rows)
min_col, max_col = min(ref_cols), max(ref_cols)
remaining = entries[:]
def pick(target_row: int, target_col: int) -> Optional[Tuple[float, float]]:
if not remaining:
return None
ranked = []
for index, (name, info) in enumerate(remaining):
row = int(info["ref_row"])
col = int(info["ref_col"])
score = abs(row - target_row) + abs(col - target_col)
ranked.append((score, abs(row - target_row), abs(col - target_col), name, index))
ranked.sort()
chosen_index = ranked[0][4]
_, chosen = remaining.pop(chosen_index)
try:
return float(chosen["lon"]), float(chosen["lat"])
except (TypeError, ValueError):
return None
top_left = pick(min_row, min_col)
top_right = pick(min_row, max_col)
bottom_left = pick(max_row, min_col)
bottom_right = pick(max_row, max_col)
if not all([top_left, top_right, bottom_left, bottom_right]):
return None
return {
"top_left": [top_left[0], top_left[1]],
"top_right": [top_right[0], top_right[1]],
"bottom_left": [bottom_left[0], bottom_left[1]],
"bottom_right": [bottom_right[0], bottom_right[1]],
"source": "xml_ref_row_col",
}
# --- Sentinel-1 (S1A/S1B/S1C) Parsers --- # --- Sentinel-1 (S1A/S1B/S1C) Parsers ---
def _radar_meta_base() -> Dict[str, Any]: def _radar_meta_base() -> Dict[str, Any]:
@@ -446,54 +508,6 @@ def parse_xml_metadata(
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
def _build_corner_pixel_mapping(corner_details: Dict[str, Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if len(corner_details) < 4:
return None
ref_rows = []
ref_cols = []
for info in corner_details.values():
if info.get("ref_row") is None or info.get("ref_col") is None:
return None
ref_rows.append(int(info["ref_row"]))
ref_cols.append(int(info["ref_col"]))
min_row, max_row = min(ref_rows), max(ref_rows)
min_col, max_col = min(ref_cols), max(ref_cols)
remaining = set(corner_details.keys())
def pick(target_row: int, target_col: int) -> Optional[Tuple[float, float]]:
if not remaining:
return None
ranked = []
for name in remaining:
info = corner_details[name]
row = int(info["ref_row"])
col = int(info["ref_col"])
score = abs(row - target_row) + abs(col - target_col)
ranked.append((score, abs(row - target_row), abs(col - target_col), name))
ranked.sort()
chosen_name = ranked[0][3]
remaining.remove(chosen_name)
chosen = corner_details[chosen_name]
return float(chosen["lon"]), float(chosen["lat"])
top_left = pick(min_row, min_col)
top_right = pick(min_row, max_col)
bottom_left = pick(max_row, min_col)
bottom_right = pick(max_row, max_col)
if not all([top_left, top_right, bottom_left, bottom_right]):
return None
return {
"top_left": [top_left[0], top_left[1]],
"top_right": [top_right[0], top_right[1]],
"bottom_left": [bottom_left[0], bottom_left[1]],
"bottom_right": [bottom_right[0], bottom_right[1]],
"source": "xml_ref_row_col",
}
corners = ['bottomLeft', 'bottomRight', 'topRight', 'topLeft'] corners = ['bottomLeft', 'bottomRight', 'topRight', 'topLeft']
polygon = [] polygon = []
@@ -652,7 +666,7 @@ def parse_xml_metadata(
if not ordered_polygon: if not ordered_polygon:
return None, None return None, None
polygon = ordered_polygon polygon = ordered_polygon
corner_pixel_mapping = _build_corner_pixel_mapping(corner_details) corner_pixel_mapping = build_corner_pixel_mapping(corner_details)
meta = { meta = {
"orbit_direction": orbit_direction, "orbit_direction": orbit_direction,
"imaging_mode": imaging_mode, "imaging_mode": imaging_mode,
@@ -0,0 +1,7 @@
# Copy this file to .env on the remote LandSAR worker host 192.168.1.6.
# DATABASE_URL points to the main InSAR management server/PostgreSQL host 192.168.1.62.
DATABASE_URL=postgresql+asyncpg://postgres:WXZXzhb123456@192.168.1.62:5432/insar_management
JOB_WORKER_ALLOWED_TYPES=LANDSAR_CLUSTER_ITEM
JOB_WORKER_CONCURRENCY=1
JOB_WORKER_POLL_INTERVAL=1.0
LANDSAR_CLUSTER_WORKER_ID=
+135
View File
@@ -0,0 +1,135 @@
# Frontend Production UI Refinement
## 定位
本前端面向科研工程单位的 InSAR 生产、分析和运维场景。界面设计服务于长期、高频、可审计的工程工作流,不做营销化展示页。
## 语言策略
- 默认并长期采用中文界面。
- 不再维护完整英文界面和运行时 DOM 翻译。
- 专业术语按行业习惯保留英文或中英混写,例如 InSAR、D-InSAR、SBAS、Gamma、LandSAR、ENVI、SARscape、IDL、Ollama、DB、Worker、Task、Run、Catalog、AOI、DEM、GeoTIFF、WebP。
- 操作、状态、错误、提示使用中文,要求准确、短句、可执行。
- 后续新增前端文案直接写中文,不再添加 `language === 'en'` 分支。
## 视觉原则
- 气质:冷静、精密、可审计,像科研生产控制台,而不是通用 SaaS 后台。
- 色彩:以蓝灰中性色为基础,蓝色只用于主操作、选中态、地图覆盖层和关键状态,不做装饰性大面积渐变。
- 信息密度:允许密集,但必须靠分组、标题、状态标签和对齐建立秩序。
- 控件:同类按钮、标签、面板、表单行使用一致样式。避免在 JSX 中散落大段 inline style。
- 动效:只表达状态变化,不做页面级展示动画。进度条可即时更新,避免无意义的 width 动画。
## 优先修整项
1. 固定中文运行时,移除顶部语言切换。
2. 修复已损坏或乱码的核心文案,优先顺序:全局顶栏、登录页、导航常量、生产工作台、雷达数据面板、运维自检。
3. 收敛视觉反模式:粗侧边强调线、装饰渐变、过宽阴影、过圆卡片。
4. 抽取基础 UI 词汇:按钮、状态标签、面板段落、工具栏、字段行。
5. 重塑生产工作台为流程导向:数据准备 -> 配对/栈规划 -> 生产运行 -> 质量检查 -> 成果发布。
## 当前决策
- `I18nProvider` 保留兼容 API,但固定返回 `language: 'zh'`,`t(text)` 原样返回。
- 暂不一次性删除所有英文分支,避免大面积回归风险;后续按面板逐步清理。
- 后端服务无需停止。只修改前端文件时,Vite 可热更新;必要时刷新浏览器即可。
## 2026-06-21 修整记录
- 固定中文运行时,移除顶部语言切换入口,保留 `useI18n` 兼容层以降低改动范围。
- 修复登录页、全局状态栏、地图底图常量、生产管理常量、雷达数据面板和共享加载态的中文文案。
- 将生产管理入口重塑为流程型工作台,突出数据准备、规划、运行、质量检查与成果发布,弱化营销式 hero 和装饰性渐变。
- 调整基础视觉规则:减少大阴影、过圆卡片、装饰渐变和无意义进度动画;保留更克制的工程控制台气质。
- 已通过 `npm run build`,并对本轮触碰文件做了乱码与视觉反模式扫描。
## 2026-06-21 页眉与生产面板补充
- 页眉移除 DB、Worker、IDL、Ollama、Nginx 等运行状态灯,系统健康状态集中放在“运行维护”模块。
- 页眉改为单位 logo、单位名称、系统名称、授权摘要、任务摘要和用户操作。
- 单位名、系统名、页眉标语和 logo URL 支持通过 `VITE_APP_*` 环境变量配置,默认使用 `frontend/src/logo.jpg`。
- 新增 `PRODUCT.md`,记录系统面向科研工程单位的产品定位:科研、专业、克制,同时要求严谨、稳定、工程化。
- SBAS 生产面板中高曝光的 Runtime Status、Task queue、Workflow 说明文案改为中文表达,保留必要英文术语。
## 2026-06-21 运行维护页设计
- 将“运行维护”定位为页眉移除健康灯后的主健康入口,顶部摘要直接回答生产是否就绪、阻断项数量、最近检查和一致性异常。
- 保留所有现有检查项,但按职责分组:核心服务、结果目录与生产索引、数据资产与运行时、一致性与精轨、维护操作。
- 固定中文运行路径,`HealthCheckPanel` 不再依赖 `language === 'en'` 进入英文界面。
- 视觉上减少阴影和卡片堆叠感,改为分组式生产控制台;状态不只靠颜色表达,同时显示“正常/异常”和具体数量。
## 2026-06-21 面板一致性策略
- 当前前端的主要观感问题来自多个面板各自生长:宽度、卡片高度、标题层级和内边距不一致,造成“碎”和“忽宽忽高”的感觉。
- 后续按页处理,不先做大规模重构:每轮选择一个高频页面,统一壳层、摘要、分区标题、最大宽度和关键文案;全量走完后再做一次整体审阅。
- D-InSAR 生产页先完成第一轮结构归整:增加生产摘要,按“引擎与能力 / 任务准备与提交 / 运行监控与审计记录”分区,页面最大宽度收敛到生产控制台尺度。
- 独立面板轻量收敛:去掉装饰背景,降低独立页头部高度和标题字号,让页面主体而不是壳层成为视觉中心。
## 2026-06-21 D-InSAR 结果目录第一轮
- D-InSAR 结果页从“结果提取与标准目录”的说明块,调整为成果归档控制台:顶部直接显示操作模式、产物任务、提取源和日志策略。
- 提取、重扫和任务日志归为“成果提取与任务监控”,下方标准目录归为“标准目录与资产详情”,降低页面碎片感。
- 页面最大宽度与 D-InSAR 生产运行页统一到 1280px,减少超宽屏下横向拉伸;卡片圆角、边框、内边距跟随当前生产控制台节奏。
- 保留现有后端接口、任务监控和目录组件行为,本轮只做前端结构、文案和壳层修整。
## 2026-06-21 SBAS-InSAR 结果目录第一轮
- SBAS 成果页顶部从普通 section 调整为结果目录控制台:显示操作模式、目录状态、登记产品数和问题数。
- 保留原有刷新、目录重建、检索、预览、时序曲线、资产下载等业务能力,只收敛外层壳、标题和主工作区比例。
- 主工作区增加“结果检索与资产复核”分区说明,左侧列表宽度从 380px 收敛到 360px,避免与详情区争抢空间。
- 与 D-InSAR 成果页保持 1280px 最大宽度和同一套状态摘要视觉词汇,形成 D-InSAR / SBAS 成果发布链路的一致入口。
## 2026-06-21 工程痕迹文案清理第一轮
- 前端不再把已经退出主流程的 ISCE2/MintPy 能力作为生产入口说明、下拉选项或运维自检对象展示。
- 运行维护页的精轨检查聚焦当前生产 TXT 池;前端不再把 ISCE2 XML 池纳入健康判断、计数摘要、修复按钮或结果说明。
- 任务中心与导航文案去掉“旧 / legacy / 停用”等工程开发痕迹,历史任务使用中性中文标签表达。
- SBAS / D-InSAR 高曝光结果文案改为面向业务人员的表达,避免把兼容层、桥接层和弃用路径暴露成用户概念。
## 2026-06-21 资产库存页第一轮
- 资产库存页定位为台账查看与质量复核入口,不再在顶部暴露“全部扫描 / LT-1 扫描 / S1 扫描 / 精轨扫描”等运维扫描按钮。
- 资产扫描能力仍保留在数据接入与运维流程中;资产库存页保留刷新和压缩包完整性审计,避免普通台账页面变成操作面板。
- 页面壳层收敛到 1280px,标题说明改为“查看源产品、精密轨道、绑定状态和开放问题”,指标区改为五列台账摘要。
## 2026-06-21 应用壳与导航第一轮
- 左侧一级任务线调整为“数据资产 / 生产管理 / 形变分析 / 灾害分析 / 运行维护”,减少泛化后台感。
- 数据域入口改为“数据接入 / 资产台账 / 影像检索 / 灾害点库”,对应接入、台账、检索和空间对象管理四类任务。
- 形变分析入口改为“D-InSAR 结果判读 / D-InSAR 专题分析 / SBAS 形变分析”,让分析链路更贴近科研业务表达。
- 独立工作区页头不再使用统一兜底说明,按模块显示专业描述,帮助用户理解当前页面在任务线中的位置。
- 左侧导航视觉从胶囊按钮堆叠调整为更克制的分层按钮,减少碎片感并强化一级任务线。
## 2026-06-21 数据接入页第一轮
- 数据接入页从“数据监控面板”调整为接入控制台:顶部显示配置状态、接入任务、源数据池和可用存储。
- 页面任务线梳理为接入路径与生产目录、本机存储状态、LT-1/Sentinel-1 源数据与精轨登记、GF3 回传成果登记、接入任务记录。
- 将“扫描压缩包 / 扫描精轨”等工程按钮文案改为“登记源压缩包 / 登记精轨”,更贴合生产数据接入语义。
- 移除接入页顶部装饰渐变提示,改为状态提示条;保留现有后端接口和任务调用路径。
## 2026-06-22 应用壳与综合统计调整
- 右侧全局日志栏不再作为主界面常驻区域展示,地图和左侧任务栏获得更完整的横向空间;各业务页内部的任务记录、审计记录和运行维护能力继续保留。
- “统计”从影像检索页按钮提升为一级导航“综合统计”,与数据资产、生产管理、形变分析、灾害分析同级,避免把生产统计能力误放在单一检索任务里。
- 综合统计页从遮罩弹窗改为全宽工作页,按源影像、D-InSAR 成果、质量判读、缓存一致性四类信息组织;图表使用克制的科研控制台风格,减少装饰色和弹窗遮挡。
- 影像检索页顶部仅保留检索相关动作,统计入口由导航承担,任务线更清晰:先接入和检索数据,再进入生产和成果分析,最终在综合统计中复核总体状态。
## 2026-06-22 接入页与统计口径回收
- 左侧主栏固定到 620px,不再保留拖拽宽度;右侧日志栏移除后,主界面直接形成“宽任务栏 + 地图工作区”的稳定布局。
- 数据接入页的“接入路径与生产目录”默认折叠。该区主要是服务器部署目录核对,不应占据日常接入操作的首屏。
- 接入页补充“开放问题复核”区,直接读取资产台账开放 issue;底部日志明确为任务执行日志,避免把 “Extracting source archive metadata...” 这类过程日志误认为问题结论。
- 综合统计暂时改为一级入口占位页,不再接临时 `/statistics` 图表。具体统计口径和图表方案记录在 `docs/STATISTICS_DASHBOARD_DESIGN.md`,后续先设计再实现。
## 2026-06-23 综合统计命名与覆盖图修正
- 综合统计页标题调整为“InSAR 数据与生产统计”,不再使用“生产态势驾驶舱”这类展示大屏语义。
- 覆盖图从经纬度散点图调整为“源数据空间覆盖密度”,按场景中心点聚合成网格热力图,不再显示笛卡尔经纬度坐标轴。
- 当前覆盖密度图只表达中心点密度,不等同于真实 footprint 面状覆盖;正式面积覆盖、行政区覆盖率和空白区判断需要后续基于有序角点/footprint 的后端聚合。
- 综合统计继续使用手动刷新,不做自动轮询,避免统计页持续打生产服务。
## 2026-06-23 综合统计热力格网升级
- 覆盖热力不再由前端临时聚合点位,而是由 `/api/statistics/dashboard` 返回后端格网单元,前端只负责渲染。
- 覆盖统计分成“源数据”和“成果”两个对象:源数据统计 LT-1 / Sentinel-1 / GF3,成果统计 `result_products` 中 D-InSAR / SBAS 等已登记产品。
- 源数据优先用 `sar_scene_geometry_profiles.footprint_polygon`,缺失时退回中心点;成果优先用 `result_products.coverage_polygon`,缺失时退回 bbox。
- 当前热力图仍是工程态势图,不作为正式面积统计或行政区覆盖率结论。
+55 -78
View File
@@ -1,106 +1,83 @@
# 文档索引 # 鏂囨。绱㈠紩
最后更新:2026-06-16 鏈€鍚庢洿鏂帮細2026-06-23
本页是当前有效文档入口。没有列在本页的历史设计、实验记录和过程文档不再作为当前系统事实依据。 鏈〉鏄綋鍓嶆湁鏁堟枃妗e叆鍙c€傛病鏈夊垪鍦ㄦ湰椤碘€滃綋鍓嶆湁鏁堟枃妗b€濅腑鐨勫巻鍙茶璁°€佸疄楠岃褰曞拰杩囩▼鏂囨。锛屼笉鍐嶄綔涓哄綋鍓嶇郴缁熶簨瀹炰緷鎹€?
## 褰撳墠鏈夋晥鏂囨。
## 总览与部署
### 鎬昏涓庨儴缃?
- [../README.md](../README.md) - [../README.md](../README.md)
项目总览、当前生产入口、启动链路和文档入口。 椤圭洰鎬昏銆佸綋鍓嶇敓浜у叆鍙c€佸惎鍔ㄩ摼璺拰鏂囨。鍏ュ彛銆?
- [DEPLOYMENT.md](DEPLOYMENT.md) - [DEPLOYMENT.md](DEPLOYMENT.md)
Windows + PostgreSQL + WSL2 + Gamma/ISCE2/ENVI 的部署与运行说明。 Windows + PostgreSQL + WSL2 + Gamma/ISCE2/ENVI 鐨勯儴缃蹭笌杩愯璇存槑銆?
- [BASEMAP_TILESERVER_PROXY_AND_ACCESS_20260613.md](BASEMAP_TILESERVER_PROXY_AND_ACCESS_20260613.md) - [BASEMAP_TILESERVER_PROXY_AND_ACCESS_20260613.md](BASEMAP_TILESERVER_PROXY_AND_ACCESS_20260613.md)
Tile-server proxy, LAN access, token configuration, and Nginx IP whitelist. Tile-server proxy銆丩AN access銆乼oken configuration 鍜?Nginx IP whitelist銆?
- [DOCUMENTATION_GOVERNANCE.md](DOCUMENTATION_GOVERNANCE.md) - [DOCUMENTATION_GOVERNANCE.md](DOCUMENTATION_GOVERNANCE.md)
文档治理规则、事实来源优先级和清理约定。 鏂囨。娌荤悊瑙勫垯銆佷簨瀹炴潵婧愪紭鍏堢骇鍜屾竻鐞嗙害瀹氥€?
- [FRONTEND_NAVIGATION_ARCHITECTURE.md](FRONTEND_NAVIGATION_ARCHITECTURE.md) - [FRONTEND_NAVIGATION_ARCHITECTURE.md](FRONTEND_NAVIGATION_ARCHITECTURE.md)
当前左侧导航和生产管理工作台视图模型。 褰撳墠宸︿晶瀵艰埅鍜岀敓浜х鐞嗗伐浣滃彴瑙嗗浘妯″瀷銆?
- [FRONTEND_PRODUCTION_UI_REFINEMENT.md](FRONTEND_PRODUCTION_UI_REFINEMENT.md)
鍓嶇瀵艰埅銆佹暟鎹帴鍏ャ€佺敓浜х鐞嗐€佺患鍚堢粺璁$瓑椤甸潰鐨勮繎鏈熷伐绋嬪寲璋冩暣璁板綍銆?
- [STATISTICS_DASHBOARD_DESIGN.md](STATISTICS_DASHBOARD_DESIGN.md)
缁煎悎缁熻涓€绾ч〉闈㈢殑宸ョ▼瀹氫綅銆佺粺璁″彛寰勩€佹簮鏁版嵁/鎴愭灉绌洪棿瑕嗙洊瀵嗗害鍥鹃檺鍒跺拰鍚庣画 footprint 鐑姏鍗囩骇璺嚎銆?
- [GLOBAL_TASK_STATUS_LOCK_REDESIGN_20260612.md](GLOBAL_TASK_STATUS_LOCK_REDESIGN_20260612.md) - [GLOBAL_TASK_STATUS_LOCK_REDESIGN_20260612.md](GLOBAL_TASK_STATUS_LOCK_REDESIGN_20260612.md)
全局界面锁降级为任务状态中心、功能级任务面板和后端资源锁的重构设计。 鍏ㄥ眬鐣岄潰閿侀檷绾т负浠诲姟鐘舵€佷腑蹇冦€佸姛鑳界骇浠诲姟闈㈡澘鍜屽悗绔祫婧愰攣鐨勯噸鏋勮璁°€?
- [OLLAMA_DINSAR_DIAGNOSIS_DEPLOYMENT_20260620.md](OLLAMA_DINSAR_DIAGNOSIS_DEPLOYMENT_20260620.md)
- [OLLAMA_DINSAR_DIAGNOSIS_DEPLOYMENT_20260620.md](OLLAMA_DINSAR_DIAGNOSIS_DEPLOYMENT_20260620.md) D-InSAR 鍒嗘瀽涓?Ollama 鏈満 VLM 璇婃柇鐨勯儴缃查厤缃€佹ā鍨嬮€夋嫨鍜岃繍琛岀害瀹氥€?
D-InSAR 分析中 Ollama 本机 VLM 诊断的部署配置、模型选择和运行约定。 - [STORAGE_PRESSURE_CLEANUP_GOVERNANCE_20260623.md](STORAGE_PRESSURE_CLEANUP_GOVERNANCE_20260623.md)
瀛樺偍鍘嬪姏娓呯悊涓庢不鐞嗚竟鐣岋細婧愭暟鎹€丟F3 `_geo`銆丏EM銆佺簿杞ㄥ拰姝e紡鎴愭灉涓嶈繘鍏ユ櫘閫氭竻鐞嗭紝缂撳瓨銆佹棩蹇椼€佽繍琛屾椂鍜?Task_Pool materialize 鍒嗛樁娈垫不鐞嗐€?
## 生产与结果 ### 鐢熶骇涓庣粨鏋?
- [THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md](THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md)
- [THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md](THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md) 褰撳墠闄嗘帰涓€鍙枫€丼entinel-1銆侀珮鍒嗕笁鏈満鐢熶骇銆佹寜闇€瑙e寘銆丟F3 澶栭儴鐢熶骇鐧昏銆佺粨鏋滅鐞嗗拰 UNC 閫€鍑虹害瀹氥€?
当前陆探一号、Sentinel-1、高分三本机生产、按需解包、GF3 外部生产登记、结果管理和 UNC 退出约定。
- [PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md](PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md) - [PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md](PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md)
统一结果目录、标准产品包、catalog 与多引擎结果共存约定。D-InSAR 当前引擎集合以 2026-06-14 三引擎 Task_Pool 重构设计为准。 缁熶竴缁撴灉鐩綍銆佹爣鍑嗕骇鍝佸寘銆乧atalog 涓庡寮曟搸缁撴灉鍏卞瓨绾﹀畾銆?
- [DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md](DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md) - [DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md](DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md)
D-InSAR 保留 ENVI/SARscape、LandSAR、Gamma/PyINT 三引擎,退出 ISCE2,统一 Task_Pool、结果聚合和中间文件清理的当前设计。 D-InSAR 淇濈暀 ENVI/SARscape銆丩andSAR銆丟amma/PyINT 涓夊紩鎿庯紝閫€鍑?ISCE2锛岀粺涓€ Task_Pool銆佺粨鏋滆仛鍚堝拰涓棿鏂囦欢娓呯悊鐨勫綋鍓嶈璁°€?
- [LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md](LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md) - [LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md](LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md)
LandSAR D-InSAR/SBAS 的全球 DEM 一次性 Int16 标准化、区域裁剪 tif、生产配置和 guardrail 约定。 LandSAR D-InSAR/SBAS 鐨勫叏鐞?DEM 涓€娆℃€?Int16 鏍囧噯鍖栥€佸尯鍩熻鍓?tif銆佺敓浜ч厤缃拰 guardrail 绾﹀畾銆?
- [UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md](UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md) - [LANDSAR_CLUSTER_WORKER_DEPLOYMENT_20260624.md](LANDSAR_CLUSTER_WORKER_DEPLOYMENT_20260624.md)
LT-1/Sentinel-1 本地源压缩包管理、包内 XML/manifest 资产化、本地 Task_Pool materialize,以及 UNC 退出运行链路后的本机部署边界。 LandSAR D-InSAR 集群 worker 的队列分片设计、主服务器 IP 白名单、远端 Windows 节点 192.168.1.6 部署和运行约束。
- [SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md](SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md) - [UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md](UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md)
LT-1/Sentinel-1 源压缩包完整性审计的独立任务、增量语义、数据库字段和问题登记规则。 LT-1/Sentinel-1 鏈湴婧愬帇缂╁寘绠$悊銆佸寘鍐?XML/manifest 璧勪骇鍖栥€佹湰鍦?Task_Pool materialize锛屼互鍙?UNC 閫€鍑哄悗鐨勬湰鏈洪儴缃茶竟鐣屻€?
- [SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md](SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md)
LT-1/Sentinel-1 婧愬帇缂╁寘瀹屾暣鎬у璁$殑鐙珛浠诲姟銆佸閲忚涔夈€佹暟鎹簱瀛楁鍜岄棶棰樼櫥璁拌鍒欍€?
- [DINSAR_PRODUCTION_CORES_OVERVIEW.md](DINSAR_PRODUCTION_CORES_OVERVIEW.md) - [DINSAR_PRODUCTION_CORES_OVERVIEW.md](DINSAR_PRODUCTION_CORES_OVERVIEW.md)
旧版 ENVI/SARscape、ISCE2、Gamma/PyINT D-InSAR 生产核心说明。ISCE2 相关内容仅作历史背景。 鏃х増 ENVI/SARscape銆両SCE2銆丟amma/PyINT D-InSAR 鐢熶骇鏍稿績璇存槑銆侷SCE2 鐩稿叧鍐呭浠呬綔鍘嗗彶鑳屾櫙銆?
- [SBAS_INSAR_CURRENT_WORKFLOW.md](SBAS_INSAR_CURRENT_WORKFLOW.md) - [SBAS_INSAR_CURRENT_WORKFLOW.md](SBAS_INSAR_CURRENT_WORKFLOW.md)
当前 Gamma SBAS-InSAR 生产、AOI 选栈、结果 catalog、产物和 LOS 符号约定。 褰撳墠 Gamma SBAS-InSAR 鐢熶骇銆丄OI 閫夋嫨銆佺粨鏋?catalog銆佷骇鍝佸拰 LOS 绗﹀彿绾﹀畾銆?
### 杩愯鏃朵笌涓撻」閰嶇疆
## 运行时与专项配置
- [WSL_RUNTIME_REFACTOR_DESIGN_20260422.md](WSL_RUNTIME_REFACTOR_DESIGN_20260422.md) - [WSL_RUNTIME_REFACTOR_DESIGN_20260422.md](WSL_RUNTIME_REFACTOR_DESIGN_20260422.md)
WSL 共享运行时和 Broker 设计。 WSL 鍏变韩杩愯鏃跺拰 Broker 璁捐銆?
- [PROJ_CONFIGURATION.md](PROJ_CONFIGURATION.md) - [PROJ_CONFIGURATION.md](PROJ_CONFIGURATION.md)
PROJ / GDAL 配置说明。 PROJ / GDAL 閰嶇疆璇存槑銆?
- [ISCE2_MANAGED_DINSAR_IMPLEMENTATION_20260424.md](ISCE2_MANAGED_DINSAR_IMPLEMENTATION_20260424.md) - [ISCE2_MANAGED_DINSAR_IMPLEMENTATION_20260424.md](ISCE2_MANAGED_DINSAR_IMPLEMENTATION_20260424.md)
ISCE2 托管 D-InSAR 历史落地说明。新 D-InSAR 生产不再采用。 ISCE2 鎵樼 D-InSAR 鍘嗗彶钀藉湴璇存槑銆傛柊 D-InSAR 鐢熶骇涓嶅啀閲囩敤銆?
- [ISCE2_PRODUCTION_RELIABILITY_HARDENING_DESIGN_20260424.md](ISCE2_PRODUCTION_RELIABILITY_HARDENING_DESIGN_20260424.md) - [ISCE2_PRODUCTION_RELIABILITY_HARDENING_DESIGN_20260424.md](ISCE2_PRODUCTION_RELIABILITY_HARDENING_DESIGN_20260424.md)
ISCE2 生产链路历史稳定性约束。新 D-InSAR 生产不再采用。 ISCE2 鐢熶骇閾捐矾鍘嗗彶绋冲畾鎬х害鏉熴€傛柊 D-InSAR 鐢熶骇涓嶅啀閲囩敤銆?
### 鏁版嵁涓庝笟鍔℃ā鍧?
## 数据与业务模块
- [SENTINEL1_SOURCE_ORBIT_ASSET_DESIGN_20260512.md](SENTINEL1_SOURCE_ORBIT_ASSET_DESIGN_20260512.md) - [SENTINEL1_SOURCE_ORBIT_ASSET_DESIGN_20260512.md](SENTINEL1_SOURCE_ORBIT_ASSET_DESIGN_20260512.md)
Sentinel-1 / LT-1 源数据与精密轨道资产层设计。 Sentinel-1 / LT-1 婧愭暟鎹笌绮惧瘑杞ㄩ亾璧勪骇灞傝璁°€?
- [PRECISE_ORBIT_PRODUCTION_CONTRACT_20260617.md](PRECISE_ORBIT_PRODUCTION_CONTRACT_20260617.md) - [PRECISE_ORBIT_PRODUCTION_CONTRACT_20260617.md](PRECISE_ORBIT_PRODUCTION_CONTRACT_20260617.md)
Current LT-1/Sentinel-1 precise-orbit source assets, LT-1 production TXT pools, Gamma/PyINT, LandSAR, and retired ISCE2 orbit boundaries. 褰撳墠 LT-1/Sentinel-1 绮捐建婧愯祫浜с€丩T-1 鐢熶骇 TXT 姹犮€丟amma/PyINT銆丩andSAR 鍜岄€€褰?ISCE2 绮捐建杈圭晫銆?
- [FLOOD_GEOTIFF_GAMMA_PREPROCESS_DESIGN_20260515.md](FLOOD_GEOTIFF_GAMMA_PREPROCESS_DESIGN_20260515.md) - [FLOOD_GEOTIFF_GAMMA_PREPROCESS_DESIGN_20260515.md](FLOOD_GEOTIFF_GAMMA_PREPROCESS_DESIGN_20260515.md)
洪涝模块 GeoTIFF 化与 Gamma 前处理方向。 娲稘妯″潡 GeoTIFF 鍖栦笌 Gamma 鍓嶅鐞嗘柟鍚戙€?
- [GF3_SARSCAPE_NATIVE_TO_GEOTIFF_DESIGN_20260530.md](GF3_SARSCAPE_NATIVE_TO_GEOTIFF_DESIGN_20260530.md) - [GF3_SARSCAPE_NATIVE_TO_GEOTIFF_DESIGN_20260530.md](GF3_SARSCAPE_NATIVE_TO_GEOTIFF_DESIGN_20260530.md)
GF3 SARscape 原生 `_geo` 二进制池、GeoTIFF 标准化、入库和洪涝接入设计。 GF3 SARscape 鍘熺敓 `_geo` 浜岃繘鍒舵睜銆丟eoTIFF 鏍囧噯鍖栥€佸叆搴撳拰娲稘鎺ュ叆璁捐銆?
- [FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md](FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md) - [FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md](FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md)
洪涝灾害分析工作台、产品包和矢量套合边界。 娲稘鐏惧鍒嗘瀽宸ヤ綔鍙般€佷骇鍝佸寘鍜岀煝閲忓鍚堣竟鐣屻€?
- [FLOOD_WATER_ALGORITHM_ENGINEERING_HANDOFF_20260602.md](FLOOD_WATER_ALGORITHM_ENGINEERING_HANDOFF_20260602.md) - [FLOOD_WATER_ALGORITHM_ENGINEERING_HANDOFF_20260602.md](FLOOD_WATER_ALGORITHM_ENGINEERING_HANDOFF_20260602.md)
洪涝/水体算法接入现状、processor 输出契约和工程交接路线。 娲稘/姘翠綋绠楁硶鎺ュ叆鐜扮姸銆乸rocessor 杈撳嚭濂戠害鍜屽伐绋嬩氦鎺ヨ矾绾裤€?
### 瀹夊叏
## 安全
- [SECURITY_AUDIT_2026-03-12.md](SECURITY_AUDIT_2026-03-12.md) - [SECURITY_AUDIT_2026-03-12.md](SECURITY_AUDIT_2026-03-12.md)
安全审计记录。 瀹夊叏瀹¤璁板綍銆?
## 宸ヤ綔绗旇
## 工作笔记
- [../INIT.md](../INIT.md) - [../INIT.md](../INIT.md)
工作笔记。只用于辅助理解现场状态,不替代正式文档。 宸ヤ綔绗旇锛屽彧鐢ㄤ簬杈呭姪鐞嗚В鐜板満鐘舵€侊紝涓嶆浛浠f寮忔枃妗c€?
## 鍘嗗彶褰掓。
## 已删除的历史材料 - [archived_20260623/README.md](archived_20260623/README.md)
2026-06-23 浠?`docs/` 鏍圭洰褰曠Щ鍑虹殑鍘嗗彶銆佸疄楠屻€佽繃绋嬪拰闃舵鎬ф枃妗f竻鍗曘€傝繖浜涙枃妗d笉鍐嶄綔涓哄綋鍓嶇郴缁熶簨瀹炰緷鎹€?
以下材料已从仓库文档树删除,不再维护: 闇€瑕佸垽鏂綋鍓嶅疄鐜版椂锛屼紭鍏堢湅浠g爜鍏ュ彛銆乣README.md` 鍜屾湰绱㈠紩鍒楀嚭鐨勫綋鍓嶆湁鏁堟枃妗c€?
- 旧 `docs/archive/` 历史堆积目录;
- 旧 SBAS 过程文档和试验 runbook;
- 旧 ISCE2/MintPy/SARscape 时序生产设计;
- 过期的 PyINT/Gamma 实验记录;
- 过期的 Sentinel-1 阶段计划;
- 过期的配对增强计划和阶段性审计快照。
需要判断当前实现时,优先看代码入口和本索引列出的文档。
@@ -0,0 +1,333 @@
# LandSAR 集群 Worker 设计与部署记录(2026-06-24)
## 结论
本次改造是在保留本机 LandSAR 生产链路的前提下,新增 LandSAR 集群执行入口。
本机旧入口仍然是 `LANDSAR_RUN`:由主服务器上的一个控制器串行处理一个批次内的 pair。新入口是 `LANDSAR_CLUSTER_ITEM`:主服务器提交集群任务后,系统按 pair 拆成多条队列任务,由本机或远端 Windows worker 领取执行。
当前规划的远端计算服务器是:
- 主服务器 / PostgreSQL / Web 系统:`192.168.1.62`
- 远端 LandSAR worker:`192.168.1.6`
远端 worker 的 `.env` 里 `DATABASE_URL` 必须指向主服务器 `192.168.1.62`,不是写它自己 `192.168.1.6`。
## 已落地代码
后端集群入口:
- `backend/app/routers/dinsar_production.py`
- 新增 `POST /dinsar-production/landsar-cluster/run`
- 只接受 `engine_code=landsar`
- 提交后按 Task/pair 拆分为多个 `LANDSAR_CLUSTER_ITEM`
生产服务:
- `backend/app/services/dinsar_production_service.py`
- 新增 `create_landsar_cluster_run`
- 复用现有 `DinsarProductionRunORM`、`DinsarProductionRunItemORM`、`DinsarProductionExecutionORM`
- 不新增 PG 表结构
- 新增 `LANDSAR_CLUSTER_RUN` 父任务类型
队列与 worker:
- `backend/app/services/job_queue_service.py`
- `claim_next_job` 支持 `allowed_job_types`
- `backend/app/services/job_worker.py`
- 新增 `JOB_WORKER_ALLOWED_TYPES` 过滤
- 远端 worker 可配置为只领取 `LANDSAR_CLUSTER_ITEM`
- `backend/app/services/job_handlers.py`
- 新增 `LANDSAR_CLUSTER_ITEM` handler
- 每个 handler 只处理一个 pair
- 使用本进程本地锁避免单台机器上多个 LandSAR 任务并发抢授权
- 不使用旧 `wsl_dinsar_landsar` 全局数据库锁,因此多台服务器可以并行处理不同 pair
远端专用入口:
- `run_landsar_cluster_worker.py`
- Windows 远端直接运行此脚本即可
- 默认只领取 `LANDSAR_CLUSTER_ITEM`
- 默认并发为 1
- `scripts/start_landsar_cluster_worker.ps1`
- 远端 Windows 推荐启动器
- 检查 `.env`
- 自动定位 Python
- 创建 `logs\landsar_cluster_worker`
- 支持前台运行和 `-Background` 后台运行
- `scripts/start_landsar_cluster_worker.bat`
- 远端双击启动入口,内部调用 PowerShell 启动器
主服务器网络准入脚本:
- `scripts/sync_landsar_cluster_network_access.ps1`
- 从主服务器 `.env` 读取 `LANDSAR_CLUSTER_ALLOWED_WORKER_IPS`
- 同步 PostgreSQL `pg_hba.conf`
- 同步 Windows 防火墙 TCP `5432`
- reload PostgreSQL
前端入口:
- `frontend/src/DinsarProductionPanel.jsx`
- LandSAR 引擎下新增“提交 LandSAR 集群”按钮
- 原“提交任务”按钮仍走本机旧链路
## 主服务器配置
主服务器 `.env` 增加:
```env
LANDSAR_CLUSTER_ALLOWED_WORKER_IPS=192.168.1.6
```
如果后续增加更多 worker,用逗号或分号分隔:
```env
LANDSAR_CLUSTER_ALLOWED_WORKER_IPS=192.168.1.6,192.168.1.7,192.168.1.8
```
每次修改后在主服务器执行:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\sync_landsar_cluster_network_access.ps1
```
脚本会把允许的 worker IP 写入 `D:\PostgreSQLData\pg_hba.conf` 的受管控区块:
```text
# BEGIN InSAR LandSAR cluster workers
host insar_management all 192.168.1.6/32 scram-sha-256
# END InSAR LandSAR cluster workers
```
同时维护 Windows 防火墙规则:
```text
InSAR PostgreSQL 5432 LandSAR Cluster
```
当前主服务器已完成配置:
- PostgreSQL 监听 `0.0.0.0:5432`
- `pg_hba.conf` 只允许 `192.168.1.6/32` 访问 `insar_management`
- 防火墙 TCP `5432` 只允许 `192.168.1.6`
## 远端 192.168.1.6 需要复制什么
推荐复制整个当前仓库,而不是只挑脚本。
原因是远端 worker 虽然只运行 `run_landsar_cluster_worker.py`,但它会 import 后端配置、ORM、队列服务、LandSAR engine、结果发布服务、任务服务等模块。只复制单个脚本会缺依赖。
建议远端目录保持一致:
```text
D:\Code\Insar_management_system_v2
```
至少要确保这些内容在远端存在并与主服务器代码版本一致:
- `run_landsar_cluster_worker.py`
- `backend/`
- `scripts/`
- `config/`
- `.env`
- Python 依赖环境
- LandSAR 安装目录
- DEM 文件
- 任务输入目录或可访问的任务输入路径
- 结果返回目录或可访问的结果目录
前端 `frontend/` 对远端 worker 不是运行必需,但为了版本一致,建议整仓同步。
## 远端 192.168.1.6 的 .env
远端 `.env` 模板已维护在:
```text
config\landsar_cluster_worker.env.example
```
复制为远端项目根目录 `.env`:
```powershell
Copy-Item config\landsar_cluster_worker.env.example .env
```
远端 `.env` 的核心配置:
```env
DATABASE_URL=postgresql+asyncpg://postgres:WXZXzhb123456@192.168.1.62:5432/insar_management
JOB_WORKER_ALLOWED_TYPES=LANDSAR_CLUSTER_ITEM
JOB_WORKER_CONCURRENCY=1
JOB_WORKER_POLL_INTERVAL=1.0
LANDSAR_CLUSTER_WORKER_ID=
```
还需要按远端实际 LandSAR 环境配置这些项:
```env
LANDSAR_ENABLED=true
LANDSAR_HOME=D:\LandSAR
LANDSAR_CONSOLE_EXE=D:\LandSAR\InSAR_Console.exe
LANDSAR_WORK_ROOT=D:\LandSAR_Work
LANDSAR_RUNTIME_PATHS=D:\LandSAR
LANDSAR_LICENSE_MODE=netVersion
LANDSAR_LICENSE_HOST=127.0.0.1
LANDSAR_LICENSE_PORT=6666
LANDSAR_CONFIG_ROW=netVersion,zh,127.0.0.1,6666
LANDSAR_CONFIG_AUTO_WRITE=true
LANDSAR_AUTH_SERVER_EXE=D:\Code\Insar_management_system_v2\third_party\LandSAR\tools\_portable_release\LandSAR_auth_tools_win64\landsar_net_auth_server.exe
LANDSAR_AUTH_SERVER_AUTO_START=true
LANDSAR_AUTH_SERVER_HOST=127.0.0.1
LANDSAR_AUTH_SERVER_PORT=6666
LANDSAR_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape_global_int16.tif
LANDSAR_DINSAR_TIMEOUT_SECONDS=43200
```
如果远端的 LandSAR 授权服务器、安装路径、DEM 路径不同,按远端实际路径填写。
建议给 `LANDSAR_CLUSTER_WORKER_ID` 一个稳定值,方便主服务器健康检查区分节点:
```env
LANDSAR_CLUSTER_WORKER_ID=landsar-worker-192-168-1-6
```
## 远端 Windows 启动命令
在 `192.168.1.6` 上进入项目目录:
```powershell
Set-Location D:\Code\Insar_management_system_v2
```
启动 worker:
```powershell
.\scripts\start_landsar_cluster_worker.ps1
```
看到类似输出即表示监听程序已启动:
```text
[*] Starting LandSAR cluster worker...
[*] Allowed job types: LANDSAR_CLUSTER_ITEM
[*] Poll interval: 1s
[*] Concurrency: 1
```
需要双击启动时,运行:
```text
D:\Code\Insar_management_system_v2\scripts\start_landsar_cluster_worker.bat
```
需要后台启动时,运行:
```powershell
.\scripts\start_landsar_cluster_worker.ps1 -Background
```
启动日志在:
```text
D:\Code\Insar_management_system_v2\logs\landsar_cluster_worker
```
## 远端连通性检查
在 `192.168.1.6` 上检查能否连主服务器数据库:
```powershell
Test-NetConnection 192.168.1.62 -Port 5432
```
应看到:
```text
TcpTestSucceeded : True
```
再用 Python 检查数据库认证:
```powershell
$env:DATABASE_URL='postgresql+asyncpg://postgres:WXZXzhb123456@192.168.1.62:5432/insar_management'
@'
import asyncio, os
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
async def main():
engine = create_async_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)
async with engine.connect() as conn:
print((await conn.execute(text("select 1"))).scalar_one())
await engine.dispose()
asyncio.run(main())
'@ | C:\ProgramData\anaconda3\envs\InSAR\python.exe -
```
应输出:
```text
1
```
## 生产使用流程
1. 主服务器前端进入 D-InSAR 生产管理。
2. 选择 LandSAR 引擎。
3. 选择生产根目录,例如 `D:\Task_Pool\DInSAR` 或某个具体 `Task_*` 父目录。
4. 点击“提交 LandSAR 集群”。
5. 后端创建一个 `LANDSAR_CLUSTER_RUN` 父任务。
6. 每个 pair 生成一条 `LANDSAR_CLUSTER_ITEM` 队列任务。
7. 本机或远端 worker 抢占 item。
8. worker 调用本机 LandSAR 环境处理该 pair。
9. item 完成后写 execution manifest,并尝试发布到 D-InSAR 结果 catalog。
10. 所有 item 进入终态后,父 run 标记为完成、失败或取消。
## 当前重要约束
这次改造解决的是“按 pair 分片调度和多 worker 领取”的问题,不是完整的数据自动搬运系统。
因此远端 `192.168.1.6` 必须满足以下路径条件之一:
1. 与主服务器保持相同盘符和目录结构,并能看到同样的 `Task_Pool` 输入数据。
2. 或者远端通过映射盘、同步工具、计划任务等方式,把所需 pair 的输入数据准备到相同路径。
3. 结果输出目录也必须让主服务器可以扫描或访问,否则 worker 虽然能计算,结果不会自然回到主服务器 catalog。
当前代码中的 LandSAR cluster item 使用数据库里已有的 `source_task_dir` 作为 LandSAR 输入目录;它不会自动从源压缩包解包到远端,也不会自动把远端本地结果复制回主服务器。
后续若要彻底工程化,应新增两个能力:
- 集群 item 开始前:按 pair 从源压缩包或主服务器 Task_Pool materialize 到远端本地工作目录。
- 集群 item 完成后:把标准产品包从远端回传到主服务器结果目录,再由主服务器统一入库。
## 不要混淆的 IP
`192.168.1.62` 是主服务器 IP,负责:
- Web 后端
- PostgreSQL
- 任务队列
- 数据库自维护
- 前端操作入口
`192.168.1.6` 是远端 LandSAR worker IP,负责:
- 常驻监听 `LANDSAR_CLUSTER_ITEM`
- 调用本机 LandSAR
- 写 item 执行状态
所以远端 `.env` 中:
```env
DATABASE_URL=...@192.168.1.62:5432/insar_management
```
而主服务器 `.env` 中:
```env
LANDSAR_CLUSTER_ALLOWED_WORKER_IPS=192.168.1.6
```
这两个配置方向不同,不能互换。
+163
View File
@@ -0,0 +1,163 @@
# 综合统计界面设计说明
## 定位
综合统计页定位为 **InSAR 数据与生产统计**,不是营销式展示大屏,也不再使用“生产态势驾驶舱”命名。
页面面向系统运维、生产管理和汇报前复核,核心目标是用可追溯统计口径回答:
- 当前 LT-1、Sentinel-1、GF3 源数据底座是否齐备。
- 元数据解析、几何画像、预览缓存和精轨保障是否支撑生产。
- D-InSAR / SBAS 任务规划、生产运行和成果登记是否稳定。
- 当前是否存在需要处理的资产、生产或成果问题。
综合统计必须以后端聚合口径为准,不能复用影像检索页的分页结果。影像检索或资产列表中的“一键显示”只代表当前检索结果或当前页可见数据,不代表全库统计。
## 当前实现
前端页面:`frontend/src/StatisticsDashboard.jsx`
前端样式:`frontend/src/App.css`
后端接口:`GET /api/statistics/dashboard`
页面不自动轮询,只提供手动刷新,避免统计聚合影响生产服务。
## 页面结构
1. 顶部 KPI:源数据、几何画像、精轨绑定、生产任务、成果和问题的核心计数。
2. 市级覆盖热力:分为源数据覆盖和成果覆盖两个视图,按场景或成果中心点落入的市级行政区统计数量。
3. 数据底座与生产链路:展示源资产登记、元数据、几何画像、可生产画像、预览缓存等链路状态。
4. 时间分布与精轨保障:展示源数据月份分布、精轨资产和精轨绑定覆盖率。
5. 生产与成果:展示 D-InSAR / SBAS 运行状态、成果趋势、开放问题和最近任务状态。
## 统计口径
### 源数据资产
主口径使用 `source_product_assets`。
当前系统约定:
- LT-1 管理源压缩包,扫描时从压缩包内提取 XML、预览和几何信息,参与生产时再按需解包到 `Task_Pool`。
- Sentinel-1 管理源压缩包,扫描时从 SAFE zip 内提取 manifest、annotation、preview 和几何信息,参与生产时再按需解包。
- GF3 管理外部服务器生产后的 `native_geo` 成果和快视图资产,本机负责登记、WebP 缓存和后续成果管理。
展示内容:
- 源数据资产总量。
- LT-1 / Sentinel-1 / GF3 分类数量。
- 元数据文档提取率。
- 几何画像可用率。
- 源资产按月份分布。
- 入库到可生产链路:源资产登记、元数据入库、几何画像、可生产画像、预览缓存。
### 市级覆盖热力
空间覆盖不能只统计三类源数据,也必须统计生产后的结果产品。两类对象分开展示,不混算。
#### 源数据覆盖
主口径使用 `sar_scene_geometry_profiles`:
- 使用 `scene_center_lon` / `scene_center_lat` 落入市级行政区的数量。
- 统计对象包括 LT-1、Sentinel-1、GF3 三类源数据。
- tooltip 展示市级单位、源数据总数和数据源构成。
#### 成果覆盖
主口径使用 `result_products`:
- 使用 `coverage_polygon` 或 bbox 中心点落入市级行政区的数量。
- 统计对象包括已登记的 D-InSAR、SBAS 等结果产品,按 `catalog_name` 区分。
- tooltip 展示市级单位、成果总数和成果类型构成。
当前实现不是经纬度散点图,也不是经纬度格网,而是后端按行政区聚合,前端用 ECharts Map 分级设色:
- 页面在“源数据市级覆盖热力”和“成果市级覆盖热力”之间切换。
- 不显示笛卡尔经纬度坐标轴。
- 每个市级行政区按源数据景数或成果项数确定填充颜色。
- 行政区边界来自系统现有全国行政区 GeoJSON/AOI 服务。
必须明确的限制:
- 当前热力图统计的是中心点落区数量,不等同于真实场景 footprint 覆盖面积。
- 结果产品当前按 coverage polygon 或 bbox 中心点落区,不等同于真实有效像元面积。
- 当前热力图用于工程态势判断,不能直接作为正式面积统计、行政区覆盖率或成果质量评价结论。
- 正式覆盖率需要进一步接入行政边界、AOI 或真实有效像元 mask。
### 精轨保障
主口径使用 `orbit_assets` 和 `scene_orbit_bindings`。
展示内容:
- 精轨资产总数。
- LT-1 / Sentinel-1 精轨分类。
- 已选中精轨绑定数。
- 精轨绑定覆盖率。
生产约束:
- 统计页只展示精轨资产和绑定情况,不负责推断具体生产引擎如何取轨。
- 具体生产取轨逻辑以 `PRECISE_ORBIT_PRODUCTION_CONTRACT_20260617.md` 为准。
### 生产运行
主口径使用:
- `dinsar_task_batches`
- `dinsar_task_items`
- `dinsar_production_runs`
- `workflow_runs`
展示内容:
- D-InSAR 任务规划状态。
- D-InSAR 生产运行状态。
- 最近生产批次。
- 平均运行耗时。
### 成果与问题
主口径使用:
- `result_products`
- `result_assets`
- `result_issues`
- `asset_inventory_issues`
- `asset_inventory_states`
展示内容:
- D-InSAR / SBAS 成果数量。
- 成果按月份趋势。
- 成果预览和缺失资产统计。
- 开放问题按类型分布。
- 最近扫描状态。
## 视觉原则
- 使用工程统计页面语义,不使用“驾驶舱”作为默认标题。
- 页面保持浅色工程管理风格,避免深色大屏、装饰化背景和无法解释的数据可视化。
- 市级覆盖热力图可以作为首屏主要图表,但必须服务于生产判断:哪些市源数据集中、哪些市已有成果、哪些市缺数据或缺成果。
- 图表文案必须写清统计口径,避免把中心点落区数量误读为正式面积覆盖。
- 不引入 Three.js;统计页优先保证真实数据、稳定性能和可追溯口径。
## 后续增强
优先级从高到低:
1. 扫描阶段固化 `corner_pixel_mapping`、有序角点和 footprint 质量标识,保证覆盖边界可追溯。
2. 结果登记阶段固化成果有效像元范围,区分产品 bbox、footprint 和有效数据 mask。
3. 增加省份/项目区筛选,支持只看黑龙江省或指定业务区。
4. 增加按数据源、结果类型、时间窗口、产品可生产性、精轨绑定状态的筛选。
5. 增加图表 drill-down,点击统计单元可跳转到资产台账、影像检索或成果列表。
6. 如果后续需要汇报模式,只做布局适配,不改变统计口径。
## 变更记录
- 2026-06-23:覆盖图升级为市级行政区热力统计,按中心点落入市级单位计数,分离“源数据覆盖”和“成果覆盖”。
- 2026-06-23:覆盖图曾短暂使用后端格网热力统计,因展示效果不适合综合统计页,调整为市级行政区分级设色。
- 2026-06-23:页面命名调整为“InSAR 数据与生产统计”;覆盖图从经纬度散点调整为空间覆盖密度图;文档明确当前图不等同于正式面积统计。
- 2026-06-22:综合统计从影像检索弹窗提升为一级页面,统计口径独立于检索分页结果。
@@ -0,0 +1,346 @@
# 存储压力清理与治理设计
最后更新:2026-06-23
本文档记录系统后续增加“释放存储压力”能力的设计边界。当前阶段仅作为维护和设计依据,不代表已经实现清理按钮、接口或数据库表。
## 1. 背景
当前系统已经明确退出 UNC 活动生产链路,LT-1、Sentinel-1、高分三、精密轨道、DEM、Task_Pool、运行时和结果发布目录均要求走本机路径。
本机化以后,磁盘压力主要来自:
- LT-1 / Sentinel-1 源压缩包持续增长;
- WebP、预览图源缓存和雷达缩略缓存持续增长;
- D-InSAR / SBAS-InSAR 的 Task_Pool materialize 目录持续增长;
- LandSAR、ENVI/SARscape、Gamma/PyINT、IDL、WSL broker 等运行时临时目录持续增长;
- `system_tasks` / `task_logs`、生产运行日志、诊断日志等数据库记录持续增长;
- 失败任务、调试任务和隔离区残留。
清理能力必须服务于生产稳定性,不能把“释放空间”做成粗暴删除目录。系统需要先判断数据角色、数据库引用、任务状态和可重建性,再生成清理计划。
## 2. 总原则
1. 源数据不清理。
LT-1 / Sentinel-1 源压缩包、高分三 `_geo` 原生成果、DEM、精密轨道池是生产输入或登记对象,普通存储清理不得删除。
2. 先 dry-run,后执行。
所有清理动作必须先生成计划,列出路径、大小、数据库影响、风险等级和预计释放空间。用户确认后才执行。
3. 清理动作必须可审计。
系统必须记录谁在什么时候按什么规则清理了哪些文件、释放了多少空间、哪些失败、哪些数据库记录被更新。
4. 优先清理可重建派生物。
日志、过期缓存、旧版本缓存、失败任务临时目录、运行时临时目录、过期隔离区优先进入第一阶段。
5. 正式成果不走普通清理。
`D:\production_results` 及其 catalog 注册成果不能被“一键清理”删除。成果删除或归档应走单独的结果管理流程。
6. Task_Pool 清理必须依赖生产状态。
`D:\Task_Pool` 下 materialize 出来的生产输入理论上可从源压缩包重建,但只有在任务已结束、无活动执行、结果已登记或用户明确确认后才能清理。
7. 文件状态和数据库状态必须同步。
如果删除了数据库引用的 WebP 缓存、运行记录、结果资产或任务日志,必须同步更新对应表,避免前端显示“可用”但文件已不存在。
## 3. 永不进入普通清理的对象
以下对象默认不可被普通“释放存储压力”功能删除:
| 对象 | 典型路径 / 配置 | 原因 |
| --- | --- | --- |
| LT-1 源压缩包 | `SOURCE_PRODUCT_DIRS` 中的 `D:\LuTan1_Image_Pool_Zip` | 源数据,是按需解包和重新生产的根 |
| Sentinel-1 源压缩包 | `SOURCE_PRODUCT_DIRS` 中的 `D:\Sentinel1_Image_Pool_ZIP` | 源数据,是按需解包和重新生产的根 |
| 高分三 `_geo` 成果池 | `GF3_SARSCAPE_NATIVE_DIRS=D:\GaoFen3_Pool\native_geo` | 本机登记对象,WebP 从这里生成 |
| 高分三 catalog | `GF3_STORAGE_DIRS=D:\GaoFen3_Pool\catalog` | 平台登记 manifest 和追踪材料 |
| LT-1 / S1 原生精轨源池 | `ORBIT_SOURCE_DIRS` | 轨道源资产 |
| LT-1 生产精轨池 | `ORBIT_POOL_ENVI` / `PYINT_ORBIT_POOL_TXT` / `GAMMA_SBAS_ORBIT_ROOTS` | ENVI、LandSAR、Gamma/PyINT 生产依赖 |
| DEM | `D:\DEM` 及相关 DEM 配置 | D-InSAR / SBAS / GF3 生产依赖 |
| 正式发布成果 | `RESULT_PUBLISH_ROOT`、`DINSAR_PRODUCT_DIR`、`TIMESERIES_PRODUCT_DIR` | 结果 catalog 管理对象,不走普通清理 |
如确需删除以上对象,必须另设“源数据归档/删除”或“成果归档/删除”专项流程,不能复用普通清理按钮。
## 4. 可清理对象分级
### 4.1 低风险:第一阶段优先实现
| 类别 | 规则 | 数据库动作 |
| --- | --- | --- |
| 任务日志 | 清理已结束任务的旧日志,保留最近 N 天或每任务最后 N 条 | 删除 `task_logs`,可保留任务摘要 |
| 已结束旧任务记录 | 只清 `COMPLETED` / `FAILED` / `CANCELLED`,不得清 `PENDING` / `RUNNING` | 删除 `system_tasks` 及日志,或仅压缩日志 |
| 无引用缓存文件 | `backend\image_cache` 下没有数据库引用、文件不存在于当前版本策略的缓存 | 文件删除即可,记录清理项 |
| 旧版本 WebP 缓存 | `RADAR_GEO_CACHE_VERSION` 已变化且数据库不再引用 | 删除文件;如仍被引用,先更新数据库 |
| 过期隔离区 | 隔离超过保留期的文件 | 删除隔离记录或更新清理项 |
### 4.2 中风险:第二阶段实现
| 类别 | 规则 | 数据库动作 |
| --- | --- | --- |
| 雷达 WebP 缓存 | 可从源压缩包或 GF3 `_geo` 重建;默认只清旧版本、孤立文件 | 若删除当前引用缓存,`radar_data.preview_cache_status` 改为 `NONE`,写入 `preview_cache_error=storage_cleanup_removed` |
| 预览图源缓存 | `radar_archive_preview_sources` 等从压缩包提取的中间缓存 | 可删除,后续扫描或预览重建 |
| 运行时临时目录 | `production_runtime`、IDL runtime、WSL jobs、PyINT work、临时 DEM 裁剪 | 仅清无活动任务、超过保留期的目录 |
| GF3 SARscape runtime | `GF3_TASK_POOL_ROOT` / `GF3_SARSCAPE_RUNTIME_DIR` | 当前本机 GF3 不生产,原则上仅清失败/过期 runtime,不清 `_geo` |
### 4.3 高风险:第三阶段谨慎实现
| 类别 | 规则 | 数据库动作 |
| --- | --- | --- |
| D-InSAR Task_Pool materialize 目录 | 任务结束、无活动执行、可由源压缩包重建、用户确认 | 更新批次/任务的 materialize 状态 |
| SBAS Task_Pool materialize 目录 | 任务结束、无活动执行、结果或失败状态明确 | 更新 SBAS 生产运行状态 |
| D-InSAR / SBAS 中间文件 | 只清已发布结果之外的中间产物 | 必须依赖 result catalog 和 run manifest |
### 4.4 不在本功能处理
- 源压缩包去重、归档、外发;
- 正式结果删除;
- DEM 版本删除;
- 精轨池删除;
- PostgreSQL VACUUM / 备份压缩;
- 洪水检测专项数据清理。
## 5. 清理计划模型
后续实现时,清理流程应分为两个动作:
1. `PLAN`
只扫描并估算,不删除文件,不修改业务表。
2. `APPLY`
按用户确认的计划执行,逐项记录结果,必要时同步更新数据库。
清理计划每一项至少包含:
```json
{
"category": "radar_preview_cache",
"action": "delete_file",
"path": "D:\\Code\\Insar_management_system_v2\\backend\\image_cache\\radar_geo\\xxx.webp",
"size_bytes": 123456,
"risk_level": "low",
"reason": "old_cache_version",
"db_table": "radar_data",
"db_pk": 123,
"db_update": {
"preview_cache_status": "NONE",
"preview_cache_error": "storage_cleanup_removed"
}
}
```
## 6. 建议新增数据库表
为了审计和可追溯,建议新增两张表。
### 6.1 `storage_cleanup_runs`
| 字段 | 含义 |
| --- | --- |
| `run_id` | 清理任务 ID |
| `status` | `PLANNED` / `RUNNING` / `COMPLETED` / `FAILED` / `CANCELLED` |
| `dry_run` | 是否只生成计划 |
| `categories` | 本次涉及类别 |
| `planned_bytes` | 计划释放空间 |
| `released_bytes` | 实际释放空间 |
| `planned_count` | 计划项数量 |
| `succeeded_count` | 成功项数量 |
| `failed_count` | 失败项数量 |
| `started_at` / `ended_at` | 执行时间 |
| `operator_user_id` | 操作用户 |
| `report_json` | 汇总报告 |
### 6.2 `storage_cleanup_items`
| 字段 | 含义 |
| --- | --- |
| `run_id` | 所属清理任务 |
| `category` | 清理类别 |
| `action` | `delete_file` / `delete_dir` / `quarantine` / `delete_db_rows` / `update_db_rows` |
| `path` | 文件或目录路径 |
| `size_bytes` | 大小 |
| `risk_level` | `low` / `medium` / `high` |
| `reason` | 命中规则 |
| `db_table` / `db_pk` | 关联数据库对象 |
| `before_json` / `after_json` | 数据库变更前后摘要 |
| `quarantine_path` | 隔离路径 |
| `status` | 单项执行状态 |
| `error` | 失败原因 |
第一阶段也可以先不建表,使用 `system_tasks` + JSON 报告落地,但正式实现建议单独建表。
## 7. 路径安全规则
所有文件清理必须满足以下规则:
1. 路径必须位于白名单根目录下。
2. 禁止删除盘符根目录,例如 `D:\`。
3. 禁止删除项目根目录、数据库目录、Python 环境目录、Nginx 目录。
4. 禁止处理 UNC 路径。
5. 禁止跟随符号链接逃逸白名单根目录。
6. 删除目录前必须重新计算 resolved path 并确认仍在白名单内。
7. 默认先移动到隔离区,隔离区过期后再永久删除。
8. 单次执行应有最大删除数量和最大删除字节数上限。
建议白名单根目录来自配置和系统常量:
- `backend\image_cache`
- `TASK_POOL_ROOT`
- `DINSAR_TASK_POOL_ROOT`
- `SBAS_TASK_POOL_ROOT`
- `GF3_TASK_POOL_ROOT`
- `DATA_DISTRIBUTION_ROOT`
- `IDL_WORKER_RUNTIME_DIR`
- `SAR_ANALYSIS_WORK_ROOT`
- `WSL_BROKER_JOB_ROOT`
- `PYINT_WORK_ROOT`
- `PYINT_DEM_ROOT`
- `GAMMA_SBAS_TRIAL_ROOT`
- `RESULT_QUARANTINE_ROOT`
其中 `RESULT_PUBLISH_ROOT` 只允许扫描统计,不允许普通清理删除。
## 8. 任务互斥和运行保护
执行清理前必须检查活动任务:
- 存在 WebP 构建任务时,禁止清理 `backend\image_cache`;
- 存在资产扫描任务时,禁止清理预览图源缓存;
- 存在 D-InSAR 生产任务时,禁止清理 `DINSAR_TASK_POOL_ROOT` 和 D-InSAR runtime;
- 存在 SBAS 生产任务时,禁止清理 `SBAS_TASK_POOL_ROOT`、`GAMMA_SBAS_WORK_ROOT` 和 SBAS runtime;
- 存在 GF3 标准化或 WebP 生成任务时,禁止清理 `GF3_TASK_POOL_ROOT`;
- 禁止清理任何 `PENDING` / `RUNNING` 任务关联的目录。
后端实现应使用任务类型锁或 PostgreSQL advisory lock,避免多个清理任务并发执行。
## 9. 建议默认保留策略
以下值是初始建议,后续可放入 `.env`:
| 配置 | 建议默认值 | 含义 |
| --- | --- | --- |
| `RUNTIME_CLEANUP_TASK_LOG_RETENTION_DAYS` | 30 | 已结束任务日志保留天数 |
| `RUNTIME_CLEANUP_TASK_RECORD_RETENTION_DAYS` | 90 | 已结束任务记录保留天数 |
| `RUNTIME_CLEANUP_IMAGE_CACHE_RETENTION_DAYS` | 60 | 无引用缓存保留天数 |
| `RUNTIME_CLEANUP_RUNTIME_RETENTION_DAYS` | 30 | 运行时临时目录保留天数 |
| `RUNTIME_CLEANUP_FAILED_RUNTIME_RETENTION_DAYS` | 7 | 失败任务临时目录保留天数 |
| `RUNTIME_CLEANUP_TASK_POOL_RETENTION_DAYS` | 30 | 可重建 Task_Pool materialize 目录保留天数 |
| `RUNTIME_CLEANUP_QUARANTINE_RETENTION_DAYS` | 14 | 隔离区永久删除前保留天数 |
默认只启用低风险类别。Task_Pool 和中间文件清理应默认关闭,需要用户显式勾选。
## 10. 前端工作台设计方向
入口建议放在“运行维护 / 存储治理”,而不是放在资产扫描、生产准备或数据分发按钮旁边。
页面结构建议:
1. 存储概览
- 按磁盘卷展示总容量、已用、剩余、压力等级;
- 展示系统可治理目录的估算占用;
- 单独标注“受保护源数据”和“可清理派生数据”。
2. 清理类别
- 任务日志;
- 图像缓存;
- 运行时临时目录;
- Task_Pool materialize;
- 隔离区。
3. 清理计划
- 预计释放空间;
- 文件数量;
- 数据库记录数量;
- 风险等级;
- 样例路径;
- 受保护跳过项。
4. 执行与审计
- 后台任务进度;
- 单项失败列表;
- 释放空间统计;
- 可下载 JSON 报告。
界面文案必须明确区分:
- “源数据,不会删除”;
- “缓存,可重建”;
- “运行临时目录,任务结束后可清”;
- “正式成果,不在本功能删除”。
## 11. 建议接口
后续实现可采用以下接口:
```text
GET /maintenance/storage/overview
POST /maintenance/storage-cleanup/plan
POST /maintenance/storage-cleanup/runs
GET /maintenance/storage-cleanup/runs/{run_id}
GET /maintenance/storage-cleanup/runs/{run_id}/items
POST /maintenance/storage-cleanup/quarantine/purge-plan
POST /maintenance/storage-cleanup/quarantine/purge
```
`plan` 接口只返回计划,不创建删除动作。`runs` 接口基于某次计划执行,并创建后台任务。
## 12. 实施阶段
### 阶段 0:文档维护
- 固化清理边界;
- 确认不清源压缩包、不清 GF3 `_geo`、不清 DEM、精轨和正式成果;
- 后续设计和编码必须引用本文档。
### 阶段 1:低风险清理
- 存储概览;
- 任务日志清理;
- 旧任务记录清理;
- 无引用缓存 dry-run;
- 清理任务审计报告。
### 阶段 2:缓存治理
- WebP 缓存计划;
- 旧版本缓存清理;
- 数据库 `preview_cache_*` 同步;
- 缓存按需重建入口。
### 阶段 3:运行时治理
- `production_runtime`、IDL、PyINT、WSL job、GF3 runtime 清理;
- 运行任务保护;
- 隔离区机制。
### 阶段 4:Task_Pool materialize 治理
- D-InSAR / SBAS Task_Pool 目录识别;
- 与生产批次、生产运行、结果 catalog 关联;
- 可重建性验证;
- 用户确认后清理或隔离。
### 阶段 5:成果归档专项
- 不纳入普通清理;
- 单独设计结果产品归档、下线、删除和恢复流程。
## 13. 验收标准
后续实现完成后,至少满足:
1. dry-run 不改文件、不改业务表;
2. 清理计划能解释每一项为什么可清;
3. 源压缩包、GF3 `_geo`、DEM、精轨池和正式成果不会出现在普通清理执行项中;
4. 删除 WebP 缓存后,数据库状态不会继续显示 `READY`;
5. 活动任务相关目录不会被清理;
6. 所有删除动作有审计记录;
7. 清理失败不会导致整批数据库状态不一致;
8. 前端能展示释放空间、失败项和跳过原因。
## 14. 与现有文档的关系
- 三类数据本机生产边界以 `THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md` 为准。
- 源压缩包管理和按需 materialize 以 `UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md` 为准。
- 源压缩包完整性审计以 `SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md` 为准。
- D-InSAR Task_Pool 和中间文件治理参考 `DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md`。
- 正式成果包和 result catalog 以 `PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md` 为准。
本文档只定义存储压力治理边界,不替代源数据、生产准备、结果管理或完整性审计文档。
+22
View File
@@ -0,0 +1,22 @@
# 2026-06-23 文档归档说明
本目录保存 2026-06-23 文档治理时从 `docs/` 根目录移出的历史、实验、过程和阶段性文档。
归档原则:
- `docs/INDEX.md` 列出的文档继续作为当前有效事实入口。
- 本目录文档只作为历史参考,不再直接指导当前系统设计、部署或生产操作。
- 如果需要重新启用某份归档文档,必须先复核其内容与当前代码、数据库结构、生产流程是否一致,再移回 `docs/` 并加入 `docs/INDEX.md`。
本次归档文件:
- `GAMMA_SBAS_EXPERT_CORRECT_IMPLEMENTATION_ROUTE_20260607.md`
- `GAMMA_SBAS_EXPERT_WORKFLOW_AUDIT_20260608.md`
- `GAMMA_SBAS_RUNTIME_OBSERVATIONS_20260611.md`
- `GAMMA_SBAS_ZERO_RATE_VALID_VISUALIZATION_DESIGN_20260608.md`
- `GF3_WATER_EXTRACTION_INTEGRATION_20260615.md`
- `LANDSAR_SBAS_ARCHIVE_20260606.md`
- `LANDSAR_SBAS_INSAR_INTEGRATION_DESIGN.md`
- `LandSAR_API服务接入采购需求说明书_20260604.md`
- `SBAS_INSAR_GAMMA_EXPERT_WORKFLOW_REVIEW_20260603.md`
- `SENTINEL1_GAMMA_SBAS_NO_STITCH_DESIGN.md`
-1
View File
@@ -26,7 +26,6 @@ export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
taskTypes: ['AI_DIAGNOSIS'], taskTypes: ['AI_DIAGNOSIS'],
showRecent: true, showRecent: true,
recentLimit: 1, recentLimit: 1,
pollRecentMs: 10000,
}); });
// 状态 // 状态
+2086 -412
View File
File diff suppressed because it is too large Load Diff
+4 -44
View File
@@ -4,7 +4,6 @@ import { useShallow } from 'zustand/react/shallow';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import './App.css'; import './App.css';
import LoginPage from './LoginPage'; import LoginPage from './LoginPage';
import AppLogPanel from './components/app/AppLogPanel';
import AppMapWorkspace from './components/app/AppMapWorkspace'; import AppMapWorkspace from './components/app/AppMapWorkspace';
import AppOverlays from './components/app/AppOverlays'; import AppOverlays from './components/app/AppOverlays';
import AppSidePanel from './components/app/AppSidePanel'; import AppSidePanel from './components/app/AppSidePanel';
@@ -18,7 +17,6 @@ import {
} from './store'; } from './store';
import useAppAuthLifecycle from './hooks/useAppAuthLifecycle'; import useAppAuthLifecycle from './hooks/useAppAuthLifecycle';
import useGlobalTaskControl from './hooks/useGlobalTaskControl'; import useGlobalTaskControl from './hooks/useGlobalTaskControl';
import usePanelResize from './hooks/usePanelResize';
import useRegionAoiHandlers from './hooks/useRegionAoiHandlers'; import useRegionAoiHandlers from './hooks/useRegionAoiHandlers';
import usePaginationControls from './hooks/usePaginationControls'; import usePaginationControls from './hooks/usePaginationControls';
import useRadarSearch from './hooks/useRadarSearch'; import useRadarSearch from './hooks/useRadarSearch';
@@ -222,20 +220,13 @@ function App() {
setPendingTaskIds: state.setPendingTaskIds, setPendingTaskIds: state.setPendingTaskIds,
}))); })));
const { const {
leftPanelTab, setLeftPanelTab, leftPanelWidth, setLeftPanelWidth, leftPanelTab, setLeftPanelTab, leftPanelWidth,
rightPanelWidth, setRightPanelWidth, isResizing, setIsResizing, setShowDataInfo, setSelectedDataInfo, showDates,
setShowStats, setShowDataInfo, setSelectedDataInfo, showDates,
baseLayerKey, setBaseLayerKey, isLoading, setIsLoading, addLog, baseLayerKey, setBaseLayerKey, isLoading, setIsLoading, addLog,
} = useUiStore(useShallow((state) => ({ } = useUiStore(useShallow((state) => ({
leftPanelTab: state.leftPanelTab, leftPanelTab: state.leftPanelTab,
setLeftPanelTab: state.setLeftPanelTab, setLeftPanelTab: state.setLeftPanelTab,
leftPanelWidth: state.leftPanelWidth, leftPanelWidth: state.leftPanelWidth,
setLeftPanelWidth: state.setLeftPanelWidth,
rightPanelWidth: state.rightPanelWidth,
setRightPanelWidth: state.setRightPanelWidth,
isResizing: state.isResizing,
setIsResizing: state.setIsResizing,
setShowStats: state.setShowStats,
setShowDataInfo: state.setShowDataInfo, setShowDataInfo: state.setShowDataInfo,
setSelectedDataInfo: state.setSelectedDataInfo, setSelectedDataInfo: state.setSelectedDataInfo,
showDates: state.showDates, showDates: state.showDates,
@@ -423,7 +414,6 @@ function App() {
const hazardLayersRef = useRef({}); const hazardLayersRef = useRef({});
const dinsarResultLayersRef = useRef({}); const dinsarResultLayersRef = useRef({});
const sbasAnalysisLayersRef = useRef({}); const sbasAnalysisLayersRef = useRef({});
const resizeStateRef = useRef({ side: null, startX: 0, startLeft: 0, startRight: 0 });
const allDataRef = useRef(allData); const allDataRef = useRef(allData);
const dinsarResultsRef = useRef(dinsarResults); const dinsarResultsRef = useRef(dinsarResults);
const mapBatchRef = useRef({ frameId: null, token: 0 }); const mapBatchRef = useRef({ frameId: null, token: 0 });
@@ -446,7 +436,7 @@ function App() {
cancelAnimationFrame(frameId); cancelAnimationFrame(frameId);
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
}; };
}, [isStandaloneLeftPage, leftPanelWidth, rightPanelWidth]); }, [isStandaloneLeftPage, leftPanelWidth]);
const getVisibleLayerRefs = useCallback(() => ({ const getVisibleLayerRefs = useCallback(() => ({
activeLayersRef: activeLayersRef.current, activeLayersRef: activeLayersRef.current,
@@ -717,16 +707,6 @@ function App() {
setShowPsModal, setShowPsModal,
}); });
const { startResize } = usePanelResize({
isResizing,
setIsResizing,
leftPanelWidth,
rightPanelWidth,
setLeftPanelWidth,
setRightPanelWidth,
resizeStateRef,
});
const { const {
handleLoginSuccess, handleLoginSuccess,
handleLogout, handleLogout,
@@ -1238,7 +1218,7 @@ function App() {
? '-' ? '-'
: `${(Number(result.ai_score) * 100).toFixed(0)}%`; : `${(Number(result.ai_score) * 100).toFixed(0)}%`;
const engine = getDinsarEngineMeta(result.engine_code); const engine = getDinsarEngineMeta(result.engine_code);
const strategy = escapeHtml(result.selection_strategy || 'legacy'); const strategy = escapeHtml(result.selection_strategy || '标准选择');
const taskAlias = escapeHtml(result.task_alias || result.task_name || result.name || '-'); const taskAlias = escapeHtml(result.task_alias || result.task_name || result.name || '-');
const pairKey = escapeHtml(result.pair_key || '-'); const pairKey = escapeHtml(result.pair_key || '-');
const pairUid = escapeHtml(result.pair_uid || '-'); const pairUid = escapeHtml(result.pair_uid || '-');
@@ -2027,10 +2007,6 @@ function App() {
fetchHealthStatus({ refresh: true }); fetchHealthStatus({ refresh: true });
}, [fetchHealthStatus]); }, [fetchHealthStatus]);
const openStatisticsDashboard = useCallback(() => {
setShowStats(true);
}, [setShowStats]);
const refreshDinsarResults = useCallback(() => { const refreshDinsarResults = useCallback(() => {
fetchDinsarResults({ offset: 0 }); fetchDinsarResults({ offset: 0 });
}, [fetchDinsarResults]); }, [fetchDinsarResults]);
@@ -2046,7 +2022,6 @@ function App() {
showRadarPageInputError, showRadarPageInputError,
radarPageInputValidationError, radarPageInputValidationError,
onSearchAll: searchAllRadarData, onSearchAll: searchAllRadarData,
onShowStats: openStatisticsDashboard,
onSearch: applyRadarSearch, onSearch: applyRadarSearch,
onReset: resetRadarSearch, onReset: resetRadarSearch,
onAoiModeChange: handleRadarSearchAoiModeChange, onAoiModeChange: handleRadarSearchAoiModeChange,
@@ -2178,12 +2153,6 @@ function App() {
sbasAnalysisPanel={sbasAnalysisPanel} sbasAnalysisPanel={sbasAnalysisPanel}
/> />
<div
className="panel-resizer"
onMouseDown={(event) => startResize('left', event)}
style={{ display: isStandaloneLeftPage ? 'none' : undefined }}
/>
<div <div
style={{ style={{
display: isStandaloneLeftPage ? 'none' : 'flex', display: isStandaloneLeftPage ? 'none' : 'flex',
@@ -2212,15 +2181,6 @@ function App() {
/> />
</div> </div>
<div
className="panel-resizer"
onMouseDown={(event) => startResize('right', event)}
style={{ display: isStandaloneLeftPage ? 'none' : undefined }}
/>
<div style={{ display: isStandaloneLeftPage ? 'none' : undefined }}>
<AppLogPanel width={rightPanelWidth} />
</div>
</div> </div>
<AppOverlays <AppOverlays
+1 -38
View File
@@ -5,7 +5,6 @@ import {
listAssetIssues, listAssetIssues,
listOrbitAssets, listOrbitAssets,
listSourceAssets, listSourceAssets,
scanAssetInventory,
} from './api/assets'; } from './api/assets';
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
@@ -58,7 +57,6 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
const [activeTab, setActiveTab] = useState('sources'); const [activeTab, setActiveTab] = useState('sources');
const [family, setFamily] = useState('all'); const [family, setFamily] = useState('all');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [scanLoading, setScanLoading] = useState(false);
const [auditLoading, setAuditLoading] = useState(false); const [auditLoading, setAuditLoading] = useState(false);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -90,35 +88,6 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
refresh(); refresh();
}, [refresh]); }, [refresh]);
const handleScan = async (scanPayload = {}, label = '源数据/精轨资产扫描') => {
if (readOnly || scanLoading) return;
setScanLoading(true);
setMessage('');
setError('');
const requestPayload =
scanPayload && typeof scanPayload === 'object' && scanPayload.nativeEvent
? {}
: scanPayload;
try {
const result = await scanAssetInventory({
inventory_types: [],
root_ids: [],
bind_orbits: true,
families: INVENTORY_FAMILIES,
...requestPayload,
});
setMessage(`资产扫描任务已入队: ${result.task_id}`);
onTaskStart?.(result.task_id, '源数据/精轨资产扫描已入队', {
taskType: 'SCAN_ASSET_INVENTORY',
nonBlocking: true,
});
} catch (err) {
setError(err?.response?.data?.detail || err.message || '启动资产扫描失败');
} finally {
setScanLoading(false);
}
};
const handleArchiveIntegrityAudit = async (auditPayload = {}, label = '压缩包完整性审计') => { const handleArchiveIntegrityAudit = async (auditPayload = {}, label = '压缩包完整性审计') => {
if (readOnly || auditLoading) return; if (readOnly || auditLoading) return;
setAuditLoading(true); setAuditLoading(true);
@@ -169,7 +138,7 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
<div className="asset-toolbar"> <div className="asset-toolbar">
<div> <div>
<h3>源数据与精轨资产</h3> <h3>源数据与精轨资产</h3>
<p>Sentinel-1 与 LT-1 的源产品、精密轨道和绑定状态</p> <p>查看 Sentinel-1 与 LT-1 的源产品、精密轨道、绑定状态和开放问题;资产登记由数据接入与运维流程维护。</p>
</div> </div>
<div className="asset-actions"> <div className="asset-actions">
<select value={family} onChange={(e) => setFamily(e.target.value)} disabled={loading}> <select value={family} onChange={(e) => setFamily(e.target.value)} disabled={loading}>
@@ -178,12 +147,6 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
<option value="LT1">LT-1</option> <option value="LT1">LT-1</option>
</select> </select>
<button type="button" onClick={() => refresh()} disabled={loading}>刷新</button> <button type="button" onClick={() => refresh()} disabled={loading}>刷新</button>
<button type="button" onClick={() => handleScan({ families: INVENTORY_FAMILIES }, '全部资产扫描')} disabled={readOnly || scanLoading}>全部扫描</button>
<button type="button" onClick={() => handleScan({ families: ['LT1'] }, 'LT-1资产扫描')} disabled={readOnly || scanLoading}>LT-1扫描</button>
<button type="button" onClick={() => handleScan({ families: ['S1'] }, 'Sentinel-1资产扫描')} disabled={readOnly || scanLoading}>S1扫描</button>
<button type="button" onClick={() => handleScan({ inventory_types: ['orbit_asset'], families: INVENTORY_FAMILIES }, '全部精轨扫描')} disabled={readOnly || scanLoading}>全部精轨</button>
<button type="button" onClick={() => handleScan({ inventory_types: ['orbit_asset'], families: ['LT1'] }, 'LT-1精轨扫描')} disabled={readOnly || scanLoading}>LT-1精轨</button>
<button type="button" onClick={() => handleScan({ inventory_types: ['orbit_asset'], families: ['S1'] }, 'Sentinel-1精轨扫描')} disabled={readOnly || scanLoading}>S1精轨</button>
<button type="button" onClick={() => handleArchiveIntegrityAudit()} disabled={readOnly || auditLoading}> <button type="button" onClick={() => handleArchiveIntegrityAudit()} disabled={readOnly || auditLoading}>
{auditLoading ? '审计启动中' : '压缩包完整性审计'} {auditLoading ? '审计启动中' : '压缩包完整性审计'}
</button> </button>
+1 -1
View File
@@ -53,7 +53,7 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
if (taskId && status === 'RUNNING') { if (taskId && status === 'RUNNING') {
logIntervalRef.current = setInterval(() => { logIntervalRef.current = setInterval(() => {
fetchLogsRef.current?.(); fetchLogsRef.current?.();
}, 1000); }, 3000);
} else if (logIntervalRef.current) { } else if (logIntervalRef.current) {
clearInterval(logIntervalRef.current); clearInterval(logIntervalRef.current);
if (taskId) fetchLogsRef.current?.(); if (taskId) fetchLogsRef.current?.();
+65 -48
View File
@@ -85,6 +85,8 @@ const SCAN_TASK_TYPES = new Set([
'GF3_SARSCAPE_SYNC', 'GF3_SARSCAPE_SYNC',
'GF3_QUICKLOOK_WEBP', 'GF3_QUICKLOOK_WEBP',
]); ]);
const MONITOR_REFRESH_MS = 10000;
const ACTIVE_TASK_LOG_REFRESH_MS = 3000;
const TASK_TYPE_LABELS = { const TASK_TYPE_LABELS = {
SCAN_ASSET_INVENTORY: 'LT/S1 资产索引', SCAN_ASSET_INVENTORY: 'LT/S1 资产索引',
@@ -270,7 +272,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
}; };
fetchLogsAndTasks(); fetchLogsAndTasks();
const intervalId = setInterval(fetchLogsAndTasks, 2000); const intervalId = setInterval(fetchLogsAndTasks, MONITOR_REFRESH_MS);
return () => { return () => {
canceled = true; canceled = true;
@@ -282,6 +284,8 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
displayActiveTasks.filter((task) => SCAN_TASK_TYPES.has(task.task_type)), displayActiveTasks.filter((task) => SCAN_TASK_TYPES.has(task.task_type)),
recentScanTasks recentScanTasks
).slice(0, 8); ).slice(0, 8);
const selectedTask = displayedScanTasks.find((task) => task.task_id === selectedTaskId) || displayedScanTasks[0] || null;
const selectedTaskActive = ['PENDING', 'RUNNING'].includes(String(selectedTask?.status || '').toUpperCase());
useEffect(() => { useEffect(() => {
if (!enabled) { if (!enabled) {
@@ -315,12 +319,17 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
} }
}; };
fetchTaskLogs(); fetchTaskLogs();
const intervalId = setInterval(fetchTaskLogs, 2000); if (!selectedTaskActive) {
return () => {
canceled = true;
};
}
const intervalId = setInterval(fetchTaskLogs, ACTIVE_TASK_LOG_REFRESH_MS);
return () => { return () => {
canceled = true; canceled = true;
clearInterval(intervalId); clearInterval(intervalId);
}; };
}, [apiEndpoint, enabled, selectedTaskId]); }, [apiEndpoint, enabled, selectedTaskActive, selectedTaskId]);
useEffect(() => { useEffect(() => {
if (logEndRef.current) { if (logEndRef.current) {
@@ -328,8 +337,6 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
} }
}, [logs, selectedTaskLogs]); }, [logs, selectedTaskLogs]);
const selectedTask = displayedScanTasks.find((task) => task.task_id === selectedTaskId) || displayedScanTasks[0] || null;
const sourceInventoryDirs = uniquePaths([...config.s1_source_dirs, ...config.s1_storage_dirs]); const sourceInventoryDirs = uniquePaths([...config.s1_source_dirs, ...config.s1_storage_dirs]);
const orbitInventoryDirs = uniquePaths([ const orbitInventoryDirs = uniquePaths([
...config.orbit_source_dirs, ...config.orbit_source_dirs,
@@ -344,6 +351,10 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
const canRunSourceProductScan = !readOnly && configLoaded && hasSourceProductDirs; const canRunSourceProductScan = !readOnly && configLoaded && hasSourceProductDirs;
const canRunOrbitAssetScan = !readOnly && configLoaded && hasOrbitAssetDirs; const canRunOrbitAssetScan = !readOnly && configLoaded && hasOrbitAssetDirs;
const canRunGf3SarscapeProduce = !readOnly && configLoaded && hasGf3SarscapeNativeDirs && hasGf3StorageDirs; const canRunGf3SarscapeProduce = !readOnly && configLoaded && hasGf3SarscapeNativeDirs && hasGf3StorageDirs;
const activeIngestTaskCount = displayedScanTasks.filter((task) =>
['PENDING', 'RUNNING'].includes(String(task.status || '').toUpperCase())
).length;
const availableStorageCount = config.storage_roots.filter((item) => ['ok', 'warning'].includes(String(item.status || '').toLowerCase())).length;
const handleClearScanTaskHistory = async () => { const handleClearScanTaskHistory = async () => {
if (readOnly) { if (readOnly) {
@@ -511,10 +522,9 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
const sectionStyle = { const sectionStyle = {
marginBottom: '12px', marginBottom: '12px',
padding: '10px 12px', padding: '10px 12px',
borderRadius: '10px', borderRadius: '8px',
background: 'var(--color-panel-bg)', background: 'var(--color-panel-bg)',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-soft)',
}; };
const labelStyle = { minWidth: '100px', color: 'var(--color-text-muted)', flexShrink: 0 }; const labelStyle = { minWidth: '100px', color: 'var(--color-text-muted)', flexShrink: 0 };
const rowStyle = { display: 'flex', gap: '8px' }; const rowStyle = { display: 'flex', gap: '8px' };
@@ -532,38 +542,43 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
return ( return (
<div <div
className="monitor-panel" className="monitor-panel data-ingest-panel"
style={{
padding: '15px',
backgroundColor: 'var(--color-panel-bg)',
borderTop: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
}}
> >
<h3 style={{ marginTop: 0, marginBottom: '8px', fontSize: '1.1em', flexShrink: 0 }}>数据监控面板</h3> <div className="data-ingest-header">
<div>
<h3>数据接入</h3>
<p>集中管理源数据、精密轨道、GF3 回传成果和接入任务记录,确保生产前数据资产可追溯。</p>
</div>
<div className="data-ingest-signals" aria-label="数据接入状态摘要">
<div className={`dinsar-production-signal tone-${configLoaded ? 'ready' : 'warn'}`}>
<span>配置状态</span>
<strong>{configLoaded ? '已加载' : '未加载'}</strong>
</div>
<div className={`dinsar-production-signal tone-${activeIngestTaskCount > 0 ? 'warn' : 'ready'}`}>
<span>接入任务</span>
<strong>{activeIngestTaskCount > 0 ? `${activeIngestTaskCount} 个运行中` : '空闲'}</strong>
</div>
<div className={`dinsar-production-signal tone-${hasSourceProductDirs ? 'ready' : 'neutral'}`}>
<span>源数据池</span>
<strong>{sourceInventoryDirs.length}</strong>
</div>
<div className={`dinsar-production-signal tone-${availableStorageCount > 0 ? 'ready' : 'neutral'}`}>
<span>可用存储</span>
<strong>{availableStorageCount}/{config.storage_roots.length || 0}</strong>
</div>
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, paddingRight: '4px' }}> <div style={{ flex: 1, overflowY: 'auto', minHeight: 0, paddingRight: '4px' }}>
<div <div className={`data-ingest-notice ${configLoaded ? 'ok' : 'warn'}`}>
style={{
margin: '0 0 12px',
padding: '10px 12px',
borderRadius: '8px',
background: 'linear-gradient(90deg, var(--color-accent-soft) 0%, #fff 70%)',
border: '1px solid #c7ddff',
color: 'var(--color-accent-strong)',
fontSize: '0.9em',
}}
>
{configLoaded {configLoaded
? '仅手动模式。路径从 .env 读取;如需修改请更新 .env 并重启后端。' ? '接入路径由环境配置统一管理;本页仅触发登记、审计和任务复核,不直接修改生产目录。'
: '未加载到监控状态,请检查后端 /api/monitor/status。'} : '未加载到接入状态,请检查后端运行维护接口。'}
</div> </div>
<div style={sectionStyle}> <details className="data-ingest-directory" style={sectionStyle}>
<div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>路径摘要</div> <summary>接入路径与生产目录</summary>
<p>以下为服务器部署目录,仅用于核对环境配置;日常接入操作不需要展开。</p>
<div style={gridStyle}> <div style={gridStyle}>
<div style={rowStyle}><span style={labelStyle}>精轨源资产</span><span style={{ wordBreak: 'break-all' }}>{formatList(orbitInventoryDirs)}</span></div> <div style={rowStyle}><span style={labelStyle}>精轨源资产</span><span style={{ wordBreak: 'break-all' }}>{formatList(orbitInventoryDirs)}</span></div>
<div style={rowStyle}><span style={labelStyle}>LT-1 生产 TXT 池</span><span style={{ wordBreak: 'break-all' }}>{config.orbit_production_txt_pool || '未配置'}</span></div> <div style={rowStyle}><span style={labelStyle}>LT-1 生产 TXT 池</span><span style={{ wordBreak: 'break-all' }}>{config.orbit_production_txt_pool || '未配置'}</span></div>
@@ -576,10 +591,10 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
<div style={rowStyle}><span style={labelStyle}>D-InSAR 结果</span><span style={{ wordBreak: 'break-all' }}>{config.dinsar_product_dir || '未配置'}</span></div> <div style={rowStyle}><span style={labelStyle}>D-InSAR 结果</span><span style={{ wordBreak: 'break-all' }}>{config.dinsar_product_dir || '未配置'}</span></div>
<div style={rowStyle}><span style={labelStyle}>SBAS 结果</span><span style={{ wordBreak: 'break-all' }}>{config.sbas_product_root || '未配置'}</span></div> <div style={rowStyle}><span style={labelStyle}>SBAS 结果</span><span style={{ wordBreak: 'break-all' }}>{config.sbas_product_root || '未配置'}</span></div>
</div> </div>
</div> </details>
<div style={sectionStyle}> <div style={sectionStyle}>
<div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>本机存储感知</div> <div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>本机存储状态</div>
<div style={{ ...gridStyle, rowGap: '8px' }}> <div style={{ ...gridStyle, rowGap: '8px' }}>
{config.storage_roots.length ? config.storage_roots.map((item, index) => ( {config.storage_roots.length ? config.storage_roots.map((item, index) => (
<div <div
@@ -608,7 +623,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
</div> </div>
<div style={sectionStyle}> <div style={sectionStyle}>
<div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>LT-1 / Sentinel-1 压缩包资产索引</div> <div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>LT-1 / Sentinel-1 源数据与精轨登记</div>
<div style={{ ...gridStyle, marginBottom: '8px' }}> <div style={{ ...gridStyle, marginBottom: '8px' }}>
<div style={rowStyle}><span style={labelStyle}>压缩包源池</span><span style={{ wordBreak: 'break-all' }}>{formatList(sourceInventoryDirs)}</span></div> <div style={rowStyle}><span style={labelStyle}>压缩包源池</span><span style={{ wordBreak: 'break-all' }}>{formatList(sourceInventoryDirs)}</span></div>
<div style={rowStyle}><span style={labelStyle}>精轨源资产</span><span style={{ wordBreak: 'break-all' }}>{formatList(orbitInventoryDirs)}</span></div> <div style={rowStyle}><span style={labelStyle}>精轨源资产</span><span style={{ wordBreak: 'break-all' }}>{formatList(orbitInventoryDirs)}</span></div>
@@ -619,42 +634,42 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
<button <button
onClick={() => handleAssetInventoryScan( onClick={() => handleAssetInventoryScan(
{ inventory_types: ['source_product'], families: ['LT1', 'S1'] }, { inventory_types: ['source_product'], families: ['LT1', 'S1'] },
'LT-1 / Sentinel-1 源压缩包扫描' 'LT-1 / Sentinel-1 源压缩包登记'
)} )}
disabled={s1ScanLoading || !canRunSourceProductScan} disabled={s1ScanLoading || !canRunSourceProductScan}
style={actionBtnStyle(s1ScanLoading, !canRunSourceProductScan)} style={actionBtnStyle(s1ScanLoading, !canRunSourceProductScan)}
> >
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '扫描压缩包')} {s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '登记源压缩包')}
</button> </button>
<button <button
onClick={() => handleAssetInventoryScan( onClick={() => handleAssetInventoryScan(
{ inventory_types: ['orbit_asset'], families: ['LT1', 'S1'] }, { inventory_types: ['orbit_asset'], families: ['LT1', 'S1'] },
'LT-1 / Sentinel-1 精轨扫描' 'LT-1 / Sentinel-1 精轨登记'
)} )}
disabled={s1ScanLoading || !canRunOrbitAssetScan} disabled={s1ScanLoading || !canRunOrbitAssetScan}
style={actionBtnStyle(s1ScanLoading, !canRunOrbitAssetScan)} style={actionBtnStyle(s1ScanLoading, !canRunOrbitAssetScan)}
> >
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '扫描全部精轨')} {s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '登记全部精轨')}
</button> </button>
<button <button
onClick={() => handleAssetInventoryScan( onClick={() => handleAssetInventoryScan(
{ inventory_types: ['orbit_asset'], families: ['S1'] }, { inventory_types: ['orbit_asset'], families: ['S1'] },
'Sentinel-1 精轨扫描' 'Sentinel-1 精轨登记'
)} )}
disabled={s1ScanLoading || !canRunOrbitAssetScan} disabled={s1ScanLoading || !canRunOrbitAssetScan}
style={actionBtnStyle(s1ScanLoading, !canRunOrbitAssetScan)} style={actionBtnStyle(s1ScanLoading, !canRunOrbitAssetScan)}
> >
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '扫描 S1 EOF')} {s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '登记 S1 EOF')}
</button> </button>
<button <button
onClick={() => handleAssetInventoryScan( onClick={() => handleAssetInventoryScan(
{ inventory_types: ['orbit_asset'], families: ['LT1'] }, { inventory_types: ['orbit_asset'], families: ['LT1'] },
'LT-1 精轨扫描' 'LT-1 精轨登记'
)} )}
disabled={s1ScanLoading || !canRunOrbitAssetScan} disabled={s1ScanLoading || !canRunOrbitAssetScan}
style={actionBtnStyle(s1ScanLoading, !canRunOrbitAssetScan)} style={actionBtnStyle(s1ScanLoading, !canRunOrbitAssetScan)}
> >
{readOnly ? '只读模式' : '扫描 LT-1 TXT'} {readOnly ? '只读模式' : '登记 LT-1 TXT'}
</button> </button>
<button <button
onClick={handleArchiveIntegrityAudit} onClick={handleArchiveIntegrityAudit}
@@ -703,7 +718,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
<div style={sectionStyle}> <div style={sectionStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '10px', alignItems: 'center', marginBottom: '8px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', gap: '10px', alignItems: 'center', marginBottom: '8px' }}>
<div style={{ fontWeight: 'bold', color: 'var(--color-text-primary)' }}>扫描任务状态</div> <div style={{ fontWeight: 'bold', color: 'var(--color-text-primary)' }}>接入任务记录</div>
<button <button
type="button" type="button"
onClick={handleClearScanTaskHistory} onClick={handleClearScanTaskHistory}
@@ -718,7 +733,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
fontSize: '0.8em', fontSize: '0.8em',
}} }}
> >
{clearScanHistoryLoading ? '清空中...' : '清空日志'} {clearScanHistoryLoading ? '清空中...' : '清空记录'}
</button> </button>
</div> </div>
{clearScanHistoryMessage && ( {clearScanHistoryMessage && (
@@ -761,7 +776,6 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
width: `${progress}%`, width: `${progress}%`,
height: '100%', height: '100%',
background: statusColor(task.status), background: statusColor(task.status),
transition: 'width 0.2s ease',
}} }}
/> />
</div> </div>
@@ -784,7 +798,10 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
<div style={{ flexShrink: 0, borderTop: '1px solid var(--color-border)', paddingTop: '10px', marginTop: '6px' }}> <div style={{ flexShrink: 0, borderTop: '1px solid var(--color-border)', paddingTop: '10px', marginTop: '6px' }}>
<div style={{ fontWeight: 'bold', marginBottom: '6px', color: 'var(--color-text-primary)' }}> <div style={{ fontWeight: 'bold', marginBottom: '6px', color: 'var(--color-text-primary)' }}>
{selectedTask ? `${taskTitle(selectedTask)} 日志` : '实时日志'} {selectedTask ? `${taskTitle(selectedTask)} 任务日志` : '实时执行日志'}
<span style={{ marginLeft: '8px', color: 'var(--color-text-muted)', fontWeight: 400, fontSize: '0.82em' }}>
日志用于追踪执行步骤,issue 为本次解析已发现的问题计数。
</span>
</div> </div>
<div <div
style={{ style={{
+134 -14
View File
@@ -1,16 +1,15 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { deleteRunLog, deleteRunRecord, getRunLog, listEngines, listRuns, listTaskRoots, previewPyintInputAssets, submitRun } from './api/dinsarProduction'; import { deleteRunLog, deleteRunRecord, getRunLog, listEngines, listRuns, listTaskRoots, previewPyintInputAssets, submitLandsarClusterRun, submitRun } from './api/dinsarProduction';
import { clearTaskLogs, deleteTaskLog, deleteTaskRecord, getRecentTasks, getTaskLogs } from './api/tasks'; import { clearTaskLogs, deleteTaskLog, deleteTaskRecord, getRecentTasks, getTaskLogs } from './api/tasks';
import { formatSatelliteFamilyLabel, inferSatelliteFamilyFromResultLike } from './utils/satelliteFamily'; import { formatSatelliteFamilyLabel, inferSatelliteFamilyFromResultLike } from './utils/satelliteFamily';
import useTaskMonitor from './hooks/useTaskMonitor'; import useTaskMonitor from './hooks/useTaskMonitor';
const card = { const card = {
background: '#fff', background: '#fff',
padding: '12px', padding: '14px',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid #e2e8f0', border: '1px solid #e2e8f0',
marginBottom: '12px',
}; };
const EMPTY_ARRAY = []; const EMPTY_ARRAY = [];
@@ -44,11 +43,12 @@ const ENGINE_LABEL = {
}; };
const TASK_TYPE_LABEL = { const TASK_TYPE_LABEL = {
PYINT_RUN: 'PyINT/Gamma生产', PYINT_RUN: 'PyINT / Gamma 生产',
LANDSAR_RUN: 'LandSAR生产', LANDSAR_RUN: 'LandSAR 生产',
IDL_RUN_DINSAR: 'ENVI生产', LANDSAR_CLUSTER_RUN: 'LandSAR 集群生产',
IDL_RUN_DINSAR: 'ENVI 生产',
}; };
const DINSAR_PRODUCTION_TASK_TYPES = ['PYINT_RUN', 'LANDSAR_RUN', 'IDL_RUN_DINSAR']; const DINSAR_PRODUCTION_TASK_TYPES = ['PYINT_RUN', 'LANDSAR_RUN', 'LANDSAR_CLUSTER_RUN', 'IDL_RUN_DINSAR'];
const STATUS_LABEL = { const STATUS_LABEL = {
PENDING: '等待中', PENDING: '等待中',
@@ -114,7 +114,7 @@ function formatStatus(status) {
function taskTypeToEngine(taskType) { function taskTypeToEngine(taskType) {
if (taskType === 'PYINT_RUN') return 'pyint'; if (taskType === 'PYINT_RUN') return 'pyint';
if (taskType === 'LANDSAR_RUN') return 'landsar'; if (taskType === 'LANDSAR_RUN' || taskType === 'LANDSAR_CLUSTER_RUN') return 'landsar';
if (taskType === 'IDL_RUN_DINSAR') return 'sarscape'; if (taskType === 'IDL_RUN_DINSAR') return 'sarscape';
return ''; return '';
} }
@@ -582,6 +582,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
const [timeoutSec, setTimeoutSec] = useState(''); const [timeoutSec, setTimeoutSec] = useState('');
const [engineExtraParams, setEngineExtraParams] = useState({}); const [engineExtraParams, setEngineExtraParams] = useState({});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [clusterSubmitting, setClusterSubmitting] = useState(false);
const [submitMsg, setSubmitMsg] = useState(''); const [submitMsg, setSubmitMsg] = useState('');
const [submitError, setSubmitError] = useState(false); const [submitError, setSubmitError] = useState(false);
const [submitDialogOpen, setSubmitDialogOpen] = useState(false); const [submitDialogOpen, setSubmitDialogOpen] = useState(false);
@@ -635,7 +636,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
latestRunWithTask.engine === 'pyint' latestRunWithTask.engine === 'pyint'
? 'PYINT_RUN' ? 'PYINT_RUN'
: latestRunWithTask.engine === 'landsar' : latestRunWithTask.engine === 'landsar'
? 'LANDSAR_RUN' ? (latestRunWithTask.mode === 'cluster' ? 'LANDSAR_CLUSTER_RUN' : 'LANDSAR_RUN')
: 'IDL_RUN_DINSAR', : 'IDL_RUN_DINSAR',
status: latestRunWithTask.raw_status || latestRunWithTask.status, status: latestRunWithTask.raw_status || latestRunWithTask.status,
progress: latestRunWithTask.raw_status === 'COMPLETED' || latestRunWithTask.status === 'success' ? 100 : null, progress: latestRunWithTask.raw_status === 'COMPLETED' || latestRunWithTask.status === 'success' ? 100 : null,
@@ -645,6 +646,9 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
); );
const logTaskId = monitoredTask?.task_id || ''; const logTaskId = monitoredTask?.task_id || '';
const showingRecentTask = !taskMonitor.isBusy && !!monitoredTask; const showingRecentTask = !taskMonitor.isBusy && !!monitoredTask;
const activeRunCount = runs.filter(run => ['running', 'pending'].includes(String(run.status || '').toLowerCase())).length;
const failedRunCount = runs.filter(run => ['failed', 'FAILED'].includes(String(run.status || ''))).length;
const productionReady = !!currentEngineObj?.available && !!rootDir.trim() && !pyintPreviewBlocksSubmit && !readOnly;
const loadEngines = useCallback(async () => { const loadEngines = useCallback(async () => {
setEnginesLoading(true); setEnginesLoading(true);
@@ -892,6 +896,48 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
} }
}; };
const handleSubmitCluster = async () => {
if (readOnly || clusterSubmitting) return;
if (selectedEngine !== 'landsar') {
setSubmitError(true);
setSubmitMsg('LandSAR 集群只支持 LandSAR 引擎。');
return;
}
if (!rootDir.trim()) {
setSubmitError(true);
setSubmitMsg('请输入或选择 D-InSAR 生产根目录。');
return;
}
setClusterSubmitting(true);
setSubmitMsg('');
setSubmitError(false);
try {
const extra = buildExtraPayload(currentParamSchema, engineExtraParams);
const result = await submitLandsarClusterRun({
engine_code: 'landsar',
profile: selectedProfile,
root_dir: rootDir.trim(),
num_to_process: Number(numToProcess) || 0,
rerun_mode: rerunMode,
timeout_seconds: timeoutSec ? Number(timeoutSec) : null,
extra,
});
const taskCount = result?.selected_task_count ? `,选中 ${result.selected_task_count} 个 pair` : '';
const skippedCompleted = Number(result?.skipped_completed_count || 0);
const skippedText = skippedCompleted > 0 ? `,跳过 ${skippedCompleted} 个已完成 pair` : '';
setSubmitError(false);
setSubmitMsg(`LandSAR 集群任务已入队:${result.task_id}${taskCount}${skippedText}`);
if (onJobQueued) onJobQueued(result.task_id);
await refreshMonitor();
} catch (err) {
setSubmitError(true);
setSubmitMsg(`LandSAR 集群提交失败:${err?.response?.data?.detail || err.message}`);
} finally {
setClusterSubmitting(false);
}
};
const handleViewLog = async run => { const handleViewLog = async run => {
const runId = typeof run === 'string' ? run : (run?.run_id || run?.task_id || ''); const runId = typeof run === 'string' ? run : (run?.run_id || run?.task_id || '');
const source = typeof run !== 'string' && run?.log_source === 'task' ? 'task' : 'run'; const source = typeof run !== 'string' && run?.log_source === 'task' ? 'task' : 'run';
@@ -1001,9 +1047,10 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
}, [logModal.open, logModal.runId, logTaskId, readOnly, refreshMonitor, runLogDeletingId]); }, [logModal.open, logModal.runId, logTaskId, readOnly, refreshMonitor, runLogDeletingId]);
const isSubmitDisabled = readOnly || submitting || !currentEngineObj?.available || !rootDir.trim() || pyintPreviewBlocksSubmit; const isSubmitDisabled = readOnly || submitting || !currentEngineObj?.available || !rootDir.trim() || pyintPreviewBlocksSubmit;
const isClusterSubmitDisabled = readOnly || clusterSubmitting || selectedEngine !== 'landsar' || !rootDir.trim();
return ( return (
<div style={{ padding: '16px 0', width: '100%' }}> <div className="dinsar-production-shell">
{logModal.open && ( {logModal.open && (
<div <div
style={{ style={{
@@ -1217,7 +1264,39 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div> </div>
)} )}
<div style={card}> <div className="dinsar-production-header">
<div>
<h3>D-InSAR 生产运行</h3>
<p>选择生产引擎、任务根目录和处理模板,完成预检后提交运行;监控区用于查看近期 Task、日志和审计记录。</p>
</div>
<div className="dinsar-production-summary">
<div className={`dinsar-production-signal ${productionReady ? 'ok' : 'warn'}`}>
<span>提交状态</span>
<strong>{productionReady ? '可提交' : readOnly ? '只读' : '待检查'}</strong>
</div>
<div className="dinsar-production-signal">
<span>当前引擎</span>
<strong>{formatEngineLabel(selectedEngine, currentEngineObj?.engine_label)}</strong>
</div>
<div className="dinsar-production-signal">
<span>运行中</span>
<strong>{activeRunCount}</strong>
</div>
<div className={`dinsar-production-signal ${failedRunCount > 0 ? 'warn' : ''}`}>
<span>失败记录</span>
<strong>{failedRunCount}</strong>
</div>
</div>
</div>
<section className="dinsar-production-section">
<div className="dinsar-production-section-header">
<div>
<h4>引擎与能力</h4>
<p>先确认生产引擎可用,再选择对应处理模板。</p>
</div>
</div>
<div style={card}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<strong style={{ fontSize: 14 }}>引擎状态</strong> <strong style={{ fontSize: 14 }}>引擎状态</strong>
<button <button
@@ -1249,8 +1328,16 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
))} ))}
</div> </div>
</div> </div>
</section>
<div style={card}> <section className="dinsar-production-section">
<div className="dinsar-production-section-header">
<div>
<h4>任务准备与提交</h4>
<p>生产任务根目录、参数模板和预检结果共同决定是否允许提交。</p>
</div>
</div>
<div style={card}>
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>提交生产任务</strong> <strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>提交生产任务</strong>
<div style={{ display: 'flex', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
@@ -1635,15 +1722,48 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
> >
{submitting ? '提交中...' : '提交任务'} {submitting ? '提交中...' : '提交任务'}
</button> </button>
{selectedEngine === 'landsar' && (
<button
onClick={handleSubmitCluster}
disabled={isClusterSubmitDisabled}
title="按 pair 拆分为 LANDSAR_CLUSTER_ITEM,由本机或远端 LandSAR worker 领取执行。"
style={{
padding: '6px 16px',
borderRadius: 6,
border: '1px solid #0f766e',
background: isClusterSubmitDisabled ? '#f1f5f9' : '#0f766e',
color: isClusterSubmitDisabled ? '#94a3b8' : '#fff',
cursor: isClusterSubmitDisabled ? 'not-allowed' : 'pointer',
fontSize: 13,
fontWeight: 600,
}}
>
{clusterSubmitting ? '集群入队中...' : '提交 LandSAR 集群'}
</button>
)}
{submitMsg && ( {submitMsg && (
<span style={{ fontSize: 12, color: submitError ? '#ef4444' : '#16a34a' }}> <span style={{ fontSize: 12, color: submitError ? '#ef4444' : '#16a34a' }}>
{submitMsg} {submitMsg}
</span> </span>
)} )}
</div> </div>
{selectedEngine === 'landsar' && (
<div style={{ marginTop: 8, fontSize: 12, color: '#64748b', lineHeight: 1.6 }}>
集群模式按 pair 拆分队列任务。远端服务器需启动只领取 LANDSAR_CLUSTER_ITEM 的 worker;
本地 LandSAR 提交流程保持不变。
</div>
)}
</div> </div>
</section>
<div style={card}> <section className="dinsar-production-section">
<div className="dinsar-production-section-header">
<div>
<h4>运行监控与审计记录</h4>
<p>监控近期 Task、查看日志,并保留运行记录删除等审计类操作。</p>
</div>
</div>
<div style={card}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<strong style={{ fontSize: 14 }}>运行监控</strong> <strong style={{ fontSize: 14 }}>运行监控</strong>
<button <button
@@ -1703,7 +1823,6 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
height: '100%', height: '100%',
width: `${monitoredTask.progress}%`, width: `${monitoredTask.progress}%`,
background: showingRecentTask ? '#3b82f6' : '#f59e0b', background: showingRecentTask ? '#3b82f6' : '#f59e0b',
transition: 'width 0.3s',
}} }}
/> />
</div> </div>
@@ -1912,6 +2031,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div> </div>
)} )}
</div> </div>
</section>
</div> </div>
); );
} }
+103 -83
View File
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { scanDinsarResults } from './api/dinsar'; import { scanDinsarResults } from './api/dinsar';
import { listTaskRoots } from './api/dinsarProduction';
import { extractDispResults } from './api/idl'; import { extractDispResults } from './api/idl';
import { clearTaskLogs, deleteTaskLog, getTaskLogs } from './api/tasks'; import { clearTaskLogs, deleteTaskLog, getTaskLogs } from './api/tasks';
import DinsarCatalogPanel from './components/DinsarCatalogPanel'; import DinsarCatalogPanel from './components/DinsarCatalogPanel';
@@ -8,14 +9,10 @@ import useTaskMonitor from './hooks/useTaskMonitor';
const PRODUCT_TASK_TYPES = [ const PRODUCT_TASK_TYPES = [
'SCAN_DINSAR', 'SCAN_DINSAR',
'PUBLISH_DINSAR_PRODUCTS',
'REBUILD_DINSAR_CATALOG',
]; ];
const TASK_TYPE_LABEL = { const TASK_TYPE_LABEL = {
SCAN_DINSAR: 'D-InSAR 结果扫描', SCAN_DINSAR: 'D-InSAR 结果扫描',
PUBLISH_DINSAR_PRODUCTS: 'D-InSAR 产物发布',
REBUILD_DINSAR_CATALOG: 'D-InSAR 目录重建',
}; };
const STATUS_LABEL = { const STATUS_LABEL = {
@@ -48,13 +45,12 @@ function getLogTone(level) {
} }
export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) { export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
const [extractRootDir, setExtractRootDir] = useState(''); const [productionRoot, setProductionRoot] = useState('');
const [extractDestDir, setExtractDestDir] = useState(''); const [productionRootReady, setProductionRootReady] = useState(false);
const [extractResult, setExtractResult] = useState(null); const [extractResult, setExtractResult] = useState(null);
const [extracting, setExtracting] = useState(false); const [syncing, setSyncing] = useState(false);
const [actionMessage, setActionMessage] = useState(''); const [actionMessage, setActionMessage] = useState('');
const [actionError, setActionError] = useState(false); const [actionError, setActionError] = useState(false);
const [scanning, setScanning] = useState(false);
const [taskLogs, setTaskLogs] = useState([]); const [taskLogs, setTaskLogs] = useState([]);
const [taskLogsLoading, setTaskLogsLoading] = useState(false); const [taskLogsLoading, setTaskLogsLoading] = useState(false);
@@ -62,13 +58,17 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
const [taskLogDeletingId, setTaskLogDeletingId] = useState(null); const [taskLogDeletingId, setTaskLogDeletingId] = useState(null);
const taskMonitor = useTaskMonitor({ const taskMonitor = useTaskMonitor({
taskTypes: PRODUCT_TASK_TYPES, taskTypes: PRODUCT_TASK_TYPES,
showRecent: true, showRecent: false,
recentLimit: 1,
}); });
const monitoredTask = taskMonitor.latestTask; const monitoredTask = taskMonitor.latestTask;
const logTaskId = monitoredTask?.task_id || ''; const logTaskId = monitoredTask?.task_id || '';
const showingRecentTask = !taskMonitor.isBusy && !!monitoredTask;
const actionTone = getMessageTone(actionMessage, actionError); const actionTone = getMessageTone(actionMessage, actionError);
const activeTaskCount = taskMonitor.activeTasks?.length || 0;
const catalogSourceState = productionRootReady ? '已配置' : '未配置';
const taskStateLabel = activeTaskCount > 0
? `${activeTaskCount} 个运行中`
: '空闲';
const taskStateTone = activeTaskCount > 0 ? 'warn' : 'ready';
const loadTaskLogs = useCallback(async (taskId) => { const loadTaskLogs = useCallback(async (taskId) => {
if (!taskId) { if (!taskId) {
@@ -87,8 +87,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
}, []); }, []);
const refreshMonitor = useCallback(async () => { const refreshMonitor = useCallback(async () => {
const nextRecentTasks = await taskMonitor.refreshRecentTasks(); const nextTaskId = taskMonitor.activeTasks[0]?.task_id || logTaskId;
const nextTaskId = taskMonitor.activeTasks[0]?.task_id || nextRecentTasks[0]?.task_id || logTaskId;
await loadTaskLogs(nextTaskId); await loadTaskLogs(nextTaskId);
}, [loadTaskLogs, logTaskId, taskMonitor]); }, [loadTaskLogs, logTaskId, taskMonitor]);
@@ -96,6 +95,33 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
loadTaskLogs(logTaskId); loadTaskLogs(logTaskId);
}, [loadTaskLogs, logTaskId]); }, [loadTaskLogs, logTaskId]);
useEffect(() => {
if (!taskMonitor.isBusy || !logTaskId) return undefined;
const timer = window.setInterval(() => {
void loadTaskLogs(logTaskId);
}, 3000);
return () => window.clearInterval(timer);
}, [loadTaskLogs, logTaskId, taskMonitor.isBusy]);
useEffect(() => {
let canceled = false;
listTaskRoots()
.then((data) => {
if (canceled) return;
const root = String(data?.root || '').trim();
setProductionRoot(root);
setProductionRootReady(Boolean(root && data?.root_exists));
})
.catch(() => {
if (canceled) return;
setProductionRoot('');
setProductionRootReady(false);
});
return () => {
canceled = true;
};
}, []);
const handleDeleteTaskLog = useCallback(async (logId) => { const handleDeleteTaskLog = useCallback(async (logId) => {
const taskId = logTaskId; const taskId = logTaskId;
if (!taskId || !logId || taskLogActionLoading) return; if (!taskId || !logId || taskLogActionLoading) return;
@@ -132,63 +158,70 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
} }
}, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]); }, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
const handleExtract = async () => { const handleExtractAndScan = async () => {
if (!extractRootDir.trim()) return; if (readOnly || !productionRootReady || !productionRoot.trim()) return;
setExtracting(true); setSyncing(true);
setExtractResult(null); setExtractResult(null);
setActionMessage(''); setActionMessage('');
setActionError(false); setActionError(false);
try { try {
const result = await extractDispResults(extractRootDir.trim(), extractDestDir.trim() || null); const result = await extractDispResults(productionRoot.trim(), null);
setExtractResult(result); setExtractResult(result);
} catch (err) { const scanResult = await scanDinsarResults();
setExtractResult({ error: err?.response?.data?.detail || err.message }); setActionMessage(scanResult?.message || `D-InSAR 结果登记任务已提交:${scanResult?.task_id || '-'}`);
} finally { if (scanResult?.task_id) {
setExtracting(false); onJobQueued?.(scanResult.task_id);
}
};
const handleScan = async () => {
if (readOnly) return;
setScanning(true);
setActionMessage('');
setActionError(false);
try {
const result = await scanDinsarResults();
setActionMessage(result?.message || `D-InSAR 结果扫描任务已入队:${result?.task_id || '-'}`);
if (result?.task_id) {
onJobQueued?.(result.task_id);
} }
await refreshMonitor(); await refreshMonitor();
} catch (err) { } catch (err) {
setActionError(true); setActionError(true);
setActionMessage(err?.response?.data?.detail || err.message || 'D-InSAR 结果扫描失败'); const message = err?.response?.data?.detail || err.message || 'D-InSAR 结果提取与登记失败';
setActionMessage(message);
setExtractResult((current) => current || { error: message });
} finally { } finally {
setScanning(false); setSyncing(false);
} }
}; };
const monitorTone = useMemo(() => { const monitorTone = useMemo(() => {
if (!monitoredTask) return 'neutral'; if (!monitoredTask) return 'neutral';
if (showingRecentTask) return 'info';
return String(monitoredTask.status || '').toUpperCase() === 'RUNNING' ? 'warn' : 'neutral'; return String(monitoredTask.status || '').toUpperCase() === 'RUNNING' ? 'warn' : 'neutral';
}, [monitoredTask, showingRecentTask]); }, [monitoredTask]);
return ( return (
<div className="dinsar-products-page"> <div className="dinsar-products-page">
<div className="dinsar-products-hero"> <div className="dinsar-products-hero">
<div> <div>
<strong>D-InSAR 结果提取与标准目录</strong> <strong>D-InSAR 结果目录</strong>
<p> <p>
这里负责把生产目录中的位移结果提取为标准成果包,并触发统一扫描、发布和编目。 将生产目录中的位移结果提取为标准成果包,并触发结果登记和目录编目。
生产运行与参数配置现已收口到“生产管理”工作台中的 “D-InSAR 运行” 子视图。 生产参数与运行提交已归入“D-InSAR 运行”,这里专注成果归档与资产登记。
</p> </p>
</div> </div>
<div className="dinsar-products-hero-badges"> <div className="dinsar-products-signals" aria-label="D-InSAR 结果目录状态摘要">
<span className={`dinsar-status-pill tone-${readOnly ? 'warn' : 'ready'}`}> <div className={`dinsar-production-signal tone-${readOnly ? 'warn' : 'ready'}`}>
{readOnly ? '只读模式' : '可执行写操作'} <span>操作模式</span>
</span> <strong>{readOnly ? '只读' : '可维护'}</strong>
<span className="dinsar-status-pill tone-info">日志改为手动刷新</span> </div>
<div className={`dinsar-production-signal tone-${taskStateTone}`}>
<span>产物任务</span>
<strong>{taskStateLabel}</strong>
</div>
<div className={`dinsar-production-signal tone-${productionRootReady ? 'ready' : 'neutral'}`}>
<span>提取源</span>
<strong>{catalogSourceState}</strong>
</div>
<div className="dinsar-production-signal tone-info">
<span>日志</span>
<strong>手动刷新</strong>
</div>
</div>
</div>
<div className="dinsar-products-section-head">
<div>
<strong>成果提取与任务监控</strong>
<span>左侧执行受控提取与登记,右侧核对后台任务与日志。</span>
</div> </div>
</div> </div>
@@ -196,46 +229,27 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
<section className="dinsar-products-card"> <section className="dinsar-products-card">
<div className="dinsar-products-card-head"> <div className="dinsar-products-card-head">
<div> <div>
<strong>结果提取与重扫</strong> <strong>D-InSAR 结果提取与登记</strong>
<span>先提取标准结果包,再按统一目录登记</span> <span>将已完成的生产成果归入标准结果目录</span>
</div> </div>
</div> </div>
<div className="dinsar-products-form-grid"> <div className="dinsar-products-controlled-source">
<label className="dinsar-products-field dinsar-products-field-wide"> <span>成果来源</span>
<span>结果根目录</span> <strong>{productionRootReady ? '生产目录已就绪' : '生产目录待完善'}</strong>
<input <p>{productionRootReady ? '可将当前生产成果提取并登记为标准结果包。' : '请先完成 D-InSAR 生产目录配置。'}</p>
value={extractRootDir}
onChange={(event) => setExtractRootDir(event.target.value)}
placeholder="例如:D:\\Task_Pool\\DInSAR"
/>
</label>
<label className="dinsar-products-field">
<span>目标目录(可选)</span>
<input
value={extractDestDir}
onChange={(event) => setExtractDestDir(event.target.value)}
placeholder="留空则使用系统默认"
/>
</label>
</div> </div>
<div className="dinsar-products-actions"> <div className="dinsar-products-actions">
<button <button
type="button" type="button"
className="primary" className="primary"
onClick={handleExtract} onClick={handleExtractAndScan}
disabled={extracting || !extractRootDir.trim()} disabled={readOnly || syncing || !productionRootReady}
> >
{extracting ? '提取中...' : '提取位移结果'} {syncing ? '处理中...' : '提取并登记结果'}
</button>
<button
type="button"
onClick={handleScan}
disabled={readOnly || scanning}
>
{scanning ? '重扫中...' : '重扫结果'}
</button> </button>
{!productionRootReady && <span className="dinsar-products-action-hint">请先在后端配置 D-InSAR 生产根目录。</span>}
</div> </div>
{actionMessage && ( {actionMessage && (
@@ -270,7 +284,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
<div className="dinsar-products-card-head"> <div className="dinsar-products-card-head">
<div> <div>
<strong>产物任务监控</strong> <strong>产物任务监控</strong>
<span>当前不轮询,按需手动刷新</span> <span>任务运行时自动更新,空闲时按需刷新</span>
</div> </div>
<button type="button" onClick={refreshMonitor}>刷新</button> <button type="button" onClick={refreshMonitor}>刷新</button>
</div> </div>
@@ -281,7 +295,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
<div className="dinsar-monitor-card"> <div className="dinsar-monitor-card">
<div className="dinsar-monitor-top"> <div className="dinsar-monitor-top">
<div> <div>
<strong>{showingRecentTask ? '最近一次任务' : '当前任务'}</strong> <strong>当前任务</strong>
<span>{formatTaskType(monitoredTask.task_type)}</span> <span>{formatTaskType(monitoredTask.task_type)}</span>
</div> </div>
<StatusSummary status={monitoredTask.status} /> <StatusSummary status={monitoredTask.status} />
@@ -303,7 +317,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
)} )}
<div className="dinsar-monitor-log-head"> <div className="dinsar-monitor-log-head">
<strong>{showingRecentTask ? '最近一次任务日志' : '当前任务日志'}</strong> <strong>当前任务日志</strong>
{!readOnly && ( {!readOnly && (
<button <button
type="button" type="button"
@@ -354,11 +368,17 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
</section> </section>
</div> </div>
<DinsarCatalogPanel <section className="dinsar-products-catalog-section">
readOnly={readOnly} <div className="dinsar-products-section-head">
initialSourceDir={extractRootDir} <div>
onTaskQueued={onJobQueued} <strong>标准目录与资产详情</strong>
/> <span>核对 AOI、时间范围、资产文件、发布状态和目录一致性。</span>
</div>
</div>
<DinsarCatalogPanel
readOnly={readOnly}
/>
</section>
</div> </div>
); );
} }
+2 -1
View File
@@ -31,6 +31,7 @@ import { RADAR_SEARCH_DEFAULTS } from './config/appConstants';
const SEARCH_PAGE_SIZE = 30; const SEARCH_PAGE_SIZE = 30;
const LIST_PAGE_SIZE = 20; const LIST_PAGE_SIZE = 20;
const ACTIVE_WORKSPACE_REFRESH_INTERVAL_MS = 15000;
const VIEWS = [ const VIEWS = [
{ key: 'extract', label: '水体提取' }, { key: 'extract', label: '水体提取' },
@@ -649,7 +650,7 @@ export default function FloodAnalysisWorkspace({
if (!runningCount) return undefined; if (!runningCount) return undefined;
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
refreshAll(); refreshAll();
}, 6000); }, ACTIVE_WORKSPACE_REFRESH_INTERVAL_MS);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [refreshAll, runningCount]); }, [refreshAll, runningCount]);
-1
View File
@@ -13,7 +13,6 @@ const HazardPointPanel = ({ onPointClick, onToggleVisibility, isVisible, onScanC
taskTypes: ['SCAN_HAZARD'], taskTypes: ['SCAN_HAZARD'],
showRecent: true, showRecent: true,
recentLimit: 1, recentLimit: 1,
pollRecentMs: 10000,
}); });
const scanBusy = isLoading || scanTaskMonitor.isBusy; const scanBusy = isLoading || scanTaskMonitor.isBusy;
+300 -314
View File
@@ -96,11 +96,6 @@ const renderOrbitSourceIssueDetails = (item, en, formatPathText) => {
{en ? 'ENVI: ' : 'ENVI:'}{formatPathText(item.envi_path)} {en ? 'ENVI: ' : 'ENVI:'}{formatPathText(item.envi_path)}
</div> </div>
)} )}
{item?.isce2_path && (
<div style={{ color: '#64748b' }}>
{en ? 'ISCE2: ' : 'ISCE2:'}{formatPathText(item.isce2_path)}
</div>
)}
{hasNulDetails && ( {hasNulDetails && (
<div style={{ color: '#64748b' }}> <div style={{ color: '#64748b' }}>
{en ? 'NUL bytes: ' : 'NUL 字节:'}{formatIntegerText(nulCount)} {en ? 'NUL bytes: ' : 'NUL 字节:'}{formatIntegerText(nulCount)}
@@ -201,10 +196,10 @@ const buildConsistencySummary = (stats, en = false) => {
}; };
}; };
const HEALTH_PANEL_POLL_INTERVAL_MS = 30000; const HEALTH_PANEL_POLL_INTERVAL_MS = 5 * 60 * 1000;
const HealthCheckPanel = ({ language = 'zh', currentUser }) => { const HealthCheckPanel = ({ currentUser }) => {
const en = language === 'en'; const en = false;
const isAdmin = currentUser?.role === 'admin'; const isAdmin = currentUser?.role === 'admin';
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -222,8 +217,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
const [wslChecking, setWslChecking] = useState(false); const [wslChecking, setWslChecking] = useState(false);
const [orbitStatus, setOrbitStatus] = useState(null); const [orbitStatus, setOrbitStatus] = useState(null);
const [orbitSyncing, setOrbitSyncing] = useState(false); const [orbitSyncing, setOrbitSyncing] = useState(false);
const [orbitRepairing, setOrbitRepairing] = useState(false);
const [orbitQuarantining, setOrbitQuarantining] = useState(false);
const [orbitSyncResult, setOrbitSyncResult] = useState(null); const [orbitSyncResult, setOrbitSyncResult] = useState(null);
const statusFetchInFlightRef = useRef(false); const statusFetchInFlightRef = useRef(false);
@@ -233,7 +226,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
setOrbitStatus(data); setOrbitStatus(data);
} catch (err) { } catch (err) {
setOrbitStatus(null); setOrbitStatus(null);
setOrbitSyncResult({ error: err.response?.data?.detail || err.message || (en ? 'Failed to fetch orbit status' : '精轨状态获取失败') }); setOrbitSyncResult({ error: err.response?.data?.detail || err.message || '精轨状态获取失败' });
} }
}, [en]); }, [en]);
@@ -266,7 +259,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
setStatus(healthData); setStatus(healthData);
setLastChecked(new Date()); setLastChecked(new Date());
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || err.message || (en ? 'Health check failed' : '运维自检失败')); setError(err.response?.data?.detail || err.message || '运维自检失败');
setStatus(null); setStatus(null);
} }
@@ -276,19 +269,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
setConsistencyError(''); setConsistencyError('');
} catch (err) { } catch (err) {
setConsistencySummary(null); setConsistencySummary(null);
setConsistencyError(err.response?.data?.detail || err.message || (en ? 'Failed to fetch consistency stats' : '一致性统计获取失败')); setConsistencyError(err.response?.data?.detail || err.message || '一致性统计获取失败');
} }
// 引擎状态独立加载,不影响主健康检查。 // 引擎状态独立加载,不影响主健康检查。
await refreshEngineStatus(); await refreshEngineStatus();
// 轨道目录状态由专门接口维护,失败时由 refreshOrbitStatus 自己写回 UI。
await refreshOrbitStatus();
} finally { } finally {
setLoading(false); setLoading(false);
statusFetchInFlightRef.current = false; statusFetchInFlightRef.current = false;
} }
}, [en, refreshEngineStatus, refreshOrbitStatus]); }, [en, refreshEngineStatus]);
useEffect(() => { useEffect(() => {
void fetchStatus(); void fetchStatus();
@@ -297,9 +288,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
syncLoading || syncLoading ||
cleanupLoading || cleanupLoading ||
wslChecking || wslChecking ||
orbitSyncing || orbitSyncing
orbitRepairing ||
orbitQuarantining
) { ) {
return; return;
} }
@@ -309,8 +298,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
}, [ }, [
cleanupLoading, cleanupLoading,
fetchStatus, fetchStatus,
orbitQuarantining,
orbitRepairing,
orbitSyncing, orbitSyncing,
syncLoading, syncLoading,
wslChecking, wslChecking,
@@ -322,6 +309,14 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</span> </span>
); );
const renderSignal = (ok, label, detail = '') => (
<div className={`health-signal ${ok ? 'ok' : 'fail'}`}>
<span className="health-signal-label">{label}</span>
<strong>{ok ? '正常' : '异常'}</strong>
{detail && <span className="health-signal-detail">{detail}</span>}
</div>
);
const formatIso = (iso) => { const formatIso = (iso) => {
if (!iso) return en ? 'Unknown' : '未知'; if (!iso) return en ? 'Unknown' : '未知';
try { try {
@@ -342,6 +337,14 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
typeof timeseriesResultCatalog.needs_rebuild === 'boolean' ? timeseriesResultCatalog.needs_rebuild : null; typeof timeseriesResultCatalog.needs_rebuild === 'boolean' ? timeseriesResultCatalog.needs_rebuild : null;
const sourceRoots = asObject(status?.source_roots); const sourceRoots = asObject(status?.source_roots);
const productPackages = asObject(status?.product_packages); const productPackages = asObject(status?.product_packages);
const assetInventory = asObject(status?.asset_inventory);
const hasAssetInventory = Boolean(status?.asset_inventory);
const assetSourceRoots = asObject(assetInventory.source_roots);
const assetOrbitRoots = asObject(assetInventory.orbit_roots);
const sourceAssets = asObject(assetInventory.source_assets);
const orbitAssets = asObject(assetInventory.orbit_assets);
const orbitBindings = asObject(assetInventory.bindings);
const assetIssues = asObject(assetInventory.issues);
const wslRuntime = asObject(status?.wsl_runtime); const wslRuntime = asObject(status?.wsl_runtime);
const wslRuntimeItems = asArray(wslRuntime.runtimes); const wslRuntimeItems = asArray(wslRuntime.runtimes);
const pairingSystem = asObject(status?.pairing_system); const pairingSystem = asObject(status?.pairing_system);
@@ -374,58 +377,60 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
const orbitPools = orbitStatus?.pools || {}; const orbitPools = orbitStatus?.pools || {};
const orbitConsistency = orbitStatus?.consistency || {}; const orbitConsistency = orbitStatus?.consistency || {};
const orbitDatabase = orbitStatus?.database || {}; const orbitDatabase = orbitStatus?.database || {};
const orbitIsce2Enabled = Boolean(orbitPools.isce2?.enabled || orbitConsistency.isce2?.enabled || orbitDatabase.isce2_enabled);
const orbitMismatchCount = toNumber(orbitConsistency.mismatch_count); const orbitMismatchCount = toNumber(orbitConsistency.mismatch_count);
const orbitDbMissingEnviCount = toNumber(orbitDatabase.stems_missing_in_envi_count); const orbitDbMissingEnviCount = toNumber(orbitDatabase.stems_missing_in_envi_count);
const orbitDbMissingIsce2Count = orbitIsce2Enabled ? toNumber(orbitDatabase.stems_missing_in_isce2_count) : 0;
const orbitDbMissingPathCount = toNumber(orbitDatabase.db_missing_path_count); const orbitDbMissingPathCount = toNumber(orbitDatabase.db_missing_path_count);
const orbitDbFlagIssueCount = const orbitDbFlagIssueCount =
toNumber(orbitDatabase.has_orbit_but_missing_path_count) + toNumber(orbitDatabase.has_orbit_but_missing_path_count) +
toNumber(orbitDatabase.without_orbit_but_path_present_count); toNumber(orbitDatabase.without_orbit_but_path_present_count);
const orbitScanErrorCount = const orbitScanErrorCount =
(orbitSource.errors?.length || 0) + (orbitSource.errors?.length || 0) +
(orbitPools.envi?.errors?.length || 0) + (orbitPools.envi?.errors?.length || 0);
(orbitIsce2Enabled ? (orbitPools.isce2?.errors?.length || 0) : 0);
const orbitDuplicateCount = const orbitDuplicateCount =
toNumber(orbitSource.duplicate_count) + toNumber(orbitSource.duplicate_count) +
toNumber(orbitPools.envi?.duplicate_count) + toNumber(orbitPools.envi?.duplicate_count);
(orbitIsce2Enabled ? toNumber(orbitPools.isce2?.duplicate_count) : 0);
const orbitSuspectBadCount = toNumber(orbitSource.suspect_bad_count); const orbitSuspectBadCount = toNumber(orbitSource.suspect_bad_count);
const orbitSourceWithoutEnviCount = toNumber(orbitSource.source_without_envi_count); const orbitSourceWithoutEnviCount = toNumber(orbitSource.source_without_envi_count);
const orbitEnviWithoutSourceCount = toNumber(orbitSource.envi_without_source_count); const orbitEnviWithoutSourceCount = toNumber(orbitSource.envi_without_source_count);
const orbitIsce2WithoutSourceCount = orbitIsce2Enabled ? toNumber(orbitSource.isce2_without_source_count) : 0;
const orbitQuarantinePath = orbitSource.quarantine_path || orbitStatus?.source_gaps?.quarantine_path;
const orbitBadSourceSamples = asArray(orbitSource.bad_source_samples).filter(hasOrbitCorruptionSignal); const orbitBadSourceSamples = asArray(orbitSource.bad_source_samples).filter(hasOrbitCorruptionSignal);
const orbitSuspectBadSamples = asArray(orbitSource.suspect_bad_samples);
const orbitSuspectWithoutCorruptionSamples = orbitSuspectBadSamples.filter(
(item) => !orbitBadSourceSamples.some((badItem) => badItem.name === item.name)
);
const orbitBadSourceSampleCount = toNumber(orbitSource.bad_source_sample_count || orbitBadSourceSamples.length); const orbitBadSourceSampleCount = toNumber(orbitSource.bad_source_sample_count || orbitBadSourceSamples.length);
const orbitOverallHealthy = Boolean( const orbitOverallHealthy = Boolean(
orbitStatus && orbitStatus &&
orbitMismatchCount === 0 && orbitMismatchCount === 0 &&
orbitDbMissingEnviCount === 0 && orbitDbMissingEnviCount === 0 &&
orbitDbMissingIsce2Count === 0 &&
orbitDbMissingPathCount === 0 && orbitDbMissingPathCount === 0 &&
orbitDbFlagIssueCount === 0 && orbitDbFlagIssueCount === 0 &&
orbitScanErrorCount === 0 && orbitScanErrorCount === 0 &&
orbitSuspectBadCount === 0 && orbitSuspectBadCount === 0 &&
orbitSourceWithoutEnviCount === 0 && orbitSourceWithoutEnviCount === 0 &&
orbitEnviWithoutSourceCount === 0 && orbitEnviWithoutSourceCount === 0
orbitIsce2WithoutSourceCount === 0
); );
const assetInventoryHealthy = Boolean(hasAssetInventory && assetInventory.ok);
const orbitAssetRiskCount =
toNumber(orbitAssets.parse_failed_count) +
toNumber(orbitBindings.missing_count) +
toNumber(orbitBindings.ambiguous_count);
const productionBlockingCount = [
!status?.ok,
!status?.database?.ok,
!status?.database?.schema_ok,
!status?.worker?.ok,
consistencySummary && consistencySummary.critical > 0,
hasAssetInventory && !assetInventoryHealthy,
].filter(Boolean).length;
return ( return (
<div className="health-panel"> <div className="health-panel">
<div className="health-header"> <div className="health-header">
<div> <div>
<div className="health-title">{en ? 'System Health' : '运维自检'}</div> <div className="health-title">运行维护</div>
<div className="health-subtitle"> <div className="health-subtitle">
{lastChecked ? `${en ? 'Last check: ' : '上次检查:'}${lastChecked.toLocaleString()}` : (en ? 'Not checked yet' : '尚未检查')} 面向生产环境的系统健康、数据一致性和维护操作入口
</div> </div>
</div> </div>
<button className="health-refresh" onClick={() => fetchStatus({ force: true })} disabled={loading}> <button className="health-refresh" onClick={() => fetchStatus({ force: true })} disabled={loading}>
{loading ? (en ? 'Checking...' : '检查中...') : (en ? 'Refresh' : '刷新')} {loading ? '检查中...' : '刷新自检'}
</button> </button>
</div> </div>
@@ -439,23 +444,37 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
<> <>
<div className="health-summary"> <div className="health-summary">
<div className="health-summary-item"> <div className="health-summary-item">
<span>{en ? 'Overall Status' : '总体状态'}</span> <span>生产就绪</span>
{renderBadge(status.ok)} {renderBadge(status.ok && productionBlockingCount === 0, productionBlockingCount > 0 ? `${productionBlockingCount} 个阻断项` : '可运行')}
</div> </div>
<div className="health-summary-item"> <div className="health-summary-item">
<span>{en ? 'Timestamp' : '时间戳'}</span> <span>最近检查</span>
<span>{formatIso(status.timestamp)}</span> <span>{lastChecked ? lastChecked.toLocaleString() : formatIso(status.timestamp)}</span>
</div> </div>
<div className="health-summary-item"> <div className="health-summary-item">
<span>{en ? 'Consistency Issues' : '一致性异常'}</span> <span>一致性异常</span>
{renderBadge( {renderBadge(
!consistencySummary || consistencySummary.total === 0, !consistencySummary || consistencySummary.total === 0,
consistencySummary ? `${consistencySummary.total} ${en ? 'items' : '项'}` : (en ? 'Unknown' : '未知') consistencySummary ? `${consistencySummary.total} 项` : '未知'
)} )}
</div> </div>
<div className="health-signal-row">
{renderSignal(!!status.database?.ok && !!status.database?.schema_ok, '数据库')}
{renderSignal(!!status.worker?.ok, 'Worker', `${status.worker?.worker_count ?? 0} 个`)}
{renderSignal(!!status.idl?.ok, 'IDL/ENVI')}
{renderSignal(!!status.nginx?.ok, 'Nginx')}
</div>
</div> </div>
<div className="health-grid"> <div className="health-sections">
<section className="health-section">
<div className="health-section-header">
<div>
<h3>核心服务</h3>
<p>判断系统是否具备基础生产能力:数据库、PostGIS、schema 和任务执行器。</p>
</div>
</div>
<div className="health-grid">
<div className="health-card"> <div className="health-card">
<div className="health-card-title">{en ? 'Database' : '数据库'}</div> <div className="health-card-title">{en ? 'Database' : '数据库'}</div>
<div className="health-card-row"> <div className="health-card-row">
@@ -587,7 +606,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</div> </div>
)} )}
</div> </div>
</div>
</section>
<section className="health-section">
<div className="health-section-header">
<div>
<h3>结果目录与生产索引</h3>
<p>检查 D-InSAR、时序 InSAR、配对基础表和兼容视图是否能支撑结果查询与生产流转。</p>
</div>
</div>
<div className="health-grid">
<div className="health-card"> <div className="health-card">
<div className="health-card-title">{en ? 'D-InSAR Result Catalog' : 'D-InSAR 结果目录'}</div> <div className="health-card-title">{en ? 'D-InSAR Result Catalog' : 'D-InSAR 结果目录'}</div>
<div className="health-card-row"> <div className="health-card-row">
@@ -758,7 +787,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</div> </div>
{bridgeIssueCount === 0 ? ( {bridgeIssueCount === 0 ? (
<div className="health-card-note ok"> <div className="health-card-note ok">
{en ? 'Catalog-first reads and legacy compat rows are aligned.' : '目录事实源与旧兼容视图当前一致。'} {en ? 'Catalog reads and compatibility rows are aligned.' : '目录事实源与兼容视图当前一致。'}
</div> </div>
) : ( ) : (
<> <>
@@ -805,7 +834,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</div> </div>
)} )}
</div> </div>
</div>
</section>
<section className="health-section">
<div className="health-section-header">
<div>
<h3>数据资产与运行时</h3>
<p>检查受管源路径、标准结果包、WSL runtime、IDL/ENVI、D-InSAR 引擎、Ollama 和 Nginx。</p>
</div>
</div>
<div className="health-grid">
<div className="health-card"> <div className="health-card">
<div className="health-card-title">{en ? 'Managed Source Roots' : '受管源路径'}</div> <div className="health-card-title">{en ? 'Managed Source Roots' : '受管源路径'}</div>
<div className="health-card-row"> <div className="health-card-row">
@@ -1037,7 +1076,115 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</div> </div>
)} )}
</div> </div>
</div>
</section>
<section className="health-section">
<div className="health-section-header">
<div>
<h3>数据资产与精轨</h3>
<p>当前生产以 XML 抽取后的源产品、精轨资产和场景绑定为准;旧精轨池核对仅作为过渡诊断。</p>
</div>
</div>
<div className="health-grid health-grid--ops">
<div className="health-card">
<div className="health-card-title">源产品资产</div>
<div className="health-card-row">
<span>总体状态</span>
{renderBadge(
assetInventoryHealthy,
assetInventory ? `${toNumber(sourceAssets.total_count)} 项` : '未知'
)}
</div>
<div className="health-card-row">
<span>LT-1 / Sentinel-1</span>
<span>{toNumber(sourceAssets.lt1_count)} / {toNumber(sourceAssets.s1_count)}</span>
</div>
<div className="health-card-row">
<span>解析异常</span>
<span>{toNumber(sourceAssets.parse_failed_count)}</span>
</div>
<div className="health-card-row">
<span>源数据根</span>
<span>{toNumber(assetSourceRoots.accessible_count)} / {toNumber(assetSourceRoots.configured_count)}</span>
</div>
<div className="health-card-row">
<span>需复扫</span>
<span>{toNumber(assetSourceRoots.needs_rescan_count)}</span>
</div>
{toNumber(sourceAssets.parse_failed_count) > 0 ? (
<div className="health-card-note error">
存在源产品解析异常,请在“数据资产”中查看开放问题并复扫相关目录。
</div>
) : (
<div className="health-card-note ok">
源产品资产已按 XML/元数据登记。
</div>
)}
</div>
<div className="health-card">
<div className="health-card-title">精轨资产状态</div>
<div className="health-card-row">
<span>总体状态</span>
{renderBadge(
orbitAssetRiskCount === 0 && assetInventoryHealthy,
orbitAssetRiskCount > 0 ? `${orbitAssetRiskCount} 个风险项` : `${toNumber(orbitAssets.total_count)} 项`
)}
</div>
<div className="health-card-row">
<span>LT-1 / Sentinel-1</span>
<span>{toNumber(orbitAssets.lt1_count)} / {toNumber(orbitAssets.s1_count)}</span>
</div>
<div className="health-card-row">
<span>解析异常</span>
<span>{toNumber(orbitAssets.parse_failed_count)}</span>
</div>
<div className="health-card-row">
<span>精轨根</span>
<span>{toNumber(assetOrbitRoots.accessible_count)} / {toNumber(assetOrbitRoots.configured_count)}</span>
</div>
<div className="health-card-row">
<span>需复扫</span>
<span>{toNumber(assetOrbitRoots.needs_rescan_count)}</span>
</div>
<div className="health-card-note">
精轨可用性以资产登记和时间窗绑定结果为准,不再以 ISCE2 XML 池作为生产判断。
</div>
</div>
<div className="health-card">
<div className="health-card-title">场景精轨绑定</div>
<div className="health-card-row">
<span>已绑定场景</span>
<span>{toNumber(orbitBindings.matched_count)} / {toNumber(orbitBindings.scene_count)}</span>
</div>
<div className="health-card-row">
<span>缺失精轨</span>
<span>{toNumber(orbitBindings.missing_count)}</span>
</div>
<div className="health-card-row">
<span>候选歧义</span>
<span>{toNumber(orbitBindings.ambiguous_count)}</span>
</div>
<div className="health-card-row">
<span>开放问题</span>
<span>{toNumber(assetIssues.open_count)}</span>
</div>
<div className="health-card-row">
<span>错误 / 警告</span>
<span>{toNumber(assetIssues.error_count)} / {toNumber(assetIssues.warning_count)}</span>
</div>
{orbitAssetRiskCount > 0 ? (
<div className="health-card-note warn">
请在“数据资产”中复核缺失或歧义精轨;生产配对会优先使用已选定的精轨资产。
</div>
) : (
<div className="health-card-note ok">
当前开放问题未显示精轨绑定风险。
</div>
)}
</div>
<div className="health-card"> <div className="health-card">
<div className="health-card-title">{en ? 'Consistency Check' : '一致性检测'}</div> <div className="health-card-title">{en ? 'Consistency Check' : '一致性检测'}</div>
<div className="health-card-row"> <div className="health-card-row">
@@ -1103,12 +1250,30 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</div> </div>
)} )}
</div> </div>
</div>
{/* 精轨管理 */}
{isAdmin && ( {isAdmin && (
<div className="health-card"> <details className="health-card health-legacy-diagnostic">
<div className="health-card-title">{en ? 'Precise Orbit Management' : '精轨管理'}</div> <summary>
<span>旧精轨池核对</span>
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void refreshOrbitStatus();
}}
disabled={orbitSyncing}
className="health-inline-button"
>
{orbitStatus ? '重新核对' : '加载诊断'}
</button>
</summary>
<div className="health-card-note">
该诊断仅核对源精轨目录与 ENVI/Gamma 生产 TXT 池,用于排查历史目录;生产判断以资产登记与场景绑定为准。
</div>
{orbitStatus ? (
<>
<div className="health-card-title">{en ? 'Precise Orbit Management' : '文件池状态'}</div>
<div className="health-card-row"> <div className="health-card-row">
<span>{en ? 'Overall' : '总体状态'}</span> <span>{en ? 'Overall' : '总体状态'}</span>
{renderBadge( {renderBadge(
@@ -1120,14 +1285,10 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</div> </div>
<div className="health-card-row"> <div className="health-card-row">
<span> <span>
{orbitIsce2Enabled {en ? 'Source / production TXT' : '源目录 / 生产 TXT'}
? (en ? 'Source / ENVI-Gamma TXT / ISCE2 XML' : '源目录 / ENVI-Gamma TXT / ISCE2 XML')
: (en ? 'Source / ENVI-Gamma TXT' : '源目录 / ENVI-Gamma TXT')}
</span> </span>
<span> <span>
{orbitIsce2Enabled {`${toNumber(orbitSource.total_source)} / ${toNumber(orbitPools.envi?.total)}`}
? `${toNumber(orbitSource.total_source)} / ${toNumber(orbitPools.envi?.total)} / ${toNumber(orbitPools.isce2?.total)}`
: `${toNumber(orbitSource.total_source)} / ${toNumber(orbitPools.envi?.total)}`}
</span> </span>
</div> </div>
<div className="health-card-row"> <div className="health-card-row">
@@ -1140,24 +1301,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
<span>{en ? 'Pool mismatches' : '池不一致'}</span> <span>{en ? 'Pool mismatches' : '池不一致'}</span>
<span>{orbitMismatchCount}</span> <span>{orbitMismatchCount}</span>
</div> </div>
{orbitIsce2Enabled ? (
<div className="health-card-row">
<span>{en ? 'Suspect bad TXT / source-only' : '疑似坏 TXT / 仅源存在'}</span>
<span>{orbitSuspectBadCount} / {orbitSourceWithoutEnviCount}</span>
</div>
) : (
<div className="health-card-row">
<span>{en ? 'Source-only' : '仅源存在'}</span>
<span>{orbitSourceWithoutEnviCount}</span>
</div>
)}
<div className="health-card-row"> <div className="health-card-row">
<span>{orbitIsce2Enabled ? (en ? 'TXT-only / ISCE2-only' : '仅 TXT / 仅 ISCE2') : (en ? 'TXT-only' : '仅 TXT')}</span> <span>{en ? 'Source-only' : '仅源存在'}</span>
<span>{orbitIsce2Enabled ? `${orbitEnviWithoutSourceCount} / ${orbitIsce2WithoutSourceCount}` : orbitEnviWithoutSourceCount}</span> <span>{orbitSourceWithoutEnviCount}</span>
</div> </div>
<div className="health-card-row"> <div className="health-card-row">
<span>{orbitIsce2Enabled ? (en ? 'DB missing in TXT / ISCE2' : '数据库在 TXT / ISCE2 缺失') : (en ? 'DB missing in TXT' : '数据库在 TXT 缺失')}</span> <span>{en ? 'Production TXT only' : '仅生产 TXT 存在'}</span>
<span>{orbitIsce2Enabled ? `${orbitDbMissingEnviCount} / ${orbitDbMissingIsce2Count}` : orbitDbMissingEnviCount}</span> <span>{orbitEnviWithoutSourceCount}</span>
</div>
<div className="health-card-row">
<span>{en ? 'DB missing in production TXT' : '数据库在生产 TXT 缺失'}</span>
<span>{orbitDbMissingEnviCount}</span>
</div> </div>
<div className="health-card-row"> <div className="health-card-row">
<span>{en ? 'Duplicate stems / scan errors' : '重复 stem / 扫描异常'}</span> <span>{en ? 'Duplicate stems / scan errors' : '重复 stem / 扫描异常'}</span>
@@ -1166,21 +1320,11 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
<div className="health-card-note" style={{ marginTop: 4 }}> <div className="health-card-note" style={{ marginTop: 4 }}>
{en {en
? (orbitIsce2Enabled ? 'LT-1 orbit scans synchronize the production TXT pool for ENVI/SARscape and Gamma. S1 EOF files remain registered as source orbit assets.'
? 'LT-1 orbit scans synchronize the production TXT pool and the legacy ISCE2 XML pool. S1 EOF files remain registered as source orbit assets.' : 'LT-1 精轨扫描会同步 ENVI/SARscape 与 Gamma 共用的生产 TXT 池;S1 EOF 只登记为源精轨资产。'}
: 'LT-1 orbit scans synchronize the production TXT pool for ENVI/SARscape and Gamma. S1 EOF files remain registered as source orbit assets; ISCE2 XML is disabled.')
: (orbitIsce2Enabled
? 'LT-1 精轨扫描会同步生产 TXT 池和 legacy ISCE2 XML 池;S1 EOF 只登记为源精轨资产。'
: 'LT-1 精轨扫描会同步 ENVI/SARscape 与 Gamma 共用的生产 TXT 池;S1 EOF 只登记为源精轨资产,ISCE2 XML 已停用。')}
</div> </div>
<div className="health-card-note">{en ? 'Source path: ' : '源目录路径:'}{formatPathText(orbitSource.path)}</div> <div className="health-card-note">{en ? 'Source path: ' : '源目录路径:'}{formatPathText(orbitSource.path)}</div>
<div className="health-card-note">{en ? 'Production TXT pool: ' : '生产 TXT 池:'}{formatPathText(orbitPools.envi?.path)}</div> <div className="health-card-note">{en ? 'Production TXT pool: ' : '生产 TXT 池:'}{formatPathText(orbitPools.envi?.path)}</div>
{orbitIsce2Enabled && (
<>
<div className="health-card-note">{en ? 'Legacy ISCE2 pool: ' : 'Legacy ISCE2 池:'}{formatPathText(orbitPools.isce2?.path)}</div>
<div className="health-card-note">{en ? 'Quarantine path: ' : '隔离目录:'}{formatPathText(orbitQuarantinePath)}</div>
</>
)}
{orbitDuplicateCount > 0 && ( {orbitDuplicateCount > 0 && (
<div className="health-card-note warn"> <div className="health-card-note warn">
@@ -1203,13 +1347,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
: `数据库 orbit_file_path 指向不存在文件:${orbitDbMissingPathCount} / ${toNumber(orbitDatabase.distinct_orbit_path_count)}`} : `数据库 orbit_file_path 指向不存在文件:${orbitDbMissingPathCount} / ${toNumber(orbitDatabase.distinct_orbit_path_count)}`}
</div> </div>
)} )}
{orbitIsce2Enabled && orbitSuspectBadCount > 0 && (
<div className="health-card-note warn">
{en
? `Suspect bad source TXT (source exists but ISCE2 XML missing): ${orbitSuspectBadCount}`
: `疑似坏源 TXT(源文件存在但 ISCE2 XML 缺失):${orbitSuspectBadCount}`}
</div>
)}
{orbitBadSourceSampleCount > 0 && ( {orbitBadSourceSampleCount > 0 && (
<div className="health-card-note error"> <div className="health-card-note error">
{en {en
@@ -1223,24 +1360,12 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
{orbitDatabase.sample_missing_in_envi.slice(0, 5).join(', ')} {orbitDatabase.sample_missing_in_envi.slice(0, 5).join(', ')}
</div> </div>
)} )}
{orbitIsce2Enabled && orbitDatabase.sample_missing_in_isce2?.length > 0 && (
<div className="health-card-note error">
{en ? 'DB expected but ISCE2 pool missing: ' : '数据库期望但 ISCE2 池缺失:'}
{orbitDatabase.sample_missing_in_isce2.slice(0, 5).join(', ')}
</div>
)}
{orbitBadSourceSamples.slice(0, 5).map((item) => ( {orbitBadSourceSamples.slice(0, 5).map((item) => (
<div key={`orbit-bad-source-${item.name}`} className="health-card-note error"> <div key={`orbit-bad-source-${item.name}`} className="health-card-note error">
{item.name} - {item.error || (en ? 'Corruption signal detected' : '检测到损坏信号')} {item.name} - {item.error || (en ? 'Corruption signal detected' : '检测到损坏信号')}
{renderOrbitSourceIssueDetails(item, en, formatPathText)} {renderOrbitSourceIssueDetails(item, en, formatPathText)}
</div> </div>
))} ))}
{orbitIsce2Enabled && orbitSuspectWithoutCorruptionSamples.slice(0, 5).map((item) => (
<div key={`orbit-suspect-bad-${item.name}`} className="health-card-note warn">
{item.name}
{renderOrbitSourceIssueDetails(item, en, formatPathText)}
</div>
))}
{(orbitConsistency.mismatches || []).slice(0, 5).map((item) => ( {(orbitConsistency.mismatches || []).slice(0, 5).map((item) => (
<div key={item.name} className="health-card-note error"> <div key={item.name} className="health-card-note error">
{item.name} - {item.issue} {item.name} - {item.issue}
@@ -1249,11 +1374,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
{en ? 'ENVI: ' : 'ENVI:'}{formatPathText(item.envi_path)} {en ? 'ENVI: ' : 'ENVI:'}{formatPathText(item.envi_path)}
</div> </div>
)} )}
{item.isce2_path && (
<div style={{ color: '#64748b' }}>
{en ? 'ISCE2: ' : 'ISCE2:'}{formatPathText(item.isce2_path)}
</div>
)}
</div> </div>
))} ))}
{orbitConsistency.mismatch_count > 5 && ( {orbitConsistency.mismatch_count > 5 && (
@@ -1278,11 +1398,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
{en ? 'ENVI pool scan error: ' : 'ENVI 池扫描异常:'}{item} {en ? 'ENVI pool scan error: ' : 'ENVI 池扫描异常:'}{item}
</div> </div>
))} ))}
{orbitIsce2Enabled && (orbitPools.isce2?.errors || []).slice(0, 3).map((item, index) => (
<div key={`orbit-isce2-error-${index}`} className="health-card-note error">
{en ? 'ISCE2 pool scan error: ' : 'ISCE2 池扫描异常:'}{item}
</div>
))}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
<button <button
@@ -1299,53 +1414,11 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
setOrbitSyncing(false); setOrbitSyncing(false);
} }
}} }}
disabled={orbitSyncing || orbitRepairing || orbitQuarantining} disabled={orbitSyncing}
style={{ padding: '4px 12px', background: '#1890ff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }} style={{ padding: '4px 12px', background: '#1890ff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
> >
{orbitSyncing ? (en ? 'Checking...' : '检查中...') : (en ? 'Check Consistency' : '精轨一致性检查')} {orbitSyncing ? (en ? 'Checking...' : '检查中...') : (en ? 'Check Consistency' : '精轨一致性检查')}
</button> </button>
{orbitIsce2Enabled && (
<>
<button
onClick={async () => {
setOrbitRepairing(true);
setOrbitSyncResult(null);
try {
const result = await syncOrbitPools({ repair: true });
setOrbitSyncResult(result);
await refreshOrbitStatus();
} catch (e) {
setOrbitSyncResult({ error: e.response?.data?.detail || e.message });
} finally {
setOrbitRepairing(false);
}
}}
disabled={orbitSyncing || orbitRepairing || orbitQuarantining}
style={{ padding: '4px 12px', background: '#0f766e', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
>
{orbitRepairing ? (en ? 'Repairing...' : '修复中...') : (en ? 'Repair Missing XML' : '修复缺失 XML')}
</button>
<button
onClick={async () => {
setOrbitQuarantining(true);
setOrbitSyncResult(null);
try {
const result = await syncOrbitPools({ quarantine_bad: true });
setOrbitSyncResult(result);
await refreshOrbitStatus();
} catch (e) {
setOrbitSyncResult({ error: e.response?.data?.detail || e.message });
} finally {
setOrbitQuarantining(false);
}
}}
disabled={orbitSyncing || orbitRepairing || orbitQuarantining}
style={{ padding: '4px 12px', background: '#b45309', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
>
{orbitQuarantining ? (en ? 'Quarantining...' : '隔离中...') : (en ? 'Quarantine Bad TXT' : '隔离坏精轨')}
</button>
</>
)}
</div> </div>
{orbitSyncResult && ( {orbitSyncResult && (
@@ -1354,176 +1427,89 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
<div className="health-card-note error">{orbitSyncResult.error}</div> <div className="health-card-note error">{orbitSyncResult.error}</div>
) : ( ) : (
<> <>
{'confirmed_bad_count' in orbitSyncResult ? ( <div className={`health-card-note ${orbitSyncResult.healthy ? 'ok' : 'error'}`}>
<> {orbitSyncResult.healthy
<div className={`health-card-note ${toNumber(orbitSyncResult.confirmed_bad_count) === 0 ? 'ok' : 'warn'}`}> ? (en ? 'Pools are consistent.' : '本地池一致。')
{en : (en ? `Detected ${toNumber(orbitSyncResult.mismatch_count)} mismatches.` : `检测到 ${toNumber(orbitSyncResult.mismatch_count)} 项不一致。`)}
? `Quarantine finished. Confirmed bad TXT: ${toNumber(orbitSyncResult.confirmed_bad_count)} / validated ${toNumber(orbitSyncResult.validated_count)}` </div>
: `隔离完成。已确认坏 TXT ${toNumber(orbitSyncResult.confirmed_bad_count)} 项 / 已校验 ${toNumber(orbitSyncResult.validated_count)} 项`} <div className="health-card-note">
</div> {en
<div className="health-card-note"> ? `TXT ${toNumber(orbitSyncResult.envi?.total)}, scan errors ${toNumber(orbitSyncResult.error_count)}`
{en : `TXT ${toNumber(orbitSyncResult.envi?.total)} 项,扫描异常 ${toNumber(orbitSyncResult.error_count)} 项`}
? `Quarantine root: ${formatPathText(orbitSyncResult.quarantine_root)}` </div>
: `隔离目录:${formatPathText(orbitSyncResult.quarantine_root)}`} {(orbitSyncResult.mismatches || []).slice(0, 5).map((item, index) => (
</div> <div key={`orbit-check-mismatch-${index}`} className="health-card-note error">
{(orbitSyncResult.confirmed_bad || []).slice(0, 5).map((item, index) => ( {item.name} - {item.issue}
<div key={`orbit-quarantine-bad-${index}`} className="health-card-note error"> {item.envi_path && (
{item.name} - {item.error} <div style={{ color: '#64748b' }}>
{renderOrbitSourceIssueDetails(item, en, formatPathText)} {en ? 'ENVI: ' : 'ENVI:'}{formatPathText(item.envi_path)}
{item.quarantined_source && (
<div style={{ color: '#64748b' }}>
{en ? 'Moved source to: ' : '源文件已移至:'}{formatPathText(item.quarantined_source)}
</div>
)}
{item.quarantined_envi && (
<div style={{ color: '#64748b' }}>
{en ? 'Moved ENVI to: ' : 'ENVI 已移至:'}{formatPathText(item.quarantined_envi)}
</div>
)}
{item.quarantined_isce2 && (
<div style={{ color: '#64748b' }}>
{en ? 'Moved ISCE2 to: ' : 'ISCE2 已移至:'}{formatPathText(item.quarantined_isce2)}
</div>
)}
</div>
))}
{(orbitSyncResult.skipped_valid || []).slice(0, 3).map((item, index) => (
<div key={`orbit-quarantine-skip-${index}`} className="health-card-note ok">
{item.name} - {item.reason}
</div>
))}
{(orbitSyncResult.errors || []).slice(0, 5).map((item, index) => (
<div key={`orbit-quarantine-error-${index}`} className="health-card-note error">
{item.name} - {item.scope} - {item.error}
</div>
))}
</>
) : 'before' in orbitSyncResult ? (
<>
<div className={`health-card-note ${orbitSyncResult.healthy ? 'ok' : 'warn'}`}>
{en
? `Repair finished. Before mismatches: ${toNumber(orbitSyncResult.before?.mismatch_count)}, after mismatches: ${toNumber(orbitSyncResult.after?.mismatch_count)}`
: `修复完成。修复前不一致 ${toNumber(orbitSyncResult.before?.mismatch_count)} 项,修复后不一致 ${toNumber(orbitSyncResult.after?.mismatch_count)} 项`}
</div>
<div className="health-card-note">
{en
? `Recovered from ENVI TXT: ${(orbitSyncResult.repaired_from_envi || []).length}, repair errors: ${toNumber(orbitSyncResult.repair_error_count)}`
: `从 ENVI TXT 补转成功 ${(orbitSyncResult.repaired_from_envi || []).length} 项,修复失败 ${toNumber(orbitSyncResult.repair_error_count)} 项`}
</div>
<div className="health-card-note">
{en
? `Source scan ${toNumber(orbitSyncResult.sync_result?.total_source)}, TXT copied ${(orbitSyncResult.sync_result?.envi?.copied || []).length}, TXT refreshed ${(orbitSyncResult.sync_result?.envi?.updated || []).length}${orbitSyncResult.isce2_enabled ? `, ISCE2 converted ${(orbitSyncResult.sync_result?.isce2?.converted || []).length}, ISCE2 refreshed ${(orbitSyncResult.sync_result?.isce2?.reconverted || []).length}` : ', ISCE2 disabled'}`
: `源目录扫描 ${toNumber(orbitSyncResult.sync_result?.total_source)} 项,TXT 新增 ${(orbitSyncResult.sync_result?.envi?.copied || []).length} 项、刷新 ${(orbitSyncResult.sync_result?.envi?.updated || []).length} 项${orbitSyncResult.isce2_enabled ? `,ISCE2 新增转换 ${(orbitSyncResult.sync_result?.isce2?.converted || []).length} 项、重转 ${(orbitSyncResult.sync_result?.isce2?.reconverted || []).length} 项` : ',ISCE2 已停用'}`}
</div>
{(orbitSyncResult.repaired_from_envi || []).slice(0, 5).length > 0 && (
<div className="health-card-note ok">
{en ? 'Recovered stems: ' : '已补转 stem:'}
{(orbitSyncResult.repaired_from_envi || []).slice(0, 5).join(', ')}
</div> </div>
)} )}
{(orbitSyncResult.repair_errors || []).slice(0, 3).map((item, index) => ( </div>
<div key={`orbit-repair-error-${index}`} className="health-card-note error"> ))}
{item.name} - {item.error} {(orbitSyncResult.errors || []).slice(0, 3).map((item, index) => (
{renderOrbitSourceIssueDetails(item, en, formatPathText)} <div key={`orbit-check-error-${index}`} className="health-card-note error">
</div> {item}
))} </div>
{(orbitSyncResult.sync_result?.source?.errors || []).slice(0, 3).map((item, index) => ( ))}
<div key={`orbit-repair-source-error-${index}`} className="health-card-note error">
{en ? 'Source scan error: ' : '源目录扫描异常:'}{item}
</div>
))}
{(orbitSyncResult.sync_result?.invalid_sources || []).slice(0, 5).map((item, index) => (
<div key={`orbit-repair-invalid-${index}`} className="health-card-note error">
{item.name} - {item.error}
{renderOrbitSourceIssueDetails(item, en, formatPathText)}
</div>
))}
{(orbitSyncResult.sync_result?.isce2?.errors || []).slice(0, 3).map((item, index) => (
<div key={`orbit-repair-isce2-error-${index}`} className="health-card-note error">
{item.file} - {item.error}
</div>
))}
</>
) : (
<>
<div className={`health-card-note ${orbitSyncResult.healthy ? 'ok' : 'error'}`}>
{orbitSyncResult.healthy
? (en ? 'Pools are consistent.' : '本地池一致。')
: (en ? `Detected ${toNumber(orbitSyncResult.mismatch_count)} mismatches.` : `检测到 ${toNumber(orbitSyncResult.mismatch_count)} 项不一致。`)}
</div>
<div className="health-card-note">
{en
? `TXT ${toNumber(orbitSyncResult.envi?.total)}${orbitSyncResult.isce2?.enabled ? `, ISCE2 ${toNumber(orbitSyncResult.isce2?.total)}` : ', ISCE2 disabled'}, scan errors ${toNumber(orbitSyncResult.error_count)}`
: `TXT ${toNumber(orbitSyncResult.envi?.total)} 项${orbitSyncResult.isce2?.enabled ? `,ISCE2 ${toNumber(orbitSyncResult.isce2?.total)} 项` : ',ISCE2 已停用'},扫描异常 ${toNumber(orbitSyncResult.error_count)} 项`}
</div>
{(orbitSyncResult.mismatches || []).slice(0, 5).map((item, index) => (
<div key={`orbit-check-mismatch-${index}`} className="health-card-note error">
{item.name} - {item.issue}
{item.envi_path && (
<div style={{ color: '#64748b' }}>
{en ? 'ENVI: ' : 'ENVI:'}{formatPathText(item.envi_path)}
</div>
)}
{item.isce2_path && (
<div style={{ color: '#64748b' }}>
{en ? 'ISCE2: ' : 'ISCE2:'}{formatPathText(item.isce2_path)}
</div>
)}
</div>
))}
{(orbitSyncResult.errors || []).slice(0, 3).map((item, index) => (
<div key={`orbit-check-error-${index}`} className="health-card-note error">
{item}
</div>
))}
</>
)}
</> </>
)} )}
</div> </div>
)} )}
</div> </>
) : (
<div className="health-card-note">展开后点击“加载诊断”获取旧精轨池状态。</div>
)}
</details>
)} )}
</div>
</section>
{/* 系统维护 */} {/* 系统维护 */}
{isAdmin && ( <section className="health-section">
<div className="health-card"> <div className="health-section-header">
<div className="health-card-title">{en ? 'System Maintenance' : '系统维护'}</div> <div>
<div className="health-card-row" style={{ alignItems: 'center' }}> <h3>维护与审计</h3>
<span>{en ? 'Expired Sessions' : '过期会话清理'}</span> <p>低频维护动作与日志审计集中管理,避免与实时健康状态混杂。</p>
<button
onClick={async () => {
setCleanupLoading(true);
setCleanupResult(null);
try {
const data = await cleanupSessions();
setCleanupResult({ ok: true, message: data.message || `已清理 ${data.deleted_count} 条` });
} catch (e) {
setCleanupResult({ ok: false, message: e.message || '清理失败' });
} finally {
setCleanupLoading(false);
}
}}
disabled={cleanupLoading}
style={{ padding: '4px 12px', background: '#1890ff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
>
{cleanupLoading ? (en ? 'Cleaning...' : '清理中...') : (en ? 'Clean Up' : '清理')}
</button>
</div>
{cleanupResult && (
<div className={`health-card-note ${cleanupResult.ok ? 'ok' : 'error'}`} style={{ marginTop: 4 }}>
{cleanupResult.message}
</div> </div>
)}
<div className="health-card-note" style={{ marginTop: 4, color: '#94a3b8' }}>
{en ? 'Delete expired and revoked session records from the database.' : '删除数据库中已过期和已撤销的会话记录,释放空间。'}
</div> </div>
</div> <div className="health-maintenance-stack">
)} {isAdmin && (
<div className="health-action-card">
<div>
<h4>会话记录维护</h4>
<p>清理已过期或已撤销的登录会话,不影响当前有效登录。</p>
{cleanupResult && (
<div className={`health-card-note ${cleanupResult.ok ? 'ok' : 'error'}`}>
{cleanupResult.message}
</div>
)}
</div>
<button
className="health-action-button"
onClick={async () => {
setCleanupLoading(true);
setCleanupResult(null);
try {
const data = await cleanupSessions();
setCleanupResult({ ok: true, message: data.message || `已清理 ${data.deleted_count} 条` });
} catch (e) {
setCleanupResult({ ok: false, message: e.message || '清理失败' });
} finally {
setCleanupLoading(false);
}
}}
disabled={cleanupLoading}
>
{cleanupLoading ? '清理中...' : '执行清理'}
</button>
</div>
)}
{/* 日志管理 */} {/* 日志管理 */}
<div className="health-card"> <LogManagementPanel isAdmin={isAdmin} />
<LogManagementPanel isAdmin={isAdmin} /> </div>
</section>
</div> </div>
</> </>
)} )}
+5 -3
View File
@@ -41,7 +41,6 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
taskTypes: ['IDL_IMPORT', 'IDL_DINSAR'], taskTypes: ['IDL_IMPORT', 'IDL_DINSAR'],
showRecent: true, showRecent: true,
recentLimit: 1, recentLimit: 1,
pollRecentMs: 10000,
}); });
const runningTask = idlTaskMonitor.activeTasks[0] || null; const runningTask = idlTaskMonitor.activeTasks[0] || null;
@@ -75,9 +74,12 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
useEffect(() => { useEffect(() => {
refreshData().catch(() => {}); refreshData().catch(() => {});
const timer = setInterval(() => refreshData().catch(() => {}), 10000); if (!runningTask) {
return undefined;
}
const timer = setInterval(() => refreshData().catch(() => {}), 15000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [refreshData]); }, [refreshData, runningTask]);
const runAction = async (action) => { const runAction = async (action) => {
setIsBusy(true); setIsBusy(true);
+101 -227
View File
@@ -13,21 +13,27 @@ const LogManagementPanel = ({ isAdmin }) => {
const [totalLines, setTotalLines] = useState(0); const [totalLines, setTotalLines] = useState(0);
const [currentOffset, setCurrentOffset] = useState(0); const [currentOffset, setCurrentOffset] = useState(0);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [message, setMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const getErrorText = error => error.response?.data?.detail || error.message || '操作失败';
const loadLogs = useCallback(async () => { const loadLogs = useCallback(async () => {
setLoading(true); setLoading(true);
setErrorMessage('');
try { try {
const data = await listLogs(filterType || null); const data = await listLogs(filterType || null);
setLogs(data); setLogs(Array.isArray(data) ? data : []);
} catch (error) { } catch (error) {
console.error('加载日志列表失败:', error); console.error('加载日志列表失败:', error);
alert(`加载日志列表失败:${error.response?.data?.detail || error.message}`); setErrorMessage(`日志列表加载失败:${getErrorText(error)}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [filterType]); }, [filterType]);
const loadLogContent = useCallback(async (logPath, offset = 0) => { const loadLogContent = useCallback(async (logPath, offset = 0) => {
setErrorMessage('');
try { try {
const data = await getLogContent(logPath, offset, PAGE_SIZE); const data = await getLogContent(logPath, offset, PAGE_SIZE);
setLogContent(data.content || ''); setLogContent(data.content || '');
@@ -35,7 +41,7 @@ const LogManagementPanel = ({ isAdmin }) => {
setCurrentOffset(offset); setCurrentOffset(offset);
} catch (error) { } catch (error) {
console.error('加载日志内容失败:', error); console.error('加载日志内容失败:', error);
alert(`加载日志内容失败:${error.response?.data?.detail || error.message}`); setErrorMessage(`日志内容加载失败:${getErrorText(error)}`);
} }
}, []); }, []);
@@ -48,12 +54,13 @@ const LogManagementPanel = ({ isAdmin }) => {
setShowModal(true); setShowModal(true);
setCurrentOffset(0); setCurrentOffset(0);
setSearchTerm(''); setSearchTerm('');
setMessage('');
await loadLogContent(log.path, 0); await loadLogContent(log.path, 0);
}; };
const handleDeleteLog = async log => { const handleDeleteLog = async log => {
if (!isAdmin) { if (!isAdmin) {
alert('只有管理员可以删除日志。'); setErrorMessage('当前账号没有日志删除权限。');
return; return;
} }
@@ -61,9 +68,11 @@ const LogManagementPanel = ({ isAdmin }) => {
return; return;
} }
setMessage('');
setErrorMessage('');
try { try {
await deleteLog(log.path); await deleteLog(log.path);
alert('日志文件已删除。'); setMessage('日志文件已删除。');
await loadLogs(); await loadLogs();
if (selectedLog && selectedLog.path === log.path) { if (selectedLog && selectedLog.path === log.path) {
setShowModal(false); setShowModal(false);
@@ -74,28 +83,27 @@ const LogManagementPanel = ({ isAdmin }) => {
} }
} catch (error) { } catch (error) {
console.error('删除日志失败:', error); console.error('删除日志失败:', error);
alert(`删除日志失败:${error.response?.data?.detail || error.message}`); setErrorMessage(`日志删除失败:${getErrorText(error)}`);
} }
}; };
const handlePrevPage = () => { const handlePrevPage = () => {
if (selectedLog && currentOffset > 0) { if (selectedLog && currentOffset > 0) {
const newOffset = Math.max(0, currentOffset - PAGE_SIZE); loadLogContent(selectedLog.path, Math.max(0, currentOffset - PAGE_SIZE));
loadLogContent(selectedLog.path, newOffset);
} }
}; };
const handleNextPage = () => { const handleNextPage = () => {
if (selectedLog && currentOffset + PAGE_SIZE < totalLines) { if (selectedLog && currentOffset + PAGE_SIZE < totalLines) {
const newOffset = currentOffset + PAGE_SIZE; loadLogContent(selectedLog.path, currentOffset + PAGE_SIZE);
loadLogContent(selectedLog.path, newOffset);
} }
}; };
const formatSize = bytes => { const formatSize = bytes => {
if (bytes < 1024) return `${bytes} B`; const size = Number(bytes) || 0;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (size < 1024) return `${size} B`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}; };
const getTypeLabel = type => { const getTypeLabel = type => {
@@ -105,17 +113,7 @@ const LogManagementPanel = ({ isAdmin }) => {
error: '错误日志', error: '错误日志',
other: '其他', other: '其他',
}; };
return labels[type] || type; return labels[type] || type || '其他';
};
const getTypeColor = type => {
const colors = {
app: '#3b82f6',
task: '#10b981',
error: '#ef4444',
other: '#6b7280',
};
return colors[type] || '#6b7280';
}; };
const filteredContent = searchTerm const filteredContent = searchTerm
@@ -125,242 +123,118 @@ const LogManagementPanel = ({ isAdmin }) => {
.join('\n') .join('\n')
: logContent; : logContent;
const pageStart = totalLines === 0 ? 0 : currentOffset + 1;
const pageEnd = Math.min(currentOffset + PAGE_SIZE, totalLines);
return ( return (
<div style={{ padding: '20px' }}> <div className="log-management-panel">
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div className="log-management-header">
<h3 style={{ margin: 0 }}>日志管理</h3> <div>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}> <h3>日志审计</h3>
<label>类型筛选:</label> <p>集中查看应用、任务与错误日志;删除操作仅限管理员。</p>
<select </div>
value={filterType} <div className="log-management-controls">
onChange={event => setFilterType(event.target.value)} <label className="ops-field">
style={{ padding: '5px 10px', borderRadius: '4px', border: '1px solid #ddd' }} <span>日志类型</span>
> <select value={filterType} onChange={event => setFilterType(event.target.value)}>
<option value="">全部</option> <option value="">全部日志</option>
<option value="app">应用日志</option> <option value="app">应用日志</option>
<option value="task">任务日志</option> <option value="task">任务日志</option>
<option value="error">错误日志</option> <option value="error">错误日志</option>
</select> </select>
<button </label>
onClick={loadLogs} <button className="ops-button ops-button--primary" onClick={loadLogs} disabled={loading}>
disabled={loading} {loading ? '刷新中...' : '刷新'}
style={{
padding: '5px 15px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
{loading ? '加载中...' : '刷新'}
</button> </button>
</div> </div>
</div> </div>
{errorMessage && <div className="ops-message ops-message--error">{errorMessage}</div>}
{message && <div className="ops-message ops-message--success">{message}</div>}
{logs.length === 0 ? ( {logs.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#6b7280' }}>暂无日志文件</div> <div className="log-empty-state">{loading ? '正在读取日志目录...' : '暂无可展示的日志文件'}</div>
) : ( ) : (
<table <div className="log-table-wrap">
style={{ <table className="log-table">
width: '100%', <thead>
borderCollapse: 'collapse', <tr>
backgroundColor: 'white', <th>文件名</th>
boxShadow: '0 1px 3px rgba(0,0,0,0.1)', <th>类型</th>
}} <th>大小</th>
> <th>修改时间</th>
<thead> <th>操作</th>
<tr style={{ backgroundColor: '#f3f4f6', borderBottom: '2px solid #e5e7eb' }}>
<th style={{ padding: '12px', textAlign: 'left' }}>文件名</th>
<th style={{ padding: '12px', textAlign: 'left' }}>类型</th>
<th style={{ padding: '12px', textAlign: 'right' }}>大小</th>
<th style={{ padding: '12px', textAlign: 'left' }}>修改时间</th>
<th style={{ padding: '12px', textAlign: 'center' }}>操作</th>
</tr>
</thead>
<tbody>
{logs.map((log, index) => (
<tr key={index} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={{ padding: '12px', fontFamily: 'monospace', fontSize: '13px' }}>{log.name}</td>
<td style={{ padding: '12px' }}>
<span
style={{
padding: '2px 8px',
borderRadius: '12px',
fontSize: '12px',
backgroundColor: `${getTypeColor(log.type)}20`,
color: getTypeColor(log.type),
}}
>
{getTypeLabel(log.type)}
</span>
</td>
<td style={{ padding: '12px', textAlign: 'right', fontFamily: 'monospace', fontSize: '13px' }}>
{formatSize(log.size)}
</td>
<td style={{ padding: '12px', fontSize: '13px' }}>{log.modified_at}</td>
<td style={{ padding: '12px', textAlign: 'center' }}>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center', alignItems: 'center' }}>
<button
onClick={() => handleViewLog(log)}
style={{
padding: '4px 12px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '13px',
whiteSpace: 'nowrap',
}}
>
查看
</button>
{isAdmin && (
<button
onClick={() => handleDeleteLog(log)}
style={{
padding: '4px 12px',
backgroundColor: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '13px',
whiteSpace: 'nowrap',
}}
>
删除
</button>
)}
</div>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {logs.map(log => (
<tr key={log.path || log.name}>
<td className="log-file-name">{log.name}</td>
<td>
<span className={`log-type-badge log-type-badge--${log.type || 'other'}`}>
{getTypeLabel(log.type)}
</span>
</td>
<td className="log-number-cell">{formatSize(log.size)}</td>
<td>{log.modified_at || '-'}</td>
<td>
<div className="log-row-actions">
<button className="ops-button ops-button--secondary ops-button--sm" onClick={() => handleViewLog(log)}>
查看
</button>
{isAdmin && (
<button className="ops-button ops-button--danger ops-button--sm" onClick={() => handleDeleteLog(log)}>
删除
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
{showModal && selectedLog && ( {showModal && selectedLog && (
<div <div className="log-modal-backdrop">
style={{ <div className="log-modal" role="dialog" aria-modal="true" aria-label="日志内容">
position: 'fixed', <div className="log-modal-header">
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
>
<div
style={{
backgroundColor: 'white',
borderRadius: '8px',
width: '90%',
maxWidth: '1200px',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
}}
>
<div
style={{
padding: '20px',
borderBottom: '1px solid #e5e7eb',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div> <div>
<h3 style={{ margin: '0 0 8px 0', fontFamily: 'monospace' }}>{selectedLog.name}</h3> <h3>{selectedLog.name}</h3>
<div style={{ fontSize: '13px', color: '#6b7280' }}> <p>
大小:{formatSize(selectedLog.size)} | 修改时间:{selectedLog.modified_at} | 总行数:{totalLines} 大小:{formatSize(selectedLog.size)} · 修改时间:{selectedLog.modified_at || '-'} · 总行数:{totalLines}
</div> </p>
</div> </div>
<button <button className="ops-button ops-button--secondary" onClick={() => setShowModal(false)}>
onClick={() => setShowModal(false)}
style={{
padding: '8px 16px',
backgroundColor: '#6b7280',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
关闭 关闭
</button> </button>
</div> </div>
<div style={{ padding: '12px 20px', borderBottom: '1px solid #e5e7eb', display: 'flex', gap: '10px', alignItems: 'center' }}> <div className="log-modal-tools">
<input <input
type="text" type="text"
placeholder="搜索日志内容..." placeholder="搜索日志内容"
value={searchTerm} value={searchTerm}
onChange={event => setSearchTerm(event.target.value)} onChange={event => setSearchTerm(event.target.value)}
style={{
flex: 1,
padding: '6px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '13px',
}}
/> />
<div style={{ fontSize: '13px', color: '#6b7280' }}> <div className="log-page-info">
显示行 {totalLines === 0 ? 0 : currentOffset + 1} - {Math.min(currentOffset + PAGE_SIZE, totalLines)} 显示行 {pageStart} - {pageEnd}
</div> </div>
<button <button className="ops-button ops-button--secondary ops-button--sm" onClick={handlePrevPage} disabled={currentOffset === 0}>
onClick={handlePrevPage}
disabled={currentOffset === 0}
style={{
padding: '6px 12px',
backgroundColor: currentOffset === 0 ? '#e5e7eb' : '#3b82f6',
color: currentOffset === 0 ? '#9ca3af' : 'white',
border: 'none',
borderRadius: '4px',
cursor: currentOffset === 0 ? 'not-allowed' : 'pointer',
fontSize: '13px',
}}
>
上一页 上一页
</button> </button>
<button <button
className="ops-button ops-button--secondary ops-button--sm"
onClick={handleNextPage} onClick={handleNextPage}
disabled={currentOffset + PAGE_SIZE >= totalLines} disabled={currentOffset + PAGE_SIZE >= totalLines}
style={{
padding: '6px 12px',
backgroundColor: currentOffset + PAGE_SIZE >= totalLines ? '#e5e7eb' : '#3b82f6',
color: currentOffset + PAGE_SIZE >= totalLines ? '#9ca3af' : 'white',
border: 'none',
borderRadius: '4px',
cursor: currentOffset + PAGE_SIZE >= totalLines ? 'not-allowed' : 'pointer',
fontSize: '13px',
}}
> >
下一页 下一页
</button> </button>
</div> </div>
<div style={{ flex: 1, overflow: 'auto', padding: '20px', backgroundColor: '#1e1e1e' }}> <div className="log-content-view">
<pre <pre>{filteredContent || '(空日志)'}</pre>
style={{
margin: 0,
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
fontSize: '12px',
lineHeight: '1.5',
color: '#d4d4d4',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{filteredContent || '(空日志)'}
</pre>
</div> </div>
</div> </div>
</div> </div>
-2
View File
@@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import apiClient from './api/client'; import apiClient from './api/client';
const LoginPage = ({ onLoginSuccess }) => { const LoginPage = ({ onLoginSuccess }) => {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -105,5 +104,4 @@ const LoginPage = ({ onLoginSuccess }) => {
); );
}; };
export default LoginPage; export default LoginPage;
+274 -306
View File
@@ -1,5 +1,4 @@
import { Suspense, lazy, useEffect, useMemo, useState } from 'react'; import { Suspense, lazy, useEffect, useMemo, useState } from 'react';
import { import {
PRODUCTION_WORKSPACE_ENTRY_TO_VIEW, PRODUCTION_WORKSPACE_ENTRY_TO_VIEW,
PRODUCTION_WORKSPACE_TAB, PRODUCTION_WORKSPACE_TAB,
@@ -17,133 +16,151 @@ const LazyPairsListPanel = lazy(() => import('./panels/PairsListPanel'));
const LazyBatchPanel = lazy(() => import('./panels/BatchPanel')); const LazyBatchPanel = lazy(() => import('./panels/BatchPanel'));
const LazyDataCopierPanel = lazy(() => import('./DataCopierPanel')); const LazyDataCopierPanel = lazy(() => import('./DataCopierPanel'));
const shellStyle = { const WORKFLOW_STEPS = [
minHeight: '100%', '数据准备',
padding: '20px 24px 28px', '配对/栈规划',
boxSizing: 'border-box', '生产运行',
background: 'linear-gradient(180deg, #f5f7fb 0%, #eef4ff 52%, #f8fafc 100%)', '质量检查',
}; '成果发布',
];
const heroStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: 16,
marginBottom: 18,
};
const heroCardStyle = {
borderRadius: 24,
border: '1px solid #d7e0eb',
background: 'linear-gradient(135deg, #ffffff 0%, #f8fbff 56%, #eef6ff 100%)',
boxShadow: '0 16px 40px rgba(15, 23, 42, 0.06)',
};
const summaryCardStyle = {
padding: '12px 14px',
borderRadius: 18,
border: '1px solid #e2e8f0',
background: 'rgba(255, 255, 255, 0.82)',
};
const SENSOR_PRODUCTION_PLACEHOLDERS = { const SENSOR_PRODUCTION_PLACEHOLDERS = {
lt1_production: { lt1_production: {
title: '陆探一号生产模块', title: '陆探一生产占位',
subtitle: '当前先占位纳入生产管理,执行链路保留 LandSAR、ENVI+SARscape、Gamma/PyINT。', note: '当前保留 LT-1 源压缩包本机登记与按需 materialize 入口。',
rows: [ rows: [
['源压缩包', 'D:\\LuTan1_Image_Pool_Zip,只索引包内 XML/元数据,不做全量解包。'], ['数据来源', '本机源压缩包 archive'],
['精密轨道', 'D:\\LT1_data_lsarorbit,本机部署并绑定到源资产。'], ['精轨策略', '按生产任务关联 orbit 资产'],
['按需解包', '生产任务需要时才 materialize 到 D:\\Task_Pool\\DInSAR 或 D:\\Task_Pool\\SBAS。'], ['准备方式', '按需 materialize 到 Task_Pool'],
['生产边界', 'D-InSAR 与 SBAS-InSAR 均使用本机 Task_Pool,不允许 UNC 参与运行。'], ['生产边界', 'D-InSAR/SBAS 不走 UNC'],
['结果管理', '生成结果进入 D-InSAR/SBAS 产物目录,由生产管理结果页统一重建 catalog。'], ['结果管理', '进入统一产品 catalog'],
], ],
}, },
sentinel1_production: { sentinel1_production: {
title: 'Sentinel-1 生产模块', title: 'Sentinel-1 生产占位',
subtitle: '当前先占位纳入生产管理,D-InSAR 保留 Gamma/PyINT 路径,SBAS 仍为规划态。', note: '当前主要沉淀数据与精轨管理约束,SBAS 仅保留规划能力。',
rows: [ rows: [
['源压缩包', 'D:\\Sentinel1_Image_Pool_ZIP,本机登记 ZIP/SAFE 元数据。'], ['数据来源', 'ZIP/SAFE 本机 archive'],
['精密轨道', 'D:\\Sentinel1_EOF_Pool,本机保存 AUX_POEORB/RESORB。'], ['精轨策略', 'EOF 精轨本机管理'],
['按需解包', '需要运行时才将 ZIP 解包到本机 Task_Pool,界面不提供全量解包按钮。'], ['准备方式', '按需解包到工作目录'],
['D-InSAR', 'Gamma/PyINT 可作为生产方向,运行材料必须来自本机路径。'], ['D-InSAR', '走统一生产任务队列'],
['SBAS', '当前仅做堆栈发现和规划,执行链路未启用。'], ['SBAS', '保留序列规划能力'],
], ],
}, },
gf3_native_registration: { gf3_native_registration: {
title: '高分三结果登记', title: '高分三结果登记',
subtitle: 'GF3 不在本机生产;另一台 SARscape 服务器完成 _geo 后复制到本机登记。', note: 'GF3 由外部 SARscape 服务生产,本系统登记回传成果并生成预览。',
rows: [ rows: [
['外部生产', '外部机器按 YYYYMMDD_geo/场景目录输出 SARscape 原生 _geo 二进制。'], ['生产方式', '外部 SARscape 服务'],
['本机落盘', '复制到 D:\\GaoFen3_Pool\\native_geo 后递归扫描登记。'], ['落地路径', '本机登记 _geo 二进制'],
['预览生成', 'WebP 从 *_geo 主二进制读取生成,不使用 *_geo_ql.tif 作为正式预览源。'], ['预览生成', '转换 WebP 供地图使用'],
['精轨', 'GF3 本链路无精密轨道管理。'], ['精轨策略', '按外部生产结果留痕'],
['结果管理', '登记后的 GF3 资产进入数据管理,后续需要全影像时再提取/标准化。'], ['结果管理', '进入统一产品 catalog'],
], ],
}, },
}; };
function SensorProductionPlaceholder({ viewKey }) { const shellStyle = {
const data = SENSOR_PRODUCTION_PLACEHOLDERS[viewKey]; minHeight: '100%',
if (!data) { background: '#f8fafc',
return null; color: '#0f172a',
} };
return (
<section style={{ ...heroCardStyle, padding: '18px 20px' }}>
<div style={{ fontSize: 12, color: '#475569', marginBottom: 8 }}>当前设计约定</div>
<h3 style={{ margin: '0 0 8px', fontSize: 20, color: '#0f172a' }}>{data.title}</h3>
<p style={{ margin: '0 0 16px', color: '#475569', fontSize: 13, lineHeight: 1.7 }}>
{data.subtitle}
</p>
<div style={{ display: 'grid', gap: 10 }}>
{data.rows.map(([label, value]) => (
<div
key={label}
style={{
display: 'grid',
gridTemplateColumns: '120px 1fr',
gap: 12,
padding: '10px 12px',
borderRadius: 8,
border: '1px solid #e2e8f0',
background: '#fff',
}}
>
<strong style={{ color: '#0f172a', fontSize: 13 }}>{label}</strong>
<span style={{ color: '#475569', fontSize: 13, lineHeight: 1.7, wordBreak: 'break-word' }}>{value}</span>
</div>
))}
</div>
</section>
);
}
function resolveView(entry) { const headerStyle = {
return PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[entry] || PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[PRODUCTION_WORKSPACE_TAB]; padding: '18px 20px 14px',
borderBottom: '1px solid #e2e8f0',
background: '#ffffff',
};
const sectionStyle = {
padding: '16px 20px 22px',
};
const compactPanelStyle = {
border: '1px solid #e2e8f0',
borderRadius: 8,
background: '#ffffff',
};
const mutedTextStyle = {
color: '#64748b',
fontSize: 13,
lineHeight: 1.6,
};
function resolveView(activeEntry) {
return PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[activeEntry] || PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[PRODUCTION_WORKSPACE_TAB];
} }
function resolveWorkbenchKey(viewKey) { function resolveWorkbenchKey(viewKey) {
const workbench = PRODUCTION_WORKSPACE_WORKBENCHES.find(item => ( const workbench = PRODUCTION_WORKSPACE_WORKBENCHES.find(item =>
item.views.some(view => view.key === viewKey) item.views.some(view => view.key === viewKey),
)); );
return workbench?.key || PRODUCTION_WORKSPACE_WORKBENCHES[0]?.key || 'dinsar_workbench'; return workbench?.key || PRODUCTION_WORKSPACE_WORKBENCHES[0]?.key;
}
function buttonStyle(active) {
return {
border: `1px solid ${active ? '#2563eb' : '#cbd5e1'}`,
background: active ? '#eff6ff' : '#ffffff',
color: active ? '#1d4ed8' : '#334155',
borderRadius: 6,
padding: '7px 10px',
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
lineHeight: 1.3,
};
}
function PlaceholderView({ config }) {
if (!config) {
return (
<div style={{ ...compactPanelStyle, padding: 18 }}>
<h3 style={{ margin: 0, fontSize: 16 }}>生产入口未配置</h3>
<p style={{ ...mutedTextStyle, margin: '8px 0 0' }}>当前视图尚未接入生产面板。</p>
</div>
);
}
return (
<div style={{ ...compactPanelStyle, padding: 18 }}>
<h3 style={{ margin: 0, fontSize: 16 }}>{config.title}</h3>
<p style={{ ...mutedTextStyle, margin: '8px 0 14px' }}>{config.note}</p>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(96px, 140px) 1fr', gap: 0, borderTop: '1px solid #e2e8f0' }}>
{config.rows.map(([label, value]) => (
<div key={label} style={{ display: 'contents' }}>
<div style={{ padding: '10px 12px', borderBottom: '1px solid #e2e8f0', color: '#475569', background: '#f8fafc', fontSize: 13 }}>
{label}
</div>
<div style={{ padding: '10px 12px', borderBottom: '1px solid #e2e8f0', color: '#0f172a', fontSize: 13 }}>
{value}
</div>
</div>
))}
</div>
</div>
);
} }
export default function ProductionWorkspace({ export default function ProductionWorkspace({
activeEntry = PRODUCTION_WORKSPACE_TAB, activeEntry,
readOnly = false, readOnly,
onTaskStart, onTaskStart,
apiEndpoint, apiEndpoint,
language, language,
foundPairs = [], foundPairs,
selectedPairsCount = 0, selectedPairsCount,
isLoading = false, isLoading,
hasEnoughRadarScenesForPlanning = false, hasEnoughRadarScenesForPlanning,
hasRadarSearched = false, hasRadarSearched,
pairingPanel = {}, pairingPanel,
radarPanel = {}, radarPanel,
pairsPanel = {}, pairsPanel,
}) { }) {
const [activeView, setActiveView] = useState(() => resolveView(activeEntry)); const initialView = resolveView(activeEntry);
const [activeWorkbench, setActiveWorkbench] = useState(() => resolveWorkbenchKey(resolveView(activeEntry))); const [activeView, setActiveView] = useState(initialView);
const [activeWorkbench, setActiveWorkbench] = useState(resolveWorkbenchKey(initialView));
useEffect(() => { useEffect(() => {
const nextView = resolveView(activeEntry); const nextView = resolveView(activeEntry);
@@ -151,17 +168,16 @@ export default function ProductionWorkspace({
setActiveWorkbench(resolveWorkbenchKey(nextView)); setActiveWorkbench(resolveWorkbenchKey(nextView));
}, [activeEntry]); }, [activeEntry]);
const activeViewMeta = useMemo( const currentWorkbench = useMemo(
() => PRODUCTION_WORKSPACE_VIEWS.find(view => view.key === activeView) || PRODUCTION_WORKSPACE_VIEWS[0],
[activeView]
);
const activeWorkbenchMeta = useMemo(
() => PRODUCTION_WORKSPACE_WORKBENCHES.find(item => item.key === activeWorkbench) || PRODUCTION_WORKSPACE_WORKBENCHES[0], () => PRODUCTION_WORKSPACE_WORKBENCHES.find(item => item.key === activeWorkbench) || PRODUCTION_WORKSPACE_WORKBENCHES[0],
[activeWorkbench] [activeWorkbench],
);
const currentView = useMemo(
() => PRODUCTION_WORKSPACE_VIEWS.find(view => view.key === activeView),
[activeView],
); );
const activeSubViews = activeWorkbenchMeta?.views || [];
const switchWorkbench = (workbench) => { const switchWorkbench = workbench => {
setActiveWorkbench(workbench.key); setActiveWorkbench(workbench.key);
if (!workbench.views.some(view => view.key === activeView)) { if (!workbench.views.some(view => view.key === activeView)) {
setActiveView(workbench.defaultView); setActiveView(workbench.defaultView);
@@ -177,224 +193,176 @@ export default function ProductionWorkspace({
}; };
const handleDinsarPrepareQueued = taskId => { const handleDinsarPrepareQueued = taskId => {
onTaskStart?.(taskId, 'D-InSAR生产准备任务已入队,正在处理...', { onTaskStart?.(taskId, 'D-InSAR 生产准备任务已入队,正在处理...', {
taskType: 'COPY_DATA', taskType: 'COPY_DATA',
nonBlocking: true, nonBlocking: true,
}); });
}; };
const handleSbasProductQueued = taskId => { const handleSbasProductQueued = taskId => {
onTaskStart?.(taskId, 'SBAS-InSAR result catalog task queued.', { onTaskStart?.(taskId, 'SBAS-InSAR 结果 catalog 任务已入队。', {
taskType: 'REBUILD_SBAS_INSAR_CATALOG', taskType: 'REBUILD_SBAS_INSAR_CATALOG',
nonBlocking: true, nonBlocking: true,
}); });
}; };
const renderContent = () => {
if (activeView === 'dinsar_pairing') {
return (
<LazyPairPlanningPanel
foundPairs={foundPairs}
selectedPairsCount={selectedPairsCount}
isLoading={isLoading}
isReadOnlyUser={readOnly}
hasEnoughRadarScenesForPlanning={hasEnoughRadarScenesForPlanning}
onOpenPairingModal={pairingPanel?.openModal}
hasRadarSearched={hasRadarSearched}
onRefreshRadarSearch={radarPanel?.refresh}
onSearchAll={radarPanel?.searchAll}
onRefreshDinsar={pairsPanel?.refreshDinsar}
language={language}
/>
);
}
if (activeView === 'dinsar_pairs') {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.1fr) minmax(360px, 0.9fr)', gap: 14 }}>
<LazyPairsListPanel pairsPanel={pairsPanel} isReadOnlyUser={readOnly} language={language} />
<LazyBatchPanel pairsPanel={pairsPanel} isReadOnlyUser={readOnly} language={language} />
</div>
);
}
if (activeView === 'dinsar_prepare') {
return (
<LazyDataCopierPanel
apiEndpoint={apiEndpoint}
readOnly={readOnly}
onJobQueued={handleDinsarPrepareQueued}
/>
);
}
if (activeView === 'dinsar_runs') {
return <LazyDinsarProductionPanel readOnly={readOnly} onJobQueued={handleDinsarRunQueued} />;
}
if (activeView === 'dinsar_products') {
return <LazyDinsarProductsPanel readOnly={readOnly} onJobQueued={handleDinsarProductQueued} />;
}
if (['sbas_insar_planning', 'sbas_insar_batches', 'sbas_insar_prepare', 'sbas_insar_runs'].includes(activeView)) {
const focusMap = {
sbas_insar_planning: 'planning',
sbas_insar_batches: 'batches',
sbas_insar_prepare: 'prepare',
sbas_insar_runs: 'runs',
};
return (
<LazySbasInsarProductionPanel
readOnly={readOnly}
onTaskStart={onTaskStart}
initialFocus={focusMap[activeView]}
/>
);
}
if (activeView === 'sbas_insar_products') {
return <LazySbasInsarProductsPanel readOnly={readOnly} onJobQueued={handleSbasProductQueued} />;
}
return <PlaceholderView config={SENSOR_PRODUCTION_PLACEHOLDERS[activeView]} />;
};
return ( return (
<div style={shellStyle}> <div style={shellStyle}>
<div style={heroStyle}> <div style={headerStyle}>
<section style={{ ...heroCardStyle, padding: '22px 24px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.08em', color: '#1d4ed8', textTransform: 'uppercase' }}> <div>
Production Management <div style={{ fontSize: 13, color: '#475569', marginBottom: 4 }}>生产工作台</div>
<h2 style={{ margin: 0, fontSize: 22, lineHeight: 1.25, letterSpacing: 0 }}>InSAR 生产管理</h2>
<p style={{ ...mutedTextStyle, margin: '8px 0 0', maxWidth: 720 }}>
面向科研工程生产的任务编排入口,统一组织数据准备、规划、运行、质量检查与成果发布。
</p>
</div> </div>
<h2 style={{ margin: '10px 0 12px', fontSize: 32, lineHeight: 1.1, color: '#0f172a' }}>生产管理</h2> {readOnly && (
<p style={{ margin: 0, maxWidth: 900, fontSize: 14, lineHeight: 1.8, color: '#475569' }}> <div style={{ border: '1px solid #f59e0b', color: '#92400e', background: '#fffbeb', borderRadius: 6, padding: '8px 10px', fontSize: 13 }}>
这里统一承载 D-InSAR 配对、批次、生产准备、运行和产物管理,以及 Gamma SBAS-InSAR 生产链。 当前为只读账号,生产提交操作已禁用。
陆探与哨兵源数据按压缩包登记,生产时再解包到本机 Task_Pool;高分三只登记外部 SARscape 服务器复制回来的 _geo 结果。 </div>
</p> )}
</section> </div>
<section <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
style={{ {WORKFLOW_STEPS.map((step, index) => (
...heroCardStyle, <div
padding: '18px', key={step}
display: 'grid', style={{
gap: 12, border: '1px solid #e2e8f0',
alignContent: 'start', background: '#f8fafc',
}} borderRadius: 6,
> padding: '6px 9px',
<div style={summaryCardStyle}> color: '#334155',
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>主生产链</div> fontSize: 12,
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>D-InSAR / SBAS</div> lineHeight: 1.2,
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}> }}
D-InSAR 使用配对批次驱动;SBAS 使用 Gamma IPTA 工作流驱动。PS/旧时序入口不再作为主流程展示。 >
{index + 1}. {step}
</div> </div>
</div> ))}
<div style={summaryCardStyle}> </div>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>运行边界</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>本机 Task_Pool</div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}>
源压缩包先登记元数据,生产需要时再按需解包;D-InSAR/SBAS 不走 UNC。
</div>
</div>
<div style={summaryCardStyle}>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>结果管理</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>产物 catalog</div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}>
生产结果进入 D-InSAR、SBAS 或 GF3 数据目录,后续分析从结果 catalog 读取。
</div>
</div>
</section>
</div> </div>
<section <div style={sectionStyle}>
style={{ <div style={{ display: 'grid', gridTemplateColumns: 'minmax(220px, 280px) minmax(0, 1fr)', gap: 14, alignItems: 'start' }}>
...heroCardStyle, <aside style={{ ...compactPanelStyle, padding: 12 }}>
padding: '14px', <div style={{ color: '#475569', fontSize: 13, marginBottom: 8 }}>工作流</div>
marginBottom: 18, <div style={{ display: 'grid', gap: 8 }}>
}} {PRODUCTION_WORKSPACE_WORKBENCHES.map(workbench => (
> <button
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}> key={workbench.key}
{PRODUCTION_WORKSPACE_WORKBENCHES.map(workbench => { type="button"
const isActive = workbench.key === activeWorkbench; onClick={() => switchWorkbench(workbench)}
return ( style={{ ...buttonStyle(activeWorkbench === workbench.key), textAlign: 'left' }}
<button >
key={workbench.key} <span style={{ display: 'block' }}>{workbench.label}</span>
type="button" <span style={{ display: 'block', marginTop: 4, color: activeWorkbench === workbench.key ? '#2563eb' : '#64748b', fontWeight: 400 }}>
onClick={() => switchWorkbench(workbench)} {workbench.description}
style={{
textAlign: 'left',
padding: '16px 18px',
borderRadius: 10,
border: `1px solid ${isActive ? '#93c5fd' : '#d7e0eb'}`,
background: isActive
? '#eff6ff'
: 'rgba(255, 255, 255, 0.88)',
boxShadow: isActive ? '0 10px 24px rgba(37, 99, 235, 0.12)' : 'none',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 8 }}>
<strong style={{ fontSize: 16, color: '#0f172a' }}>{workbench.label}</strong>
<span
style={{
padding: '4px 8px',
borderRadius: 8,
fontSize: 11,
fontWeight: 700,
color: isActive ? '#1d4ed8' : '#64748b',
background: isActive ? '#dbeafe' : '#f1f5f9',
}}
>
{isActive ? '当前视图' : '切换'}
</span> </span>
</div> </button>
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#475569' }}>{workbench.description}</div> ))}
</button>
);
})}
</div>
</section>
<section
style={{
...heroCardStyle,
padding: '12px',
marginBottom: 18,
}}
>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10 }}>
{activeSubViews.map(view => {
const isActive = view.key === activeView;
return (
<button
key={view.key}
type="button"
onClick={() => setActiveView(view.key)}
style={{
textAlign: 'left',
padding: '12px 14px',
borderRadius: 8,
border: `1px solid ${isActive ? '#2563eb' : '#d7e0eb'}`,
background: isActive ? '#ffffff' : '#f8fafc',
cursor: 'pointer',
}}
>
<strong style={{ display: 'block', marginBottom: 6, fontSize: 14, color: isActive ? '#1d4ed8' : '#0f172a' }}>
{view.label}
</strong>
<span style={{ display: 'block', fontSize: 12, lineHeight: 1.6, color: '#475569' }}>
{view.description}
</span>
</button>
);
})}
</div>
</section>
<section>
<div style={{ marginBottom: 12, fontSize: 12, color: '#64748b' }}>
{activeWorkbenchMeta?.label} / {activeViewMeta.label}
</div>
<Suspense fallback={<PanelLoadingBody message={`正在加载 ${activeViewMeta.label}...`} />}>
{SENSOR_PRODUCTION_PLACEHOLDERS[activeView] && (
<SensorProductionPlaceholder viewKey={activeView} />
)}
{activeView === 'dinsar_pairing' && (
<LazyPairPlanningPanel
foundPairs={foundPairs}
selectedPairsCount={selectedPairsCount}
isLoading={isLoading}
isReadOnlyUser={readOnly}
hasEnoughRadarScenesForPlanning={hasEnoughRadarScenesForPlanning}
onOpenPairingModal={pairingPanel.onOpenPairingModal}
hasRadarSearched={hasRadarSearched}
onRefreshRadarSearch={pairingPanel.onRefreshRadarSearch}
onSearchAll={radarPanel.onSearchAll}
onRefreshDinsar={pairingPanel.onRefreshDinsar}
language={language}
/>
)}
{activeView === 'dinsar_pairs' && (
<div style={{ display: 'grid', gap: 16 }}>
<LazyPairsListPanel
onVisualizePair={pairsPanel.onVisualizePair}
onTogglePairVisibility={pairsPanel.onTogglePairVisibility}
onCreateDinsarBatch={pairsPanel.onCreateDinsarBatch}
/>
<LazyBatchPanel />
</div> </div>
)} </aside>
{activeView === 'dinsar_prepare' && (
<LazyDataCopierPanel <main style={{ display: 'grid', gap: 14, minWidth: 0 }}>
apiEndpoint={apiEndpoint} <div style={{ ...compactPanelStyle, padding: 12 }}>
readOnly={readOnly} <div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
onJobQueued={handleDinsarPrepareQueued} <div>
/> <div style={{ color: '#475569', fontSize: 13 }}>{currentWorkbench?.label}</div>
)} <h3 style={{ margin: '3px 0 0', fontSize: 18 }}>{currentView?.label || '生产视图'}</h3>
{activeView === 'dinsar_runs' && ( </div>
<LazyDinsarProductionPanel <div style={{ ...mutedTextStyle, maxWidth: 560 }}>{currentView?.description}</div>
readOnly={readOnly} </div>
onJobQueued={handleDinsarRunQueued} <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 12 }}>
/> {currentWorkbench?.views.map(view => (
)} <button
{['sbas_insar_planning', 'sbas_insar_batches', 'sbas_insar_prepare', 'sbas_insar_runs'].includes(activeView) && ( key={view.key}
<LazySbasInsarProductionPanel type="button"
readOnly={readOnly} onClick={() => setActiveView(view.key)}
onTaskStart={onTaskStart} style={buttonStyle(activeView === view.key)}
initialFocus={{ >
sbas_insar_planning: 'planning', {view.label}
sbas_insar_batches: 'batches', </button>
sbas_insar_prepare: 'prepare', ))}
sbas_insar_runs: 'runs', </div>
}[activeView]} </div>
/>
)} <Suspense fallback={<PanelLoadingBody message="正在加载生产面板..." />}>
{activeView === 'sbas_insar_products' && ( {renderContent()}
<LazySbasInsarProductsPanel </Suspense>
readOnly={readOnly} </main>
onJobQueued={handleSbasProductQueued} </div>
/> </div>
)}
{activeView === 'dinsar_products' && (
<LazyDinsarProductsPanel
readOnly={readOnly}
onJobQueued={handleDinsarProductQueued}
/>
)}
</Suspense>
</section>
</div> </div>
); );
} }
+418
View File
@@ -0,0 +1,418 @@
import { useEffect, useMemo, useState } from 'react';
import { exportDinsarResults, getDinsarResults } from './api/dinsar';
import { listSbasInsarProducts } from './api/sbasInsarProducts';
import { getDinsarEngineMeta } from './utils/dinsarEngines';
const DEFAULT_TARGET_DIR = String.raw`D:\Result_Export\DInSAR`;
const PAGE_SIZE = 100;
const PRODUCT_CHANNELS = [
{
key: 'dinsar',
group: 'InSAR 成果',
label: 'D-InSAR 结果',
state: 'ready',
stateText: '可提取',
description: '从已登记的 D-InSAR 成果中选择位移结果,复制到服务器指定交付目录。',
},
{
key: 'sbas',
group: 'InSAR 成果',
label: 'SBAS-InSAR 结果',
state: 'planned',
stateText: '目录可查',
description: '成果目录和预览已接入,统一提取接口待补齐。',
},
{
key: 'lt1_ortho',
group: '正射成果',
label: 'LT-1 正射结果',
state: 'placeholder',
stateText: '待接入',
description: '陆探一正射生产结果后续接入标准成果目录,并开放提取。',
},
{
key: 's1_ortho',
group: '正射成果',
label: 'Sentinel-1 正射结果',
state: 'placeholder',
stateText: '待接入',
description: 'Sentinel-1 正射生产占位,后续登记后统一提取。',
},
{
key: 'gf3_ortho',
group: '正射成果',
label: 'GF3 SARscape _geo',
state: 'placeholder',
stateText: '待接入',
description: 'GF3 外部生产后的 _geo 二进制和 WebP 已按本机登记思路设计,统一导出接口待接入。',
},
];
function formatNumber(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '-';
return new Intl.NumberFormat('zh-CN').format(number);
}
function normalizeItems(payload) {
return Array.isArray(payload?.items) ? payload.items : [];
}
function extractTotal(payload, fallback = 0) {
const total = Number(payload?.total);
return Number.isFinite(total) ? total : fallback;
}
function resultDisplayName(result) {
return String(result?.name || result?.task_alias || result?.task_name || result?.product_id || `#${result?.id || ''}`).trim();
}
function resultDateText(result) {
const name = resultDisplayName(result);
const matches = name.match(/(\d{8})/g);
if (matches?.length >= 2) return `${matches[0]} / ${matches[1]}`;
if (matches?.length === 1) return matches[0];
return '-';
}
function stateClass(state) {
if (state === 'ready') return 'ready';
if (state === 'planned') return 'planned';
return 'pending';
}
export default function ResultExtractionPanel({ readOnly = false }) {
const [activeChannel, setActiveChannel] = useState('dinsar');
const [dinsarPayload, setDinsarPayload] = useState({ items: [], total: 0 });
const [sbasPayload, setSbasPayload] = useState({ items: [], total: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [query, setQuery] = useState('');
const [targetDir, setTargetDir] = useState(DEFAULT_TARGET_DIR);
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState('');
const [exportResult, setExportResult] = useState(null);
const selectedChannel = PRODUCT_CHANNELS.find(channel => channel.key === activeChannel) || PRODUCT_CHANNELS[0];
const loadCatalogs = async () => {
setLoading(true);
setError('');
try {
const [dinsarData, sbasData] = await Promise.all([
getDinsarResults({ limit: PAGE_SIZE, offset: 0 }),
listSbasInsarProducts({ limit: 30, offset: 0 }),
]);
const dinsarItems = normalizeItems(dinsarData);
setDinsarPayload({ ...dinsarData, items: dinsarItems, total: extractTotal(dinsarData, dinsarItems.length) });
const sbasItems = normalizeItems(sbasData);
setSbasPayload({ ...sbasData, items: sbasItems, total: extractTotal(sbasData, sbasItems.length) });
setSelectedIds(new Set(dinsarItems.map(item => item.id).filter(id => id !== undefined && id !== null)));
} catch (err) {
setError(err?.response?.data?.detail || err.message || '结果目录加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadCatalogs();
}, []);
const filteredDinsar = useMemo(() => {
const value = query.trim().toLowerCase();
const items = dinsarPayload.items || [];
if (!value) return items;
return items.filter(item => {
const haystack = [
item.name,
item.task_name,
item.task_alias,
item.pair_key,
item.product_id,
item.engine_code,
item.file_path,
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(value);
});
}, [dinsarPayload.items, query]);
const filteredIds = useMemo(
() => filteredDinsar.map(item => item.id).filter(id => id !== undefined && id !== null),
[filteredDinsar],
);
const selectedCountInView = filteredIds.filter(id => selectedIds.has(id)).length;
const allVisibleSelected = filteredIds.length > 0 && selectedCountInView === filteredIds.length;
const orthoPlaceholderCount = PRODUCT_CHANNELS.filter(channel => channel.group === '正射成果').length;
const currentCatalogTotal = Number(dinsarPayload.total || 0) + Number(sbasPayload.total || 0);
const metrics = [
{
label: 'D-InSAR 可提取',
value: dinsarPayload.total,
note: `当前载入 ${filteredDinsar.length}/${dinsarPayload.items.length} 条`,
tone: 'primary',
},
{
label: 'SBAS 目录',
value: sbasPayload.total,
note: '统一提取接口待接入',
tone: 'neutral',
},
{
label: '当前接入目录',
value: currentCatalogTotal,
note: 'D-InSAR + SBAS 已接入清单',
tone: 'neutral',
},
{
label: '正射通道',
value: orthoPlaceholderCount,
note: 'LT-1 / S1 / GF3 占位',
tone: 'warning',
},
];
const toggleOne = (id) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const toggleVisible = () => {
setSelectedIds(prev => {
const next = new Set(prev);
if (allVisibleSelected) {
filteredIds.forEach(id => next.delete(id));
} else {
filteredIds.forEach(id => next.add(id));
}
return next;
});
};
const handleExport = async () => {
const dir = targetDir.trim();
if (!dir) {
setExportError('请输入服务器目标目录。');
return;
}
const ids = [...selectedIds].filter(id => filteredIds.includes(id));
if (ids.length === 0) {
setExportError('请至少选择一条 D-InSAR 结果。');
return;
}
setExporting(true);
setExportError('');
setExportResult(null);
try {
const response = await exportDinsarResults(ids, dir);
setExportResult(response);
} catch (err) {
setExportError(err?.response?.data?.detail || err.message || 'D-InSAR 结果提取失败');
} finally {
setExporting(false);
}
};
const renderDinsarWorkspace = () => (
<section className="result-extraction-main-card">
<div className="result-extraction-card-head">
<div>
<span>D-InSAR 交付提取</span>
<strong>选择已登记结果并复制到服务器目录</strong>
</div>
<button type="button" onClick={loadCatalogs} disabled={loading || exporting}>
刷新目录
</button>
</div>
<div className="result-extraction-controls">
<label className="result-extraction-field">
<span>结果检索</span>
<input
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="任务名、日期、pair_key、引擎"
disabled={loading || exporting}
/>
</label>
<label className="result-extraction-field result-extraction-field-wide">
<span>服务器目标目录</span>
<input
type="text"
value={targetDir}
onChange={event => setTargetDir(event.target.value)}
placeholder={DEFAULT_TARGET_DIR}
disabled={loading || exporting || readOnly}
/>
</label>
<div className="result-extraction-action-stack">
<button type="button" onClick={toggleVisible} disabled={loading || exporting || filteredIds.length === 0}>
{allVisibleSelected ? '取消本页' : '选择本页'}
</button>
<button
type="button"
className="primary"
onClick={handleExport}
disabled={readOnly || exporting || loading || selectedCountInView === 0 || !targetDir.trim()}
>
{exporting ? '正在提取' : `提取 ${selectedCountInView} 项`}
</button>
</div>
</div>
<div className="result-extraction-hint">
目标目录是服务器可访问路径,后端会按任务名或成果名创建子目录,避免直接覆盖同名成果。
</div>
{error && <div className="result-extraction-message error">{error}</div>}
{exportError && <div className="result-extraction-message error">{exportError}</div>}
{exportResult && (
<div className="result-extraction-message success">
<strong>提取完成</strong>
<span>复制 {formatNumber(exportResult.copied)} 项,跳过 {formatNumber(exportResult.skipped)} 项,失败 {formatNumber(exportResult.failed)} 项。</span>
<code>{exportResult.target_dir}</code>
</div>
)}
<div className="result-extraction-list-head">
<span>结果列表</span>
<strong>{selectedCountInView}/{filteredDinsar.length}</strong>
</div>
<div className="result-extraction-result-list">
{loading ? (
<div className="result-extraction-empty">正在加载成果目录...</div>
) : filteredDinsar.length === 0 ? (
<div className="result-extraction-empty">当前条件下没有可提取的 D-InSAR 结果。</div>
) : (
filteredDinsar.map(result => {
const id = result.id;
const engineMeta = getDinsarEngineMeta(result.engine_code);
return (
<label key={id} className="result-extraction-result-row">
<input
type="checkbox"
checked={selectedIds.has(id)}
onChange={() => toggleOne(id)}
disabled={exporting}
/>
<span className="result-extraction-result-main">
<strong title={result.file_path || resultDisplayName(result)}>{resultDisplayName(result)}</strong>
<span>
{resultDateText(result)}
{' · '}
{result.pair_key || result.product_id || '-'}
</span>
</span>
<span className={`dinsar-engine-badge tone-${engineMeta.tone}`}>{engineMeta.shortLabel}</span>
<span className="result-extraction-status-chip">{result.is_cached ? '预览就绪' : '预览待建'}</span>
</label>
);
})
)}
</div>
</section>
);
const renderPlaceholderWorkspace = () => (
<section className="result-extraction-main-card">
<div className="result-extraction-placeholder">
<span className={`result-extraction-state ${stateClass(selectedChannel.state)}`}>
{selectedChannel.stateText}
</span>
<strong>{selectedChannel.label}</strong>
<p>{selectedChannel.description}</p>
<div className="result-extraction-contract">
<div>
<span>登记入口</span>
<strong>{selectedChannel.key === 'sbas' ? 'SBAS-InSAR 成果目录' : '生产管理成果登记'}</strong>
</div>
<div>
<span>提取接口</span>
<strong>待实现</strong>
</div>
<div>
<span>交付目录</span>
<strong>服务器固定/指定路径</strong>
</div>
</div>
{selectedChannel.key === 'sbas' && (
<div className="result-extraction-sbas-sample">
<span>当前 SBAS 目录样例</span>
{sbasPayload.items.length === 0 ? (
<p>暂无可展示的 SBAS-InSAR 成果。</p>
) : (
sbasPayload.items.slice(0, 5).map(item => (
<div key={item.id || item.product_id} className="result-extraction-sbas-row">
<strong>{item.product_id || item.name || `#${item.id}`}</strong>
<span>{item.status || 'UNKNOWN'}</span>
</div>
))
)}
</div>
)}
</div>
</section>
);
return (
<div className="result-extraction-page">
<section className="result-extraction-hero">
<div>
<span>成果交付出口</span>
<strong>结果提取工作台</strong>
<p>
将三类正射生产成果、D-InSAR 成果和 SBAS-InSAR 成果集中管理。当前 D-InSAR 已接入真实提取,
其余链路先保留清晰占位,避免把未完成流程误当成可执行功能。
</p>
</div>
<div className="result-extraction-hero-meta">
<span>D-InSAR {formatNumber(dinsarPayload.total)}</span>
<span>SBAS {formatNumber(sbasPayload.total)}</span>
<span>{readOnly ? '只读账号' : '可执行账号'}</span>
</div>
</section>
<section className="result-extraction-metrics">
{metrics.map(metric => (
<div key={metric.label} className={`result-extraction-metric tone-${metric.tone}`}>
<span>{metric.label}</span>
<strong>{formatNumber(metric.value)}</strong>
<p>{metric.note}</p>
</div>
))}
</section>
<section className="result-extraction-layout">
<aside className="result-extraction-channel-list">
{PRODUCT_CHANNELS.map(channel => (
<button
key={channel.key}
type="button"
className={activeChannel === channel.key ? 'active' : ''}
onClick={() => setActiveChannel(channel.key)}
>
<span>{channel.group}</span>
<strong>{channel.label}</strong>
<em className={stateClass(channel.state)}>{channel.stateText}</em>
</button>
))}
</aside>
{activeChannel === 'dinsar' ? renderDinsarWorkspace() : renderPlaceholderWorkspace()}
</section>
</div>
);
}
+22 -22
View File
@@ -285,33 +285,33 @@ function RuntimeStatusPanel({ status }) {
return ( return (
<div style={{ border: '1px solid #bae6fd', borderRadius: 8, padding: 10, background: '#f0f9ff' }}> <div style={{ border: '1px solid #bae6fd', borderRadius: 8, padding: 10, background: '#f0f9ff' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={valueStyle}>Runtime Status</div> <div style={valueStyle}>运行状态</div>
<StatusBadge value={status.active ? 'RUNNING' : (status.run_status || 'IDLE')} /> <StatusBadge value={status.active ? 'RUNNING' : (status.run_status || 'IDLE')} />
</div> </div>
<div style={{ ...metricGridStyle, marginTop: 8 }}> <div style={{ ...metricGridStyle, marginTop: 8 }}>
<Metric label="Current step" value={status.current_step?.id || '-'} /> <Metric label="当前步骤" value={status.current_step?.id || '-'} />
<Metric label="Workflow updated" value={status.workflow_updated_at || '-'} /> <Metric label="Workflow 更新时间" value={status.workflow_updated_at || '-'} />
<Metric label="Latest log" value={status.latest_log_updated_at || '-'} /> <Metric label="最近日志" value={status.latest_log_updated_at || '-'} />
<Metric <Metric
label="Common overlap" label="公共重叠率"
value={`${formatPercent(gate.common_overlap_ratio)} / ${formatPercent(gate.min_common_overlap_ratio)}`} value={`${formatPercent(gate.common_overlap_ratio)} / ${formatPercent(gate.min_common_overlap_ratio)}`}
/> />
</div> </div>
{(currentTask || currentJob) && ( {(currentTask || currentJob) && (
<div style={{ ...mutedStyle, marginTop: 8, wordBreak: 'break-word' }}> <div style={{ ...mutedStyle, marginTop: 8, wordBreak: 'break-word' }}>
Task: {currentTask ? `${currentTask.task_type || '-'} ${currentTask.status || '-'} ${currentTask.progress ?? 0}%` : '-'} Task:{currentTask ? `${currentTask.task_type || '-'} ${currentTask.status || '-'} ${currentTask.progress ?? 0}%` : '-'}
{'; '} {'; '}
Job: {currentJob ? `${currentJob.job_type || '-'} ${currentJob.status || '-'}` : '-'} Job:{currentJob ? `${currentJob.job_type || '-'} ${currentJob.status || '-'}` : '-'}
</div> </div>
)} )}
{latestTaskLog && ( {latestTaskLog && (
<div style={{ ...mutedStyle, marginTop: 6, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}> <div style={{ ...mutedStyle, marginTop: 6, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
DB log: [{latestTaskLog.level || 'INFO'}] {latestTaskLog.message} DB 日志:[{latestTaskLog.level || 'INFO'}] {latestTaskLog.message}
</div> </div>
)} )}
{latestFileLog?.tail && ( {latestFileLog?.tail && (
<details style={{ ...compactDetailsStyle, marginTop: 8, borderColor: '#bae6fd' }}> <details style={{ ...compactDetailsStyle, marginTop: 8, borderColor: '#bae6fd' }}>
<summary style={compactSummaryStyle}>{latestFileLog.name || 'latest log'}</summary> <summary style={compactSummaryStyle}>{latestFileLog.name || '最近日志'}</summary>
<pre <pre
style={{ style={{
margin: '8px 0 0', margin: '8px 0 0',
@@ -329,10 +329,10 @@ function RuntimeStatusPanel({ status }) {
</details> </details>
)} )}
<details style={{ ...compactDetailsStyle, marginTop: 8, borderColor: '#bae6fd' }}> <details style={{ ...compactDetailsStyle, marginTop: 8, borderColor: '#bae6fd' }}>
<summary style={compactSummaryStyle}>WSL processes ({wslProcesses.length})</summary> <summary style={compactSummaryStyle}>WSL 进程({wslProcesses.length})</summary>
<div style={{ display: 'grid', gap: 6, marginTop: 8 }}> <div style={{ display: 'grid', gap: 6, marginTop: 8 }}>
{wslProcesses.length === 0 && ( {wslProcesses.length === 0 && (
<div style={mutedStyle}>{status.wsl_processes?.error || 'No matching WSL process reported.'}</div> <div style={mutedStyle}>{status.wsl_processes?.error || '未发现匹配的 WSL 进程。'}</div>
)} )}
{wslProcesses.map(item => ( {wslProcesses.map(item => (
<div key={`${item.pid}-${item.command}`} style={{ ...mutedStyle, fontFamily: 'monospace', wordBreak: 'break-word' }}> <div key={`${item.pid}-${item.command}`} style={{ ...mutedStyle, fontFamily: 'monospace', wordBreak: 'break-word' }}>
@@ -1021,7 +1021,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
setLandsarRunDetail(detailData); setLandsarRunDetail(detailData);
} }
if (data?.task_id) { if (data?.task_id) {
onTaskStart?.(data.task_id, 'LandSAR SBAS workflow queued.', { onTaskStart?.(data.task_id, 'LandSAR SBAS Workflow 已入队。', {
taskType: data.job_type || 'SBAS_LANDSAR_WORKFLOW', taskType: data.job_type || 'SBAS_LANDSAR_WORKFLOW',
nonBlocking: true, nonBlocking: true,
}); });
@@ -1070,7 +1070,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
const data = await submitSbasInsarWorkflowJob(selectedRunId, workflowPayload); const data = await submitSbasInsarWorkflowJob(selectedRunId, workflowPayload);
setWorkflowJob(data); setWorkflowJob(data);
if (data?.task_id) { if (data?.task_id) {
onTaskStart?.(data.task_id, 'Gamma SBAS workflow queued.', { onTaskStart?.(data.task_id, 'Gamma SBAS Workflow 已入队。', {
taskType: data.job_type || 'SBAS_GAMMA_WORKFLOW', taskType: data.job_type || 'SBAS_GAMMA_WORKFLOW',
nonBlocking: true, nonBlocking: true,
}); });
@@ -1163,7 +1163,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
}); });
setCoregistrationJob(data); setCoregistrationJob(data);
if (data?.task_id) { if (data?.task_id) {
onTaskStart?.(data.task_id, 'SBAS coregistration task queued.', { onTaskStart?.(data.task_id, 'SBAS 共参考配准 Task 已入队。', {
taskType: data.job_type || 'SBAS_COREGISTRATION', taskType: data.job_type || 'SBAS_COREGISTRATION',
nonBlocking: true, nonBlocking: true,
}); });
@@ -1210,7 +1210,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
}); });
setRdcDemJob(data); setRdcDemJob(data);
if (data?.task_id) { if (data?.task_id) {
onTaskStart?.(data.task_id, 'SBAS RDC DEM task queued.', { onTaskStart?.(data.task_id, 'SBAS RDC DEM Task 已入队。', {
taskType: data.job_type || 'SBAS_RDC_DEM', taskType: data.job_type || 'SBAS_RDC_DEM',
nonBlocking: true, nonBlocking: true,
}); });
@@ -1261,7 +1261,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
}); });
setInterferogramJob(data); setInterferogramJob(data);
if (data?.task_id) { if (data?.task_id) {
onTaskStart?.(data.task_id, 'SBAS interferogram task queued.', { onTaskStart?.(data.task_id, 'SBAS 干涉图 Task 已入队。', {
taskType: data.job_type || 'SBAS_INTERFEROGRAMS', taskType: data.job_type || 'SBAS_INTERFEROGRAMS',
nonBlocking: true, nonBlocking: true,
}); });
@@ -1310,7 +1310,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
}); });
setIptaTimeseriesJob(data); setIptaTimeseriesJob(data);
if (data?.task_id) { if (data?.task_id) {
onTaskStart?.(data.task_id, 'SBAS IPTA timeseries task queued.', { onTaskStart?.(data.task_id, 'SBAS IPTA 时序 Task 已入队。', {
taskType: data.job_type || 'SBAS_IPTA_TIMESERIES', taskType: data.job_type || 'SBAS_IPTA_TIMESERIES',
nonBlocking: true, nonBlocking: true,
}); });
@@ -1379,7 +1379,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
}} }}
style={{ border: '1px solid #0369a1', borderRadius: 8, background: '#e0f2fe', color: '#0369a1', padding: '7px 11px', fontWeight: 750 }} style={{ border: '1px solid #0369a1', borderRadius: 8, background: '#e0f2fe', color: '#0369a1', padding: '7px 11px', fontWeight: 750 }}
> >
Open Runtime Status 打开运行状态
</button> </button>
</div> </div>
) : null; ) : null;
@@ -2058,7 +2058,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
</div> </div>
{running && ( {running && (
<div style={{ marginTop: 6, color: '#0369a1', fontSize: 12, fontWeight: 700 }}> <div style={{ marginTop: 6, color: '#0369a1', fontSize: 12, fontWeight: 700 }}>
正在运行,已自动打开右侧 Runtime Status 正在运行,已自动打开右侧运行状态
</div> </div>
)} )}
</button> </button>
@@ -2153,11 +2153,11 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
color: '#9a3412', color: '#9a3412',
fontSize: 12, fontSize: 12,
}}> }}>
Sentinel-1 Gamma SBAS is planning-only. Stack discovery, audit manifest and run record are enabled; Gamma execution is disabled until the S1 TOPS/SBAS scripts are verified. Sentinel-1 Gamma SBAS 当前仅开放规划能力:可进行栈发现、审计 Manifest 和 Run 记录管理;Gamma 执行需等待 S1 TOPS/SBAS 脚本验证完成后启用。
</div> </div>
)} )}
<div style={{ ...mutedStyle, marginTop: 6 }}> <div style={{ ...mutedStyle, marginTop: 6 }}>
专家文档目录 + manifest + WSL runner 主路径。旧分阶段执行仅作为兼容桥接。 专家文档目录 + manifest + WSL runner 主路径,生产执行以当前统一工作流为准。
</div> </div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
<button <button
@@ -2237,7 +2237,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
<details style={{ ...compactDetailsStyle, marginTop: 10 }}> <details style={{ ...compactDetailsStyle, marginTop: 10 }}>
<summary style={compactSummaryStyle}>Expert document path ({expertDocumentSteps.length})</summary> <summary style={compactSummaryStyle}>Expert document path ({expertDocumentSteps.length})</summary>
<div style={{ ...mutedStyle, marginTop: 4 }}> <div style={{ ...mutedStyle, marginTop: 4 }}>
{expertDocumentSteps.length} sections from the LT1 Gamma SBAS expert document. Commands are used as the acceptance checklist; completed workflow steps must pass the expert command audit. 已载入 LT1 Gamma SBAS 专家文档中的 {expertDocumentSteps.length} 个章节。命令清单作为验收检查项,已完成的 Workflow 步骤必须通过专家命令审计。
</div> </div>
<div style={{ display: 'grid', gap: 6, marginTop: 8 }}> <div style={{ display: 'grid', gap: 6, marginTop: 8 }}>
{expertDocumentSteps.map(item => { {expertDocumentSteps.map(item => {
+51 -13
View File
@@ -40,7 +40,6 @@ const statusColors = {
ERROR: '#dc2626', ERROR: '#dc2626',
}; };
const panelStyle = { display: 'grid', gap: 12 };
const sectionStyle = { const sectionStyle = {
background: '#ffffff', background: '#ffffff',
border: '1px solid #d8dee8', border: '1px solid #d8dee8',
@@ -203,11 +202,11 @@ const assetRoleInfo = {
}, },
product_summary: { product_summary: {
label: '产品摘要', label: '产品摘要',
description: '旧版托管产品摘要;专家 Gamma 模式下不再作为必需产物。', description: '托管产品摘要;专家 Gamma 模式下不作为必需产物。',
}, },
quality_summary: { quality_summary: {
label: '质量摘要', label: '质量摘要',
description: '旧版质量统计摘要;专家 Gamma 模式下统计由 GeoTIFF 派生。', description: '质量统计摘要;专家 Gamma 模式下统计由 GeoTIFF 派生。',
}, },
monitor_points_summary: { monitor_points_summary: {
label: '监测点摘要', label: '监测点摘要',
@@ -271,7 +270,7 @@ const assetRoleInfo = {
}, },
alternate_geotiff: { alternate_geotiff: {
label: 'LOS 反向约定 GeoTIFF', label: 'LOS 反向约定 GeoTIFF',
description: '旧版 away-from-radar 符号约定下的备用速率栅格。', description: 'away-from-radar 符号约定下的备用速率栅格。',
}, },
quality_geotiff: { quality_geotiff: {
label: 'LOS Sigma GeoTIFF', label: 'LOS Sigma GeoTIFF',
@@ -1528,6 +1527,18 @@ export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued }
const sigmaStats = quality.los_sigma_mm_per_year_rdc || quality.los_sigma_m_per_year_rdc || {}; const sigmaStats = quality.los_sigma_mm_per_year_rdc || quality.los_sigma_m_per_year_rdc || {};
const hasSigmaStats = Object.keys(sigmaStats || {}).length > 0; const hasSigmaStats = Object.keys(sigmaStats || {}).length > 0;
const catalogColor = statusColors[catalogStatus?.status] || '#64748b'; const catalogColor = statusColors[catalogStatus?.status] || '#64748b';
const catalogStatusValue = catalogStatus?.status || 'UNKNOWN';
const catalogIssueCount = catalogStatus?.issue_count ?? 0;
const productCount = products.length;
const catalogTone = catalogStatusValue === 'READY'
? 'ready'
: catalogStatusValue === 'ERROR'
? 'error'
: catalogStatusValue === 'REBUILDING'
? 'info'
: catalogStatusValue === 'UNKNOWN'
? 'neutral'
: 'warn';
const openAssetLightbox = useCallback((title, asset) => { const openAssetLightbox = useCallback((title, asset) => {
if (!detail?.id || !asset) return; if (!detail?.id || !asset) return;
setLightboxImage({ setLightboxImage({
@@ -1538,17 +1549,18 @@ export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued }
}, [detail?.id]); }, [detail?.id]);
return ( return (
<div style={panelStyle}> <div className="sbas-products-page">
<ImageLightbox image={lightboxImage} onClose={() => setLightboxImage(null)} /> <ImageLightbox image={lightboxImage} onClose={() => setLightboxImage(null)} />
<section style={sectionStyle}> <section className="sbas-products-header">
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}> <div className="sbas-products-header-main">
<div> <div>
<h3 style={{ margin: 0, color: '#0f172a', fontSize: 18 }}>SBAS-InSAR 结果管理</h3> <h3>SBAS-InSAR 结果目录</h3>
<div style={{ ...mutedStyle, marginTop: 5 }}> <p>
管理 Gamma SBAS 生产结果、重要预览图、GeoTIFF、监测点曲线和发布资产。 管理 Gamma SBAS 生产结果、重要预览图、GeoTIFF、监测点曲线和发布资产。
</div> 这里用于成果登记、资产复核和目录重建,生产提交仍归入 SBAS 运行视图。
</p>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div className="sbas-products-actions">
<button type="button" onClick={loadCatalog} disabled={loading || actionLoading} style={buttonStyle}> <button type="button" onClick={loadCatalog} disabled={loading || actionLoading} style={buttonStyle}>
{loading ? '刷新中...' : '刷新'} {loading ? '刷新中...' : '刷新'}
</button> </button>
@@ -1558,7 +1570,26 @@ export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued }
</div> </div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 10, marginTop: 12 }}> <div className="sbas-products-signals" aria-label="SBAS-InSAR 结果目录状态摘要">
<div className={`dinsar-production-signal tone-${readOnly ? 'warn' : 'ready'}`}>
<span>操作模式</span>
<strong>{readOnly ? '只读' : '可维护'}</strong>
</div>
<div className={`dinsar-production-signal tone-${catalogTone}`}>
<span>目录状态</span>
<strong>{catalogStatusValue}</strong>
</div>
<div className={`dinsar-production-signal tone-${productCount > 0 ? 'ready' : 'neutral'}`}>
<span>登记产品</span>
<strong>{productCount}</strong>
</div>
<div className={`dinsar-production-signal tone-${catalogIssueCount > 0 ? 'warn' : 'ready'}`}>
<span>问题数</span>
<strong>{catalogIssueCount}</strong>
</div>
</div>
<div className="sbas-products-metrics">
<Metric label="目录状态" value={<StatusBadge value={catalogStatus?.status || 'UNKNOWN'} />} accent={catalogColor} /> <Metric label="目录状态" value={<StatusBadge value={catalogStatus?.status || 'UNKNOWN'} />} accent={catalogColor} />
<Metric label="需要重建" value={catalogStatus?.needs_rebuild ? '是' : '否'} accent={catalogStatus?.needs_rebuild ? '#dc2626' : '#15803d'} /> <Metric label="需要重建" value={catalogStatus?.needs_rebuild ? '是' : '否'} accent={catalogStatus?.needs_rebuild ? '#dc2626' : '#15803d'} />
<Metric label="Run / DB" value={`${catalogStatus?.run_count ?? catalogStatus?.manifest_count ?? 0} / ${catalogStatus?.db_count ?? 0}`} /> <Metric label="Run / DB" value={`${catalogStatus?.run_count ?? catalogStatus?.manifest_count ?? 0} / ${catalogStatus?.db_count ?? 0}`} />
@@ -1575,7 +1606,14 @@ export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued }
)} )}
</section> </section>
<section style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 380px) minmax(0, 1fr)', gap: 12, alignItems: 'start' }}> <div className="sbas-products-section-head">
<div>
<strong>结果检索与资产复核</strong>
<span>左侧筛选已登记结果,右侧查看预览图、位置摘要、统计、下载资产和目录问题。</span>
</div>
</div>
<section className="sbas-products-workspace">
<div style={sectionStyle}> <div style={sectionStyle}>
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}> <div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
<input <input
File diff suppressed because it is too large Load Diff
+21 -5
View File
@@ -42,6 +42,20 @@ const STATUS_COLOR = {
PUBLISHED: '#16a34a', PUBLISHED: '#16a34a',
}; };
const ACTIVE_RUN_STATUSES = new Set([
'PENDING',
'RUNNING',
'PREPARING',
'STACK_PREPARING',
'MATERIALIZING',
'STACK_RUNNING',
'MINTPY_RUNNING',
'EXPORTING',
'REGISTERING',
]);
const ACTIVE_RUN_REFRESH_INTERVAL_MS = 30000;
const PREPARED_STACK_STATE = { const PREPARED_STACK_STATE = {
not_prepared: { label: 'Not prepared', color: '#64748b' }, not_prepared: { label: 'Not prepared', color: '#64748b' },
manifest_unreadable: { label: 'Manifest unreadable', color: '#dc2626' }, manifest_unreadable: { label: 'Manifest unreadable', color: '#dc2626' },
@@ -209,6 +223,9 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
const [wslReport, setWslReport] = useState(null); const [wslReport, setWslReport] = useState(null);
const [preflightLoading, setPreflightLoading] = useState(false); const [preflightLoading, setPreflightLoading] = useState(false);
const [preflightReport, setPreflightReport] = useState(null); const [preflightReport, setPreflightReport] = useState(null);
const hasActiveRun = useMemo(() => (
runs.some(item => ACTIVE_RUN_STATUSES.has(String(item.status || '').toUpperCase()))
), [runs]);
const [retryingStepId, setRetryingStepId] = useState(''); const [retryingStepId, setRetryingStepId] = useState('');
const selectedBatch = useMemo( const selectedBatch = useMemo(
@@ -355,9 +372,10 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
}, [loadBatches, loadRuns]); }, [loadBatches, loadRuns]);
useEffect(() => { useEffect(() => {
const timer = setInterval(loadRuns, 10000); if (!hasActiveRun) return undefined;
const timer = setInterval(loadRuns, ACTIVE_RUN_REFRESH_INTERVAL_MS);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [loadRuns]); }, [hasActiveRun, loadRuns]);
useEffect(() => { useEffect(() => {
loadRunDetail(selectedRunId); loadRunDetail(selectedRunId);
@@ -465,8 +483,7 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
}} }}
> >
当前生产入口采用分层 SBAS 模型:时序配对先形成候选大池,提交 run 后由 prepare 冻结 prepared SBAS 小栈。 当前生产入口采用分层 SBAS 模型:时序配对先形成候选大池,提交 run 后由 prepare 冻结 prepared SBAS 小栈。
ENVI/SARscape SBAS 后续只读取 prepared manifest 和 selected_network_edges 审计图,不再重新扫描全量数据。 ENVI/SARscape SBAS 后续只读取 prepared manifest 和 selected_network_edges 审计图,确保生产输入可复核、可追溯。
ISCE2 + MintPy 路径仍沿用 stack_prep、materialize、stack、MintPy、publish、register 链路。
</div> </div>
{wslReport && ( {wslReport && (
<div <div
@@ -560,7 +577,6 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
style={{ width: '100%', padding: '6px 8px', borderRadius: 6, border: '1px solid #cbd5e1' }} style={{ width: '100%', padding: '6px 8px', borderRadius: 6, border: '1px solid #cbd5e1' }}
> >
<option value="sarscape_sbas">ENVI/SARscape SBAS</option> <option value="sarscape_sbas">ENVI/SARscape SBAS</option>
<option value="isce2_stack_mintpy">ISCE2 + MintPy</option>
</select> </select>
</div> </div>
<div> <div>
+2 -1
View File
@@ -10,6 +10,7 @@ const getStatusLabel = (lang) => lang === 'en'
? { PENDING: 'Pending', RUNNING: 'Processing', DONE: 'Done', FAILED: 'Failed' } ? { PENDING: 'Pending', RUNNING: 'Processing', DONE: 'Done', FAILED: 'Failed' }
: { PENDING: '等待中', RUNNING: '处理中', DONE: '完成', FAILED: '失败' }; : { PENDING: '等待中', RUNNING: '处理中', DONE: '完成', FAILED: '失败' };
const STATUS_COLOR = { PENDING: '#64748b', RUNNING: '#2563eb', DONE: '#16a34a', FAILED: '#dc2626' }; const STATUS_COLOR = { PENDING: '#64748b', RUNNING: '#2563eb', DONE: '#16a34a', FAILED: '#dc2626' };
const ACTIVE_SCENE_REFRESH_INTERVAL_MS = 15000;
const UI_COLORS = { const UI_COLORS = {
pageText: '#0f172a', pageText: '#0f172a',
@@ -468,7 +469,7 @@ export default function WaterMonitorPanel({ readOnly, onShowOnMap, onShowFloodOn
const timer = setInterval(() => { const timer = setInterval(() => {
loadScenes(scenesPageRef.current); loadScenes(scenesPageRef.current);
loadStatusIds(); loadStatusIds();
}, 5000); }, ACTIVE_SCENE_REFRESH_INTERVAL_MS);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [activeRadarIds, tab, loadScenes, loadStatusIds]); }, [activeRadarIds, tab, loadScenes, loadStatusIds]);
+3
View File
@@ -18,6 +18,9 @@ export const runWslCheck = (payload = {}) =>
export const submitRun = (payload) => export const submitRun = (payload) =>
apiClient.post('/dinsar-production/run', payload).then(r => r.data); apiClient.post('/dinsar-production/run', payload).then(r => r.data);
export const submitLandsarClusterRun = (payload) =>
apiClient.post('/dinsar-production/landsar-cluster/run', payload).then(r => r.data);
// 运行历史 // 运行历史
export const listRuns = (limit = 20, offset = 0) => export const listRuns = (limit = 20, offset = 0) =>
apiClient.get( apiClient.get(
+3
View File
@@ -2,3 +2,6 @@ import apiClient from './client';
export const getStatistics = (fresh = false) => export const getStatistics = (fresh = false) =>
apiClient.get('/statistics', { params: fresh ? { fresh: true } : undefined }).then(r => r.data); apiClient.get('/statistics', { params: fresh ? { fresh: true } : undefined }).then(r => r.data);
export const getStatisticsDashboard = () =>
apiClient.get('/statistics/dashboard').then(r => r.data);
@@ -54,7 +54,6 @@ const createSentinelRows = (dataInfo, language, formatYmd) => {
field(language === 'en' ? 'Orbit File:' : '轨道文件:', dataInfo.orbit_file_path, { field(language === 'en' ? 'Orbit File:' : '轨道文件:', dataInfo.orbit_file_path, {
valueStyle: { wordBreak: 'break-all' }, valueStyle: { wordBreak: 'break-all' },
}), }),
field(language === 'en' ? 'ENVI Processed:' : 'ENVI已处理:', yesNo(dataInfo.is_envi_processed, language)),
]; ];
}; };
@@ -77,7 +76,6 @@ const createDefaultRows = (dataInfo, language, formatYmd) => [
field(language === 'en' ? 'Orbit File:' : '轨道文件:', dataInfo.orbit_file_path, { field(language === 'en' ? 'Orbit File:' : '轨道文件:', dataInfo.orbit_file_path, {
valueStyle: { wordBreak: 'break-all' }, valueStyle: { wordBreak: 'break-all' },
}), }),
field(language === 'en' ? 'ENVI Processed:' : 'ENVI已处理:', yesNo(dataInfo.is_envi_processed, language)),
]; ];
const createRows = (dataInfo, language, formatYmd) => { const createRows = (dataInfo, language, formatYmd) => {
+8 -114
View File
@@ -6,8 +6,6 @@ import {
getDinsarProductDetail, getDinsarProductDetail,
getDinsarProductCleanupPlan, getDinsarProductCleanupPlan,
listDinsarProductPairs, listDinsarProductPairs,
queueDinsarCatalogRebuild,
queueDinsarProductPublish,
} from '../api/dinsarProducts'; } from '../api/dinsarProducts';
import { import {
DINSAR_ENGINE_ALL, DINSAR_ENGINE_ALL,
@@ -52,15 +50,6 @@ function formatBytes(value) {
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`; return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
} }
function parseDirectoryList(value) {
return [...new Set(
String(value || '')
.split(/[\r\n,;]+/)
.map((item) => item.trim())
.filter(Boolean)
)];
}
function getMessageTone(message) { function getMessageTone(message) {
return /失败|error|Error|ERROR/.test(String(message || '')) ? 'error' : 'success'; return /失败|error|Error|ERROR/.test(String(message || '')) ? 'error' : 'success';
} }
@@ -91,8 +80,6 @@ function MetaField({ label, value, multiline = false }) {
export default function DinsarCatalogPanel({ export default function DinsarCatalogPanel({
readOnly = false, readOnly = false,
compact = false, compact = false,
initialSourceDir = '',
onTaskQueued,
}) { }) {
const [catalogStatus, setCatalogStatus] = useState(null); const [catalogStatus, setCatalogStatus] = useState(null);
const [products, setProducts] = useState([]); const [products, setProducts] = useState([]);
@@ -104,27 +91,14 @@ export default function DinsarCatalogPanel({
const [cleanupPlanLoading, setCleanupPlanLoading] = useState(false); const [cleanupPlanLoading, setCleanupPlanLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [actionMessage, setActionMessage] = useState(''); const [actionMessage, setActionMessage] = useState('');
const [sourceDirectoriesText, setSourceDirectoriesText] = useState(initialSourceDir || '');
const [publishRoot, setPublishRoot] = useState('');
const [engineFilter, setEngineFilter] = useState(DINSAR_ENGINE_ALL); const [engineFilter, setEngineFilter] = useState(DINSAR_ENGINE_ALL);
const [queryDraft, setQueryDraft] = useState(''); const [queryDraft, setQueryDraft] = useState('');
const [queryApplied, setQueryApplied] = useState(''); const [queryApplied, setQueryApplied] = useState('');
const listLimit = compact ? 8 : 24; const listLimit = compact ? 8 : 18;
const previewBaseUrl = apiClient.defaults.baseURL || '/api'; const previewBaseUrl = apiClient.defaults.baseURL || '/api';
useEffect(() => {
if (!initialSourceDir) return;
setSourceDirectoriesText((current) => (current.trim() ? current : initialSourceDir));
}, [initialSourceDir]);
const sourceDirectories = useMemo(
() => parseDirectoryList(sourceDirectoriesText),
[sourceDirectoriesText]
);
const engineOptions = useMemo( const engineOptions = useMemo(
() => buildDinsarEngineOptions([], { includeKnown: true }), () => buildDinsarEngineOptions([], { includeKnown: true }),
[] []
@@ -210,6 +184,11 @@ export default function DinsarCatalogPanel({
} }
}, []); }, []);
const handleSelectProduct = useCallback((pairKey, productId) => {
setSelectedPairKey(pairKey);
setSelectedProductId((current) => (current === productId ? current : productId || null));
}, []);
const loadCleanupPlan = useCallback(async () => { const loadCleanupPlan = useCallback(async () => {
if (!selectedProductId) return; if (!selectedProductId) return;
setCleanupPlanLoading(true); setCleanupPlanLoading(true);
@@ -243,45 +222,6 @@ export default function DinsarCatalogPanel({
setQueryApplied(''); setQueryApplied('');
}, []); }, []);
const handleQueuePublish = async () => {
if (readOnly || sourceDirectories.length === 0) return;
setActionLoading(true);
setActionMessage('');
try {
const result = await queueDinsarProductPublish({
source_directories: sourceDirectories,
publish_root: publishRoot.trim() || null,
rebuild_catalog: true,
});
setActionMessage(`结果包发布任务已入队:${result.task_id}`);
onTaskQueued?.(result.task_id);
await loadCatalog();
} catch (error) {
setActionMessage(`结果包发布失败:${error?.response?.data?.detail || error.message}`);
} finally {
setActionLoading(false);
}
};
const handleQueueRebuild = async () => {
if (readOnly) return;
setActionLoading(true);
setActionMessage('');
try {
const result = await queueDinsarCatalogRebuild({
publish_root: publishRoot.trim() || null,
full_rebuild: true,
});
setActionMessage(`结果目录重建任务已入队:${result.task_id}`);
onTaskQueued?.(result.task_id);
await loadCatalog();
} catch (error) {
setActionMessage(`结果目录重建失败:${error?.response?.data?.detail || error.message}`);
} finally {
setActionLoading(false);
}
};
const catalogTone = STATUS_TONE_MAP[catalogStatus?.status] || 'neutral'; const catalogTone = STATUS_TONE_MAP[catalogStatus?.status] || 'neutral';
const actionTone = getMessageTone(actionMessage); const actionTone = getMessageTone(actionMessage);
const selectedIssues = Array.isArray(selectedProduct?.issues) ? selectedProduct.issues : []; const selectedIssues = Array.isArray(selectedProduct?.issues) ? selectedProduct.issues : [];
@@ -311,7 +251,7 @@ export default function DinsarCatalogPanel({
{selectedEngineMeta.shortLabel} {selectedEngineMeta.shortLabel}
</span> </span>
)} )}
<button onClick={loadCatalog} disabled={loading || actionLoading}> <button onClick={loadCatalog} disabled={loading}>
{loading ? '刷新中...' : '刷新'} {loading ? '刷新中...' : '刷新'}
</button> </button>
</div> </div>
@@ -352,49 +292,6 @@ export default function DinsarCatalogPanel({
</div> </div>
)} )}
{!compact && (
<div className="dinsar-catalog-manage">
<div className="dinsar-catalog-manage-copy">
<strong>手动发布与目录重建</strong>
<p>
这里用于把既有结果目录重新发布为标准结果包,并按最新规则重建目录索引。
同一对影像的 ENVI/SARscape、LandSAR、Gamma/PyINT 结果会按任务聚合展示,底层仍依赖 `engine_code` 与 `run_key` 分别登记。
</p>
</div>
<div className="dinsar-catalog-manage-form">
<textarea
value={sourceDirectoriesText}
onChange={(event) => setSourceDirectoriesText(event.target.value)}
placeholder="输入一个或多个结果源目录,支持换行、逗号或分号分隔"
disabled={readOnly || actionLoading}
/>
<input
value={publishRoot}
onChange={(event) => setPublishRoot(event.target.value)}
placeholder="可选:自定义标准结果包根目录,留空使用系统配置"
disabled={readOnly || actionLoading}
/>
<div className="dinsar-catalog-manage-actions">
<button
type="button"
className="primary"
onClick={handleQueuePublish}
disabled={readOnly || actionLoading || sourceDirectories.length === 0}
>
{actionLoading ? '处理中...' : '发布结果包并重建'}
</button>
<button
type="button"
onClick={handleQueueRebuild}
disabled={readOnly || actionLoading}
>
仅重建目录
</button>
</div>
</div>
</div>
)}
<div className={`dinsar-catalog-workspace ${compact ? 'compact' : ''}`}> <div className={`dinsar-catalog-workspace ${compact ? 'compact' : ''}`}>
<aside className="dinsar-catalog-list-card"> <aside className="dinsar-catalog-list-card">
<div className="dinsar-catalog-card-head"> <div className="dinsar-catalog-card-head">
@@ -456,10 +353,7 @@ export default function DinsarCatalogPanel({
key={rowKey} key={rowKey}
type="button" type="button"
className={`dinsar-catalog-list-item ${selectedPairKey === rowKey ? 'active' : ''}`} className={`dinsar-catalog-list-item ${selectedPairKey === rowKey ? 'active' : ''}`}
onClick={() => { onClick={() => handleSelectProduct(rowKey, item.primary_product_id)}
setSelectedPairKey(rowKey);
setSelectedProductId(item.primary_product_id || null);
}}
> >
<div className="dinsar-catalog-list-item-top"> <div className="dinsar-catalog-list-item-top">
<strong>{item.task_alias || item.task_name || item.pair_key || '未命名任务'}</strong> <strong>{item.task_alias || item.task_name || item.pair_key || '未命名任务'}</strong>
@@ -24,6 +24,8 @@ const statusColorMap = {
REBUILDING: '#2563eb', REBUILDING: '#2563eb',
}; };
const CATALOG_ACTIVE_REFRESH_INTERVAL_MS = 30000;
function formatDateTime(value) { function formatDateTime(value) {
if (!value) return '-'; if (!value) return '-';
try { try {
@@ -125,13 +127,18 @@ export default function PsinsarCatalogPanel({
useEffect(() => { useEffect(() => {
loadCatalog(); loadCatalog();
}, [loadCatalog]);
useEffect(() => {
const status = String(catalogStatus?.status || '').toUpperCase();
if (status !== 'REBUILDING') return undefined;
const timer = setInterval(() => { const timer = setInterval(() => {
if (!actionLoading) { if (!actionLoading) {
loadCatalog(); loadCatalog();
} }
}, 10000); }, CATALOG_ACTIVE_REFRESH_INTERVAL_MS);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [actionLoading, loadCatalog]); }, [actionLoading, catalogStatus?.status, loadCatalog]);
useEffect(() => { useEffect(() => {
loadProductDetail(selectedProductId); loadProductDetail(selectedProductId);
@@ -1,161 +0,0 @@
import { useMemo, useState } from 'react';
import { exportDinsarResults } from '../api/dinsar';
import { getDinsarEngineMeta } from '../utils/dinsarEngines';
const EXAMPLE_TARGET_DIR = String.raw`例如: D:\Export\Results 或 \\server\share\results`;
export default function ResultExportModal({ results = [], onClose }) {
const [targetDir, setTargetDir] = useState('');
const [selectedIds, setSelectedIds] = useState(() => new Set(results.map((result) => result.id)));
const [exporting, setExporting] = useState(false);
const [exportResult, setExportResult] = useState(null);
const [error, setError] = useState('');
const selectedCount = selectedIds.size;
const sortedResults = useMemo(() => [...results], [results]);
const toggleSelect = (id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const toggleAll = () => {
if (selectedIds.size === results.length) {
setSelectedIds(new Set());
return;
}
setSelectedIds(new Set(results.map((result) => result.id)));
};
const handleExport = async () => {
const dir = targetDir.trim();
if (!dir) {
setError('请输入目标路径。');
return;
}
if (selectedIds.size === 0) {
setError('请至少选择一个结果。');
return;
}
setError('');
setExporting(true);
setExportResult(null);
try {
const response = await exportDinsarResults([...selectedIds], dir);
setExportResult(response);
} catch (eventualError) {
setError(eventualError.response?.data?.detail || eventualError.message || '提取失败。');
} finally {
setExporting(false);
}
};
return (
<div className="modal-overlay visible" onClick={onClose}>
<div className="modal-content result-export-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>提取 D-InSAR 结果</h3>
<button type="button" className="modal-close-btn" onClick={onClose} aria-label="关闭">
&times;
</button>
</div>
<div className="modal-body">
<div className="export-path-section">
<label>目标路径,支持本地盘符或 UNC 路径,例如 `D:\Export\Results`。</label>
<input
type="text"
value={targetDir}
onChange={(event) => setTargetDir(event.target.value)}
placeholder={EXAMPLE_TARGET_DIR}
disabled={exporting}
className="export-path-input"
/>
</div>
<div className="export-select-section">
<div className="export-select-header">
<label>
<input
type="checkbox"
checked={selectedIds.size === results.length && results.length > 0}
onChange={toggleAll}
disabled={exporting || results.length === 0}
/>
全选 ({selectedCount}/{results.length})
</label>
</div>
<div className="export-select-hint">
导出时会优先按任务名创建子目录;如果同名结果已存在且内容不同,会自动追加后缀避免覆盖。
</div>
<ul className="export-result-list">
{sortedResults.map((result) => {
const engineMeta = getDinsarEngineMeta(result.engine_code);
return (
<li key={result.id} className="export-result-item">
<label>
<input
type="checkbox"
checked={selectedIds.has(result.id)}
onChange={() => toggleSelect(result.id)}
disabled={exporting}
/>
<span className="export-result-name" title={result.file_path || result.name}>
{result.name}
</span>
<span className={`dinsar-engine-badge tone-${engineMeta.tone}`}>
{engineMeta.shortLabel}
</span>
</label>
</li>
);
})}
</ul>
</div>
{error && <div className="export-error">{error}</div>}
{exportResult && (
<div className="export-summary">
<div className="export-summary-title">提取完成</div>
<div className="export-summary-stats">
<span className="stat-ok">复制: {exportResult.copied}</span>
<span className="stat-skip">跳过: {exportResult.skipped}</span>
{exportResult.failed > 0 && (
<span className="stat-fail">失败: {exportResult.failed}</span>
)}
</div>
<div className="export-summary-dir">
目标目录: {exportResult.target_dir}
</div>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={onClose} disabled={exporting}>
关闭
</button>
<button
type="button"
onClick={handleExport}
disabled={exporting || selectedIds.size === 0 || !targetDir.trim()}
className="btn-primary"
>
{exporting ? '提取中...' : `确定提取 ${selectedIds.size} 个结果`}
</button>
</div>
</div>
</div>
);
}
@@ -1,44 +1,44 @@
export function PanelLoadingBody({ message = '正在加载面板...' }) { export function PanelLoadingBody({ message = '正在加载面板...' }) {
return ( return (
<div style={{ padding: '16px' }}> <div style={{ padding: 16 }}>
<p className="empty-state">{message}</p> <p className="empty-state">{message}</p>
</div> </div>
); );
} }
export function PanelLoadingPanel({ message = '正在加载面板...' }) { export function PanelLoadingPanel({ message = '正在加载面板...' }) {
return ( return (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}> <div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<PanelLoadingBody message={message} /> <PanelLoadingBody message={message} />
</div> </div>
); );
} }
export function ModalLoadingFallback({ message = '正在加载内容...' }) { export function ModalLoadingFallback({ message = '正在加载内容...' }) {
return ( return (
<div <div
style={{ style={{
position: 'fixed', position: 'fixed',
inset: 0, inset: 0,
background: 'rgba(15, 23, 42, 0.4)', background: 'rgba(15, 23, 42, 0.4)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
zIndex: 2000, zIndex: 2000,
}} }}
> >
<div <div
style={{ style={{
background: '#fff', background: '#fff',
borderRadius: '12px', borderRadius: 8,
border: '1px solid #e2e8f0', border: '1px solid #e2e8f0',
padding: '24px 28px', padding: '24px 28px',
minWidth: '280px', minWidth: 280,
boxShadow: '0 20px 45px rgba(15, 23, 42, 0.18)', boxShadow: '0 16px 32px rgba(15, 23, 42, 0.16)',
}} }}
> >
<p className="empty-state" style={{ margin: 0 }}>{message}</p> <p className="empty-state" style={{ margin: 0 }}>{message}</p>
</div> </div>
</div> </div>
); );
} }
@@ -1,27 +0,0 @@
import { useUiStore } from '../../store';
import { useI18n } from '../../i18n/I18nContext';
export default function AppLogPanel({ width }) {
const logs = useUiStore((state) => state.logs);
const { t } = useI18n();
return (
<aside className="panel right-panel" style={{ width }}>
<header>
<h3>日志</h3>
</header>
<div className="panel-content log-entries">
{logs.length === 0 ? (
<p className="empty-state">暂无日志。</p>
) : (
logs.map((log, index) => (
<div key={index} className="log-entry">
<span className="log-time">[{log.time}]</span>
<span className="log-message" data-log-type={log.type}>{t(log.message)}</span>
</div>
))
)}
</div>
</aside>
);
}
@@ -9,7 +9,6 @@ import { ModalLoadingFallback } from './AppLoadingFallbacks';
const LazyPairingModal = lazy(() => import('../PairingModal')); const LazyPairingModal = lazy(() => import('../PairingModal'));
const LazyDataInfoModal = lazy(() => import('../DataInfoModal')); const LazyDataInfoModal = lazy(() => import('../DataInfoModal'));
const LazyGlobalTaskCenter = lazy(() => import('../GlobalTaskCenter')); const LazyGlobalTaskCenter = lazy(() => import('../GlobalTaskCenter'));
const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard'));
const LazyAiReportModal = lazy(() => import('../AiReportModal')); const LazyAiReportModal = lazy(() => import('../AiReportModal'));
const LazyMapExportModal = lazy(() => import('../MapExportModal')); const LazyMapExportModal = lazy(() => import('../MapExportModal'));
@@ -40,14 +39,10 @@ export default function AppOverlays({
showPairingModal: state.showPairingModal, showPairingModal: state.showPairingModal,
}))); })));
const { const {
showStats,
setShowStats,
showDataInfo, showDataInfo,
setShowDataInfo, setShowDataInfo,
selectedDataInfo, selectedDataInfo,
} = useUiStore(useShallow((state) => ({ } = useUiStore(useShallow((state) => ({
showStats: state.showStats,
setShowStats: state.setShowStats,
showDataInfo: state.showDataInfo, showDataInfo: state.showDataInfo,
setShowDataInfo: state.setShowDataInfo, setShowDataInfo: state.setShowDataInfo,
selectedDataInfo: state.selectedDataInfo, selectedDataInfo: state.selectedDataInfo,
@@ -79,12 +74,6 @@ export default function AppOverlays({
</Suspense> </Suspense>
)} )}
{showStats && (
<Suspense fallback={<ModalLoadingFallback message="正在加载统计看板..." />}>
<LazyStatisticsDashboard onClose={() => setShowStats(false)} />
</Suspense>
)}
<LicenseOverlay <LicenseOverlay
licenseLoading={licenseLoading} licenseLoading={licenseLoading}
licenseStatus={licenseStatus} licenseStatus={licenseStatus}
+24 -6
View File
@@ -10,7 +10,7 @@ import {
LEFT_TAB_SECTION, LEFT_TAB_SECTION,
PRODUCTION_WORKSPACE_ROUTE_TABS, PRODUCTION_WORKSPACE_ROUTE_TABS,
} from '../../config/appConstants'; } from '../../config/appConstants';
import { getLeftTabLabel } from '../../utils/appUiHelpers'; import { getLeftTabDescription, getLeftTabLabel } from '../../utils/appUiHelpers';
import { PanelLoadingBody, PanelLoadingPanel } from './AppLoadingFallbacks'; import { PanelLoadingBody, PanelLoadingPanel } from './AppLoadingFallbacks';
const LazyDataMonitorPanel = lazy(() => import('../../DataMonitorPanel')); const LazyDataMonitorPanel = lazy(() => import('../../DataMonitorPanel'));
@@ -28,6 +28,8 @@ const LazyDinsarResultPanel = lazy(() => import('../../panels/DinsarResultPanel'
const LazyPsinsarCatalogPanel = lazy(() => import('../PsinsarCatalogPanel')); const LazyPsinsarCatalogPanel = lazy(() => import('../PsinsarCatalogPanel'));
const LazySbasInsarMapAnalysisPanel = lazy(() => import('../../panels/SbasInsarMapAnalysisPanel')); const LazySbasInsarMapAnalysisPanel = lazy(() => import('../../panels/SbasInsarMapAnalysisPanel'));
const LazyProductionWorkspace = lazy(() => import('../../ProductionWorkspace')); const LazyProductionWorkspace = lazy(() => import('../../ProductionWorkspace'));
const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard'));
const LazyResultExtractionPanel = lazy(() => import('../../ResultExtractionPanel'));
export default function AppSidePanel({ export default function AppSidePanel({
leftPanelWidth, leftPanelWidth,
@@ -105,8 +107,8 @@ export default function AppSidePanel({
? '生产管理' ? '生产管理'
: getLeftTabLabel(leftPanelTab, leftTabLabelContext); : getLeftTabLabel(leftPanelTab, leftTabLabelContext);
const standaloneDescription = isProductionWorkspace const standaloneDescription = isProductionWorkspace
? '这里统一承载 D-InSAR 与 Gamma SBAS-InSAR 生产;旧 ISCE2/MintPy 时序入口已停用。' ? '这里统一承载 D-InSAR 与 Gamma SBAS-InSAR 的数据准备、生产运行、质量检查和成果发布。'
: '当前模块已切换为独立工作区模式。'; : getLeftTabDescription(leftPanelTab);
return ( return (
<aside <aside
@@ -198,7 +200,6 @@ export default function AppSidePanel({
showRadarPageInputError={radarPanel.showRadarPageInputError} showRadarPageInputError={radarPanel.showRadarPageInputError}
radarPageInputValidationError={radarPanel.radarPageInputValidationError} radarPageInputValidationError={radarPanel.radarPageInputValidationError}
onSearchAll={radarPanel.onSearchAll} onSearchAll={radarPanel.onSearchAll}
onShowStats={radarPanel.onShowStats}
onSearch={radarPanel.onSearch} onSearch={radarPanel.onSearch}
onReset={radarPanel.onReset} onReset={radarPanel.onReset}
onAoiModeChange={radarPanel.onAoiModeChange} onAoiModeChange={radarPanel.onAoiModeChange}
@@ -220,6 +221,14 @@ export default function AppSidePanel({
/> />
)} )}
{leftPanelTab === 'statistics' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载综合统计..." />}>
<LazyStatisticsDashboard />
</Suspense>
</div>
)}
{leftPanelTab === 'ingest' && ( {leftPanelTab === 'ingest' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}> <div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载数据接入面板..." />}> <Suspense fallback={<PanelLoadingBody message="正在加载数据接入面板..." />}>
@@ -235,7 +244,7 @@ export default function AppSidePanel({
{leftPanelTab === 'asset_inventory' && ( {leftPanelTab === 'asset_inventory' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}> <div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载资产库存..." />}> <Suspense fallback={<PanelLoadingBody message="正在加载资产台账..." />}>
<LazyAssetInventoryPanel <LazyAssetInventoryPanel
readOnly={isReadOnlyUser} readOnly={isReadOnlyUser}
onTaskStart={taskPanel.onTaskStart} onTaskStart={taskPanel.onTaskStart}
@@ -311,7 +320,7 @@ export default function AppSidePanel({
{leftPanelTab === 'health' && ( {leftPanelTab === 'health' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}> <div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载运维自检面板..." />}> <Suspense fallback={<PanelLoadingBody message="正在加载运行维护面板..." />}>
<LazyHealthCheckPanel <LazyHealthCheckPanel
apiEndpoint={apiEndpoint} apiEndpoint={apiEndpoint}
language={language} language={language}
@@ -364,6 +373,7 @@ export default function AppSidePanel({
onToggleVisibility={dinsarPanel.onToggleVisibility} onToggleVisibility={dinsarPanel.onToggleVisibility}
onLabel={dinsarPanel.onLabel} onLabel={dinsarPanel.onLabel}
onAnalyze={dinsarPanel.onAnalyze} onAnalyze={dinsarPanel.onAnalyze}
onOpenResultExtraction={() => setLeftPanelTab('result_extraction')}
/> />
</Suspense> </Suspense>
)} )}
@@ -405,6 +415,14 @@ export default function AppSidePanel({
</div> </div>
)} )}
{leftPanelTab === 'result_extraction' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载结果提取工作台..." />}>
<LazyResultExtractionPanel readOnly={isReadOnlyUser} />
</Suspense>
</div>
)}
{leftPanelTab === 'ai_quality' && ( {leftPanelTab === 'ai_quality' && (
<Suspense fallback={<PanelLoadingPanel message="正在加载 AI 质量面板..." />}> <Suspense fallback={<PanelLoadingPanel message="正在加载 AI 质量面板..." />}>
<LazyAiQualityPanel <LazyAiQualityPanel
+76 -103
View File
@@ -1,112 +1,85 @@
import { memo } from 'react'; import { memo } from 'react';
import { formatUtc, getStatusClass } from '../../utils/appUiHelpers'; import defaultLogoUrl from '../../logo.jpg';
import { formatUtc } from '../../utils/appUiHelpers';
const ORGANIZATION_NAME = import.meta.env.VITE_APP_ORG_NAME || '黑龙江省自然资源卫星应用技术中心';
const SYSTEM_NAME = import.meta.env.VITE_APP_SYSTEM_NAME || 'InSAR 自动化管理系统';
const SYSTEM_TAGLINE = import.meta.env.VITE_APP_SYSTEM_TAGLINE || '科研工程生产平台';
const LOGO_URL = import.meta.env.VITE_APP_LOGO_URL || defaultLogoUrl;
function AppStatusHeader({ function AppStatusHeader({
language, language,
setLanguage, currentUser,
currentUser, isAdmin,
isAdmin, isReadOnlyUser,
isReadOnlyUser, activeTasks,
activeTasks, avgTaskProgress,
avgTaskProgress, licenseStatus,
licenseStatus, onLogout,
healthStatus,
healthLoading,
healthError,
onRefreshHealth,
onLogout,
}) { }) {
const hasHealthStatus = !!healthStatus; const licenseOk = !!licenseStatus?.ok;
const licenseOk = !!licenseStatus?.ok; const hasActiveTasks = activeTasks.length > 0;
const dbOk = !!(healthStatus?.database?.ok && healthStatus?.database?.schema_ok && healthStatus?.database?.postgis_ok);
const workerOk = !!healthStatus?.worker?.ok;
const idlOk = !!healthStatus?.idl?.ok;
const aiOk = !!healthStatus?.ollama?.ok;
const nginxOk = !!healthStatus?.nginx?.ok;
return ( return (
<> <>
<div className="top-status-bar"> <div className="top-status-bar">
<div className="status-brand"> <div className="status-brand">
<div className="brand-title">InSAR 自动化管理系统</div> {LOGO_URL && (
<div className="brand-subtitle">科研工程模式 · {licenseOk ? '已授权' : '未授权'}</div> <img
</div> className="status-brand-logo"
<div className="status-items"> src={LOGO_URL}
<div className="status-item"> alt={`${ORGANIZATION_NAME} logo`}
<span className={`status-dot ${getStatusClass(dbOk, hasHealthStatus)}`}></span>DB />
</div> )}
<div className="status-item"> <div className="status-brand-copy">
<span className={`status-dot ${getStatusClass(workerOk, hasHealthStatus)}`}></span>Worker <div className="brand-org">{ORGANIZATION_NAME}</div>
</div> <div className="brand-title">{SYSTEM_NAME}</div>
<div className="status-item"> </div>
<span className={`status-dot ${getStatusClass(idlOk, hasHealthStatus)}`}></span>IDL </div>
</div>
<div className="status-item"> <div className="status-system-meta" aria-label="系统状态摘要">
<span className={`status-dot ${getStatusClass(aiOk, hasHealthStatus)}`}></span>Ollama <span>{SYSTEM_TAGLINE}</span>
</div> <span className={`status-license-chip ${licenseOk ? 'ok' : 'fail'}`}>
<div className="status-item"> {licenseOk ? '已授权' : '未授权'}
<span className={`status-dot ${getStatusClass(nginxOk, hasHealthStatus)}`}></span>Nginx </span>
</div> {licenseStatus?.expires_at && (
</div> <span className="status-license">
<div className="status-actions"> 授权至 {formatUtc(licenseStatus.expires_at, language)}
<div className="status-lang-switch" data-no-i18n="true"> </span>
<button )}
className={`status-lang-btn ${language === 'zh' ? 'active' : ''}`} </div>
onClick={() => setLanguage('zh')}
title="切换到中文" <div className="status-actions">
> <div className={`status-task ${hasActiveTasks ? 'has-active-tasks' : ''}`}>
中文 <span>{hasActiveTasks ? `运行中 ${activeTasks.length}` : '任务空闲'}</span>
</button> {hasActiveTasks && (
<button <div className="status-task-bar" aria-hidden="true">
className={`status-lang-btn ${language === 'en' ? 'active' : ''}`} <div className="status-task-fill" style={{ width: `${avgTaskProgress}%` }} />
onClick={() => setLanguage('en')} </div>
title="Switch to English"
>
EN
</button>
</div>
<div className={`user-role-chip ${isAdmin ? 'admin' : 'viewer'}`}>
<span className="user-role-name">{currentUser.username}</span>
<span className="user-role-divider">·</span>
<span>{isAdmin ? '管理员' : '只读账号'}</span>
</div>
<div className={`status-task ${activeTasks.length > 0 ? 'has-active-tasks' : ''}`}>
<span>{activeTasks.length > 0 ? `⚙️ 运行中 ${activeTasks.length}` : '✓ 空闲'}</span>
{activeTasks.length > 0 && (
<div className="status-task-bar">
<div className="status-task-fill" style={{ width: `${avgTaskProgress}%` }}></div>
</div>
)}
</div>
{licenseStatus?.expires_at && (
<div className="status-license">
授权至 {formatUtc(licenseStatus.expires_at, language)}
</div>
)}
<button
className="status-refresh"
onClick={onRefreshHealth}
disabled={healthLoading}
title={healthError || '刷新自检状态'}
>
{healthLoading ? '自检中...' : '刷新自检'}
</button>
<button
className="status-refresh"
onClick={onLogout}
title="退出登录"
>
退出登录
</button>
</div>
</div>
{isReadOnlyUser && (
<div className="read-only-banner">
当前账号为只读权限:可查看数据与状态,但不能执行写操作。
</div>
)} )}
</> </div>
); <div className={`user-role-chip ${isAdmin ? 'admin' : 'viewer'}`}>
<span className="user-role-name">{currentUser.username}</span>
<span className="user-role-divider">·</span>
<span>{isAdmin ? '管理员' : '只读账号'}</span>
</div>
<button
className="status-refresh"
type="button"
onClick={onLogout}
title="退出登录"
>
退出登录
</button>
</div>
</div>
{isReadOnlyUser && (
<div className="read-only-banner">
当前账号为只读权限:可查看数据与状态,但不能执行写操作。
</div>
)}
</>
);
} }
export default memo(AppStatusHeader); export default memo(AppStatusHeader);
@@ -73,7 +73,7 @@ function DinsarResultRow({
className="dinsar-trace-pill" className="dinsar-trace-pill"
title={language === 'en' ? 'Pairing selection strategy' : '配对选择策略'} title={language === 'en' ? 'Pairing selection strategy' : '配对选择策略'}
> >
{result.selection_strategy || 'legacy'} {result.selection_strategy || '标准选择'}
</span> </span>
{result.run_key && ( {result.run_key && (
<span <span
@@ -56,9 +56,6 @@ function RadarDataRow({
: (language === 'en' ? 'Rebuild' : '重建')} : (language === 'en' ? 'Rebuild' : '重建')}
</button> </button>
)} )}
{item.is_envi_processed && (
<span className="envi-status" title={language === 'en' ? 'Processed to ENVI format' : '已处理为ENVI格式'}>E</span>
)}
<span <span
className={`orbit-status ${item.has_orbit_data ? 'has-orbit' : ''}`} className={`orbit-status ${item.has_orbit_data ? 'has-orbit' : ''}`}
title={item.has_orbit_data title={item.has_orbit_data
+22 -17
View File
@@ -86,12 +86,12 @@ export const PRODUCTION_WORKSPACE_DINSAR_VIEWS = [
{ {
key: 'dinsar_runs', key: 'dinsar_runs',
label: '生产运行', label: '生产运行',
description: '运行任务编排、引擎切换与过程监控', description: '运行任务编排、引擎切换与过程监控。',
}, },
{ {
key: 'dinsar_products', key: 'dinsar_products',
label: '结果管理', label: '结果管理',
description: '结果提取、标准目录发布与产物编目', description: '结果提取、标准目录发布与产品编目。',
}, },
]; ];
@@ -99,11 +99,11 @@ export const PRODUCTION_WORKSPACE_SBAS_VIEWS = [
{ {
key: 'sbas_insar_planning', key: 'sbas_insar_planning',
label: '序列规划', label: '序列规划',
description: '按生产区域发现 SBAS 候选序列,审计覆盖、时序密度、精轨和公共重叠范围。', description: '按生产区域发现 SBAS 候选序列,审查覆盖、时序密度、精轨和公共重叠范围。',
}, },
{ {
key: 'sbas_insar_batches', key: 'sbas_insar_batches',
label: '候选栈与Run', label: '候选栈与 Run',
description: '查看候选序列、Manifest 与已创建的 SBAS 生产 Run。', description: '查看候选序列、Manifest 与已创建的 SBAS 生产 Run。',
}, },
{ {
@@ -119,21 +119,21 @@ export const PRODUCTION_WORKSPACE_SBAS_VIEWS = [
{ {
key: 'sbas_insar_products', key: 'sbas_insar_products',
label: '结果管理', label: '结果管理',
description: 'Gamma SBAS LOS velocity, uncertainty, coverage and monitoring-point product catalog', description: '管理 Gamma SBAS LOS velocity、uncertainty、coverage 和监测点产品目录。',
}, },
]; ];
export const PRODUCTION_WORKSPACE_WORKBENCHES = [ export const PRODUCTION_WORKSPACE_WORKBENCHES = [
{ {
key: 'dinsar_workbench', key: 'dinsar_workbench',
label: 'D-InSAR工作台', label: 'D-InSAR 工作台',
description: '配对规划、候选对与批次、生产准备、生产运行和结果管理集中到一条 D-InSAR 工作流。', description: '将配对规划、候选对与批次、生产准备、生产运行和结果管理集中到一条 D-InSAR 工作流。',
defaultView: 'dinsar_pairing', defaultView: 'dinsar_pairing',
views: PRODUCTION_WORKSPACE_DINSAR_VIEWS, views: PRODUCTION_WORKSPACE_DINSAR_VIEWS,
}, },
{ {
key: 'sbas_workbench', key: 'sbas_workbench',
label: 'SBAS-InSAR工作台', label: 'SBAS-InSAR 工作台',
description: '围绕 SBAS 序列发现、生产执行和结果 catalog 管理组织时序 InSAR 生产。', description: '围绕 SBAS 序列发现、生产执行和结果 catalog 管理组织时序 InSAR 生产。',
defaultView: 'sbas_insar_planning', defaultView: 'sbas_insar_planning',
views: PRODUCTION_WORKSPACE_SBAS_VIEWS, views: PRODUCTION_WORKSPACE_SBAS_VIEWS,
@@ -145,18 +145,18 @@ export const PRODUCTION_WORKSPACE_VIEWS = [
...PRODUCTION_WORKSPACE_SBAS_VIEWS, ...PRODUCTION_WORKSPACE_SBAS_VIEWS,
{ {
key: 'lt1_production', key: 'lt1_production',
label: '陆探生产占位', label: '陆探一生产占位',
description: 'LT-1 源压缩包本机登记,按需 materialize 到 Task_Pool;D-InSAR/SBAS 生产不走 UNC。', description: 'LT-1 源压缩包本机登记,按需 materialize 到 Task_Pool;D-InSAR/SBAS 生产不走 UNC。',
}, },
{ {
key: 'sentinel1_production', key: 'sentinel1_production',
label: '哨兵生产占位', label: 'Sentinel-1 生产占位',
description: 'Sentinel-1 ZIP/SAFE 与 EOF 精轨本机管理,按需解包;当前 SBAS 仅保留规划能力。', description: 'Sentinel-1 ZIP/SAFE 与 EOF 精轨本机管理,按需解包;当前 SBAS 仅保留规划能力。',
}, },
{ {
key: 'gf3_native_registration', key: 'gf3_native_registration',
label: '高分三结果登记', label: '高分三结果登记',
description: 'GF3 在外部 SARscape 服务器生产,本机只登记复制回来的 _geo 二进制并生成 WebP。', description: 'GF3 在外部 SARscape 服务生产,本机只登记复制回来的 _geo 二进制并生成 WebP。',
}, },
]; ];
@@ -179,10 +179,12 @@ export const PRODUCTION_WORKSPACE_ROUTE_TABS = new Set([
]); ]);
export const LEFT_GROUP_LABELS = { export const LEFT_GROUP_LABELS = {
data: '数据管理', data: '数据资产',
production_management: '生产管理', production_management: '生产管理',
insar_analysis: 'InSAR形变分析', result_extraction: '结果提取',
flood_analysis: '洪涝灾害分析', insar_analysis: '形变分析',
statistics: '综合统计',
flood_analysis: '灾害分析',
ops: '运行维护', ops: '运行维护',
}; };
@@ -190,12 +192,12 @@ export const LEFT_GROUP_SECTIONS = {
insar_analysis: [ insar_analysis: [
{ {
key: 'dinsar', key: 'dinsar',
label: 'D-InSAR', label: 'D-InSAR 判读',
tabs: ['dinsar_results', 'dinsar_analysis'], tabs: ['dinsar_results', 'dinsar_analysis'],
}, },
{ {
key: 'psinsar', key: 'psinsar',
label: 'SBAS', label: 'SBAS 形变',
tabs: ['psinsar_analysis'], tabs: ['psinsar_analysis'],
}, },
], ],
@@ -204,7 +206,9 @@ export const LEFT_GROUP_SECTIONS = {
export const LEFT_GROUP_TABS = { export const LEFT_GROUP_TABS = {
data: ['ingest', 'asset_inventory', 'data', 'hazard'], data: ['ingest', 'asset_inventory', 'data', 'hazard'],
production_management: [PRODUCTION_WORKSPACE_TAB], production_management: [PRODUCTION_WORKSPACE_TAB],
result_extraction: ['result_extraction'],
insar_analysis: LEFT_GROUP_SECTIONS.insar_analysis.flatMap(section => section.tabs), insar_analysis: LEFT_GROUP_SECTIONS.insar_analysis.flatMap(section => section.tabs),
statistics: ['statistics'],
flood_analysis: ['flood_analysis'], flood_analysis: ['flood_analysis'],
ops: ['health', 'users', 'audit'], ops: ['health', 'users', 'audit'],
}; };
@@ -231,6 +235,8 @@ export const LEFT_TAB_SECTION = Object.entries(LEFT_GROUP_SECTIONS).reduce((acc,
export const FULL_WIDTH_LEFT_TABS = new Set([ export const FULL_WIDTH_LEFT_TABS = new Set([
...PRODUCTION_WORKSPACE_ROUTE_TABS, ...PRODUCTION_WORKSPACE_ROUTE_TABS,
'statistics',
'result_extraction',
]); ]);
export const ADMIN_ONLY_TABS = new Set([ export const ADMIN_ONLY_TABS = new Set([
@@ -271,7 +277,6 @@ export const RADAR_SEARCH_DEFAULTS = {
product_unique_id: '', product_unique_id: '',
orbit_direction: '', orbit_direction: '',
has_orbit_data: '', has_orbit_data: '',
is_envi_processed: '',
imaging_date_from: '', imaging_date_from: '',
imaging_date_to: '', imaging_date_to: '',
}; };
+7 -7
View File
@@ -4,16 +4,16 @@ const TASK_UI_POLICIES = {
DINSAR_RESULT_SCAN: { label: 'D-InSAR 结果扫描', featureScope: 'dinsar_products' }, DINSAR_RESULT_SCAN: { label: 'D-InSAR 结果扫描', featureScope: 'dinsar_products' },
AI_TRAIN: { label: '训练 AI 模型', featureScope: 'insar_analysis' }, AI_TRAIN: { label: '训练 AI 模型', featureScope: 'insar_analysis' },
AI_PREDICT: { label: '全量质量评估', featureScope: 'insar_analysis' }, AI_PREDICT: { label: '全量质量评估', featureScope: 'insar_analysis' },
AI_ANALYZE: { label: 'AI 智能诊断(旧)', featureScope: 'insar_analysis' }, AI_ANALYZE: { label: 'AI 智能诊断', featureScope: 'insar_analysis' },
AI_DIAGNOSIS: { label: 'D-InSAR诊断', featureScope: 'insar_analysis' }, AI_DIAGNOSIS: { label: 'D-InSAR诊断', featureScope: 'insar_analysis' },
AI_WARMUP: { label: 'AI 模型预热', featureScope: 'insar_analysis' }, AI_WARMUP: { label: 'AI 模型预热', featureScope: 'insar_analysis' },
COPY_DATA: { label: '数据分发拷贝', featureScope: 'data_monitor' }, COPY_DATA: { label: '数据分发拷贝', featureScope: 'data_monitor' },
SCAN_HAZARD: { label: '灾害点同步', featureScope: 'hazard' }, SCAN_HAZARD: { label: '灾害点同步', featureScope: 'hazard' },
UNPACK_ARCHIVES: { label: 'LT-1 批量解包(旧)', featureScope: 'data_monitor' }, UNPACK_ARCHIVES: { label: 'LT-1 批量解包', featureScope: 'data_monitor' },
UNPACK_SENTINEL1: { label: 'Sentinel-1 批量解包(旧)', featureScope: 'data_monitor' }, UNPACK_SENTINEL1: { label: 'Sentinel-1 批量解包', featureScope: 'data_monitor' },
GF3_UNPACK: { label: 'GF3 legacy 解包', featureScope: 'data_monitor' }, GF3_UNPACK: { label: 'GF3 数据解包', featureScope: 'data_monitor' },
GF3_BATCH_PROCESS: { label: 'GF3 legacy 预处理', featureScope: 'data_monitor' }, GF3_BATCH_PROCESS: { label: 'GF3 数据预处理', featureScope: 'data_monitor' },
GF3_SARSCAPE_PRODUCE: { label: 'GF3 SARscape 生产(停用)', featureScope: 'data_monitor' }, GF3_SARSCAPE_PRODUCE: { label: 'GF3 SARscape 生产', featureScope: 'data_monitor' },
GF3_SARSCAPE_SYNC: { label: 'GF3 _geo 原生入库', featureScope: 'data_monitor' }, GF3_SARSCAPE_SYNC: { label: 'GF3 _geo 原生入库', featureScope: 'data_monitor' },
GF3_QUICKLOOK_WEBP: { label: 'GF3 _geo WebP', featureScope: 'data_monitor' }, GF3_QUICKLOOK_WEBP: { label: 'GF3 _geo WebP', featureScope: 'data_monitor' },
GF3_SARSCAPE_CLEAN: { label: 'GF3 中间清理', featureScope: 'data_monitor' }, GF3_SARSCAPE_CLEAN: { label: 'GF3 中间清理', featureScope: 'data_monitor' },
@@ -22,7 +22,7 @@ const TASK_UI_POLICIES = {
IDL_IMPORT: { label: 'ENVI 数据导入', featureScope: 'dinsar_production' }, IDL_IMPORT: { label: 'ENVI 数据导入', featureScope: 'dinsar_production' },
IDL_DINSAR: { label: 'ENVI D-InSAR 生产', featureScope: 'dinsar_production' }, IDL_DINSAR: { label: 'ENVI D-InSAR 生产', featureScope: 'dinsar_production' },
IDL_RUN_DINSAR: { label: 'ENVI D-InSAR 生产', featureScope: 'dinsar_production' }, IDL_RUN_DINSAR: { label: 'ENVI D-InSAR 生产', featureScope: 'dinsar_production' },
ISCE2_RUN: { label: 'ISCE2 D-InSAR 生产', featureScope: 'dinsar_production' }, ISCE2_RUN: { label: 'D-InSAR 历史任务', featureScope: 'dinsar_production' },
PYINT_RUN: { label: 'PyINT D-InSAR 生产', featureScope: 'dinsar_production' }, PYINT_RUN: { label: 'PyINT D-InSAR 生产', featureScope: 'dinsar_production' },
LANDSAR_RUN: { label: 'LandSAR D-InSAR 生产', featureScope: 'dinsar_production' }, LANDSAR_RUN: { label: 'LandSAR D-InSAR 生产', featureScope: 'dinsar_production' },
SBAS_GAMMA_WORKFLOW: { label: 'Gamma SBAS 工作流', featureScope: 'sbas_insar' }, SBAS_GAMMA_WORKFLOW: { label: 'Gamma SBAS 工作流', featureScope: 'sbas_insar' },
+10 -3
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from 'react';
import apiClient from '../api/client'; import apiClient from '../api/client';
import { getHealth } from '../api/health'; import { getHealth } from '../api/health';
const HEALTH_POLL_INTERVAL_MS = 30000; const HEALTH_POLL_INTERVAL_MS = 5 * 60 * 1000;
const STARTUP_RETRY_DELAYS_MS = [750, 1500, 3000]; const STARTUP_RETRY_DELAYS_MS = [750, 1500, 3000];
const sleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)); const sleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
@@ -95,6 +95,8 @@ export default function useAppAuthLifecycle({
if (userFromLogin) { if (userFromLogin) {
setCurrentUser(userFromLogin); setCurrentUser(userFromLogin);
setAuthChecked(true); setAuthChecked(true);
void fetchCurrentUser({ clearOnFailure: false });
return;
} }
await fetchCurrentUser({ clearOnFailure: !userFromLogin }); await fetchCurrentUser({ clearOnFailure: !userFromLogin });
}, [fetchCurrentUser, setCurrentUser, setAuthChecked]); }, [fetchCurrentUser, setCurrentUser, setAuthChecked]);
@@ -236,11 +238,16 @@ export default function useAppAuthLifecycle({
}, [fetchCurrentUser, fetchLicenseStatus]); }, [fetchCurrentUser, fetchLicenseStatus]);
useEffect(() => { useEffect(() => {
void fetchHealthStatus(); const startupTimer = setTimeout(() => {
void fetchHealthStatus({ silent: true });
}, 1500);
const interval = setInterval(() => { const interval = setInterval(() => {
void fetchHealthStatus({ silent: true }); void fetchHealthStatus({ silent: true });
}, HEALTH_POLL_INTERVAL_MS); }, HEALTH_POLL_INTERVAL_MS);
return () => clearInterval(interval); return () => {
clearTimeout(startupTimer);
clearInterval(interval);
};
}, [fetchHealthStatus]); }, [fetchHealthStatus]);
return { return {
+3 -1
View File
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import apiClient from '../api/client'; import apiClient from '../api/client';
import { normalizeTaskStatus } from '../utils/appUiHelpers'; import { normalizeTaskStatus } from '../utils/appUiHelpers';
const ACTIVE_TASK_FALLBACK_POLL_MS = 10000;
export default function useGlobalTaskControl({ export default function useGlobalTaskControl({
currentUser, currentUser,
licenseOk, licenseOk,
@@ -135,7 +137,7 @@ export default function useGlobalTaskControl({
es.close(); es.close();
es = null; es = null;
if (!fallbackInterval) { if (!fallbackInterval) {
fallbackInterval = setInterval(syncActiveTasks, 5000); fallbackInterval = setInterval(syncActiveTasks, ACTIVE_TASK_FALLBACK_POLL_MS);
} }
}; };
}; };
-55
View File
@@ -1,55 +0,0 @@
import { useCallback, useEffect } from 'react';
import { clamp } from '../utils/appUiHelpers';
export default function usePanelResize({
isResizing,
setIsResizing,
leftPanelWidth,
rightPanelWidth,
setLeftPanelWidth,
setRightPanelWidth,
resizeStateRef,
}) {
const startResize = useCallback((side, event) => {
event.preventDefault();
resizeStateRef.current = {
side,
startX: event.clientX,
startLeft: leftPanelWidth,
startRight: rightPanelWidth,
};
setIsResizing(true);
}, [resizeStateRef, leftPanelWidth, rightPanelWidth, setIsResizing]);
useEffect(() => {
if (!isResizing) {
return;
}
const handleMove = (event) => {
const { side, startX, startLeft, startRight } = resizeStateRef.current;
const delta = event.clientX - startX;
if (side === 'left') {
setLeftPanelWidth(clamp(startLeft + delta, 320, 620));
} else if (side === 'right') {
setRightPanelWidth(clamp(startRight - delta, 280, 560));
}
};
const handleUp = () => {
setIsResizing(false);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
return () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleUp);
};
}, [isResizing, resizeStateRef, setLeftPanelWidth, setRightPanelWidth, setIsResizing]);
return {
startResize,
};
}
+13 -192
View File
@@ -1,205 +1,26 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { I18nContext } from './I18nContext'; import { I18nContext } from './I18nContext';
import { SUPPORTED_LANGUAGES, translateText } from './translations';
const STORAGE_KEY = 'ims_ui_language'; const FIXED_LANGUAGE = 'zh';
const isSupportedLanguage = (value) => SUPPORTED_LANGUAGES.includes(value);
const getInitialLanguage = () => {
const stored = localStorage.getItem(STORAGE_KEY);
if (isSupportedLanguage(stored)) return stored;
return 'zh';
};
const shouldSkipNode = (node) => {
if (!node || !node.parentElement) return true;
const parent = node.parentElement;
if (parent.closest('[data-no-i18n="true"]')) return true;
const tag = parent.tagName;
if (!tag) return true;
return tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'CODE' || tag === 'PRE';
};
const getNodeSourceText = (textSourceMap, node) => {
if (!textSourceMap.has(node)) {
textSourceMap.set(node, node.nodeValue ?? '');
}
return textSourceMap.get(node);
};
const setNodeSourceText = (textSourceMap, node, sourceText) => {
if (!node) return;
textSourceMap.set(node, sourceText ?? '');
};
const getAttrSourceMap = (attrSourceMap, element) => {
if (!attrSourceMap.has(element)) {
attrSourceMap.set(element, new Map());
}
return attrSourceMap.get(element);
};
const getAttrSourceText = (attrSourceMap, element, attr, fallbackValue) => {
const elementMap = getAttrSourceMap(attrSourceMap, element);
if (!elementMap.has(attr)) {
elementMap.set(attr, fallbackValue ?? '');
}
return elementMap.get(attr);
};
const setAttrSourceText = (attrSourceMap, element, attr, sourceText) => {
if (!element) return;
const elementMap = getAttrSourceMap(attrSourceMap, element);
elementMap.set(attr, sourceText ?? '');
};
const translateElementAttrs = (element, language, attrSourceMap) => {
if (!element || element.closest('[data-no-i18n="true"]')) return;
['placeholder', 'title', 'aria-label'].forEach((attr) => {
const value = element.getAttribute(attr);
if (!value) return;
const source = getAttrSourceText(attrSourceMap, element, attr, value);
const translated = translateText(source, language);
if (translated !== value) {
element.setAttribute(attr, translated);
}
});
if (element.tagName === 'INPUT') {
const type = (element.getAttribute('type') || '').toLowerCase();
if (type === 'button' || type === 'submit' || type === 'reset') {
const value = element.getAttribute('value');
if (!value) return;
const source = getAttrSourceText(attrSourceMap, element, 'value', value);
const translated = translateText(source, language);
if (translated !== value) {
element.setAttribute('value', translated);
}
}
}
};
const translateSubtree = (root, language, textSourceMap, attrSourceMap) => {
if (!root) return;
if (root.nodeType === Node.ELEMENT_NODE) {
translateElementAttrs(root, language, attrSourceMap);
}
if (root.nodeType === Node.TEXT_NODE) {
if (!shouldSkipNode(root)) {
const source = getNodeSourceText(textSourceMap, root);
const translated = translateText(source, language);
if (translated !== root.nodeValue) {
root.nodeValue = translated;
}
}
return;
}
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let current = walker.nextNode();
while (current) {
if (!shouldSkipNode(current)) {
const source = getNodeSourceText(textSourceMap, current);
const translated = translateText(source, language);
if (translated !== current.nodeValue) {
current.nodeValue = translated;
}
}
current = walker.nextNode();
}
if (root.querySelectorAll) {
root.querySelectorAll('*').forEach((element) => {
translateElementAttrs(element, language, attrSourceMap);
});
}
};
export const I18nProvider = ({ children }) => { export const I18nProvider = ({ children }) => {
const [language, setLanguageState] = useState(getInitialLanguage); const setLanguage = useCallback(() => {
const textSourceMapRef = useRef(new WeakMap()); localStorage.setItem('ims_ui_language', FIXED_LANGUAGE);
const attrSourceMapRef = useRef(new WeakMap());
const setLanguage = useCallback((nextLanguage) => {
if (!isSupportedLanguage(nextLanguage)) return;
setLanguageState(nextLanguage);
}, []); }, []);
const t = useCallback((text) => translateText(text, language), [language]); const t = useCallback((text) => text, []);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, language);
document.documentElement.setAttribute('lang', language === 'en' ? 'en-US' : 'zh-CN');
}, [language]);
useEffect(() => {
if (!document?.body) return undefined;
let isMutating = false;
const safeTranslate = (node) => {
if (!node) return;
isMutating = true;
try {
translateSubtree(node, language, textSourceMapRef.current, attrSourceMapRef.current);
} finally {
isMutating = false;
}
};
safeTranslate(document.body);
const observer = new MutationObserver((mutations) => {
if (isMutating) return;
const roots = new Set();
mutations.forEach((mutation) => {
if (mutation.type === 'characterData') {
setNodeSourceText(textSourceMapRef.current, mutation.target, mutation.target?.nodeValue ?? '');
const parent = mutation.target?.parentElement;
if (parent) roots.add(parent);
}
if (mutation.type === 'attributes' && mutation.attributeName) {
const targetElement = mutation.target;
const attrName = mutation.attributeName;
const attrValue = targetElement.getAttribute(attrName);
setAttrSourceText(attrSourceMapRef.current, targetElement, attrName, attrValue ?? '');
roots.add(targetElement);
}
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
roots.add(node);
} else if (node.nodeType === Node.TEXT_NODE) {
setNodeSourceText(textSourceMapRef.current, node, node.nodeValue ?? '');
if (node.parentElement) {
roots.add(node.parentElement);
}
} else if (node.parentElement) {
roots.add(node.parentElement);
}
});
});
if (roots.size === 0) return;
roots.forEach((node) => safeTranslate(node));
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ['placeholder', 'title', 'aria-label', 'value'],
});
return () => observer.disconnect();
}, [language]);
const value = useMemo(() => ({ const value = useMemo(() => ({
language, language: FIXED_LANGUAGE,
en: false,
setLanguage, setLanguage,
t, t,
}), [language, setLanguage, t]); }), [setLanguage, t]);
useEffect(() => {
localStorage.setItem('ims_ui_language', FIXED_LANGUAGE);
document.documentElement.setAttribute('lang', 'zh-CN');
}, []);
return ( return (
<I18nContext.Provider value={value}> <I18nContext.Provider value={value}>
-4
View File
@@ -61,9 +61,6 @@
{ zh: '有精轨:全部', en: 'Precise Orbit: All' }, { zh: '有精轨:全部', en: 'Precise Orbit: All' },
{ zh: '有精轨:是', en: 'Precise Orbit: Yes' }, { zh: '有精轨:是', en: 'Precise Orbit: Yes' },
{ zh: '有精轨:否', en: 'Precise Orbit: No' }, { zh: '有精轨:否', en: 'Precise Orbit: No' },
{ zh: 'ENVI已处理:全部', en: 'ENVI Processed: All' },
{ zh: 'ENVI已处理:是', en: 'ENVI Processed: Yes' },
{ zh: 'ENVI已处理:否', en: 'ENVI Processed: No' },
{ zh: '源数据检索选项加载中...', en: 'Loading source data search options...' }, { zh: '源数据检索选项加载中...', en: 'Loading source data search options...' },
{ zh: '无源数据', en: 'No source data.' }, { zh: '无源数据', en: 'No source data.' },
@@ -116,7 +113,6 @@
{ zh: '轨道方向:', en: 'Orbit Direction:' }, { zh: '轨道方向:', en: 'Orbit Direction:' },
{ zh: '有精轨:', en: 'Has Orbit:' }, { zh: '有精轨:', en: 'Has Orbit:' },
{ zh: '轨道文件:', en: 'Orbit File:' }, { zh: '轨道文件:', en: 'Orbit File:' },
{ zh: 'ENVI已处理:', en: 'ENVI Processed:' },
{ zh: '关闭', en: 'Close' }, { zh: '关闭', en: 'Close' },
{ zh: '是', en: 'Yes' }, { zh: '是', en: 'Yes' },
Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

+6 -14
View File
@@ -1,10 +1,9 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useDinsarStore, useHazardStore, useUiStore, useAuthStore } from '../store'; import { useDinsarStore, useHazardStore, useUiStore, useAuthStore } from '../store';
import { useI18n } from '../i18n/I18nContext'; import { useI18n } from '../i18n/I18nContext';
import VirtualizedList from '../components/common/VirtualizedList'; import VirtualizedList from '../components/common/VirtualizedList';
import DinsarResultRow from '../components/panels/DinsarResultRow'; import DinsarResultRow from '../components/panels/DinsarResultRow';
import ResultExportModal from '../components/ResultExportModal';
import { PAGE_SIZE_OPTIONS } from '../config/appConstants'; import { PAGE_SIZE_OPTIONS } from '../config/appConstants';
import { getPageHintText } from '../utils/appUiHelpers'; import { getPageHintText } from '../utils/appUiHelpers';
import { import {
@@ -36,6 +35,7 @@ export default function DinsarResultPanel({
onToggleVisibility, onToggleVisibility,
onLabel, onLabel,
onAnalyze, onAnalyze,
onOpenResultExtraction,
}) { }) {
const { language } = useI18n(); const { language } = useI18n();
const { const {
@@ -76,8 +76,6 @@ export default function DinsarResultPanel({
}))); })));
const { currentUser } = useAuthStore(); const { currentUser } = useAuthStore();
const isReadOnlyUser = !!currentUser && currentUser.role !== 'admin'; const isReadOnlyUser = !!currentUser && currentUser.role !== 'admin';
const [showExportModal, setShowExportModal] = useState(false);
const strategyOptions = useMemo( const strategyOptions = useMemo(
() => buildDinsarStrategyOptions(dinsarResults), () => buildDinsarStrategyOptions(dinsarResults),
[dinsarResults] [dinsarResults]
@@ -261,11 +259,11 @@ export default function DinsarResultPanel({
: (language === 'en' ? 'Show Dates' : '显示日期')} : (language === 'en' ? 'Show Dates' : '显示日期')}
</button> </button>
<button <button
onClick={() => setShowExportModal(true)} onClick={onOpenResultExtraction}
disabled={isLoading || filteredResults.length === 0} disabled={isLoading || !onOpenResultExtraction}
title={language === 'en' title={language === 'en'
? 'Export visible results in the current filter scope' ? 'Open the unified result extraction workspace'
: '按当前筛选范围导出结果文件'} : '进入统一结果提取工作台'}
> >
{language === 'en' ? 'Export...' : '提取结果...'} {language === 'en' ? 'Export...' : '提取结果...'}
</button> </button>
@@ -450,12 +448,6 @@ export default function DinsarResultPanel({
</> </>
)} )}
{showExportModal && (
<ResultExportModal
results={filteredResults}
onClose={() => setShowExportModal(false)}
/>
)}
</div> </div>
); );
} }
+46 -67
View File
@@ -15,9 +15,7 @@ export default function RadarDataPanel({
radarTotalPages, radarTotalPages,
showRadarPageInputError, showRadarPageInputError,
radarPageInputValidationError, radarPageInputValidationError,
// handlers
onSearchAll, onSearchAll,
onShowStats,
onSearch, onSearch,
onReset, onReset,
onAoiModeChange, onAoiModeChange,
@@ -73,7 +71,6 @@ export default function RadarDataPanel({
[allData] [allData]
); );
// Build visible satellite group buttons based on available satellites in the database
const visibleSatelliteGroups = useMemo(() => { const visibleSatelliteGroups = useMemo(() => {
const allSatellites = radarSearchOptions.satellite || []; const allSatellites = radarSearchOptions.satellite || [];
return SATELLITE_GROUPS.filter((group) => return SATELLITE_GROUPS.filter((group) =>
@@ -86,15 +83,12 @@ export default function RadarDataPanel({
<div style={{ padding: '10px', borderBottom: '1px solid #eee', flex: '0 0 auto' }}> <div style={{ padding: '10px', borderBottom: '1px solid #eee', flex: '0 0 auto' }}>
<div className="header-buttons"> <div className="header-buttons">
<button onClick={onSearchAll} disabled={isLoading} style={{ flex: 1 }}> <button onClick={onSearchAll} disabled={isLoading} style={{ flex: 1 }}>
{language === 'en' ? 'Search All Source Data' : '搜索全部源数据'} 搜索全部源数据
</button>
<button onClick={onShowStats} disabled={isLoading} style={{ width: 'auto' }}>
{language === 'en' ? 'Statistics' : '统计'}
</button> </button>
</div> </div>
<div style={{ marginTop: '10px', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '10px', background: '#f8fafc' }}> <div style={{ marginTop: '10px', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '10px', background: '#f8fafc' }}>
<div style={{ fontWeight: 600, marginBottom: '8px', fontSize: '13px' }}> <div style={{ fontWeight: 600, marginBottom: '8px', fontSize: '13px' }}>
{language === 'en' ? 'Source Data Search' : '源数据检索'} 源数据检索
</div> </div>
{visibleSatelliteGroups.length > 0 && ( {visibleSatelliteGroups.length > 0 && (
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}> <div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
@@ -114,7 +108,7 @@ export default function RadarDataPanel({
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
{language === 'en' ? 'All' : '全部'} 全部
</button> </button>
{visibleSatelliteGroups.map((group) => ( {visibleSatelliteGroups.map((group) => (
<button <button
@@ -141,7 +135,7 @@ export default function RadarDataPanel({
)} )}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
<select value={radarSearchDraft.imaging_mode} onChange={(e) => updateDraft('imaging_mode', e.target.value)} disabled={radarSearchOptionsLoading}> <select value={radarSearchDraft.imaging_mode} onChange={(e) => updateDraft('imaging_mode', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Imaging Mode: All' : '成像模式:全部'}</option> <option value="">成像模式:全部</option>
{radarSearchOptions.imaging_mode.map((item) => ( {radarSearchOptions.imaging_mode.map((item) => (
<option key={item} value={item}>{item}</option> <option key={item} value={item}>{item}</option>
))} ))}
@@ -150,28 +144,28 @@ export default function RadarDataPanel({
value={radarSearchDraft.imaging_date_from} value={radarSearchDraft.imaging_date_from}
onChange={(nextValue) => updateDraft('imaging_date_from', nextValue)} onChange={(nextValue) => updateDraft('imaging_date_from', nextValue)}
language={language} language={language}
title={language === 'en' ? 'Imaging Date From: Any' : '成像时间起:不限'} title="成像时间起:不限"
ariaLabel={language === 'en' ? 'Imaging Date From' : '成像时间起'} ariaLabel="成像时间起"
placeholder={language === 'en' ? 'Select start date' : '选择起始日期'} placeholder="选择起始日期"
/> />
<UnifiedDatePicker <UnifiedDatePicker
value={radarSearchDraft.imaging_date_to} value={radarSearchDraft.imaging_date_to}
onChange={(nextValue) => updateDraft('imaging_date_to', nextValue)} onChange={(nextValue) => updateDraft('imaging_date_to', nextValue)}
language={language} language={language}
title={language === 'en' ? 'Imaging Date To: Any' : '成像时间止:不限'} title="成像时间止:不限"
ariaLabel={language === 'en' ? 'Imaging Date To' : '成像时间止'} ariaLabel="成像时间止"
placeholder={language === 'en' ? 'Select end date' : '选择结束日期'} placeholder="选择结束日期"
/> />
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
<select value={radarSearchDraft.polarization} onChange={(e) => updateDraft('polarization', e.target.value)} disabled={radarSearchOptionsLoading}> <select value={radarSearchDraft.polarization} onChange={(e) => updateDraft('polarization', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Polarization: All' : '极化方式:全部'}</option> <option value="">极化方式:全部</option>
{radarSearchOptions.polarization.map((item) => ( {radarSearchOptions.polarization.map((item) => (
<option key={item} value={item}>{item}</option> <option key={item} value={item}>{item}</option>
))} ))}
</select> </select>
<select value={radarSearchDraft.product_level} onChange={(e) => updateDraft('product_level', e.target.value)} disabled={radarSearchOptionsLoading}> <select value={radarSearchDraft.product_level} onChange={(e) => updateDraft('product_level', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Product Level: All' : '产品级别:全部'}</option> <option value="">产品级别:全部</option>
{radarSearchOptions.product_level.map((item) => ( {radarSearchOptions.product_level.map((item) => (
<option key={item} value={item}>{item}</option> <option key={item} value={item}>{item}</option>
))} ))}
@@ -179,16 +173,16 @@ export default function RadarDataPanel({
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
<select value={radarSearchAoiMode} onChange={(e) => onAoiModeChange(e.target.value)}> <select value={radarSearchAoiMode} onChange={(e) => onAoiModeChange(e.target.value)}>
<option value="none">{language === 'en' ? 'AOI: Any' : '空间范围:不限'}</option> <option value="none">空间范围:不限</option>
<option value="region">{language === 'en' ? 'AOI: Region' : '空间范围:行政区'}</option> <option value="region">空间范围:行政区</option>
<option value="shp">{language === 'en' ? 'AOI: Upload SHP' : '空间范围:上传SHP'}</option> <option value="shp">空间范围:上传 SHP</option>
</select> </select>
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<button type="button" onClick={onSearch} disabled={isLoading} style={{ flex: 1 }}> <button type="button" onClick={onSearch} disabled={isLoading} style={{ flex: 1 }}>
{language === 'en' ? 'Search' : '搜索'} 搜索
</button> </button>
<button type="button" onClick={onReset} disabled={isLoading} style={{ flex: 1 }}> <button type="button" onClick={onReset} disabled={isLoading} style={{ flex: 1 }}>
{language === 'en' ? 'Reset' : '重置'} 重置
</button> </button>
</div> </div>
</div> </div>
@@ -199,7 +193,7 @@ export default function RadarDataPanel({
onChange={(e) => onProvinceChange(e.target.value)} onChange={(e) => onProvinceChange(e.target.value)}
disabled={radarSearchRegionLoading} disabled={radarSearchRegionLoading}
> >
<option value="">{language === 'en' ? 'Select Province' : '选择省份'}</option> <option value="">选择省份</option>
{radarSearchRegionOptions.provinces.map((item) => ( {radarSearchRegionOptions.provinces.map((item) => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option> <option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))} ))}
@@ -209,7 +203,7 @@ export default function RadarDataPanel({
onChange={(e) => onCityChange(e.target.value)} onChange={(e) => onCityChange(e.target.value)}
disabled={radarSearchRegionLoading || !radarSearchRegionSelection.province} disabled={radarSearchRegionLoading || !radarSearchRegionSelection.province}
> >
<option value="">{language === 'en' ? 'Select City (Optional)' : '选择地市(可选)'}</option> <option value="">选择地市(可选)</option>
{radarSearchRegionOptions.cities.map((item) => ( {radarSearchRegionOptions.cities.map((item) => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option> <option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))} ))}
@@ -231,66 +225,61 @@ export default function RadarDataPanel({
)} )}
<details> <details>
<summary style={{ cursor: 'pointer', fontSize: '12px', color: '#334155' }}> <summary style={{ cursor: 'pointer', fontSize: '12px', color: '#334155' }}>
{language === 'en' ? 'Advanced Fields' : '高级字段'} 高级字段
</summary> </summary>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginTop: '8px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginTop: '8px' }}>
<select value={radarSearchDraft.satellite_mode} onChange={(e) => updateDraft('satellite_mode', e.target.value)} disabled={radarSearchOptionsLoading}> <select value={radarSearchDraft.satellite_mode} onChange={(e) => updateDraft('satellite_mode', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Satellite Mode: All' : '卫星模式:全部'}</option> <option value="">卫星模式:全部</option>
{radarSearchOptions.satellite_mode.map((item) => ( {radarSearchOptions.satellite_mode.map((item) => (
<option key={item} value={item}>{item}</option> <option key={item} value={item}>{item}</option>
))} ))}
</select> </select>
<select value={radarSearchDraft.receiving_station} onChange={(e) => updateDraft('receiving_station', e.target.value)} disabled={radarSearchOptionsLoading}> <select value={radarSearchDraft.receiving_station} onChange={(e) => updateDraft('receiving_station', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Receiving Station: All' : '接收站:全部'}</option> <option value="">接收站:全部</option>
{radarSearchOptions.receiving_station.map((item) => ( {radarSearchOptions.receiving_station.map((item) => (
<option key={item} value={item}>{item}</option> <option key={item} value={item}>{item}</option>
))} ))}
</select> </select>
<select value={radarSearchDraft.orbit_circle} onChange={(e) => updateDraft('orbit_circle', e.target.value)} disabled={radarSearchOptionsLoading}> <select value={radarSearchDraft.orbit_circle} onChange={(e) => updateDraft('orbit_circle', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Orbit Circle: All' : '轨道圈号:全部'}</option> <option value="">轨道圈号:全部</option>
{radarSearchOptions.orbit_circle.map((item) => ( {radarSearchOptions.orbit_circle.map((item) => (
<option key={item} value={item}>{item}</option> <option key={item} value={item}>{item}</option>
))} ))}
</select> </select>
<select value={radarSearchDraft.acquisition_time_utc} onChange={(e) => updateDraft('acquisition_time_utc', e.target.value)} disabled={radarSearchOptionsLoading}> <select value={radarSearchDraft.acquisition_time_utc} onChange={(e) => updateDraft('acquisition_time_utc', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Acquisition Time: All' : '采集时间:全部'}</option> <option value="">采集时间:全部</option>
{radarSearchOptions.acquisition_time_utc.map((item) => ( {radarSearchOptions.acquisition_time_utc.map((item) => (
<option key={item} value={item}>{item}</option> <option key={item} value={item}>{item}</option>
))} ))}
</select> </select>
<select value={radarSearchDraft.product_type} onChange={(e) => updateDraft('product_type', e.target.value)} disabled={radarSearchOptionsLoading}> <select value={radarSearchDraft.product_type} onChange={(e) => updateDraft('product_type', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Product Type: All' : '产品类型:全部'}</option> <option value="">产品类型:全部</option>
{radarSearchOptions.product_type.map((item) => ( {radarSearchOptions.product_type.map((item) => (
<option key={item} value={item}>{item}</option> <option key={item} value={item}>{item}</option>
))} ))}
</select> </select>
<select value={radarSearchDraft.product_unique_id} onChange={(e) => updateDraft('product_unique_id', e.target.value)} disabled={radarSearchOptionsLoading}> <select value={radarSearchDraft.product_unique_id} onChange={(e) => updateDraft('product_unique_id', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Product Unique ID: All' : '产品唯一ID:全部'}</option> <option value="">产品唯一 ID:全部</option>
{radarSearchOptions.product_unique_id.map((item) => ( {radarSearchOptions.product_unique_id.map((item) => (
<option key={item} value={item}>{item}</option> <option key={item} value={item}>{item}</option>
))} ))}
</select> </select>
<select value={radarSearchDraft.orbit_direction} onChange={(e) => updateDraft('orbit_direction', e.target.value)} disabled={radarSearchOptionsLoading}> <select value={radarSearchDraft.orbit_direction} onChange={(e) => updateDraft('orbit_direction', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Orbit Direction: All' : '轨道方向:全部'}</option> <option value="">轨道方向:全部</option>
{radarSearchOptions.orbit_direction.map((item) => ( {radarSearchOptions.orbit_direction.map((item) => (
<option key={item} value={item}>{item}</option> <option key={item} value={item}>{item}</option>
))} ))}
</select> </select>
<select value={radarSearchDraft.has_orbit_data} onChange={(e) => updateDraft('has_orbit_data', e.target.value)}> <select value={radarSearchDraft.has_orbit_data} onChange={(e) => updateDraft('has_orbit_data', e.target.value)}>
<option value="">{language === 'en' ? 'Precise Orbit: All' : '有精轨:全部'}</option> <option value="">精轨状态:全部</option>
<option value="true">{language === 'en' ? 'Precise Orbit: Yes' : '有精轨:是'}</option> <option value="true">精轨状态:有</option>
<option value="false">{language === 'en' ? 'Precise Orbit: No' : '有精轨:否'}</option> <option value="false">精轨状态:无</option>
</select>
<select value={radarSearchDraft.is_envi_processed} onChange={(e) => updateDraft('is_envi_processed', e.target.value)}>
<option value="">{language === 'en' ? 'ENVI Processed: All' : 'ENVI已处理:全部'}</option>
<option value="true">{language === 'en' ? 'ENVI Processed: Yes' : 'ENVI已处理:是'}</option>
<option value="false">{language === 'en' ? 'ENVI Processed: No' : 'ENVI已处理:否'}</option>
</select> </select>
</div> </div>
</details> </details>
{radarSearchOptionsLoading && ( {radarSearchOptionsLoading && (
<div style={{ marginTop: '8px', fontSize: '12px', color: '#64748b' }}> <div style={{ marginTop: '8px', fontSize: '12px', color: '#64748b' }}>
{language === 'en' ? 'Loading source data search options...' : '源数据检索选项加载中...'} 源数据检索选项加载中...
</div> </div>
)} )}
</div> </div>
@@ -300,12 +289,8 @@ export default function RadarDataPanel({
<div className="empty-state"> <div className="empty-state">
<p> <p>
{!hasRadarSearched {!hasRadarSearched
? (language === 'en' ? '尚未执行检索,请先点击“搜索”或“搜索全部源数据”。'
? 'No query yet. Please run Search or Search All first.' : '当前检索条件下没有匹配的数据。'}
: '尚未执行检索,请先点击"搜索"或"搜索全部"。')
: (language === 'en'
? 'No data matched this query.'
: '当前检索未命中数据。')}
</p> </p>
</div> </div>
) : ( ) : (
@@ -319,35 +304,31 @@ export default function RadarDataPanel({
onChange={onSelectAllVisibility} onChange={onSelectAllVisibility}
/> />
<label htmlFor="select-all-visibility"> <label htmlFor="select-all-visibility">
{language === 'en' 覆盖面全部显示/隐藏(当前页 {allData.length} 条 / 总计 {radarPagination.total} 条)
? `Toggle all coverage (Current page ${allData.length} items / Total ${radarPagination.total} items)`
: `覆盖面全部显示/隐藏(当前页 ${allData.length} 条 / 总计 ${radarPagination.total} 条)`}
</label> </label>
</div> </div>
<div className="toolbar-row"> <div className="toolbar-row">
<button type="button" onClick={() => onSetAllPreviewVisibility(true)} disabled={allData.length === 0}> <button type="button" onClick={() => onSetAllPreviewVisibility(true)} disabled={allData.length === 0}>
{language === 'en' ? 'Show All Source Previews' : '源影像一键显示'} 源影像一键显示
</button> </button>
<button type="button" onClick={() => onSetAllPreviewVisibility(false)} disabled={allData.length === 0}> <button type="button" onClick={() => onSetAllPreviewVisibility(false)} disabled={allData.length === 0}>
{language === 'en' ? 'Hide All Source Previews' : '源影像一键隐藏'} 源影像一键隐藏
</button> </button>
</div> </div>
<div className="toolbar-row"> <div className="toolbar-row">
<button type="button" onClick={() => onPageChange(-1)} disabled={isLoading || !hasRadarSearched || radarPagination.offset <= 0}> <button type="button" onClick={() => onPageChange(-1)} disabled={isLoading || !hasRadarSearched || radarPagination.offset <= 0}>
{language === 'en' ? 'Previous' : '上一页'} 上一页
</button> </button>
<span style={{ fontSize: '12px', color: '#4a5568' }}> <span style={{ fontSize: '12px', color: '#4a5568' }}>
{language === 'en' 第 {radarCurrentPage}/{radarTotalPages} 页
? `Page ${radarCurrentPage}/${radarTotalPages}`
: `第 ${radarCurrentPage}/${radarTotalPages} 页`}
</span> </span>
<button type="button" onClick={() => onPageChange(1)} disabled={isLoading || !hasRadarSearched || !radarPagination.hasMore}> <button type="button" onClick={() => onPageChange(1)} disabled={isLoading || !hasRadarSearched || !radarPagination.hasMore}>
{language === 'en' ? 'Next' : '下一页'} 下一页
</button> </button>
</div> </div>
<div className="toolbar-row"> <div className="toolbar-row">
<label style={{ fontSize: '12px', color: '#4a5568' }}> <label style={{ fontSize: '12px', color: '#4a5568' }}>
{language === 'en' ? 'Per page' : '每页'} 每页
<select <select
value={radarPagination.limit} value={radarPagination.limit}
onChange={onPageSizeChange} onChange={onPageSizeChange}
@@ -358,10 +339,10 @@ export default function RadarDataPanel({
<option key={size} value={size}>{size}</option> <option key={size} value={size}>{size}</option>
))} ))}
</select> </select>
{language === 'en' ? 'items' : '条'} 条
</label> </label>
<label style={{ fontSize: '12px', color: '#4a5568' }}> <label style={{ fontSize: '12px', color: '#4a5568' }}>
{language === 'en' ? 'Go to' : '跳到'} 跳到
<input <input
type="number" type="number"
min={1} min={1}
@@ -387,10 +368,10 @@ export default function RadarDataPanel({
boxShadow: showRadarPageInputError ? '0 0 0 1px rgba(229,62,62,0.25)' : undefined, boxShadow: showRadarPageInputError ? '0 0 0 1px rgba(229,62,62,0.25)' : undefined,
}} }}
/> />
{language === 'en' ? 'page' : '页'} 页
</label> </label>
<button type="button" onClick={onGoToPage} disabled={isLoading || !hasRadarSearched}> <button type="button" onClick={onGoToPage} disabled={isLoading || !hasRadarSearched}>
{language === 'en' ? 'Jump' : '跳转'} 跳转
</button> </button>
</div> </div>
<div className="toolbar-row"> <div className="toolbar-row">
@@ -398,10 +379,8 @@ export default function RadarDataPanel({
{showRadarPageInputError {showRadarPageInputError
? radarPageInputValidationError ? radarPageInputValidationError
: (hasRadarSearched : (hasRadarSearched
? getPageHintText(radarTotalPages, language) ? getPageHintText(radarTotalPages)
: (language === 'en' : '请先执行检索,再使用分页。')}
? 'Run a search first to enable pagination.'
: '请先执行检索,再使用分页。'))}
</span> </span>
</div> </div>
</div> </div>
+1 -1
View File
@@ -9,7 +9,7 @@ const RADAR_SEARCH_DEFAULTS = {
satellite: '', satellite_mode: '', receiving_station: '', imaging_mode: '', satellite: '', satellite_mode: '', receiving_station: '', imaging_mode: '',
orbit_circle: '', acquisition_time_utc: '', product_type: '', polarization: '', orbit_circle: '', acquisition_time_utc: '', product_type: '', polarization: '',
product_level: '', product_unique_id: '', orbit_direction: '', product_level: '', product_unique_id: '', orbit_direction: '',
has_orbit_data: '', is_envi_processed: '', imaging_date_from: '', imaging_date_to: '', has_orbit_data: '', imaging_date_from: '', imaging_date_to: '',
}; };
const RADAR_SEARCH_OPTIONS_DEFAULTS = { const RADAR_SEARCH_OPTIONS_DEFAULTS = {
+1 -7
View File
@@ -7,10 +7,7 @@ const s = (set, key) => (v) =>
export const useUiStore = create((set) => ({ export const useUiStore = create((set) => ({
leftPanelTab: 'ingest', leftPanelTab: 'ingest',
leftPanelWidth: 380, leftPanelWidth: 620,
rightPanelWidth: 360,
isResizing: false,
showStats: false,
showDataInfo: false, showDataInfo: false,
selectedDataInfo: null, selectedDataInfo: null,
showDates: false, showDates: false,
@@ -19,9 +16,6 @@ export const useUiStore = create((set) => ({
logs: [], logs: [],
setLeftPanelTab: s(set, 'leftPanelTab'), setLeftPanelTab: s(set, 'leftPanelTab'),
setLeftPanelWidth: s(set, 'leftPanelWidth'), setLeftPanelWidth: s(set, 'leftPanelWidth'),
setRightPanelWidth: s(set, 'rightPanelWidth'),
setIsResizing: s(set, 'isResizing'),
setShowStats: s(set, 'showStats'),
setShowDataInfo: s(set, 'showDataInfo'), setShowDataInfo: s(set, 'showDataInfo'),
setSelectedDataInfo: s(set, 'selectedDataInfo'), setSelectedDataInfo: s(set, 'selectedDataInfo'),
setShowDates: s(set, 'showDates'), setShowDates: s(set, 'showDates'),
+71 -42
View File
@@ -32,57 +32,61 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => {
switch (tabKey) { switch (tabKey) {
case 'ingest': case 'ingest':
return '入库监控'; return '数据接入';
case 'asset_inventory': case 'asset_inventory':
return '资产库存'; return '资产台账';
case 'data': case 'data':
return '数据列表'; return '影像检索';
case 'hazard': case 'hazard':
return '灾害点'; return '灾害点库';
case 'statistics':
return '综合统计';
case 'pairing': case 'pairing':
return 'D-InSAR配对规划'; return 'D-InSAR 配对规划';
case 'pairs': case 'pairs':
return `D-InSAR候选对与批次 (${pairCount})`; return `D-InSAR 候选对与批次 (${pairCount})`;
case 'ps_results': case 'ps_results':
return `SBAS序列规划 (${psResultCount})`; return `SBAS 序列规划 (${psResultCount})`;
case 'batches': case 'batches':
return 'D-InSAR候选对与批次'; return 'D-InSAR 候选对与批次';
case 'copier': case 'copier':
return 'D-InSAR生产准备'; return 'D-InSAR 生产准备';
case 'production_management': case 'production_management':
return '生产管理'; return '生产管理';
case 'idl': case 'idl':
return 'D-InSAR生产(旧)'; return 'D-InSAR 生产';
case 'dinsar_production': case 'dinsar_production':
return 'D-InSAR生产运行'; return 'D-InSAR 生产运行';
case 'dinsar_products': case 'dinsar_products':
return 'D-InSAR结果管理'; return 'D-InSAR 成果目录';
case 'ps_production': case 'ps_production':
return 'SBAS-InSAR生产工作流'; return 'SBAS-InSAR 生产工作流';
case 'ps_products': case 'ps_products':
return 'SBAS-InSAR结果管理'; return 'SBAS-InSAR 成果目录';
case 'dinsar_results': case 'dinsar_results':
return `D-InSAR结果 (${dinsarTotal})`; return `D-InSAR 结果判读 (${dinsarTotal})`;
case 'dinsar_analysis': case 'dinsar_analysis':
return 'D-InSAR分析'; return 'D-InSAR 专题分析';
case 'psinsar_results': case 'psinsar_results':
return 'SBAS-InSAR结果'; return 'SBAS-InSAR 结果';
case 'psinsar_analysis': case 'psinsar_analysis':
return 'SBAS-InSAR分析'; return 'SBAS 形变分析';
case 'result_extraction':
return '结果提取';
case 'ai_quality': case 'ai_quality':
return 'AI质量评估'; return 'AI 质量评估';
case 'ai_diagnosis': case 'ai_diagnosis':
return 'D-InSAR诊断'; return 'D-InSAR 诊断';
case 'landslide_segmentation': case 'landslide_segmentation':
return '滑坡语义分割'; return '滑坡语义分割';
case 'uav_image_analysis': case 'uav_image_analysis':
return '无人机影像分析'; return '无人机影像分析';
case 'water': case 'water':
return '水体监测(旧)'; return '水体监测';
case 'flood_analysis': case 'flood_analysis':
return '洪涝灾害分析'; return '洪涝灾害分析';
case 'health': case 'health':
return '运维自检'; return '运行维护';
case 'users': case 'users':
return '用户管理'; return '用户管理';
case 'audit': case 'audit':
@@ -92,6 +96,39 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => {
} }
}; };
export const getLeftTabDescription = (tabKey) => {
switch (tabKey) {
case 'ingest':
return '监控源数据、精轨和派生资产的接入任务,集中处理扫描、登记和运行记录。';
case 'asset_inventory':
return '查看源产品、精密轨道、绑定状态和开放问题,作为生产前的数据资产台账。';
case 'data':
return '按卫星、日期、轨道、AOI 和产品属性检索 SAR 影像,并在地图上核对覆盖范围。';
case 'hazard':
return '管理灾害点位与专题分析对象,为形变判读和洪涝分析提供空间参照。';
case 'statistics':
return '汇总源数据、生产成果、质量判读和缓存一致性,形成面向生产管理的统计视图。';
case 'dinsar_results':
return '核对 D-InSAR 形变结果、质量评分、标签和空间分布,支撑成果判读。';
case 'dinsar_analysis':
return '围绕选定 D-InSAR 结果开展专题分析、诊断和报告辅助。';
case 'psinsar_analysis':
return '查看 SBAS-InSAR 速率场、监测点和时序曲线,开展区域形变分析。';
case 'result_extraction':
return '统一提取三类正射生产成果、D-InSAR 成果和 SBAS-InSAR 成果,作为系统对外成果交付出口。';
case 'flood_analysis':
return '围绕洪涝场景开展水体提取、过程分析和专题制图。';
case 'health':
return '检查核心服务、数据目录、生产索引和运行环境,定位影响生产的阻断项。';
case 'users':
return '维护系统用户、角色与访问权限。';
case 'audit':
return '查看关键操作和生产任务的审计记录。';
default:
return '当前模块用于支撑科研工程生产流程。';
}
};
export const getSelectedRegionTreeId = selection => ( export const getSelectedRegionTreeId = selection => (
selection?.city || selection?.province || '' selection?.city || selection?.province || ''
); );
@@ -145,56 +182,48 @@ export const normalizeRadarSearchCriteria = (draft, defaults) => {
return normalized; return normalized;
}; };
export const formatUtc = (isoString, language) => { export const formatUtc = (isoString) => {
if (!isoString) return language === 'en' ? 'Unknown' : '未知'; if (!isoString) return '未知';
const date = new Date(isoString); const date = new Date(isoString);
if (Number.isNaN(date.getTime())) return language === 'en' ? 'Unknown' : '未知'; if (Number.isNaN(date.getTime())) return '未知';
const pad = n => String(n).padStart(2, '0'); const pad = n => String(n).padStart(2, '0');
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())} UTC`; return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())} UTC`;
}; };
export const formatYmd = (rawValue, language) => { export const formatYmd = (rawValue) => {
const value = String(rawValue ?? '').trim(); const value = String(rawValue ?? '').trim();
if (!value) return '-'; if (!value) return '-';
const compactMatch = value.match(/^(\d{4})(\d{2})(\d{2})$/); const compactMatch = value.match(/^(\d{4})(\d{2})(\d{2})$/);
if (compactMatch) { if (compactMatch) {
const [, yyyy, mm, dd] = compactMatch; const [, yyyy, mm, dd] = compactMatch;
return language === 'en' return `${yyyy}年${mm}月${dd}日`;
? `${yyyy}-${mm}-${dd}`
: `${yyyy}年${mm}月${dd}日`;
} }
const dashMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})/); const dashMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (dashMatch) { if (dashMatch) {
const [, yyyy, mm, dd] = dashMatch; const [, yyyy, mm, dd] = dashMatch;
return language === 'en' return `${yyyy}年${mm}月${dd}日`;
? `${yyyy}-${mm}-${dd}`
: `${yyyy}年${mm}月${dd}日`;
} }
return value; return value;
}; };
export const getPageHintText = (totalPages, language) => ( export const getPageHintText = (totalPages) => (
language === 'en' `提示:可跳转页码范围为 1-${totalPages},按 Enter 可快速跳转。`
? `Tip: valid page range is 1-${totalPages}. Press Enter to jump.`
: `提示:可跳转页码范围为 1-${totalPages},按 Enter 可快速跳转。`
); );
export const getPageInputErrorText = (rawValue, totalPages, language) => { export const getPageInputErrorText = (rawValue, totalPages) => {
const value = String(rawValue ?? '').trim(); const value = String(rawValue ?? '').trim();
if (!value) { if (!value) {
return language === 'en' ? 'Please enter a page number.' : '请输入页码。'; return '请输入页码。';
} }
const numericValue = Number(value); const numericValue = Number(value);
if (!Number.isFinite(numericValue) || !Number.isInteger(numericValue)) { if (!Number.isFinite(numericValue) || !Number.isInteger(numericValue)) {
return language === 'en' ? 'Page number must be an integer.' : '页码必须为整数。'; return '页码必须为整数。';
} }
if (numericValue < 1 || numericValue > totalPages) { if (numericValue < 1 || numericValue > totalPages) {
return language === 'en' return `页码必须在 1-${totalPages} 之间。`;
? `Page must be between 1 and ${totalPages}.`
: `页码必须在 1-${totalPages} 之间。`;
} }
return ''; return '';
}; };
+37
View File
@@ -0,0 +1,37 @@
import asyncio
import os
from dotenv import load_dotenv
from backend.app.database import init_db
def main() -> None:
load_dotenv()
os.environ.setdefault("JOB_WORKER_ALLOWED_TYPES", "LANDSAR_CLUSTER_ITEM")
os.environ.setdefault("JOB_WORKER_CONCURRENCY", "1")
init_db()
poll_interval = float(os.getenv("JOB_WORKER_POLL_INTERVAL", "1.0"))
concurrency = int(os.getenv("JOB_WORKER_CONCURRENCY", "1"))
worker_id = os.getenv("LANDSAR_CLUSTER_WORKER_ID", "").strip()
from backend.app.services.job_worker import run_worker_loop
print("[*] Starting LandSAR cluster worker...")
print("[*] Allowed job types: LANDSAR_CLUSTER_ITEM")
print(f"[*] Poll interval: {poll_interval}s")
print(f"[*] Concurrency: {concurrency}")
asyncio.run(
run_worker_loop(
worker_id=worker_id,
poll_interval=poll_interval,
concurrency=concurrency,
)
)
if __name__ == "__main__":
main()
+282
View File
@@ -0,0 +1,282 @@
"""Backfill XML refRow/refColumn corner mappings for archive-managed radar records."""
from __future__ import annotations
import argparse
import asyncio
import gzip
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterable, Optional, Sequence, Tuple
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from geoalchemy2.shape import from_shape # noqa: E402
from shapely.geometry import Polygon # noqa: E402
from sqlalchemy import or_, select # noqa: E402
from sqlalchemy.ext.asyncio import AsyncSession # noqa: E402
from backend.app import database # noqa: E402
from backend.app.config import settings # noqa: E402
from backend.app.models import RadarDataORM, SourceMetadataDocumentORM, SourceProductAssetORM # noqa: E402
from backend.app.services.asset_inventory_service import _parse_radar_xml_metadata_bytes # noqa: E402
from backend.app.utils import build_corner_pixel_mapping # noqa: E402
SOURCE_FORMATS = {"LT1_ARCHIVE"}
DOCUMENT_TYPES = {"LT1_META"}
def _metadata_dict(value: Any) -> Dict[str, Any]:
return dict(value or {}) if isinstance(value, dict) else {}
def _normalize_families(values: Optional[Iterable[str]]) -> Sequence[str]:
families = []
for value in values or ["LT1"]:
for item in str(value or "").split(","):
family = item.strip().upper()
if family and family not in families:
families.append(family)
supported = {"LT1"}
invalid = [item for item in families if item not in supported]
if invalid:
raise SystemExit(f"Unsupported family: {', '.join(invalid)}")
return families or ["LT1"]
def _decode_document(doc: SourceMetadataDocumentORM) -> Optional[bytes]:
payload = bytes(doc.content_bytes or b"")
if not payload:
return None
if str(doc.content_encoding or "").lower() == "gzip":
return gzip.decompress(payload)
return payload
def _mapping_from_xml_document(doc: SourceMetadataDocumentORM) -> Tuple[Optional[Dict[str, Any]], Optional[Any]]:
payload = _decode_document(doc)
if not payload:
return None, None
coverage_polygon, xml_meta = _parse_radar_xml_metadata_bytes(payload)
mapping = xml_meta.get("corner_pixel_mapping") if isinstance(xml_meta, dict) else None
return mapping if isinstance(mapping, dict) else None, coverage_polygon
def _mapping_from_metadata(metadata: Dict[str, Any]) -> Optional[Dict[str, Any]]:
existing = metadata.get("corner_pixel_mapping")
if isinstance(existing, dict):
return existing
corners = metadata.get("corner_details")
if isinstance(corners, dict):
mapping = build_corner_pixel_mapping(corners)
if isinstance(mapping, dict):
return mapping
return None
def _bbox(points: Any) -> Optional[Tuple[float, float, float, float]]:
try:
ring = [(float(item[0]), float(item[1])) for item in points or []]
except (TypeError, ValueError, IndexError):
return None
if len(ring) < 3:
return None
lons = [item[0] for item in ring]
lats = [item[1] for item in ring]
return min(lons), min(lats), max(lons), max(lats)
async def _first_metadata_document(
db: AsyncSession,
*,
source_asset_id: Optional[int],
radar_data_id: Optional[int],
) -> Optional[SourceMetadataDocumentORM]:
clauses = []
if source_asset_id:
clauses.append(SourceMetadataDocumentORM.source_asset_id == int(source_asset_id))
if radar_data_id:
clauses.append(SourceMetadataDocumentORM.radar_data_id == int(radar_data_id))
if not clauses:
return None
stmt = (
select(SourceMetadataDocumentORM)
.where(or_(*clauses))
.where(SourceMetadataDocumentORM.document_type.in_(sorted(DOCUMENT_TYPES)))
.order_by(SourceMetadataDocumentORM.id.asc())
.limit(1)
)
return (await db.execute(stmt)).scalar_one_or_none()
async def _load_source_assets(db: AsyncSession, families: Sequence[str], limit: int) -> Sequence[SourceProductAssetORM]:
stmt = (
select(SourceProductAssetORM)
.where(SourceProductAssetORM.satellite_family.in_(families))
.where(SourceProductAssetORM.source_format.in_(sorted(SOURCE_FORMATS)))
.where(SourceProductAssetORM.is_active.is_(True))
.order_by(SourceProductAssetORM.id.asc())
)
if limit > 0:
stmt = stmt.limit(limit)
return list((await db.execute(stmt)).scalars().all())
async def _load_radar_records(db: AsyncSession, families: Sequence[str], limit: int) -> Sequence[RadarDataORM]:
stmt = (
select(RadarDataORM)
.where(RadarDataORM.satellite_family.in_(families))
.where(RadarDataORM.source_format.in_(sorted(SOURCE_FORMATS)))
.order_by(RadarDataORM.id.asc())
)
if limit > 0:
stmt = stmt.limit(limit)
return list((await db.execute(stmt)).scalars().all())
async def backfill(args: argparse.Namespace) -> Dict[str, int]:
families = _normalize_families(args.family)
database.init_db(settings.DATABASE_URL)
stats = {
"source_seen": 0,
"source_updated": 0,
"source_existing": 0,
"source_missing_mapping": 0,
"radar_seen": 0,
"radar_updated": 0,
"radar_existing": 0,
"radar_missing_mapping": 0,
"cache_invalidated": 0,
"document_parse_failed": 0,
}
async with database.AsyncSessionLocal() as db:
source_assets = await _load_source_assets(db, families, int(args.limit or 0))
source_mappings: Dict[int, Dict[str, Any]] = {}
source_polygons: Dict[int, Any] = {}
now = datetime.utcnow()
for asset in source_assets:
stats["source_seen"] += 1
metadata = _metadata_dict(asset.metadata_json)
mapping = _mapping_from_metadata(metadata)
coverage_polygon = metadata.get("coverage_polygon")
if mapping:
stats["source_existing"] += 1
else:
doc = await _first_metadata_document(db, source_asset_id=asset.id, radar_data_id=None)
if doc:
try:
mapping, coverage_polygon_from_doc = _mapping_from_xml_document(doc)
coverage_polygon = coverage_polygon_from_doc or coverage_polygon
except Exception:
stats["document_parse_failed"] += 1
mapping = None
if not mapping:
stats["source_missing_mapping"] += 1
continue
metadata["corner_pixel_mapping"] = mapping
if coverage_polygon:
metadata["coverage_polygon"] = coverage_polygon
metadata["corner_pixel_mapping_backfilled_at"] = now.isoformat()
asset.metadata_json = metadata
asset.updated_at = now
db.add(asset)
stats["source_updated"] += 1
if mapping and asset.id is not None:
source_mappings[int(asset.id)] = mapping
if coverage_polygon:
source_polygons[int(asset.id)] = coverage_polygon
radar_records = await _load_radar_records(db, families, int(args.limit or 0))
for radar in radar_records:
stats["radar_seen"] += 1
metadata = _metadata_dict(radar.metadata_json)
mapping = _mapping_from_metadata(metadata)
coverage_polygon = radar.coverage_polygon or metadata.get("coverage_polygon")
if mapping:
stats["radar_existing"] += 1
else:
source_id = int(radar.source_product_ref_id or 0)
mapping = source_mappings.get(source_id)
if source_id and source_id in source_polygons:
coverage_polygon = coverage_polygon or source_polygons[source_id]
if not mapping:
doc = await _first_metadata_document(
db,
source_asset_id=radar.source_product_ref_id,
radar_data_id=radar.id,
)
if doc:
try:
mapping, coverage_polygon_from_doc = _mapping_from_xml_document(doc)
coverage_polygon = coverage_polygon_from_doc or coverage_polygon
except Exception:
stats["document_parse_failed"] += 1
mapping = None
if not mapping:
stats["radar_missing_mapping"] += 1
continue
metadata["corner_pixel_mapping"] = mapping
if coverage_polygon:
metadata["coverage_polygon"] = coverage_polygon
radar.coverage_polygon = coverage_polygon
bbox = _bbox(coverage_polygon)
if bbox:
radar.min_lon, radar.min_lat, radar.max_lon, radar.max_lat = bbox
try:
polygon = Polygon(coverage_polygon)
if not polygon.is_valid:
polygon = polygon.buffer(0)
if not polygon.is_empty:
radar.geom = from_shape(polygon, srid=4326)
except Exception:
pass
metadata["corner_pixel_mapping_backfilled_at"] = now.isoformat()
radar.metadata_json = metadata
db.add(radar)
stats["radar_updated"] += 1
if args.invalidate_preview and mapping:
radar.preview_cache_status = "NONE"
radar.preview_cache_version = None
radar.preview_cache_path = None
radar.preview_cache_error = "corner_pixel_mapping_backfilled"
radar.preview_cache_updated_at = now
db.add(radar)
stats["cache_invalidated"] += 1
if args.apply:
await db.commit()
else:
await db.rollback()
return stats
def main() -> int:
parser = argparse.ArgumentParser(description="Backfill corner_pixel_mapping into source/radar metadata_json.")
parser.add_argument("--apply", action="store_true", help="commit database changes")
parser.add_argument("--family", action="append", help="family to process: LT1 or GF3; defaults to LT1")
parser.add_argument("--limit", type=int, default=0, help="maximum source/radar rows per table; 0 means all")
parser.add_argument(
"--no-invalidate-preview",
dest="invalidate_preview",
action="store_false",
help="do not mark old geocorrected WebP caches stale",
)
parser.set_defaults(invalidate_preview=True)
args = parser.parse_args()
stats = asyncio.run(backfill(args))
print(json.dumps({"apply": bool(args.apply), **stats}, ensure_ascii=False, indent=2, default=str))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+54
View File
@@ -5,6 +5,7 @@ import argparse
import asyncio import asyncio
import json import json
import sys import sys
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Iterable, List, Optional from typing import Iterable, List, Optional
@@ -34,6 +35,55 @@ def _normalize_families(values: Optional[Iterable[str]]) -> List[str]:
async def _run(args: argparse.Namespace) -> dict: async def _run(args: argparse.Namespace) -> dict:
families = _normalize_families(args.family) families = _normalize_families(args.family)
database.init_db(settings.DATABASE_URL) database.init_db(settings.DATABASE_URL)
def _print_progress(payload: dict) -> None:
stamp = datetime.now().strftime("%H:%M:%S")
event = payload.get("event")
if event == "planned":
print(
"[{}] planned records_seen={} candidates={} skipped_ready={} families={}".format(
stamp,
payload.get("records_seen", 0),
payload.get("candidate_count", 0),
payload.get("skipped_ready", 0),
",".join(payload.get("families") or []),
),
flush=True,
)
return
if event == "item":
print(
(
"[{}] {}/{} ready={} failed={} missing={} raw_failed={} "
"skipped_ready={} status={} product={}"
).format(
stamp,
payload.get("processed", 0),
payload.get("total", 0),
payload.get("ready", 0),
payload.get("failed", 0),
payload.get("missing_source", 0),
payload.get("raw_failed", 0),
payload.get("skipped_ready", 0),
payload.get("status") or "",
payload.get("product_name") or "",
),
flush=True,
)
return
if event == "completed":
print(
"[{}] completed ready={} cached={} skipped_ready={} failed={} missing={}".format(
stamp,
payload.get("ready", 0),
payload.get("cached", 0),
payload.get("skipped_ready", 0),
payload.get("failed", 0),
payload.get("missing_source", 0),
),
flush=True,
)
summary = await asset_inventory_service.build_archive_preview_caches( summary = await asset_inventory_service.build_archive_preview_caches(
families=families, families=families,
limit=args.limit, limit=args.limit,
@@ -41,6 +91,8 @@ async def _run(args: argparse.Namespace) -> dict:
apply=bool(args.apply), apply=bool(args.apply),
progress_start=0, progress_start=0,
progress_end=100, progress_end=100,
progress_callback=_print_progress if args.progress else None,
progress_interval=args.progress_interval,
) )
return {"apply": bool(args.apply), **summary} return {"apply": bool(args.apply), **summary}
@@ -51,6 +103,8 @@ def main() -> int:
parser.add_argument("--family", action="append", help="family to process: LT1, S1, or comma-separated values") parser.add_argument("--family", action="append", help="family to process: LT1, S1, or comma-separated values")
parser.add_argument("--limit", type=int, default=0, help="maximum rows to build; 0 means all pending rows") parser.add_argument("--limit", type=int, default=0, help="maximum rows to build; 0 means all pending rows")
parser.add_argument("--force", action="store_true", help="rebuild even if preview_cache_status is READY") parser.add_argument("--force", action="store_true", help="rebuild even if preview_cache_status is READY")
parser.add_argument("--progress", action="store_true", help="print command-line progress while building")
parser.add_argument("--progress-interval", type=int, default=10, help="print every N processed candidates")
args = parser.parse_args() args = parser.parse_args()
payload = asyncio.run(_run(args)) payload = asyncio.run(_run(args))
+1 -1
View File
@@ -399,7 +399,7 @@ async def _build_archive_previews(
summary["items"].append(item) summary["items"].append(item)
continue continue
source_corner_mapping = DataService.get_radar_source_corner_mapping(record.file_path) source_corner_mapping = DataService.get_radar_record_corner_mapping(record)
ok_geo, geo_error = image_service.create_geocorrected_radar_cached_image( ok_geo, geo_error = image_service.create_geocorrected_radar_cached_image(
preview_source, preview_source,
geo_cache_path, geo_cache_path,
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
cd /d "%~dp0\.."
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0start_landsar_cluster_worker.ps1"
pause
+116
View File
@@ -0,0 +1,116 @@
param(
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path,
[string]$PythonPath = "",
[string]$WorkerId = "",
[switch]$Background
)
$ErrorActionPreference = "Stop"
function Read-DotEnvValue {
param(
[string]$Path,
[string]$Name
)
if (-not (Test-Path -LiteralPath $Path)) {
return ""
}
$line = Get-Content -LiteralPath $Path |
Where-Object { $_ -match "^\s*$([regex]::Escape($Name))\s*=" } |
Select-Object -Last 1
if (-not $line) {
return ""
}
$value = ($line -split "=", 2)[1].Trim()
if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) {
$value = $value.Substring(1, $value.Length - 2)
}
return $value
}
function Resolve-PythonPath {
param(
[string]$ExplicitPath,
[string]$EnvPath
)
$candidates = @(
$ExplicitPath,
$EnvPath,
"C:\ProgramData\anaconda3\envs\InSAR\python.exe",
"python.exe"
) | Where-Object { $_ -and $_.Trim() } | Select-Object -Unique
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate) {
return (Resolve-Path -LiteralPath $candidate).Path
}
$command = Get-Command $candidate -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
}
throw "Python interpreter not found. Set PYTHON_PATH in .env or pass -PythonPath."
}
$RepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path
$envPath = Join-Path $RepoRoot ".env"
$templatePath = Join-Path $RepoRoot "config\landsar_cluster_worker.env.example"
$workerScript = Join-Path $RepoRoot "run_landsar_cluster_worker.py"
if (-not (Test-Path -LiteralPath $workerScript)) {
throw "Worker script not found: $workerScript"
}
if (-not (Test-Path -LiteralPath $envPath)) {
if (Test-Path -LiteralPath $templatePath) {
Copy-Item -LiteralPath $templatePath -Destination $envPath
throw ".env was created from config\landsar_cluster_worker.env.example. Review paths and credentials, then run this launcher again."
}
throw ".env not found: $envPath"
}
$dotenvPythonPath = Read-DotEnvValue -Path $envPath -Name "PYTHON_PATH"
$python = Resolve-PythonPath -ExplicitPath $PythonPath -EnvPath $dotenvPythonPath
$allowedTypes = Read-DotEnvValue -Path $envPath -Name "JOB_WORKER_ALLOWED_TYPES"
if (-not $allowedTypes) {
$env:JOB_WORKER_ALLOWED_TYPES = "LANDSAR_CLUSTER_ITEM"
}
$concurrency = Read-DotEnvValue -Path $envPath -Name "JOB_WORKER_CONCURRENCY"
if (-not $concurrency) {
$env:JOB_WORKER_CONCURRENCY = "1"
}
if ($WorkerId) {
$env:LANDSAR_CLUSTER_WORKER_ID = $WorkerId
}
$logDir = Join-Path $RepoRoot "logs\landsar_cluster_worker"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$stdoutLog = Join-Path $logDir "worker_$timestamp.log"
$stderrLog = Join-Path $logDir "worker_$timestamp.err.log"
Write-Host "LandSAR cluster worker launcher"
Write-Host "RepoRoot: $RepoRoot"
Write-Host "Python: $python"
Write-Host "Env: $envPath"
Write-Host "Log: $stdoutLog"
Write-Host "Mode: $(if ($Background) { 'background' } else { 'foreground' })"
Set-Location $RepoRoot
if ($Background) {
$process = Start-Process `
-FilePath $python `
-ArgumentList @($workerScript) `
-WorkingDirectory $RepoRoot `
-RedirectStandardOutput $stdoutLog `
-RedirectStandardError $stderrLog `
-WindowStyle Hidden `
-PassThru
Write-Host "Started background worker. PID=$($process.Id)"
return
}
& $python $workerScript 2>&1 | Tee-Object -FilePath $stdoutLog -Append
exit $LASTEXITCODE
@@ -0,0 +1,138 @@
param(
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path,
[string]$PostgresDataDir = "D:\PostgreSQLData",
[string]$PostgresServiceName = "postgresql-x64-17",
[string]$FirewallRuleName = "InSAR PostgreSQL 5432 LandSAR Cluster",
[int]$PostgresPort = 5432
)
$ErrorActionPreference = "Stop"
function Read-DotEnvValue {
param(
[string]$Path,
[string]$Name
)
if (-not (Test-Path -LiteralPath $Path)) {
return ""
}
$line = Get-Content -LiteralPath $Path |
Where-Object { $_ -match "^\s*$([regex]::Escape($Name))\s*=" } |
Select-Object -Last 1
if (-not $line) {
return ""
}
$value = ($line -split "=", 2)[1].Trim()
if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) {
$value = $value.Substring(1, $value.Length - 2)
}
return $value
}
function Normalize-WorkerAddress {
param([string]$Raw)
$value = $Raw.Trim()
if (-not $value) {
return $null
}
if ($value -match "/") {
$parts = $value -split "/", 2
$ip = $parts[0]
$prefix = [int]$parts[1]
if ($prefix -lt 0 -or $prefix -gt 32) {
throw "Invalid CIDR prefix: $value"
}
} else {
$ip = $value
$prefix = 32
}
$parsed = $null
if (-not [System.Net.IPAddress]::TryParse($ip, [ref]$parsed)) {
throw "Invalid IP address: $value"
}
if ($parsed.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) {
throw "Only IPv4 addresses are supported for LandSAR cluster workers: $value"
}
return "$ip/$prefix"
}
$envPath = Join-Path $RepoRoot ".env"
$allowedRaw = Read-DotEnvValue -Path $envPath -Name "LANDSAR_CLUSTER_ALLOWED_WORKER_IPS"
if (-not $allowedRaw) {
throw "LANDSAR_CLUSTER_ALLOWED_WORKER_IPS is empty. Set it in .env, for example: LANDSAR_CLUSTER_ALLOWED_WORKER_IPS=192.168.1.6"
}
$allowed = @()
foreach ($part in ($allowedRaw -split "[,;]")) {
$normalized = Normalize-WorkerAddress $part
if ($normalized -and ($allowed -notcontains $normalized)) {
$allowed += $normalized
}
}
if (-not $allowed) {
throw "No valid LandSAR cluster worker IPs found in LANDSAR_CLUSTER_ALLOWED_WORKER_IPS."
}
$hbaPath = Join-Path $PostgresDataDir "pg_hba.conf"
if (-not (Test-Path -LiteralPath $hbaPath)) {
throw "pg_hba.conf not found: $hbaPath"
}
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
Copy-Item -LiteralPath $hbaPath -Destination "$hbaPath.bak_$timestamp"
$begin = "# BEGIN InSAR LandSAR cluster workers"
$end = "# END InSAR LandSAR cluster workers"
$content = Get-Content -LiteralPath $hbaPath
$newContent = New-Object System.Collections.Generic.List[string]
$inside = $false
foreach ($line in $content) {
if ($line -eq $begin) {
$inside = $true
continue
}
if ($line -eq $end) {
$inside = $false
continue
}
if (-not $inside) {
$newContent.Add($line)
}
}
$newContent.Add("")
$newContent.Add($begin)
foreach ($address in $allowed) {
$newContent.Add(("host insar_management all {0,-20} scram-sha-256" -f $address))
}
$newContent.Add($end)
Set-Content -LiteralPath $hbaPath -Value $newContent -Encoding ASCII
$pgCtl = "C:\Program Files\PostgreSQL\17\bin\pg_ctl.exe"
if (Test-Path -LiteralPath $pgCtl) {
& $pgCtl reload -D $PostgresDataDir | Out-Host
} else {
Restart-Service -Name $PostgresServiceName
}
$remoteAddresses = $allowed | ForEach-Object { ($_ -split "/", 2)[0] }
$rule = Get-NetFirewallRule -DisplayName $FirewallRuleName -ErrorAction SilentlyContinue
if (-not $rule) {
New-NetFirewallRule `
-DisplayName $FirewallRuleName `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-LocalPort $PostgresPort `
-RemoteAddress $remoteAddresses `
-Profile Any | Out-Null
} else {
$rule | Set-NetFirewallRule -Enabled True -Direction Inbound -Action Allow -Profile Any
$rule | Get-NetFirewallAddressFilter | Set-NetFirewallAddressFilter -RemoteAddress $remoteAddresses
$rule | Get-NetFirewallPortFilter | Set-NetFirewallPortFilter -Protocol TCP -LocalPort $PostgresPort
}
Write-Host "LandSAR cluster network access synced."
Write-Host ("Allowed workers: " + ($allowed -join ", "))
Write-Host "PostgreSQL hba: $hbaPath"
Write-Host "Firewall rule: $FirewallRuleName"