feat(datasources): govern approvals and retention
Module Package Release / publish-packages (push) Successful in 11s
Module Package Release / publish-packages (push) Successful in 11s
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
Plus,
|
||||
Snowflake,
|
||||
ShieldCheck,
|
||||
ShieldQuestion,
|
||||
Trash2,
|
||||
Upload
|
||||
} from "lucide-react";
|
||||
@@ -49,14 +50,18 @@ import { FormGrid, DialogSection, ActionToolbar,
|
||||
type AuthInfo
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
applyDatasourceRetention,
|
||||
createDatasourceStage,
|
||||
decideDatasourceStage,
|
||||
freezeDatasource,
|
||||
listDatasourceMaterializations,
|
||||
listDatasourceOrigins,
|
||||
listDatasourceStages,
|
||||
listDatasources,
|
||||
previewDatasource,
|
||||
previewDatasourceRetention,
|
||||
promoteDatasourceStage,
|
||||
prepareDatasourceRefresh,
|
||||
refreshDatasource,
|
||||
registerDatasourceOrigin,
|
||||
retireDatasource,
|
||||
@@ -66,6 +71,7 @@ import {
|
||||
type DatasourceMaterialization,
|
||||
type DatasourceOrigin,
|
||||
type DatasourcePreview,
|
||||
type DatasourceRetentionPlan,
|
||||
type DatasourceSchemaChange,
|
||||
type DatasourceStage,
|
||||
type DatasourceValidationDiagnostic
|
||||
@@ -113,12 +119,17 @@ export default function DatasourcesPage({
|
||||
const [retireOpen, setRetireOpen] = useState(false);
|
||||
const [promoteOpen, setPromoteOpen] = useState(false);
|
||||
const [governanceOpen, setGovernanceOpen] = useState(false);
|
||||
const [decisionOpen, setDecisionOpen] = useState(false);
|
||||
const [retentionOpen, setRetentionOpen] = useState(false);
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const canManage = hasScope(auth, "datasources:source:write")
|
||||
|| hasScope(auth, "datasources:source:admin");
|
||||
const canStage = hasScope(auth, "datasources:stage:write")
|
||||
|| hasScope(auth, "datasources:source:admin");
|
||||
const canApprove = hasScope(auth, "datasources:stage:approve")
|
||||
|| hasScope(auth, "datasources:source:admin");
|
||||
const canAdmin = hasScope(auth, "datasources:source:admin");
|
||||
|
||||
const reload = useCallback(async (preferredDatasourceRef?: string) => {
|
||||
setLoading(true);
|
||||
@@ -228,6 +239,14 @@ export default function DatasourcesPage({
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
if (selectedDatasource.governance.approval_policy.required === true) {
|
||||
const stage = await prepareDatasourceRefresh(settings, selectedDatasource.ref);
|
||||
await reload(selectedDatasource.ref);
|
||||
setSelectedStageRef(stage.ref);
|
||||
setView("staging");
|
||||
setSuccess(`Prepared refresh stage ${stage.name}. It must be approved before promotion.`);
|
||||
return;
|
||||
}
|
||||
const result = await refreshDatasource(settings, selectedDatasource.ref);
|
||||
setSuccess(`Refreshed ${result.datasource.name} as revision ${result.materialization.revision}.`);
|
||||
await reload(result.datasource.ref);
|
||||
@@ -425,7 +444,12 @@ export default function DatasourcesPage({
|
||||
primaryActions={<>
|
||||
{view === "catalogue" && selectedDatasource?.mode === "cached" ? (
|
||||
<Button onClick={() => void refreshSelected()} disabled={!canManage || working} disabledReason={working ? DATASOURCES_I18N.working : !canManage ? DATASOURCES_I18N.manageReason : undefined}>
|
||||
<Download size={16} /> Refresh
|
||||
<Download size={16} /> {selectedDatasource.governance.approval_policy.required === true ? "Stage refresh" : "Refresh"}
|
||||
</Button>
|
||||
) : null}
|
||||
{view === "catalogue" && canAdmin ? (
|
||||
<Button onClick={() => setRetentionOpen(true)} disabled={working}>
|
||||
<Archive size={16} /> Retention
|
||||
</Button>
|
||||
) : null}
|
||||
{view === "catalogue" && selectedDatasource ? (
|
||||
@@ -452,6 +476,16 @@ export default function DatasourcesPage({
|
||||
<Upload size={16} /> Promote
|
||||
</Button>
|
||||
) : null}
|
||||
{view === "staging" && selectedStage?.state === "awaiting_approval" ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setDecisionOpen(true)}
|
||||
disabled={!canApprove || working}
|
||||
disabledReason={working ? DATASOURCES_I18N.working : !canApprove ? "Datasource approval permission is required." : undefined}
|
||||
>
|
||||
<ShieldQuestion size={16} /> Decide
|
||||
</Button>
|
||||
) : null}
|
||||
{view === "origins" && selectedOrigin ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -563,6 +597,28 @@ export default function DatasourcesPage({
|
||||
await reload(updated.ref);
|
||||
}}
|
||||
/>
|
||||
<StageDecisionDialog
|
||||
open={decisionOpen}
|
||||
settings={settings}
|
||||
stage={selectedStage}
|
||||
onClose={() => setDecisionOpen(false)}
|
||||
onDecided={async (updated) => {
|
||||
setDecisionOpen(false);
|
||||
await reload();
|
||||
setSelectedStageRef(updated.ref);
|
||||
setSuccess(`Recorded ${updated.approval.state ?? "approval"} decision state for ${updated.name}.`);
|
||||
}}
|
||||
/>
|
||||
<RetentionDialog
|
||||
open={retentionOpen}
|
||||
settings={settings}
|
||||
onClose={() => setRetentionOpen(false)}
|
||||
onApplied={async (count) => {
|
||||
setRetentionOpen(false);
|
||||
setSuccess(`Applied retention to ${count} eligible target${count === 1 ? "" : "s"}.`);
|
||||
await reload(selectedDatasourceRef);
|
||||
}}
|
||||
/>
|
||||
<Dialog
|
||||
open={freezeOpen}
|
||||
title="Freeze datasource state"
|
||||
@@ -651,6 +707,8 @@ function DatasourceDetail({
|
||||
<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>
|
||||
<span><small>Approval gate</small><strong>{datasource.governance.approval_policy.required === true ? "Required" : "Not required"}</strong></span>
|
||||
<span><small>Retention</small><strong>{datasource.governance.retention_policy.enabled === true ? "Configured" : "Disabled"}</strong></span>
|
||||
</div>
|
||||
{datasource.governance.semantic_definition ? (
|
||||
<p className="datasources-dialog-copy">{datasource.governance.semantic_definition}</p>
|
||||
@@ -692,7 +750,9 @@ function DatasourceDetail({
|
||||
<td>{formatDate(item.created_at)}</td>
|
||||
<td>{formatNumber(item.row_count)}</td>
|
||||
<td>
|
||||
{item.frozen_at ? (
|
||||
{item.disposed_at ? (
|
||||
<StatusBadge status="disposed" label="payload disposed" />
|
||||
) : item.frozen_at ? (
|
||||
<StatusBadge
|
||||
status="frozen"
|
||||
label={item.frozen_label || "frozen"}
|
||||
@@ -773,6 +833,28 @@ function StageDetail({ stage }: { stage: DatasourceStage }) {
|
||||
</div>
|
||||
)}
|
||||
</ContentSection>
|
||||
<ContentSection>
|
||||
<ActionToolbar surface="section-header" className="datasources-section-heading">
|
||||
<span><ShieldQuestion size={16} /> Promotion approval</span>
|
||||
<StatusBadge status={stage.approval.state ?? "not_required"} label={readableToken(stage.approval.state ?? "not_required")} />
|
||||
</ActionToolbar>
|
||||
<div className="datasources-key-values">
|
||||
<span><small>Progress</small><strong>{stage.approval.approval_count ?? 0} / {stage.approval.required_approvals ?? 1}</strong></span>
|
||||
<span><small>Policy hash</small><strong>{shortFingerprint(stage.approval.policy_hash ?? "")}</strong></span>
|
||||
<span><small>Subject digest</small><strong>{shortFingerprint(stage.approval.subject_digest ?? "")}</strong></span>
|
||||
<span><small>Expires</small><strong>{formatDate(stage.approval.expires_at)}</strong></span>
|
||||
</div>
|
||||
{stage.approval.approvals?.length ? (
|
||||
<ul className="datasources-diagnostic-list">
|
||||
{stage.approval.approvals.map((item, index) => (
|
||||
<li key={`${item.actor_ref ?? "actor"}-${index}`}>
|
||||
<span>{item.actor_ref ?? "Unknown actor"} · {readableToken(item.decision ?? "decision")}</span>
|
||||
<small>{item.reason ?? "No reason recorded"} · {formatDate(item.decided_at)}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <div className="datasources-validation-ok">No approval decision has been recorded.</div>}
|
||||
</ContentSection>
|
||||
{schemaChanges.length ? (
|
||||
<ContentSection>
|
||||
<ActionToolbar surface="section-header" className="datasources-section-heading">
|
||||
@@ -797,6 +879,211 @@ function StageDetail({ stage }: { stage: DatasourceStage }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StageDecisionDialog({
|
||||
open,
|
||||
settings,
|
||||
stage,
|
||||
onClose,
|
||||
onDecided
|
||||
}: {
|
||||
open: boolean;
|
||||
settings: ApiSettings;
|
||||
stage: DatasourceStage | null;
|
||||
onClose: () => void;
|
||||
onDecided: (stage: DatasourceStage) => void | Promise<void>;
|
||||
}) {
|
||||
const [decision, setDecision] = useState<"approve" | "reject">("approve");
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDecision("approve");
|
||||
setReason("");
|
||||
setError("");
|
||||
}, [open, stage?.ref]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!stage?.approval.policy_hash || !stage.approval.subject_digest) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await decideDatasourceStage(settings, stage.ref, {
|
||||
decision,
|
||||
reason,
|
||||
expected_policy_hash: stage.approval.policy_hash,
|
||||
expected_subject_digest: stage.approval.subject_digest
|
||||
});
|
||||
await onDecided(updated);
|
||||
} catch (operationError) {
|
||||
setError(apiErrorMessage(operationError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title="Decide datasource promotion"
|
||||
onClose={() => !busy && onClose()}
|
||||
footer={<>
|
||||
<Button onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button
|
||||
variant={decision === "reject" ? "danger" : "primary"}
|
||||
onClick={() => void submit()}
|
||||
disabled={busy || reason.trim().length < 5 || !stage?.approval.policy_hash}
|
||||
>
|
||||
{decision === "approve" ? "Approve stage" : "Reject stage"}
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
<p className="datasources-dialog-copy">
|
||||
The decision is bound to this exact stage, validation result, policy hash, and your account authority.
|
||||
</p>
|
||||
<div className="datasources-key-values">
|
||||
<span><small>Stage</small><strong>{stage?.name ?? "Unavailable"}</strong></span>
|
||||
<span><small>Approval progress</small><strong>{stage?.approval.approval_count ?? 0} / {stage?.approval.required_approvals ?? 1}</strong></span>
|
||||
<span><small>Policy hash</small><strong>{shortFingerprint(stage?.approval.policy_hash ?? "")}</strong></span>
|
||||
<span><small>Expires</small><strong>{formatDate(stage?.approval.expires_at)}</strong></span>
|
||||
</div>
|
||||
<SegmentedControl<"approve" | "reject">
|
||||
ariaLabel="Approval decision"
|
||||
width="fill"
|
||||
size="equal"
|
||||
options={[
|
||||
{ id: "approve", label: "Approve" },
|
||||
{ id: "reject", label: "Reject" }
|
||||
]}
|
||||
value={decision}
|
||||
onChange={setDecision}
|
||||
/>
|
||||
<FormField label="Decision reason" interfaceId="datasources.action.approve" helpContextId="datasources.action.approve" helpModuleId="datasources">
|
||||
<textarea value={reason} onChange={(event) => setReason(event.target.value)} placeholder="Record what was reviewed and why this decision is justified." />
|
||||
</FormField>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RetentionDialog({
|
||||
open,
|
||||
settings,
|
||||
onClose,
|
||||
onApplied
|
||||
}: {
|
||||
open: boolean;
|
||||
settings: ApiSettings;
|
||||
onClose: () => void;
|
||||
onApplied: (count: number) => void | Promise<void>;
|
||||
}) {
|
||||
const [plan, setPlan] = useState<DatasourceRetentionPlan | null>(null);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loadPlan = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = await previewDatasourceRetention(settings);
|
||||
setPlan(next);
|
||||
setSelected([]);
|
||||
} catch (loadError) {
|
||||
setError(apiErrorMessage(loadError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void loadPlan();
|
||||
}, [loadPlan, open]);
|
||||
|
||||
const apply = async () => {
|
||||
if (!plan || !selected.length) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await applyDatasourceRetention(settings, plan, selected);
|
||||
setConfirming(false);
|
||||
await onApplied(result.disposed_refs.length);
|
||||
} catch (operationError) {
|
||||
setConfirming(false);
|
||||
setError(apiErrorMessage(operationError));
|
||||
await loadPlan();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (ref: string) => setSelected((current) =>
|
||||
current.includes(ref)
|
||||
? current.filter((item) => item !== ref)
|
||||
: [...current, ref]);
|
||||
|
||||
return <>
|
||||
<Dialog
|
||||
open={open}
|
||||
title="Datasource retention preview"
|
||||
size="wide"
|
||||
onClose={() => !busy && onClose()}
|
||||
footer={<>
|
||||
<Button onClick={() => void loadPlan()} disabled={loading || busy}>Reload preview</Button>
|
||||
<Button onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy || loading || !selected.length}>
|
||||
Apply selected dispositions
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
<p className="datasources-dialog-copy">
|
||||
The preview is read-only. Legal holds, current materializations, pending approvals, and publication evidence remain blocked.
|
||||
</p>
|
||||
{plan ? (
|
||||
<div className="datasources-key-values">
|
||||
<span><small>As of</small><strong>{formatDate(plan.as_of)}</strong></span>
|
||||
<span><small>Plan hash</small><strong>{shortFingerprint(plan.plan_hash)}</strong></span>
|
||||
<span><small>Due targets</small><strong>{plan.candidates.length}</strong></span>
|
||||
<span><small>Selected</small><strong>{selected.length}</strong></span>
|
||||
</div>
|
||||
) : null}
|
||||
<LoadingFrame loading={loading}>
|
||||
<div className="datasources-table-scroll">
|
||||
<table>
|
||||
<thead><tr><th className="datasources-retention-select-cell" aria-label="Select" /><th>Target</th><th>Disposition</th><th>Eligible since</th><th>Policy / blockers</th></tr></thead>
|
||||
<tbody>
|
||||
{plan?.candidates.map((candidate) => (
|
||||
<tr key={candidate.ref}>
|
||||
<td className="datasources-retention-select-cell"><input type="checkbox" aria-label={`Select ${candidate.ref}`} checked={selected.includes(candidate.ref)} disabled={!candidate.eligible} onChange={() => toggle(candidate.ref)} /></td>
|
||||
<td><strong>{candidate.ref}</strong><small>{candidate.datasource_ref ?? "Unpromoted stage"}</small></td>
|
||||
<td>{readableToken(candidate.disposition)}</td>
|
||||
<td>{formatDate(candidate.eligible_at)}</td>
|
||||
<td>{candidate.blockers.length ? candidate.blockers.map(readableToken).join(", ") : `v${candidate.policy_version} · eligible`}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!plan?.candidates.length ? <tr><td colSpan={5}>No retention targets are due.</td></tr> : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={confirming}
|
||||
title="Apply datasource retention"
|
||||
message={`Dispose ${selected.length} selected target${selected.length === 1 ? "" : "s"}? Payload rows or transient stages will be removed; lifecycle evidence remains.`}
|
||||
confirmLabel="Apply retention"
|
||||
tone="danger"
|
||||
busy={busy}
|
||||
onCancel={() => setConfirming(false)}
|
||||
onConfirm={() => void apply()}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
function ValidationDiagnosticList({
|
||||
diagnostics
|
||||
}: {
|
||||
@@ -900,6 +1187,8 @@ function GovernanceDialog({
|
||||
const [freshness, setFreshness] = useState("{}");
|
||||
const [quality, setQuality] = useState("{}");
|
||||
const [visibility, setVisibility] = useState("{}");
|
||||
const [approval, setApproval] = useState("{}");
|
||||
const [retention, setRetention] = useState("{}");
|
||||
const [baselineKey, setBaselineKey] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -911,15 +1200,19 @@ function GovernanceDialog({
|
||||
const nextFreshness = JSON.stringify(datasource.governance.freshness_policy, null, 2);
|
||||
const nextQuality = JSON.stringify(datasource.governance.quality_policy, null, 2);
|
||||
const nextVisibility = JSON.stringify(datasource.governance.visibility_policy ?? {}, null, 2);
|
||||
const nextApproval = JSON.stringify(datasource.governance.approval_policy ?? {}, null, 2);
|
||||
const nextRetention = JSON.stringify(datasource.governance.retention_policy ?? {}, null, 2);
|
||||
setDraft(nextDraft);
|
||||
setFreshness(nextFreshness);
|
||||
setQuality(nextQuality);
|
||||
setVisibility(nextVisibility);
|
||||
setBaselineKey(JSON.stringify({ draft: nextDraft, freshness: nextFreshness, quality: nextQuality, visibility: nextVisibility }));
|
||||
setApproval(nextApproval);
|
||||
setRetention(nextRetention);
|
||||
setBaselineKey(JSON.stringify({ draft: nextDraft, freshness: nextFreshness, quality: nextQuality, visibility: nextVisibility, approval: nextApproval, retention: nextRetention }));
|
||||
setError("");
|
||||
}, [datasource, open]);
|
||||
|
||||
const dirty = Boolean(open && draft && JSON.stringify({ draft, freshness, quality, visibility }) !== baselineKey);
|
||||
const dirty = Boolean(open && draft && JSON.stringify({ draft, freshness, quality, visibility, approval, retention }) !== baselineKey);
|
||||
|
||||
const save = async (): Promise<boolean> => {
|
||||
if (!datasource || !draft) return false;
|
||||
@@ -930,7 +1223,9 @@ function GovernanceDialog({
|
||||
...draft,
|
||||
freshness_policy: parseObject(freshness, "Freshness policy"),
|
||||
quality_policy: parseObject(quality, "Quality policy"),
|
||||
visibility_policy: parseObject(visibility, "Visibility policy")
|
||||
visibility_policy: parseObject(visibility, "Visibility policy"),
|
||||
approval_policy: parseObject(approval, "Approval policy"),
|
||||
retention_policy: parseObject(retention, "Retention policy")
|
||||
});
|
||||
await onSaved(updated);
|
||||
return true;
|
||||
@@ -1058,6 +1353,14 @@ function GovernanceDialog({
|
||||
<FormField label="Quality policy (JSON)" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
|
||||
<textarea value={quality} onChange={(event) => setQuality(event.target.value)} spellCheck={false} />
|
||||
</FormField>
|
||||
<FormField label="Approval policy (JSON)" interfaceId="datasources.field.approval-policy" helpContextId="datasources.field.approval-policy" helpModuleId="datasources" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
|
||||
<textarea value={approval} onChange={(event) => setApproval(event.target.value)} spellCheck={false} />
|
||||
<small>Configure a version, required approvals, separation of duties, and optional expiry.</small>
|
||||
</FormField>
|
||||
<FormField label="Retention policy (JSON)" interfaceId="datasources.field.retention-policy-contract" helpContextId="datasources.field.retention-policy-contract" helpModuleId="datasources" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
|
||||
<textarea value={retention} onChange={(event) => setRetention(event.target.value)} spellCheck={false} />
|
||||
<small>Configure explicit stage, materialization, and frozen-evidence durations; execution always starts with a preview.</small>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</DialogSection>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user