chore: initialize insar management system v2
This commit is contained in:
@@ -0,0 +1,737 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js';
|
||||
import { Doughnut, Bar } from 'react-chartjs-2';
|
||||
import {
|
||||
getEnviStatus,
|
||||
inspectImport,
|
||||
inspectDinsar,
|
||||
queueImportJob,
|
||||
queueDinsarJob,
|
||||
getRecentRuns,
|
||||
getActiveTasks,
|
||||
forceCancelTask,
|
||||
extractDispResults,
|
||||
getTaskOverview,
|
||||
getJobLog,
|
||||
deleteRun,
|
||||
} from './api/idl';
|
||||
import { scanDinsarResults } from './api/dinsar';
|
||||
|
||||
import { getStatistics } from './api/stats';
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title);
|
||||
|
||||
const cardStyle = {
|
||||
background: '#fff',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
marginBottom: '12px',
|
||||
};
|
||||
|
||||
function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [recentRuns, setRecentRuns] = useState([]);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [runningTask, setRunningTask] = useState(null); // active IDL task from backend
|
||||
const [showUnlockInput, setShowUnlockInput] = useState(false);
|
||||
const [unlockPassword, setUnlockPassword] = useState('');
|
||||
|
||||
const [importRootDir, setImportRootDir] = useState('');
|
||||
const [importNumToProcess, setImportNumToProcess] = useState(0);
|
||||
const [dinsarRootDir, setDinsarRootDir] = useState('');
|
||||
const [dinsarNumToProcess, setDinsarNumToProcess] = useState(0);
|
||||
const [extractRootDir, setExtractRootDir] = useState('');
|
||||
const [extractDestDir, setExtractDestDir] = useState('');
|
||||
const [extractResult, setExtractResult] = useState(null);
|
||||
const [overview, setOverview] = useState(null);
|
||||
const [overviewLoading, setOverviewLoading] = useState(false);
|
||||
const [logModal, setLogModal] = useState({ open: false, runId: '', content: '', loading: false, truncated: false, sizeBytes: 0 });
|
||||
const [chartStats, setChartStats] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
getStatistics().then(setChartStats).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const [importInspect, setImportInspect] = useState(null);
|
||||
const [dinsarInspect, setDinsarInspect] = useState(null);
|
||||
|
||||
const refreshData = useCallback(async () => {
|
||||
const [s, runs, tasks] = await Promise.all([
|
||||
getEnviStatus(),
|
||||
getRecentRuns(20),
|
||||
getActiveTasks().catch(() => []),
|
||||
]);
|
||||
setStatus(s);
|
||||
setRecentRuns(Array.isArray(runs?.runs) ? runs.runs : []);
|
||||
// Find any running IDL task (IDL_IMPORT or IDL_DINSAR)
|
||||
const taskList = Array.isArray(tasks) ? tasks : [];
|
||||
const active = taskList.find(
|
||||
(t) => ['IDL_IMPORT', 'IDL_DINSAR'].includes(t.task_type) &&
|
||||
['PENDING', 'RUNNING'].includes(t.status)
|
||||
);
|
||||
setRunningTask(active || null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshData().catch(() => {});
|
||||
const timer = setInterval(() => refreshData().catch(() => {}), 10000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refreshData]);
|
||||
|
||||
const runAction = async (action) => {
|
||||
setIsBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
await action();
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
const detail = error?.response?.data?.detail || error?.message || '未知错误';
|
||||
setMessage(`执行失败: ${detail}`);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInspectImport = () =>
|
||||
runAction(async () => {
|
||||
const r = await inspectImport(importRootDir.trim());
|
||||
setImportInspect(r);
|
||||
setMessage(`Import 预检查: ${r?.ready ? '就绪' : '未就绪'}`);
|
||||
});
|
||||
|
||||
const handleInspectDinsar = () =>
|
||||
runAction(async () => {
|
||||
const r = await inspectDinsar(dinsarRootDir.trim());
|
||||
setDinsarInspect(r);
|
||||
setMessage(`D-InSAR 预检查: ${r?.ready ? '就绪' : '未就绪'}`);
|
||||
});
|
||||
|
||||
const handleQueueImport = () => {
|
||||
if (readOnly) return setMessage('只读账号无法提交任务。');
|
||||
const root = importRootDir.trim();
|
||||
if (!root) return setMessage('请填写数据目录。');
|
||||
runAction(async () => {
|
||||
const r = await queueImportJob({
|
||||
root_dir: root,
|
||||
num_to_process: Number(importNumToProcess) || 0,
|
||||
});
|
||||
setMessage(`Import 任务已入队。task_id=${r?.task_id || '-'}`);
|
||||
if (r?.task_id) onJobQueued?.(r.task_id);
|
||||
});
|
||||
};
|
||||
|
||||
const handleQueueDinsar = (mode) => {
|
||||
if (readOnly) return setMessage('只读账号无法提交任务。');
|
||||
const root = dinsarRootDir.trim();
|
||||
if (!root) return setMessage('请填写数据目录。');
|
||||
const modeLabel = mode === 'custom' ? '自定义' : '默认';
|
||||
runAction(async () => {
|
||||
const r = await queueDinsarJob({
|
||||
root_dir: root,
|
||||
num_to_process: Number(dinsarNumToProcess) || 0,
|
||||
mode,
|
||||
});
|
||||
setMessage(`D-InSAR (${modeLabel}) 任务已入队。task_id=${r?.task_id || '-'}`);
|
||||
if (r?.task_id) onJobQueued?.(r.task_id);
|
||||
});
|
||||
};
|
||||
|
||||
const handleExtractDisp = () => {
|
||||
if (readOnly) return setMessage('只读账号无法执行提取。');
|
||||
const root = extractRootDir.trim();
|
||||
if (!root) return setMessage('请填写数据目录。');
|
||||
const dest = extractDestDir.trim() || null;
|
||||
runAction(async () => {
|
||||
const r = await extractDispResults(root, dest);
|
||||
setExtractResult(r);
|
||||
// 提取完成后自动触发扫描
|
||||
let scanMsg = '';
|
||||
if (r.copied > 0 || r.overwritten > 0) {
|
||||
try {
|
||||
await scanDinsarResults({ results_directories: [r.target_dir] });
|
||||
scanMsg = ',已自动触发结果扫描入库';
|
||||
} catch (e) {
|
||||
scanMsg = ',扫描入库触发失败: ' + (e?.response?.data?.detail || e?.message);
|
||||
}
|
||||
}
|
||||
setMessage(`提取完成: ${r.copied} 新增, ${r.overwritten} 更新, ${r.skipped} 跳过${scanMsg}`);
|
||||
});
|
||||
};
|
||||
|
||||
const handleLoadOverview = async () => {
|
||||
const root = dinsarRootDir.trim();
|
||||
if (!root) return;
|
||||
setOverviewLoading(true);
|
||||
try {
|
||||
const r = await getTaskOverview(root);
|
||||
setOverview(r);
|
||||
} catch (e) {
|
||||
setOverview(null);
|
||||
setMessage(`总览加载失败: ${e?.response?.data?.detail || e?.message}`);
|
||||
} finally {
|
||||
setOverviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenLog = async (runId) => {
|
||||
setLogModal({ open: true, runId, content: '', loading: true, truncated: false, sizeBytes: 0 });
|
||||
try {
|
||||
const r = await getJobLog(runId);
|
||||
setLogModal({ open: true, runId, content: r.content, loading: false, truncated: r.truncated, sizeBytes: r.size_bytes });
|
||||
} catch (e) {
|
||||
const msg = e?.response?.data?.detail || e?.message || '加载失败';
|
||||
setLogModal({ open: true, runId, content: `错误: ${msg}`, loading: false, truncated: false, sizeBytes: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceUnlock = async () => {
|
||||
if (!runningTask || !unlockPassword) return;
|
||||
try {
|
||||
await forceCancelTask(runningTask.task_id, unlockPassword);
|
||||
setMessage('任务已强制取消,前端已解锁。');
|
||||
setShowUnlockInput(false);
|
||||
setUnlockPassword('');
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
const detail = error?.response?.data?.detail || error?.message || '解锁失败';
|
||||
setMessage(`强制解锁失败: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
const renderInspect = (title, result) => {
|
||||
if (!result) return null;
|
||||
return (
|
||||
<div style={{ marginTop: '8px', padding: '10px', background: '#f8fafc', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '4px' }}>{title}</div>
|
||||
<div style={{ fontSize: '12px', color: '#334155' }}>
|
||||
状态: <strong style={{ color: result?.ready ? '#16a34a' : '#dc2626' }}>{result?.ready ? '就绪' : '未就绪'}</strong>
|
||||
</div>
|
||||
{result?.warnings?.length > 0 && (
|
||||
<div style={{ fontSize: '12px', color: '#b45309', marginTop: '4px' }}>
|
||||
{result.warnings.map((w, i) => <div key={i}>⚠ {w}</div>)}
|
||||
</div>
|
||||
)}
|
||||
<pre style={{ margin: '8px 0 0', whiteSpace: 'pre-wrap', fontSize: '12px', color: '#475569' }}>
|
||||
{JSON.stringify(result?.summary || {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Buttons are locked when: submitting API call, readOnly user, or a backend task is running
|
||||
const isLocked = isBusy || !!runningTask;
|
||||
|
||||
const demDisplay = status?.dem_base_file || '-';
|
||||
const demOk = status?.dem_exists;
|
||||
|
||||
return (
|
||||
<div className="idl-automation-panel" style={{ padding: '15px', height: '100%', overflowY: 'auto' }}>
|
||||
<h3 style={{ marginTop: 0 }}>SARscape 处理中心</h3>
|
||||
|
||||
{/* Status bar */}
|
||||
<div style={{ ...cardStyle, background: '#f8fafc' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '12px', flexWrap: 'wrap', fontSize: '13px' }}>
|
||||
<span>
|
||||
ENVI: <strong>{status?.idl_installed ? '已安装' : '未检测到'}</strong>
|
||||
{status?.idl_running && ' (运行中)'}
|
||||
</span>
|
||||
<span>引擎: <strong>{status?.engine || '-'}</strong></span>
|
||||
<span style={{ color: demOk ? '#16a34a' : '#dc2626' }}>
|
||||
DEM: <code style={{ fontSize: '11px' }}>{demDisplay.length > 40 ? '...' + demDisplay.slice(-37) : demDisplay}</code>
|
||||
{demOk ? ' ✓' : ' ✗'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Running task indicator */}
|
||||
{runningTask && (
|
||||
<div style={{
|
||||
...cardStyle,
|
||||
background: '#fffbeb',
|
||||
borderColor: '#f59e0b',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={{ fontSize: '16px' }}>⚙</span>
|
||||
<div style={{ flex: 1, fontSize: '13px' }}>
|
||||
<strong style={{ color: '#b45309' }}>
|
||||
{runningTask.task_type === 'IDL_IMPORT' ? 'Import' : 'D-InSAR'} 任务运行中
|
||||
</strong>
|
||||
{runningTask.progress > 0 && (
|
||||
<span style={{ color: '#92400e', marginLeft: '8px', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{runningTask.progress}%
|
||||
</span>
|
||||
)}
|
||||
<div style={{ color: '#92400e', marginTop: '3px', fontSize: '12px', wordBreak: 'break-all' }}>
|
||||
{runningTask.message || runningTask.status}
|
||||
</div>
|
||||
{runningTask.progress > 0 && (
|
||||
<div style={{ marginTop: '5px', height: '6px', background: '#fde68a', borderRadius: '3px', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${runningTask.progress}%`,
|
||||
background: '#f59e0b',
|
||||
borderRadius: '3px',
|
||||
transition: 'width 0.5s ease',
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && !showUnlockInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUnlockInput(true)}
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #dc2626',
|
||||
background: '#fef2f2',
|
||||
color: '#dc2626',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
强制解锁
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showUnlockInput && (
|
||||
<div style={{ marginTop: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="输入管理员密码"
|
||||
value={unlockPassword}
|
||||
onChange={(e) => setUnlockPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleForceUnlock()}
|
||||
style={{ padding: '4px 8px', fontSize: '12px', borderRadius: '4px', border: '1px solid #d1d5db', width: '160px' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForceUnlock}
|
||||
disabled={!unlockPassword}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #dc2626',
|
||||
background: '#dc2626',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
cursor: unlockPassword ? 'pointer' : 'not-allowed',
|
||||
opacity: unlockPassword ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
确认取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowUnlockInput(false); setUnlockPassword(''); }}
|
||||
style={{ padding: '4px 10px', borderRadius: '4px', border: '1px solid #d1d5db', background: '#fff', fontSize: '12px', cursor: 'pointer' }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!showUnlockInput && (
|
||||
<div style={{ fontSize: '11px', color: '#92400e', marginTop: '4px', marginLeft: '26px' }}>
|
||||
按钮已锁定,等待任务完成
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task 状态总览 */}
|
||||
<div style={cardStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<h4 style={{ margin: 0 }}>Task 状态总览</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-btn"
|
||||
onClick={handleLoadOverview}
|
||||
disabled={overviewLoading || !dinsarRootDir.trim()}
|
||||
style={{ fontSize: '12px', padding: '3px 10px' }}
|
||||
>
|
||||
{overviewLoading ? '加载中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
{!overview && !overviewLoading && (
|
||||
<div style={{ fontSize: '12px', color: '#94a3b8' }}>填写下方数据目录后点击刷新</div>
|
||||
)}
|
||||
{overview && (
|
||||
<>
|
||||
<div style={{ fontSize: '12px', color: '#475569', marginBottom: '8px' }}>
|
||||
共 <strong>{overview.summary.total}</strong> 个 Task ·
|
||||
已导入 <strong style={{ color: '#2563eb' }}>{overview.summary.imported}</strong> ·
|
||||
已处理 <strong style={{ color: '#7c3aed' }}>{overview.summary.dinsar_done}</strong> ·
|
||||
已提取 <strong style={{ color: '#16a34a' }}>{overview.summary.extracted}</strong>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '12px' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f1f5f9' }}>
|
||||
<th style={{ textAlign: 'left', padding: '4px 8px', fontWeight: 600 }}>Task</th>
|
||||
<th style={{ textAlign: 'center', padding: '4px 8px', fontWeight: 600 }}>结构</th>
|
||||
<th style={{ textAlign: 'center', padding: '4px 8px', fontWeight: 600 }}>已导入</th>
|
||||
<th style={{ textAlign: 'center', padding: '4px 8px', fontWeight: 600 }}>已处理</th>
|
||||
<th style={{ textAlign: 'center', padding: '4px 8px', fontWeight: 600 }}>已提取</th>
|
||||
<th style={{ textAlign: 'right', padding: '4px 8px', fontWeight: 600, color: '#94a3b8' }}>修改时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{overview.tasks.map((t) => (
|
||||
<tr key={t.task_name} style={{ borderTop: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '4px 8px', fontFamily: 'monospace', fontSize: '11px' }}>{t.task_name}</td>
|
||||
{[t.has_structure, t.imported, t.dinsar_done, t.extracted].map((v, i) => (
|
||||
<td key={i} style={{ textAlign: 'center', padding: '4px 8px' }}>
|
||||
<span style={{ color: v ? '#16a34a' : '#dc2626', fontWeight: 700 }}>{v ? '✓' : '✗'}</span>
|
||||
</td>
|
||||
))}
|
||||
<td style={{ textAlign: 'right', padding: '4px 8px', color: '#94a3b8', fontSize: '11px' }}>{t.last_modified}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Import */}
|
||||
<div style={cardStyle}>
|
||||
<h4 style={{ marginTop: 0 }}>Step 1: 数据导入 (Import)</h4>
|
||||
<p style={{ fontSize: '12px', color: 'var(--color-text-secondary)', margin: '0 0 10px' }}>
|
||||
扫描目录下的 LuTan-1 原始数据 (*.meta.xml),转换为 ENVI SARscape 格式 (*.sml)。
|
||||
支持 Task_*/master|slave 结构和平铺目录。
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 140px', gap: '10px', alignItems: 'start' }}>
|
||||
<label className="user-admin-field">
|
||||
<span>数据目录 (root_dir)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={importRootDir}
|
||||
onChange={(e) => setImportRootDir(e.target.value)}
|
||||
placeholder="例如: Z:/Test_data/Test_IDL_1"
|
||||
disabled={isLocked}
|
||||
/>
|
||||
</label>
|
||||
<label className="user-admin-field">
|
||||
<span>最大处理数</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={importNumToProcess}
|
||||
onChange={(e) => setImportNumToProcess(e.target.value)}
|
||||
placeholder="0"
|
||||
disabled={isLocked}
|
||||
/>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-muted, #94a3b8)' }}>0 = 不限制</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginTop: '10px', display: 'flex', gap: '8px' }}>
|
||||
<button type="button" className="secondary-btn" disabled={isLocked || readOnly} onClick={handleInspectImport}>
|
||||
预检查
|
||||
</button>
|
||||
<button type="button" className="primary-btn" disabled={isLocked || readOnly} onClick={handleQueueImport}>
|
||||
开始导入
|
||||
</button>
|
||||
</div>
|
||||
{renderInspect('Import 检查结果', importInspect)}
|
||||
</div>
|
||||
|
||||
{/* Step 2: D-InSAR */}
|
||||
<div style={cardStyle}>
|
||||
<h4 style={{ marginTop: 0 }}>Step 2: D-InSAR 生产</h4>
|
||||
<p style={{ fontSize: '12px', color: 'var(--color-text-secondary)', margin: '0 0 10px' }}>
|
||||
对 Task_* 目录执行 D-InSAR 位移生成。
|
||||
未导入的数据将自动先执行 Import,再进行 D-InSAR 处理。
|
||||
DEM 路径由系统配置管理,无需手动指定。
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 140px', gap: '10px', alignItems: 'start' }}>
|
||||
<label className="user-admin-field">
|
||||
<span>数据目录 (root_dir)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={dinsarRootDir}
|
||||
onChange={(e) => setDinsarRootDir(e.target.value)}
|
||||
placeholder="例如: Z:/Test_data/Test_IDL_1"
|
||||
disabled={isLocked}
|
||||
/>
|
||||
</label>
|
||||
<label className="user-admin-field">
|
||||
<span>最大处理数</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={dinsarNumToProcess}
|
||||
onChange={(e) => setDinsarNumToProcess(e.target.value)}
|
||||
placeholder="0"
|
||||
disabled={isLocked}
|
||||
/>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-muted, #94a3b8)' }}>0 = 不限制</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginTop: '10px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{/* 预检查 */}
|
||||
<div>
|
||||
<button type="button" className="secondary-btn" disabled={isLocked || readOnly} onClick={handleInspectDinsar}>
|
||||
预检查
|
||||
</button>
|
||||
<div style={{ marginTop: '3px', fontSize: '11px', color: '#64748b' }}>
|
||||
扫描目录,统计待处理对数,检查 DEM 和 SML 文件是否就绪,不执行任何处理。
|
||||
</div>
|
||||
</div>
|
||||
{/* 默认处理 */}
|
||||
<div>
|
||||
<button type="button" className="primary-btn" disabled={isLocked || readOnly} onClick={() => handleQueueDinsar('metatask')}>
|
||||
默认处理
|
||||
</button>
|
||||
<div style={{ marginTop: '3px', fontSize: '11px', color: '#64748b' }}>
|
||||
调用 ENVI SARscape metatask 一键执行全流程。参数:3×3 多视、默认自适应滤波、自动 GCP 精配准。
|
||||
</div>
|
||||
</div>
|
||||
{/* 自定义处理 */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLocked || readOnly}
|
||||
onClick={() => handleQueueDinsar('custom')}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #818cf8',
|
||||
background: '#eef2ff',
|
||||
color: '#4338ca',
|
||||
fontWeight: 500,
|
||||
fontSize: '13px',
|
||||
cursor: isLocked || readOnly ? 'not-allowed' : 'pointer',
|
||||
opacity: isLocked || readOnly ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
自定义处理
|
||||
</button>
|
||||
<div style={{ marginTop: '3px', fontSize: '11px', color: '#64748b' }}>
|
||||
逐步调用各处理模块,参数可控。多视:动态计算、GOLDSTEIN 滤波、自动 GCP、相干阈值 coh=0.05。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderInspect('D-InSAR 检查结果', dinsarInspect)}
|
||||
</div>
|
||||
|
||||
{/* Step 3: Extract Disp */}
|
||||
<div style={cardStyle}>
|
||||
<h4 style={{ marginTop: 0 }}>Step 3: 结果提取</h4>
|
||||
<p style={{ fontSize: '12px', color: 'var(--color-text-secondary)', margin: '0 0 10px' }}>
|
||||
将 D-InSAR 生产结果中的 disp 文件提取到目标目录,以便系统识别。
|
||||
</p>
|
||||
<label className="user-admin-field">
|
||||
<span>数据目录 (root_dir)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={extractRootDir}
|
||||
onChange={(e) => setExtractRootDir(e.target.value)}
|
||||
placeholder="例如: Z:/Test_data/Test_IDL_1"
|
||||
disabled={isLocked}
|
||||
/>
|
||||
</label>
|
||||
<label className="user-admin-field" style={{ marginTop: '8px' }}>
|
||||
<span>目标目录 (可选)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={extractDestDir}
|
||||
onChange={(e) => setExtractDestDir(e.target.value)}
|
||||
placeholder="留空则使用默认路径 (MONITOR_DINSAR_DIRS[0])"
|
||||
disabled={isLocked}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ marginTop: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-btn"
|
||||
disabled={isLocked || readOnly || isBusy}
|
||||
onClick={handleExtractDisp}
|
||||
>
|
||||
提取 Disp 结果
|
||||
</button>
|
||||
</div>
|
||||
{extractResult && (
|
||||
<div style={{ marginTop: '8px', padding: '10px', background: '#f0fdf4', borderRadius: '6px', border: '1px solid #bbf7d0', fontSize: '12px', color: '#166534' }}>
|
||||
<div>目标目录: <code style={{ fontSize: '11px' }}>{extractResult.target_dir}</code></div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
处理 {extractResult.processed} 个 Task ·
|
||||
新增 {extractResult.copied} ·
|
||||
更新 {extractResult.overwritten} ·
|
||||
跳过 {extractResult.skipped}
|
||||
{extractResult.failed > 0 && (
|
||||
<span style={{ color: '#dc2626' }}> · 失败 {extractResult.failed}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Run history */}
|
||||
<div style={cardStyle}>
|
||||
<h4 style={{ marginTop: 0 }}>任务历史</h4>
|
||||
{recentRuns.length === 0 ? (
|
||||
<div style={{ color: '#64748b', fontSize: '13px' }}>暂无运行记录</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '6px' }}>
|
||||
{recentRuns.map((run) => {
|
||||
const s = run.summary || {};
|
||||
const counts = s.processed != null
|
||||
? `成功 ${s.processed} / 失败 ${s.failed || 0} / 跳过 ${s.skipped || 0}`
|
||||
: '';
|
||||
const autoImported = s.auto_imported > 0 ? ` (自动导入 ${s.auto_imported})` : '';
|
||||
const statusLabel =
|
||||
run.status === 'success' ? '成功' :
|
||||
run.status === 'failed' ? '失败' : run.status;
|
||||
const statusColor =
|
||||
run.status === 'success' ? '#16a34a' :
|
||||
run.status === 'failed' ? '#dc2626' : '#d97706';
|
||||
return (
|
||||
<div key={run.run_id} style={{ border: '1px solid #e2e8f0', borderRadius: '6px', padding: '8px', fontSize: '13px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<strong>{run.workflow === 'dinsar' ? 'D-InSAR' : run.workflow === 'dinsar_custom' ? 'D-InSAR (自定义)' : 'Import'}</strong>
|
||||
<span style={{ color: statusColor, fontWeight: 500 }}>{statusLabel}</span>
|
||||
<span style={{ color: '#475569' }}>{run.duration_seconds}s</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenLog(run.run_id)}
|
||||
style={{ padding: '2px 8px', borderRadius: '4px', border: '1px solid #cbd5e1', background: 'transparent', color: '#64748b', fontSize: '11px', cursor: 'pointer' }}
|
||||
title="查看日志"
|
||||
>
|
||||
日志
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteRun(run.run_id);
|
||||
setRecentRuns((prev) => prev.filter((r) => r.run_id !== run.run_id));
|
||||
} catch (err) {
|
||||
setMessage(`删除失败: ${err.response?.data?.detail || err.message}`);
|
||||
}
|
||||
}}
|
||||
style={{ marginLeft: 'auto', padding: '2px 8px', borderRadius: '4px', border: '1px solid #e2e8f0', background: 'transparent', color: '#94a3b8', fontSize: '11px', cursor: 'pointer' }}
|
||||
title="删除运行记录"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{counts && (
|
||||
<div style={{ fontSize: '12px', color: '#475569', marginTop: '2px' }}>
|
||||
{counts}{autoImported}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '11px', color: '#94a3b8', marginTop: '2px' }}>
|
||||
{run.finished_at || run.started_at || '-'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div style={{ ...cardStyle, borderColor: '#cbd5e1', background: '#f8fafc', marginBottom: 0 }}>
|
||||
<strong>消息:</strong> {message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统计图表 */}
|
||||
{(recentRuns.length > 0 || chartStats) && (() => {
|
||||
// 图表1:近期运行成功/失败(来自 recentRuns)
|
||||
const successCount = recentRuns.filter(r => r.status === 'success').length;
|
||||
const failedCount = recentRuns.filter(r => r.status === 'failed').length;
|
||||
const otherCount = recentRuns.length - successCount - failedCount;
|
||||
const doughnutData = {
|
||||
labels: ['成功', '失败', '其他'],
|
||||
datasets: [{ data: [successCount, failedCount, otherCount], backgroundColor: ['#16a34a', '#dc2626', '#94a3b8'] }],
|
||||
};
|
||||
|
||||
// 图表2:各工作流平均耗时
|
||||
const avgDur = chartStats?.idl_processing_stats?.avg_duration_by_workflow || {};
|
||||
const wfLabels = { import: 'Import', dinsar: 'D-InSAR', dinsar_custom: 'D-InSAR 自定义' };
|
||||
const durKeys = Object.keys(avgDur);
|
||||
const barDurData = {
|
||||
labels: durKeys.map(k => wfLabels[k] || k),
|
||||
datasets: [{ label: '平均耗时 (s)', data: durKeys.map(k => avgDur[k]), backgroundColor: '#818cf8' }],
|
||||
};
|
||||
|
||||
// 图表3:D-InSAR 结果按月入库
|
||||
const byMonth = chartStats?.dinsar_by_month || [];
|
||||
const barMonthData = {
|
||||
labels: byMonth.map(d => d.month),
|
||||
datasets: [{ label: '入库数量', data: byMonth.map(d => d.count), backgroundColor: '#34d399' }],
|
||||
};
|
||||
|
||||
const miniOpts = { responsive: true, plugins: { legend: { display: false }, title: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } };
|
||||
const doughnutOpts = { responsive: true, plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } } };
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<h4 style={{ marginTop: 0, marginBottom: '12px' }}>处理统计</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '16px', alignItems: 'start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: '6px', textAlign: 'center' }}>近期运行结果</div>
|
||||
<Doughnut data={doughnutData} options={doughnutOpts} />
|
||||
</div>
|
||||
{durKeys.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: '6px', textAlign: 'center' }}>平均耗时 (秒)</div>
|
||||
<Bar data={barDurData} options={miniOpts} />
|
||||
</div>
|
||||
)}
|
||||
{byMonth.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: '6px', textAlign: 'center' }}>结果入库趋势</div>
|
||||
<Bar data={barMonthData} options={miniOpts} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 日志 Modal */}
|
||||
{logModal.open && (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => setLogModal((s) => ({ ...s, open: false }))}
|
||||
>
|
||||
<div
|
||||
style={{ background: '#1e293b', borderRadius: '8px', width: '700px', maxWidth: '95vw', maxHeight: '75vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid #334155', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ color: '#e2e8f0', fontWeight: 600, fontSize: '13px', fontFamily: 'monospace' }}>{logModal.runId}.log</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogModal((s) => ({ ...s, open: false }))}
|
||||
style={{ background: 'transparent', border: 'none', color: '#94a3b8', fontSize: '16px', cursor: 'pointer', lineHeight: 1 }}
|
||||
>✕</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px' }}>
|
||||
{logModal.loading ? (
|
||||
<div style={{ color: '#94a3b8', fontSize: '13px' }}>加载中...</div>
|
||||
) : (
|
||||
<pre style={{ margin: 0, color: '#cbd5e1', fontSize: '11px', lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{logModal.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: '8px 16px', borderTop: '1px solid #334155', fontSize: '11px', color: '#64748b' }}>
|
||||
{logModal.sizeBytes > 0 && `文件大小: ${(logModal.sizeBytes / 1024).toFixed(1)} KB`}
|
||||
{logModal.truncated && ' · 已截断,仅显示末尾 200KB'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IDLAutomationPanel;
|
||||
Reference in New Issue
Block a user