Extract export and QA frontend workflows
This commit is contained in:
@@ -7,6 +7,13 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Sprint 27 export and QA workflow hook hardening (2026-06-17)
|
||||||
|
|
||||||
|
- Moved Export Center orchestration state and API calls from `App.tsx` into `useExportWorkflow`.
|
||||||
|
- Moved QA/QC comparison state and persisted quality-check loading from `App.tsx` into `useQualityWorkflow`.
|
||||||
|
- Added regression coverage to verify `App.tsx` wires the new hooks while export and QA API ownership stays inside focused hooks.
|
||||||
|
- No API contracts, backend behavior, migrations, product features, provider fetching, AI behavior or UI redesign were introduced.
|
||||||
|
|
||||||
## Sprint 26 frontend workflow hook hardening (2026-06-17)
|
## Sprint 26 frontend workflow hook hardening (2026-06-17)
|
||||||
|
|
||||||
- Moved Detection Lab orchestration state and API calls from `App.tsx` into `useDetectionWorkflow`.
|
- Moved Detection Lab orchestration state and API calls from `App.tsx` into `useDetectionWorkflow`.
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_uses_export_and_quality_workflow_hooks() -> None:
|
||||||
|
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "useExportWorkflow" in app
|
||||||
|
assert "useQualityWorkflow" in app
|
||||||
|
assert "from './hooks/useExportWorkflow'" in app
|
||||||
|
assert "from './hooks/useQualityWorkflow'" in app
|
||||||
|
assert "exportsApi" not in app
|
||||||
|
assert "qaApi" not in app
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_workflow_hook_owns_export_api_calls() -> None:
|
||||||
|
hook = (ROOT / "frontend" / "src" / "hooks" / "useExportWorkflow.ts").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "exportsApi.listProjectExports" in hook
|
||||||
|
assert "exportsApi.exportGeojson" in hook
|
||||||
|
assert "exportsApi.exportProjectMetadata" in hook
|
||||||
|
assert "exportsApi.exportProjectReport" in hook
|
||||||
|
assert "exportsApi.getContent" in hook
|
||||||
|
assert "exportsApi.downloadUrl" in hook
|
||||||
|
assert "resetExportsForProject" in hook
|
||||||
|
|
||||||
|
|
||||||
|
def test_quality_workflow_hook_owns_quality_api_calls() -> None:
|
||||||
|
hook = (ROOT / "frontend" / "src" / "hooks" / "useQualityWorkflow.ts").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "qaApi.listQualityChecks" in hook
|
||||||
|
assert "qaApi.runQa" in hook
|
||||||
|
assert "runQaComparison" in hook
|
||||||
|
assert "loadQualityChecks" in hook
|
||||||
|
assert "setQaIouThreshold" in hook
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_still_wires_quality_results_and_export_center() -> None:
|
||||||
|
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "Refresh QA/QC results" in app
|
||||||
|
assert "onClick={() => loadQualityChecks()}" in app
|
||||||
|
assert "<ExportCenter" in app
|
||||||
|
assert "onExportDataset={exportSelectedDatasetGeoJson}" in app
|
||||||
|
assert "onExportDetectionRun={exportSelectedDetectionRunGeoJson}" in app
|
||||||
|
assert "onExportSegmentationRun={exportSelectedSegmentationRunGeoJson}" in app
|
||||||
|
assert "onPreviewContent={previewExportContent}" in app
|
||||||
|
assert "onDownload={downloadExportArtifact}" in app
|
||||||
@@ -1,3 +1,27 @@
|
|||||||
|
## Sprint 27 export and QA workflow hook hardening (2026-06-17)
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
- Moved Export Center orchestration state and API calls from `frontend/src/App.tsx` into `frontend/src/hooks/useExportWorkflow.ts`.
|
||||||
|
- Moved QA/QC comparison state and persisted quality-check listing from `frontend/src/App.tsx` into `frontend/src/hooks/useQualityWorkflow.ts`.
|
||||||
|
- Added regression tests to verify App uses export/quality hooks and still wires QA/QC results plus ExportCenter callbacks.
|
||||||
|
- Updated frontend README, TODO and changelog docs.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `python -m pytest backend/tests/test_sprint27_frontend_workflow_hooks.py backend/tests/test_sprint26_frontend_workflow_hooks.py` passed: 8 tests.
|
||||||
|
- `python -m compileall backend/app` passed.
|
||||||
|
- `cd backend && python -m pytest` passed: 164 tests.
|
||||||
|
- `cd frontend && npm run typecheck` passed.
|
||||||
|
- `cd frontend && npm run build` passed.
|
||||||
|
- `bash scripts/run_readiness_check.sh` passed: 164 backend tests, frontend typecheck/build, Alembic head check and script syntax checks.
|
||||||
|
- `cd backend && python -m alembic heads` passed: single head `202606120900`.
|
||||||
|
- `cd backend && python -m alembic upgrade head --sql` passed.
|
||||||
|
- `bash -n scripts/live_migration_smoke.sh` passed.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- No API contracts, backend behavior, migrations, product features, provider fetching, AI behavior or UI redesign changed.
|
||||||
|
- Local Windows Docker CLI was unavailable (`docker` command not found); Tower deployment remains handled through `scripts/deploy_tower.ps1`.
|
||||||
|
- Next maintainability pass should extract dataset/raster/vector operation workflows from `App.tsx`.
|
||||||
|
|
||||||
## Sprint 26 frontend workflow hook hardening (2026-06-17)
|
## Sprint 26 frontend workflow hook hardening (2026-06-17)
|
||||||
|
|
||||||
Changed:
|
Changed:
|
||||||
|
|||||||
+2
-1
@@ -37,7 +37,8 @@ This file now starts with the current implementation status. Older preparation/b
|
|||||||
- [x] Live Docker/PostGIS validation on Tower/Unraid.
|
- [x] Live Docker/PostGIS validation on Tower/Unraid.
|
||||||
- [x] Real YOLO compatibility smoke with optional AI extras and local model file.
|
- [x] Real YOLO compatibility smoke with optional AI extras and local model file.
|
||||||
- [x] Detection and segmentation workflow hook extraction beyond Sprint 10.
|
- [x] Detection and segmentation workflow hook extraction beyond Sprint 10.
|
||||||
- [ ] Further frontend state/module decomposition for datasets, exports and QA/QC.
|
- [x] Export and QA/QC workflow hook extraction beyond Sprint 10.
|
||||||
|
- [ ] Further frontend state/module decomposition for datasets and raster/vector operations.
|
||||||
|
|
||||||
## Sprint 8 status
|
## Sprint 8 status
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
- 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.
|
- 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
|
## Release hardening updates
|
||||||
|
|
||||||
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.
|
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.
|
||||||
|
|||||||
+47
-228
@@ -4,7 +4,7 @@ import GeoMap from './components/GeoMap'
|
|||||||
import { areasApi } from './services/api/areas'
|
import { areasApi } from './services/api/areas'
|
||||||
import { datasetsApi } from './services/api/datasets'
|
import { datasetsApi } from './services/api/datasets'
|
||||||
import { projectsApi } from './services/api/projects'
|
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 { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
|
||||||
import { DetectionLab } from './components/detection/DetectionLab'
|
import { DetectionLab } from './components/detection/DetectionLab'
|
||||||
import { ExportCenter } from './components/exports/ExportCenter'
|
import { ExportCenter } from './components/exports/ExportCenter'
|
||||||
@@ -17,7 +17,6 @@ import type {
|
|||||||
DatasetCreateResponse,
|
DatasetCreateResponse,
|
||||||
DatasetListResponse,
|
DatasetListResponse,
|
||||||
JobRead,
|
JobRead,
|
||||||
QaComparisonRequest,
|
|
||||||
RasterMetadataResponse,
|
RasterMetadataResponse,
|
||||||
RasterInspectResponse,
|
RasterInspectResponse,
|
||||||
RasterStatsResponse,
|
RasterStatsResponse,
|
||||||
@@ -25,18 +24,16 @@ import type {
|
|||||||
VectorSummary,
|
VectorSummary,
|
||||||
ProjectRead,
|
ProjectRead,
|
||||||
ProjectCreate,
|
ProjectCreate,
|
||||||
QualityCheckRead,
|
|
||||||
ExportCreateResponse,
|
|
||||||
ExportRead,
|
|
||||||
AreaCreate,
|
AreaCreate,
|
||||||
AreaListResponse,
|
AreaListResponse,
|
||||||
AreaRead,
|
AreaRead,
|
||||||
ProviderCapability,
|
ProviderCapability,
|
||||||
QaComparisonResult,
|
|
||||||
} from './types'
|
} from './types'
|
||||||
import { ProviderPanel } from './components/providers/ProviderPanel'
|
import { ProviderPanel } from './components/providers/ProviderPanel'
|
||||||
import { SegmentationLab } from './components/segmentation/SegmentationLab'
|
import { SegmentationLab } from './components/segmentation/SegmentationLab'
|
||||||
import { useDetectionWorkflow } from './hooks/useDetectionWorkflow'
|
import { useDetectionWorkflow } from './hooks/useDetectionWorkflow'
|
||||||
|
import { useExportWorkflow } from './hooks/useExportWorkflow'
|
||||||
|
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
|
||||||
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
|
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
|
||||||
import { formatError } from './lib/formatError'
|
import { formatError } from './lib/formatError'
|
||||||
|
|
||||||
@@ -100,21 +97,6 @@ function App(): JSX.Element {
|
|||||||
const [rasterPreview, setRasterPreview] = useState<RasterPreviewResponse | null>(null)
|
const [rasterPreview, setRasterPreview] = useState<RasterPreviewResponse | null>(null)
|
||||||
const [selectedIntersectTargetId, setSelectedIntersectTargetId] = useState('')
|
const [selectedIntersectTargetId, setSelectedIntersectTargetId] = useState('')
|
||||||
const [selectedClipAreaId, setSelectedClipAreaId] = 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 [rasterTileSize, setRasterTileSize] = useState(512)
|
||||||
const [rasterTileOverlap, setRasterTileOverlap] = useState(64)
|
const [rasterTileOverlap, setRasterTileOverlap] = useState(64)
|
||||||
const [rasterTileOutputName, setRasterTileOutputName] = useState('')
|
const [rasterTileOutputName, setRasterTileOutputName] = useState('')
|
||||||
@@ -187,6 +169,26 @@ function App(): JSX.Element {
|
|||||||
const candidateDatasets = availableVectorDatasets
|
const candidateDatasets = availableVectorDatasets
|
||||||
const providers = useMemo(() => providerCapabilities, [providerCapabilities])
|
const providers = useMemo(() => providerCapabilities, [providerCapabilities])
|
||||||
const rasterDatasets = useMemo(() => datasets.filter((item) => item.dataset_type === 'raster'), [datasets])
|
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 {
|
const {
|
||||||
detectionModels,
|
detectionModels,
|
||||||
loadingDetectionModels,
|
loadingDetectionModels,
|
||||||
@@ -272,6 +274,29 @@ function App(): JSX.Element {
|
|||||||
loadProjectData,
|
loadProjectData,
|
||||||
loadQualityChecks,
|
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(
|
const selectedMapArea = useMemo(
|
||||||
() => areas.find((area) => area.id === selectedMapAreaId) ?? null,
|
() => areas.find((area) => area.id === selectedMapAreaId) ?? null,
|
||||||
[areas, selectedMapAreaId],
|
[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 loadDatasetJobs = async (projectId: string, datasetId: string) => {
|
||||||
const response = await jobsApi.list(projectId, { dataset_id: datasetId, limit: 20, offset: 0 })
|
const response = await jobsApi.list(projectId, { dataset_id: datasetId, limit: 20, offset: 0 })
|
||||||
setJobs(response.items)
|
setJobs(response.items)
|
||||||
@@ -609,9 +488,7 @@ function App(): JSX.Element {
|
|||||||
setJobs([])
|
setJobs([])
|
||||||
resetDetectionForProject()
|
resetDetectionForProject()
|
||||||
resetSegmentationForProject()
|
resetSegmentationForProject()
|
||||||
setExports([])
|
resetExportsForProject()
|
||||||
setLatestExport(null)
|
|
||||||
setExportPreview(null)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadProjectData(selectedProjectId).catch(() => null)
|
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 runChangeDetection = async () => {
|
||||||
const sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id
|
const sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id
|
||||||
const targetDatasetId =
|
const targetDatasetId =
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user