Add production run history cleanup
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
from typing import Any, Dict, Literal, Optional
|
from typing import Any, Dict, Literal, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
@@ -18,6 +19,9 @@ from ..services.task_service import task_service
|
|||||||
|
|
||||||
router = APIRouter(prefix="/dinsar-production", tags=["dinsar-production"])
|
router = APIRouter(prefix="/dinsar-production", tags=["dinsar-production"])
|
||||||
|
|
||||||
|
_RUN_ID_RE = re.compile(r"^[\w\-]{4,128}$")
|
||||||
|
_LOG_MAX_BYTES = 200 * 1024
|
||||||
|
|
||||||
DINSAR_PRODUCTION_JOB_MAX_ATTEMPTS = read_int_env(
|
DINSAR_PRODUCTION_JOB_MAX_ATTEMPTS = read_int_env(
|
||||||
"DINSAR_PRODUCTION_JOB_MAX_ATTEMPTS",
|
"DINSAR_PRODUCTION_JOB_MAX_ATTEMPTS",
|
||||||
1,
|
1,
|
||||||
@@ -351,7 +355,74 @@ async def submit_run(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/runs")
|
@router.get("/runs")
|
||||||
async def list_runs(limit: int = 20):
|
async def list_runs(limit: int = 20, offset: int = 0):
|
||||||
async with _new_session() as db:
|
async with _new_session() as db:
|
||||||
result = await dinsar_production_service.list_runs(db, limit=limit)
|
result = await dinsar_production_service.list_runs(db, limit=limit, offset=offset)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{run_id}/log")
|
||||||
|
async def get_run_log(
|
||||||
|
run_id: str,
|
||||||
|
current_user: AuthUserORM = Depends(_get_current_user),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
normalized_run_id = str(run_id or "").strip()
|
||||||
|
if not _RUN_ID_RE.match(normalized_run_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid run_id format.")
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
dinsar_production_service.read_run_log,
|
||||||
|
normalized_run_id,
|
||||||
|
max_bytes=_LOG_MAX_BYTES,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="Run log file not found.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/runs/{run_id}/log")
|
||||||
|
async def delete_run_log(
|
||||||
|
run_id: str,
|
||||||
|
current_user: AuthUserORM = Depends(_require_admin),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
normalized_run_id = str(run_id or "").strip()
|
||||||
|
if not _RUN_ID_RE.match(normalized_run_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid run_id format.")
|
||||||
|
deleted = await asyncio.to_thread(
|
||||||
|
dinsar_production_service.delete_run_log,
|
||||||
|
normalized_run_id,
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Run log file not found.")
|
||||||
|
return {
|
||||||
|
"run_id": normalized_run_id,
|
||||||
|
"deleted": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/runs/{run_id}")
|
||||||
|
async def delete_run_record(
|
||||||
|
run_id: str,
|
||||||
|
current_user: AuthUserORM = Depends(_require_admin),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
normalized_run_id = str(run_id or "").strip()
|
||||||
|
if not _RUN_ID_RE.match(normalized_run_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid run_id format.")
|
||||||
|
|
||||||
|
async with _new_session() as db:
|
||||||
|
try:
|
||||||
|
result = await dinsar_production_service.delete_run_record(
|
||||||
|
normalized_run_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Production run not found.")
|
||||||
|
return {
|
||||||
|
**result,
|
||||||
|
"deleted": True,
|
||||||
|
"products_deleted": False,
|
||||||
|
}
|
||||||
|
|||||||
@@ -181,6 +181,26 @@ async def clear_task_logs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tasks/{task_id}")
|
||||||
|
async def delete_task_record(
|
||||||
|
task_id: str,
|
||||||
|
admin_user: AuthUserORM = Depends(_require_admin),
|
||||||
|
):
|
||||||
|
task = await task_service.get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found.")
|
||||||
|
if str(task.status or "").upper() in {"PENDING", "RUNNING"}:
|
||||||
|
raise HTTPException(status_code=409, detail="Cannot delete a pending or running task.")
|
||||||
|
|
||||||
|
deleted = await task_service.delete_task_record(task_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found.")
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"deleted": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/force-cancel")
|
@router.post("/tasks/{task_id}/force-cancel")
|
||||||
async def force_cancel_task(
|
async def force_cancel_task(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from .. import database
|
from .. import database
|
||||||
@@ -18,7 +18,12 @@ from ..models import (
|
|||||||
DinsarProductionExecutionORM,
|
DinsarProductionExecutionORM,
|
||||||
DinsarProductionRunItemORM,
|
DinsarProductionRunItemORM,
|
||||||
DinsarProductionRunORM,
|
DinsarProductionRunORM,
|
||||||
|
SystemJobORM,
|
||||||
SystemTaskORM,
|
SystemTaskORM,
|
||||||
|
TaskLogORM,
|
||||||
|
WorkflowArtifactORM,
|
||||||
|
WorkflowRunORM,
|
||||||
|
WorkflowStepORM,
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -1000,6 +1005,81 @@ class DinsarProductionService:
|
|||||||
def append_run_log(self, run_id: str, message: str) -> str:
|
def append_run_log(self, run_id: str, message: str) -> str:
|
||||||
return _append_run_log_sync(run_id, message)
|
return _append_run_log_sync(run_id, message)
|
||||||
|
|
||||||
|
def read_run_log(self, run_id: str, *, max_bytes: int = 200 * 1024) -> Dict[str, Any]:
|
||||||
|
log_path = _run_log_path(run_id)
|
||||||
|
if not os.path.isfile(log_path):
|
||||||
|
raise FileNotFoundError(log_path)
|
||||||
|
size_bytes = os.path.getsize(log_path)
|
||||||
|
truncated = size_bytes > max_bytes
|
||||||
|
with open(log_path, "r", encoding="utf-8", errors="replace") as fp:
|
||||||
|
if truncated:
|
||||||
|
fp.seek(size_bytes - max_bytes)
|
||||||
|
content = "...[日志已截断,仅显示末尾]...\n" + fp.read()
|
||||||
|
else:
|
||||||
|
content = fp.read()
|
||||||
|
return {
|
||||||
|
"run_id": run_id,
|
||||||
|
"content": content,
|
||||||
|
"size_bytes": size_bytes,
|
||||||
|
"truncated": truncated,
|
||||||
|
"log_path": log_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete_run_log(self, run_id: str) -> bool:
|
||||||
|
log_path = _run_log_path(run_id)
|
||||||
|
if not os.path.isfile(log_path):
|
||||||
|
return False
|
||||||
|
os.unlink(log_path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def delete_run_record(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
run = await self.get_run(run_id, db)
|
||||||
|
if run is None:
|
||||||
|
return None
|
||||||
|
if str(run.status or "").strip().upper() not in TERMINAL_RUN_STATUSES:
|
||||||
|
raise ValueError("Cannot delete a pending or running production run.")
|
||||||
|
|
||||||
|
task_id = str(run.task_id or "").strip()
|
||||||
|
workflow_run_id = str(run.workflow_run_id or "").strip()
|
||||||
|
if task_id:
|
||||||
|
task_result = await db.execute(
|
||||||
|
select(SystemTaskORM).where(SystemTaskORM.task_id == task_id)
|
||||||
|
)
|
||||||
|
task = task_result.scalar_one_or_none()
|
||||||
|
if task is not None and str(task.status or "").strip().upper() in {"PENDING", "RUNNING"}:
|
||||||
|
raise ValueError("Cannot delete a production run with a pending or running task.")
|
||||||
|
|
||||||
|
deleted = {
|
||||||
|
"run_id": run.run_id,
|
||||||
|
"task_id": task_id or None,
|
||||||
|
"workflow_run_id": workflow_run_id or None,
|
||||||
|
}
|
||||||
|
await db.execute(delete(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id))
|
||||||
|
await db.execute(delete(DinsarProductionRunItemORM).where(DinsarProductionRunItemORM.run_id == run.run_id))
|
||||||
|
await db.execute(delete(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id == run.run_id))
|
||||||
|
|
||||||
|
if task_id:
|
||||||
|
await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id == task_id))
|
||||||
|
await db.execute(delete(SystemJobORM).where(SystemJobORM.task_id == task_id))
|
||||||
|
await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id == task_id))
|
||||||
|
if workflow_run_id:
|
||||||
|
await db.execute(delete(SystemJobORM).where(SystemJobORM.workflow_run_id == workflow_run_id))
|
||||||
|
await db.execute(delete(WorkflowArtifactORM).where(WorkflowArtifactORM.run_id == workflow_run_id))
|
||||||
|
await db.execute(delete(WorkflowStepORM).where(WorkflowStepORM.run_id == workflow_run_id))
|
||||||
|
await db.execute(delete(WorkflowRunORM).where(WorkflowRunORM.run_id == workflow_run_id))
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
try:
|
||||||
|
deleted["log_deleted"] = await asyncio.to_thread(self.delete_run_log, run_id)
|
||||||
|
except OSError:
|
||||||
|
deleted["log_deleted"] = False
|
||||||
|
return deleted
|
||||||
|
|
||||||
def build_execution_manifest(
|
def build_execution_manifest(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -479,5 +479,32 @@ class TaskService:
|
|||||||
if gen_db:
|
if gen_db:
|
||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
async def delete_task_record(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
db: Optional[AsyncSession] = None,
|
||||||
|
) -> bool:
|
||||||
|
gen_db = db is None
|
||||||
|
if gen_db:
|
||||||
|
db = get_db_session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await db.execute(
|
||||||
|
select(SystemTaskORM).where(SystemTaskORM.task_id == task_id)
|
||||||
|
)
|
||||||
|
task = result.scalar_one_or_none()
|
||||||
|
if task is None:
|
||||||
|
return False
|
||||||
|
await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id == task_id))
|
||||||
|
await db.delete(task)
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if gen_db:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
task_service = TaskService()
|
task_service = TaskService()
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { listEngines, listRuns, previewPyintInputAssets, submitRun } from './api/dinsarProduction';
|
import { deleteRunLog, deleteRunRecord, getRunLog, listEngines, listRuns, previewPyintInputAssets, submitRun } from './api/dinsarProduction';
|
||||||
import { getJobLog } from './api/idl';
|
import { clearTaskLogs, deleteTaskLog, deleteTaskRecord, getActiveTasks, getRecentTasks, getTaskLogs } from './api/tasks';
|
||||||
import { clearTaskLogs, deleteTaskLog, getActiveTasks, getRecentTasks, getTaskLogs } from './api/tasks';
|
|
||||||
|
|
||||||
const card = {
|
const card = {
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
@@ -14,6 +13,10 @@ const card = {
|
|||||||
|
|
||||||
const EMPTY_ARRAY = [];
|
const EMPTY_ARRAY = [];
|
||||||
const EMPTY_OBJECT = {};
|
const EMPTY_OBJECT = {};
|
||||||
|
const RUN_HISTORY_PAGE_SIZE = 200;
|
||||||
|
const TASK_HISTORY_PAGE_SIZE = 500;
|
||||||
|
const TASK_LOG_PAGE_SIZE = 1000;
|
||||||
|
const TERMINAL_STATUS_VALUES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'CANCELED', 'success', 'failed', 'cancelled', 'canceled']);
|
||||||
|
|
||||||
const ENGINE_STATUS_COLOR = {
|
const ENGINE_STATUS_COLOR = {
|
||||||
ok: '#22c55e',
|
ok: '#22c55e',
|
||||||
@@ -120,6 +123,11 @@ function taskStatusToRunStatus(status) {
|
|||||||
return 'pending';
|
return 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTerminalRunRow(run) {
|
||||||
|
return TERMINAL_STATUS_VALUES.has(String(run?.raw_status || '').trim())
|
||||||
|
|| TERMINAL_STATUS_VALUES.has(String(run?.status || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
function epochSeconds(value) {
|
function epochSeconds(value) {
|
||||||
if (value == null || value === '') return null;
|
if (value == null || value === '') return null;
|
||||||
if (typeof value === 'number') return value;
|
if (typeof value === 'number') return value;
|
||||||
@@ -166,7 +174,7 @@ function inferTaskPaths(task) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeRunRows(productionRuns, recentTasks, limit = 20) {
|
function mergeRunRows(productionRuns, recentTasks, limit = null) {
|
||||||
const rows = (productionRuns || []).map(run => ({
|
const rows = (productionRuns || []).map(run => ({
|
||||||
...run,
|
...run,
|
||||||
record_type: run?.record_type || 'run',
|
record_type: run?.record_type || 'run',
|
||||||
@@ -190,9 +198,21 @@ function mergeRunRows(productionRuns, recentTasks, limit = 20) {
|
|||||||
representedTaskIds.add(taskId);
|
representedTaskIds.add(taskId);
|
||||||
});
|
});
|
||||||
|
|
||||||
return rows
|
const sortedRows = rows.sort((a, b) => Number(b?.started_at || 0) - Number(a?.started_at || 0));
|
||||||
.sort((a, b) => Number(b?.started_at || 0) - Number(a?.started_at || 0))
|
return limit == null ? sortedRows : sortedRows.slice(0, limit);
|
||||||
.slice(0, limit);
|
}
|
||||||
|
|
||||||
|
async function fetchAllTaskLogs(taskId) {
|
||||||
|
const allLogs = [];
|
||||||
|
let offset = 0;
|
||||||
|
while (true) {
|
||||||
|
const data = await getTaskLogs(taskId, TASK_LOG_PAGE_SIZE, offset);
|
||||||
|
const pageLogs = data?.logs || [];
|
||||||
|
allLogs.push(...pageLogs);
|
||||||
|
if (pageLogs.length < TASK_LOG_PAGE_SIZE) break;
|
||||||
|
offset += pageLogs.length;
|
||||||
|
}
|
||||||
|
return allLogs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTaskLogContent(taskId, logs) {
|
function formatTaskLogContent(taskId, logs) {
|
||||||
@@ -535,7 +555,15 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
const [runs, setRuns] = useState([]);
|
const [runs, setRuns] = useState([]);
|
||||||
const [runsLoading, setRunsLoading] = useState(false);
|
const [runsLoading, setRunsLoading] = useState(false);
|
||||||
|
|
||||||
const [logModal, setLogModal] = useState({ open: false, runId: '', content: '', loading: false });
|
const [logModal, setLogModal] = useState({
|
||||||
|
open: false,
|
||||||
|
runId: '',
|
||||||
|
taskId: '',
|
||||||
|
source: 'run',
|
||||||
|
content: '',
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
const [runLogDeletingId, setRunLogDeletingId] = useState('');
|
||||||
const [activeTask, setActiveTask] = useState(null);
|
const [activeTask, setActiveTask] = useState(null);
|
||||||
const [recentTask, setRecentTask] = useState(null);
|
const [recentTask, setRecentTask] = useState(null);
|
||||||
const [taskLogs, setTaskLogs] = useState([]);
|
const [taskLogs, setTaskLogs] = useState([]);
|
||||||
@@ -591,12 +619,36 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
const silent = !!options.silent;
|
const silent = !!options.silent;
|
||||||
if (!silent) setRunsLoading(true);
|
if (!silent) setRunsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [runData, taskData] = await Promise.all([
|
const loadProductionRuns = async () => {
|
||||||
listRuns(20),
|
const allRuns = [];
|
||||||
getRecentTasks(['ISCE2_RUN', 'PYINT_RUN', 'IDL_RUN_DINSAR'], [], 20, 0),
|
let offset = 0;
|
||||||
|
while (true) {
|
||||||
|
const data = await listRuns(RUN_HISTORY_PAGE_SIZE, offset);
|
||||||
|
const pageRuns = data?.runs || [];
|
||||||
|
allRuns.push(...pageRuns);
|
||||||
|
const total = Number(data?.total || 0);
|
||||||
|
if (pageRuns.length < RUN_HISTORY_PAGE_SIZE || allRuns.length >= total) break;
|
||||||
|
offset += pageRuns.length;
|
||||||
|
}
|
||||||
|
return allRuns;
|
||||||
|
};
|
||||||
|
const loadRecentTasks = async () => {
|
||||||
|
const allTasks = [];
|
||||||
|
let offset = 0;
|
||||||
|
while (true) {
|
||||||
|
const data = await getRecentTasks(['ISCE2_RUN', 'PYINT_RUN', 'IDL_RUN_DINSAR'], [], TASK_HISTORY_PAGE_SIZE, offset);
|
||||||
|
const pageTasks = Array.isArray(data) ? data : (data?.tasks || []);
|
||||||
|
allTasks.push(...pageTasks);
|
||||||
|
if (pageTasks.length < TASK_HISTORY_PAGE_SIZE) break;
|
||||||
|
offset += pageTasks.length;
|
||||||
|
}
|
||||||
|
return allTasks;
|
||||||
|
};
|
||||||
|
const [productionRuns, recentTasks] = await Promise.all([
|
||||||
|
loadProductionRuns(),
|
||||||
|
loadRecentTasks(),
|
||||||
]);
|
]);
|
||||||
const recentTasks = Array.isArray(taskData) ? taskData : (taskData?.tasks || []);
|
const nextRuns = mergeRunRows(productionRuns, recentTasks);
|
||||||
const nextRuns = mergeRunRows(runData.runs || [], recentTasks, 20);
|
|
||||||
setRuns(nextRuns);
|
setRuns(nextRuns);
|
||||||
return nextRuns;
|
return nextRuns;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -641,8 +693,8 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
}
|
}
|
||||||
if (!silent) setTaskLogsLoading(true);
|
if (!silent) setTaskLogsLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getTaskLogs(taskId, 120, 0);
|
const logs = await fetchAllTaskLogs(taskId);
|
||||||
setTaskLogs(data?.logs || []);
|
setTaskLogs(logs);
|
||||||
} catch {
|
} catch {
|
||||||
setTaskLogs([]);
|
setTaskLogs([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -830,25 +882,112 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
|
|
||||||
const handleViewLog = async run => {
|
const handleViewLog = async run => {
|
||||||
const runId = typeof run === 'string' ? run : (run?.run_id || run?.task_id || '');
|
const runId = typeof run === 'string' ? run : (run?.run_id || run?.task_id || '');
|
||||||
setLogModal({ open: true, runId, content: '', loading: true });
|
const source = typeof run !== 'string' && run?.log_source === 'task' ? 'task' : 'run';
|
||||||
|
const taskId = typeof run !== 'string' ? (run?.task_id || '') : '';
|
||||||
|
setLogModal({ open: true, runId, taskId, source, content: '', loading: true });
|
||||||
try {
|
try {
|
||||||
if (typeof run !== 'string' && run?.log_source === 'task' && run?.task_id) {
|
if (source === 'task' && taskId) {
|
||||||
const data = await getTaskLogs(run.task_id, 200, 0);
|
const logs = await fetchAllTaskLogs(taskId);
|
||||||
setLogModal({
|
setLogModal({
|
||||||
open: true,
|
open: true,
|
||||||
runId,
|
runId,
|
||||||
content: formatTaskLogContent(run.task_id, data?.logs || []),
|
taskId,
|
||||||
|
source,
|
||||||
|
content: formatTaskLogContent(taskId, logs),
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await getJobLog(runId);
|
const data = await getRunLog(runId);
|
||||||
setLogModal({ open: true, runId, content: data.content || '', loading: false });
|
setLogModal({ open: true, runId, taskId: '', source, content: data.content || '', loading: false });
|
||||||
} catch {
|
} catch {
|
||||||
setLogModal({ open: true, runId, content: '日志加载失败。', loading: false });
|
setLogModal({ open: true, runId, taskId, source, content: '日志加载失败,可能日志文件不存在或已被删除。', loading: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteRunRowLog = useCallback(async run => {
|
||||||
|
if (readOnly || runLogDeletingId) return;
|
||||||
|
const source = run?.log_source === 'task' ? 'task' : 'run';
|
||||||
|
const runId = run?.run_id || run?.task_id || '';
|
||||||
|
const taskId = run?.task_id || '';
|
||||||
|
const label = source === 'task' ? `任务 ${taskId}` : `运行 ${runId}`;
|
||||||
|
if (!runId) return;
|
||||||
|
if (!window.confirm(`确定要删除${label}的日志吗?运行记录和产物不会删除。`)) return;
|
||||||
|
|
||||||
|
setRunLogDeletingId(runId);
|
||||||
|
try {
|
||||||
|
if (source === 'task') {
|
||||||
|
await clearTaskLogs(taskId || runId);
|
||||||
|
if (logTaskId === (taskId || runId)) {
|
||||||
|
await loadTaskLogs(taskId || runId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await deleteRunLog(runId);
|
||||||
|
}
|
||||||
|
if (logModal.open && logModal.runId === runId) {
|
||||||
|
setLogModal(current => ({
|
||||||
|
...current,
|
||||||
|
content: '日志已删除。',
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setSubmitError(false);
|
||||||
|
setSubmitMsg('日志已删除。');
|
||||||
|
await refreshMonitor({ silent: true });
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(true);
|
||||||
|
setSubmitMsg(`删除日志失败:${err?.response?.data?.detail || err.message}`);
|
||||||
|
} finally {
|
||||||
|
setRunLogDeletingId('');
|
||||||
|
}
|
||||||
|
}, [loadTaskLogs, logModal.open, logModal.runId, logTaskId, readOnly, refreshMonitor, runLogDeletingId]);
|
||||||
|
|
||||||
|
const handleDeleteOpenLog = useCallback(async () => {
|
||||||
|
if (!logModal.open || readOnly || runLogDeletingId) return;
|
||||||
|
await handleDeleteRunRowLog({
|
||||||
|
run_id: logModal.runId,
|
||||||
|
task_id: logModal.taskId,
|
||||||
|
log_source: logModal.source,
|
||||||
|
});
|
||||||
|
}, [handleDeleteRunRowLog, logModal, readOnly, runLogDeletingId]);
|
||||||
|
|
||||||
|
const handleDeleteRunHistory = useCallback(async run => {
|
||||||
|
if (readOnly || runLogDeletingId) return;
|
||||||
|
const isTaskRecord = run?.log_source === 'task';
|
||||||
|
const recordId = isTaskRecord ? (run?.task_id || run?.run_id || '') : (run?.run_id || '');
|
||||||
|
const taskId = run?.task_id || '';
|
||||||
|
if (!recordId) return;
|
||||||
|
if (!isTerminalRunRow(run)) {
|
||||||
|
setSubmitError(true);
|
||||||
|
setSubmitMsg('运行中记录不能删除。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(`确定要删除运行记录 ${recordId} 吗?只删除记录和日志,不删除产物目录。`)) return;
|
||||||
|
|
||||||
|
setRunLogDeletingId(recordId);
|
||||||
|
try {
|
||||||
|
if (isTaskRecord) {
|
||||||
|
await deleteTaskRecord(recordId);
|
||||||
|
} else {
|
||||||
|
await deleteRunRecord(recordId);
|
||||||
|
}
|
||||||
|
if (logTaskId === (taskId || recordId)) {
|
||||||
|
setTaskLogs([]);
|
||||||
|
}
|
||||||
|
if (logModal.open && logModal.runId === recordId) {
|
||||||
|
setLogModal({ open: false, runId: '', taskId: '', source: 'run', content: '', loading: false });
|
||||||
|
}
|
||||||
|
setSubmitError(false);
|
||||||
|
setSubmitMsg('运行记录已删除,产物目录未删除。');
|
||||||
|
await refreshMonitor({ silent: true });
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(true);
|
||||||
|
setSubmitMsg(`删除运行记录失败:${err?.response?.data?.detail || err.message}`);
|
||||||
|
} finally {
|
||||||
|
setRunLogDeletingId('');
|
||||||
|
}
|
||||||
|
}, [logModal.open, logModal.runId, logTaskId, readOnly, refreshMonitor, runLogDeletingId]);
|
||||||
|
|
||||||
const isSubmitDisabled = readOnly || submitting || !currentEngineObj?.available || pyintPreviewBlocksSubmit;
|
const isSubmitDisabled = readOnly || submitting || !currentEngineObj?.available || pyintPreviewBlocksSubmit;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -871,30 +1010,53 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
color: '#e2e8f0',
|
color: '#e2e8f0',
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
padding: 24,
|
padding: 24,
|
||||||
width: 720,
|
width: 'min(960px, 92vw)',
|
||||||
maxHeight: '80vh',
|
maxHeight: '82vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
<strong>运行日志 - {logModal.runId}</strong>
|
<strong>运行日志 - {logModal.runId}</strong>
|
||||||
<button
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
onClick={() => setLogModal({ open: false, runId: '', content: '', loading: false })}
|
{!readOnly && (
|
||||||
style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: 18 }}
|
<button
|
||||||
>
|
onClick={handleDeleteOpenLog}
|
||||||
关闭
|
disabled={runLogDeletingId === logModal.runId}
|
||||||
</button>
|
style={{
|
||||||
|
border: '1px solid #7f1d1d',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: '#450a0a',
|
||||||
|
color: '#fecaca',
|
||||||
|
cursor: runLogDeletingId === logModal.runId ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '4px 10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{runLogDeletingId === logModal.runId ? '删除中...' : '删除日志'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setLogModal({ open: false, runId: '', taskId: '', source: 'run', content: '', loading: false })}
|
||||||
|
style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: 18 }}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pre
|
<pre
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
wordBreak: 'break-all',
|
wordBreak: 'break-all',
|
||||||
margin: 0,
|
margin: 0,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: '#0f172a',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{logModal.loading ? '加载中...' : logModal.content || '(日志为空)'}
|
{logModal.loading ? '加载中...' : logModal.content || '(日志为空)'}
|
||||||
@@ -1560,63 +1722,115 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>最近 20 条运行记录</div>
|
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>运行记录(已加载 {runs.length} 条)</div>
|
||||||
{runsLoading ? (
|
{runsLoading ? (
|
||||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>加载中...</div>
|
<div style={{ fontSize: 12, color: '#94a3b8' }}>加载中...</div>
|
||||||
) : runs.length === 0 ? (
|
) : runs.length === 0 ? (
|
||||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>暂无记录。</div>
|
<div style={{ fontSize: 12, color: '#94a3b8' }}>暂无记录。</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
<div style={{ maxHeight: 520, overflowY: 'auto', border: '1px solid #e2e8f0', borderRadius: 6 }}>
|
||||||
<thead>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||||
<tr style={{ background: '#f8fafc' }}>
|
<thead>
|
||||||
{['运行ID', '引擎', '状态', '时间', '路径', '操作'].map(header => (
|
<tr>
|
||||||
<th
|
{['运行ID', '引擎', '状态', '时间', '路径', '操作'].map(header => (
|
||||||
key={header}
|
<th
|
||||||
style={{ padding: '4px 8px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', color: '#64748b' }}
|
key={header}
|
||||||
>
|
|
||||||
{header}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{runs.map(run => (
|
|
||||||
<tr key={`${run.record_type || 'run'}-${run.run_id}`} style={{ borderBottom: '1px solid #f1f5f9' }}>
|
|
||||||
<td style={{ padding: '4px 8px', fontFamily: 'monospace', fontSize: 11 }}>{run.run_id}</td>
|
|
||||||
<td style={{ padding: '4px 8px' }}>{formatEngineLabel(run.engine)}</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: '4px 8px',
|
|
||||||
color: run.status === 'success' ? '#16a34a' : run.status === 'failed' ? '#ef4444' : '#64748b',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatStatus(run.status)}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '4px 8px', color: '#94a3b8' }}>
|
|
||||||
{run.started_at ? new Date(run.started_at * 1000).toLocaleString() : '-'}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '4px 8px', maxWidth: 520, fontSize: 11 }}>
|
|
||||||
<RunPathBlock run={run} />
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '4px 8px' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => handleViewLog(run)}
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
position: 'sticky',
|
||||||
padding: '2px 8px',
|
top: 0,
|
||||||
borderRadius: 3,
|
zIndex: 1,
|
||||||
border: '1px solid #e2e8f0',
|
padding: '6px 8px',
|
||||||
cursor: 'pointer',
|
textAlign: 'left',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
background: '#f8fafc',
|
background: '#f8fafc',
|
||||||
|
color: '#64748b',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
查看日志
|
{header}
|
||||||
</button>
|
</th>
|
||||||
</td>
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{runs.map(run => {
|
||||||
|
const recordId = run.log_source === 'task' ? (run.task_id || run.run_id) : run.run_id;
|
||||||
|
const canDeleteRecord = isTerminalRunRow(run);
|
||||||
|
return (
|
||||||
|
<tr key={`${run.record_type || 'run'}-${run.run_id}`} style={{ borderBottom: '1px solid #f1f5f9' }}>
|
||||||
|
<td style={{ padding: '6px 8px', fontFamily: 'monospace', fontSize: 11 }}>{run.run_id}</td>
|
||||||
|
<td style={{ padding: '6px 8px' }}>{formatEngineLabel(run.engine)}</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
color: run.status === 'success' ? '#16a34a' : run.status === 'failed' ? '#ef4444' : '#64748b',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatStatus(run.status)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 8px', color: '#94a3b8', whiteSpace: 'nowrap' }}>
|
||||||
|
{run.started_at ? new Date(run.started_at * 1000).toLocaleString() : '-'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 8px', maxWidth: 520, fontSize: 11 }}>
|
||||||
|
<RunPathBlock run={run} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 8px', whiteSpace: 'nowrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewLog(run)}
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 3,
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: '#f8fafc',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查看日志
|
||||||
|
</button>
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteRunRowLog(run)}
|
||||||
|
disabled={!!runLogDeletingId}
|
||||||
|
style={{
|
||||||
|
marginLeft: 6,
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 3,
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
cursor: runLogDeletingId ? 'not-allowed' : 'pointer',
|
||||||
|
background: '#fef2f2',
|
||||||
|
color: '#b91c1c',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{runLogDeletingId === (run.run_id || run.task_id) ? '删除中...' : '删除日志'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteRunHistory(run)}
|
||||||
|
disabled={!!runLogDeletingId || !canDeleteRecord}
|
||||||
|
title={canDeleteRecord ? '只删除记录和日志,不删除产物目录' : '运行中记录不能删除'}
|
||||||
|
style={{
|
||||||
|
marginLeft: 6,
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 3,
|
||||||
|
border: '1px solid #fed7aa',
|
||||||
|
cursor: runLogDeletingId || !canDeleteRecord ? 'not-allowed' : 'pointer',
|
||||||
|
background: runLogDeletingId || !canDeleteRecord ? '#f8fafc' : '#fff7ed',
|
||||||
|
color: runLogDeletingId || !canDeleteRecord ? '#94a3b8' : '#9a3412',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{runLogDeletingId === recordId ? '删除中...' : '删除记录'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,8 +16,19 @@ export const submitRun = (payload) =>
|
|||||||
apiClient.post('/dinsar-production/run', payload).then(r => r.data);
|
apiClient.post('/dinsar-production/run', payload).then(r => r.data);
|
||||||
|
|
||||||
// 运行历史
|
// 运行历史
|
||||||
export const listRuns = (limit = 20) =>
|
export const listRuns = (limit = 20, offset = 0) =>
|
||||||
apiClient.get(`/dinsar-production/runs?limit=${encodeURIComponent(limit)}`).then(r => r.data);
|
apiClient.get(
|
||||||
|
`/dinsar-production/runs?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`,
|
||||||
|
).then(r => r.data);
|
||||||
|
|
||||||
|
export const getRunLog = (runId) =>
|
||||||
|
apiClient.get(`/dinsar-production/runs/${encodeURIComponent(runId)}/log`).then(r => r.data);
|
||||||
|
|
||||||
|
export const deleteRunLog = (runId) =>
|
||||||
|
apiClient.delete(`/dinsar-production/runs/${encodeURIComponent(runId)}/log`).then(r => r.data);
|
||||||
|
|
||||||
|
export const deleteRunRecord = (runId) =>
|
||||||
|
apiClient.delete(`/dinsar-production/runs/${encodeURIComponent(runId)}`).then(r => r.data);
|
||||||
|
|
||||||
export const previewPyintInputAssets = (payload) =>
|
export const previewPyintInputAssets = (payload) =>
|
||||||
apiClient.post('/dinsar-production/engines/pyint/preview-input-assets', payload).then(r => r.data);
|
apiClient.post('/dinsar-production/engines/pyint/preview-input-assets', payload).then(r => r.data);
|
||||||
|
|||||||
@@ -17,3 +17,5 @@ export const deleteTaskLog = (taskId, logId) =>
|
|||||||
apiClient.delete(`/tasks/${taskId}/logs/${encodeURIComponent(logId)}`).then(r => r.data);
|
apiClient.delete(`/tasks/${taskId}/logs/${encodeURIComponent(logId)}`).then(r => r.data);
|
||||||
export const clearTaskLogs = (taskId) =>
|
export const clearTaskLogs = (taskId) =>
|
||||||
apiClient.delete(`/tasks/${taskId}/logs`).then(r => r.data);
|
apiClient.delete(`/tasks/${taskId}/logs`).then(r => r.data);
|
||||||
|
export const deleteTaskRecord = (taskId) =>
|
||||||
|
apiClient.delete(`/tasks/${taskId}`).then(r => r.data);
|
||||||
|
|||||||
Reference in New Issue
Block a user