Module Package Release / publish-packages (push) Successful in 12s
Release v0.1.26. Coordinated integrity review: GovOPlaN/govoplan-core#298.
1990 lines
80 KiB
TypeScript
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.";
|
|
}
|