Fix D-InSAR runtime logs and UI encoding

This commit is contained in:
2026-04-16 22:37:09 +08:00
parent 0a8f3331e3
commit a666ed0e5c
11 changed files with 949 additions and 199 deletions
+115 -26
View File
@@ -1,7 +1,8 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { listEngines, listRuns, submitRun } from './api/dinsarProduction';
import { getActiveTasks, getJobLog, getTaskLogs } from './api/idl';
import { getJobLog } from './api/idl';
import { clearTaskLogs, deleteTaskLog, getActiveTasks, getTaskLogs } from './api/tasks';
const card = {
background: '#fff',
@@ -24,10 +25,10 @@ const ENGINE_STATUS_COLOR = {
const ENGINE_STATUS_LABEL = {
ok: '可用',
degraded: '部分可用',
degraded: '降级',
unavailable: '不可用',
not_implemented: '预留',
error: '错误',
error: '异常',
};
const ENGINE_LABEL = {
@@ -37,8 +38,8 @@ const ENGINE_LABEL = {
};
const TASK_TYPE_LABEL = {
ISCE2_RUN: 'ISCE2生产任务',
IDL_RUN_DINSAR: 'ENVI生产任务',
ISCE2_RUN: 'ISCE2生产',
IDL_RUN_DINSAR: 'ENVI生产',
};
const STATUS_LABEL = {
@@ -220,7 +221,7 @@ function ParamField({ name, schema, value, disabled, onChange }) {
style={inputStyle}
/>
{description && <div style={{ fontSize: 11, color: '#94a3b8', marginTop: 4 }}>{description}</div>}
{recommendation && <div style={{ fontSize: 11, color: '#2563eb', marginTop: 4 }}>建议{recommendation}</div>}
{recommendation && <div style={{ fontSize: 11, color: '#2563eb', marginTop: 4 }}>推荐{recommendation}</div>}
</div>
);
}
@@ -236,6 +237,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
const [engineExtraParams, setEngineExtraParams] = useState({});
const [submitting, setSubmitting] = useState(false);
const [submitMsg, setSubmitMsg] = useState('');
const [submitError, setSubmitError] = useState(false);
const [runs, setRuns] = useState([]);
const [runsLoading, setRunsLoading] = useState(false);
@@ -244,6 +246,8 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
const [activeTask, setActiveTask] = useState(null);
const [taskLogs, setTaskLogs] = useState([]);
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
const [taskLogActionLoading, setTaskLogActionLoading] = useState(false);
const [taskLogDeletingId, setTaskLogDeletingId] = useState(null);
const currentEngineObj = engines.find(engine => engine.engine_code === selectedEngine) || null;
const currentProfiles = currentEngineObj?.profiles || EMPTY_ARRAY;
@@ -301,6 +305,42 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
}
}, []);
const handleDeleteTaskLog = useCallback(async logId => {
const taskId = activeTask?.task_id;
if (!taskId || !logId || taskLogActionLoading) return;
if (!window.confirm('确定要删除这条任务日志吗?')) return;
setTaskLogDeletingId(logId);
setTaskLogActionLoading(true);
try {
await deleteTaskLog(taskId, logId);
await loadTaskLogs(taskId);
} catch (err) {
setSubmitError(true);
setSubmitMsg(`删除日志失败:${err?.response?.data?.detail || err.message}`);
} finally {
setTaskLogDeletingId(null);
setTaskLogActionLoading(false);
}
}, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading]);
const handleClearTaskLogs = useCallback(async () => {
const taskId = activeTask?.task_id;
if (!taskId || taskLogActionLoading || taskLogs.length === 0) return;
if (!window.confirm('确定要清空当前任务的全部日志吗?')) return;
setTaskLogActionLoading(true);
try {
await clearTaskLogs(taskId);
await loadTaskLogs(taskId);
} catch (err) {
setSubmitError(true);
setSubmitMsg(`清空日志失败:${err?.response?.data?.detail || err.message}`);
} finally {
setTaskLogActionLoading(false);
}
}, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
useEffect(() => {
loadEngines();
loadRuns();
@@ -339,12 +379,14 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
const handleSubmit = async () => {
if (!rootDir.trim()) {
setSubmitMsg('请填写根目录。');
setSubmitError(true);
setSubmitMsg('请输入根目录。');
return;
}
setSubmitting(true);
setSubmitMsg('');
setSubmitError(false);
try {
const extra = buildExtraPayload(currentParamSchema, engineExtraParams);
const result = await submitRun({
@@ -355,12 +397,14 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
timeout_seconds: timeoutSec ? Number(timeoutSec) : null,
extra,
});
const taskCount = result?.selected_task_count ? `${result.selected_task_count} 个任务` : '';
const taskCount = result?.selected_task_count ? `,选 ${result.selected_task_count} 个任务` : '';
setSubmitError(false);
setSubmitMsg(`任务已入队:${result.task_id}${taskCount}`);
if (onJobQueued) onJobQueued(result.task_id);
loadRuns();
loadActiveTask();
} catch (err) {
setSubmitError(true);
setSubmitMsg(`提交失败:${err?.response?.data?.detail || err.message}`);
} finally {
setSubmitting(false);
@@ -411,7 +455,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
onClick={() => setLogModal({ open: false, runId: '', content: '', loading: false })}
style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: 18 }}
>
×
关闭
</button>
</div>
<pre
@@ -451,7 +495,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{engines.length === 0 && !enginesLoading && (
<span style={{ fontSize: 12, color: '#94a3b8' }}>暂无可用引擎信息</span>
<span style={{ fontSize: 12, color: '#94a3b8' }}>暂无引擎信息</span>
)}
{engines.map(engine => (
<EngineStatusCard
@@ -465,7 +509,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div>
<div style={card}>
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>生产提交</strong>
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>提交生产任务</strong>
<div style={{ display: 'flex', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 180 }}>
@@ -498,7 +542,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
<input
value={rootDir}
onChange={event => setRootDir(event.target.value)}
placeholder="批根目录或单个任务目录"
placeholder="批处理根目录或单个任务目录"
disabled={readOnly}
style={{
width: '100%',
@@ -515,7 +559,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
<div style={{ display: 'flex', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
<div style={{ minWidth: 120 }}>
<label style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 4 }}>
处理任务数0=全部
任务数0 表示全部
</label>
<input
type="number"
@@ -528,7 +572,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div>
<div style={{ minWidth: 140 }}>
<label style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 4 }}>
超时秒数留空默认
超时时间可选
</label>
<input
type="number"
@@ -565,7 +609,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
borderRadius: 6,
}}
>
这些参数主要影响目标网格尺寸裁剪地理编码范围和位移结果掩膜建议先使用默认值通常优先只调整目标网格尺寸只有在边缘被裁切时间窗异常或噪声较多时再继续调整其他参数
这些参数主要影响目标网格大小精裁剪范围地理编码范围和位移结果掩膜建议先使用默认值通常优先只调整目标网格大小只有在边缘被裁切时间窗异常或噪声较多时再继续调整其他参数
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{Object.entries(currentParamSchema).map(([name, schema]) => (
@@ -600,7 +644,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
{submitting ? '提交中...' : '提交任务'}
</button>
{submitMsg && (
<span style={{ fontSize: 12, color: submitMsg.includes('失败') ? '#ef4444' : '#16a34a' }}>
<span style={{ fontSize: 12, color: submitError ? '#ef4444' : '#16a34a' }}>
{submitMsg}
</span>
)}
@@ -651,7 +695,26 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
)}
<div style={{ marginTop: 8, background: '#fff', border: '1px solid #fde68a', borderRadius: 6, padding: '8px 10px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#92400e', marginBottom: 6 }}>任务日志</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#92400e' }}>任务日志</div>
{!readOnly && (
<button
onClick={handleClearTaskLogs}
disabled={taskLogActionLoading || taskLogs.length === 0}
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 4,
border: '1px solid #fcd34d',
background: taskLogActionLoading || taskLogs.length === 0 ? '#fef3c7' : '#fff7ed',
color: '#9a3412',
cursor: taskLogActionLoading || taskLogs.length === 0 ? 'not-allowed' : 'pointer',
}}
>
{taskLogActionLoading && taskLogDeletingId == null ? '清空中...' : '清空日志'}
</button>
)}
</div>
{taskLogsLoading ? (
<div style={{ fontSize: 11, color: '#a16207' }}>加载中...</div>
) : taskLogs.length === 0 ? (
@@ -660,13 +723,38 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
<div style={{ maxHeight: 220, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
{taskLogs.map((log, index) => (
<div
key={`${log.timestamp || 'log'}-${index}`}
style={{ fontSize: 11, lineHeight: 1.45, color: log.level === 'WARNING' ? '#b45309' : '#334155' }}
key={log.id || `${log.timestamp || 'log'}-${index}`}
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 8,
alignItems: 'flex-start',
}}
>
<div style={{ color: '#64748b' }}>
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
<div style={{ flex: 1, minWidth: 0, fontSize: 11, lineHeight: 1.45, color: log.level === 'WARNING' ? '#b45309' : '#334155' }}>
<div style={{ color: '#64748b' }}>
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{log.message}</div>
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{log.message}</div>
{!readOnly && (
<button
onClick={() => handleDeleteTaskLog(log.id)}
disabled={taskLogActionLoading || !log.id}
style={{
flexShrink: 0,
fontSize: 11,
padding: '2px 8px',
borderRadius: 4,
border: '1px solid #fecaca',
background: '#fef2f2',
color: '#b91c1c',
cursor: taskLogActionLoading || !log.id ? 'not-allowed' : 'pointer',
}}
>
{taskLogDeletingId === log.id ? '删除中...' : '删除'}
</button>
)}
</div>
))}
</div>
@@ -675,7 +763,7 @@ 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 }}>最近 20 条运行记录</div>
{runsLoading ? (
<div style={{ fontSize: 12, color: '#94a3b8' }}>加载中...</div>
) : runs.length === 0 ? (
@@ -684,7 +772,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8fafc' }}>
{['任务编号', '引擎', '状态', '时间', '操作'].map(header => (
{['运行ID', '引擎', '状态', '时间', '操作'].map(header => (
<th
key={header}
style={{ padding: '4px 8px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', color: '#64748b' }}
@@ -722,7 +810,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
background: '#f8fafc',
}}
>
日志
查看日志
</button>
</td>
</tr>
@@ -734,3 +822,4 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div>
);
}
+105 -19
View File
@@ -1,7 +1,8 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { scanDinsarResults } from './api/dinsar';
import { extractDispResults, getActiveTasks, getTaskLogs } from './api/idl';
import { extractDispResults } from './api/idl';
import { clearTaskLogs, deleteTaskLog, getActiveTasks, getTaskLogs } from './api/tasks';
import DinsarCatalogPanel from './components/DinsarCatalogPanel';
const card = {
@@ -19,9 +20,9 @@ const PRODUCT_TASK_TYPES = [
];
const TASK_TYPE_LABEL = {
SCAN_DINSAR: 'D-InSAR结果扫描任务',
PUBLISH_DINSAR_PRODUCTS: '结果包发布任务',
REBUILD_DINSAR_CATALOG: '结果目录重建任务',
SCAN_DINSAR: 'D-InSAR结果扫描',
PUBLISH_DINSAR_PRODUCTS: 'D-InSAR产物发布',
REBUILD_DINSAR_CATALOG: 'D-InSAR目录重建',
};
const STATUS_LABEL = {
@@ -53,6 +54,8 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
const [activeTask, setActiveTask] = useState(null);
const [taskLogs, setTaskLogs] = useState([]);
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
const [taskLogActionLoading, setTaskLogActionLoading] = useState(false);
const [taskLogDeletingId, setTaskLogDeletingId] = useState(null);
const loadActiveTask = useCallback(async () => {
try {
@@ -81,6 +84,42 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
}
}, []);
const handleDeleteTaskLog = useCallback(async logId => {
const taskId = activeTask?.task_id;
if (!taskId || !logId || taskLogActionLoading) return;
if (!window.confirm('确定要删除这条任务日志吗?')) return;
setTaskLogDeletingId(logId);
setTaskLogActionLoading(true);
try {
await deleteTaskLog(taskId, logId);
await loadTaskLogs(taskId);
} catch (error) {
setActionMessage(`删除日志失败:${error?.response?.data?.detail || error.message}`);
setActionError(true);
} finally {
setTaskLogDeletingId(null);
setTaskLogActionLoading(false);
}
}, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading]);
const handleClearTaskLogs = useCallback(async () => {
const taskId = activeTask?.task_id;
if (!taskId || taskLogActionLoading || taskLogs.length === 0) return;
if (!window.confirm('确定要清空当前任务的全部日志吗?')) return;
setTaskLogActionLoading(true);
try {
await clearTaskLogs(taskId);
await loadTaskLogs(taskId);
} catch (error) {
setActionMessage(`清空日志失败:${error?.response?.data?.detail || error.message}`);
setActionError(true);
} finally {
setTaskLogActionLoading(false);
}
}, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
useEffect(() => {
loadActiveTask();
}, [loadActiveTask]);
@@ -137,7 +176,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
return (
<div style={{ padding: '16px', maxWidth: 960 }}>
<div style={card}>
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>产物提取与重扫</strong>
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>D-InSAR 产物提取与重扫</strong>
<div
style={{
@@ -151,20 +190,20 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
borderRadius: 6,
}}
>
这里负责把生产目录中的位移结果提取为标准果包并触发结果重扫发布和编目生产运行与参数配置已独立放到D-InSAR生产选项卡
这里负责把生产目录中的位移结果提取为标准果包并触发结果重扫发布和编目生产运行与参数配置已独立放到D-InSAR生产选项卡
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 8 }}>
<input
value={extractRootDir}
onChange={event => setExtractRootDir(event.target.value)}
placeholder="结果根目录(提取位移结果)"
placeholder="结果根目录"
style={{ flex: 2, minWidth: 220, padding: '5px 8px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 13 }}
/>
<input
value={extractDestDir}
onChange={event => setExtractDestDir(event.target.value)}
placeholder="目标目录(留空使用默认"
placeholder="目标目录(可选"
style={{ flex: 1, minWidth: 180, padding: '5px 8px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 13 }}
/>
<button
@@ -209,15 +248,17 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
<span style={{ color: '#ef4444' }}>提取失败{extractResult.error}</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, color: '#16a34a' }}>
<span>提取完成复制 {extractResult.copied || 0} 个文件覆盖 {extractResult.overwritten || 0} 个文件</span>
<span>
提取完成复制 {extractResult.copied || 0} 个文件覆盖 {extractResult.overwritten || 0} 个文件
</span>
{extractResult.catalog?.attempted && extractResult.catalog?.status === 'ok' && (
<span style={{ color: '#166534' }}>
已同步标准结果包目录发布 {extractResult.catalog?.publish?.processed || 0} 重建登记 {extractResult.catalog?.rebuild?.registered || 0}
成果目录已同步发布 {extractResult.catalog?.publish?.processed || 0} 重建登记 {extractResult.catalog?.rebuild?.registered || 0}
</span>
)}
{extractResult.catalog?.attempted && extractResult.catalog?.status === 'error' && (
<span style={{ color: '#b45309' }}>
标准果包目录同步失败{extractResult.catalog?.message}
标准果包目录同步失败{extractResult.catalog?.message}
</span>
)}
</div>
@@ -245,7 +286,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
</div>
{!activeTask ? (
<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 style={{ fontSize: 12, fontWeight: 600, color: '#92400e', marginBottom: 4 }}>当前任务</div>
@@ -269,7 +310,26 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
)}
<div style={{ marginTop: 8, background: '#fff', border: '1px solid #fde68a', borderRadius: 6, padding: '8px 10px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#92400e', marginBottom: 6 }}>任务日志</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#92400e' }}>任务日志</div>
{!readOnly && (
<button
onClick={handleClearTaskLogs}
disabled={taskLogActionLoading || taskLogs.length === 0}
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 4,
border: '1px solid #fcd34d',
background: taskLogActionLoading || taskLogs.length === 0 ? '#fef3c7' : '#fff7ed',
color: '#9a3412',
cursor: taskLogActionLoading || taskLogs.length === 0 ? 'not-allowed' : 'pointer',
}}
>
{taskLogActionLoading && taskLogDeletingId == null ? '清空中...' : '清空日志'}
</button>
)}
</div>
{taskLogsLoading ? (
<div style={{ fontSize: 11, color: '#a16207' }}>加载中...</div>
) : taskLogs.length === 0 ? (
@@ -278,13 +338,38 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
<div style={{ maxHeight: 220, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
{taskLogs.map((log, index) => (
<div
key={`${log.timestamp || 'log'}-${index}`}
style={{ fontSize: 11, lineHeight: 1.45, color: log.level === 'WARNING' ? '#b45309' : '#334155' }}
key={log.id || `${log.timestamp || 'log'}-${index}`}
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 8,
alignItems: 'flex-start',
}}
>
<div style={{ color: '#64748b' }}>
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
<div style={{ flex: 1, minWidth: 0, fontSize: 11, lineHeight: 1.45, color: log.level === 'WARNING' ? '#b45309' : '#334155' }}>
<div style={{ color: '#64748b' }}>
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{log.message}</div>
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{log.message}</div>
{!readOnly && (
<button
onClick={() => handleDeleteTaskLog(log.id)}
disabled={taskLogActionLoading || !log.id}
style={{
flexShrink: 0,
fontSize: 11,
padding: '2px 8px',
borderRadius: 4,
border: '1px solid #fecaca',
background: '#fef2f2',
color: '#b91c1c',
cursor: taskLogActionLoading || !log.id ? 'not-allowed' : 'pointer',
}}
>
{taskLogDeletingId === log.id ? '删除中...' : '删除'}
</button>
)}
</div>
))}
</div>
@@ -302,3 +387,4 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
</div>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { cleanupSessions } from './api/auth';
import { syncWaterScenesFromDisk } from './api/water';
import { listEngines, runWslCheck } from './api/dinsarProduction';
import { getOrbitStatus, syncOrbitPools } from './api/orbit';
import LogManagementPanel from './LogManagementPanel';
import LogManagementPanel from './LogManagementPanel.clean';
import DinsarCatalogPanel from './components/DinsarCatalogPanel';
const toNumber = (value) => {
+372
View File
@@ -0,0 +1,372 @@
import React, { useState, useEffect, useCallback } from 'react';
import { listLogs, getLogContent, deleteLog } from './api/logs';
const PAGE_SIZE = 1000;
const LogManagementPanel = ({ isAdmin }) => {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(false);
const [selectedLog, setSelectedLog] = useState(null);
const [logContent, setLogContent] = useState('');
const [showModal, setShowModal] = useState(false);
const [filterType, setFilterType] = useState('');
const [totalLines, setTotalLines] = useState(0);
const [currentOffset, setCurrentOffset] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const loadLogs = useCallback(async () => {
setLoading(true);
try {
const data = await listLogs(filterType || null);
setLogs(data);
} catch (error) {
console.error('加载日志列表失败:', error);
alert(`加载日志列表失败:${error.response?.data?.detail || error.message}`);
} finally {
setLoading(false);
}
}, [filterType]);
const loadLogContent = useCallback(async (logPath, offset = 0) => {
try {
const data = await getLogContent(logPath, offset, PAGE_SIZE);
setLogContent(data.content || '');
setTotalLines(data.total_lines || 0);
setCurrentOffset(offset);
} catch (error) {
console.error('加载日志内容失败:', error);
alert(`加载日志内容失败:${error.response?.data?.detail || error.message}`);
}
}, []);
useEffect(() => {
loadLogs();
}, [loadLogs]);
const handleViewLog = async log => {
setSelectedLog(log);
setShowModal(true);
setCurrentOffset(0);
setSearchTerm('');
await loadLogContent(log.path, 0);
};
const handleDeleteLog = async log => {
if (!isAdmin) {
alert('只有管理员可以删除日志。');
return;
}
if (!window.confirm(`确定要删除日志文件“${log.name}”吗?\n\n此操作不可恢复。`)) {
return;
}
try {
await deleteLog(log.path);
alert('日志文件已删除。');
await loadLogs();
if (selectedLog && selectedLog.path === log.path) {
setShowModal(false);
setSelectedLog(null);
setLogContent('');
setTotalLines(0);
setCurrentOffset(0);
}
} catch (error) {
console.error('删除日志失败:', error);
alert(`删除日志失败:${error.response?.data?.detail || error.message}`);
}
};
const handlePrevPage = () => {
if (selectedLog && currentOffset > 0) {
const newOffset = Math.max(0, currentOffset - PAGE_SIZE);
loadLogContent(selectedLog.path, newOffset);
}
};
const handleNextPage = () => {
if (selectedLog && currentOffset + PAGE_SIZE < totalLines) {
const newOffset = currentOffset + PAGE_SIZE;
loadLogContent(selectedLog.path, newOffset);
}
};
const formatSize = bytes => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const getTypeLabel = type => {
const labels = {
app: '应用日志',
task: '任务日志',
error: '错误日志',
other: '其他',
};
return labels[type] || type;
};
const getTypeColor = type => {
const colors = {
app: '#3b82f6',
task: '#10b981',
error: '#ef4444',
other: '#6b7280',
};
return colors[type] || '#6b7280';
};
const filteredContent = searchTerm
? logContent
.split('\n')
.filter(line => line.toLowerCase().includes(searchTerm.toLowerCase()))
.join('\n')
: logContent;
return (
<div style={{ padding: '20px' }}>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0 }}>日志管理</h3>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<label>类型筛选</label>
<select
value={filterType}
onChange={event => setFilterType(event.target.value)}
style={{ padding: '5px 10px', borderRadius: '4px', border: '1px solid #ddd' }}
>
<option value="">全部</option>
<option value="app">应用日志</option>
<option value="task">任务日志</option>
<option value="error">错误日志</option>
</select>
<button
onClick={loadLogs}
disabled={loading}
style={{
padding: '5px 15px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
{loading ? '加载中...' : '刷新'}
</button>
</div>
</div>
{logs.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#6b7280' }}>暂无日志文件</div>
) : (
<table
style={{
width: '100%',
borderCollapse: 'collapse',
backgroundColor: 'white',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
<thead>
<tr style={{ backgroundColor: '#f3f4f6', borderBottom: '2px solid #e5e7eb' }}>
<th style={{ padding: '12px', textAlign: 'left' }}>文件名</th>
<th style={{ padding: '12px', textAlign: 'left' }}>类型</th>
<th style={{ padding: '12px', textAlign: 'right' }}>大小</th>
<th style={{ padding: '12px', textAlign: 'left' }}>修改时间</th>
<th style={{ padding: '12px', textAlign: 'center' }}>操作</th>
</tr>
</thead>
<tbody>
{logs.map((log, index) => (
<tr key={index} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={{ padding: '12px', fontFamily: 'monospace', fontSize: '13px' }}>{log.name}</td>
<td style={{ padding: '12px' }}>
<span
style={{
padding: '2px 8px',
borderRadius: '12px',
fontSize: '12px',
backgroundColor: `${getTypeColor(log.type)}20`,
color: getTypeColor(log.type),
}}
>
{getTypeLabel(log.type)}
</span>
</td>
<td style={{ padding: '12px', textAlign: 'right', fontFamily: 'monospace', fontSize: '13px' }}>
{formatSize(log.size)}
</td>
<td style={{ padding: '12px', fontSize: '13px' }}>{log.modified_at}</td>
<td style={{ padding: '12px', textAlign: 'center' }}>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center', alignItems: 'center' }}>
<button
onClick={() => handleViewLog(log)}
style={{
padding: '4px 12px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '13px',
whiteSpace: 'nowrap',
}}
>
查看
</button>
{isAdmin && (
<button
onClick={() => handleDeleteLog(log)}
style={{
padding: '4px 12px',
backgroundColor: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '13px',
whiteSpace: 'nowrap',
}}
>
删除
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
{showModal && selectedLog && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
>
<div
style={{
backgroundColor: 'white',
borderRadius: '8px',
width: '90%',
maxWidth: '1200px',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
}}
>
<div
style={{
padding: '20px',
borderBottom: '1px solid #e5e7eb',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<h3 style={{ margin: '0 0 8px 0', fontFamily: 'monospace' }}>{selectedLog.name}</h3>
<div style={{ fontSize: '13px', color: '#6b7280' }}>
大小{formatSize(selectedLog.size)} | 修改时间{selectedLog.modified_at} | 总行数{totalLines}
</div>
</div>
<button
onClick={() => setShowModal(false)}
style={{
padding: '8px 16px',
backgroundColor: '#6b7280',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
关闭
</button>
</div>
<div style={{ padding: '12px 20px', borderBottom: '1px solid #e5e7eb', display: 'flex', gap: '10px', alignItems: 'center' }}>
<input
type="text"
placeholder="搜索日志内容..."
value={searchTerm}
onChange={event => setSearchTerm(event.target.value)}
style={{
flex: 1,
padding: '6px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '13px',
}}
/>
<div style={{ fontSize: '13px', color: '#6b7280' }}>
显示行 {totalLines === 0 ? 0 : currentOffset + 1} - {Math.min(currentOffset + PAGE_SIZE, totalLines)}
</div>
<button
onClick={handlePrevPage}
disabled={currentOffset === 0}
style={{
padding: '6px 12px',
backgroundColor: currentOffset === 0 ? '#e5e7eb' : '#3b82f6',
color: currentOffset === 0 ? '#9ca3af' : 'white',
border: 'none',
borderRadius: '4px',
cursor: currentOffset === 0 ? 'not-allowed' : 'pointer',
fontSize: '13px',
}}
>
上一页
</button>
<button
onClick={handleNextPage}
disabled={currentOffset + PAGE_SIZE >= totalLines}
style={{
padding: '6px 12px',
backgroundColor: currentOffset + PAGE_SIZE >= totalLines ? '#e5e7eb' : '#3b82f6',
color: currentOffset + PAGE_SIZE >= totalLines ? '#9ca3af' : 'white',
border: 'none',
borderRadius: '4px',
cursor: currentOffset + PAGE_SIZE >= totalLines ? 'not-allowed' : 'pointer',
fontSize: '13px',
}}
>
下一页
</button>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '20px', backgroundColor: '#1e1e1e' }}>
<pre
style={{
margin: 0,
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
fontSize: '12px',
lineHeight: '1.5',
color: '#d4d4d4',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{filteredContent || '(空日志)'}
</pre>
</div>
</div>
</div>
)}
</div>
);
};
export default LogManagementPanel;
+6
View File
@@ -2,3 +2,9 @@ import apiClient from './client';
export const getActiveTasks = () => apiClient.get('/tasks/active').then(r => r.data);
export const getTask = (taskId) => apiClient.get(`/tasks/${taskId}`).then(r => r.data);
export const getTaskLogs = (taskId, limit = 50, offset = 0) =>
apiClient.get(`/tasks/${taskId}/logs?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`).then(r => r.data);
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);