Add governed GRB refresh workflow
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user