Add governed source freshness audit
This commit is contained in:
@@ -4,6 +4,13 @@ React + TypeScript + MapLibre workbench for regional geographic analysis.
|
||||
|
||||
The persisted `Kempen Regional Workbench` is the automatic operational data context. Its datasets are loaded once for the complete official 28-municipality Vlaamse vervoerregio; the operator chooses Mol, another municipality or the complete region as a spatial work-area filter. The primary map no longer asks the user to choose a technical project or region before data becomes usable. Regional population and modern forest snapshots use the same current/evolution flow as Mol, while explicit project selection stays available under advanced management.
|
||||
|
||||
The Status workspace includes one compact `Actualiteit en versiecontrole`
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Detection defaults to the configured local YOLO asset and automatically selects an available raster and active model asset where possible. The model registry and preflight remain honest when PyTorch, Ultralytics, a local model file or a tile manifest is unavailable. The active building profile is operational but remains review-required: its current coverage-aligned benchmark is approximately precision 0.614, recall 0.606 and F1 0.607 over seven positive AOIs, with zero detections in all three pure-empty controls. A reviewed challenger remains inactive because it produced two detections in empty Postel forest.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user