Extract export and QA frontend workflows
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-17 02:28:55 +02:00
parent f38ac47a04
commit fd0000f828
8 changed files with 433 additions and 229 deletions
+7
View File
@@ -177,6 +177,13 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow.
- Shared frontend API error formatting now lives in `src/lib/formatError.ts`.
- Detection Lab and Segmentation Lab UI behavior is unchanged; `App.tsx` still wires the same panel props and shared project/map state.
## Sprint 27 maintainability updates
- Export Center orchestration moved from `src/App.tsx` into `src/hooks/useExportWorkflow.ts`.
- QA/QC comparison and persisted quality-check listing moved from `src/App.tsx` into `src/hooks/useQualityWorkflow.ts`.
- Detection and segmentation QA continue to share the same persisted QA/QC refresh path through `loadQualityChecks`.
- Export Center and QA/QC UI behavior is unchanged; `App.tsx` still coordinates selected project, dataset and analysis-run state.
## Release hardening updates
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.
+47 -228
View File
@@ -4,7 +4,7 @@ import GeoMap from './components/GeoMap'
import { areasApi } from './services/api/areas'
import { datasetsApi } from './services/api/datasets'
import { projectsApi } from './services/api/projects'
import { analysisApi, demoApi, jobsApi, externalApi, exportsApi, qaApi } from './services/api'
import { analysisApi, demoApi, jobsApi, externalApi } from './services/api'
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
import { DetectionLab } from './components/detection/DetectionLab'
import { ExportCenter } from './components/exports/ExportCenter'
@@ -17,7 +17,6 @@ import type {
DatasetCreateResponse,
DatasetListResponse,
JobRead,
QaComparisonRequest,
RasterMetadataResponse,
RasterInspectResponse,
RasterStatsResponse,
@@ -25,18 +24,16 @@ import type {
VectorSummary,
ProjectRead,
ProjectCreate,
QualityCheckRead,
ExportCreateResponse,
ExportRead,
AreaCreate,
AreaListResponse,
AreaRead,
ProviderCapability,
QaComparisonResult,
} from './types'
import { ProviderPanel } from './components/providers/ProviderPanel'
import { SegmentationLab } from './components/segmentation/SegmentationLab'
import { useDetectionWorkflow } from './hooks/useDetectionWorkflow'
import { useExportWorkflow } from './hooks/useExportWorkflow'
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
import { formatError } from './lib/formatError'
@@ -100,21 +97,6 @@ function App(): JSX.Element {
const [rasterPreview, setRasterPreview] = useState<RasterPreviewResponse | null>(null)
const [selectedIntersectTargetId, setSelectedIntersectTargetId] = useState('')
const [selectedClipAreaId, setSelectedClipAreaId] = useState('')
const [qaCandidateDatasetId, setQaCandidateDatasetId] = useState('')
const [qaReferenceDatasetId, setQaReferenceDatasetId] = useState('')
const [qaAreaId, setQaAreaId] = useState('')
const [qaIouThreshold, setQaIouThreshold] = useState(0.5)
const [qaRunning, setQaRunning] = useState(false)
const [qaResult, setQaResult] = useState<QaComparisonResult | null>(null)
const [qaError, setQaError] = useState<string | null>(null)
const [qualityChecks, setQualityChecks] = useState<QualityCheckRead[]>([])
const [qualityChecksError, setQualityChecksError] = useState<string | null>(null)
const [exports, setExports] = useState<ExportRead[]>([])
const [latestExport, setLatestExport] = useState<ExportCreateResponse | null>(null)
const [exportError, setExportError] = useState<string | null>(null)
const [loadingExports, setLoadingExports] = useState(false)
const [exporting, setExporting] = useState(false)
const [exportPreview, setExportPreview] = useState<Record<string, unknown> | null>(null)
const [rasterTileSize, setRasterTileSize] = useState(512)
const [rasterTileOverlap, setRasterTileOverlap] = useState(64)
const [rasterTileOutputName, setRasterTileOutputName] = useState('')
@@ -187,6 +169,26 @@ function App(): JSX.Element {
const candidateDatasets = availableVectorDatasets
const providers = useMemo(() => providerCapabilities, [providerCapabilities])
const rasterDatasets = useMemo(() => datasets.filter((item) => item.dataset_type === 'raster'), [datasets])
const {
qaCandidateDatasetId,
qaReferenceDatasetId,
qaAreaId,
qaIouThreshold,
qaRunning,
qaResult,
qaError,
qualityChecks,
qualityChecksError,
loadQualityChecks,
runQaComparison,
setQaCandidateDatasetId,
setQaReferenceDatasetId,
setQaAreaId,
setQaIouThreshold,
} = useQualityWorkflow({
selectedProjectId,
loadProjectData,
})
const {
detectionModels,
loadingDetectionModels,
@@ -272,6 +274,29 @@ function App(): JSX.Element {
loadProjectData,
loadQualityChecks,
})
const {
exports,
latestExport,
exportError,
loadingExports,
exporting,
exportPreview,
loadExports,
exportSelectedDatasetGeoJson,
exportSelectedDetectionRunGeoJson,
exportSelectedSegmentationRunGeoJson,
exportProjectMetadata,
exportProjectReport,
previewExportContent,
downloadExportArtifact,
resetExportsForProject,
} = useExportWorkflow({
selectedProjectId,
selectedDataset,
selectedDetectionRunId,
selectedSegmentationRunId,
isVectorDatasetType,
})
const selectedMapArea = useMemo(
() => areas.find((area) => area.id === selectedMapAreaId) ?? null,
[areas, selectedMapAreaId],
@@ -398,152 +423,6 @@ function App(): JSX.Element {
}
}
async function loadQualityChecks(projectId = selectedProjectId) {
if (!projectId) {
setQualityChecks([])
return
}
setQualityChecksError(null)
try {
const response = await qaApi.listQualityChecks(projectId)
setQualityChecks(response.items)
return response.items
} catch (error) {
setQualityChecksError(formatError(error, 'Failed to load QA/QC results'))
}
}
const loadExports = async (projectId = selectedProjectId) => {
if (!projectId) {
setExports([])
return
}
setLoadingExports(true)
setExportError(null)
try {
const response = await exportsApi.listProjectExports(projectId)
setExports(response.items)
} catch (error) {
setExportError(formatError(error, 'Failed to load exports'))
} finally {
setLoadingExports(false)
}
}
const exportSelectedDatasetGeoJson = async () => {
if (!selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) {
setExportError('Select a vector dataset before exporting GeoJSON.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportGeojson({
dataset_id: selectedDataset.id,
export_kind: 'dataset',
name: selectedDataset.name.replace(/\.(geo)?json$/i, ''),
})
setLatestExport(response)
await loadExports(selectedDataset.project_id)
} catch (error) {
setExportError(formatError(error, 'Failed to export selected dataset'))
} finally {
setExporting(false)
}
}
const exportSelectedDetectionRunGeoJson = async () => {
if (!selectedDetectionRunId) {
setExportError('Select a detection run before exporting GeoJSON.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportGeojson({
analysis_run_id: selectedDetectionRunId,
export_kind: 'detection_run',
})
setLatestExport(response)
await loadExports(selectedProjectId)
} catch (error) {
setExportError(formatError(error, 'Failed to export detection run'))
} finally {
setExporting(false)
}
}
const exportSelectedSegmentationRunGeoJson = async () => {
if (!selectedSegmentationRunId) {
setExportError('Select a segmentation run before exporting GeoJSON.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportGeojson({
analysis_run_id: selectedSegmentationRunId,
export_kind: 'segmentation_run',
})
setLatestExport(response)
await loadExports(selectedProjectId)
} catch (error) {
setExportError(formatError(error, 'Failed to export segmentation run'))
} finally {
setExporting(false)
}
}
const exportProjectMetadata = async () => {
if (!selectedProjectId) {
setExportError('Select a project before exporting metadata.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportProjectMetadata(selectedProjectId)
setLatestExport(response)
await loadExports(selectedProjectId)
} catch (error) {
setExportError(formatError(error, 'Failed to export project metadata'))
} finally {
setExporting(false)
}
}
const exportProjectReport = async () => {
if (!selectedProjectId) {
setExportError('Select a project before exporting a report.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportProjectReport(selectedProjectId)
setLatestExport(response)
await loadExports(selectedProjectId)
} catch (error) {
setExportError(formatError(error, 'Failed to export project report'))
} finally {
setExporting(false)
}
}
const previewExportContent = async (exportId: string) => {
setExportError(null)
try {
const response = await exportsApi.getContent(exportId)
setExportPreview(response.content)
} catch (error) {
setExportError(formatError(error, 'Failed to load export content'))
}
}
const downloadExportArtifact = (exportId: string) => {
window.open(exportsApi.downloadUrl(exportId), '_blank', 'noopener,noreferrer')
}
const loadDatasetJobs = async (projectId: string, datasetId: string) => {
const response = await jobsApi.list(projectId, { dataset_id: datasetId, limit: 20, offset: 0 })
setJobs(response.items)
@@ -609,9 +488,7 @@ function App(): JSX.Element {
setJobs([])
resetDetectionForProject()
resetSegmentationForProject()
setExports([])
setLatestExport(null)
setExportPreview(null)
resetExportsForProject()
return
}
loadProjectData(selectedProjectId).catch(() => null)
@@ -1008,64 +885,6 @@ function App(): JSX.Element {
}
}
const runQaComparison = async () => {
if (!selectedProjectId) {
setQaError('Select a project first')
return
}
if (!qaCandidateDatasetId) {
setQaError('Select candidate dataset')
return
}
if (!qaReferenceDatasetId) {
setQaError('Select reference dataset')
return
}
if (qaCandidateDatasetId === qaReferenceDatasetId) {
setQaError('Candidate and reference datasets must be different')
return
}
if (!Number.isFinite(qaIouThreshold) || qaIouThreshold < 0 || qaIouThreshold > 1) {
setQaError('IoU threshold must be between 0 and 1')
return
}
setQaError(null)
setQaResult(null)
setQaRunning(true)
try {
const request: QaComparisonRequest = {
candidate_dataset_id: qaCandidateDatasetId,
reference_dataset_id: qaReferenceDatasetId,
iou_threshold: qaIouThreshold,
area_id: qaAreaId || null,
}
const job: JobRead = await qaApi.runQa(request)
if (job.status === 'failed') {
setQaError(job.error_message || 'QA comparison failed')
return
}
const payload = job.result_json
if (!payload || typeof payload !== 'object') {
setQaError('QA result was not available')
return
}
const parsed = payload as unknown as QaComparisonResult
if (!parsed || typeof parsed.status !== 'string') {
setQaError('QA result format was unexpected')
return
}
setQaResult(parsed)
await loadQualityChecks(selectedProjectId)
if (job.output_dataset_id) {
await loadProjectData(selectedProjectId)
}
} catch (error) {
setQaError(error instanceof Error ? error.message : 'QA comparison failed')
} finally {
setQaRunning(false)
}
}
const runChangeDetection = async () => {
const sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id
const targetDatasetId =
+182
View File
@@ -0,0 +1,182 @@
import { useState } from 'react'
import { exportsApi } from '../services/api'
import type { DatasetCreateResponse, ExportCreateResponse, ExportRead } from '../types'
import { formatError } from '../lib/formatError'
interface ExportWorkflowOptions {
selectedProjectId: string | null
selectedDataset: DatasetCreateResponse | null
selectedDetectionRunId: string
selectedSegmentationRunId: string
isVectorDatasetType: (datasetType: string) => boolean
}
export function useExportWorkflow({
selectedProjectId,
selectedDataset,
selectedDetectionRunId,
selectedSegmentationRunId,
isVectorDatasetType,
}: ExportWorkflowOptions) {
const [exports, setExports] = useState<ExportRead[]>([])
const [latestExport, setLatestExport] = useState<ExportCreateResponse | null>(null)
const [exportError, setExportError] = useState<string | null>(null)
const [loadingExports, setLoadingExports] = useState(false)
const [exporting, setExporting] = useState(false)
const [exportPreview, setExportPreview] = useState<Record<string, unknown> | null>(null)
const loadExports = async (projectId = selectedProjectId) => {
if (!projectId) {
setExports([])
return
}
setLoadingExports(true)
setExportError(null)
try {
const response = await exportsApi.listProjectExports(projectId)
setExports(response.items)
} catch (error) {
setExportError(formatError(error, 'Failed to load exports'))
} finally {
setLoadingExports(false)
}
}
const exportSelectedDatasetGeoJson = async () => {
if (!selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) {
setExportError('Select a vector dataset before exporting GeoJSON.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportGeojson({
dataset_id: selectedDataset.id,
export_kind: 'dataset',
name: selectedDataset.name.replace(/\.(geo)?json$/i, ''),
})
setLatestExport(response)
await loadExports(selectedDataset.project_id)
} catch (error) {
setExportError(formatError(error, 'Failed to export selected dataset'))
} finally {
setExporting(false)
}
}
const exportSelectedDetectionRunGeoJson = async () => {
if (!selectedDetectionRunId) {
setExportError('Select a detection run before exporting GeoJSON.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportGeojson({
analysis_run_id: selectedDetectionRunId,
export_kind: 'detection_run',
})
setLatestExport(response)
await loadExports(selectedProjectId)
} catch (error) {
setExportError(formatError(error, 'Failed to export detection run'))
} finally {
setExporting(false)
}
}
const exportSelectedSegmentationRunGeoJson = async () => {
if (!selectedSegmentationRunId) {
setExportError('Select a segmentation run before exporting GeoJSON.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportGeojson({
analysis_run_id: selectedSegmentationRunId,
export_kind: 'segmentation_run',
})
setLatestExport(response)
await loadExports(selectedProjectId)
} catch (error) {
setExportError(formatError(error, 'Failed to export segmentation run'))
} finally {
setExporting(false)
}
}
const exportProjectMetadata = async () => {
if (!selectedProjectId) {
setExportError('Select a project before exporting metadata.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportProjectMetadata(selectedProjectId)
setLatestExport(response)
await loadExports(selectedProjectId)
} catch (error) {
setExportError(formatError(error, 'Failed to export project metadata'))
} finally {
setExporting(false)
}
}
const exportProjectReport = async () => {
if (!selectedProjectId) {
setExportError('Select a project before exporting a report.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportProjectReport(selectedProjectId)
setLatestExport(response)
await loadExports(selectedProjectId)
} catch (error) {
setExportError(formatError(error, 'Failed to export project report'))
} finally {
setExporting(false)
}
}
const previewExportContent = async (exportId: string) => {
setExportError(null)
try {
const response = await exportsApi.getContent(exportId)
setExportPreview(response.content)
} catch (error) {
setExportError(formatError(error, 'Failed to load export content'))
}
}
const downloadExportArtifact = (exportId: string) => {
window.open(exportsApi.downloadUrl(exportId), '_blank', 'noopener,noreferrer')
}
const resetExportsForProject = () => {
setExports([])
setLatestExport(null)
setExportPreview(null)
}
return {
exports,
latestExport,
exportError,
loadingExports,
exporting,
exportPreview,
loadExports,
exportSelectedDatasetGeoJson,
exportSelectedDetectionRunGeoJson,
exportSelectedSegmentationRunGeoJson,
exportProjectMetadata,
exportProjectReport,
previewExportContent,
downloadExportArtifact,
resetExportsForProject,
}
}
+112
View File
@@ -0,0 +1,112 @@
import { useState } from 'react'
import { qaApi } from '../services/api'
import type { JobRead, QaComparisonRequest, QaComparisonResult, QualityCheckRead } from '../types'
import { formatError } from '../lib/formatError'
interface QualityWorkflowOptions {
selectedProjectId: string | null
loadProjectData: (projectId: string) => Promise<unknown>
}
export function useQualityWorkflow({ selectedProjectId, loadProjectData }: QualityWorkflowOptions) {
const [qaCandidateDatasetId, setQaCandidateDatasetId] = useState('')
const [qaReferenceDatasetId, setQaReferenceDatasetId] = useState('')
const [qaAreaId, setQaAreaId] = useState('')
const [qaIouThreshold, setQaIouThreshold] = useState(0.5)
const [qaRunning, setQaRunning] = useState(false)
const [qaResult, setQaResult] = useState<QaComparisonResult | null>(null)
const [qaError, setQaError] = useState<string | null>(null)
const [qualityChecks, setQualityChecks] = useState<QualityCheckRead[]>([])
const [qualityChecksError, setQualityChecksError] = useState<string | null>(null)
const loadQualityChecks = async (projectId = selectedProjectId): Promise<QualityCheckRead[] | void> => {
if (!projectId) {
setQualityChecks([])
return []
}
setQualityChecksError(null)
try {
const response = await qaApi.listQualityChecks(projectId)
setQualityChecks(response.items)
return response.items
} catch (error) {
setQualityChecksError(formatError(error, 'Failed to load QA/QC results'))
}
}
const runQaComparison = async () => {
if (!selectedProjectId) {
setQaError('Select a project first')
return
}
if (!qaCandidateDatasetId) {
setQaError('Select candidate dataset')
return
}
if (!qaReferenceDatasetId) {
setQaError('Select reference dataset')
return
}
if (qaCandidateDatasetId === qaReferenceDatasetId) {
setQaError('Candidate and reference datasets must be different')
return
}
if (!Number.isFinite(qaIouThreshold) || qaIouThreshold < 0 || qaIouThreshold > 1) {
setQaError('IoU threshold must be between 0 and 1')
return
}
setQaError(null)
setQaResult(null)
setQaRunning(true)
try {
const request: QaComparisonRequest = {
candidate_dataset_id: qaCandidateDatasetId,
reference_dataset_id: qaReferenceDatasetId,
iou_threshold: qaIouThreshold,
area_id: qaAreaId || null,
}
const job: JobRead = await qaApi.runQa(request)
if (job.status === 'failed') {
setQaError(job.error_message || 'QA comparison failed')
return
}
const payload = job.result_json
if (!payload || typeof payload !== 'object') {
setQaError('QA result was not available')
return
}
const parsed = payload as unknown as QaComparisonResult
if (!parsed || typeof parsed.status !== 'string') {
setQaError('QA result format was unexpected')
return
}
setQaResult(parsed)
await loadQualityChecks(selectedProjectId)
if (job.output_dataset_id) {
await loadProjectData(selectedProjectId)
}
} catch (error) {
setQaError(error instanceof Error ? error.message : 'QA comparison failed')
} finally {
setQaRunning(false)
}
}
return {
qaCandidateDatasetId,
qaReferenceDatasetId,
qaAreaId,
qaIouThreshold,
qaRunning,
qaResult,
qaError,
qualityChecks,
qualityChecksError,
loadQualityChecks,
runQaComparison,
setQaCandidateDatasetId,
setQaReferenceDatasetId,
setQaAreaId,
setQaIouThreshold,
}
}