feat: add governed nationwide AOI orchestration and CUDA enforcement
This commit is contained in:
@@ -1238,6 +1238,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
||||
|
||||
{activeWorkspace === 'system' ? (
|
||||
<ProviderPanel
|
||||
selectedProjectId={selectedProjectId}
|
||||
providers={providers}
|
||||
loadingCapabilities={loadingCapabilities}
|
||||
capabilitiesError={capabilitiesError}
|
||||
|
||||
@@ -108,7 +108,7 @@ interface PlannedOnDemandMapProduct extends OnDemandMapProduct {
|
||||
|
||||
function productSupportsSelection(product: OnDemandMapProduct, bbox: VectorSelectionBBox): boolean {
|
||||
const scale = selectionAnalysisScale(bbox)
|
||||
if (scale === 'overview') return false
|
||||
if (scale === 'overview') return true
|
||||
const dimensions = selectionDimensions(bbox)
|
||||
if (product.kind === 'dhmv' || product.kind === 'spw_terrain' || product.kind === 'flood_hazard') {
|
||||
return dimensions.areaSquareMetres <= 280_000_000
|
||||
@@ -1563,10 +1563,10 @@ export function MapWorkspace({
|
||||
const heightKm = dimensions.heightMetres / 1000
|
||||
if (mapSelectionScale === 'regional') {
|
||||
const partitionCount = splitSelectionBbox(mapSelectionBbox).length
|
||||
return `Regionale selectie van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} x ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. Het gekozen thema kan over maximaal ${partitionCount} begrensde bronpartities worden verwerkt; teken een kleiner gebied wanneer een fijnmazige rasterbron buiten het veilige pixelbudget valt.`
|
||||
return `Regionale selectie van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} x ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. De server verwerkt dit thema hervatbaar over ${partitionCount} of meer bronafhankelijke partities en presenteert één gezamenlijke status.`
|
||||
}
|
||||
if (mapSelectionScale === 'overview') {
|
||||
return `Overzichtsselectie van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} x ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. Het gekozen detailthema wordt niet automatisch op deze schaal bevraagd. Teken maximaal 50 x 50 km voor regionale thema's en ongeveer 16 x 16 km voor 5 m-rasters.`
|
||||
return `Overzichtsselectie van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} x ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. De server kiest automatisch bronlimieten, checkpoints en partities; resolutie en beschikbare dekking blijven die van de officiële bron.`
|
||||
}
|
||||
return null
|
||||
}, [mapSelectionBbox, mapSelectionScale])
|
||||
@@ -1946,11 +1946,11 @@ export function MapWorkspace({
|
||||
resolvedZones = resolvedCoverage.intersected_zones
|
||||
}
|
||||
let resolvedProducts: PlannedOnDemandMapProduct[] = []
|
||||
if (analysisMode === 'current' && scale !== 'overview') {
|
||||
if (analysisMode === 'current') {
|
||||
const zoneProducts = resolvedZones
|
||||
? onDemandProductsForZones(resolvedZones)
|
||||
: []
|
||||
if (scale === 'detail' || !selectedProjectId) {
|
||||
if (scale === 'detail' || !selectedProjectId || scale === 'overview') {
|
||||
resolvedProducts = zoneProducts
|
||||
.filter((product) => productSupportsSelection(product, bbox))
|
||||
.map((product) => ({
|
||||
@@ -2025,6 +2025,8 @@ export function MapWorkspace({
|
||||
productKey: onDemandProduct.productKey,
|
||||
displayName: onDemandProduct.displayName,
|
||||
historyProductKeys: onDemandProduct.historyProductKeys,
|
||||
coverageZone: onDemandProduct.coverageZones.find((zone) => resolvedZones?.includes(zone)),
|
||||
serverOrchestrated: scale !== 'detail',
|
||||
},
|
||||
acquisitionBboxes: onDemandProduct.acquisitionBboxes,
|
||||
featureLimit: resultFeatureLimit,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ProviderCapability } from '../../types'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { AoiOperation, ProviderCapability } from '../../types'
|
||||
import { aoiOperationsApi } from '../../services/api'
|
||||
|
||||
interface ProviderPanelProps {
|
||||
selectedProjectId: string | null
|
||||
providers: ProviderCapability[]
|
||||
loadingCapabilities: boolean
|
||||
capabilitiesError: string | null
|
||||
@@ -40,6 +43,7 @@ function providerLayerLabel(value: string): string {
|
||||
}
|
||||
|
||||
export function ProviderPanel({
|
||||
selectedProjectId,
|
||||
providers,
|
||||
loadingCapabilities,
|
||||
capabilitiesError,
|
||||
@@ -49,6 +53,32 @@ export function ProviderPanel({
|
||||
onOpenMap,
|
||||
}: ProviderPanelProps): JSX.Element {
|
||||
const configuredCount = providers.filter((provider) => provider.configured).length
|
||||
const [operations, setOperations] = useState<AoiOperation[]>([])
|
||||
const [operationsError, setOperationsError] = useState<string | null>(null)
|
||||
const [loadingOperations, setLoadingOperations] = useState(false)
|
||||
const loadOperations = useCallback(async () => {
|
||||
if (!selectedProjectId) {
|
||||
setOperations([])
|
||||
return
|
||||
}
|
||||
setLoadingOperations(true)
|
||||
try {
|
||||
const response = await aoiOperationsApi.list(selectedProjectId)
|
||||
setOperations(response.items)
|
||||
setOperationsError(null)
|
||||
} catch (error) {
|
||||
setOperationsError(error instanceof Error ? error.message : 'AOI-verwerking kon niet worden geladen.')
|
||||
} finally {
|
||||
setLoadingOperations(false)
|
||||
}
|
||||
}, [selectedProjectId])
|
||||
|
||||
useEffect(() => {
|
||||
void loadOperations()
|
||||
const timer = window.setInterval(() => void loadOperations(), 5000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [loadOperations])
|
||||
|
||||
return (
|
||||
<section className="system-provider-panel">
|
||||
<div className="system-provider-shell">
|
||||
@@ -93,6 +123,31 @@ export function ProviderPanel({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="system-provider-capability-surface" aria-label="AOI-verwerkingsvoortgang">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<strong>Gebiedsverwerking</strong>
|
||||
<p className="muted">Server-side partities, hervatbare voortgang en bronfouten voor grote selecties.</p>
|
||||
</div>
|
||||
<button type="button" className="secondary-action" onClick={() => void loadOperations()} disabled={loadingOperations || !selectedProjectId}>Vernieuwen</button>
|
||||
</div>
|
||||
{loadingOperations && operations.length === 0 ? <div className="result-state result-state-loading"><strong>Verwerkingen laden.</strong></div> : null}
|
||||
{operationsError ? <div className="result-state result-state-error"><strong>Verwerkingsstatus niet bereikbaar.</strong><p>{operationsError}</p></div> : null}
|
||||
{!loadingOperations && !operationsError && operations.length === 0 ? <div className="result-state result-state-empty"><strong>Nog geen gebiedsverwerking.</strong><p>Grote officiële bronselecties verschijnen hier met één gezamenlijke voortgang.</p></div> : null}
|
||||
{operations.length > 0 ? (
|
||||
<ul className="system-provider-list">
|
||||
{operations.map((operation) => (
|
||||
<li className="system-provider-card" key={operation.id}>
|
||||
<div className="system-provider-header"><div><strong>{operation.operation_type}</strong><span>{String(operation.plan_json.provider_key ?? 'bron')} · {String(operation.plan_json.product_key ?? 'product')}</span></div><span className={operation.status === 'success' ? 'status-badge status-badge-ready' : 'status-badge'}>{operation.status}</span></div>
|
||||
<progress value={operation.progress} max={1} aria-label={`Voortgang ${operation.operation_type}`} />
|
||||
<div className="entity-meta"><span>{Math.round(operation.progress * 100)}%</span><span>{operation.partitions.length} partities</span><span>{operation.partition_counts.failed ?? 0} mislukt</span></div>
|
||||
{operation.error_message ? <p className="inline-error">{operation.error_message}</p> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<div className="system-provider-capability-surface" aria-label="Provider capability registry">
|
||||
<div className="provider-detail-stack">
|
||||
<strong>Officiële referentiebronnen</strong>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { formatError } from '../lib/formatError'
|
||||
import { datasetsApi } from '../services/api/datasets'
|
||||
import { aoiOperationsApi } from '../services/api/aoiOperations'
|
||||
import type { DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../types'
|
||||
import { terrainSelectionToMapSelection } from '../lib/terrainSelection'
|
||||
import { floodHazardSelectionToMapSelection } from '../lib/floodHazardSelection'
|
||||
@@ -22,6 +23,8 @@ export interface MapThemeAcquisition {
|
||||
productKey: string
|
||||
displayName: string
|
||||
historyProductKeys?: string[]
|
||||
coverageZone?: string
|
||||
serverOrchestrated?: boolean
|
||||
}
|
||||
|
||||
export interface MapThemeQuery<TThemeId extends string> {
|
||||
@@ -116,6 +119,30 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
|
||||
const resultLimit = featureLimit ?? 1000
|
||||
if (acquisition) {
|
||||
const requestedBboxes = acquisitionBboxes?.length ? acquisitionBboxes : [bbox]
|
||||
if (acquisition.serverOrchestrated) {
|
||||
let operation = await aoiOperationsApi.create(selectedProjectId, {
|
||||
...(areaId ? { area_id: areaId } : { bbox }),
|
||||
operation_type: 'acquire',
|
||||
provider_key: acquisition.kind,
|
||||
product_key: acquisition.productKey,
|
||||
coverage_zone: acquisition.coverageZone,
|
||||
max_attempts: 3,
|
||||
parameters_json: { force_refresh: false },
|
||||
})
|
||||
const deadline = Date.now() + 20 * 60 * 1000
|
||||
while (['queued', 'running'].includes(operation.status) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1500))
|
||||
operation = await aoiOperationsApi.get(selectedProjectId, operation.id)
|
||||
}
|
||||
if (operation.status !== 'success') {
|
||||
throw new Error(operation.error_message || `De gebiedsverwerking eindigde als ${operation.status}.`)
|
||||
}
|
||||
const outputIds = Array.isArray(operation.result_json?.['output_dataset_ids'])
|
||||
? operation.result_json['output_dataset_ids'].map(String)
|
||||
: []
|
||||
acquiredDatasets = await Promise.all(outputIds.map((datasetId) => datasetsApi.get(selectedProjectId, datasetId)))
|
||||
dataset = acquiredDatasets[0]
|
||||
}
|
||||
const acquireProduct = async (acquisitionBbox: VectorSelectionBBox, productKey: string) => {
|
||||
const commonPayload = {
|
||||
bbox: acquisitionBbox,
|
||||
@@ -166,7 +193,7 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
|
||||
}
|
||||
return datasetsApi.get(selectedProjectId, acquisitionJob.output_dataset_id)
|
||||
}
|
||||
const acquisitionResults = await settleWithConcurrency(
|
||||
const acquisitionResults = acquisition.serverOrchestrated ? [] : await settleWithConcurrency(
|
||||
requestedBboxes,
|
||||
1,
|
||||
(acquisitionBbox) => acquireProduct(acquisitionBbox, acquisition.productKey),
|
||||
@@ -175,7 +202,7 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
|
||||
if (failedAcquisition?.status === 'rejected') {
|
||||
throw failedAcquisition.reason
|
||||
}
|
||||
acquiredDatasets = acquisitionResults.flatMap((item) => item.status === 'fulfilled' ? [item.value] : [])
|
||||
if (!acquisition.serverOrchestrated) acquiredDatasets = acquisitionResults.flatMap((item) => item.status === 'fulfilled' ? [item.value] : [])
|
||||
dataset = acquiredDatasets[0]
|
||||
const historyProductKeys = acquisition.kind === 'walous'
|
||||
? [...new Set(acquisition.historyProductKeys ?? [])].filter((key) => key !== acquisition.productKey)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { apiGet, apiPost, apiPut } from './client'
|
||||
import type { AoiOperation, AoiOperationListResponse, AoiOperationPartition } from '../../types'
|
||||
|
||||
export const aoiOperationsApi = {
|
||||
list: (projectId: string): Promise<AoiOperationListResponse> =>
|
||||
apiGet(`/api/v1/projects/${projectId}/aoi-operations`),
|
||||
get: (projectId: string, operationId: string): Promise<AoiOperation> =>
|
||||
apiGet(`/api/v1/projects/${projectId}/aoi-operations/${operationId}`),
|
||||
create: (projectId: string, payload: Record<string, unknown>): Promise<AoiOperation> =>
|
||||
apiPost(`/api/v1/projects/${projectId}/aoi-operations`, payload),
|
||||
executeNext: (projectId: string, operationId: string): Promise<AoiOperation> =>
|
||||
apiPost(`/api/v1/projects/${projectId}/aoi-operations/${operationId}/execute-next`, {}),
|
||||
checkpoint: (projectId: string, operationId: string, partitionId: string, checkpoint: Record<string, unknown>): Promise<AoiOperationPartition> =>
|
||||
apiPut(`/api/v1/projects/${projectId}/aoi-operations/${operationId}/partitions/${partitionId}/checkpoint`, { checkpoint_json: checkpoint }),
|
||||
}
|
||||
@@ -56,6 +56,16 @@ export async function apiPatch<T>(path: string, body?: object): Promise<T> {
|
||||
return parseResponse<T>(response);
|
||||
}
|
||||
|
||||
export async function apiPut<T>(path: string, body?: object): Promise<T> {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return parseResponse<T>(response);
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(path: string): Promise<T> {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { areasApi } from './areas'
|
||||
export { aoiOperationsApi } from './aoiOperations'
|
||||
export { analysisApi } from './analysis'
|
||||
export { assistantApi } from './assistant'
|
||||
export { datasetsApi } from './datasets'
|
||||
|
||||
@@ -147,6 +147,44 @@ export interface JobListResponse {
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface AoiOperationPartition {
|
||||
id: string
|
||||
partition_key: string
|
||||
provider_key: string
|
||||
product_key: string
|
||||
ordinal: number
|
||||
status: 'queued' | 'running' | 'success' | 'failed' | 'skipped'
|
||||
attempt_count: number
|
||||
max_attempts: number
|
||||
checkpoint_json?: Record<string, unknown> | null
|
||||
result_json?: Record<string, unknown> | null
|
||||
error_message?: string | null
|
||||
}
|
||||
|
||||
export interface AoiOperation {
|
||||
id: string
|
||||
project_id: string
|
||||
area_id?: string | null
|
||||
parent_job_id?: string | null
|
||||
operation_type: string
|
||||
status: 'queued' | 'running' | 'partial' | 'success' | 'failed' | 'cancelled'
|
||||
request_json: Record<string, unknown>
|
||||
plan_json: Record<string, unknown>
|
||||
result_json?: Record<string, unknown> | null
|
||||
error_message?: string | null
|
||||
progress: number
|
||||
partition_counts: Record<string, number>
|
||||
partitions: AoiOperationPartition[]
|
||||
created_at?: string | null
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
}
|
||||
|
||||
export interface AoiOperationListResponse {
|
||||
items: AoiOperation[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface VectorSummary {
|
||||
feature_count?: number | null
|
||||
geometry_types?: string[] | null
|
||||
@@ -1064,6 +1102,20 @@ export interface CoverageResolutionItem {
|
||||
status: CoverageStatus
|
||||
source_names: string[]
|
||||
materialized_dataset_ids: string[]
|
||||
evidence: Array<{
|
||||
dataset_id: string
|
||||
source_name: string
|
||||
authority_level: 'authoritative' | 'official_context' | 'contextual'
|
||||
source_version?: string | null
|
||||
observed_at?: string | null
|
||||
published_at?: string | null
|
||||
crs?: string | null
|
||||
resolution?: Record<string, unknown> | null
|
||||
coverage_bbox_epsg4326?: number[] | null
|
||||
attribution?: string | null
|
||||
license_note?: string | null
|
||||
checksum_sha256?: string | null
|
||||
}>
|
||||
limitation_message: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user