chore: initialize insar management system v2

This commit is contained in:
2026-04-14 13:16:01 +08:00
commit ecc72ec9cd
361 changed files with 2142522 additions and 0 deletions
+736
View File
@@ -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>
);
}