Add official source edition probes
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:33:40 +02:00
parent fd05c46e2a
commit 595967f892
27 changed files with 1513 additions and 18 deletions
+4 -1
View File
@@ -9,7 +9,10 @@ surface. It separates sources that are current, due for a catalogue review,
require local integrity review or are local artifacts. Only attention items are
expanded by default; all source detail remains available through disclosure.
The refresh button reruns the local read-only audit and never downloads or
replaces source data.
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.
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.
+4
View File
@@ -860,6 +860,10 @@ function App(): JSX.Element {
loading={sourceFreshness.loading}
error={sourceFreshness.error}
onRefresh={() => { void sourceFreshness.refresh() }}
catalogReport={sourceFreshness.catalogReport}
catalogLoading={sourceFreshness.catalogLoading}
catalogError={sourceFreshness.catalogError}
onProbeCatalogs={(force) => { void sourceFreshness.probeCatalogs(force) }}
/>
<details className="status-details-disclosure">
<summary>
@@ -1,10 +1,22 @@
import type { SourceFreshnessItem, SourceFreshnessReport, SourceFreshnessStatus } from '../../types'
import type {
SourceCatalogComparisonStatus,
SourceCatalogProbeItem,
SourceCatalogProbeReport,
SourceCatalogProbeStatus,
SourceFreshnessItem,
SourceFreshnessReport,
SourceFreshnessStatus,
} from '../../types'
interface SourceFreshnessPanelProps {
report: SourceFreshnessReport | null
loading: boolean
error: string | null
onRefresh: () => void
catalogReport: SourceCatalogProbeReport | null
catalogLoading: boolean
catalogError: string | null
onProbeCatalogs: (force: boolean) => void
}
function statusLabel(status: SourceFreshnessStatus): string {
@@ -14,6 +26,21 @@ function statusLabel(status: SourceFreshnessStatus): string {
return 'lokaal'
}
function catalogStatusLabel(status: SourceCatalogProbeStatus): string {
if (status === 'available') return 'bereikbaar'
if (status === 'degraded') return 'beperkt'
if (status === 'unavailable') return 'niet bereikbaar'
return 'uitgeschakeld'
}
function comparisonLabel(status: SourceCatalogComparisonStatus): string {
if (status === 'same') return 'zelfde editie'
if (status === 'different') return 'verschil controleren'
if (status === 'no_local_data') return 'nog niet lokaal ingeladen'
if (status === 'not_comparable') return 'andere versienotatie'
return 'niet vergelijkbaar'
}
function formatDate(value?: string | null): string {
if (!value) return 'geen datum'
return new Intl.DateTimeFormat('nl-BE', { dateStyle: 'medium' }).format(new Date(value))
@@ -23,6 +50,27 @@ function integrityIssueCount(item: SourceFreshnessItem): number {
return Object.values(item.integrity).reduce((total, value) => total + value, 0)
}
function CatalogRow({ item }: { item: SourceCatalogProbeItem }): JSX.Element {
return (
<div className={`source-catalog-row source-catalog-row-${item.status}`}>
<div className="source-freshness-main">
<div>
<strong>{item.display_name}</strong>
<span>{catalogStatusLabel(item.status)} / {comparisonLabel(item.comparison_status)}</span>
</div>
<span className="source-freshness-status">{item.remote_version ?? 'geen editie'}</span>
</div>
<p>{item.message}</p>
<div className="source-freshness-meta">
<span>Lokaal: {item.local_source_version ?? 'niet aanwezig'}</span>
<span>Lagen: {item.matched_layers.length}/{item.expected_layers.length} bevestigd</span>
{item.remote_modified_at ? <span>Metadata: {formatDate(item.remote_modified_at)}</span> : null}
{item.cached ? <span>cache gebruikt</span> : null}
</div>
</div>
)
}
function SourceRow({ item }: { item: SourceFreshnessItem }): JSX.Element {
const issueCount = integrityIssueCount(item)
return (
@@ -30,7 +78,7 @@ function SourceRow({ item }: { item: SourceFreshnessItem }): JSX.Element {
<div className="source-freshness-main">
<div>
<strong>{item.display_name}</strong>
<span>{item.dataset_count} datasets · {item.version_count} versies</span>
<span>{item.dataset_count} datasets / {item.version_count} versies</span>
</div>
<span className="source-freshness-status">{statusLabel(item.status)}</span>
</div>
@@ -45,7 +93,16 @@ function SourceRow({ item }: { item: SourceFreshnessItem }): JSX.Element {
)
}
export function SourceFreshnessPanel({ report, loading, error, onRefresh }: SourceFreshnessPanelProps): JSX.Element {
export function SourceFreshnessPanel({
report,
loading,
error,
onRefresh,
catalogReport,
catalogLoading,
catalogError,
onProbeCatalogs,
}: SourceFreshnessPanelProps): JSX.Element {
const attentionItems = report?.items.filter((item) => item.status === 'due' || item.status === 'review_required') ?? []
const summary = report?.summary
@@ -55,11 +112,21 @@ export function SourceFreshnessPanel({ report, loading, error, onRefresh }: Sour
<div>
<p className="eyebrow">Bronbeheer</p>
<h2>Actualiteit en versiecontrole</h2>
<p>Controleert lokale publicaties, versies en bestanden zonder externe bronnen automatisch te wijzigen.</p>
<p>Controleert lokale publicaties en kan op aanvraag de officiële GRB- en orthofoto-editie uitlezen.</p>
</div>
<div className="source-freshness-actions">
<button className="secondary-action" type="button" onClick={onRefresh} disabled={loading}>
{loading ? 'Controleren...' : 'Lokale status vernieuwen'}
</button>
<button
className="primary-action"
type="button"
onClick={() => onProbeCatalogs(Boolean(catalogReport))}
disabled={catalogLoading}
>
{catalogLoading ? 'Officiële bronnen lezen...' : catalogReport ? 'Opnieuw bij bron controleren' : 'Officiële edities controleren'}
</button>
</div>
<button className="secondary-action" type="button" onClick={onRefresh} disabled={loading}>
{loading ? 'Controleren...' : 'Opnieuw controleren'}
</button>
</div>
{error ? <p className="inline-error">{error}</p> : null}
@@ -89,6 +156,31 @@ export function SourceFreshnessPanel({ report, loading, error, onRefresh }: Sour
</div>
<p className="source-freshness-limitation">{report.limitations.join(' ')}</p>
</details>
<div className="source-catalog-section" aria-live="polite">
<div className="source-catalog-heading">
<div>
<strong>Officiële catalogusedities</strong>
<span>Alleen na jouw expliciete controle; er wordt niets ingeladen of vervangen.</span>
</div>
{catalogReport ? (
<span>{catalogReport.summary.available_count}/{catalogReport.summary.provider_count} bereikbaar</span>
) : null}
</div>
{catalogError ? <p className="inline-error">{catalogError}</p> : null}
{!catalogReport && !catalogError ? (
<p className="source-catalog-empty">Controleer GRB en orthofoto wanneer je een lokale bronversie met de officiële editie wilt vergelijken.</p>
) : null}
{catalogReport ? (
<>
<div className="source-catalog-list">
{catalogReport.items.map((item) => <CatalogRow item={item} key={item.source_name} />)}
</div>
<p className="source-freshness-limitation">
Gecontroleerd {formatDate(catalogReport.generated_at)}. Een verschil vraagt menselijke beoordeling en start nooit automatisch een import.
</p>
</>
) : null}
</div>
</>
) : null}
</section>
+48 -2
View File
@@ -1,13 +1,17 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { datasetsApi } from '../services/api'
import type { SourceFreshnessReport } from '../types'
import type { SourceCatalogProbeReport, SourceFreshnessReport } from '../types'
export function useSourceFreshness(selectedProjectId: string | null) {
const [report, setReport] = useState<SourceFreshnessReport | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const requestSequence = useRef(0)
const catalogRequestSequence = useRef(0)
const [catalogReport, setCatalogReport] = useState<SourceCatalogProbeReport | null>(null)
const [catalogLoading, setCatalogLoading] = useState(false)
const [catalogError, setCatalogError] = useState<string | null>(null)
const refresh = useCallback(async () => {
const requestId = ++requestSequence.current
@@ -35,6 +39,32 @@ export function useSourceFreshness(selectedProjectId: string | null) {
}
}, [selectedProjectId])
const probeCatalogs = useCallback(async (force = false) => {
const requestId = ++catalogRequestSequence.current
if (!selectedProjectId) {
setCatalogReport(null)
setCatalogError(null)
setCatalogLoading(false)
return
}
setCatalogLoading(true)
setCatalogError(null)
try {
const nextReport = await datasetsApi.sourceCatalogProbes(selectedProjectId, force)
if (catalogRequestSequence.current === requestId) {
setCatalogReport(nextReport)
}
} catch (caught) {
if (catalogRequestSequence.current === requestId) {
setCatalogError(caught instanceof Error ? caught.message : 'De officiële broncatalogi konden niet worden gecontroleerd.')
}
} finally {
if (catalogRequestSequence.current === requestId) {
setCatalogLoading(false)
}
}
}, [selectedProjectId])
useEffect(() => {
void refresh()
return () => {
@@ -42,5 +72,21 @@ export function useSourceFreshness(selectedProjectId: string | null) {
}
}, [refresh])
return { report, loading, error, refresh }
useEffect(() => {
catalogRequestSequence.current += 1
setCatalogReport(null)
setCatalogError(null)
setCatalogLoading(false)
}, [selectedProjectId])
return {
report,
loading,
error,
refresh,
catalogReport,
catalogLoading,
catalogError,
probeCatalogs,
}
}
+5
View File
@@ -28,6 +28,7 @@ import type {
ThematicRasterProductRead,
ThematicRasterSelectionResponse,
SourceFreshnessReport,
SourceCatalogProbeReport,
} from '../../types'
const DATASET_PAGE_SIZE = 200
@@ -61,6 +62,10 @@ export const datasetsApi = {
list: listProjectDatasets,
sourceFreshness: (projectId: string): Promise<SourceFreshnessReport> =>
apiGet<SourceFreshnessReport>(`/api/v1/projects/${projectId}/datasets/source-freshness`),
sourceCatalogProbes: (projectId: string, refresh = false): Promise<SourceCatalogProbeReport> =>
apiGet<SourceCatalogProbeReport>(
`/api/v1/projects/${projectId}/datasets/source-catalog-probes?refresh=${refresh ? 'true' : 'false'}`,
),
upload: (
projectId: string,
payload: {
+102 -2
View File
@@ -836,13 +836,20 @@ details.ai-lab-model-surface > summary strong {
padding: 1rem 1.2rem;
}
.source-freshness-header .secondary-action {
.source-freshness-header .secondary-action,
.source-freshness-header .primary-action {
flex: 0 0 auto;
width: auto;
min-width: 10rem;
margin-top: 0;
}
.source-freshness-actions {
display: flex;
flex: 0 0 auto;
gap: 0.55rem;
}
.source-freshness-header h2 {
margin: 0;
font-size: 1.05rem;
@@ -959,6 +966,81 @@ details.ai-lab-model-surface > summary strong {
border-top: 1px solid var(--line);
}
.source-catalog-section {
border-top: 1px solid var(--line);
background: #fbfcfd;
}
.source-catalog-heading {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
padding: 0.8rem 1rem;
}
.source-catalog-heading > div {
display: grid;
gap: 0.12rem;
}
.source-catalog-heading strong {
color: #23313b;
font-size: 0.84rem;
}
.source-catalog-heading span,
.source-catalog-empty {
color: var(--muted);
font-size: 0.75rem;
}
.source-catalog-heading > span {
flex: 0 0 auto;
font-weight: 700;
}
.source-catalog-empty {
margin: 0;
padding: 0 1rem 0.85rem;
}
.source-catalog-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-top: 1px solid var(--line);
}
.source-catalog-row {
min-width: 0;
padding: 0.8rem 1rem;
border-right: 1px solid var(--line);
box-shadow: inset 0 3px 0 #8a98a3;
}
.source-catalog-row:last-child {
border-right: 0;
}
.source-catalog-row-available {
box-shadow: inset 0 3px 0 #2d9272;
}
.source-catalog-row-degraded {
box-shadow: inset 0 3px 0 #ca7a18;
}
.source-catalog-row-unavailable,
.source-catalog-row-disabled {
box-shadow: inset 0 3px 0 #b84e4e;
}
.source-catalog-row p {
margin: 0.45rem 0;
color: var(--muted);
font-size: 0.75rem;
}
.source-freshness-details > summary {
display: flex;
cursor: pointer;
@@ -982,10 +1064,15 @@ details.ai-lab-model-surface > summary strong {
flex-direction: column;
}
.source-freshness-header .secondary-action {
.source-freshness-header .secondary-action,
.source-freshness-header .primary-action {
width: 100%;
}
.source-freshness-actions {
flex-direction: column;
}
.source-freshness-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -997,6 +1084,19 @@ details.ai-lab-model-surface > summary strong {
.source-freshness-summary span:nth-child(-n + 2) {
border-bottom: 1px solid var(--line);
}
.source-catalog-list {
grid-template-columns: 1fr;
}
.source-catalog-row {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.source-catalog-row:last-child {
border-bottom: 0;
}
}
.workflow-guidance-panel {
+46
View File
@@ -709,6 +709,52 @@ export interface SourceFreshnessReport {
limitations: string[]
}
export type SourceCatalogProbeStatus = 'available' | 'degraded' | 'unavailable' | 'disabled'
export type SourceCatalogComparisonStatus = 'same' | 'different' | 'not_comparable' | 'no_local_data' | 'unavailable'
export interface SourceCatalogProbeItem {
source_name: string
display_name: string
service_type: 'WFS' | 'WMS'
endpoint_url: string
status: SourceCatalogProbeStatus
reachable: boolean
checked_at: string
cached: boolean
expected_layers: string[]
matched_layers: string[]
missing_layers: string[]
advertised_layer_count: number
metadata_url?: string | null
metadata_identifier?: string | null
remote_title?: string | null
remote_version?: string | null
remote_modified_at?: string | null
remote_published_at?: string | null
local_source_version?: string | null
comparison_status: SourceCatalogComparisonStatus
capabilities_sha256?: string | null
capabilities_etag?: string | null
capabilities_last_modified_at?: string | null
message: string
error_code?: string | null
}
export interface SourceCatalogProbeReport {
project_id: string
generated_at: string
summary: {
provider_count: number
available_count: number
degraded_count: number
unavailable_count: number
disabled_count: number
different_version_count: number
}
items: SourceCatalogProbeItem[]
limitations: string[]
}
export interface GeojsonEnvelopeResponse {
data: object
}