Upgrade async GPU analysis and workbench UX

This commit is contained in:
Jens
2026-08-23 21:50:11 +02:00
parent 4040cbca7b
commit b996986d20
59 changed files with 3999 additions and 274 deletions
+141 -5
View File
@@ -63,9 +63,34 @@ async function auditInteractiveNames(page, label) {
return unnamed.length
}
async function prepareAuditSession(page, baseUrl) {
const sessionResponse = await page.request.get(`${baseUrl}/api/v1/auth/session`)
assert(sessionResponse.ok(), `Session preflight failed with HTTP ${sessionResponse.status()}`)
const sessionEnvelope = await sessionResponse.json()
const session = sessionEnvelope?.data
if (!session?.authentication_required || session.authenticated) return session
assert.equal(
session.guest_access_enabled,
true,
'UX audit needs an authenticated session or enabled guest access',
)
const guestResponse = await page.request.post(`${baseUrl}/api/v1/auth/guest`)
assert(guestResponse.ok(), `Guest audit session failed with HTTP ${guestResponse.status()}`)
const guestEnvelope = await guestResponse.json()
return guestEnvelope?.data
}
async function layoutEvidence(page) {
return page.evaluate(() => {
const root = document.documentElement
const rect = (selector) => {
const bounds = document.querySelector(selector)?.getBoundingClientRect()
return bounds
? { top: bounds.top, bottom: bounds.bottom, left: bounds.left, right: bounds.right, width: bounds.width, height: bounds.height }
: null
}
const main = document.querySelector('.workbench-main')?.getBoundingClientRect()
const map = document.querySelector('.geo-map-stage')?.getBoundingClientRect()
const theme = document.querySelector('.geo-theme-panel')?.getBoundingClientRect()
@@ -75,6 +100,11 @@ async function layoutEvidence(page) {
document_width: root.scrollWidth,
body_width: document.body.scrollWidth,
horizontal_overflow_px: Math.max(0, root.scrollWidth - root.clientWidth),
shell_navigation: rect('.workbench-sidebar'),
topbar: rect('.workbench-topbar'),
guest_banner: rect('.guest-mode-banner'),
explorer_header: rect('.geo-explorer-header'),
live_analysis_journey: rect('.live-analysis-journey'),
main: main ? { left: main.left, right: main.right, width: main.width } : null,
map: map ? { left: map.left, right: map.right, width: map.width, height: map.height } : null,
theme: theme ? { left: theme.left, right: theme.right, width: theme.width } : null,
@@ -82,6 +112,57 @@ async function layoutEvidence(page) {
})
}
async function runLandingViewport(browser, baseUrl, outputDir, viewport) {
const page = await browser.newPage({ viewport })
const consoleErrors = []
const failedRequests = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
page.on('pageerror', (error) => consoleErrors.push(error.message))
page.on('requestfailed', (request) => {
if (request.url().startsWith(baseUrl)) {
failedRequests.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`)
}
})
try {
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 })
await page.locator('.landing-page').waitFor({ state: 'visible', timeout: 15_000 })
await auditInteractiveNames(page, `${viewport.width}px landing`)
const horizontalOverflow = await page.evaluate(() => (
Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth)
))
assert.equal(horizontalOverflow, 0, `${viewport.width}px landing overflows horizontally`)
assert.equal(
await page.getByRole('heading', { level: 1 }).count(),
1,
`${viewport.width}px landing needs one clear primary heading`,
)
if (viewport.width <= 760) {
const menu = page.locator('.landing-menu-toggle')
assert.equal(await menu.getAttribute('aria-label'), 'Navigatie openen')
await menu.click()
assert.equal(await menu.getAttribute('aria-expanded'), 'true')
await page.getByRole('navigation', { name: 'Landingspagina' }).waitFor({ state: 'visible' })
await page.getByRole('button', { name: 'Navigatie sluiten' }).click()
}
await page.screenshot({
path: path.join(outputDir, `landing-${viewport.width}x${viewport.height}.png`),
fullPage: true,
})
return {
viewport,
horizontal_overflow_px: horizontalOverflow,
console_errors: consoleErrors,
failed_requests: failedRequests,
}
} finally {
await page.close()
}
}
async function runViewport(browser, baseUrl, outputDir, viewport) {
const page = await browser.newPage({ viewport })
const consoleErrors = []
@@ -96,14 +177,41 @@ async function runViewport(browser, baseUrl, outputDir, viewport) {
}
})
try {
await prepareAuditSession(page, baseUrl)
const startedAt = Date.now()
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 })
await page.getByTestId('map-workspace').waitFor({ state: 'visible', timeout: 30_000 })
const readyMs = Date.now() - startedAt
await auditInteractiveNames(page, `${viewport.width}px map explorer`)
const layout = await layoutEvidence(page)
const clippedNavigationLabels = await page.locator('.nav-item span').evaluateAll((labels) => labels
.filter((label) => label.getClientRects().length > 0 && label.scrollWidth > label.clientWidth + 1)
.map((label) => label.textContent?.trim() || ''))
assert.equal(layout.horizontal_overflow_px, 0, `${viewport.width}px layout overflows horizontally`)
assert.deepEqual(clippedNavigationLabels, [], `${viewport.width}px navigation clips visible labels`)
assert(layout.map && layout.map.width >= Math.min(320, viewport.width - 32), `${viewport.width}px map is too narrow`)
if (layout.topbar && layout.guest_banner) {
assert(
layout.topbar.bottom <= layout.guest_banner.top + 1,
`${viewport.width}px topbar overlaps the guest access banner`,
)
}
if (layout.guest_banner && layout.explorer_header) {
assert(
layout.guest_banner.bottom <= layout.explorer_header.top + 1,
`${viewport.width}px guest access banner overlaps the explorer heading`,
)
}
if (layout.shell_navigation && layout.live_analysis_journey) {
const verticalOverlap = Math.min(layout.shell_navigation.bottom, layout.live_analysis_journey.bottom)
- Math.max(layout.shell_navigation.top, layout.live_analysis_journey.top)
const horizontalOverlap = Math.min(layout.shell_navigation.right, layout.live_analysis_journey.right)
- Math.max(layout.shell_navigation.left, layout.live_analysis_journey.left)
assert(
verticalOverlap <= 1 || horizontalOverlap <= 1,
`${viewport.width}px navigation overlaps the live analysis journey`,
)
}
const currentTab = page.getByRole('tab', { name: 'Laatste toestand' })
const evolutionTab = page.getByRole('tab', { name: 'Evolutie' })
@@ -127,6 +235,7 @@ async function runViewport(browser, baseUrl, outputDir, viewport) {
viewport,
ready_ms: readyMs,
layout,
clipped_navigation_labels: clippedNavigationLabels,
console_errors: consoleErrors,
failed_requests: failedRequests,
}
@@ -144,6 +253,7 @@ async function runLoadingAndAdvancedAudit(browser, baseUrl, outputDir) {
await route.continue()
})
try {
const auditSession = await prepareAuditSession(page, baseUrl)
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 })
const loadingStatus = page.getByRole('status', { name: '' }).filter({
hasText: 'Databronnen worden gecontroleerd',
@@ -175,10 +285,30 @@ async function runLoadingAndAdvancedAudit(browser, baseUrl, outputDir) {
await page.screenshot({ path: path.join(outputDir, 'advanced-coverage-budget.png') })
const auditedWorkspaces = []
for (const workspace of ['data', 'assistant', 'analysis', 'ai', 'exports', 'overview', 'system']) {
const workspaceKeys = ['data', 'assistant', 'analysis', 'ai', 'exports', 'overview']
if (auditSession?.role === 'guest') {
assert.equal(
await page.getByTestId('workspace-nav-system').count(),
0,
'Guest session exposes operator-only system settings',
)
} else {
workspaceKeys.push('system')
}
for (const workspace of workspaceKeys) {
await page.getByTestId(`workspace-nav-${workspace}`).click()
await page.waitForTimeout(100)
await auditInteractiveNames(page, `${workspace} workspace`)
if (workspace === 'ai') {
await page.screenshot({ path: path.join(outputDir, 'ai-workspace.png'), fullPage: true })
const segmentationDisclosure = page.locator('.segmentation-disclosure')
await segmentationDisclosure.scrollIntoViewIfNeeded()
await segmentationDisclosure.locator('summary').first().click()
await page.waitForTimeout(150)
await auditInteractiveNames(page, 'open segmentation lab')
await segmentationDisclosure.locator('.ai-lab-run-surface').scrollIntoViewIfNeeded()
await page.screenshot({ path: path.join(outputDir, 'ai-segmentation.png') })
}
auditedWorkspaces.push(workspace)
}
@@ -203,21 +333,27 @@ async function main() {
schema_version: 1,
base_url: args.baseUrl,
started_at: new Date().toISOString(),
landing_viewports: [],
viewports: [],
bootstrap: null,
status: 'running',
}
try {
for (const viewport of [
const viewports = [
{ width: 390, height: 844 },
{ width: 1366, height: 768 },
{ width: 2560, height: 1080 },
]) {
]
for (const viewport of viewports) {
evidence.landing_viewports.push(await runLandingViewport(browser, args.baseUrl, outputDir, viewport))
}
for (const viewport of viewports) {
evidence.viewports.push(await runViewport(browser, args.baseUrl, outputDir, viewport))
}
evidence.bootstrap = await runLoadingAndAdvancedAudit(browser, args.baseUrl, outputDir)
const unexpectedConsoleErrors = evidence.viewports.flatMap((item) => item.console_errors)
const unexpectedFailedRequests = evidence.viewports.flatMap((item) => item.failed_requests)
const auditedPages = [...evidence.landing_viewports, ...evidence.viewports]
const unexpectedConsoleErrors = auditedPages.flatMap((item) => item.console_errors)
const unexpectedFailedRequests = auditedPages.flatMap((item) => item.failed_requests)
assert.deepEqual(unexpectedConsoleErrors, [], 'UX audit captured console errors')
assert.deepEqual(unexpectedFailedRequests, [], 'UX audit captured failed API requests')
evidence.status = 'passed'
+20 -4
View File
@@ -84,8 +84,8 @@ const workspaceNavItems: WorkspaceNavigationItem[] = [
{ key: 'map', label: 'Kaart', description: 'Selecteren, uitlezen en vergelijken' },
{ key: 'assistant', label: 'AI-vragen', description: 'Vraag de lokale assistent over het actieve gebied' },
{ key: 'analysis', label: 'Kwaliteit', description: 'Resultaten controleren' },
{ key: 'ai', label: 'Beeldanalyse', description: 'Gebouwen herkennen op luchtbeelden' },
{ key: 'exports', label: 'Downloads', description: 'Resultaten bewaren en delen' },
{ key: 'ai', label: 'Beeldanalyse', navigationLabel: 'AI-beeld', description: 'Gebouwen herkennen op luchtbeelden' },
{ key: 'exports', label: 'Downloads', navigationLabel: 'Export', description: 'Resultaten bewaren en delen' },
{ key: 'system', label: 'Systeem', description: 'Bronkoppelingen en operationele status' },
]
@@ -364,6 +364,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
detectionTileManifestPath,
detectionConfidenceThreshold,
runningDetection,
detectionJob,
detectionRunResult,
detectionRunError,
detectionRuns,
@@ -438,11 +439,14 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
segmentationTileManifestPath,
segmentationConfidenceThreshold,
runningSegmentation,
segmentationJob,
segmentationRunResult,
segmentationRunError,
segmentationRuns,
selectedSegmentationRunId,
segmentationItems,
segmentationTotal,
segmentationTruncated,
segmentationGeoJson,
segmentationClassFilter,
segmentationMinConfidenceFilter,
@@ -986,7 +990,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
<ShieldCheck aria-hidden="true" />
<div>
<strong>Tijdelijke demowerkruimte</strong>
<span>Alle analysemodellen en werkfuncties zijn beschikbaar. Beheer, instellingen en blijvende gegevenswijzigingen blijven afgeschermd.</span>
<span>De demo gebruikt dezelfde geconfigureerde analysemodellen en werkfuncties als een gebruiker. Beheer, instellingen en blijvende gegevenswijzigingen blijven afgeschermd.</span>
</div>
<span className="guest-mode-badge">Analyse-toegang</span>
</div>
@@ -1278,6 +1282,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
detectionTileManifestPath={detectionTileManifestPath}
detectionConfidenceThreshold={detectionConfidenceThreshold}
runningDetection={runningDetection}
detectionJob={detectionJob}
detectionRunResult={detectionRunResult}
detectionRunError={detectionRunError}
detectionRuns={detectionRuns}
@@ -1331,7 +1336,15 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
<details className="secondary-analysis-disclosure segmentation-disclosure">
<summary>
<span>Segmentatie van beeldvlakken</span>
<strong>Nog niet geconfigureerd</strong>
<strong>
{runningSegmentation
? 'In uitvoering'
: selectedSegmentationModelId === 'fixture-segmenter'
? 'Alleen test'
: selectedSegmentationModel?.configured
? 'Beschikbaar'
: 'Niet geconfigureerd'}
</strong>
</summary>
<SegmentationLab
segmentationModels={segmentationModels}
@@ -1342,11 +1355,14 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
segmentationTileManifestPath={segmentationTileManifestPath}
segmentationConfidenceThreshold={segmentationConfidenceThreshold}
runningSegmentation={runningSegmentation}
segmentationJob={segmentationJob}
segmentationRunResult={segmentationRunResult}
segmentationRunError={segmentationRunError}
segmentationRuns={segmentationRuns}
selectedSegmentationRunId={selectedSegmentationRunId}
segmentationItems={segmentationItems}
segmentationTotal={segmentationTotal}
segmentationTruncated={segmentationTruncated}
segmentationClassFilter={segmentationClassFilter}
segmentationMinConfidenceFilter={segmentationMinConfidenceFilter}
loadingSegmentationResults={loadingSegmentationResults}
+11 -6
View File
@@ -79,6 +79,15 @@ export function LandingPage({
return () => document.body.classList.remove('landing-body')
}, [])
const scrollAccessPanelIntoView = () => {
if (typeof accessPanelRef.current?.scrollIntoView !== 'function') return
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
accessPanelRef.current.scrollIntoView({
behavior: reducedMotion ? 'auto' : 'smooth',
block: 'center',
})
}
const submitLogin = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setPendingAction('operator')
@@ -99,9 +108,7 @@ export function LandingPage({
setPendingAction('guest')
setAttempted(true)
setAuthError(null)
if (typeof accessPanelRef.current?.scrollIntoView === 'function') {
accessPanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
scrollAccessPanelIntoView()
try {
const session = await loginAsGuest()
onAuthenticated(session)
@@ -114,9 +121,7 @@ export function LandingPage({
const focusLogin = () => {
setMenuOpen(false)
if (typeof accessPanelRef.current?.scrollIntoView === 'function') {
accessPanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
scrollAccessPanelIntoView()
window.requestAnimationFrame(() => usernameRef.current?.focus())
}
@@ -23,4 +23,45 @@ describe('AiPipelineIllustration', () => {
fireEvent.click(screen.getByRole('tab', { name: /Berekening/ }))
expect(screen.getByRole('tabpanel').textContent).toContain('De herkenning draait lokaal')
})
it('moves selection and focus through the tablist with keyboard controls', () => {
render(
<AiPipelineIllustration
hasImagery
hasTiles
gpuReady
hasDetections={false}
hasQualityEvidence={false}
running={false}
/>,
)
const tabs = screen.getAllByRole('tab') as HTMLButtonElement[]
const selectedTab = screen.getByRole('tab', { name: /Detecties/ }) as HTMLButtonElement
const panel = screen.getByRole('tabpanel')
expect(selectedTab.tabIndex).toBe(0)
expect(tabs.filter((tab) => tab.tabIndex === 0)).toHaveLength(1)
expect(selectedTab.getAttribute('aria-controls')).toBe(panel.id)
expect(panel.getAttribute('aria-labelledby')).toBe(selectedTab.id)
selectedTab.focus()
fireEvent.keyDown(selectedTab, { key: 'ArrowRight' })
expect(screen.getByRole('tab', { name: /QA-bewijs/ }).getAttribute('aria-selected')).toBe('true')
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /QA-bewijs/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowRight' })
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /Orthofoto/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'End' })
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /QA-bewijs/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Home' })
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /Orthofoto/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowLeft' })
const wrappedTab = screen.getByRole('tab', { name: /QA-bewijs/ })
expect(document.activeElement).toBe(wrappedTab)
expect(screen.getByRole('tabpanel').getAttribute('aria-labelledby')).toBe(wrappedTab.id)
})
})
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useId, useRef, useState, type KeyboardEvent } from 'react'
import { BadgeCheck, Boxes, Cpu, Image, ScanSearch } from 'lucide-react'
interface AiPipelineIllustrationProps {
@@ -29,14 +29,47 @@ export function AiPipelineIllustration({
const readiness = [hasImagery, hasTiles, gpuReady, hasDetections, hasQualityEvidence]
const firstIncomplete = readiness.findIndex((ready) => !ready)
const [selectedIndex, setSelectedIndex] = useState(firstIncomplete === -1 ? 4 : firstIncomplete)
const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
const componentId = useId()
const titleId = `${componentId}-title`
const panelId = `${componentId}-panel`
const selected = pipelineStages[selectedIndex]
const selectAndFocus = (index: number) => {
setSelectedIndex(index)
tabRefs.current[index]?.focus()
}
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let nextIndex: number | null = null
switch (event.key) {
case 'ArrowRight':
nextIndex = (index + 1) % pipelineStages.length
break
case 'ArrowLeft':
nextIndex = (index - 1 + pipelineStages.length) % pipelineStages.length
break
case 'Home':
nextIndex = 0
break
case 'End':
nextIndex = pipelineStages.length - 1
break
default:
return
}
event.preventDefault()
selectAndFocus(nextIndex)
}
return (
<section className={running ? 'ai-pipeline ai-pipeline-running' : 'ai-pipeline'} aria-labelledby="ai-pipeline-title">
<section className={running ? 'ai-pipeline ai-pipeline-running' : 'ai-pipeline'} aria-labelledby={titleId}>
<div className="ai-pipeline-heading">
<div>
<p className="eyebrow">Van pixel naar bewijs</p>
<h3 id="ai-pipeline-title">Van luchtbeeld naar controleerbare detectie</h3>
<h3 id={titleId}>Van luchtbeeld naar controleerbare detectie</h3>
<p>Open een schakel om te zien welke technische context GeoIntel door de volledige analyse bewaart.</p>
</div>
<span className={gpuReady ? 'ai-pipeline-gpu ai-pipeline-gpu-ready' : 'ai-pipeline-gpu'}>
@@ -49,13 +82,16 @@ export function AiPipelineIllustration({
{pipelineStages.map(({ key, label, icon: Icon }, index) => (
<button
key={key}
id={`ai-pipeline-${key}`}
id={`${componentId}-${key}`}
ref={(element) => { tabRefs.current[index] = element }}
type="button"
role="tab"
aria-selected={selectedIndex === index}
aria-controls="ai-pipeline-detail"
aria-controls={panelId}
tabIndex={selectedIndex === index ? 0 : -1}
className={readiness[index] ? 'ai-pipeline-stage ai-pipeline-stage-ready' : 'ai-pipeline-stage'}
onClick={() => setSelectedIndex(index)}
onKeyDown={(event) => handleTabKeyDown(event, index)}
>
<span><Icon aria-hidden="true" /></span>
<strong>{label}</strong>
@@ -65,10 +101,11 @@ export function AiPipelineIllustration({
</div>
<div
id="ai-pipeline-detail"
id={panelId}
className="ai-pipeline-detail"
role="tabpanel"
aria-labelledby={`ai-pipeline-${selected.key}`}
aria-labelledby={`${componentId}-${selected.key}`}
tabIndex={0}
key={selected.key}
>
<span>{String(selectedIndex + 1).padStart(2, '0')}</span>
@@ -6,6 +6,7 @@ import type {
DetectionRead,
DetectionRunRead,
DetectionRunResponse,
JobRead,
ModelAssetRead,
QualityCheckRead,
YoloPreflightResponse,
@@ -15,7 +16,7 @@ import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './de
import { DetectionModelManagement, detectionModelLabel } from './DetectionModelManagement'
import { AiPipelineIllustration } from './AiPipelineIllustration'
import { ModelSelector } from '../models/ModelSelector'
import { toAnalysisModelOption } from '../models/modelOptions'
import { analysisModelAvailabilityMessage, toAnalysisModelOption } from '../models/modelOptions'
const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const
const DEFAULT_DETECTION_PAGE_SIZE = 50
@@ -87,6 +88,7 @@ interface DetectionLabProps {
detectionTileManifestPath: string
detectionConfidenceThreshold: number
runningDetection: boolean
detectionJob: JobRead | null
detectionRunResult: DetectionRunResponse | null
detectionRunError: string | null
detectionRuns: DetectionRunRead[]
@@ -151,6 +153,7 @@ export function DetectionLab({
detectionTileManifestPath,
detectionConfidenceThreshold,
runningDetection,
detectionJob,
detectionRunResult,
detectionRunError,
detectionRuns,
@@ -208,12 +211,17 @@ export function DetectionLab({
const yoloRuntimeReady = Boolean(
yoloPreflight?.checks?.enabled &&
yoloPreflight.checks?.dependencies_available &&
yoloPreflight.checks?.accelerator_ready === true &&
yoloPreflight.checks?.model_file_exists,
)
const detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured'
const detectionJobActive = detectionJob?.status === 'queued' || detectionJob?.status === 'running'
const detectionHasDataset = selectedDetectionDatasetId.length > 0
const detectionHasModel = selectedDetectionModel !== null
const detectionModelReady = Boolean(selectedDetectionModel?.configured)
const selectedDetectionModelAvailability = selectedDetectionModel
? analysisModelAvailabilityMessage(selectedDetectionModel)
: 'Het gekozen model is niet geconfigureerd'
const detectionModelUiRunnable = detectionModelReady && selectedDetectionModelId !== 'manual-fixture-detector'
const detectionHasExplicitModelAsset =
selectedDetectionModelId !== 'yolo-configured' || modelAssets.length === 0 || selectedModelAssetId.length > 0
@@ -259,7 +267,7 @@ export function DetectionLab({
: selectedDetectionModelId === 'manual-fixture-detector'
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo\'s'
: !detectionModelReady
? selectedDetectionModel?.limitation_message ?? 'Het gekozen model is niet geconfigureerd'
? selectedDetectionModelAvailability
: !detectionHasExplicitModelAsset
? 'Kies een lokaal modelbestand onder beheer'
: !detectionHasTileManifest
@@ -275,7 +283,7 @@ export function DetectionLab({
: selectedDetectionModelId === 'manual-fixture-detector'
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo\'s'
: !detectionModelReady
? selectedDetectionModel?.limitation_message ?? 'Het gekozen model is niet geconfigureerd'
? selectedDetectionModelAvailability
: !detectionHasExplicitModelAsset
? 'Kies een lokaal modelbestand onder beheer'
: null
@@ -499,8 +507,8 @@ export function DetectionLab({
<DetectionWorkflowStep label="3. Modelcontrole" complete={detectionWorkflowStage === 'detecting' || detectionWorkflowStage === 'loading' || detectionWorkflowStage === 'complete'} active={detectionWorkflowStage === 'validating'} />
<DetectionWorkflowStep label="4. Resultaat" complete={detectionWorkflowStage === 'complete'} active={detectionWorkflowStage === 'detecting' || detectionWorkflowStage === 'loading'} />
</div>
<button className="primary-action guided-detection-action" type="button" onClick={onPrepareAndRunDetection} disabled={runningDetection || !guidedDetectionReady}>
{detectionWorkflowActionLabel(detectionWorkflowStage)}
<button className="primary-action guided-detection-action" type="button" onClick={onPrepareAndRunDetection} disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !guidedDetectionReady}>
{detectionWorkflowActionLabel(detectionWorkflowStage, detectionJob?.status)}
</button>
{!managementLocked ? <details className="ai-lab-model-surface technical-manifest-surface" aria-label="Technische tegelinstellingen">
@@ -528,7 +536,7 @@ export function DetectionLab({
<span>De technische controle wordt vernieuwd wanneer het model of tegelbestand wijzigt.</span>
</div>
) : null}
<button className="secondary-action" type="button" onClick={onRunDetection} disabled={runningDetection || !detectionRunReady}>
<button className="secondary-action" type="button" onClick={onRunDetection} disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !detectionRunReady}>
Bestaande beeldtegels analyseren
</button>
</div>
@@ -537,15 +545,26 @@ export function DetectionLab({
</div>
<div className="ai-lab-state-stack">
{detectionJob && (detectionJob.status === 'queued' || detectionJob.status === 'running') ? (
<div className="result-state" role="status" aria-live="polite">
<strong>{detectionJob.status === 'queued' ? 'GPU-taak staat in de wachtrij.' : 'GPU-analyse wordt uitgevoerd.'}</strong>
<p>
{detectionJob.status === 'queued'
? 'De server heeft de aanvraag veilig bewaard en start ze zodra de NVIDIA-worker beschikbaar is.'
: 'Het model verwerkt de beeldtegels op de server. Dit scherm volgt de bewaarde taak automatisch.'}
</p>
<span className="muted">Taak-ID: {detectionJob.id}</span>
</div>
) : null}
{detectionRunError ? (
<div className="result-state result-state-error">
<strong>De beeldanalyse is mislukt.</strong>
<div className="result-state result-state-error" role="alert">
<strong>{detectionJobActive ? 'Het volgen van de servertaak is onderbroken.' : 'De beeldanalyse is mislukt.'}</strong>
<p>{detectionRunError}</p>
</div>
) : null}
{detectionRunResult ? (
<div className="result-summary-card">
<p>Status: {detectionRunResult.status === 'completed' ? 'afgerond' : detectionRunResult.status}</p>
<div className={detectionRunResult.detection_count === 0 ? 'result-state result-state-warning' : 'result-summary-card'} role="status">
<p>Status: {detectionStatusLabel(detectionRunResult.status)}</p>
<p>{detectionRunResult.message}</p>
<p>Gevonden objecten: {detectionRunResult.detection_count}</p>
{detectionRunResult.error_code ? <p className="error">Code: {detectionRunResult.error_code}</p> : null}
@@ -602,7 +621,7 @@ export function DetectionLab({
className="primary-action"
type="button"
onClick={onRunCalibration}
disabled={runningDetectionCalibration || !calibrationRunReady}
disabled={runningDetectionCalibration || runningDetection || detectionJobActive || !calibrationRunReady}
>
Drempels vergelijken
</button>
@@ -927,7 +946,7 @@ export function DetectionLab({
) : null}
{detectionQaResult ? (
<div className="result-summary-card">
<p>Status: {detectionQaResult.status === 'completed' ? 'afgerond' : detectionQaResult.status}</p>
<p>Status: {detectionStatusLabel(detectionQaResult.status)}</p>
<p>Precisie: {detectionQaResult.precision?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Herkenningsgraad: {detectionQaResult.recall?.toFixed(3) ?? 'n.v.t.'}</p>
<p>F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n.v.t.'}</p>
@@ -1042,10 +1061,11 @@ function DetectionWorkflowStep({
)
}
function detectionWorkflowActionLabel(stage: DetectionWorkflowStage): string {
function detectionWorkflowActionLabel(stage: DetectionWorkflowStage, jobStatus?: string): string {
if (stage === 'tiling') return 'Beeldtegels voorbereiden...'
if (stage === 'validating') return 'Model en beeld controleren...'
if (stage === 'detecting') return 'Gebouwen zoeken...'
if (stage === 'detecting' && jobStatus === 'queued') return 'Wachten op NVIDIA GPU...'
if (stage === 'detecting') return 'Gebouwen zoeken op NVIDIA GPU...'
if (stage === 'loading') return 'Resultaat op kaart laden...'
if (stage === 'complete') return 'Analyse opnieuw uitvoeren'
return 'Gebouwen zoeken en op kaart tonen'
@@ -4,6 +4,7 @@ import type {
YoloPreflightResponse,
} from '../../types'
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
import { analysisModelAvailabilityMessage } from '../models/modelOptions'
interface DetectionModelManagementProps {
detectionModels: DetectionModelCapability[]
@@ -39,6 +40,8 @@ function statusLabel(value: string): string {
if (value === 'configured' || value === 'ready') return 'gereed'
if (value === 'not_configured') return 'niet geconfigureerd'
if (value === 'dependency_unavailable') return 'software ontbreekt'
if (value === 'accelerator_unavailable') return 'GPU niet beschikbaar'
if (value === 'contract_incomplete') return 'provenance onvolledig'
return value.replace(/_/g, ' ')
}
@@ -65,6 +68,7 @@ export function DetectionModelManagement({
const yoloRuntimeReady = Boolean(
yoloPreflight?.checks.enabled
&& yoloPreflight.checks.dependencies_available
&& yoloPreflight.checks.accelerator_ready === true
&& yoloPreflight.checks.model_file_exists,
)
@@ -110,7 +114,7 @@ export function DetectionModelManagement({
{statusLabel(model.status)}
</span>
<p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p>
<p className="muted">{model.limitation_message}</p>
<p className="muted">{analysisModelAvailabilityMessage(model)}</p>
<details className="technical-inline-details">
<summary>Technische identificatie</summary>
<div className="entity-meta">
@@ -255,6 +255,14 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
visibleThemes,
walloniaScopeSelected,
} = view
const resultsError = (
analysisMode === 'evolution'
? [temporalComparisonError]
: [mapSelectionError, themeResultsError]
)
.filter((message): message is string => Boolean(message))
.filter((message, index, messages) => messages.indexOf(message) === index)
.join(' ')
return (
<section
@@ -406,7 +414,7 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
: 'Niet beschikbaar'}
</small>
</span>
<i>{active ? 'Gekozen' : available ? 'Kies' : '—'}</i>
<i>{workspaceLoading ? 'Laden' : active ? 'Gekozen' : available ? 'Kies' : '—'}</i>
</button>
)
})}
@@ -859,6 +867,25 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
<span />
<strong>De gekozen bronnen worden begrensd geladen en geanalyseerd</strong>
</div>
) : resultsError ? (
<div className="geo-results-error" role="alert">
<strong>De analyse kon niet worden voltooid</strong>
<p>{resultsError}</p>
<button
className="secondary-action"
type="button"
disabled={analysisMode === 'evolution' ? !temporalSelectionValid : selectedThemes.length === 0}
onClick={() => {
if (analysisMode === 'evolution') {
runTemporalComparison()
} else if (mapSelectionBbox) {
void analyzeSelection(mapSelectionBbox, areaIdForSelection(mapSelectionBbox))
}
}}
>
Opnieuw proberen
</button>
</div>
) : analysisMode === 'current' && themeInsights.length === 0 && !mapSelectionResult ? (
<div className="geo-results-empty">
<strong>Nog niet geanalyseerd</strong>
@@ -1019,10 +1046,6 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
{analysisMode === 'current' && activeSelectionResult?.summary?.warning ? (
<p className="geo-data-notice">{activeSelectionResult.summary.warning}</p>
) : null}
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
{themeResultsError ? <p className="error">{themeResultsError}</p> : null}
{temporalComparisonError ? <p className="error">{temporalComparisonError}</p> : null}
{analysisMode === 'current' && selectedResultProperties.length > 0 ? (
<details className="geo-result-details">
<summary>Kenmerken van de gevonden objecten</summary>
@@ -18,16 +18,43 @@ describe('ModelSelector', () => {
it('opens the selector and returns an available model choice', () => {
const onChange = vi.fn()
render(<ModelSelector label="AI-model" value="automatic" options={options} onChange={onChange} automaticOption={{ id: 'automatic', name: 'Automatisch aanbevolen', status: 'available', tone: 'recommended' }} />)
fireEvent.click(screen.getByRole('button', { name: /Automatisch aanbevolen/ }))
const trigger = screen.getByRole('button', { name: /Automatisch aanbevolen/ })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
expect(trigger.getAttribute('aria-controls')).toBe(screen.getByRole('dialog').id)
expect(document.activeElement).toBe(screen.getByRole('radio', { name: /Automatisch aanbevolen/ }))
fireEvent.click(screen.getByText('Concrete modellen'))
fireEvent.click(screen.getByRole('radio', { name: /Snel lokaal model/ }))
expect(onChange).toHaveBeenCalledWith('fast')
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(trigger)
})
it('keeps unavailable runtime models disabled', () => {
render(<ModelSelector label="Analysemodel" value="fast" options={options} onChange={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: /Snel lokaal model/ }))
fireEvent.click(screen.getByText('Concrete modellen'))
const trigger = screen.getByRole('button', { name: /Snel lokaal model/ })
fireEvent.click(trigger)
expect(document.activeElement).toBe(screen.getByRole('radio', { name: /Snel lokaal model/ }))
expect((screen.getByRole('radio', { name: /Niet geconfigureerd/ }) as HTMLButtonElement).disabled).toBe(true)
})
})
it('closes predictably and restores trigger focus after close or cancel', () => {
render(<ModelSelector label="Analysemodel" value="fast" options={options} onChange={vi.fn()} />)
const trigger = screen.getByRole('button', { name: /Snel lokaal model/ })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: 'Modelkeuze sluiten' }))
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(trigger)
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog')
fireEvent(dialog, new Event('cancel', { bubbles: false, cancelable: true }))
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(dialog.hasAttribute('open')).toBe(false)
expect(document.activeElement).toBe(trigger)
})
})
@@ -53,7 +53,11 @@ export function ModelSelector({
advancedLabel = 'Concrete modellen',
}: ModelSelectorProps): JSX.Element {
const dialogRef = useRef<HTMLDialogElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const titleId = useId()
const dialogId = useId()
const [isOpen, setIsOpen] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const allOptions = useMemo(
() => automaticOption ? [automaticOption, ...options] : options,
@@ -64,27 +68,44 @@ export function ModelSelector({
?? null
useEffect(() => {
if (!dialogRef.current?.open) return
const selectedButton = dialogRef.current.querySelector<HTMLElement>('[aria-checked="true"]')
selectedButton?.focus()
}, [showAdvanced])
if (!isOpen || !dialogRef.current?.open) return
const selectedButton = dialogRef.current.querySelector<HTMLButtonElement>('[role="radio"][aria-checked="true"]:not(:disabled)')
const firstAvailableButton = dialogRef.current.querySelector<HTMLButtonElement>('[role="radio"]:not(:disabled)')
;(selectedButton ?? firstAvailableButton ?? closeButtonRef.current)?.focus()
}, [isOpen, showAdvanced, value])
const openDialog = () => {
const dialog = dialogRef.current
if (!dialog || dialog.open) return
setShowAdvanced(options.some((option) => option.id === value))
dialog.showModal()
setIsOpen(true)
}
const closeDialog = () => {
if (dialogRef.current?.open) dialogRef.current.close()
setIsOpen(false)
triggerRef.current?.focus()
}
const select = (option: ModelSelectionOption) => {
if (option.status !== 'available') return
onChange(option.id)
dialogRef.current?.close()
closeDialog()
}
return (
<div className="model-selector">
<span className="model-selector-label">{label}</span>
<button
ref={triggerRef}
type="button"
className="model-selector-trigger"
aria-haspopup="dialog"
aria-expanded={dialogRef.current?.open ?? false}
aria-expanded={isOpen}
aria-controls={dialogId}
disabled={disabled || loading || allOptions.length === 0}
onClick={() => dialogRef.current?.showModal()}
onClick={openDialog}
>
<span className="model-selector-trigger-icon"><Bot aria-hidden="true" /></span>
<span>
@@ -94,14 +115,27 @@ export function ModelSelector({
<ChevronDown aria-hidden="true" />
</button>
<dialog ref={dialogRef} className="model-selector-dialog" aria-labelledby={titleId}>
<dialog
id={dialogId}
ref={dialogRef}
className="model-selector-dialog"
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault()
closeDialog()
}}
onClose={() => {
setIsOpen(false)
triggerRef.current?.focus()
}}
>
<div className="model-selector-dialog-header">
<div>
<span className="section-kicker">Taakgerichte modelkeuze</span>
<h2 id={titleId}>Kies hoe GeoIntel analyseert</h2>
<p>GeoIntel toont alleen modellen die door de huidige omgeving worden gerapporteerd.</p>
</div>
<button type="button" className="icon-action" aria-label="Modelkeuze sluiten" onClick={() => dialogRef.current?.close()}>
<button ref={closeButtonRef} type="button" className="icon-action" aria-label="Modelkeuze sluiten" onClick={closeDialog}>
<X aria-hidden="true" />
</button>
</div>
@@ -169,4 +203,4 @@ function ModelOptionCard({ option, checked, onSelect }: { option: ModelSelection
) : null}
</div>
)
}
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import type { DetectionModelCapability } from '../../types'
import {
analysisModelAvailabilityMessage,
analysisModelDisplayName,
toAnalysisModelOption,
} from './modelOptions'
function model(overrides: Partial<DetectionModelCapability> = {}): DetectionModelCapability {
return {
model_id: 'segmentation-placeholder',
display_name: 'Segmentation placeholder',
framework: 'none',
task_type: 'segmentation',
supported_classes: [],
configured: false,
status: 'not_configured',
limitation_message: 'Segmentation inference is not configured for this placeholder.',
validated_regions: [],
nationally_validated: false,
operator_review_required: true,
...overrides,
}
}
describe('analysis model availability copy', () => {
it('does not expose raw English backend placeholder copy in the Dutch UI', () => {
const capability = model()
expect(analysisModelAvailabilityMessage(capability)).toContain('nog geen productiegeschikt segmentatiemodel')
expect(analysisModelDisplayName(capability)).toBe('Segmentatiemodel nog niet geconfigureerd')
expect(toAnalysisModelOption(capability).description).not.toContain('Segmentation inference')
})
it('explains an unavailable NVIDIA runtime explicitly', () => {
const capability = model({
model_id: 'yolo-configured',
task_type: 'object_detection',
status: 'accelerator_unavailable',
})
expect(analysisModelAvailabilityMessage(capability)).toContain('NVIDIA CUDA')
})
})
+54 -4
View File
@@ -1,15 +1,65 @@
import type { DetectionModelCapability } from '../../types'
import type { ModelSelectionOption } from './ModelSelector'
export function analysisModelDisplayName(model: DetectionModelCapability): string {
const knownNames: Record<string, string> = {
'yolo-configured': 'Lokaal gebouwmodel',
'manual-fixture-detector': 'Testdetectie (geen productie)',
'yolo-placeholder': 'Gebouwmodel nog niet geconfigureerd',
'segmentation-placeholder': 'Segmentatiemodel nog niet geconfigureerd',
'fixture-segmenter': 'Testsegmentatie (geen productie)',
'yolo-seg-configured': 'Lokaal YOLO-segmentatiemodel',
'sam-configured': 'Lokaal SAM-segmentatiemodel',
'yolo-seg-placeholder': 'YOLO-segmentatie nog niet geconfigureerd',
'sam-placeholder': 'SAM-segmentatie nog niet geconfigureerd',
}
return knownNames[model.model_id] ?? model.display_name
}
function supportedClassLabel(value: string): string {
const labels: Record<string, string> = {
building: 'gebouwen',
vegetation: 'vegetatie',
water: 'water',
landuse: 'landgebruik',
segment: 'algemene vlakken',
}
return labels[value.toLowerCase()] ?? value
}
export function analysisModelAvailabilityMessage(model: DetectionModelCapability): string {
const task = model.task_type === 'segmentation' ? 'segmentatiemodel' : 'detectiemodel'
if (model.model_id === 'manual-fixture-detector' || model.model_id === 'fixture-segmenter') {
return 'Alleen beschikbaar voor expliciete geautomatiseerde tests; dit is geen productie-inferentie.'
}
if (model.configured) {
return `Dit lokale ${task} is op de server geconfigureerd. Resultaten blijven operatorcontrole vereisen.`
}
if (model.status === 'accelerator_unavailable') {
return 'De vereiste NVIDIA CUDA-runtime is momenteel niet beschikbaar op de server.'
}
if (model.status === 'dependency_unavailable') {
return 'De vereiste PyTorch- of modelsoftware is nog niet beschikbaar op de server.'
}
if (model.status === 'contract_incomplete') {
return 'Het modelbestand is aanwezig, maar de versieerbare provenancecontrole is nog niet volledig.'
}
if (model.model_id.includes('placeholder')) {
return `Er is nog geen productiegeschikt ${task} aan deze registratie gekoppeld.`
}
return `Dit ${task} is nog niet volledig geconfigureerd op de server.`
}
export function toAnalysisModelOption(model: DetectionModelCapability): ModelSelectionOption {
const task = model.task_type === 'segmentation' ? 'segmentatie' : 'objectdetectie'
const configured = model.configured && model.status !== 'not_configured'
const supportedClasses = model.supported_classes.map(supportedClassLabel)
return {
id: model.model_id,
name: model.display_name,
name: analysisModelDisplayName(model),
description: configured
? `Beschikbaar voor lokale ${task}${model.supported_classes.length ? ` van ${model.supported_classes.join(', ')}` : ''}.`
: model.limitation_message,
? `Beschikbaar voor lokale ${task}${supportedClasses.length ? ` van ${supportedClasses.join(', ')}` : ''}.`
: analysisModelAvailabilityMessage(model),
recommendation: model.validation_scope ? `Gevalideerd voor ${model.validation_scope}.` : undefined,
status: configured ? 'available' : 'unavailable',
statusLabel: configured ? 'Beschikbaar' : 'Niet geconfigureerd',
@@ -24,4 +74,4 @@ export function toAnalysisModelOption(model: DetectionModelCapability): ModelSel
model.operator_review_required ? 'Operatorcontrole vereist' : '',
].filter(Boolean),
}
}
}
@@ -1,5 +1,6 @@
import type {
DatasetCreateResponse,
JobRead,
SegmentationModelCapability,
SegmentationQaResult,
SegmentationRead,
@@ -7,7 +8,11 @@ import type {
SegmentationRunResponse,
} from '../../types'
import { ModelSelector } from '../models/ModelSelector'
import { toAnalysisModelOption } from '../models/modelOptions'
import {
analysisModelAvailabilityMessage,
analysisModelDisplayName,
toAnalysisModelOption,
} from '../models/modelOptions'
interface SegmentationLabProps {
segmentationModels: SegmentationModelCapability[]
@@ -18,11 +23,14 @@ interface SegmentationLabProps {
segmentationTileManifestPath: string
segmentationConfidenceThreshold: number
runningSegmentation: boolean
segmentationJob: JobRead | null
segmentationRunResult: SegmentationRunResponse | null
segmentationRunError: string | null
segmentationRuns: SegmentationRunRead[]
selectedSegmentationRunId: string
segmentationItems: SegmentationRead[]
segmentationTotal: number
segmentationTruncated: boolean
segmentationClassFilter: string
segmentationMinConfidenceFilter: number
loadingSegmentationResults: boolean
@@ -92,11 +100,14 @@ export function SegmentationLab({
segmentationTileManifestPath,
segmentationConfidenceThreshold,
runningSegmentation,
segmentationJob,
segmentationRunResult,
segmentationRunError,
segmentationRuns,
selectedSegmentationRunId,
segmentationItems,
segmentationTotal,
segmentationTruncated,
segmentationClassFilter,
segmentationMinConfidenceFilter,
loadingSegmentationResults,
@@ -127,8 +138,15 @@ export function SegmentationLab({
const segmentationHasTileManifest = segmentationTileManifestPath.trim().length > 0
const segmentationModelUiRunnable =
selectedSegmentationModelConfigured && selectedSegmentationModelId !== 'fixture-segmenter'
const selectedSegmentationModel = segmentationModels.find(
(model) => model.model_id === selectedSegmentationModelId,
) ?? null
const selectedSegmentationModelAvailability = selectedSegmentationModel
? analysisModelAvailabilityMessage(selectedSegmentationModel)
: selectedSegmentationModelLimitation ?? 'Het gekozen segmentatiemodel is niet geconfigureerd'
const segmentationRunReady =
Boolean(selectedProjectId) && segmentationHasDataset && segmentationModelUiRunnable
Boolean(selectedProjectId) && segmentationHasDataset && segmentationModelUiRunnable && segmentationHasTileManifest
const segmentationJobActive = segmentationJob?.status === 'queued' || segmentationJob?.status === 'running'
const segmentationRunBlockedReason = !selectedProjectId
? 'Kies eerst een werkruimte'
: !segmentationHasDataset
@@ -136,8 +154,10 @@ export function SegmentationLab({
: selectedSegmentationModelId === 'fixture-segmenter'
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demos'
: !selectedSegmentationModelConfigured
? selectedSegmentationModelLimitation ?? 'Het gekozen segmentatiemodel is niet geconfigureerd'
: null
? selectedSegmentationModelAvailability
: !segmentationHasTileManifest
? 'Koppel eerst het beeldtegelmanifest van het gekozen rasterbestand'
: null
return (
<section className="workspace-panel ai-lab-shell segmentation-lab-shell">
@@ -180,10 +200,10 @@ export function SegmentationLab({
<ul className="model-list">
{segmentationModels.map((model) => (
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
<strong>{model.display_name}</strong>
<strong>{analysisModelDisplayName(model)}</strong>
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.configured ? 'gereed' : 'niet geconfigureerd'}</span>
<p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p>
<p className="muted">{model.limitation_message}</p>
<p className="muted">{analysisModelAvailabilityMessage(model)}</p>
<details className="technical-inline-details">
<summary>Technische identificatie</summary>
<div className="entity-meta">
@@ -219,17 +239,19 @@ export function SegmentationLab({
<span>Rasterbestand</span>
<strong>{segmentationHasDataset ? 'Geselecteerd' : 'Kies een rasterbestand'}</strong>
</div>
<div className={selectedSegmentationModelConfigured ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<div className={segmentationModelUiRunnable ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Analysemodel</span>
<strong>
{selectedSegmentationModelConfigured
{selectedSegmentationModelId === 'fixture-segmenter'
? 'Alleen beschikbaar voor geautomatiseerde tests'
: selectedSegmentationModelConfigured
? 'Het gekozen model is beschikbaar'
: selectedSegmentationModelLimitation ?? 'Kies een geconfigureerd segmentatiemodel'}
: selectedSegmentationModelAvailability}
</strong>
</div>
<div className={segmentationHasTileManifest ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Beeldtegels</span>
<strong>{segmentationHasTileManifest ? 'Technisch manifest gekoppeld' : 'Niet vereist voor het fixturemodel'}</strong>
<strong>{segmentationHasTileManifest ? 'Technisch manifest gekoppeld' : 'Koppel het tegelmanifest van het rasterbestand'}</strong>
</div>
</div>
</div>
@@ -292,29 +314,44 @@ export function SegmentationLab({
className="primary-action"
type="button"
onClick={onRunSegmentation}
disabled={runningSegmentation || !segmentationRunReady}
disabled={runningSegmentation || segmentationJobActive || !segmentationRunReady}
>
Segmentatie starten
{segmentationJob?.status === 'queued'
? 'Wachten op NVIDIA GPU…'
: runningSegmentation
? 'GPU-segmentatie wordt verwerkt…'
: 'Segmentatie starten'}
</button>
</div>
</div>
<div className="ai-lab-state-stack">
{segmentationJobActive ? (
<div className="result-state result-state-loading" role="status" aria-live="polite">
<strong>{segmentationJob?.status === 'queued' ? 'GPU-taak staat in de wachtrij.' : 'GPU-segmentatie wordt uitgevoerd.'}</strong>
<p>
{segmentationJob?.status === 'queued'
? 'De server start de taak zodra de NVIDIA-worker beschikbaar is.'
: 'GeoIntel volgt de servertaak en toont na voltooiing alleen de werkelijk bewaarde polygonen.'}
</p>
<span className="muted">Taak-ID: {segmentationJob?.id}</span>
</div>
) : null}
{!selectedSegmentationModelConfigured ? (
<div className="result-state result-state-empty">
<strong>Het segmentatiemodel is nog niet gereed.</strong>
<p>{selectedSegmentationModelLimitation ?? 'Kies een geconfigureerd segmentatiemodel.'}</p>
<p>{selectedSegmentationModelAvailability}</p>
</div>
) : null}
{segmentationRunError ? (
<div className="result-state result-state-error">
<div className="result-state result-state-error" role="alert">
<strong>De segmentatie is mislukt.</strong>
<p>{segmentationRunError}</p>
</div>
) : null}
{segmentationRunResult ? (
<div className="result-summary-card">
<p>Status: {segmentationRunResult.status === 'completed' ? 'afgerond' : segmentationRunResult.status}</p>
<div className={segmentationRunResult.segmentation_count === 0 ? 'result-state result-state-warning' : 'result-summary-card'} role="status">
<p>Status: {analysisStatusLabel(segmentationRunResult.status)}</p>
<p>{segmentationRunResult.message}</p>
<p>Herkende vlakken: {segmentationRunResult.segmentation_count}</p>
{segmentationRunResult.error_code ? <p className="error">Code: {segmentationRunResult.error_code}</p> : null}
@@ -382,10 +419,30 @@ export function SegmentationLab({
</div>
) : null}
<div className="ai-lab-state-stack">
<div className="result-state result-state-ready">
<strong>{segmentationItems.length} vlakken geladen</strong>
<p>{selectedSegmentationRunId ? 'Deze resultaten zijn bewaard in de database.' : 'Kies eerst een bewaarde analyse.'}</p>
</div>
{loadingSegmentationResults || segmentationRunError ? null : !selectedSegmentationRunId ? (
<div className="result-state result-state-empty">
<strong>Kies eerst een bewaarde analyse.</strong>
<p>Daarna toont GeoIntel uitsluitend de polygonen van die analyserun.</p>
</div>
) : segmentationTotal === 0 ? (
<div className="result-state result-state-empty">
<strong>Geen bewaarde vlakken binnen deze filters.</strong>
<p>Dit bewijst niet dat het gebied geen relevante objecten bevat.</p>
</div>
) : (
<div className={segmentationTruncated ? 'result-state result-state-warning' : 'result-state result-state-ready'}>
<strong>
{segmentationTruncated
? `${segmentationItems.length} van ${segmentationTotal} vlakken geladen`
: `${segmentationTotal} vlakken geladen`}
</strong>
<p>
{segmentationTruncated
? 'De kaart en tabel tonen een begrensde pagina. Gebruik filters om het resultaat gericht te verfijnen.'
: 'Deze resultaten zijn bewaard in de database.'}
</p>
</div>
)}
</div>
{segmentationItems.length > 0 ? (
<div className="table-scroll">
@@ -15,6 +15,7 @@ import { GeoIntelMark } from '../brand/GeoIntelBrand'
export interface WorkspaceNavigationItem {
key: WorkspaceKey
label: string
navigationLabel?: string
description: string
}
@@ -78,7 +79,7 @@ export function WorkbenchNavigation({
data-testid={`workspace-nav-${item.key}`}
>
<Icon className="nav-item-icon" aria-hidden="true" strokeWidth={1.8} />
<span>{item.label}</span>
<span>{item.navigationLabel ?? item.label}</span>
</button>
)
})}
@@ -85,6 +85,28 @@ describe('useCoverageResolver', () => {
expect(result.current.coverageDurationMs).toBeNull()
})
it('clears stale coverage as soon as a different selection starts resolving', async () => {
const nextBbox = { ...bbox, min_x: 5.1, max_x: 5.2 }
const { result, rerender } = renderHook(
({ selection }) => useCoverageResolver({ projectId: 'project-1', bbox: selection }),
{ initialProps: { selection: bbox } },
)
await act(async () => {
await vi.advanceTimersByTimeAsync(250)
})
expect(result.current.coverage).toEqual(coverageResult)
rerender({ selection: nextBbox })
expect(result.current.coverage).toBeNull()
expect(result.current.loadingCoverage).toBe(true)
await act(async () => {
await vi.advanceTimersByTimeAsync(249)
})
expect(mocks.resolveCoverage).toHaveBeenCalledTimes(1)
})
it('exposes provider failures without retaining stale results', async () => {
mocks.resolveCoverage.mockRejectedValueOnce(new Error('provider unavailable'))
const { result } = renderHook(() => useCoverageResolver({ projectId: 'project-1', bbox }))
+6 -3
View File
@@ -26,11 +26,14 @@ export function useCoverageResolver({ projectId, bbox }: CoverageResolverOptions
return
}
let cancelled = false
// A new AOI must never temporarily display the previous AOI's coverage.
// Clear immediately; the debounce only postpones the network request.
setCoverage(null)
setCoverageError(null)
setLoadingCoverage(true)
setCoverageDurationMs(null)
const timer = window.setTimeout(() => {
const startedAt = Date.now()
setLoadingCoverage(true)
setCoverageError(null)
setCoverageDurationMs(null)
externalApi.resolveCoverage({
projectId,
bbox: {
@@ -0,0 +1,301 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DetectionRunRead, JobRead, YoloPreflightResponse } from '../types'
const mocks = vi.hoisted(() => ({
listModels: vi.fn(),
listModelAssets: vi.fn(),
getYoloPreflight: vi.fn(),
runAsync: vi.fn(),
listRuns: vi.fn(),
listDetections: vi.fn(),
getRunGeoJson: vi.fn(),
getRun: vi.fn(),
compareWithReference: vi.fn(),
rasterInspect: vi.fn(),
rasterTile: vi.fn(),
upload: vi.fn(),
}))
vi.mock('../services/api', () => ({
detectionApi: {
listModels: mocks.listModels,
listModelAssets: mocks.listModelAssets,
getYoloPreflight: mocks.getYoloPreflight,
runAsync: mocks.runAsync,
listRuns: mocks.listRuns,
listDetections: mocks.listDetections,
getRunGeoJson: mocks.getRunGeoJson,
getRun: mocks.getRun,
compareWithReference: mocks.compareWithReference,
},
datasetsApi: {
rasterInspect: mocks.rasterInspect,
rasterTile: mocks.rasterTile,
upload: mocks.upload,
},
}))
import { useDetectionWorkflow } from './useDetectionWorkflow'
const projectId = 'project-1'
const datasetId = 'dataset-1'
const jobId = 'job-1'
const analysisRunId = 'run-1'
const completedJob: JobRead = {
id: jobId,
job_type: 'detection.run',
status: 'success',
project_id: projectId,
dataset_id: datasetId,
parameters_json: {},
result_json: { detection_count: 1 },
}
const persistedRun: DetectionRunRead = {
id: analysisRunId,
project_id: projectId,
dataset_id: datasetId,
job_id: jobId,
analysis_type: 'detection',
status: 'success',
model_name: 'yolo-configured',
parameters_json: {},
result_json: { detection_count: 1 },
}
function preflight(acceleratorReady: boolean): YoloPreflightResponse {
return {
model_id: 'yolo-configured',
status: acceleratorReady ? 'ready' : 'accelerator_unavailable',
message: acceleratorReady ? 'Gereed' : 'NVIDIA CUDA is niet beschikbaar',
checks: {
enabled: true,
dependencies_available: true,
accelerator_ready: acceleratorReady,
model_path_set: true,
model_file_exists: true,
model_load_requested: false,
manifest_path_set: true,
manifest_valid: true,
tile_paths_exist: true,
tile_limit_ok: true,
},
runtime: { dependencies_assumed: false, cuda_available: acceleratorReady },
tile_count: 1,
max_tiles: 256,
will_download_models: false,
will_run_inference: acceleratorReady,
}
}
function renderWorkflow() {
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const view = renderHook(() => useDetectionWorkflow({
selectedProjectId: projectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}))
return { ...view, loadProjectData }
}
describe('useDetectionWorkflow GPU execution', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.listRuns.mockResolvedValue({ items: [persistedRun], total: 1 })
mocks.listDetections.mockResolvedValue({ items: [], total: 1, truncated: false })
mocks.getRunGeoJson.mockResolvedValue({ type: 'FeatureCollection', features: [] })
mocks.listModels.mockResolvedValue({
models: [{
model_id: 'yolo-configured',
display_name: 'YOLO',
framework: 'ultralytics/pytorch',
task_type: 'object_detection',
supported_classes: ['building'],
configured: true,
status: 'configured',
limitation_message: '',
operator_review_required: true,
}],
})
mocks.listModelAssets.mockResolvedValue({ items: [], total: 0, model_directory: '/models' })
})
it('queues, follows and loads a persisted result without a synchronous inference fallback', async () => {
mocks.runAsync.mockResolvedValue(completedJob)
const { result, loadProjectData } = renderWorkflow()
act(() => {
result.current.setSelectedDetectionDatasetId(datasetId)
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
})
await act(async () => {
await result.current.runDetection()
})
expect(mocks.runAsync).toHaveBeenCalledWith(expect.objectContaining({
project_id: projectId,
dataset_id: datasetId,
model_id: 'yolo-configured',
tile_manifest_path: '/tiles/manifest.json',
}))
expect(result.current.detectionJob?.status).toBe('success')
expect(result.current.detectionRunResult).toMatchObject({
analysis_run_id: analysisRunId,
job_id: jobId,
detection_count: 1,
status: 'success',
})
expect(result.current.detectionWorkflowStage).toBe('complete')
expect(result.current.detectionRunError).toBeNull()
expect(loadProjectData).toHaveBeenCalledWith(projectId)
})
it('blocks the queue when preflight says the NVIDIA accelerator is unavailable', async () => {
mocks.getYoloPreflight.mockResolvedValue(preflight(false))
const { result } = renderWorkflow()
await act(async () => {
await result.current.loadDetectionModels()
})
act(() => {
result.current.setSelectedDetectionDatasetId(datasetId)
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
})
await act(async () => {
await result.current.prepareAndRunDetection()
})
expect(mocks.runAsync).not.toHaveBeenCalled()
expect(result.current.detectionWorkflowStage).toBe('failed')
expect(result.current.detectionRunError).toContain('NVIDIA CUDA')
})
it('does not let a late run list from another project overwrite the active project', async () => {
let resolveOlder!: (value: { items: DetectionRunRead[]; total: number }) => void
let resolveNewer!: (value: { items: DetectionRunRead[]; total: number }) => void
mocks.listRuns
.mockReturnValueOnce(new Promise((resolve) => { resolveOlder = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewer = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useDetectionWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
let olderRequest!: Promise<void>
let newerRequest!: Promise<void>
act(() => { olderRequest = result.current.loadDetectionRuns('project-1') })
rerender({ selectedProjectId: 'project-2' })
act(() => { newerRequest = result.current.loadDetectionRuns('project-2') })
const projectTwoRun = { ...persistedRun, id: 'run-2', project_id: 'project-2' }
await act(async () => {
resolveNewer({ items: [projectTwoRun], total: 1 })
await newerRequest
})
await act(async () => {
resolveOlder({ items: [persistedRun], total: 1 })
await olderRequest
})
expect(result.current.detectionRuns).toEqual([projectTwoRun])
expect(result.current.selectedDetectionRunId).toBe('run-2')
})
it('does not let late detection results from another project overwrite the active project', async () => {
type DetectionList = { items: Array<{ id: string }>; total: number; truncated: boolean }
type DetectionGeoJson = { type: 'FeatureCollection'; features: Array<{ id: string }> }
let resolveOlderList!: (value: DetectionList) => void
let resolveNewerList!: (value: DetectionList) => void
let resolveOlderGeoJson!: (value: DetectionGeoJson) => void
let resolveNewerGeoJson!: (value: DetectionGeoJson) => void
mocks.listDetections
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderList = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerList = resolve }))
mocks.getRunGeoJson
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderGeoJson = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerGeoJson = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useDetectionWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
let olderRequest!: Promise<void>
let newerRequest!: Promise<void>
act(() => { olderRequest = result.current.loadDetectionResults('run-1') })
rerender({ selectedProjectId: 'project-2' })
act(() => { newerRequest = result.current.loadDetectionResults('run-2') })
await act(async () => {
resolveNewerList({ items: [{ id: 'result-2' }], total: 1, truncated: false })
resolveNewerGeoJson({ type: 'FeatureCollection', features: [{ id: 'feature-2' }] })
await newerRequest
})
await act(async () => {
resolveOlderList({ items: [{ id: 'result-1' }], total: 1, truncated: false })
resolveOlderGeoJson({ type: 'FeatureCollection', features: [{ id: 'feature-1' }] })
await olderRequest
})
expect(result.current.detectionItems).toEqual([{ id: 'result-2' }])
expect(result.current.detectionGeoJson).toEqual({
type: 'FeatureCollection',
features: [{ id: 'feature-2' }],
})
expect(result.current.loadingDetectionResults).toBe(false)
})
it('drops a late queue response when the user has already changed project', async () => {
let resolveQueuedJob!: (value: JobRead) => void
mocks.runAsync.mockReturnValue(new Promise((resolve) => { resolveQueuedJob = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useDetectionWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
act(() => {
result.current.setSelectedDetectionDatasetId(datasetId)
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
})
let request!: Promise<void>
act(() => { request = result.current.runDetection() })
rerender({ selectedProjectId: 'project-2' })
await act(async () => {
resolveQueuedJob(completedJob)
await request
})
expect(result.current.detectionJob).toBeNull()
expect(result.current.detectionRunResult).toBeNull()
expect(result.current.runningDetection).toBe(false)
expect(mocks.getRun).not.toHaveBeenCalled()
})
})
+300 -64
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { datasetsApi, detectionApi } from '../services/api'
import type {
DatasetCreateResponse,
@@ -13,6 +13,12 @@ import type {
YoloPreflightResponse,
} from '../types'
import { formatError } from '../lib/formatError'
import {
analysisRunIdFromJob,
completedDetectionResponse,
DetectionJobError,
waitForDetectionJob,
} from '../services/detectionJob'
interface DetectionWorkflowOptions {
selectedProjectId: string | null
@@ -88,6 +94,16 @@ function rasterTileCount(metadata: Record<string, unknown>, tileSize: number, ov
return Math.ceil(width / step) * Math.ceil(height / step)
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
function abortedError(): Error {
const error = new Error('Het volgen van de detectietaak is gestopt')
error.name = 'AbortError'
return error
}
export function useDetectionWorkflow({
selectedProjectId,
rasterDatasets,
@@ -106,6 +122,7 @@ export function useDetectionWorkflow({
const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('')
const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.15)
const [runningDetection, setRunningDetection] = useState(false)
const [detectionJob, setDetectionJob] = useState<JobRead | null>(null)
const [detectionRunResult, setDetectionRunResult] = useState<DetectionRunResponse | null>(null)
const [detectionRunError, setDetectionRunError] = useState<string | null>(null)
const [detectionRuns, setDetectionRuns] = useState<DetectionRunRead[]>([])
@@ -130,6 +147,36 @@ export function useDetectionWorkflow({
const [detectionCalibrationRows, setDetectionCalibrationRows] = useState<DetectionCalibrationRunRow[]>([])
const [detectionCalibrationError, setDetectionCalibrationError] = useState<string | null>(null)
const [detectionWorkflowStage, setDetectionWorkflowStage] = useState<DetectionWorkflowStage>('idle')
const activeDetectionControllerRef = useRef<AbortController | null>(null)
const selectedProjectIdRef = useRef(selectedProjectId)
const detectionExecutionSequence = useRef(0)
const detectionRunsRequestSequence = useRef(0)
const detectionResultsRequestSequence = useRef(0)
const detectionQaRequestSequence = useRef(0)
const detectionCalibrationSequence = useRef(0)
selectedProjectIdRef.current = selectedProjectId
useEffect(() => {
activeDetectionControllerRef.current?.abort()
activeDetectionControllerRef.current = null
detectionExecutionSequence.current += 1
detectionQaRequestSequence.current += 1
detectionCalibrationSequence.current += 1
setDetectionJob(null)
setRunningDetection(false)
setDetectionRunResult(null)
setDetectionRunError(null)
setDetectionWorkflowStage('idle')
setDetectionQaResult(null)
setDetectionQaError(null)
setRunningDetectionQa(false)
setDetectionCalibrationRows([])
setDetectionCalibrationError(null)
setRunningDetectionCalibration(false)
return () => {
activeDetectionControllerRef.current?.abort()
}
}, [selectedProjectId])
const loadDetectionModels = async () => {
setLoadingDetectionModels(true)
@@ -187,26 +234,36 @@ export function useDetectionWorkflow({
}
const loadDetectionRuns = async (projectId = selectedProjectId) => {
const sequence = detectionRunsRequestSequence.current + 1
detectionRunsRequestSequence.current = sequence
if (!projectId) {
setDetectionRuns([])
return
}
try {
const response = await detectionApi.listRuns({ project_id: projectId })
if (
detectionRunsRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return
setDetectionRuns(response.items)
if (!selectedDetectionRunId && response.items.length > 0) {
setSelectedDetectionRunId(response.items[0].id)
}
setSelectedDetectionRunId((current) => current || response.items[0]?.id || '')
} catch (error) {
setDetectionRunError(formatError(error, 'De detectieruns konden niet worden geladen'))
if (
detectionRunsRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setDetectionRunError(formatError(error, 'De detectieruns konden niet worden geladen'))
}
}
}
const loadDetectionResults = async (analysisRunId = selectedDetectionRunId) => {
if (!analysisRunId) {
const sequence = detectionResultsRequestSequence.current + 1
detectionResultsRequestSequence.current = sequence
const requestProjectId = selectedProjectIdRef.current
if (!analysisRunId || !requestProjectId) {
setDetectionItems([])
setDetectionTotal(0)
setDetectionTruncated(false)
setDetectionTotal(0)
setDetectionTruncated(false)
setDetectionGeoJson(null)
@@ -216,7 +273,7 @@ export function useDetectionWorkflow({
setDetectionRunError(null)
try {
const params = {
project_id: selectedProjectId ?? '',
project_id: requestProjectId,
class_name: detectionClassFilter || null,
min_confidence: detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
}
@@ -224,14 +281,28 @@ export function useDetectionWorkflow({
detectionApi.listDetections(analysisRunId, params),
detectionApi.getRunGeoJson(analysisRunId, params),
])
if (
detectionResultsRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== requestProjectId
) return
setDetectionItems(detectionsResponse.items)
setDetectionTotal(detectionsResponse.total)
setDetectionTruncated(Boolean(detectionsResponse.truncated))
setDetectionGeoJson(geoJsonResponse)
} catch (error) {
setDetectionRunError(formatError(error, 'De detectieresultaten konden niet worden geladen'))
if (
detectionResultsRequestSequence.current === sequence
&& selectedProjectIdRef.current === requestProjectId
) {
setDetectionRunError(formatError(error, 'De detectieresultaten konden niet worden geladen'))
}
} finally {
setLoadingDetectionResults(false)
if (
detectionResultsRequestSequence.current === sequence
&& selectedProjectIdRef.current === requestProjectId
) {
setLoadingDetectionResults(false)
}
}
}
@@ -241,23 +312,91 @@ export function useDetectionWorkflow({
manifestPath: string | null,
modelId = selectedDetectionModelId,
modelAssetId = selectedModelAssetId,
confidenceThreshold = detectionConfidenceThreshold,
parametersJson: Record<string, unknown> = {},
) => {
const result = await detectionApi.run({
if (
(activeDetectionControllerRef.current && !activeDetectionControllerRef.current.signal.aborted)
|| detectionJob?.status === 'queued'
|| detectionJob?.status === 'running'
) {
throw new DetectionJobError(
'Er wordt al een GPU-detectietaak gevolgd. Wacht tot die taak klaar is voordat u een nieuwe start.',
'DETECTION_JOB_ALREADY_ACTIVE',
detectionJob?.id ?? 'unknown',
)
}
const request = {
project_id: projectId,
dataset_id: datasetId,
model_id: modelId,
model_asset_id: modelAssetId || null,
confidence_threshold: detectionConfidenceThreshold,
confidence_threshold: confidenceThreshold,
tile_manifest_path: manifestPath,
parameters_json: {},
})
setDetectionRunResult(result)
setSelectedDetectionRunId(result.analysis_run_id)
setDetectionWorkflowStage('loading')
await loadDetectionRuns(projectId)
await loadDetectionResults(result.analysis_run_id)
await loadProjectData(projectId)
return result
parameters_json: parametersJson,
}
const controller = new AbortController()
const executionSequence = detectionExecutionSequence.current + 1
detectionExecutionSequence.current = executionSequence
activeDetectionControllerRef.current = controller
const assertExecutionCurrent = () => {
if (
controller.signal.aborted
|| detectionExecutionSequence.current !== executionSequence
|| selectedProjectIdRef.current !== projectId
) {
throw abortedError()
}
}
try {
setDetectionJob(null)
const queuedJob = await detectionApi.runAsync(request)
assertExecutionCurrent()
setDetectionJob(queuedJob)
const completedJob = await waitForDetectionJob({
projectId,
initialJob: queuedJob,
signal: controller.signal,
onStatus: (job) => {
if (
detectionExecutionSequence.current === executionSequence
&& selectedProjectIdRef.current === projectId
) {
setDetectionJob(job)
}
},
})
assertExecutionCurrent()
const explicitAnalysisRunId = analysisRunIdFromJob(completedJob)
const run = explicitAnalysisRunId
? await detectionApi.getRun(explicitAnalysisRunId, projectId)
: (await detectionApi.listRuns({ project_id: projectId, dataset_id: datasetId })).items
.find((candidate) => candidate.job_id === completedJob.id)
assertExecutionCurrent()
if (!run) {
throw new DetectionJobError(
'De GPU-taak is voltooid, maar de bijbehorende bewaarde detectierun ontbreekt.',
'DETECTION_RUN_RESULT_NOT_FOUND',
completedJob.id,
)
}
const result = completedDetectionResponse(request, completedJob, run)
setDetectionRunResult(result)
setSelectedDetectionRunId(result.analysis_run_id)
setDetectionWorkflowStage('loading')
await loadDetectionRuns(projectId)
assertExecutionCurrent()
await loadDetectionResults(result.analysis_run_id)
assertExecutionCurrent()
await loadProjectData(projectId)
assertExecutionCurrent()
return result
} finally {
if (activeDetectionControllerRef.current === controller) {
activeDetectionControllerRef.current = null
}
}
}
const runDetection = async () => {
@@ -265,6 +404,7 @@ export function useDetectionWorkflow({
setDetectionRunError('Kies eerst een werkruimte')
return
}
const projectId = selectedProjectId
const datasetId = selectedDetectionDatasetId
if (!datasetId) {
setDetectionRunError('Kies eerst een rasterbron')
@@ -275,13 +415,19 @@ export function useDetectionWorkflow({
setRunningDetection(true)
setDetectionWorkflowStage('detecting')
try {
await executeDetection(selectedProjectId, datasetId, detectionTileManifestPath.trim() || null)
setDetectionWorkflowStage('complete')
await executeDetection(projectId, datasetId, detectionTileManifestPath.trim() || null)
if (selectedProjectIdRef.current === projectId) {
setDetectionWorkflowStage('complete')
}
} catch (error) {
setDetectionRunError(formatError(error, 'Detection run failed'))
setDetectionWorkflowStage('failed')
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
setDetectionRunError(formatError(error, 'Detection run failed'))
setDetectionWorkflowStage('failed')
}
} finally {
setRunningDetection(false)
if (selectedProjectIdRef.current === projectId) {
setRunningDetection(false)
}
}
}
@@ -290,10 +436,11 @@ export function useDetectionWorkflow({
setDetectionRunError('De regionale werkruimte is nog niet geladen')
return false
}
const projectId = selectedProjectId
setDetectionRunError(null)
setDetectionWorkflowStage('uploading')
try {
const dataset = await datasetsApi.upload(selectedProjectId, {
const dataset = await datasetsApi.upload(projectId, {
file,
datasetType: 'raster',
source: 'user_upload',
@@ -302,15 +449,19 @@ export function useDetectionWorkflow({
sourceMetadataJson: JSON.stringify({ purpose: 'building_detection' }),
provenanceMetadataJson: JSON.stringify({ original_filename: file.name, acquisition: 'explicit_user_upload' }),
})
if (selectedProjectIdRef.current !== projectId) throw abortedError()
setSelectedDetectionDatasetId(dataset.id)
setDetectionTileManifestPath('')
setDetectionRunResult(null)
setDetectionWorkflowStage('ready')
await loadProjectData(selectedProjectId)
await loadProjectData(projectId)
if (selectedProjectIdRef.current !== projectId) throw abortedError()
return true
} catch (error) {
setDetectionRunError(formatError(error, 'Het luchtbeeld kon niet worden toegevoegd'))
setDetectionWorkflowStage('failed')
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
setDetectionRunError(formatError(error, 'Het luchtbeeld kon niet worden toegevoegd'))
setDetectionWorkflowStage('failed')
}
return false
}
}
@@ -323,6 +474,10 @@ export function useDetectionWorkflow({
setDetectionRunError('De regionale werkruimte is nog niet geladen')
return null
}
const projectId = selectedProjectId
const assertProjectCurrent = () => {
if (selectedProjectIdRef.current !== projectId) throw abortedError()
}
const datasetId = datasetIdOverride || selectedDetectionDatasetId
if (!datasetId) {
setDetectionRunError('Kies of voeg eerst een gegeorefereerd luchtbeeld toe')
@@ -334,7 +489,7 @@ export function useDetectionWorkflow({
: selectedModelAssetId
const selectedModel = detectionModels.find((model) => model.model_id === effectiveModelId)
if (!selectedModel?.configured || effectiveModelId === 'manual-fixture-detector') {
setDetectionRunError(selectedModel?.limitation_message ?? 'Het gekozen analysemodel is niet beschikbaar')
setDetectionRunError('Het gekozen productie-analysemodel is niet beschikbaar; vernieuw de modelstatus en controleer de serverconfiguratie')
return null
}
if (effectiveModelId === 'yolo-configured' && modelAssets.length > 0 && !effectiveModelAssetId) {
@@ -349,7 +504,8 @@ export function useDetectionWorkflow({
let manifestPath = detectionTileManifestPath.trim()
if (!manifestPath) {
setDetectionWorkflowStage('tiling')
const inspection = await datasetsApi.rasterInspect(selectedProjectId, datasetId)
const inspection = await datasetsApi.rasterInspect(projectId, datasetId)
assertProjectCurrent()
const expectedTileCount = rasterTileCount(inspection.metadata, 512, 64)
const maxTiles = yoloPreflight?.max_tiles ?? 256
if (expectedTileCount === null) {
@@ -360,10 +516,11 @@ export function useDetectionWorkflow({
`Dit luchtbeeld zou ${expectedTileCount} beeldtegels maken; het veilige maximum is ${maxTiles}. Knip het beeld eerst tot het gewenste werkgebied.`,
)
}
const tileJob = await datasetsApi.rasterTile(selectedProjectId, datasetId, {
const tileJob = await datasetsApi.rasterTile(projectId, datasetId, {
tile_size: 512,
overlap: 64,
})
assertProjectCurrent()
manifestPath = tileManifestPathFromJob(tileJob) ?? ''
if (!manifestPath) {
throw new Error(tileJob.error_message || 'De tegelvoorbereiding leverde geen geldig manifest op')
@@ -376,6 +533,7 @@ export function useDetectionWorkflow({
tile_manifest_path: manifestPath,
model_asset_id: effectiveModelAssetId || null,
})
assertProjectCurrent()
setYoloPreflight(preflight)
setYoloPreflightError(null)
if (
@@ -383,6 +541,7 @@ export function useDetectionWorkflow({
!preflight.checks.tile_paths_exist ||
!preflight.checks.tile_limit_ok ||
!preflight.checks.dependencies_available ||
preflight.checks.accelerator_ready !== true ||
!preflight.checks.model_file_exists
) {
throw new Error(preflight.message || 'De beeldtegels of modelruntime zijn niet startklaar')
@@ -390,20 +549,25 @@ export function useDetectionWorkflow({
setDetectionWorkflowStage('detecting')
const result = await executeDetection(
selectedProjectId,
projectId,
datasetId,
manifestPath,
effectiveModelId,
effectiveModelAssetId,
)
assertProjectCurrent()
setDetectionWorkflowStage('complete')
return result
} catch (error) {
setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt'))
setDetectionWorkflowStage('failed')
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt'))
setDetectionWorkflowStage('failed')
}
return null
} finally {
setRunningDetection(false)
if (selectedProjectIdRef.current === projectId) {
setRunningDetection(false)
}
}
}
@@ -421,26 +585,51 @@ export function useDetectionWorkflow({
setDetectionQaError('Kies eerst een referentiebron')
return null
}
const projectId = selectedProjectIdRef.current
if (!projectId) {
setDetectionQaError('Kies eerst een werkruimte')
return null
}
const sequence = detectionQaRequestSequence.current + 1
detectionQaRequestSequence.current = sequence
setSelectedDetectionRunId(analysisRunId)
setDetectionReferenceDatasetId(referenceDatasetId)
setDetectionQaError(null)
setDetectionQaResult(null)
setRunningDetectionQa(true)
try {
const result = await detectionApi.compareWithReference(analysisRunId, selectedProjectId!, {
const result = await detectionApi.compareWithReference(analysisRunId, projectId, {
reference_dataset_id: referenceDatasetId,
iou_threshold: iouThresholdOverride ?? qaIouThreshold,
class_name: useCurrentFilters ? detectionClassFilter || null : null,
min_confidence: useCurrentFilters && detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
})
if (
detectionQaRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return null
setDetectionQaResult(result)
await loadQualityChecks(selectedProjectId)
await loadQualityChecks(projectId)
if (
detectionQaRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return null
return result
} catch (error) {
setDetectionQaError(formatError(error, 'Detection QA failed'))
if (
detectionQaRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setDetectionQaError(formatError(error, 'Detection QA failed'))
}
return null
} finally {
setRunningDetectionQa(false)
if (
detectionQaRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setRunningDetectionQa(false)
}
}
}
@@ -452,6 +641,7 @@ export function useDetectionWorkflow({
setDetectionCalibrationError('Kies eerst een werkruimte om te kalibreren')
return
}
const projectId = selectedProjectId
const datasetId = selectedDetectionDatasetId
if (!datasetId) {
setDetectionCalibrationError('Kies eerst een rasterbron om te kalibreren')
@@ -461,13 +651,14 @@ export function useDetectionWorkflow({
setDetectionCalibrationError('Kies eerst een referentiebron om te kalibreren')
return
}
const referenceDatasetId = detectionReferenceDatasetId
const selectedModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId)
if (!selectedModel?.configured || selectedDetectionModelId === 'manual-fixture-detector') {
setDetectionCalibrationError('Kies eerst een geconfigureerd detectiemodel; testgegevens kunnen niet gekalibreerd worden')
return
}
if (selectedDetectionModelId === 'yolo-configured' && !detectionTileManifestPath.trim()) {
setDetectionCalibrationError('Configured YOLO calibration requires a tile manifest')
setDetectionCalibrationError('Kalibratie met YOLO vereist een beeldtegelmanifest')
return
}
if (selectedDetectionModelId === 'yolo-configured' && modelAssets.length > 0 && !selectedModelAssetId) {
@@ -476,9 +667,17 @@ export function useDetectionWorkflow({
}
const thresholds = parseCalibrationThresholds(calibrationThresholdText)
if (thresholds.length === 0) {
setDetectionCalibrationError('Provide at least one valid threshold between 0 and 1')
setDetectionCalibrationError('Geef minstens één geldige drempel tussen 0 en 1 op')
return
}
const sequence = detectionCalibrationSequence.current + 1
detectionCalibrationSequence.current = sequence
const assertCalibrationCurrent = () => {
if (
detectionCalibrationSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) throw abortedError()
}
setDetectionCalibrationError(null)
setDetectionCalibrationRows(thresholds.map((threshold) => ({ threshold, status: 'queued' })))
setRunningDetectionCalibration(true)
@@ -492,24 +691,28 @@ export function useDetectionWorkflow({
setDetectionCalibrationRows((rows) =>
rows.map((row) => ({ ...row, status: 'running', message: 'Eén inferentie voor alle drempels' })),
)
const result = await detectionApi.run({
project_id: selectedProjectId,
dataset_id: datasetId,
model_id: selectedDetectionModelId,
model_asset_id: selectedModelAssetId || null,
confidence_threshold: lowestThreshold,
tile_manifest_path: detectionTileManifestPath.trim() || null,
parameters_json: { calibration: true, calibration_thresholds: thresholds },
})
setDetectionWorkflowStage('detecting')
const result = await executeDetection(
projectId,
datasetId,
detectionTileManifestPath.trim() || null,
selectedDetectionModelId,
selectedModelAssetId,
lowestThreshold,
{ calibration: true, calibration_thresholds: thresholds },
)
assertCalibrationCurrent()
setDetectionWorkflowStage('complete')
setSelectedDetectionRunId(result.analysis_run_id)
const qa = await detectionApi.compareWithReference(result.analysis_run_id, selectedProjectId, {
reference_dataset_id: detectionReferenceDatasetId,
const qa = await detectionApi.compareWithReference(result.analysis_run_id, projectId, {
reference_dataset_id: referenceDatasetId,
iou_threshold: qaIouThreshold,
class_name: detectionClassFilter || null,
min_confidence: null,
calibration_thresholds: thresholds,
})
assertCalibrationCurrent()
const sweep = new Map((qa.calibration_sweep ?? []).map((point) => [point.min_confidence, point]))
setDetectionCalibrationRows((rows) =>
@@ -536,17 +739,31 @@ export function useDetectionWorkflow({
}),
)
await loadDetectionRuns(selectedProjectId)
await loadQualityChecks(selectedProjectId)
await loadProjectData(selectedProjectId)
await loadDetectionRuns(projectId)
assertCalibrationCurrent()
await loadQualityChecks(projectId)
assertCalibrationCurrent()
await loadProjectData(projectId)
assertCalibrationCurrent()
} catch (error) {
const message = formatError(error, 'Calibration failed')
setDetectionCalibrationRows((rows) =>
rows.map((row) => (row.status === 'success' ? row : { ...row, status: 'failed', message })),
)
setDetectionCalibrationError(message)
if (
!isAbortError(error)
&& detectionCalibrationSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
const message = formatError(error, 'Kalibratie mislukt')
setDetectionCalibrationRows((rows) =>
rows.map((row) => (row.status === 'success' ? row : { ...row, status: 'failed', message })),
)
setDetectionCalibrationError(message)
}
} finally {
setRunningDetectionCalibration(false)
if (
detectionCalibrationSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setRunningDetectionCalibration(false)
}
}
}
@@ -557,14 +774,32 @@ export function useDetectionWorkflow({
}
const resetDetectionForProject = () => {
detectionExecutionSequence.current += 1
detectionRunsRequestSequence.current += 1
detectionResultsRequestSequence.current += 1
detectionQaRequestSequence.current += 1
detectionCalibrationSequence.current += 1
activeDetectionControllerRef.current?.abort()
activeDetectionControllerRef.current = null
setSelectedDetectionDatasetId('')
setDetectionRuns([])
setSelectedDetectionRunId('')
setDetectionItems([])
setDetectionTotal(0)
setDetectionTruncated(false)
setDetectionGeoJson(null)
setDetectionRunResult(null)
setDetectionJob(null)
setDetectionReferenceDatasetId('')
setDetectionQaResult(null)
setDetectionQaError(null)
setRunningDetectionQa(false)
setDetectionCalibrationRows([])
setDetectionCalibrationError(null)
setRunningDetectionCalibration(false)
setDetectionRunError(null)
setLoadingDetectionResults(false)
setRunningDetection(false)
setDetectionWorkflowStage('idle')
}
@@ -580,6 +815,7 @@ export function useDetectionWorkflow({
detectionTileManifestPath,
detectionConfidenceThreshold,
runningDetection,
detectionJob,
detectionRunResult,
detectionRunError,
detectionRuns,
+117
View File
@@ -0,0 +1,117 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssistantQueryResponse } from '../types'
const mocks = vi.hoisted(() => ({
status: vi.fn(),
models: vi.fn(),
query: vi.fn(),
}))
vi.mock('../services/api/assistant', () => ({
assistantApi: mocks,
}))
import { useGeoAssistant } from './useGeoAssistant'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
function response(answer: string): AssistantQueryResponse {
return {
answer,
model: 'geo-model',
scope_label: 'testgebied',
context_metrics: [],
temporal_series: [],
source_dataset_ids: [],
warnings: [],
generated_at: '2026-08-23T12:00:00Z',
}
}
describe('useGeoAssistant request scope', () => {
beforeEach(() => {
window.localStorage.clear()
mocks.status.mockResolvedValue({
enabled: true,
reachable: true,
status: 'ready',
base_url: 'http://localhost',
default_model: 'geo-model',
model_count: 1,
limitation_message: '',
})
mocks.models.mockResolvedValue({
items: [{ name: 'geo-model', capabilities: ['chat'] }],
total: 1,
default_model: 'geo-model',
})
})
it('ignores an answer that returns after the active project changed', async () => {
const pending = deferred<AssistantQueryResponse>()
mocks.query.mockReturnValueOnce(pending.promise)
const { result, rerender } = renderHook(
({ projectId }) => useGeoAssistant({
selectedProjectId: projectId,
selectedAreaId: null,
selectionBbox: null,
}),
{ initialProps: { projectId: 'project-1' } },
)
await waitFor(() => expect(result.current.selectedModel).toBe('geo-model'))
let request!: Promise<boolean>
act(() => {
request = result.current.ask('Wat staat hier?')
})
rerender({ projectId: 'project-2' })
await act(async () => {
pending.resolve(response('antwoord uit project 1'))
await request
})
expect(result.current.messages).toEqual([])
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
it('lets only the newest request update a conversation', async () => {
const older = deferred<AssistantQueryResponse>()
const newer = deferred<AssistantQueryResponse>()
mocks.query
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise)
const { result } = renderHook(() => useGeoAssistant({
selectedProjectId: 'project-1',
selectedAreaId: null,
selectionBbox: null,
}))
await waitFor(() => expect(result.current.selectedModel).toBe('geo-model'))
let olderRequest!: Promise<boolean>
let newerRequest!: Promise<boolean>
act(() => { olderRequest = result.current.ask('Eerste vraag') })
act(() => { newerRequest = result.current.ask('Tweede vraag') })
await act(async () => {
newer.resolve(response('nieuwste antwoord'))
await newerRequest
})
await act(async () => {
older.resolve(response('verouderd antwoord'))
await olderRequest
})
const assistantMessages = result.current.messages.filter((message) => message.role === 'assistant')
expect(assistantMessages.map((message) => message.content)).toEqual(['nieuwste antwoord'])
expect(result.current.loading).toBe(false)
})
})
+101 -15
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { formatError } from '../lib/formatError'
import { assistantApi } from '../services/api/assistant'
import type {
@@ -36,15 +36,61 @@ function readStoredPreference(): string {
}
}
function assistantScopeKey(
projectId: string | null,
areaId: string | null,
bbox: VectorSelectionBBox | null,
): string {
return JSON.stringify([
projectId,
areaId,
bbox?.min_x ?? null,
bbox?.min_y ?? null,
bbox?.max_x ?? null,
bbox?.max_y ?? null,
bbox?.crs ?? null,
])
}
interface AssistantConversationState {
scopeKey: string
messages: GeoAssistantMessage[]
}
interface AssistantRequestState {
scopeKey: string
requestId: number
loading: boolean
error: string | null
}
export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBbox }: UseGeoAssistantOptions) {
const scopeKey = assistantScopeKey(selectedProjectId, selectedAreaId, selectionBbox)
const activeScopeRef = useRef(scopeKey)
const latestRequestIdRef = useRef(0)
if (activeScopeRef.current !== scopeKey) {
activeScopeRef.current = scopeKey
latestRequestIdRef.current += 1
}
const [status, setStatus] = useState<AssistantStatus | null>(null)
const [models, setModels] = useState<AssistantModelRead[]>([])
const [selectedModelChoice, setSelectedModelChoice] = useState(readStoredPreference)
const [defaultModel, setDefaultModel] = useState('')
const [messages, setMessages] = useState<GeoAssistantMessage[]>([])
const [loading, setLoading] = useState(false)
const [conversation, setConversation] = useState<AssistantConversationState>({ scopeKey, messages: [] })
const [requestState, setRequestState] = useState<AssistantRequestState>({
scopeKey,
requestId: 0,
loading: false,
error: null,
})
const [loadingModels, setLoadingModels] = useState(false)
const [error, setError] = useState<string | null>(null)
const [modelError, setModelError] = useState<string | null>(null)
const messages = conversation.scopeKey === scopeKey ? conversation.messages : []
const loading = requestState.scopeKey === scopeKey && requestState.loading
const queryError = requestState.scopeKey === scopeKey ? requestState.error : null
const error = queryError ?? modelError
const selectedModel = useMemo(() => {
const available = new Set(models.map((model) => model.name))
@@ -62,7 +108,7 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
const loadModels = async () => {
setLoadingModels(true)
setError(null)
setModelError(null)
try {
const currentStatus = await assistantApi.status()
setStatus(currentStatus)
@@ -82,22 +128,39 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
setStatus(null)
setModels([])
setDefaultModel('')
setError(formatError(requestError, 'De lokale AI-assistent kon niet worden bereikt.'))
setModelError(formatError(requestError, 'De lokale AI-assistent kon niet worden bereikt.'))
} finally {
setLoadingModels(false)
}
}
useEffect(() => { void loadModels() }, [])
useEffect(() => { setMessages([]); setError(null) }, [selectedProjectId])
useEffect(() => {
setConversation({ scopeKey, messages: [] })
setRequestState({
scopeKey,
requestId: latestRequestIdRef.current,
loading: false,
error: null,
})
}, [scopeKey])
const ask = async (question: string): Promise<boolean> => {
const trimmed = question.trim()
if (!selectedProjectId || !trimmed || !selectedModel) return false
const requestId = latestRequestIdRef.current + 1
latestRequestIdRef.current = requestId
const requestScopeKey = scopeKey
const userMessage: GeoAssistantMessage = { id: nextAssistantMessageId('user'), role: 'user', content: trimmed }
setMessages((current) => [...current, userMessage])
setLoading(true)
setError(null)
setConversation((current) => ({
scopeKey: requestScopeKey,
messages: [...(current.scopeKey === requestScopeKey ? current.messages : []), userMessage],
}))
setRequestState({ scopeKey: requestScopeKey, requestId, loading: true, error: null })
const isLatestRequest = () => (
latestRequestIdRef.current === requestId
&& activeScopeRef.current === requestScopeKey
)
try {
const history = messages.slice(-6).map(({ role, content }) => ({ role, content }))
const result = await assistantApi.query(selectedProjectId, {
@@ -107,17 +170,40 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
area_id: selectedAreaId,
history,
})
setMessages((current) => [...current, { id: nextAssistantMessageId('assistant'), role: 'assistant', content: result.answer, response: result }])
if (!isLatestRequest()) return false
setConversation((current) => current.scopeKey === requestScopeKey ? {
scopeKey: requestScopeKey,
messages: [...current.messages, { id: nextAssistantMessageId('assistant'), role: 'assistant', content: result.answer, response: result }],
} : current)
return true
} catch (requestError) {
setError(formatError(requestError, 'GeoIntel kon de vraag niet beantwoorden.'))
if (!isLatestRequest()) return false
setRequestState({
scopeKey: requestScopeKey,
requestId,
loading: false,
error: formatError(requestError, 'GeoIntel kon de vraag niet beantwoorden.'),
})
return false
} finally {
setLoading(false)
if (isLatestRequest()) {
setRequestState((current) => current.scopeKey === requestScopeKey && current.requestId === requestId
? { ...current, loading: false }
: current)
}
}
}
const clear = () => { setMessages([]); setError(null) }
const clear = () => {
latestRequestIdRef.current += 1
setConversation({ scopeKey, messages: [] })
setRequestState({
scopeKey,
requestId: latestRequestIdRef.current,
loading: false,
error: null,
})
}
return { status, models, selectedModel, selectedModelChoice, defaultModel, messages, loading, loadingModels, error, loadModels, ask, clear, setSelectedModel }
}
}
@@ -0,0 +1,220 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JobRead, SegmentationRead, SegmentationRunRead } from '../types'
const mocks = vi.hoisted(() => ({
listModels: vi.fn(),
runAsync: vi.fn(),
listRuns: vi.fn(),
getRun: vi.fn(),
listSegmentations: vi.fn(),
getRunGeoJson: vi.fn(),
compareWithReference: vi.fn(),
}))
vi.mock('../services/api', () => ({
segmentationApi: {
listModels: mocks.listModels,
runAsync: mocks.runAsync,
listRuns: mocks.listRuns,
getRun: mocks.getRun,
listSegmentations: mocks.listSegmentations,
getRunGeoJson: mocks.getRunGeoJson,
compareWithReference: mocks.compareWithReference,
},
}))
import { useSegmentationWorkflow } from './useSegmentationWorkflow'
const projectId = 'project-1'
const datasetId = 'dataset-1'
const jobId = 'job-1'
const analysisRunId = 'run-1'
const completedJob: JobRead = {
id: jobId,
job_type: 'segmentation.run',
status: 'success',
project_id: projectId,
dataset_id: datasetId,
parameters_json: {},
result_json: { analysis_run_id: analysisRunId, segmentation_count: 2 },
}
const persistedRun: SegmentationRunRead = {
id: analysisRunId,
project_id: projectId,
dataset_id: datasetId,
job_id: jobId,
analysis_type: 'segmentation',
status: 'success',
model_name: 'yolo-seg-configured',
parameters_json: {},
result_json: { segmentation_count: 2 },
}
function renderWorkflow(selectedProjectId = projectId) {
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const view = renderHook(() => useSegmentationWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}))
return { ...view, loadProjectData }
}
describe('useSegmentationWorkflow GPU execution', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.listModels.mockResolvedValue({
models: [{
model_id: 'yolo-seg-configured',
display_name: 'YOLO segmentatie',
framework: 'ultralytics/pytorch',
task_type: 'segmentation',
supported_classes: ['building'],
configured: true,
status: 'configured',
limitation_message: '',
operator_review_required: true,
}],
})
mocks.runAsync.mockResolvedValue(completedJob)
mocks.listRuns.mockResolvedValue({ items: [persistedRun], total: 1 })
mocks.getRun.mockResolvedValue(persistedRun)
mocks.listSegmentations.mockResolvedValue({ items: [], total: 0, truncated: false })
mocks.getRunGeoJson.mockResolvedValue({ type: 'FeatureCollection', features: [] })
})
it('queues, follows and reconciles a persisted segmentation result', async () => {
const { result, loadProjectData } = renderWorkflow()
await act(async () => { await result.current.loadSegmentationModels() })
act(() => {
result.current.setSelectedSegmentationDatasetId(datasetId)
result.current.setSegmentationTileManifestPath('/tiles/manifest.json')
})
await act(async () => { await result.current.runSegmentation() })
expect(mocks.runAsync).toHaveBeenCalledWith(expect.objectContaining({
project_id: projectId,
dataset_id: datasetId,
model_id: 'yolo-seg-configured',
tile_manifest_path: '/tiles/manifest.json',
}))
expect(mocks.getRun).toHaveBeenCalledWith(analysisRunId, projectId)
expect(result.current.segmentationRunResult).toMatchObject({
analysis_run_id: analysisRunId,
job_id: jobId,
segmentation_count: 2,
status: 'success',
})
expect(result.current.segmentationRunError).toBeNull()
expect(result.current.segmentationTotal).toBe(0)
expect(result.current.segmentationTruncated).toBe(false)
expect(loadProjectData).toHaveBeenCalledWith(projectId)
})
it('does not queue a configured model without a tile manifest', async () => {
const { result } = renderWorkflow()
await act(async () => { await result.current.loadSegmentationModels() })
act(() => { result.current.setSelectedSegmentationDatasetId(datasetId) })
await act(async () => { await result.current.runSegmentation() })
expect(mocks.runAsync).not.toHaveBeenCalled()
expect(result.current.segmentationRunError).toContain('beeldtegelmanifest')
})
it('ignores a late run list after the active project changes', async () => {
let resolveOlder!: (value: { items: SegmentationRunRead[]; total: number }) => void
let resolveNewer!: (value: { items: SegmentationRunRead[]; total: number }) => void
mocks.listRuns
.mockReturnValueOnce(new Promise((resolve) => { resolveOlder = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewer = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useSegmentationWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
let olderRequest!: Promise<void>
let newerRequest!: Promise<void>
act(() => { olderRequest = result.current.loadSegmentationRuns('project-1') })
rerender({ selectedProjectId: 'project-2' })
act(() => { newerRequest = result.current.loadSegmentationRuns('project-2') })
const projectTwoRun = { ...persistedRun, id: 'run-2', project_id: 'project-2' }
await act(async () => {
resolveNewer({ items: [projectTwoRun], total: 1 })
await newerRequest
})
await act(async () => {
resolveOlder({ items: [persistedRun], total: 1 })
await olderRequest
})
expect(result.current.segmentationRuns).toEqual([projectTwoRun])
expect(result.current.selectedSegmentationRunId).toBe('run-2')
})
it('ignores late polygons from another project and clears an empty selection loader', async () => {
let resolveOlderList!: (value: { items: SegmentationRead[]; total: number }) => void
let resolveNewerList!: (value: { items: SegmentationRead[]; total: number }) => void
let resolveOlderGeo!: (value: GeoJSON.FeatureCollection) => void
let resolveNewerGeo!: (value: GeoJSON.FeatureCollection) => void
mocks.listSegmentations
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderList = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerList = resolve }))
mocks.getRunGeoJson
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderGeo = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerGeo = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useSegmentationWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
const oldItem: SegmentationRead = {
id: 'segment-1', project_id: 'project-1', analysis_run_id: 'run-1', model_name: 'model', class_name: 'building',
}
const newItem: SegmentationRead = {
id: 'segment-2', project_id: 'project-2', analysis_run_id: 'run-2', model_name: 'model', class_name: 'building',
}
let olderRequest!: Promise<void>
let newerRequest!: Promise<void>
act(() => { olderRequest = result.current.loadSegmentationResults('run-1') })
rerender({ selectedProjectId: 'project-2' })
act(() => { newerRequest = result.current.loadSegmentationResults('run-2') })
await act(async () => {
resolveNewerList({ items: [newItem], total: 1 })
resolveNewerGeo({ type: 'FeatureCollection', features: [] })
await newerRequest
})
await act(async () => {
resolveOlderList({ items: [oldItem], total: 1 })
resolveOlderGeo({ type: 'FeatureCollection', features: [] })
await olderRequest
})
expect(result.current.segmentationItems).toEqual([newItem])
await act(async () => { await result.current.loadSegmentationResults('') })
expect(result.current.loadingSegmentationResults).toBe(false)
expect(result.current.segmentationItems).toEqual([])
})
})
+246 -29
View File
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { segmentationApi } from '../services/api'
import type {
DatasetCreateResponse,
JobRead,
QualityCheckRead,
SegmentationModelCapability,
SegmentationQaResult,
@@ -10,6 +11,12 @@ import type {
SegmentationRunResponse,
} from '../types'
import { formatError } from '../lib/formatError'
import {
analysisRunIdFromSegmentationJob,
completedSegmentationResponse,
SegmentationJobError,
waitForSegmentationJob,
} from '../services/segmentationJob'
interface SegmentationWorkflowOptions {
selectedProjectId: string | null
@@ -19,6 +26,16 @@ interface SegmentationWorkflowOptions {
loadQualityChecks: (projectId?: string | null) => Promise<QualityCheckRead[] | void>
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
function abortedError(): Error {
const error = new Error('Het volgen van de segmentatietaak is gestopt')
error.name = 'AbortError'
return error
}
export function useSegmentationWorkflow({
selectedProjectId,
rasterDatasets,
@@ -34,11 +51,14 @@ export function useSegmentationWorkflow({
const [segmentationTileManifestPath, setSegmentationTileManifestPath] = useState('')
const [segmentationConfidenceThreshold, setSegmentationConfidenceThreshold] = useState(0.5)
const [runningSegmentation, setRunningSegmentation] = useState(false)
const [segmentationJob, setSegmentationJob] = useState<JobRead | null>(null)
const [segmentationRunResult, setSegmentationRunResult] = useState<SegmentationRunResponse | null>(null)
const [segmentationRunError, setSegmentationRunError] = useState<string | null>(null)
const [segmentationRuns, setSegmentationRuns] = useState<SegmentationRunRead[]>([])
const [selectedSegmentationRunId, setSelectedSegmentationRunId] = useState('')
const [segmentationItems, setSegmentationItems] = useState<SegmentationRead[]>([])
const [segmentationTotal, setSegmentationTotal] = useState(0)
const [segmentationTruncated, setSegmentationTruncated] = useState(false)
const [segmentationGeoJson, setSegmentationGeoJson] = useState<GeoJSON.FeatureCollection | null>(null)
const [segmentationClassFilter, setSegmentationClassFilter] = useState('')
const [segmentationMinConfidenceFilter, setSegmentationMinConfidenceFilter] = useState(0)
@@ -47,6 +67,41 @@ export function useSegmentationWorkflow({
const [segmentationQaResult, setSegmentationQaResult] = useState<SegmentationQaResult | null>(null)
const [segmentationQaError, setSegmentationQaError] = useState<string | null>(null)
const [runningSegmentationQa, setRunningSegmentationQa] = useState(false)
const activeSegmentationControllerRef = useRef<AbortController | null>(null)
const selectedProjectIdRef = useRef(selectedProjectId)
const segmentationExecutionSequence = useRef(0)
const segmentationRunsRequestSequence = useRef(0)
const segmentationResultsRequestSequence = useRef(0)
const segmentationQaRequestSequence = useRef(0)
selectedProjectIdRef.current = selectedProjectId
useEffect(() => {
activeSegmentationControllerRef.current?.abort()
activeSegmentationControllerRef.current = null
segmentationExecutionSequence.current += 1
segmentationRunsRequestSequence.current += 1
segmentationResultsRequestSequence.current += 1
segmentationQaRequestSequence.current += 1
setSelectedSegmentationDatasetId('')
setSegmentationRuns([])
setSelectedSegmentationRunId('')
setSegmentationItems([])
setSegmentationTotal(0)
setSegmentationTruncated(false)
setSegmentationGeoJson(null)
setSegmentationRunResult(null)
setSegmentationRunError(null)
setSegmentationJob(null)
setRunningSegmentation(false)
setLoadingSegmentationResults(false)
setSegmentationTileManifestPath('')
setSegmentationQaResult(null)
setSegmentationQaError(null)
setRunningSegmentationQa(false)
return () => {
activeSegmentationControllerRef.current?.abort()
}
}, [selectedProjectId])
const selectedSegmentationModel = useMemo(
() => segmentationModels.find((model) => model.model_id === selectedSegmentationModelId) ?? null,
@@ -79,32 +134,54 @@ export function useSegmentationWorkflow({
}
const loadSegmentationRuns = async (projectId = selectedProjectId) => {
const sequence = segmentationRunsRequestSequence.current + 1
segmentationRunsRequestSequence.current = sequence
if (!projectId) {
setSegmentationRuns([])
setSelectedSegmentationRunId('')
return
}
try {
const response = await segmentationApi.listRuns({ project_id: projectId })
if (
segmentationRunsRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return
setSegmentationRuns(response.items)
if (!selectedSegmentationRunId && response.items.length > 0) {
setSelectedSegmentationRunId(response.items[0].id)
}
setSelectedSegmentationRunId((current) => (
response.items.some((run) => run.id === current) ? current : response.items[0]?.id ?? ''
))
} catch (error) {
setSegmentationRunError(formatError(error, 'De segmentatieruns konden niet worden geladen'))
if (
segmentationRunsRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setSegmentationRunError(formatError(error, 'De segmentatieruns konden niet worden geladen'))
}
}
}
const loadSegmentationResults = async (analysisRunId = selectedSegmentationRunId) => {
if (!analysisRunId) {
const sequence = segmentationResultsRequestSequence.current + 1
segmentationResultsRequestSequence.current = sequence
const requestProjectId = selectedProjectIdRef.current
if (!analysisRunId || !requestProjectId) {
setSegmentationItems([])
setSegmentationTotal(0)
setSegmentationTruncated(false)
setSegmentationGeoJson(null)
setLoadingSegmentationResults(false)
return
}
setLoadingSegmentationResults(true)
setSegmentationRunError(null)
setSegmentationItems([])
setSegmentationTotal(0)
setSegmentationTruncated(false)
setSegmentationGeoJson(null)
try {
const params = {
project_id: selectedProjectId ?? '',
project_id: requestProjectId,
class_name: segmentationClassFilter || null,
min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null,
}
@@ -112,12 +189,33 @@ export function useSegmentationWorkflow({
segmentationApi.listSegmentations(analysisRunId, params),
segmentationApi.getRunGeoJson(analysisRunId, params),
])
if (
segmentationResultsRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== requestProjectId
) return
if (segmentationsResponse.items.some((item) => (
item.project_id !== requestProjectId || item.analysis_run_id !== analysisRunId
))) {
throw new Error('De server retourneerde segmentaties uit een andere werkruimte of analyserun')
}
setSegmentationItems(segmentationsResponse.items)
setSegmentationTotal(segmentationsResponse.total)
setSegmentationTruncated(Boolean(segmentationsResponse.truncated))
setSegmentationGeoJson(geoJsonResponse)
} catch (error) {
setSegmentationRunError(formatError(error, 'De segmentatieresultaten konden niet worden geladen'))
if (
segmentationResultsRequestSequence.current === sequence
&& selectedProjectIdRef.current === requestProjectId
) {
setSegmentationRunError(formatError(error, 'De segmentatieresultaten konden niet worden geladen'))
}
} finally {
setLoadingSegmentationResults(false)
if (
segmentationResultsRequestSequence.current === sequence
&& selectedProjectIdRef.current === requestProjectId
) {
setLoadingSegmentationResults(false)
}
}
}
@@ -135,31 +233,109 @@ export function useSegmentationWorkflow({
setSegmentationRunError('Het gekozen segmentatiemodel is niet geconfigureerd')
return
}
if (selectedSegmentationModelId === 'fixture-segmenter') {
setSegmentationRunError('Het fixturemodel is uitsluitend beschikbaar voor expliciete geautomatiseerde tests')
return
}
if (!segmentationTileManifestPath.trim()) {
setSegmentationRunError('Koppel eerst het beeldtegelmanifest van het gekozen rasterbestand')
return
}
if (
(activeSegmentationControllerRef.current && !activeSegmentationControllerRef.current.signal.aborted)
|| segmentationJob?.status === 'queued'
|| segmentationJob?.status === 'running'
) {
setSegmentationRunError('Er wordt al een GPU-segmentatietaak verwerkt. Wacht tot die taak klaar is.')
return
}
const projectId = selectedProjectId
const parameters: Record<string, unknown> = {}
const request = {
project_id: projectId,
dataset_id: datasetId,
model_id: selectedSegmentationModelId,
confidence_threshold: segmentationConfidenceThreshold,
tile_manifest_path: segmentationTileManifestPath.trim() || null,
parameters_json: parameters,
}
const controller = new AbortController()
const executionSequence = segmentationExecutionSequence.current + 1
segmentationExecutionSequence.current = executionSequence
activeSegmentationControllerRef.current = controller
const assertExecutionCurrent = () => {
if (
controller.signal.aborted
|| segmentationExecutionSequence.current !== executionSequence
|| selectedProjectIdRef.current !== projectId
) {
throw abortedError()
}
}
setSegmentationRunError(null)
setSegmentationRunResult(null)
setRunningSegmentation(true)
setSegmentationJob(null)
try {
const parameters =
selectedSegmentationModelId === 'fixture-segmenter'
? { fixture_mode: true, fixture_segmentations: [] }
: {}
const result = await segmentationApi.run({
project_id: selectedProjectId,
dataset_id: datasetId,
model_id: selectedSegmentationModelId,
confidence_threshold: segmentationConfidenceThreshold,
tile_manifest_path: segmentationTileManifestPath.trim() || null,
parameters_json: parameters,
const queuedJob = await segmentationApi.runAsync(request)
assertExecutionCurrent()
setSegmentationJob(queuedJob)
const completedJob = await waitForSegmentationJob({
projectId,
initialJob: queuedJob,
signal: controller.signal,
onStatus: (job) => {
if (
segmentationExecutionSequence.current === executionSequence
&& selectedProjectIdRef.current === projectId
) {
setSegmentationJob(job)
}
},
})
assertExecutionCurrent()
const explicitAnalysisRunId = analysisRunIdFromSegmentationJob(completedJob)
const run = explicitAnalysisRunId
? await segmentationApi.getRun(explicitAnalysisRunId, projectId)
: (await segmentationApi.listRuns({ project_id: projectId, dataset_id: datasetId })).items
.find((candidate) => candidate.job_id === completedJob.id)
assertExecutionCurrent()
if (!run) {
throw new SegmentationJobError(
'De GPU-taak is voltooid, maar de bijbehorende bewaarde segmentatierun ontbreekt.',
'SEGMENTATION_RUN_RESULT_NOT_FOUND',
completedJob.id,
)
}
const result = completedSegmentationResponse(request, completedJob, run)
setSegmentationRunError(null)
setSegmentationRunResult(result)
setSelectedSegmentationRunId(result.analysis_run_id)
await loadSegmentationRuns(selectedProjectId)
await loadSegmentationRuns(projectId)
assertExecutionCurrent()
await loadSegmentationResults(result.analysis_run_id)
await loadProjectData(selectedProjectId)
assertExecutionCurrent()
await loadProjectData(projectId)
} catch (error) {
setSegmentationRunError(formatError(error, 'Segmentation run failed'))
if (
!isAbortError(error)
&& segmentationExecutionSequence.current === executionSequence
&& selectedProjectIdRef.current === projectId
) {
setSegmentationRunError(formatError(error, 'De segmentatie is mislukt'))
}
} finally {
setRunningSegmentation(false)
if (activeSegmentationControllerRef.current === controller) {
activeSegmentationControllerRef.current = null
}
if (
segmentationExecutionSequence.current === executionSequence
&& selectedProjectIdRef.current === projectId
) {
setRunningSegmentation(false)
}
}
}
@@ -172,33 +348,71 @@ export function useSegmentationWorkflow({
setSegmentationQaError('Kies eerst een referentiebron')
return
}
const projectId = selectedProjectIdRef.current
if (!projectId) {
setSegmentationQaError('Kies eerst een werkruimte')
return
}
const analysisRunId = selectedSegmentationRunId
const referenceDatasetId = segmentationReferenceDatasetId
const sequence = segmentationQaRequestSequence.current + 1
segmentationQaRequestSequence.current = sequence
setSegmentationQaError(null)
setSegmentationQaResult(null)
setRunningSegmentationQa(true)
try {
const result = await segmentationApi.compareWithReference(selectedSegmentationRunId, selectedProjectId!, {
reference_dataset_id: segmentationReferenceDatasetId,
const result = await segmentationApi.compareWithReference(analysisRunId, projectId, {
reference_dataset_id: referenceDatasetId,
iou_threshold: qaIouThreshold,
class_name: segmentationClassFilter || null,
min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null,
})
if (
segmentationQaRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return
setSegmentationQaResult(result)
await loadQualityChecks(selectedProjectId)
await loadQualityChecks(projectId)
} catch (error) {
setSegmentationQaError(formatError(error, 'Segmentation QA failed'))
if (
segmentationQaRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setSegmentationQaError(formatError(error, 'De segmentatiecontrole is mislukt'))
}
} finally {
setRunningSegmentationQa(false)
if (
segmentationQaRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setRunningSegmentationQa(false)
}
}
}
const resetSegmentationForProject = () => {
activeSegmentationControllerRef.current?.abort()
activeSegmentationControllerRef.current = null
segmentationExecutionSequence.current += 1
segmentationRunsRequestSequence.current += 1
segmentationResultsRequestSequence.current += 1
segmentationQaRequestSequence.current += 1
setSelectedSegmentationDatasetId('')
setSegmentationRuns([])
setSelectedSegmentationRunId('')
setSegmentationItems([])
setSegmentationTotal(0)
setSegmentationTruncated(false)
setSegmentationGeoJson(null)
setSegmentationRunResult(null)
setSegmentationRunError(null)
setSegmentationJob(null)
setRunningSegmentation(false)
setLoadingSegmentationResults(false)
setSegmentationTileManifestPath('')
setSegmentationQaResult(null)
setSegmentationQaError(null)
setRunningSegmentationQa(false)
}
return {
@@ -211,11 +425,14 @@ export function useSegmentationWorkflow({
segmentationTileManifestPath,
segmentationConfidenceThreshold,
runningSegmentation,
segmentationJob,
segmentationRunResult,
segmentationRunError,
segmentationRuns,
selectedSegmentationRunId,
segmentationItems,
segmentationTotal,
segmentationTruncated,
segmentationGeoJson,
segmentationClassFilter,
segmentationMinConfidenceFilter,
@@ -69,4 +69,34 @@ describe('useTemporalComparison', () => {
preview_limit: 500,
})
})
it('keeps a newer comparison when an older request finishes last', async () => {
const resolvers: Array<(value: TemporalComparisonResponse) => void> = []
mocks.compare.mockImplementation(() => new Promise<TemporalComparisonResponse>((resolve) => {
resolvers.push(resolve)
}))
const older = { earlier_dataset_id: 'older' } as unknown as TemporalComparisonResponse
const newer = { earlier_dataset_id: 'newer' } as unknown as TemporalComparisonResponse
const { result } = renderHook(() => useTemporalComparison('project-1'))
let olderRequest: Promise<TemporalComparisonResponse | null>
let newerRequest: Promise<TemporalComparisonResponse | null>
await act(async () => {
olderRequest = result.current.compareTemporalSnapshots('older', 'later', bbox)
newerRequest = result.current.compareTemporalSnapshots('newer', 'later', bbox)
await Promise.resolve()
})
await act(async () => {
resolvers[1](newer)
await newerRequest!
})
expect(result.current.temporalComparison).toEqual(newer)
await act(async () => {
resolvers[0](older)
await olderRequest!
})
expect(result.current.temporalComparison).toEqual(newer)
expect(result.current.temporalComparisonLoading).toBe(false)
})
})
+18 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { formatError } from '../lib/formatError'
import { temporalApi } from '../services/api/temporal'
import type { TemporalComparisonResponse, VectorSelectionBBox } from '../types'
@@ -7,15 +7,20 @@ export function useTemporalComparison(selectedProjectId: string | null) {
const [temporalComparison, setTemporalComparison] = useState<TemporalComparisonResponse | null>(null)
const [temporalComparisonLoading, setTemporalComparisonLoading] = useState(false)
const [temporalComparisonError, setTemporalComparisonError] = useState<string | null>(null)
const requestSequence = useRef(0)
useEffect(() => {
requestSequence.current += 1
setTemporalComparison(null)
setTemporalComparisonError(null)
setTemporalComparisonLoading(false)
}, [selectedProjectId])
const clearTemporalComparison = () => {
requestSequence.current += 1
setTemporalComparison(null)
setTemporalComparisonError(null)
setTemporalComparisonLoading(false)
}
const compareTemporalSnapshots = async (
@@ -24,6 +29,8 @@ export function useTemporalComparison(selectedProjectId: string | null) {
bbox: VectorSelectionBBox,
areaId?: string,
): Promise<TemporalComparisonResponse | null> => {
const sequence = requestSequence.current + 1
requestSequence.current = sequence
if (!selectedProjectId) {
setTemporalComparisonError('Open eerst een project om evoluties te vergelijken.')
return null
@@ -43,14 +50,20 @@ export function useTemporalComparison(selectedProjectId: string | null) {
area_id: areaId || null,
preview_limit: 500,
})
setTemporalComparison(result)
if (requestSequence.current === sequence) {
setTemporalComparison(result)
}
return result
} catch (error) {
setTemporalComparison(null)
setTemporalComparisonError(formatError(error, 'De evolutieanalyse is mislukt.'))
if (requestSequence.current === sequence) {
setTemporalComparison(null)
setTemporalComparisonError(formatError(error, 'De evolutieanalyse is mislukt.'))
}
return null
} finally {
setTemporalComparisonLoading(false)
if (requestSequence.current === sequence) {
setTemporalComparisonLoading(false)
}
}
}
@@ -97,19 +97,25 @@ describe('useWorkbenchBootstrap', () => {
await waitFor(() => expect(systeem.loadCapabilities).toHaveBeenCalledOnce())
})
it('blijft geladen wanneer de gebruiker terugkeert naar de kaart', async () => {
it('herlaadt bezochte werkbladen niet wanneer een ander werkblad opent', async () => {
const state = options('project-1', 'ai')
const { rerender } = renderHook((props: { werkblad: string }) =>
useWorkbenchBootstrap({ ...state, activeWorkspace: props.werkblad }), {
initialProps: { werkblad: 'ai' },
})
await waitFor(() => expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1'))
const naEerste = state.loadDetectionRuns.mock.calls.length
const detectionRunCalls = state.loadDetectionRuns.mock.calls.length
const detectionResultCalls = state.loadDetectionResults.mock.calls.length
rerender({ werkblad: 'map' })
// Een bezocht werkblad blijft bijgewerkt worden; het wordt niet opnieuw
// dichtgezet zodra de gebruiker wegklikt.
expect(state.loadDetectionRuns.mock.calls.length).toBeGreaterThanOrEqual(naEerste)
rerender({ werkblad: 'exports' })
await waitFor(() => expect(state.loadExports).toHaveBeenCalledOnce())
rerender({ werkblad: 'analysis' })
await waitFor(() => expect(state.loadQualityChecks).toHaveBeenCalledOnce())
expect(state.loadDetectionRuns).toHaveBeenCalledTimes(detectionRunCalls)
expect(state.loadDetectionResults).toHaveBeenCalledTimes(detectionResultCalls)
expect(state.loadExports).toHaveBeenCalledOnce()
})
it('meldt een mislukte laadactie in plaats van haar weg te slikken', async () => {
+7 -14
View File
@@ -74,24 +74,17 @@ export function useWorkbenchBootstrap({
return null
}
// Welke werkbladen welke gegevens nodig hebben. Alles werd voorheen bij het
// opstarten opgehaald, ook voor werkbladen die de gebruiker nooit opent; dat
// waren 27 verzoeken in drie golven voordat de kaart bruikbaar was.
const bezocht = useRef(new Set<string>())
bezocht.current.add(activeWorkspace)
const geopend = (werkblad: string): boolean => bezocht.current.has(werkblad)
useEffect(() => {
loadProjects().catch(meld('werkruimtes'))
}, [restrictedMode])
useEffect(() => {
if (!geopend('system')) return
if (activeWorkspace !== 'system') return
loadCapabilities().catch(meld('bronkoppelingen'))
}, [restrictedMode, activeWorkspace])
useEffect(() => {
if (!geopend('ai')) return
if (activeWorkspace !== 'ai') return
loadDetectionModels().catch(meld('detectiemodellen'))
loadSegmentationModels().catch(meld('segmentatiemodellen'))
}, [restrictedMode, activeWorkspace])
@@ -114,28 +107,28 @@ export function useWorkbenchBootstrap({
}, [restrictedMode, selectedProjectId])
useEffect(() => {
if (!selectedProjectId || !geopend('analysis')) return
if (!selectedProjectId || activeWorkspace !== 'analysis') return
loadQualityChecks(selectedProjectId).catch(meld('kwaliteitscontroles'))
}, [restrictedMode, selectedProjectId, activeWorkspace])
useEffect(() => {
if (!selectedProjectId || !geopend('ai')) return
if (!selectedProjectId || activeWorkspace !== 'ai') return
loadDetectionRuns(selectedProjectId).catch(meld('detectieruns'))
loadSegmentationRuns(selectedProjectId).catch(meld('segmentatieruns'))
}, [restrictedMode, selectedProjectId, activeWorkspace])
useEffect(() => {
if (!selectedProjectId || !geopend('exports')) return
if (!selectedProjectId || activeWorkspace !== 'exports') return
loadExports(selectedProjectId).catch(meld('downloads'))
}, [restrictedMode, selectedProjectId, activeWorkspace])
useEffect(() => {
if (!geopend('ai')) return
if (activeWorkspace !== 'ai') return
loadDetectionResults().catch(meld('detectieresultaten'))
}, [restrictedMode, activeWorkspace, selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter])
useEffect(() => {
if (!geopend('ai')) return
if (activeWorkspace !== 'ai') return
loadSegmentationResults().catch(meld('segmentatieresultaten'))
}, [restrictedMode, activeWorkspace, selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
}
+1 -1
View File
@@ -10,7 +10,7 @@ import '@fontsource/public-sans/latin-400.css'
import '@fontsource/public-sans/latin-500.css'
import '@fontsource/public-sans/latin-600.css'
import '@fontsource/public-sans/latin-700.css'
import './styles/app.css'
import './styles/base.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { DetectionRunRequest } from '../../types'
import { detectionApi } from './detection'
describe('detectionApi.runAsync', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('starts production inference only through the queued endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
data: {
id: 'job-1',
job_type: 'detection.run',
status: 'queued',
project_id: 'project 1',
parameters_json: {},
},
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
vi.stubGlobal('fetch', fetchMock)
const payload: DetectionRunRequest = {
project_id: 'project 1',
dataset_id: 'dataset-1',
model_id: 'yolo-configured',
confidence_threshold: 0.15,
tile_manifest_path: '/tiles/manifest.json',
}
const response = await detectionApi.runAsync(payload)
expect(response.status).toBe('queued')
expect(fetchMock).toHaveBeenCalledOnce()
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('/api/v1/detection/run-async?project_id=project%201')
expect(init).toMatchObject({ method: 'POST', credentials: 'same-origin' })
expect(JSON.parse(String(init.body))).toEqual(payload)
})
})
+5 -5
View File
@@ -7,7 +7,7 @@ import type {
DetectionRunListResponse,
DetectionRunRead,
DetectionRunRequest,
DetectionRunResponse,
JobRead,
ModelAssetListResponse,
YoloPreflightResponse,
} from '../../types'
@@ -28,12 +28,12 @@ export const detectionApi = {
listModelAssets: (): Promise<ModelAssetListResponse> => apiGet<ModelAssetListResponse>('/api/v1/detection/model-assets'),
getYoloPreflight: (params: { tile_manifest_path?: string | null; check_model_load?: boolean | null; model_asset_id?: string | null } = {}): Promise<YoloPreflightResponse> =>
apiGet<YoloPreflightResponse>(`/api/v1/detection/yolo/preflight${queryString(params)}`),
run: (payload: DetectionRunRequest): Promise<DetectionRunResponse> =>
apiPost<DetectionRunResponse>(`/api/v1/detection/run?project_id=${encodeURIComponent(payload.project_id)}`, payload),
runAsync: (payload: DetectionRunRequest): Promise<JobRead> =>
apiPost<JobRead>(`/api/v1/detection/run-async?project_id=${encodeURIComponent(payload.project_id)}`, payload),
listRuns: (params: { project_id?: string | null; dataset_id?: string | null } = {}): Promise<DetectionRunListResponse> =>
apiGet<DetectionRunListResponse>(`/api/v1/detection/runs${queryString(params)}`),
getRun: (analysisRunId: string): Promise<DetectionRunRead> =>
apiGet<DetectionRunRead>(`/api/v1/detection/runs/${analysisRunId}`),
getRun: (analysisRunId: string, projectId?: string | null): Promise<DetectionRunRead> =>
apiGet<DetectionRunRead>(`/api/v1/detection/runs/${analysisRunId}${queryString({ project_id: projectId })}`),
listDetections: (
analysisRunId: string,
params: {
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SegmentationRunRequest } from '../../types'
import { segmentationApi } from './segmentation'
describe('segmentationApi.runAsync', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('starts production segmentation only through the queued endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
data: {
id: 'job-1',
job_type: 'segmentation.run',
status: 'queued',
project_id: 'project 1',
parameters_json: {},
},
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
vi.stubGlobal('fetch', fetchMock)
const payload: SegmentationRunRequest = {
project_id: 'project 1',
dataset_id: 'dataset-1',
model_id: 'yolo-seg-configured',
confidence_threshold: 0.5,
tile_manifest_path: '/tiles/manifest.json',
}
const response = await segmentationApi.runAsync(payload)
expect(response.status).toBe('queued')
expect(fetchMock).toHaveBeenCalledOnce()
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('/api/v1/segmentation/run-async?project_id=project%201')
expect(init).toMatchObject({ method: 'POST', credentials: 'same-origin' })
expect(JSON.parse(String(init.body))).toEqual(payload)
})
it('scopes a persisted run read to the active guest project', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
data: {
id: 'run-1',
analysis_type: 'segmentation',
status: 'success',
project_id: 'project 1',
parameters_json: {},
},
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
vi.stubGlobal('fetch', fetchMock)
await segmentationApi.getRun('run-1', 'project 1')
expect(fetchMock).toHaveBeenCalledOnce()
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/segmentation/runs/run-1?project_id=project+1')
})
})
+5 -5
View File
@@ -7,7 +7,7 @@ import type {
SegmentationRunListResponse,
SegmentationRunRead,
SegmentationRunRequest,
SegmentationRunResponse,
JobRead,
} from '../../types'
function queryString(params: Record<string, string | number | null | undefined>): string {
@@ -23,12 +23,12 @@ function queryString(params: Record<string, string | number | null | undefined>)
export const segmentationApi = {
listModels: (): Promise<SegmentationModelsResponse> => apiGet<SegmentationModelsResponse>('/api/v1/segmentation/models'),
run: (payload: SegmentationRunRequest): Promise<SegmentationRunResponse> =>
apiPost<SegmentationRunResponse>(`/api/v1/segmentation/run?project_id=${encodeURIComponent(payload.project_id)}`, payload),
runAsync: (payload: SegmentationRunRequest): Promise<JobRead> =>
apiPost<JobRead>(`/api/v1/segmentation/run-async?project_id=${encodeURIComponent(payload.project_id)}`, payload),
listRuns: (params: { project_id?: string | null; dataset_id?: string | null } = {}): Promise<SegmentationRunListResponse> =>
apiGet<SegmentationRunListResponse>(`/api/v1/segmentation/runs${queryString(params)}`),
getRun: (analysisRunId: string): Promise<SegmentationRunRead> =>
apiGet<SegmentationRunRead>(`/api/v1/segmentation/runs/${analysisRunId}`),
getRun: (analysisRunId: string, projectId?: string | null): Promise<SegmentationRunRead> =>
apiGet<SegmentationRunRead>(`/api/v1/segmentation/runs/${analysisRunId}${queryString({ project_id: projectId })}`),
listSegmentations: (
analysisRunId: string,
params: { project_id: string; dataset_id?: string | null; class_name?: string | null; min_confidence?: number | null },
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from 'vitest'
import type { DetectionRunRead, DetectionRunRequest, JobRead } from '../types'
import {
completedDetectionResponse,
DetectionJobError,
waitForDetectionJob,
} from './detectionJob'
const projectId = 'project-1'
const datasetId = 'dataset-1'
const jobId = 'job-1'
function job(status: string, overrides: Partial<JobRead> = {}): JobRead {
return {
id: jobId,
job_type: 'detection.run',
status,
project_id: projectId,
dataset_id: datasetId,
parameters_json: {},
...overrides,
}
}
function run(overrides: Partial<DetectionRunRead> = {}): DetectionRunRead {
return {
id: 'run-1',
project_id: projectId,
dataset_id: datasetId,
job_id: jobId,
analysis_type: 'detection',
status: 'success',
model_name: 'yolo-configured',
parameters_json: {},
result_json: { detection_count: 4 },
...overrides,
}
}
const request: DetectionRunRequest = {
project_id: projectId,
dataset_id: datasetId,
model_id: 'yolo-configured',
confidence_threshold: 0.15,
tile_manifest_path: '/tiles/manifest.json',
}
describe('waitForDetectionJob', () => {
it('follows queued and running states until the persisted GPU job succeeds', async () => {
const readJob = vi.fn()
.mockResolvedValueOnce(job('running'))
.mockResolvedValueOnce(job('success', { result_json: { detection_count: 4 } }))
const statuses: string[] = []
const completed = await waitForDetectionJob({
projectId,
initialJob: job('queued'),
intervalMs: 0,
readJob,
onStatus: (value) => statuses.push(value.status),
})
expect(completed.status).toBe('success')
expect(statuses).toEqual(['queued', 'running', 'success'])
expect(readJob).toHaveBeenCalledTimes(2)
})
it('does not reinterpret a failed model/runtime job as an empty success', async () => {
await expect(waitForDetectionJob({
projectId,
initialJob: job('failed', {
error_message: 'NVIDIA CUDA is niet beschikbaar',
result_json: { error_code: 'DETECTION_ACCELERATOR_UNAVAILABLE' },
}),
intervalMs: 0,
})).rejects.toMatchObject({
name: 'DetectionJobError',
code: 'DETECTION_ACCELERATOR_UNAVAILABLE',
message: 'NVIDIA CUDA is niet beschikbaar',
})
})
it('rejects partial and cross-project jobs instead of treating them as complete', async () => {
await expect(waitForDetectionJob({
projectId,
initialJob: job('partial'),
intervalMs: 0,
})).rejects.toBeInstanceOf(DetectionJobError)
await expect(waitForDetectionJob({
projectId,
initialJob: job('success', { project_id: 'other-project' }),
intervalMs: 0,
})).rejects.toMatchObject({ code: 'DETECTION_JOB_IDENTITY_MISMATCH' })
})
})
describe('completedDetectionResponse', () => {
it('uses the persisted count and explicitly avoids claiming that a zero result means absence', () => {
const response = completedDetectionResponse(
request,
job('success', { result_json: { detection_count: 0 } }),
run({ result_json: { detection_count: 0 } }),
)
expect(response.detection_count).toBe(0)
expect(response.status).toBe('success')
expect(response.message).toContain('bewijst niet')
})
it('fails closed when the server omits the persisted count or links another run', () => {
expect(() => completedDetectionResponse(
request,
job('success'),
run({ result_json: null }),
)).toThrowError(DetectionJobError)
expect(() => completedDetectionResponse(
request,
job('success', { result_json: { detection_count: 2 } }),
run({ job_id: 'another-job' }),
)).toThrowError(DetectionJobError)
})
})
+197
View File
@@ -0,0 +1,197 @@
import type { DetectionRunRead, DetectionRunRequest, DetectionRunResponse, JobRead } from '../types'
import { jobsApi } from './api/jobs'
const ACTIVE_JOB_STATUSES = new Set(['queued', 'running'])
const TERMINAL_FAILURE_STATUSES = new Set(['failed', 'cancelled', 'partial'])
export const DETECTION_JOB_POLL_INTERVAL_MS = 1_500
export const DETECTION_JOB_TIMEOUT_MS = 30 * 60 * 1_000
export class DetectionJobError extends Error {
readonly code: string
readonly jobId: string
constructor(message: string, code: string, jobId: string) {
super(message)
this.name = 'DetectionJobError'
this.code = code
this.jobId = jobId
}
}
interface WaitForDetectionJobOptions {
projectId: string
initialJob: JobRead
signal?: AbortSignal
intervalMs?: number
timeoutMs?: number
maxConsecutiveReadErrors?: number
readJob?: (projectId: string, jobId: string) => Promise<JobRead>
onStatus?: (job: JobRead) => void
}
function abortedError(): Error {
const error = new Error('Het volgen van de detectietaak is gestopt')
error.name = 'AbortError'
return error
}
function wait(milliseconds: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return Promise.reject(abortedError())
}
if (milliseconds <= 0) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, milliseconds)
const onAbort = () => {
window.clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
reject(abortedError())
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
function stringValue(record: Record<string, unknown> | null | undefined, key: string): string | null {
const value = record?.[key]
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function numberValue(record: Record<string, unknown> | null | undefined, key: string): number | null {
const value = record?.[key]
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function assertDetectionJobIdentity(projectId: string, job: JobRead): void {
if (job.project_id !== projectId || job.job_type !== 'detection.run') {
throw new DetectionJobError(
'De server koppelde een onverwachte taak aan deze beeldanalyse',
'DETECTION_JOB_IDENTITY_MISMATCH',
job.id,
)
}
}
/**
* Follow one queued GPU run until the backend marks it terminal.
*
* A transient polling failure is retried, but an unknown or partial terminal
* state is never interpreted as a completed inference. The backend remains
* the only authority for the outcome and persisted detection count.
*/
export async function waitForDetectionJob({
projectId,
initialJob,
signal,
intervalMs = DETECTION_JOB_POLL_INTERVAL_MS,
timeoutMs = DETECTION_JOB_TIMEOUT_MS,
maxConsecutiveReadErrors = 3,
readJob = jobsApi.get,
onStatus,
}: WaitForDetectionJobOptions): Promise<JobRead> {
const startedAt = Date.now()
let job = initialJob
let consecutiveReadErrors = 0
while (true) {
if (signal?.aborted) {
throw abortedError()
}
assertDetectionJobIdentity(projectId, job)
onStatus?.(job)
if (job.status === 'success') {
return job
}
if (TERMINAL_FAILURE_STATUSES.has(job.status)) {
const code = stringValue(job.result_json, 'error_code') ?? `DETECTION_JOB_${job.status.toUpperCase()}`
const message = job.error_message
?? stringValue(job.result_json, 'message')
?? 'De GPU-taak is niet volledig uitgevoerd'
throw new DetectionJobError(message, code, job.id)
}
if (!ACTIVE_JOB_STATUSES.has(job.status)) {
throw new DetectionJobError(
`De detectietaak heeft een onbekende status: ${job.status}`,
'DETECTION_JOB_STATUS_INVALID',
job.id,
)
}
if (Date.now() - startedAt >= timeoutMs) {
throw new DetectionJobError(
'De detectietaak loopt nog op de server, maar de wachttijd in dit scherm is verstreken. Herlaad de bewaarde detectieruns om het resultaat later te bekijken.',
'DETECTION_JOB_POLL_TIMEOUT',
job.id,
)
}
await wait(intervalMs, signal)
try {
job = await readJob(projectId, job.id)
consecutiveReadErrors = 0
} catch (error) {
if (signal?.aborted) {
throw abortedError()
}
consecutiveReadErrors += 1
if (consecutiveReadErrors >= maxConsecutiveReadErrors) {
throw error
}
}
}
}
/** Convert persisted server evidence into the existing UI summary contract. */
export function completedDetectionResponse(
request: DetectionRunRequest,
job: JobRead,
run: DetectionRunRead,
): DetectionRunResponse {
assertDetectionJobIdentity(request.project_id, job)
if (
job.status !== 'success'
|| run.status !== 'success'
|| run.project_id !== request.project_id
|| run.dataset_id !== request.dataset_id
|| run.job_id !== job.id
) {
throw new DetectionJobError(
'De bewaarde detectierun komt niet overeen met de voltooide GPU-taak',
'DETECTION_RUN_RESULT_MISMATCH',
job.id,
)
}
const detectionCount = numberValue(job.result_json, 'detection_count')
?? numberValue(run.result_json, 'detection_count')
if (detectionCount === null || !Number.isInteger(detectionCount) || detectionCount < 0) {
throw new DetectionJobError(
'De voltooide detectietaak bevat geen geldige, herleidbare objecttelling',
'DETECTION_RUN_RESULT_INCOMPLETE',
job.id,
)
}
return {
analysis_run_id: run.id,
job_id: job.id,
project_id: request.project_id,
dataset_id: request.dataset_id,
model_id: run.model_name ?? request.model_id,
status: 'success',
detection_count: detectionCount,
error_code: null,
message: detectionCount === 0
? 'Analyse voltooid zonder objecten boven de gekozen zekerheidsdrempel. Dit bewijst niet dat het gebied objectvrij is.'
: 'GPU-analyse voltooid; de bewaarde objecten zijn geladen.',
}
}
export function analysisRunIdFromJob(job: JobRead): string | null {
return stringValue(job.result_json, 'analysis_run_id')
}
@@ -0,0 +1,144 @@
import { describe, expect, it, vi } from 'vitest'
import type { JobRead, SegmentationRunRead, SegmentationRunRequest } from '../types'
import {
completedSegmentationResponse,
SegmentationJobError,
waitForSegmentationJob,
} from './segmentationJob'
const projectId = 'project-1'
const datasetId = 'dataset-1'
const jobId = 'job-1'
function job(status: string, overrides: Partial<JobRead> = {}): JobRead {
return {
id: jobId,
job_type: 'segmentation.run',
status,
project_id: projectId,
dataset_id: datasetId,
parameters_json: {},
...overrides,
}
}
function run(overrides: Partial<SegmentationRunRead> = {}): SegmentationRunRead {
return {
id: 'run-1',
project_id: projectId,
dataset_id: datasetId,
job_id: jobId,
analysis_type: 'segmentation',
status: 'success',
model_name: 'yolo-seg-configured',
parameters_json: {},
result_json: { segmentation_count: 4 },
...overrides,
}
}
const request: SegmentationRunRequest = {
project_id: projectId,
dataset_id: datasetId,
model_id: 'yolo-seg-configured',
confidence_threshold: 0.5,
tile_manifest_path: '/tiles/manifest.json',
}
describe('waitForSegmentationJob', () => {
it('polls the project-bound job until the GPU task succeeds', async () => {
const readJob = vi.fn()
.mockResolvedValueOnce(job('running'))
.mockResolvedValueOnce(job('success', { result_json: { segmentation_count: 4 } }))
const statuses: string[] = []
const completed = await waitForSegmentationJob({
projectId,
initialJob: job('queued'),
intervalMs: 0,
readJob,
onStatus: (value) => statuses.push(value.status),
})
expect(completed.status).toBe('success')
expect(statuses).toEqual(['queued', 'running', 'success'])
expect(readJob).toHaveBeenNthCalledWith(1, projectId, jobId)
expect(readJob).toHaveBeenCalledTimes(2)
})
it('keeps server failure and timeout distinct from a valid empty result', async () => {
await expect(waitForSegmentationJob({
projectId,
initialJob: job('failed', {
error_message: 'NVIDIA CUDA is niet beschikbaar',
result_json: { error_code: 'SEGMENTATION_ACCELERATOR_UNAVAILABLE' },
}),
intervalMs: 0,
})).rejects.toMatchObject({
name: 'SegmentationJobError',
code: 'SEGMENTATION_ACCELERATOR_UNAVAILABLE',
message: 'NVIDIA CUDA is niet beschikbaar',
})
await expect(waitForSegmentationJob({
projectId,
initialJob: job('running'),
intervalMs: 0,
timeoutMs: 0,
})).rejects.toMatchObject({ code: 'SEGMENTATION_JOB_POLL_TIMEOUT' })
})
it('rejects partial, cross-project and wrong-task jobs', async () => {
await expect(waitForSegmentationJob({
projectId,
initialJob: job('partial'),
intervalMs: 0,
})).rejects.toBeInstanceOf(SegmentationJobError)
await expect(waitForSegmentationJob({
projectId,
initialJob: job('success', { project_id: 'other-project' }),
intervalMs: 0,
})).rejects.toMatchObject({ code: 'SEGMENTATION_JOB_IDENTITY_MISMATCH' })
await expect(waitForSegmentationJob({
projectId,
initialJob: job('success', { job_type: 'detection.run' }),
intervalMs: 0,
})).rejects.toMatchObject({ code: 'SEGMENTATION_JOB_IDENTITY_MISMATCH' })
})
})
describe('completedSegmentationResponse', () => {
it('accepts a persisted zero-result run without claiming that the area is empty', () => {
const response = completedSegmentationResponse(
request,
job('success', { result_json: { segmentation_count: 0 } }),
run({ result_json: { segmentation_count: 0 } }),
)
expect(response.segmentation_count).toBe(0)
expect(response.status).toBe('success')
expect(response.message).toContain('bewijst niet')
})
it('fails closed for missing counts or a mismatched persisted run', () => {
expect(() => completedSegmentationResponse(
request,
job('success'),
run({ result_json: null }),
)).toThrowError(SegmentationJobError)
expect(() => completedSegmentationResponse(
request,
job('success', { result_json: { segmentation_count: 2 } }),
run({ project_id: 'other-project' }),
)).toThrowError(SegmentationJobError)
expect(() => completedSegmentationResponse(
request,
job('success', { result_json: { segmentation_count: 2 } }),
run({ model_name: 'sam-configured' }),
)).toThrowError(SegmentationJobError)
})
})
+193
View File
@@ -0,0 +1,193 @@
import type { JobRead, SegmentationRunRead, SegmentationRunRequest, SegmentationRunResponse } from '../types'
import { jobsApi } from './api/jobs'
const ACTIVE_JOB_STATUSES = new Set(['queued', 'running'])
const TERMINAL_FAILURE_STATUSES = new Set(['failed', 'cancelled', 'partial'])
export const SEGMENTATION_JOB_POLL_INTERVAL_MS = 1_500
export const SEGMENTATION_JOB_TIMEOUT_MS = 30 * 60 * 1_000
export class SegmentationJobError extends Error {
readonly code: string
readonly jobId: string
constructor(message: string, code: string, jobId: string) {
super(message)
this.name = 'SegmentationJobError'
this.code = code
this.jobId = jobId
}
}
interface WaitForSegmentationJobOptions {
projectId: string
initialJob: JobRead
signal?: AbortSignal
intervalMs?: number
timeoutMs?: number
maxConsecutiveReadErrors?: number
readJob?: (projectId: string, jobId: string) => Promise<JobRead>
onStatus?: (job: JobRead) => void
}
function abortedError(): Error {
const error = new Error('Het volgen van de segmentatietaak is gestopt')
error.name = 'AbortError'
return error
}
function wait(milliseconds: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return Promise.reject(abortedError())
}
if (milliseconds <= 0) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, milliseconds)
const onAbort = () => {
window.clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
reject(abortedError())
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
function stringValue(record: Record<string, unknown> | null | undefined, key: string): string | null {
const value = record?.[key]
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function numberValue(record: Record<string, unknown> | null | undefined, key: string): number | null {
const value = record?.[key]
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function assertSegmentationJobIdentity(projectId: string, job: JobRead): void {
if (job.project_id !== projectId || job.job_type !== 'segmentation.run') {
throw new SegmentationJobError(
'De server koppelde een onverwachte taak aan deze segmentatie',
'SEGMENTATION_JOB_IDENTITY_MISMATCH',
job.id,
)
}
}
/** Follow one queued GPU segmentation until the backend marks it terminal. */
export async function waitForSegmentationJob({
projectId,
initialJob,
signal,
intervalMs = SEGMENTATION_JOB_POLL_INTERVAL_MS,
timeoutMs = SEGMENTATION_JOB_TIMEOUT_MS,
maxConsecutiveReadErrors = 3,
readJob = jobsApi.get,
onStatus,
}: WaitForSegmentationJobOptions): Promise<JobRead> {
const startedAt = Date.now()
let job = initialJob
let consecutiveReadErrors = 0
while (true) {
if (signal?.aborted) {
throw abortedError()
}
assertSegmentationJobIdentity(projectId, job)
onStatus?.(job)
if (job.status === 'success') {
return job
}
if (TERMINAL_FAILURE_STATUSES.has(job.status)) {
const code = stringValue(job.result_json, 'error_code') ?? `SEGMENTATION_JOB_${job.status.toUpperCase()}`
const message = job.error_message
?? stringValue(job.result_json, 'message')
?? 'De GPU-taak is niet volledig uitgevoerd'
throw new SegmentationJobError(message, code, job.id)
}
if (!ACTIVE_JOB_STATUSES.has(job.status)) {
throw new SegmentationJobError(
`De segmentatietaak heeft een onbekende status: ${job.status}`,
'SEGMENTATION_JOB_STATUS_INVALID',
job.id,
)
}
if (Date.now() - startedAt >= timeoutMs) {
throw new SegmentationJobError(
'De segmentatietaak loopt nog op de server, maar de wachttijd in dit scherm is verstreken. Herlaad de bewaarde segmentatieruns om het resultaat later te bekijken.',
'SEGMENTATION_JOB_POLL_TIMEOUT',
job.id,
)
}
await wait(intervalMs, signal)
try {
job = await readJob(projectId, job.id)
consecutiveReadErrors = 0
} catch (error) {
if (signal?.aborted) {
throw abortedError()
}
consecutiveReadErrors += 1
if (consecutiveReadErrors >= maxConsecutiveReadErrors) {
throw error
}
}
}
}
/** Convert persisted server evidence into the UI summary contract. */
export function completedSegmentationResponse(
request: SegmentationRunRequest,
job: JobRead,
run: SegmentationRunRead,
): SegmentationRunResponse {
assertSegmentationJobIdentity(request.project_id, job)
if (
job.status !== 'success'
|| run.status !== 'success'
|| run.analysis_type !== 'segmentation'
|| run.project_id !== request.project_id
|| run.dataset_id !== request.dataset_id
|| run.job_id !== job.id
|| (run.model_name != null && run.model_name !== request.model_id)
) {
throw new SegmentationJobError(
'De bewaarde segmentatierun komt niet overeen met de voltooide GPU-taak',
'SEGMENTATION_RUN_RESULT_MISMATCH',
job.id,
)
}
const segmentationCount = numberValue(job.result_json, 'segmentation_count')
?? numberValue(run.result_json, 'segmentation_count')
if (segmentationCount === null || !Number.isInteger(segmentationCount) || segmentationCount < 0) {
throw new SegmentationJobError(
'De voltooide segmentatietaak bevat geen geldige, herleidbare vlakkentelling',
'SEGMENTATION_RUN_RESULT_INCOMPLETE',
job.id,
)
}
return {
analysis_run_id: run.id,
job_id: job.id,
project_id: request.project_id,
dataset_id: request.dataset_id,
model_id: run.model_name ?? request.model_id,
status: 'success',
segmentation_count: segmentationCount,
error_code: null,
message: segmentationCount === 0
? 'Segmentatie voltooid zonder vlakken boven de gekozen zekerheidsdrempel. Dit bewijst niet dat het gebied geen relevante objecten bevat.'
: 'GPU-segmentatie voltooid; de bewaarde vlakken zijn geladen.',
}
}
export function analysisRunIdFromSegmentationJob(job: JobRead): string | null {
return stringValue(job.result_json, 'analysis_run_id')
}
+139
View File
@@ -0,0 +1,139 @@
/*
* Kleine, route-onafhankelijke basis.
*
* De kaartwerkbank importeert zijn omvangrijke app.css zelf via de lazy
* WorkbenchApp-chunk. Houd hier alleen de globale regels die ook het
* aanmeldscherm en de korte laadstatus nodig hebben; MapLibre hoort niet in de
* publieke landing-bundel.
*/
:root {
--bg: #f4f7f5;
--panel: #ffffff;
--panel-soft: #fafcfb;
--surface-raised: #ffffff;
--surface-sunken: #f7faf8;
--text: #132018;
--muted: #5f6f67;
--line: #dbe4de;
--line-strong: #b8c8bf;
--accent: #0f766e;
--accent-strong: #115e59;
--accent-soft: #e3f4ef;
--focus-ring: #0f766e;
--focus-ring-soft: rgba(15, 118, 110, 0.2);
--warning: #b45309;
--danger: #991b1b;
--shadow: 0 10px 26px rgba(33, 48, 41, 0.06);
--shadow-soft: 0 6px 18px rgba(33, 48, 41, 0.045);
/* De landing gebruikt dezelfde vormtaal, maar laadt het volledige
werkbank-designsysteem bewust pas na authenticatie. */
--gi-radius-sm: 6px;
--gi-radius-md: 10px;
--gi-radius-lg: 14px;
--gi-radius-xl: 20px;
--gi-radius-pill: 999px;
--gi-shadow-md: 0 12px 28px rgba(6, 37, 31, 0.1);
--gi-shadow-lg: 0 24px 60px rgba(6, 37, 31, 0.16);
color-scheme: light;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
overflow-x: hidden;
background: var(--bg);
}
body {
overflow-x: hidden;
margin: 0;
font-family: 'Public Sans', 'Segoe UI', Arial, sans-serif;
color: var(--text);
background:
linear-gradient(180deg, rgba(15, 118, 110, 0.08), rgba(238, 244, 241, 0) 18rem),
var(--bg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
min-height: 2.35rem;
border: 1px solid var(--line-strong);
border-radius: var(--gi-radius-sm);
padding: 0.52rem 0.78rem;
background: linear-gradient(180deg, #ffffff, #eef8f6);
color: var(--text);
cursor: pointer;
font-weight: 600;
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
}
button:hover:not(:disabled) {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
}
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { cursor: not-allowed; opacity: 0.52; }
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
a:focus-visible {
border-color: var(--focus-ring);
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
box-shadow: 0 0 0 5px var(--focus-ring-soft);
}
input,
select,
textarea {
width: 100%;
min-height: 2.35rem;
border: 1px solid var(--line-strong);
border-radius: var(--gi-radius-sm);
padding: 0.52rem 0.62rem;
background: #ffffff;
color: var(--text);
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.16);
}
h1,
h2,
h3,
p { overflow-wrap: anywhere; }
h1 {
max-width: 42rem;
margin: 0;
font-size: clamp(1.85rem, 2.6vw, 2.7rem);
letter-spacing: 0;
line-height: 1.02;
}
h2 { margin: 0 0 0.9rem; font-size: 1.28rem; letter-spacing: 0; line-height: 1.15; }
h3 { margin: 1.1rem 0 0.55rem; font-size: 1rem; letter-spacing: 0; line-height: 1.2; }
p { line-height: 1.45; }
+49
View File
@@ -918,6 +918,24 @@
text-align: center;
}
/* Een mislukte analyse is geen lege toestand: houd de resultatenlade open en
maak de herstelactie bereikbaar zonder hover op de smalle ladegreep. */
.geo-results-error {
display: grid;
gap: var(--gi-space-3);
place-content: center;
justify-items: center;
min-height: 15rem;
padding: var(--gi-space-6) var(--gi-space-4);
border: 1px solid var(--gi-danger-soft);
border-radius: var(--gi-radius-sm);
background: color-mix(in srgb, var(--gi-danger-soft) 26%, var(--gi-surface));
text-align: center;
}
.geo-results-error strong { color: var(--gi-danger); }
.geo-results-error p { max-width: 22rem; margin: 0; color: var(--gi-ink-600); }
/* -- 3. Bedieningspaneel compacter ----------------------------------------- */
/* Het statuslabel stond in een derde kolom en duwde de titel kapot
@@ -2368,6 +2386,7 @@ button.overview-command-card { cursor: pointer; }
@media (max-width: 600px) {
.workbench-layout { display: block; min-height: 100dvh; }
.workbench-topbar { top: 0; }
.workbench-sidebar {
position: fixed; z-index: 120; top: auto; right: 0; bottom: 0; left: 0; width: 100%;
min-height: 4.15rem; max-height: 4.15rem; border-top: 1px solid rgba(153, 218, 202, 0.22);
@@ -2415,6 +2434,17 @@ button.overview-command-card { cursor: pointer; }
.geo-map-actions button { min-width: 0; justify-content: center; padding-inline: 0.45rem; }
.geo-map-actions button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.geo-map-actions button:last-child span { display: none; }
.live-analysis-journey {
top: 4.4rem;
right: 3.25rem;
bottom: auto;
left: 0.5rem;
width: auto;
min-width: 0;
padding: 0.45rem 0.55rem;
}
.live-analysis-status-card { display: none; }
.live-analysis-steps { width: 100%; }
.geo-results-panel {
position: fixed; z-index: 130; top: 0; right: 0; bottom: 4.15rem;
width: min(31rem, calc(100% - 2.75rem)); height: auto; max-height: none;
@@ -2760,6 +2790,25 @@ body:not([data-theme='light']) .workbench-shell :where(input, select, textarea)
}
}
@media (max-width: 600px) {
/* De primaire navigatie staat op smartphones onderaan. De kop toont daarom
alleen merk, werkstand en sessie; de werkcontext staat direct eronder in
het kaartscherm. Dit voorkomt dat logo en afgekorte contextlabels in
dezelfde smalle rastercel over elkaar heen worden getekend. */
.workbench-topbar {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.workbench-topbar .context-bar,
.workbench-topbar .context-health {
display: none;
}
.workbench-topbar .mobile-brand {
display: flex;
}
}
/* ============================================================================
AI-vragen: raster zonder botsingen
----------------------------------------------------------------------------
+1
View File
@@ -1321,6 +1321,7 @@ export interface ModelAssetListResponse {
export interface YoloPreflightChecks {
enabled: boolean
dependencies_available?: boolean | null
accelerator_ready?: boolean | null
model_path_set?: boolean | null
model_file_exists?: boolean | null
model_load_requested: boolean