Compare commits
10
Commits
04210b0551
...
9c5cbc4637
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c5cbc4637 | ||
|
|
8ea3084b14 | ||
|
|
23958dbafb | ||
|
|
ce052b909e | ||
|
|
9213ac6eeb | ||
|
|
38f4629c5f | ||
|
|
157d8133f9 | ||
|
|
05f0c62d15 | ||
|
|
d19c9d4d3e | ||
|
|
ab2c9740b8 |
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,20 +2243,26 @@ 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"
|
||||
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 finished with failures. completed={run.completed_items} "
|
||||
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:
|
||||
@@ -2259,22 +2304,22 @@ 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,
|
||||
)
|
||||
if final_status == RUN_STATUS_FAILED:
|
||||
raise RuntimeError(latest_message)
|
||||
|
||||
|
||||
@@ -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"
|
||||
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 finished with failures. completed={run.completed_items} "
|
||||
f"{engine_title} D-InSAR production partially completed. completed={run.completed_items} "
|
||||
f"failed={run.failed_items} total={run.total_items}"
|
||||
)
|
||||
else:
|
||||
final_status = "COMPLETED"
|
||||
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 = 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,22 +3297,22 @@ 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,
|
||||
)
|
||||
if final_status == RUN_STATUS_FAILED:
|
||||
raise RuntimeError(latest_message)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
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,
|
||||
)
|
||||
else:
|
||||
sources = []
|
||||
|
||||
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(
|
||||
max_items = int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)
|
||||
sources = await self._resolve_sources_for_channel(
|
||||
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)),
|
||||
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),
|
||||
)
|
||||
else:
|
||||
sources = []
|
||||
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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,10 +77,11 @@ const LoginPage = ({ onLoginSuccess }) => {
|
||||
|
||||
return (
|
||||
<div className="login-page-wrapper">
|
||||
<div className="login-card">
|
||||
<div className="login-shell">
|
||||
<section className="login-card" aria-label="系统登录">
|
||||
<div className="login-card-header">
|
||||
<div className="login-brand-tag">InSAR</div>
|
||||
<h2>管理系统登录</h2>
|
||||
<div className="login-brand-tag">InSAR Production</div>
|
||||
<h2>雷达数据生产管理系统</h2>
|
||||
<p className="login-subtitle">内网受控访问,请使用管理员分配的账号登录。</p>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +114,7 @@ const LoginPage = ({ onLoginSuccess }) => {
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="primary-btn" disabled={loading}>
|
||||
<button type="submit" className="primary-btn login-submit-btn" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
@@ -99,6 +129,37 @@ const LoginPage = ({ onLoginSuccess }) => {
|
||||
<strong>提示:</strong>
|
||||
<span>首次部署请确认已在 `.env` 配置管理员账号与密码。</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -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 |
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user