Files
govoplan-datasources/webui/src/features/datasources/DatasourcesPage.tsx
T
zemion 9d067f1bad
Module Package Release / publish-packages (push) Successful in 12s
fix(datasources): preserve governed CSV originals and fresh detail state
Release v0.1.26. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:19:38 +02:00

1990 lines
80 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from "react";
import {
Archive,
Database,
DatabaseZap,
Download,
Eye,
Layers3,
Pencil,
Plus,
Snowflake,
ShieldCheck,
ShieldQuestion,
Trash2,
Upload
} from "lucide-react";
import { FormGrid, DialogSection, ActionToolbar,
ActionBlockerHint,
Button,
ConfirmDialog,
ContentSection,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FilterBar,
FormField,
IconButton,
LoadingFrame,
MetricCard,
MetricGrid,
SegmentedControl,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
WorkspaceActionBar,
WorkspaceFrame,
WorkspaceLayout,
hasScope,
authAuthorityKey,
isApiError,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
applyDatasourceRetention,
createDatasourceStage,
decideDatasourceStage,
freezeDatasource,
listDatasourceMaterializations,
listDatasourceOrigins,
listDatasourceStages,
listDatasources,
previewDatasource,
previewDatasourceRetention,
promoteDatasourceStage,
prepareDatasourceRefresh,
refreshDatasource,
registerDatasourceOrigin,
retireDatasource,
updateDatasourceGovernance,
type Datasource,
type DatasourceGovernance,
type DatasourceMaterialization,
type DatasourceOrigin,
type DatasourcePreview,
type DatasourceRetentionPlan,
type DatasourceSchemaChange,
type DatasourceStage,
type DatasourceValidationDiagnostic
} from "../../api/datasources";
import {
DATASOURCE_FIELDS_DOCUMENTATION,
DATASOURCE_GOVERNANCE_DOCUMENTATION,
DATASOURCE_VISIBILITY_DOCUMENTATION,
DATASOURCES_DOCUMENTATION,
DATASOURCES_I18N
} from "./interfacePatterns";
type CatalogueView = "catalogue" | "staging" | "origins";
type AddKind = "upload" | "origin";
type UploadFormat = "json" | "csv";
export default function DatasourcesPage({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const [view, setView] = useState<CatalogueView>("catalogue");
const [loadedDatasources, setDatasources] = useState<Datasource[]>([]);
const [loadedStages, setStages] = useState<DatasourceStage[]>([]);
const [loadedOrigins, setOrigins] = useState<DatasourceOrigin[]>([]);
const [loadedOriginsAvailable, setOriginsAvailable] = useState(false);
const [selectedDatasourceRef, setSelectedDatasourceRef] = useState(
initialDatasourceRef
);
const [selectedStageRef, setSelectedStageRef] = useState("");
const [selectedOriginRef, setSelectedOriginRef] = useState("");
const [preview, setPreview] = useState<DatasourcePreview | null>(null);
const [materializations, setMaterializations] = useState<DatasourceMaterialization[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [detailRevision, setDetailRevision] = useState(0);
const authorityKey = authAuthorityKey(auth, settings);
const authority = useRef(authorityKey);
authority.current = authorityKey;
const authorityEpoch = useRef({ key: authorityKey, revision: 0 });
if (authorityEpoch.current.key !== authorityKey) {
authorityEpoch.current = { key: authorityKey, revision: authorityEpoch.current.revision + 1 };
}
const dialogScope = authorityEpoch.current.revision;
const isCurrentAuthority = () => authority.current === authorityKey
&& authorityEpoch.current.revision === dialogScope;
const [catalogueAuthority, setCatalogueAuthority] = useState<number | null>(null);
const catalogueReady = catalogueAuthority === dialogScope;
const datasources = catalogueReady ? loadedDatasources : [];
const stages = catalogueReady ? loadedStages : [];
const origins = catalogueReady ? loadedOrigins : [];
const originsAvailable = catalogueReady && loadedOriginsAvailable;
const detailScope = useMemo(() => ({}), [selectedDatasourceRef, dialogScope, detailRevision]);
const currentDetailScope = useRef(detailScope);
currentDetailScope.current = detailScope;
const [detailResponseScope, setDetailResponseScope] = useState<object | null>(null);
const reloadRequestId = useRef(0);
const [working, setWorking] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [addOpen, setAddOpen] = useState(false);
const [freezeOpen, setFreezeOpen] = useState(false);
const [freezeLabel, setFreezeLabel] = useState("");
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) => {
// An old mutation callback can invoke this closure AFTER authority changed.
// Compare the closure's scope before any request, state write or generation bump.
if (!isCurrentAuthority()) return;
const requestId = ++reloadRequestId.current;
const isCurrent = () => requestId === reloadRequestId.current
&& isCurrentAuthority();
setLoading(true);
setError("");
try {
const [nextDatasources, nextStages, originCatalogue] = await Promise.all([
listDatasources(settings),
listDatasourceStages(settings),
listDatasourceOrigins(settings)
]);
if (!isCurrent()) return;
setCatalogueAuthority(dialogScope);
setDatasources(nextDatasources);
setStages(nextStages);
setOrigins(originCatalogue.origins);
setOriginsAvailable(originCatalogue.available);
// Reload also invalidates history/preview when the selected reference did
// not change (refresh, freeze, promotion, governance and manual reload).
setDetailRevision((current) => current + 1);
setSelectedDatasourceRef((current) => {
const preferred = preferredDatasourceRef || current;
return nextDatasources.some((item) => item.ref === preferred)
? preferred
: nextDatasources[0]?.ref ?? "";
});
setSelectedStageRef((current) => nextStages.some((item) => item.ref === current)
? current
: nextStages[0]?.ref ?? "");
setSelectedOriginRef((current) => originCatalogue.origins.some((item) => item.ref === current)
? current
: originCatalogue.origins[0]?.ref ?? "");
} catch (loadError) {
if (isCurrent()) setError(apiErrorMessage(loadError));
} finally {
if (isCurrent()) setLoading(false);
}
}, [dialogScope]);
useEffect(() => {
authority.current = authorityKey;
setWorking(false);
setSuccess("");
// Confirmations and their input must not follow a selection into a new
// authority context. Equivalent session refreshes keep this effect stable.
setAddOpen(false);
setFreezeOpen(false);
setFreezeLabel("");
setRetireOpen(false);
setPromoteOpen(false);
setGovernanceOpen(false);
setDecisionOpen(false);
setRetentionOpen(false);
void reload();
return () => {
reloadRequestId.current += 1;
if (authority.current === authorityKey) authority.current = "";
};
}, [reload]);
useEffect(() => {
if (!selectedDatasourceRef || loading || !catalogueReady) {
setPreview(null);
setMaterializations([]);
setDetailLoading(false);
return;
}
let cancelled = false;
setDetailLoading(true);
Promise.allSettled([
previewDatasource(settings, selectedDatasourceRef),
listDatasourceMaterializations(settings, selectedDatasourceRef)
]).then(([previewResult, materializationResult]) => {
if (cancelled || currentDetailScope.current !== detailScope) return;
setDetailResponseScope(detailScope);
if (previewResult.status === "fulfilled") {
setPreview(previewResult.value);
} else {
setPreview(null);
setError(apiErrorMessage(previewResult.reason));
}
setMaterializations(
materializationResult.status === "fulfilled"
? materializationResult.value
: []
);
setDetailLoading(false);
});
return () => {
cancelled = true;
};
}, [selectedDatasourceRef, detailScope, loading, catalogueReady]);
const selectedDatasource = datasources.find((item) => item.ref === selectedDatasourceRef) ?? null;
const selectedStage = stages.find((item) => item.ref === selectedStageRef) ?? null;
const selectedOrigin = origins.find((item) => item.ref === selectedOriginRef) ?? null;
const visibleDatasources = useMemo(
() => filterItems(datasources, search, (item) =>
`${item.name} ${item.source_name} ${item.description ?? ""} ${item.mode} ${item.kind}`),
[datasources, search]
);
const visibleStages = useMemo(
() => filterItems(stages, search, (item) =>
`${item.name} ${item.source_name} ${item.state} ${item.mode}`),
[stages, search]
);
const visibleOrigins = useMemo(
() => filterItems(origins, search, (item) =>
`${item.name} ${item.source_name} ${item.provider} ${item.kind}`),
[origins, search]
);
const promoteStage = async () => {
if (!isCurrentAuthority() || !selectedStage || selectedStage.state !== "ready") return;
setWorking(true);
setError("");
try {
const result = await promoteDatasourceStage(settings, selectedStage.ref);
if (!isCurrentAuthority()) return;
setSuccess(`Promoted ${result.datasource.name} as revision ${result.materialization.revision}.`);
setView("catalogue");
await reload(result.datasource.ref);
} catch (operationError) {
if (isCurrentAuthority()) setError(apiErrorMessage(operationError));
} finally {
if (isCurrentAuthority()) setWorking(false);
}
};
const refreshSelected = async () => {
if (!isCurrentAuthority() || !selectedDatasource) return;
setWorking(true);
setError("");
try {
if (selectedDatasource.governance.approval_policy.required === true) {
const stage = await prepareDatasourceRefresh(settings, selectedDatasource.ref);
if (!isCurrentAuthority()) return;
await reload(selectedDatasource.ref);
if (!isCurrentAuthority()) return;
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);
if (!isCurrentAuthority()) return;
setSuccess(`Refreshed ${result.datasource.name} as revision ${result.materialization.revision}.`);
await reload(result.datasource.ref);
} catch (operationError) {
if (isCurrentAuthority()) setError(apiErrorMessage(operationError));
} finally {
if (isCurrentAuthority()) setWorking(false);
}
};
const freezeSelected = async (): Promise<boolean> => {
if (!isCurrentAuthority() || !selectedDatasource) return false;
setWorking(true);
setError("");
try {
const materialization = await freezeDatasource(
settings,
selectedDatasource.ref,
freezeLabel
);
if (!isCurrentAuthority()) return false;
setSuccess(`Created frozen revision ${materialization.revision}.`);
setFreezeOpen(false);
setFreezeLabel("");
await reload(selectedDatasource.ref);
return isCurrentAuthority();
} catch (operationError) {
if (isCurrentAuthority()) setError(apiErrorMessage(operationError));
return false;
} finally {
if (isCurrentAuthority()) setWorking(false);
}
};
const retireSelected = async () => {
if (!isCurrentAuthority() || !selectedDatasource) return;
setWorking(true);
setError("");
try {
await retireDatasource(settings, selectedDatasource.ref);
if (!isCurrentAuthority()) return;
setSuccess(`Retired ${selectedDatasource.name}.`);
setRetireOpen(false);
await reload();
} catch (operationError) {
if (isCurrentAuthority()) setError(apiErrorMessage(operationError));
} finally {
if (isCurrentAuthority()) setWorking(false);
}
};
useUnsavedDraftGuard({
dirty: Boolean(freezeOpen && freezeLabel.trim()),
onSave: freezeSelected,
onDiscard: () => {
setFreezeOpen(false);
setFreezeLabel("");
},
title: "i18n:govoplan-datasources.unsaved_freeze_title",
message: "i18n:govoplan-datasources.unsaved_freeze_message"
});
const closeFreeze = () => {
if (working) return;
if (freezeLabel.trim()) requestDiscard(() => setFreezeOpen(false));
else setFreezeOpen(false);
};
return (
<WorkspaceFrame as="main" height="viewport" surface="plain" className="datasources-page" label="Datasource workspace">
<WorkspaceActionBar
scope="workspace"
variant="collection"
refreshable
reloadAction={{ onReload: () => void reload(selectedDatasourceRef), loading: loading || working }}
contextActions={<strong>Data</strong>}
createAction={<IconButton
label="Add datasource or stage"
icon={<Plus size={17} />}
variant="primary"
onClick={() => setAddOpen(true)}
disabled={!canStage && !canManage}
disabledReason={!canStage && !canManage ? DATASOURCES_I18N.manageReason : undefined}
/>}
/>
<WorkspaceLayout
variant="split"
primarySize="compact"
surface="contained"
primaryScrollable={false}
contentScrollable={false}
primaryLabel="Datasource catalogue"
contentLabel="Datasource workspace"
contentClassName="datasources-workspace"
primary={<>
<div className="datasources-view-switch">
<SegmentedControl<CatalogueView>
ariaLabel="Datasource view"
size="equal"
width="fill"
options={[
{ id: "catalogue", label: "Catalogue" },
{ id: "staging", label: "Staging" },
{ id: "origins", label: "Origins" }
]}
value={view}
onChange={(next) => {
setView(next);
setSearch("");
setError("");
}}
/>
</div>
<FilterBar surface="panel">
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={`Search ${view}`}
aria-label={`Search ${view}`}
/>
</FilterBar>
<LoadingFrame
loading={loading}
className="datasources-list-frame"
>
<SelectionList variant="navigation" label="Datasources">
{view === "catalogue" ? visibleDatasources.map((item) => (
<SelectionListItem
key={item.ref}
selected={item.ref === selectedDatasourceRef}
onClick={() => setSelectedDatasourceRef(item.ref)}
>
<SelectionListItemContent title={item.name} description={`${item.source_name} · ${item.kind}`} />
<StatusBadge status={item.mode} label={item.mode} />
</SelectionListItem>
)) : null}
{view === "staging" ? visibleStages.map((item) => (
<SelectionListItem
key={item.ref}
selected={item.ref === selectedStageRef}
onClick={() => setSelectedStageRef(item.ref)}
>
<SelectionListItemContent title={item.name} description={`${item.source_name} · ${formatCount(item.row_count, "row")}`} />
<StatusBadge status={item.state} label={item.state} />
</SelectionListItem>
)) : null}
{view === "origins" ? visibleOrigins.map((item) => (
<SelectionListItem
key={item.ref}
selected={item.ref === selectedOriginRef}
onClick={() => setSelectedOriginRef(item.ref)}
>
<SelectionListItemContent title={item.name} description={`${item.provider} · ${item.kind}`} />
<StatusBadge status={item.shape} label={item.shape} />
</SelectionListItem>
)) : null}
{view === "catalogue" && !visibleDatasources.length ? (
<StatePanel size="compact" description="No datasources" />
) : null}
{view === "staging" && !visibleStages.length ? (
<StatePanel size="compact" description="No staged data" />
) : null}
{view === "origins" && !visibleOrigins.length ? (
<StatePanel size="compact" description={originsAvailable ? "No connector origins" : "Connectors unavailable"} />
) : null}
</SelectionList>
</LoadingFrame>
</>}
>
<WorkspaceActionBar
scope="detail-pane"
variant="detail"
className="datasources-workspace-toolbar"
contextActions={<span className="datasources-current-title">
{view === "catalogue" ? <DatabaseZap size={19} />
: view === "staging" ? <Layers3 size={19} />
: <Database size={19} />}
<span>
<strong>
{view === "catalogue"
? selectedDatasource?.name ?? "Catalogue"
: view === "staging"
? selectedStage?.name ?? "Staging"
: selectedOrigin?.name ?? "Connector origins"}
</strong>
<small>
{view === "catalogue"
? selectedDatasource?.source_name ?? "Governed data"
: view === "staging"
? selectedStage?.source_name ?? "Inspect before promotion"
: selectedOrigin?.provider ?? "External acquisition"}
</small>
</span>
</span>}
helpAction={<DocumentationHelpLink reference={DATASOURCES_DOCUMENTATION} />}
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} /> {selectedDatasource.governance.approval_policy.required === true ? "Stage refresh" : "Refresh"}
</Button>
) : null}
{view === "catalogue" && canAdmin ? (
<Button helpContextId="datasources.retention" helpModuleId="datasources" onClick={() => setRetentionOpen(true)} disabled={working}>
<Archive size={16} /> Retention
</Button>
) : null}
{view === "catalogue" && selectedDatasource ? (
<>
<IconButton
label="Edit datasource governance"
icon={<Pencil size={16} />}
onClick={() => setGovernanceOpen(true)}
disabled={!canManage || working}
disabledReason={working ? DATASOURCES_I18N.working : !canManage ? DATASOURCES_I18N.manageReason : undefined}
/>
<Button onClick={() => setFreezeOpen(true)} disabled={!canManage || working} disabledReason={working ? DATASOURCES_I18N.working : !canManage ? DATASOURCES_I18N.manageReason : undefined}>
<Snowflake size={16} /> Freeze
</Button>
</>
) : null}
{view === "staging" && selectedStage?.state === "ready" ? (
<Button
variant="primary"
onClick={() => setPromoteOpen(true)}
disabled={!canStage || working}
disabledReason={working ? DATASOURCES_I18N.working : !canStage ? DATASOURCES_I18N.stageReason : undefined}
>
<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"
onClick={() => setAddOpen(true)}
disabled={!canManage || working}
disabledReason={working ? DATASOURCES_I18N.working : !canManage ? DATASOURCES_I18N.manageReason : undefined}
>
<Plus size={16} /> Register
</Button>
) : null}
</>}
destructiveActions={view === "catalogue" && selectedDatasource ? (
<IconButton
label="Retire datasource"
icon={<Trash2 size={16} />}
variant="danger"
onClick={() => setRetireOpen(true)}
disabled={!canManage || working}
disabledReason={working ? DATASOURCES_I18N.working : !canManage ? DATASOURCES_I18N.manageReason : undefined}
/>
) : undefined}
/>
<div className="datasources-alerts">
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
) : null}
{success ? (
<DismissibleAlert tone="success" resetKey={success}>
{success}
</DismissibleAlert>
) : null}
{!canManage && !canStage ? <ActionBlockerHint
tone="info"
reason={{
summary: "Datasources are read-only",
details: DATASOURCES_I18N.manageReason,
requiredAction: DATASOURCES_I18N.permissionAction,
actor: DATASOURCES_I18N.permissionActor,
target: DATASOURCES_I18N.permissionDestination
}}
labels={{ requiredAction: DATASOURCES_I18N.requiredAction, actor: DATASOURCES_I18N.actor, target: DATASOURCES_I18N.destination }}
documentation={DATASOURCES_DOCUMENTATION}
/> : null}
</div>
<div className="datasources-content">
{view === "catalogue" ? (
selectedDatasource ? (
<DatasourceDetail
datasource={selectedDatasource}
preview={!loading && detailResponseScope === detailScope ? preview : null}
materializations={!loading && detailResponseScope === detailScope ? materializations : []}
loading={loading || detailLoading || detailResponseScope !== detailScope}
/>
) : <EmptyWorkspace icon={<DatabaseZap size={32} />} label="No datasource selected" />
) : null}
{view === "staging" ? (
selectedStage
? <StageDetail stage={selectedStage} />
: <EmptyWorkspace icon={<Layers3 size={32} />} label="No stage selected" />
) : null}
{view === "origins" ? (
selectedOrigin
? <OriginDetail origin={selectedOrigin} />
: <EmptyWorkspace icon={<Database size={32} />} label={
originsAvailable ? "No origin selected" : "Connectors unavailable"
} />
) : null}
</div>
{working ? <div className="datasources-working" role="status">Working...</div> : null}
</WorkspaceLayout>
<AddDatasourceDialog
key={`add:${dialogScope}`}
open={addOpen && catalogueReady}
settings={settings}
initialKind={view === "origins" ? "origin" : "upload"}
initialOrigin={view === "origins" ? selectedOrigin : null}
origins={origins}
originsAvailable={originsAvailable}
datasources={datasources}
canStage={canStage}
canManage={canManage}
onClose={() => setAddOpen(false)}
onCreated={async (result) => {
if (!isCurrentAuthority()) return;
setAddOpen(false);
if ("state" in result) {
setView("staging");
setSelectedStageRef(result.ref);
setSuccess(`Created stage ${result.name}. Review it before promotion.`);
await reload();
} else {
setView("catalogue");
setSuccess(`Registered ${result.name}.`);
await reload(result.ref);
}
}}
/>
<GovernanceDialog
key={`governance:${dialogScope}`}
open={governanceOpen && catalogueReady}
settings={settings}
datasource={selectedDatasource}
onClose={() => setGovernanceOpen(false)}
onSaved={async (updated) => {
if (!isCurrentAuthority()) return;
setGovernanceOpen(false);
setSuccess(`Updated governance for ${updated.name}.`);
await reload(updated.ref);
}}
/>
<StageDecisionDialog
key={`decision:${dialogScope}`}
open={decisionOpen && catalogueReady}
settings={settings}
stage={selectedStage}
onClose={() => setDecisionOpen(false)}
onDecided={async (updated) => {
if (!isCurrentAuthority()) return;
setDecisionOpen(false);
await reload();
if (!isCurrentAuthority()) return;
setSelectedStageRef(updated.ref);
setSuccess(`Recorded ${updated.approval.state ?? "approval"} decision state for ${updated.name}.`);
}}
/>
<RetentionDialog
key={`retention:${dialogScope}`}
open={retentionOpen && catalogueReady}
settings={settings}
onClose={() => setRetentionOpen(false)}
onApplied={async (count) => {
if (!isCurrentAuthority()) return;
setRetentionOpen(false);
setSuccess(`Applied retention to ${count} eligible target${count === 1 ? "" : "s"}.`);
await reload(selectedDatasourceRef);
}}
/>
<Dialog
open={freezeOpen && catalogueReady}
title="Freeze datasource state"
onClose={closeFreeze}
footer={(
<>
<Button onClick={closeFreeze} disabled={working} disabledReason={working ? DATASOURCES_I18N.working : undefined}>Cancel</Button>
<Button variant="primary" onClick={() => void freezeSelected()} disabled={working}>
<Snowflake size={16} /> Freeze
</Button>
</>
)}
>
<p className="datasources-dialog-copy">
Create an immutable, addressable state for reproducible runs and evidence.
</p>
<FormField label="Label" documentation={DATASOURCE_FIELDS_DOCUMENTATION}>
<input
value={freezeLabel}
onChange={(event) => setFreezeLabel(event.target.value)}
placeholder="Optional evidence label"
/>
</FormField>
</Dialog>
<ConfirmDialog
open={promoteOpen && catalogueReady}
title="i18n:govoplan-datasources.promote_title"
message="i18n:govoplan-datasources.promote_message"
confirmLabel="Promote"
busy={working}
onCancel={() => setPromoteOpen(false)}
onConfirm={() => {
setPromoteOpen(false);
void promoteStage();
}}
/>
<ConfirmDialog
open={retireOpen && catalogueReady}
title="Retire datasource"
message={`Retire ${selectedDatasource?.name ?? "this datasource"}? Existing materialization references remain in the database, but the datasource will no longer be available to new definitions.`}
confirmLabel="Retire"
tone="danger"
busy={working}
onCancel={() => setRetireOpen(false)}
onConfirm={() => void retireSelected()}
/>
</WorkspaceFrame>
);
}
function DatasourceDetail({
datasource,
preview,
materializations,
loading
}: {
datasource: Datasource;
preview: DatasourcePreview | null;
materializations: DatasourceMaterialization[];
loading: boolean;
}) {
return (
<>
<MetricGrid columns={5} density="compact" minimum="compact">
<MetricCard density="compact" label="Mode" value={datasource.mode} valueTitle={datasource.mode} />
<MetricCard density="compact" label="Rows" value={formatNumber(datasource.row_count)} />
<MetricCard density="compact" label="Fields" value={String(datasource.schema.length)} />
<MetricCard density="compact" label="Revisions" value={loading ? "…" : String(materializations.length)} />
<MetricCard density="compact" label="Updated" value={formatDate(datasource.updated_at)} />
</MetricGrid>
{datasource.description ? (
<div className="datasources-description">{datasource.description}</div>
) : null}
<ContentSection>
<ActionToolbar surface="section-header" className="datasources-section-heading">
<span><ShieldCheck size={16} /> Governance</span>
<StatusBadge
status={datasource.governance.publication_state}
label={datasource.governance.publication_state}
/>
</ActionToolbar>
<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>
<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>
) : null}
</ContentSection>
<ContentSection>
<ActionToolbar surface="section-header" className="datasources-section-heading">
<span><Eye size={16} /> Preview</span>
<small>{preview ? `${formatNumber(preview.total_rows)} total rows` : ""}</small>
</ActionToolbar>
{loading ? (
<div className="datasources-inline-loading">Loading preview...</div>
) : preview ? (
<PreviewTable datasource={datasource} rows={preview.rows} />
) : (
<StatePanel size="inline" description="No preview available" />
)}
</ContentSection>
<ContentSection>
<ActionToolbar surface="section-header" className="datasources-section-heading">
<span><Archive size={16} /> Materializations</span>
<small>Immutable revisions</small>
</ActionToolbar>
<div className="datasources-table-scroll">
<table>
<thead>
<tr>
<th>Revision</th>
<th>Created</th>
<th>Rows</th>
<th>State</th>
<th>Fingerprint</th>
</tr>
</thead>
<tbody>
{materializations.map((item) => (
<tr key={item.ref}>
<td>{item.revision}</td>
<td>{formatDate(item.created_at)}</td>
<td>{formatNumber(item.row_count)}</td>
<td>
{item.disposed_at ? (
<StatusBadge status="disposed" label="payload disposed" />
) : item.frozen_at ? (
<StatusBadge
status="frozen"
label={item.frozen_label || "frozen"}
/>
) : <StatusBadge status={item.state} label={item.state} />}
</td>
<td className="datasources-fingerprint" title={item.fingerprint}>
{shortFingerprint(item.fingerprint)}
</td>
</tr>
))}
{loading ? (
<tr><td colSpan={5}>Loading materializations...</td></tr>
) : !materializations.length ? (
<tr><td colSpan={5}>Live source without materializations</td></tr>
) : null}
</tbody>
</table>
</div>
</ContentSection>
<ContentSection>
<ActionToolbar surface="section-header" className="datasources-section-heading">
<span>Schema</span>
<small>Version {datasource.schema_version}</small>
</ActionToolbar>
<SchemaTable fields={datasource.schema} />
</ContentSection>
</>
);
}
function StageDetail({ stage }: { stage: DatasourceStage }) {
const errors = stage.validation.errors ?? [];
const warnings = stage.validation.warnings ?? [];
const schemaChanges = stage.validation.schema_change?.changes ?? [];
const schemaClassification = stage.validation.schema_change?.classification ?? "new";
return (
<>
<MetricGrid columns={5} density="compact" minimum="compact">
<MetricCard density="compact" label="State" value={stage.state} valueTitle={stage.state} />
<MetricCard density="compact" label="Mode" value={stage.mode} valueTitle={stage.mode} />
<MetricCard density="compact" label="Rows" value={formatNumber(stage.row_count)} />
<MetricCard density="compact" label="Fields" value={String(stage.schema.length)} />
<MetricCard density="compact" label="Created" value={formatDate(stage.created_at)} />
</MetricGrid>
<ContentSection>
<ActionToolbar surface="section-header" className="datasources-section-heading">
<span>Validation</span>
<StatusBadge
status={stage.validation.valid === false ? "invalid" : "ready"}
label={stage.validation.valid === false ? "Invalid" : "Ready"}
/>
</ActionToolbar>
<div className="datasources-key-values">
<span><small>Fingerprint</small><strong>{shortFingerprint(stage.fingerprint)}</strong></span>
<span><small>Target</small><strong>{stage.target_datasource_ref || "New datasource"}</strong></span>
<span><small>Promoted revision</small><strong>{stage.promoted_materialization_ref || "Not promoted"}</strong></span>
<span><small>Quality policy</small><strong>{stage.validation.policy_version || "Local default"}</strong></span>
<span><small>Policy hash</small><strong>{shortFingerprint(stage.validation.policy_hash || "")}</strong></span>
<span><small>Schema change</small><strong>{readableToken(schemaClassification)}</strong></span>
</div>
{errors.length || warnings.length ? (
<div className="datasources-validation-alerts">
{errors.length ? (
<DismissibleAlert tone="danger" compact dismissible={false}>
<strong>Promotion blockers</strong>
<ValidationDiagnosticList diagnostics={errors} />
</DismissibleAlert>
) : null}
{warnings.length ? (
<DismissibleAlert tone="warning" compact dismissible={false}>
<strong>Review warnings</strong>
<ValidationDiagnosticList diagnostics={warnings} />
</DismissibleAlert>
) : null}
</div>
) : (
<div className="datasources-validation-ok">
All configured quality rules passed and no blocking schema change was detected.
</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">
<span>Schema comparison</span>
<StatusBadge status={schemaClassification} label={readableToken(schemaClassification)} />
</ActionToolbar>
<ul className="datasources-schema-changes">
{schemaChanges.map((change, index) => (
<SchemaChangeItem key={`${change.code}-${change.field ?? index}`} change={change} />
))}
</ul>
</ContentSection>
) : null}
<ContentSection>
<ActionToolbar surface="section-header" className="datasources-section-heading">
<span>Detected schema</span>
<small>{stage.shape}</small>
</ActionToolbar>
<SchemaTable fields={stage.schema} />
</ContentSection>
</>
);
}
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
}: {
diagnostics: DatasourceValidationDiagnostic[];
}) {
return (
<ul className="datasources-diagnostic-list">
{diagnostics.map((diagnostic, index) => {
const details = [
diagnostic.rule_id ? `Rule ${diagnostic.rule_id}` : "",
diagnostic.affected_rows !== undefined ? `${diagnostic.affected_rows} affected row${diagnostic.affected_rows === 1 ? "" : "s"}` : "",
diagnostic.row_numbers?.length ? `Rows ${diagnostic.row_numbers.join(", ")}${diagnostic.row_numbers_truncated ? ", …" : ""}` : ""
].filter(Boolean);
return (
<li key={`${diagnostic.code}-${diagnostic.rule_id ?? index}`}>
<span>{diagnostic.message}</span>
{details.length ? <small>{details.join(" · ")}</small> : null}
</li>
);
})}
</ul>
);
}
function SchemaChangeItem({ change }: { change: DatasourceSchemaChange }) {
return (
<li>
<StatusBadge status={change.classification} label={readableToken(change.classification)} />
<span>
<strong>{change.message}</strong>
{change.field ? <small>{change.field}</small> : null}
</span>
</li>
);
}
function OriginDetail({ origin }: { origin: DatasourceOrigin }) {
return (
<>
<MetricGrid columns="auto" density="compact" minimum="compact">
<MetricCard density="compact" label="Provider" value={origin.provider} valueTitle={origin.provider} />
<MetricCard density="compact" label="Mode" value={readableToken(origin.source_mode)} />
<MetricCard density="compact" label="Health" value={readableToken(origin.health.status)} />
<MetricCard density="compact" label="Rows" value={formatNumber(origin.row_count)} />
<MetricCard density="compact" label="Fields" value={String(origin.schema.length)} />
<MetricCard density="compact" label="Updated" value={formatDate(origin.updated_at)} />
</MetricGrid>
{origin.description ? (
<div className="datasources-description">{origin.description}</div>
) : null}
<ContentSection>
<ActionToolbar surface="section-header" className="datasources-section-heading">
<span>Registration options</span>
<small>{origin.supported_modes.join(", ")}</small>
</ActionToolbar>
<div className="datasources-key-values">
<span><small>Origin reference</small><strong>{origin.ref}</strong></span>
<span><small>Kind</small><strong>{readableToken(origin.kind)}</strong></span>
<span><small>Shape</small><strong>{readableToken(origin.shape)}</strong></span>
<span><small>Fingerprint</small><strong>{shortFingerprint(origin.fingerprint)}</strong></span>
<span><small>Health</small><strong>{origin.health.summary}</strong></span>
<span><small>Pushdown</small><strong>{pushdownSummary(origin)}</strong></span>
</div>
</ContentSection>
<ContentSection>
<ActionToolbar surface="section-header" className="datasources-section-heading">
<span>Discovered schema</span>
<small>Version {origin.schema_version}</small>
</ActionToolbar>
<SchemaTable fields={origin.schema} />
</ContentSection>
</>
);
}
function pushdownSummary(origin: DatasourceOrigin): string {
const capabilities = [
origin.pushdown.projections ? "projections" : "",
origin.pushdown.pagination ? "pagination" : "",
...origin.pushdown.filters.map((item) => `filter:${item}`),
...origin.pushdown.aggregations.map((item) => `aggregate:${item}`),
...origin.pushdown.sorting.map((item) => `sort:${item}`)
].filter(Boolean);
return capabilities.join(", ") || "None declared";
}
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 [visibility, setVisibility] = useState("{}");
const [approval, setApproval] = useState("{}");
const [retention, setRetention] = useState("{}");
const [baselineKey, setBaselineKey] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { requestDiscard } = useUnsavedChanges();
useEffect(() => {
if (!open || !datasource) return;
const nextDraft = structuredClone(datasource.governance);
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);
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, approval, retention }) !== baselineKey);
const save = async (): Promise<boolean> => {
if (!datasource || !draft) return false;
setBusy(true);
setError("");
try {
const updated = await updateDatasourceGovernance(settings, datasource.ref, {
...draft,
freshness_policy: parseObject(freshness, "Freshness policy"),
quality_policy: parseObject(quality, "Quality policy"),
visibility_policy: parseObject(visibility, "Visibility policy"),
approval_policy: parseObject(approval, "Approval policy"),
retention_policy: parseObject(retention, "Retention policy")
});
await onSaved(updated);
return true;
} catch (saveError) {
setError(apiErrorMessage(saveError));
return false;
} finally {
setBusy(false);
}
};
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: onClose,
title: "i18n:govoplan-datasources.unsaved_governance_title",
message: "i18n:govoplan-datasources.unsaved_governance_message"
});
const close = () => {
if (busy) return;
if (dirty) requestDiscard(onClose);
else onClose();
};
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={close}
footer={(
<>
<Button onClick={close} disabled={busy} disabledReason={busy ? DATASOURCES_I18N.working : undefined}>Cancel</Button>
<Button variant="primary" onClick={() => void save()} disabled={!draft || busy} disabledReason={busy ? DATASOURCES_I18N.working : !draft ? DATASOURCES_I18N.incomplete : undefined}>
Save governance
</Button>
</>
)}
>
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
{draft ? (
<DialogSection className="datasources-dialog-fields">
<FormGrid columns={2} gap="small" collapseAt="narrow">
<FormField label="Authority mode" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
<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" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
<input value={draft.authoritative_source_ref ?? ""} onChange={(event) => setValue("authoritative_source_ref", event.target.value || null)} />
</FormField>
<FormField label="Classification" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
<input value={draft.classification} onChange={(event) => setValue("classification", event.target.value)} />
</FormField>
<FormField label="Publication state" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
<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" interfaceId="datasources.field.retention-policy" helpContextId="datasources.field.retention-policy" helpModuleId="datasources" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
<input value={draft.retention_policy_ref ?? ""} onChange={(event) => setValue("retention_policy_ref", event.target.value || null)} />
</FormField>
<FormField label="Access policy reference" interfaceId="datasources.field.access-policy" helpContextId="datasources.field.access-policy" helpModuleId="datasources" documentation={DATASOURCE_VISIBILITY_DOCUMENTATION}>
<input value={draft.access_policy_ref ?? ""} onChange={(event) => setValue("access_policy_ref", event.target.value || null)} placeholder="Optional Policy module target" />
</FormField>
<FormField label="Transfer agreement" interfaceId="datasources.field.transfer-agreement" helpContextId="datasources.field.transfer-agreement" helpModuleId="datasources" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
<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>
</FormGrid>
<FormField label="Semantic definition" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
<textarea value={draft.semantic_definition ?? ""} onChange={(event) => setValue("semantic_definition", event.target.value || null)} />
</FormField>
<FormGrid columns={2} gap="small" collapseAt="narrow">
<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" helpContextId="datasources.field.retention-policy-contract" 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)} />
</FormGrid>
<FormGrid columns={2} gap="small" collapseAt="narrow">
<FormField label="Visibility policy (JSON)" interfaceId="datasources.field.visibility-policy" helpContextId="datasources.field.visibility-policy" helpModuleId="datasources" documentation={DATASOURCE_VISIBILITY_DOCUMENTATION}>
<textarea value={visibility} onChange={(event) => setVisibility(event.target.value)} spellCheck={false} />
<small>Configure source and materialization ACLs, field redaction or omission, and principal-bound row filters.</small>
</FormField>
<FormField label="Freshness policy (JSON)" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
<textarea value={freshness} onChange={(event) => setFreshness(event.target.value)} spellCheck={false} />
</FormField>
<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}
</Dialog>
);
}
function GovernanceListField({
label,
helpContextId,
values,
onChange
}: {
label: string;
helpContextId?: string;
values: string[];
onChange: (values: string[]) => void;
}) {
return (
<FormField label={label} helpContextId={helpContextId} helpModuleId="datasources">
<textarea
value={values.join("\n")}
onChange={(event) => onChange(splitLines(event.target.value))}
placeholder="One reference or value per line"
/>
</FormField>
);
}
function AddDatasourceDialog({
open,
settings,
initialKind,
initialOrigin,
origins,
originsAvailable,
datasources,
canStage,
canManage,
onClose,
onCreated
}: {
open: boolean;
settings: ApiSettings;
initialKind: AddKind;
initialOrigin: DatasourceOrigin | null;
origins: DatasourceOrigin[];
originsAvailable: boolean;
datasources: Datasource[];
canStage: boolean;
canManage: boolean;
onClose: () => void;
onCreated: (result: DatasourceStage | Datasource) => void | Promise<void>;
}) {
const [kind, setKind] = useState<AddKind>(initialKind);
const [format, setFormat] = useState<UploadFormat>("csv");
const [mode, setMode] = useState<"live" | "cached" | "static">(
initialKind === "origin" ? "live" : "static"
);
const [originRef, setOriginRef] = useState(initialOrigin?.ref ?? "");
const [targetRef, setTargetRef] = useState("");
const [name, setName] = useState(initialOrigin?.name ?? "");
const [sourceName, setSourceName] = useState(initialOrigin?.source_name ?? "");
const [description, setDescription] = useState(initialOrigin?.description ?? "");
const [rowsText, setRowsText] = useState('[\n { "id": 1 }\n]');
const [csvText, setCsvText] = useState("");
const [delimiter, setDelimiter] = useState(";");
const [csvValueMode, setCsvValueMode] = useState<"text" | "legacy_typed">("text");
const [baselineKey, setBaselineKey] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { requestDiscard } = useUnsavedChanges();
useEffect(() => {
if (!open) return;
setKind(initialKind);
setFormat("csv");
setOriginRef(initialOrigin?.ref ?? "");
setName(initialOrigin?.name ?? "");
setSourceName(initialOrigin?.source_name ?? "");
setDescription(initialOrigin?.description ?? "");
setMode(initialKind === "origin" ? "live" : "static");
setTargetRef("");
setRowsText('[\n { "id": 1 }\n]');
setCsvText("");
setDelimiter(";");
setCsvValueMode("text");
setBaselineKey(addDatasourceDraftKey({
kind: initialKind,
format: "csv",
mode: initialKind === "origin" ? "live" : "static",
originRef: initialOrigin?.ref ?? "",
targetRef: "",
name: initialOrigin?.name ?? "",
sourceName: initialOrigin?.source_name ?? "",
description: initialOrigin?.description ?? "",
rowsText: '[\n { "id": 1 }\n]',
csvText: "",
delimiter: ";",
csvValueMode: "text"
}));
setError("");
}, [initialKind, initialOrigin, open]);
const selectedOrigin = origins.find((item) => item.ref === originRef) ?? null;
const dirty = Boolean(open && addDatasourceDraftKey({
kind,
format,
mode,
originRef,
targetRef,
name,
sourceName,
description,
rowsText,
csvText,
delimiter,
csvValueMode
}) !== baselineKey);
const chooseOrigin = (ref: string) => {
const origin = origins.find((item) => item.ref === ref);
setOriginRef(ref);
if (!origin) return;
setName(origin.name);
setSourceName(origin.source_name);
setDescription(origin.description ?? "");
setMode(origin.supported_modes.includes("live") ? "live" : "cached");
};
const chooseTarget = (ref: string) => {
setTargetRef(ref);
const target = datasources.find((item) => item.ref === ref);
if (!target) return;
setName(target.name);
setSourceName(target.source_name);
setDescription(target.description ?? "");
setMode(target.mode === "cached" ? "cached" : "static");
};
const create = async (): Promise<boolean> => {
setBusy(true);
setError("");
try {
if (kind === "origin") {
if (!selectedOrigin || (mode !== "live" && mode !== "cached")) {
throw new Error("Choose a connector origin and registration mode.");
}
const datasource = await registerDatasourceOrigin(settings, {
origin_ref: selectedOrigin.ref,
name,
source_name: sourceName,
description,
mode
});
await onCreated(datasource);
} else {
const common = {
name,
source_name: sourceName,
description,
mode: mode === "cached" ? "cached" as const : "static" as const,
target_datasource_ref: targetRef || null
};
const stage = format === "csv"
? await createDatasourceStage(settings, {
...common,
format: "csv",
csv_text: csvText,
delimiter,
csv_value_mode: csvValueMode
})
: await createDatasourceStage(settings, {
...common,
format: "json",
rows: parseRows(rowsText)
});
await onCreated(stage);
}
return true;
} catch (createError) {
setError(apiErrorMessage(createError));
return false;
} finally {
setBusy(false);
}
};
useUnsavedDraftGuard({
dirty,
onSave: create,
onDiscard: onClose,
title: "i18n:govoplan-datasources.unsaved_add_title",
message: "i18n:govoplan-datasources.unsaved_add_message"
});
const close = () => {
if (busy) return;
if (dirty) requestDiscard(onClose);
else onClose();
};
const loadFile = async (file: File | undefined) => {
if (!file) return;
try {
const text = await file.text();
const isJson = file.name.toLowerCase().endsWith(".json");
setFormat(isJson ? "json" : "csv");
if (isJson) setRowsText(text);
else setCsvText(text);
const baseName = file.name.replace(/\.[^.]+$/, "");
setName((current) => current || baseName);
setSourceName((current) => current || sourceNameFromFile(baseName));
} catch {
setError("The selected file could not be read.");
}
};
return (
<Dialog
open={open}
title="Add datasource"
className="datasources-add-dialog"
onClose={close}
footer={(
<>
<Button onClick={close} disabled={busy} disabledReason={busy ? DATASOURCES_I18N.working : undefined}>Cancel</Button>
<Button
variant="primary"
onClick={() => void create()}
disabled={
busy
|| !name.trim()
|| !sourceName.trim()
|| (kind === "upload" && !canStage)
|| (kind === "origin" && (!canManage || !originRef))
}
disabledReason={busy ? DATASOURCES_I18N.working : !name.trim() || !sourceName.trim() ? DATASOURCES_I18N.incomplete : kind === "upload" && !canStage ? DATASOURCES_I18N.stageReason : kind === "origin" && !canManage ? DATASOURCES_I18N.manageReason : kind === "origin" && !originRef ? DATASOURCES_I18N.incomplete : undefined}
>
{kind === "upload" ? <Upload size={16} /> : <Database size={16} />}
{kind === "upload" ? "Create stage" : "Register"}
</Button>
</>
)}
>
<DialogSection className="datasources-dialog-fields">
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>
) : null}
<SegmentedControl<AddKind>
ariaLabel="Datasource origin"
width="fill"
size="equal"
options={[
{ id: "upload", label: "Upload", disabled: !canStage },
{
id: "origin",
label: "Connector origin",
disabled: !canManage || !originsAvailable
}
]}
value={kind}
onChange={(next) => {
setKind(next);
setMode(next === "origin" ? "live" : "static");
setError("");
}}
/>
{kind === "origin" ? (
<FormField label="Connector origin" documentation={DATASOURCE_FIELDS_DOCUMENTATION}>
<select value={originRef} onChange={(event) => chooseOrigin(event.target.value)}>
<option value="">Choose an origin</option>
{origins.map((origin) => (
<option key={origin.ref} value={origin.ref}>
{origin.name} ({origin.provider})
</option>
))}
</select>
</FormField>
) : (
<>
<FormField label="Update existing datasource" documentation={DATASOURCE_FIELDS_DOCUMENTATION}>
<select value={targetRef} onChange={(event) => chooseTarget(event.target.value)}>
<option value="">Create a new datasource</option>
{datasources.filter((item) => item.mode !== "live" && item.shape === "tabular").map((item) => (
<option key={item.ref} value={item.ref}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="File">
<input
type="file"
accept=".csv,.tsv,.json,text/csv,application/json,text/plain"
onChange={(event) => void loadFile(event.target.files?.[0])}
/>
</FormField>
</>
)}
<FormGrid columns={2} gap="small" collapseAt="narrow">
<FormField label="Name" documentation={DATASOURCE_FIELDS_DOCUMENTATION}>
<input value={name} onChange={(event) => setName(event.target.value)} />
</FormField>
<FormField label="Datasource key" documentation={DATASOURCE_FIELDS_DOCUMENTATION}>
<input
value={sourceName}
onChange={(event) => setSourceName(event.target.value)}
pattern="[A-Za-z_][A-Za-z0-9_]*"
placeholder="monthly_cases"
disabled={Boolean(targetRef)}
/>
</FormField>
</FormGrid>
<FormField label="Description">
<input value={description} onChange={(event) => setDescription(event.target.value)} />
</FormField>
<FormField label="Mode" documentation={DATASOURCE_FIELDS_DOCUMENTATION}>
<SegmentedControl
ariaLabel="Datasource mode"
width="fill"
size="equal"
options={kind === "origin"
? [
{
id: "live",
label: "Live",
disabled: !selectedOrigin?.supported_modes.includes("live")
},
{
id: "cached",
label: "Cached",
disabled: !selectedOrigin?.supported_modes.includes("cached")
}
]
: [
{ id: "static", label: "Static" },
{ id: "cached", label: "Cached stage" }
]}
value={mode}
onChange={(next) => setMode(next as typeof mode)}
/>
</FormField>
{kind === "upload" ? (
<>
<FormField label="Format">
<SegmentedControl<UploadFormat>
ariaLabel="Upload format"
options={[
{ id: "json", label: "JSON" },
{ id: "csv", label: "CSV" }
]}
value={format}
onChange={setFormat}
/>
</FormField>
{format === "csv" ? (
<>
<FormField label="Delimiter">
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value)}>
<option value=",">Comma</option>
<option value=";">Semicolon</option>
<option value={"\t"}>Tab</option>
<option value="|">Pipe</option>
</select>
</FormField>
<FormField label="CSV values" documentation={DATASOURCE_FIELDS_DOCUMENTATION}>
<select
value={csvValueMode}
onChange={(event) => setCsvValueMode(event.target.value as "text" | "legacy_typed")}
disabled={busy}
>
<option value="text">Preserve text (no automatic conversion)</option>
<option value="legacy_typed">Infer types (legacy)</option>
</select>
</FormField>
<FormField label="CSV data">
<textarea
value={csvText}
onChange={(event) => setCsvText(event.target.value)}
spellCheck={false}
/>
</FormField>
</>
) : (
<FormField label="JSON rows">
<textarea
value={rowsText}
onChange={(event) => setRowsText(event.target.value)}
spellCheck={false}
/>
</FormField>
)}
</>
) : null}
</DialogSection>
</Dialog>
);
}
function PreviewTable({
datasource,
rows
}: {
datasource: Datasource;
rows: Record<string, unknown>[];
}) {
const columns = datasource.schema.map((field) => field.name);
return (
<div className="datasources-table-scroll datasources-preview-table">
<table>
<thead>
<tr>{columns.map((column) => <th key={column}>{column}</th>)}</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index}>
{columns.map((column) => (
<td key={column}>{displayValue(row[column])}</td>
))}
</tr>
))}
{!rows.length ? (
<tr><td colSpan={Math.max(1, columns.length)}>No rows</td></tr>
) : null}
</tbody>
</table>
</div>
);
}
function SchemaTable({ fields }: { fields: Datasource["schema"] }) {
return (
<div className="datasources-table-scroll">
<table>
<thead>
<tr><th>Field</th><th>Type</th><th>Classification</th><th>Nullable</th></tr>
</thead>
<tbody>
{fields.map((field) => (
<tr key={field.name}>
<td>{field.name}</td>
<td>{field.data_type}</td>
<td>{readableToken(field.classification)}</td>
<td>{field.nullable ? "Yes" : "No"}</td>
</tr>
))}
{!fields.length ? <tr><td colSpan={4}>Schema not available</td></tr> : null}
</tbody>
</table>
</div>
);
}
function EmptyWorkspace({ icon, label }: { icon: React.ReactNode; label: string }) {
return <StatePanel size="fill" icon={icon} title={label} />;
}
function filterItems<T>(
items: T[],
query: string,
searchable: (item: T) => string
): T[] {
const normalized = query.trim().toLocaleLowerCase();
return normalized
? items.filter((item) => searchable(item).toLocaleLowerCase().includes(normalized))
: items;
}
function initialDatasourceRef(): string {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get("datasource") ?? "";
}
function parseRows(text: string): Record<string, unknown>[] {
const value: unknown = JSON.parse(text);
if (!Array.isArray(value) || value.some((row) => !isRecord(row))) {
throw new Error("JSON data must be an array of row objects.");
}
return value;
}
function addDatasourceDraftKey(value: {
kind: AddKind;
format: UploadFormat;
mode: "live" | "cached" | "static";
originRef: string;
targetRef: string;
name: string;
sourceName: string;
description: string;
rowsText: string;
csvText: string;
delimiter: string;
csvValueMode: "text" | "legacy_typed";
}): string {
return JSON.stringify(value);
}
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);
return String(value);
}
function sourceNameFromFile(value: string): string {
const normalized = value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^A-Za-z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return /^[A-Za-z_]/.test(normalized) ? normalized : `source_${normalized || "upload"}`;
}
function formatNumber(value: number | null | undefined): string {
return value === null || value === undefined ? "Unknown" : value.toLocaleString();
}
function formatCount(value: number | null | undefined, label: string): string {
return value === null || value === undefined
? `Unknown ${label}s`
: `${value.toLocaleString()} ${label}${value === 1 ? "" : "s"}`;
}
function formatDate(value: string | null | undefined): string {
if (!value) return "Not available";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function shortFingerprint(value: string): string {
return value ? `${value.slice(0, 12)}…` : "Not available";
}
function apiErrorMessage(error: unknown): string {
if (isApiError(error)) return error.message;
return error instanceof Error ? error.message : "The datasource request failed.";
}