feat: implement governed datasource catalogue metadata

This commit is contained in:
2026-08-01 17:48:28 +02:00
parent 8da8035693
commit 98d93e707f
14 changed files with 1382 additions and 12 deletions
@@ -11,9 +11,11 @@ import {
Download,
Eye,
Layers3,
Pencil,
Plus,
RefreshCw,
Snowflake,
ShieldCheck,
Trash2,
Upload
} from "lucide-react";
@@ -44,7 +46,9 @@ import {
refreshDatasource,
registerDatasourceOrigin,
retireDatasource,
updateDatasourceGovernance,
type Datasource,
type DatasourceGovernance,
type DatasourceMaterialization,
type DatasourceOrigin,
type DatasourcePreview,
@@ -82,6 +86,7 @@ export default function DatasourcesPage({
const [freezeOpen, setFreezeOpen] = useState(false);
const [freezeLabel, setFreezeLabel] = useState("");
const [retireOpen, setRetireOpen] = useState(false);
const [governanceOpen, setGovernanceOpen] = useState(false);
const canManage = hasScope(auth, "datasources:source:write")
|| hasScope(auth, "datasources:source:admin");
@@ -386,6 +391,12 @@ export default function DatasourcesPage({
) : null}
{view === "catalogue" && selectedDatasource ? (
<>
<IconButton
label="Edit datasource governance"
icon={<Pencil size={16} />}
onClick={() => setGovernanceOpen(true)}
disabled={!canManage || working}
/>
<Button onClick={() => setFreezeOpen(true)} disabled={!canManage || working}>
<Snowflake size={16} /> Freeze
</Button>
@@ -485,6 +496,17 @@ export default function DatasourcesPage({
}
}}
/>
<GovernanceDialog
open={governanceOpen}
settings={settings}
datasource={selectedDatasource}
onClose={() => setGovernanceOpen(false)}
onSaved={async (updated) => {
setGovernanceOpen(false);
setSuccess(`Updated governance for ${updated.name}.`);
await reload(updated.ref);
}}
/>
<Dialog
open={freezeOpen}
title="Freeze datasource state"
@@ -548,6 +570,26 @@ function DatasourceDetail({
{datasource.description ? (
<div className="datasources-description">{datasource.description}</div>
) : null}
<section className="datasources-detail-section">
<div className="datasources-section-heading">
<span><ShieldCheck size={16} /> Governance</span>
<StatusBadge
status={datasource.governance.publication_state}
label={datasource.governance.publication_state}
/>
</div>
<div className="datasources-key-values">
<span><small>Authority</small><strong>{readableToken(datasource.governance.authority_mode)}</strong></span>
<span><small>Classification</small><strong>{datasource.governance.classification}</strong></span>
<span><small>Owner</small><strong>{datasource.governance.owner_ref || "Not assigned"}</strong></span>
<span><small>Steward</small><strong>{datasource.governance.steward_ref || "Not assigned"}</strong></span>
<span><small>Responsible organization</small><strong>{datasource.governance.responsible_organization_ref || "Not assigned"}</strong></span>
<span><small>Purposes</small><strong>{datasource.governance.purposes.join(", ") || "Not declared"}</strong></span>
</div>
{datasource.governance.semantic_definition ? (
<p className="datasources-dialog-copy">{datasource.governance.semantic_definition}</p>
) : null}
</section>
<section className="datasources-detail-section">
<div className="datasources-section-heading">
<span><Eye size={16} /> Preview</span>
@@ -684,6 +726,173 @@ function OriginDetail({ origin }: { origin: DatasourceOrigin }) {
);
}
function GovernanceDialog({
open,
settings,
datasource,
onClose,
onSaved
}: {
open: boolean;
settings: ApiSettings;
datasource: Datasource | null;
onClose: () => void;
onSaved: (datasource: Datasource) => void | Promise<void>;
}) {
const [draft, setDraft] = useState<DatasourceGovernance | null>(null);
const [freshness, setFreshness] = useState("{}");
const [quality, setQuality] = useState("{}");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open || !datasource) return;
setDraft(structuredClone(datasource.governance));
setFreshness(JSON.stringify(datasource.governance.freshness_policy, null, 2));
setQuality(JSON.stringify(datasource.governance.quality_policy, null, 2));
setError("");
}, [datasource, open]);
const save = async () => {
if (!datasource || !draft) return;
setBusy(true);
setError("");
try {
const updated = await updateDatasourceGovernance(settings, datasource.ref, {
...draft,
freshness_policy: parseObject(freshness, "Freshness policy"),
quality_policy: parseObject(quality, "Quality policy")
});
await onSaved(updated);
} catch (saveError) {
setError(apiErrorMessage(saveError));
} finally {
setBusy(false);
}
};
const setValue = <K extends keyof DatasourceGovernance>(
key: K,
value: DatasourceGovernance[K]
) => setDraft((current) => current ? { ...current, [key]: value } : current);
return (
<Dialog
open={open}
title="Datasource governance"
className="datasources-governance-dialog"
onClose={() => { if (!busy) onClose(); }}
footer={(
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={() => void save()} disabled={!draft || busy}>
Save governance
</Button>
</>
)}
>
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
{draft ? (
<div className="datasources-dialog-fields">
<div className="datasources-dialog-grid">
<FormField label="Authority mode">
<select
value={draft.authority_mode}
onChange={(event) => setValue("authority_mode", event.target.value as DatasourceGovernance["authority_mode"])}
>
{[
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
"governance_overlay",
"linked_reference"
].map((value) => <option key={value} value={value}>{readableToken(value)}</option>)}
</select>
</FormField>
<FormField label="Authoritative source">
<input value={draft.authoritative_source_ref ?? ""} onChange={(event) => setValue("authoritative_source_ref", event.target.value || null)} />
</FormField>
<FormField label="Classification">
<input value={draft.classification} onChange={(event) => setValue("classification", event.target.value)} />
</FormField>
<FormField label="Publication state">
<input value={draft.publication_state} onChange={(event) => setValue("publication_state", event.target.value)} />
</FormField>
<FormField label="Owner reference">
<input value={draft.owner_ref ?? ""} onChange={(event) => setValue("owner_ref", event.target.value || null)} />
</FormField>
<FormField label="Steward reference">
<input value={draft.steward_ref ?? ""} onChange={(event) => setValue("steward_ref", event.target.value || null)} />
</FormField>
<FormField label="Responsible organization">
<input value={draft.responsible_organization_ref ?? ""} onChange={(event) => setValue("responsible_organization_ref", event.target.value || null)} />
</FormField>
<FormField label="Responsible function">
<input value={draft.responsible_function_ref ?? ""} onChange={(event) => setValue("responsible_function_ref", event.target.value || null)} />
</FormField>
<FormField label="Schema owner">
<input value={draft.schema_owner_ref ?? ""} onChange={(event) => setValue("schema_owner_ref", event.target.value || null)} />
</FormField>
<FormField label="Privacy profile">
<input value={draft.privacy_profile_ref ?? ""} onChange={(event) => setValue("privacy_profile_ref", event.target.value || null)} />
</FormField>
<FormField label="Retention policy">
<input value={draft.retention_policy_ref ?? ""} onChange={(event) => setValue("retention_policy_ref", event.target.value || null)} />
</FormField>
<FormField label="Transfer agreement">
<input value={draft.transfer_agreement_ref ?? ""} onChange={(event) => setValue("transfer_agreement_ref", event.target.value || null)} />
</FormField>
<FormField label="Correction procedure">
<input value={draft.correction_procedure_ref ?? ""} onChange={(event) => setValue("correction_procedure_ref", event.target.value || null)} />
</FormField>
</div>
<FormField label="Semantic definition">
<textarea value={draft.semantic_definition ?? ""} onChange={(event) => setValue("semantic_definition", event.target.value || null)} />
</FormField>
<div className="datasources-dialog-grid">
<GovernanceListField label="Purposes" values={draft.purposes} onChange={(values) => setValue("purposes", values)} />
<GovernanceListField label="Legal basis references" values={draft.legal_basis_refs} onChange={(values) => setValue("legal_basis_refs", values)} />
<GovernanceListField label="Official keys" values={draft.official_keys} onChange={(values) => setValue("official_keys", values)} />
<GovernanceListField label="Legal hold references" values={draft.hold_refs} onChange={(values) => setValue("hold_refs", values)} />
<GovernanceListField label="Affected services and processes" values={draft.affected_refs} onChange={(values) => setValue("affected_refs", values)} />
<GovernanceListField label="Dependent flows, reports, controls and decisions" values={draft.dependency_refs} onChange={(values) => setValue("dependency_refs", values)} />
<GovernanceListField label="Known limits" values={draft.known_limits} onChange={(values) => setValue("known_limits", values)} />
</div>
<div className="datasources-dialog-grid">
<FormField label="Freshness policy (JSON)">
<textarea value={freshness} onChange={(event) => setFreshness(event.target.value)} spellCheck={false} />
</FormField>
<FormField label="Quality policy (JSON)">
<textarea value={quality} onChange={(event) => setQuality(event.target.value)} spellCheck={false} />
</FormField>
</div>
</div>
) : null}
</Dialog>
);
}
function GovernanceListField({
label,
values,
onChange
}: {
label: string;
values: string[];
onChange: (values: string[]) => void;
}) {
return (
<FormField label={label}>
<textarea
value={values.join("\n")}
onChange={(event) => onChange(splitLines(event.target.value))}
placeholder="One reference or value per line"
/>
</FormField>
);
}
function AddDatasourceDialog({
open,
settings,
@@ -1080,6 +1289,20 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function parseObject(value: string, label: string): Record<string, unknown> {
const parsed: unknown = JSON.parse(value || "{}");
if (!isRecord(parsed)) throw new Error(`${label} must be a JSON object.`);
return parsed;
}
function splitLines(value: string): string[] {
return [...new Set(value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean))];
}
function readableToken(value: string): string {
return value.replace(/_/g, " ").replace(/^./, (first: string) => first.toUpperCase());
}
function displayValue(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "object") return JSON.stringify(value);