Add governed source freshness audit
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 15:05:42 +02:00
parent 2723043e3d
commit 1e0e7b6499
22 changed files with 1148 additions and 0 deletions
+9
View File
@@ -14,6 +14,7 @@ import { AreaPanel } from './components/project/AreaPanel'
import { ProjectPanel } from './components/project/ProjectPanel'
import { QualityResultsPanel } from './components/quality/QualityResultsPanel'
import { WorkbenchStatusStrip } from './components/WorkbenchStatusStrip'
import { SourceFreshnessPanel } from './components/status/SourceFreshnessPanel'
import type { DatasetCreateResponse } from './types'
import { ProviderPanel } from './components/providers/ProviderPanel'
import { SegmentationLab } from './components/segmentation/SegmentationLab'
@@ -31,6 +32,7 @@ import { useProviderCapabilities } from './hooks/useProviderCapabilities'
import { useProjectWorkspace } from './hooks/useProjectWorkspace'
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
import { useSourceFreshness } from './hooks/useSourceFreshness'
import { useWorkbenchBootstrap } from './hooks/useWorkbenchBootstrap'
import { useViewportVectorLayer } from './hooks/useViewportVectorLayer'
import { getDatasetDisplayName } from './lib/datasetDisplay'
@@ -92,6 +94,7 @@ function App(): JSX.Element {
setProjectForm,
setAreaForm,
} = useProjectWorkspace()
const sourceFreshness = useSourceFreshness(selectedProjectId)
const selectProject = (projectId: string) => {
setMapContentMode('dataset')
setSelectedProjectId(projectId)
@@ -852,6 +855,12 @@ function App(): JSX.Element {
activeLayerFeatureCount={mapFeatureCount}
selectedAreaHasGeometry={Boolean(areaFeatureCollection)}
/>
<SourceFreshnessPanel
report={sourceFreshness.report}
loading={sourceFreshness.loading}
error={sourceFreshness.error}
onRefresh={() => { void sourceFreshness.refresh() }}
/>
<details className="status-details-disclosure">
<summary>
<span>Volledige workflowstatus</span>
@@ -0,0 +1,96 @@
import type { SourceFreshnessItem, SourceFreshnessReport, SourceFreshnessStatus } from '../../types'
interface SourceFreshnessPanelProps {
report: SourceFreshnessReport | null
loading: boolean
error: string | null
onRefresh: () => void
}
function statusLabel(status: SourceFreshnessStatus): string {
if (status === 'current') return 'actueel'
if (status === 'due') return 'controle nodig'
if (status === 'review_required') return 'nakijken'
return 'lokaal'
}
function formatDate(value?: string | null): string {
if (!value) return 'geen datum'
return new Intl.DateTimeFormat('nl-BE', { dateStyle: 'medium' }).format(new Date(value))
}
function integrityIssueCount(item: SourceFreshnessItem): number {
return Object.values(item.integrity).reduce((total, value) => total + value, 0)
}
function SourceRow({ item }: { item: SourceFreshnessItem }): JSX.Element {
const issueCount = integrityIssueCount(item)
return (
<div className={`source-freshness-row source-freshness-row-${item.status}`}>
<div className="source-freshness-main">
<div>
<strong>{item.display_name}</strong>
<span>{item.dataset_count} datasets · {item.version_count} versies</span>
</div>
<span className="source-freshness-status">{statusLabel(item.status)}</span>
</div>
<p>{item.reason}</p>
<div className="source-freshness-meta">
<span>Laatste import: {formatDate(item.latest_imported_at)}</span>
{item.latest_source_version ? <span>Editie: {item.latest_source_version}</span> : null}
{item.historical_series ? <span>Historische reeks</span> : null}
{issueCount ? <span>{issueCount} integriteitsafwijking{issueCount === 1 ? '' : 'en'}</span> : null}
</div>
</div>
)
}
export function SourceFreshnessPanel({ report, loading, error, onRefresh }: SourceFreshnessPanelProps): JSX.Element {
const attentionItems = report?.items.filter((item) => item.status === 'due' || item.status === 'review_required') ?? []
const summary = report?.summary
return (
<section className="source-freshness-panel" aria-label="Bronactualiteit en versie-integriteit">
<div className="source-freshness-header">
<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>
</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}
{!report && !error ? <p className="empty-state">{loading ? 'Bronstatus wordt gecontroleerd...' : 'Geen projectbronstatus beschikbaar.'}</p> : null}
{report && summary ? (
<>
<div className="source-freshness-summary" aria-label="Samenvatting broncontrole">
<span><strong>{summary.current_count}</strong> actueel</span>
<span><strong>{summary.due_count}</strong> controle nodig</span>
<span><strong>{summary.review_required_count}</strong> nakijken</span>
<span><strong>{summary.integrity_issue_count}</strong> integriteitsfouten</span>
</div>
{attentionItems.length ? (
<div className="source-freshness-attention" aria-label="Bronnen die aandacht vragen">
{attentionItems.map((item) => <SourceRow item={item} key={item.source_name} />)}
</div>
) : (
<p className="source-freshness-ok">Alle externe bronpublicaties hebben geldige lokale versie-evidentie.</p>
)}
<details className="source-freshness-details">
<summary>
<span>Alle {summary.source_count} bronnen bekijken</span>
<strong>gecontroleerd {formatDate(report.generated_at)}</strong>
</summary>
<div className="source-freshness-list">
{report.items.map((item) => <SourceRow item={item} key={item.source_name} />)}
</div>
<p className="source-freshness-limitation">{report.limitations.join(' ')}</p>
</details>
</>
) : null}
</section>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { datasetsApi } from '../services/api'
import type { 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 refresh = useCallback(async () => {
const requestId = ++requestSequence.current
if (!selectedProjectId) {
setReport(null)
setError(null)
setLoading(false)
return
}
setLoading(true)
setError(null)
try {
const nextReport = await datasetsApi.sourceFreshness(selectedProjectId)
if (requestSequence.current === requestId) {
setReport(nextReport)
}
} catch (caught) {
if (requestSequence.current === requestId) {
setError(caught instanceof Error ? caught.message : 'De broncontrole kon niet worden geladen.')
}
} finally {
if (requestSequence.current === requestId) {
setLoading(false)
}
}
}, [selectedProjectId])
useEffect(() => {
void refresh()
return () => {
requestSequence.current += 1
}
}, [refresh])
return { report, loading, error, refresh }
}
+3
View File
@@ -27,6 +27,7 @@ import type {
ThematicRasterAcquireRequest,
ThematicRasterProductRead,
ThematicRasterSelectionResponse,
SourceFreshnessReport,
} from '../../types'
const DATASET_PAGE_SIZE = 200
@@ -58,6 +59,8 @@ async function listProjectDatasets(projectId: string): Promise<DatasetListRespon
export const datasetsApi = {
list: listProjectDatasets,
sourceFreshness: (projectId: string): Promise<SourceFreshnessReport> =>
apiGet<SourceFreshnessReport>(`/api/v1/projects/${projectId}/datasets/source-freshness`),
upload: (
projectId: string,
payload: {
+167
View File
@@ -821,6 +821,173 @@ details.ai-lab-model-surface > summary strong {
-webkit-line-clamp: 2;
}
.source-freshness-panel {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 7px;
background: #ffffff;
}
.source-freshness-header {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
padding: 1rem 1.2rem;
}
.source-freshness-header h2 {
margin: 0;
font-size: 1.05rem;
}
.source-freshness-header p:last-child {
max-width: 52rem;
margin: 0.3rem 0 0;
color: var(--muted);
font-size: 0.82rem;
}
.source-freshness-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: #f8fafb;
}
.source-freshness-summary span {
padding: 0.7rem 1rem;
border-right: 1px solid var(--line);
color: var(--muted);
font-size: 0.75rem;
}
.source-freshness-summary span:last-child {
border-right: 0;
}
.source-freshness-summary strong {
display: block;
margin-bottom: 0.15rem;
color: #24333d;
font-size: 1.05rem;
}
.source-freshness-attention,
.source-freshness-list {
display: grid;
}
.source-freshness-row {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--line);
}
.source-freshness-row:last-child {
border-bottom: 0;
}
.source-freshness-row-review_required {
box-shadow: inset 3px 0 0 #b84e4e;
}
.source-freshness-row-due {
box-shadow: inset 3px 0 0 #ca7a18;
}
.source-freshness-row-current {
box-shadow: inset 3px 0 0 #2d9272;
}
.source-freshness-main {
display: flex;
gap: 1rem;
align-items: start;
justify-content: space-between;
}
.source-freshness-main > div {
display: grid;
gap: 0.15rem;
}
.source-freshness-main strong {
color: #23313b;
font-size: 0.86rem;
}
.source-freshness-main span,
.source-freshness-row p,
.source-freshness-meta {
color: var(--muted);
font-size: 0.75rem;
}
.source-freshness-row p {
margin: 0.4rem 0;
}
.source-freshness-status {
flex: 0 0 auto;
color: #33424d !important;
font-weight: 750;
}
.source-freshness-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 1rem;
}
.source-freshness-ok,
.source-freshness-limitation {
margin: 0;
padding: 0.8rem 1rem;
color: var(--muted);
font-size: 0.78rem;
}
.source-freshness-details {
border-top: 1px solid var(--line);
}
.source-freshness-details > summary {
display: flex;
cursor: pointer;
gap: 1rem;
justify-content: space-between;
padding: 0.72rem 1rem;
color: #33424d;
font-size: 0.78rem;
font-weight: 700;
}
.source-freshness-list {
max-height: 30rem;
overflow: auto;
border-top: 1px solid var(--line);
}
@media (max-width: 760px) {
.source-freshness-header {
align-items: stretch;
flex-direction: column;
}
.source-freshness-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.source-freshness-summary span:nth-child(2) {
border-right: 0;
}
.source-freshness-summary span:nth-child(-n + 2) {
border-bottom: 1px solid var(--line);
}
}
.workflow-guidance-panel {
border: 1px solid var(--line);
border-left: 1px solid var(--line);
+46
View File
@@ -663,6 +663,52 @@ export interface DatasetListResponse {
offset: number
}
export type SourceFreshnessStatus = 'current' | 'due' | 'review_required' | 'local'
export interface SourceIntegritySummary {
missing_version_count: number
checksum_mismatch_count: number
missing_storage_file_count: number
size_mismatch_count: number
}
export interface SourceFreshnessItem {
source_name: string
display_name: string
dataset_count: number
ready_count: number
version_count: number
latest_imported_at?: string | null
latest_observed_at?: string | null
latest_source_version?: string | null
refresh_policy: 'rolling_snapshot' | 'annual_release' | 'edition' | 'scenario' | 'archive' | 'local'
review_interval_days?: number | null
next_review_at?: string | null
status: SourceFreshnessStatus
historical_series: boolean
auto_refresh_supported: false
reason: string
recommended_action: string
integrity: SourceIntegritySummary
}
export interface SourceFreshnessReport {
project_id: string
generated_at: string
summary: {
source_count: number
dataset_count: number
current_count: number
due_count: number
review_required_count: number
local_count: number
sources_with_integrity_issues: number
integrity_issue_count: number
}
items: SourceFreshnessItem[]
limitations: string[]
}
export interface GeojsonEnvelopeResponse {
data: object
}