Fix D-InSAR task log access after completion
This commit is contained in:
@@ -4,7 +4,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -38,6 +38,15 @@ def _new_session():
|
|||||||
return database.AsyncSessionLocal()
|
return database.AsyncSessionLocal()
|
||||||
|
|
||||||
|
|
||||||
|
def _split_csv_param(raw: Optional[str]) -> List[str]:
|
||||||
|
values: List[str] = []
|
||||||
|
for chunk in str(raw or "").split(","):
|
||||||
|
text = chunk.strip()
|
||||||
|
if text and text not in values:
|
||||||
|
values.append(text)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/active", response_model=List[TaskInfo])
|
@router.get("/tasks/active", response_model=List[TaskInfo])
|
||||||
async def get_active_tasks(limit: int = TASK_ACTIVE_DEFAULT_LIMIT, offset: int = 0):
|
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)))
|
safe_limit = min(TASK_ACTIVE_MAX_LIMIT, max(1, int(limit or TASK_ACTIVE_DEFAULT_LIMIT)))
|
||||||
@@ -46,6 +55,24 @@ async def get_active_tasks(limit: int = TASK_ACTIVE_DEFAULT_LIMIT, offset: int =
|
|||||||
return [TaskInfo.model_validate(task) for task in orm_tasks]
|
return [TaskInfo.model_validate(task) for task in orm_tasks]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/recent", response_model=List[TaskInfo])
|
||||||
|
async def get_recent_tasks(
|
||||||
|
task_types: Optional[str] = Query(None, description="Comma-separated task types."),
|
||||||
|
statuses: Optional[str] = Query(None, description="Comma-separated task statuses."),
|
||||||
|
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)))
|
||||||
|
orm_tasks = await task_service.list_tasks(
|
||||||
|
task_types=_split_csv_param(task_types),
|
||||||
|
statuses=_split_csv_param(statuses),
|
||||||
|
limit=safe_limit,
|
||||||
|
offset=safe_offset,
|
||||||
|
)
|
||||||
|
return [TaskInfo.model_validate(task) for task in orm_tasks]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/active/stream")
|
@router.get("/tasks/active/stream")
|
||||||
async def stream_active_tasks(request: Request):
|
async def stream_active_tasks(request: Request):
|
||||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from ..models import (
|
|||||||
DinsarProductionExecutionORM,
|
DinsarProductionExecutionORM,
|
||||||
DinsarProductionRunItemORM,
|
DinsarProductionRunItemORM,
|
||||||
DinsarProductionRunORM,
|
DinsarProductionRunORM,
|
||||||
|
SystemTaskORM,
|
||||||
)
|
)
|
||||||
from .envi_service import RUNTIME_DIR, _collect_task_folders, _resolve_dinsar_pair_identity, _to_local_path
|
from .envi_service import RUNTIME_DIR, _collect_task_folders, _resolve_dinsar_pair_identity, _to_local_path
|
||||||
from .task_service import task_service
|
from .task_service import task_service
|
||||||
@@ -206,6 +207,41 @@ def _public_run_status(value: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class DinsarProductionService:
|
class DinsarProductionService:
|
||||||
|
async def reconcile_run_with_task(
|
||||||
|
self,
|
||||||
|
run: DinsarProductionRunORM,
|
||||||
|
task: Optional[SystemTaskORM],
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> bool:
|
||||||
|
if task is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
run_status = str(run.status or "").strip().upper()
|
||||||
|
task_status = str(task.status or "").strip().upper()
|
||||||
|
if run_status in TERMINAL_RUN_STATUSES:
|
||||||
|
return False
|
||||||
|
if task_status not in {"COMPLETED", "FAILED", "CANCELLED"}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if task_status == "COMPLETED":
|
||||||
|
next_status = RUN_STATUS_COMPLETED
|
||||||
|
elif task_status == "CANCELLED":
|
||||||
|
next_status = RUN_STATUS_CANCELLED
|
||||||
|
run.cancel_requested = True
|
||||||
|
else:
|
||||||
|
next_status = RUN_STATUS_FAILED
|
||||||
|
|
||||||
|
summary_payload = dict(run.summary_json or {})
|
||||||
|
summary_payload["reconciled_from_task_status"] = task_status
|
||||||
|
latest_message = str(task.message or "").strip() or f"Reconciled from task status {task_status}"
|
||||||
|
run.status = next_status
|
||||||
|
run.summary_json = summary_payload
|
||||||
|
run.latest_message = latest_message
|
||||||
|
run.ended_at = run.ended_at or _utcnow()
|
||||||
|
await self.refresh_run_counters(run, db=db, latest_message=latest_message)
|
||||||
|
return True
|
||||||
|
|
||||||
async def create_run(
|
async def create_run(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -374,6 +410,26 @@ class DinsarProductionService:
|
|||||||
)
|
)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
runs = result.scalars().all()
|
runs = result.scalars().all()
|
||||||
|
pending_reconcile = [
|
||||||
|
run
|
||||||
|
for run in runs
|
||||||
|
if run.task_id and str(run.status or "").strip().upper() not in TERMINAL_RUN_STATUSES
|
||||||
|
]
|
||||||
|
if pending_reconcile:
|
||||||
|
task_ids = [run.task_id for run in pending_reconcile if run.task_id]
|
||||||
|
task_result = await db.execute(
|
||||||
|
select(SystemTaskORM).where(SystemTaskORM.task_id.in_(task_ids))
|
||||||
|
)
|
||||||
|
tasks_by_id = {task.task_id: task for task in task_result.scalars().all()}
|
||||||
|
changed = False
|
||||||
|
for run in pending_reconcile:
|
||||||
|
changed = await self.reconcile_run_with_task(
|
||||||
|
run,
|
||||||
|
tasks_by_id.get(run.task_id),
|
||||||
|
db=db,
|
||||||
|
) or changed
|
||||||
|
if changed:
|
||||||
|
await db.commit()
|
||||||
return {
|
return {
|
||||||
"runs": [
|
"runs": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1823,7 +1823,43 @@ async def _handle_idl_run_dinsar(job: SystemJobORM) -> None:
|
|||||||
payload = job.payload or {}
|
payload = job.payload or {}
|
||||||
production_run_id = str(payload.get("production_run_id") or "").strip()
|
production_run_id = str(payload.get("production_run_id") or "").strip()
|
||||||
if production_run_id:
|
if production_run_id:
|
||||||
|
try:
|
||||||
await _run_dinsar_production_controller(job)
|
await _run_dinsar_production_controller(job)
|
||||||
|
except Exception as exc:
|
||||||
|
latest_message = f"D-InSAR production controller failed: {exc}"
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
run = await dinsar_production_service.get_run(production_run_id, db)
|
||||||
|
if run is not None and str(run.status or "").strip().upper() not in {"COMPLETED", "FAILED", "CANCELLED"}:
|
||||||
|
summary_payload = dict(run.summary_json or {})
|
||||||
|
summary_payload["controller_error"] = str(exc)
|
||||||
|
await dinsar_production_service.finalize_run(
|
||||||
|
run,
|
||||||
|
db=db,
|
||||||
|
status="FAILED",
|
||||||
|
summary_payload=summary_payload,
|
||||||
|
latest_message=latest_message,
|
||||||
|
)
|
||||||
|
dinsar_production_service.append_run_log(
|
||||||
|
run.run_id,
|
||||||
|
f"[controller-failed] {exc}",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_task = await task_service.get_task(job.task_id)
|
||||||
|
if current_task and current_task.status not in {"COMPLETED", "FAILED", "CANCELLED"}:
|
||||||
|
await task_service.add_log(job.task_id, "ERROR", latest_message)
|
||||||
|
await task_service.update_task(
|
||||||
|
job.task_id,
|
||||||
|
status="FAILED",
|
||||||
|
progress=100,
|
||||||
|
message=latest_message,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
return
|
return
|
||||||
|
|
||||||
mode = payload.get("mode", "metatask")
|
mode = payload.get("mode", "metatask")
|
||||||
|
|||||||
@@ -65,6 +65,19 @@ def _clamp_pagination(limit: int, offset: int, *, default_limit: int, max_limit:
|
|||||||
return safe_limit, safe_offset
|
return safe_limit, safe_offset
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_string_list(values: Optional[List[str]], *, uppercase: bool = False) -> List[str]:
|
||||||
|
normalized: List[str] = []
|
||||||
|
for raw in values or []:
|
||||||
|
text = str(raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if uppercase:
|
||||||
|
text = text.upper()
|
||||||
|
if text not in normalized:
|
||||||
|
normalized.append(text)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def _task_type_lock_key(task_type: str) -> int:
|
def _task_type_lock_key(task_type: str) -> int:
|
||||||
normalized = (task_type or "").strip().lower().encode("utf-8")
|
normalized = (task_type or "").strip().lower().encode("utf-8")
|
||||||
digest = hashlib.sha256(normalized).digest()
|
digest = hashlib.sha256(normalized).digest()
|
||||||
@@ -110,6 +123,27 @@ class TaskService:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _expire_zombie_tasks(self, db: AsyncSession) -> None:
|
||||||
|
timeout_threshold = datetime.now() - timedelta(minutes=TASK_TIMEOUT_MINUTES)
|
||||||
|
result = await db.execute(
|
||||||
|
select(SystemTaskORM).where(
|
||||||
|
and_(
|
||||||
|
SystemTaskORM.status == "RUNNING",
|
||||||
|
SystemTaskORM.updated_at < timeout_threshold
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
zombie_tasks = result.scalars().all()
|
||||||
|
|
||||||
|
for task in zombie_tasks:
|
||||||
|
task.status = "FAILED"
|
||||||
|
task.message = "系统检测超时: 任务被认为已失效 (心跳超时)"
|
||||||
|
log = TaskLogORM(task_id=task.task_id, log_level="WARNING", message="任务因超时被自动标记为失败")
|
||||||
|
db.add(log)
|
||||||
|
|
||||||
|
if zombie_tasks:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
async def create_task(
|
async def create_task(
|
||||||
self,
|
self,
|
||||||
task_type: str,
|
task_type: str,
|
||||||
@@ -269,26 +303,7 @@ class TaskService:
|
|||||||
max_limit=TASK_ACTIVE_MAX_LIMIT,
|
max_limit=TASK_ACTIVE_MAX_LIMIT,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1. 查找僵尸任务并标记为失败
|
await self._expire_zombie_tasks(db)
|
||||||
timeout_threshold = datetime.now() - timedelta(minutes=TASK_TIMEOUT_MINUTES)
|
|
||||||
result = await db.execute(
|
|
||||||
select(SystemTaskORM).where(
|
|
||||||
and_(
|
|
||||||
SystemTaskORM.status == "RUNNING",
|
|
||||||
SystemTaskORM.updated_at < timeout_threshold
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
zombie_tasks = result.scalars().all()
|
|
||||||
|
|
||||||
for task in zombie_tasks:
|
|
||||||
task.status = "FAILED"
|
|
||||||
task.message = "系统检测超时: 任务被认为已失效 (心跳超时)"
|
|
||||||
log = TaskLogORM(task_id=task.task_id, log_level="WARNING", message="任务因超时被自动标记为失败")
|
|
||||||
db.add(log)
|
|
||||||
|
|
||||||
if zombie_tasks:
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# 2. 获取活跃任务
|
# 2. 获取活跃任务
|
||||||
active_result = await db.execute(
|
active_result = await db.execute(
|
||||||
@@ -304,6 +319,48 @@ class TaskService:
|
|||||||
if gen_db:
|
if gen_db:
|
||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
async def list_tasks(
|
||||||
|
self,
|
||||||
|
task_types: Optional[List[str]] = None,
|
||||||
|
statuses: Optional[List[str]] = None,
|
||||||
|
limit: int = TASK_ACTIVE_DEFAULT_LIMIT,
|
||||||
|
offset: int = 0,
|
||||||
|
db: Optional[AsyncSession] = None,
|
||||||
|
) -> List[SystemTaskORM]:
|
||||||
|
gen_db = db is None
|
||||||
|
if gen_db:
|
||||||
|
db = get_db_session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
safe_limit, safe_offset = _clamp_pagination(
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
default_limit=TASK_ACTIVE_DEFAULT_LIMIT,
|
||||||
|
max_limit=TASK_ACTIVE_MAX_LIMIT,
|
||||||
|
)
|
||||||
|
await self._expire_zombie_tasks(db)
|
||||||
|
|
||||||
|
normalized_task_types = _normalize_string_list(task_types)
|
||||||
|
normalized_statuses = _normalize_string_list(statuses, uppercase=True)
|
||||||
|
|
||||||
|
stmt = select(SystemTaskORM)
|
||||||
|
if normalized_task_types:
|
||||||
|
stmt = stmt.where(SystemTaskORM.task_type.in_(normalized_task_types))
|
||||||
|
if normalized_statuses:
|
||||||
|
stmt = stmt.where(SystemTaskORM.status.in_(normalized_statuses))
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
stmt
|
||||||
|
.order_by(SystemTaskORM.created_at.desc(), SystemTaskORM.id.desc())
|
||||||
|
.offset(safe_offset)
|
||||||
|
.limit(safe_limit)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
finally:
|
||||||
|
if gen_db:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
async def get_task(self, task_id: str, db: Optional[AsyncSession] = None) -> Optional[SystemTaskORM]:
|
async def get_task(self, task_id: str, db: Optional[AsyncSession] = None) -> Optional[SystemTaskORM]:
|
||||||
gen_db = db is None
|
gen_db = db is None
|
||||||
if gen_db:
|
if gen_db:
|
||||||
|
|||||||
@@ -253,6 +253,20 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
const currentProfiles = currentEngineObj?.profiles || EMPTY_ARRAY;
|
const currentProfiles = currentEngineObj?.profiles || EMPTY_ARRAY;
|
||||||
const currentProfileObj = currentProfiles.find(profile => profile.code === selectedProfile) || null;
|
const currentProfileObj = currentProfiles.find(profile => profile.code === selectedProfile) || null;
|
||||||
const currentParamSchema = currentProfileObj?.params_schema || EMPTY_OBJECT;
|
const currentParamSchema = currentProfileObj?.params_schema || EMPTY_OBJECT;
|
||||||
|
const latestRunWithTask = runs.find(run => run?.task_id) || null;
|
||||||
|
const monitoredTask = activeTask || (
|
||||||
|
latestRunWithTask
|
||||||
|
? {
|
||||||
|
task_id: latestRunWithTask.task_id,
|
||||||
|
task_type: latestRunWithTask.engine === 'isce2' ? 'ISCE2_RUN' : 'IDL_RUN_DINSAR',
|
||||||
|
status: latestRunWithTask.raw_status || latestRunWithTask.status,
|
||||||
|
progress: latestRunWithTask.raw_status === 'COMPLETED' || latestRunWithTask.status === 'success' ? 100 : null,
|
||||||
|
message: latestRunWithTask.message || '最近一次任务',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const logTaskId = monitoredTask?.task_id || '';
|
||||||
|
const showingRecentTask = !activeTask && !!monitoredTask;
|
||||||
|
|
||||||
const loadEngines = useCallback(async () => {
|
const loadEngines = useCallback(async () => {
|
||||||
setEnginesLoading(true);
|
setEnginesLoading(true);
|
||||||
@@ -306,7 +320,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDeleteTaskLog = useCallback(async logId => {
|
const handleDeleteTaskLog = useCallback(async logId => {
|
||||||
const taskId = activeTask?.task_id;
|
const taskId = logTaskId;
|
||||||
if (!taskId || !logId || taskLogActionLoading) return;
|
if (!taskId || !logId || taskLogActionLoading) return;
|
||||||
if (!window.confirm('确定要删除这条任务日志吗?')) return;
|
if (!window.confirm('确定要删除这条任务日志吗?')) return;
|
||||||
|
|
||||||
@@ -322,12 +336,12 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
setTaskLogDeletingId(null);
|
setTaskLogDeletingId(null);
|
||||||
setTaskLogActionLoading(false);
|
setTaskLogActionLoading(false);
|
||||||
}
|
}
|
||||||
}, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading]);
|
}, [logTaskId, loadTaskLogs, taskLogActionLoading]);
|
||||||
|
|
||||||
const handleClearTaskLogs = useCallback(async () => {
|
const handleClearTaskLogs = useCallback(async () => {
|
||||||
const taskId = activeTask?.task_id;
|
const taskId = logTaskId;
|
||||||
if (!taskId || taskLogActionLoading || taskLogs.length === 0) return;
|
if (!taskId || taskLogActionLoading || taskLogs.length === 0) return;
|
||||||
if (!window.confirm('确定要清空当前任务的全部日志吗?')) return;
|
if (!window.confirm(`确定要清空任务 ${taskId} 的全部日志吗?`)) return;
|
||||||
|
|
||||||
setTaskLogActionLoading(true);
|
setTaskLogActionLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -339,7 +353,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
} finally {
|
} finally {
|
||||||
setTaskLogActionLoading(false);
|
setTaskLogActionLoading(false);
|
||||||
}
|
}
|
||||||
}, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
|
}, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEngines();
|
loadEngines();
|
||||||
@@ -348,17 +362,20 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
}, [loadActiveTask, loadEngines, loadRuns]);
|
}, [loadActiveTask, loadEngines, loadRuns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(loadActiveTask, 5000);
|
const timer = setInterval(() => {
|
||||||
|
loadRuns();
|
||||||
|
loadActiveTask();
|
||||||
|
}, 5000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [loadActiveTask]);
|
}, [loadActiveTask, loadRuns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const taskId = activeTask?.task_id || '';
|
const taskId = logTaskId;
|
||||||
loadTaskLogs(taskId);
|
loadTaskLogs(taskId);
|
||||||
if (!taskId) return undefined;
|
if (!taskId) return undefined;
|
||||||
const timer = setInterval(() => loadTaskLogs(taskId), 5000);
|
const timer = setInterval(() => loadTaskLogs(taskId), 5000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [activeTask?.task_id, loadTaskLogs]);
|
}, [logTaskId, loadTaskLogs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentProfiles.length > 0) {
|
if (currentProfiles.length > 0) {
|
||||||
@@ -672,31 +689,65 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTask && (
|
{monitoredTask && (
|
||||||
<div style={{ marginBottom: 10, padding: '8px 10px', background: '#fefce8', borderRadius: 6, border: '1px solid #fde68a' }}>
|
<div
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#92400e', marginBottom: 4 }}>当前任务</div>
|
style={{
|
||||||
<div style={{ fontSize: 12, color: '#78350f', wordBreak: 'break-all' }}>
|
marginBottom: 10,
|
||||||
{activeTask.task_id} - {formatTaskType(activeTask.task_type)} - {formatStatus(activeTask.status)} - {activeTask.message}
|
padding: '8px 10px',
|
||||||
|
background: showingRecentTask ? '#eff6ff' : '#fefce8',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: `1px solid ${showingRecentTask ? '#bfdbfe' : '#fde68a'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: showingRecentTask ? '#1d4ed8' : '#92400e',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showingRecentTask ? '最近一次任务' : '当前任务'}
|
||||||
</div>
|
</div>
|
||||||
{activeTask.progress != null && (
|
<div style={{ fontSize: 12, color: showingRecentTask ? '#1e40af' : '#78350f', wordBreak: 'break-all' }}>
|
||||||
|
{monitoredTask.task_id} - {formatTaskType(monitoredTask.task_type)} - {formatStatus(monitoredTask.status)} - {monitoredTask.message}
|
||||||
|
</div>
|
||||||
|
{monitoredTask.progress != null && (
|
||||||
<div style={{ marginTop: 6 }}>
|
<div style={{ marginTop: 6 }}>
|
||||||
<div style={{ height: 6, background: '#fde68a', borderRadius: 3, overflow: 'hidden' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
height: 6,
|
||||||
|
background: showingRecentTask ? '#dbeafe' : '#fde68a',
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
width: `${activeTask.progress}%`,
|
width: `${monitoredTask.progress}%`,
|
||||||
background: '#f59e0b',
|
background: showingRecentTask ? '#3b82f6' : '#f59e0b',
|
||||||
transition: 'width 0.3s',
|
transition: 'width 0.3s',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: '#92400e', marginTop: 2 }}>{activeTask.progress}%</div>
|
<div style={{ fontSize: 11, color: showingRecentTask ? '#1d4ed8' : '#92400e', marginTop: 2 }}>{monitoredTask.progress}%</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: 8, background: '#fff', border: '1px solid #fde68a', borderRadius: 6, padding: '8px 10px' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
background: '#fff',
|
||||||
|
border: `1px solid ${showingRecentTask ? '#bfdbfe' : '#fde68a'}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '8px 10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#92400e' }}>任务日志</div>
|
<div style={{ fontSize: 11, fontWeight: 600, color: showingRecentTask ? '#1d4ed8' : '#92400e' }}>
|
||||||
|
{showingRecentTask ? '最近一次任务日志' : '当前任务日志'}
|
||||||
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
onClick={handleClearTaskLogs}
|
onClick={handleClearTaskLogs}
|
||||||
@@ -716,9 +767,9 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{taskLogsLoading ? (
|
{taskLogsLoading ? (
|
||||||
<div style={{ fontSize: 11, color: '#a16207' }}>加载中...</div>
|
<div style={{ fontSize: 11, color: showingRecentTask ? '#1d4ed8' : '#a16207' }}>加载中...</div>
|
||||||
) : taskLogs.length === 0 ? (
|
) : taskLogs.length === 0 ? (
|
||||||
<div style={{ fontSize: 11, color: '#a16207' }}>暂无日志。</div>
|
<div style={{ fontSize: 11, color: showingRecentTask ? '#1d4ed8' : '#a16207' }}>暂无日志。</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ maxHeight: 220, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ maxHeight: 220, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{taskLogs.map((log, index) => (
|
{taskLogs.map((log, index) => (
|
||||||
@@ -731,7 +782,15 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ flex: 1, minWidth: 0, fontSize: 11, lineHeight: 1.45, color: log.level === 'WARNING' ? '#b45309' : '#334155' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
color: log.level === 'ERROR' ? '#b91c1c' : log.level === 'WARNING' ? '#b45309' : '#334155',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ color: '#64748b' }}>
|
<div style={{ color: '#64748b' }}>
|
||||||
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
|
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { scanDinsarResults } from './api/dinsar';
|
import { scanDinsarResults } from './api/dinsar';
|
||||||
import { extractDispResults } from './api/idl';
|
import { extractDispResults } from './api/idl';
|
||||||
import { clearTaskLogs, deleteTaskLog, getActiveTasks, getTaskLogs } from './api/tasks';
|
import { clearTaskLogs, deleteTaskLog, getActiveTasks, getRecentTasks, getTaskLogs } from './api/tasks';
|
||||||
import DinsarCatalogPanel from './components/DinsarCatalogPanel';
|
import DinsarCatalogPanel from './components/DinsarCatalogPanel';
|
||||||
|
|
||||||
const card = {
|
const card = {
|
||||||
@@ -52,10 +52,14 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
|
|
||||||
const [activeTask, setActiveTask] = useState(null);
|
const [activeTask, setActiveTask] = useState(null);
|
||||||
|
const [recentTask, setRecentTask] = useState(null);
|
||||||
const [taskLogs, setTaskLogs] = useState([]);
|
const [taskLogs, setTaskLogs] = useState([]);
|
||||||
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
|
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
|
||||||
const [taskLogActionLoading, setTaskLogActionLoading] = useState(false);
|
const [taskLogActionLoading, setTaskLogActionLoading] = useState(false);
|
||||||
const [taskLogDeletingId, setTaskLogDeletingId] = useState(null);
|
const [taskLogDeletingId, setTaskLogDeletingId] = useState(null);
|
||||||
|
const monitoredTask = activeTask || recentTask;
|
||||||
|
const logTaskId = monitoredTask?.task_id || '';
|
||||||
|
const showingRecentTask = !activeTask && !!recentTask;
|
||||||
|
|
||||||
const loadActiveTask = useCallback(async () => {
|
const loadActiveTask = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -68,6 +72,15 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadRecentTask = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const tasks = await getRecentTasks(PRODUCT_TASK_TYPES, [], 1, 0);
|
||||||
|
setRecentTask(Array.isArray(tasks) ? (tasks[0] || null) : null);
|
||||||
|
} catch {
|
||||||
|
setRecentTask(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadTaskLogs = useCallback(async taskId => {
|
const loadTaskLogs = useCallback(async taskId => {
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
setTaskLogs([]);
|
setTaskLogs([]);
|
||||||
@@ -85,7 +98,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDeleteTaskLog = useCallback(async logId => {
|
const handleDeleteTaskLog = useCallback(async logId => {
|
||||||
const taskId = activeTask?.task_id;
|
const taskId = logTaskId;
|
||||||
if (!taskId || !logId || taskLogActionLoading) return;
|
if (!taskId || !logId || taskLogActionLoading) return;
|
||||||
if (!window.confirm('确定要删除这条任务日志吗?')) return;
|
if (!window.confirm('确定要删除这条任务日志吗?')) return;
|
||||||
|
|
||||||
@@ -101,12 +114,12 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
setTaskLogDeletingId(null);
|
setTaskLogDeletingId(null);
|
||||||
setTaskLogActionLoading(false);
|
setTaskLogActionLoading(false);
|
||||||
}
|
}
|
||||||
}, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading]);
|
}, [logTaskId, loadTaskLogs, taskLogActionLoading]);
|
||||||
|
|
||||||
const handleClearTaskLogs = useCallback(async () => {
|
const handleClearTaskLogs = useCallback(async () => {
|
||||||
const taskId = activeTask?.task_id;
|
const taskId = logTaskId;
|
||||||
if (!taskId || taskLogActionLoading || taskLogs.length === 0) return;
|
if (!taskId || taskLogActionLoading || taskLogs.length === 0) return;
|
||||||
if (!window.confirm('确定要清空当前任务的全部日志吗?')) return;
|
if (!window.confirm(`确定要清空任务 ${taskId} 的全部日志吗?`)) return;
|
||||||
|
|
||||||
setTaskLogActionLoading(true);
|
setTaskLogActionLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -118,24 +131,28 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
} finally {
|
} finally {
|
||||||
setTaskLogActionLoading(false);
|
setTaskLogActionLoading(false);
|
||||||
}
|
}
|
||||||
}, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
|
}, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadActiveTask();
|
loadActiveTask();
|
||||||
}, [loadActiveTask]);
|
loadRecentTask();
|
||||||
|
}, [loadActiveTask, loadRecentTask]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(loadActiveTask, 5000);
|
const timer = setInterval(() => {
|
||||||
|
loadActiveTask();
|
||||||
|
loadRecentTask();
|
||||||
|
}, 5000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [loadActiveTask]);
|
}, [loadActiveTask, loadRecentTask]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const taskId = activeTask?.task_id || '';
|
const taskId = logTaskId;
|
||||||
loadTaskLogs(taskId);
|
loadTaskLogs(taskId);
|
||||||
if (!taskId) return undefined;
|
if (!taskId) return undefined;
|
||||||
const timer = setInterval(() => loadTaskLogs(taskId), 5000);
|
const timer = setInterval(() => loadTaskLogs(taskId), 5000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [activeTask?.task_id, loadTaskLogs]);
|
}, [logTaskId, loadTaskLogs]);
|
||||||
|
|
||||||
const handleExtract = async () => {
|
const handleExtract = async () => {
|
||||||
if (!extractRootDir.trim()) return;
|
if (!extractRootDir.trim()) return;
|
||||||
@@ -165,6 +182,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
onJobQueued?.(result.task_id);
|
onJobQueued?.(result.task_id);
|
||||||
}
|
}
|
||||||
loadActiveTask();
|
loadActiveTask();
|
||||||
|
loadRecentTask();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError(true);
|
setActionError(true);
|
||||||
setActionMessage(err?.response?.data?.detail || err.message || 'D-InSAR结果扫描失败');
|
setActionMessage(err?.response?.data?.detail || err.message || 'D-InSAR结果扫描失败');
|
||||||
@@ -271,7 +289,10 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
<strong style={{ fontSize: 14 }}>产物任务监控</strong>
|
<strong style={{ fontSize: 14 }}>产物任务监控</strong>
|
||||||
<button
|
<button
|
||||||
onClick={loadActiveTask}
|
onClick={() => {
|
||||||
|
loadActiveTask();
|
||||||
|
loadRecentTask();
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
padding: '3px 10px',
|
padding: '3px 10px',
|
||||||
@@ -285,33 +306,66 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!activeTask ? (
|
{!monitoredTask ? (
|
||||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>当前没有正在执行的产物处理任务。</div>
|
<div style={{ fontSize: 12, color: '#94a3b8' }}>当前没有正在执行的产物处理任务。</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ padding: '8px 10px', background: '#fefce8', borderRadius: 6, border: '1px solid #fde68a' }}>
|
<div
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#92400e', marginBottom: 4 }}>当前任务</div>
|
style={{
|
||||||
<div style={{ fontSize: 12, color: '#78350f', wordBreak: 'break-all' }}>
|
padding: '8px 10px',
|
||||||
{activeTask.task_id} - {formatTaskType(activeTask.task_type)} - {formatStatus(activeTask.status)} - {activeTask.message}
|
background: showingRecentTask ? '#eff6ff' : '#fefce8',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: `1px solid ${showingRecentTask ? '#bfdbfe' : '#fde68a'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: showingRecentTask ? '#1d4ed8' : '#92400e',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showingRecentTask ? '最近一次任务' : '当前任务'}
|
||||||
</div>
|
</div>
|
||||||
{activeTask.progress != null && (
|
<div style={{ fontSize: 12, color: showingRecentTask ? '#1e40af' : '#78350f', wordBreak: 'break-all' }}>
|
||||||
|
{monitoredTask.task_id} - {formatTaskType(monitoredTask.task_type)} - {formatStatus(monitoredTask.status)} - {monitoredTask.message}
|
||||||
|
</div>
|
||||||
|
{monitoredTask.progress != null && (
|
||||||
<div style={{ marginTop: 6 }}>
|
<div style={{ marginTop: 6 }}>
|
||||||
<div style={{ height: 6, background: '#fde68a', borderRadius: 3, overflow: 'hidden' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
height: 6,
|
||||||
|
background: showingRecentTask ? '#dbeafe' : '#fde68a',
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
width: `${activeTask.progress}%`,
|
width: `${monitoredTask.progress}%`,
|
||||||
background: '#f59e0b',
|
background: showingRecentTask ? '#3b82f6' : '#f59e0b',
|
||||||
transition: 'width 0.3s',
|
transition: 'width 0.3s',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: '#92400e', marginTop: 2 }}>{activeTask.progress}%</div>
|
<div style={{ fontSize: 11, color: showingRecentTask ? '#1d4ed8' : '#92400e', marginTop: 2 }}>{monitoredTask.progress}%</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: 8, background: '#fff', border: '1px solid #fde68a', borderRadius: 6, padding: '8px 10px' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
background: '#fff',
|
||||||
|
border: `1px solid ${showingRecentTask ? '#bfdbfe' : '#fde68a'}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '8px 10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#92400e' }}>任务日志</div>
|
<div style={{ fontSize: 11, fontWeight: 600, color: showingRecentTask ? '#1d4ed8' : '#92400e' }}>
|
||||||
|
{showingRecentTask ? '最近一次任务日志' : '当前任务日志'}
|
||||||
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
onClick={handleClearTaskLogs}
|
onClick={handleClearTaskLogs}
|
||||||
@@ -331,9 +385,9 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{taskLogsLoading ? (
|
{taskLogsLoading ? (
|
||||||
<div style={{ fontSize: 11, color: '#a16207' }}>加载中...</div>
|
<div style={{ fontSize: 11, color: showingRecentTask ? '#1d4ed8' : '#a16207' }}>加载中...</div>
|
||||||
) : taskLogs.length === 0 ? (
|
) : taskLogs.length === 0 ? (
|
||||||
<div style={{ fontSize: 11, color: '#a16207' }}>暂无日志。</div>
|
<div style={{ fontSize: 11, color: showingRecentTask ? '#1d4ed8' : '#a16207' }}>暂无日志。</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ maxHeight: 220, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ maxHeight: 220, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{taskLogs.map((log, index) => (
|
{taskLogs.map((log, index) => (
|
||||||
@@ -346,7 +400,15 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ flex: 1, minWidth: 0, fontSize: 11, lineHeight: 1.45, color: log.level === 'WARNING' ? '#b45309' : '#334155' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
color: log.level === 'ERROR' ? '#b91c1c' : log.level === 'WARNING' ? '#b45309' : '#334155',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ color: '#64748b' }}>
|
<div style={{ color: '#64748b' }}>
|
||||||
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
|
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
|
|
||||||
export const getActiveTasks = () => apiClient.get('/tasks/active').then(r => r.data);
|
export const getActiveTasks = () => apiClient.get('/tasks/active').then(r => r.data);
|
||||||
|
export const getRecentTasks = (taskTypes = [], statuses = [], limit = 20, offset = 0) =>
|
||||||
|
apiClient.get('/tasks/recent', {
|
||||||
|
params: {
|
||||||
|
task_types: taskTypes.length ? taskTypes.join(',') : undefined,
|
||||||
|
statuses: statuses.length ? statuses.join(',') : undefined,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
},
|
||||||
|
}).then(r => r.data);
|
||||||
export const getTask = (taskId) => apiClient.get(`/tasks/${taskId}`).then(r => r.data);
|
export const getTask = (taskId) => apiClient.get(`/tasks/${taskId}`).then(r => r.data);
|
||||||
export const getTaskLogs = (taskId, limit = 50, offset = 0) =>
|
export const getTaskLogs = (taskId, limit = 50, offset = 0) =>
|
||||||
apiClient.get(`/tasks/${taskId}/logs?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`).then(r => r.data);
|
apiClient.get(`/tasks/${taskId}/logs?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`).then(r => r.data);
|
||||||
|
|||||||
Reference in New Issue
Block a user