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
+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
}