chore: initialize insar management system v2
This commit is contained in:
@@ -0,0 +1,736 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { listEngines, listRuns, submitRun } from './api/dinsarProduction';
|
||||
import { getActiveTasks, getJobLog, getTaskLogs } from './api/idl';
|
||||
|
||||
const card = {
|
||||
background: '#fff',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
marginBottom: '12px',
|
||||
};
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
const EMPTY_OBJECT = {};
|
||||
|
||||
const ENGINE_STATUS_COLOR = {
|
||||
ok: '#22c55e',
|
||||
degraded: '#f59e0b',
|
||||
unavailable: '#ef4444',
|
||||
not_implemented: '#94a3b8',
|
||||
error: '#ef4444',
|
||||
};
|
||||
|
||||
const ENGINE_STATUS_LABEL = {
|
||||
ok: '可用',
|
||||
degraded: '部分可用',
|
||||
unavailable: '不可用',
|
||||
not_implemented: '预留',
|
||||
error: '错误',
|
||||
};
|
||||
|
||||
const ENGINE_LABEL = {
|
||||
sarscape: 'SARscape',
|
||||
isce2: 'ISCE2',
|
||||
landsar: 'LANDSAR',
|
||||
};
|
||||
|
||||
const TASK_TYPE_LABEL = {
|
||||
ISCE2_RUN: 'ISCE2生产任务',
|
||||
IDL_RUN_DINSAR: 'ENVI生产任务',
|
||||
};
|
||||
|
||||
const STATUS_LABEL = {
|
||||
PENDING: '等待中',
|
||||
RUNNING: '运行中',
|
||||
COMPLETED: '已完成',
|
||||
FAILED: '失败',
|
||||
CANCELLED: '已取消',
|
||||
CANCELED: '已取消',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
running: '运行中',
|
||||
pending: '等待中',
|
||||
};
|
||||
|
||||
function formatEngineLabel(engineCode, engineLabel = '') {
|
||||
return engineLabel || ENGINE_LABEL[engineCode] || engineCode || '-';
|
||||
}
|
||||
|
||||
function formatTaskType(taskType) {
|
||||
return TASK_TYPE_LABEL[taskType] || taskType || '-';
|
||||
}
|
||||
|
||||
function formatStatus(status) {
|
||||
return STATUS_LABEL[status] || status || '-';
|
||||
}
|
||||
|
||||
function EngineStatusCard({ engine, onSelect, selected }) {
|
||||
const color = ENGINE_STATUS_COLOR[engine.status] || '#94a3b8';
|
||||
const label = ENGINE_STATUS_LABEL[engine.status] || engine.status;
|
||||
const clickable = engine.available;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => clickable && onSelect(engine.engine_code)}
|
||||
style={{
|
||||
...card,
|
||||
marginBottom: 0,
|
||||
cursor: clickable ? 'pointer' : 'default',
|
||||
border: selected ? '2px solid #3b82f6' : `1px solid ${clickable ? '#e2e8f0' : '#f1f5f9'}`,
|
||||
background: selected ? '#eff6ff' : clickable ? '#fff' : '#f8fafc',
|
||||
opacity: clickable ? 1 : 0.7,
|
||||
minWidth: 160,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
display: 'inline-block',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{engine.engine_label}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color }}>{label}</div>
|
||||
{engine.message && (
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2, lineHeight: 1.35 }}>
|
||||
{engine.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildDefaults(schema) {
|
||||
const defaults = {};
|
||||
Object.entries(schema || {}).forEach(([key, item]) => {
|
||||
if (Object.prototype.hasOwnProperty.call(item, 'default')) {
|
||||
defaults[key] = item.default;
|
||||
} else if (item.type === 'boolean') {
|
||||
defaults[key] = false;
|
||||
} else {
|
||||
defaults[key] = '';
|
||||
}
|
||||
});
|
||||
return defaults;
|
||||
}
|
||||
|
||||
function buildExtraPayload(schema, values) {
|
||||
const payload = {};
|
||||
Object.entries(schema || {}).forEach(([key, item]) => {
|
||||
if (item.readonly || item.include_in_payload === false) {
|
||||
return;
|
||||
}
|
||||
const rawValue = values[key];
|
||||
if (item.type === 'boolean') {
|
||||
payload[key] = !!rawValue;
|
||||
return;
|
||||
}
|
||||
if (rawValue == null || String(rawValue).trim() === '') return;
|
||||
if (item.type === 'number') {
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
|
||||
payload[key] = parsed;
|
||||
}
|
||||
return;
|
||||
}
|
||||
payload[key] = String(rawValue).trim();
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
function ParamField({ name, schema, value, disabled, onChange }) {
|
||||
const label = schema.label || name;
|
||||
const description = schema.description || '';
|
||||
const recommendation = schema.recommendation || '';
|
||||
const isReadonly = !!schema.readonly;
|
||||
const inputStyle = {
|
||||
width: '100%',
|
||||
padding: '5px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: 13,
|
||||
boxSizing: 'border-box',
|
||||
background: isReadonly ? '#f8fafc' : '#fff',
|
||||
color: isReadonly ? '#475569' : '#0f172a',
|
||||
};
|
||||
|
||||
if (schema.type === 'boolean') {
|
||||
return (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: '#0f172a' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
disabled={disabled || isReadonly}
|
||||
onChange={event => onChange(name, event.target.checked)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
{isReadonly && (
|
||||
<span
|
||||
style={{
|
||||
padding: '1px 6px',
|
||||
borderRadius: 999,
|
||||
background: '#e2e8f0',
|
||||
color: '#475569',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
固定值
|
||||
</span>
|
||||
)}
|
||||
{description && <span style={{ color: '#64748b' }}>{description}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: 180, flex: schema.type === 'string' ? '1 1 280px' : '0 1 180px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<label style={{ fontSize: 12, color: '#64748b', display: 'block' }}>{label}</label>
|
||||
{isReadonly && (
|
||||
<span
|
||||
style={{
|
||||
padding: '1px 6px',
|
||||
borderRadius: 999,
|
||||
background: '#e2e8f0',
|
||||
color: '#475569',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
固定值
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type={schema.type === 'number' ? 'number' : 'text'}
|
||||
value={value ?? ''}
|
||||
disabled={disabled || isReadonly}
|
||||
step={schema.step}
|
||||
min={schema.min}
|
||||
max={schema.max}
|
||||
placeholder={schema.placeholder || ''}
|
||||
onChange={event => onChange(name, event.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{description && <div style={{ fontSize: 11, color: '#94a3b8', marginTop: 4 }}>{description}</div>}
|
||||
{recommendation && <div style={{ fontSize: 11, color: '#2563eb', marginTop: 4 }}>建议:{recommendation}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) {
|
||||
const [engines, setEngines] = useState([]);
|
||||
const [enginesLoading, setEnginesLoading] = useState(false);
|
||||
const [selectedEngine, setSelectedEngine] = useState('sarscape');
|
||||
const [selectedProfile, setSelectedProfile] = useState('custom6');
|
||||
const [rootDir, setRootDir] = useState('');
|
||||
const [numToProcess, setNumToProcess] = useState(0);
|
||||
const [timeoutSec, setTimeoutSec] = useState('');
|
||||
const [engineExtraParams, setEngineExtraParams] = useState({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitMsg, setSubmitMsg] = useState('');
|
||||
|
||||
const [runs, setRuns] = useState([]);
|
||||
const [runsLoading, setRunsLoading] = useState(false);
|
||||
|
||||
const [logModal, setLogModal] = useState({ open: false, runId: '', content: '', loading: false });
|
||||
const [activeTask, setActiveTask] = useState(null);
|
||||
const [taskLogs, setTaskLogs] = useState([]);
|
||||
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
|
||||
|
||||
const currentEngineObj = engines.find(engine => engine.engine_code === selectedEngine) || null;
|
||||
const currentProfiles = currentEngineObj?.profiles || EMPTY_ARRAY;
|
||||
const currentProfileObj = currentProfiles.find(profile => profile.code === selectedProfile) || null;
|
||||
const currentParamSchema = currentProfileObj?.params_schema || EMPTY_OBJECT;
|
||||
|
||||
const loadEngines = useCallback(async () => {
|
||||
setEnginesLoading(true);
|
||||
try {
|
||||
const data = await listEngines();
|
||||
setEngines(data.engines || []);
|
||||
} catch {
|
||||
setEngines([]);
|
||||
} finally {
|
||||
setEnginesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRuns = useCallback(async () => {
|
||||
setRunsLoading(true);
|
||||
try {
|
||||
const data = await listRuns(20);
|
||||
setRuns(data.runs || []);
|
||||
} catch {
|
||||
setRuns([]);
|
||||
} finally {
|
||||
setRunsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadActiveTask = useCallback(async () => {
|
||||
try {
|
||||
const data = await getActiveTasks();
|
||||
const tasks = Array.isArray(data) ? data : (data?.tasks || []);
|
||||
const relevantTask = tasks.find(task => ['ISCE2_RUN', 'IDL_RUN_DINSAR'].includes(task.task_type)) || null;
|
||||
setActiveTask(relevantTask);
|
||||
} catch {
|
||||
setActiveTask(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadTaskLogs = useCallback(async taskId => {
|
||||
if (!taskId) {
|
||||
setTaskLogs([]);
|
||||
return;
|
||||
}
|
||||
setTaskLogsLoading(true);
|
||||
try {
|
||||
const data = await getTaskLogs(taskId, 50, 0);
|
||||
setTaskLogs(data?.logs || []);
|
||||
} catch {
|
||||
setTaskLogs([]);
|
||||
} finally {
|
||||
setTaskLogsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadEngines();
|
||||
loadRuns();
|
||||
loadActiveTask();
|
||||
}, [loadActiveTask, loadEngines, loadRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(loadActiveTask, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadActiveTask]);
|
||||
|
||||
useEffect(() => {
|
||||
const taskId = activeTask?.task_id || '';
|
||||
loadTaskLogs(taskId);
|
||||
if (!taskId) return undefined;
|
||||
const timer = setInterval(() => loadTaskLogs(taskId), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [activeTask?.task_id, loadTaskLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProfiles.length > 0) {
|
||||
setSelectedProfile(currentProfiles[0].code);
|
||||
}
|
||||
}, [selectedEngine, currentProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
setEngineExtraParams(buildDefaults(currentParamSchema));
|
||||
}, [selectedEngine, selectedProfile, currentParamSchema]);
|
||||
|
||||
const handleParamChange = useCallback((name, value) => {
|
||||
setEngineExtraParams(current => ({
|
||||
...current,
|
||||
[name]: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!rootDir.trim()) {
|
||||
setSubmitMsg('请填写根目录。');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setSubmitMsg('');
|
||||
try {
|
||||
const extra = buildExtraPayload(currentParamSchema, engineExtraParams);
|
||||
const result = await submitRun({
|
||||
engine_code: selectedEngine,
|
||||
profile: selectedProfile,
|
||||
root_dir: rootDir.trim(),
|
||||
num_to_process: Number(numToProcess) || 0,
|
||||
timeout_seconds: timeoutSec ? Number(timeoutSec) : null,
|
||||
extra,
|
||||
});
|
||||
const taskCount = result?.selected_task_count ? `,已选 ${result.selected_task_count} 个任务` : '';
|
||||
setSubmitMsg(`任务已入队:${result.task_id}${taskCount}`);
|
||||
if (onJobQueued) onJobQueued(result.task_id);
|
||||
loadRuns();
|
||||
loadActiveTask();
|
||||
} catch (err) {
|
||||
setSubmitMsg(`提交失败:${err?.response?.data?.detail || err.message}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewLog = async runId => {
|
||||
setLogModal({ open: true, runId, content: '', loading: true });
|
||||
try {
|
||||
const data = await getJobLog(runId);
|
||||
setLogModal({ open: true, runId, content: data.content || '', loading: false });
|
||||
} catch {
|
||||
setLogModal({ open: true, runId, content: '日志加载失败。', loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
const isSubmitDisabled = readOnly || submitting || !currentEngineObj?.available;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px', maxWidth: 960 }}>
|
||||
{logModal.open && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
borderRadius: 10,
|
||||
padding: 24,
|
||||
width: 720,
|
||||
maxHeight: '80vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<strong>运行日志 - {logModal.runId}</strong>
|
||||
<button
|
||||
onClick={() => setLogModal({ open: false, runId: '', content: '', loading: false })}
|
||||
style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: 18 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{logModal.loading ? '加载中...' : logModal.content || '(日志为空)'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={card}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<strong style={{ fontSize: 14 }}>引擎状态</strong>
|
||||
<button
|
||||
onClick={loadEngines}
|
||||
disabled={enginesLoading}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '3px 10px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
background: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
{enginesLoading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{engines.length === 0 && !enginesLoading && (
|
||||
<span style={{ fontSize: 12, color: '#94a3b8' }}>暂无可用引擎信息。</span>
|
||||
)}
|
||||
{engines.map(engine => (
|
||||
<EngineStatusCard
|
||||
key={engine.engine_code}
|
||||
engine={engine}
|
||||
selected={selectedEngine === engine.engine_code}
|
||||
onSelect={setSelectedEngine}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={card}>
|
||||
<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 }}>
|
||||
<label style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 4 }}>处理模板</label>
|
||||
<select
|
||||
value={selectedProfile}
|
||||
onChange={event => setSelectedProfile(event.target.value)}
|
||||
disabled={readOnly || !currentEngineObj?.available}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '5px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{currentProfiles.map(profile => (
|
||||
<option key={profile.code} value={profile.code}>
|
||||
{profile.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{currentProfileObj?.description && (
|
||||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 4 }}>{currentProfileObj.description}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 2, minWidth: 280 }}>
|
||||
<label style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 4 }}>根目录</label>
|
||||
<input
|
||||
value={rootDir}
|
||||
onChange={event => setRootDir(event.target.value)}
|
||||
placeholder="批量根目录或单个任务目录"
|
||||
disabled={readOnly}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '5px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: 13,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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=全部)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={numToProcess}
|
||||
onChange={event => setNumToProcess(event.target.value)}
|
||||
disabled={readOnly}
|
||||
style={{ width: 120, padding: '5px 8px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 13 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 140 }}>
|
||||
<label style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 4 }}>
|
||||
超时秒数(留空默认)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={60}
|
||||
value={timeoutSec}
|
||||
onChange={event => setTimeoutSec(event.target.value)}
|
||||
placeholder="默认"
|
||||
disabled={readOnly}
|
||||
style={{ width: 140, padding: '5px 8px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 13 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.keys(currentParamSchema).length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 10,
|
||||
padding: '10px 12px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: '#0f172a', marginBottom: 8, fontWeight: 600 }}>处理参数</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: '#475569',
|
||||
lineHeight: 1.6,
|
||||
marginBottom: 10,
|
||||
padding: '8px 10px',
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
这些参数主要影响目标网格尺寸、精轨裁剪、地理编码范围和位移结果掩膜。建议先使用默认值,通常优先只调整目标网格尺寸;只有在边缘被裁切、时间窗口异常或噪声较多时,再继续调整其他参数。
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{Object.entries(currentParamSchema).map(([name, schema]) => (
|
||||
<ParamField
|
||||
key={name}
|
||||
name={name}
|
||||
schema={schema}
|
||||
value={engineExtraParams[name]}
|
||||
disabled={readOnly}
|
||||
onChange={handleParamChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitDisabled}
|
||||
style={{
|
||||
padding: '6px 20px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
background: currentEngineObj?.available ? '#3b82f6' : '#94a3b8',
|
||||
color: '#fff',
|
||||
cursor: currentEngineObj?.available ? 'pointer' : 'not-allowed',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{submitting ? '提交中...' : '提交任务'}
|
||||
</button>
|
||||
{submitMsg && (
|
||||
<span style={{ fontSize: 12, color: submitMsg.includes('失败') ? '#ef4444' : '#16a34a' }}>
|
||||
{submitMsg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={card}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<strong style={{ fontSize: 14 }}>运行监控</strong>
|
||||
<button
|
||||
onClick={() => {
|
||||
loadRuns();
|
||||
loadActiveTask();
|
||||
}}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '3px 10px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
background: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTask && (
|
||||
<div style={{ marginBottom: 10, padding: '8px 10px', background: '#fefce8', borderRadius: 6, border: '1px solid #fde68a' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#92400e', marginBottom: 4 }}>当前任务</div>
|
||||
<div style={{ fontSize: 12, color: '#78350f', wordBreak: 'break-all' }}>
|
||||
{activeTask.task_id} - {formatTaskType(activeTask.task_type)} - {formatStatus(activeTask.status)} - {activeTask.message}
|
||||
</div>
|
||||
{activeTask.progress != null && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<div style={{ height: 6, background: '#fde68a', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${activeTask.progress}%`,
|
||||
background: '#f59e0b',
|
||||
transition: 'width 0.3s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#92400e', marginTop: 2 }}>{activeTask.progress}%</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{taskLogsLoading ? (
|
||||
<div style={{ fontSize: 11, color: '#a16207' }}>加载中...</div>
|
||||
) : taskLogs.length === 0 ? (
|
||||
<div style={{ fontSize: 11, color: '#a16207' }}>暂无日志。</div>
|
||||
) : (
|
||||
<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' }}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>最近20条运行记录</div>
|
||||
{runsLoading ? (
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>加载中...</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>暂无记录。</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8fafc' }}>
|
||||
{['任务编号', '引擎', '状态', '时间', '操作'].map(header => (
|
||||
<th
|
||||
key={header}
|
||||
style={{ padding: '4px 8px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', color: '#64748b' }}
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map(run => (
|
||||
<tr key={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' }}>
|
||||
<button
|
||||
onClick={() => handleViewLog(run.run_id)}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 3,
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
background: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
日志
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user