Add production run history cleanup
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
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"])
|
||||
|
||||
_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",
|
||||
1,
|
||||
@@ -351,7 +355,74 @@ async def submit_run(
|
||||
|
||||
|
||||
@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:
|
||||
result = await dinsar_production_service.list_runs(db, limit=limit)
|
||||
result = await dinsar_production_service.list_runs(db, limit=limit, offset=offset)
|
||||
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")
|
||||
async def force_cancel_task(
|
||||
task_id: str,
|
||||
|
||||
@@ -9,7 +9,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
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 .. import database
|
||||
@@ -18,7 +18,12 @@ from ..models import (
|
||||
DinsarProductionExecutionORM,
|
||||
DinsarProductionRunItemORM,
|
||||
DinsarProductionRunORM,
|
||||
SystemJobORM,
|
||||
SystemTaskORM,
|
||||
TaskLogORM,
|
||||
WorkflowArtifactORM,
|
||||
WorkflowRunORM,
|
||||
WorkflowStepORM,
|
||||
)
|
||||
from .envi_service import RUNTIME_DIR, _collect_task_folders, _resolve_dinsar_pair_identity, _to_local_path
|
||||
from .task_service import task_service
|
||||
@@ -1000,6 +1005,81 @@ class DinsarProductionService:
|
||||
def append_run_log(self, run_id: str, message: str) -> str:
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -479,5 +479,32 @@ class TaskService:
|
||||
if gen_db:
|
||||
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()
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { listEngines, listRuns, previewPyintInputAssets, submitRun } from './api/dinsarProduction';
|
||||
import { getJobLog } from './api/idl';
|
||||
import { clearTaskLogs, deleteTaskLog, getActiveTasks, getRecentTasks, getTaskLogs } from './api/tasks';
|
||||
import { deleteRunLog, deleteRunRecord, getRunLog, listEngines, listRuns, previewPyintInputAssets, submitRun } from './api/dinsarProduction';
|
||||
import { clearTaskLogs, deleteTaskLog, deleteTaskRecord, getActiveTasks, getRecentTasks, getTaskLogs } from './api/tasks';
|
||||
|
||||
const card = {
|
||||
background: '#fff',
|
||||
@@ -14,6 +13,10 @@ const card = {
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
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 = {
|
||||
ok: '#22c55e',
|
||||
@@ -120,6 +123,11 @@ function taskStatusToRunStatus(status) {
|
||||
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) {
|
||||
if (value == null || value === '') return null;
|
||||
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 => ({
|
||||
...run,
|
||||
record_type: run?.record_type || 'run',
|
||||
@@ -190,9 +198,21 @@ function mergeRunRows(productionRuns, recentTasks, limit = 20) {
|
||||
representedTaskIds.add(taskId);
|
||||
});
|
||||
|
||||
return rows
|
||||
.sort((a, b) => Number(b?.started_at || 0) - Number(a?.started_at || 0))
|
||||
.slice(0, limit);
|
||||
const sortedRows = rows.sort((a, b) => Number(b?.started_at || 0) - Number(a?.started_at || 0));
|
||||
return limit == null ? sortedRows : sortedRows.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) {
|
||||
@@ -535,7 +555,15 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
const [runs, setRuns] = useState([]);
|
||||
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 [recentTask, setRecentTask] = useState(null);
|
||||
const [taskLogs, setTaskLogs] = useState([]);
|
||||
@@ -591,12 +619,36 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
const silent = !!options.silent;
|
||||
if (!silent) setRunsLoading(true);
|
||||
try {
|
||||
const [runData, taskData] = await Promise.all([
|
||||
listRuns(20),
|
||||
getRecentTasks(['ISCE2_RUN', 'PYINT_RUN', 'IDL_RUN_DINSAR'], [], 20, 0),
|
||||
const loadProductionRuns = async () => {
|
||||
const allRuns = [];
|
||||
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(runData.runs || [], recentTasks, 20);
|
||||
const nextRuns = mergeRunRows(productionRuns, recentTasks);
|
||||
setRuns(nextRuns);
|
||||
return nextRuns;
|
||||
} catch {
|
||||
@@ -641,8 +693,8 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
}
|
||||
if (!silent) setTaskLogsLoading(true);
|
||||
try {
|
||||
const data = await getTaskLogs(taskId, 120, 0);
|
||||
setTaskLogs(data?.logs || []);
|
||||
const logs = await fetchAllTaskLogs(taskId);
|
||||
setTaskLogs(logs);
|
||||
} catch {
|
||||
setTaskLogs([]);
|
||||
} finally {
|
||||
@@ -830,25 +882,112 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
|
||||
const handleViewLog = async run => {
|
||||
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 {
|
||||
if (typeof run !== 'string' && run?.log_source === 'task' && run?.task_id) {
|
||||
const data = await getTaskLogs(run.task_id, 200, 0);
|
||||
if (source === 'task' && taskId) {
|
||||
const logs = await fetchAllTaskLogs(taskId);
|
||||
setLogModal({
|
||||
open: true,
|
||||
runId,
|
||||
content: formatTaskLogContent(run.task_id, data?.logs || []),
|
||||
taskId,
|
||||
source,
|
||||
content: formatTaskLogContent(taskId, logs),
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const data = await getJobLog(runId);
|
||||
setLogModal({ open: true, runId, content: data.content || '', loading: false });
|
||||
const data = await getRunLog(runId);
|
||||
setLogModal({ open: true, runId, taskId: '', source, content: data.content || '', loading: false });
|
||||
} 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;
|
||||
|
||||
return (
|
||||
@@ -871,30 +1010,53 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
color: '#e2e8f0',
|
||||
borderRadius: 10,
|
||||
padding: 24,
|
||||
width: 720,
|
||||
maxHeight: '80vh',
|
||||
width: 'min(960px, 92vw)',
|
||||
maxHeight: '82vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<strong>运行日志 - {logModal.runId}</strong>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => setLogModal({ open: false, runId: '', content: '', loading: false })}
|
||||
onClick={handleDeleteOpenLog}
|
||||
disabled={runLogDeletingId === logModal.runId}
|
||||
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>
|
||||
<pre
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
background: '#0f172a',
|
||||
}}
|
||||
>
|
||||
{logModal.loading ? '加载中...' : logModal.content || '(日志为空)'}
|
||||
@@ -1560,19 +1722,29 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>最近 20 条运行记录</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>运行记录(已加载 {runs.length} 条)</div>
|
||||
{runsLoading ? (
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>加载中...</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>暂无记录。</div>
|
||||
) : (
|
||||
<div style={{ maxHeight: 520, overflowY: 'auto', border: '1px solid #e2e8f0', borderRadius: 6 }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8fafc' }}>
|
||||
<tr>
|
||||
{['运行ID', '引擎', '状态', '时间', '路径', '操作'].map(header => (
|
||||
<th
|
||||
key={header}
|
||||
style={{ padding: '4px 8px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', color: '#64748b' }}
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
padding: '6px 8px',
|
||||
textAlign: 'left',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
background: '#f8fafc',
|
||||
color: '#64748b',
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
@@ -1580,25 +1752,28 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map(run => (
|
||||
{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: '4px 8px', fontFamily: 'monospace', fontSize: 11 }}>{run.run_id}</td>
|
||||
<td style={{ padding: '4px 8px' }}>{formatEngineLabel(run.engine)}</td>
|
||||
<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: '4px 8px',
|
||||
padding: '6px 8px',
|
||||
color: run.status === 'success' ? '#16a34a' : run.status === 'failed' ? '#ef4444' : '#64748b',
|
||||
}}
|
||||
>
|
||||
{formatStatus(run.status)}
|
||||
</td>
|
||||
<td style={{ padding: '4px 8px', color: '#94a3b8' }}>
|
||||
<td style={{ padding: '6px 8px', color: '#94a3b8', whiteSpace: 'nowrap' }}>
|
||||
{run.started_at ? new Date(run.started_at * 1000).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td style={{ padding: '4px 8px', maxWidth: 520, fontSize: 11 }}>
|
||||
<td style={{ padding: '6px 8px', maxWidth: 520, fontSize: 11 }}>
|
||||
<RunPathBlock run={run} />
|
||||
</td>
|
||||
<td style={{ padding: '4px 8px' }}>
|
||||
<td style={{ padding: '6px 8px', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
onClick={() => handleViewLog(run)}
|
||||
style={{
|
||||
@@ -1612,11 +1787,50 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
>
|
||||
查看日志
|
||||
</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>
|
||||
|
||||
@@ -16,8 +16,19 @@ export const submitRun = (payload) =>
|
||||
apiClient.post('/dinsar-production/run', payload).then(r => r.data);
|
||||
|
||||
// 运行历史
|
||||
export const listRuns = (limit = 20) =>
|
||||
apiClient.get(`/dinsar-production/runs?limit=${encodeURIComponent(limit)}`).then(r => r.data);
|
||||
export const listRuns = (limit = 20, offset = 0) =>
|
||||
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) =>
|
||||
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);
|
||||
export const clearTaskLogs = (taskId) =>
|
||||
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