Add LandSAR cluster worker deployment
This commit is contained in:
@@ -36,6 +36,7 @@ TRUSTED_PROXY_IPS=127.0.0.1
|
||||
PORT=18000
|
||||
BACKEND_BIND_HOST=127.0.0.1
|
||||
UVICORN_LOG_LEVEL=info
|
||||
DASHBOARD_STATS_CACHE_TTL_SECONDS=120
|
||||
|
||||
PYTHON_PATH=C:\ProgramData\anaconda3\envs\InSAR\python.exe
|
||||
CONDA_EXE=
|
||||
@@ -65,6 +66,10 @@ SBAS_TASK_POOL_ROOT=D:\Task_Pool\SBAS
|
||||
DATA_DISTRIBUTION_ROOT=D:\Task_Pool\Data_Distribution
|
||||
GF3_TASK_POOL_ROOT=D:\GaoFen3_Pool\task_pool
|
||||
SOURCE_PRODUCT_DIRS=D:\LuTan1_Image_Pool_Zip;D:\Sentinel1_Image_Pool_ZIP
|
||||
ASSET_SCAN_PARSE_WORKERS=4
|
||||
ASSET_SCAN_PARSE_INFLIGHT=64
|
||||
ASSET_SCAN_SKIP_UNCHANGED_FAILURES=true
|
||||
ASSET_SCAN_DB_BATCH_SIZE=50
|
||||
SENTINEL1_STORAGE_DIRS=
|
||||
INSAR_STORAGE_DIRS=
|
||||
MONITOR_RADAR_DIRS=
|
||||
@@ -333,6 +338,12 @@ JOB_WORKER_JOB_HEARTBEAT_INTERVAL=5
|
||||
JOB_WORKER_STALE_RECOVER_INTERVAL=15
|
||||
JOB_WORKER_STALE_RUNNING_SECONDS=7200
|
||||
JOB_WORKER_HEARTBEAT_INTERVAL=5
|
||||
# Main server only. Comma/semicolon-separated IPv4 addresses or CIDR blocks allowed
|
||||
# to run LandSAR cluster workers against this PostgreSQL server.
|
||||
LANDSAR_CLUSTER_ALLOWED_WORKER_IPS=192.168.1.6
|
||||
# Empty means the worker can claim all job types. Remote LandSAR nodes should set:
|
||||
# JOB_WORKER_ALLOWED_TYPES=LANDSAR_CLUSTER_ITEM
|
||||
JOB_WORKER_ALLOWED_TYPES=
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -394,6 +405,11 @@ DEFAULT_VLM_MODEL=qwen3-vl:30b
|
||||
# Empty means same-origin /tiles, routed by the main nginx proxy.
|
||||
VITE_TILE_SERVER_URL=
|
||||
VITE_TILE_SERVER_TOKEN=change_me
|
||||
VITE_APP_ORG_NAME=黑龙江省自然资源卫星应用技术中心
|
||||
VITE_APP_SYSTEM_NAME=InSAR 自动化管理系统
|
||||
VITE_APP_SYSTEM_TAGLINE=科研工程生产平台
|
||||
# Optional override for deployed builds. Empty uses frontend/src/logo.jpg.
|
||||
VITE_APP_LOGO_URL=
|
||||
TILE_SERVER_AUTO_START=true
|
||||
TILE_SERVER_AUTO_STOP=true
|
||||
TILE_SERVER_ROOT=D:\Code\tile-server
|
||||
|
||||
+33
@@ -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
@@ -235,9 +235,13 @@ class Settings(BaseSettings):
|
||||
RADAR_THUMBNAIL_MAX_SIZE: int = 1600
|
||||
RADAR_CACHE_WORKERS: int = 2
|
||||
RADAR_GEO_CACHE_WORKERS: int = 2
|
||||
RADAR_GEO_CACHE_VERSION: str = "b1"
|
||||
RADAR_GEO_CACHE_VERSION: str = "b2"
|
||||
RADAR_GEO_CACHE_QUALITY: int = 84
|
||||
RADAR_PREVIEW_BUILD_ON_DEMAND: bool = True
|
||||
ASSET_SCAN_PARSE_WORKERS: int = 4
|
||||
ASSET_SCAN_PARSE_INFLIGHT: int = 64
|
||||
ASSET_SCAN_SKIP_UNCHANGED_FAILURES: bool = True
|
||||
ASSET_SCAN_DB_BATCH_SIZE: int = 50
|
||||
|
||||
WATER_RESULTS_DIR: str = ""
|
||||
GF3_WATER_DEM_PATH: str = ""
|
||||
@@ -417,6 +421,7 @@ class Settings(BaseSettings):
|
||||
JOB_WORKER_STALE_RECOVER_INTERVAL: float = 15.0
|
||||
JOB_WORKER_STALE_RUNNING_SECONDS: int = 300
|
||||
JOB_WORKER_HEARTBEAT_INTERVAL: float = 5.0
|
||||
JOB_WORKER_ALLOWED_TYPES: str = ""
|
||||
|
||||
TIMESERIES_ENABLED: bool = False
|
||||
TIMESERIES_WSL_DISTRO: str = ""
|
||||
@@ -495,6 +500,13 @@ class Settings(BaseSettings):
|
||||
)
|
||||
if not self.SRTM_DEM_DIR:
|
||||
object.__setattr__(self, "SRTM_DEM_DIR", os.path.join(backend_dir, "dem_data"))
|
||||
object.__setattr__(self, "ASSET_SCAN_PARSE_WORKERS", max(1, int(self.ASSET_SCAN_PARSE_WORKERS or 1)))
|
||||
object.__setattr__(
|
||||
self,
|
||||
"ASSET_SCAN_PARSE_INFLIGHT",
|
||||
max(self.ASSET_SCAN_PARSE_WORKERS, int(self.ASSET_SCAN_PARSE_INFLIGHT or self.ASSET_SCAN_PARSE_WORKERS)),
|
||||
)
|
||||
object.__setattr__(self, "ASSET_SCAN_DB_BATCH_SIZE", max(1, int(self.ASSET_SCAN_DB_BATCH_SIZE or 1)))
|
||||
if not self.GF3_ARCHIVE_SOURCE_DIRS:
|
||||
object.__setattr__(
|
||||
self,
|
||||
|
||||
@@ -17,6 +17,7 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -32,6 +33,13 @@ _PUBLIC_KEY_B64 = "QOpR1c3bONDwOzrj3IVTogE1ZHIphpwxJY8nhWa09yw="
|
||||
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_BACKEND_DIR = os.path.dirname(_APP_DIR)
|
||||
LICENSE_PATH_DEFAULT = os.path.join(_BACKEND_DIR, "license", "license.lic")
|
||||
LICENSE_STATUS_CACHE_SECONDS = int(os.getenv("LICENSE_STATUS_CACHE_SECONDS", "30"))
|
||||
_LICENSE_STATUS_CACHE: Dict[str, Any] = {
|
||||
"path": None,
|
||||
"mtime": None,
|
||||
"checked_at": 0.0,
|
||||
"payload": None,
|
||||
}
|
||||
|
||||
|
||||
def _now_utc() -> datetime:
|
||||
@@ -86,47 +94,71 @@ def _get_machine_fingerprint() -> str:
|
||||
|
||||
# ── 授权验证 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _license_result(result: Dict[str, Any], *, use_cache: bool, path: str, mtime: Optional[float]) -> Dict[str, Any]:
|
||||
if use_cache:
|
||||
_LICENSE_STATUS_CACHE.update({
|
||||
"path": path,
|
||||
"mtime": mtime,
|
||||
"checked_at": time.monotonic(),
|
||||
"payload": dict(result),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def check_license(license_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
use_cache = license_path is None
|
||||
license_path = license_path or settings.LICENSE_PATH or LICENSE_PATH_DEFAULT
|
||||
mtime = os.path.getmtime(license_path) if os.path.exists(license_path) else None
|
||||
|
||||
if use_cache:
|
||||
cached = _LICENSE_STATUS_CACHE.get("payload")
|
||||
cache_age = time.monotonic() - float(_LICENSE_STATUS_CACHE.get("checked_at") or 0.0)
|
||||
if (
|
||||
cached is not None
|
||||
and _LICENSE_STATUS_CACHE.get("path") == license_path
|
||||
and _LICENSE_STATUS_CACHE.get("mtime") == mtime
|
||||
and cache_age <= LICENSE_STATUS_CACHE_SECONDS
|
||||
):
|
||||
return dict(cached)
|
||||
|
||||
if not os.path.exists(license_path):
|
||||
return {"ok": False, "reason": "未找到授权文件"}
|
||||
return _license_result({"ok": False, "reason": "未找到授权文件"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
||||
|
||||
try:
|
||||
blob = open(license_path, "rb").read().strip()
|
||||
parts = blob.split(b"|", 2)
|
||||
if len(parts) != 3 or parts[0] != b"LIC2":
|
||||
return {"ok": False, "reason": "授权文件格式无效"}
|
||||
return _license_result({"ok": False, "reason": "授权文件格式无效"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
||||
|
||||
_, sig_b64, payload_b64 = parts
|
||||
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(_PUBLIC_KEY_B64))
|
||||
pub.verify(base64.b64decode(sig_b64), payload_b64)
|
||||
payload = json.loads(base64.b64decode(payload_b64))
|
||||
except InvalidSignature:
|
||||
return {"ok": False, "reason": "授权文件签名无效(可能已被篡改)"}
|
||||
return _license_result({"ok": False, "reason": "授权文件签名无效(可能已被篡改)"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
||||
except Exception as e:
|
||||
return {"ok": False, "reason": f"授权文件解析失败: {e}"}
|
||||
return _license_result({"ok": False, "reason": f"授权文件解析失败: {e}"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
||||
|
||||
fp_expected = payload.get("fingerprint")
|
||||
fp_actual = _get_machine_fingerprint()
|
||||
if not fp_expected or fp_expected != fp_actual:
|
||||
return {"ok": False, "reason": "机器指纹不匹配"}
|
||||
return _license_result({"ok": False, "reason": "机器指纹不匹配"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
||||
|
||||
expires_at = payload.get("expires_at")
|
||||
if not expires_at:
|
||||
return {"ok": False, "reason": "授权文件缺少有效期"}
|
||||
return _license_result({"ok": False, "reason": "授权文件缺少有效期"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
||||
try:
|
||||
expires_dt = datetime.fromisoformat(expires_at)
|
||||
except Exception:
|
||||
return {"ok": False, "reason": "有效期格式错误"}
|
||||
return _license_result({"ok": False, "reason": "有效期格式错误"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
||||
|
||||
if _now_utc() > expires_dt:
|
||||
return {"ok": False, "reason": "授权已过期"}
|
||||
return _license_result({"ok": False, "reason": "授权已过期"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
||||
|
||||
return {
|
||||
return _license_result({
|
||||
"ok": True,
|
||||
"issued_to": payload.get("issued_to"),
|
||||
"expires_at": expires_at,
|
||||
"fingerprint": fp_actual,
|
||||
"license_path": license_path,
|
||||
}
|
||||
}, use_cache=use_cache, path=license_path, mtime=mtime)
|
||||
|
||||
@@ -126,6 +126,17 @@ _STATS_CACHE_DATA: Optional[Dict[str, Any]] = None
|
||||
_STATS_CACHE_EXPIRES_AT = 0.0
|
||||
_STATS_CACHE_GENERATED_AT_UTC: Optional[str] = None
|
||||
|
||||
DASHBOARD_STATS_CACHE_TTL_SECONDS = read_int_env(
|
||||
"DASHBOARD_STATS_CACHE_TTL_SECONDS",
|
||||
STATS_CACHE_TTL_SECONDS,
|
||||
minimum=0,
|
||||
maximum=3600,
|
||||
)
|
||||
_DASHBOARD_STATS_CACHE_LOCK = asyncio.Lock()
|
||||
_DASHBOARD_STATS_CACHE_DATA: Optional[Dict[str, Any]] = None
|
||||
_DASHBOARD_STATS_CACHE_EXPIRES_AT = 0.0
|
||||
_DASHBOARD_STATS_CACHE_GENERATED_AT_UTC: Optional[str] = None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AOI token store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -42,6 +42,12 @@ LANDSAR_PRODUCTION_JOB_MAX_ATTEMPTS = read_int_env(
|
||||
minimum=1,
|
||||
maximum=10,
|
||||
)
|
||||
LANDSAR_CLUSTER_ITEM_JOB_MAX_ATTEMPTS = read_int_env(
|
||||
"LANDSAR_CLUSTER_ITEM_JOB_MAX_ATTEMPTS",
|
||||
1,
|
||||
minimum=1,
|
||||
maximum=10,
|
||||
)
|
||||
|
||||
|
||||
class RunJobRequest(BaseModel):
|
||||
@@ -441,6 +447,114 @@ async def submit_run(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/landsar-cluster/run")
|
||||
async def submit_landsar_cluster_run(
|
||||
req: RunJobRequest,
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
if str(req.engine_code or "").strip().lower() != "landsar":
|
||||
raise HTTPException(status_code=400, detail="LandSAR cluster only accepts engine_code='landsar'.")
|
||||
|
||||
registry = _get_registry()
|
||||
engine = registry.get_engine("landsar")
|
||||
if not engine:
|
||||
raise HTTPException(status_code=400, detail="Engine 'landsar' not found.")
|
||||
|
||||
valid_profiles = {profile.code for profile in engine.get_profiles()}
|
||||
if req.profile not in valid_profiles:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Engine 'landsar' does not support profile '{req.profile}'. "
|
||||
f"Available profiles: {sorted(valid_profiles)}"
|
||||
),
|
||||
)
|
||||
|
||||
validation_summary = None
|
||||
if hasattr(engine, "validate_root_dir"):
|
||||
try:
|
||||
validation_summary = await asyncio.to_thread(
|
||||
engine.validate_root_dir,
|
||||
req.root_dir,
|
||||
req.num_to_process,
|
||||
req.rerun_mode,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if int(validation_summary.get("task_count", 0) or 0) <= 0:
|
||||
if (
|
||||
req.rerun_mode == "unfinished_only"
|
||||
and int(validation_summary.get("skipped_completed_count", 0) or 0) > 0
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"All discovered Task_* directories already have completed "
|
||||
f"landsar/{req.profile} results under: {req.root_dir}"
|
||||
),
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="No valid LandSAR task directories selected.")
|
||||
|
||||
normalized_extra = dict(req.extra or {})
|
||||
if hasattr(engine, "normalize_extra"):
|
||||
try:
|
||||
normalized_extra = engine.normalize_extra(normalized_extra)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
if validation_summary is not None:
|
||||
validated_task_count = validation_summary.get("task_count", 0)
|
||||
normalized_extra.update(
|
||||
{
|
||||
"__validated_task_count": validated_task_count,
|
||||
"__validated_mode": validation_summary.get("mode", ""),
|
||||
"__rerun_mode": req.rerun_mode,
|
||||
"__discovered_task_count": int(validation_summary.get("discovered_task_count", validated_task_count) or 0),
|
||||
"__skipped_completed_count": int(validation_summary.get("skipped_completed_count", 0) or 0),
|
||||
"__cluster": True,
|
||||
}
|
||||
)
|
||||
|
||||
effective_timeout_seconds = req.timeout_seconds
|
||||
if effective_timeout_seconds is None:
|
||||
engine_default_timeout = getattr(engine, "default_timeout_seconds", None)
|
||||
if engine_default_timeout:
|
||||
effective_timeout_seconds = int(engine_default_timeout)
|
||||
|
||||
try:
|
||||
async with _new_session() as db:
|
||||
result = await dinsar_production_service.create_landsar_cluster_run(
|
||||
profile_code=req.profile,
|
||||
root_dir=req.root_dir,
|
||||
num_to_process=req.num_to_process,
|
||||
rerun_mode=req.rerun_mode,
|
||||
timeout_seconds=effective_timeout_seconds,
|
||||
extra=normalized_extra,
|
||||
created_by=getattr(current_user, "username", None),
|
||||
max_attempts=LANDSAR_CLUSTER_ITEM_JOB_MAX_ATTEMPTS,
|
||||
db=db,
|
||||
)
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
status_code = 409 if "浠诲姟鍐茬獊" in message else 400
|
||||
raise HTTPException(status_code=status_code, detail=message) from exc
|
||||
|
||||
return {
|
||||
"task_id": result["task_id"],
|
||||
"job_id": None,
|
||||
"run_id": result["run_id"],
|
||||
"workflow_run_id": None,
|
||||
"job_type": "LANDSAR_CLUSTER_ITEM",
|
||||
"engine_code": "landsar",
|
||||
"profile": req.profile,
|
||||
"selected_task_count": result.get("selected_task_count", 0),
|
||||
"discovered_task_count": result.get("discovered_task_count", result.get("selected_task_count", 0)),
|
||||
"skipped_completed_count": result.get("skipped_completed_count", 0),
|
||||
"rerun_mode": result.get("rerun_mode", req.rerun_mode),
|
||||
"message": "LandSAR cluster items queued.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
async def list_runs(limit: int = 20, offset: int = 0):
|
||||
async with _new_session() as db:
|
||||
|
||||
@@ -318,10 +318,7 @@ async def _build_radar_preview_cache(
|
||||
geo_error = "invalid_bbox"
|
||||
has_geo_cache = False
|
||||
else:
|
||||
source_corner_mapping = await asyncio.to_thread(
|
||||
data_service.get_radar_source_corner_mapping,
|
||||
record.file_path,
|
||||
)
|
||||
source_corner_mapping = data_service.get_radar_record_corner_mapping(record)
|
||||
ok_geo, geo_error = await asyncio.to_thread(
|
||||
image_service.create_geocorrected_radar_cached_image,
|
||||
preview_source,
|
||||
@@ -408,6 +405,7 @@ async def _get_cached_radar_preview(data_id: int, db: AsyncSession):
|
||||
raw_cache_path, geo_cache_path = _radar_preview_paths(record)
|
||||
if (
|
||||
(record.preview_cache_status or "NONE") == "READY"
|
||||
and (record.preview_cache_version or "") == settings.RADAR_GEO_CACHE_VERSION
|
||||
and record.preview_cache_path
|
||||
and str(record.preview_cache_path).lower().endswith(".webp")
|
||||
and os.path.exists(record.preview_cache_path)
|
||||
@@ -418,7 +416,7 @@ async def _get_cached_radar_preview(data_id: int, db: AsyncSession):
|
||||
headers={"Cache-Control": "public, max-age=31536000"},
|
||||
)
|
||||
|
||||
if os.path.exists(geo_cache_path):
|
||||
if os.path.exists(geo_cache_path) and (record.preview_cache_version or "") == settings.RADAR_GEO_CACHE_VERSION:
|
||||
return FileResponse(
|
||||
geo_cache_path,
|
||||
media_type="image/webp",
|
||||
@@ -523,7 +521,6 @@ async def search_radar_data_endpoint(
|
||||
product_unique_id: Optional[str] = Form(None),
|
||||
orbit_direction: Optional[str] = Form(None),
|
||||
has_orbit_data: Optional[bool] = Form(None),
|
||||
is_envi_processed: Optional[bool] = Form(None),
|
||||
imaging_date_from: Optional[str] = Form(None),
|
||||
imaging_date_to: Optional[str] = Form(None),
|
||||
region_tree_id: Optional[str] = Form(None),
|
||||
@@ -609,8 +606,6 @@ async def search_radar_data_endpoint(
|
||||
filters.append(RadarDataORM.orbit_direction.ilike(f"%{n_orbit_direction}%"))
|
||||
if has_orbit_data is not None:
|
||||
filters.append(RadarDataORM.has_orbit_data == has_orbit_data)
|
||||
if is_envi_processed is not None:
|
||||
filters.append(RadarDataORM.is_envi_processed == is_envi_processed)
|
||||
if n_date_from:
|
||||
filters.append(RadarDataORM.imaging_date >= n_date_from)
|
||||
if n_date_to:
|
||||
|
||||
+1245
-1
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import Point, shape
|
||||
from shapely.geometry import MultiPolygon, Point, shape
|
||||
from shapely.ops import unary_union
|
||||
|
||||
try:
|
||||
@@ -36,6 +36,7 @@ class _RegionGeometryRecord:
|
||||
_REGION_GEOMETRY_CACHE: list[_RegionGeometryRecord] | None = None
|
||||
_REGION_BY_ID_LOOKUP_CACHE: dict[str, dict[str, Any]] | None = None
|
||||
_REGION_LOAD_ERROR: str | None = None
|
||||
_POLYGONAL_GEOMETRY_TYPES = {"Polygon", "MultiPolygon"}
|
||||
|
||||
|
||||
def _backend_geojson_dir() -> Path:
|
||||
@@ -190,6 +191,40 @@ def _build_region_path(tree_id: str, region_by_id: dict[str, dict[str, Any]]) ->
|
||||
return names, tree_ids
|
||||
|
||||
|
||||
def _as_polygonal_geometry(geometry):
|
||||
if geometry is None or geometry.is_empty:
|
||||
return None
|
||||
if getattr(geometry, "geom_type", None) in _POLYGONAL_GEOMETRY_TYPES:
|
||||
return geometry
|
||||
|
||||
polygon_parts = []
|
||||
for part in getattr(geometry, "geoms", []) or []:
|
||||
polygonal = _as_polygonal_geometry(part)
|
||||
if polygonal is None or polygonal.is_empty:
|
||||
continue
|
||||
if getattr(polygonal, "geom_type", None) == "Polygon":
|
||||
polygon_parts.append(polygonal)
|
||||
else:
|
||||
polygon_parts.extend([item for item in getattr(polygonal, "geoms", []) if not item.is_empty])
|
||||
|
||||
if not polygon_parts:
|
||||
return None
|
||||
if len(polygon_parts) == 1:
|
||||
return polygon_parts[0]
|
||||
try:
|
||||
merged = unary_union(polygon_parts)
|
||||
if merged is not None and not merged.is_empty:
|
||||
if getattr(merged, "geom_type", None) in _POLYGONAL_GEOMETRY_TYPES:
|
||||
return merged
|
||||
return _as_polygonal_geometry(merged)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return MultiPolygon(polygon_parts)
|
||||
except Exception:
|
||||
return polygon_parts[0]
|
||||
|
||||
|
||||
def _load_region_records() -> tuple[list[_RegionGeometryRecord], dict[str, dict[str, Any]], str | None]:
|
||||
global _REGION_BY_ID_LOOKUP_CACHE, _REGION_GEOMETRY_CACHE, _REGION_LOAD_ERROR
|
||||
if _REGION_GEOMETRY_CACHE is not None:
|
||||
@@ -228,6 +263,7 @@ def _load_region_records() -> tuple[list[_RegionGeometryRecord], dict[str, dict[
|
||||
if not geometries:
|
||||
continue
|
||||
geometry = _merge_region_geometries(geometries)
|
||||
geometry = _as_polygonal_geometry(geometry)
|
||||
if geometry is None or geometry.is_empty:
|
||||
continue
|
||||
node = region_by_id.get(tree_id) or {}
|
||||
|
||||
@@ -9,8 +9,10 @@ import re
|
||||
import shutil
|
||||
import tarfile
|
||||
import zipfile
|
||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import PurePosixPath
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
from geoalchemy2.shape import from_shape
|
||||
@@ -35,6 +37,7 @@ from ..models import (
|
||||
SourceProductAssetORM,
|
||||
)
|
||||
from ..utils import (
|
||||
build_corner_pixel_mapping,
|
||||
find_xml_file,
|
||||
normalize_satellite_family,
|
||||
parse_gf3_l2_dirname,
|
||||
@@ -55,6 +58,9 @@ LT1_ORBIT_MATCH_RULE_VERSION = "lt1_orbit_day_v1"
|
||||
ASSET_SCAN_LOG_INTERVAL = 100
|
||||
ASSET_SCAN_DETAILED_PARSE_LOG_LIMIT = 200
|
||||
ARCHIVE_INTEGRITY_LOG_INTERVAL = 10
|
||||
DEFAULT_ASSET_SCAN_PARSE_WORKERS = 4
|
||||
DEFAULT_ASSET_SCAN_PARSE_INFLIGHT = 64
|
||||
DEFAULT_ASSET_SCAN_DB_BATCH_SIZE = 50
|
||||
|
||||
_WINDOWS_DRIVE_RE = re.compile(r"^[a-zA-Z]:[\\/]")
|
||||
_S1_SOURCE_RE = re.compile(
|
||||
@@ -458,32 +464,60 @@ def _archive_read_first_matching(path: str, predicate: Callable[[str], bool]) ->
|
||||
return None, None, members
|
||||
|
||||
|
||||
def _archive_list_matching(path: str, predicate: Callable[[str], bool], *, limit: int = 20) -> List[str]:
|
||||
matches: List[str] = []
|
||||
def _archive_collect_members(
|
||||
path: str,
|
||||
*,
|
||||
content_predicate: Callable[[str], bool],
|
||||
list_predicate: Callable[[str], bool],
|
||||
list_limit: int = 20,
|
||||
) -> Tuple[Optional[str], Optional[bytes], List[str], List[str]]:
|
||||
content_name: Optional[str] = None
|
||||
content_data: Optional[bytes] = None
|
||||
listed: List[str] = []
|
||||
scanned: List[str] = []
|
||||
target_list_count = max(0, int(list_limit))
|
||||
|
||||
def _visit(name: str) -> None:
|
||||
if predicate(name):
|
||||
matches.append(name)
|
||||
def _visit(name: str, reader: Callable[[], bytes]) -> None:
|
||||
nonlocal content_name, content_data
|
||||
scanned.append(name)
|
||||
if list_predicate(name) and len(listed) < target_list_count:
|
||||
listed.append(name)
|
||||
if content_name is None and content_predicate(name):
|
||||
content_name = name
|
||||
content_data = reader()
|
||||
|
||||
def _done() -> bool:
|
||||
return content_name is not None and len(listed) >= target_list_count
|
||||
|
||||
if zipfile.is_zipfile(path):
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
for info in archive.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
_visit(info.filename)
|
||||
if len(matches) >= limit:
|
||||
_visit(info.filename, lambda info=info: archive.read(info))
|
||||
if _done():
|
||||
break
|
||||
return matches
|
||||
return content_name, content_data, listed, scanned
|
||||
|
||||
if tarfile.is_tarfile(path):
|
||||
with tarfile.open(path, "r:*") as archive:
|
||||
for member in archive:
|
||||
if not member.isfile():
|
||||
continue
|
||||
_visit(member.name)
|
||||
if len(matches) >= limit:
|
||||
|
||||
def _read(member=member) -> bytes:
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
return b""
|
||||
with source:
|
||||
return source.read()
|
||||
|
||||
_visit(member.name, _read)
|
||||
if _done():
|
||||
break
|
||||
return matches
|
||||
return content_name, content_data, listed, scanned
|
||||
|
||||
return None, None, listed, scanned
|
||||
|
||||
|
||||
def _safe_archive_member_name(member_name: str, archive_path: str) -> str:
|
||||
@@ -783,8 +817,19 @@ def _parse_radar_xml_metadata_bytes(data: bytes) -> Tuple[Optional[List[Tuple[fl
|
||||
)
|
||||
|
||||
coverage_polygon: Optional[List[Tuple[float, float]]] = None
|
||||
corner_details: Dict[str, Dict[str, Any]] = {}
|
||||
if len(corners) >= 4:
|
||||
coverage_polygon = _ordered_closed_polygon_from_corners(corners[:4])
|
||||
corner_details = {
|
||||
str(item.get("name")): {
|
||||
"lon": item.get("lon"),
|
||||
"lat": item.get("lat"),
|
||||
"ref_row": item.get("ref_row"),
|
||||
"ref_col": item.get("ref_col"),
|
||||
}
|
||||
for item in corners
|
||||
if item.get("name")
|
||||
}
|
||||
|
||||
start_time = (
|
||||
_xml_text_under_local_path(root, "start", "timeUTC")
|
||||
@@ -834,6 +879,7 @@ def _parse_radar_xml_metadata_bytes(data: bytes) -> Tuple[Optional[List[Tuple[fl
|
||||
for item in corners
|
||||
if item.get("name")
|
||||
},
|
||||
"corner_pixel_mapping": build_corner_pixel_mapping(corner_details),
|
||||
"coverage_polygon": coverage_polygon,
|
||||
}
|
||||
return coverage_polygon, {key: value for key, value in metadata.items() if value not in (None, "", [])}
|
||||
@@ -890,6 +936,58 @@ def _metadata_document(
|
||||
}
|
||||
|
||||
|
||||
def _s1_annotation_sort_key(path: str) -> Tuple[int, str]:
|
||||
normalized = str(path or "").replace("\\", "/")
|
||||
lower = normalized.lower()
|
||||
base = PurePosixPath(normalized).name.lower()
|
||||
is_direct_annotation = "/annotation/" in lower and lower.count("/annotation/") == 1
|
||||
is_measurement_annotation = is_direct_annotation and base.startswith("s1") and base.endswith(".xml")
|
||||
if is_measurement_annotation:
|
||||
rank = 0
|
||||
elif "/annotation/calibration/" in lower:
|
||||
rank = 2
|
||||
elif "/annotation/noise/" in lower:
|
||||
rank = 3
|
||||
elif "/annotation/rfi/" in lower:
|
||||
rank = 4
|
||||
else:
|
||||
rank = 1
|
||||
return rank, lower
|
||||
|
||||
|
||||
def _parse_s1_preview_kml_bytes(data: bytes) -> Dict[str, Any]:
|
||||
try:
|
||||
root = etree.fromstring(data, parser=_xml_parser())
|
||||
except Exception:
|
||||
return {}
|
||||
coordinate_texts = root.xpath("//*[local-name()='LatLonQuad']/*[local-name()='coordinates']/text()")
|
||||
if not coordinate_texts:
|
||||
return {}
|
||||
points: List[Tuple[float, float]] = []
|
||||
for token in str(coordinate_texts[0] or "").strip().split():
|
||||
parts = token.split(",")
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
try:
|
||||
points.append((float(parts[0]), float(parts[1])))
|
||||
except ValueError:
|
||||
continue
|
||||
if len(points) != 4:
|
||||
return {}
|
||||
mapping = {
|
||||
"bottom_left": [points[0][0], points[0][1]],
|
||||
"bottom_right": [points[1][0], points[1][1]],
|
||||
"top_right": [points[2][0], points[2][1]],
|
||||
"top_left": [points[3][0], points[3][1]],
|
||||
"source": "s1_preview_map_overlay_kml",
|
||||
}
|
||||
polygon = [points[0], points[1], points[2], points[3], points[0]]
|
||||
return {
|
||||
"preview_map_overlay_polygon": polygon,
|
||||
"corner_pixel_mapping": mapping,
|
||||
}
|
||||
|
||||
|
||||
def _extract_s1_annotation_documents(source_path: str, *, limit: int = 16) -> List[Dict[str, Any]]:
|
||||
docs: List[Dict[str, Any]] = []
|
||||
stat = _stat_path(source_path)
|
||||
@@ -902,7 +1000,7 @@ def _extract_s1_annotation_documents(source_path: str, *, limit: int = 16) -> Li
|
||||
for file_name in files:
|
||||
if file_name.lower().endswith(".xml"):
|
||||
candidates.append(os.path.join(current_root, file_name))
|
||||
for path in sorted(candidates)[: max(0, limit)]:
|
||||
for path in sorted(candidates, key=_s1_annotation_sort_key)[: max(0, limit)]:
|
||||
try:
|
||||
with open(path, "rb") as stream:
|
||||
data = stream.read()
|
||||
@@ -928,7 +1026,7 @@ def _extract_s1_annotation_documents(source_path: str, *, limit: int = 16) -> Li
|
||||
for name in archive.namelist()
|
||||
if "/annotation/" in name.lower() and name.lower().endswith(".xml")
|
||||
]
|
||||
for name in sorted(names)[: max(0, limit)]:
|
||||
for name in sorted(names, key=_s1_annotation_sort_key)[: max(0, limit)]:
|
||||
docs.append(
|
||||
_metadata_document(
|
||||
document_type="S1_ANNOTATION",
|
||||
@@ -1099,13 +1197,48 @@ def _parse_s1_manifest_bytes(data: bytes) -> Dict[str, Any]:
|
||||
def _parse_s1_zip_manifest(path: str) -> Dict[str, Any]:
|
||||
stat = _stat_path(path)
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
manifest_name = next(
|
||||
(name for name in archive.namelist() if name.lower().endswith("/manifest.safe") or name.lower() == "manifest.safe"),
|
||||
None,
|
||||
)
|
||||
manifest_name = None
|
||||
preview_kml_name = None
|
||||
annotation_names: List[str] = []
|
||||
for info in archive.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
name = info.filename
|
||||
lower = name.lower()
|
||||
if manifest_name is None and (lower.endswith("/manifest.safe") or lower == "manifest.safe"):
|
||||
manifest_name = name
|
||||
if preview_kml_name is None and lower.endswith("/preview/map-overlay.kml"):
|
||||
preview_kml_name = name
|
||||
if "/annotation/" in lower and lower.endswith(".xml"):
|
||||
annotation_names.append(name)
|
||||
if not manifest_name:
|
||||
return {"manifest_parse_status": "MISSING"}
|
||||
manifest_bytes = archive.read(manifest_name)
|
||||
annotation_documents = [
|
||||
_metadata_document(
|
||||
document_type="S1_ANNOTATION",
|
||||
member_path=name,
|
||||
content=archive.read(name),
|
||||
source_format="S1_ZIP",
|
||||
satellite_family="S1",
|
||||
archive_path=path,
|
||||
archive_mtime=stat.get("mtime_epoch"),
|
||||
)
|
||||
for name in sorted(annotation_names, key=_s1_annotation_sort_key)[:16]
|
||||
]
|
||||
preview_kml_bytes = archive.read(preview_kml_name) if preview_kml_name else None
|
||||
preview_kml_meta = _parse_s1_preview_kml_bytes(preview_kml_bytes) if preview_kml_bytes else {}
|
||||
preview_kml_documents = [
|
||||
_metadata_document(
|
||||
document_type="S1_PREVIEW_KML",
|
||||
member_path=preview_kml_name or "preview/map-overlay.kml",
|
||||
content=preview_kml_bytes,
|
||||
source_format="S1_ZIP",
|
||||
satellite_family="S1",
|
||||
archive_path=path,
|
||||
archive_mtime=stat.get("mtime_epoch"),
|
||||
)
|
||||
] if preview_kml_bytes else []
|
||||
return {
|
||||
"manifest_parse_status": "OK",
|
||||
"manifest_path": manifest_name,
|
||||
@@ -1119,8 +1252,10 @@ def _parse_s1_zip_manifest(path: str) -> Dict[str, Any]:
|
||||
archive_path=path,
|
||||
archive_mtime=stat.get("mtime_epoch"),
|
||||
),
|
||||
*_extract_s1_annotation_documents(path),
|
||||
*preview_kml_documents,
|
||||
*annotation_documents,
|
||||
],
|
||||
**preview_kml_meta,
|
||||
**_parse_s1_manifest_bytes(manifest_bytes),
|
||||
}
|
||||
|
||||
@@ -1132,6 +1267,23 @@ def _parse_s1_safe_manifest(path: str) -> Dict[str, Any]:
|
||||
stat = _stat_path(path)
|
||||
with open(manifest_path, "rb") as stream:
|
||||
manifest_bytes = stream.read()
|
||||
preview_kml_path = os.path.join(path, "preview", "map-overlay.kml")
|
||||
preview_kml_bytes = None
|
||||
if os.path.isfile(preview_kml_path):
|
||||
with open(preview_kml_path, "rb") as preview_stream:
|
||||
preview_kml_bytes = preview_stream.read()
|
||||
preview_kml_meta = _parse_s1_preview_kml_bytes(preview_kml_bytes) if preview_kml_bytes else {}
|
||||
preview_kml_documents = [
|
||||
_metadata_document(
|
||||
document_type="S1_PREVIEW_KML",
|
||||
member_path="preview/map-overlay.kml",
|
||||
content=preview_kml_bytes,
|
||||
source_format="S1_SAFE_DIR",
|
||||
satellite_family="S1",
|
||||
archive_path=path,
|
||||
archive_mtime=stat.get("mtime_epoch"),
|
||||
)
|
||||
] if preview_kml_bytes else []
|
||||
return {
|
||||
"manifest_parse_status": "OK",
|
||||
"manifest_path": manifest_path,
|
||||
@@ -1145,8 +1297,10 @@ def _parse_s1_safe_manifest(path: str) -> Dict[str, Any]:
|
||||
archive_path=path,
|
||||
archive_mtime=stat.get("mtime_epoch"),
|
||||
),
|
||||
*preview_kml_documents,
|
||||
*_extract_s1_annotation_documents(path),
|
||||
],
|
||||
**preview_kml_meta,
|
||||
**_parse_s1_manifest_bytes(manifest_bytes),
|
||||
}
|
||||
|
||||
@@ -1154,14 +1308,11 @@ def _parse_s1_safe_manifest(path: str) -> Dict[str, Any]:
|
||||
def _parse_lt1_archive_metadata(path: str) -> Dict[str, Any]:
|
||||
archive_stem = _strip_known_suffix(os.path.basename(path))
|
||||
stat = _stat_path(path)
|
||||
xml_member, xml_data, members = _archive_read_first_matching(
|
||||
xml_member, xml_data, tiff_members, members = _archive_collect_members(
|
||||
path,
|
||||
lambda name: _archive_member_base_name(name).lower().endswith(".meta.xml"),
|
||||
)
|
||||
tiff_members = _archive_list_matching(
|
||||
path,
|
||||
lambda name: _archive_member_base_name(name).lower().endswith((".tiff", ".tif")),
|
||||
limit=8,
|
||||
content_predicate=lambda name: _archive_member_base_name(name).lower().endswith(".meta.xml"),
|
||||
list_predicate=lambda name: _archive_member_base_name(name).lower().endswith((".tiff", ".tif")),
|
||||
list_limit=8,
|
||||
)
|
||||
if not xml_member or not xml_data:
|
||||
return {
|
||||
@@ -1193,14 +1344,11 @@ def _parse_lt1_archive_metadata(path: str) -> Dict[str, Any]:
|
||||
|
||||
def _parse_gf3_archive_metadata(path: str) -> Dict[str, Any]:
|
||||
archive_stem = _strip_known_suffix(os.path.basename(path))
|
||||
xml_member, xml_data, members = _archive_read_first_matching(
|
||||
xml_member, xml_data, quicklooks, members = _archive_collect_members(
|
||||
path,
|
||||
lambda name: _archive_member_base_name(name).lower().endswith(".xml"),
|
||||
)
|
||||
quicklooks = _archive_list_matching(
|
||||
path,
|
||||
lambda name: _archive_member_base_name(name).lower().endswith((".jpg", ".jpeg", ".png", ".bmp", "_ql.tif", "_ql.tiff")),
|
||||
limit=8,
|
||||
content_predicate=lambda name: _archive_member_base_name(name).lower().endswith(".xml"),
|
||||
list_predicate=lambda name: _archive_member_base_name(name).lower().endswith((".jpg", ".jpeg", ".png", ".bmp", "_ql.tif", "_ql.tiff")),
|
||||
list_limit=8,
|
||||
)
|
||||
if not xml_member or not xml_data:
|
||||
return {
|
||||
@@ -1782,6 +1930,8 @@ def _cached_source_asset_is_unchanged(
|
||||
cached: Optional[Dict[str, Any]],
|
||||
stat: Dict[str, Optional[float]],
|
||||
root: ManagedRootORM,
|
||||
*,
|
||||
skip_unchanged_failures: bool = True,
|
||||
) -> bool:
|
||||
if not cached:
|
||||
return False
|
||||
@@ -1797,7 +1947,11 @@ def _cached_source_asset_is_unchanged(
|
||||
return False
|
||||
if str(cached.get("parser_version") or "") != PARSER_VERSION:
|
||||
return False
|
||||
if str(cached.get("parse_status") or "").upper() != "OK":
|
||||
parse_status = str(cached.get("parse_status") or "").upper()
|
||||
allowed_statuses = {"OK"}
|
||||
if skip_unchanged_failures:
|
||||
allowed_statuses.update({"PARTIAL", "FAILED"})
|
||||
if parse_status not in allowed_statuses:
|
||||
return False
|
||||
return _same_size(cached.get("size_bytes"), stat.get("size_bytes")) and _same_mtime(
|
||||
cached.get("mtime_epoch"),
|
||||
@@ -1840,14 +1994,27 @@ def _collect_source_assets_incremental(
|
||||
log_callback: Optional[Callable[[str, str], None]] = None,
|
||||
progress_start: int = 0,
|
||||
progress_end: int = 100,
|
||||
parse_workers: int = DEFAULT_ASSET_SCAN_PARSE_WORKERS,
|
||||
parse_inflight: int = DEFAULT_ASSET_SCAN_PARSE_INFLIGHT,
|
||||
skip_unchanged_failures: bool = True,
|
||||
row_batch_callback: Optional[Callable[[List[Dict[str, Any]]], None]] = None,
|
||||
row_batch_size: int = DEFAULT_ASSET_SCAN_DB_BATCH_SIZE,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], int, int, List[str]]:
|
||||
rows: List[Dict[str, Any]] = []
|
||||
pending_rows: List[Dict[str, Any]] = []
|
||||
issues: List[Dict[str, Any]] = []
|
||||
seen_paths: List[str] = []
|
||||
seen_path_set: set[str] = set()
|
||||
entry_count = 0
|
||||
skipped_unchanged = 0
|
||||
skipped_unchanged_ok = 0
|
||||
skipped_unchanged_failed = 0
|
||||
parse_attempts = 0
|
||||
parse_completed = 0
|
||||
last_progress_count = 0
|
||||
parse_workers = max(1, int(parse_workers or 1))
|
||||
parse_inflight = max(parse_workers, int(parse_inflight or parse_workers))
|
||||
row_batch_size = max(1, int(row_batch_size or 1))
|
||||
|
||||
def _log(level: str, message: str) -> None:
|
||||
if log_callback:
|
||||
@@ -1857,39 +2024,37 @@ def _collect_source_assets_incremental(
|
||||
if progress_callback:
|
||||
progress_callback(_activity_progress(progress_start, progress_end, entry_count), message)
|
||||
|
||||
_log("INFO", f"Source root discovery started: {root.path}")
|
||||
for path in _iter_source_candidates(root.path):
|
||||
entry_count += 1
|
||||
normalized_path = _normalize_path(path)
|
||||
stat = _stat_path(normalized_path)
|
||||
if _cached_source_asset_is_unchanged(existing_by_path.get(normalized_path), stat, root):
|
||||
seen_paths.append(normalized_path)
|
||||
skipped_unchanged += 1
|
||||
if skipped_unchanged % ASSET_SCAN_LOG_INTERVAL == 0:
|
||||
_log(
|
||||
"INFO",
|
||||
f"Skipped unchanged source archives: {skipped_unchanged} (candidates={entry_count})",
|
||||
)
|
||||
if entry_count - last_progress_count >= ASSET_SCAN_LOG_INTERVAL:
|
||||
_progress(
|
||||
"Scanning source archives: "
|
||||
f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, issues={len(issues)}"
|
||||
)
|
||||
last_progress_count = entry_count
|
||||
continue
|
||||
parse_attempts += 1
|
||||
def _mark_seen(path: str) -> None:
|
||||
normalized = _normalize_path(path)
|
||||
if normalized and normalized not in seen_path_set:
|
||||
seen_path_set.add(normalized)
|
||||
seen_paths.append(normalized)
|
||||
|
||||
def _emit_row_batch(*, force: bool = False) -> None:
|
||||
if not row_batch_callback or not pending_rows:
|
||||
return
|
||||
if not force and len(pending_rows) < row_batch_size:
|
||||
return
|
||||
batch = pending_rows[:]
|
||||
pending_rows.clear()
|
||||
row_batch_callback(batch)
|
||||
|
||||
def _parse_one(index: int, normalized_path: str) -> Dict[str, Any]:
|
||||
file_name = os.path.basename(normalized_path)
|
||||
if parse_attempts <= ASSET_SCAN_DETAILED_PARSE_LOG_LIMIT or parse_attempts % ASSET_SCAN_LOG_INTERVAL == 0:
|
||||
_log("INFO", f"Extracting source archive metadata {parse_attempts}: {file_name}")
|
||||
if parse_attempts <= 50 or parse_attempts % 25 == 0:
|
||||
_progress(
|
||||
"Extracting source archive metadata: "
|
||||
f"{file_name} (changed/new={parse_attempts}, skipped={skipped_unchanged})"
|
||||
)
|
||||
try:
|
||||
row = _parse_source_entry(normalized_path, root)
|
||||
row = _parse_source_entry(normalized_path, parse_root)
|
||||
return {"index": index, "path": normalized_path, "file_name": file_name, "row": row, "error": None}
|
||||
except Exception as exc:
|
||||
row = None
|
||||
return {"index": index, "path": normalized_path, "file_name": file_name, "row": None, "error": exc}
|
||||
|
||||
def _handle_parse_result(result: Dict[str, Any]) -> None:
|
||||
nonlocal parse_completed, last_progress_count
|
||||
parse_completed += 1
|
||||
normalized_path = str(result.get("path") or "")
|
||||
file_name = str(result.get("file_name") or os.path.basename(normalized_path))
|
||||
exc = result.get("error")
|
||||
if exc is not None:
|
||||
_mark_seen(normalized_path)
|
||||
_log("WARNING", f"Source archive metadata parse failed: {file_name}: {exc}")
|
||||
issues.append(
|
||||
{
|
||||
@@ -1899,10 +2064,14 @@ def _collect_source_assets_incremental(
|
||||
"source_path": normalized_path,
|
||||
}
|
||||
)
|
||||
return
|
||||
row = result.get("row")
|
||||
if row is None:
|
||||
continue
|
||||
_mark_seen(normalized_path)
|
||||
return
|
||||
rows.append(row)
|
||||
seen_paths.append(str(row["file_path"]))
|
||||
pending_rows.append(row)
|
||||
_mark_seen(str(row["file_path"]))
|
||||
if row.get("parse_status") in {"FAILED", "PARTIAL"}:
|
||||
_log(
|
||||
"WARNING",
|
||||
@@ -1920,17 +2089,91 @@ def _collect_source_assets_incremental(
|
||||
if entry_count - last_progress_count >= ASSET_SCAN_LOG_INTERVAL:
|
||||
_progress(
|
||||
"Scanning source archives: "
|
||||
f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, issues={len(issues)}"
|
||||
f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, "
|
||||
f"completed={parse_completed}/{parse_attempts}, issues={len(issues)}"
|
||||
)
|
||||
last_progress_count = entry_count
|
||||
_emit_row_batch()
|
||||
|
||||
def _drain_completed(pending: set, *, wait_for_one: bool = False) -> set:
|
||||
if not pending:
|
||||
return pending
|
||||
timeout = None if wait_for_one else 0
|
||||
done, remaining = wait(pending, timeout=timeout, return_when=FIRST_COMPLETED)
|
||||
for future in done:
|
||||
_handle_parse_result(future.result())
|
||||
return remaining
|
||||
|
||||
_log(
|
||||
"INFO",
|
||||
"Source root discovery started: "
|
||||
f"{root.path} (workers={parse_workers}, inflight={parse_inflight}, "
|
||||
f"db_batch_size={row_batch_size}, skip_unchanged_failures={skip_unchanged_failures})",
|
||||
)
|
||||
parse_root = SimpleNamespace(id=root.id, path=root.path)
|
||||
with ThreadPoolExecutor(max_workers=parse_workers, thread_name_prefix="asset-parse") as executor:
|
||||
pending = set()
|
||||
for path in _iter_source_candidates(root.path):
|
||||
entry_count += 1
|
||||
normalized_path = _normalize_path(path)
|
||||
stat = _stat_path(normalized_path)
|
||||
cached = existing_by_path.get(normalized_path)
|
||||
if _cached_source_asset_is_unchanged(
|
||||
cached,
|
||||
stat,
|
||||
root,
|
||||
skip_unchanged_failures=skip_unchanged_failures,
|
||||
):
|
||||
_mark_seen(normalized_path)
|
||||
skipped_unchanged += 1
|
||||
cached_status = str((cached or {}).get("parse_status") or "").upper()
|
||||
if cached_status == "OK":
|
||||
skipped_unchanged_ok += 1
|
||||
else:
|
||||
skipped_unchanged_failed += 1
|
||||
if skipped_unchanged % ASSET_SCAN_LOG_INTERVAL == 0:
|
||||
_log(
|
||||
"INFO",
|
||||
"Skipped unchanged source archives: "
|
||||
f"{skipped_unchanged} (ok={skipped_unchanged_ok}, failed_cached={skipped_unchanged_failed}, "
|
||||
f"candidates={entry_count})",
|
||||
)
|
||||
if entry_count - last_progress_count >= ASSET_SCAN_LOG_INTERVAL:
|
||||
_progress(
|
||||
"Scanning source archives: "
|
||||
f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, "
|
||||
f"completed={parse_completed}/{parse_attempts}, issues={len(issues)}"
|
||||
)
|
||||
last_progress_count = entry_count
|
||||
continue
|
||||
parse_attempts += 1
|
||||
file_name = os.path.basename(normalized_path)
|
||||
if parse_attempts <= ASSET_SCAN_DETAILED_PARSE_LOG_LIMIT or parse_attempts % ASSET_SCAN_LOG_INTERVAL == 0:
|
||||
_log("INFO", f"Extracting source archive metadata {parse_attempts}: {file_name}")
|
||||
if parse_attempts <= 50 or parse_attempts % 25 == 0:
|
||||
_progress(
|
||||
"Extracting source archive metadata: "
|
||||
f"{file_name} (changed/new={parse_attempts}, completed={parse_completed}, "
|
||||
f"workers={parse_workers}, skipped={skipped_unchanged}, issue={len(issues)})"
|
||||
)
|
||||
pending.add(executor.submit(_parse_one, parse_attempts, normalized_path))
|
||||
while len(pending) >= parse_inflight:
|
||||
pending = _drain_completed(pending, wait_for_one=True)
|
||||
pending = _drain_completed(pending, wait_for_one=False)
|
||||
while pending:
|
||||
pending = _drain_completed(pending, wait_for_one=True)
|
||||
_emit_row_batch(force=True)
|
||||
_log(
|
||||
"INFO",
|
||||
"Source root discovery finished: "
|
||||
f"candidates={entry_count}, skipped={skipped_unchanged}, changed_or_new={len(rows)}, issues={len(issues)}",
|
||||
f"candidates={entry_count}, skipped={skipped_unchanged}, "
|
||||
f"skipped_ok={skipped_unchanged_ok}, skipped_failed_cached={skipped_unchanged_failed}, "
|
||||
f"changed_or_new={len(rows)}, parse_attempts={parse_attempts}, issues={len(issues)}",
|
||||
)
|
||||
_progress(
|
||||
"Source archive discovery finished: "
|
||||
f"candidates={entry_count}, skipped={skipped_unchanged}, changed_or_new={len(rows)}"
|
||||
f"candidates={entry_count}, skipped={skipped_unchanged}, changed_or_new={len(rows)}, "
|
||||
f"workers={parse_workers}"
|
||||
)
|
||||
return rows, issues, entry_count, skipped_unchanged, seen_paths
|
||||
|
||||
@@ -2152,6 +2395,8 @@ class AssetInventoryService:
|
||||
task_id: Optional[str] = None,
|
||||
progress_start: int = 84,
|
||||
progress_end: int = 96,
|
||||
progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
progress_interval: int = 1,
|
||||
) -> Dict[str, Any]:
|
||||
generated_session = db is None
|
||||
if generated_session:
|
||||
@@ -2218,6 +2463,16 @@ class AssetInventoryService:
|
||||
if limit and limit > 0:
|
||||
candidates = candidates[: int(limit)]
|
||||
summary["candidate_count"] = len(candidates)
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
{
|
||||
"event": "planned",
|
||||
"records_seen": summary["records_seen"],
|
||||
"candidate_count": summary["candidate_count"],
|
||||
"skipped_ready": summary["skipped_ready"],
|
||||
"families": target_families,
|
||||
}
|
||||
)
|
||||
if not apply:
|
||||
return summary
|
||||
if not candidates:
|
||||
@@ -2226,6 +2481,8 @@ class AssetInventoryService:
|
||||
f"Archive preview cache already ready: skipped={summary['skipped_ready']}",
|
||||
progress_end,
|
||||
)
|
||||
if progress_callback:
|
||||
progress_callback({"event": "completed", **summary})
|
||||
return summary
|
||||
|
||||
await self._progress(
|
||||
@@ -2235,6 +2492,7 @@ class AssetInventoryService:
|
||||
)
|
||||
thumb_size = (settings.RADAR_THUMBNAIL_MAX_SIZE, settings.RADAR_THUMBNAIL_MAX_SIZE)
|
||||
total = len(candidates)
|
||||
progress_interval = max(1, int(progress_interval or 1))
|
||||
for index, record in enumerate(candidates, start=1):
|
||||
unique_id = record.unique_id or record.file_path
|
||||
raw_cache_path = DataService.get_radar_raw_cache_path(unique_id, record.file_path)
|
||||
@@ -2277,10 +2535,7 @@ class AssetInventoryService:
|
||||
await task_service.add_log(task_id, "ERROR", f"Preview geometry invalid: {product_name}: {record.preview_cache_error}")
|
||||
db.add(record)
|
||||
else:
|
||||
source_corner_mapping = await asyncio.to_thread(
|
||||
DataService.get_radar_source_corner_mapping,
|
||||
record.file_path,
|
||||
)
|
||||
source_corner_mapping = DataService.get_radar_record_corner_mapping(record)
|
||||
ok_geo, geo_error = await asyncio.to_thread(
|
||||
image_service.create_geocorrected_radar_cached_image,
|
||||
preview_source,
|
||||
@@ -2327,6 +2582,31 @@ class AssetInventoryService:
|
||||
f"Building archive preview cache ({index}/{total}): ready={summary['ready']}, failed={summary['failed']}, missing={summary['missing_source']}",
|
||||
min(progress_end, progress),
|
||||
)
|
||||
if progress_callback and (
|
||||
index == 1
|
||||
or index == total
|
||||
or index % progress_interval == 0
|
||||
or (record.preview_cache_status or "").upper() == "FAILED"
|
||||
):
|
||||
progress_callback(
|
||||
{
|
||||
"event": "item",
|
||||
"processed": index,
|
||||
"total": total,
|
||||
"records_seen": summary["records_seen"],
|
||||
"candidate_count": summary["candidate_count"],
|
||||
"skipped_ready": summary["skipped_ready"],
|
||||
"ready": summary["ready"],
|
||||
"cached": summary["cached"],
|
||||
"failed": summary["failed"],
|
||||
"missing_source": summary["missing_source"],
|
||||
"raw_cached": summary["raw_cached"],
|
||||
"raw_failed": summary["raw_failed"],
|
||||
"product_name": product_name,
|
||||
"status": record.preview_cache_status,
|
||||
"error": record.preview_cache_error,
|
||||
}
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await self._progress(
|
||||
@@ -2338,6 +2618,8 @@ class AssetInventoryService:
|
||||
),
|
||||
progress_end,
|
||||
)
|
||||
if progress_callback:
|
||||
progress_callback({"event": "completed", **summary})
|
||||
return summary
|
||||
except Exception:
|
||||
if db is not None:
|
||||
@@ -2748,6 +3030,109 @@ class AssetInventoryService:
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
progress_callback, log_callback, drain_thread_events = self._thread_callbacks(task_id, loop)
|
||||
batch_write_start = max(progress_start, progress_end - 10)
|
||||
batch_write_end = max(batch_write_start, progress_end - 3)
|
||||
persisted_changed_count = 0
|
||||
|
||||
async def _upsert_source_asset_batch(rows_batch: Sequence[Dict[str, Any]], *, batch_index: int) -> int:
|
||||
if not rows_batch:
|
||||
return 0
|
||||
now = _utcnow()
|
||||
db_rows = [
|
||||
{key: value for key, value in row.items() if not str(key).startswith("_")}
|
||||
for row in rows_batch
|
||||
]
|
||||
for row in db_rows:
|
||||
stmt = pg_insert(SourceProductAssetORM).values(row)
|
||||
excluded = stmt.excluded
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["file_path"],
|
||||
set_={
|
||||
"asset_uid": excluded.asset_uid,
|
||||
"logical_product_uid": excluded.logical_product_uid,
|
||||
"satellite_family": excluded.satellite_family,
|
||||
"satellite": excluded.satellite,
|
||||
"source_format": excluded.source_format,
|
||||
"product_type": excluded.product_type,
|
||||
"product_level": excluded.product_level,
|
||||
"imaging_mode": excluded.imaging_mode,
|
||||
"polarization": excluded.polarization,
|
||||
"absolute_orbit": excluded.absolute_orbit,
|
||||
"relative_orbit": excluded.relative_orbit,
|
||||
"orbit_direction": excluded.orbit_direction,
|
||||
"acquisition_start_time_utc": excluded.acquisition_start_time_utc,
|
||||
"acquisition_stop_time_utc": excluded.acquisition_stop_time_utc,
|
||||
"imaging_date": excluded.imaging_date,
|
||||
"root_ref_id": excluded.root_ref_id,
|
||||
"root_path": excluded.root_path,
|
||||
"archive_path": excluded.archive_path,
|
||||
"path_kind": excluded.path_kind,
|
||||
"file_name": excluded.file_name,
|
||||
"file_stem": excluded.file_stem,
|
||||
"file_ext": excluded.file_ext,
|
||||
"size_bytes": excluded.size_bytes,
|
||||
"mtime_epoch": excluded.mtime_epoch,
|
||||
"checksum_status": excluded.checksum_status,
|
||||
"archive_integrity_status": "NOT_CHECKED",
|
||||
"archive_integrity_method": None,
|
||||
"archive_integrity_checked_at": None,
|
||||
"archive_integrity_error": None,
|
||||
"archive_integrity_version": None,
|
||||
"archive_integrity_member_count": None,
|
||||
"parser_name": excluded.parser_name,
|
||||
"parser_version": excluded.parser_version,
|
||||
"parse_status": excluded.parse_status,
|
||||
"parse_error": excluded.parse_error,
|
||||
"parsed_at": excluded.parsed_at,
|
||||
"metadata_json": excluded.metadata_json,
|
||||
"is_active": True,
|
||||
"missing_since": None,
|
||||
"updated_at": now,
|
||||
},
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.flush()
|
||||
|
||||
changed_paths = [str(row["file_path"]) for row in rows_batch if row.get("file_path")]
|
||||
asset_ids_by_path: Dict[str, int] = {}
|
||||
if changed_paths:
|
||||
result = await db.execute(
|
||||
select(SourceProductAssetORM.file_path, SourceProductAssetORM.id).where(
|
||||
SourceProductAssetORM.file_path.in_(changed_paths)
|
||||
)
|
||||
)
|
||||
asset_ids_by_path = {str(path): int(asset_id) for path, asset_id in result.all()}
|
||||
await self._upsert_metadata_documents_for_source_assets(db, rows_batch, asset_ids_by_path)
|
||||
await self._upsert_radar_records_for_source_assets(db, rows_batch, asset_ids_by_path)
|
||||
|
||||
await db.commit()
|
||||
if task_id:
|
||||
await task_service.add_log(
|
||||
task_id,
|
||||
"INFO",
|
||||
f"Source asset DB batch committed: batch={batch_index}, rows={len(rows_batch)}",
|
||||
)
|
||||
await self._progress(
|
||||
task_id,
|
||||
f"Committed source asset DB batch {batch_index}: rows={len(rows_batch)}",
|
||||
batch_write_start,
|
||||
)
|
||||
return len(rows_batch)
|
||||
|
||||
batch_index = 0
|
||||
|
||||
def _persist_row_batch(rows_batch: List[Dict[str, Any]]) -> None:
|
||||
nonlocal batch_index, persisted_changed_count
|
||||
if not rows_batch:
|
||||
return
|
||||
batch_index += 1
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
_upsert_source_asset_batch(rows_batch, batch_index=batch_index),
|
||||
loop,
|
||||
)
|
||||
persisted = int(future.result())
|
||||
persisted_changed_count += persisted
|
||||
|
||||
rows, issues, entry_count, skipped_unchanged, seen_paths = await asyncio.to_thread(
|
||||
_collect_source_assets_incremental,
|
||||
root,
|
||||
@@ -2756,104 +3141,24 @@ class AssetInventoryService:
|
||||
log_callback=log_callback,
|
||||
progress_start=progress_start,
|
||||
progress_end=max(progress_start + 1, progress_end - 8),
|
||||
parse_workers=settings.ASSET_SCAN_PARSE_WORKERS,
|
||||
parse_inflight=settings.ASSET_SCAN_PARSE_INFLIGHT,
|
||||
skip_unchanged_failures=settings.ASSET_SCAN_SKIP_UNCHANGED_FAILURES,
|
||||
row_batch_callback=_persist_row_batch if task_id else None,
|
||||
row_batch_size=settings.ASSET_SCAN_DB_BATCH_SIZE,
|
||||
)
|
||||
await drain_thread_events()
|
||||
changed_paths = [row["file_path"] for row in rows]
|
||||
if not task_id and rows:
|
||||
persisted_changed_count += await _upsert_source_asset_batch(rows, batch_index=1)
|
||||
now = _utcnow()
|
||||
write_start = max(progress_start, progress_end - 7)
|
||||
write_end = max(write_start, progress_end - 3)
|
||||
await self._progress(
|
||||
task_id,
|
||||
f"Writing source asset index: changed_or_new={len(rows)}, skipped={skipped_unchanged}",
|
||||
write_start,
|
||||
)
|
||||
db_rows = [
|
||||
{key: value for key, value in row.items() if not str(key).startswith("_")}
|
||||
for row in rows
|
||||
]
|
||||
if task_id:
|
||||
await task_service.add_log(
|
||||
task_id,
|
||||
"INFO",
|
||||
f"Source asset DB upsert started: changed_or_new={len(rows)}, skipped={skipped_unchanged}, seen={len(seen_paths)}",
|
||||
"Source asset DB batch upsert finished: "
|
||||
f"changed_or_new={len(rows)}, persisted={persisted_changed_count}, "
|
||||
f"batches={batch_index}, skipped={skipped_unchanged}, seen={len(seen_paths)}",
|
||||
)
|
||||
for index, row in enumerate(db_rows, start=1):
|
||||
stmt = pg_insert(SourceProductAssetORM).values(row)
|
||||
excluded = stmt.excluded
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["file_path"],
|
||||
set_={
|
||||
"asset_uid": excluded.asset_uid,
|
||||
"logical_product_uid": excluded.logical_product_uid,
|
||||
"satellite_family": excluded.satellite_family,
|
||||
"satellite": excluded.satellite,
|
||||
"source_format": excluded.source_format,
|
||||
"product_type": excluded.product_type,
|
||||
"product_level": excluded.product_level,
|
||||
"imaging_mode": excluded.imaging_mode,
|
||||
"polarization": excluded.polarization,
|
||||
"absolute_orbit": excluded.absolute_orbit,
|
||||
"relative_orbit": excluded.relative_orbit,
|
||||
"orbit_direction": excluded.orbit_direction,
|
||||
"acquisition_start_time_utc": excluded.acquisition_start_time_utc,
|
||||
"acquisition_stop_time_utc": excluded.acquisition_stop_time_utc,
|
||||
"imaging_date": excluded.imaging_date,
|
||||
"root_ref_id": excluded.root_ref_id,
|
||||
"root_path": excluded.root_path,
|
||||
"archive_path": excluded.archive_path,
|
||||
"path_kind": excluded.path_kind,
|
||||
"file_name": excluded.file_name,
|
||||
"file_stem": excluded.file_stem,
|
||||
"file_ext": excluded.file_ext,
|
||||
"size_bytes": excluded.size_bytes,
|
||||
"mtime_epoch": excluded.mtime_epoch,
|
||||
"checksum_status": excluded.checksum_status,
|
||||
"archive_integrity_status": "NOT_CHECKED",
|
||||
"archive_integrity_method": None,
|
||||
"archive_integrity_checked_at": None,
|
||||
"archive_integrity_error": None,
|
||||
"archive_integrity_version": None,
|
||||
"archive_integrity_member_count": None,
|
||||
"parser_name": excluded.parser_name,
|
||||
"parser_version": excluded.parser_version,
|
||||
"parse_status": excluded.parse_status,
|
||||
"parse_error": excluded.parse_error,
|
||||
"parsed_at": excluded.parsed_at,
|
||||
"metadata_json": excluded.metadata_json,
|
||||
"is_active": True,
|
||||
"missing_since": None,
|
||||
"updated_at": now,
|
||||
},
|
||||
)
|
||||
await db.execute(stmt)
|
||||
if index % ASSET_SCAN_LOG_INTERVAL == 0 or index == len(rows):
|
||||
progress_value = write_start
|
||||
if rows and write_end > write_start:
|
||||
progress_value = write_start + int(index / max(1, len(rows)) * (write_end - write_start))
|
||||
await self._progress(
|
||||
task_id,
|
||||
f"Writing source asset index: {index}/{len(rows)} changed_or_new, skipped={skipped_unchanged}",
|
||||
progress_value,
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
asset_ids_by_path: Dict[str, int] = {}
|
||||
if changed_paths:
|
||||
await self._progress(
|
||||
task_id,
|
||||
f"Updating radar scene records for changed source assets: {len(changed_paths)}",
|
||||
max(write_end, progress_end - 2),
|
||||
)
|
||||
result = await db.execute(
|
||||
select(SourceProductAssetORM.file_path, SourceProductAssetORM.id).where(
|
||||
SourceProductAssetORM.file_path.in_(changed_paths)
|
||||
)
|
||||
)
|
||||
asset_ids_by_path = {str(path): int(asset_id) for path, asset_id in result.all()}
|
||||
await self._upsert_metadata_documents_for_source_assets(db, rows, asset_ids_by_path)
|
||||
await self._upsert_radar_records_for_source_assets(db, rows, asset_ids_by_path)
|
||||
elif task_id:
|
||||
await task_service.add_log(task_id, "INFO", "No changed source assets; radar scene record update skipped.")
|
||||
|
||||
await self._progress(task_id, "Marking missing source assets and refreshing scan issues...", max(progress_end - 2, progress_start))
|
||||
await self._mark_missing_source_assets(db, root, seen_paths, now)
|
||||
|
||||
@@ -644,6 +644,21 @@ class DataService:
|
||||
return corner_mapping
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_radar_record_corner_mapping(record: Any) -> Optional[Dict[str, Any]]:
|
||||
record_state = getattr(record, "__dict__", {}) if record is not None else {}
|
||||
for metadata in (
|
||||
getattr(record, "metadata_json", None),
|
||||
getattr(record_state.get("source_product_asset"), "metadata_json", None),
|
||||
getattr(record_state.get("source_archive_asset"), "metadata_json", None),
|
||||
):
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
corner_mapping = metadata.get("corner_pixel_mapping")
|
||||
if isinstance(corner_mapping, dict):
|
||||
return corner_mapping
|
||||
return DataService.get_radar_source_corner_mapping(str(getattr(record, "file_path", "") or ""))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_coverage_polygon(coverage_polygon: Any) -> Optional[List[Tuple[float, float]]]:
|
||||
if isinstance(coverage_polygon, list):
|
||||
@@ -1102,6 +1117,7 @@ class DataService:
|
||||
"max_lon": record.max_lon,
|
||||
"max_lat": record.max_lat,
|
||||
"preview_cache_version": record.preview_cache_version,
|
||||
"source_corner_mapping": DataService.get_radar_record_corner_mapping(record),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1164,10 +1180,7 @@ class DataService:
|
||||
except (TypeError, ValueError):
|
||||
bbox = None
|
||||
|
||||
source_corner_mapping = await asyncio.to_thread(
|
||||
DataService.get_radar_source_corner_mapping,
|
||||
file_path,
|
||||
)
|
||||
source_corner_mapping = item.get("source_corner_mapping")
|
||||
|
||||
need_geo_rebuild = (
|
||||
(not geo_cache_mtime)
|
||||
|
||||
@@ -35,6 +35,7 @@ from .workflow_service import workflow_service
|
||||
TASK_TYPE_DINSAR_PRODUCTION = "IDL_RUN_DINSAR"
|
||||
TASK_TYPE_PYINT_DINSAR_PRODUCTION = "PYINT_RUN"
|
||||
TASK_TYPE_LANDSAR_DINSAR_PRODUCTION = "LANDSAR_RUN"
|
||||
TASK_TYPE_LANDSAR_CLUSTER_PRODUCTION = "LANDSAR_CLUSTER_RUN"
|
||||
RUN_STATUS_PENDING = "PENDING"
|
||||
RUN_STATUS_RUNNING = "RUNNING"
|
||||
RUN_STATUS_COMPLETED = "COMPLETED"
|
||||
@@ -775,6 +776,172 @@ class DinsarProductionService:
|
||||
"rerun_mode": selection["rerun_mode"],
|
||||
}
|
||||
|
||||
async def create_landsar_cluster_run(
|
||||
self,
|
||||
*,
|
||||
profile_code: str,
|
||||
root_dir: str,
|
||||
num_to_process: int,
|
||||
rerun_mode: Optional[str],
|
||||
timeout_seconds: Optional[int],
|
||||
extra: Optional[Dict[str, Any]],
|
||||
created_by: Optional[str],
|
||||
max_attempts: int,
|
||||
db: AsyncSession,
|
||||
) -> Dict[str, Any]:
|
||||
normalized_engine = "landsar"
|
||||
normalized_profile = str(profile_code or "").strip()
|
||||
normalized_root = _normalize_dir(root_dir, "root_dir")
|
||||
selection = await asyncio.to_thread(
|
||||
_select_run_items,
|
||||
normalized_root,
|
||||
engine_code=normalized_engine,
|
||||
profile_code=normalized_profile,
|
||||
num_to_process=max(0, int(num_to_process or 0)),
|
||||
rerun_mode=rerun_mode,
|
||||
)
|
||||
item_payloads = selection["items"]
|
||||
if not item_payloads:
|
||||
if (
|
||||
selection["discovered_task_count"] > 0
|
||||
and selection["skipped_completed_count"] > 0
|
||||
and selection["rerun_mode"] == RERUN_MODE_UNFINISHED_ONLY
|
||||
):
|
||||
raise ValueError(
|
||||
f"All discovered Task_* directories already have completed "
|
||||
f"landsar/{normalized_profile} results under: {normalized_root}"
|
||||
)
|
||||
raise ValueError(f"No Task_* directories found under: {normalized_root}")
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
mode = "cluster"
|
||||
task_name = f"D-InSAR production: landsar/{normalized_profile} cluster"
|
||||
task_params = {
|
||||
"engine_code": normalized_engine,
|
||||
"profile": normalized_profile,
|
||||
"root_dir": normalized_root,
|
||||
"num_to_process": int(num_to_process or 0),
|
||||
"rerun_mode": selection["rerun_mode"],
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"extra": dict(extra or {}),
|
||||
"mode": mode,
|
||||
"production_run_id": run_id,
|
||||
"cluster_job_type": "LANDSAR_CLUSTER_ITEM",
|
||||
}
|
||||
|
||||
task_id: Optional[str] = None
|
||||
created_items: List[DinsarProductionRunItemORM] = []
|
||||
try:
|
||||
task_id = await task_service.create_task(
|
||||
task_type=TASK_TYPE_LANDSAR_CLUSTER_PRODUCTION,
|
||||
task_name=task_name,
|
||||
params=task_params,
|
||||
db=db,
|
||||
)
|
||||
|
||||
run = DinsarProductionRunORM(
|
||||
run_id=run_id,
|
||||
task_id=task_id,
|
||||
product_family="dinsar",
|
||||
engine_code=normalized_engine,
|
||||
profile_code=normalized_profile,
|
||||
mode=mode,
|
||||
source_root=normalized_root,
|
||||
publish_root_dir=settings.DINSAR_PRODUCT_DIR,
|
||||
status=RUN_STATUS_PENDING,
|
||||
cancel_requested=False,
|
||||
total_items=len(item_payloads),
|
||||
completed_items=0,
|
||||
failed_items=0,
|
||||
skipped_items=0,
|
||||
latest_message="Queued LandSAR cluster items",
|
||||
params_json=task_params,
|
||||
summary_json={
|
||||
"phase": "queued",
|
||||
"selected_task_count": len(item_payloads),
|
||||
"discovered_task_count": selection["discovered_task_count"],
|
||||
"skipped_completed_count": selection["skipped_completed_count"],
|
||||
"rerun_mode": selection["rerun_mode"],
|
||||
"product_family": "dinsar",
|
||||
"publish_root_dir": settings.DINSAR_PRODUCT_DIR,
|
||||
"cluster_job_type": "LANDSAR_CLUSTER_ITEM",
|
||||
},
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(run)
|
||||
await db.flush()
|
||||
|
||||
for item_payload in item_payloads:
|
||||
item = DinsarProductionRunItemORM(
|
||||
run_id=run_id,
|
||||
order_index=item_payload["order_index"],
|
||||
task_name=item_payload["task_name"],
|
||||
task_alias=item_payload["task_alias"],
|
||||
pair_key=item_payload["pair_key"],
|
||||
pair_uid=item_payload["pair_uid"],
|
||||
network_run_id=item_payload["network_run_id"],
|
||||
network_edge_id=item_payload["network_edge_id"],
|
||||
policy_version=item_payload["policy_version"],
|
||||
selection_strategy=item_payload["selection_strategy"],
|
||||
source_task_dir=item_payload["source_task_dir"],
|
||||
results_root_dir=item_payload["results_root_dir"],
|
||||
status=RUN_ITEM_STATUS_PENDING,
|
||||
)
|
||||
db.add(item)
|
||||
created_items.append(item)
|
||||
|
||||
await db.flush()
|
||||
|
||||
from .job_queue_service import job_queue_service
|
||||
|
||||
for item in created_items:
|
||||
await job_queue_service.create_job(
|
||||
"LANDSAR_CLUSTER_ITEM",
|
||||
payload={
|
||||
"production_run_id": run_id,
|
||||
"item_id": item.id,
|
||||
},
|
||||
task_id=task_id,
|
||||
priority=0,
|
||||
max_attempts=max(1, int(max_attempts or 1)),
|
||||
db=db,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
if task_id:
|
||||
try:
|
||||
await task_service.update_task(
|
||||
task_id,
|
||||
status="FAILED",
|
||||
message=f"Failed to create LandSAR cluster run: {exc}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
await asyncio.to_thread(
|
||||
_append_run_log_sync,
|
||||
run_id,
|
||||
(
|
||||
f"[cluster-queued] run_id={run_id} profile={normalized_profile} root={normalized_root} "
|
||||
f"items={len(item_payloads)} rerun_mode={selection['rerun_mode']} "
|
||||
f"skipped_completed={selection['skipped_completed_count']}"
|
||||
),
|
||||
)
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"task_id": task_id,
|
||||
"workflow_run_id": None,
|
||||
"status": run.status,
|
||||
"selected_task_count": len(item_payloads),
|
||||
"discovered_task_count": selection["discovered_task_count"],
|
||||
"skipped_completed_count": selection["skipped_completed_count"],
|
||||
"rerun_mode": selection["rerun_mode"],
|
||||
}
|
||||
|
||||
async def list_runs(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -923,6 +1090,80 @@ class DinsarProductionService:
|
||||
run.latest_message = latest_message
|
||||
return run
|
||||
|
||||
async def finalize_cluster_run_if_complete(
|
||||
self,
|
||||
run: DinsarProductionRunORM,
|
||||
*,
|
||||
db: AsyncSession,
|
||||
publish_error: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
rows = await db.execute(
|
||||
select(DinsarProductionRunItemORM.status, func.count(DinsarProductionRunItemORM.id))
|
||||
.where(DinsarProductionRunItemORM.run_id == run.run_id)
|
||||
.group_by(DinsarProductionRunItemORM.status)
|
||||
)
|
||||
counts = {str(status or "").upper(): int(count or 0) for status, count in rows.fetchall()}
|
||||
total_items = int(run.total_items or 0)
|
||||
completed = counts.get(RUN_ITEM_STATUS_COMPLETED, 0)
|
||||
failed = counts.get(RUN_ITEM_STATUS_FAILED, 0)
|
||||
skipped = counts.get(RUN_ITEM_STATUS_SKIPPED, 0)
|
||||
cancelled = counts.get(RUN_ITEM_STATUS_CANCELLED, 0)
|
||||
terminal_count = completed + failed + skipped + cancelled
|
||||
|
||||
run.completed_items = completed
|
||||
run.failed_items = failed
|
||||
run.skipped_items = skipped
|
||||
if total_items <= 0 or terminal_count < total_items:
|
||||
run.status = RUN_STATUS_RUNNING
|
||||
run.latest_message = (
|
||||
f"LandSAR cluster running: completed={completed} failed={failed} "
|
||||
f"skipped={skipped} cancelled={cancelled} total={total_items}"
|
||||
)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
if publish_error:
|
||||
final_status = RUN_STATUS_FAILED
|
||||
latest_message = f"LandSAR cluster publish failed: {publish_error}"
|
||||
elif cancelled > 0 or bool(run.cancel_requested):
|
||||
final_status = RUN_STATUS_CANCELLED
|
||||
latest_message = (
|
||||
f"LandSAR cluster cancelled. completed={completed} failed={failed} "
|
||||
f"cancelled={cancelled} total={total_items}"
|
||||
)
|
||||
elif failed > 0:
|
||||
final_status = RUN_STATUS_FAILED
|
||||
latest_message = (
|
||||
f"LandSAR cluster finished with failures. completed={completed} "
|
||||
f"failed={failed} total={total_items}"
|
||||
)
|
||||
else:
|
||||
final_status = RUN_STATUS_COMPLETED
|
||||
latest_message = (
|
||||
f"LandSAR cluster completed. completed={completed} failed={failed} total={total_items}"
|
||||
)
|
||||
|
||||
summary_payload = dict(run.summary_json or {})
|
||||
summary_payload.update(
|
||||
{
|
||||
"phase": "completed",
|
||||
"cluster": True,
|
||||
"total_items": total_items,
|
||||
"completed_items": completed,
|
||||
"failed_items": failed,
|
||||
"skipped_items": skipped,
|
||||
"cancelled_items": cancelled,
|
||||
"publish_error": publish_error,
|
||||
}
|
||||
)
|
||||
run.status = final_status
|
||||
run.summary_json = summary_payload
|
||||
run.latest_message = latest_message
|
||||
run.ended_at = _utcnow()
|
||||
await db.commit()
|
||||
await asyncio.to_thread(_append_run_log_sync, run.run_id, f"[cluster-finish] status={final_status} message={latest_message}")
|
||||
return final_status
|
||||
|
||||
async def mark_run_started(
|
||||
self,
|
||||
run: DinsarProductionRunORM,
|
||||
|
||||
@@ -1136,7 +1136,7 @@ async def _check_product_packages() -> Dict[str, Any]:
|
||||
status["missing_publish_dir_count"] += 1
|
||||
if not str(processor_code or "").strip():
|
||||
status["missing_processor_count"] += 1
|
||||
if engine_key in {"isce2", "pyint", "gamma"} and not str(runtime_id or "").strip():
|
||||
if engine_key in {"pyint", "gamma"} and not str(runtime_id or "").strip():
|
||||
status["missing_runtime_count"] += 1
|
||||
if not str(native_output_dir or "").strip():
|
||||
status["missing_native_output_count"] += 1
|
||||
@@ -1386,7 +1386,7 @@ async def _check_wsl_runtime() -> Dict[str, Any]:
|
||||
}
|
||||
try:
|
||||
required_by_engine = {
|
||||
"isce2": bool(settings.ISCE2_ENABLED or settings.TIMESERIES_ENABLED),
|
||||
"isce2": bool(settings.ISCE2_ENABLED),
|
||||
"pyint": bool(settings.PYINT_ENABLED),
|
||||
"gamma": bool(settings.GAMMA_SBAS_ENABLED),
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ from sqlalchemy import select
|
||||
|
||||
from .. import database
|
||||
from ..config import settings, split_env_paths
|
||||
from ..models import SystemJobORM, DinsarResultORM, HazardPointORM, DinsarTaskItemORM, PsTaskItemORM, RadarDataORM, SARSceneGeoORM, FloodDetectionORM, WaterDetectionORM, WaterExtractionORM, GF3ProcessingORM, AiDiagnosisORM
|
||||
from ..models import SystemJobORM, DinsarResultORM, HazardPointORM, DinsarTaskItemORM, PsTaskItemORM, RadarDataORM, SARSceneGeoORM, FloodDetectionORM, WaterDetectionORM, WaterExtractionORM, GF3ProcessingORM, AiDiagnosisORM, DinsarProductionRunItemORM
|
||||
from ..scheduler import scan_data_job
|
||||
from .data_service import data_service
|
||||
from .asset_inventory_service import asset_inventory_service
|
||||
@@ -93,6 +93,7 @@ JOB_TYPE_GF3_SARSCAPE_CLEAN = "GF3_SARSCAPE_CLEAN"
|
||||
JOB_TYPE_ISCE2_RUN = "ISCE2_RUN"
|
||||
JOB_TYPE_PYINT_RUN = "PYINT_RUN"
|
||||
JOB_TYPE_LANDSAR_RUN = "LANDSAR_RUN"
|
||||
JOB_TYPE_LANDSAR_CLUSTER_ITEM = "LANDSAR_CLUSTER_ITEM"
|
||||
JOB_TYPE_PUBLISH_DINSAR_PRODUCTS = "PUBLISH_DINSAR_PRODUCTS"
|
||||
JOB_TYPE_REBUILD_DINSAR_CATALOG = "REBUILD_DINSAR_CATALOG"
|
||||
JOB_TYPE_REBUILD_PSINSAR_CATALOG = "REBUILD_PSINSAR_CATALOG"
|
||||
@@ -107,6 +108,15 @@ JOB_TYPE_SBAS_GAMMA_WORKFLOW = "SBAS_GAMMA_WORKFLOW"
|
||||
JOB_TYPE_SBAS_LANDSAR_WORKFLOW = "SBAS_LANDSAR_WORKFLOW"
|
||||
|
||||
COPY_ALLOWED_STATUSES = {"PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"}
|
||||
_LOCAL_ENGINE_LOCKS: Dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _local_engine_lock(name: str) -> asyncio.Lock:
|
||||
lock = _LOCAL_ENGINE_LOCKS.get(name)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_LOCAL_ENGINE_LOCKS[name] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def AsyncSessionLocal():
|
||||
@@ -1997,6 +2007,15 @@ async def _run_dinsar_production_controller(job: SystemJobORM) -> None:
|
||||
)
|
||||
except Exception as exc:
|
||||
publish_error = str(exc)
|
||||
item.status = "FAILED"
|
||||
item.current_step = "publish_failed"
|
||||
item.last_error = publish_error
|
||||
await dinsar_production_service.refresh_run_counters(
|
||||
run,
|
||||
db=db,
|
||||
latest_message=f"Publish failed {item_label}: {publish_error}",
|
||||
)
|
||||
await db.commit()
|
||||
await task_service.add_log(
|
||||
job.task_id,
|
||||
"WARNING",
|
||||
@@ -3209,6 +3228,357 @@ async def _handle_landsar_run(job: SystemJobORM) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _handle_landsar_cluster_item(job: SystemJobORM) -> None:
|
||||
payload = job.payload or {}
|
||||
production_run_id = str(payload.get("production_run_id") or "").strip()
|
||||
item_id = _normalize_positive_int(payload.get("item_id"))
|
||||
if not production_run_id or not item_id:
|
||||
raise ValueError("LANDSAR_CLUSTER_ITEM requires production_run_id and item_id.")
|
||||
|
||||
from ..dinsar_engines import registry
|
||||
from ..dinsar_engines.base import RunRequest
|
||||
|
||||
engine = registry.get_engine("landsar")
|
||||
if engine is None:
|
||||
raise RuntimeError("LandSAR engine is not registered on this worker.")
|
||||
engine_title = "LandSAR"
|
||||
per_task_timeout = int(getattr(settings, "LANDSAR_DINSAR_TIMEOUT_SECONDS", 0) or 43200)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
run = await dinsar_production_service.get_run(production_run_id, db)
|
||||
if run is None:
|
||||
raise ValueError(f"LandSAR cluster run not found: {production_run_id}")
|
||||
|
||||
item = await db.get(DinsarProductionRunItemORM, int(item_id))
|
||||
if item is None or item.run_id != run.run_id:
|
||||
raise ValueError(f"LandSAR cluster item not found: {item_id}")
|
||||
|
||||
await db.refresh(item)
|
||||
item_status = str(item.status or "").strip().upper()
|
||||
if item_status in {"COMPLETED", "FAILED", "SKIPPED", "CANCELLED"}:
|
||||
await dinsar_production_service.finalize_cluster_run_if_complete(run, db=db)
|
||||
return
|
||||
|
||||
current_task = await task_service.get_task(job.task_id)
|
||||
task_cancelled = bool(current_task and current_task.status == "CANCELLED")
|
||||
if bool(run.cancel_requested) or task_cancelled:
|
||||
run.cancel_requested = True
|
||||
item.status = "CANCELLED"
|
||||
item.current_step = "cancelled"
|
||||
item.last_error = "Cancelled before worker execution."
|
||||
await dinsar_production_service.refresh_run_counters(run, db=db, latest_message=item.last_error)
|
||||
await db.commit()
|
||||
await dinsar_production_service.finalize_cluster_run_if_complete(run, db=db)
|
||||
return
|
||||
|
||||
if str(run.status or "").strip().upper() == "PENDING":
|
||||
await task_service.start_task(
|
||||
job.task_id,
|
||||
message=f"Starting LandSAR cluster run {run.run_id} ({run.total_items} items)...",
|
||||
)
|
||||
await dinsar_production_service.mark_run_started(
|
||||
run,
|
||||
db=db,
|
||||
message=f"LandSAR cluster started. total={run.total_items}",
|
||||
)
|
||||
else:
|
||||
await task_service.update_task(
|
||||
job.task_id,
|
||||
status="RUNNING",
|
||||
message=f"LandSAR cluster running. item={item.task_alias or item.task_name}",
|
||||
)
|
||||
|
||||
params = run.params_json or {}
|
||||
user_extra = dict(params.get("extra") or {})
|
||||
timeout_seconds_raw = params.get("timeout_seconds")
|
||||
if timeout_seconds_raw not in (None, ""):
|
||||
per_task_timeout = int(timeout_seconds_raw)
|
||||
|
||||
total_items = max(1, int(run.total_items or 1))
|
||||
item_index = max(1, int(item.order_index or 1))
|
||||
item_label = item.task_alias or item.task_name
|
||||
run_key = f"{build_run_key('landsar', run.profile_code, started_at=datetime.utcnow())}_{item.id}_{uuid.uuid4().hex[:6]}"
|
||||
execution = await dinsar_production_service.begin_item_execution(
|
||||
run=run,
|
||||
item=item,
|
||||
run_key=run_key,
|
||||
db=db,
|
||||
)
|
||||
|
||||
managed_run_dir = os.path.normpath(execution.output_dir)
|
||||
landsar_work_root = str(getattr(settings, "LANDSAR_WORK_ROOT", "") or "").strip()
|
||||
if landsar_work_root:
|
||||
managed_native_output_dir = os.path.normpath(os.path.join(landsar_work_root, run_key, "native"))
|
||||
else:
|
||||
managed_native_output_dir = os.path.join(managed_run_dir, "native")
|
||||
managed_work_dir = os.path.join(managed_native_output_dir, "workflow")
|
||||
managed_export_dir = os.path.join(managed_native_output_dir, "export")
|
||||
managed_orbit_output_dir = os.path.join(managed_work_dir, "orbits")
|
||||
base_progress = min(95, 5 + int(((item_index - 1) / total_items) * 90))
|
||||
progress_state: Dict[str, Any] = {
|
||||
"progress": base_progress,
|
||||
"message": f"[landsar/cluster] Running {item_index}/{total_items}: {item_label}",
|
||||
"started_monotonic": time.monotonic(),
|
||||
}
|
||||
progress_queue: asyncio.Queue[Optional[Dict[str, Any]]] = asyncio.Queue()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _emit_progress(event: Dict[str, Any]) -> None:
|
||||
if not event:
|
||||
return
|
||||
try:
|
||||
loop.call_soon_threadsafe(progress_queue.put_nowait, dict(event))
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
async def _consume_progress() -> None:
|
||||
while True:
|
||||
event = await progress_queue.get()
|
||||
if event is None:
|
||||
return
|
||||
event_type = str(event.get("event") or "").strip().lower()
|
||||
if event_type == "log":
|
||||
level = str(event.get("level") or "INFO").strip().upper()
|
||||
if level not in {"DEBUG", "INFO", "WARNING", "ERROR"}:
|
||||
level = "INFO"
|
||||
source = str(event.get("source") or "").strip()
|
||||
message = str(event.get("message") or "").strip()
|
||||
if message:
|
||||
prefix = f"[cluster {item_index}/{total_items}] {engine_title} {item_label}"
|
||||
if source:
|
||||
prefix = f"{prefix} {source}"
|
||||
await task_service.add_log(job.task_id, level, f"{prefix}: {message}")
|
||||
elif event_type == "pair_started":
|
||||
progress_state["message"] = f"[landsar/cluster] Running {item_index}/{total_items}: {item_label}"
|
||||
progress_state["started_monotonic"] = time.monotonic()
|
||||
await task_service.add_log(
|
||||
job.task_id,
|
||||
"INFO",
|
||||
f"[cluster {item_index}/{total_items}] {engine_title} started {item_label}",
|
||||
)
|
||||
elif event_type == "pair_finished":
|
||||
if bool(event.get("success")):
|
||||
progress_state["progress"] = min(98, 5 + int((item_index / total_items) * 90))
|
||||
progress_state["message"] = f"[landsar/cluster] Finished {item_index}/{total_items}: {item_label}"
|
||||
else:
|
||||
progress_state["message"] = f"[landsar/cluster] Failed {item_index}/{total_items}: {item_label}"
|
||||
|
||||
async def _task_keepalive() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
try:
|
||||
message = str(progress_state.get("message") or "")
|
||||
started_monotonic = progress_state.get("started_monotonic")
|
||||
if isinstance(started_monotonic, (int, float)):
|
||||
elapsed_seconds = max(0, int(time.monotonic() - float(started_monotonic)))
|
||||
message = f"{message} (elapsed={elapsed_seconds}s)"
|
||||
await task_service.update_task(
|
||||
job.task_id,
|
||||
status="RUNNING",
|
||||
progress=int(progress_state.get("progress") or base_progress),
|
||||
message=message,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("LandSAR cluster keepalive failed for item %s: %s", item_label, exc)
|
||||
|
||||
progress_task = asyncio.create_task(_consume_progress())
|
||||
keepalive_task = asyncio.create_task(_task_keepalive())
|
||||
task_result: Dict[str, Any] = {}
|
||||
result = None
|
||||
run_exception_text = ""
|
||||
|
||||
await task_service.add_log(
|
||||
job.task_id,
|
||||
"INFO",
|
||||
f"[cluster {item_index}/{total_items}] Launching {item_label} -> {managed_run_dir}",
|
||||
)
|
||||
dinsar_production_service.append_run_log(
|
||||
run.run_id,
|
||||
f"[cluster-item-start] {item_index}/{total_items} {item_label} run_key={run_key} output={managed_run_dir}",
|
||||
)
|
||||
|
||||
request = RunRequest(
|
||||
engine_code="landsar",
|
||||
profile=run.profile_code,
|
||||
root_dir=str(item.source_task_dir),
|
||||
job_id=job.job_id,
|
||||
num_to_process=1,
|
||||
timeout_seconds=per_task_timeout or None,
|
||||
extra={
|
||||
**user_extra,
|
||||
"__managed_run_dir": managed_run_dir,
|
||||
"__managed_native_output_dir": managed_native_output_dir,
|
||||
"__managed_work_dir": managed_work_dir,
|
||||
"__managed_export_dir": managed_export_dir,
|
||||
"__managed_orbit_output_dir": managed_orbit_output_dir,
|
||||
"__managed_run_key": run_key,
|
||||
"__source_root_override": run.source_root,
|
||||
"__rerun_mode": "rerun_all",
|
||||
"__cluster_item": True,
|
||||
},
|
||||
progress_callback=_emit_progress,
|
||||
)
|
||||
|
||||
publish_error: Optional[str] = None
|
||||
item_error: Optional[str] = None
|
||||
try:
|
||||
availability = await asyncio.to_thread(engine.check_available)
|
||||
if not availability.available:
|
||||
raise RuntimeError(f"LandSAR engine is unavailable on worker: {availability.message}")
|
||||
|
||||
async with _local_engine_lock("landsar"):
|
||||
result = await asyncio.to_thread(engine.run, request)
|
||||
|
||||
detail = result.detail or {} if result else {}
|
||||
task_result = ((detail.get("task_results") or [{}])[0]) if result else {}
|
||||
result_error = str(result.error or "").strip() if result else ""
|
||||
result_success = bool(result.success) if result else False
|
||||
if not result or not result_success or not bool(task_result.get("success", result_success)):
|
||||
error_message = (
|
||||
str(task_result.get("error") or "").strip()
|
||||
or result_error
|
||||
or run_exception_text
|
||||
or str(task_result.get("stderr_tail") or "").strip()
|
||||
or "LandSAR cluster item failed."
|
||||
)
|
||||
raise RuntimeError(error_message)
|
||||
|
||||
run_dir = os.path.normpath(
|
||||
str(task_result.get("run_dir") or task_result.get("output_dir") or execution.output_dir)
|
||||
)
|
||||
if run_dir != managed_run_dir:
|
||||
raise RuntimeError(f"LandSAR managed run dir mismatch: expected {managed_run_dir}, got {run_dir}")
|
||||
|
||||
primary_file = str(task_result.get("primary_file") or "").strip()
|
||||
source_files = [
|
||||
str(path)
|
||||
for path in (task_result.get("source_files") or [])
|
||||
if str(path or "").strip()
|
||||
]
|
||||
native_output_dir = str(task_result.get("native_output_dir") or managed_native_output_dir).strip() or managed_native_output_dir
|
||||
if not primary_file or not os.path.isfile(primary_file):
|
||||
raise RuntimeError(f"LandSAR primary output is missing: {primary_file or '<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:
|
||||
"""单景 SAR 地理编码 job handler(多视 + 地理编码 + 辐射定标)。"""
|
||||
from .water_service import run_geocoding_workflow, WATER_RESULTS_DIR
|
||||
@@ -5259,6 +5629,7 @@ _HANDLERS = {
|
||||
JOB_TYPE_ISCE2_RUN: _handle_isce2_run,
|
||||
JOB_TYPE_PYINT_RUN: _handle_pyint_run,
|
||||
JOB_TYPE_LANDSAR_RUN: _handle_landsar_run,
|
||||
JOB_TYPE_LANDSAR_CLUSTER_ITEM: _handle_landsar_cluster_item,
|
||||
JOB_TYPE_WATER_GEOCODE: _handle_water_geocode,
|
||||
JOB_TYPE_SAR_SCENE_PREPROCESS: _handle_sar_scene_preprocess,
|
||||
JOB_TYPE_WATER_FLOOD: _handle_water_flood,
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
from sqlalchemy import and_, or_, select, update, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -183,6 +183,7 @@ class JobQueueService:
|
||||
self,
|
||||
worker_id: str,
|
||||
lock_timeout_seconds: int = 1800,
|
||||
allowed_job_types: Optional[Sequence[str]] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> Optional[SystemJobORM]:
|
||||
gen_db = db is None
|
||||
@@ -191,14 +192,30 @@ class JobQueueService:
|
||||
|
||||
try:
|
||||
lock_timeout_seconds = int(lock_timeout_seconds)
|
||||
normalized_allowed = [
|
||||
_normalize_job_type(job_type)
|
||||
for job_type in (allowed_job_types or [])
|
||||
if str(job_type or "").strip()
|
||||
]
|
||||
normalized_allowed = list(dict.fromkeys(normalized_allowed))
|
||||
allowed_clause = ""
|
||||
params: Dict[str, Any] = {"lock_timeout_seconds": lock_timeout_seconds}
|
||||
if normalized_allowed:
|
||||
allowed_params = []
|
||||
for index, job_type in enumerate(normalized_allowed):
|
||||
key = f"allowed_job_type_{index}"
|
||||
allowed_params.append(f":{key}")
|
||||
params[key] = job_type
|
||||
allowed_clause = f"AND job_type IN ({', '.join(allowed_params)})"
|
||||
async with db.begin():
|
||||
result = await db.execute(
|
||||
text(
|
||||
"""
|
||||
f"""
|
||||
SELECT id
|
||||
FROM system_jobs
|
||||
WHERE status IN ('READY', 'RETRY')
|
||||
AND (next_run_at IS NULL OR next_run_at <= NOW())
|
||||
{allowed_clause}
|
||||
AND (
|
||||
locked_by IS NULL OR
|
||||
locked_at IS NULL OR
|
||||
@@ -209,7 +226,7 @@ class JobQueueService:
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"lock_timeout_seconds": lock_timeout_seconds},
|
||||
params,
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import socket
|
||||
import uuid
|
||||
import time
|
||||
from typing import Set
|
||||
from typing import Optional, Set
|
||||
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy import func
|
||||
@@ -26,6 +26,15 @@ IDL_JOB_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
def _parse_allowed_job_types(raw_value: str) -> Optional[Set[str]]:
|
||||
values = {
|
||||
part.strip().upper()
|
||||
for part in str(raw_value or "").replace(";", ",").split(",")
|
||||
if part.strip()
|
||||
}
|
||||
return values or None
|
||||
|
||||
|
||||
def _default_worker_id() -> str:
|
||||
host = socket.gethostname()
|
||||
pid = os.getpid()
|
||||
@@ -129,6 +138,7 @@ async def run_worker_loop(
|
||||
concurrency = max(1, int(concurrency))
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
active: Set[asyncio.Task] = set()
|
||||
allowed_job_types = _parse_allowed_job_types(getattr(settings, "JOB_WORKER_ALLOWED_TYPES", ""))
|
||||
job_heartbeat_interval = float(settings.JOB_WORKER_JOB_HEARTBEAT_INTERVAL)
|
||||
stale_recover_interval = float(settings.JOB_WORKER_STALE_RECOVER_INTERVAL)
|
||||
stale_running_seconds = int(settings.JOB_WORKER_STALE_RUNNING_SECONDS)
|
||||
@@ -187,7 +197,10 @@ async def run_worker_loop(
|
||||
print(f"[WARN] worker poll: {exc}")
|
||||
|
||||
if len(active) < concurrency:
|
||||
job = await job_queue_service.claim_next_job(worker_id)
|
||||
job = await job_queue_service.claim_next_job(
|
||||
worker_id,
|
||||
allowed_job_types=allowed_job_types,
|
||||
)
|
||||
if job:
|
||||
task = asyncio.create_task(_wrap(job))
|
||||
active.add(task)
|
||||
|
||||
+63
-49
@@ -95,6 +95,68 @@ def _ordered_closed_polygon_from_corner_details(corner_details: Dict[str, Dict[s
|
||||
|
||||
return _ordered_closed_polygon([(value["lon"], value["lat"]) for value in (corner_details or {}).values()])
|
||||
|
||||
|
||||
def build_corner_pixel_mapping(corner_details: Dict[str, Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if len(corner_details or {}) < 4:
|
||||
return None
|
||||
|
||||
entries: List[Tuple[str, Dict[str, Any]]] = []
|
||||
ref_rows = []
|
||||
ref_cols = []
|
||||
for name, info in (corner_details or {}).items():
|
||||
if info.get("lon") is None or info.get("lat") is None:
|
||||
return None
|
||||
if info.get("ref_row") is None or info.get("ref_col") is None:
|
||||
return None
|
||||
try:
|
||||
ref_row = int(float(info["ref_row"]))
|
||||
ref_col = int(float(info["ref_col"]))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
normalized = dict(info)
|
||||
normalized["ref_row"] = ref_row
|
||||
normalized["ref_col"] = ref_col
|
||||
entries.append((str(name), normalized))
|
||||
ref_rows.append(ref_row)
|
||||
ref_cols.append(ref_col)
|
||||
|
||||
min_row, max_row = min(ref_rows), max(ref_rows)
|
||||
min_col, max_col = min(ref_cols), max(ref_cols)
|
||||
remaining = entries[:]
|
||||
|
||||
def pick(target_row: int, target_col: int) -> Optional[Tuple[float, float]]:
|
||||
if not remaining:
|
||||
return None
|
||||
ranked = []
|
||||
for index, (name, info) in enumerate(remaining):
|
||||
row = int(info["ref_row"])
|
||||
col = int(info["ref_col"])
|
||||
score = abs(row - target_row) + abs(col - target_col)
|
||||
ranked.append((score, abs(row - target_row), abs(col - target_col), name, index))
|
||||
ranked.sort()
|
||||
chosen_index = ranked[0][4]
|
||||
_, chosen = remaining.pop(chosen_index)
|
||||
try:
|
||||
return float(chosen["lon"]), float(chosen["lat"])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
top_left = pick(min_row, min_col)
|
||||
top_right = pick(min_row, max_col)
|
||||
bottom_left = pick(max_row, min_col)
|
||||
bottom_right = pick(max_row, max_col)
|
||||
|
||||
if not all([top_left, top_right, bottom_left, bottom_right]):
|
||||
return None
|
||||
|
||||
return {
|
||||
"top_left": [top_left[0], top_left[1]],
|
||||
"top_right": [top_right[0], top_right[1]],
|
||||
"bottom_left": [bottom_left[0], bottom_left[1]],
|
||||
"bottom_right": [bottom_right[0], bottom_right[1]],
|
||||
"source": "xml_ref_row_col",
|
||||
}
|
||||
|
||||
# --- Sentinel-1 (S1A/S1B/S1C) Parsers ---
|
||||
|
||||
def _radar_meta_base() -> Dict[str, Any]:
|
||||
@@ -446,54 +508,6 @@ def parse_xml_metadata(
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _build_corner_pixel_mapping(corner_details: Dict[str, Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if len(corner_details) < 4:
|
||||
return None
|
||||
|
||||
ref_rows = []
|
||||
ref_cols = []
|
||||
for info in corner_details.values():
|
||||
if info.get("ref_row") is None or info.get("ref_col") is None:
|
||||
return None
|
||||
ref_rows.append(int(info["ref_row"]))
|
||||
ref_cols.append(int(info["ref_col"]))
|
||||
|
||||
min_row, max_row = min(ref_rows), max(ref_rows)
|
||||
min_col, max_col = min(ref_cols), max(ref_cols)
|
||||
remaining = set(corner_details.keys())
|
||||
|
||||
def pick(target_row: int, target_col: int) -> Optional[Tuple[float, float]]:
|
||||
if not remaining:
|
||||
return None
|
||||
ranked = []
|
||||
for name in remaining:
|
||||
info = corner_details[name]
|
||||
row = int(info["ref_row"])
|
||||
col = int(info["ref_col"])
|
||||
score = abs(row - target_row) + abs(col - target_col)
|
||||
ranked.append((score, abs(row - target_row), abs(col - target_col), name))
|
||||
ranked.sort()
|
||||
chosen_name = ranked[0][3]
|
||||
remaining.remove(chosen_name)
|
||||
chosen = corner_details[chosen_name]
|
||||
return float(chosen["lon"]), float(chosen["lat"])
|
||||
|
||||
top_left = pick(min_row, min_col)
|
||||
top_right = pick(min_row, max_col)
|
||||
bottom_left = pick(max_row, min_col)
|
||||
bottom_right = pick(max_row, max_col)
|
||||
|
||||
if not all([top_left, top_right, bottom_left, bottom_right]):
|
||||
return None
|
||||
|
||||
return {
|
||||
"top_left": [top_left[0], top_left[1]],
|
||||
"top_right": [top_right[0], top_right[1]],
|
||||
"bottom_left": [bottom_left[0], bottom_left[1]],
|
||||
"bottom_right": [bottom_right[0], bottom_right[1]],
|
||||
"source": "xml_ref_row_col",
|
||||
}
|
||||
|
||||
corners = ['bottomLeft', 'bottomRight', 'topRight', 'topLeft']
|
||||
polygon = []
|
||||
|
||||
@@ -652,7 +666,7 @@ def parse_xml_metadata(
|
||||
if not ordered_polygon:
|
||||
return None, None
|
||||
polygon = ordered_polygon
|
||||
corner_pixel_mapping = _build_corner_pixel_mapping(corner_details)
|
||||
corner_pixel_mapping = build_corner_pixel_mapping(corner_details)
|
||||
meta = {
|
||||
"orbit_direction": orbit_direction,
|
||||
"imaging_mode": imaging_mode,
|
||||
|
||||
@@ -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=
|
||||
@@ -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
@@ -1,106 +1,83 @@
|
||||
# 文档索引
|
||||
# 鏂囨。绱㈠紩
|
||||
|
||||
最后更新:2026-06-16
|
||||
鏈€鍚庢洿鏂帮細2026-06-23
|
||||
|
||||
本页是当前有效文档入口。没有列在本页的历史设计、实验记录和过程文档不再作为当前系统事实依据。
|
||||
|
||||
## 总览与部署
|
||||
鏈〉鏄綋鍓嶆湁鏁堟枃妗e叆鍙c€傛病鏈夊垪鍦ㄦ湰椤碘€滃綋鍓嶆湁鏁堟枃妗b€濅腑鐨勫巻鍙茶璁°€佸疄楠岃褰曞拰杩囩▼鏂囨。锛屼笉鍐嶄綔涓哄綋鍓嶇郴缁熶簨瀹炰緷鎹€?
|
||||
## 褰撳墠鏈夋晥鏂囨。
|
||||
|
||||
### 鎬昏涓庨儴缃?
|
||||
- [../README.md](../README.md)
|
||||
项目总览、当前生产入口、启动链路和文档入口。
|
||||
|
||||
椤圭洰鎬昏銆佸綋鍓嶇敓浜у叆鍙c€佸惎鍔ㄩ摼璺拰鏂囨。鍏ュ彛銆?
|
||||
- [DEPLOYMENT.md](DEPLOYMENT.md)
|
||||
Windows + PostgreSQL + WSL2 + Gamma/ISCE2/ENVI 的部署与运行说明。
|
||||
|
||||
Windows + PostgreSQL + WSL2 + Gamma/ISCE2/ENVI 鐨勯儴缃蹭笌杩愯璇存槑銆?
|
||||
- [BASEMAP_TILESERVER_PROXY_AND_ACCESS_20260613.md](BASEMAP_TILESERVER_PROXY_AND_ACCESS_20260613.md)
|
||||
Tile-server proxy, LAN access, token configuration, and Nginx IP whitelist.
|
||||
Tile-server proxy銆丩AN access銆乼oken configuration 鍜?Nginx IP whitelist銆?
|
||||
- [DOCUMENTATION_GOVERNANCE.md](DOCUMENTATION_GOVERNANCE.md)
|
||||
文档治理规则、事实来源优先级和清理约定。
|
||||
|
||||
鏂囨。娌荤悊瑙勫垯銆佷簨瀹炴潵婧愪紭鍏堢骇鍜屾竻鐞嗙害瀹氥€?
|
||||
- [FRONTEND_NAVIGATION_ARCHITECTURE.md](FRONTEND_NAVIGATION_ARCHITECTURE.md)
|
||||
当前左侧导航和生产管理工作台视图模型。
|
||||
|
||||
褰撳墠宸︿晶瀵艰埅鍜岀敓浜х鐞嗗伐浣滃彴瑙嗗浘妯″瀷銆?
|
||||
- [FRONTEND_PRODUCTION_UI_REFINEMENT.md](FRONTEND_PRODUCTION_UI_REFINEMENT.md)
|
||||
鍓嶇瀵艰埅銆佹暟鎹帴鍏ャ€佺敓浜х鐞嗐€佺患鍚堢粺璁$瓑椤甸潰鐨勮繎鏈熷伐绋嬪寲璋冩暣璁板綍銆?
|
||||
- [STATISTICS_DASHBOARD_DESIGN.md](STATISTICS_DASHBOARD_DESIGN.md)
|
||||
缁煎悎缁熻涓€绾ч〉闈㈢殑宸ョ▼瀹氫綅銆佺粺璁″彛寰勩€佹簮鏁版嵁/鎴愭灉绌洪棿瑕嗙洊瀵嗗害鍥鹃檺鍒跺拰鍚庣画 footprint 鐑姏鍗囩骇璺嚎銆?
|
||||
- [GLOBAL_TASK_STATUS_LOCK_REDESIGN_20260612.md](GLOBAL_TASK_STATUS_LOCK_REDESIGN_20260612.md)
|
||||
全局界面锁降级为任务状态中心、功能级任务面板和后端资源锁的重构设计。
|
||||
|
||||
- [OLLAMA_DINSAR_DIAGNOSIS_DEPLOYMENT_20260620.md](OLLAMA_DINSAR_DIAGNOSIS_DEPLOYMENT_20260620.md)
|
||||
D-InSAR 分析中 Ollama 本机 VLM 诊断的部署配置、模型选择和运行约定。
|
||||
|
||||
## 生产与结果
|
||||
|
||||
- [THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md](THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md)
|
||||
当前陆探一号、Sentinel-1、高分三本机生产、按需解包、GF3 外部生产登记、结果管理和 UNC 退出约定。
|
||||
|
||||
鍏ㄥ眬鐣岄潰閿侀檷绾т负浠诲姟鐘舵€佷腑蹇冦€佸姛鑳界骇浠诲姟闈㈡澘鍜屽悗绔祫婧愰攣鐨勯噸鏋勮璁°€?
|
||||
- [OLLAMA_DINSAR_DIAGNOSIS_DEPLOYMENT_20260620.md](OLLAMA_DINSAR_DIAGNOSIS_DEPLOYMENT_20260620.md)
|
||||
D-InSAR 鍒嗘瀽涓?Ollama 鏈満 VLM 璇婃柇鐨勯儴缃查厤缃€佹ā鍨嬮€夋嫨鍜岃繍琛岀害瀹氥€?
|
||||
- [STORAGE_PRESSURE_CLEANUP_GOVERNANCE_20260623.md](STORAGE_PRESSURE_CLEANUP_GOVERNANCE_20260623.md)
|
||||
瀛樺偍鍘嬪姏娓呯悊涓庢不鐞嗚竟鐣岋細婧愭暟鎹€丟F3 `_geo`銆丏EM銆佺簿杞ㄥ拰姝e紡鎴愭灉涓嶈繘鍏ユ櫘閫氭竻鐞嗭紝缂撳瓨銆佹棩蹇椼€佽繍琛屾椂鍜?Task_Pool materialize 鍒嗛樁娈垫不鐞嗐€?
|
||||
### 鐢熶骇涓庣粨鏋?
|
||||
- [THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md](THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md)
|
||||
褰撳墠闄嗘帰涓€鍙枫€丼entinel-1銆侀珮鍒嗕笁鏈満鐢熶骇銆佹寜闇€瑙e寘銆丟F3 澶栭儴鐢熶骇鐧昏銆佺粨鏋滅鐞嗗拰 UNC 閫€鍑虹害瀹氥€?
|
||||
- [PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md](PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md)
|
||||
统一结果目录、标准产品包、catalog 与多引擎结果共存约定。D-InSAR 当前引擎集合以 2026-06-14 三引擎 Task_Pool 重构设计为准。
|
||||
|
||||
缁熶竴缁撴灉鐩綍銆佹爣鍑嗕骇鍝佸寘銆乧atalog 涓庡寮曟搸缁撴灉鍏卞瓨绾﹀畾銆?
|
||||
- [DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md](DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md)
|
||||
D-InSAR 保留 ENVI/SARscape、LandSAR、Gamma/PyINT 三引擎,退出 ISCE2,统一 Task_Pool、结果聚合和中间文件清理的当前设计。
|
||||
- [LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md](LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md)
|
||||
LandSAR D-InSAR/SBAS 的全球 DEM 一次性 Int16 标准化、区域裁剪 tif、生产配置和 guardrail 约定。
|
||||
- [UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md](UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md)
|
||||
LT-1/Sentinel-1 本地源压缩包管理、包内 XML/manifest 资产化、本地 Task_Pool materialize,以及 UNC 退出运行链路后的本机部署边界。
|
||||
- [SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md](SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md)
|
||||
LT-1/Sentinel-1 源压缩包完整性审计的独立任务、增量语义、数据库字段和问题登记规则。
|
||||
|
||||
D-InSAR 淇濈暀 ENVI/SARscape銆丩andSAR銆丟amma/PyINT 涓夊紩鎿庯紝閫€鍑?ISCE2锛岀粺涓€ Task_Pool銆佺粨鏋滆仛鍚堝拰涓棿鏂囦欢娓呯悊鐨勫綋鍓嶈璁°€?
|
||||
- [LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md](LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md)
|
||||
LandSAR D-InSAR/SBAS 鐨勫叏鐞?DEM 涓€娆℃€?Int16 鏍囧噯鍖栥€佸尯鍩熻鍓?tif銆佺敓浜ч厤缃拰 guardrail 绾﹀畾銆?
|
||||
- [LANDSAR_CLUSTER_WORKER_DEPLOYMENT_20260624.md](LANDSAR_CLUSTER_WORKER_DEPLOYMENT_20260624.md)
|
||||
LandSAR D-InSAR 集群 worker 的队列分片设计、主服务器 IP 白名单、远端 Windows 节点 192.168.1.6 部署和运行约束。
|
||||
- [UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md](UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md)
|
||||
LT-1/Sentinel-1 鏈湴婧愬帇缂╁寘绠$悊銆佸寘鍐?XML/manifest 璧勪骇鍖栥€佹湰鍦?Task_Pool materialize锛屼互鍙?UNC 閫€鍑哄悗鐨勬湰鏈洪儴缃茶竟鐣屻€?
|
||||
- [SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md](SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md)
|
||||
LT-1/Sentinel-1 婧愬帇缂╁寘瀹屾暣鎬у璁$殑鐙珛浠诲姟銆佸閲忚涔夈€佹暟鎹簱瀛楁鍜岄棶棰樼櫥璁拌鍒欍€?
|
||||
- [DINSAR_PRODUCTION_CORES_OVERVIEW.md](DINSAR_PRODUCTION_CORES_OVERVIEW.md)
|
||||
旧版 ENVI/SARscape、ISCE2、Gamma/PyINT D-InSAR 生产核心说明。ISCE2 相关内容仅作历史背景。
|
||||
|
||||
鏃х増 ENVI/SARscape銆両SCE2銆丟amma/PyINT D-InSAR 鐢熶骇鏍稿績璇存槑銆侷SCE2 鐩稿叧鍐呭浠呬綔鍘嗗彶鑳屾櫙銆?
|
||||
- [SBAS_INSAR_CURRENT_WORKFLOW.md](SBAS_INSAR_CURRENT_WORKFLOW.md)
|
||||
当前 Gamma SBAS-InSAR 生产、AOI 选栈、结果 catalog、产物和 LOS 符号约定。
|
||||
|
||||
## 运行时与专项配置
|
||||
褰撳墠 Gamma SBAS-InSAR 鐢熶骇銆丄OI 閫夋嫨銆佺粨鏋?catalog銆佷骇鍝佸拰 LOS 绗﹀彿绾﹀畾銆?
|
||||
### 杩愯鏃朵笌涓撻」閰嶇疆
|
||||
|
||||
- [WSL_RUNTIME_REFACTOR_DESIGN_20260422.md](WSL_RUNTIME_REFACTOR_DESIGN_20260422.md)
|
||||
WSL 共享运行时和 Broker 设计。
|
||||
|
||||
WSL 鍏变韩杩愯鏃跺拰 Broker 璁捐銆?
|
||||
- [PROJ_CONFIGURATION.md](PROJ_CONFIGURATION.md)
|
||||
PROJ / GDAL 配置说明。
|
||||
|
||||
PROJ / GDAL 閰嶇疆璇存槑銆?
|
||||
- [ISCE2_MANAGED_DINSAR_IMPLEMENTATION_20260424.md](ISCE2_MANAGED_DINSAR_IMPLEMENTATION_20260424.md)
|
||||
ISCE2 托管 D-InSAR 历史落地说明。新 D-InSAR 生产不再采用。
|
||||
|
||||
ISCE2 鎵樼 D-InSAR 鍘嗗彶钀藉湴璇存槑銆傛柊 D-InSAR 鐢熶骇涓嶅啀閲囩敤銆?
|
||||
- [ISCE2_PRODUCTION_RELIABILITY_HARDENING_DESIGN_20260424.md](ISCE2_PRODUCTION_RELIABILITY_HARDENING_DESIGN_20260424.md)
|
||||
ISCE2 生产链路历史稳定性约束。新 D-InSAR 生产不再采用。
|
||||
|
||||
## 数据与业务模块
|
||||
|
||||
ISCE2 鐢熶骇閾捐矾鍘嗗彶绋冲畾鎬х害鏉熴€傛柊 D-InSAR 鐢熶骇涓嶅啀閲囩敤銆?
|
||||
### 鏁版嵁涓庝笟鍔℃ā鍧?
|
||||
- [SENTINEL1_SOURCE_ORBIT_ASSET_DESIGN_20260512.md](SENTINEL1_SOURCE_ORBIT_ASSET_DESIGN_20260512.md)
|
||||
Sentinel-1 / LT-1 源数据与精密轨道资产层设计。
|
||||
- [PRECISE_ORBIT_PRODUCTION_CONTRACT_20260617.md](PRECISE_ORBIT_PRODUCTION_CONTRACT_20260617.md)
|
||||
Current LT-1/Sentinel-1 precise-orbit source assets, LT-1 production TXT pools, Gamma/PyINT, LandSAR, and retired ISCE2 orbit boundaries.
|
||||
|
||||
Sentinel-1 / LT-1 婧愭暟鎹笌绮惧瘑杞ㄩ亾璧勪骇灞傝璁°€?
|
||||
- [PRECISE_ORBIT_PRODUCTION_CONTRACT_20260617.md](PRECISE_ORBIT_PRODUCTION_CONTRACT_20260617.md)
|
||||
褰撳墠 LT-1/Sentinel-1 绮捐建婧愯祫浜с€丩T-1 鐢熶骇 TXT 姹犮€丟amma/PyINT銆丩andSAR 鍜岄€€褰?ISCE2 绮捐建杈圭晫銆?
|
||||
- [FLOOD_GEOTIFF_GAMMA_PREPROCESS_DESIGN_20260515.md](FLOOD_GEOTIFF_GAMMA_PREPROCESS_DESIGN_20260515.md)
|
||||
洪涝模块 GeoTIFF 化与 Gamma 前处理方向。
|
||||
|
||||
娲稘妯″潡 GeoTIFF 鍖栦笌 Gamma 鍓嶅鐞嗘柟鍚戙€?
|
||||
- [GF3_SARSCAPE_NATIVE_TO_GEOTIFF_DESIGN_20260530.md](GF3_SARSCAPE_NATIVE_TO_GEOTIFF_DESIGN_20260530.md)
|
||||
GF3 SARscape 原生 `_geo` 二进制池、GeoTIFF 标准化、入库和洪涝接入设计。
|
||||
|
||||
GF3 SARscape 鍘熺敓 `_geo` 浜岃繘鍒舵睜銆丟eoTIFF 鏍囧噯鍖栥€佸叆搴撳拰娲稘鎺ュ叆璁捐銆?
|
||||
- [FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md](FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md)
|
||||
洪涝灾害分析工作台、产品包和矢量套合边界。
|
||||
|
||||
娲稘鐏惧鍒嗘瀽宸ヤ綔鍙般€佷骇鍝佸寘鍜岀煝閲忓鍚堣竟鐣屻€?
|
||||
- [FLOOD_WATER_ALGORITHM_ENGINEERING_HANDOFF_20260602.md](FLOOD_WATER_ALGORITHM_ENGINEERING_HANDOFF_20260602.md)
|
||||
洪涝/水体算法接入现状、processor 输出契约和工程交接路线。
|
||||
|
||||
## 安全
|
||||
娲稘/姘翠綋绠楁硶鎺ュ叆鐜扮姸銆乸rocessor 杈撳嚭濂戠害鍜屽伐绋嬩氦鎺ヨ矾绾裤€?
|
||||
### 瀹夊叏
|
||||
|
||||
- [SECURITY_AUDIT_2026-03-12.md](SECURITY_AUDIT_2026-03-12.md)
|
||||
安全审计记录。
|
||||
|
||||
## 工作笔记
|
||||
瀹夊叏瀹¤璁板綍銆?
|
||||
## 宸ヤ綔绗旇
|
||||
|
||||
- [../INIT.md](../INIT.md)
|
||||
工作笔记。只用于辅助理解现场状态,不替代正式文档。
|
||||
宸ヤ綔绗旇锛屽彧鐢ㄤ簬杈呭姪鐞嗚В鐜板満鐘舵€侊紝涓嶆浛浠f寮忔枃妗c€?
|
||||
## 鍘嗗彶褰掓。
|
||||
|
||||
## 已删除的历史材料
|
||||
|
||||
以下材料已从仓库文档树删除,不再维护:
|
||||
|
||||
- 旧 `docs/archive/` 历史堆积目录;
|
||||
- 旧 SBAS 过程文档和试验 runbook;
|
||||
- 旧 ISCE2/MintPy/SARscape 时序生产设计;
|
||||
- 过期的 PyINT/Gamma 实验记录;
|
||||
- 过期的 Sentinel-1 阶段计划;
|
||||
- 过期的配对增强计划和阶段性审计快照。
|
||||
|
||||
需要判断当前实现时,优先看代码入口和本索引列出的文档。
|
||||
- [archived_20260623/README.md](archived_20260623/README.md)
|
||||
2026-06-23 浠?`docs/` 鏍圭洰褰曠Щ鍑虹殑鍘嗗彶銆佸疄楠屻€佽繃绋嬪拰闃舵鎬ф枃妗f竻鍗曘€傝繖浜涙枃妗d笉鍐嶄綔涓哄綋鍓嶇郴缁熶簨瀹炰緷鎹€?
|
||||
闇€瑕佸垽鏂綋鍓嶅疄鐜版椂锛屼紭鍏堢湅浠g爜鍏ュ彛銆乣README.md` 鍜屾湰绱㈠紩鍒楀嚭鐨勫綋鍓嶆湁鏁堟枃妗c€?
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
这两个配置方向不同,不能互换。
|
||||
@@ -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` 为准。
|
||||
|
||||
本文档只定义存储压力治理边界,不替代源数据、生产准备、结果管理或完整性审计文档。
|
||||
@@ -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`
|
||||
@@ -26,7 +26,6 @@ export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
|
||||
taskTypes: ['AI_DIAGNOSIS'],
|
||||
showRecent: true,
|
||||
recentLimit: 1,
|
||||
pollRecentMs: 10000,
|
||||
});
|
||||
|
||||
// 状态
|
||||
|
||||
+2086
-412
File diff suppressed because it is too large
Load Diff
+4
-44
@@ -4,7 +4,6 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './App.css';
|
||||
import LoginPage from './LoginPage';
|
||||
import AppLogPanel from './components/app/AppLogPanel';
|
||||
import AppMapWorkspace from './components/app/AppMapWorkspace';
|
||||
import AppOverlays from './components/app/AppOverlays';
|
||||
import AppSidePanel from './components/app/AppSidePanel';
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
} from './store';
|
||||
import useAppAuthLifecycle from './hooks/useAppAuthLifecycle';
|
||||
import useGlobalTaskControl from './hooks/useGlobalTaskControl';
|
||||
import usePanelResize from './hooks/usePanelResize';
|
||||
import useRegionAoiHandlers from './hooks/useRegionAoiHandlers';
|
||||
import usePaginationControls from './hooks/usePaginationControls';
|
||||
import useRadarSearch from './hooks/useRadarSearch';
|
||||
@@ -222,20 +220,13 @@ function App() {
|
||||
setPendingTaskIds: state.setPendingTaskIds,
|
||||
})));
|
||||
const {
|
||||
leftPanelTab, setLeftPanelTab, leftPanelWidth, setLeftPanelWidth,
|
||||
rightPanelWidth, setRightPanelWidth, isResizing, setIsResizing,
|
||||
setShowStats, setShowDataInfo, setSelectedDataInfo, showDates,
|
||||
leftPanelTab, setLeftPanelTab, leftPanelWidth,
|
||||
setShowDataInfo, setSelectedDataInfo, showDates,
|
||||
baseLayerKey, setBaseLayerKey, isLoading, setIsLoading, addLog,
|
||||
} = useUiStore(useShallow((state) => ({
|
||||
leftPanelTab: state.leftPanelTab,
|
||||
setLeftPanelTab: state.setLeftPanelTab,
|
||||
leftPanelWidth: state.leftPanelWidth,
|
||||
setLeftPanelWidth: state.setLeftPanelWidth,
|
||||
rightPanelWidth: state.rightPanelWidth,
|
||||
setRightPanelWidth: state.setRightPanelWidth,
|
||||
isResizing: state.isResizing,
|
||||
setIsResizing: state.setIsResizing,
|
||||
setShowStats: state.setShowStats,
|
||||
setShowDataInfo: state.setShowDataInfo,
|
||||
setSelectedDataInfo: state.setSelectedDataInfo,
|
||||
showDates: state.showDates,
|
||||
@@ -423,7 +414,6 @@ function App() {
|
||||
const hazardLayersRef = useRef({});
|
||||
const dinsarResultLayersRef = useRef({});
|
||||
const sbasAnalysisLayersRef = useRef({});
|
||||
const resizeStateRef = useRef({ side: null, startX: 0, startLeft: 0, startRight: 0 });
|
||||
const allDataRef = useRef(allData);
|
||||
const dinsarResultsRef = useRef(dinsarResults);
|
||||
const mapBatchRef = useRef({ frameId: null, token: 0 });
|
||||
@@ -446,7 +436,7 @@ function App() {
|
||||
cancelAnimationFrame(frameId);
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isStandaloneLeftPage, leftPanelWidth, rightPanelWidth]);
|
||||
}, [isStandaloneLeftPage, leftPanelWidth]);
|
||||
|
||||
const getVisibleLayerRefs = useCallback(() => ({
|
||||
activeLayersRef: activeLayersRef.current,
|
||||
@@ -717,16 +707,6 @@ function App() {
|
||||
setShowPsModal,
|
||||
});
|
||||
|
||||
const { startResize } = usePanelResize({
|
||||
isResizing,
|
||||
setIsResizing,
|
||||
leftPanelWidth,
|
||||
rightPanelWidth,
|
||||
setLeftPanelWidth,
|
||||
setRightPanelWidth,
|
||||
resizeStateRef,
|
||||
});
|
||||
|
||||
const {
|
||||
handleLoginSuccess,
|
||||
handleLogout,
|
||||
@@ -1238,7 +1218,7 @@ function App() {
|
||||
? '-'
|
||||
: `${(Number(result.ai_score) * 100).toFixed(0)}%`;
|
||||
const engine = getDinsarEngineMeta(result.engine_code);
|
||||
const strategy = escapeHtml(result.selection_strategy || 'legacy');
|
||||
const strategy = escapeHtml(result.selection_strategy || '标准选择');
|
||||
const taskAlias = escapeHtml(result.task_alias || result.task_name || result.name || '-');
|
||||
const pairKey = escapeHtml(result.pair_key || '-');
|
||||
const pairUid = escapeHtml(result.pair_uid || '-');
|
||||
@@ -2027,10 +2007,6 @@ function App() {
|
||||
fetchHealthStatus({ refresh: true });
|
||||
}, [fetchHealthStatus]);
|
||||
|
||||
const openStatisticsDashboard = useCallback(() => {
|
||||
setShowStats(true);
|
||||
}, [setShowStats]);
|
||||
|
||||
const refreshDinsarResults = useCallback(() => {
|
||||
fetchDinsarResults({ offset: 0 });
|
||||
}, [fetchDinsarResults]);
|
||||
@@ -2046,7 +2022,6 @@ function App() {
|
||||
showRadarPageInputError,
|
||||
radarPageInputValidationError,
|
||||
onSearchAll: searchAllRadarData,
|
||||
onShowStats: openStatisticsDashboard,
|
||||
onSearch: applyRadarSearch,
|
||||
onReset: resetRadarSearch,
|
||||
onAoiModeChange: handleRadarSearchAoiModeChange,
|
||||
@@ -2178,12 +2153,6 @@ function App() {
|
||||
sbasAnalysisPanel={sbasAnalysisPanel}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="panel-resizer"
|
||||
onMouseDown={(event) => startResize('left', event)}
|
||||
style={{ display: isStandaloneLeftPage ? 'none' : undefined }}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: isStandaloneLeftPage ? 'none' : 'flex',
|
||||
@@ -2212,15 +2181,6 @@ function App() {
|
||||
/>
|
||||
</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>
|
||||
|
||||
<AppOverlays
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
listAssetIssues,
|
||||
listOrbitAssets,
|
||||
listSourceAssets,
|
||||
scanAssetInventory,
|
||||
} from './api/assets';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
@@ -58,7 +57,6 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
|
||||
const [activeTab, setActiveTab] = useState('sources');
|
||||
const [family, setFamily] = useState('all');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [scanLoading, setScanLoading] = useState(false);
|
||||
const [auditLoading, setAuditLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -90,35 +88,6 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
|
||||
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 = '压缩包完整性审计') => {
|
||||
if (readOnly || auditLoading) return;
|
||||
setAuditLoading(true);
|
||||
@@ -169,7 +138,7 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
|
||||
<div className="asset-toolbar">
|
||||
<div>
|
||||
<h3>源数据与精轨资产</h3>
|
||||
<p>Sentinel-1 与 LT-1 的源产品、精密轨道和绑定状态</p>
|
||||
<p>查看 Sentinel-1 与 LT-1 的源产品、精密轨道、绑定状态和开放问题;资产登记由数据接入与运维流程维护。</p>
|
||||
</div>
|
||||
<div className="asset-actions">
|
||||
<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>
|
||||
</select>
|
||||
<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}>
|
||||
{auditLoading ? '审计启动中' : '压缩包完整性审计'}
|
||||
</button>
|
||||
|
||||
@@ -53,7 +53,7 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
|
||||
if (taskId && status === 'RUNNING') {
|
||||
logIntervalRef.current = setInterval(() => {
|
||||
fetchLogsRef.current?.();
|
||||
}, 1000);
|
||||
}, 3000);
|
||||
} else if (logIntervalRef.current) {
|
||||
clearInterval(logIntervalRef.current);
|
||||
if (taskId) fetchLogsRef.current?.();
|
||||
|
||||
@@ -85,6 +85,8 @@ const SCAN_TASK_TYPES = new Set([
|
||||
'GF3_SARSCAPE_SYNC',
|
||||
'GF3_QUICKLOOK_WEBP',
|
||||
]);
|
||||
const MONITOR_REFRESH_MS = 10000;
|
||||
const ACTIVE_TASK_LOG_REFRESH_MS = 3000;
|
||||
|
||||
const TASK_TYPE_LABELS = {
|
||||
SCAN_ASSET_INVENTORY: 'LT/S1 资产索引',
|
||||
@@ -270,7 +272,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
};
|
||||
|
||||
fetchLogsAndTasks();
|
||||
const intervalId = setInterval(fetchLogsAndTasks, 2000);
|
||||
const intervalId = setInterval(fetchLogsAndTasks, MONITOR_REFRESH_MS);
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
@@ -282,6 +284,8 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
displayActiveTasks.filter((task) => SCAN_TASK_TYPES.has(task.task_type)),
|
||||
recentScanTasks
|
||||
).slice(0, 8);
|
||||
const selectedTask = displayedScanTasks.find((task) => task.task_id === selectedTaskId) || displayedScanTasks[0] || null;
|
||||
const selectedTaskActive = ['PENDING', 'RUNNING'].includes(String(selectedTask?.status || '').toUpperCase());
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
@@ -315,12 +319,17 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
}
|
||||
};
|
||||
fetchTaskLogs();
|
||||
const intervalId = setInterval(fetchTaskLogs, 2000);
|
||||
if (!selectedTaskActive) {
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}
|
||||
const intervalId = setInterval(fetchTaskLogs, ACTIVE_TASK_LOG_REFRESH_MS);
|
||||
return () => {
|
||||
canceled = true;
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [apiEndpoint, enabled, selectedTaskId]);
|
||||
}, [apiEndpoint, enabled, selectedTaskActive, selectedTaskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (logEndRef.current) {
|
||||
@@ -328,8 +337,6 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
}
|
||||
}, [logs, selectedTaskLogs]);
|
||||
|
||||
const selectedTask = displayedScanTasks.find((task) => task.task_id === selectedTaskId) || displayedScanTasks[0] || null;
|
||||
|
||||
const sourceInventoryDirs = uniquePaths([...config.s1_source_dirs, ...config.s1_storage_dirs]);
|
||||
const orbitInventoryDirs = uniquePaths([
|
||||
...config.orbit_source_dirs,
|
||||
@@ -344,6 +351,10 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
const canRunSourceProductScan = !readOnly && configLoaded && hasSourceProductDirs;
|
||||
const canRunOrbitAssetScan = !readOnly && configLoaded && hasOrbitAssetDirs;
|
||||
const canRunGf3SarscapeProduce = !readOnly && configLoaded && hasGf3SarscapeNativeDirs && hasGf3StorageDirs;
|
||||
const activeIngestTaskCount = displayedScanTasks.filter((task) =>
|
||||
['PENDING', 'RUNNING'].includes(String(task.status || '').toUpperCase())
|
||||
).length;
|
||||
const availableStorageCount = config.storage_roots.filter((item) => ['ok', 'warning'].includes(String(item.status || '').toLowerCase())).length;
|
||||
|
||||
const handleClearScanTaskHistory = async () => {
|
||||
if (readOnly) {
|
||||
@@ -511,10 +522,9 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
const sectionStyle = {
|
||||
marginBottom: '12px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '10px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--color-panel-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
};
|
||||
const labelStyle = { minWidth: '100px', color: 'var(--color-text-muted)', flexShrink: 0 };
|
||||
const rowStyle = { display: 'flex', gap: '8px' };
|
||||
@@ -532,38 +542,43 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
|
||||
return (
|
||||
<div
|
||||
className="monitor-panel"
|
||||
style={{
|
||||
padding: '15px',
|
||||
backgroundColor: 'var(--color-panel-bg)',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className="monitor-panel data-ingest-panel"
|
||||
>
|
||||
<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={{
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<div className={`data-ingest-notice ${configLoaded ? 'ok' : 'warn'}`}>
|
||||
{configLoaded
|
||||
? '仅手动模式。路径从 .env 读取;如需修改请更新 .env 并重启后端。'
|
||||
: '未加载到监控状态,请检查后端 /api/monitor/status。'}
|
||||
? '接入路径由环境配置统一管理;本页仅触发登记、审计和任务复核,不直接修改生产目录。'
|
||||
: '未加载到接入状态,请检查后端运行维护接口。'}
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>路径摘要</div>
|
||||
<details className="data-ingest-directory" style={sectionStyle}>
|
||||
<summary>接入路径与生产目录</summary>
|
||||
<p>以下为服务器部署目录,仅用于核对环境配置;日常接入操作不需要展开。</p>
|
||||
<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}>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}>SBAS 结果</span><span style={{ wordBreak: 'break-all' }}>{config.sbas_product_root || '未配置'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<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' }}>
|
||||
{config.storage_roots.length ? config.storage_roots.map((item, index) => (
|
||||
<div
|
||||
@@ -608,7 +623,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
</div>
|
||||
|
||||
<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={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>
|
||||
@@ -619,42 +634,42 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
<button
|
||||
onClick={() => handleAssetInventoryScan(
|
||||
{ inventory_types: ['source_product'], families: ['LT1', 'S1'] },
|
||||
'LT-1 / Sentinel-1 源压缩包扫描'
|
||||
'LT-1 / Sentinel-1 源压缩包登记'
|
||||
)}
|
||||
disabled={s1ScanLoading || !canRunSourceProductScan}
|
||||
style={actionBtnStyle(s1ScanLoading, !canRunSourceProductScan)}
|
||||
>
|
||||
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '扫描压缩包')}
|
||||
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '登记源压缩包')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAssetInventoryScan(
|
||||
{ inventory_types: ['orbit_asset'], families: ['LT1', 'S1'] },
|
||||
'LT-1 / Sentinel-1 精轨扫描'
|
||||
'LT-1 / Sentinel-1 精轨登记'
|
||||
)}
|
||||
disabled={s1ScanLoading || !canRunOrbitAssetScan}
|
||||
style={actionBtnStyle(s1ScanLoading, !canRunOrbitAssetScan)}
|
||||
>
|
||||
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '扫描全部精轨')}
|
||||
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '登记全部精轨')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAssetInventoryScan(
|
||||
{ inventory_types: ['orbit_asset'], families: ['S1'] },
|
||||
'Sentinel-1 精轨扫描'
|
||||
'Sentinel-1 精轨登记'
|
||||
)}
|
||||
disabled={s1ScanLoading || !canRunOrbitAssetScan}
|
||||
style={actionBtnStyle(s1ScanLoading, !canRunOrbitAssetScan)}
|
||||
>
|
||||
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '扫描 S1 EOF')}
|
||||
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '登记 S1 EOF')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAssetInventoryScan(
|
||||
{ inventory_types: ['orbit_asset'], families: ['LT1'] },
|
||||
'LT-1 精轨扫描'
|
||||
'LT-1 精轨登记'
|
||||
)}
|
||||
disabled={s1ScanLoading || !canRunOrbitAssetScan}
|
||||
style={actionBtnStyle(s1ScanLoading, !canRunOrbitAssetScan)}
|
||||
>
|
||||
{readOnly ? '只读模式' : '扫描 LT-1 TXT'}
|
||||
{readOnly ? '只读模式' : '登记 LT-1 TXT'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleArchiveIntegrityAudit}
|
||||
@@ -703,7 +718,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<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
|
||||
type="button"
|
||||
onClick={handleClearScanTaskHistory}
|
||||
@@ -718,7 +733,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
fontSize: '0.8em',
|
||||
}}
|
||||
>
|
||||
{clearScanHistoryLoading ? '清空中...' : '清空日志'}
|
||||
{clearScanHistoryLoading ? '清空中...' : '清空记录'}
|
||||
</button>
|
||||
</div>
|
||||
{clearScanHistoryMessage && (
|
||||
@@ -761,7 +776,6 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
width: `${progress}%`,
|
||||
height: '100%',
|
||||
background: statusColor(task.status),
|
||||
transition: 'width 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</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={{ 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
|
||||
style={{
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
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 { formatSatelliteFamilyLabel, inferSatelliteFamilyFromResultLike } from './utils/satelliteFamily';
|
||||
import useTaskMonitor from './hooks/useTaskMonitor';
|
||||
|
||||
const card = {
|
||||
background: '#fff',
|
||||
padding: '12px',
|
||||
padding: '14px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
marginBottom: '12px',
|
||||
};
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
@@ -44,11 +43,12 @@ const ENGINE_LABEL = {
|
||||
};
|
||||
|
||||
const TASK_TYPE_LABEL = {
|
||||
PYINT_RUN: 'PyINT/Gamma生产',
|
||||
LANDSAR_RUN: 'LandSAR生产',
|
||||
IDL_RUN_DINSAR: 'ENVI生产',
|
||||
PYINT_RUN: 'PyINT / Gamma 生产',
|
||||
LANDSAR_RUN: 'LandSAR 生产',
|
||||
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 = {
|
||||
PENDING: '等待中',
|
||||
@@ -114,7 +114,7 @@ function formatStatus(status) {
|
||||
|
||||
function taskTypeToEngine(taskType) {
|
||||
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';
|
||||
return '';
|
||||
}
|
||||
@@ -582,6 +582,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
const [timeoutSec, setTimeoutSec] = useState('');
|
||||
const [engineExtraParams, setEngineExtraParams] = useState({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [clusterSubmitting, setClusterSubmitting] = useState(false);
|
||||
const [submitMsg, setSubmitMsg] = useState('');
|
||||
const [submitError, setSubmitError] = useState(false);
|
||||
const [submitDialogOpen, setSubmitDialogOpen] = useState(false);
|
||||
@@ -635,7 +636,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
latestRunWithTask.engine === 'pyint'
|
||||
? 'PYINT_RUN'
|
||||
: latestRunWithTask.engine === 'landsar'
|
||||
? 'LANDSAR_RUN'
|
||||
? (latestRunWithTask.mode === 'cluster' ? 'LANDSAR_CLUSTER_RUN' : 'LANDSAR_RUN')
|
||||
: 'IDL_RUN_DINSAR',
|
||||
status: latestRunWithTask.raw_status || latestRunWithTask.status,
|
||||
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 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 () => {
|
||||
setEnginesLoading(true);
|
||||
@@ -892,6 +896,48 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitCluster = async () => {
|
||||
if (readOnly || clusterSubmitting) return;
|
||||
if (selectedEngine !== 'landsar') {
|
||||
setSubmitError(true);
|
||||
setSubmitMsg('LandSAR 集群只支持 LandSAR 引擎。');
|
||||
return;
|
||||
}
|
||||
if (!rootDir.trim()) {
|
||||
setSubmitError(true);
|
||||
setSubmitMsg('请输入或选择 D-InSAR 生产根目录。');
|
||||
return;
|
||||
}
|
||||
|
||||
setClusterSubmitting(true);
|
||||
setSubmitMsg('');
|
||||
setSubmitError(false);
|
||||
try {
|
||||
const extra = buildExtraPayload(currentParamSchema, engineExtraParams);
|
||||
const result = await submitLandsarClusterRun({
|
||||
engine_code: 'landsar',
|
||||
profile: selectedProfile,
|
||||
root_dir: rootDir.trim(),
|
||||
num_to_process: Number(numToProcess) || 0,
|
||||
rerun_mode: rerunMode,
|
||||
timeout_seconds: timeoutSec ? Number(timeoutSec) : null,
|
||||
extra,
|
||||
});
|
||||
const taskCount = result?.selected_task_count ? `,选中 ${result.selected_task_count} 个 pair` : '';
|
||||
const skippedCompleted = Number(result?.skipped_completed_count || 0);
|
||||
const skippedText = skippedCompleted > 0 ? `,跳过 ${skippedCompleted} 个已完成 pair` : '';
|
||||
setSubmitError(false);
|
||||
setSubmitMsg(`LandSAR 集群任务已入队:${result.task_id}${taskCount}${skippedText}`);
|
||||
if (onJobQueued) onJobQueued(result.task_id);
|
||||
await refreshMonitor();
|
||||
} catch (err) {
|
||||
setSubmitError(true);
|
||||
setSubmitMsg(`LandSAR 集群提交失败:${err?.response?.data?.detail || err.message}`);
|
||||
} finally {
|
||||
setClusterSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewLog = async run => {
|
||||
const runId = typeof run === 'string' ? run : (run?.run_id || run?.task_id || '');
|
||||
const source = typeof run !== 'string' && run?.log_source === 'task' ? 'task' : 'run';
|
||||
@@ -1001,9 +1047,10 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
}, [logModal.open, logModal.runId, logTaskId, readOnly, refreshMonitor, runLogDeletingId]);
|
||||
|
||||
const isSubmitDisabled = readOnly || submitting || !currentEngineObj?.available || !rootDir.trim() || pyintPreviewBlocksSubmit;
|
||||
const isClusterSubmitDisabled = readOnly || clusterSubmitting || selectedEngine !== 'landsar' || !rootDir.trim();
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px 0', width: '100%' }}>
|
||||
<div className="dinsar-production-shell">
|
||||
{logModal.open && (
|
||||
<div
|
||||
style={{
|
||||
@@ -1217,7 +1264,39 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
</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 }}>
|
||||
<strong style={{ fontSize: 14 }}>引擎状态</strong>
|
||||
<button
|
||||
@@ -1249,8 +1328,16 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
))}
|
||||
</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>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
@@ -1635,15 +1722,48 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
>
|
||||
{submitting ? '提交中...' : '提交任务'}
|
||||
</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 && (
|
||||
<span style={{ fontSize: 12, color: submitError ? '#ef4444' : '#16a34a' }}>
|
||||
{submitMsg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedEngine === 'landsar' && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#64748b', lineHeight: 1.6 }}>
|
||||
集群模式按 pair 拆分队列任务。远端服务器需启动只领取 LANDSAR_CLUSTER_ITEM 的 worker;
|
||||
本地 LandSAR 提交流程保持不变。
|
||||
</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 }}>
|
||||
<strong style={{ fontSize: 14 }}>运行监控</strong>
|
||||
<button
|
||||
@@ -1703,7 +1823,6 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
height: '100%',
|
||||
width: `${monitoredTask.progress}%`,
|
||||
background: showingRecentTask ? '#3b82f6' : '#f59e0b',
|
||||
transition: 'width 0.3s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1912,6 +2031,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { scanDinsarResults } from './api/dinsar';
|
||||
import { listTaskRoots } from './api/dinsarProduction';
|
||||
import { extractDispResults } from './api/idl';
|
||||
import { clearTaskLogs, deleteTaskLog, getTaskLogs } from './api/tasks';
|
||||
import DinsarCatalogPanel from './components/DinsarCatalogPanel';
|
||||
@@ -8,14 +9,10 @@ import useTaskMonitor from './hooks/useTaskMonitor';
|
||||
|
||||
const PRODUCT_TASK_TYPES = [
|
||||
'SCAN_DINSAR',
|
||||
'PUBLISH_DINSAR_PRODUCTS',
|
||||
'REBUILD_DINSAR_CATALOG',
|
||||
];
|
||||
|
||||
const TASK_TYPE_LABEL = {
|
||||
SCAN_DINSAR: 'D-InSAR 结果扫描',
|
||||
PUBLISH_DINSAR_PRODUCTS: 'D-InSAR 产物发布',
|
||||
REBUILD_DINSAR_CATALOG: 'D-InSAR 目录重建',
|
||||
};
|
||||
|
||||
const STATUS_LABEL = {
|
||||
@@ -48,13 +45,12 @@ function getLogTone(level) {
|
||||
}
|
||||
|
||||
export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
const [extractRootDir, setExtractRootDir] = useState('');
|
||||
const [extractDestDir, setExtractDestDir] = useState('');
|
||||
const [productionRoot, setProductionRoot] = useState('');
|
||||
const [productionRootReady, setProductionRootReady] = useState(false);
|
||||
const [extractResult, setExtractResult] = useState(null);
|
||||
const [extracting, setExtracting] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [actionMessage, setActionMessage] = useState('');
|
||||
const [actionError, setActionError] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const [taskLogs, setTaskLogs] = useState([]);
|
||||
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
|
||||
@@ -62,13 +58,17 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
const [taskLogDeletingId, setTaskLogDeletingId] = useState(null);
|
||||
const taskMonitor = useTaskMonitor({
|
||||
taskTypes: PRODUCT_TASK_TYPES,
|
||||
showRecent: true,
|
||||
recentLimit: 1,
|
||||
showRecent: false,
|
||||
});
|
||||
const monitoredTask = taskMonitor.latestTask;
|
||||
const logTaskId = monitoredTask?.task_id || '';
|
||||
const showingRecentTask = !taskMonitor.isBusy && !!monitoredTask;
|
||||
const actionTone = getMessageTone(actionMessage, actionError);
|
||||
const activeTaskCount = taskMonitor.activeTasks?.length || 0;
|
||||
const catalogSourceState = productionRootReady ? '已配置' : '未配置';
|
||||
const taskStateLabel = activeTaskCount > 0
|
||||
? `${activeTaskCount} 个运行中`
|
||||
: '空闲';
|
||||
const taskStateTone = activeTaskCount > 0 ? 'warn' : 'ready';
|
||||
|
||||
const loadTaskLogs = useCallback(async (taskId) => {
|
||||
if (!taskId) {
|
||||
@@ -87,8 +87,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
}, []);
|
||||
|
||||
const refreshMonitor = useCallback(async () => {
|
||||
const nextRecentTasks = await taskMonitor.refreshRecentTasks();
|
||||
const nextTaskId = taskMonitor.activeTasks[0]?.task_id || nextRecentTasks[0]?.task_id || logTaskId;
|
||||
const nextTaskId = taskMonitor.activeTasks[0]?.task_id || logTaskId;
|
||||
await loadTaskLogs(nextTaskId);
|
||||
}, [loadTaskLogs, logTaskId, taskMonitor]);
|
||||
|
||||
@@ -96,6 +95,33 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
loadTaskLogs(logTaskId);
|
||||
}, [loadTaskLogs, logTaskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskMonitor.isBusy || !logTaskId) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
void loadTaskLogs(logTaskId);
|
||||
}, 3000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadTaskLogs, logTaskId, taskMonitor.isBusy]);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
listTaskRoots()
|
||||
.then((data) => {
|
||||
if (canceled) return;
|
||||
const root = String(data?.root || '').trim();
|
||||
setProductionRoot(root);
|
||||
setProductionRootReady(Boolean(root && data?.root_exists));
|
||||
})
|
||||
.catch(() => {
|
||||
if (canceled) return;
|
||||
setProductionRoot('');
|
||||
setProductionRootReady(false);
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDeleteTaskLog = useCallback(async (logId) => {
|
||||
const taskId = logTaskId;
|
||||
if (!taskId || !logId || taskLogActionLoading) return;
|
||||
@@ -132,63 +158,70 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
}
|
||||
}, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
|
||||
|
||||
const handleExtract = async () => {
|
||||
if (!extractRootDir.trim()) return;
|
||||
setExtracting(true);
|
||||
const handleExtractAndScan = async () => {
|
||||
if (readOnly || !productionRootReady || !productionRoot.trim()) return;
|
||||
setSyncing(true);
|
||||
setExtractResult(null);
|
||||
setActionMessage('');
|
||||
setActionError(false);
|
||||
try {
|
||||
const result = await extractDispResults(extractRootDir.trim(), extractDestDir.trim() || null);
|
||||
const result = await extractDispResults(productionRoot.trim(), null);
|
||||
setExtractResult(result);
|
||||
} catch (err) {
|
||||
setExtractResult({ error: err?.response?.data?.detail || err.message });
|
||||
} finally {
|
||||
setExtracting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScan = async () => {
|
||||
if (readOnly) return;
|
||||
setScanning(true);
|
||||
setActionMessage('');
|
||||
setActionError(false);
|
||||
try {
|
||||
const result = await scanDinsarResults();
|
||||
setActionMessage(result?.message || `D-InSAR 结果扫描任务已入队:${result?.task_id || '-'}`);
|
||||
if (result?.task_id) {
|
||||
onJobQueued?.(result.task_id);
|
||||
const scanResult = await scanDinsarResults();
|
||||
setActionMessage(scanResult?.message || `D-InSAR 结果登记任务已提交:${scanResult?.task_id || '-'}`);
|
||||
if (scanResult?.task_id) {
|
||||
onJobQueued?.(scanResult.task_id);
|
||||
}
|
||||
await refreshMonitor();
|
||||
} catch (err) {
|
||||
setActionError(true);
|
||||
setActionMessage(err?.response?.data?.detail || err.message || 'D-InSAR 结果扫描失败');
|
||||
const message = err?.response?.data?.detail || err.message || 'D-InSAR 结果提取与登记失败';
|
||||
setActionMessage(message);
|
||||
setExtractResult((current) => current || { error: message });
|
||||
} finally {
|
||||
setScanning(false);
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const monitorTone = useMemo(() => {
|
||||
if (!monitoredTask) return 'neutral';
|
||||
if (showingRecentTask) return 'info';
|
||||
return String(monitoredTask.status || '').toUpperCase() === 'RUNNING' ? 'warn' : 'neutral';
|
||||
}, [monitoredTask, showingRecentTask]);
|
||||
}, [monitoredTask]);
|
||||
|
||||
return (
|
||||
<div className="dinsar-products-page">
|
||||
<div className="dinsar-products-hero">
|
||||
<div>
|
||||
<strong>D-InSAR 结果提取与标准目录</strong>
|
||||
<strong>D-InSAR 结果目录</strong>
|
||||
<p>
|
||||
这里负责把生产目录中的位移结果提取为标准成果包,并触发统一扫描、发布和编目。
|
||||
生产运行与参数配置现已收口到“生产管理”工作台中的 “D-InSAR 运行” 子视图。
|
||||
将生产目录中的位移结果提取为标准成果包,并触发结果登记和目录编目。
|
||||
生产参数与运行提交已归入“D-InSAR 运行”,这里专注成果归档与资产登记。
|
||||
</p>
|
||||
</div>
|
||||
<div className="dinsar-products-hero-badges">
|
||||
<span className={`dinsar-status-pill tone-${readOnly ? 'warn' : 'ready'}`}>
|
||||
{readOnly ? '只读模式' : '可执行写操作'}
|
||||
</span>
|
||||
<span className="dinsar-status-pill tone-info">日志改为手动刷新</span>
|
||||
<div className="dinsar-products-signals" aria-label="D-InSAR 结果目录状态摘要">
|
||||
<div className={`dinsar-production-signal tone-${readOnly ? 'warn' : 'ready'}`}>
|
||||
<span>操作模式</span>
|
||||
<strong>{readOnly ? '只读' : '可维护'}</strong>
|
||||
</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>
|
||||
|
||||
@@ -196,46 +229,27 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
<section className="dinsar-products-card">
|
||||
<div className="dinsar-products-card-head">
|
||||
<div>
|
||||
<strong>结果提取与重扫</strong>
|
||||
<span>先提取标准结果包,再按统一目录登记</span>
|
||||
<strong>D-InSAR 结果提取与登记</strong>
|
||||
<span>将已完成的生产成果归入标准结果目录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-products-form-grid">
|
||||
<label className="dinsar-products-field dinsar-products-field-wide">
|
||||
<span>结果根目录</span>
|
||||
<input
|
||||
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 className="dinsar-products-controlled-source">
|
||||
<span>成果来源</span>
|
||||
<strong>{productionRootReady ? '生产目录已就绪' : '生产目录待完善'}</strong>
|
||||
<p>{productionRootReady ? '可将当前生产成果提取并登记为标准结果包。' : '请先完成 D-InSAR 生产目录配置。'}</p>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-products-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={handleExtract}
|
||||
disabled={extracting || !extractRootDir.trim()}
|
||||
onClick={handleExtractAndScan}
|
||||
disabled={readOnly || syncing || !productionRootReady}
|
||||
>
|
||||
{extracting ? '提取中...' : '提取位移结果'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScan}
|
||||
disabled={readOnly || scanning}
|
||||
>
|
||||
{scanning ? '重扫中...' : '重扫结果'}
|
||||
{syncing ? '处理中...' : '提取并登记结果'}
|
||||
</button>
|
||||
{!productionRootReady && <span className="dinsar-products-action-hint">请先在后端配置 D-InSAR 生产根目录。</span>}
|
||||
</div>
|
||||
|
||||
{actionMessage && (
|
||||
@@ -270,7 +284,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
<div className="dinsar-products-card-head">
|
||||
<div>
|
||||
<strong>产物任务监控</strong>
|
||||
<span>当前不轮询,按需手动刷新</span>
|
||||
<span>任务运行时自动更新,空闲时按需刷新</span>
|
||||
</div>
|
||||
<button type="button" onClick={refreshMonitor}>刷新</button>
|
||||
</div>
|
||||
@@ -281,7 +295,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
<div className="dinsar-monitor-card">
|
||||
<div className="dinsar-monitor-top">
|
||||
<div>
|
||||
<strong>{showingRecentTask ? '最近一次任务' : '当前任务'}</strong>
|
||||
<strong>当前任务</strong>
|
||||
<span>{formatTaskType(monitoredTask.task_type)}</span>
|
||||
</div>
|
||||
<StatusSummary status={monitoredTask.status} />
|
||||
@@ -303,7 +317,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
)}
|
||||
|
||||
<div className="dinsar-monitor-log-head">
|
||||
<strong>{showingRecentTask ? '最近一次任务日志' : '当前任务日志'}</strong>
|
||||
<strong>当前任务日志</strong>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -354,11 +368,17 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DinsarCatalogPanel
|
||||
readOnly={readOnly}
|
||||
initialSourceDir={extractRootDir}
|
||||
onTaskQueued={onJobQueued}
|
||||
/>
|
||||
<section className="dinsar-products-catalog-section">
|
||||
<div className="dinsar-products-section-head">
|
||||
<div>
|
||||
<strong>标准目录与资产详情</strong>
|
||||
<span>核对 AOI、时间范围、资产文件、发布状态和目录一致性。</span>
|
||||
</div>
|
||||
</div>
|
||||
<DinsarCatalogPanel
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { RADAR_SEARCH_DEFAULTS } from './config/appConstants';
|
||||
|
||||
const SEARCH_PAGE_SIZE = 30;
|
||||
const LIST_PAGE_SIZE = 20;
|
||||
const ACTIVE_WORKSPACE_REFRESH_INTERVAL_MS = 15000;
|
||||
|
||||
const VIEWS = [
|
||||
{ key: 'extract', label: '水体提取' },
|
||||
@@ -649,7 +650,7 @@ export default function FloodAnalysisWorkspace({
|
||||
if (!runningCount) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
refreshAll();
|
||||
}, 6000);
|
||||
}, ACTIVE_WORKSPACE_REFRESH_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshAll, runningCount]);
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ const HazardPointPanel = ({ onPointClick, onToggleVisibility, isVisible, onScanC
|
||||
taskTypes: ['SCAN_HAZARD'],
|
||||
showRecent: true,
|
||||
recentLimit: 1,
|
||||
pollRecentMs: 10000,
|
||||
});
|
||||
const scanBusy = isLoading || scanTaskMonitor.isBusy;
|
||||
|
||||
|
||||
+300
-314
@@ -96,11 +96,6 @@ const renderOrbitSourceIssueDetails = (item, en, formatPathText) => {
|
||||
{en ? 'ENVI: ' : 'ENVI:'}{formatPathText(item.envi_path)}
|
||||
</div>
|
||||
)}
|
||||
{item?.isce2_path && (
|
||||
<div style={{ color: '#64748b' }}>
|
||||
{en ? 'ISCE2: ' : 'ISCE2:'}{formatPathText(item.isce2_path)}
|
||||
</div>
|
||||
)}
|
||||
{hasNulDetails && (
|
||||
<div style={{ color: '#64748b' }}>
|
||||
{en ? 'NUL bytes: ' : 'NUL 字节:'}{formatIntegerText(nulCount)}
|
||||
@@ -201,10 +196,10 @@ const buildConsistencySummary = (stats, en = false) => {
|
||||
};
|
||||
};
|
||||
|
||||
const HEALTH_PANEL_POLL_INTERVAL_MS = 30000;
|
||||
const HEALTH_PANEL_POLL_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
const en = language === 'en';
|
||||
const HealthCheckPanel = ({ currentUser }) => {
|
||||
const en = false;
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
const [status, setStatus] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -222,8 +217,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
const [wslChecking, setWslChecking] = useState(false);
|
||||
const [orbitStatus, setOrbitStatus] = useState(null);
|
||||
const [orbitSyncing, setOrbitSyncing] = useState(false);
|
||||
const [orbitRepairing, setOrbitRepairing] = useState(false);
|
||||
const [orbitQuarantining, setOrbitQuarantining] = useState(false);
|
||||
const [orbitSyncResult, setOrbitSyncResult] = useState(null);
|
||||
const statusFetchInFlightRef = useRef(false);
|
||||
|
||||
@@ -233,7 +226,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
setOrbitStatus(data);
|
||||
} catch (err) {
|
||||
setOrbitStatus(null);
|
||||
setOrbitSyncResult({ error: err.response?.data?.detail || err.message || (en ? 'Failed to fetch orbit status' : '精轨状态获取失败') });
|
||||
setOrbitSyncResult({ error: err.response?.data?.detail || err.message || '精轨状态获取失败' });
|
||||
}
|
||||
}, [en]);
|
||||
|
||||
@@ -266,7 +259,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
setStatus(healthData);
|
||||
setLastChecked(new Date());
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || err.message || (en ? 'Health check failed' : '运维自检失败'));
|
||||
setError(err.response?.data?.detail || err.message || '运维自检失败');
|
||||
setStatus(null);
|
||||
}
|
||||
|
||||
@@ -276,19 +269,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
setConsistencyError('');
|
||||
} catch (err) {
|
||||
setConsistencySummary(null);
|
||||
setConsistencyError(err.response?.data?.detail || err.message || (en ? 'Failed to fetch consistency stats' : '一致性统计获取失败'));
|
||||
setConsistencyError(err.response?.data?.detail || err.message || '一致性统计获取失败');
|
||||
}
|
||||
|
||||
// 引擎状态独立加载,不影响主健康检查。
|
||||
await refreshEngineStatus();
|
||||
|
||||
// 轨道目录状态由专门接口维护,失败时由 refreshOrbitStatus 自己写回 UI。
|
||||
await refreshOrbitStatus();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
statusFetchInFlightRef.current = false;
|
||||
}
|
||||
}, [en, refreshEngineStatus, refreshOrbitStatus]);
|
||||
}, [en, refreshEngineStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchStatus();
|
||||
@@ -297,9 +288,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
syncLoading ||
|
||||
cleanupLoading ||
|
||||
wslChecking ||
|
||||
orbitSyncing ||
|
||||
orbitRepairing ||
|
||||
orbitQuarantining
|
||||
orbitSyncing
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -309,8 +298,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
}, [
|
||||
cleanupLoading,
|
||||
fetchStatus,
|
||||
orbitQuarantining,
|
||||
orbitRepairing,
|
||||
orbitSyncing,
|
||||
syncLoading,
|
||||
wslChecking,
|
||||
@@ -322,6 +309,14 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
</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) => {
|
||||
if (!iso) return en ? 'Unknown' : '未知';
|
||||
try {
|
||||
@@ -342,6 +337,14 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
typeof timeseriesResultCatalog.needs_rebuild === 'boolean' ? timeseriesResultCatalog.needs_rebuild : null;
|
||||
const sourceRoots = asObject(status?.source_roots);
|
||||
const productPackages = asObject(status?.product_packages);
|
||||
const assetInventory = asObject(status?.asset_inventory);
|
||||
const hasAssetInventory = Boolean(status?.asset_inventory);
|
||||
const assetSourceRoots = asObject(assetInventory.source_roots);
|
||||
const assetOrbitRoots = asObject(assetInventory.orbit_roots);
|
||||
const sourceAssets = asObject(assetInventory.source_assets);
|
||||
const orbitAssets = asObject(assetInventory.orbit_assets);
|
||||
const orbitBindings = asObject(assetInventory.bindings);
|
||||
const assetIssues = asObject(assetInventory.issues);
|
||||
const wslRuntime = asObject(status?.wsl_runtime);
|
||||
const wslRuntimeItems = asArray(wslRuntime.runtimes);
|
||||
const pairingSystem = asObject(status?.pairing_system);
|
||||
@@ -374,58 +377,60 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
const orbitPools = orbitStatus?.pools || {};
|
||||
const orbitConsistency = orbitStatus?.consistency || {};
|
||||
const orbitDatabase = orbitStatus?.database || {};
|
||||
const orbitIsce2Enabled = Boolean(orbitPools.isce2?.enabled || orbitConsistency.isce2?.enabled || orbitDatabase.isce2_enabled);
|
||||
const orbitMismatchCount = toNumber(orbitConsistency.mismatch_count);
|
||||
const orbitDbMissingEnviCount = toNumber(orbitDatabase.stems_missing_in_envi_count);
|
||||
const orbitDbMissingIsce2Count = orbitIsce2Enabled ? toNumber(orbitDatabase.stems_missing_in_isce2_count) : 0;
|
||||
const orbitDbMissingPathCount = toNumber(orbitDatabase.db_missing_path_count);
|
||||
const orbitDbFlagIssueCount =
|
||||
toNumber(orbitDatabase.has_orbit_but_missing_path_count) +
|
||||
toNumber(orbitDatabase.without_orbit_but_path_present_count);
|
||||
const orbitScanErrorCount =
|
||||
(orbitSource.errors?.length || 0) +
|
||||
(orbitPools.envi?.errors?.length || 0) +
|
||||
(orbitIsce2Enabled ? (orbitPools.isce2?.errors?.length || 0) : 0);
|
||||
(orbitPools.envi?.errors?.length || 0);
|
||||
const orbitDuplicateCount =
|
||||
toNumber(orbitSource.duplicate_count) +
|
||||
toNumber(orbitPools.envi?.duplicate_count) +
|
||||
(orbitIsce2Enabled ? toNumber(orbitPools.isce2?.duplicate_count) : 0);
|
||||
toNumber(orbitPools.envi?.duplicate_count);
|
||||
const orbitSuspectBadCount = toNumber(orbitSource.suspect_bad_count);
|
||||
const orbitSourceWithoutEnviCount = toNumber(orbitSource.source_without_envi_count);
|
||||
const orbitEnviWithoutSourceCount = toNumber(orbitSource.envi_without_source_count);
|
||||
const orbitIsce2WithoutSourceCount = orbitIsce2Enabled ? toNumber(orbitSource.isce2_without_source_count) : 0;
|
||||
const orbitQuarantinePath = orbitSource.quarantine_path || orbitStatus?.source_gaps?.quarantine_path;
|
||||
const orbitBadSourceSamples = asArray(orbitSource.bad_source_samples).filter(hasOrbitCorruptionSignal);
|
||||
const orbitSuspectBadSamples = asArray(orbitSource.suspect_bad_samples);
|
||||
const orbitSuspectWithoutCorruptionSamples = orbitSuspectBadSamples.filter(
|
||||
(item) => !orbitBadSourceSamples.some((badItem) => badItem.name === item.name)
|
||||
);
|
||||
const orbitBadSourceSampleCount = toNumber(orbitSource.bad_source_sample_count || orbitBadSourceSamples.length);
|
||||
const orbitOverallHealthy = Boolean(
|
||||
orbitStatus &&
|
||||
orbitMismatchCount === 0 &&
|
||||
orbitDbMissingEnviCount === 0 &&
|
||||
orbitDbMissingIsce2Count === 0 &&
|
||||
orbitDbMissingPathCount === 0 &&
|
||||
orbitDbFlagIssueCount === 0 &&
|
||||
orbitScanErrorCount === 0 &&
|
||||
orbitSuspectBadCount === 0 &&
|
||||
orbitSourceWithoutEnviCount === 0 &&
|
||||
orbitEnviWithoutSourceCount === 0 &&
|
||||
orbitIsce2WithoutSourceCount === 0
|
||||
orbitEnviWithoutSourceCount === 0
|
||||
);
|
||||
const assetInventoryHealthy = Boolean(hasAssetInventory && assetInventory.ok);
|
||||
const orbitAssetRiskCount =
|
||||
toNumber(orbitAssets.parse_failed_count) +
|
||||
toNumber(orbitBindings.missing_count) +
|
||||
toNumber(orbitBindings.ambiguous_count);
|
||||
|
||||
const productionBlockingCount = [
|
||||
!status?.ok,
|
||||
!status?.database?.ok,
|
||||
!status?.database?.schema_ok,
|
||||
!status?.worker?.ok,
|
||||
consistencySummary && consistencySummary.critical > 0,
|
||||
hasAssetInventory && !assetInventoryHealthy,
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div className="health-panel">
|
||||
<div className="health-header">
|
||||
<div>
|
||||
<div className="health-title">{en ? 'System Health' : '运维自检'}</div>
|
||||
<div className="health-title">运行维护</div>
|
||||
<div className="health-subtitle">
|
||||
{lastChecked ? `${en ? 'Last check: ' : '上次检查:'}${lastChecked.toLocaleString()}` : (en ? 'Not checked yet' : '尚未检查')}
|
||||
面向生产环境的系统健康、数据一致性和维护操作入口
|
||||
</div>
|
||||
</div>
|
||||
<button className="health-refresh" onClick={() => fetchStatus({ force: true })} disabled={loading}>
|
||||
{loading ? (en ? 'Checking...' : '检查中...') : (en ? 'Refresh' : '刷新')}
|
||||
{loading ? '检查中...' : '刷新自检'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -439,23 +444,37 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
<>
|
||||
<div className="health-summary">
|
||||
<div className="health-summary-item">
|
||||
<span>{en ? 'Overall Status' : '总体状态'}</span>
|
||||
{renderBadge(status.ok)}
|
||||
<span>生产就绪</span>
|
||||
{renderBadge(status.ok && productionBlockingCount === 0, productionBlockingCount > 0 ? `${productionBlockingCount} 个阻断项` : '可运行')}
|
||||
</div>
|
||||
<div className="health-summary-item">
|
||||
<span>{en ? 'Timestamp' : '时间戳'}</span>
|
||||
<span>{formatIso(status.timestamp)}</span>
|
||||
<span>最近检查</span>
|
||||
<span>{lastChecked ? lastChecked.toLocaleString() : formatIso(status.timestamp)}</span>
|
||||
</div>
|
||||
<div className="health-summary-item">
|
||||
<span>{en ? 'Consistency Issues' : '一致性异常'}</span>
|
||||
<span>一致性异常</span>
|
||||
{renderBadge(
|
||||
!consistencySummary || consistencySummary.total === 0,
|
||||
consistencySummary ? `${consistencySummary.total} ${en ? 'items' : '项'}` : (en ? 'Unknown' : '未知')
|
||||
consistencySummary ? `${consistencySummary.total} 项` : '未知'
|
||||
)}
|
||||
</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 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-title">{en ? 'Database' : '数据库'}</div>
|
||||
<div className="health-card-row">
|
||||
@@ -587,7 +606,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
</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-title">{en ? 'D-InSAR Result Catalog' : 'D-InSAR 结果目录'}</div>
|
||||
<div className="health-card-row">
|
||||
@@ -758,7 +787,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
</div>
|
||||
{bridgeIssueCount === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
@@ -805,7 +834,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
</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-title">{en ? 'Managed Source Roots' : '受管源路径'}</div>
|
||||
<div className="health-card-row">
|
||||
@@ -1037,7 +1076,115 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
</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-title">{en ? 'Consistency Check' : '一致性检测'}</div>
|
||||
<div className="health-card-row">
|
||||
@@ -1103,12 +1250,30 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 精轨管理 */}
|
||||
{isAdmin && (
|
||||
<div className="health-card">
|
||||
<div className="health-card-title">{en ? 'Precise Orbit Management' : '精轨管理'}</div>
|
||||
<details className="health-card health-legacy-diagnostic">
|
||||
<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">
|
||||
<span>{en ? 'Overall' : '总体状态'}</span>
|
||||
{renderBadge(
|
||||
@@ -1120,14 +1285,10 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
</div>
|
||||
<div className="health-card-row">
|
||||
<span>
|
||||
{orbitIsce2Enabled
|
||||
? (en ? 'Source / ENVI-Gamma TXT / ISCE2 XML' : '源目录 / ENVI-Gamma TXT / ISCE2 XML')
|
||||
: (en ? 'Source / ENVI-Gamma TXT' : '源目录 / ENVI-Gamma TXT')}
|
||||
{en ? 'Source / production TXT' : '源目录 / 生产 TXT'}
|
||||
</span>
|
||||
<span>
|
||||
{orbitIsce2Enabled
|
||||
? `${toNumber(orbitSource.total_source)} / ${toNumber(orbitPools.envi?.total)} / ${toNumber(orbitPools.isce2?.total)}`
|
||||
: `${toNumber(orbitSource.total_source)} / ${toNumber(orbitPools.envi?.total)}`}
|
||||
{`${toNumber(orbitSource.total_source)} / ${toNumber(orbitPools.envi?.total)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="health-card-row">
|
||||
@@ -1140,24 +1301,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
<span>{en ? 'Pool mismatches' : '池不一致'}</span>
|
||||
<span>{orbitMismatchCount}</span>
|
||||
</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">
|
||||
<span>{orbitIsce2Enabled ? (en ? 'TXT-only / ISCE2-only' : '仅 TXT / 仅 ISCE2') : (en ? 'TXT-only' : '仅 TXT')}</span>
|
||||
<span>{orbitIsce2Enabled ? `${orbitEnviWithoutSourceCount} / ${orbitIsce2WithoutSourceCount}` : orbitEnviWithoutSourceCount}</span>
|
||||
<span>{en ? 'Source-only' : '仅源存在'}</span>
|
||||
<span>{orbitSourceWithoutEnviCount}</span>
|
||||
</div>
|
||||
<div className="health-card-row">
|
||||
<span>{orbitIsce2Enabled ? (en ? 'DB missing in TXT / ISCE2' : '数据库在 TXT / ISCE2 缺失') : (en ? 'DB missing in TXT' : '数据库在 TXT 缺失')}</span>
|
||||
<span>{orbitIsce2Enabled ? `${orbitDbMissingEnviCount} / ${orbitDbMissingIsce2Count}` : orbitDbMissingEnviCount}</span>
|
||||
<span>{en ? 'Production TXT only' : '仅生产 TXT 存在'}</span>
|
||||
<span>{orbitEnviWithoutSourceCount}</span>
|
||||
</div>
|
||||
<div className="health-card-row">
|
||||
<span>{en ? 'DB missing in production TXT' : '数据库在生产 TXT 缺失'}</span>
|
||||
<span>{orbitDbMissingEnviCount}</span>
|
||||
</div>
|
||||
<div className="health-card-row">
|
||||
<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 }}>
|
||||
{en
|
||||
? (orbitIsce2Enabled
|
||||
? 'LT-1 orbit scans synchronize the production TXT pool and the legacy ISCE2 XML pool. S1 EOF files remain registered as source orbit assets.'
|
||||
: 'LT-1 orbit scans synchronize the production TXT pool for ENVI/SARscape and Gamma. S1 EOF files remain registered as source orbit assets; ISCE2 XML is disabled.')
|
||||
: (orbitIsce2Enabled
|
||||
? 'LT-1 精轨扫描会同步生产 TXT 池和 legacy ISCE2 XML 池;S1 EOF 只登记为源精轨资产。'
|
||||
: 'LT-1 精轨扫描会同步 ENVI/SARscape 与 Gamma 共用的生产 TXT 池;S1 EOF 只登记为源精轨资产,ISCE2 XML 已停用。')}
|
||||
? 'LT-1 orbit scans synchronize the production TXT pool for ENVI/SARscape and Gamma. S1 EOF files remain registered as source orbit assets.'
|
||||
: 'LT-1 精轨扫描会同步 ENVI/SARscape 与 Gamma 共用的生产 TXT 池;S1 EOF 只登记为源精轨资产。'}
|
||||
</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>
|
||||
{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 && (
|
||||
<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)}`}
|
||||
</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 && (
|
||||
<div className="health-card-note error">
|
||||
{en
|
||||
@@ -1223,24 +1360,12 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
{orbitDatabase.sample_missing_in_envi.slice(0, 5).join(', ')}
|
||||
</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) => (
|
||||
<div key={`orbit-bad-source-${item.name}`} className="health-card-note error">
|
||||
{item.name} - {item.error || (en ? 'Corruption signal detected' : '检测到损坏信号')}
|
||||
{renderOrbitSourceIssueDetails(item, en, formatPathText)}
|
||||
</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) => (
|
||||
<div key={item.name} className="health-card-note error">
|
||||
{item.name} - {item.issue}
|
||||
@@ -1249,11 +1374,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
{en ? 'ENVI: ' : 'ENVI:'}{formatPathText(item.envi_path)}
|
||||
</div>
|
||||
)}
|
||||
{item.isce2_path && (
|
||||
<div style={{ color: '#64748b' }}>
|
||||
{en ? 'ISCE2: ' : 'ISCE2:'}{formatPathText(item.isce2_path)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{orbitConsistency.mismatch_count > 5 && (
|
||||
@@ -1278,11 +1398,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
{en ? 'ENVI pool scan error: ' : 'ENVI 池扫描异常:'}{item}
|
||||
</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 }}>
|
||||
<button
|
||||
@@ -1299,53 +1414,11 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
setOrbitSyncing(false);
|
||||
}
|
||||
}}
|
||||
disabled={orbitSyncing || orbitRepairing || orbitQuarantining}
|
||||
disabled={orbitSyncing}
|
||||
style={{ padding: '4px 12px', background: '#1890ff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
|
||||
>
|
||||
{orbitSyncing ? (en ? 'Checking...' : '检查中...') : (en ? 'Check Consistency' : '精轨一致性检查')}
|
||||
</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>
|
||||
|
||||
{orbitSyncResult && (
|
||||
@@ -1354,176 +1427,89 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
<div className="health-card-note error">{orbitSyncResult.error}</div>
|
||||
) : (
|
||||
<>
|
||||
{'confirmed_bad_count' in orbitSyncResult ? (
|
||||
<>
|
||||
<div className={`health-card-note ${toNumber(orbitSyncResult.confirmed_bad_count) === 0 ? 'ok' : 'warn'}`}>
|
||||
{en
|
||||
? `Quarantine finished. Confirmed bad TXT: ${toNumber(orbitSyncResult.confirmed_bad_count)} / validated ${toNumber(orbitSyncResult.validated_count)}`
|
||||
: `隔离完成。已确认坏 TXT ${toNumber(orbitSyncResult.confirmed_bad_count)} 项 / 已校验 ${toNumber(orbitSyncResult.validated_count)} 项`}
|
||||
</div>
|
||||
<div className="health-card-note">
|
||||
{en
|
||||
? `Quarantine root: ${formatPathText(orbitSyncResult.quarantine_root)}`
|
||||
: `隔离目录:${formatPathText(orbitSyncResult.quarantine_root)}`}
|
||||
</div>
|
||||
{(orbitSyncResult.confirmed_bad || []).slice(0, 5).map((item, index) => (
|
||||
<div key={`orbit-quarantine-bad-${index}`} className="health-card-note error">
|
||||
{item.name} - {item.error}
|
||||
{renderOrbitSourceIssueDetails(item, en, formatPathText)}
|
||||
{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 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)}, scan errors ${toNumber(orbitSyncResult.error_count)}`
|
||||
: `TXT ${toNumber(orbitSyncResult.envi?.total)} 项,扫描异常 ${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>
|
||||
)}
|
||||
{(orbitSyncResult.repair_errors || []).slice(0, 3).map((item, index) => (
|
||||
<div key={`orbit-repair-error-${index}`} className="health-card-note error">
|
||||
{item.name} - {item.error}
|
||||
{renderOrbitSourceIssueDetails(item, en, formatPathText)}
|
||||
</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>
|
||||
))}
|
||||
{(orbitSyncResult.errors || []).slice(0, 3).map((item, index) => (
|
||||
<div key={`orbit-check-error-${index}`} className="health-card-note error">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="health-card-note">展开后点击“加载诊断”获取旧精轨池状态。</div>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 系统维护 */}
|
||||
{isAdmin && (
|
||||
<div className="health-card">
|
||||
<div className="health-card-title">{en ? 'System Maintenance' : '系统维护'}</div>
|
||||
<div className="health-card-row" style={{ alignItems: 'center' }}>
|
||||
<span>{en ? 'Expired Sessions' : '过期会话清理'}</span>
|
||||
<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}
|
||||
<section className="health-section">
|
||||
<div className="health-section-header">
|
||||
<div>
|
||||
<h3>维护与审计</h3>
|
||||
<p>低频维护动作与日志审计集中管理,避免与实时健康状态混杂。</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="health-card-note" style={{ marginTop: 4, color: '#94a3b8' }}>
|
||||
{en ? 'Delete expired and revoked session records from the database.' : '删除数据库中已过期和已撤销的会话记录,释放空间。'}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -41,7 +41,6 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
|
||||
taskTypes: ['IDL_IMPORT', 'IDL_DINSAR'],
|
||||
showRecent: true,
|
||||
recentLimit: 1,
|
||||
pollRecentMs: 10000,
|
||||
});
|
||||
const runningTask = idlTaskMonitor.activeTasks[0] || null;
|
||||
|
||||
@@ -75,9 +74,12 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
|
||||
|
||||
useEffect(() => {
|
||||
refreshData().catch(() => {});
|
||||
const timer = setInterval(() => refreshData().catch(() => {}), 10000);
|
||||
if (!runningTask) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = setInterval(() => refreshData().catch(() => {}), 15000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refreshData]);
|
||||
}, [refreshData, runningTask]);
|
||||
|
||||
const runAction = async (action) => {
|
||||
setIsBusy(true);
|
||||
|
||||
+101
-227
@@ -13,21 +13,27 @@ const LogManagementPanel = ({ isAdmin }) => {
|
||||
const [totalLines, setTotalLines] = useState(0);
|
||||
const [currentOffset, setCurrentOffset] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const getErrorText = error => error.response?.data?.detail || error.message || '操作失败';
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const data = await listLogs(filterType || null);
|
||||
setLogs(data);
|
||||
setLogs(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error('加载日志列表失败:', error);
|
||||
alert(`加载日志列表失败:${error.response?.data?.detail || error.message}`);
|
||||
setErrorMessage(`日志列表加载失败:${getErrorText(error)}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterType]);
|
||||
|
||||
const loadLogContent = useCallback(async (logPath, offset = 0) => {
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const data = await getLogContent(logPath, offset, PAGE_SIZE);
|
||||
setLogContent(data.content || '');
|
||||
@@ -35,7 +41,7 @@ const LogManagementPanel = ({ isAdmin }) => {
|
||||
setCurrentOffset(offset);
|
||||
} catch (error) {
|
||||
console.error('加载日志内容失败:', error);
|
||||
alert(`加载日志内容失败:${error.response?.data?.detail || error.message}`);
|
||||
setErrorMessage(`日志内容加载失败:${getErrorText(error)}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -48,12 +54,13 @@ const LogManagementPanel = ({ isAdmin }) => {
|
||||
setShowModal(true);
|
||||
setCurrentOffset(0);
|
||||
setSearchTerm('');
|
||||
setMessage('');
|
||||
await loadLogContent(log.path, 0);
|
||||
};
|
||||
|
||||
const handleDeleteLog = async log => {
|
||||
if (!isAdmin) {
|
||||
alert('只有管理员可以删除日志。');
|
||||
setErrorMessage('当前账号没有日志删除权限。');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,9 +68,11 @@ const LogManagementPanel = ({ isAdmin }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage('');
|
||||
setErrorMessage('');
|
||||
try {
|
||||
await deleteLog(log.path);
|
||||
alert('日志文件已删除。');
|
||||
setMessage('日志文件已删除。');
|
||||
await loadLogs();
|
||||
if (selectedLog && selectedLog.path === log.path) {
|
||||
setShowModal(false);
|
||||
@@ -74,28 +83,27 @@ const LogManagementPanel = ({ isAdmin }) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除日志失败:', error);
|
||||
alert(`删除日志失败:${error.response?.data?.detail || error.message}`);
|
||||
setErrorMessage(`日志删除失败:${getErrorText(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (selectedLog && currentOffset > 0) {
|
||||
const newOffset = Math.max(0, currentOffset - PAGE_SIZE);
|
||||
loadLogContent(selectedLog.path, newOffset);
|
||||
loadLogContent(selectedLog.path, Math.max(0, currentOffset - PAGE_SIZE));
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (selectedLog && currentOffset + PAGE_SIZE < totalLines) {
|
||||
const newOffset = currentOffset + PAGE_SIZE;
|
||||
loadLogContent(selectedLog.path, newOffset);
|
||||
loadLogContent(selectedLog.path, currentOffset + PAGE_SIZE);
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = bytes => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
const size = Number(bytes) || 0;
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const getTypeLabel = type => {
|
||||
@@ -105,17 +113,7 @@ const LogManagementPanel = ({ isAdmin }) => {
|
||||
error: '错误日志',
|
||||
other: '其他',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
const getTypeColor = type => {
|
||||
const colors = {
|
||||
app: '#3b82f6',
|
||||
task: '#10b981',
|
||||
error: '#ef4444',
|
||||
other: '#6b7280',
|
||||
};
|
||||
return colors[type] || '#6b7280';
|
||||
return labels[type] || type || '其他';
|
||||
};
|
||||
|
||||
const filteredContent = searchTerm
|
||||
@@ -125,242 +123,118 @@ const LogManagementPanel = ({ isAdmin }) => {
|
||||
.join('\n')
|
||||
: logContent;
|
||||
|
||||
const pageStart = totalLines === 0 ? 0 : currentOffset + 1;
|
||||
const pageEnd = Math.min(currentOffset + PAGE_SIZE, totalLines);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0 }}>日志管理</h3>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<label>类型筛选:</label>
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={event => setFilterType(event.target.value)}
|
||||
style={{ padding: '5px 10px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
<option value="app">应用日志</option>
|
||||
<option value="task">任务日志</option>
|
||||
<option value="error">错误日志</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={loadLogs}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '5px 15px',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? '加载中...' : '刷新'}
|
||||
<div className="log-management-panel">
|
||||
<div className="log-management-header">
|
||||
<div>
|
||||
<h3>日志审计</h3>
|
||||
<p>集中查看应用、任务与错误日志;删除操作仅限管理员。</p>
|
||||
</div>
|
||||
<div className="log-management-controls">
|
||||
<label className="ops-field">
|
||||
<span>日志类型</span>
|
||||
<select value={filterType} onChange={event => setFilterType(event.target.value)}>
|
||||
<option value="">全部日志</option>
|
||||
<option value="app">应用日志</option>
|
||||
<option value="task">任务日志</option>
|
||||
<option value="error">错误日志</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="ops-button ops-button--primary" onClick={loadLogs} disabled={loading}>
|
||||
{loading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
</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 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#6b7280' }}>暂无日志文件</div>
|
||||
<div className="log-empty-state">{loading ? '正在读取日志目录...' : '暂无可展示的日志文件'}</div>
|
||||
) : (
|
||||
<table
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<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>
|
||||
<div className="log-table-wrap">
|
||||
<table className="log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名</th>
|
||||
<th>类型</th>
|
||||
<th>大小</th>
|
||||
<th>修改时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
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 className="log-modal-backdrop">
|
||||
<div className="log-modal" role="dialog" aria-modal="true" aria-label="日志内容">
|
||||
<div className="log-modal-header">
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontFamily: 'monospace' }}>{selectedLog.name}</h3>
|
||||
<div style={{ fontSize: '13px', color: '#6b7280' }}>
|
||||
大小:{formatSize(selectedLog.size)} | 修改时间:{selectedLog.modified_at} | 总行数:{totalLines}
|
||||
</div>
|
||||
<h3>{selectedLog.name}</h3>
|
||||
<p>
|
||||
大小:{formatSize(selectedLog.size)} · 修改时间:{selectedLog.modified_at || '-'} · 总行数:{totalLines}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6b7280',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<button className="ops-button ops-button--secondary" onClick={() => setShowModal(false)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px 20px', borderBottom: '1px solid #e5e7eb', display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<div className="log-modal-tools">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索日志内容..."
|
||||
placeholder="搜索日志内容"
|
||||
value={searchTerm}
|
||||
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' }}>
|
||||
显示行 {totalLines === 0 ? 0 : currentOffset + 1} - {Math.min(currentOffset + PAGE_SIZE, totalLines)}
|
||||
<div className="log-page-info">
|
||||
显示行 {pageStart} - {pageEnd}
|
||||
</div>
|
||||
<button
|
||||
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 className="ops-button ops-button--secondary ops-button--sm" onClick={handlePrevPage} disabled={currentOffset === 0}>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
className="ops-button ops-button--secondary ops-button--sm"
|
||||
onClick={handleNextPage}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '20px', backgroundColor: '#1e1e1e' }}>
|
||||
<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 className="log-content-view">
|
||||
<pre>{filteredContent || '(空日志)'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import apiClient from './api/client';
|
||||
|
||||
|
||||
const LoginPage = ({ onLoginSuccess }) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -105,5 +104,4 @@ const LoginPage = ({ onLoginSuccess }) => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default LoginPage;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Suspense, lazy, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
PRODUCTION_WORKSPACE_ENTRY_TO_VIEW,
|
||||
PRODUCTION_WORKSPACE_TAB,
|
||||
@@ -17,133 +16,151 @@ const LazyPairsListPanel = lazy(() => import('./panels/PairsListPanel'));
|
||||
const LazyBatchPanel = lazy(() => import('./panels/BatchPanel'));
|
||||
const LazyDataCopierPanel = lazy(() => import('./DataCopierPanel'));
|
||||
|
||||
const shellStyle = {
|
||||
minHeight: '100%',
|
||||
padding: '20px 24px 28px',
|
||||
boxSizing: 'border-box',
|
||||
background: 'linear-gradient(180deg, #f5f7fb 0%, #eef4ff 52%, #f8fafc 100%)',
|
||||
};
|
||||
|
||||
const heroStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||
gap: 16,
|
||||
marginBottom: 18,
|
||||
};
|
||||
|
||||
const heroCardStyle = {
|
||||
borderRadius: 24,
|
||||
border: '1px solid #d7e0eb',
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f8fbff 56%, #eef6ff 100%)',
|
||||
boxShadow: '0 16px 40px rgba(15, 23, 42, 0.06)',
|
||||
};
|
||||
|
||||
const summaryCardStyle = {
|
||||
padding: '12px 14px',
|
||||
borderRadius: 18,
|
||||
border: '1px solid #e2e8f0',
|
||||
background: 'rgba(255, 255, 255, 0.82)',
|
||||
};
|
||||
const WORKFLOW_STEPS = [
|
||||
'数据准备',
|
||||
'配对/栈规划',
|
||||
'生产运行',
|
||||
'质量检查',
|
||||
'成果发布',
|
||||
];
|
||||
|
||||
const SENSOR_PRODUCTION_PLACEHOLDERS = {
|
||||
lt1_production: {
|
||||
title: '陆探一号生产模块',
|
||||
subtitle: '当前先占位纳入生产管理,执行链路保留 LandSAR、ENVI+SARscape、Gamma/PyINT。',
|
||||
title: '陆探一生产占位',
|
||||
note: '当前保留 LT-1 源压缩包本机登记与按需 materialize 入口。',
|
||||
rows: [
|
||||
['源压缩包', 'D:\\LuTan1_Image_Pool_Zip,只索引包内 XML/元数据,不做全量解包。'],
|
||||
['精密轨道', 'D:\\LT1_data_lsarorbit,本机部署并绑定到源资产。'],
|
||||
['按需解包', '生产任务需要时才 materialize 到 D:\\Task_Pool\\DInSAR 或 D:\\Task_Pool\\SBAS。'],
|
||||
['生产边界', 'D-InSAR 与 SBAS-InSAR 均使用本机 Task_Pool,不允许 UNC 参与运行。'],
|
||||
['结果管理', '生成结果进入 D-InSAR/SBAS 产物目录,由生产管理结果页统一重建 catalog。'],
|
||||
['数据来源', '本机源压缩包 archive'],
|
||||
['精轨策略', '按生产任务关联 orbit 资产'],
|
||||
['准备方式', '按需 materialize 到 Task_Pool'],
|
||||
['生产边界', 'D-InSAR/SBAS 不走 UNC'],
|
||||
['结果管理', '进入统一产品 catalog'],
|
||||
],
|
||||
},
|
||||
sentinel1_production: {
|
||||
title: 'Sentinel-1 生产模块',
|
||||
subtitle: '当前先占位纳入生产管理,D-InSAR 保留 Gamma/PyINT 路径,SBAS 仍为规划态。',
|
||||
title: 'Sentinel-1 生产占位',
|
||||
note: '当前主要沉淀数据与精轨管理约束,SBAS 仅保留规划能力。',
|
||||
rows: [
|
||||
['源压缩包', 'D:\\Sentinel1_Image_Pool_ZIP,本机登记 ZIP/SAFE 元数据。'],
|
||||
['精密轨道', 'D:\\Sentinel1_EOF_Pool,本机保存 AUX_POEORB/RESORB。'],
|
||||
['按需解包', '需要运行时才将 ZIP 解包到本机 Task_Pool,界面不提供全量解包按钮。'],
|
||||
['D-InSAR', 'Gamma/PyINT 可作为生产方向,运行材料必须来自本机路径。'],
|
||||
['SBAS', '当前仅做堆栈发现和规划,执行链路未启用。'],
|
||||
['数据来源', 'ZIP/SAFE 本机 archive'],
|
||||
['精轨策略', 'EOF 精轨本机管理'],
|
||||
['准备方式', '按需解包到工作目录'],
|
||||
['D-InSAR', '走统一生产任务队列'],
|
||||
['SBAS', '保留序列规划能力'],
|
||||
],
|
||||
},
|
||||
gf3_native_registration: {
|
||||
title: '高分三结果登记',
|
||||
subtitle: 'GF3 不在本机生产;另一台 SARscape 服务器完成 _geo 后复制到本机登记。',
|
||||
note: 'GF3 由外部 SARscape 服务生产,本系统登记回传成果并生成预览。',
|
||||
rows: [
|
||||
['外部生产', '外部机器按 YYYYMMDD_geo/场景目录输出 SARscape 原生 _geo 二进制。'],
|
||||
['本机落盘', '复制到 D:\\GaoFen3_Pool\\native_geo 后递归扫描登记。'],
|
||||
['预览生成', 'WebP 从 *_geo 主二进制读取生成,不使用 *_geo_ql.tif 作为正式预览源。'],
|
||||
['精轨', 'GF3 本链路无精密轨道管理。'],
|
||||
['结果管理', '登记后的 GF3 资产进入数据管理,后续需要全影像时再提取/标准化。'],
|
||||
['生产方式', '外部 SARscape 服务'],
|
||||
['落地路径', '本机登记 _geo 二进制'],
|
||||
['预览生成', '转换 WebP 供地图使用'],
|
||||
['精轨策略', '按外部生产结果留痕'],
|
||||
['结果管理', '进入统一产品 catalog'],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function SensorProductionPlaceholder({ viewKey }) {
|
||||
const data = SENSOR_PRODUCTION_PLACEHOLDERS[viewKey];
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
const shellStyle = {
|
||||
minHeight: '100%',
|
||||
background: '#f8fafc',
|
||||
color: '#0f172a',
|
||||
};
|
||||
|
||||
function resolveView(entry) {
|
||||
return PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[entry] || PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[PRODUCTION_WORKSPACE_TAB];
|
||||
const headerStyle = {
|
||||
padding: '18px 20px 14px',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
background: '#ffffff',
|
||||
};
|
||||
|
||||
const sectionStyle = {
|
||||
padding: '16px 20px 22px',
|
||||
};
|
||||
|
||||
const compactPanelStyle = {
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 8,
|
||||
background: '#ffffff',
|
||||
};
|
||||
|
||||
const mutedTextStyle = {
|
||||
color: '#64748b',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
};
|
||||
|
||||
function resolveView(activeEntry) {
|
||||
return PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[activeEntry] || PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[PRODUCTION_WORKSPACE_TAB];
|
||||
}
|
||||
|
||||
function resolveWorkbenchKey(viewKey) {
|
||||
const workbench = PRODUCTION_WORKSPACE_WORKBENCHES.find(item => (
|
||||
item.views.some(view => view.key === viewKey)
|
||||
));
|
||||
return workbench?.key || PRODUCTION_WORKSPACE_WORKBENCHES[0]?.key || 'dinsar_workbench';
|
||||
const workbench = PRODUCTION_WORKSPACE_WORKBENCHES.find(item =>
|
||||
item.views.some(view => view.key === viewKey),
|
||||
);
|
||||
return workbench?.key || PRODUCTION_WORKSPACE_WORKBENCHES[0]?.key;
|
||||
}
|
||||
|
||||
function buttonStyle(active) {
|
||||
return {
|
||||
border: `1px solid ${active ? '#2563eb' : '#cbd5e1'}`,
|
||||
background: active ? '#eff6ff' : '#ffffff',
|
||||
color: active ? '#1d4ed8' : '#334155',
|
||||
borderRadius: 6,
|
||||
padding: '7px 10px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
lineHeight: 1.3,
|
||||
};
|
||||
}
|
||||
|
||||
function PlaceholderView({ config }) {
|
||||
if (!config) {
|
||||
return (
|
||||
<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({
|
||||
activeEntry = PRODUCTION_WORKSPACE_TAB,
|
||||
readOnly = false,
|
||||
activeEntry,
|
||||
readOnly,
|
||||
onTaskStart,
|
||||
apiEndpoint,
|
||||
language,
|
||||
foundPairs = [],
|
||||
selectedPairsCount = 0,
|
||||
isLoading = false,
|
||||
hasEnoughRadarScenesForPlanning = false,
|
||||
hasRadarSearched = false,
|
||||
pairingPanel = {},
|
||||
radarPanel = {},
|
||||
pairsPanel = {},
|
||||
foundPairs,
|
||||
selectedPairsCount,
|
||||
isLoading,
|
||||
hasEnoughRadarScenesForPlanning,
|
||||
hasRadarSearched,
|
||||
pairingPanel,
|
||||
radarPanel,
|
||||
pairsPanel,
|
||||
}) {
|
||||
const [activeView, setActiveView] = useState(() => resolveView(activeEntry));
|
||||
const [activeWorkbench, setActiveWorkbench] = useState(() => resolveWorkbenchKey(resolveView(activeEntry)));
|
||||
const initialView = resolveView(activeEntry);
|
||||
const [activeView, setActiveView] = useState(initialView);
|
||||
const [activeWorkbench, setActiveWorkbench] = useState(resolveWorkbenchKey(initialView));
|
||||
|
||||
useEffect(() => {
|
||||
const nextView = resolveView(activeEntry);
|
||||
@@ -151,17 +168,16 @@ export default function ProductionWorkspace({
|
||||
setActiveWorkbench(resolveWorkbenchKey(nextView));
|
||||
}, [activeEntry]);
|
||||
|
||||
const activeViewMeta = useMemo(
|
||||
() => PRODUCTION_WORKSPACE_VIEWS.find(view => view.key === activeView) || PRODUCTION_WORKSPACE_VIEWS[0],
|
||||
[activeView]
|
||||
);
|
||||
const activeWorkbenchMeta = useMemo(
|
||||
const currentWorkbench = useMemo(
|
||||
() => PRODUCTION_WORKSPACE_WORKBENCHES.find(item => item.key === activeWorkbench) || PRODUCTION_WORKSPACE_WORKBENCHES[0],
|
||||
[activeWorkbench]
|
||||
[activeWorkbench],
|
||||
);
|
||||
const currentView = useMemo(
|
||||
() => PRODUCTION_WORKSPACE_VIEWS.find(view => view.key === activeView),
|
||||
[activeView],
|
||||
);
|
||||
const activeSubViews = activeWorkbenchMeta?.views || [];
|
||||
|
||||
const switchWorkbench = (workbench) => {
|
||||
const switchWorkbench = workbench => {
|
||||
setActiveWorkbench(workbench.key);
|
||||
if (!workbench.views.some(view => view.key === activeView)) {
|
||||
setActiveView(workbench.defaultView);
|
||||
@@ -177,224 +193,176 @@ export default function ProductionWorkspace({
|
||||
};
|
||||
|
||||
const handleDinsarPrepareQueued = taskId => {
|
||||
onTaskStart?.(taskId, 'D-InSAR生产准备任务已入队,正在处理...', {
|
||||
onTaskStart?.(taskId, 'D-InSAR 生产准备任务已入队,正在处理...', {
|
||||
taskType: 'COPY_DATA',
|
||||
nonBlocking: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSbasProductQueued = taskId => {
|
||||
onTaskStart?.(taskId, 'SBAS-InSAR result catalog task queued.', {
|
||||
onTaskStart?.(taskId, 'SBAS-InSAR 结果 catalog 任务已入队。', {
|
||||
taskType: 'REBUILD_SBAS_INSAR_CATALOG',
|
||||
nonBlocking: true,
|
||||
});
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (activeView === 'dinsar_pairing') {
|
||||
return (
|
||||
<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 (
|
||||
<div style={shellStyle}>
|
||||
<div style={heroStyle}>
|
||||
<section style={{ ...heroCardStyle, padding: '22px 24px' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.08em', color: '#1d4ed8', textTransform: 'uppercase' }}>
|
||||
Production Management
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<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>
|
||||
<h2 style={{ margin: '10px 0 12px', fontSize: 32, lineHeight: 1.1, color: '#0f172a' }}>生产管理</h2>
|
||||
<p style={{ margin: 0, maxWidth: 900, fontSize: 14, lineHeight: 1.8, color: '#475569' }}>
|
||||
这里统一承载 D-InSAR 配对、批次、生产准备、运行和产物管理,以及 Gamma SBAS-InSAR 生产链。
|
||||
陆探与哨兵源数据按压缩包登记,生产时再解包到本机 Task_Pool;高分三只登记外部 SARscape 服务器复制回来的 _geo 结果。
|
||||
</p>
|
||||
</section>
|
||||
{readOnly && (
|
||||
<div style={{ border: '1px solid #f59e0b', color: '#92400e', background: '#fffbeb', borderRadius: 6, padding: '8px 10px', fontSize: 13 }}>
|
||||
当前为只读账号,生产提交操作已禁用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section
|
||||
style={{
|
||||
...heroCardStyle,
|
||||
padding: '18px',
|
||||
display: 'grid',
|
||||
gap: 12,
|
||||
alignContent: 'start',
|
||||
}}
|
||||
>
|
||||
<div style={summaryCardStyle}>
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>主生产链</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>D-InSAR / SBAS</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}>
|
||||
D-InSAR 使用配对批次驱动;SBAS 使用 Gamma IPTA 工作流驱动。PS/旧时序入口不再作为主流程展示。
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
|
||||
{WORKFLOW_STEPS.map((step, index) => (
|
||||
<div
|
||||
key={step}
|
||||
style={{
|
||||
border: '1px solid #e2e8f0',
|
||||
background: '#f8fafc',
|
||||
borderRadius: 6,
|
||||
padding: '6px 9px',
|
||||
color: '#334155',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{index + 1}. {step}
|
||||
</div>
|
||||
</div>
|
||||
<div style={summaryCardStyle}>
|
||||
<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
|
||||
style={{
|
||||
...heroCardStyle,
|
||||
padding: '14px',
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
|
||||
{PRODUCTION_WORKSPACE_WORKBENCHES.map(workbench => {
|
||||
const isActive = workbench.key === activeWorkbench;
|
||||
return (
|
||||
<button
|
||||
key={workbench.key}
|
||||
type="button"
|
||||
onClick={() => switchWorkbench(workbench)}
|
||||
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 ? '当前视图' : '切换'}
|
||||
<div style={sectionStyle}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(220px, 280px) minmax(0, 1fr)', gap: 14, alignItems: 'start' }}>
|
||||
<aside style={{ ...compactPanelStyle, padding: 12 }}>
|
||||
<div style={{ color: '#475569', fontSize: 13, marginBottom: 8 }}>工作流</div>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{PRODUCTION_WORKSPACE_WORKBENCHES.map(workbench => (
|
||||
<button
|
||||
key={workbench.key}
|
||||
type="button"
|
||||
onClick={() => switchWorkbench(workbench)}
|
||||
style={{ ...buttonStyle(activeWorkbench === workbench.key), textAlign: 'left' }}
|
||||
>
|
||||
<span style={{ display: 'block' }}>{workbench.label}</span>
|
||||
<span style={{ display: 'block', marginTop: 4, color: activeWorkbench === workbench.key ? '#2563eb' : '#64748b', fontWeight: 400 }}>
|
||||
{workbench.description}
|
||||
</span>
|
||||
</div>
|
||||
<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 />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{activeView === 'dinsar_prepare' && (
|
||||
<LazyDataCopierPanel
|
||||
apiEndpoint={apiEndpoint}
|
||||
readOnly={readOnly}
|
||||
onJobQueued={handleDinsarPrepareQueued}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'dinsar_runs' && (
|
||||
<LazyDinsarProductionPanel
|
||||
readOnly={readOnly}
|
||||
onJobQueued={handleDinsarRunQueued}
|
||||
/>
|
||||
)}
|
||||
{['sbas_insar_planning', 'sbas_insar_batches', 'sbas_insar_prepare', 'sbas_insar_runs'].includes(activeView) && (
|
||||
<LazySbasInsarProductionPanel
|
||||
readOnly={readOnly}
|
||||
onTaskStart={onTaskStart}
|
||||
initialFocus={{
|
||||
sbas_insar_planning: 'planning',
|
||||
sbas_insar_batches: 'batches',
|
||||
sbas_insar_prepare: 'prepare',
|
||||
sbas_insar_runs: 'runs',
|
||||
}[activeView]}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'sbas_insar_products' && (
|
||||
<LazySbasInsarProductsPanel
|
||||
readOnly={readOnly}
|
||||
onJobQueued={handleSbasProductQueued}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'dinsar_products' && (
|
||||
<LazyDinsarProductsPanel
|
||||
readOnly={readOnly}
|
||||
onJobQueued={handleDinsarProductQueued}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main style={{ display: 'grid', gap: 14, minWidth: 0 }}>
|
||||
<div style={{ ...compactPanelStyle, padding: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ color: '#475569', fontSize: 13 }}>{currentWorkbench?.label}</div>
|
||||
<h3 style={{ margin: '3px 0 0', fontSize: 18 }}>{currentView?.label || '生产视图'}</h3>
|
||||
</div>
|
||||
<div style={{ ...mutedTextStyle, maxWidth: 560 }}>{currentView?.description}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 12 }}>
|
||||
{currentWorkbench?.views.map(view => (
|
||||
<button
|
||||
key={view.key}
|
||||
type="button"
|
||||
onClick={() => setActiveView(view.key)}
|
||||
style={buttonStyle(activeView === view.key)}
|
||||
>
|
||||
{view.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载生产面板..." />}>
|
||||
{renderContent()}
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -285,33 +285,33 @@ function RuntimeStatusPanel({ status }) {
|
||||
return (
|
||||
<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={valueStyle}>Runtime Status</div>
|
||||
<div style={valueStyle}>运行状态</div>
|
||||
<StatusBadge value={status.active ? 'RUNNING' : (status.run_status || 'IDLE')} />
|
||||
</div>
|
||||
<div style={{ ...metricGridStyle, marginTop: 8 }}>
|
||||
<Metric label="Current step" value={status.current_step?.id || '-'} />
|
||||
<Metric label="Workflow updated" value={status.workflow_updated_at || '-'} />
|
||||
<Metric label="Latest log" value={status.latest_log_updated_at || '-'} />
|
||||
<Metric label="当前步骤" value={status.current_step?.id || '-'} />
|
||||
<Metric label="Workflow 更新时间" value={status.workflow_updated_at || '-'} />
|
||||
<Metric label="最近日志" value={status.latest_log_updated_at || '-'} />
|
||||
<Metric
|
||||
label="Common overlap"
|
||||
label="公共重叠率"
|
||||
value={`${formatPercent(gate.common_overlap_ratio)} / ${formatPercent(gate.min_common_overlap_ratio)}`}
|
||||
/>
|
||||
</div>
|
||||
{(currentTask || currentJob) && (
|
||||
<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>
|
||||
)}
|
||||
{latestTaskLog && (
|
||||
<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>
|
||||
)}
|
||||
{latestFileLog?.tail && (
|
||||
<details style={{ ...compactDetailsStyle, marginTop: 8, borderColor: '#bae6fd' }}>
|
||||
<summary style={compactSummaryStyle}>{latestFileLog.name || 'latest log'}</summary>
|
||||
<summary style={compactSummaryStyle}>{latestFileLog.name || '最近日志'}</summary>
|
||||
<pre
|
||||
style={{
|
||||
margin: '8px 0 0',
|
||||
@@ -329,10 +329,10 @@ function RuntimeStatusPanel({ status }) {
|
||||
</details>
|
||||
)}
|
||||
<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 }}>
|
||||
{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 => (
|
||||
<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);
|
||||
}
|
||||
if (data?.task_id) {
|
||||
onTaskStart?.(data.task_id, 'LandSAR SBAS workflow queued.', {
|
||||
onTaskStart?.(data.task_id, 'LandSAR SBAS Workflow 已入队。', {
|
||||
taskType: data.job_type || 'SBAS_LANDSAR_WORKFLOW',
|
||||
nonBlocking: true,
|
||||
});
|
||||
@@ -1070,7 +1070,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
|
||||
const data = await submitSbasInsarWorkflowJob(selectedRunId, workflowPayload);
|
||||
setWorkflowJob(data);
|
||||
if (data?.task_id) {
|
||||
onTaskStart?.(data.task_id, 'Gamma SBAS workflow queued.', {
|
||||
onTaskStart?.(data.task_id, 'Gamma SBAS Workflow 已入队。', {
|
||||
taskType: data.job_type || 'SBAS_GAMMA_WORKFLOW',
|
||||
nonBlocking: true,
|
||||
});
|
||||
@@ -1163,7 +1163,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
|
||||
});
|
||||
setCoregistrationJob(data);
|
||||
if (data?.task_id) {
|
||||
onTaskStart?.(data.task_id, 'SBAS coregistration task queued.', {
|
||||
onTaskStart?.(data.task_id, 'SBAS 共参考配准 Task 已入队。', {
|
||||
taskType: data.job_type || 'SBAS_COREGISTRATION',
|
||||
nonBlocking: true,
|
||||
});
|
||||
@@ -1210,7 +1210,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
|
||||
});
|
||||
setRdcDemJob(data);
|
||||
if (data?.task_id) {
|
||||
onTaskStart?.(data.task_id, 'SBAS RDC DEM task queued.', {
|
||||
onTaskStart?.(data.task_id, 'SBAS RDC DEM Task 已入队。', {
|
||||
taskType: data.job_type || 'SBAS_RDC_DEM',
|
||||
nonBlocking: true,
|
||||
});
|
||||
@@ -1261,7 +1261,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
|
||||
});
|
||||
setInterferogramJob(data);
|
||||
if (data?.task_id) {
|
||||
onTaskStart?.(data.task_id, 'SBAS interferogram task queued.', {
|
||||
onTaskStart?.(data.task_id, 'SBAS 干涉图 Task 已入队。', {
|
||||
taskType: data.job_type || 'SBAS_INTERFEROGRAMS',
|
||||
nonBlocking: true,
|
||||
});
|
||||
@@ -1310,7 +1310,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
|
||||
});
|
||||
setIptaTimeseriesJob(data);
|
||||
if (data?.task_id) {
|
||||
onTaskStart?.(data.task_id, 'SBAS IPTA timeseries task queued.', {
|
||||
onTaskStart?.(data.task_id, 'SBAS IPTA 时序 Task 已入队。', {
|
||||
taskType: data.job_type || 'SBAS_IPTA_TIMESERIES',
|
||||
nonBlocking: true,
|
||||
});
|
||||
@@ -1379,7 +1379,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
|
||||
}}
|
||||
style={{ border: '1px solid #0369a1', borderRadius: 8, background: '#e0f2fe', color: '#0369a1', padding: '7px 11px', fontWeight: 750 }}
|
||||
>
|
||||
Open Runtime Status
|
||||
打开运行状态
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
@@ -2058,7 +2058,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
|
||||
</div>
|
||||
{running && (
|
||||
<div style={{ marginTop: 6, color: '#0369a1', fontSize: 12, fontWeight: 700 }}>
|
||||
正在运行,已自动打开右侧 Runtime Status
|
||||
正在运行,已自动打开右侧运行状态
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
@@ -2153,11 +2153,11 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
|
||||
color: '#9a3412',
|
||||
fontSize: 12,
|
||||
}}>
|
||||
Sentinel-1 Gamma SBAS is planning-only. Stack discovery, audit manifest and run record are enabled; Gamma execution is disabled until the S1 TOPS/SBAS scripts are verified.
|
||||
Sentinel-1 Gamma SBAS 当前仅开放规划能力:可进行栈发现、审计 Manifest 和 Run 记录管理;Gamma 执行需等待 S1 TOPS/SBAS 脚本验证完成后启用。
|
||||
</div>
|
||||
)}
|
||||
<div style={{ ...mutedStyle, marginTop: 6 }}>
|
||||
专家文档目录 + manifest + WSL runner 主路径。旧分阶段执行仅作为兼容桥接。
|
||||
专家文档目录 + manifest + WSL runner 主路径,生产执行以当前统一工作流为准。
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
|
||||
<button
|
||||
@@ -2237,7 +2237,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
|
||||
<details style={{ ...compactDetailsStyle, marginTop: 10 }}>
|
||||
<summary style={compactSummaryStyle}>Expert document path ({expertDocumentSteps.length})</summary>
|
||||
<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 style={{ display: 'grid', gap: 6, marginTop: 8 }}>
|
||||
{expertDocumentSteps.map(item => {
|
||||
|
||||
@@ -40,7 +40,6 @@ const statusColors = {
|
||||
ERROR: '#dc2626',
|
||||
};
|
||||
|
||||
const panelStyle = { display: 'grid', gap: 12 };
|
||||
const sectionStyle = {
|
||||
background: '#ffffff',
|
||||
border: '1px solid #d8dee8',
|
||||
@@ -203,11 +202,11 @@ const assetRoleInfo = {
|
||||
},
|
||||
product_summary: {
|
||||
label: '产品摘要',
|
||||
description: '旧版托管产品摘要;专家 Gamma 模式下不再作为必需产物。',
|
||||
description: '托管产品摘要;专家 Gamma 模式下不作为必需产物。',
|
||||
},
|
||||
quality_summary: {
|
||||
label: '质量摘要',
|
||||
description: '旧版质量统计摘要;专家 Gamma 模式下统计由 GeoTIFF 派生。',
|
||||
description: '质量统计摘要;专家 Gamma 模式下统计由 GeoTIFF 派生。',
|
||||
},
|
||||
monitor_points_summary: {
|
||||
label: '监测点摘要',
|
||||
@@ -271,7 +270,7 @@ const assetRoleInfo = {
|
||||
},
|
||||
alternate_geotiff: {
|
||||
label: 'LOS 反向约定 GeoTIFF',
|
||||
description: '旧版 away-from-radar 符号约定下的备用速率栅格。',
|
||||
description: 'away-from-radar 符号约定下的备用速率栅格。',
|
||||
},
|
||||
quality_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 hasSigmaStats = Object.keys(sigmaStats || {}).length > 0;
|
||||
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) => {
|
||||
if (!detail?.id || !asset) return;
|
||||
setLightboxImage({
|
||||
@@ -1538,17 +1549,18 @@ export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued }
|
||||
}, [detail?.id]);
|
||||
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<div className="sbas-products-page">
|
||||
<ImageLightbox image={lightboxImage} onClose={() => setLightboxImage(null)} />
|
||||
<section style={sectionStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
|
||||
<section className="sbas-products-header">
|
||||
<div className="sbas-products-header-main">
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: '#0f172a', fontSize: 18 }}>SBAS-InSAR 结果管理</h3>
|
||||
<div style={{ ...mutedStyle, marginTop: 5 }}>
|
||||
<h3>SBAS-InSAR 结果目录</h3>
|
||||
<p>
|
||||
管理 Gamma SBAS 生产结果、重要预览图、GeoTIFF、监测点曲线和发布资产。
|
||||
</div>
|
||||
这里用于成果登记、资产复核和目录重建,生产提交仍归入 SBAS 运行视图。
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div className="sbas-products-actions">
|
||||
<button type="button" onClick={loadCatalog} disabled={loading || actionLoading} style={buttonStyle}>
|
||||
{loading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
@@ -1558,7 +1570,26 @@ export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued }
|
||||
</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={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}`} />
|
||||
@@ -1575,7 +1606,14 @@ export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued }
|
||||
)}
|
||||
</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={{ display: 'grid', gap: 8, marginBottom: 10 }}>
|
||||
<input
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,20 @@ const STATUS_COLOR = {
|
||||
PUBLISHED: '#16a34a',
|
||||
};
|
||||
|
||||
const ACTIVE_RUN_STATUSES = new Set([
|
||||
'PENDING',
|
||||
'RUNNING',
|
||||
'PREPARING',
|
||||
'STACK_PREPARING',
|
||||
'MATERIALIZING',
|
||||
'STACK_RUNNING',
|
||||
'MINTPY_RUNNING',
|
||||
'EXPORTING',
|
||||
'REGISTERING',
|
||||
]);
|
||||
|
||||
const ACTIVE_RUN_REFRESH_INTERVAL_MS = 30000;
|
||||
|
||||
const PREPARED_STACK_STATE = {
|
||||
not_prepared: { label: 'Not prepared', color: '#64748b' },
|
||||
manifest_unreadable: { label: 'Manifest unreadable', color: '#dc2626' },
|
||||
@@ -209,6 +223,9 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
const [wslReport, setWslReport] = useState(null);
|
||||
const [preflightLoading, setPreflightLoading] = useState(false);
|
||||
const [preflightReport, setPreflightReport] = useState(null);
|
||||
const hasActiveRun = useMemo(() => (
|
||||
runs.some(item => ACTIVE_RUN_STATUSES.has(String(item.status || '').toUpperCase()))
|
||||
), [runs]);
|
||||
const [retryingStepId, setRetryingStepId] = useState('');
|
||||
|
||||
const selectedBatch = useMemo(
|
||||
@@ -355,9 +372,10 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
}, [loadBatches, loadRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(loadRuns, 10000);
|
||||
if (!hasActiveRun) return undefined;
|
||||
const timer = setInterval(loadRuns, ACTIVE_RUN_REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadRuns]);
|
||||
}, [hasActiveRun, loadRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRunDetail(selectedRunId);
|
||||
@@ -465,8 +483,7 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
}}
|
||||
>
|
||||
当前生产入口采用分层 SBAS 模型:时序配对先形成候选大池,提交 run 后由 prepare 冻结 prepared SBAS 小栈。
|
||||
ENVI/SARscape SBAS 后续只读取 prepared manifest 和 selected_network_edges 审计图,不再重新扫描全量数据。
|
||||
ISCE2 + MintPy 路径仍沿用 stack_prep、materialize、stack、MintPy、publish、register 链路。
|
||||
ENVI/SARscape SBAS 后续只读取 prepared manifest 和 selected_network_edges 审计图,确保生产输入可复核、可追溯。
|
||||
</div>
|
||||
{wslReport && (
|
||||
<div
|
||||
@@ -560,7 +577,6 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
style={{ width: '100%', padding: '6px 8px', borderRadius: 6, border: '1px solid #cbd5e1' }}
|
||||
>
|
||||
<option value="sarscape_sbas">ENVI/SARscape SBAS</option>
|
||||
<option value="isce2_stack_mintpy">ISCE2 + MintPy</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -10,6 +10,7 @@ const getStatusLabel = (lang) => lang === 'en'
|
||||
? { PENDING: 'Pending', RUNNING: 'Processing', DONE: 'Done', FAILED: 'Failed' }
|
||||
: { PENDING: '等待中', RUNNING: '处理中', DONE: '完成', FAILED: '失败' };
|
||||
const STATUS_COLOR = { PENDING: '#64748b', RUNNING: '#2563eb', DONE: '#16a34a', FAILED: '#dc2626' };
|
||||
const ACTIVE_SCENE_REFRESH_INTERVAL_MS = 15000;
|
||||
|
||||
const UI_COLORS = {
|
||||
pageText: '#0f172a',
|
||||
@@ -468,7 +469,7 @@ export default function WaterMonitorPanel({ readOnly, onShowOnMap, onShowFloodOn
|
||||
const timer = setInterval(() => {
|
||||
loadScenes(scenesPageRef.current);
|
||||
loadStatusIds();
|
||||
}, 5000);
|
||||
}, ACTIVE_SCENE_REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [activeRadarIds, tab, loadScenes, loadStatusIds]);
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ export const runWslCheck = (payload = {}) =>
|
||||
export const submitRun = (payload) =>
|
||||
apiClient.post('/dinsar-production/run', payload).then(r => r.data);
|
||||
|
||||
export const submitLandsarClusterRun = (payload) =>
|
||||
apiClient.post('/dinsar-production/landsar-cluster/run', payload).then(r => r.data);
|
||||
|
||||
// 运行历史
|
||||
export const listRuns = (limit = 20, offset = 0) =>
|
||||
apiClient.get(
|
||||
|
||||
@@ -2,3 +2,6 @@ import apiClient from './client';
|
||||
|
||||
export const getStatistics = (fresh = false) =>
|
||||
apiClient.get('/statistics', { params: fresh ? { fresh: true } : undefined }).then(r => r.data);
|
||||
|
||||
export const getStatisticsDashboard = () =>
|
||||
apiClient.get('/statistics/dashboard').then(r => r.data);
|
||||
|
||||
@@ -54,7 +54,6 @@ const createSentinelRows = (dataInfo, language, formatYmd) => {
|
||||
field(language === 'en' ? 'Orbit File:' : '轨道文件:', dataInfo.orbit_file_path, {
|
||||
valueStyle: { wordBreak: 'break-all' },
|
||||
}),
|
||||
field(language === 'en' ? 'ENVI Processed:' : 'ENVI已处理:', yesNo(dataInfo.is_envi_processed, language)),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -77,7 +76,6 @@ const createDefaultRows = (dataInfo, language, formatYmd) => [
|
||||
field(language === 'en' ? 'Orbit File:' : '轨道文件:', dataInfo.orbit_file_path, {
|
||||
valueStyle: { wordBreak: 'break-all' },
|
||||
}),
|
||||
field(language === 'en' ? 'ENVI Processed:' : 'ENVI已处理:', yesNo(dataInfo.is_envi_processed, language)),
|
||||
];
|
||||
|
||||
const createRows = (dataInfo, language, formatYmd) => {
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
getDinsarProductDetail,
|
||||
getDinsarProductCleanupPlan,
|
||||
listDinsarProductPairs,
|
||||
queueDinsarCatalogRebuild,
|
||||
queueDinsarProductPublish,
|
||||
} from '../api/dinsarProducts';
|
||||
import {
|
||||
DINSAR_ENGINE_ALL,
|
||||
@@ -52,15 +50,6 @@ function formatBytes(value) {
|
||||
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function parseDirectoryList(value) {
|
||||
return [...new Set(
|
||||
String(value || '')
|
||||
.split(/[\r\n,;]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
)];
|
||||
}
|
||||
|
||||
function getMessageTone(message) {
|
||||
return /失败|error|Error|ERROR/.test(String(message || '')) ? 'error' : 'success';
|
||||
}
|
||||
@@ -91,8 +80,6 @@ function MetaField({ label, value, multiline = false }) {
|
||||
export default function DinsarCatalogPanel({
|
||||
readOnly = false,
|
||||
compact = false,
|
||||
initialSourceDir = '',
|
||||
onTaskQueued,
|
||||
}) {
|
||||
const [catalogStatus, setCatalogStatus] = useState(null);
|
||||
const [products, setProducts] = useState([]);
|
||||
@@ -104,27 +91,14 @@ export default function DinsarCatalogPanel({
|
||||
const [cleanupPlanLoading, setCleanupPlanLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [actionMessage, setActionMessage] = useState('');
|
||||
const [sourceDirectoriesText, setSourceDirectoriesText] = useState(initialSourceDir || '');
|
||||
const [publishRoot, setPublishRoot] = useState('');
|
||||
const [engineFilter, setEngineFilter] = useState(DINSAR_ENGINE_ALL);
|
||||
const [queryDraft, setQueryDraft] = useState('');
|
||||
const [queryApplied, setQueryApplied] = useState('');
|
||||
|
||||
const listLimit = compact ? 8 : 24;
|
||||
const listLimit = compact ? 8 : 18;
|
||||
const previewBaseUrl = apiClient.defaults.baseURL || '/api';
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialSourceDir) return;
|
||||
setSourceDirectoriesText((current) => (current.trim() ? current : initialSourceDir));
|
||||
}, [initialSourceDir]);
|
||||
|
||||
const sourceDirectories = useMemo(
|
||||
() => parseDirectoryList(sourceDirectoriesText),
|
||||
[sourceDirectoriesText]
|
||||
);
|
||||
|
||||
const engineOptions = useMemo(
|
||||
() => buildDinsarEngineOptions([], { includeKnown: true }),
|
||||
[]
|
||||
@@ -210,6 +184,11 @@ export default function DinsarCatalogPanel({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectProduct = useCallback((pairKey, productId) => {
|
||||
setSelectedPairKey(pairKey);
|
||||
setSelectedProductId((current) => (current === productId ? current : productId || null));
|
||||
}, []);
|
||||
|
||||
const loadCleanupPlan = useCallback(async () => {
|
||||
if (!selectedProductId) return;
|
||||
setCleanupPlanLoading(true);
|
||||
@@ -243,45 +222,6 @@ export default function DinsarCatalogPanel({
|
||||
setQueryApplied('');
|
||||
}, []);
|
||||
|
||||
const handleQueuePublish = async () => {
|
||||
if (readOnly || sourceDirectories.length === 0) return;
|
||||
setActionLoading(true);
|
||||
setActionMessage('');
|
||||
try {
|
||||
const result = await queueDinsarProductPublish({
|
||||
source_directories: sourceDirectories,
|
||||
publish_root: publishRoot.trim() || null,
|
||||
rebuild_catalog: true,
|
||||
});
|
||||
setActionMessage(`结果包发布任务已入队:${result.task_id}`);
|
||||
onTaskQueued?.(result.task_id);
|
||||
await loadCatalog();
|
||||
} catch (error) {
|
||||
setActionMessage(`结果包发布失败:${error?.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQueueRebuild = async () => {
|
||||
if (readOnly) return;
|
||||
setActionLoading(true);
|
||||
setActionMessage('');
|
||||
try {
|
||||
const result = await queueDinsarCatalogRebuild({
|
||||
publish_root: publishRoot.trim() || null,
|
||||
full_rebuild: true,
|
||||
});
|
||||
setActionMessage(`结果目录重建任务已入队:${result.task_id}`);
|
||||
onTaskQueued?.(result.task_id);
|
||||
await loadCatalog();
|
||||
} catch (error) {
|
||||
setActionMessage(`结果目录重建失败:${error?.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const catalogTone = STATUS_TONE_MAP[catalogStatus?.status] || 'neutral';
|
||||
const actionTone = getMessageTone(actionMessage);
|
||||
const selectedIssues = Array.isArray(selectedProduct?.issues) ? selectedProduct.issues : [];
|
||||
@@ -311,7 +251,7 @@ export default function DinsarCatalogPanel({
|
||||
{selectedEngineMeta.shortLabel}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={loadCatalog} disabled={loading || actionLoading}>
|
||||
<button onClick={loadCatalog} disabled={loading}>
|
||||
{loading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -352,49 +292,6 @@ export default function DinsarCatalogPanel({
|
||||
</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' : ''}`}>
|
||||
<aside className="dinsar-catalog-list-card">
|
||||
<div className="dinsar-catalog-card-head">
|
||||
@@ -456,10 +353,7 @@ export default function DinsarCatalogPanel({
|
||||
key={rowKey}
|
||||
type="button"
|
||||
className={`dinsar-catalog-list-item ${selectedPairKey === rowKey ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedPairKey(rowKey);
|
||||
setSelectedProductId(item.primary_product_id || null);
|
||||
}}
|
||||
onClick={() => handleSelectProduct(rowKey, item.primary_product_id)}
|
||||
>
|
||||
<div className="dinsar-catalog-list-item-top">
|
||||
<strong>{item.task_alias || item.task_name || item.pair_key || '未命名任务'}</strong>
|
||||
|
||||
@@ -24,6 +24,8 @@ const statusColorMap = {
|
||||
REBUILDING: '#2563eb',
|
||||
};
|
||||
|
||||
const CATALOG_ACTIVE_REFRESH_INTERVAL_MS = 30000;
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-';
|
||||
try {
|
||||
@@ -125,13 +127,18 @@ export default function PsinsarCatalogPanel({
|
||||
|
||||
useEffect(() => {
|
||||
loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
const status = String(catalogStatus?.status || '').toUpperCase();
|
||||
if (status !== 'REBUILDING') return undefined;
|
||||
const timer = setInterval(() => {
|
||||
if (!actionLoading) {
|
||||
loadCatalog();
|
||||
}
|
||||
}, 10000);
|
||||
}, CATALOG_ACTIVE_REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [actionLoading, loadCatalog]);
|
||||
}, [actionLoading, catalogStatus?.status, loadCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
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="关闭">
|
||||
×
|
||||
</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 = '正在加载面板...' }) {
|
||||
return (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<p className="empty-state">{message}</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<p className="empty-state">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelLoadingPanel({ message = '正在加载面板...' }) {
|
||||
return (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<PanelLoadingBody message={message} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<PanelLoadingBody message={message} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalLoadingFallback({ message = '正在加载内容...' }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(15, 23, 42, 0.4)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '24px 28px',
|
||||
minWidth: '280px',
|
||||
boxShadow: '0 20px 45px rgba(15, 23, 42, 0.18)',
|
||||
}}
|
||||
>
|
||||
<p className="empty-state" style={{ margin: 0 }}>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(15, 23, 42, 0.4)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '24px 28px',
|
||||
minWidth: 280,
|
||||
boxShadow: '0 16px 32px rgba(15, 23, 42, 0.16)',
|
||||
}}
|
||||
>
|
||||
<p className="empty-state" style={{ margin: 0 }}>{message}</p>
|
||||
</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 LazyDataInfoModal = lazy(() => import('../DataInfoModal'));
|
||||
const LazyGlobalTaskCenter = lazy(() => import('../GlobalTaskCenter'));
|
||||
const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard'));
|
||||
const LazyAiReportModal = lazy(() => import('../AiReportModal'));
|
||||
const LazyMapExportModal = lazy(() => import('../MapExportModal'));
|
||||
|
||||
@@ -40,14 +39,10 @@ export default function AppOverlays({
|
||||
showPairingModal: state.showPairingModal,
|
||||
})));
|
||||
const {
|
||||
showStats,
|
||||
setShowStats,
|
||||
showDataInfo,
|
||||
setShowDataInfo,
|
||||
selectedDataInfo,
|
||||
} = useUiStore(useShallow((state) => ({
|
||||
showStats: state.showStats,
|
||||
setShowStats: state.setShowStats,
|
||||
showDataInfo: state.showDataInfo,
|
||||
setShowDataInfo: state.setShowDataInfo,
|
||||
selectedDataInfo: state.selectedDataInfo,
|
||||
@@ -79,12 +74,6 @@ export default function AppOverlays({
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{showStats && (
|
||||
<Suspense fallback={<ModalLoadingFallback message="正在加载统计看板..." />}>
|
||||
<LazyStatisticsDashboard onClose={() => setShowStats(false)} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<LicenseOverlay
|
||||
licenseLoading={licenseLoading}
|
||||
licenseStatus={licenseStatus}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
LEFT_TAB_SECTION,
|
||||
PRODUCTION_WORKSPACE_ROUTE_TABS,
|
||||
} from '../../config/appConstants';
|
||||
import { getLeftTabLabel } from '../../utils/appUiHelpers';
|
||||
import { getLeftTabDescription, getLeftTabLabel } from '../../utils/appUiHelpers';
|
||||
import { PanelLoadingBody, PanelLoadingPanel } from './AppLoadingFallbacks';
|
||||
|
||||
const LazyDataMonitorPanel = lazy(() => import('../../DataMonitorPanel'));
|
||||
@@ -28,6 +28,8 @@ const LazyDinsarResultPanel = lazy(() => import('../../panels/DinsarResultPanel'
|
||||
const LazyPsinsarCatalogPanel = lazy(() => import('../PsinsarCatalogPanel'));
|
||||
const LazySbasInsarMapAnalysisPanel = lazy(() => import('../../panels/SbasInsarMapAnalysisPanel'));
|
||||
const LazyProductionWorkspace = lazy(() => import('../../ProductionWorkspace'));
|
||||
const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard'));
|
||||
const LazyResultExtractionPanel = lazy(() => import('../../ResultExtractionPanel'));
|
||||
|
||||
export default function AppSidePanel({
|
||||
leftPanelWidth,
|
||||
@@ -105,8 +107,8 @@ export default function AppSidePanel({
|
||||
? '生产管理'
|
||||
: getLeftTabLabel(leftPanelTab, leftTabLabelContext);
|
||||
const standaloneDescription = isProductionWorkspace
|
||||
? '这里统一承载 D-InSAR 与 Gamma SBAS-InSAR 生产;旧 ISCE2/MintPy 时序入口已停用。'
|
||||
: '当前模块已切换为独立工作区模式。';
|
||||
? '这里统一承载 D-InSAR 与 Gamma SBAS-InSAR 的数据准备、生产运行、质量检查和成果发布。'
|
||||
: getLeftTabDescription(leftPanelTab);
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -198,7 +200,6 @@ export default function AppSidePanel({
|
||||
showRadarPageInputError={radarPanel.showRadarPageInputError}
|
||||
radarPageInputValidationError={radarPanel.radarPageInputValidationError}
|
||||
onSearchAll={radarPanel.onSearchAll}
|
||||
onShowStats={radarPanel.onShowStats}
|
||||
onSearch={radarPanel.onSearch}
|
||||
onReset={radarPanel.onReset}
|
||||
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' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载数据接入面板..." />}>
|
||||
@@ -235,7 +244,7 @@ export default function AppSidePanel({
|
||||
|
||||
{leftPanelTab === 'asset_inventory' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载资产库存..." />}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载资产台账..." />}>
|
||||
<LazyAssetInventoryPanel
|
||||
readOnly={isReadOnlyUser}
|
||||
onTaskStart={taskPanel.onTaskStart}
|
||||
@@ -311,7 +320,7 @@ export default function AppSidePanel({
|
||||
|
||||
{leftPanelTab === 'health' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载运维自检面板..." />}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载运行维护面板..." />}>
|
||||
<LazyHealthCheckPanel
|
||||
apiEndpoint={apiEndpoint}
|
||||
language={language}
|
||||
@@ -364,6 +373,7 @@ export default function AppSidePanel({
|
||||
onToggleVisibility={dinsarPanel.onToggleVisibility}
|
||||
onLabel={dinsarPanel.onLabel}
|
||||
onAnalyze={dinsarPanel.onAnalyze}
|
||||
onOpenResultExtraction={() => setLeftPanelTab('result_extraction')}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
@@ -405,6 +415,14 @@ export default function AppSidePanel({
|
||||
</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' && (
|
||||
<Suspense fallback={<PanelLoadingPanel message="正在加载 AI 质量面板..." />}>
|
||||
<LazyAiQualityPanel
|
||||
|
||||
@@ -1,112 +1,85 @@
|
||||
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({
|
||||
language,
|
||||
setLanguage,
|
||||
currentUser,
|
||||
isAdmin,
|
||||
isReadOnlyUser,
|
||||
activeTasks,
|
||||
avgTaskProgress,
|
||||
licenseStatus,
|
||||
healthStatus,
|
||||
healthLoading,
|
||||
healthError,
|
||||
onRefreshHealth,
|
||||
onLogout,
|
||||
language,
|
||||
currentUser,
|
||||
isAdmin,
|
||||
isReadOnlyUser,
|
||||
activeTasks,
|
||||
avgTaskProgress,
|
||||
licenseStatus,
|
||||
onLogout,
|
||||
}) {
|
||||
const hasHealthStatus = !!healthStatus;
|
||||
const licenseOk = !!licenseStatus?.ok;
|
||||
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;
|
||||
const licenseOk = !!licenseStatus?.ok;
|
||||
const hasActiveTasks = activeTasks.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="top-status-bar">
|
||||
<div className="status-brand">
|
||||
<div className="brand-title">InSAR 自动化管理系统</div>
|
||||
<div className="brand-subtitle">科研工程模式 · {licenseOk ? '已授权' : '未授权'}</div>
|
||||
</div>
|
||||
<div className="status-items">
|
||||
<div className="status-item">
|
||||
<span className={`status-dot ${getStatusClass(dbOk, hasHealthStatus)}`}></span>DB
|
||||
</div>
|
||||
<div className="status-item">
|
||||
<span className={`status-dot ${getStatusClass(workerOk, hasHealthStatus)}`}></span>Worker
|
||||
</div>
|
||||
<div className="status-item">
|
||||
<span className={`status-dot ${getStatusClass(idlOk, hasHealthStatus)}`}></span>IDL
|
||||
</div>
|
||||
<div className="status-item">
|
||||
<span className={`status-dot ${getStatusClass(aiOk, hasHealthStatus)}`}></span>Ollama
|
||||
</div>
|
||||
<div className="status-item">
|
||||
<span className={`status-dot ${getStatusClass(nginxOk, hasHealthStatus)}`}></span>Nginx
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-actions">
|
||||
<div className="status-lang-switch" data-no-i18n="true">
|
||||
<button
|
||||
className={`status-lang-btn ${language === 'zh' ? 'active' : ''}`}
|
||||
onClick={() => setLanguage('zh')}
|
||||
title="切换到中文"
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
<button
|
||||
className={`status-lang-btn ${language === 'en' ? 'active' : ''}`}
|
||||
onClick={() => setLanguage('en')}
|
||||
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>
|
||||
return (
|
||||
<>
|
||||
<div className="top-status-bar">
|
||||
<div className="status-brand">
|
||||
{LOGO_URL && (
|
||||
<img
|
||||
className="status-brand-logo"
|
||||
src={LOGO_URL}
|
||||
alt={`${ORGANIZATION_NAME} logo`}
|
||||
/>
|
||||
)}
|
||||
<div className="status-brand-copy">
|
||||
<div className="brand-org">{ORGANIZATION_NAME}</div>
|
||||
<div className="brand-title">{SYSTEM_NAME}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="status-system-meta" aria-label="系统状态摘要">
|
||||
<span>{SYSTEM_TAGLINE}</span>
|
||||
<span className={`status-license-chip ${licenseOk ? 'ok' : 'fail'}`}>
|
||||
{licenseOk ? '已授权' : '未授权'}
|
||||
</span>
|
||||
{licenseStatus?.expires_at && (
|
||||
<span className="status-license">
|
||||
授权至 {formatUtc(licenseStatus.expires_at, language)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="status-actions">
|
||||
<div className={`status-task ${hasActiveTasks ? 'has-active-tasks' : ''}`}>
|
||||
<span>{hasActiveTasks ? `运行中 ${activeTasks.length}` : '任务空闲'}</span>
|
||||
{hasActiveTasks && (
|
||||
<div className="status-task-bar" aria-hidden="true">
|
||||
<div className="status-task-fill" style={{ width: `${avgTaskProgress}%` }} />
|
||||
</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);
|
||||
|
||||
@@ -73,7 +73,7 @@ function DinsarResultRow({
|
||||
className="dinsar-trace-pill"
|
||||
title={language === 'en' ? 'Pairing selection strategy' : '配对选择策略'}
|
||||
>
|
||||
{result.selection_strategy || 'legacy'}
|
||||
{result.selection_strategy || '标准选择'}
|
||||
</span>
|
||||
{result.run_key && (
|
||||
<span
|
||||
|
||||
@@ -56,9 +56,6 @@ function RadarDataRow({
|
||||
: (language === 'en' ? 'Rebuild' : '重建')}
|
||||
</button>
|
||||
)}
|
||||
{item.is_envi_processed && (
|
||||
<span className="envi-status" title={language === 'en' ? 'Processed to ENVI format' : '已处理为ENVI格式'}>E</span>
|
||||
)}
|
||||
<span
|
||||
className={`orbit-status ${item.has_orbit_data ? 'has-orbit' : ''}`}
|
||||
title={item.has_orbit_data
|
||||
|
||||
@@ -86,12 +86,12 @@ export const PRODUCTION_WORKSPACE_DINSAR_VIEWS = [
|
||||
{
|
||||
key: 'dinsar_runs',
|
||||
label: '生产运行',
|
||||
description: '运行任务编排、引擎切换与过程监控',
|
||||
description: '运行任务编排、引擎切换与过程监控。',
|
||||
},
|
||||
{
|
||||
key: 'dinsar_products',
|
||||
label: '结果管理',
|
||||
description: '结果提取、标准目录发布与产物编目',
|
||||
description: '结果提取、标准目录发布与产品编目。',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -99,11 +99,11 @@ export const PRODUCTION_WORKSPACE_SBAS_VIEWS = [
|
||||
{
|
||||
key: 'sbas_insar_planning',
|
||||
label: '序列规划',
|
||||
description: '按生产区域发现 SBAS 候选序列,审计覆盖、时序密度、精轨和公共重叠范围。',
|
||||
description: '按生产区域发现 SBAS 候选序列,审查覆盖、时序密度、精轨和公共重叠范围。',
|
||||
},
|
||||
{
|
||||
key: 'sbas_insar_batches',
|
||||
label: '候选栈与Run',
|
||||
label: '候选栈与 Run',
|
||||
description: '查看候选序列、Manifest 与已创建的 SBAS 生产 Run。',
|
||||
},
|
||||
{
|
||||
@@ -119,21 +119,21 @@ export const PRODUCTION_WORKSPACE_SBAS_VIEWS = [
|
||||
{
|
||||
key: 'sbas_insar_products',
|
||||
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 = [
|
||||
{
|
||||
key: 'dinsar_workbench',
|
||||
label: 'D-InSAR工作台',
|
||||
description: '配对规划、候选对与批次、生产准备、生产运行和结果管理集中到一条 D-InSAR 工作流。',
|
||||
label: 'D-InSAR 工作台',
|
||||
description: '将配对规划、候选对与批次、生产准备、生产运行和结果管理集中到一条 D-InSAR 工作流。',
|
||||
defaultView: 'dinsar_pairing',
|
||||
views: PRODUCTION_WORKSPACE_DINSAR_VIEWS,
|
||||
},
|
||||
{
|
||||
key: 'sbas_workbench',
|
||||
label: 'SBAS-InSAR工作台',
|
||||
label: 'SBAS-InSAR 工作台',
|
||||
description: '围绕 SBAS 序列发现、生产执行和结果 catalog 管理组织时序 InSAR 生产。',
|
||||
defaultView: 'sbas_insar_planning',
|
||||
views: PRODUCTION_WORKSPACE_SBAS_VIEWS,
|
||||
@@ -145,18 +145,18 @@ export const PRODUCTION_WORKSPACE_VIEWS = [
|
||||
...PRODUCTION_WORKSPACE_SBAS_VIEWS,
|
||||
{
|
||||
key: 'lt1_production',
|
||||
label: '陆探生产占位',
|
||||
label: '陆探一生产占位',
|
||||
description: 'LT-1 源压缩包本机登记,按需 materialize 到 Task_Pool;D-InSAR/SBAS 生产不走 UNC。',
|
||||
},
|
||||
{
|
||||
key: 'sentinel1_production',
|
||||
label: '哨兵生产占位',
|
||||
label: 'Sentinel-1 生产占位',
|
||||
description: 'Sentinel-1 ZIP/SAFE 与 EOF 精轨本机管理,按需解包;当前 SBAS 仅保留规划能力。',
|
||||
},
|
||||
{
|
||||
key: 'gf3_native_registration',
|
||||
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 = {
|
||||
data: '数据管理',
|
||||
data: '数据资产',
|
||||
production_management: '生产管理',
|
||||
insar_analysis: 'InSAR形变分析',
|
||||
flood_analysis: '洪涝灾害分析',
|
||||
result_extraction: '结果提取',
|
||||
insar_analysis: '形变分析',
|
||||
statistics: '综合统计',
|
||||
flood_analysis: '灾害分析',
|
||||
ops: '运行维护',
|
||||
};
|
||||
|
||||
@@ -190,12 +192,12 @@ export const LEFT_GROUP_SECTIONS = {
|
||||
insar_analysis: [
|
||||
{
|
||||
key: 'dinsar',
|
||||
label: 'D-InSAR',
|
||||
label: 'D-InSAR 判读',
|
||||
tabs: ['dinsar_results', 'dinsar_analysis'],
|
||||
},
|
||||
{
|
||||
key: 'psinsar',
|
||||
label: 'SBAS',
|
||||
label: 'SBAS 形变',
|
||||
tabs: ['psinsar_analysis'],
|
||||
},
|
||||
],
|
||||
@@ -204,7 +206,9 @@ export const LEFT_GROUP_SECTIONS = {
|
||||
export const LEFT_GROUP_TABS = {
|
||||
data: ['ingest', 'asset_inventory', 'data', 'hazard'],
|
||||
production_management: [PRODUCTION_WORKSPACE_TAB],
|
||||
result_extraction: ['result_extraction'],
|
||||
insar_analysis: LEFT_GROUP_SECTIONS.insar_analysis.flatMap(section => section.tabs),
|
||||
statistics: ['statistics'],
|
||||
flood_analysis: ['flood_analysis'],
|
||||
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([
|
||||
...PRODUCTION_WORKSPACE_ROUTE_TABS,
|
||||
'statistics',
|
||||
'result_extraction',
|
||||
]);
|
||||
|
||||
export const ADMIN_ONLY_TABS = new Set([
|
||||
@@ -271,7 +277,6 @@ export const RADAR_SEARCH_DEFAULTS = {
|
||||
product_unique_id: '',
|
||||
orbit_direction: '',
|
||||
has_orbit_data: '',
|
||||
is_envi_processed: '',
|
||||
imaging_date_from: '',
|
||||
imaging_date_to: '',
|
||||
};
|
||||
|
||||
@@ -4,16 +4,16 @@ const TASK_UI_POLICIES = {
|
||||
DINSAR_RESULT_SCAN: { label: 'D-InSAR 结果扫描', featureScope: 'dinsar_products' },
|
||||
AI_TRAIN: { label: '训练 AI 模型', 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_WARMUP: { label: 'AI 模型预热', featureScope: 'insar_analysis' },
|
||||
COPY_DATA: { label: '数据分发拷贝', featureScope: 'data_monitor' },
|
||||
SCAN_HAZARD: { label: '灾害点同步', featureScope: 'hazard' },
|
||||
UNPACK_ARCHIVES: { label: 'LT-1 批量解包(旧)', featureScope: 'data_monitor' },
|
||||
UNPACK_SENTINEL1: { label: 'Sentinel-1 批量解包(旧)', featureScope: 'data_monitor' },
|
||||
GF3_UNPACK: { label: 'GF3 legacy 解包', featureScope: 'data_monitor' },
|
||||
GF3_BATCH_PROCESS: { label: 'GF3 legacy 预处理', featureScope: 'data_monitor' },
|
||||
GF3_SARSCAPE_PRODUCE: { label: 'GF3 SARscape 生产(停用)', featureScope: 'data_monitor' },
|
||||
UNPACK_ARCHIVES: { label: 'LT-1 批量解包', featureScope: 'data_monitor' },
|
||||
UNPACK_SENTINEL1: { label: 'Sentinel-1 批量解包', featureScope: 'data_monitor' },
|
||||
GF3_UNPACK: { label: 'GF3 数据解包', featureScope: 'data_monitor' },
|
||||
GF3_BATCH_PROCESS: { label: 'GF3 数据预处理', featureScope: 'data_monitor' },
|
||||
GF3_SARSCAPE_PRODUCE: { label: 'GF3 SARscape 生产', featureScope: 'data_monitor' },
|
||||
GF3_SARSCAPE_SYNC: { label: 'GF3 _geo 原生入库', featureScope: 'data_monitor' },
|
||||
GF3_QUICKLOOK_WEBP: { label: 'GF3 _geo WebP', 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_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' },
|
||||
LANDSAR_RUN: { label: 'LandSAR D-InSAR 生产', featureScope: 'dinsar_production' },
|
||||
SBAS_GAMMA_WORKFLOW: { label: 'Gamma SBAS 工作流', featureScope: 'sbas_insar' },
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from 'react';
|
||||
import apiClient from '../api/client';
|
||||
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 sleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
@@ -95,6 +95,8 @@ export default function useAppAuthLifecycle({
|
||||
if (userFromLogin) {
|
||||
setCurrentUser(userFromLogin);
|
||||
setAuthChecked(true);
|
||||
void fetchCurrentUser({ clearOnFailure: false });
|
||||
return;
|
||||
}
|
||||
await fetchCurrentUser({ clearOnFailure: !userFromLogin });
|
||||
}, [fetchCurrentUser, setCurrentUser, setAuthChecked]);
|
||||
@@ -236,11 +238,16 @@ export default function useAppAuthLifecycle({
|
||||
}, [fetchCurrentUser, fetchLicenseStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchHealthStatus();
|
||||
const startupTimer = setTimeout(() => {
|
||||
void fetchHealthStatus({ silent: true });
|
||||
}, 1500);
|
||||
const interval = setInterval(() => {
|
||||
void fetchHealthStatus({ silent: true });
|
||||
}, HEALTH_POLL_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
return () => {
|
||||
clearTimeout(startupTimer);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [fetchHealthStatus]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import apiClient from '../api/client';
|
||||
import { normalizeTaskStatus } from '../utils/appUiHelpers';
|
||||
|
||||
const ACTIVE_TASK_FALLBACK_POLL_MS = 10000;
|
||||
|
||||
export default function useGlobalTaskControl({
|
||||
currentUser,
|
||||
licenseOk,
|
||||
@@ -135,7 +137,7 @@ export default function useGlobalTaskControl({
|
||||
es.close();
|
||||
es = null;
|
||||
if (!fallbackInterval) {
|
||||
fallbackInterval = setInterval(syncActiveTasks, 5000);
|
||||
fallbackInterval = setInterval(syncActiveTasks, ACTIVE_TASK_FALLBACK_POLL_MS);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,205 +1,26 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { I18nContext } from './I18nContext';
|
||||
import { SUPPORTED_LANGUAGES, translateText } from './translations';
|
||||
|
||||
const STORAGE_KEY = 'ims_ui_language';
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
};
|
||||
const FIXED_LANGUAGE = 'zh';
|
||||
|
||||
export const I18nProvider = ({ children }) => {
|
||||
const [language, setLanguageState] = useState(getInitialLanguage);
|
||||
const textSourceMapRef = useRef(new WeakMap());
|
||||
const attrSourceMapRef = useRef(new WeakMap());
|
||||
|
||||
const setLanguage = useCallback((nextLanguage) => {
|
||||
if (!isSupportedLanguage(nextLanguage)) return;
|
||||
setLanguageState(nextLanguage);
|
||||
const setLanguage = useCallback(() => {
|
||||
localStorage.setItem('ims_ui_language', FIXED_LANGUAGE);
|
||||
}, []);
|
||||
|
||||
const t = useCallback((text) => translateText(text, language), [language]);
|
||||
|
||||
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 t = useCallback((text) => text, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
language,
|
||||
language: FIXED_LANGUAGE,
|
||||
en: false,
|
||||
setLanguage,
|
||||
t,
|
||||
}), [language, setLanguage, t]);
|
||||
}), [setLanguage, t]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ims_ui_language', FIXED_LANGUAGE);
|
||||
document.documentElement.setAttribute('lang', 'zh-CN');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={value}>
|
||||
|
||||
@@ -61,9 +61,6 @@
|
||||
{ zh: '有精轨:全部', en: 'Precise Orbit: All' },
|
||||
{ zh: '有精轨:是', en: 'Precise Orbit: Yes' },
|
||||
{ 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: 'No source data.' },
|
||||
|
||||
@@ -116,7 +113,6 @@
|
||||
{ zh: '轨道方向:', en: 'Orbit Direction:' },
|
||||
{ zh: '有精轨:', en: 'Has Orbit:' },
|
||||
{ zh: '轨道文件:', en: 'Orbit File:' },
|
||||
{ zh: 'ENVI已处理:', en: 'ENVI Processed:' },
|
||||
{ zh: '关闭', en: 'Close' },
|
||||
|
||||
{ zh: '是', en: 'Yes' },
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useDinsarStore, useHazardStore, useUiStore, useAuthStore } from '../store';
|
||||
import { useI18n } from '../i18n/I18nContext';
|
||||
import VirtualizedList from '../components/common/VirtualizedList';
|
||||
import DinsarResultRow from '../components/panels/DinsarResultRow';
|
||||
import ResultExportModal from '../components/ResultExportModal';
|
||||
import { PAGE_SIZE_OPTIONS } from '../config/appConstants';
|
||||
import { getPageHintText } from '../utils/appUiHelpers';
|
||||
import {
|
||||
@@ -36,6 +35,7 @@ export default function DinsarResultPanel({
|
||||
onToggleVisibility,
|
||||
onLabel,
|
||||
onAnalyze,
|
||||
onOpenResultExtraction,
|
||||
}) {
|
||||
const { language } = useI18n();
|
||||
const {
|
||||
@@ -76,8 +76,6 @@ export default function DinsarResultPanel({
|
||||
})));
|
||||
const { currentUser } = useAuthStore();
|
||||
const isReadOnlyUser = !!currentUser && currentUser.role !== 'admin';
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
const strategyOptions = useMemo(
|
||||
() => buildDinsarStrategyOptions(dinsarResults),
|
||||
[dinsarResults]
|
||||
@@ -261,11 +259,11 @@ export default function DinsarResultPanel({
|
||||
: (language === 'en' ? 'Show Dates' : '显示日期')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowExportModal(true)}
|
||||
disabled={isLoading || filteredResults.length === 0}
|
||||
onClick={onOpenResultExtraction}
|
||||
disabled={isLoading || !onOpenResultExtraction}
|
||||
title={language === 'en'
|
||||
? 'Export visible results in the current filter scope'
|
||||
: '按当前筛选范围导出结果文件'}
|
||||
? 'Open the unified result extraction workspace'
|
||||
: '进入统一结果提取工作台'}
|
||||
>
|
||||
{language === 'en' ? 'Export...' : '提取结果...'}
|
||||
</button>
|
||||
@@ -450,12 +448,6 @@ export default function DinsarResultPanel({
|
||||
</>
|
||||
)}
|
||||
|
||||
{showExportModal && (
|
||||
<ResultExportModal
|
||||
results={filteredResults}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ export default function RadarDataPanel({
|
||||
radarTotalPages,
|
||||
showRadarPageInputError,
|
||||
radarPageInputValidationError,
|
||||
// handlers
|
||||
onSearchAll,
|
||||
onShowStats,
|
||||
onSearch,
|
||||
onReset,
|
||||
onAoiModeChange,
|
||||
@@ -73,7 +71,6 @@ export default function RadarDataPanel({
|
||||
[allData]
|
||||
);
|
||||
|
||||
// Build visible satellite group buttons based on available satellites in the database
|
||||
const visibleSatelliteGroups = useMemo(() => {
|
||||
const allSatellites = radarSearchOptions.satellite || [];
|
||||
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 className="header-buttons">
|
||||
<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>
|
||||
</div>
|
||||
<div style={{ marginTop: '10px', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '10px', background: '#f8fafc' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '8px', fontSize: '13px' }}>
|
||||
{language === 'en' ? 'Source Data Search' : '源数据检索'}
|
||||
源数据检索
|
||||
</div>
|
||||
{visibleSatelliteGroups.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
|
||||
@@ -114,7 +108,7 @@ export default function RadarDataPanel({
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{language === 'en' ? 'All' : '全部'}
|
||||
全部
|
||||
</button>
|
||||
{visibleSatelliteGroups.map((group) => (
|
||||
<button
|
||||
@@ -141,7 +135,7 @@ export default function RadarDataPanel({
|
||||
)}
|
||||
<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}>
|
||||
<option value="">{language === 'en' ? 'Imaging Mode: All' : '成像模式:全部'}</option>
|
||||
<option value="">成像模式:全部</option>
|
||||
{radarSearchOptions.imaging_mode.map((item) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
@@ -150,28 +144,28 @@ export default function RadarDataPanel({
|
||||
value={radarSearchDraft.imaging_date_from}
|
||||
onChange={(nextValue) => updateDraft('imaging_date_from', nextValue)}
|
||||
language={language}
|
||||
title={language === 'en' ? 'Imaging Date From: Any' : '成像时间起:不限'}
|
||||
ariaLabel={language === 'en' ? 'Imaging Date From' : '成像时间起'}
|
||||
placeholder={language === 'en' ? 'Select start date' : '选择起始日期'}
|
||||
title="成像时间起:不限"
|
||||
ariaLabel="成像时间起"
|
||||
placeholder="选择起始日期"
|
||||
/>
|
||||
<UnifiedDatePicker
|
||||
value={radarSearchDraft.imaging_date_to}
|
||||
onChange={(nextValue) => updateDraft('imaging_date_to', nextValue)}
|
||||
language={language}
|
||||
title={language === 'en' ? 'Imaging Date To: Any' : '成像时间止:不限'}
|
||||
ariaLabel={language === 'en' ? 'Imaging Date To' : '成像时间止'}
|
||||
placeholder={language === 'en' ? 'Select end date' : '选择结束日期'}
|
||||
title="成像时间止:不限"
|
||||
ariaLabel="成像时间止"
|
||||
placeholder="选择结束日期"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
|
||||
<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) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<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) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
@@ -179,16 +173,16 @@ export default function RadarDataPanel({
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
|
||||
<select value={radarSearchAoiMode} onChange={(e) => onAoiModeChange(e.target.value)}>
|
||||
<option value="none">{language === 'en' ? 'AOI: Any' : '空间范围:不限'}</option>
|
||||
<option value="region">{language === 'en' ? 'AOI: Region' : '空间范围:行政区'}</option>
|
||||
<option value="shp">{language === 'en' ? 'AOI: Upload SHP' : '空间范围:上传SHP'}</option>
|
||||
<option value="none">空间范围:不限</option>
|
||||
<option value="region">空间范围:行政区</option>
|
||||
<option value="shp">空间范围:上传 SHP</option>
|
||||
</select>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button type="button" onClick={onSearch} disabled={isLoading} style={{ flex: 1 }}>
|
||||
{language === 'en' ? 'Search' : '搜索'}
|
||||
搜索
|
||||
</button>
|
||||
<button type="button" onClick={onReset} disabled={isLoading} style={{ flex: 1 }}>
|
||||
{language === 'en' ? 'Reset' : '重置'}
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,7 +193,7 @@ export default function RadarDataPanel({
|
||||
onChange={(e) => onProvinceChange(e.target.value)}
|
||||
disabled={radarSearchRegionLoading}
|
||||
>
|
||||
<option value="">{language === 'en' ? 'Select Province' : '选择省份'}</option>
|
||||
<option value="">选择省份</option>
|
||||
{radarSearchRegionOptions.provinces.map((item) => (
|
||||
<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)}
|
||||
disabled={radarSearchRegionLoading || !radarSearchRegionSelection.province}
|
||||
>
|
||||
<option value="">{language === 'en' ? 'Select City (Optional)' : '选择地市(可选)'}</option>
|
||||
<option value="">选择地市(可选)</option>
|
||||
{radarSearchRegionOptions.cities.map((item) => (
|
||||
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
|
||||
))}
|
||||
@@ -231,66 +225,61 @@ export default function RadarDataPanel({
|
||||
)}
|
||||
<details>
|
||||
<summary style={{ cursor: 'pointer', fontSize: '12px', color: '#334155' }}>
|
||||
{language === 'en' ? 'Advanced Fields' : '高级字段'}
|
||||
高级字段
|
||||
</summary>
|
||||
<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}>
|
||||
<option value="">{language === 'en' ? 'Satellite Mode: All' : '卫星模式:全部'}</option>
|
||||
<option value="">卫星模式:全部</option>
|
||||
{radarSearchOptions.satellite_mode.map((item) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<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) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<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) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<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) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<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) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<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) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<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) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<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="true">{language === 'en' ? 'Precise Orbit: Yes' : '有精轨:是'}</option>
|
||||
<option value="false">{language === 'en' ? 'Precise Orbit: No' : '有精轨:否'}</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>
|
||||
<option value="">精轨状态:全部</option>
|
||||
<option value="true">精轨状态:有</option>
|
||||
<option value="false">精轨状态:无</option>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
{radarSearchOptionsLoading && (
|
||||
<div style={{ marginTop: '8px', fontSize: '12px', color: '#64748b' }}>
|
||||
{language === 'en' ? 'Loading source data search options...' : '源数据检索选项加载中...'}
|
||||
源数据检索选项加载中...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -300,12 +289,8 @@ export default function RadarDataPanel({
|
||||
<div className="empty-state">
|
||||
<p>
|
||||
{!hasRadarSearched
|
||||
? (language === 'en'
|
||||
? 'No query yet. Please run Search or Search All first.'
|
||||
: '尚未执行检索,请先点击"搜索"或"搜索全部"。')
|
||||
: (language === 'en'
|
||||
? 'No data matched this query.'
|
||||
: '当前检索未命中数据。')}
|
||||
? '尚未执行检索,请先点击“搜索”或“搜索全部源数据”。'
|
||||
: '当前检索条件下没有匹配的数据。'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -319,35 +304,31 @@ export default function RadarDataPanel({
|
||||
onChange={onSelectAllVisibility}
|
||||
/>
|
||||
<label htmlFor="select-all-visibility">
|
||||
{language === 'en'
|
||||
? `Toggle all coverage (Current page ${allData.length} items / Total ${radarPagination.total} items)`
|
||||
: `覆盖面全部显示/隐藏(当前页 ${allData.length} 条 / 总计 ${radarPagination.total} 条)`}
|
||||
覆盖面全部显示/隐藏(当前页 {allData.length} 条 / 总计 {radarPagination.total} 条)
|
||||
</label>
|
||||
</div>
|
||||
<div className="toolbar-row">
|
||||
<button type="button" onClick={() => onSetAllPreviewVisibility(true)} disabled={allData.length === 0}>
|
||||
{language === 'en' ? 'Show All Source Previews' : '源影像一键显示'}
|
||||
源影像一键显示
|
||||
</button>
|
||||
<button type="button" onClick={() => onSetAllPreviewVisibility(false)} disabled={allData.length === 0}>
|
||||
{language === 'en' ? 'Hide All Source Previews' : '源影像一键隐藏'}
|
||||
源影像一键隐藏
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-row">
|
||||
<button type="button" onClick={() => onPageChange(-1)} disabled={isLoading || !hasRadarSearched || radarPagination.offset <= 0}>
|
||||
{language === 'en' ? 'Previous' : '上一页'}
|
||||
上一页
|
||||
</button>
|
||||
<span style={{ fontSize: '12px', color: '#4a5568' }}>
|
||||
{language === 'en'
|
||||
? `Page ${radarCurrentPage}/${radarTotalPages}`
|
||||
: `第 ${radarCurrentPage}/${radarTotalPages} 页`}
|
||||
第 {radarCurrentPage}/{radarTotalPages} 页
|
||||
</span>
|
||||
<button type="button" onClick={() => onPageChange(1)} disabled={isLoading || !hasRadarSearched || !radarPagination.hasMore}>
|
||||
{language === 'en' ? 'Next' : '下一页'}
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-row">
|
||||
<label style={{ fontSize: '12px', color: '#4a5568' }}>
|
||||
{language === 'en' ? 'Per page' : '每页'}
|
||||
每页
|
||||
<select
|
||||
value={radarPagination.limit}
|
||||
onChange={onPageSizeChange}
|
||||
@@ -358,10 +339,10 @@ export default function RadarDataPanel({
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
{language === 'en' ? 'items' : '条'}
|
||||
条
|
||||
</label>
|
||||
<label style={{ fontSize: '12px', color: '#4a5568' }}>
|
||||
{language === 'en' ? 'Go to' : '跳到'}
|
||||
跳到
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -387,10 +368,10 @@ export default function RadarDataPanel({
|
||||
boxShadow: showRadarPageInputError ? '0 0 0 1px rgba(229,62,62,0.25)' : undefined,
|
||||
}}
|
||||
/>
|
||||
{language === 'en' ? 'page' : '页'}
|
||||
页
|
||||
</label>
|
||||
<button type="button" onClick={onGoToPage} disabled={isLoading || !hasRadarSearched}>
|
||||
{language === 'en' ? 'Jump' : '跳转'}
|
||||
跳转
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-row">
|
||||
@@ -398,10 +379,8 @@ export default function RadarDataPanel({
|
||||
{showRadarPageInputError
|
||||
? radarPageInputValidationError
|
||||
: (hasRadarSearched
|
||||
? getPageHintText(radarTotalPages, language)
|
||||
: (language === 'en'
|
||||
? 'Run a search first to enable pagination.'
|
||||
: '请先执行检索,再使用分页。'))}
|
||||
? getPageHintText(radarTotalPages)
|
||||
: '请先执行检索,再使用分页。')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ const RADAR_SEARCH_DEFAULTS = {
|
||||
satellite: '', satellite_mode: '', receiving_station: '', imaging_mode: '',
|
||||
orbit_circle: '', acquisition_time_utc: '', product_type: '', polarization: '',
|
||||
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 = {
|
||||
|
||||
@@ -7,10 +7,7 @@ const s = (set, key) => (v) =>
|
||||
|
||||
export const useUiStore = create((set) => ({
|
||||
leftPanelTab: 'ingest',
|
||||
leftPanelWidth: 380,
|
||||
rightPanelWidth: 360,
|
||||
isResizing: false,
|
||||
showStats: false,
|
||||
leftPanelWidth: 620,
|
||||
showDataInfo: false,
|
||||
selectedDataInfo: null,
|
||||
showDates: false,
|
||||
@@ -19,9 +16,6 @@ export const useUiStore = create((set) => ({
|
||||
logs: [],
|
||||
setLeftPanelTab: s(set, 'leftPanelTab'),
|
||||
setLeftPanelWidth: s(set, 'leftPanelWidth'),
|
||||
setRightPanelWidth: s(set, 'rightPanelWidth'),
|
||||
setIsResizing: s(set, 'isResizing'),
|
||||
setShowStats: s(set, 'showStats'),
|
||||
setShowDataInfo: s(set, 'showDataInfo'),
|
||||
setSelectedDataInfo: s(set, 'selectedDataInfo'),
|
||||
setShowDates: s(set, 'showDates'),
|
||||
|
||||
@@ -32,57 +32,61 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => {
|
||||
|
||||
switch (tabKey) {
|
||||
case 'ingest':
|
||||
return '入库监控';
|
||||
return '数据接入';
|
||||
case 'asset_inventory':
|
||||
return '资产库存';
|
||||
return '资产台账';
|
||||
case 'data':
|
||||
return '数据列表';
|
||||
return '影像检索';
|
||||
case 'hazard':
|
||||
return '灾害点';
|
||||
return '灾害点库';
|
||||
case 'statistics':
|
||||
return '综合统计';
|
||||
case 'pairing':
|
||||
return 'D-InSAR配对规划';
|
||||
return 'D-InSAR 配对规划';
|
||||
case 'pairs':
|
||||
return `D-InSAR候选对与批次 (${pairCount})`;
|
||||
return `D-InSAR 候选对与批次 (${pairCount})`;
|
||||
case 'ps_results':
|
||||
return `SBAS序列规划 (${psResultCount})`;
|
||||
return `SBAS 序列规划 (${psResultCount})`;
|
||||
case 'batches':
|
||||
return 'D-InSAR候选对与批次';
|
||||
return 'D-InSAR 候选对与批次';
|
||||
case 'copier':
|
||||
return 'D-InSAR生产准备';
|
||||
return 'D-InSAR 生产准备';
|
||||
case 'production_management':
|
||||
return '生产管理';
|
||||
case 'idl':
|
||||
return 'D-InSAR生产(旧)';
|
||||
return 'D-InSAR 生产';
|
||||
case 'dinsar_production':
|
||||
return 'D-InSAR生产运行';
|
||||
return 'D-InSAR 生产运行';
|
||||
case 'dinsar_products':
|
||||
return 'D-InSAR结果管理';
|
||||
return 'D-InSAR 成果目录';
|
||||
case 'ps_production':
|
||||
return 'SBAS-InSAR生产工作流';
|
||||
return 'SBAS-InSAR 生产工作流';
|
||||
case 'ps_products':
|
||||
return 'SBAS-InSAR结果管理';
|
||||
return 'SBAS-InSAR 成果目录';
|
||||
case 'dinsar_results':
|
||||
return `D-InSAR结果 (${dinsarTotal})`;
|
||||
return `D-InSAR 结果判读 (${dinsarTotal})`;
|
||||
case 'dinsar_analysis':
|
||||
return 'D-InSAR分析';
|
||||
return 'D-InSAR 专题分析';
|
||||
case 'psinsar_results':
|
||||
return 'SBAS-InSAR结果';
|
||||
return 'SBAS-InSAR 结果';
|
||||
case 'psinsar_analysis':
|
||||
return 'SBAS-InSAR分析';
|
||||
return 'SBAS 形变分析';
|
||||
case 'result_extraction':
|
||||
return '结果提取';
|
||||
case 'ai_quality':
|
||||
return 'AI质量评估';
|
||||
return 'AI 质量评估';
|
||||
case 'ai_diagnosis':
|
||||
return 'D-InSAR诊断';
|
||||
return 'D-InSAR 诊断';
|
||||
case 'landslide_segmentation':
|
||||
return '滑坡语义分割';
|
||||
case 'uav_image_analysis':
|
||||
return '无人机影像分析';
|
||||
case 'water':
|
||||
return '水体监测(旧)';
|
||||
return '水体监测';
|
||||
case 'flood_analysis':
|
||||
return '洪涝灾害分析';
|
||||
case 'health':
|
||||
return '运维自检';
|
||||
return '运行维护';
|
||||
case 'users':
|
||||
return '用户管理';
|
||||
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 => (
|
||||
selection?.city || selection?.province || ''
|
||||
);
|
||||
@@ -145,56 +182,48 @@ export const normalizeRadarSearchCriteria = (draft, defaults) => {
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const formatUtc = (isoString, language) => {
|
||||
if (!isoString) return language === 'en' ? 'Unknown' : '未知';
|
||||
export const formatUtc = (isoString) => {
|
||||
if (!isoString) return '未知';
|
||||
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');
|
||||
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();
|
||||
if (!value) return '-';
|
||||
|
||||
const compactMatch = value.match(/^(\d{4})(\d{2})(\d{2})$/);
|
||||
if (compactMatch) {
|
||||
const [, yyyy, mm, dd] = compactMatch;
|
||||
return language === 'en'
|
||||
? `${yyyy}-${mm}-${dd}`
|
||||
: `${yyyy}年${mm}月${dd}日`;
|
||||
return `${yyyy}年${mm}月${dd}日`;
|
||||
}
|
||||
|
||||
const dashMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (dashMatch) {
|
||||
const [, yyyy, mm, dd] = dashMatch;
|
||||
return language === 'en'
|
||||
? `${yyyy}-${mm}-${dd}`
|
||||
: `${yyyy}年${mm}月${dd}日`;
|
||||
return `${yyyy}年${mm}月${dd}日`;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const getPageHintText = (totalPages, language) => (
|
||||
language === 'en'
|
||||
? `Tip: valid page range is 1-${totalPages}. Press Enter to jump.`
|
||||
: `提示:可跳转页码范围为 1-${totalPages},按 Enter 可快速跳转。`
|
||||
export const getPageHintText = (totalPages) => (
|
||||
`提示:可跳转页码范围为 1-${totalPages},按 Enter 可快速跳转。`
|
||||
);
|
||||
|
||||
export const getPageInputErrorText = (rawValue, totalPages, language) => {
|
||||
export const getPageInputErrorText = (rawValue, totalPages) => {
|
||||
const value = String(rawValue ?? '').trim();
|
||||
if (!value) {
|
||||
return language === 'en' ? 'Please enter a page number.' : '请输入页码。';
|
||||
return '请输入页码。';
|
||||
}
|
||||
const numericValue = Number(value);
|
||||
if (!Number.isFinite(numericValue) || !Number.isInteger(numericValue)) {
|
||||
return language === 'en' ? 'Page number must be an integer.' : '页码必须为整数。';
|
||||
return '页码必须为整数。';
|
||||
}
|
||||
if (numericValue < 1 || numericValue > totalPages) {
|
||||
return language === 'en'
|
||||
? `Page must be between 1 and ${totalPages}.`
|
||||
: `页码必须在 1-${totalPages} 之间。`;
|
||||
return `页码必须在 1-${totalPages} 之间。`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -5,6 +5,7 @@ import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
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:
|
||||
families = _normalize_families(args.family)
|
||||
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(
|
||||
families=families,
|
||||
limit=args.limit,
|
||||
@@ -41,6 +91,8 @@ async def _run(args: argparse.Namespace) -> dict:
|
||||
apply=bool(args.apply),
|
||||
progress_start=0,
|
||||
progress_end=100,
|
||||
progress_callback=_print_progress if args.progress else None,
|
||||
progress_interval=args.progress_interval,
|
||||
)
|
||||
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("--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("--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()
|
||||
|
||||
payload = asyncio.run(_run(args))
|
||||
|
||||
@@ -399,7 +399,7 @@ async def _build_archive_previews(
|
||||
summary["items"].append(item)
|
||||
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(
|
||||
preview_source,
|
||||
geo_cache_path,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0\.."
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0start_landsar_cluster_worker.ps1"
|
||||
pause
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user