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
@@ -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">