Move pairing cache repair into planning flow

This commit is contained in:
2026-04-15 08:06:15 +08:00
parent 608c4ce257
commit 572c454a8e
8 changed files with 412 additions and 7 deletions
+2
View File
@@ -218,6 +218,8 @@ async def find_pairs_endpoint(
except Exception as e:
if isinstance(e, HTTPException):
raise e
if isinstance(e, RuntimeError):
raise HTTPException(status_code=409, detail=str(e))
logger.exception("处理 AOI 或查找干涉对时发生错误")
raise HTTPException(status_code=500, detail="处理 AOI 或查找干涉对时发生错误,请查看后端日志")
+2 -2
View File
@@ -74,12 +74,12 @@ class SpatialService:
if cache_status in {"FAILED", "UNINITIALIZED", "ERROR"} or (scene_count > 1 and pair_count == 0):
raise RuntimeError(
"配对候选缓存当前不可用,请先在系统自检中执行“全量重建配对缓存”或“增量修复”。"
"配对候选缓存当前不可用,请先在生产规划页执行“修复配对基础”或“强制全量重建”。"
)
if degraded:
warnings.append(
f"配对候选缓存当前状态为 {cache_status},本次结果基于现有缓存生成,建议尽快执行增量修复或全量重建"
f"配对候选缓存当前状态为 {cache_status},本次结果基于现有缓存生成,建议尽快在生产规划页执行缓存修复"
)
candidate_pool = await self._query_pairing_metric_cache(
+123 -2
View File
@@ -6,6 +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 { rebuildPairingCache, reconcileDirtyPairingCache } from './api/pairing';
import LogManagementPanel from './LogManagementPanel';
import DinsarCatalogPanel from './components/DinsarCatalogPanel';
@@ -120,6 +121,19 @@ const buildConsistencySummary = (stats, en = false) => {
const HEALTH_PANEL_POLL_INTERVAL_MS = 30000;
const formatPairingActionMode = (mode, en = false) => {
switch (mode) {
case 'full_rebuild':
return en ? 'Full rebuild' : '全量重建';
case 'incremental_reconcile':
return en ? 'Incremental reconcile' : '增量修复';
case 'noop':
return en ? 'No-op reconcile' : '无需修复';
default:
return en ? 'Pairing cache action' : '配对缓存操作';
}
};
const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
const en = language === 'en';
const isAdmin = currentUser?.role === 'admin';
@@ -142,6 +156,9 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
const [orbitRepairing, setOrbitRepairing] = useState(false);
const [orbitQuarantining, setOrbitQuarantining] = useState(false);
const [orbitSyncResult, setOrbitSyncResult] = useState(null);
const [pairingRebuilding, setPairingRebuilding] = useState(false);
const [pairingReconciling, setPairingReconciling] = useState(false);
const [pairingActionResult, setPairingActionResult] = useState(null);
const statusFetchInFlightRef = useRef(false);
const refreshOrbitStatus = useCallback(async () => {
@@ -166,6 +183,31 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
}
}, []);
const handlePairingCacheAction = useCallback(async (mode) => {
if (!isAdmin) {
return;
}
const setBusyState = mode === 'rebuild' ? setPairingRebuilding : setPairingReconciling;
setBusyState(true);
setPairingActionResult(null);
try {
const result = mode === 'rebuild'
? await rebuildPairingCache()
: await reconcileDirtyPairingCache();
setPairingActionResult(result);
await fetchStatus({ force: true });
} catch (err) {
setPairingActionResult({
mode,
error: err.response?.data?.detail || err.message || (en ? 'Pairing cache action failed' : '配对缓存操作失败'),
});
} finally {
setBusyState(false);
}
}, [en, fetchStatus, isAdmin]);
const fetchStatus = useCallback(async (options = {}) => {
const { force = false } = options;
if (statusFetchInFlightRef.current) {
@@ -210,13 +252,32 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
useEffect(() => {
void fetchStatus();
const timer = setInterval(() => {
if (syncLoading || cleanupLoading || wslChecking || orbitSyncing || orbitRepairing || orbitQuarantining) {
if (
syncLoading ||
cleanupLoading ||
wslChecking ||
orbitSyncing ||
orbitRepairing ||
orbitQuarantining ||
pairingRebuilding ||
pairingReconciling
) {
return;
}
void fetchStatus();
}, HEALTH_PANEL_POLL_INTERVAL_MS);
return () => clearInterval(timer);
}, [cleanupLoading, fetchStatus, orbitQuarantining, orbitRepairing, orbitSyncing, syncLoading, wslChecking]);
}, [
cleanupLoading,
fetchStatus,
orbitQuarantining,
orbitRepairing,
orbitSyncing,
pairingRebuilding,
pairingReconciling,
syncLoading,
wslChecking,
]);
const renderBadge = (ok, label = '') => (
<span className={`health-badge ${ok ? 'ok' : 'fail'}`}>
@@ -287,6 +348,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
const orbitEnviWithoutSourceCount = toNumber(orbitSource.envi_without_source_count);
const orbitIsce2WithoutSourceCount = toNumber(orbitSource.isce2_without_source_count);
const orbitQuarantinePath = orbitSource.quarantine_path || orbitStatus?.source_gaps?.quarantine_path;
const pairingActionBusy = pairingRebuilding || pairingReconciling;
const orbitOverallHealthy = Boolean(
orbitStatus &&
orbitMismatchCount === 0 &&
@@ -540,6 +602,65 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
{pairingSystem.error}
</div>
)}
{false && (
<div className="health-card-note">
{en
? 'Repair operations require an admin account. Open this panel as admin to rebuild or reconcile the pairing cache.'
: '配对缓存修复需要管理员账号。请使用管理员登录后,在此面板执行“全量重建配对缓存”或“增量修复”。'}
</div>
)}
{pairingSystem.needs_rebuild && (
<div className="health-card-note">
{en
? 'Repair entry has moved to Pair Planning. Use the production planning page to repair or rebuild the pairing foundation.'
: '配对缓存修复入口已移到“生产规划 -> 配对规划”。请在生产规划页执行配对基础修复或强制全量重建。'}
</div>
)}
{false && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
<button
onClick={() => void handlePairingCacheAction('rebuild')}
disabled={pairingActionBusy}
style={{ padding: '4px 12px', background: '#b45309', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
>
{pairingRebuilding
? (en ? 'Rebuilding...' : '重建中...')
: (en ? 'Full Pairing Rebuild' : '全量重建配对缓存')}
</button>
<button
onClick={() => void handlePairingCacheAction('reconcile')}
disabled={pairingActionBusy}
style={{ padding: '4px 12px', background: '#0f766e', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
>
{pairingReconciling
? (en ? 'Reconciling...' : '修复中...')
: (en ? 'Incremental Reconcile' : '增量修复')}
</button>
</div>
)}
{false && (
<div style={{ marginTop: 8, fontSize: 11, color: '#475569', padding: '8px 10px', background: '#f8fafc', borderRadius: 6 }}>
{pairingActionResult.error ? (
<div className="health-card-note error">{pairingActionResult.error}</div>
) : (
<>
<div className={`health-card-note ${pairingActionResult.ok ? 'ok' : 'warn'}`}>
{`${formatPairingActionMode(pairingActionResult.mode, en)}${en ? ' finished.' : '已完成。'}`}
</div>
<div className="health-card-note">
{en
? `Scenes / pairs / dirty: ${toNumber(pairingActionResult.scene_count)} / ${toNumber(pairingActionResult.pair_count)} / ${toNumber(pairingActionResult.dirty_scene_count)}`
: `场景 / 候选对 / dirty${toNumber(pairingActionResult.scene_count)} / ${toNumber(pairingActionResult.pair_count)} / ${toNumber(pairingActionResult.dirty_scene_count)}`}
</div>
<div className="health-card-note">
{en
? `Resolved dirty rows: ${toNumber(pairingActionResult.resolved_dirty_rows)}, deleted pair rows: ${toNumber(pairingActionResult.deleted_pair_rows)}`
: `已解决 dirty 记录:${toNumber(pairingActionResult.resolved_dirty_rows)},删除旧候选对:${toNumber(pairingActionResult.deleted_pair_rows)}`}
</div>
</>
)}
</div>
)}
</div>
<div className="health-card">
+5
View File
@@ -2,3 +2,8 @@ import apiClient from './client';
export const findPairs = (formData) => apiClient.post('/find-pairs', formData).then(r => r.data);
export const findPsTimeseries = (formData) => apiClient.post('/find-ps-timeseries', formData).then(r => r.data);
export const getPairingHealth = () => apiClient.get('/pairing/health').then(r => r.data);
export const rebuildPairingCache = () => apiClient.post('/pairing/rebuild-cache').then(r => r.data);
export const reconcileDirtyPairingCache = (forceFull = false) => (
apiClient.post('/pairing/reconcile-dirty', null, { params: { force_full: forceFull } }).then(r => r.data)
);
+10
View File
@@ -101,6 +101,16 @@ function PairingModal({
}
}, [showPairingModal]);
useEffect(() => {
if (!showPairingModal || availableSatellites.length === 0) {
return;
}
setSelectedSatellites((prev) => {
const normalized = prev.filter((satellite) => availableSatellites.includes(satellite));
return normalized.length === prev.length ? prev : normalized;
});
}, [availableSatellites, showPairingModal]);
// 同步 selectedSatellites 到 pairingParams
useEffect(() => {
if (selectedSatellites.length > 0) {
+1 -1
View File
@@ -25,7 +25,7 @@ const LazyUserAdminPanel = lazy(() => import('../../UserAdminPanel'));
const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel'));
const LazyAiQualityPanel = lazy(() => import('../../panels/AiQualityPanel'));
const LazyAiAnalysisPanel = lazy(() => import('../../AiAnalysisPanel'));
const LazyPairingPanel = lazy(() => import('../../panels/PairingPanel'));
const LazyPairingPanel = lazy(() => import('../../panels/PairPlanningPanel'));
const LazyDinsarResultPanel = lazy(() => import('../../panels/DinsarResultPanel'));
const LazyBatchPanel = lazy(() => import('../../panels/BatchPanel'));
const LazyPairsListPanel = lazy(() => import('../../panels/PairsListPanel'));
+2 -2
View File
@@ -103,7 +103,6 @@ export default function usePairingLogic({
const findPairs = async (e, externalRequireOrbitRef) => {
e.preventDefault();
if (!ensureCanOperate()) return;
setShowPairingModal(false);
setIsLoading(true);
addLog('info', '开始寻找干涉对...');
setPairingAlert({ warnings: [], fallbackUsed: false });
@@ -182,13 +181,14 @@ export default function usePairingLogic({
addLog('warn', '当前配对结果来自降级缓存状态,建议尽快执行缓存修复。');
}
addLog('success', `成功找到 ${pairs.length} 个干涉对(候选 ${candidateCount},入选 ${selectedEdgeCount})。`);
setShowPairingModal(false);
setPairingFiles(null);
setLeftPanelTab('pairs');
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message;
addLog('error', `寻找干涉对失败: ${errorMessage}`);
} finally {
setIsLoading(false);
setPairingFiles(null);
}
};
+267
View File
@@ -0,0 +1,267 @@
import { useCallback, useEffect, useState } from 'react';
import {
getPairingHealth,
rebuildPairingCache,
reconcileDirtyPairingCache,
} from '../api/pairing';
const formatIso = (value, en = false) => {
if (!value) return en ? 'Never' : '未执行';
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
};
const formatActionMode = (mode, en = false) => {
switch (mode) {
case 'full_rebuild':
return en ? 'Full rebuild' : '全量重建';
case 'incremental_reconcile':
return en ? 'Incremental reconcile' : '增量修复';
case 'noop':
return en ? 'No-op reconcile' : '无需修复';
default:
return en ? 'Pairing cache action' : '配对缓存操作';
}
};
export default function PairPlanningPanel({
foundPairs,
selectedPairsCount,
isLoading,
isReadOnlyUser,
hasEnoughRadarScenesForPlanning,
onOpenPairingModal,
onOpenPsModal,
hasRadarSearched,
onRefreshRadarSearch,
onSearchAll,
onRefreshDinsar,
language,
}) {
const en = language === 'en';
const [pairingStatus, setPairingStatus] = useState(null);
const [pairingStatusLoading, setPairingStatusLoading] = useState(false);
const [pairingStatusError, setPairingStatusError] = useState('');
const [pairingRepairing, setPairingRepairing] = useState(false);
const [pairingFullRebuilding, setPairingFullRebuilding] = useState(false);
const [pairingActionResult, setPairingActionResult] = useState(null);
const refreshPairingStatus = useCallback(async () => {
if (isReadOnlyUser) {
setPairingStatus(null);
setPairingStatusError('');
return;
}
setPairingStatusLoading(true);
setPairingStatusError('');
try {
const result = await getPairingHealth();
setPairingStatus(result);
} catch (error) {
setPairingStatus(null);
setPairingStatusError(
error.response?.data?.detail ||
error.message ||
(en ? 'Failed to fetch pairing foundation status.' : '配对基础状态获取失败。')
);
} finally {
setPairingStatusLoading(false);
}
}, [en, isReadOnlyUser]);
useEffect(() => {
void refreshPairingStatus();
}, [refreshPairingStatus]);
const handlePairingCacheAction = useCallback(async (mode) => {
const setBusy = mode === 'auto' ? setPairingRepairing : setPairingFullRebuilding;
setBusy(true);
setPairingActionResult(null);
try {
const result = mode === 'auto'
? await reconcileDirtyPairingCache()
: await rebuildPairingCache();
setPairingActionResult(result);
await refreshPairingStatus();
} catch (error) {
setPairingActionResult({
mode,
error: error.response?.data?.detail || error.message || (
en ? 'Pairing cache action failed.' : '配对缓存操作失败。'
),
});
} finally {
setBusy(false);
}
}, [en, refreshPairingStatus]);
const pairingActionBusy = pairingRepairing || pairingFullRebuilding;
return (
<div className="panel-content" style={{ flex: '1 1 auto', overflowY: 'auto', padding: '12px' }}>
<div className="panel-card">
<div className="panel-card-title">{en ? 'Pair Planning' : '配对规划'}</div>
<p className="panel-card-desc">
{en
? 'Filter interferometric pairs by temporal baseline, spatial baseline, and overlap ratio. Optional AOI constraint.'
: '基于时间基线、空间基线和重叠率筛选干涉对,可选 AOI 约束范围。'}
</p>
<div className="header-buttons" style={{ marginTop: '10px' }}>
<button onClick={onOpenPairingModal} disabled={isLoading || !hasEnoughRadarScenesForPlanning || isReadOnlyUser} style={{ flex: 1 }}>
{en ? 'Pair' : '配对'}
</button>
<button onClick={onOpenPsModal} disabled={isLoading || !hasEnoughRadarScenesForPlanning || isReadOnlyUser} style={{ flex: 1 }}>
{en ? 'PS Prep' : 'PS 准备'}
</button>
</div>
</div>
<div className="panel-card" style={{ marginTop: '12px' }}>
<div className="panel-card-title">{en ? 'Pairing Foundation' : '配对基础'}</div>
{isReadOnlyUser ? (
<p className="panel-card-desc" style={{ marginBottom: 0 }}>
{en ? 'Repair operations require an admin account.' : '配对缓存修复需要管理员账号。'}
</p>
) : pairingStatusLoading ? (
<p className="panel-card-desc" style={{ marginBottom: 0 }}>
{en ? 'Loading pairing foundation status...' : '正在加载配对基础状态...'}
</p>
) : pairingStatusError ? (
<div style={{ color: '#b91c1c', fontSize: 13 }}>{pairingStatusError}</div>
) : pairingStatus ? (
<>
<div className="panel-card-row">
<span>{en ? 'Status' : '状态'}</span>
<strong>{pairingStatus.status || (en ? 'Unknown' : '未知')}</strong>
</div>
<div className="panel-card-row">
<span>{en ? 'Scenes / pairs' : '场景 / 候选对'}</span>
<strong>{Number(pairingStatus.scene_count || 0)} / {Number(pairingStatus.pair_count || 0)}</strong>
</div>
<div className="panel-card-row">
<span>{en ? 'Dirty scenes' : 'Dirty 场景'}</span>
<strong>{Number(pairingStatus.dirty_scene_count || 0)}</strong>
</div>
<div className="panel-card-row">
<span>{en ? 'Last full rebuild' : '上次全量重建'}</span>
<strong>{formatIso(pairingStatus.last_full_rebuild_at, en)}</strong>
</div>
<div className="panel-card-row">
<span>{en ? 'Last incremental reconcile' : '上次增量修复'}</span>
<strong>{formatIso(pairingStatus.last_incremental_reconcile_at, en)}</strong>
</div>
<div
style={{
marginTop: 10,
padding: '10px 12px',
borderRadius: 8,
fontSize: 13,
lineHeight: 1.5,
background: pairingStatus.needs_rebuild ? '#fff7ed' : '#f0fdf4',
color: pairingStatus.needs_rebuild ? '#9a3412' : '#166534',
border: `1px solid ${pairingStatus.needs_rebuild ? '#fdba74' : '#86efac'}`,
}}
>
{pairingStatus.needs_rebuild
? (en
? 'Pairing candidate cache is not ready. Repair it here before running pair search.'
: '配对候选缓存当前不可直接用于配对。请先在这里修复,再执行配对搜索。')
: (en
? 'Pairing foundation is ready. You can proceed with pair planning.'
: '配对基础已就绪,可以直接进行配对规划。')}
</div>
<div className="header-buttons" style={{ marginTop: '10px' }}>
<button
onClick={() => void handlePairingCacheAction('auto')}
disabled={pairingActionBusy || isLoading}
style={{ flex: 1 }}
>
{pairingRepairing
? (en ? 'Repairing...' : '修复中...')
: (en ? 'Repair Pairing Foundation' : '修复配对基础')}
</button>
<button
onClick={() => void handlePairingCacheAction('full')}
disabled={pairingActionBusy || isLoading}
style={{ flex: 1 }}
>
{pairingFullRebuilding
? (en ? 'Rebuilding...' : '重建中...')
: (en ? 'Force Full Rebuild' : '强制全量重建')}
</button>
</div>
<div style={{ marginTop: 8, fontSize: 12, color: '#64748b', lineHeight: 1.5 }}>
{en
? '"Repair Pairing Foundation" will choose incremental reconcile or full rebuild automatically based on current dirty state.'
: '“修复配对基础”会根据当前 dirty 状态自动选择增量修复或全量重建;只有需要彻底重算时再使用“强制全量重建”。'}
</div>
{pairingActionResult && (
<div
style={{
marginTop: 10,
padding: '10px 12px',
borderRadius: 8,
background: '#f8fafc',
border: '1px solid #e2e8f0',
fontSize: 12,
lineHeight: 1.6,
}}
>
{pairingActionResult.error ? (
<div style={{ color: '#b91c1c' }}>{pairingActionResult.error}</div>
) : (
<>
<div style={{ color: '#0f172a', fontWeight: 600 }}>
{formatActionMode(pairingActionResult.mode, en)}
</div>
<div>
{en
? `Scenes / pairs / dirty: ${Number(pairingActionResult.scene_count || 0)} / ${Number(pairingActionResult.pair_count || 0)} / ${Number(pairingActionResult.dirty_scene_count || 0)}`
: `场景 / 候选对 / dirty${Number(pairingActionResult.scene_count || 0)} / ${Number(pairingActionResult.pair_count || 0)} / ${Number(pairingActionResult.dirty_scene_count || 0)}`}
</div>
<div>
{en
? `Resolved dirty rows: ${Number(pairingActionResult.resolved_dirty_rows || 0)}, deleted pair rows: ${Number(pairingActionResult.deleted_pair_rows || 0)}`
: `已解决 dirty 记录:${Number(pairingActionResult.resolved_dirty_rows || 0)},删除旧候选对:${Number(pairingActionResult.deleted_pair_rows || 0)}`}
</div>
</>
)}
</div>
)}
</>
) : (
<p className="panel-card-desc" style={{ marginBottom: 0 }}>
{en ? 'Pairing foundation status unavailable.' : '配对基础状态暂不可用。'}
</p>
)}
</div>
<div className="panel-card" style={{ marginTop: '12px' }}>
<div className="panel-card-title">{en ? 'Results & Refresh' : '结果与刷新'}</div>
<div className="panel-card-row">
<span>{en ? 'Generated Pairs' : '已生成配对'}</span>
<strong>{foundPairs.length}</strong>
</div>
<div className="panel-card-row">
<span>{en ? 'Selected' : '已选中'}</span>
<strong>{selectedPairsCount}</strong>
</div>
<div className="header-buttons" style={{ marginTop: '10px' }}>
<button onClick={hasRadarSearched ? onRefreshRadarSearch : onSearchAll} disabled={isLoading} style={{ flex: 1 }}>
{hasRadarSearched
? (en ? 'Refresh Current Search' : '刷新当前搜索')
: (en ? 'Search All Source Data' : '搜索全部源数据')
}
</button>
<button onClick={onRefreshDinsar} disabled={isLoading} style={{ flex: 1 }}>
{en ? 'Refresh Results' : '刷新结果'}
</button>
</div>
</div>
</div>
);
}