Add governed GRB refresh workflow
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-16 17:59:00 +02:00
parent bbf56d762c
commit eed6ee796e
21 changed files with 1313 additions and 1 deletions
+7
View File
@@ -13,6 +13,13 @@ replaces source data. A separate `Officiële edities controleren` action is the
only trigger for bounded GRB/orthophoto catalog reads. It presents official and
local editions, layer-contract coverage and honest non-comparable version
markers without starting an import or background poll.
After that explicit check, the same surface shows a compact GRB refresh plan
for buildings, roads, water and parcels. It states whether each local regional
snapshot is current, updateable or needs review and shows the current object
count. The UI cannot start the million-feature import: staging and checksum-
confirmed application remain an operator action so a browser request cannot
silently replace regional reference data. Existing snapshots remain visible
as historical observations.
The user-facing shell is task based: `Kaart`, `Bronnen`, `Kwaliteit`, `Beeldanalyse`, `Downloads`, `Status` and `Beheer`. Internal benchmark projects, raw dataset metadata, provider capabilities, model registry details and QA evidence remain accessible through labelled advanced disclosures instead of competing with the normal workflow.
+3
View File
@@ -864,6 +864,9 @@ function App(): JSX.Element {
catalogLoading={sourceFreshness.catalogLoading}
catalogError={sourceFreshness.catalogError}
onProbeCatalogs={(force) => { void sourceFreshness.probeCatalogs(force) }}
grbRefreshPlan={sourceFreshness.grbRefreshPlan}
grbRefreshPlanLoading={sourceFreshness.grbRefreshPlanLoading}
grbRefreshPlanError={sourceFreshness.grbRefreshPlanError}
/>
<details className="status-details-disclosure">
<summary>
@@ -1,4 +1,7 @@
import type {
GrbRefreshLayerPlan,
GrbRefreshLayerStatus,
GrbRefreshPlan,
SourceCatalogComparisonStatus,
SourceCatalogProbeItem,
SourceCatalogProbeReport,
@@ -17,6 +20,9 @@ interface SourceFreshnessPanelProps {
catalogLoading: boolean
catalogError: string | null
onProbeCatalogs: (force: boolean) => void
grbRefreshPlan: GrbRefreshPlan | null
grbRefreshPlanLoading: boolean
grbRefreshPlanError: string | null
}
function statusLabel(status: SourceFreshnessStatus): string {
@@ -46,6 +52,35 @@ function formatDate(value?: string | null): string {
return new Intl.DateTimeFormat('nl-BE', { dateStyle: 'medium' }).format(new Date(value))
}
function formatCount(value?: number | null): string {
return value == null ? 'nog niet bekend' : value.toLocaleString('nl-BE')
}
function grbLayerStatusLabel(status: GrbRefreshLayerStatus): string {
if (status === 'current') return 'actueel'
if (status === 'update_available') return 'update beschikbaar'
if (status === 'not_loaded') return 'nog niet ingeladen'
if (status === 'remote_unavailable') return 'bron niet bereikbaar'
return 'beoordeling nodig'
}
function GrbRefreshLayerRow({ item, remoteEdition }: { item: GrbRefreshLayerPlan; remoteEdition?: string | null }): JSX.Element {
return (
<div className={`grb-refresh-layer grb-refresh-layer-${item.status}`}>
<div>
<strong>{item.display_name}</strong>
<span>{grbLayerStatusLabel(item.status)}</span>
</div>
<p>{item.action_message}</p>
<div className="source-freshness-meta">
<span>Lokaal: {item.local_source_version ?? 'niet aanwezig'}</span>
<span>Officieel: {remoteEdition ?? 'onbekend'}</span>
<span>{formatCount(item.local_feature_count)} objecten lokaal</span>
</div>
</div>
)
}
function integrityIssueCount(item: SourceFreshnessItem): number {
return Object.values(item.integrity).reduce((total, value) => total + value, 0)
}
@@ -102,6 +137,9 @@ export function SourceFreshnessPanel({
catalogLoading,
catalogError,
onProbeCatalogs,
grbRefreshPlan,
grbRefreshPlanLoading,
grbRefreshPlanError,
}: SourceFreshnessPanelProps): JSX.Element {
const attentionItems = report?.items.filter((item) => item.status === 'due' || item.status === 'review_required') ?? []
const summary = report?.summary
@@ -180,6 +218,27 @@ export function SourceFreshnessPanel({
</p>
</>
) : null}
{grbRefreshPlanLoading ? <p className="source-catalog-empty">Veilig vernieuwingsplan wordt opgebouwd...</p> : null}
{grbRefreshPlanError ? <p className="inline-error">{grbRefreshPlanError}</p> : null}
{grbRefreshPlan ? (
<div className="grb-refresh-plan">
<div className="grb-refresh-plan-heading">
<div>
<strong>Veilige GRB-vernieuwing</strong>
<span>{grbRefreshPlan.message}</span>
</div>
<span>{grbRefreshPlan.summary.update_available_count} updates</span>
</div>
<div className="grb-refresh-layer-list">
{grbRefreshPlan.layers.map((item) => (
<GrbRefreshLayerRow item={item} remoteEdition={grbRefreshPlan.remote_edition_date} key={item.theme} />
))}
</div>
<p className="source-freshness-limitation">
Een serveroperator controleert eerst exacte aantallen en checksums. Bestaande snapshots blijven altijd behouden.
</p>
</div>
) : null}
</div>
</>
) : null}
+31 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { datasetsApi } from '../services/api'
import type { SourceCatalogProbeReport, SourceFreshnessReport } from '../types'
import type { GrbRefreshPlan, SourceCatalogProbeReport, SourceFreshnessReport } from '../types'
export function useSourceFreshness(selectedProjectId: string | null) {
const [report, setReport] = useState<SourceFreshnessReport | null>(null)
@@ -12,6 +12,9 @@ export function useSourceFreshness(selectedProjectId: string | null) {
const [catalogReport, setCatalogReport] = useState<SourceCatalogProbeReport | null>(null)
const [catalogLoading, setCatalogLoading] = useState(false)
const [catalogError, setCatalogError] = useState<string | null>(null)
const [grbRefreshPlan, setGrbRefreshPlan] = useState<GrbRefreshPlan | null>(null)
const [grbRefreshPlanLoading, setGrbRefreshPlanLoading] = useState(false)
const [grbRefreshPlanError, setGrbRefreshPlanError] = useState<string | null>(null)
const refresh = useCallback(async () => {
const requestId = ++requestSequence.current
@@ -45,10 +48,15 @@ export function useSourceFreshness(selectedProjectId: string | null) {
setCatalogReport(null)
setCatalogError(null)
setCatalogLoading(false)
setGrbRefreshPlan(null)
setGrbRefreshPlanError(null)
setGrbRefreshPlanLoading(false)
return
}
setCatalogLoading(true)
setCatalogError(null)
setGrbRefreshPlanLoading(true)
setGrbRefreshPlanError(null)
try {
const nextReport = await datasetsApi.sourceCatalogProbes(selectedProjectId, force)
if (catalogRequestSequence.current === requestId) {
@@ -58,9 +66,25 @@ export function useSourceFreshness(selectedProjectId: string | null) {
if (catalogRequestSequence.current === requestId) {
setCatalogError(caught instanceof Error ? caught.message : 'De officiële broncatalogi konden niet worden gecontroleerd.')
}
if (catalogRequestSequence.current === requestId) {
setCatalogLoading(false)
setGrbRefreshPlanLoading(false)
}
return
}
try {
const nextPlan = await datasetsApi.grbRefreshPlan(selectedProjectId)
if (catalogRequestSequence.current === requestId) {
setGrbRefreshPlan(nextPlan)
}
} catch (caught) {
if (catalogRequestSequence.current === requestId) {
setGrbRefreshPlanError(caught instanceof Error ? caught.message : 'Het veilige GRB-vernieuwingsplan kon niet worden opgebouwd.')
}
} finally {
if (catalogRequestSequence.current === requestId) {
setCatalogLoading(false)
setGrbRefreshPlanLoading(false)
}
}
}, [selectedProjectId])
@@ -77,6 +101,9 @@ export function useSourceFreshness(selectedProjectId: string | null) {
setCatalogReport(null)
setCatalogError(null)
setCatalogLoading(false)
setGrbRefreshPlan(null)
setGrbRefreshPlanError(null)
setGrbRefreshPlanLoading(false)
}, [selectedProjectId])
return {
@@ -88,5 +115,8 @@ export function useSourceFreshness(selectedProjectId: string | null) {
catalogLoading,
catalogError,
probeCatalogs,
grbRefreshPlan,
grbRefreshPlanLoading,
grbRefreshPlanError,
}
}
+5
View File
@@ -29,6 +29,7 @@ import type {
ThematicRasterSelectionResponse,
SourceFreshnessReport,
SourceCatalogProbeReport,
GrbRefreshPlan,
} from '../../types'
const DATASET_PAGE_SIZE = 200
@@ -66,6 +67,10 @@ export const datasetsApi = {
apiGet<SourceCatalogProbeReport>(
`/api/v1/projects/${projectId}/datasets/source-catalog-probes?refresh=${refresh ? 'true' : 'false'}`,
),
grbRefreshPlan: (projectId: string, refreshCatalog = false): Promise<GrbRefreshPlan> =>
apiGet<GrbRefreshPlan>(
`/api/v1/projects/${projectId}/datasets/grb-refresh-plan?refresh_catalog=${refreshCatalog ? 'true' : 'false'}`,
),
upload: (
projectId: string,
payload: {
+109
View File
@@ -1041,6 +1041,95 @@ details.ai-lab-model-surface > summary strong {
font-size: 0.75rem;
}
.grb-refresh-plan {
border-top: 1px solid var(--line);
background: #ffffff;
}
.grb-refresh-plan-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 1rem;
}
.grb-refresh-plan-heading > div {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.grb-refresh-plan-heading strong {
color: #23313b;
font-size: 0.84rem;
}
.grb-refresh-plan-heading span {
color: var(--muted);
font-size: 0.75rem;
}
.grb-refresh-plan-heading > span {
flex: 0 0 auto;
font-weight: 750;
}
.grb-refresh-layer-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-top: 1px solid var(--line);
}
.grb-refresh-layer {
min-width: 0;
padding: 0.78rem 1rem;
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
box-shadow: inset 3px 0 0 #8a98a3;
}
.grb-refresh-layer:nth-child(2n) {
border-right: 0;
}
.grb-refresh-layer:nth-last-child(-n + 2) {
border-bottom: 0;
}
.grb-refresh-layer-update_available,
.grb-refresh-layer-not_loaded {
box-shadow: inset 3px 0 0 #ca7a18;
}
.grb-refresh-layer-current {
box-shadow: inset 3px 0 0 #2d9272;
}
.grb-refresh-layer-remote_unavailable,
.grb-refresh-layer-review_required {
box-shadow: inset 3px 0 0 #b84e4e;
}
.grb-refresh-layer > div:first-child {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.grb-refresh-layer > div:first-child span {
color: #4d5c66;
font-size: 0.72rem;
font-weight: 750;
}
.grb-refresh-layer p {
margin: 0.4rem 0;
color: var(--muted);
font-size: 0.75rem;
}
.source-freshness-details > summary {
display: flex;
cursor: pointer;
@@ -1097,6 +1186,26 @@ details.ai-lab-model-surface > summary strong {
.source-catalog-row:last-child {
border-bottom: 0;
}
.grb-refresh-plan-heading {
align-items: flex-start;
flex-direction: column;
}
.grb-refresh-layer-list {
grid-template-columns: 1fr;
}
.grb-refresh-layer,
.grb-refresh-layer:nth-child(2n),
.grb-refresh-layer:nth-last-child(-n + 2) {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.grb-refresh-layer:last-child {
border-bottom: 0;
}
}
.workflow-guidance-panel {
+52
View File
@@ -755,6 +755,58 @@ export interface SourceCatalogProbeReport {
limitations: string[]
}
export type GrbRefreshLayerStatus =
| 'current'
| 'update_available'
| 'not_loaded'
| 'review_required'
| 'remote_unavailable'
export interface GrbRefreshLayerPlan {
theme: 'buildings' | 'roads' | 'water' | 'parcels'
display_name: string
collections: string[]
temporal_series_key: string
status: GrbRefreshLayerStatus
local_dataset_id?: string | null
local_source_version?: string | null
local_observed_at?: string | null
local_imported_at?: string | null
local_feature_count?: number | null
local_size_bytes?: number | null
retained_after_refresh: boolean
action_message: string
}
export interface GrbRefreshPlan {
project_id: string
scope: string
generated_at: string
remote_status: string
remote_version?: string | null
remote_edition_date?: string | null
catalog_checked_at?: string | null
summary: {
layer_count: number
current_count: number
update_available_count: number
not_loaded_count: number
review_required_count: number
remote_unavailable_count: number
new_dataset_count_if_applied: number
retained_dataset_count: number
current_feature_count: number
current_size_bytes: number
}
layers: GrbRefreshLayerPlan[]
execution_mode: 'operator_stage_then_apply'
staging_required: true
automatic_import: false
destructive_replacement: false
message: string
limitations: string[]
}
export interface GeojsonEnvelopeResponse {
data: object
}