Refactor local InSAR asset and production workflows
This commit is contained in:
@@ -15,7 +15,9 @@ from typing import List, Optional
|
||||
|
||||
from ..ai_service import (
|
||||
analyze_map_with_vlm,
|
||||
get_ollama_models,
|
||||
get_model_info,
|
||||
is_likely_vlm_model,
|
||||
is_model_trained,
|
||||
predict_quality,
|
||||
train_quality_model,
|
||||
@@ -146,12 +148,12 @@ async def get_ai_status(db: AsyncSession = Depends(get_db)):
|
||||
counts = await dinsar_read_service.get_ai_status_counts(db)
|
||||
|
||||
ollama_online = False
|
||||
ollama_models: List[str] = []
|
||||
ollama_vlm_models: List[str] = []
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=1.0) as client:
|
||||
ollama_base = settings.OLLAMA_BASE_URL
|
||||
response = await client.get(f"{ollama_base.rstrip('/')}/api/tags")
|
||||
ollama_online = response.status_code == 200
|
||||
ollama_models = await get_ollama_models(timeout=1.0)
|
||||
ollama_vlm_models = [model for model in ollama_models if is_likely_vlm_model(model)]
|
||||
ollama_online = True
|
||||
except Exception:
|
||||
ollama_online = False
|
||||
|
||||
@@ -161,7 +163,11 @@ async def get_ai_status(db: AsyncSession = Depends(get_db)):
|
||||
"labeled_count": counts["labeled_count"],
|
||||
"good_count": counts["good_count"],
|
||||
"bad_count": counts["bad_count"],
|
||||
"ollama_online": ollama_online
|
||||
"ollama_online": ollama_online,
|
||||
"ollama_models": ollama_models,
|
||||
"ollama_vlm_models": ollama_vlm_models,
|
||||
"ollama_base_url": settings.OLLAMA_BASE_URL,
|
||||
"default_vlm_model": settings.DEFAULT_VLM_MODEL,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,21 +22,29 @@ router = APIRouter(prefix="/assets", tags=["assets"])
|
||||
class AssetScanRequest(BaseModel):
|
||||
inventory_types: List[str] = Field(default_factory=list)
|
||||
root_ids: List[int] = Field(default_factory=list)
|
||||
families: List[str] = Field(default_factory=list)
|
||||
bind_orbits: bool = True
|
||||
build_previews: bool = True
|
||||
|
||||
|
||||
class ArchiveIntegrityAuditRequest(BaseModel):
|
||||
families: List[str] = Field(default_factory=list)
|
||||
source_formats: List[str] = Field(default_factory=list)
|
||||
asset_ids: List[int] = Field(default_factory=list)
|
||||
force: bool = False
|
||||
limit: Optional[int] = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class S1UnpackRequest(BaseModel):
|
||||
target_root: Optional[str] = None
|
||||
overwrite: bool = False
|
||||
min_disk_space_gb: Optional[float] = Field(default=None, ge=0)
|
||||
delete_archive: Optional[bool] = None
|
||||
|
||||
|
||||
class S1BatchUnpackRequest(BaseModel):
|
||||
target_root: Optional[str] = None
|
||||
overwrite: bool = False
|
||||
min_disk_space_gb: Optional[float] = Field(default=None, ge=0)
|
||||
delete_archive: Optional[bool] = None
|
||||
scan_before_unpack: bool = True
|
||||
|
||||
|
||||
@@ -89,7 +97,50 @@ async def run_asset_inventory_scan_now(
|
||||
db,
|
||||
inventory_types=payload.get("inventory_types") or None,
|
||||
root_ids=payload.get("root_ids") or None,
|
||||
families=payload.get("families") or None,
|
||||
bind_orbits=bool(payload.get("bind_orbits", True)),
|
||||
build_previews=bool(payload.get("build_previews", True)),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/inventory/archive-integrity-audit", status_code=202)
|
||||
async def run_archive_integrity_audit(
|
||||
request: Optional[ArchiveIntegrityAuditRequest] = None,
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
_ = admin_user
|
||||
payload = (request or ArchiveIntegrityAuditRequest()).model_dump()
|
||||
try:
|
||||
task_id = await task_service.create_task(
|
||||
"AUDIT_SOURCE_ARCHIVE_INTEGRITY",
|
||||
"Source archive integrity audit",
|
||||
params=payload,
|
||||
)
|
||||
job_id = await job_queue_service.create_job(
|
||||
"AUDIT_SOURCE_ARCHIVE_INTEGRITY",
|
||||
payload=payload,
|
||||
task_id=task_id,
|
||||
)
|
||||
return {"message": "Source archive integrity audit queued", "task_id": task_id, "job_id": job_id}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/inventory/archive-integrity-audit-now")
|
||||
async def run_archive_integrity_audit_now(
|
||||
request: Optional[ArchiveIntegrityAuditRequest] = None,
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = admin_user
|
||||
payload = (request or ArchiveIntegrityAuditRequest()).model_dump()
|
||||
return await asset_inventory_service.audit_source_archive_integrity(
|
||||
db,
|
||||
families=payload.get("families") or None,
|
||||
source_formats=payload.get("source_formats") or None,
|
||||
asset_ids=payload.get("asset_ids") or None,
|
||||
force=bool(payload.get("force", False)),
|
||||
limit=payload.get("limit"),
|
||||
)
|
||||
|
||||
|
||||
@@ -186,8 +237,6 @@ async def unpack_sentinel1_source_asset(
|
||||
}
|
||||
if request_data.min_disk_space_gb is not None:
|
||||
payload["min_disk_space_gb"] = request_data.min_disk_space_gb
|
||||
if request_data.delete_archive is not None:
|
||||
payload["delete_archive"] = request_data.delete_archive
|
||||
try:
|
||||
task_id = await task_service.create_task(
|
||||
"UNPACK_SENTINEL1",
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -65,6 +67,43 @@ class PreviewInputAssetsRequest(BaseModel):
|
||||
num_to_process: int = Field(default=0, ge=0, description="How many tasks to preview; 0 means all")
|
||||
|
||||
|
||||
def _is_dinsar_task_dir(path: str) -> bool:
|
||||
return os.path.isdir(os.path.join(path, "master")) and os.path.isdir(os.path.join(path, "slave"))
|
||||
|
||||
|
||||
def _is_landsar_task_dir(path: str) -> bool:
|
||||
return os.path.isdir(os.path.join(path, "Input_Data")) or _is_dinsar_task_dir(path)
|
||||
|
||||
|
||||
def _count_candidate_tasks(root_dir: str) -> Dict[str, Any]:
|
||||
try:
|
||||
if _is_dinsar_task_dir(root_dir) or _is_landsar_task_dir(root_dir):
|
||||
return {"task_count": 1, "invalid_child_count": 0, "mode": "single_task"}
|
||||
task_count = 0
|
||||
invalid_child_count = 0
|
||||
with os.scandir(root_dir) as entries:
|
||||
for entry in entries:
|
||||
if not entry.is_dir() or entry.name.startswith("._"):
|
||||
continue
|
||||
child_path = entry.path
|
||||
if _is_dinsar_task_dir(child_path) or _is_landsar_task_dir(child_path):
|
||||
task_count += 1
|
||||
elif entry.name.lower().startswith("task_"):
|
||||
invalid_child_count += 1
|
||||
return {
|
||||
"task_count": task_count,
|
||||
"invalid_child_count": invalid_child_count,
|
||||
"mode": "task_root",
|
||||
}
|
||||
except OSError as exc:
|
||||
return {
|
||||
"task_count": 0,
|
||||
"invalid_child_count": 0,
|
||||
"mode": "unreadable",
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def _get_registry():
|
||||
from ..dinsar_engines import registry
|
||||
|
||||
@@ -136,6 +175,56 @@ async def run_wsl_check(
|
||||
return report.to_dict()
|
||||
|
||||
|
||||
@router.get("/task-roots")
|
||||
async def list_task_roots(
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
_ = current_user
|
||||
root_dir = os.path.normpath(os.path.abspath(str(settings.DINSAR_TASK_POOL_ROOT or "").strip()))
|
||||
root_exists = bool(root_dir and os.path.isdir(root_dir))
|
||||
items = []
|
||||
child_dir_count = 0
|
||||
if root_exists:
|
||||
try:
|
||||
with os.scandir(root_dir) as entries:
|
||||
child_dirs = sorted(
|
||||
[entry for entry in entries if entry.is_dir() and not entry.name.startswith("._")],
|
||||
key=lambda entry: entry.name.lower(),
|
||||
)
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"无法读取 D-InSAR Task_Pool: {exc}") from exc
|
||||
child_dir_count = len(child_dirs)
|
||||
|
||||
for entry in child_dirs:
|
||||
path = os.path.normpath(entry.path)
|
||||
stat = entry.stat()
|
||||
summary = _count_candidate_tasks(path)
|
||||
task_count = int(summary.get("task_count") or 0)
|
||||
invalid_child_count = int(summary.get("invalid_child_count") or 0)
|
||||
items.append(
|
||||
{
|
||||
"name": entry.name,
|
||||
"path": path,
|
||||
"task_count": task_count,
|
||||
"invalid_child_count": invalid_child_count,
|
||||
"valid": task_count > 0,
|
||||
"mode": summary.get("mode"),
|
||||
"error": summary.get("error"),
|
||||
"mtime": stat.st_mtime,
|
||||
"updated_at": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"),
|
||||
}
|
||||
)
|
||||
|
||||
items.sort(key=lambda item: str(item.get("name") or "").lower())
|
||||
return {
|
||||
"root": root_dir,
|
||||
"root_exists": root_exists,
|
||||
"items": items,
|
||||
"count": len(items),
|
||||
"child_dir_count": child_dir_count,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/engines/pyint/preview-input-assets")
|
||||
async def preview_pyint_input_assets(
|
||||
req: PreviewInputAssetsRequest,
|
||||
|
||||
+409
-120
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any, Iterable, List, Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
@@ -17,6 +20,15 @@ from .dependencies import _require_admin
|
||||
|
||||
router = APIRouter()
|
||||
MONITOR_ALLOWED_TARGETS = {"radar", "orbit", "dinsar", "gf3"}
|
||||
MONITOR_SCAN_TASK_TYPES = {
|
||||
"SCAN_ASSET_INVENTORY",
|
||||
"AUDIT_SOURCE_ARCHIVE_INTEGRITY",
|
||||
"SCAN_DATA",
|
||||
"SCAN_DINSAR",
|
||||
"GF3_SARSCAPE_SYNC",
|
||||
"GF3_QUICKLOOK_WEBP",
|
||||
}
|
||||
MONITOR_TERMINAL_TASK_STATUSES = {"COMPLETED", "FAILED", "CANCELLED"}
|
||||
MONITOR_LOG_DEFAULT_LIMIT = read_int_env(
|
||||
"MONITOR_LOG_DEFAULT_LIMIT",
|
||||
50,
|
||||
@@ -37,10 +49,272 @@ MONITOR_LOG_MAX_OFFSET = read_int_env(
|
||||
)
|
||||
|
||||
|
||||
def _path_kind(path: str) -> str:
|
||||
text = str(path or "").strip()
|
||||
if text.startswith("\\\\"):
|
||||
return "unc"
|
||||
drive, _tail = os.path.splitdrive(os.path.normpath(text))
|
||||
if drive:
|
||||
return "windows"
|
||||
if text.startswith("/mnt/"):
|
||||
return "wsl_mount"
|
||||
if text.startswith("/"):
|
||||
return "posix"
|
||||
return "relative"
|
||||
|
||||
|
||||
def _nearest_existing_path(path: str) -> str:
|
||||
candidate = os.path.normpath(str(path or "").strip())
|
||||
while candidate and not os.path.exists(candidate):
|
||||
parent = os.path.dirname(candidate)
|
||||
if not parent or parent == candidate:
|
||||
break
|
||||
candidate = parent
|
||||
return candidate
|
||||
|
||||
|
||||
def _storage_status(label: str, path: str, role: str) -> dict[str, Any]:
|
||||
text = str(path or "").strip()
|
||||
item: dict[str, Any] = {
|
||||
"label": label,
|
||||
"role": role,
|
||||
"path": text,
|
||||
"path_kind": _path_kind(text),
|
||||
"exists": bool(text and os.path.exists(text)),
|
||||
"probe_path": "",
|
||||
"total_gb": None,
|
||||
"used_gb": None,
|
||||
"free_gb": None,
|
||||
"free_ratio": None,
|
||||
"status": "missing" if text else "empty",
|
||||
"message": "",
|
||||
}
|
||||
if not text:
|
||||
item["message"] = "Path is not configured."
|
||||
return item
|
||||
if item["path_kind"] == "unc":
|
||||
item["status"] = "blocked"
|
||||
item["message"] = "UNC paths are not allowed for active local production."
|
||||
return item
|
||||
|
||||
probe = _nearest_existing_path(text)
|
||||
if not probe or not os.path.exists(probe):
|
||||
item["message"] = "No existing parent path found."
|
||||
return item
|
||||
item["probe_path"] = probe
|
||||
try:
|
||||
total, used, free = shutil.disk_usage(probe)
|
||||
except OSError as exc:
|
||||
item["status"] = "error"
|
||||
item["message"] = str(exc)
|
||||
return item
|
||||
|
||||
gb = 1024 ** 3
|
||||
free_ratio = float(free) / float(total or 1)
|
||||
item.update(
|
||||
{
|
||||
"total_gb": round(total / gb, 2),
|
||||
"used_gb": round(used / gb, 2),
|
||||
"free_gb": round(free / gb, 2),
|
||||
"free_ratio": round(free_ratio, 4),
|
||||
"status": "ok",
|
||||
"message": "",
|
||||
}
|
||||
)
|
||||
if not item["exists"]:
|
||||
item["status"] = "missing"
|
||||
item["message"] = f"Path does not exist; disk usage probed from {probe}."
|
||||
elif free_ratio < 0.10:
|
||||
item["status"] = "critical"
|
||||
item["message"] = "Free space is below 10%."
|
||||
elif free_ratio < 0.20:
|
||||
item["status"] = "warning"
|
||||
item["message"] = "Free space is below 20%."
|
||||
return item
|
||||
|
||||
|
||||
def _add_storage_roots(rows: list[dict[str, str]], label: str, paths: Iterable[str], role: str) -> None:
|
||||
for path in paths:
|
||||
text = str(path or "").strip()
|
||||
if text:
|
||||
rows.append({"label": label, "path": text, "role": role})
|
||||
|
||||
|
||||
def _storage_group_identity(item: dict[str, Any]) -> tuple[str, str, str]:
|
||||
path = str(item.get("path") or "").strip()
|
||||
path_kind = str(item.get("path_kind") or "")
|
||||
if path_kind == "windows":
|
||||
base = str(item.get("probe_path") or path).strip()
|
||||
drive, _tail = os.path.splitdrive(os.path.normpath(base))
|
||||
if drive:
|
||||
drive = drive.upper()
|
||||
volume_path = f"{drive}\\"
|
||||
return f"windows:{drive}", volume_path, f"本机磁盘 {volume_path}"
|
||||
if path_kind == "unc":
|
||||
key = os.path.normcase(os.path.normpath(path))
|
||||
return f"unc:{key}", path, "UNC path"
|
||||
if path_kind in {"posix", "wsl_mount"}:
|
||||
base = str(item.get("probe_path") or path or "/").strip()
|
||||
root = "/mnt/" + base.split("/")[2] if base.startswith("/mnt/") and len(base.split("/")) > 2 else "/"
|
||||
return f"{path_kind}:{root}", root, root
|
||||
key = os.path.normcase(os.path.normpath(path))
|
||||
return f"path:{key}", path, str(item.get("label") or item.get("role") or path or "Storage")
|
||||
|
||||
|
||||
def _capacity_status(free_ratio: Any) -> str:
|
||||
if free_ratio is None:
|
||||
return "missing"
|
||||
try:
|
||||
ratio = float(free_ratio)
|
||||
except (TypeError, ValueError):
|
||||
return "missing"
|
||||
if ratio < 0.10:
|
||||
return "critical"
|
||||
if ratio < 0.20:
|
||||
return "warning"
|
||||
return "ok"
|
||||
|
||||
|
||||
def _group_storage_statuses(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
groups: dict[str, dict[str, Any]] = {}
|
||||
order: list[str] = []
|
||||
|
||||
for item in items:
|
||||
group_key, volume_path, volume_label = _storage_group_identity(item)
|
||||
group = groups.get(group_key)
|
||||
if group is None:
|
||||
group = {
|
||||
"label": volume_label,
|
||||
"role": "storage_volume",
|
||||
"path": volume_path,
|
||||
"path_kind": item.get("path_kind"),
|
||||
"exists": False,
|
||||
"probe_path": item.get("probe_path") or "",
|
||||
"total_gb": None,
|
||||
"used_gb": None,
|
||||
"free_gb": None,
|
||||
"free_ratio": None,
|
||||
"status": "missing",
|
||||
"message": "",
|
||||
"paths": [],
|
||||
"configured_path_count": 0,
|
||||
"existing_path_count": 0,
|
||||
"missing_path_count": 0,
|
||||
"blocked_path_count": 0,
|
||||
"error_path_count": 0,
|
||||
}
|
||||
groups[group_key] = group
|
||||
order.append(group_key)
|
||||
|
||||
group["configured_path_count"] += 1
|
||||
if item.get("exists"):
|
||||
group["existing_path_count"] += 1
|
||||
if item.get("status") == "missing":
|
||||
group["missing_path_count"] += 1
|
||||
elif item.get("status") == "blocked":
|
||||
group["blocked_path_count"] += 1
|
||||
elif item.get("status") == "error":
|
||||
group["error_path_count"] += 1
|
||||
|
||||
group["paths"].append(
|
||||
{
|
||||
"label": item.get("label"),
|
||||
"role": item.get("role"),
|
||||
"path": item.get("path"),
|
||||
"exists": item.get("exists"),
|
||||
"status": item.get("status"),
|
||||
"message": item.get("message"),
|
||||
}
|
||||
)
|
||||
|
||||
if item.get("total_gb") is not None:
|
||||
group["exists"] = True
|
||||
group["probe_path"] = item.get("probe_path") or group["probe_path"]
|
||||
group["total_gb"] = item.get("total_gb")
|
||||
group["used_gb"] = item.get("used_gb")
|
||||
group["free_gb"] = item.get("free_gb")
|
||||
group["free_ratio"] = item.get("free_ratio")
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for key in order:
|
||||
group = groups[key]
|
||||
status = _capacity_status(group.get("free_ratio"))
|
||||
if group["blocked_path_count"]:
|
||||
status = "blocked"
|
||||
elif group["error_path_count"]:
|
||||
status = "error"
|
||||
elif group["missing_path_count"] and status == "ok":
|
||||
status = "partial"
|
||||
group["status"] = status
|
||||
|
||||
messages = [f"{group['configured_path_count']} 个配置路径"]
|
||||
if group["missing_path_count"]:
|
||||
messages.append(f"{group['missing_path_count']} 个路径缺失")
|
||||
if group["blocked_path_count"]:
|
||||
messages.append(f"{group['blocked_path_count']} 个 UNC 被禁用")
|
||||
if group["error_path_count"]:
|
||||
messages.append(f"{group['error_path_count']} 个探测失败")
|
||||
if status == "critical":
|
||||
messages.append("剩余空间低于 10%")
|
||||
elif status == "warning" and not group["missing_path_count"]:
|
||||
messages.append("剩余空间低于 20%")
|
||||
group["message"] = ";".join(messages) + "。"
|
||||
result.append(group)
|
||||
return result
|
||||
|
||||
|
||||
def _collect_storage_roots() -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, str]] = []
|
||||
_add_storage_roots(rows, "Task_Pool", [settings.TASK_POOL_ROOT], "task_pool")
|
||||
_add_storage_roots(rows, "D-InSAR Task_Pool", [settings.DINSAR_TASK_POOL_ROOT], "dinsar_task_pool")
|
||||
_add_storage_roots(rows, "SBAS Task_Pool", [settings.SBAS_TASK_POOL_ROOT], "sbas_task_pool")
|
||||
_add_storage_roots(rows, "Data distribution root", [settings.DATA_DISTRIBUTION_ROOT], "data_distribution")
|
||||
source_paths = split_env_paths(settings.SOURCE_PRODUCT_DIRS)
|
||||
source_path_keys = {os.path.normcase(os.path.normpath(path)) for path in source_paths}
|
||||
s1_storage_paths = [
|
||||
path
|
||||
for path in split_env_paths(settings.SENTINEL1_STORAGE_DIRS)
|
||||
if os.path.normcase(os.path.normpath(path)) not in source_path_keys
|
||||
]
|
||||
_add_storage_roots(rows, "LT/S1 local source pools", source_paths, "source_local")
|
||||
_add_storage_roots(rows, "Sentinel-1 local source pool", s1_storage_paths, "source_local")
|
||||
_add_storage_roots(rows, "Orbit source pool", split_env_paths(settings.ORBIT_SOURCE_DIRS), "orbit_source")
|
||||
_add_storage_roots(rows, "LT-1 radar scan pool", split_env_paths(settings.MONITOR_RADAR_DIRS), "lt1_storage")
|
||||
_add_storage_roots(rows, "D-InSAR product root", [settings.DINSAR_PRODUCT_DIR], "dinsar_product")
|
||||
_add_storage_roots(rows, "GF3 native _geo", split_env_paths(settings.GF3_SARSCAPE_NATIVE_DIRS), "gf3_native")
|
||||
_add_storage_roots(rows, "GF3 standard/index", split_env_paths(settings.GF3_STORAGE_DIRS), "gf3_storage")
|
||||
_add_storage_roots(rows, "GF3 task/runtime pool", [settings.GF3_TASK_POOL_ROOT], "gf3_task_pool")
|
||||
_add_storage_roots(rows, "GF3 SARscape runtime", [settings.GF3_SARSCAPE_RUNTIME_DIR], "gf3_runtime")
|
||||
_add_storage_roots(rows, "Result publish root", [settings.RESULT_PUBLISH_ROOT], "result_publish")
|
||||
_add_storage_roots(rows, "SBAS work root", [settings.GAMMA_SBAS_WORK_ROOT], "sbas_work")
|
||||
_add_storage_roots(rows, "SBAS product root", [settings.GAMMA_SBAS_PRODUCT_ROOT], "sbas_product")
|
||||
_add_storage_roots(rows, "SAR analysis ready", [settings.SAR_ANALYSIS_READY_ROOT], "analysis_ready")
|
||||
_add_storage_roots(rows, "SAR analysis work", [settings.SAR_ANALYSIS_WORK_ROOT], "analysis_work")
|
||||
|
||||
seen: set[str] = set()
|
||||
result: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
key = os.path.normcase(os.path.normpath(row["path"]))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(_storage_status(row["label"], row["path"], row["role"]))
|
||||
return _group_storage_statuses(result)
|
||||
|
||||
|
||||
class MonitorConfig(BaseModel):
|
||||
radar_dirs: List[str] = []
|
||||
orbit_dir: Optional[str] = None
|
||||
orbit_source_dirs: List[str] = []
|
||||
orbit_production_txt_pool: Optional[str] = None
|
||||
dinsar_dirs: List[str] = []
|
||||
dinsar_product_dir: Optional[str] = None
|
||||
sbas_product_root: Optional[str] = None
|
||||
task_pool_root: Optional[str] = None
|
||||
dinsar_task_pool_root: Optional[str] = None
|
||||
sbas_task_pool_root: Optional[str] = None
|
||||
gf3_task_pool_root: Optional[str] = None
|
||||
data_distribution_root: Optional[str] = None
|
||||
gf3_archive_source_dirs: List[str] = []
|
||||
gf3_source_dirs: List[str] = []
|
||||
gf3_legacy_gdal_enabled: bool = False
|
||||
@@ -53,6 +327,7 @@ class MonitorConfig(BaseModel):
|
||||
gf3_sarscape_polarizations: Optional[str] = None
|
||||
gf3_sarscape_auto_standardize: bool = True
|
||||
gf3_sarscape_clean_after_success: bool = True
|
||||
storage_roots: List[dict[str, Any]] = []
|
||||
# Manual-only: config is read from .env
|
||||
|
||||
|
||||
@@ -70,6 +345,13 @@ class GF3UnpackRunRequest(BaseModel):
|
||||
class GF3SarscapeSyncRequest(BaseModel):
|
||||
force: bool = False
|
||||
register: bool = True
|
||||
quicklook_only: bool = False
|
||||
native_dirs: List[str] = []
|
||||
|
||||
|
||||
class GF3QuicklookWebpRequest(BaseModel):
|
||||
force: bool = False
|
||||
max_records: Optional[int] = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class GF3SarscapeProduceRequest(BaseModel):
|
||||
@@ -91,7 +373,7 @@ class GF3SarscapeCleanRequest(BaseModel):
|
||||
@router.post("/monitor/config")
|
||||
async def update_monitor_config(config: MonitorConfig):
|
||||
"""
|
||||
更新数据监控配置(雷达、精轨和Dinsar结果)。
|
||||
鏇存柊鏁版嵁鐩戞帶閰嶇疆锛堥浄杈俱€佺簿杞ㄥ拰Dinsar缁撴灉锛夈€?
|
||||
"""
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
@@ -102,8 +384,8 @@ async def update_monitor_config(config: MonitorConfig):
|
||||
@router.post("/monitor/run-now")
|
||||
async def run_monitor_now(target: Optional[str] = None, background_tasks: BackgroundTasks = None, admin_user: AuthUserORM = Depends(_require_admin)):
|
||||
"""
|
||||
手动触发一次监控任务(扫描所有配置的目录)。
|
||||
target: 'radar', 'orbit', 'dinsar' 或 None (全部)
|
||||
鎵嬪姩瑙﹀彂涓€娆$洃鎺т换鍔★紙鎵弿鎵€鏈夐厤缃殑鐩綍锛夈€?
|
||||
target: 'radar', 'orbit', 'dinsar' 鎴?None (鍏ㄩ儴)
|
||||
"""
|
||||
normalized_target = (target or "").strip().lower() or None
|
||||
if normalized_target and normalized_target not in MONITOR_ALLOWED_TARGETS:
|
||||
@@ -127,7 +409,7 @@ async def run_monitor_now(target: Optional[str] = None, background_tasks: Backgr
|
||||
raise HTTPException(status_code=400, detail="Monitor paths are not configured in .env.")
|
||||
|
||||
task_type = "SCAN_DATA" if normalized_target in ["radar", "orbit", "gf3", None] else "SCAN_DINSAR"
|
||||
task_name = f"手动触发扫描 ({normalized_target or '全部'})"
|
||||
task_name = f"Manual scan ({normalized_target or 'all'})"
|
||||
|
||||
try:
|
||||
task_id = await task_service.create_task(task_type, task_name)
|
||||
@@ -135,7 +417,7 @@ async def run_monitor_now(target: Optional[str] = None, background_tasks: Backgr
|
||||
payload = {"dirs": dinsar_dirs}
|
||||
job_type = "SCAN_DINSAR"
|
||||
elif normalized_target == "gf3":
|
||||
# GF3 扫描:用 gf3_storage_dirs 作为 radar_dirs
|
||||
# GF3 scan uses gf3_storage_dirs as radar_dirs.
|
||||
payload = {
|
||||
"radar_dirs": gf3_storage_dirs,
|
||||
"target": "radar",
|
||||
@@ -150,7 +432,7 @@ async def run_monitor_now(target: Optional[str] = None, background_tasks: Backgr
|
||||
await job_queue_service.create_job(job_type, payload=payload, task_id=task_id)
|
||||
|
||||
return {
|
||||
"message": f"已触发{normalized_target or '全部'}手动扫描任务(已进入队列)",
|
||||
"message": f"Manual scan queued for {normalized_target or 'all'}.",
|
||||
"task_id": task_id
|
||||
}
|
||||
except ValueError as e:
|
||||
@@ -160,14 +442,14 @@ async def run_monitor_now(target: Optional[str] = None, background_tasks: Backgr
|
||||
@router.post("/monitor/gf3-process")
|
||||
async def run_gf3_batch_process(admin_user: AuthUserORM = Depends(_require_admin)):
|
||||
"""
|
||||
批量 GF3 L1A→L2 处理:扫描 GF3_SOURCE_DIRS,过滤已处理的,逐个处理并自动入库。
|
||||
Batch GF3 legacy L1A to L2 processing.
|
||||
"""
|
||||
if not settings.GF3_LEGACY_GDAL_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Legacy GF3 Python/GDAL preprocessing is disabled. "
|
||||
"Use GF3 SARscape production or set GF3_LEGACY_GDAL_ENABLED=true explicitly."
|
||||
"Use GF3 native _geo result registration or set GF3_LEGACY_GDAL_ENABLED=true explicitly."
|
||||
),
|
||||
)
|
||||
gf3_source_dirs = MONITOR_CONFIG.get("gf3_source_dirs") or []
|
||||
@@ -175,7 +457,7 @@ async def run_gf3_batch_process(admin_user: AuthUserORM = Depends(_require_admin
|
||||
raise HTTPException(status_code=400, detail="GF3_SOURCE_DIRS is not configured.")
|
||||
|
||||
task_type = "GF3_BATCH_PROCESS"
|
||||
task_name = "GF3 批量 L1A→L2 处理"
|
||||
task_name = "GF3 legacy batch process"
|
||||
|
||||
try:
|
||||
task_id = await task_service.create_task(task_type, task_name)
|
||||
@@ -185,7 +467,7 @@ async def run_gf3_batch_process(admin_user: AuthUserORM = Depends(_require_admin
|
||||
task_id=task_id,
|
||||
)
|
||||
return {
|
||||
"message": "GF3 批量处理任务已提交",
|
||||
"message": "GF3 batch process task submitted",
|
||||
"task_id": task_id,
|
||||
}
|
||||
except ValueError as e:
|
||||
@@ -198,147 +480,100 @@ async def run_gf3_sarscape_sync(
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""
|
||||
扫描 GF3 SARscape 原生 _geo 二进制池,转换为标准 GeoTIFF,并登记入库。
|
||||
Scan GF3 SARscape native _geo outputs.
|
||||
"""
|
||||
gf3_native_dirs = MONITOR_CONFIG.get("gf3_sarscape_native_dirs") or []
|
||||
options = request_data or GF3SarscapeSyncRequest()
|
||||
gf3_native_dirs = options.native_dirs or MONITOR_CONFIG.get("gf3_sarscape_native_dirs") or []
|
||||
gf3_storage_dirs = MONITOR_CONFIG.get("gf3_storage_dirs") or []
|
||||
if not gf3_native_dirs:
|
||||
raise HTTPException(status_code=400, detail="GF3_SARSCAPE_NATIVE_DIRS is not configured.")
|
||||
if not gf3_storage_dirs:
|
||||
raise HTTPException(status_code=400, detail="GF3_STORAGE_DIRS is not configured.")
|
||||
|
||||
options = request_data or GF3SarscapeSyncRequest()
|
||||
task_type = "GF3_SARSCAPE_SYNC"
|
||||
task_name = "GF3 SARscape 原生结果标准化"
|
||||
task_name = "GF3 SARscape native result inventory" if options.quicklook_only else "GF3 SARscape native standardize"
|
||||
payload = {
|
||||
"native_dirs": gf3_native_dirs,
|
||||
"storage_root": gf3_storage_dirs[0],
|
||||
"force": bool(options.force),
|
||||
"register": bool(options.register),
|
||||
"quicklook_only": bool(options.quicklook_only),
|
||||
}
|
||||
|
||||
try:
|
||||
task_id = await task_service.create_task(task_type, task_name, params=payload)
|
||||
await job_queue_service.create_job(task_type, payload=payload, task_id=task_id)
|
||||
return {
|
||||
"message": "GF3 SARscape 原生结果标准化任务已提交",
|
||||
"message": (
|
||||
"GF3 SARscape native result inventory task submitted"
|
||||
if options.quicklook_only
|
||||
else "GF3 SARscape native standardize task submitted"
|
||||
),
|
||||
"task_id": task_id,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/monitor/gf3-quicklook-webp", status_code=202)
|
||||
async def run_gf3_quicklook_webp(
|
||||
request_data: GF3QuicklookWebpRequest | None = None,
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""
|
||||
Generate local WebP cache files from registered GF3 SARscape native _geo records.
|
||||
"""
|
||||
options = request_data or GF3QuicklookWebpRequest()
|
||||
task_type = "GF3_QUICKLOOK_WEBP"
|
||||
task_name = "GF3 native _geo WebP cache"
|
||||
gf3_native_dirs = MONITOR_CONFIG.get("gf3_sarscape_native_dirs") or []
|
||||
if not gf3_native_dirs:
|
||||
raise HTTPException(status_code=400, detail="GF3_SARSCAPE_NATIVE_DIRS is not configured.")
|
||||
payload = {
|
||||
"force": bool(options.force),
|
||||
"max_records": int(options.max_records or 0),
|
||||
"native_dirs": gf3_native_dirs,
|
||||
}
|
||||
|
||||
try:
|
||||
task_id = await task_service.create_task(task_type, task_name, params=payload)
|
||||
await job_queue_service.create_job(task_type, payload=payload, task_id=task_id)
|
||||
return {
|
||||
"message": "GF3 native _geo WebP cache task submitted",
|
||||
"task_id": task_id,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
|
||||
|
||||
@router.post("/monitor/gf3-sarscape-produce", status_code=202)
|
||||
async def run_gf3_sarscape_produce(
|
||||
request_data: GF3SarscapeProduceRequest | None = None,
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""
|
||||
Run GF3 raw archives through the SARscape wrapper, standardize outputs, and optionally clean intermediates.
|
||||
GF3 SARscape production is disabled on this management machine.
|
||||
"""
|
||||
gf3_archive_source_dirs = MONITOR_CONFIG.get("gf3_archive_source_dirs") or []
|
||||
gf3_native_dirs = MONITOR_CONFIG.get("gf3_sarscape_native_dirs") or []
|
||||
gf3_storage_dirs = MONITOR_CONFIG.get("gf3_storage_dirs") or []
|
||||
if not gf3_archive_source_dirs:
|
||||
raise HTTPException(status_code=400, detail="GF3_ARCHIVE_SOURCE_DIRS is not configured.")
|
||||
if not gf3_native_dirs:
|
||||
raise HTTPException(status_code=400, detail="GF3_SARSCAPE_NATIVE_DIRS is not configured.")
|
||||
if not gf3_storage_dirs:
|
||||
raise HTTPException(status_code=400, detail="GF3_STORAGE_DIRS is not configured.")
|
||||
if not settings.GF3_SARSCAPE_WRAPPER_EXE:
|
||||
raise HTTPException(status_code=400, detail="GF3_SARSCAPE_WRAPPER_EXE is not configured.")
|
||||
if not (settings.GF3_SARSCAPE_DEM_PATH or settings.GF3_GEO_DEM_PATH):
|
||||
raise HTTPException(status_code=400, detail="GF3_SARSCAPE_DEM_PATH or GF3_GEO_DEM_PATH is not configured.")
|
||||
|
||||
options = request_data or GF3SarscapeProduceRequest()
|
||||
selected_dates = []
|
||||
for raw_date in options.selected_dates or []:
|
||||
text = str(raw_date or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
normalized = text.replace("-", "").replace("_", "")
|
||||
if len(normalized) != 8 or not normalized.isdigit():
|
||||
raise HTTPException(status_code=400, detail=f"Invalid GF3 scene date: {raw_date}")
|
||||
if normalized not in selected_dates:
|
||||
selected_dates.append(normalized)
|
||||
task_type = "GF3_SARSCAPE_PRODUCE"
|
||||
task_name = "GF3 SARscape production"
|
||||
auto_standardize = settings.GF3_SARSCAPE_AUTO_STANDARDIZE if options.auto_standardize is None else bool(options.auto_standardize)
|
||||
clean_after_success = settings.GF3_SARSCAPE_CLEAN_AFTER_SUCCESS if options.clean_after_success is None else bool(options.clean_after_success)
|
||||
payload = {
|
||||
"source_dirs": gf3_archive_source_dirs,
|
||||
"native_dirs": gf3_native_dirs,
|
||||
"native_root": gf3_native_dirs[0],
|
||||
"storage_root": gf3_storage_dirs[0],
|
||||
"wrapper_exe": settings.GF3_SARSCAPE_WRAPPER_EXE,
|
||||
"idlrt_path": settings.GF3_SARSCAPE_IDLRT_PATH,
|
||||
"dem_path": settings.GF3_SARSCAPE_DEM_PATH or settings.GF3_GEO_DEM_PATH,
|
||||
"polarizations": settings.GF3_SARSCAPE_POLARIZATIONS,
|
||||
"archive_exts": split_env_paths(settings.GF3_ARCHIVE_EXTS),
|
||||
"max_archives_per_run": int(options.max_archives_per_run or 0),
|
||||
"selected_dates": selected_dates,
|
||||
"local_staging_root": settings.GF3_TASK_POOL_ROOT,
|
||||
"timeout_seconds": int(settings.GF3_SARSCAPE_PRODUCE_TIMEOUT_SECONDS or 0),
|
||||
"keep_extracted": bool(settings.GF3_SARSCAPE_KEEP_EXTRACTED),
|
||||
"auto_standardize": bool(auto_standardize),
|
||||
"clean_after_success": bool(clean_after_success),
|
||||
"force_standardize": bool(options.force_standardize),
|
||||
"register": bool(options.register),
|
||||
"cleanup_require_standardized": True,
|
||||
"cleanup_dry_run": bool(options.cleanup_dry_run),
|
||||
}
|
||||
|
||||
try:
|
||||
task_id = await task_service.create_task(task_type, task_name, params=payload)
|
||||
await job_queue_service.create_job(task_type, payload=payload, task_id=task_id)
|
||||
return {
|
||||
"message": (
|
||||
f"GF3 SARscape production task submitted for {', '.join(selected_dates)}"
|
||||
if selected_dates
|
||||
else "GF3 SARscape production task submitted"
|
||||
),
|
||||
"task_id": task_id,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"GF3 SARscape production is disabled on this management machine. "
|
||||
"Run GF3 production on the SARscape host, copy completed _geo results to local "
|
||||
"GF3_SARSCAPE_NATIVE_DIRS, then use GF3 native result registration and GF3 _geo WebP generation."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/monitor/gf3-sarscape-dates")
|
||||
async def list_gf3_sarscape_dates(admin_user: AuthUserORM = Depends(_require_admin)):
|
||||
"""
|
||||
List available GF3 SARscape source dates from configured raw archive roots.
|
||||
GF3 SARscape production date selection is disabled on this management machine.
|
||||
"""
|
||||
from ..services.gf3_sarscape_production_service import discover_gf3_sarscape_inputs
|
||||
|
||||
gf3_archive_source_dirs = MONITOR_CONFIG.get("gf3_archive_source_dirs") or []
|
||||
if not gf3_archive_source_dirs:
|
||||
raise HTTPException(status_code=400, detail="GF3_ARCHIVE_SOURCE_DIRS is not configured.")
|
||||
|
||||
discovery = discover_gf3_sarscape_inputs(
|
||||
gf3_archive_source_dirs,
|
||||
archive_exts=split_env_paths(settings.GF3_ARCHIVE_EXTS),
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="GF3 SARscape production date selection is disabled. Register local _geo native results instead.",
|
||||
)
|
||||
by_date: dict[str, dict[str, object]] = {}
|
||||
undated = 0
|
||||
for item in discovery.get("inputs") or []:
|
||||
scene_name = str(item.get("scene_name") or "")
|
||||
date_text = str(item.get("scene_date") or "")
|
||||
if not date_text:
|
||||
undated += 1
|
||||
continue
|
||||
bucket = by_date.setdefault(date_text, {"date": date_text, "scene_count": 0, "scenes": []})
|
||||
bucket["scene_count"] = int(bucket.get("scene_count") or 0) + 1
|
||||
scenes = bucket.get("scenes")
|
||||
if isinstance(scenes, list) and len(scenes) < 20:
|
||||
scenes.append(scene_name)
|
||||
|
||||
dates = sorted(by_date.values(), key=lambda item: str(item.get("date") or ""), reverse=True)
|
||||
return {
|
||||
"dates": dates,
|
||||
"input_count": discovery.get("input_count") or 0,
|
||||
"undated_count": undated,
|
||||
"missing_roots": discovery.get("missing_roots") or [],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/monitor/gf3-sarscape-clean", status_code=202)
|
||||
@@ -394,14 +629,14 @@ async def run_gf3_unpack(
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""
|
||||
将 GF3 压缩包池解包到 GF3_SOURCE_DIRS,作为后续 L1A→L2 预处理输入。
|
||||
Unpack GF3 archives into GF3_SOURCE_DIRS for the legacy pipeline.
|
||||
"""
|
||||
if not settings.GF3_LEGACY_GDAL_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Legacy GF3 archive unpack is disabled. "
|
||||
"Use GF3 SARscape production or set GF3_LEGACY_GDAL_ENABLED=true explicitly."
|
||||
"Use GF3 native _geo result registration or set GF3_LEGACY_GDAL_ENABLED=true explicitly."
|
||||
),
|
||||
)
|
||||
gf3_archive_source_dirs = MONITOR_CONFIG.get("gf3_archive_source_dirs") or []
|
||||
@@ -416,7 +651,7 @@ async def run_gf3_unpack(
|
||||
max_files = max(0, int(request_data.max_files_per_run))
|
||||
|
||||
task_type = "GF3_UNPACK"
|
||||
task_name = "GF3 压缩包解包"
|
||||
task_name = "GF3 archive unpack"
|
||||
payload = {
|
||||
"source_dirs": gf3_archive_source_dirs,
|
||||
"target_dirs": gf3_source_dirs,
|
||||
@@ -429,7 +664,7 @@ async def run_gf3_unpack(
|
||||
task_id = await task_service.create_task(task_type, task_name, params=payload)
|
||||
await job_queue_service.create_job(task_type, payload=payload, task_id=task_id)
|
||||
return {
|
||||
"message": "GF3 解包任务已提交",
|
||||
"message": "GF3 unpack task submitted",
|
||||
"task_id": task_id,
|
||||
}
|
||||
except ValueError as e:
|
||||
@@ -439,9 +674,24 @@ async def run_gf3_unpack(
|
||||
@router.get("/monitor/status")
|
||||
async def get_monitor_status():
|
||||
"""
|
||||
获取当前监控状态。
|
||||
鑾峰彇褰撳墠鐩戞帶鐘舵€併€?
|
||||
"""
|
||||
return MONITOR_CONFIG
|
||||
config = dict(MONITOR_CONFIG)
|
||||
config.update(
|
||||
{
|
||||
"task_pool_root": settings.TASK_POOL_ROOT,
|
||||
"dinsar_task_pool_root": settings.DINSAR_TASK_POOL_ROOT,
|
||||
"sbas_task_pool_root": settings.SBAS_TASK_POOL_ROOT,
|
||||
"gf3_task_pool_root": settings.GF3_TASK_POOL_ROOT,
|
||||
"data_distribution_root": settings.DATA_DISTRIBUTION_ROOT,
|
||||
"dinsar_product_dir": settings.DINSAR_PRODUCT_DIR,
|
||||
"sbas_product_root": settings.GAMMA_SBAS_PRODUCT_ROOT,
|
||||
"orbit_source_dirs": split_env_paths(settings.ORBIT_SOURCE_DIRS) or split_env_paths(settings.MONITOR_ORBIT_DIR),
|
||||
"orbit_production_txt_pool": settings.ORBIT_POOL_ENVI,
|
||||
"storage_roots": _collect_storage_roots(),
|
||||
}
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@router.get("/monitor/logs")
|
||||
@@ -451,7 +701,7 @@ async def get_monitor_logs(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取最新的监控日志 (来自任务日志表)。
|
||||
鑾峰彇鏈€鏂扮殑鐩戞帶鏃ュ織 (鏉ヨ嚜浠诲姟鏃ュ織琛?銆?
|
||||
"""
|
||||
safe_limit = min(MONITOR_LOG_MAX_LIMIT, max(1, int(limit or MONITOR_LOG_DEFAULT_LIMIT)))
|
||||
safe_offset = min(MONITOR_LOG_MAX_OFFSET, max(0, int(offset or 0)))
|
||||
@@ -478,3 +728,42 @@ async def get_monitor_logs(
|
||||
"count": len(logs),
|
||||
"logs": logs,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/monitor/scan-task-history")
|
||||
async def clear_monitor_scan_task_history(
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Clear finished scan task records and their logs from the monitor panel.
|
||||
|
||||
Running and pending tasks are intentionally preserved.
|
||||
"""
|
||||
task_rows = await db.execute(
|
||||
select(SystemTaskORM.task_id)
|
||||
.where(SystemTaskORM.task_type.in_(MONITOR_SCAN_TASK_TYPES))
|
||||
.where(SystemTaskORM.status.in_(MONITOR_TERMINAL_TASK_STATUSES))
|
||||
)
|
||||
task_ids = [str(task_id) for task_id in task_rows.scalars().all() if task_id]
|
||||
if not task_ids:
|
||||
return {
|
||||
"deleted_task_count": 0,
|
||||
"deleted_log_count": 0,
|
||||
"preserved_active": True,
|
||||
}
|
||||
|
||||
log_count_result = await db.execute(
|
||||
select(func.count(TaskLogORM.id)).where(TaskLogORM.task_id.in_(task_ids))
|
||||
)
|
||||
deleted_log_count = int(log_count_result.scalar_one() or 0)
|
||||
|
||||
await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id.in_(task_ids)))
|
||||
task_delete_result = await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id.in_(task_ids)))
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"deleted_task_count": int(task_delete_result.rowcount or len(task_ids)),
|
||||
"deleted_log_count": deleted_log_count,
|
||||
"preserved_active": True,
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ class OrbitPoolActionRequest(BaseModel):
|
||||
async def _build_orbit_database_stats(
|
||||
db: AsyncSession,
|
||||
pool_inventory: Dict[str, Any],
|
||||
*,
|
||||
isce2_enabled: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
total_radar_count = (
|
||||
await db.execute(select(func.count(RadarDataORM.id)))
|
||||
@@ -94,9 +96,9 @@ async def _build_orbit_database_stats(
|
||||
}
|
||||
|
||||
envi_stems = set(pool_inventory["envi"]["files"].keys())
|
||||
isce2_stems = set(pool_inventory["isce2"]["files"].keys())
|
||||
isce2_stems = set(pool_inventory["isce2"]["files"].keys()) if isce2_enabled else set()
|
||||
missing_in_envi = sorted(db_expected_stems - envi_stems)
|
||||
missing_in_isce2 = sorted(db_expected_stems - isce2_stems)
|
||||
missing_in_isce2 = sorted(db_expected_stems - isce2_stems) if isce2_enabled else []
|
||||
|
||||
return {
|
||||
"total_radar_count": int(total_radar_count or 0),
|
||||
@@ -110,6 +112,7 @@ async def _build_orbit_database_stats(
|
||||
"db_expected_stem_count": len(db_expected_stems),
|
||||
"stems_missing_in_envi_count": len(missing_in_envi),
|
||||
"stems_missing_in_isce2_count": len(missing_in_isce2),
|
||||
"isce2_enabled": bool(isce2_enabled),
|
||||
"sample_missing_in_envi": missing_in_envi[:20],
|
||||
"sample_missing_in_isce2": missing_in_isce2[:20],
|
||||
"path_errors": db_path_errors,
|
||||
@@ -123,26 +126,27 @@ async def get_orbit_status(
|
||||
):
|
||||
"""Return source, pool, consistency, and database orbit status."""
|
||||
source_dir = settings.MONITOR_ORBIT_DIR
|
||||
isce2_pool = settings.ORBIT_POOL_ISCE2 if settings.ISCE2_ENABLED else ""
|
||||
source_stats = await asyncio.to_thread(scan_orbit_dir, source_dir)
|
||||
pool_inventory = await asyncio.to_thread(
|
||||
get_orbit_pool_inventory,
|
||||
settings.ORBIT_POOL_ENVI,
|
||||
settings.ORBIT_POOL_ISCE2,
|
||||
isce2_pool,
|
||||
True,
|
||||
)
|
||||
consistency = await asyncio.to_thread(
|
||||
check_orbit_consistency,
|
||||
settings.ORBIT_POOL_ENVI,
|
||||
settings.ORBIT_POOL_ISCE2,
|
||||
isce2_pool,
|
||||
)
|
||||
source_gap_summary = await asyncio.to_thread(
|
||||
summarize_source_orbit_gaps,
|
||||
source_dir,
|
||||
settings.ORBIT_POOL_ENVI,
|
||||
settings.ORBIT_POOL_ISCE2,
|
||||
isce2_pool,
|
||||
settings.ORBIT_QUARANTINE_DIR,
|
||||
)
|
||||
database_stats = await _build_orbit_database_stats(db, pool_inventory)
|
||||
database_stats = await _build_orbit_database_stats(db, pool_inventory, isce2_enabled=bool(settings.ISCE2_ENABLED))
|
||||
|
||||
return {
|
||||
"orbit_root": source_stats.orbit_root,
|
||||
@@ -184,7 +188,8 @@ async def get_orbit_status(
|
||||
"errors": pool_inventory["envi"]["errors"],
|
||||
},
|
||||
"isce2": {
|
||||
"path": settings.ORBIT_POOL_ISCE2,
|
||||
"path": isce2_pool,
|
||||
"enabled": bool(settings.ISCE2_ENABLED),
|
||||
"total": pool_inventory["isce2"]["total"],
|
||||
"duplicate_count": pool_inventory["isce2"]["duplicate_count"],
|
||||
"errors": pool_inventory["isce2"]["errors"],
|
||||
@@ -207,25 +212,28 @@ async def sync_orbit_pool_action(
|
||||
Check pool consistency, or repair missing entries when repair=true.
|
||||
"""
|
||||
if payload and payload.quarantine_bad:
|
||||
isce2_pool = settings.ORBIT_POOL_ISCE2 if settings.ISCE2_ENABLED else ""
|
||||
return await asyncio.to_thread(
|
||||
quarantine_bad_orbits,
|
||||
settings.MONITOR_ORBIT_DIR,
|
||||
settings.ORBIT_POOL_ENVI,
|
||||
settings.ORBIT_POOL_ISCE2,
|
||||
isce2_pool,
|
||||
settings.ORBIT_QUARANTINE_DIR,
|
||||
)
|
||||
if payload and payload.repair:
|
||||
isce2_pool = settings.ORBIT_POOL_ISCE2 if settings.ISCE2_ENABLED else ""
|
||||
return await asyncio.to_thread(
|
||||
repair_orbit_pools,
|
||||
settings.MONITOR_ORBIT_DIR,
|
||||
settings.ORBIT_POOL_ENVI,
|
||||
settings.ORBIT_POOL_ISCE2,
|
||||
isce2_pool,
|
||||
settings.ORBIT_POOL_LANDSAR,
|
||||
)
|
||||
isce2_pool = settings.ORBIT_POOL_ISCE2 if settings.ISCE2_ENABLED else ""
|
||||
return await asyncio.to_thread(
|
||||
check_orbit_consistency,
|
||||
settings.ORBIT_POOL_ENVI,
|
||||
settings.ORBIT_POOL_ISCE2,
|
||||
isce2_pool,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -40,10 +40,10 @@ router = APIRouter()
|
||||
|
||||
def get_pairing_request_from_form(
|
||||
time_baseline_min: int = Form(1),
|
||||
time_baseline_max: int = Form(90),
|
||||
time_baseline_max: int = Form(30),
|
||||
overlap_threshold: float = Form(0.5),
|
||||
spatial_baseline_max_meters: int = Form(3000),
|
||||
limit_footprint_center_distance: bool = Form(False),
|
||||
spatial_baseline_max_meters: int = Form(5000),
|
||||
limit_footprint_center_distance: bool = Form(True),
|
||||
max_temporal_baseline_days: Optional[int] = Form(None),
|
||||
pair_footprint_overlap_min_ratio: Optional[float] = Form(None),
|
||||
footprint_center_distance_max_meters: Optional[int] = Form(None),
|
||||
@@ -57,7 +57,7 @@ def get_pairing_request_from_form(
|
||||
master_date_to: Optional[str] = Form(None),
|
||||
slave_date_from: Optional[str] = Form(None),
|
||||
slave_date_to: Optional[str] = Form(None),
|
||||
strategy: str = Form("all"),
|
||||
strategy: str = Form("dinsar_production"),
|
||||
num_connections: int = Form(1),
|
||||
reference_image_id: Optional[int] = Form(None),
|
||||
allowed_satellites: Optional[str] = Form(None), # JSON string
|
||||
@@ -91,7 +91,7 @@ def get_pairing_request_from_form(
|
||||
master_date_to=master_date_to,
|
||||
slave_date_from=slave_date_from,
|
||||
slave_date_to=slave_date_to,
|
||||
strategy=strategy,
|
||||
strategy="dinsar_production",
|
||||
num_connections=num_connections,
|
||||
reference_image_id=reference_image_id,
|
||||
allowed_satellites=satellites_list,
|
||||
|
||||
@@ -41,6 +41,7 @@ from .dependencies import (
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
GF3_NATIVE_PREVIEW_SOURCE_FORMAT = "GF3_SARSCAPE_NATIVE_PREVIEW"
|
||||
LIST_QUERY_MAX_LIMIT = read_int_env(
|
||||
"LIST_QUERY_MAX_LIMIT",
|
||||
2000,
|
||||
@@ -188,6 +189,38 @@ def _radar_preview_paths(record: RadarDataORM) -> Tuple[str, str]:
|
||||
return raw_cache_path, geo_cache_path
|
||||
|
||||
|
||||
def _is_gf3_native_preview_record(record: RadarDataORM) -> bool:
|
||||
return str(record.source_format or "") == GF3_NATIVE_PREVIEW_SOURCE_FORMAT
|
||||
|
||||
|
||||
def _build_gf3_native_preview_status(record: RadarDataORM) -> RadarPreviewStatusInfo:
|
||||
preview_path = str(record.preview_cache_path or "")
|
||||
has_native_cache = (
|
||||
(record.preview_cache_status or "NONE") == "READY"
|
||||
and preview_path.lower().endswith(".webp")
|
||||
and os.path.exists(preview_path)
|
||||
)
|
||||
metadata = record.metadata_json or {}
|
||||
source_path = str(metadata.get("default_native_path") or "")
|
||||
source_found = bool(source_path and os.path.exists(source_path))
|
||||
return RadarPreviewStatusInfo(
|
||||
radar_id=record.id,
|
||||
status="READY" if has_native_cache else (record.preview_cache_status or "NONE"),
|
||||
cache_version=record.preview_cache_version,
|
||||
cache_updated_at=record.preview_cache_updated_at,
|
||||
has_geo_cache=has_native_cache,
|
||||
has_raw_cache=has_native_cache,
|
||||
source_found=source_found,
|
||||
fallback_in_use=False,
|
||||
message=(
|
||||
"GF3 native _geo WebP cache is available."
|
||||
if has_native_cache
|
||||
else "GF3 native _geo WebP cache has not been generated."
|
||||
),
|
||||
error=None if has_native_cache else record.preview_cache_error,
|
||||
)
|
||||
|
||||
|
||||
def _build_radar_preview_status(
|
||||
record: RadarDataORM,
|
||||
source_found: bool,
|
||||
@@ -228,6 +261,9 @@ async def _build_radar_preview_cache(
|
||||
db: AsyncSession,
|
||||
force: bool = False,
|
||||
) -> RadarPreviewStatusInfo:
|
||||
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)
|
||||
@@ -355,7 +391,33 @@ async def _get_cached_radar_preview(data_id: int, db: AsyncSession):
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail=f"ID为 {data_id} 的源数据不存在。")
|
||||
|
||||
if _is_gf3_native_preview_record(record):
|
||||
preview_path = str(record.preview_cache_path or "")
|
||||
if (
|
||||
(record.preview_cache_status or "NONE") == "READY"
|
||||
and preview_path.lower().endswith(".webp")
|
||||
and os.path.exists(preview_path)
|
||||
):
|
||||
return FileResponse(
|
||||
preview_path,
|
||||
media_type="image/webp",
|
||||
headers={"Cache-Control": "public, max-age=31536000"},
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="GF3 native _geo WebP cache has not been generated.")
|
||||
|
||||
raw_cache_path, geo_cache_path = _radar_preview_paths(record)
|
||||
if (
|
||||
(record.preview_cache_status or "NONE") == "READY"
|
||||
and record.preview_cache_path
|
||||
and str(record.preview_cache_path).lower().endswith(".webp")
|
||||
and os.path.exists(record.preview_cache_path)
|
||||
):
|
||||
return FileResponse(
|
||||
record.preview_cache_path,
|
||||
media_type="image/webp",
|
||||
headers={"Cache-Control": "public, max-age=31536000"},
|
||||
)
|
||||
|
||||
if os.path.exists(geo_cache_path):
|
||||
return FileResponse(
|
||||
geo_cache_path,
|
||||
@@ -664,6 +726,9 @@ async def get_radar_preview_status_endpoint(data_id: int, db: AsyncSession = Dep
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail=f"ID为 {data_id} 的源数据不存在。")
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import read_int_env
|
||||
from ..config import read_int_env, settings
|
||||
from ..database import get_db
|
||||
from ..services.job_queue_service import job_queue_service
|
||||
from ..services.task_service import TASK_LOG_DEFAULT_LIMIT, TASK_LOG_MAX_LIMIT, TASK_QUERY_MAX_OFFSET, task_service
|
||||
@@ -33,12 +35,14 @@ COPY_BATCH_MAX_COPY_ITEMS = read_int_env(
|
||||
minimum=1,
|
||||
maximum=200000,
|
||||
)
|
||||
COPY_DINSAR_PACKAGE_MODES = {"task_folder", "task_zip", "source_bundle"}
|
||||
COPY_DINSAR_PACKAGE_MODES = {"task_folder", "source_bundle"}
|
||||
_COPY_TARGET_NAME_RE = re.compile(r"[^0-9A-Za-z\u4e00-\u9fff._-]+")
|
||||
|
||||
|
||||
class CopyBatchRequest(BaseModel):
|
||||
batch_id: str = Field(max_length=COPY_BATCH_TEXT_MAX_LENGTH)
|
||||
dest_dir: str = Field(max_length=COPY_BATCH_TEXT_MAX_LENGTH)
|
||||
dest_dir: str = Field(default="", max_length=COPY_BATCH_TEXT_MAX_LENGTH)
|
||||
target_name: Optional[str] = Field(default=None, max_length=255)
|
||||
copy_statuses: Optional[List[str]] = None
|
||||
include_orbit_files: bool = False
|
||||
export_zip: bool = False
|
||||
@@ -46,7 +50,7 @@ class CopyBatchRequest(BaseModel):
|
||||
skip_existing: bool = True
|
||||
max_items: Optional[int] = None
|
||||
|
||||
@field_validator("batch_id", "dest_dir", mode="before")
|
||||
@field_validator("batch_id", mode="before")
|
||||
@classmethod
|
||||
def _normalize_required_text(cls, value):
|
||||
normalized = str(value or "").strip()
|
||||
@@ -54,6 +58,17 @@ class CopyBatchRequest(BaseModel):
|
||||
raise ValueError("Field must not be empty.")
|
||||
return normalized
|
||||
|
||||
@field_validator("dest_dir", mode="before")
|
||||
@classmethod
|
||||
def _normalize_optional_dest_dir(cls, value):
|
||||
return str(value or "").strip()
|
||||
|
||||
@field_validator("target_name", mode="before")
|
||||
@classmethod
|
||||
def _normalize_optional_target_name(cls, value):
|
||||
normalized = str(value or "").strip()
|
||||
return normalized or None
|
||||
|
||||
@field_validator("copy_statuses", mode="before")
|
||||
@classmethod
|
||||
def _validate_copy_statuses_length(cls, value):
|
||||
@@ -88,6 +103,10 @@ class CopyBatchRequest(BaseModel):
|
||||
@classmethod
|
||||
def _normalize_package_mode(cls, value):
|
||||
normalized = str(value or "task_folder").strip().lower()
|
||||
if normalized == "task_zip":
|
||||
normalized = "task_folder"
|
||||
if normalized in {"bundle", "dedupe_source"}:
|
||||
normalized = "source_bundle"
|
||||
if normalized not in COPY_DINSAR_PACKAGE_MODES:
|
||||
raise ValueError(
|
||||
f"package_mode must be one of: {sorted(COPY_DINSAR_PACKAGE_MODES)}."
|
||||
@@ -115,6 +134,28 @@ def _normalize_copy_batch_statuses(copy_statuses: Optional[List[str]]) -> List[s
|
||||
return normalized or ["COMPLETED"]
|
||||
|
||||
|
||||
def _safe_copy_target_name(value: Optional[str]) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="target_name 不能为空")
|
||||
if os.path.isabs(raw) or os.path.splitdrive(raw)[0] or "\\" in raw or "/" in raw:
|
||||
raise HTTPException(status_code=400, detail="target_name 只能是任务名,不能包含路径")
|
||||
normalized = _COPY_TARGET_NAME_RE.sub("_", raw).strip("._ ")
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=400, detail="target_name 不合法")
|
||||
if normalized.upper() in {"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "LPT1", "LPT2", "LPT3"}:
|
||||
raise HTTPException(status_code=400, detail="target_name 是 Windows 保留名称")
|
||||
return normalized[:120]
|
||||
|
||||
|
||||
def _server_copy_destination(package_mode: str, target_name: Optional[str]) -> str:
|
||||
safe_name = _safe_copy_target_name(target_name)
|
||||
root = settings.DINSAR_TASK_POOL_ROOT if package_mode == "task_folder" else settings.DATA_DISTRIBUTION_ROOT
|
||||
if not root:
|
||||
raise HTTPException(status_code=500, detail="服务器目标根目录未配置")
|
||||
return os.path.normpath(os.path.join(root, safe_name))
|
||||
|
||||
|
||||
@router.post("/tools/copy-ps-stack")
|
||||
async def copy_ps_stack_endpoint(
|
||||
request: CopyBatchRequest,
|
||||
@@ -124,20 +165,26 @@ async def copy_ps_stack_endpoint(
|
||||
"""
|
||||
Start PS-InSAR copy task from a batch.
|
||||
"""
|
||||
_validate_export_path(request.dest_dir, "dest_dir")
|
||||
try:
|
||||
copy_statuses = _normalize_copy_batch_statuses(request.copy_statuses)
|
||||
if request.target_name:
|
||||
dest_dir = _server_copy_destination("source_bundle", request.target_name)
|
||||
else:
|
||||
dest_dir = request.dest_dir
|
||||
_validate_export_path(dest_dir, "dest_dir")
|
||||
params = {
|
||||
"dest_dir": request.dest_dir,
|
||||
"dest_dir": dest_dir,
|
||||
"target_name": request.target_name,
|
||||
"file_type": "PS_STACK",
|
||||
"batch_id": request.batch_id,
|
||||
"copy_statuses": copy_statuses,
|
||||
}
|
||||
task_id = await task_service.create_task("COPY_DATA", f"PS数据分发: {request.dest_dir}", params=params)
|
||||
task_id = await task_service.create_task("COPY_DATA", f"PS数据分发: {request.target_name or dest_dir}", params=params)
|
||||
|
||||
payload = {
|
||||
"file_type": "PS_STACK",
|
||||
"dest_dir": request.dest_dir,
|
||||
"dest_dir": dest_dir,
|
||||
"target_name": request.target_name,
|
||||
"batch_id": request.batch_id,
|
||||
"copy_statuses": copy_statuses,
|
||||
}
|
||||
@@ -150,7 +197,8 @@ async def copy_ps_stack_endpoint(
|
||||
detail={
|
||||
"task_id": task_id,
|
||||
"batch_id": request.batch_id,
|
||||
"dest_dir": request.dest_dir,
|
||||
"dest_dir": dest_dir,
|
||||
"target_name": request.target_name,
|
||||
"copy_statuses": copy_statuses,
|
||||
},
|
||||
)
|
||||
@@ -169,32 +217,41 @@ async def copy_dinsar_pairs_endpoint(
|
||||
"""
|
||||
Start D-InSAR copy task from a batch.
|
||||
"""
|
||||
_validate_export_path(request.dest_dir, "dest_dir")
|
||||
try:
|
||||
copy_statuses = _normalize_copy_batch_statuses(request.copy_statuses)
|
||||
package_mode = request.package_mode
|
||||
if bool(request.export_zip) and package_mode == "task_folder":
|
||||
package_mode = "task_zip"
|
||||
if request.target_name:
|
||||
dest_dir = _server_copy_destination(package_mode, request.target_name)
|
||||
else:
|
||||
dest_dir = request.dest_dir
|
||||
_validate_export_path(dest_dir, "dest_dir")
|
||||
params = {
|
||||
"dest_dir": request.dest_dir,
|
||||
"dest_dir": dest_dir,
|
||||
"target_name": request.target_name,
|
||||
"file_type": "DINSAR_PAIRS",
|
||||
"batch_id": request.batch_id,
|
||||
"copy_statuses": copy_statuses,
|
||||
"include_orbit_files": bool(request.include_orbit_files),
|
||||
"export_zip": package_mode == "task_zip",
|
||||
"export_zip": False,
|
||||
"package_mode": package_mode,
|
||||
"skip_existing": bool(request.skip_existing),
|
||||
"max_items": request.max_items,
|
||||
}
|
||||
task_id = await task_service.create_task("COPY_DATA", f"D-InSAR 数据分发: {request.dest_dir}", params=params)
|
||||
task_name = (
|
||||
f"D-InSAR 生产数据准备: {request.target_name or dest_dir}"
|
||||
if package_mode == "task_folder"
|
||||
else f"D-InSAR 数据分发: {request.target_name or dest_dir}"
|
||||
)
|
||||
task_id = await task_service.create_task("COPY_DATA", task_name, params=params)
|
||||
|
||||
payload = {
|
||||
"file_type": "DINSAR_PAIRS",
|
||||
"dest_dir": request.dest_dir,
|
||||
"dest_dir": dest_dir,
|
||||
"target_name": request.target_name,
|
||||
"batch_id": request.batch_id,
|
||||
"copy_statuses": copy_statuses,
|
||||
"include_orbit_files": bool(request.include_orbit_files),
|
||||
"export_zip": package_mode == "task_zip",
|
||||
"export_zip": False,
|
||||
"package_mode": package_mode,
|
||||
"skip_existing": bool(request.skip_existing),
|
||||
"max_items": request.max_items,
|
||||
@@ -208,10 +265,11 @@ async def copy_dinsar_pairs_endpoint(
|
||||
detail={
|
||||
"task_id": task_id,
|
||||
"batch_id": request.batch_id,
|
||||
"dest_dir": request.dest_dir,
|
||||
"dest_dir": dest_dir,
|
||||
"target_name": request.target_name,
|
||||
"copy_statuses": copy_statuses,
|
||||
"include_orbit_files": bool(request.include_orbit_files),
|
||||
"export_zip": package_mode == "task_zip",
|
||||
"export_zip": False,
|
||||
"package_mode": package_mode,
|
||||
"skip_existing": bool(request.skip_existing),
|
||||
"max_items": request.max_items,
|
||||
|
||||
Reference in New Issue
Block a user