Add official source edition probes
This commit is contained in:
+4
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user