Checkpoint production workflow updates
This commit is contained in:
@@ -14,6 +14,7 @@ from . import (
|
||||
hazard,
|
||||
health,
|
||||
idl,
|
||||
landsar_lt1_production,
|
||||
license,
|
||||
logs,
|
||||
monitor,
|
||||
@@ -55,6 +56,7 @@ def include_all_routers(router: APIRouter) -> None:
|
||||
router.include_router(dinsar.router)
|
||||
router.include_router(dinsar_products.router)
|
||||
router.include_router(dinsar_production.router)
|
||||
router.include_router(landsar_lt1_production.router)
|
||||
router.include_router(sbas_insar_production.router)
|
||||
router.include_router(sbas_insar_products.router)
|
||||
router.include_router(timeseries_production.router)
|
||||
|
||||
+23
-79
@@ -16,7 +16,6 @@ from .dependencies import _require_admin, _get_current_user, _validate_export_pa
|
||||
from ..models import AuthUserORM
|
||||
from ..services import envi_service
|
||||
from ..services.job_queue_service import job_queue_service
|
||||
from ..services.result_catalog_service import result_catalog_service
|
||||
from ..services.task_service import task_service
|
||||
|
||||
router = APIRouter()
|
||||
@@ -61,38 +60,6 @@ class SarscapeSbasInspectRequest(BaseModel):
|
||||
timeout_seconds: Optional[int] = Field(default=120, ge=10, le=600)
|
||||
|
||||
|
||||
def _normalize_existing_dir(path: Optional[str]) -> Optional[str]:
|
||||
text = str(path or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
normalized = os.path.normpath(os.path.abspath(text))
|
||||
if not os.path.isdir(normalized):
|
||||
return None
|
||||
return normalized
|
||||
|
||||
|
||||
def _dedupe_publish_roots(*paths: Optional[str]) -> list[str]:
|
||||
ordered: list[str] = []
|
||||
for raw_path in paths:
|
||||
normalized = _normalize_existing_dir(raw_path)
|
||||
if not normalized:
|
||||
continue
|
||||
|
||||
if any(
|
||||
normalized == existing or normalized.startswith(existing + os.sep)
|
||||
for existing in ordered
|
||||
):
|
||||
continue
|
||||
|
||||
ordered = [
|
||||
existing
|
||||
for existing in ordered
|
||||
if not existing.startswith(normalized + os.sep)
|
||||
]
|
||||
ordered.append(normalized)
|
||||
return ordered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job queue helper
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -292,7 +259,7 @@ async def get_task_overview_endpoint(
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/idl/extract-disp")
|
||||
@router.post("/idl/extract-disp", status_code=202)
|
||||
async def extract_disp_endpoint(
|
||||
request: ExtractDispRequest,
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
@@ -303,52 +270,29 @@ async def extract_disp_endpoint(
|
||||
if request.dest_dir:
|
||||
_validate_export_path(request.dest_dir, "dest_dir")
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
envi_service.extract_disp_results, request.root_dir, request.dest_dir
|
||||
payload = {
|
||||
"root_dir": request.root_dir,
|
||||
"dest_dir": request.dest_dir,
|
||||
}
|
||||
task_id = await task_service.create_task(
|
||||
"EXTRACT_DINSAR_PRODUCTS",
|
||||
"D-InSAR 结果提取与登记",
|
||||
params=payload,
|
||||
db=db,
|
||||
)
|
||||
job_id = await job_queue_service.create_job(
|
||||
"EXTRACT_DINSAR_PRODUCTS",
|
||||
payload=payload,
|
||||
task_id=task_id,
|
||||
db=db,
|
||||
)
|
||||
await db.commit()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
publish_roots = _dedupe_publish_roots(result.get("target_dir"))
|
||||
catalog_status: Dict[str, Any] = {
|
||||
"attempted": False,
|
||||
"status": "skipped",
|
||||
"source_directories": publish_roots,
|
||||
"message": "catalog publish skipped",
|
||||
return {
|
||||
"queued": True,
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"message": "D-InSAR 结果提取与登记任务已入队",
|
||||
}
|
||||
if publish_roots:
|
||||
try:
|
||||
catalog_status["attempted"] = True
|
||||
publish_result = await result_catalog_service.publish_from_sources(
|
||||
db,
|
||||
publish_roots,
|
||||
)
|
||||
rebuild_result = None
|
||||
if int(publish_result.get("processed", 0) or 0) > 0:
|
||||
rebuild_result = await result_catalog_service.rebuild_catalog(
|
||||
db,
|
||||
full_rebuild=True,
|
||||
)
|
||||
catalog_status = {
|
||||
"attempted": True,
|
||||
"status": "ok",
|
||||
"source_directories": publish_roots,
|
||||
"publish": publish_result,
|
||||
"rebuild": rebuild_result,
|
||||
"message": (
|
||||
"catalog published and rebuilt"
|
||||
if rebuild_result is not None
|
||||
else "catalog publish finished with no rebuild needed"
|
||||
),
|
||||
}
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
catalog_status = {
|
||||
"attempted": True,
|
||||
"status": "error",
|
||||
"source_directories": publish_roots,
|
||||
"message": str(exc),
|
||||
}
|
||||
|
||||
result["catalog"] = catalog_status
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import AuthUserORM, RadarDataORM, SARSceneGeoORM
|
||||
from ..services.job_handlers import JOB_TYPE_SAR_SCENE_PREPROCESS
|
||||
from ..services.job_queue_service import job_queue_service
|
||||
from ..services.landsar_lt1_production_service import landsar_lt1_production_service
|
||||
from ..services.task_service import task_service
|
||||
from ..utils import normalize_satellite_family
|
||||
from .dependencies import _add_operation_audit_log, _get_current_user, _require_admin
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
STATIC_ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
|
||||
|
||||
|
||||
class LandsarLt1ImageProductionRequest(BaseModel):
|
||||
source_asset_ids: List[int] = Field(default_factory=list)
|
||||
radar_data_ids: List[int] = Field(default_factory=list)
|
||||
mode: str = "scene"
|
||||
task_name: Optional[str] = None
|
||||
|
||||
@field_validator("mode")
|
||||
@classmethod
|
||||
def _validate_mode(cls, value):
|
||||
mode = str(value or "scene").strip().lower()
|
||||
if mode == "stack":
|
||||
mode = "batch"
|
||||
if mode not in {"scene", "batch"}:
|
||||
raise ValueError("mode must be scene or batch")
|
||||
return mode
|
||||
|
||||
|
||||
def _dedupe_positive_ids(values: List[int]) -> List[int]:
|
||||
result: List[int] = []
|
||||
for value in values or []:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if parsed > 0 and parsed not in result:
|
||||
result.append(parsed)
|
||||
return result
|
||||
|
||||
|
||||
def _scene_product_marker(scene: SARSceneGeoORM) -> Dict[str, Any]:
|
||||
return {
|
||||
"scene_id": scene.id,
|
||||
"radar_data_id": scene.radar_data_id,
|
||||
"product_id": f"sar_scene_geo:{scene.id}",
|
||||
"product_family": "lt1_analysis_ready_geotiff",
|
||||
"engine_code": scene.analysis_engine,
|
||||
"profile_code": scene.analysis_profile,
|
||||
"analysis_tif_path": scene.analysis_tif_path,
|
||||
"analysis_dir": scene.analysis_dir,
|
||||
"analysis_preview_path": scene.analysis_preview_path,
|
||||
"status": scene.status,
|
||||
"published_at": scene.updated_at.isoformat() if scene.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _scene_asset_items(scene: SARSceneGeoORM) -> List[Dict[str, Any]]:
|
||||
candidates = [
|
||||
(1, "analysis_tif", "analysis_ready.tif", scene.analysis_tif_path, "image/tiff", True),
|
||||
(2, "preview", "preview.png", scene.analysis_preview_path, "image/png", False),
|
||||
]
|
||||
metadata = scene.analysis_metadata_json if isinstance(scene.analysis_metadata_json, dict) else {}
|
||||
manifest_path = str(metadata.get("manifest_path") or "").strip()
|
||||
if manifest_path:
|
||||
candidates.append((3, "manifest", "manifest.json", manifest_path, "application/json", False))
|
||||
if scene.analysis_dir:
|
||||
quality_path = os.path.join(scene.analysis_dir, "quality.json")
|
||||
candidates.append((4, "quality", "quality.json", quality_path, "application/json", False))
|
||||
assets: List[Dict[str, Any]] = []
|
||||
for asset_id, role, name, path, media_type, primary in candidates:
|
||||
if not path:
|
||||
continue
|
||||
assets.append(
|
||||
{
|
||||
"id": asset_id,
|
||||
"role": role,
|
||||
"name": name,
|
||||
"relative_path": os.path.basename(path),
|
||||
"absolute_path": path,
|
||||
"format": os.path.splitext(path)[1].lower().lstrip(".") or None,
|
||||
"media_type": media_type,
|
||||
"is_required": primary,
|
||||
"is_primary": primary,
|
||||
"exists": os.path.isfile(path),
|
||||
"file_size": os.path.getsize(path) if os.path.isfile(path) else None,
|
||||
}
|
||||
)
|
||||
return assets
|
||||
|
||||
|
||||
async def _resolve_lt1_radars_for_request(
|
||||
db: AsyncSession,
|
||||
request: LandsarLt1ImageProductionRequest,
|
||||
) -> List[RadarDataORM]:
|
||||
source_asset_ids = _dedupe_positive_ids(request.source_asset_ids)
|
||||
radar_data_ids = _dedupe_positive_ids(request.radar_data_ids)
|
||||
filters = []
|
||||
if radar_data_ids:
|
||||
filters.append(RadarDataORM.id.in_(radar_data_ids))
|
||||
if source_asset_ids:
|
||||
filters.append(RadarDataORM.source_product_ref_id.in_(source_asset_ids))
|
||||
if not filters:
|
||||
return []
|
||||
result = await db.execute(select(RadarDataORM).where(*([filters[0]] if len(filters) == 1 else [filters[0] | filters[1]])))
|
||||
radars = list(result.scalars().all())
|
||||
unique: Dict[int, RadarDataORM] = {}
|
||||
for radar in radars:
|
||||
if not radar.id:
|
||||
continue
|
||||
family = normalize_satellite_family(radar.satellite_family or radar.satellite)
|
||||
if str(family or "").upper() != "LT1":
|
||||
continue
|
||||
unique[int(radar.id)] = radar
|
||||
return [unique[key] for key in sorted(unique.keys())]
|
||||
|
||||
|
||||
async def _produced_radars_for_request(
|
||||
db: AsyncSession,
|
||||
request: LandsarLt1ImageProductionRequest,
|
||||
) -> Dict[int, dict]:
|
||||
radars = await _resolve_lt1_radars_for_request(db, request)
|
||||
radar_ids = [int(item.id) for item in radars if item.id]
|
||||
if not radar_ids:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM).where(
|
||||
SARSceneGeoORM.radar_data_id.in_(radar_ids),
|
||||
SARSceneGeoORM.status == "DONE",
|
||||
SARSceneGeoORM.analysis_tif_path.isnot(None),
|
||||
SARSceneGeoORM.analysis_engine == "lt_gamma",
|
||||
SARSceneGeoORM.analysis_profile == "lt1_gamma_geocoded_mli",
|
||||
)
|
||||
)
|
||||
return {int(scene.radar_data_id): _scene_product_marker(scene) for scene in result.scalars().all()}
|
||||
|
||||
|
||||
async def _active_radars_for_request(
|
||||
db: AsyncSession,
|
||||
request: LandsarLt1ImageProductionRequest,
|
||||
) -> Dict[int, dict]:
|
||||
radars = await _resolve_lt1_radars_for_request(db, request)
|
||||
radar_ids = [int(item.id) for item in radars if item.id]
|
||||
if not radar_ids:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM).where(
|
||||
SARSceneGeoORM.radar_data_id.in_(radar_ids),
|
||||
SARSceneGeoORM.status.in_(["PENDING", "RUNNING"]),
|
||||
)
|
||||
)
|
||||
return {int(scene.radar_data_id): _scene_product_marker(scene) for scene in result.scalars().all()}
|
||||
|
||||
|
||||
def _already_produced_blocker(produced: Dict[int, dict]) -> str:
|
||||
first_id = sorted(produced.keys())[0]
|
||||
marker = produced[first_id] or {}
|
||||
product_id = marker.get("product_id") or "unknown"
|
||||
return f"Radar data {first_id} already has an analysis-ready GeoTIFF: {product_id}"
|
||||
|
||||
|
||||
def _active_blocker(active: Dict[int, dict]) -> str:
|
||||
first_id = sorted(active.keys())[0]
|
||||
marker = active[first_id] or {}
|
||||
return f"Radar data {first_id} already has an active GeoTIFF production task (scene_id={marker.get('scene_id')})."
|
||||
|
||||
|
||||
@router.get("/landsar-lt1-production/capabilities")
|
||||
async def get_landsar_lt1_capabilities(
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
_ = current_user
|
||||
legacy = landsar_lt1_production_service.check_capabilities()
|
||||
return {
|
||||
"catalog_name": "sar_scene_geo",
|
||||
"supported_profiles": ["lt1_gamma_geocoded_mli"],
|
||||
"engine": "lt_gamma",
|
||||
"available": True,
|
||||
"status": "configured",
|
||||
"message": "LT-1 image production uses the existing Gamma single-scene pipeline: multilook, geocode, and analysis-ready GeoTIFF registration.",
|
||||
"legacy_landsar_import": legacy,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/landsar-lt1-production/preview")
|
||||
async def preview_landsar_lt1_production(
|
||||
request: LandsarLt1ImageProductionRequest,
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
blockers: List[str] = []
|
||||
warnings: List[str] = []
|
||||
radars = await _resolve_lt1_radars_for_request(db, request)
|
||||
if not radars:
|
||||
blockers.append("No LT-1 radar records were resolved from the selected source assets.")
|
||||
if request.mode == "scene" and len(radars) != 1:
|
||||
blockers.append("Scene mode requires exactly one LT-1 source asset.")
|
||||
if request.mode == "batch" and len(radars) < 1:
|
||||
blockers.append("Batch mode requires at least one LT-1 source asset.")
|
||||
produced = await _produced_radars_for_request(db, request)
|
||||
if produced:
|
||||
blockers.append(_already_produced_blocker(produced))
|
||||
active = await _active_radars_for_request(db, request)
|
||||
if active:
|
||||
blockers.append(_active_blocker(active))
|
||||
if request.mode == "batch":
|
||||
warnings.append("Batch mode submits one independent geocoded GeoTIFF task per scene; it does not build a D-InSAR stack.")
|
||||
preview = {
|
||||
"allow_submit": not blockers,
|
||||
"blockers": blockers,
|
||||
"warnings": warnings,
|
||||
"mode": request.mode,
|
||||
"profile_code": "lt1_gamma_geocoded_mli",
|
||||
"engine": "lt_gamma",
|
||||
"scene_count": len(radars),
|
||||
"source_asset_count": len(_dedupe_positive_ids(request.source_asset_ids)),
|
||||
"radar_data_count": len(radars),
|
||||
"produced_radars": produced,
|
||||
"active_radars": active,
|
||||
"scenes": [
|
||||
{
|
||||
"radar_data_id": radar.id,
|
||||
"source_asset_id": radar.source_product_ref_id,
|
||||
"satellite": radar.satellite,
|
||||
"imaging_date": radar.imaging_date,
|
||||
"imaging_mode": radar.imaging_mode,
|
||||
"polarization": radar.polarization,
|
||||
"file_path": radar.file_path,
|
||||
}
|
||||
for radar in radars
|
||||
],
|
||||
}
|
||||
return preview
|
||||
|
||||
|
||||
@router.post("/landsar-lt1-production/run", status_code=202)
|
||||
async def queue_landsar_lt1_production(
|
||||
request: LandsarLt1ImageProductionRequest,
|
||||
http_request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
_ = admin_user
|
||||
preview = await preview_landsar_lt1_production(request, current_user=admin_user, db=db)
|
||||
if preview.get("blockers"):
|
||||
raise HTTPException(status_code=400, detail={"blockers": preview.get("blockers")})
|
||||
|
||||
queued: List[Dict[str, Any]] = []
|
||||
radars = await _resolve_lt1_radars_for_request(db, request)
|
||||
for radar in radars:
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM)
|
||||
.where(SARSceneGeoORM.radar_data_id == int(radar.id))
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
scene = result.scalar_one_or_none()
|
||||
if scene and scene.status in ("PENDING", "RUNNING"):
|
||||
raise HTTPException(status_code=409, detail=f"Radar data {radar.id} already has an active GeoTIFF production task.")
|
||||
if scene and scene.status == "DONE" and scene.analysis_tif_path:
|
||||
raise HTTPException(status_code=409, detail=f"Radar data {radar.id} already has an analysis-ready GeoTIFF.")
|
||||
if not scene:
|
||||
scene = SARSceneGeoORM(radar_data_id=int(radar.id), status="PENDING")
|
||||
db.add(scene)
|
||||
await db.flush()
|
||||
else:
|
||||
scene.status = "PENDING"
|
||||
scene.error_msg = None
|
||||
await db.flush()
|
||||
scene_id = int(scene.id)
|
||||
await db.commit()
|
||||
payload = {
|
||||
"scene_id": scene_id,
|
||||
"radar_data_id": int(radar.id),
|
||||
"engine": "lt_gamma",
|
||||
"source_asset_id": radar.source_product_ref_id,
|
||||
"requested_from": "landsar_lt1_production",
|
||||
}
|
||||
task_label = request.task_name or radar.product_unique_id or radar.unique_id or f"radar_id={radar.id}"
|
||||
task_type = f"LT1_SCENE_GEOTIFF_{scene_id}"
|
||||
try:
|
||||
task_id = await task_service.create_task(
|
||||
task_type,
|
||||
f"LT-1 geocoded GeoTIFF: {task_label}",
|
||||
params=payload,
|
||||
)
|
||||
job_id = await job_queue_service.create_job(
|
||||
JOB_TYPE_SAR_SCENE_PREPROCESS,
|
||||
payload=payload,
|
||||
task_id=task_id,
|
||||
max_attempts=3,
|
||||
)
|
||||
except Exception as exc:
|
||||
failed_scene = await db.get(SARSceneGeoORM, scene_id)
|
||||
if failed_scene and failed_scene.status == "PENDING":
|
||||
failed_scene.status = "FAILED"
|
||||
failed_scene.error_msg = "Job queue failed"
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=409 if "conflict" in str(exc).lower() else 400, detail=str(exc)) from exc
|
||||
queued.append(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"scene_id": scene_id,
|
||||
"radar_data_id": int(radar.id),
|
||||
"source_asset_id": radar.source_product_ref_id,
|
||||
}
|
||||
)
|
||||
await _add_operation_audit_log(
|
||||
db,
|
||||
request=http_request,
|
||||
action="lt1_geotiff_production_queued",
|
||||
resource="landsar-lt1-production/run",
|
||||
detail={
|
||||
"queued": queued,
|
||||
"mode": request.mode,
|
||||
"scene_count": len(queued),
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"message": "LT-1 geocoded GeoTIFF production job queued.",
|
||||
"task_id": queued[0]["task_id"] if len(queued) == 1 else None,
|
||||
"job_id": queued[0]["job_id"] if len(queued) == 1 else None,
|
||||
"queued": queued,
|
||||
"preview": preview,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/landsar-lt1-production/products")
|
||||
async def list_landsar_lt1_products(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
status: Optional[str] = None,
|
||||
query: Optional[str] = None,
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
safe_limit = max(1, min(500, int(limit or 100)))
|
||||
safe_offset = max(0, int(offset or 0))
|
||||
filters = [
|
||||
SARSceneGeoORM.analysis_engine == "lt_gamma",
|
||||
SARSceneGeoORM.analysis_profile == "lt1_gamma_geocoded_mli",
|
||||
]
|
||||
if status:
|
||||
filters.append(SARSceneGeoORM.status == str(status).strip().upper())
|
||||
if query:
|
||||
like = f"%{str(query).strip()}%"
|
||||
filters.append(RadarDataORM.product_unique_id.ilike(like) | RadarDataORM.unique_id.ilike(like) | RadarDataORM.file_path.ilike(like))
|
||||
total_result = await db.execute(
|
||||
select(func.count(SARSceneGeoORM.id))
|
||||
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.where(*filters)
|
||||
)
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
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 result.all():
|
||||
marker = _scene_product_marker(scene)
|
||||
items.append(
|
||||
{
|
||||
"id": scene.id,
|
||||
"product_id": marker["product_id"],
|
||||
"catalog_name": "sar_scene_geo",
|
||||
"product_family": "lt1_analysis_ready_geotiff",
|
||||
"product_type": "analysis_ready_geotiff",
|
||||
"display_name": radar.product_unique_id or radar.unique_id or f"radar_id={radar.id}",
|
||||
"task_name": "",
|
||||
"profile_code": scene.analysis_profile,
|
||||
"engine_code": scene.analysis_engine,
|
||||
"status": scene.status,
|
||||
"health_status": "OK" if scene.status == "DONE" and scene.analysis_tif_path else "PENDING",
|
||||
"publish_dir": scene.analysis_dir,
|
||||
"manifest_path": (scene.analysis_metadata_json or {}).get("manifest_path") if isinstance(scene.analysis_metadata_json, dict) else None,
|
||||
"native_output_dir": scene.analysis_dir,
|
||||
"primary_asset_path": scene.analysis_tif_path,
|
||||
"summary": {
|
||||
"scene_count": 1,
|
||||
"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,
|
||||
"backscatter_unit": scene.analysis_backscatter_unit,
|
||||
},
|
||||
"tags": {"engine": scene.analysis_engine, "profile": scene.analysis_profile},
|
||||
"produced_at": scene.updated_at.isoformat() if scene.updated_at else None,
|
||||
"published_at": scene.updated_at.isoformat() if scene.updated_at else None,
|
||||
"registered_at": scene.created_at.isoformat() if scene.created_at else None,
|
||||
}
|
||||
)
|
||||
return {"total": total, "limit": safe_limit, "offset": safe_offset, "items": items}
|
||||
|
||||
|
||||
@router.get("/landsar-lt1-production/products/{product_db_id}")
|
||||
async def get_landsar_lt1_product_detail(
|
||||
product_db_id: int,
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM, RadarDataORM)
|
||||
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.where(
|
||||
SARSceneGeoORM.id == product_db_id,
|
||||
SARSceneGeoORM.analysis_engine == "lt_gamma",
|
||||
SARSceneGeoORM.analysis_profile == "lt1_gamma_geocoded_mli",
|
||||
)
|
||||
)
|
||||
row = result.first()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="LT-1 geocoded GeoTIFF product not found")
|
||||
scene, radar = row
|
||||
marker = _scene_product_marker(scene)
|
||||
detail = {
|
||||
"id": scene.id,
|
||||
"product_id": marker["product_id"],
|
||||
"catalog_name": "sar_scene_geo",
|
||||
"product_family": "lt1_analysis_ready_geotiff",
|
||||
"product_type": "analysis_ready_geotiff",
|
||||
"display_name": radar.product_unique_id or radar.unique_id or f"radar_id={radar.id}",
|
||||
"profile_code": scene.analysis_profile,
|
||||
"engine_code": scene.analysis_engine,
|
||||
"status": scene.status,
|
||||
"publish_dir": scene.analysis_dir,
|
||||
"primary_asset_path": scene.analysis_tif_path,
|
||||
"summary": {
|
||||
"scene_count": 1,
|
||||
"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,
|
||||
"backscatter_unit": scene.analysis_backscatter_unit,
|
||||
},
|
||||
"assets": _scene_asset_items(scene),
|
||||
}
|
||||
return detail
|
||||
|
||||
|
||||
@router.get("/landsar-lt1-production/products/{product_db_id}/assets/{asset_id}")
|
||||
async def get_landsar_lt1_product_asset(
|
||||
product_db_id: int,
|
||||
asset_id: int,
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
scene = await db.get(SARSceneGeoORM, product_db_id)
|
||||
if scene is None or scene.analysis_engine != "lt_gamma" or scene.analysis_profile != "lt1_gamma_geocoded_mli":
|
||||
raise HTTPException(status_code=404, detail="LT-1 geocoded GeoTIFF product not found")
|
||||
asset = next((item for item in _scene_asset_items(scene) if int(item["id"]) == int(asset_id)), None)
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=404, detail="LT-1 geocoded GeoTIFF asset not found")
|
||||
absolute_path = str(asset.get("absolute_path") or "")
|
||||
if not absolute_path or not os.path.isfile(absolute_path):
|
||||
raise HTTPException(status_code=404, detail="Asset file not found")
|
||||
return FileResponse(
|
||||
absolute_path,
|
||||
media_type=str(asset.get("media_type") or "application/octet-stream"),
|
||||
filename=str(asset.get("name") or os.path.basename(absolute_path)),
|
||||
headers=STATIC_ASSET_CACHE_HEADERS,
|
||||
)
|
||||
@@ -26,7 +26,10 @@ from ..models import (
|
||||
TimeseriesStackPlanORM,
|
||||
)
|
||||
from ..services.pairing_cache_service import pairing_cache_service
|
||||
from ..services.job_handlers import JOB_TYPE_PAIRING_CACHE_REBUILD
|
||||
from ..services.job_queue_service import job_queue_service
|
||||
from ..services.spatial_service import spatial_service
|
||||
from ..services.task_service import task_service
|
||||
from .dependencies import (
|
||||
_parse_aoi_from_files,
|
||||
_parse_aoi_geojson_form_value,
|
||||
@@ -38,6 +41,49 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _queue_pairing_cache_job(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
mode: str,
|
||||
force_full: bool = False,
|
||||
) -> Dict[str, object]:
|
||||
full_rebuild = mode in {"full", "full_rebuild"} or force_full
|
||||
task_name = (
|
||||
"D-InSAR pairing cache full rebuild"
|
||||
if full_rebuild
|
||||
else "D-InSAR pairing cache dirty reconcile"
|
||||
)
|
||||
payload = {
|
||||
"mode": "full_rebuild" if full_rebuild else "auto_reconcile",
|
||||
"force_full": bool(full_rebuild),
|
||||
}
|
||||
try:
|
||||
task_id = await task_service.create_task(
|
||||
JOB_TYPE_PAIRING_CACHE_REBUILD,
|
||||
task_name,
|
||||
params=payload,
|
||||
db=db,
|
||||
)
|
||||
job_id = await job_queue_service.create_job(
|
||||
JOB_TYPE_PAIRING_CACHE_REBUILD,
|
||||
payload=payload,
|
||||
max_attempts=1,
|
||||
task_id=task_id,
|
||||
db=db,
|
||||
)
|
||||
await db.commit()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {
|
||||
"ok": True,
|
||||
"queued": True,
|
||||
"mode": payload["mode"],
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"message": f"{task_name} queued",
|
||||
}
|
||||
|
||||
|
||||
def get_pairing_request_from_form(
|
||||
time_baseline_min: int = Form(1),
|
||||
time_baseline_max: int = Form(30),
|
||||
@@ -130,26 +176,26 @@ async def get_pairing_health_endpoint(
|
||||
return await pairing_cache_service.get_admin_summary(db)
|
||||
|
||||
|
||||
@router.post("/pairing/rebuild-cache")
|
||||
@router.post("/pairing/rebuild-cache", status_code=202)
|
||||
async def rebuild_pairing_cache_endpoint(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
_ = current_user
|
||||
return await pairing_cache_service.rebuild_metric_cache(db, commit=True)
|
||||
return await _queue_pairing_cache_job(db=db, mode="full_rebuild", force_full=True)
|
||||
|
||||
|
||||
@router.post("/pairing/reconcile-dirty")
|
||||
@router.post("/pairing/reconcile-dirty", status_code=202)
|
||||
async def reconcile_dirty_pairing_endpoint(
|
||||
force_full: bool = Query(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
_ = current_user
|
||||
return await pairing_cache_service.reconcile_dirty_scenes(
|
||||
db,
|
||||
return await _queue_pairing_cache_job(
|
||||
db=db,
|
||||
mode="full_rebuild" if force_full else "auto_reconcile",
|
||||
force_full=force_full,
|
||||
commit=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from ..models import (
|
||||
RadarDataORM,
|
||||
RadarDataPage,
|
||||
RadarPreviewStatusInfo,
|
||||
SARSceneGeoORM,
|
||||
ScanRequest,
|
||||
)
|
||||
from ..services.data_service import data_service
|
||||
@@ -154,6 +155,51 @@ def _normalize_list_pagination(limit: int, offset: int) -> Tuple[int, int]:
|
||||
return safe_limit, safe_offset
|
||||
|
||||
|
||||
def _lt1_image_marker(scene: SARSceneGeoORM) -> Dict[str, Any]:
|
||||
return {
|
||||
"scene_id": scene.id,
|
||||
"radar_data_id": scene.radar_data_id,
|
||||
"product_id": f"sar_scene_geo:{scene.id}",
|
||||
"product_family": "lt1_analysis_ready_geotiff",
|
||||
"engine_code": scene.analysis_engine,
|
||||
"profile_code": scene.analysis_profile,
|
||||
"analysis_tif_path": scene.analysis_tif_path,
|
||||
"analysis_dir": scene.analysis_dir,
|
||||
"analysis_preview_path": scene.analysis_preview_path,
|
||||
"status": scene.status,
|
||||
"published_at": scene.updated_at.isoformat() if scene.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def _decorate_lt1_landsar_status(db: AsyncSession, items: List[RadarDataORM]) -> List[RadarData]:
|
||||
payloads = [RadarData.model_validate(item) for item in items]
|
||||
radar_ids = [
|
||||
int(item.id)
|
||||
for item in items
|
||||
if item.id and str(item.satellite_family or item.satellite or "").upper().replace("-", "") in {"LT1", "LT"}
|
||||
]
|
||||
if not radar_ids:
|
||||
return payloads
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM).where(
|
||||
SARSceneGeoORM.radar_data_id.in_(radar_ids),
|
||||
SARSceneGeoORM.status == "DONE",
|
||||
SARSceneGeoORM.analysis_tif_path.isnot(None),
|
||||
SARSceneGeoORM.analysis_engine == "lt_gamma",
|
||||
SARSceneGeoORM.analysis_profile == "lt1_gamma_geocoded_mli",
|
||||
)
|
||||
)
|
||||
produced = {int(scene.radar_data_id): _lt1_image_marker(scene) for scene in result.scalars().all()}
|
||||
for payload in payloads:
|
||||
marker = produced.get(int(payload.id or 0))
|
||||
if marker:
|
||||
payload.lt1_image_produced = True
|
||||
payload.lt1_image_product = marker
|
||||
payload.lt1_landsar_produced = True
|
||||
payload.lt1_landsar_product = marker
|
||||
return payloads
|
||||
|
||||
|
||||
def _normalize_optional_text(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -256,6 +302,24 @@ def _build_radar_preview_status(
|
||||
)
|
||||
|
||||
|
||||
def _build_cached_radar_preview_status(record: RadarDataORM) -> RadarPreviewStatusInfo:
|
||||
raw_cache_path, geo_cache_path = _radar_preview_paths(record)
|
||||
preview_cache_path = str(record.preview_cache_path or "")
|
||||
has_geo_cache = (
|
||||
os.path.exists(geo_cache_path)
|
||||
or bool(preview_cache_path and os.path.exists(preview_cache_path))
|
||||
)
|
||||
has_raw_cache = os.path.exists(raw_cache_path)
|
||||
cached_status = str(record.preview_cache_status or "NONE").upper()
|
||||
source_found = cached_status in {"READY", "FAILED"} or has_geo_cache or has_raw_cache
|
||||
return _build_radar_preview_status(
|
||||
record=record,
|
||||
source_found=source_found,
|
||||
has_geo_cache=has_geo_cache,
|
||||
has_raw_cache=has_raw_cache,
|
||||
)
|
||||
|
||||
|
||||
async def _build_radar_preview_cache(
|
||||
record: RadarDataORM,
|
||||
db: AsyncSession,
|
||||
@@ -510,10 +574,13 @@ async def search_radar_data_endpoint(
|
||||
limit: int = Form(500),
|
||||
offset: int = Form(0),
|
||||
satellite: Optional[str] = Form(None),
|
||||
satellite_family: Optional[str] = Form(None),
|
||||
source_format: Optional[str] = Form(None),
|
||||
satellite_mode: Optional[str] = Form(None),
|
||||
receiving_station: Optional[str] = Form(None),
|
||||
imaging_mode: Optional[str] = Form(None),
|
||||
orbit_circle: Optional[str] = Form(None),
|
||||
relative_orbit: Optional[str] = Form(None),
|
||||
acquisition_time_utc: Optional[str] = Form(None),
|
||||
product_type: Optional[str] = Form(None),
|
||||
polarization: Optional[str] = Form(None),
|
||||
@@ -536,10 +603,13 @@ async def search_radar_data_endpoint(
|
||||
n_satellite_list: Optional[List[str]] = None
|
||||
if n_satellite_raw and "," in n_satellite_raw:
|
||||
n_satellite_list = [s.strip() for s in n_satellite_raw.split(",") if s.strip()]
|
||||
n_satellite_family = _normalize_optional_text(satellite_family)
|
||||
n_source_format = _normalize_optional_text(source_format)
|
||||
n_satellite_mode = _normalize_optional_text(satellite_mode)
|
||||
n_receiving_station = _normalize_optional_text(receiving_station)
|
||||
n_imaging_mode = _normalize_optional_text(imaging_mode)
|
||||
n_orbit_circle = _normalize_optional_text(orbit_circle)
|
||||
n_relative_orbit = _normalize_optional_text(relative_orbit)
|
||||
n_acquisition_time = _normalize_optional_text(acquisition_time_utc)
|
||||
n_product_type = _normalize_optional_text(product_type)
|
||||
n_polarization = _normalize_optional_text(polarization)
|
||||
@@ -584,6 +654,10 @@ async def search_radar_data_endpoint(
|
||||
filters.append(RadarDataORM.satellite.in_(n_satellite_list))
|
||||
elif n_satellite_raw:
|
||||
filters.append(RadarDataORM.satellite.ilike(f"%{n_satellite_raw}%"))
|
||||
if n_satellite_family:
|
||||
filters.append(func.upper(RadarDataORM.satellite_family) == n_satellite_family.upper())
|
||||
if n_source_format:
|
||||
filters.append(func.upper(RadarDataORM.source_format) == n_source_format.upper())
|
||||
if n_satellite_mode:
|
||||
filters.append(RadarDataORM.satellite_mode.ilike(f"%{n_satellite_mode}%"))
|
||||
if n_receiving_station:
|
||||
@@ -592,6 +666,8 @@ async def search_radar_data_endpoint(
|
||||
filters.append(RadarDataORM.imaging_mode.ilike(f"%{n_imaging_mode}%"))
|
||||
if n_orbit_circle:
|
||||
filters.append(RadarDataORM.orbit_circle.ilike(f"%{n_orbit_circle}%"))
|
||||
if n_relative_orbit:
|
||||
filters.append(RadarDataORM.relative_orbit.ilike(f"%{n_relative_orbit}%"))
|
||||
if n_acquisition_time:
|
||||
filters.append(RadarDataORM.acquisition_time_utc.ilike(f"%{n_acquisition_time}%"))
|
||||
if n_product_type:
|
||||
@@ -606,10 +682,11 @@ async def search_radar_data_endpoint(
|
||||
filters.append(RadarDataORM.orbit_direction.ilike(f"%{n_orbit_direction}%"))
|
||||
if has_orbit_data is not None:
|
||||
filters.append(RadarDataORM.has_orbit_data == has_orbit_data)
|
||||
normalized_imaging_date = func.replace(RadarDataORM.imaging_date, "-", "")
|
||||
if n_date_from:
|
||||
filters.append(RadarDataORM.imaging_date >= n_date_from)
|
||||
filters.append(normalized_imaging_date >= n_date_from.replace("-", ""))
|
||||
if n_date_to:
|
||||
filters.append(RadarDataORM.imaging_date <= n_date_to)
|
||||
filters.append(normalized_imaging_date <= n_date_to.replace("-", ""))
|
||||
if resolved_aoi_wkt:
|
||||
aoi_geom = func.ST_GeomFromText(resolved_aoi_wkt, 4326)
|
||||
filters.append(ST_Intersects(RadarDataORM.geom, aoi_geom))
|
||||
@@ -630,8 +707,9 @@ async def search_radar_data_endpoint(
|
||||
result = await db.execute(data_stmt)
|
||||
items = result.scalars().all()
|
||||
|
||||
decorated_items = await _decorate_lt1_landsar_status(db, items)
|
||||
return RadarDataSearchPageResponse(
|
||||
items=items,
|
||||
items=decorated_items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -663,8 +741,9 @@ async def get_all_data_endpoint(
|
||||
.limit(limit)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
decorated_items = await _decorate_lt1_landsar_status(db, items)
|
||||
return RadarDataPage(
|
||||
items=items,
|
||||
items=decorated_items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -724,16 +803,7 @@ async def get_radar_preview_status_endpoint(data_id: int, db: AsyncSession = Dep
|
||||
if _is_gf3_native_preview_record(record):
|
||||
return _build_gf3_native_preview_status(record)
|
||||
|
||||
raw_cache_path, geo_cache_path = _radar_preview_paths(record)
|
||||
has_geo_cache = os.path.exists(geo_cache_path)
|
||||
has_raw_cache = os.path.exists(raw_cache_path)
|
||||
source_found = bool(await asyncio.to_thread(data_service.find_radar_preview_source, record.file_path))
|
||||
return _build_radar_preview_status(
|
||||
record=record,
|
||||
source_found=source_found,
|
||||
has_geo_cache=has_geo_cache,
|
||||
has_raw_cache=has_raw_cache,
|
||||
)
|
||||
return _build_cached_radar_preview_status(record)
|
||||
|
||||
|
||||
@router.post("/radar-data/{data_id}/rebuild-preview-cache", response_model=RadarPreviewStatusInfo)
|
||||
|
||||
@@ -2,16 +2,19 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import case, func, select
|
||||
|
||||
from .. import database
|
||||
from ..auth_service import SESSION_COOKIE_NAME, get_user_by_session_token
|
||||
from ..auth_utils import verify_password
|
||||
from ..models import AuthUserORM, TaskInfo
|
||||
from ..config import settings
|
||||
from ..models import AuthUserORM, SystemJobORM, SystemTaskORM, SystemWorkerHeartbeatORM, TaskInfo
|
||||
from ..services.dinsar_production_service import dinsar_production_service
|
||||
from ..services.task_service import (
|
||||
TASK_ACTIVE_DEFAULT_LIMIT,
|
||||
@@ -47,6 +50,73 @@ def _split_csv_param(raw: Optional[str]) -> List[str]:
|
||||
return values
|
||||
|
||||
|
||||
def _dt(value):
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def _task_payload(task: SystemTaskORM) -> dict:
|
||||
return TaskInfo.model_validate(task).model_dump(mode="json")
|
||||
|
||||
|
||||
def _worker_note(worker: SystemWorkerHeartbeatORM) -> dict:
|
||||
try:
|
||||
parsed = json.loads(str(worker.note or "") or "{}")
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _worker_concurrency(worker: SystemWorkerHeartbeatORM) -> int:
|
||||
note = _worker_note(worker)
|
||||
try:
|
||||
return max(1, int(note.get("concurrency") or 1))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def _job_payload(job: SystemJobORM, task_by_id: dict[str, SystemTaskORM]) -> dict:
|
||||
task = task_by_id.get(str(job.task_id or ""))
|
||||
return {
|
||||
"job_id": job.job_id,
|
||||
"job_type": job.job_type,
|
||||
"status": job.status,
|
||||
"priority": int(job.priority or 0),
|
||||
"attempts": int(job.attempts or 0),
|
||||
"max_attempts": int(job.max_attempts or 0),
|
||||
"task_id": job.task_id,
|
||||
"task_type": task.task_type if task else None,
|
||||
"task_name": task.task_name if task else None,
|
||||
"task_status": task.status if task else None,
|
||||
"task_progress": int(task.progress or 0) if task else None,
|
||||
"task_message": task.message if task else None,
|
||||
"workflow_run_id": job.workflow_run_id,
|
||||
"workflow_step_id": job.workflow_step_id,
|
||||
"locked_by": job.locked_by,
|
||||
"locked_at": _dt(job.locked_at),
|
||||
"heartbeat_at": _dt(job.heartbeat_at),
|
||||
"next_run_at": _dt(job.next_run_at),
|
||||
"created_at": _dt(job.created_at),
|
||||
"started_at": _dt(job.started_at),
|
||||
"finished_at": _dt(job.finished_at),
|
||||
"last_error": job.last_error,
|
||||
}
|
||||
|
||||
|
||||
def _worker_payload(worker: SystemWorkerHeartbeatORM, active_job_count: int, concurrency: int) -> dict:
|
||||
note = _worker_note(worker)
|
||||
return {
|
||||
"worker_id": worker.worker_id,
|
||||
"hostname": worker.hostname,
|
||||
"pid": worker.pid,
|
||||
"note": worker.note,
|
||||
"concurrency": concurrency,
|
||||
"allowed_job_types": note.get("allowed_job_types") if isinstance(note.get("allowed_job_types"), list) else [],
|
||||
"started_at": _dt(worker.started_at),
|
||||
"last_seen": _dt(worker.last_seen),
|
||||
"active_job_count": active_job_count,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tasks/active", response_model=List[TaskInfo])
|
||||
async def get_active_tasks(limit: int = TASK_ACTIVE_DEFAULT_LIMIT, offset: int = 0):
|
||||
safe_limit = min(TASK_ACTIVE_MAX_LIMIT, max(1, int(limit or TASK_ACTIVE_DEFAULT_LIMIT)))
|
||||
@@ -73,6 +143,199 @@ async def get_recent_tasks(
|
||||
return [TaskInfo.model_validate(task) for task in orm_tasks]
|
||||
|
||||
|
||||
@router.get("/tasks/runtime-summary")
|
||||
async def get_task_runtime_summary(limit: int = TASK_ACTIVE_DEFAULT_LIMIT, offset: int = 0):
|
||||
safe_limit = min(TASK_ACTIVE_MAX_LIMIT, max(1, int(limit or TASK_ACTIVE_DEFAULT_LIMIT)))
|
||||
safe_offset = min(TASK_QUERY_MAX_OFFSET, max(0, int(offset or 0)))
|
||||
active_job_statuses = ["READY", "RETRY", "RUNNING"]
|
||||
scan_job_types = {
|
||||
"SCAN_DATA",
|
||||
"SCAN_DINSAR",
|
||||
"SCAN_ASSET_INVENTORY",
|
||||
"AUDIT_SOURCE_ARCHIVE_INTEGRITY",
|
||||
"GF3_SARSCAPE_SYNC",
|
||||
"GF3_QUICKLOOK_WEBP",
|
||||
}
|
||||
|
||||
worker_timeout = max(5, int(getattr(settings, "JOB_WORKER_HEALTH_TIMEOUT", 60) or 60))
|
||||
worker_threshold = datetime.utcnow() - timedelta(seconds=worker_timeout)
|
||||
configured_concurrency = max(1, int(getattr(settings, "JOB_WORKER_CONCURRENCY", 1) or 1))
|
||||
|
||||
async with _new_session() as db:
|
||||
active_tasks = await task_service.get_active_tasks(limit=safe_limit, offset=safe_offset, db=db)
|
||||
|
||||
workers_result = await db.execute(
|
||||
select(SystemWorkerHeartbeatORM)
|
||||
.where(SystemWorkerHeartbeatORM.last_seen >= worker_threshold)
|
||||
.order_by(SystemWorkerHeartbeatORM.last_seen.desc())
|
||||
)
|
||||
active_workers = workers_result.scalars().all()
|
||||
active_worker_ids = {str(worker.worker_id) for worker in active_workers}
|
||||
|
||||
running_by_worker_result = await db.execute(
|
||||
select(SystemJobORM.locked_by, func.count(SystemJobORM.id))
|
||||
.where(SystemJobORM.status == "RUNNING")
|
||||
.group_by(SystemJobORM.locked_by)
|
||||
)
|
||||
running_by_worker = {
|
||||
str(worker_id or ""): int(count or 0)
|
||||
for worker_id, count in running_by_worker_result.all()
|
||||
}
|
||||
|
||||
status_counts_result = await db.execute(
|
||||
select(SystemJobORM.status, func.count(SystemJobORM.id))
|
||||
.where(SystemJobORM.status.in_(active_job_statuses))
|
||||
.group_by(SystemJobORM.status)
|
||||
)
|
||||
job_status_counts = {
|
||||
"READY": 0,
|
||||
"RETRY": 0,
|
||||
"RUNNING": 0,
|
||||
}
|
||||
for status, count in status_counts_result.all():
|
||||
job_status_counts[str(status or "").upper()] = int(count or 0)
|
||||
|
||||
status_rank = case(
|
||||
(SystemJobORM.status == "RUNNING", 0),
|
||||
(SystemJobORM.status == "RETRY", 1),
|
||||
else_=2,
|
||||
)
|
||||
jobs_result = await db.execute(
|
||||
select(SystemJobORM)
|
||||
.where(SystemJobORM.status.in_(active_job_statuses))
|
||||
.order_by(status_rank, SystemJobORM.priority.desc(), SystemJobORM.id.asc())
|
||||
.offset(safe_offset)
|
||||
.limit(safe_limit)
|
||||
)
|
||||
active_jobs = jobs_result.scalars().all()
|
||||
|
||||
task_ids = {
|
||||
str(task.task_id)
|
||||
for task in active_tasks
|
||||
if task.task_id
|
||||
}
|
||||
task_ids.update(
|
||||
str(job.task_id)
|
||||
for job in active_jobs
|
||||
if job.task_id
|
||||
)
|
||||
task_by_id: dict[str, SystemTaskORM] = {}
|
||||
if task_ids:
|
||||
task_result = await db.execute(
|
||||
select(SystemTaskORM).where(SystemTaskORM.task_id.in_(sorted(task_ids)))
|
||||
)
|
||||
task_by_id = {
|
||||
str(task.task_id): task
|
||||
for task in task_result.scalars().all()
|
||||
if task.task_id
|
||||
}
|
||||
|
||||
worker_concurrency_by_id = {
|
||||
str(worker.worker_id): _worker_concurrency(worker)
|
||||
for worker in active_workers
|
||||
}
|
||||
total_slots = sum(worker_concurrency_by_id.values())
|
||||
busy_slots = sum(
|
||||
count
|
||||
for worker_id, count in running_by_worker.items()
|
||||
if worker_id in active_worker_ids
|
||||
)
|
||||
running_count = int(job_status_counts.get("RUNNING") or 0)
|
||||
stale_running_count = max(0, running_count - busy_slots)
|
||||
queued_count = int(job_status_counts.get("READY") or 0) + int(job_status_counts.get("RETRY") or 0)
|
||||
|
||||
task_items = [_task_payload(task) for task in active_tasks]
|
||||
job_items = [_job_payload(job, task_by_id) for job in active_jobs]
|
||||
scan_jobs = [
|
||||
item for item in job_items
|
||||
if str(item.get("job_type") or "").upper() in scan_job_types
|
||||
]
|
||||
scan_tasks = [
|
||||
item for item in task_items
|
||||
if str(item.get("task_type") or "").upper() in scan_job_types
|
||||
]
|
||||
|
||||
return {
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"worker": {
|
||||
"ok": len(active_workers) > 0,
|
||||
"worker_count": len(active_workers),
|
||||
"configured_concurrency": configured_concurrency,
|
||||
"total_slots": total_slots,
|
||||
"busy_slots": busy_slots,
|
||||
"idle_slots": max(0, total_slots - busy_slots),
|
||||
"timeout_seconds": worker_timeout,
|
||||
"stale_running_job_count": stale_running_count,
|
||||
"workers": [
|
||||
_worker_payload(
|
||||
worker,
|
||||
running_by_worker.get(str(worker.worker_id), 0),
|
||||
worker_concurrency_by_id.get(str(worker.worker_id), 1),
|
||||
)
|
||||
for worker in active_workers
|
||||
],
|
||||
},
|
||||
"jobs": {
|
||||
"active_count": running_count + queued_count,
|
||||
"running_count": running_count,
|
||||
"queued_count": queued_count,
|
||||
"ready_count": int(job_status_counts.get("READY") or 0),
|
||||
"retry_count": int(job_status_counts.get("RETRY") or 0),
|
||||
"items": job_items,
|
||||
},
|
||||
"tasks": {
|
||||
"active_count": len(task_items),
|
||||
"running_count": sum(1 for item in task_items if item.get("status") == "RUNNING"),
|
||||
"pending_count": sum(1 for item in task_items if item.get("status") == "PENDING"),
|
||||
"items": task_items,
|
||||
},
|
||||
"scan": {
|
||||
"active_task_count": len(scan_tasks),
|
||||
"active_job_count": len(scan_jobs),
|
||||
"running_job_count": sum(1 for item in scan_jobs if item.get("status") == "RUNNING"),
|
||||
"queued_job_count": sum(1 for item in scan_jobs if item.get("status") in {"READY", "RETRY"}),
|
||||
"tasks": scan_tasks,
|
||||
"jobs": scan_jobs,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tasks/runtime-summary/stream")
|
||||
async def stream_task_runtime_summary(request: Request):
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Authentication required.")
|
||||
|
||||
async with _new_session() as db:
|
||||
user = await get_user_by_session_token(db, token)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required.")
|
||||
|
||||
async def event_generator():
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
summary = await get_task_runtime_summary(
|
||||
limit=TASK_ACTIVE_MAX_LIMIT,
|
||||
offset=0,
|
||||
)
|
||||
yield f"data: {json.dumps(summary)}\n\n"
|
||||
except Exception:
|
||||
yield "data: {}\n\n"
|
||||
await asyncio.sleep(3)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/active/stream")
|
||||
async def stream_active_tasks(request: Request):
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
|
||||
Reference in New Issue
Block a user