Compare commits

...
10 Commits
31 changed files with 3259 additions and 248 deletions
+1
View File
@@ -43,6 +43,7 @@ MIGRATION_FILES = [
"011_source_metadata_documents.sql",
"012_source_archive_integrity.sql",
"013_result_delivery_requests.sql",
"014_result_delivery_ortho_sources.sql",
]
+6
View File
@@ -377,6 +377,8 @@ class ResultDeliveryItemORM(Base):
source_product_id = Column(Integer, ForeignKey("result_products.id", ondelete="SET NULL"), nullable=True, index=True)
source_result_id = Column(Integer, ForeignKey("dinsar_results.id", ondelete="SET NULL"), nullable=True, index=True)
source_asset_id = Column(Integer, ForeignKey("result_assets.id", ondelete="SET NULL"), nullable=True, index=True)
source_radar_data_id = Column(Integer, ForeignKey("radar_data.id", ondelete="SET NULL"), nullable=True, index=True)
source_scene_geo_id = Column(Integer, ForeignKey("sar_scene_geo.id", ondelete="SET NULL"), nullable=True, index=True)
display_name = Column(String(255), nullable=False)
source_path = Column(String, nullable=False)
relative_path = Column(String, nullable=True)
@@ -391,10 +393,14 @@ class ResultDeliveryItemORM(Base):
product = relationship("ResultProductORM", foreign_keys=[source_product_id])
compat_result = relationship("DinsarResultORM", foreign_keys=[source_result_id])
asset = relationship("ResultAssetORM", foreign_keys=[source_asset_id])
radar_data = relationship("RadarDataORM", foreign_keys=[source_radar_data_id])
scene_geo = relationship("SARSceneGeoORM", foreign_keys=[source_scene_geo_id])
__table_args__ = (
Index("idx_result_delivery_items_delivery_status", "delivery_id", "status"),
Index("idx_result_delivery_items_product", "source_product_id"),
Index("idx_result_delivery_items_radar", "source_radar_data_id"),
Index("idx_result_delivery_items_scene_geo", "source_scene_geo_id"),
)
+2
View File
@@ -18,6 +18,7 @@ from . import (
license,
logs,
monitor,
ops_maintenance,
orbit,
pairing,
ps_products,
@@ -48,6 +49,7 @@ def include_all_routers(router: APIRouter) -> None:
router.include_router(tools.router)
router.include_router(unpack.router)
router.include_router(monitor.router)
router.include_router(ops_maintenance.router)
router.include_router(orbit.router)
router.include_router(assets.router)
router.include_router(root_registry.router)
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import AuthUserORM
from ..services.ops_maintenance_service import ops_maintenance_service
from .dependencies import _add_operation_audit_log, _get_current_user, _require_admin
router = APIRouter(prefix="/ops-maintenance", tags=["ops-maintenance"])
class CleanupRequest(BaseModel):
confirm: bool = False
delete_task_records: bool = True
delete_logs: bool = True
delete_production_records: bool = True
delete_result_products: bool = True
delete_production_dirs: bool = True
delete_task_pool_dir: bool = True
@router.get("/tasks")
async def list_maintenance_tasks(
task_type: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
return await ops_maintenance_service.list_tasks(
db,
task_type=task_type,
status=status,
limit=limit,
offset=offset,
)
@router.get("/tasks/{task_id}/diagnosis")
async def diagnose_maintenance_task(
task_id: str,
_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
diagnosis = await ops_maintenance_service.diagnose_task(db, task_id)
if diagnosis is None:
raise HTTPException(status_code=404, detail="Task not found.")
return diagnosis
@router.post("/tasks/{task_id}/cleanup-preview")
async def preview_maintenance_cleanup(
task_id: str,
_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
preview = await ops_maintenance_service.cleanup_preview(db, task_id)
if preview is None:
raise HTTPException(status_code=404, detail="Task not found.")
return preview
@router.post("/tasks/{task_id}/cleanup")
async def cleanup_maintenance_task(
task_id: str,
request_body: CleanupRequest,
request: Request,
admin_user: AuthUserORM = Depends(_require_admin),
db: AsyncSession = Depends(get_db),
):
if not request_body.confirm:
raise HTTPException(status_code=400, detail="Cleanup confirmation is required.")
try:
result = await ops_maintenance_service.cleanup_task(
db,
task_id,
options=request_body.model_dump(),
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=404, detail="Task not found.")
await _add_operation_audit_log(
db,
request,
action="ops_task_cleanup",
resource=f"ops-maintenance/tasks/{task_id}",
detail={
"task_id": task_id,
"options": request_body.model_dump(),
"result": result,
},
user=admin_user,
)
await db.commit()
return result
+24
View File
@@ -22,6 +22,7 @@ class ResultDeliveryCreateRequest(BaseModel):
channel: str
product_ids: Optional[List[int]] = None
compat_result_ids: Optional[List[int]] = None
item_ids: Optional[List[int]] = None
package_mode: str = "directory"
include_checksums: Optional[bool] = None
@@ -64,6 +65,7 @@ async def create_result_delivery(
channel=request.channel,
product_ids=request.product_ids,
compat_result_ids=request.compat_result_ids,
item_ids=request.item_ids,
package_mode=request.package_mode,
include_checksums=request.include_checksums,
)
@@ -89,6 +91,28 @@ async def create_result_delivery(
return result_delivery_service.serialize_delivery(delivery)
@router.get("/result-deliveries/catalog/{channel}")
async def list_result_delivery_catalog(
channel: str,
limit: int = 100,
offset: int = 0,
query: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
_ = current_user
try:
return await result_delivery_service.list_channel_catalog(
db,
channel=channel,
limit=limit,
offset=offset,
query=query,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/result-deliveries")
async def list_result_deliveries(
mine: bool = True,
+89 -3
View File
@@ -5,8 +5,9 @@ from typing import Any, Dict, Iterable, List, Optional
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..models import DinsarTaskItemORM, ResultProductORM
from ..models import DinsarProductionRunItemORM, DinsarTaskItemORM, ResultProductORM
from ..utils import normalize_satellite_family
@@ -102,11 +103,32 @@ def _product_status(product: ResultProductORM) -> str:
return "ready"
def _status_timestamp(value: Optional[datetime]) -> float:
return _timestamp(value)
def _latest_failed_attempt(attempts: Iterable[DinsarProductionRunItemORM]) -> Optional[DinsarProductionRunItemORM]:
latest: Optional[DinsarProductionRunItemORM] = None
for attempt in attempts:
if str(attempt.status or "").strip().upper() != "FAILED":
continue
if latest is None or (
_status_timestamp(attempt.ended_at or attempt.updated_at or attempt.created_at),
attempt.id or 0,
) > (
_status_timestamp(latest.ended_at or latest.updated_at or latest.created_at),
latest.id or 0,
):
latest = attempt
return latest
def serialize_engine_result(
*,
engine_code: str,
data_family: str,
product: Optional[ResultProductORM],
failed_attempt: Optional[DinsarProductionRunItemORM] = None,
allowed: bool,
legacy: bool = False,
) -> Dict[str, Any]:
@@ -119,6 +141,10 @@ def serialize_engine_result(
status = "blocked"
can_dispatch = False
skip_reason = "unsupported_data_family"
elif product is None and failed_attempt is not None:
status = "failed"
can_dispatch = True
skip_reason = "production_failed"
elif product is None:
status = "missing"
can_dispatch = True
@@ -140,11 +166,18 @@ def serialize_engine_result(
),
"latest_product_id": product.id if product is not None else None,
"product_id": product.product_id if product is not None else None,
"run_key": product.run_key if product is not None else None,
"run_key": product.run_key if product is not None else failed_attempt.latest_run_key if failed_attempt is not None else None,
"published_at": product.published_at if product is not None else None,
"health_status": product.health_status if product is not None else None,
"primary_asset_path": product.primary_asset_path if product is not None else None,
"preview_path": product.preview_path if product is not None else None,
"production_run_id": failed_attempt.run_id if failed_attempt is not None else None,
"production_item_id": failed_attempt.id if failed_attempt is not None else None,
"production_status": failed_attempt.status if failed_attempt is not None else None,
"production_error": failed_attempt.last_error if failed_attempt is not None else None,
"latest_log_path": failed_attempt.latest_log_path if failed_attempt is not None else None,
"latest_output_dir": failed_attempt.latest_output_dir if failed_attempt is not None else None,
"attempt_ended_at": failed_attempt.ended_at if failed_attempt is not None else None,
"can_dispatch": can_dispatch,
"skip_reason": skip_reason,
}
@@ -153,6 +186,7 @@ def serialize_engine_result(
def build_engine_results(
*,
products: Iterable[ResultProductORM],
failed_attempts: Iterable[DinsarProductionRunItemORM] = (),
data_family: str = "unknown",
include_legacy: bool = False,
) -> Dict[str, Dict[str, Any]]:
@@ -168,6 +202,17 @@ def build_engine_results(
):
target[engine] = product
failed_by_engine: Dict[str, DinsarProductionRunItemORM] = {}
attempts_by_engine: Dict[str, List[DinsarProductionRunItemORM]] = {}
for attempt in failed_attempts:
engine = normalize_dinsar_engine_code(getattr(getattr(attempt, "run", None), "engine_code", ""))
if engine:
attempts_by_engine.setdefault(engine, []).append(attempt)
for engine, attempts in attempts_by_engine.items():
latest = _latest_failed_attempt(attempts)
if latest is not None:
failed_by_engine[engine] = latest
allowed = allowed_engines_for_data_family(data_family)
matrix: Dict[str, Dict[str, Any]] = {}
for engine in CURRENT_DINSAR_ENGINE_ORDER:
@@ -175,6 +220,7 @@ def build_engine_results(
engine_code=engine,
data_family=data_family,
product=latest_by_engine.get(engine),
failed_attempt=failed_by_engine.get(engine) if latest_by_engine.get(engine) is None else None,
allowed=engine in allowed,
)
@@ -184,6 +230,7 @@ def build_engine_results(
engine_code=engine,
data_family=data_family,
product=product,
failed_attempt=None,
allowed=False,
legacy=True,
)
@@ -229,6 +276,27 @@ async def build_engine_results_for_task_items(
)
products = result.scalars().all()
attempt_conditions = []
if pair_keys:
attempt_conditions.append(DinsarProductionRunItemORM.pair_key.in_(pair_keys))
if aliases:
attempt_conditions.append(DinsarProductionRunItemORM.task_alias.in_(aliases))
attempt_conditions.append(DinsarProductionRunItemORM.task_name.in_(aliases))
failed_attempts: List[DinsarProductionRunItemORM] = []
if attempt_conditions:
attempt_result = await db.execute(
select(DinsarProductionRunItemORM)
.options(selectinload(DinsarProductionRunItemORM.run))
.where(DinsarProductionRunItemORM.status == "FAILED")
.where(or_(*attempt_conditions))
.order_by(
DinsarProductionRunItemORM.ended_at.desc().nullslast(),
DinsarProductionRunItemORM.updated_at.desc().nullslast(),
DinsarProductionRunItemORM.id.desc(),
)
)
failed_attempts = attempt_result.scalars().all()
by_pair_key: Dict[str, List[ResultProductORM]] = {}
by_alias: Dict[str, List[ResultProductORM]] = {}
for product in products:
@@ -239,15 +307,33 @@ async def build_engine_results_for_task_items(
if alias:
by_alias.setdefault(alias, []).append(product)
failed_by_pair_key: Dict[str, List[DinsarProductionRunItemORM]] = {}
failed_by_alias: Dict[str, List[DinsarProductionRunItemORM]] = {}
for attempt in failed_attempts:
pair_key = str(attempt.pair_key or "").strip()
if pair_key:
failed_by_pair_key.setdefault(pair_key, []).append(attempt)
for alias in {str(attempt.task_alias or "").strip(), str(attempt.task_name or "").strip()}:
if alias:
failed_by_alias.setdefault(alias, []).append(attempt)
output: Dict[int, Dict[str, Dict[str, Any]]] = {}
for item in items:
item_products: List[ResultProductORM] = []
item_failed_attempts: List[DinsarProductionRunItemORM] = []
pair_key = str(item.pair_key or "").strip()
alias = str(item.task_alias or item.task_name or "").strip()
if pair_key:
item_products.extend(by_pair_key.get(pair_key, []))
item_failed_attempts.extend(failed_by_pair_key.get(pair_key, []))
if not item_products and alias:
item_products.extend(by_alias.get(alias, []))
if not item_failed_attempts and alias:
item_failed_attempts.extend(failed_by_alias.get(alias, []))
data_family = infer_dinsar_data_family(item.master_satellite, item.slave_satellite)
output[int(item.id)] = build_engine_results(products=item_products, data_family=data_family)
output[int(item.id)] = build_engine_results(
products=item_products,
failed_attempts=item_failed_attempts,
data_family=data_family,
)
return output
@@ -39,6 +39,7 @@ TASK_TYPE_LANDSAR_CLUSTER_PRODUCTION = "LANDSAR_CLUSTER_RUN"
RUN_STATUS_PENDING = "PENDING"
RUN_STATUS_RUNNING = "RUNNING"
RUN_STATUS_COMPLETED = "COMPLETED"
RUN_STATUS_PARTIAL_SUCCESS = "PARTIAL_SUCCESS"
RUN_STATUS_FAILED = "FAILED"
RUN_STATUS_CANCELLED = "CANCELLED"
RUN_ITEM_STATUS_PENDING = "PENDING"
@@ -66,6 +67,7 @@ RUNS_STEP_ID = "execute_items"
RUNS_STEP_NAME = "Execute ENVI D-InSAR items"
TERMINAL_RUN_STATUSES = {
RUN_STATUS_COMPLETED,
RUN_STATUS_PARTIAL_SUCCESS,
RUN_STATUS_FAILED,
RUN_STATUS_CANCELLED,
}
@@ -549,6 +551,8 @@ def _public_run_status(value: str) -> str:
normalized = str(value or "").strip().upper()
if normalized == RUN_STATUS_COMPLETED:
return "success"
if normalized == RUN_STATUS_PARTIAL_SUCCESS:
return "partial_success"
if normalized == RUN_STATUS_FAILED:
return "failed"
if normalized == RUN_STATUS_CANCELLED:
@@ -573,12 +577,14 @@ class DinsarProductionService:
task_status = str(task.status or "").strip().upper()
if run_status in TERMINAL_RUN_STATUSES:
return False
if task_status not in {"COMPLETED", "FAILED", "CANCELLED"}:
if task_status not in {"COMPLETED", "PARTIAL_SUCCESS", "FAILED", "CANCELLED", "CANCELED"}:
return False
if task_status == "COMPLETED":
next_status = RUN_STATUS_COMPLETED
elif task_status == "CANCELLED":
elif task_status == "PARTIAL_SUCCESS":
next_status = RUN_STATUS_PARTIAL_SUCCESS
elif task_status in {"CANCELLED", "CANCELED"}:
next_status = RUN_STATUS_CANCELLED
run.cancel_requested = True
else:
@@ -1132,6 +1138,12 @@ class DinsarProductionService:
f"LandSAR cluster cancelled. completed={completed} failed={failed} "
f"cancelled={cancelled} total={total_items}"
)
elif failed > 0 and (completed > 0 or skipped > 0):
final_status = RUN_STATUS_PARTIAL_SUCCESS
latest_message = (
f"LandSAR cluster partially completed. completed={completed} "
f"failed={failed} skipped={skipped} total={total_items}"
)
elif failed > 0:
final_status = RUN_STATUS_FAILED
latest_message = (
+87 -37
View File
@@ -32,7 +32,14 @@ from .cluster_transport import (
)
from .dinsar_compat_service import dinsar_compat_service
from .dinsar_naming import build_run_key
from .dinsar_production_service import dinsar_production_service
from .dinsar_production_service import (
RUN_STATUS_CANCELLED,
RUN_STATUS_COMPLETED,
RUN_STATUS_FAILED,
RUN_STATUS_PARTIAL_SUCCESS,
TERMINAL_RUN_STATUSES,
dinsar_production_service,
)
from .dinsar_read_service import dinsar_read_service
from .dinsar_result_layout_service import (
get_run_disp_asset_paths,
@@ -73,6 +80,16 @@ from ..ai_service import (
JobHandler = Callable[[SystemJobORM], Awaitable[None]]
FINAL_TASK_STATUSES = {
RUN_STATUS_COMPLETED,
RUN_STATUS_PARTIAL_SUCCESS,
RUN_STATUS_FAILED,
RUN_STATUS_CANCELLED,
"CANCELED",
"CANCELLED",
}
JOB_TYPE_SCAN_DATA = "SCAN_DATA"
JOB_TYPE_SCAN_DINSAR = "SCAN_DINSAR"
JOB_TYPE_COPY_DATA = "COPY_DATA"
@@ -246,6 +263,28 @@ def _classify_dinsar_failure(error_message: Any) -> str:
return compact if compact != "Unknown error" else "Unclassified D-InSAR failure"
def _dinsar_final_status(completed: Any, failed: Any, skipped: Any = 0) -> str:
completed_count = int(completed or 0)
failed_count = int(failed or 0)
skipped_count = int(skipped or 0)
if failed_count > 0 and (completed_count > 0 or skipped_count > 0):
return RUN_STATUS_PARTIAL_SUCCESS
if failed_count > 0:
return RUN_STATUS_FAILED
return RUN_STATUS_COMPLETED
def _task_status_for_dinsar_run(final_status: str) -> str:
normalized = str(final_status or "").strip().upper()
if normalized == RUN_STATUS_COMPLETED:
return "COMPLETED"
if normalized == RUN_STATUS_PARTIAL_SUCCESS:
return "PARTIAL_SUCCESS"
if normalized in {RUN_STATUS_CANCELLED, "CANCELLED"}:
return "CANCELLED"
return "FAILED"
async def _build_dinsar_failure_summary(db, run) -> Dict[str, Any]:
result = await db.execute(
select(DinsarProductionRunItemORM)
@@ -2204,22 +2243,28 @@ async def _run_dinsar_production_controller(job: SystemJobORM) -> None:
cancelled = await _refresh_cancel_state()
await db.refresh(run)
latest_message = ""
final_status = "COMPLETED"
final_status = RUN_STATUS_COMPLETED
if publish_error:
final_status = "FAILED"
final_status = RUN_STATUS_FAILED
latest_message = f"Result catalog publish failed: {publish_error}"
elif cancelled:
final_status = "CANCELLED"
final_status = RUN_STATUS_CANCELLED
latest_message = (
f"D-InSAR production cancelled. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
elif int(run.failed_items or 0) > 0:
final_status = "FAILED"
latest_message = (
f"D-InSAR production finished with failures. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
final_status = _dinsar_final_status(run.completed_items, run.failed_items, run.skipped_items)
if final_status == RUN_STATUS_PARTIAL_SUCCESS:
latest_message = (
f"D-InSAR production partially completed. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
else:
latest_message = (
f"D-InSAR production failed. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
else:
latest_message = (
f"D-InSAR production completed. completed={run.completed_items} "
@@ -2259,23 +2304,23 @@ async def _run_dinsar_production_controller(job: SystemJobORM) -> None:
)
run_log(run.run_id, f"[finish] status={final_status} message={latest_message}")
if final_status == "COMPLETED":
if final_status in {RUN_STATUS_COMPLETED, RUN_STATUS_PARTIAL_SUCCESS}:
await task_service.update_task(
job.task_id,
status="COMPLETED",
status=_task_status_for_dinsar_run(final_status),
progress=100,
message=latest_message,
)
return
task_status = "CANCELLED" if final_status == "CANCELLED" else "FAILED"
await task_service.update_task(
job.task_id,
status=task_status,
status=_task_status_for_dinsar_run(final_status),
progress=100,
message=latest_message,
)
raise RuntimeError(latest_message)
if final_status == RUN_STATUS_FAILED:
raise RuntimeError(latest_message)
async def _handle_idl_run_import(job: SystemJobORM) -> None:
@@ -2298,7 +2343,7 @@ async def _handle_idl_run_dinsar(job: SystemJobORM) -> None:
try:
async with AsyncSessionLocal() as db:
run = await dinsar_production_service.get_run(production_run_id, db)
if run is not None and str(run.status or "").strip().upper() not in {"COMPLETED", "FAILED", "CANCELLED"}:
if run is not None and str(run.status or "").strip().upper() not in TERMINAL_RUN_STATUSES:
summary_payload = dict(run.summary_json or {})
summary_payload["controller_error"] = str(exc)
await dinsar_production_service.finalize_run(
@@ -2317,7 +2362,7 @@ async def _handle_idl_run_dinsar(job: SystemJobORM) -> None:
try:
current_task = await task_service.get_task(job.task_id)
if current_task and current_task.status not in {"COMPLETED", "FAILED", "CANCELLED"}:
if current_task and str(current_task.status or "").strip().upper() not in FINAL_TASK_STATUSES:
await task_service.add_log(job.task_id, "ERROR", latest_message)
await task_service.update_task(
job.task_id,
@@ -3191,22 +3236,28 @@ async def _run_wsl_dinsar_production_controller(
cancelled = await _refresh_cancel_state()
await dinsar_production_service.refresh_run_counters(run, db=db)
if publish_error:
final_status = "FAILED"
final_status = RUN_STATUS_FAILED
latest_message = f"Result catalog publish failed: {publish_error}"
elif cancelled:
final_status = "CANCELLED"
final_status = RUN_STATUS_CANCELLED
latest_message = (
f"{engine_title} D-InSAR production cancelled. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
elif int(run.failed_items or 0) > 0:
final_status = "FAILED"
latest_message = (
f"{engine_title} D-InSAR production finished with failures. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
final_status = _dinsar_final_status(run.completed_items, run.failed_items, run.skipped_items)
if final_status == RUN_STATUS_PARTIAL_SUCCESS:
latest_message = (
f"{engine_title} D-InSAR production partially completed. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
else:
latest_message = (
f"{engine_title} D-InSAR production failed. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
else:
final_status = "COMPLETED"
final_status = RUN_STATUS_COMPLETED
latest_message = (
f"{engine_title} D-InSAR production completed. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
@@ -3246,23 +3297,23 @@ async def _run_wsl_dinsar_production_controller(
)
run_log(run.run_id, f"[finish] status={final_status} message={latest_message}")
if final_status == "COMPLETED":
if final_status in {RUN_STATUS_COMPLETED, RUN_STATUS_PARTIAL_SUCCESS}:
await task_service.update_task(
job.task_id,
status="COMPLETED",
status=_task_status_for_dinsar_run(final_status),
progress=100,
message=latest_message,
)
return
task_status = "CANCELLED" if final_status == "CANCELLED" else "FAILED"
await task_service.update_task(
job.task_id,
status=task_status,
status=_task_status_for_dinsar_run(final_status),
progress=100,
message=latest_message,
)
raise RuntimeError(latest_message)
if final_status == RUN_STATUS_FAILED:
raise RuntimeError(latest_message)
async def _handle_isce2_run(job: SystemJobORM) -> None:
@@ -3281,7 +3332,7 @@ async def _handle_isce2_run(job: SystemJobORM) -> None:
try:
async with AsyncSessionLocal() as db:
run = await dinsar_production_service.get_run(production_run_id, db)
if run is not None and str(run.status or "").strip().upper() not in {"COMPLETED", "FAILED", "CANCELLED"}:
if run is not None and str(run.status or "").strip().upper() not in TERMINAL_RUN_STATUSES:
summary_payload = dict(run.summary_json or {})
summary_payload["controller_error"] = str(exc)
await dinsar_production_service.finalize_run(
@@ -3300,7 +3351,7 @@ async def _handle_isce2_run(job: SystemJobORM) -> None:
try:
current_task = await task_service.get_task(job.task_id)
if current_task and current_task.status not in {"COMPLETED", "FAILED", "CANCELLED"}:
if current_task and str(current_task.status or "").strip().upper() not in FINAL_TASK_STATUSES:
await task_service.add_log(job.task_id, "ERROR", latest_message)
await task_service.update_task(
job.task_id,
@@ -3335,7 +3386,7 @@ async def _handle_pyint_run(job: SystemJobORM) -> None:
try:
async with AsyncSessionLocal() as db:
run = await dinsar_production_service.get_run(production_run_id, db)
if run is not None and str(run.status or "").strip().upper() not in {"COMPLETED", "FAILED", "CANCELLED"}:
if run is not None and str(run.status or "").strip().upper() not in TERMINAL_RUN_STATUSES:
summary_payload = dict(run.summary_json or {})
summary_payload["controller_error"] = str(exc)
await dinsar_production_service.finalize_run(
@@ -3354,7 +3405,7 @@ async def _handle_pyint_run(job: SystemJobORM) -> None:
try:
current_task = await task_service.get_task(job.task_id)
if current_task and current_task.status not in {"COMPLETED", "FAILED", "CANCELLED"}:
if current_task and str(current_task.status or "").strip().upper() not in FINAL_TASK_STATUSES:
await task_service.add_log(job.task_id, "ERROR", latest_message)
await task_service.update_task(
job.task_id,
@@ -3389,7 +3440,7 @@ async def _handle_landsar_run(job: SystemJobORM) -> None:
try:
async with AsyncSessionLocal() as db:
run = await dinsar_production_service.get_run(production_run_id, db)
if run is not None and str(run.status or "").strip().upper() not in {"COMPLETED", "FAILED", "CANCELLED"}:
if run is not None and str(run.status or "").strip().upper() not in TERMINAL_RUN_STATUSES:
summary_payload = dict(run.summary_json or {})
summary_payload["controller_error"] = str(exc)
await dinsar_production_service.finalize_run(
@@ -3408,7 +3459,7 @@ async def _handle_landsar_run(job: SystemJobORM) -> None:
try:
current_task = await task_service.get_task(job.task_id)
if current_task and current_task.status not in {"COMPLETED", "FAILED", "CANCELLED"}:
if current_task and str(current_task.status or "").strip().upper() not in FINAL_TASK_STATUSES:
await task_service.add_log(job.task_id, "ERROR", latest_message)
await task_service.update_task(
job.task_id,
@@ -4002,10 +4053,9 @@ async def _handle_landsar_cluster_item(job: SystemJobORM) -> None:
)
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,
status=_task_status_for_dinsar_run(final_status),
progress=100,
message=run.latest_message,
)
@@ -0,0 +1,695 @@
from __future__ import annotations
import os
import shutil
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from sqlalchemy import delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..models import (
DinsarProductionExecutionORM,
DinsarProductionRunItemORM,
DinsarProductionRunORM,
DinsarResultORM,
ResultProductORM,
SystemJobORM,
SystemTaskORM,
TaskLogORM,
WorkflowArtifactORM,
WorkflowRunORM,
WorkflowStepORM,
)
from .dinsar_production_service import dinsar_production_service
TERMINAL_TASK_STATUSES = {"COMPLETED", "FAILED", "PARTIAL_SUCCESS", "CANCELLED"}
MAINTENANCE_LIST_STATUSES = {"FAILED", "PARTIAL_SUCCESS", "CANCELLED", "PENDING", "RUNNING"}
ACTIVE_TASK_STATUSES = {"PENDING", "RUNNING"}
ACTIVE_JOB_STATUSES = {"READY", "RETRY", "RUNNING"}
DINSAR_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN", "PYINT_RUN", "IDL_RUN_DINSAR"}
SUPPORTED_CLEANUP_TASK_TYPES = DINSAR_TASK_TYPES | {"COPY_DATA"}
DEFAULT_TASK_TYPES = SUPPORTED_CLEANUP_TASK_TYPES | {"PAIRING_CACHE_REBUILD"}
def _utcnow_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _dt(value: Any) -> Optional[str]:
if value is None:
return None
if hasattr(value, "isoformat"):
return value.isoformat()
return str(value)
def _norm_status(value: Any) -> str:
return str(value or "").strip().upper()
def _compact(value: Any, limit: int = 220) -> Optional[str]:
if value is None:
return None
text = str(value).replace("\r", " ").replace("\n", " ").strip()
if len(text) <= limit:
return text
return text[:limit] + "..."
def _normalize_path_text(value: Any) -> str:
return os.path.normpath(str(value or "").strip().strip('"').strip("'"))
def _path_exists(path: str) -> bool:
return bool(path) and os.path.exists(path)
def _safe_count(rows: Iterable[Any]) -> int:
return len(list(rows))
class OpsMaintenanceService:
async def list_tasks(
self,
db: AsyncSession,
*,
task_type: Optional[str] = None,
status: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> Dict[str, Any]:
safe_limit = max(1, min(int(limit or 50), 200))
safe_offset = max(0, int(offset or 0))
task_types = [task_type.strip().upper()] if task_type else sorted(DEFAULT_TASK_TYPES)
status_filter = status.strip().upper() if status else ""
conditions = [SystemTaskORM.task_type.in_(task_types)]
if status_filter:
conditions.append(SystemTaskORM.status == status_filter)
else:
conditions.append(SystemTaskORM.status.in_(sorted(MAINTENANCE_LIST_STATUSES)))
result = await db.execute(
select(SystemTaskORM)
.where(*conditions)
.order_by(SystemTaskORM.updated_at.desc(), SystemTaskORM.id.desc())
.offset(safe_offset)
.limit(safe_limit)
)
tasks = list(result.scalars().all())
items = [await self._build_task_summary(db, task) for task in tasks]
abnormal_items = [
item
for item in items
if item.get("issue_level") in {"warning", "danger"}
or _norm_status(item.get("status")) in {"FAILED", "PARTIAL_SUCCESS", "CANCELLED"}
]
return {
"items": abnormal_items,
"limit": safe_limit,
"offset": safe_offset,
"returned": len(abnormal_items),
}
async def diagnose_task(self, db: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]:
task = await self._get_task(db, task_id)
if task is None:
return None
jobs = await self._get_jobs(db, task.task_id)
run = await self._get_production_run_for_task(db, task, jobs)
item_counts: Dict[str, int] = {}
execution_counts: Dict[str, int] = {}
disk_paths: List[Dict[str, Any]] = []
products: List[Dict[str, Any]] = []
if run is not None:
item_counts = await self._status_counts(db, DinsarProductionRunItemORM, run.run_id)
execution_counts = await self._status_counts(db, DinsarProductionExecutionORM, run.run_id)
disk_paths.extend(await self._collect_run_disk_paths(db, run))
products = await self._collect_result_products(db, run)
related_tasks = await self._related_copy_tasks_for_run(db, run) if run is not None else []
copy_dest = self._copy_task_dest_dir(task)
if copy_dest:
disk_paths.append(self._path_payload(copy_dest, "task_pool"))
recent_logs = await self._recent_logs(db, task.task_id)
findings, cleanup_supported, cleanup_blockers = self._diagnose_findings(
task=task,
jobs=jobs,
run=run,
item_counts=item_counts,
execution_counts=execution_counts,
disk_paths=disk_paths,
)
return {
"task": self._task_payload(task),
"jobs": [self._job_payload(job) for job in jobs],
"production_run": self._run_payload(run) if run else None,
"production_item_counts": item_counts,
"production_execution_counts": execution_counts,
"result_products": products,
"related_tasks": [self._task_payload(item) for item in related_tasks],
"recent_logs": recent_logs,
"disk_paths": disk_paths,
"diagnosis": {
"summary": findings[0] if findings else "未发现明显异常。",
"findings": findings,
"cleanup_supported": cleanup_supported,
"cleanup_blockers": cleanup_blockers,
},
}
async def cleanup_preview(self, db: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]:
diagnosis = await self.diagnose_task(db, task_id)
if diagnosis is None:
return None
task = diagnosis["task"]
task_type = _norm_status(task.get("task_type"))
task_status = _norm_status(task.get("status"))
cleanup_supported = bool(diagnosis["diagnosis"].get("cleanup_supported"))
blockers = list(diagnosis["diagnosis"].get("cleanup_blockers") or [])
if task_type not in SUPPORTED_CLEANUP_TASK_TYPES:
blockers.append(f"第一版暂不支持清理任务类型 {task_type}。")
if task_status in ACTIVE_TASK_STATUSES:
blockers.append("任务仍处于活动状态,不能清理。")
db_counts = await self._cleanup_db_counts(db, diagnosis)
disk_deletes = self._cleanup_disk_targets(diagnosis)
blocked = bool(blockers) or not cleanup_supported
return {
"task_id": task_id,
"blocked": blocked,
"blockers": blockers,
"cleanup_supported": cleanup_supported and not blocked,
"database_deletes": db_counts,
"disk_deletes": disk_deletes,
}
async def cleanup_task(
self,
db: AsyncSession,
task_id: str,
*,
options: Dict[str, bool],
) -> Optional[Dict[str, Any]]:
preview = await self.cleanup_preview(db, task_id)
if preview is None:
return None
if preview.get("blocked"):
raise ValueError("; ".join(preview.get("blockers") or ["清理被阻止。"]))
deleted_db: Dict[str, int] = {}
task = await self._get_task(db, task_id)
if task is None:
return None
jobs = await self._get_jobs(db, task_id)
run = await self._get_production_run_for_task(db, task, jobs)
delete_logs = bool(options.get("delete_logs", True))
delete_task_records = bool(options.get("delete_task_records", True))
delete_production_records = bool(options.get("delete_production_records", True))
delete_result_products = bool(options.get("delete_result_products", True))
delete_task_pool_dir = bool(options.get("delete_task_pool_dir", True))
related_copy_tasks = await self._related_copy_tasks_for_run(db, run) if run is not None else []
if delete_result_products and run is not None:
products = await self._result_product_orms_for_run(db, run)
product_ids = [item.product_id for item in products]
compat_ids = await self._compat_ids_for_products(db, product_ids)
if product_ids:
result = await db.execute(delete(ResultProductORM).where(ResultProductORM.product_id.in_(product_ids)))
deleted_db["result_products"] = int(result.rowcount or 0)
if compat_ids:
result = await db.execute(delete(DinsarResultORM).where(DinsarResultORM.id.in_(compat_ids)))
deleted_db["dinsar_results"] = int(result.rowcount or 0)
if delete_production_records and run is not None:
deleted_run = await dinsar_production_service.delete_run_record(run.run_id, db=db)
deleted_db["dinsar_production_runs"] = 1 if deleted_run else 0
deleted_db["dinsar_production_run_items"] = int(preview["database_deletes"].get("dinsar_production_run_items", 0))
deleted_db["dinsar_production_executions"] = int(preview["database_deletes"].get("dinsar_production_executions", 0))
deleted_db["system_jobs"] = int(preview["database_deletes"].get("system_jobs", 0))
deleted_db["system_tasks"] = 1
deleted_db["task_logs"] = int(preview["database_deletes"].get("task_logs", 0))
if delete_task_pool_dir:
related_deleted = await self._delete_related_tasks(db, related_copy_tasks)
for key, value in related_deleted.items():
deleted_db[key] = int(deleted_db.get(key, 0)) + int(value or 0)
else:
if delete_logs:
result = await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id == task_id))
deleted_db["task_logs"] = int(result.rowcount or 0)
if delete_task_records:
result = await db.execute(delete(SystemJobORM).where(SystemJobORM.task_id == task_id))
deleted_db["system_jobs"] = int(result.rowcount or 0)
result = await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id == task_id))
deleted_db["system_tasks"] = int(result.rowcount or 0)
await db.commit()
disk_result = await self._delete_disk_targets(preview, options)
return {
"task_id": task_id,
"deleted_database": deleted_db,
"deleted_disk": disk_result,
}
async def _delete_related_tasks(
self,
db: AsyncSession,
tasks: List[SystemTaskORM],
) -> Dict[str, int]:
task_ids = [task.task_id for task in tasks if task.task_id]
if not task_ids:
return {}
result = await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id.in_(task_ids)))
logs = int(result.rowcount or 0)
result = await db.execute(delete(SystemJobORM).where(SystemJobORM.task_id.in_(task_ids)))
jobs = int(result.rowcount or 0)
result = await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id.in_(task_ids)))
task_count = int(result.rowcount or 0)
await db.commit()
return {
"related_task_logs": logs,
"related_system_jobs": jobs,
"related_system_tasks": task_count,
}
async def _get_task(self, db: AsyncSession, task_id: str) -> Optional[SystemTaskORM]:
result = await db.execute(select(SystemTaskORM).where(SystemTaskORM.task_id == str(task_id or "").strip()))
return result.scalar_one_or_none()
async def _get_jobs(self, db: AsyncSession, task_id: str) -> List[SystemJobORM]:
result = await db.execute(
select(SystemJobORM)
.where(SystemJobORM.task_id == str(task_id or "").strip())
.order_by(SystemJobORM.updated_at.desc(), SystemJobORM.id.desc())
)
return list(result.scalars().all())
async def _get_production_run_for_task(
self,
db: AsyncSession,
task: SystemTaskORM,
jobs: List[SystemJobORM],
) -> Optional[DinsarProductionRunORM]:
candidates: List[str] = []
params = task.params or {}
if isinstance(params, dict):
for key in ("production_run_id", "run_id"):
if params.get(key):
candidates.append(str(params[key]))
for job in jobs:
payload = job.payload or {}
if isinstance(payload, dict):
for key in ("production_run_id", "run_id", "dinsar_run_id"):
if payload.get(key):
candidates.append(str(payload[key]))
if candidates:
result = await db.execute(select(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id.in_(candidates)))
run = result.scalar_one_or_none()
if run is not None:
return run
result = await db.execute(select(DinsarProductionRunORM).where(DinsarProductionRunORM.task_id == task.task_id))
return result.scalar_one_or_none()
async def _build_task_summary(self, db: AsyncSession, task: SystemTaskORM) -> Dict[str, Any]:
jobs = await self._get_jobs(db, task.task_id)
run = await self._get_production_run_for_task(db, task, jobs)
item_counts: Dict[str, int] = {}
execution_counts: Dict[str, int] = {}
if run is not None:
item_counts = await self._status_counts(db, DinsarProductionRunItemORM, run.run_id)
execution_counts = await self._status_counts(db, DinsarProductionExecutionORM, run.run_id)
findings, cleanup_supported, blockers = self._diagnose_findings(
task=task,
jobs=jobs,
run=run,
item_counts=item_counts,
execution_counts=execution_counts,
disk_paths=[],
)
log_count = await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id == task.task_id))
return {
**self._task_payload(task),
"run_id": run.run_id if run else None,
"job_id": jobs[0].job_id if jobs else None,
"job_status": jobs[0].status if jobs else None,
"issue_level": "danger" if _norm_status(task.status) == "FAILED" else ("warning" if findings else "info"),
"issue_summary": findings[0] if findings else _compact(task.message, 160),
"cleanup_supported": cleanup_supported,
"cleanup_blocked_reason": "; ".join(blockers) if blockers else "",
"counts": {
"jobs": len(jobs),
"logs": log_count,
"run_items": sum(item_counts.values()),
"executions": sum(execution_counts.values()),
"completed_items": item_counts.get("COMPLETED", 0),
"failed_items": item_counts.get("FAILED", 0),
"pending_items": item_counts.get("PENDING", 0),
"running_items": item_counts.get("RUNNING", 0),
},
}
async def _status_counts(self, db: AsyncSession, model: Any, run_id: str) -> Dict[str, int]:
result = await db.execute(
select(model.status, func.count())
.where(model.run_id == run_id)
.group_by(model.status)
)
return {str(status or "UNKNOWN").upper(): int(count or 0) for status, count in result.all()}
async def _recent_logs(self, db: AsyncSession, task_id: str) -> List[Dict[str, Any]]:
result = await db.execute(
select(TaskLogORM)
.where(TaskLogORM.task_id == task_id)
.order_by(TaskLogORM.timestamp.desc(), TaskLogORM.id.desc())
.limit(30)
)
return [
{
"id": item.id,
"level": item.log_level,
"message": _compact(item.message, 500),
"timestamp": _dt(item.timestamp),
}
for item in result.scalars().all()
]
async def _collect_run_disk_paths(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]:
paths: Dict[str, Dict[str, Any]] = {}
if run.source_root:
paths[_normalize_path_text(run.source_root)] = self._path_payload(run.source_root, "task_pool")
result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id))
for execution in result.scalars().all():
if execution.output_dir:
publish_dir = self._publish_package_dir(execution.output_dir)
paths[publish_dir] = self._path_payload(publish_dir, "production_result")
log_path = dinsar_production_service.read_run_log(run.run_id, max_bytes=1).get("path")
if log_path:
paths[_normalize_path_text(log_path)] = self._path_payload(log_path, "run_log")
return list(paths.values())
async def _collect_result_products(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]:
products = await self._result_product_orms_for_run(db, run)
return [
{
"product_id": item.product_id,
"display_name": item.display_name,
"status": item.status,
"health_status": item.health_status,
"publish_dir": item.publish_dir,
"manifest_path": item.manifest_path,
}
for item in products
]
async def _result_product_orms_for_run(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[ResultProductORM]:
result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id))
dirs = [self._publish_package_dir(item.output_dir) for item in result.scalars().all() if item.output_dir]
clauses = []
for path in dirs:
clauses.append(ResultProductORM.publish_dir == path)
clauses.append(ResultProductORM.native_output_dir.like(path + "%"))
clauses.append(ResultProductORM.manifest_path.like(path + "%"))
clauses.append(ResultProductORM.primary_asset_path.like(path + "%"))
if not clauses:
return []
products = await db.execute(select(ResultProductORM).where(or_(*clauses)))
by_id: Dict[str, ResultProductORM] = {}
for product in products.scalars().all():
by_id[product.product_id] = product
return list(by_id.values())
async def _compat_ids_for_products(self, db: AsyncSession, product_ids: List[str]) -> List[int]:
if not product_ids:
return []
result = await db.execute(select(DinsarResultORM).where(DinsarResultORM.compat_product_id.in_(product_ids)))
return [int(item.id) for item in result.scalars().all()]
def _publish_package_dir(self, output_dir: str) -> str:
normalized = _normalize_path_text(output_dir)
marker = os.sep + "runs" + os.sep
if marker.lower() in normalized.lower():
lower = normalized.lower()
index = lower.index(marker.lower())
return normalized[:index]
return normalized
def _copy_task_dest_dir(self, task: SystemTaskORM) -> str:
params = task.params if isinstance(task.params, dict) else {}
return _normalize_path_text(params.get("dest_dir")) if params.get("dest_dir") else ""
async def _related_copy_tasks_for_run(
self,
db: AsyncSession,
run: Optional[DinsarProductionRunORM],
) -> List[SystemTaskORM]:
source_root = _normalize_path_text(run.source_root if run is not None else "")
if not source_root:
return []
result = await db.execute(
select(SystemTaskORM)
.where(SystemTaskORM.task_type == "COPY_DATA")
.order_by(SystemTaskORM.updated_at.desc(), SystemTaskORM.id.desc())
.limit(1000)
)
tasks = []
for task in result.scalars().all():
if _normalize_path_text(self._copy_task_dest_dir(task)).lower() == source_root.lower():
tasks.append(task)
return tasks
def _diagnose_findings(
self,
*,
task: SystemTaskORM,
jobs: List[SystemJobORM],
run: Optional[DinsarProductionRunORM],
item_counts: Dict[str, int],
execution_counts: Dict[str, int],
disk_paths: List[Dict[str, Any]],
) -> tuple[List[str], bool, List[str]]:
findings: List[str] = []
blockers: List[str] = []
status = _norm_status(task.status)
if status == "FAILED":
findings.append("任务已失败,需要人工确认后清理。")
elif status == "PARTIAL_SUCCESS":
findings.append("任务部分成功,清理前请确认保留策略。")
elif status == "CANCELLED":
findings.append("任务已取消,可按需清理残留记录和目录。")
elif status in ACTIVE_TASK_STATUSES:
blockers.append("任务仍处于活动状态。")
active_jobs = [job for job in jobs if _norm_status(job.status) in ACTIVE_JOB_STATUSES]
stale_cutoff = _utcnow_naive() - timedelta(seconds=int(getattr(settings, "JOB_WORKER_STALE_RUNNING_SECONDS", 7200)))
stale_jobs = [
job for job in active_jobs
if job.heartbeat_at is not None and job.heartbeat_at < stale_cutoff
]
if stale_jobs:
findings.append(f"发现 {len(stale_jobs)} 个心跳超时 job。")
if active_jobs and not stale_jobs:
blockers.append("仍存在活动 job。")
residual_running = item_counts.get("RUNNING", 0) + execution_counts.get("RUNNING", 0)
if run is not None and residual_running:
findings.append(f"生产 run 中仍有 {residual_running} 个 RUNNING 残留。")
if run is not None and item_counts.get("PENDING", 0):
findings.append(f"生产 run 中仍有 {item_counts.get('PENDING', 0)} 个 PENDING 项未执行。")
missing_paths = [item for item in disk_paths if item.get("path") and not item.get("exists")]
if missing_paths:
findings.append(f"有 {len(missing_paths)} 个登记路径已不存在。")
cleanup_supported = _norm_status(task.task_type) in SUPPORTED_CLEANUP_TASK_TYPES and not blockers
return findings, cleanup_supported, blockers
async def _cleanup_db_counts(self, db: AsyncSession, diagnosis: Dict[str, Any]) -> Dict[str, int]:
task_id = diagnosis["task"]["task_id"]
run = diagnosis.get("production_run") or {}
run_id = run.get("run_id")
workflow_run_id = run.get("workflow_run_id")
counts = {
"system_tasks": await self._count(db, select(func.count()).select_from(SystemTaskORM).where(SystemTaskORM.task_id == task_id)),
"system_jobs": await self._count(db, select(func.count()).select_from(SystemJobORM).where(SystemJobORM.task_id == task_id)),
"task_logs": await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id == task_id)),
"related_system_tasks": 0,
"related_system_jobs": 0,
"related_task_logs": 0,
"dinsar_production_runs": 0,
"dinsar_production_run_items": 0,
"dinsar_production_executions": 0,
"result_products": len(diagnosis.get("result_products") or []),
"dinsar_results": 0,
"workflow_runs": 0,
"workflow_steps": 0,
"workflow_artifacts": 0,
}
if run_id:
counts["dinsar_production_runs"] = await self._count(db, select(func.count()).select_from(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id == run_id))
counts["dinsar_production_run_items"] = await self._count(db, select(func.count()).select_from(DinsarProductionRunItemORM).where(DinsarProductionRunItemORM.run_id == run_id))
counts["dinsar_production_executions"] = await self._count(db, select(func.count()).select_from(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run_id))
run_obj = await self._get_run_by_id(db, run_id)
related_tasks = await self._related_copy_tasks_for_run(db, run_obj)
related_task_ids = [item.task_id for item in related_tasks if item.task_id]
counts["related_system_tasks"] = len(related_task_ids)
if related_task_ids:
counts["related_system_jobs"] = await self._count(db, select(func.count()).select_from(SystemJobORM).where(SystemJobORM.task_id.in_(related_task_ids)))
counts["related_task_logs"] = await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id.in_(related_task_ids)))
if workflow_run_id:
counts["workflow_runs"] = await self._count(db, select(func.count()).select_from(WorkflowRunORM).where(WorkflowRunORM.run_id == workflow_run_id))
counts["workflow_steps"] = await self._count(db, select(func.count()).select_from(WorkflowStepORM).where(WorkflowStepORM.run_id == workflow_run_id))
counts["workflow_artifacts"] = await self._count(db, select(func.count()).select_from(WorkflowArtifactORM).where(WorkflowArtifactORM.run_id == workflow_run_id))
return counts
async def _get_run_by_id(self, db: AsyncSession, run_id: str) -> Optional[DinsarProductionRunORM]:
result = await db.execute(select(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id == run_id))
return result.scalar_one_or_none()
async def _count(self, db: AsyncSession, stmt: Any) -> int:
return int((await db.execute(stmt)).scalar_one() or 0)
def _cleanup_disk_targets(self, diagnosis: Dict[str, Any]) -> List[Dict[str, Any]]:
targets: Dict[str, Dict[str, Any]] = {}
for item in diagnosis.get("disk_paths") or []:
path = _normalize_path_text(item.get("path"))
if not path:
continue
payload = self._path_payload(path, item.get("kind") or "unknown")
payload["allowed"] = self._is_allowed_delete_path(path)
targets[path.lower()] = payload
return list(targets.values())
def _path_payload(self, path: str, kind: str) -> Dict[str, Any]:
normalized = _normalize_path_text(path)
exists = _path_exists(normalized)
return {
"path": normalized,
"kind": kind,
"exists": exists,
"allowed": self._is_allowed_delete_path(normalized),
}
def _is_allowed_delete_path(self, path: str) -> bool:
normalized = _normalize_path_text(path)
if not normalized:
return False
roots = [
settings.DINSAR_TASK_POOL_ROOT,
settings.DINSAR_PRODUCT_DIR,
os.path.join(settings.PROJECT_ROOT, "backend", "runtime", "dinsar_production"),
]
full = os.path.abspath(normalized)
for root in roots:
root_text = _normalize_path_text(root)
if not root_text:
continue
root_full = os.path.abspath(root_text)
if full == root_full:
return False
try:
if os.path.commonpath([full, root_full]) == root_full:
return True
except ValueError:
continue
return False
async def _delete_disk_targets(self, preview: Dict[str, Any], options: Dict[str, bool]) -> Dict[str, Any]:
delete_production_dirs = bool(options.get("delete_production_dirs", True))
delete_task_pool_dir = bool(options.get("delete_task_pool_dir", True))
deleted: List[str] = []
missing: List[str] = []
skipped: List[str] = []
failed: List[Dict[str, str]] = []
for item in preview.get("disk_deletes") or []:
kind = item.get("kind")
path = _normalize_path_text(item.get("path"))
if not item.get("allowed"):
skipped.append(path)
continue
if kind == "task_pool" and not delete_task_pool_dir:
skipped.append(path)
continue
if kind == "production_result" and not delete_production_dirs:
skipped.append(path)
continue
if not _path_exists(path):
missing.append(path)
continue
try:
if os.path.isdir(path):
shutil.rmtree(path)
else:
Path(path).unlink()
deleted.append(path)
except OSError as exc:
failed.append({"path": path, "error": str(exc)})
return {
"deleted": deleted,
"missing": missing,
"skipped": skipped,
"failed": failed,
}
def _task_payload(self, task: SystemTaskORM) -> Dict[str, Any]:
return {
"id": task.id,
"task_id": task.task_id,
"task_type": task.task_type,
"task_name": task.task_name,
"status": task.status,
"progress": task.progress,
"message": _compact(task.message),
"params": task.params,
"created_at": _dt(task.created_at),
"updated_at": _dt(task.updated_at),
"started_at": _dt(task.started_at),
"ended_at": _dt(task.ended_at),
}
def _job_payload(self, job: SystemJobORM) -> Dict[str, Any]:
return {
"job_id": job.job_id,
"job_type": job.job_type,
"status": job.status,
"attempts": job.attempts,
"max_attempts": job.max_attempts,
"locked_by": job.locked_by,
"locked_at": _dt(job.locked_at),
"heartbeat_at": _dt(job.heartbeat_at),
"started_at": _dt(job.started_at),
"finished_at": _dt(job.finished_at),
"last_error": _compact(job.last_error),
}
def _run_payload(self, run: DinsarProductionRunORM) -> Dict[str, Any]:
return {
"run_id": run.run_id,
"task_id": run.task_id,
"workflow_run_id": run.workflow_run_id,
"engine_code": run.engine_code,
"profile_code": run.profile_code,
"mode": run.mode,
"source_root": run.source_root,
"publish_root_dir": run.publish_root_dir,
"status": run.status,
"total_items": run.total_items,
"completed_items": run.completed_items,
"failed_items": run.failed_items,
"skipped_items": run.skipped_items,
"latest_message": _compact(run.latest_message),
"created_at": _dt(run.created_at),
"updated_at": _dt(run.updated_at),
"started_at": _dt(run.started_at),
"ended_at": _dt(run.ended_at),
}
ops_maintenance_service = OpsMaintenanceService()
+58 -5
View File
@@ -81,6 +81,41 @@ class PairingStateService:
)
return result.scalar_one_or_none()
def _cache_requires_rebuild(
self,
*,
scene_count: int,
dirty_scene_count: int,
metric_cache_count: int,
) -> bool:
return bool(
dirty_scene_count > 0
or (scene_count > 1 and metric_cache_count == 0)
)
def _effective_status(
self,
*,
state: Optional[PairingCacheStateORM],
scene_count: int,
dirty_scene_count: int,
metric_cache_count: int,
) -> str:
raw_status = str(getattr(state, "status", None) or "").strip().upper()
if not raw_status:
return "DIRTY" if self._cache_requires_rebuild(
scene_count=scene_count,
dirty_scene_count=dirty_scene_count,
metric_cache_count=metric_cache_count,
) else "READY"
if raw_status == "DIRTY" and not self._cache_requires_rebuild(
scene_count=scene_count,
dirty_scene_count=dirty_scene_count,
metric_cache_count=metric_cache_count,
):
return "READY"
return raw_status
def _build_state_payload(
self,
*,
@@ -89,7 +124,13 @@ class PairingStateService:
dirty_scene_count: int,
metric_cache_count: int,
) -> Dict[str, Any]:
status = state.status if state is not None else "UNINITIALIZED"
raw_status = state.status if state is not None else "UNINITIALIZED"
status = self._effective_status(
state=state,
scene_count=scene_count,
dirty_scene_count=dirty_scene_count,
metric_cache_count=metric_cache_count,
)
last_error = state.last_error if state is not None else None
needs_rebuild = bool(
dirty_scene_count > 0
@@ -103,11 +144,13 @@ class PairingStateService:
"cache_scope": state.cache_scope if state is not None else PAIRING_CACHE_SCOPE_GLOBAL,
"metric_version": (state.metric_version if state is not None else None) or self.metric_version,
"status": status,
"raw_status": raw_status,
"scene_count": int(scene_count),
"pair_count": int(metric_cache_count),
"dirty_scene_count": int(dirty_scene_count),
"needs_rebuild": needs_rebuild,
"cache_ready": cache_ready,
"stale_dirty_normalized": raw_status == "DIRTY" and status == "READY",
"last_full_rebuild_at": state_dict.get("last_full_rebuild_at"),
"last_incremental_reconcile_at": state_dict.get("last_incremental_reconcile_at"),
"last_error": last_error,
@@ -130,7 +173,11 @@ class PairingStateService:
state = PairingCacheStateORM(
cache_scope=PAIRING_CACHE_SCOPE_GLOBAL,
metric_version=self.metric_version,
status="DIRTY" if scene_count > 1 else "READY",
status="DIRTY" if self._cache_requires_rebuild(
scene_count=scene_count,
dirty_scene_count=dirty_scene_count,
metric_cache_count=metric_cache_count,
) else "READY",
scene_count=scene_count,
pair_count=metric_cache_count,
dirty_scene_count=dirty_scene_count,
@@ -149,10 +196,16 @@ class PairingStateService:
state.pair_count = metric_cache_count
state.dirty_scene_count = dirty_scene_count
if state.status == "READY" and (
dirty_scene_count > 0 or (scene_count > 1 and metric_cache_count == 0)
):
requires_rebuild = self._cache_requires_rebuild(
scene_count=scene_count,
dirty_scene_count=dirty_scene_count,
metric_cache_count=metric_cache_count,
)
if requires_rebuild and state.status == "READY":
state.status = "DIRTY"
elif not requires_rebuild and state.status == "DIRTY":
state.status = "READY"
state.last_error = None
elif state.status in {None, ""}:
state.status = "DIRTY" if scene_count > 1 else "READY"
@@ -12,11 +12,13 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
from geoalchemy2.shape import from_shape
from sqlalchemy import delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from shapely.geometry import Polygon, shape
from ..config import settings
from ..models import (
DinsarProductProfileORM,
DinsarProductionRunItemORM,
DinsarTaskItemORM,
PairingMetricCacheORM,
PairingNetworkEdgeORM,
@@ -1519,8 +1521,16 @@ class ResultCatalogService:
products = result.scalars().all()
groups: Dict[str, Dict[str, Any]] = {}
pair_keys_for_trace = set()
aliases_for_trace = set()
for product in products:
pair_key = str(product.pair_key or "").strip() or f"product:{product.id}"
if str(product.pair_key or "").strip():
pair_keys_for_trace.add(str(product.pair_key or "").strip())
for alias_value in (product.task_alias, product.task_name):
alias_text = str(alias_value or "").strip()
if alias_text:
aliases_for_trace.add(alias_text)
group = groups.setdefault(
pair_key,
{
@@ -1552,11 +1562,49 @@ class ResultCatalogService:
if group.get(key) in (None, "") and getattr(product, key) not in (None, ""):
group[key] = getattr(product, key)
failed_trace_by_pair_key: Dict[str, List[DinsarProductionRunItemORM]] = {}
failed_trace_by_alias: Dict[str, List[DinsarProductionRunItemORM]] = {}
trace_conditions = []
if pair_keys_for_trace:
trace_conditions.append(DinsarProductionRunItemORM.pair_key.in_(sorted(pair_keys_for_trace)))
if aliases_for_trace:
trace_conditions.append(DinsarProductionRunItemORM.task_alias.in_(sorted(aliases_for_trace)))
trace_conditions.append(DinsarProductionRunItemORM.task_name.in_(sorted(aliases_for_trace)))
if trace_conditions:
trace_result = await db.execute(
select(DinsarProductionRunItemORM)
.options(selectinload(DinsarProductionRunItemORM.run))
.where(DinsarProductionRunItemORM.status == "FAILED")
.where(or_(*trace_conditions))
.order_by(
DinsarProductionRunItemORM.ended_at.desc().nullslast(),
DinsarProductionRunItemORM.updated_at.desc().nullslast(),
DinsarProductionRunItemORM.id.desc(),
)
)
for attempt in trace_result.scalars().all():
pair_key = str(attempt.pair_key or "").strip()
if pair_key:
failed_trace_by_pair_key.setdefault(pair_key, []).append(attempt)
for alias_value in (attempt.task_alias, attempt.task_name):
alias_text = str(alias_value or "").strip()
if alias_text:
failed_trace_by_alias.setdefault(alias_text, []).append(attempt)
items: List[Dict[str, Any]] = []
for group in groups.values():
products_for_group: List[ResultProductORM] = group.pop("_products")
failed_attempts_for_group: List[DinsarProductionRunItemORM] = []
pair_key = str(group.get("pair_key") or "").strip()
if pair_key:
failed_attempts_for_group.extend(failed_trace_by_pair_key.get(pair_key, []))
for alias_value in (group.get("task_alias"), group.get("task_name")):
alias_text = str(alias_value or "").strip()
if alias_text and not failed_attempts_for_group:
failed_attempts_for_group.extend(failed_trace_by_alias.get(alias_text, []))
engine_results = build_engine_results(
products=products_for_group,
failed_attempts=failed_attempts_for_group,
data_family=group.get("data_family") or "unknown",
include_legacy=include_legacy,
)
+453 -42
View File
@@ -20,10 +20,12 @@ from ..config import settings
from ..models import (
AuthUserORM,
DinsarResultORM,
RadarDataORM,
ResultAssetORM,
ResultDeliveryItemORM,
ResultDeliveryRequestORM,
ResultProductORM,
SARSceneGeoORM,
)
from .dinsar_read_service import dinsar_read_service
from .job_queue_service import job_queue_service
@@ -46,7 +48,17 @@ ITEM_STATUS_FAILED = "FAILED"
ITEM_STATUS_SKIPPED = "SKIPPED"
CHANNEL_DINSAR = "dinsar"
SUPPORTED_READY_CHANNELS = {CHANNEL_DINSAR}
CHANNEL_LT1_ORTHO = "lt1_ortho"
CHANNEL_GF3_ORTHO = "gf3_ortho"
CHANNEL_SBAS = "sbas"
CHANNEL_S1_ORTHO = "s1_ortho"
SUPPORTED_READY_CHANNELS = {CHANNEL_DINSAR, CHANNEL_LT1_ORTHO, CHANNEL_GF3_ORTHO}
LT1_ANALYSIS_ENGINE = "lt_gamma"
LT1_ANALYSIS_PROFILE = "lt1_gamma_geocoded_mli"
GF3_STANDARD_SOURCE_FORMAT = "GF3_SARSCAPE_L2"
GF3_NATIVE_PREVIEW_SOURCE_FORMAT = "GF3_SARSCAPE_NATIVE_PREVIEW"
GF3_DELIVERABLE_SOURCE_FORMATS = {GF3_STANDARD_SOURCE_FORMAT, GF3_NATIVE_PREVIEW_SOURCE_FORMAT}
PACKAGE_MODE_DIRECTORY = "directory"
PACKAGE_MODE_ZIP = "zip"
@@ -58,11 +70,16 @@ _SAFE_ID_RE = re.compile(r"[^0-9A-Za-z_-]+")
@dataclass(frozen=True)
class DeliverySource:
product: ResultProductORM
compat_row: Optional[DinsarResultORM]
display_name: str
source_path: str
product: Optional[ResultProductORM] = None
compat_row: Optional[DinsarResultORM] = None
source_asset_id: Optional[int] = None
source_radar_data_id: Optional[int] = None
source_scene_geo_id: Optional[int] = None
product_id: Optional[str] = None
task_name: Optional[str] = None
source_kind: str = "result_product"
def _new_session() -> AsyncSession:
@@ -208,6 +225,59 @@ def _resolve_source_path(product: ResultProductORM, assets: List[ResultAssetORM]
return "", None
def _scene_product_id(scene: SARSceneGeoORM) -> str:
return f"sar_scene_geo:{scene.id}"
def _scene_display_name(scene: SARSceneGeoORM, radar: RadarDataORM) -> str:
for value in (
radar.product_unique_id,
radar.unique_id,
radar.source_product_token,
os.path.basename(str(radar.file_path or "").rstrip("/\\")),
_scene_product_id(scene),
):
text = str(value or "").strip()
if text:
return text
return f"scene_{scene.id}"
def _radar_display_name(radar: RadarDataORM) -> str:
for value in (
radar.product_unique_id,
radar.unique_id,
radar.source_product_token,
os.path.basename(str(radar.file_path or "").rstrip("/\\")),
f"radar_{radar.id}",
):
text = str(value or "").strip()
if text:
return text
return f"radar_{radar.id}"
def _manifest_path_for_scene(scene: SARSceneGeoORM) -> Optional[str]:
metadata = scene.analysis_metadata_json if isinstance(scene.analysis_metadata_json, dict) else {}
text = str(metadata.get("manifest_path") or "").strip()
if text:
return text
if scene.analysis_dir:
return os.path.join(scene.analysis_dir, "manifest.json")
return None
def _quality_path_for_scene(scene: SARSceneGeoORM) -> Optional[str]:
if scene.analysis_dir:
return os.path.join(scene.analysis_dir, "quality.json")
return None
def _catalog_file_size(path: Any) -> int:
text = str(path or "").strip()
return _file_size(text) if text else 0
class ResultDeliveryService:
def channels(self) -> List[Dict[str, Any]]:
return [
@@ -220,7 +290,7 @@ class ResultDeliveryService:
"description": "已登记 D-InSAR catalog,可创建后台交付包并下载到本地。",
},
{
"key": "sbas",
"key": CHANNEL_SBAS,
"group": "InSAR 成果",
"label": "SBAS-InSAR 结果",
"state": "planned",
@@ -228,15 +298,15 @@ class ResultDeliveryService:
"description": "SBAS 结果 catalog 已有基础能力,本阶段暂不开放交付打包。",
},
{
"key": "lt1_ortho",
"key": CHANNEL_LT1_ORTHO,
"group": "正射成果",
"label": "LT-1 正射结果",
"state": "placeholder",
"state_text": "待接入",
"description": "陆探一正射生产将由 LandSAR 生产链注册后接入统一交付。",
"state": "ready",
"state_text": "可交付",
"description": "服务器生产的 LT-1 分析就绪正射 GeoTIFF 已接入交付,可打包下载到本地。",
},
{
"key": "s1_ortho",
"key": CHANNEL_S1_ORTHO,
"group": "正射成果",
"label": "Sentinel-1 正射结果",
"state": "placeholder",
@@ -244,12 +314,12 @@ class ResultDeliveryService:
"description": "Sentinel-1 正射生产尚未接入,当前只保留交付通道占位。",
},
{
"key": "gf3_ortho",
"key": CHANNEL_GF3_ORTHO,
"group": "正射成果",
"label": "GF3 SARscape _geo",
"state": "placeholder",
"state_text": "待接入",
"description": "GF3 外部生产成果登记后再接入统一交付。",
"state": "ready",
"state_text": "可交付",
"description": "已登记的 GF3 SARscape 标准化正射成品可直接创建交付包。",
},
]
@@ -282,11 +352,14 @@ class ResultDeliveryService:
seen_products.add(int(product.id))
sources.append(
DeliverySource(
product=product,
compat_row=None,
display_name=dinsar_read_service.get_display_name(product),
source_path=source_path,
product=product,
compat_row=None,
source_asset_id=source_asset_id,
product_id=product.product_id,
task_name=product.task_alias or product.task_name,
source_kind="dinsar_product",
)
)
@@ -319,16 +392,344 @@ class ResultDeliveryService:
seen_products.add(int(product.id))
sources.append(
DeliverySource(
product=product,
compat_row=record.compat_row,
display_name=record.display_name,
source_path=source_path,
product=product,
compat_row=record.compat_row,
source_asset_id=source_asset_id,
product_id=product.product_id,
task_name=product.task_alias or product.task_name,
source_kind="dinsar_product",
)
)
return sources
async def _resolve_lt1_sources(
self,
db: AsyncSession,
*,
item_ids: List[int],
product_ids: List[int],
) -> List[DeliverySource]:
selected_ids = item_ids or product_ids
if not selected_ids:
return []
result = await db.execute(
select(SARSceneGeoORM, RadarDataORM)
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(
SARSceneGeoORM.id.in_(selected_ids),
SARSceneGeoORM.status == "DONE",
SARSceneGeoORM.analysis_tif_path.isnot(None),
SARSceneGeoORM.analysis_engine == LT1_ANALYSIS_ENGINE,
SARSceneGeoORM.analysis_profile == LT1_ANALYSIS_PROFILE,
)
.order_by(SARSceneGeoORM.id.asc())
)
sources: List[DeliverySource] = []
for scene, radar in result.all():
analysis_dir = str(scene.analysis_dir or "").strip()
analysis_tif = str(scene.analysis_tif_path or "").strip()
source_path = analysis_dir if analysis_dir and os.path.isdir(analysis_dir) else analysis_tif
if not source_path:
continue
product_id = _scene_product_id(scene)
display_name = _scene_display_name(scene, radar)
sources.append(
DeliverySource(
display_name=display_name,
source_path=source_path,
source_radar_data_id=int(radar.id) if radar.id is not None else None,
source_scene_geo_id=int(scene.id) if scene.id is not None else None,
product_id=product_id,
task_name=display_name,
source_kind="lt1_scene_geo",
)
)
return sources
async def _resolve_gf3_sources(
self,
db: AsyncSession,
*,
item_ids: List[int],
) -> List[DeliverySource]:
if not item_ids:
return []
result = await db.execute(
select(RadarDataORM, SARSceneGeoORM)
.outerjoin(SARSceneGeoORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(
RadarDataORM.id.in_(item_ids),
RadarDataORM.source_format.in_(GF3_DELIVERABLE_SOURCE_FORMATS),
RadarDataORM.geocoded_flag.is_(True),
)
.order_by(RadarDataORM.id.asc())
)
sources: List[DeliverySource] = []
for radar, scene in result.all():
source_path = ""
source_scene_geo_id = None
if scene is not None and scene.analysis_tif_path:
analysis_dir = str(scene.analysis_dir or "").strip()
analysis_tif = str(scene.analysis_tif_path or "").strip()
source_path = analysis_dir if analysis_dir and os.path.isdir(analysis_dir) else analysis_tif
source_scene_geo_id = int(scene.id) if scene.id is not None else None
if not source_path:
source_path = str(radar.file_path or "").strip()
if not source_path:
continue
display_name = _radar_display_name(radar)
sources.append(
DeliverySource(
display_name=display_name,
source_path=source_path,
source_radar_data_id=int(radar.id) if radar.id is not None else None,
source_scene_geo_id=source_scene_geo_id,
product_id=f"gf3_sarscape:{radar.id}",
task_name=display_name,
source_kind="gf3_radar_data",
)
)
return sources
async def _resolve_sources_for_channel(
self,
db: AsyncSession,
*,
channel: str,
product_ids: List[int],
compat_result_ids: List[int],
item_ids: List[int],
) -> List[DeliverySource]:
if channel == CHANNEL_DINSAR:
return await self._resolve_dinsar_sources(
db,
product_ids=product_ids,
compat_result_ids=compat_result_ids,
)
if channel == CHANNEL_LT1_ORTHO:
return await self._resolve_lt1_sources(db, item_ids=item_ids, product_ids=product_ids)
if channel == CHANNEL_GF3_ORTHO:
return await self._resolve_gf3_sources(db, item_ids=item_ids or product_ids)
return []
async def list_channel_catalog(
self,
db: AsyncSession,
*,
channel: str,
limit: int = 100,
offset: int = 0,
query: Optional[str] = None,
) -> Dict[str, Any]:
channel = _normalize_channel(channel)
safe_limit = min(500, max(1, int(limit or 100)))
safe_offset = max(0, int(offset or 0))
query_text = str(query or "").strip()
if channel == CHANNEL_LT1_ORTHO:
filters = [
SARSceneGeoORM.status == "DONE",
SARSceneGeoORM.analysis_tif_path.isnot(None),
SARSceneGeoORM.analysis_engine == LT1_ANALYSIS_ENGINE,
SARSceneGeoORM.analysis_profile == LT1_ANALYSIS_PROFILE,
]
if query_text:
like = f"%{query_text}%"
filters.append(
or_(
RadarDataORM.product_unique_id.ilike(like),
RadarDataORM.unique_id.ilike(like),
RadarDataORM.file_path.ilike(like),
RadarDataORM.imaging_date.ilike(like),
)
)
total_result = await db.execute(
select(func.count(SARSceneGeoORM.id))
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(*filters)
)
rows_result = await db.execute(
select(SARSceneGeoORM, RadarDataORM)
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(*filters)
.order_by(SARSceneGeoORM.updated_at.desc().nullslast(), SARSceneGeoORM.id.desc())
.limit(safe_limit)
.offset(safe_offset)
)
items = []
for scene, radar in rows_result.all():
display_name = _scene_display_name(scene, radar)
manifest_path = _manifest_path_for_scene(scene)
quality_path = _quality_path_for_scene(scene)
items.append(
{
"id": scene.id,
"item_id": scene.id,
"source_kind": "lt1_scene_geo",
"product_id": _scene_product_id(scene),
"catalog_name": "sar_scene_geo",
"product_family": "lt1_analysis_ready_geotiff",
"product_type": "analysis_ready_geotiff",
"display_name": display_name,
"task_name": display_name,
"status": scene.status,
"health_status": "OK",
"engine_code": scene.analysis_engine,
"profile_code": scene.analysis_profile,
"radar_data_id": radar.id,
"scene_geo_id": scene.id,
"imaging_date": radar.imaging_date,
"polarization": radar.polarization,
"pixel_size_m": scene.pixel_size_m,
"backscatter_unit": scene.analysis_backscatter_unit,
"publish_dir": scene.analysis_dir,
"manifest_path": manifest_path,
"quality_path": quality_path,
"primary_asset_path": scene.analysis_tif_path,
"file_size": _catalog_file_size(scene.analysis_tif_path),
"summary": {
"radar_data_id": radar.id,
"source_asset_ids": [radar.source_product_ref_id] if radar.source_product_ref_id else [],
"imaging_date": radar.imaging_date,
"polarization": radar.polarization,
"pixel_size_m": scene.pixel_size_m,
},
"produced_at": scene.updated_at.isoformat() if scene.updated_at else None,
"published_at": scene.updated_at.isoformat() if scene.updated_at else None,
}
)
return {
"items": items,
"total": int(total_result.scalar_one() or 0),
"limit": safe_limit,
"offset": safe_offset,
}
if channel == CHANNEL_GF3_ORTHO:
filters = [
RadarDataORM.source_format.in_(GF3_DELIVERABLE_SOURCE_FORMATS),
RadarDataORM.geocoded_flag.is_(True),
]
if query_text:
like = f"%{query_text}%"
filters.append(
or_(
RadarDataORM.product_unique_id.ilike(like),
RadarDataORM.unique_id.ilike(like),
RadarDataORM.file_path.ilike(like),
RadarDataORM.imaging_date.ilike(like),
)
)
total_result = await db.execute(select(func.count(RadarDataORM.id)).where(*filters))
rows_result = await db.execute(
select(RadarDataORM, SARSceneGeoORM)
.outerjoin(SARSceneGeoORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(*filters)
.order_by(RadarDataORM.acquisition_start_time_utc.desc().nullslast(), RadarDataORM.id.desc())
.limit(safe_limit)
.offset(safe_offset)
)
items = []
for radar, scene in rows_result.all():
display_name = _radar_display_name(radar)
primary_path = (
str(scene.analysis_tif_path or "").strip()
if scene is not None and scene.analysis_tif_path
else str(radar.file_path or "").strip()
)
publish_dir = (
str(scene.analysis_dir or "").strip()
if scene is not None and scene.analysis_dir
else str(radar.file_path or "").strip()
)
items.append(
{
"id": radar.id,
"item_id": radar.id,
"source_kind": "gf3_radar_data",
"product_id": f"gf3_sarscape:{radar.id}",
"catalog_name": "radar_data",
"product_family": "gf3_ortho",
"product_type": "sarscape_l2_geotiff" if radar.source_format == GF3_STANDARD_SOURCE_FORMAT else "sarscape_native_geo",
"display_name": display_name,
"task_name": display_name,
"status": "READY",
"health_status": "OK",
"engine_code": (scene.analysis_engine if scene is not None else None) or "gf3_sarscape",
"profile_code": (
(scene.analysis_profile if scene is not None else None)
or ("gf3_standard_geotiff" if radar.source_format == GF3_STANDARD_SOURCE_FORMAT else "gf3_native_geo")
),
"source_format": radar.source_format,
"radar_data_id": radar.id,
"scene_geo_id": scene.id if scene is not None else None,
"imaging_date": radar.imaging_date,
"polarization": radar.polarization,
"pixel_size_m": scene.pixel_size_m if scene is not None else None,
"backscatter_unit": scene.analysis_backscatter_unit if scene is not None else None,
"publish_dir": publish_dir,
"manifest_path": _manifest_path_for_scene(scene) if scene is not None else None,
"primary_asset_path": primary_path,
"file_size": _catalog_file_size(primary_path),
"summary": {
"radar_data_id": radar.id,
"source_asset_ids": [radar.source_product_ref_id] if radar.source_product_ref_id else [],
"imaging_date": radar.imaging_date,
"polarization": radar.polarization,
},
"produced_at": (
scene.updated_at.isoformat() if scene is not None and scene.updated_at else None
),
"published_at": (
scene.updated_at.isoformat() if scene is not None and scene.updated_at else None
),
}
)
return {
"items": items,
"total": int(total_result.scalar_one() or 0),
"limit": safe_limit,
"offset": safe_offset,
}
if channel == CHANNEL_DINSAR:
total = await dinsar_read_service.count_compat_records(db)
records = await dinsar_read_service.list_compat_records(db, limit=safe_limit, offset=safe_offset)
items = []
for record in records:
product = record.product
compat_row = record.compat_row
items.append(
{
"id": int(compat_row.id if compat_row is not None else product.id),
"item_id": int(compat_row.id if compat_row is not None else product.id),
"source_kind": "dinsar_result",
"product_id": product.product_id,
"catalog_name": product.catalog_name,
"product_family": product.product_family,
"product_type": product.product_type,
"display_name": record.display_name,
"task_name": product.task_alias or product.task_name,
"status": product.status,
"health_status": product.health_status,
"engine_code": product.engine_code,
"profile_code": product.profile_code,
"pair_key": product.pair_key,
"publish_dir": product.publish_dir,
"manifest_path": product.manifest_path,
"primary_asset_path": product.primary_asset_path or product.source_primary_path,
"file_size": _catalog_file_size(product.primary_asset_path or product.source_primary_path),
"produced_at": product.produced_at.isoformat() if product.produced_at else None,
"published_at": product.published_at.isoformat() if product.published_at else None,
}
)
return {"items": items, "total": total, "limit": safe_limit, "offset": safe_offset}
return {"items": [], "total": 0, "limit": safe_limit, "offset": safe_offset}
async def create_delivery(
self,
db: AsyncSession,
@@ -337,6 +738,7 @@ class ResultDeliveryService:
channel: str,
product_ids: Optional[List[int]] = None,
compat_result_ids: Optional[List[int]] = None,
item_ids: Optional[List[int]] = None,
package_mode: str = PACKAGE_MODE_DIRECTORY,
include_checksums: Optional[bool] = None,
) -> ResultDeliveryRequestORM:
@@ -348,20 +750,20 @@ class ResultDeliveryService:
max_items = max(1, int(settings.RESULT_DELIVERY_MAX_ITEMS or 500))
normalized_product_ids = _normalize_int_ids(product_ids, max_count=max_items)
normalized_compat_ids = _normalize_int_ids(compat_result_ids, max_count=max_items)
if not normalized_product_ids and not normalized_compat_ids:
normalized_item_ids = _normalize_int_ids(item_ids, max_count=max_items)
if not normalized_product_ids and not normalized_compat_ids and not normalized_item_ids:
raise ValueError("select at least one result")
if len(normalized_product_ids) + len(normalized_compat_ids) > max_items:
if len(normalized_product_ids) + len(normalized_compat_ids) + len(normalized_item_ids) > max_items:
raise ValueError(f"selected item count exceeds max limit ({max_items})")
if channel == CHANNEL_DINSAR:
sources = await self._resolve_dinsar_sources(
db,
product_ids=normalized_product_ids,
compat_result_ids=normalized_compat_ids,
)
else:
sources = []
sources = await self._resolve_sources_for_channel(
db,
channel=channel,
product_ids=normalized_product_ids,
compat_result_ids=normalized_compat_ids,
item_ids=normalized_item_ids,
)
if not sources:
raise ValueError("selected results do not have deliverable files")
@@ -376,6 +778,7 @@ class ResultDeliveryService:
"channel": channel,
"product_ids": normalized_product_ids,
"compat_result_ids": normalized_compat_ids,
"item_ids": normalized_item_ids,
"package_mode": package_mode,
"include_checksums": (
bool(settings.RESULT_DELIVERY_CHECKSUM_ENABLED)
@@ -544,6 +947,8 @@ class ResultDeliveryService:
"source_product_id": item.source_product_id,
"source_result_id": item.source_result_id,
"source_asset_id": item.source_asset_id,
"source_radar_data_id": item.source_radar_data_id,
"source_scene_geo_id": item.source_scene_geo_id,
"display_name": item.display_name,
"relative_path": item.relative_path,
"file_size": item.file_size,
@@ -636,14 +1041,14 @@ class ResultDeliveryService:
)
delivery = result.scalar_one()
request_json = delivery.request_json if isinstance(delivery.request_json, dict) else {}
if delivery.channel == CHANNEL_DINSAR:
sources = await self._resolve_dinsar_sources(
db,
product_ids=_normalize_int_ids(request_json.get("product_ids"), max_count=int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)),
compat_result_ids=_normalize_int_ids(request_json.get("compat_result_ids"), max_count=int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)),
)
else:
sources = []
max_items = int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)
sources = await self._resolve_sources_for_channel(
db,
channel=delivery.channel,
product_ids=_normalize_int_ids(request_json.get("product_ids"), max_count=max_items),
compat_result_ids=_normalize_int_ids(request_json.get("compat_result_ids"), max_count=max_items),
item_ids=_normalize_int_ids(request_json.get("item_ids"), max_count=max_items),
)
task_id = delivery.task_id
if not sources:
@@ -672,10 +1077,9 @@ class ResultDeliveryService:
total_sources = len(sources)
for source_index, source in enumerate(sources, start=1):
folder = _sanitize_segment(
source.product.task_alias
or source.product.task_name
source.task_name
or source.display_name
or source.product.product_id,
or source.product_id,
default=f"product_{source_index}",
)
source_files = _iter_associated_files(source.source_path)
@@ -684,9 +1088,11 @@ class ResultDeliveryService:
item_payloads.append(
{
"delivery_id": delivery_id,
"source_product_id": source.product.id,
"source_product_id": source.product.id if source.product else None,
"source_result_id": source.compat_row.id if source.compat_row else None,
"source_asset_id": source.source_asset_id,
"source_radar_data_id": source.source_radar_data_id,
"source_scene_geo_id": source.source_scene_geo_id,
"display_name": source.display_name,
"source_path": source.source_path,
"relative_path": None,
@@ -736,9 +1142,11 @@ class ResultDeliveryService:
item_payload = {
"delivery_id": delivery_id,
"source_product_id": source.product.id,
"source_product_id": source.product.id if source.product else None,
"source_result_id": source.compat_row.id if source.compat_row else None,
"source_asset_id": source.source_asset_id if file_index == 1 else None,
"source_radar_data_id": source.source_radar_data_id,
"source_scene_geo_id": source.source_scene_geo_id,
"display_name": source.display_name,
"source_path": source_file,
"relative_path": relative_path,
@@ -751,9 +1159,12 @@ class ResultDeliveryService:
manifest_items.append(
{
"display_name": source.display_name,
"product_id": source.product.product_id,
"product_ref_id": source.product.id,
"product_id": source.product_id or (source.product.product_id if source.product else None),
"product_ref_id": source.product.id if source.product else None,
"source_result_id": source.compat_row.id if source.compat_row else None,
"source_radar_data_id": source.source_radar_data_id,
"source_scene_geo_id": source.source_scene_geo_id,
"source_kind": source.source_kind,
"relative_path": relative_path.replace(os.sep, "/"),
"file_size": size,
"checksum_sha256": checksum,
+8 -1
View File
@@ -48,6 +48,13 @@ TASK_QUERY_MAX_OFFSET = read_int_env(
minimum=0,
maximum=20000000,
)
TERMINAL_TASK_STATUSES = {
"COMPLETED",
"PARTIAL_SUCCESS",
"FAILED",
"CANCELLED",
"CANCELED",
}
def _clamp_pagination(limit: int, offset: int, *, default_limit: int, max_limit: int) -> tuple[int, int]:
@@ -270,7 +277,7 @@ class TaskService:
await self._add_log(task_id, "INFO", message, db=db)
# 如果任务结束,更新结束时间
if status in ["COMPLETED", "FAILED", "CANCELLED"]:
if status and str(status).strip().upper() in TERMINAL_TASK_STATUSES:
task.ended_at = datetime.now()
await self._add_log(task_id, "INFO", f"任务已结束: {status}", db=db)
else:
@@ -0,0 +1,7 @@
ALTER TABLE result_delivery_items ADD COLUMN IF NOT EXISTS source_radar_data_id INTEGER NULL REFERENCES radar_data(id) ON DELETE SET NULL;
ALTER TABLE result_delivery_items ADD COLUMN IF NOT EXISTS source_scene_geo_id INTEGER NULL REFERENCES sar_scene_geo(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS ix_result_delivery_items_source_radar_data_id ON result_delivery_items(source_radar_data_id);
CREATE INDEX IF NOT EXISTS ix_result_delivery_items_source_scene_geo_id ON result_delivery_items(source_scene_geo_id);
CREATE INDEX IF NOT EXISTS idx_result_delivery_items_radar ON result_delivery_items(source_radar_data_id);
CREATE INDEX IF NOT EXISTS idx_result_delivery_items_scene_geo ON result_delivery_items(source_scene_geo_id);
@@ -0,0 +1,274 @@
# 运维任务维护面板设计
日期:2026-07-02
## 背景
当前系统的生产任务、扫描任务和数据准备任务已经统一沉淀到 `system_tasks`、`system_jobs`、`task_logs` 以及各业务 run 表中。实际运维中经常出现以下场景:
- 用户提交 D-InSAR 生产或数据准备任务后,外部进程失败、服务器断电或 worker 心跳超时。
- 数据库任务已经 `FAILED`,但业务 item/execution 仍残留 `RUNNING`。
- 磁盘上存在半成品目录,但数据库记录已经失败或需要清理。
- 需要先判断任务是否仍在真实执行,再决定是否清理数据库记录和磁盘目录。
过去这些动作依赖人工查表和命令行处理。第一版运维面板要把这个过程产品化:用户能查看任务状态、诊断失败原因、预览清理影响,并由管理员执行清理。面板不负责重跑,清理完成后用户回到业务页面重新提交。
## 目标
1. 在“运行维护”中增加“任务维护”能力。
2. 自动列出异常任务,尤其是 D-InSAR 生产、D-InSAR 数据准备、扫描、配对缓存重建等长耗时任务。
3. 对单个任务提供诊断详情:任务、job、run、item、execution、日志、结果登记和磁盘路径。
4. 对可清理任务提供清理预览,明确会删除哪些数据库记录和哪些目录。
5. 管理员确认后执行清理,并写入审计日志。
6. 不提供重跑按钮,不在运维页重新提交业务任务。
## 非目标
- 不做自动重跑。
- 不做跨业务的复杂恢复编排。
- 不在第一版支持任意 SQL 或任意路径删除。
- 不清理源资产库、DEM 库、精密轨道库、代码目录。
- 不处理仍有真实生产进程运行的任务;这种任务应先取消或等待结束。
## 异常任务定义
第一版将以下任务纳入维护列表:
- `system_tasks.status` 属于 `FAILED`、`PARTIAL_SUCCESS`、`CANCELLED`。
- `system_jobs.status` 为 `RUNNING` 但 `heartbeat_at` 超过阈值。
- `system_tasks.status` 为 `RUNNING`,但无活跃 job 且无真实进程证据。
- D-InSAR production run 终态失败,但 item/execution 中存在 `RUNNING` 或 `PENDING` 残留。
- 数据库记录引用的生产结果目录不存在。
- 生产结果目录存在,但没有匹配的 run 或 result product 登记。
第一版优先实现:
- `LANDSAR_RUN` / `LANDSAR_CLUSTER_RUN` / `PYINT_RUN` / `IDL_RUN_DINSAR`
- `COPY_DATA`
- `PAIRING_CACHE_REBUILD`
- 扫描类任务只读展示,清理动作后续扩展
## 后端设计
新增运维任务维护路由:
`backend/app/routers/ops_maintenance.py`
建议接口:
- `GET /api/ops-maintenance/tasks`
- 返回异常任务列表。
- 支持 `task_type`、`status`、`limit`、`offset`。
- `GET /api/ops-maintenance/tasks/{task_id}/diagnosis`
- 返回单任务诊断详情。
- `POST /api/ops-maintenance/tasks/{task_id}/cleanup-preview`
- 生成清理预览,不执行删除。
- `POST /api/ops-maintenance/tasks/{task_id}/cleanup`
- 管理员确认后执行清理。
### 列表返回模型
每条任务至少包含:
- `task_id`
- `task_type`
- `task_name`
- `status`
- `created_at`
- `started_at`
- `ended_at`
- `updated_at`
- `progress`
- `message`
- `run_id`
- `job_id`
- `job_status`
- `issue_level`: `info` / `warning` / `danger`
- `issue_summary`
- `cleanup_supported`
- `cleanup_blocked_reason`
- `counts`
- `jobs`
- `logs`
- `run_items`
- `executions`
- `result_products`
- `disk_paths`
### 诊断详情模型
诊断详情包含:
- `task`
- `jobs`
- `production_run`
- `production_item_counts`
- `production_execution_counts`
- `result_products`
- `compat_results`
- `recent_logs`
- `disk_paths`
- `process_check`
- `diagnosis`
- `summary`
- `findings`
- `cleanup_supported`
- `cleanup_blockers`
### 清理预览模型
清理预览必须返回完整影响范围:
- `database_deletes`
- `system_tasks`
- `system_jobs`
- `task_logs`
- `dinsar_production_runs`
- `dinsar_production_run_items`
- `dinsar_production_executions`
- `result_products`
- `dinsar_results`
- `workflow_runs`
- `workflow_steps`
- `workflow_artifacts`
- `disk_deletes`
- `path`
- `kind`: `task_pool` / `production_result` / `run_log`
- `exists`
- `size_bytes` 可选
- `allowed`
- `blocked`
- `blockers`
- `requires_admin`
### 清理执行
执行接口只接受预览中允许的清理项,不接受前端传来的任意路径。
请求体:
```json
{
"confirm": true,
"delete_task_records": true,
"delete_logs": true,
"delete_production_records": true,
"delete_result_products": true,
"delete_production_dirs": true,
"delete_task_pool_dir": true
}
```
执行顺序:
1. 重新生成清理预览。
2. 校验任务不是 `PENDING` / `RUNNING`,或已被诊断为 stale 且无真实进程。
3. 校验所有待删目录在白名单根目录内。
4. 删除数据库记录。
5. 删除磁盘目录。
6. 写审计日志。
7. 返回复查结果。
## 路径白名单
允许删除的目录根:
- `settings.DINSAR_TASK_ROOTS` 或系统配置的数据准备根,例如 `D:\Task_Pool\DInSAR`
- `settings.PRODUCTION_RESULTS_ROOT` 下的 D-InSAR 发布目录,例如 `D:\production_results\dinsar`
- 系统 runtime run log 目录中与 run_id 精确匹配的日志文件
禁止删除:
- 磁盘根目录
- 项目代码目录
- 源产品资产目录
- 精密轨道目录
- DEM 目录
- 未由系统登记的任意路径
## 前端设计
入口放在 `HealthCheckPanel.jsx` 的“低频维护”区域,新增“任务维护”区块。整体延续现有运维页视觉:高密度、少装饰、状态明确。
### 页面结构
1. 顶部摘要条
- 异常任务数
- 可清理任务数
- 阻塞清理任务数
- 最近一次清理时间
2. 筛选栏
- 任务类型
- 状态
- 关键词
- 刷新按钮
3. 异常任务表
- 状态
- 类型
- 名称
- 进度/统计
- 失败摘要
- 更新时间
- 操作:诊断
4. 诊断详情抽屉或内联详情
- 基础任务信息
- job 状态
- D-InSAR run 状态
- item/execution 统计
- 磁盘路径检查
- 最近日志
- 诊断结论
5. 清理预览
- 数据库删除数量
- 磁盘目录列表
- 阻塞原因
- 管理员确认按钮
### 交互原则
- 非管理员可以查看诊断,但不能执行清理。
- 清理按钮只在预览完成且无阻塞时启用。
- 真实进程仍在运行时禁止清理。
- 清理完成后刷新列表,不提供“重新执行”入口。
- 删除影响必须在按钮前可见,不用弹窗隐藏关键信息。
## 审计日志
每次清理写入审计日志:
- 操作人
- 操作时间
- `task_id`
- `run_id`
- 删除选项
- 删除前状态快照
- 数据库删除数量
- 磁盘删除路径
- 删除结果
- 失败原因
## 第一版实现边界
第一版聚焦 D-InSAR 生产和数据准备任务:
- `LANDSAR_RUN`
- `LANDSAR_CLUSTER_RUN`
- `PYINT_RUN`
- `IDL_RUN_DINSAR`
- `COPY_DATA`
扫描任务、SBAS 任务和结果交付任务先进入只读诊断列表,后续按同一接口扩展清理策略。
## 验收标准
1. 运维页能列出失败或部分成功的 D-InSAR/COPY_DATA 任务。
2. 点击任务能看到 task/job/run/item/execution/log/path 诊断详情。
3. 对失败且无真实进程的 D-InSAR 任务能生成清理预览。
4. 管理员能执行清理,普通用户不能。
5. 清理后关联数据库记录为 0,登记过的生产目录被删除。
6. 清理动作写入审计日志。
7. 前端构建通过。
@@ -14,8 +14,9 @@
## 目标
- 所有登录用户都可以申请成果交付。
- D-InSAR 已登记成果先接入真实交付下载。
- SBAS、LT-1 正射、Sentinel-1 正射、GF3 正射在页面和接口中保留清晰占位,不暴露假执行能力。
- D-InSAR 已登记成果接入真实交付下载。
- LT-1 正射与 GF3 正射接入真实交付下载。
- SBAS 与 Sentinel-1 正射在页面和接口中保留清晰占位,不暴露假执行能力。
- 大文件交付改为后台任务,不再由 HTTP 请求同步复制。
- 用户最终可以把成果下载到本地;服务器交付区只是临时缓存。
- 每次交付可审计、可过期清理、可校验。
@@ -25,6 +26,7 @@
- 本阶段不实现 Sentinel-1 正射生产。
- 本阶段不实现 SBAS 成果交付打包,只显示目录已接入但交付未接入。
- 本阶段不触发 GF3 生产;GF3 交付只面向已经登记的 SARscape 标准化正射成品。
- 本阶段不做跨节点对象存储或外部网盘。
- 本阶段不把普通用户开放到任意服务器路径写入。
@@ -43,6 +45,13 @@
## 交付模式
## 正射成果口径
- LT-1 正射:由服务器侧单景正射流水线生产,登记在 `sar_scene_geo`,主交付物是 `analysis_ready.tif`,随包包含预览、manifest、quality 等 sidecar 文件。
- GF3 正射:系统拿到的就是外部 SARscape 已生产成品,当前可登记为 `GF3_SARSCAPE_NATIVE_PREVIEW`,标准化后也可登记为 `GF3_SARSCAPE_L2`;交付阶段只做受控打包和下载,不触发 GF3 生产。
- Sentinel-1 正射:生产链尚未接入,结果提取页面保留占位,避免用户误操作。
- SBAS-InSAR:成果目录可查,但交付打包尚未接入。
### 1. 目录交付
默认模式。后台将选中的结果文件复制到:
@@ -97,6 +106,7 @@
- `delivery_id`。
- `source_product_id` / `source_result_id`。
- `source_radar_data_id` / `source_scene_geo_id`:用于 LT-1/GF3 单景正射资产追踪。
- `display_name`。
- `source_path`。
- `relative_path`。
@@ -133,9 +143,23 @@ GET /api/result-deliveries/channels
- `dinsar`: `ready`
- `sbas`: `planned`
- `lt1_ortho`: `planned`
- `lt1_ortho`: `ready`
- `s1_ortho`: `placeholder`
- `gf3_ortho`: `placeholder`
- `gf3_ortho`: `ready`
### 获取可交付目录
```http
GET /api/result-deliveries/catalog/{channel}
```
第一版支持:
- `dinsar`:兼容已有 D-InSAR catalog。
- `lt1_ortho`:读取 `sar_scene_geo` 中 `lt_gamma / lt1_gamma_geocoded_mli` 且 `DONE` 的分析就绪 GeoTIFF。
- `gf3_ortho`:读取 `radar_data.source_format in (GF3_SARSCAPE_NATIVE_PREVIEW, GF3_SARSCAPE_L2)` 且 `geocoded_flag = true` 的 SARscape 正射成品。
返回统一字段包括 `item_id`、`source_kind`、`product_id`、`display_name`、`primary_asset_path`、`publish_dir`、`radar_data_id`、`scene_geo_id`。
### 创建交付任务
@@ -150,6 +174,7 @@ POST /api/result-deliveries
"channel": "dinsar",
"product_ids": ["..."],
"compat_result_ids": [1, 2, 3],
"item_ids": [101, 102],
"package_mode": "directory",
"include_manifest": true,
"include_checksums": true
@@ -159,7 +184,9 @@ POST /api/result-deliveries
约束:
- 普通用户只创建自己的任务。
- `channel != dinsar` 时第一版返回 409 或 422,提示“通道尚未接入交付”。
- `dinsar` 使用 `compat_result_ids` 或 `product_ids`。
- `lt1_ortho` 使用 `item_ids = sar_scene_geo.id`。
- `gf3_ortho` 使用 `item_ids = radar_data.id`。
- 每次最大数量由 `RESULT_DELIVERY_MAX_ITEMS` 控制。
- 不允许传入任意服务器输出路径。
@@ -200,7 +227,7 @@ RESULT_DELIVERY_BUILD
处理流程:
1. 将 delivery 标记为 `RUNNING`。
2. 解析 D-InSAR catalog 中的文件路径。
2. 按 channel 解析可交付源文件路径。
3. 复制到交付目录。
4. 生成 `manifest.json`。
5. 可选计算 checksum。
@@ -244,8 +271,8 @@ RESULT_DELIVERY_CHECKSUM_ENABLED=true
页面结构:
- 通道栏:D-InSAR 可用,SBAS/LT-1/Sentinel-1/GF3 明确显示“未接入交付”。
- 成果选择区:复用现有 D-InSAR catalog 列表。
- 通道栏:D-InSAR、LT-1 正射、GF3 正射可用;SBAS/Sentinel-1 显示未接入交付。
- 成果选择区:D-InSAR 复用现有 catalog 列表,LT-1/GF3 使用统一交付 catalog 查询。
- 交付选项:目录交付 / 压缩包交付。
- 我的交付包:状态、大小、文件数、过期时间、下载入口。
@@ -283,7 +310,7 @@ RESULT_DELIVERY_CHECKSUM_ENABLED=true
- 文档落地。
- 数据表和自维护 migration。
- D-InSAR 目录交付后台任务。
- D-InSAR、LT-1 正射、GF3 正射目录交付后台任务。
- 我的交付包列表和详情。
### 阶段 2
@@ -295,12 +322,12 @@ RESULT_DELIVERY_CHECKSUM_ENABLED=true
### 阶段 3
- SBAS 交付接入。
- LT-1 正射、Sentinel-1 正射、GF3 正射在生产 catalog 完成后接入。
- Sentinel-1 正射在生产 catalog 完成后接入。
- exporter/operator 角色拆分。
## 验收标准
- 普通登录用户能创建 D-InSAR 成果交付任务。
- 普通登录用户能创建 D-InSAR、LT-1 正射、GF3 正射成果交付任务。
- HTTP 请求只排队任务,不再同步复制大文件。
- 交付完成后用户能下载到本地。
- 普通用户不能指定任意服务器目录。
+498 -17
View File
@@ -3109,6 +3109,269 @@ input[type="checkbox"] {
cursor: not-allowed;
}
.health-action-button.danger {
border-color: #b91c1c;
background: #b91c1c;
}
.ops-task-panel {
display: grid;
gap: 12px;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: #fff;
}
.ops-task-header,
.ops-task-detail-header,
.ops-task-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.ops-task-header h4,
.ops-task-detail-header h4 {
margin: 0;
color: var(--color-text-primary);
font-size: 0.95em;
}
.ops-task-header p,
.ops-task-detail-header p {
margin: 4px 0 0;
color: var(--color-text-secondary);
font-size: 0.82em;
line-height: 1.5;
}
.ops-task-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.ops-task-summary > div,
.ops-task-preview-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
}
.ops-task-summary span,
.ops-task-preview-cell span {
color: #64748b;
font-size: 0.78em;
}
.ops-task-summary strong,
.ops-task-preview-cell strong {
color: #0f172a;
font-size: 0.9em;
}
.ops-task-filters {
display: flex;
align-items: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.ops-task-filters label {
display: grid;
gap: 4px;
color: #475569;
font-size: 0.78em;
font-weight: 700;
}
.ops-task-filters select {
min-width: 150px;
padding: 7px 9px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #0f172a;
}
.ops-task-table-wrap {
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.ops-task-table {
width: 100%;
min-width: 880px;
border-collapse: collapse;
font-size: 0.82em;
}
.ops-task-table th,
.ops-task-table td {
padding: 9px 10px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
vertical-align: top;
}
.ops-task-table th {
color: #475569;
background: #f8fafc;
font-weight: 800;
}
.ops-task-table tr.selected td {
background: #eff6ff;
}
.ops-task-table tr:last-child td {
border-bottom: 0;
}
.ops-task-name {
color: #0f172a;
font-weight: 800;
}
.ops-task-muted {
color: #64748b;
font-size: 0.82em;
line-height: 1.5;
}
.ops-task-empty {
color: #64748b;
text-align: center !important;
}
.ops-task-badge {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.76em;
font-weight: 800;
white-space: nowrap;
}
.ops-task-badge.tone-danger {
background: #fee2e2;
color: #991b1b;
}
.ops-task-badge.tone-warn {
background: #fef3c7;
color: #92400e;
}
.ops-task-badge.tone-ok {
background: #dcfce7;
color: #166534;
}
.ops-task-badge.tone-info {
background: #dbeafe;
color: #1d4ed8;
}
.ops-task-badge.tone-neutral {
background: #e2e8f0;
color: #334155;
}
.ops-task-detail,
.ops-task-preview {
display: grid;
gap: 12px;
padding: 12px;
border: 1px solid #dbe3ef;
border-radius: 8px;
background: #fbfdff;
}
.ops-task-diagnosis {
display: grid;
gap: 6px;
}
.ops-task-two-col {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
}
.ops-task-section-title {
margin-bottom: 6px;
color: #334155;
font-size: 0.82em;
font-weight: 800;
}
.ops-task-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 6px;
}
.ops-task-path-list,
.ops-task-log-list {
display: grid;
gap: 6px;
}
.ops-task-path {
display: grid;
gap: 4px;
padding: 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
}
.ops-task-path.blocked {
border-color: #fecaca;
background: #fff7f7;
}
.ops-task-path span,
.ops-task-path em,
.ops-task-log-list span {
color: #64748b;
font-size: 0.76em;
font-style: normal;
}
.ops-task-path code {
color: #0f172a;
font-size: 0.78em;
white-space: normal;
overflow-wrap: anywhere;
}
.ops-task-log-list > div {
padding: 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
}
.ops-task-log-list p {
margin: 3px 0 0;
color: #334155;
font-size: 0.8em;
line-height: 1.45;
}
.log-management-panel {
display: grid;
gap: 10px;
@@ -3460,6 +3723,23 @@ input[type="checkbox"] {
.health-grid {
grid-template-columns: 1fr;
}
.ops-task-header,
.ops-task-detail-header,
.ops-task-actions {
align-items: stretch;
flex-direction: column;
}
.ops-task-summary,
.ops-task-two-col {
grid-template-columns: 1fr;
}
.ops-task-filters label,
.ops-task-filters select {
width: 100%;
}
}
/* Top Status Bar */
@@ -4125,30 +4405,47 @@ input[type="checkbox"] {
.login-page-wrapper {
min-height: 100vh;
background: radial-gradient(circle at 12% 10%, rgba(37, 99, 235, 0.15) 0%, transparent 42%),
radial-gradient(circle at 90% 0%, rgba(14, 165, 233, 0.14) 0%, transparent 45%),
linear-gradient(180deg, #f8fafc 0%, #e8eefc 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: #020617;
display: grid;
padding: 0;
}
.login-shell {
grid-area: 1 / 1;
width: 100%;
min-height: 100vh;
display: grid;
grid-template-columns: clamp(360px, 32vw, 480px) minmax(0, 1fr);
background: #ffffff;
overflow: hidden;
}
.login-card {
width: min(420px, 94vw);
background: #ffffff;
border: 1px solid #dbe4f3;
border-radius: 16px;
box-shadow: 0 24px 52px rgba(15, 23, 42, 0.16);
padding: 24px;
width: 100%;
min-height: 100vh;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border-right: 1px solid #dbe4f3;
padding: clamp(32px, 5vh, 56px) clamp(28px, 4vw, 52px);
display: flex;
flex-direction: column;
gap: 14px;
justify-content: center;
gap: 16px;
}
.login-card--checking {
place-self: center;
width: min(420px, 94vw);
min-height: auto;
border: 1px solid #dbe4f3;
border-radius: 14px;
box-shadow: var(--shadow-panel);
padding: 28px;
}
.login-card h2 {
margin: 0;
font-size: 1.4em;
font-size: 1.55em;
line-height: 1.25;
color: var(--color-text-primary);
}
@@ -4166,7 +4463,7 @@ input[type="checkbox"] {
color: var(--color-accent-strong);
font-weight: 700;
font-size: 0.75em;
letter-spacing: 0.4px;
letter-spacing: 0;
padding: 4px 10px;
}
@@ -4196,7 +4493,7 @@ input[type="checkbox"] {
.login-card input,
.login-card select {
width: 100%;
padding: 10px 12px;
padding: 11px 12px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font-size: 0.95em;
@@ -4240,6 +4537,184 @@ input[type="checkbox"] {
color: #1e3a8a;
}
.login-submit-btn {
min-height: 42px;
border-radius: 8px;
font-weight: 700;
}
.login-visual-panel {
position: relative;
min-width: 0;
min-height: 100vh;
display: flex;
align-items: flex-end;
overflow: hidden;
padding: clamp(32px, 5vw, 72px);
background: #020617;
color: #f8fafc;
}
.login-visual-panel::after {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(2, 6, 23, 0.68) 0%, rgba(2, 6, 23, 0.34) 38%, rgba(2, 6, 23, 0.08) 100%),
linear-gradient(0deg, rgba(2, 6, 23, 0.72) 0%, rgba(2, 6, 23, 0.18) 52%, rgba(2, 6, 23, 0.18) 100%);
pointer-events: none;
}
.login-slide-copy {
position: relative;
z-index: 2;
max-width: min(720px, 74%);
padding-bottom: 36px;
}
.login-slide-copy span {
color: #7dd3fc;
font-size: 0.82em;
font-weight: 700;
}
.login-slide-copy h3 {
margin: 8px 0 8px;
color: #f8fafc;
font-size: 3.4rem;
line-height: 1.08;
text-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
}
.login-slide-copy p {
margin: 0;
max-width: 680px;
color: #dbeafe;
font-size: 1.02em;
line-height: 1.68;
text-shadow: 0 10px 26px rgba(0, 0, 0, 0.38);
}
.login-slide-frame {
position: absolute;
inset: 0;
overflow: hidden;
background: #0f172a;
}
.login-slide-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transform: scale(1.018);
transition: opacity 0.45s ease, transform 0.7s ease;
}
.login-slide-image.active {
opacity: 1;
transform: scale(1);
}
.login-slide-dots {
position: absolute;
z-index: 2;
right: clamp(28px, 4vw, 60px);
bottom: clamp(28px, 4vw, 56px);
display: flex;
gap: 8px;
}
.login-slide-dots button {
width: 30px;
height: 8px;
border: 1px solid #93a4bd;
border-radius: 999px;
background: #e2e8f0;
padding: 0;
cursor: pointer;
}
.login-slide-dots button.active {
width: 42px;
border-color: var(--color-accent);
background: var(--color-accent);
}
@media (max-width: 1240px) {
.login-slide-copy h3 {
font-size: 2.8rem;
}
}
@media (max-width: 920px) {
.login-page-wrapper {
align-items: flex-start;
overflow-y: auto;
}
.login-shell {
min-height: 100vh;
grid-template-columns: 1fr;
}
.login-card {
min-height: auto;
border-right: none;
border-bottom: 1px solid #dbe4f3;
padding: 24px;
}
.login-visual-panel {
min-height: min(58vh, 560px);
padding: 24px;
}
.login-slide-copy {
max-width: 100%;
padding-bottom: 34px;
}
.login-slide-copy h3 {
font-size: 2.2rem;
}
.login-slide-dots {
right: auto;
left: 24px;
bottom: 20px;
}
}
@media (max-width: 560px) {
.login-card,
.login-visual-panel {
padding: 18px;
}
.login-card h2,
.login-slide-copy h3 {
font-size: 1.28rem;
}
.login-visual-panel {
min-height: 360px;
}
}
@media (prefers-reduced-motion: reduce) {
.login-slide-image {
transition: opacity 0.15s ease;
transform: none;
}
.login-slide-image.active {
transform: none;
}
}
.user-role-chip {
display: inline-flex;
align-items: center;
@@ -5857,6 +6332,12 @@ input[type="checkbox"] {
border-color: rgba(245, 158, 11, 0.25);
}
.dinsar-status-pill.tone-partial {
background: rgba(217, 119, 6, 0.13);
color: #92400e;
border-color: rgba(217, 119, 6, 0.28);
}
.dinsar-status-pill.tone-error {
background: rgba(220, 38, 38, 0.12);
color: #b91c1c;
+1 -1
View File
@@ -2095,7 +2095,7 @@ function App() {
if (!authChecked) {
return (
<div className="login-page-wrapper">
<div className="login-card">
<div className="login-card login-card--checking">
<h2>正在检查登录状态...</h2>
<p>请稍候,系统正在验证会话。</p>
</div>
+131 -18
View File
@@ -18,7 +18,18 @@ const RUN_HISTORY_PAGE_SIZE = 200;
const TASK_HISTORY_PAGE_SIZE = 200;
const TASK_LOG_PAGE_SIZE = 1000;
const INLINE_TASK_LOG_LIMIT = 200;
const TERMINAL_STATUS_VALUES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'CANCELED', 'success', 'failed', 'cancelled', 'canceled']);
const TERMINAL_STATUS_VALUES = new Set([
'COMPLETED',
'PARTIAL_SUCCESS',
'FAILED',
'CANCELLED',
'CANCELED',
'success',
'partial_success',
'failed',
'cancelled',
'canceled',
]);
const ENGINE_STATUS_COLOR = {
ok: '#22c55e',
@@ -54,10 +65,12 @@ const STATUS_LABEL = {
PENDING: '等待中',
RUNNING: '运行中',
COMPLETED: '已完成',
PARTIAL_SUCCESS: '部分成功',
FAILED: '失败',
CANCELLED: '已取消',
CANCELED: '已取消',
success: '成功',
partial_success: '部分成功',
failed: '失败',
running: '运行中',
pending: '等待中',
@@ -133,6 +146,7 @@ function taskTypeToEngine(taskType) {
function taskStatusToRunStatus(status) {
const normalized = String(status || '').toUpperCase();
if (normalized === 'COMPLETED') return 'success';
if (normalized === 'PARTIAL_SUCCESS') return 'partial_success';
if (normalized === 'FAILED') return 'failed';
if (normalized === 'CANCELLED' || normalized === 'CANCELED') return 'cancelled';
if (normalized === 'RUNNING') return 'running';
@@ -296,6 +310,40 @@ function hasRunCounts(run) {
return [completed, failed, skipped, total].some(value => value != null);
}
function hasMixedRunOutcome(run) {
const { completed, failed, skipped } = getRunCounts(run);
return Number(failed || 0) > 0 && (Number(completed || 0) > 0 || Number(skipped || 0) > 0);
}
function isActiveRunStatus(status) {
const normalized = String(status || '').trim().toLowerCase();
return normalized === 'running' || normalized === 'pending';
}
function getDisplayRunStatus(run) {
const rawStatus = String(run?.raw_status || '').toUpperCase();
const status = String(run?.status || '').toLowerCase();
if (isActiveRunStatus(rawStatus) || isActiveRunStatus(status)) {
return run?.status;
}
if (rawStatus === 'PARTIAL_SUCCESS' || status === 'partial_success' || hasMixedRunOutcome(run)) {
return 'partial_success';
}
return run?.status;
}
function getDisplayTaskStatus(task, relatedRun = null) {
if (!task) return '';
const taskStatus = String(task?.status || '').trim();
if (isActiveRunStatus(taskStatus) || isActiveRunStatus(relatedRun?.raw_status) || isActiveRunStatus(relatedRun?.status)) {
return task.status;
}
if (taskStatus.toUpperCase() === 'PARTIAL_SUCCESS' || hasMixedRunOutcome(relatedRun)) {
return 'PARTIAL_SUCCESS';
}
return task.status;
}
function formatRunCounts(run) {
const { completed, failed, skipped, total } = getRunCounts(run);
const parts = [];
@@ -309,6 +357,7 @@ function formatRunCounts(run) {
function statusToneClass(status) {
const normalized = String(status || '').toLowerCase();
if (normalized === 'success' || normalized === 'completed') return 'tone-ready';
if (normalized === 'partial_success' || normalized === 'partial' || normalized === 'completed_with_errors') return 'tone-partial';
if (normalized === 'failed') return 'tone-error';
if (normalized === 'running') return 'tone-info';
if (normalized === 'cancelled' || normalized === 'canceled') return 'tone-neutral';
@@ -428,10 +477,11 @@ function RunPathBlock({ run }) {
}
function RunStatusBlock({ run }) {
const displayStatus = getDisplayRunStatus(run);
return (
<div style={{ display: 'grid', gap: 4, minWidth: 120 }}>
<span className={`dinsar-status-pill ${statusToneClass(run?.status)}`}>
{formatStatus(run?.status)}
<span className={`dinsar-status-pill ${statusToneClass(displayStatus)}`}>
{formatStatus(displayStatus)}
</span>
{hasRunCounts(run) && (
<span style={{ color: '#475569', fontSize: 11, lineHeight: 1.35 }}>
@@ -817,6 +867,10 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
recentLimit: 1,
});
const latestRunWithTask = runs.find(run => run?.task_id) || null;
const relatedMonitoredRun = runs.find(run => {
const taskId = String(run?.task_id || '').trim();
return taskId && taskId === String(taskMonitor.latestTask?.task_id || '').trim();
}) || latestRunWithTask || null;
const monitoredTask = taskMonitor.latestTask || (
latestRunWithTask
? {
@@ -827,16 +881,28 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
: latestRunWithTask.engine === 'landsar'
? (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,
status: getDisplayTaskStatus(
{ status: latestRunWithTask.raw_status || latestRunWithTask.status },
latestRunWithTask,
),
progress: ['COMPLETED', 'PARTIAL_SUCCESS'].includes(String(latestRunWithTask.raw_status || '').toUpperCase())
|| ['success', 'partial_success'].includes(String(latestRunWithTask.status || '').toLowerCase())
? 100
: null,
message: latestRunWithTask.message || '最近一次任务',
}
: null
);
const monitoredTaskDisplayStatus = getDisplayTaskStatus(monitoredTask, relatedMonitoredRun);
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 issueRunCount = runs.filter(run => {
const displayStatus = String(getDisplayRunStatus(run) || '').toLowerCase();
const rawStatus = String(run.raw_status || '').toUpperCase();
return ['failed', 'partial_success'].includes(displayStatus)
|| ['FAILED', 'PARTIAL_SUCCESS'].includes(rawStatus);
}).length;
const productionReady = !!currentEngineObj?.available && !!rootDir.trim() && !pyintPreviewBlocksSubmit && !readOnly;
const loadEngines = useCallback(async () => {
@@ -1471,9 +1537,9 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
<span>运行中</span>
<strong>{activeRunCount}</strong>
</div>
<div className={`dinsar-production-signal ${failedRunCount > 0 ? 'warn' : ''}`}>
<span>失败记录</span>
<strong>{failedRunCount}</strong>
<div className={`dinsar-production-signal ${issueRunCount > 0 ? 'warn' : ''}`}>
<span>异常记录</span>
<strong>{issueRunCount}</strong>
</div>
</div>
</div>
@@ -1975,34 +2041,79 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div>
{monitoredTask && (
(() => {
const isPartial = String(monitoredTaskDisplayStatus || '').toUpperCase() === 'PARTIAL_SUCCESS';
const isFailedStatus = String(monitoredTaskDisplayStatus || '').toUpperCase() === 'FAILED';
const panelTone = isPartial
? {
background: '#fffbeb',
border: '#fcd34d',
title: '#92400e',
text: '#78350f',
track: '#fde68a',
fill: '#d97706',
}
: isFailedStatus
? {
background: '#fef2f2',
border: '#fecaca',
title: '#b91c1c',
text: '#7f1d1d',
track: '#fee2e2',
fill: '#dc2626',
}
: showingRecentTask
? {
background: '#eff6ff',
border: '#bfdbfe',
title: '#1d4ed8',
text: '#1e40af',
track: '#dbeafe',
fill: '#3b82f6',
}
: {
background: '#fefce8',
border: '#fde68a',
title: '#92400e',
text: '#78350f',
track: '#fde68a',
fill: '#f59e0b',
};
const monitoredCountsText = hasRunCounts(relatedMonitoredRun) ? formatRunCounts(relatedMonitoredRun) : '';
return (
<div
style={{
marginBottom: 10,
padding: '8px 10px',
background: showingRecentTask ? '#eff6ff' : '#fefce8',
background: panelTone.background,
borderRadius: 6,
border: `1px solid ${showingRecentTask ? '#bfdbfe' : '#fde68a'}`,
border: `1px solid ${panelTone.border}`,
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: showingRecentTask ? '#1d4ed8' : '#92400e',
color: panelTone.title,
marginBottom: 4,
}}
>
{showingRecentTask ? '最近一次任务' : '当前任务'}
</div>
<div style={{ fontSize: 12, color: showingRecentTask ? '#1e40af' : '#78350f', wordBreak: 'break-all' }}>
{monitoredTask.task_id} - {formatTaskType(monitoredTask.task_type)} - {formatStatus(monitoredTask.status)} - {monitoredTask.message}
<div style={{ fontSize: 12, color: panelTone.text, wordBreak: 'break-all' }}>
{monitoredTask.task_id} - {formatTaskType(monitoredTask.task_type)} - {formatStatus(monitoredTaskDisplayStatus)} - {monitoredTask.message}
</div>
{monitoredCountsText && (
<div style={{ marginTop: 4, fontSize: 11, color: panelTone.text }}>
运行情况:{monitoredCountsText}
</div>
)}
{monitoredTask.progress != null && (
<div style={{ marginTop: 6 }}>
<div
style={{
height: 6,
background: showingRecentTask ? '#dbeafe' : '#fde68a',
background: panelTone.track,
borderRadius: 3,
overflow: 'hidden',
}}
@@ -2011,11 +2122,11 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
style={{
height: '100%',
width: `${monitoredTask.progress}%`,
background: showingRecentTask ? '#3b82f6' : '#f59e0b',
background: panelTone.fill,
}}
/>
</div>
<div style={{ fontSize: 11, color: showingRecentTask ? '#1d4ed8' : '#92400e', marginTop: 2 }}>{monitoredTask.progress}%</div>
<div style={{ fontSize: 11, color: panelTone.title, marginTop: 2 }}>{monitoredTask.progress}%</div>
</div>
)}
@@ -2023,7 +2134,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
style={{
marginTop: 8,
background: '#fff',
border: `1px solid ${showingRecentTask ? '#bfdbfe' : '#fde68a'}`,
border: `1px solid ${panelTone.border}`,
borderRadius: 6,
padding: '8px 10px',
}}
@@ -2104,6 +2215,8 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
)}
</div>
</div>
);
})()
)}
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>运行记录(已加载 {runs.length} 条)</div>
+2
View File
@@ -7,6 +7,7 @@ import { syncWaterScenesFromDisk } from './api/water';
import { listEngines, runWslCheck } from './api/dinsarProduction';
import { getOrbitStatus, syncOrbitPools } from './api/orbit';
import LogManagementPanel from './LogManagementPanel';
import OpsTaskMaintenancePanel from './OpsTaskMaintenancePanel';
const toNumber = (value) => {
const parsed = Number(value);
@@ -1507,6 +1508,7 @@ const HealthCheckPanel = ({ currentUser }) => {
)}
{/* 日志管理 */}
<OpsTaskMaintenancePanel isAdmin={isAdmin} />
<LogManagementPanel isAdmin={isAdmin} />
</div>
</section>
+107 -46
View File
@@ -1,5 +1,26 @@
import React, { useEffect, useState } from 'react';
import apiClient from './api/client';
import clusterDeliveryUrl from './assets/login/cluster-delivery-art.webp';
import processingArchitectureUrl from './assets/login/processing-architecture-art.webp';
import systemOverviewUrl from './assets/login/system-overview-art.webp';
const loginSlides = [
{
title: '统一管理雷达数据生产流程',
description: '覆盖源数据入库、精密轨道匹配、生产编排、成果目录和地图分析,形成可审计的工程闭环。',
image: systemOverviewUrl,
},
{
title: '面向 D-InSAR / SBAS 的处理链路',
description: '扫描与解析先行,生产任务进入队列,运行结果和失败归因统一登记,便于复查和补跑。',
image: processingArchitectureUrl,
},
{
title: '支持集群生产与成果交付',
description: '主服务器负责资产、权限和审计,生产节点承担受控处理服务,结果回传后进入交付申请与下载流程。',
image: clusterDeliveryUrl,
},
];
const LoginPage = ({ onLoginSuccess }) => {
const [username, setUsername] = useState('');
@@ -7,6 +28,7 @@ const LoginPage = ({ onLoginSuccess }) => {
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [licenseStatus, setLicenseStatus] = useState(null);
const [activeSlide, setActiveSlide] = useState(0);
useEffect(() => {
const checkLicense = async () => {
@@ -20,6 +42,13 @@ const LoginPage = ({ onLoginSuccess }) => {
checkLicense();
}, []);
useEffect(() => {
const timer = window.setInterval(() => {
setActiveSlide((current) => (current + 1) % loginSlides.length);
}, 5200);
return () => window.clearInterval(timer);
}, []);
const handleSubmit = async (event) => {
event.preventDefault();
setLoading(true);
@@ -48,57 +77,89 @@ const LoginPage = ({ onLoginSuccess }) => {
return (
<div className="login-page-wrapper">
<div className="login-card">
<div className="login-card-header">
<div className="login-brand-tag">InSAR</div>
<h2>管理系统登录</h2>
<p className="login-subtitle">内网受控访问,请使用管理员分配的账号登录。</p>
</div>
{licenseStatus && !licenseStatus.ok && (
<div className="login-alert warn">
当前授权状态异常:{licenseStatus.reason || '未授权'}
<div className="login-shell">
<section className="login-card" aria-label="系统登录">
<div className="login-card-header">
<div className="login-brand-tag">InSAR Production</div>
<h2>雷达数据生产管理系统</h2>
<p className="login-subtitle">内网受控访问,请使用管理员分配的账号登录。</p>
</div>
)}
<form onSubmit={handleSubmit} className="login-form-grid">
<label className="login-field">
<span>用户名</span>
<input
type="text"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</label>
<label className="login-field">
<span>密码</span>
<input
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
<button type="submit" className="primary-btn" disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
</form>
{licenseStatus && !licenseStatus.ok && (
<div className="login-alert warn">
当前授权状态异常:{licenseStatus.reason || '未授权'}
</div>
)}
{message && (
<div className="login-alert error">
{message}
<form onSubmit={handleSubmit} className="login-form-grid">
<label className="login-field">
<span>用户名</span>
<input
type="text"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</label>
<label className="login-field">
<span>密码</span>
<input
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
<button type="submit" className="primary-btn login-submit-btn" disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
</form>
{message && (
<div className="login-alert error">
{message}
</div>
)}
<div className="login-tips">
<strong>提示:</strong>
<span>首次部署请确认已在 `.env` 配置管理员账号与密码。</span>
</div>
)}
</section>
<div className="login-tips">
<strong>提示:</strong>
<span>首次部署请确认已在 `.env` 配置管理员账号与密码。</span>
</div>
<section className="login-visual-panel" aria-label="系统能力介绍">
<div className="login-slide-copy">
<span>系统能力</span>
<h3>{loginSlides[activeSlide].title}</h3>
<p>{loginSlides[activeSlide].description}</p>
</div>
<div className="login-slide-frame">
{loginSlides.map((slide, index) => (
<img
key={slide.title}
src={slide.image}
alt={slide.title}
className={`login-slide-image ${index === activeSlide ? 'active' : ''}`}
aria-hidden={index !== activeSlide}
/>
))}
</div>
<div className="login-slide-dots" aria-label="切换系统介绍图">
{loginSlides.map((slide, index) => (
<button
key={slide.title}
type="button"
className={index === activeSlide ? 'active' : ''}
aria-label={`查看${slide.title}`}
onClick={() => setActiveSlide(index)}
/>
))}
</div>
</section>
</div>
</div>
);
+368
View File
@@ -0,0 +1,368 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
cleanupMaintenanceTask,
getMaintenanceTaskDiagnosis,
listMaintenanceTasks,
previewMaintenanceCleanup,
} from './api/opsMaintenance';
const TASK_TYPES = [
['', '全部类型'],
['LANDSAR_RUN', 'LandSAR D-InSAR'],
['LANDSAR_CLUSTER_RUN', 'LandSAR 集群'],
['PYINT_RUN', 'PyINT/Gamma'],
['IDL_RUN_DINSAR', 'SARscape D-InSAR'],
['COPY_DATA', '数据准备'],
['PAIRING_CACHE_REBUILD', '配对缓存'],
];
const STATUSES = [
['', '全部状态'],
['FAILED', '失败'],
['PARTIAL_SUCCESS', '部分成功'],
['CANCELLED', '已取消'],
['RUNNING', '运行中'],
['PENDING', '等待中'],
];
const formatDate = (value) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', { hour12: false });
};
const formatCount = (value) => Number(value || 0).toLocaleString('zh-CN');
const toneForStatus = (status) => {
const value = String(status || '').toUpperCase();
if (value === 'FAILED') return 'danger';
if (value === 'PARTIAL_SUCCESS' || value === 'CANCELLED') return 'warn';
if (value === 'COMPLETED') return 'ok';
if (value === 'RUNNING' || value === 'PENDING') return 'info';
return 'neutral';
};
const StatusBadge = ({ status }) => (
<span className={`ops-task-badge tone-${toneForStatus(status)}`}>{status || '-'}</span>
);
const CountLine = ({ counts = {} }) => (
<span>
成功 {formatCount(counts.completed_items)} / 失败 {formatCount(counts.failed_items)} / 等待 {formatCount(counts.pending_items)} / 运行 {formatCount(counts.running_items)}
</span>
);
const DatabasePreview = ({ counts = {} }) => {
const entries = Object.entries(counts).filter(([, value]) => Number(value || 0) > 0);
if (!entries.length) return <div className="ops-task-muted">没有将删除的数据库记录。</div>;
return (
<div className="ops-task-preview-grid">
{entries.map(([key, value]) => (
<div key={key} className="ops-task-preview-cell">
<span>{key}</span>
<strong>{formatCount(value)}</strong>
</div>
))}
</div>
);
};
const DiskPreview = ({ paths = [] }) => {
if (!paths.length) return <div className="ops-task-muted">没有将删除的磁盘路径。</div>;
return (
<div className="ops-task-path-list">
{paths.map(item => (
<div key={`${item.kind}-${item.path}`} className={`ops-task-path ${item.allowed ? '' : 'blocked'}`}>
<span>{item.kind || 'path'}</span>
<code>{item.path}</code>
<em>{item.exists ? '存在' : '不存在'} / {item.allowed ? '允许' : '禁止'}</em>
</div>
))}
</div>
);
};
const OpsTaskMaintenancePanel = ({ isAdmin }) => {
const [filters, setFilters] = useState({ task_type: '', status: '' });
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [selectedTaskId, setSelectedTaskId] = useState('');
const [diagnosis, setDiagnosis] = useState(null);
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [preview, setPreview] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [cleanupLoading, setCleanupLoading] = useState(false);
const [cleanupResult, setCleanupResult] = useState(null);
const [panelMessage, setPanelMessage] = useState(null);
const loadTasks = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await listMaintenanceTasks({
task_type: filters.task_type || undefined,
status: filters.status || undefined,
limit: 80,
});
setTasks(Array.isArray(data?.items) ? data.items : []);
} catch (err) {
setError(err.response?.data?.detail || err.message || '任务维护列表加载失败');
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => {
loadTasks();
}, [loadTasks]);
const selectedTask = useMemo(
() => tasks.find(item => item.task_id === selectedTaskId) || null,
[tasks, selectedTaskId],
);
const loadDiagnosis = useCallback(async (taskId) => {
setSelectedTaskId(taskId);
setDiagnosis(null);
setPreview(null);
setCleanupResult(null);
setPanelMessage(null);
setDiagnosisLoading(true);
try {
const data = await getMaintenanceTaskDiagnosis(taskId);
setDiagnosis(data);
} catch (err) {
setCleanupResult({ ok: false, message: err.response?.data?.detail || err.message || '诊断加载失败' });
} finally {
setDiagnosisLoading(false);
}
}, []);
const loadPreview = useCallback(async () => {
if (!selectedTaskId) return;
setPreviewLoading(true);
setCleanupResult(null);
try {
const data = await previewMaintenanceCleanup(selectedTaskId);
setPreview(data);
} catch (err) {
setCleanupResult({ ok: false, message: err.response?.data?.detail || err.message || '清理预览失败' });
} finally {
setPreviewLoading(false);
}
}, [selectedTaskId]);
const executeCleanup = useCallback(async () => {
if (!selectedTaskId || !preview || preview.blocked || !isAdmin) return;
setCleanupLoading(true);
setCleanupResult(null);
try {
const data = await cleanupMaintenanceTask(selectedTaskId, {
confirm: true,
delete_task_records: true,
delete_logs: true,
delete_production_records: true,
delete_result_products: true,
delete_production_dirs: true,
delete_task_pool_dir: true,
});
setCleanupResult({
ok: true,
message: `清理完成:数据库 ${Object.values(data.deleted_database || {}).reduce((sum, value) => sum + Number(value || 0), 0)} 条,目录 ${(data.deleted_disk?.deleted || []).length} 个。`,
});
setPanelMessage({
ok: true,
message: `清理完成:数据库 ${Object.values(data.deleted_database || {}).reduce((sum, value) => sum + Number(value || 0), 0)} 条,目录 ${(data.deleted_disk?.deleted || []).length} 个。`,
});
setPreview(null);
setDiagnosis(null);
setSelectedTaskId('');
await loadTasks();
} catch (err) {
setCleanupResult({ ok: false, message: err.response?.data?.detail || err.message || '清理失败' });
setPanelMessage({ ok: false, message: err.response?.data?.detail || err.message || '清理失败' });
} finally {
setCleanupLoading(false);
}
}, [isAdmin, loadTasks, preview, selectedTaskId]);
const abnormalCount = tasks.length;
const cleanableCount = tasks.filter(item => item.cleanup_supported).length;
const blockedCount = tasks.filter(item => !item.cleanup_supported).length;
return (
<div className="ops-task-panel">
<div className="ops-task-header">
<div>
<h4>任务维护</h4>
<p>查看失败、部分成功、取消和残留运行的任务;清理后请回到业务页面重新提交。</p>
</div>
<button className="health-action-button" onClick={loadTasks} disabled={loading}>
{loading ? '刷新中...' : '刷新任务'}
</button>
</div>
<div className="ops-task-summary">
<div><span>异常任务</span><strong>{formatCount(abnormalCount)}</strong></div>
<div><span>可清理</span><strong>{formatCount(cleanableCount)}</strong></div>
<div><span>需复核</span><strong>{formatCount(blockedCount)}</strong></div>
</div>
<div className="ops-task-filters">
<label>
<span>任务类型</span>
<select value={filters.task_type} onChange={event => setFilters(prev => ({ ...prev, task_type: event.target.value }))}>
{TASK_TYPES.map(([value, label]) => <option key={value || 'all'} value={value}>{label}</option>)}
</select>
</label>
<label>
<span>状态</span>
<select value={filters.status} onChange={event => setFilters(prev => ({ ...prev, status: event.target.value }))}>
{STATUSES.map(([value, label]) => <option key={value || 'all'} value={value}>{label}</option>)}
</select>
</label>
</div>
{error && <div className="health-card-note error">{error}</div>}
{panelMessage && (
<div className={`health-card-note ${panelMessage.ok ? 'ok' : 'error'}`}>
{panelMessage.message}
</div>
)}
<div className="ops-task-table-wrap">
<table className="ops-task-table">
<thead>
<tr>
<th>状态</th>
<th>任务</th>
<th>统计</th>
<th>问题摘要</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{tasks.map(task => (
<tr key={task.task_id} className={task.task_id === selectedTaskId ? 'selected' : ''}>
<td><StatusBadge status={task.status} /></td>
<td>
<div className="ops-task-name">{task.task_name || task.task_id}</div>
<div className="ops-task-muted">{task.task_type} / {task.task_id}</div>
</td>
<td><CountLine counts={task.counts} /></td>
<td>{task.issue_summary || '-'}</td>
<td>{formatDate(task.updated_at)}</td>
<td>
<button className="health-inline-button" onClick={() => loadDiagnosis(task.task_id)}>
诊断
</button>
</td>
</tr>
))}
{!loading && !tasks.length && (
<tr>
<td colSpan={6} className="ops-task-empty">当前没有匹配的异常任务。</td>
</tr>
)}
</tbody>
</table>
</div>
{selectedTaskId && (
<div className="ops-task-detail">
<div className="ops-task-detail-header">
<div>
<h4>{selectedTask?.task_name || selectedTaskId}</h4>
<p>{selectedTaskId}</p>
</div>
<button className="health-inline-button" onClick={loadPreview} disabled={previewLoading || diagnosisLoading}>
{previewLoading ? '生成中...' : '生成清理预览'}
</button>
</div>
{diagnosisLoading ? (
<div className="ops-task-muted">正在加载诊断...</div>
) : diagnosis ? (
<>
<div className="ops-task-diagnosis">
{(diagnosis.diagnosis?.findings || []).map(item => (
<div key={item} className="health-card-note warn">{item}</div>
))}
{!(diagnosis.diagnosis?.findings || []).length && (
<div className="health-card-note ok">未发现明显异常。</div>
)}
{(diagnosis.diagnosis?.cleanup_blockers || []).map(item => (
<div key={item} className="health-card-note error">{item}</div>
))}
</div>
<div className="ops-task-two-col">
<div>
<div className="ops-task-section-title">生产统计</div>
<DatabasePreview counts={{
...diagnosis.production_item_counts,
...Object.fromEntries(Object.entries(diagnosis.production_execution_counts || {}).map(([key, value]) => [`execution_${key}`, value])),
}} />
</div>
<div>
<div className="ops-task-section-title">最近日志</div>
<div className="ops-task-log-list">
{(diagnosis.recent_logs || []).slice(0, 8).map(log => (
<div key={log.id}>
<span>[{formatDate(log.timestamp)}] {log.level}</span>
<p>{log.message}</p>
</div>
))}
{!(diagnosis.recent_logs || []).length && <div className="ops-task-muted">无日志。</div>}
</div>
</div>
</div>
</>
) : null}
{preview && (
<div className="ops-task-preview">
<div className="ops-task-section-title">清理预览</div>
{preview.blocked && (
<div className="health-card-note error">
{preview.blockers?.join(';') || '当前任务不允许清理。'}
</div>
)}
<div className="ops-task-two-col">
<div>
<div className="ops-task-section-title">数据库记录</div>
<DatabasePreview counts={preview.database_deletes} />
</div>
<div>
<div className="ops-task-section-title">磁盘路径</div>
<DiskPreview paths={preview.disk_deletes} />
</div>
</div>
<div className="ops-task-actions">
{!isAdmin && <span className="ops-task-muted">仅管理员可执行清理。</span>}
<button
className="health-action-button danger"
onClick={executeCleanup}
disabled={!isAdmin || cleanupLoading || preview.blocked}
>
{cleanupLoading ? '清理中...' : '确认清理'}
</button>
</div>
</div>
)}
{cleanupResult && (
<div className={`health-card-note ${cleanupResult.ok ? 'ok' : 'error'}`}>
{cleanupResult.message}
</div>
)}
</div>
)}
</div>
);
};
export default OpsTaskMaintenancePanel;
+170 -60
View File
@@ -7,6 +7,7 @@ import {
getResultDeliveryArchiveUrl,
getResultDeliveryDownloadUrl,
getResultDeliveryManifestUrl,
listResultDeliveryCatalog,
listResultDeliveries,
} from './api/resultDeliveries';
import { getDinsarEngineMeta } from './utils/dinsarEngines';
@@ -34,9 +35,9 @@ const PRODUCT_CHANNELS = [
key: 'lt1_ortho',
group: '正射成果',
label: 'LT-1 正射结果',
state: 'placeholder',
stateText: '待接入',
description: '陆探一正射生产将由 LandSAR 生产链注册到统一成果目录后开放交付。',
state: 'ready',
stateText: '可交付',
description: '服务器生产的 LT-1 分析就绪正射 GeoTIFF 已接入交付,可打包下载到本地。',
},
{
key: 's1_ortho',
@@ -50,9 +51,9 @@ const PRODUCT_CHANNELS = [
key: 'gf3_ortho',
group: '正射成果',
label: 'GF3 SARscape _geo',
state: 'placeholder',
stateText: '待接入',
description: 'GF3 外部生产成果登记后再接入统一交付。',
state: 'ready',
stateText: '可交付',
description: '已登记的 GF3 SARscape 标准化正射成品可直接创建交付包。',
},
];
@@ -85,17 +86,89 @@ function extractTotal(payload, fallback = 0) {
}
function resultDisplayName(result) {
return String(result?.name || result?.task_alias || result?.task_name || result?.product_id || `#${result?.id || ''}`).trim();
return String(result?.display_name || result?.name || result?.task_alias || result?.task_name || result?.product_id || `#${result?.id || ''}`).trim();
}
function resultItemId(result) {
return result?.item_id ?? result?.id;
}
function selectionKey(channelKey, id) {
return `${channelKey}:${id}`;
}
function resultDateText(result) {
const name = resultDisplayName(result);
if (result?.imaging_date) return String(result.imaging_date);
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 channelMetricLabel(channelKey) {
return {
dinsar: 'D-InSAR 可交付',
sbas: 'SBAS 目录',
lt1_ortho: 'LT-1 正射',
gf3_ortho: 'GF3 正射',
s1_ortho: 'Sentinel-1 正射',
}[channelKey] || '可交付结果';
}
function channelListTitle(channelKey) {
return {
dinsar: 'D-InSAR 结果列表',
lt1_ortho: 'LT-1 正射结果列表',
gf3_ortho: 'GF3 正射结果列表',
}[channelKey] || '结果列表';
}
function channelEmptyText(channelKey) {
return {
dinsar: '当前条件下没有可交付的 D-InSAR 结果。',
lt1_ortho: '当前条件下没有可交付的 LT-1 正射结果。',
gf3_ortho: '当前条件下没有可交付的 GF3 正射结果。',
}[channelKey] || '当前条件下没有可交付结果。';
}
function channelCreateTitle(channelKey) {
return {
dinsar: 'D-InSAR 成果交付',
lt1_ortho: 'LT-1 正射成果交付',
gf3_ortho: 'GF3 正射成果交付',
}[channelKey] || '成果交付';
}
function channelCreateSubtitle(channelKey) {
return {
dinsar: '选择已登记结果并生成下载包',
lt1_ortho: '选择服务器已生产正射 GeoTIFF 并生成下载包',
gf3_ortho: '选择已登记 GF3 SARscape 正射成品并生成下载包',
}[channelKey] || '选择结果并生成下载包';
}
function itemMetaText(item, channelKey) {
if (channelKey === 'dinsar') {
return `${resultDateText(item)} · ${item.pair_key || item.product_id || '-'}`;
}
const parts = [
item.imaging_date || resultDateText(item),
item.polarization,
item.pixel_size_m ? `${item.pixel_size_m} m` : null,
item.product_id,
].filter(Boolean);
return parts.join(' · ') || '-';
}
function itemStatusText(item, channelKey) {
if (channelKey === 'dinsar') {
return item.is_cached ? '预览就绪' : '预览待建';
}
if (item.file_size) return formatBytes(item.file_size);
return item.health_status || item.status || 'READY';
}
function stateClass(state) {
if (state === 'ready') return 'ready';
if (state === 'planned') return 'planned';
@@ -125,6 +198,8 @@ 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 [lt1Payload, setLt1Payload] = useState({ items: [], total: 0 });
const [gf3Payload, setGf3Payload] = useState({ items: [], total: 0 });
const [deliveriesPayload, setDeliveriesPayload] = useState({ items: [], total: 0 });
const [loading, setLoading] = useState(true);
const [deliveryLoading, setDeliveryLoading] = useState(false);
@@ -161,13 +236,21 @@ export default function ResultExtractionPanel({ readOnly = false }) {
listSbasInsarProducts({ limit: 30, offset: 0 }),
listResultDeliveries({ mine: true, limit: 20, offset: 0 }),
]);
const [lt1Data, gf3Data] = await Promise.all([
listResultDeliveryCatalog('lt1_ortho', { limit: PAGE_SIZE, offset: 0 }),
listResultDeliveryCatalog('gf3_ortho', { limit: PAGE_SIZE, 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) });
const lt1Items = normalizeItems(lt1Data);
setLt1Payload({ ...lt1Data, items: lt1Items, total: extractTotal(lt1Data, lt1Items.length) });
const gf3Items = normalizeItems(gf3Data);
setGf3Payload({ ...gf3Data, items: gf3Items, total: extractTotal(gf3Data, gf3Items.length) });
const deliveryItems = normalizeItems(deliveryData);
setDeliveriesPayload({ ...deliveryData, items: deliveryItems, total: extractTotal(deliveryData, deliveryItems.length) });
setSelectedIds(new Set(dinsarItems.map(item => item.id).filter(id => id !== undefined && id !== null)));
setSelectedIds(new Set(dinsarItems.map(item => resultItemId(item)).filter(id => id !== undefined && id !== null).map(id => selectionKey('dinsar', id))));
} catch (err) {
setError(err?.response?.data?.detail || err.message || '结果目录加载失败');
} finally {
@@ -191,41 +274,63 @@ export default function ResultExtractionPanel({ readOnly = false }) {
return () => window.clearInterval(timer);
}, [deliveriesPayload.items]);
const filteredDinsar = useMemo(() => {
const activePayload = activeChannel === 'lt1_ortho'
? lt1Payload
: activeChannel === 'gf3_ortho'
? gf3Payload
: activeChannel === 'sbas'
? sbasPayload
: activeChannel === 's1_ortho'
? { items: [], total: 0 }
: dinsarPayload;
const activeItems = activePayload.items || [];
const filteredResults = useMemo(() => {
const value = query.trim().toLowerCase();
const items = dinsarPayload.items || [];
const items = activeItems;
if (!value) return items;
return items.filter(item => {
const haystack = [
item.display_name,
item.name,
item.task_name,
item.task_alias,
item.pair_key,
item.product_id,
item.engine_code,
item.profile_code,
item.imaging_date,
item.polarization,
item.file_path,
item.primary_asset_path,
item.publish_dir,
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(value);
});
}, [dinsarPayload.items, query]);
}, [activeItems, query]);
const filteredIds = useMemo(
() => filteredDinsar.map(item => item.id).filter(id => id !== undefined && id !== null),
[filteredDinsar],
() => filteredResults.map(item => resultItemId(item)).filter(id => id !== undefined && id !== null),
[filteredResults],
);
const selectedCountInView = filteredIds.filter(id => selectedIds.has(id)).length;
const filteredKeys = useMemo(
() => filteredIds.map(id => selectionKey(activeChannel, id)),
[activeChannel, filteredIds],
);
const selectedCountInView = filteredKeys.filter(key => selectedIds.has(key)).length;
const allVisibleSelected = filteredIds.length > 0 && selectedCountInView === filteredIds.length;
const latestDelivery = deliveriesPayload.items?.[0] || null;
const orthoPlaceholderCount = PRODUCT_CHANNELS.filter(channel => channel.group === '正射成果').length;
const currentCatalogTotal = Number(dinsarPayload.total || 0) + Number(sbasPayload.total || 0);
const readyOrthoCount = Number(lt1Payload.total || 0) + Number(gf3Payload.total || 0);
const currentCatalogTotal = Number(dinsarPayload.total || 0) + Number(sbasPayload.total || 0) + readyOrthoCount;
const metrics = [
{
label: 'D-InSAR 可交付',
value: dinsarPayload.total,
note: `当前载入 ${filteredDinsar.length}/${dinsarPayload.items.length} 条`,
label: channelMetricLabel(activeChannel),
value: activePayload.total,
note: `当前载入 ${filteredResults.length}/${activeItems.length} 条`,
tone: 'primary',
},
{
@@ -237,24 +342,25 @@ export default function ResultExtractionPanel({ readOnly = false }) {
{
label: '当前接入目录',
value: currentCatalogTotal,
note: 'D-InSAR + SBAS 已接入清单',
note: 'D-InSAR + SBAS + LT-1/GF3 正射',
tone: 'neutral',
},
{
label: '正射通道',
value: orthoPlaceholderCount,
note: 'LT-1 / S1 / GF3 占位',
tone: 'warning',
label: '可交付正射',
value: readyOrthoCount,
note: 'LT-1 与 GF3 已接入,S1 预留',
tone: 'neutral',
},
];
const toggleOne = (id) => {
const key = selectionKey(activeChannel, id);
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
if (next.has(key)) {
next.delete(key);
} else {
next.add(id);
next.add(key);
}
return next;
});
@@ -264,30 +370,35 @@ export default function ResultExtractionPanel({ readOnly = false }) {
setSelectedIds(prev => {
const next = new Set(prev);
if (allVisibleSelected) {
filteredIds.forEach(id => next.delete(id));
filteredKeys.forEach(key => next.delete(key));
} else {
filteredIds.forEach(id => next.add(id));
filteredKeys.forEach(key => next.add(key));
}
return next;
});
};
const handleCreateDelivery = async () => {
const ids = [...selectedIds].filter(id => filteredIds.includes(id));
const ids = filteredIds.filter(id => selectedIds.has(selectionKey(activeChannel, id)));
if (ids.length === 0) {
setCreateError('请至少选择一条 D-InSAR 结果。');
setCreateError(`请至少选择一条${selectedChannel.label}。`);
return;
}
setCreating(true);
setCreateError('');
setCreateResult(null);
try {
const response = await createResultDelivery({
channel: 'dinsar',
compat_result_ids: ids,
const payload = {
channel: activeChannel,
package_mode: packageMode,
include_checksums: includeChecksums,
});
};
if (activeChannel === 'dinsar') {
payload.compat_result_ids = ids;
} else {
payload.item_ids = ids;
}
const response = await createResultDelivery(payload);
setCreateResult(response);
await loadDeliveries();
} catch (err) {
@@ -353,12 +464,12 @@ export default function ResultExtractionPanel({ readOnly = false }) {
</section>
);
const renderDinsarWorkspace = () => (
const renderReadyWorkspace = () => (
<section className="result-extraction-main-card">
<div className="result-extraction-card-head">
<div>
<span>D-InSAR 成果交付</span>
<strong>选择已登记结果并生成下载包</strong>
<span>{channelCreateTitle(activeChannel)}</span>
<strong>{channelCreateSubtitle(activeChannel)}</strong>
</div>
<button type="button" onClick={loadCatalogs} disabled={loading || creating}>
刷新目录
@@ -372,7 +483,7 @@ export default function ResultExtractionPanel({ readOnly = false }) {
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="任务名、日期、pair_key、引擎"
placeholder="任务名、日期、产品号、极化、引擎"
disabled={loading || creating}
/>
</label>
@@ -424,36 +535,34 @@ export default function ResultExtractionPanel({ readOnly = false }) {
<div className="result-extraction-workspace-split">
<div className="result-extraction-result-column">
<div className="result-extraction-list-head">
<span>结果列表</span>
<strong>{selectedCountInView}/{filteredDinsar.length}</strong>
<span>{channelListTitle(activeChannel)}</span>
<strong>{selectedCountInView}/{filteredResults.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>
) : filteredResults.length === 0 ? (
<div className="result-extraction-empty">{channelEmptyText(activeChannel)}</div>
) : (
filteredDinsar.map(result => {
const id = result.id;
const engineMeta = getDinsarEngineMeta(result.engine_code);
filteredResults.map(result => {
const id = resultItemId(result);
const engineMeta = activeChannel === 'dinsar'
? getDinsarEngineMeta(result.engine_code)
: { tone: 'landsar', shortLabel: result.engine_code || result.profile_code || 'ORTHO' };
return (
<label key={id} className="result-extraction-result-row">
<label key={`${activeChannel}-${id}`} className="result-extraction-result-row">
<input
type="checkbox"
checked={selectedIds.has(id)}
checked={selectedIds.has(selectionKey(activeChannel, id))}
onChange={() => toggleOne(id)}
disabled={creating}
/>
<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>
<strong title={result.file_path || result.primary_asset_path || resultDisplayName(result)}>{resultDisplayName(result)}</strong>
<span>{itemMetaText(result, activeChannel)}</span>
</span>
<span className={`dinsar-engine-badge tone-${engineMeta.tone}`}>{engineMeta.shortLabel}</span>
<span className="result-extraction-status-chip">{result.is_cached ? '预览就绪' : '预览待建'}</span>
<span className="result-extraction-status-chip">{itemStatusText(result, activeChannel)}</span>
</label>
);
})
@@ -480,7 +589,7 @@ export default function ResultExtractionPanel({ readOnly = false }) {
</div>
<div>
<span>交付接口</span>
<strong>{selectedChannel.key === 'dinsar' ? '已接入' : '待实现'}</strong>
<strong>{selectedChannel.state === 'ready' ? '已接入' : '待实现'}</strong>
</div>
<div>
<span>用户权限</span>
@@ -513,13 +622,14 @@ export default function ResultExtractionPanel({ readOnly = false }) {
<span>成果交付出口</span>
<strong>结果提取工作台</strong>
<p>
将 D-InSAR 成果交付下载和后续正射成果交付统一管理。当前 D-InSAR 支持后台交付包,
SBAS、LT-1 正射、Sentinel-1 正射和 GF3 正射先保留清晰占位。
将 D-InSAR、LT-1 正射和 GF3 正射成果交付下载统一管理。后台生成受控交付包,用户完成后下载到本地;
SBAS 和 Sentinel-1 正射保留清晰接入状态。
</p>
</div>
<div className="result-extraction-hero-meta">
<span>D-InSAR {formatNumber(dinsarPayload.total)}</span>
<span>SBAS {formatNumber(sbasPayload.total)}</span>
<span>LT-1 {formatNumber(lt1Payload.total)}</span>
<span>GF3 {formatNumber(gf3Payload.total)}</span>
<span>{readOnly ? '自助交付' : '管理员'}</span>
</div>
</section>
@@ -549,7 +659,7 @@ export default function ResultExtractionPanel({ readOnly = false }) {
</button>
))}
</aside>
{activeChannel === 'dinsar' ? renderDinsarWorkspace() : renderPlaceholderWorkspace()}
{selectedChannel.state === 'ready' ? renderReadyWorkspace() : renderPlaceholderWorkspace()}
</section>
</div>
);
+13
View File
@@ -0,0 +1,13 @@
import apiClient from './client';
export const listMaintenanceTasks = (params = {}) =>
apiClient.get('/ops-maintenance/tasks', { params }).then(r => r.data);
export const getMaintenanceTaskDiagnosis = (taskId) =>
apiClient.get(`/ops-maintenance/tasks/${encodeURIComponent(taskId)}/diagnosis`).then(r => r.data);
export const previewMaintenanceCleanup = (taskId) =>
apiClient.post(`/ops-maintenance/tasks/${encodeURIComponent(taskId)}/cleanup-preview`).then(r => r.data);
export const cleanupMaintenanceTask = (taskId, payload) =>
apiClient.post(`/ops-maintenance/tasks/${encodeURIComponent(taskId)}/cleanup`, payload).then(r => r.data);
+3
View File
@@ -9,6 +9,9 @@ export const createResultDelivery = payload =>
export const listResultDeliveries = (params = {}) =>
apiClient.get('/result-deliveries', { params }).then(r => r.data);
export const listResultDeliveryCatalog = (channel, params = {}) =>
apiClient.get(`/result-deliveries/catalog/${encodeURIComponent(channel)}`, { params }).then(r => r.data);
export const getResultDelivery = deliveryId =>
apiClient.get(`/result-deliveries/${encodeURIComponent(deliveryId)}`).then(r => r.data);
Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

+30 -1
View File
@@ -67,6 +67,34 @@ function engineStatusTone(status) {
return 'neutral';
}
function formatEngineResultStatus(status) {
const normalized = String(status || 'missing').toLowerCase();
if (normalized === 'ready') return '已生产';
if (normalized === 'failed') return '生产失败';
if (normalized === 'missing') return '未生产';
if (normalized === 'blocked') return '不适用';
if (normalized === 'running') return '生产中';
if (normalized === 'legacy') return '历史结果';
return status || '未生产';
}
function buildEngineResultTitle(result = {}) {
const parts = [formatEngineResultStatus(result.status)];
if (result.skip_reason === 'production_failed') {
parts.push('该引擎已有失败生产记录,不是单纯缺失结果。');
}
if (result.production_run_id) {
parts.push(`run=${result.production_run_id}`);
}
if (result.production_error) {
parts.push(String(result.production_error).replace(/\s+/g, ' ').slice(0, 220));
}
if (result.latest_log_path) {
parts.push(`log=${result.latest_log_path}`);
}
return parts.filter(Boolean).join('\n');
}
function MetaField({ label, value, multiline = false }) {
const displayValue = value === null || value === undefined || value === '' ? '-' : value;
return (
@@ -373,8 +401,9 @@ export default function DinsarCatalogPanel({
<span
key={engineCode}
className={`dinsar-engine-result-chip tone-${engineStatusTone(result.status)}`}
title={buildEngineResultTitle(result)}
>
{engineMeta.shortLabel}: {result.status || 'missing'}
{engineMeta.shortLabel}: {formatEngineResultStatus(result.status)}
</span>
);
})}
@@ -25,6 +25,22 @@ const toneColor = {
text: '#b91c1c',
fill: '#dc2626',
},
partial: {
border: '#fcd34d',
bg: '#fffbeb',
text: '#92400e',
fill: '#d97706',
},
};
const TASK_STATUS_LABEL = {
PENDING: '等待中',
RUNNING: '运行中',
COMPLETED: '已完成',
PARTIAL_SUCCESS: '部分成功',
FAILED: '失败',
CANCELLED: '已取消',
CANCELED: '已取消',
};
const normalizeProgress = (value) => {
@@ -34,6 +50,11 @@ const normalizeProgress = (value) => {
};
const isFailed = (task) => String(task?.status || '').toUpperCase() === 'FAILED';
const isPartialSuccess = (task) => String(task?.status || '').toUpperCase() === 'PARTIAL_SUCCESS';
const formatTaskStatus = (status) => {
const normalized = String(status || '-').toUpperCase();
return TASK_STATUS_LABEL[normalized] || normalized;
};
export default function TaskStatusPanel({
title = '任务状态',
@@ -48,7 +69,9 @@ export default function TaskStatusPanel({
}) {
const task = latestTask || activeTasks[0] || recentTasks[0] || null;
const showingRecent = !isBusy && !!task;
const tone = task ? (isFailed(task) ? 'error' : (showingRecent ? 'recent' : 'active')) : 'idle';
const tone = task
? (isFailed(task) ? 'error' : (isPartialSuccess(task) ? 'partial' : (showingRecent ? 'recent' : 'active')))
: 'idle';
const colors = toneColor[tone];
const progress = normalizeProgress(task?.progress);
@@ -82,7 +105,7 @@ export default function TaskStatusPanel({
<div style={{ marginTop: 8, color: colors.text, fontSize: 12, wordBreak: 'break-all' }}>
<span style={{ fontWeight: 700 }}>{getTaskTypeLabel(task.task_type)}</span>
<span style={{ margin: '0 6px' }}>·</span>
<span>{String(task.status || '-').toUpperCase()}</span>
<span>{formatTaskStatus(task.status)}</span>
{task.task_id && (
<>
<span style={{ margin: '0 6px' }}>·</span>
+3 -2
View File
@@ -3,6 +3,7 @@ import apiClient from '../api/client';
import { normalizeTaskStatus } from '../utils/appUiHelpers';
const ACTIVE_TASK_FALLBACK_POLL_MS = 10000;
const TERMINAL_TASK_STATUSES = new Set(['COMPLETED', 'PARTIAL_SUCCESS', 'FAILED', 'CANCELLED', 'CANCELED']);
export default function useGlobalTaskControl({
currentUser,
@@ -56,7 +57,7 @@ export default function useGlobalTaskControl({
const taskStatus = normalizeTaskStatus(taskInfo?.status);
// 检查任务是否真正完成:解析 message 中的进度信息
let isReallyFinished = taskStatus === 'COMPLETED' || taskStatus === 'FAILED';
let isReallyFinished = TERMINAL_TASK_STATUSES.has(taskStatus);
// 如果任务状态是 PENDING,检查进度信息
if (taskStatus === 'PENDING' && taskInfo?.message) {
@@ -77,7 +78,7 @@ export default function useGlobalTaskControl({
if (isReallyFinished) {
reallyFinishedIds.push(taskId);
if (taskStatus === 'COMPLETED' || taskStatus === 'FAILED') {
if (TERMINAL_TASK_STATUSES.has(taskStatus)) {
handleTaskCompletionRef.current?.(taskInfo);
}
}