feat: govern connector configurations and simulations
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@govoplan/connectors-webui",
|
||||
"version": "0.1.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/connectors.css": "./src/styles/connectors.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test:connector-governance-ui": "node tests/connector-governance-ui-structure.test.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type GovernedConnectorSpecification = {
|
||||
provider: string;
|
||||
protocol: string;
|
||||
capabilities: string[];
|
||||
input_schema: Record<string, unknown>;
|
||||
output_schema: Record<string, unknown>;
|
||||
mapping: {
|
||||
version: string;
|
||||
rules: Array<{
|
||||
source: string;
|
||||
target: string;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
}>;
|
||||
};
|
||||
validation_rules: Array<{
|
||||
kind: "required" | "one_of" | "unique";
|
||||
field: string;
|
||||
values?: unknown[];
|
||||
severity: "warning" | "error";
|
||||
code: string;
|
||||
message: string;
|
||||
}>;
|
||||
dry_run: {
|
||||
supported: boolean;
|
||||
simulation_supported: boolean;
|
||||
sample_rows: Array<Record<string, unknown>>;
|
||||
max_items: number;
|
||||
redacted_fields: string[];
|
||||
};
|
||||
audit: {
|
||||
event_prefix: string;
|
||||
expected_events: string[];
|
||||
evidence_fields: string[];
|
||||
};
|
||||
privacy_classification: "public" | "internal" | "confidential" | "restricted";
|
||||
retention_class: string;
|
||||
operational_limits: Record<string, unknown>;
|
||||
retry_policy: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ConnectorDefinition = {
|
||||
id: string;
|
||||
definition_key: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
current_revision: number;
|
||||
source_package?: string | null;
|
||||
local_definition: boolean;
|
||||
definition_hash: string;
|
||||
specification: GovernedConnectorSpecification;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ConnectorConfiguration = {
|
||||
id: string;
|
||||
definition_id: string;
|
||||
definition_key: string;
|
||||
definition_name: string;
|
||||
name: string;
|
||||
status: "draft" | "active" | "disabled";
|
||||
endpoint_url?: string | null;
|
||||
credential_ref?: string | null;
|
||||
base_definition_revision: number;
|
||||
latest_definition_revision: number;
|
||||
update_available: boolean;
|
||||
local_overrides: Record<string, unknown>;
|
||||
protected_paths: string[];
|
||||
effective_configuration: GovernedConnectorSpecification;
|
||||
effective_hash: string;
|
||||
resource_revision: number;
|
||||
ambiguity_policy: "manual_review" | "quarantine" | "reject";
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ConnectorRun = {
|
||||
id: string;
|
||||
configuration_id: string;
|
||||
mode: "dry_run" | "simulation";
|
||||
idempotency_key: string;
|
||||
status: string;
|
||||
review_state: string;
|
||||
definition_revision: number;
|
||||
configuration_revision: number;
|
||||
configuration_hash: string;
|
||||
input_hash: string;
|
||||
summary: Record<string, number | boolean>;
|
||||
effects: Array<Record<string, unknown>>;
|
||||
diagnostics: Array<Record<string, unknown>>;
|
||||
provenance: Record<string, unknown>;
|
||||
reviewed_by?: string | null;
|
||||
reviewed_at?: string | null;
|
||||
review_reason?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ConnectorConfigurationDraft = {
|
||||
name: string;
|
||||
status: ConnectorConfiguration["status"];
|
||||
endpoint_url: string;
|
||||
credential_ref: string;
|
||||
local_overrides: string;
|
||||
ambiguity_policy: ConnectorConfiguration["ambiguity_policy"];
|
||||
};
|
||||
|
||||
const ROOT = "/api/v1/connectors/governed";
|
||||
|
||||
export async function listConnectorDefinitions(
|
||||
settings: ApiSettings
|
||||
): Promise<ConnectorDefinition[]> {
|
||||
const response = await apiFetch<{ items: ConnectorDefinition[] }>(
|
||||
settings,
|
||||
`${ROOT}/definitions`
|
||||
);
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function upsertConnectorDefinition(
|
||||
settings: ApiSettings,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<ConnectorDefinition> {
|
||||
return apiFetch(settings, `${ROOT}/definitions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function listConnectorConfigurations(
|
||||
settings: ApiSettings
|
||||
): Promise<ConnectorConfiguration[]> {
|
||||
const response = await apiFetch<{ items: ConnectorConfiguration[] }>(
|
||||
settings,
|
||||
`${ROOT}/configurations`
|
||||
);
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export function createConnectorConfiguration(
|
||||
settings: ApiSettings,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<ConnectorConfiguration> {
|
||||
return apiFetch(settings, `${ROOT}/configurations`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateConnectorConfiguration(
|
||||
settings: ApiSettings,
|
||||
configurationId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<ConnectorConfiguration> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`${ROOT}/configurations/${encodeURIComponent(configurationId)}`,
|
||||
{ method: "PUT", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listConnectorRuns(
|
||||
settings: ApiSettings,
|
||||
configurationId?: string
|
||||
): Promise<ConnectorRun[]> {
|
||||
const response = await apiFetch<{ items: ConnectorRun[] }>(
|
||||
settings,
|
||||
apiPath(`${ROOT}/runs`, {
|
||||
configuration_id: configurationId || undefined,
|
||||
limit: 100
|
||||
})
|
||||
);
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export function executeConnectorRun(
|
||||
settings: ApiSettings,
|
||||
configurationId: string,
|
||||
mode: "dry-runs" | "simulations",
|
||||
payload: Record<string, unknown>
|
||||
): Promise<ConnectorRun> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`${ROOT}/configurations/${encodeURIComponent(configurationId)}/${mode}`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export function reviewConnectorRun(
|
||||
settings: ApiSettings,
|
||||
runId: string,
|
||||
decision: "approved" | "rejected",
|
||||
reason: string
|
||||
): Promise<ConnectorRun> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`${ROOT}/runs/${encodeURIComponent(runId)}/review`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ decision, reason })
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AdminPageLayout,
|
||||
Button,
|
||||
Card,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
FilterBar,
|
||||
FormField,
|
||||
FormGrid,
|
||||
MetricCard,
|
||||
MetricGrid,
|
||||
PageActionBar,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatePanel,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
WorkspaceLayout,
|
||||
formatDateTime,
|
||||
hasScope,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createConnectorConfiguration,
|
||||
executeConnectorRun,
|
||||
listConnectorConfigurations,
|
||||
listConnectorDefinitions,
|
||||
listConnectorRuns,
|
||||
reviewConnectorRun,
|
||||
updateConnectorConfiguration,
|
||||
upsertConnectorDefinition,
|
||||
type ConnectorConfiguration,
|
||||
type ConnectorConfigurationDraft,
|
||||
type ConnectorDefinition,
|
||||
type ConnectorRun
|
||||
} from "../api/governedConnectors";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
};
|
||||
|
||||
const EMPTY_DRAFT: ConnectorConfigurationDraft = {
|
||||
name: "",
|
||||
status: "draft",
|
||||
endpoint_url: "",
|
||||
credential_ref: "",
|
||||
local_overrides: "{}",
|
||||
ambiguity_policy: "manual_review"
|
||||
};
|
||||
|
||||
const EXAMPLE_DEFINITION = JSON.stringify({
|
||||
definition_key: "example.reference-data",
|
||||
name: "Example reference data",
|
||||
description: "Locally governed example connector",
|
||||
origin: "local",
|
||||
specification: {
|
||||
provider: "example-provider",
|
||||
protocol: "rest",
|
||||
capabilities: ["discover", "read", "dry_run"],
|
||||
input_schema: { type: "object" },
|
||||
output_schema: { type: "object" },
|
||||
mapping: {
|
||||
version: "1",
|
||||
rules: [{ source: "id", target: "record.id", required: true }]
|
||||
},
|
||||
validation_rules: [{
|
||||
kind: "unique",
|
||||
field: "record.id",
|
||||
severity: "error",
|
||||
code: "record.id.ambiguous",
|
||||
message: "The record identifier is not unique."
|
||||
}],
|
||||
dry_run: {
|
||||
supported: true,
|
||||
simulation_supported: true,
|
||||
sample_rows: [{ id: "sample-1" }],
|
||||
max_items: 500,
|
||||
redacted_fields: []
|
||||
},
|
||||
audit: {
|
||||
event_prefix: "connectors.example",
|
||||
expected_events: ["simulation.completed"],
|
||||
evidence_fields: ["input_hash", "configuration_hash"]
|
||||
},
|
||||
privacy_classification: "internal",
|
||||
retention_class: "connector-preview-30d",
|
||||
operational_limits: { timeout_seconds: 30 },
|
||||
retry_policy: { max_attempts: 2 }
|
||||
}
|
||||
}, null, 2);
|
||||
|
||||
export default function ConnectorGovernancePage({ settings, auth }: Props) {
|
||||
const [definitions, setDefinitions] = useState<ConnectorDefinition[]>([]);
|
||||
const [configurations, setConfigurations] = useState<ConnectorConfiguration[]>([]);
|
||||
const [runs, setRuns] = useState<ConnectorRun[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [draft, setDraft] = useState<ConnectorConfigurationDraft>(EMPTY_DRAFT);
|
||||
const [savedKey, setSavedKey] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [definitionOpen, setDefinitionOpen] = useState(false);
|
||||
const [definitionJson, setDefinitionJson] = useState(EXAMPLE_DEFINITION);
|
||||
const [configurationOpen, setConfigurationOpen] = useState(false);
|
||||
const [newDefinitionId, setNewDefinitionId] = useState("");
|
||||
const [newDraft, setNewDraft] = useState<ConnectorConfigurationDraft>(EMPTY_DRAFT);
|
||||
const [sampleJson, setSampleJson] = useState("[]");
|
||||
const [externalRevision, setExternalRevision] = useState("");
|
||||
const [reviewRun, setReviewRun] = useState<ConnectorRun | null>(null);
|
||||
const [reviewReason, setReviewReason] = useState("");
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const selected = configurations.find((item) => item.id === selectedId) ?? null;
|
||||
const canAdmin = hasScope(auth, "connectors:source:admin");
|
||||
const canExecute = canAdmin || hasScope(auth, "connectors:source:write");
|
||||
const dirty = Boolean(selected && draftKey(draft) !== savedKey);
|
||||
|
||||
const applyConfiguration = useCallback((item: ConnectorConfiguration | null) => {
|
||||
const next = item ? draftFromConfiguration(item) : EMPTY_DRAFT;
|
||||
setDraft(next);
|
||||
setSavedKey(item ? draftKey(next) : "");
|
||||
setSampleJson(JSON.stringify(
|
||||
item?.effective_configuration.dry_run.sample_rows ?? [],
|
||||
null,
|
||||
2
|
||||
));
|
||||
}, []);
|
||||
|
||||
const reload = useCallback(async (preferredId?: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextDefinitions, nextConfigurations] = await Promise.all([
|
||||
listConnectorDefinitions(settings),
|
||||
listConnectorConfigurations(settings)
|
||||
]);
|
||||
const nextId = preferredId && nextConfigurations.some((item) => item.id === preferredId)
|
||||
? preferredId
|
||||
: nextConfigurations.some((item) => item.id === selectedId)
|
||||
? selectedId
|
||||
: nextConfigurations[0]?.id ?? "";
|
||||
const nextRuns = await listConnectorRuns(settings, nextId || undefined);
|
||||
setDefinitions(nextDefinitions);
|
||||
setConfigurations(nextConfigurations);
|
||||
setRuns(nextRuns);
|
||||
setSelectedId(nextId);
|
||||
applyConfiguration(nextConfigurations.find((item) => item.id === nextId) ?? null);
|
||||
if (!newDefinitionId && nextDefinitions[0]) setNewDefinitionId(nextDefinitions[0].id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [applyConfiguration, newDefinitionId, selectedId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
const visibleConfigurations = useMemo(() => {
|
||||
const needle = search.trim().toLocaleLowerCase();
|
||||
return configurations.filter((item) => !needle ||
|
||||
`${item.name} ${item.definition_name} ${item.status}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(needle));
|
||||
}, [configurations, search]);
|
||||
|
||||
const save = async (): Promise<boolean> => {
|
||||
if (!selected || !canAdmin) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const overrides = parseObject(draft.local_overrides, "Local overrides");
|
||||
const updated = await updateConnectorConfiguration(settings, selected.id, {
|
||||
expected_revision: selected.resource_revision,
|
||||
name: draft.name.trim(),
|
||||
status: draft.status,
|
||||
endpoint_url: draft.endpoint_url.trim() || null,
|
||||
credential_ref: draft.credential_ref.trim() || null,
|
||||
local_overrides: overrides,
|
||||
ambiguity_policy: draft.ambiguity_policy
|
||||
});
|
||||
setSuccess("Connector configuration saved.");
|
||||
await reload(updated.id);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: () => applyConfiguration(selected),
|
||||
title: "Unsaved connector changes",
|
||||
message: "Save or discard the current connector changes before continuing."
|
||||
});
|
||||
|
||||
const selectConfiguration = (item: ConnectorConfiguration) => {
|
||||
if (item.id === selectedId) return;
|
||||
requestDiscard(() => {
|
||||
setSelectedId(item.id);
|
||||
applyConfiguration(item);
|
||||
setRuns([]);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
void listConnectorRuns(settings, item.id).then(setRuns).catch((caught) => {
|
||||
setError(errorMessage(caught));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const createDefinition = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = parseObject(definitionJson, "Definition");
|
||||
const created = await upsertConnectorDefinition(settings, payload);
|
||||
setDefinitionOpen(false);
|
||||
setSuccess("Connector definition revision saved.");
|
||||
setNewDefinitionId(created.id);
|
||||
await reload(selectedId);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createConfiguration = async () => {
|
||||
if (!newDefinitionId || !newDraft.name.trim()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createConnectorConfiguration(settings, {
|
||||
definition_id: newDefinitionId,
|
||||
name: newDraft.name.trim(),
|
||||
status: newDraft.status,
|
||||
endpoint_url: newDraft.endpoint_url.trim() || null,
|
||||
credential_ref: newDraft.credential_ref.trim() || null,
|
||||
local_overrides: parseObject(newDraft.local_overrides, "Local overrides"),
|
||||
ambiguity_policy: newDraft.ambiguity_policy
|
||||
});
|
||||
setConfigurationOpen(false);
|
||||
setNewDraft(EMPTY_DRAFT);
|
||||
setSuccess("Connector configuration created.");
|
||||
await reload(created.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const adoptUpdate = async () => {
|
||||
if (!selected || dirty || !selected.update_available) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await updateConnectorConfiguration(settings, selected.id, {
|
||||
expected_revision: selected.resource_revision,
|
||||
adopt_latest_definition: true
|
||||
});
|
||||
setSuccess("Package revision adopted; protected local overrides were reapplied.");
|
||||
await reload(updated.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const run = async (mode: "dry-runs" | "simulations") => {
|
||||
if (!selected || dirty) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const inputRows = parseRows(sampleJson);
|
||||
const created = await executeConnectorRun(settings, selected.id, mode, {
|
||||
idempotency_key: `${mode}-${crypto.randomUUID()}`,
|
||||
input_rows: inputRows,
|
||||
external_revision: externalRevision.trim() || null
|
||||
});
|
||||
setSuccess(`${mode === "dry-runs" ? "Dry-run" : "Simulation"} completed with status ${created.status}.`);
|
||||
setRuns(await listConnectorRuns(settings, selected.id));
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const decideReview = async (decision: "approved" | "rejected") => {
|
||||
if (!reviewRun || reviewReason.trim().length < 5) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await reviewConnectorRun(settings, reviewRun.id, decision, reviewReason.trim());
|
||||
setReviewRun(null);
|
||||
setReviewReason("");
|
||||
setSuccess(`Simulation ${decision}.`);
|
||||
setRuns(await listConnectorRuns(settings, selectedId));
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runColumns = useMemo<DataGridColumn<ConnectorRun>[]>(() => [
|
||||
{
|
||||
id: "created",
|
||||
header: "Run",
|
||||
width: 190,
|
||||
sortable: true,
|
||||
value: (row) => row.created_at,
|
||||
render: (row) => <>{row.mode}<br /><span className="muted">{formatDateTime(row.created_at)}</span></>
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
width: 150,
|
||||
sortable: true,
|
||||
value: (row) => row.status,
|
||||
render: (row) => <StatusBadge status={row.status} label={row.status.replaceAll("_", " ")} />
|
||||
},
|
||||
{
|
||||
id: "summary",
|
||||
header: "Effects",
|
||||
width: "1fr",
|
||||
minWidth: 220,
|
||||
render: (row) => `${row.summary.total ?? 0} total · ${row.summary.ambiguous ?? 0} ambiguous · ${row.summary.errors ?? 0} errors`
|
||||
},
|
||||
{
|
||||
id: "revision",
|
||||
header: "Evidence",
|
||||
width: 170,
|
||||
render: (row) => <code title={row.configuration_hash}>r{row.configuration_revision} · {row.input_hash.slice(0, 8)}</code>
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 90,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (row) => <TableActionGroup actions={[{
|
||||
id: "review",
|
||||
label: "Review result",
|
||||
icon: <span>✓</span>,
|
||||
applicable: ["pending", "quarantined"].includes(row.review_state),
|
||||
disabled: !canAdmin || busy,
|
||||
disabledReason: !canAdmin ? "Connector administration permission is required." : undefined,
|
||||
onClick: () => setReviewRun(row)
|
||||
}]} />
|
||||
}
|
||||
], [busy, canAdmin]);
|
||||
|
||||
const actionBar = <PageActionBar
|
||||
variant="editor"
|
||||
state={busy ? "saving" : dirty ? "dirty" : "clean"}
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void reload(selectedId), loading }}
|
||||
primaryActions={<>
|
||||
<Button onClick={() => setDefinitionOpen(true)} disabled={!canAdmin || busy}>
|
||||
New definition
|
||||
</Button>
|
||||
<Button onClick={() => setConfigurationOpen(true)} disabled={!canAdmin || busy || !definitions.length}>
|
||||
New configuration
|
||||
</Button>
|
||||
{selected?.update_available ? <Button
|
||||
variant="secondary"
|
||||
onClick={() => void adoptUpdate()}
|
||||
disabled={!canAdmin || busy || dirty}
|
||||
disabledReason={dirty ? "Save or discard local edits before adopting a package update." : undefined}
|
||||
>
|
||||
Adopt package revision {selected.latest_definition_revision}
|
||||
</Button> : null}
|
||||
</>}
|
||||
discardAction={{
|
||||
label: "Discard changes",
|
||||
disabled: !selected,
|
||||
onClick: () => applyConfiguration(selected)
|
||||
}}
|
||||
saveAction={{
|
||||
label: "Save",
|
||||
disabled: !selected || !canAdmin || busy,
|
||||
disabledReason: !canAdmin ? "Connector administration permission is required." : undefined,
|
||||
onClick: () => void save()
|
||||
}}
|
||||
/>;
|
||||
|
||||
return <AdminPageLayout
|
||||
archetype="workspace"
|
||||
title="Connector governance"
|
||||
description="Version schemas and mappings, protect local overrides, and review deterministic simulations before provider-specific writes."
|
||||
loading={loading && !configurations.length}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={actionBar}
|
||||
className="connector-governance-page"
|
||||
helpContextId="connectors.admin.governed-configurations"
|
||||
>
|
||||
<MetricGrid columns={4} density="compact" minimum="compact">
|
||||
<MetricCard label="Definitions" value={definitions.length} />
|
||||
<MetricCard label="Configurations" value={configurations.length} />
|
||||
<MetricCard label="Updates available" value={configurations.filter((item) => item.update_available).length} tone="warning" />
|
||||
<MetricCard label="Awaiting review" value={runs.filter((item) => ["pending", "quarantined"].includes(item.review_state)).length} tone="warning" />
|
||||
</MetricGrid>
|
||||
|
||||
<WorkspaceLayout
|
||||
variant="split"
|
||||
primarySize="compact"
|
||||
surface="contained"
|
||||
primaryScrollable={false}
|
||||
contentScrollable={false}
|
||||
primaryLabel="Connector configurations"
|
||||
contentLabel="Configuration details"
|
||||
primary={<div className="connector-governance-list">
|
||||
<FilterBar surface="panel">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search configurations"
|
||||
aria-label="Search connector configurations"
|
||||
/>
|
||||
</FilterBar>
|
||||
<SelectionList variant="navigation" label="Connector configurations">
|
||||
{visibleConfigurations.map((item) => <SelectionListItem
|
||||
key={item.id}
|
||||
selected={item.id === selectedId}
|
||||
onClick={() => selectConfiguration(item)}
|
||||
>
|
||||
<SelectionListItemContent
|
||||
title={item.name}
|
||||
description={`${item.definition_name} · revision ${item.base_definition_revision}`}
|
||||
/>
|
||||
<StatusBadge status={item.update_available ? "warning" : item.status} />
|
||||
</SelectionListItem>)}
|
||||
{!visibleConfigurations.length
|
||||
? <StatePanel size="compact" description="No matching configurations." />
|
||||
: null}
|
||||
</SelectionList>
|
||||
</div>}
|
||||
>
|
||||
{!selected ? <StatePanel
|
||||
size="fill"
|
||||
title="Connector configurations"
|
||||
description="Create or select a configuration to inspect its pinned definition and simulation evidence."
|
||||
/> : <div className="connector-governance-detail">
|
||||
<Card title={selected.name}>
|
||||
<div className="connector-revision-line">
|
||||
<StatusBadge status={selected.status} />
|
||||
<span>Definition revision {selected.base_definition_revision}</span>
|
||||
{selected.update_available
|
||||
? <StatusBadge status="warning" label={`Revision ${selected.latest_definition_revision} available`} />
|
||||
: null}
|
||||
<code title={selected.effective_hash}>{selected.effective_hash.slice(0, 12)}</code>
|
||||
</div>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="Name">
|
||||
<input value={draft.name} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, name: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Status">
|
||||
<select value={draft.status} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, status: event.target.value as ConnectorConfigurationDraft["status"] })}>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Endpoint URL" hint="Credentials are rejected in URLs.">
|
||||
<input value={draft.endpoint_url} disabled={!canAdmin || busy} placeholder="https://provider.example/api" onChange={(event) => setDraft({ ...draft, endpoint_url: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Credential reference" hint="Reference an approved secret; do not paste a secret.">
|
||||
<input value={draft.credential_ref} disabled={!canAdmin || busy} placeholder="vault://connectors/provider" onChange={(event) => setDraft({ ...draft, credential_ref: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Ambiguous-result policy">
|
||||
<select value={draft.ambiguity_policy} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, ambiguity_policy: event.target.value as ConnectorConfigurationDraft["ambiguity_policy"] })}>
|
||||
<option value="manual_review">Manual review</option>
|
||||
<option value="quarantine">Quarantine</option>
|
||||
<option value="reject">Reject</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Protected local overrides" hint="JSON object leaf paths remain protected when package revisions are adopted.">
|
||||
<textarea rows={8} value={draft.local_overrides} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, local_overrides: event.target.value })} />
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<p className="muted">
|
||||
Protected paths: {selected.protected_paths.length ? selected.protected_paths.join(", ") : "none"}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="Dry-run and simulation">
|
||||
<p className="muted">Runs never apply changes. They retain redacted, revision-pinned evidence for review.</p>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="Sample input rows" hint="JSON array, bounded by the definition's maximum.">
|
||||
<textarea rows={9} value={sampleJson} disabled={!canExecute || busy} onChange={(event) => setSampleJson(event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="External revision" hint="Optional provider ETag, cursor, or snapshot revision.">
|
||||
<input value={externalRevision} disabled={!canExecute || busy} onChange={(event) => setExternalRevision(event.target.value)} />
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<div className="connector-run-actions">
|
||||
<Button onClick={() => void run("dry-runs")} disabled={!canExecute || busy || dirty}>Run dry-run</Button>
|
||||
<Button variant="primary" onClick={() => void run("simulations")} disabled={!canExecute || busy || dirty}>Run simulation</Button>
|
||||
{dirty ? <span className="muted">Save or discard configuration changes before running.</span> : null}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Simulation evidence">
|
||||
<DataGrid
|
||||
id="connector-simulation-runs"
|
||||
rows={runs}
|
||||
columns={runColumns}
|
||||
getRowKey={(row) => row.id}
|
||||
initialFit="container"
|
||||
emptyText="No dry-runs or simulations have been recorded."
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Effective governed definition">
|
||||
<pre className="connector-json-preview">{JSON.stringify(selected.effective_configuration, null, 2)}</pre>
|
||||
</Card>
|
||||
</div>}
|
||||
</WorkspaceLayout>
|
||||
|
||||
<Dialog
|
||||
open={definitionOpen}
|
||||
title="Create or revise connector definition"
|
||||
onClose={() => !busy && setDefinitionOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setDefinitionOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void createDefinition()} disabled={!canAdmin || busy}>Save definition revision</Button>
|
||||
</>}
|
||||
>
|
||||
<p className="muted">The definition is schema-validated and every changed specification creates an immutable revision. Package definitions must name their package reference.</p>
|
||||
<FormField label="Governed definition JSON">
|
||||
<textarea className="connector-definition-editor" rows={24} value={definitionJson} disabled={busy} onChange={(event) => setDefinitionJson(event.target.value)} />
|
||||
</FormField>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={configurationOpen}
|
||||
title="Create connector configuration"
|
||||
onClose={() => !busy && setConfigurationOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setConfigurationOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void createConfiguration()} disabled={busy || !newDefinitionId || !newDraft.name.trim()}>Create configuration</Button>
|
||||
</>}
|
||||
>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="Definition">
|
||||
<select value={newDefinitionId} disabled={busy} onChange={(event) => setNewDefinitionId(event.target.value)}>
|
||||
{definitions.map((item) => <option key={item.id} value={item.id}>{item.name} · revision {item.current_revision}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Name">
|
||||
<input value={newDraft.name} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, name: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Endpoint URL">
|
||||
<input value={newDraft.endpoint_url} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, endpoint_url: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Credential reference">
|
||||
<input value={newDraft.credential_ref} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, credential_ref: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Ambiguous-result policy">
|
||||
<select value={newDraft.ambiguity_policy} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, ambiguity_policy: event.target.value as ConnectorConfigurationDraft["ambiguity_policy"] })}>
|
||||
<option value="manual_review">Manual review</option>
|
||||
<option value="quarantine">Quarantine</option>
|
||||
<option value="reject">Reject</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Local overrides">
|
||||
<textarea rows={7} value={newDraft.local_overrides} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, local_overrides: event.target.value })} />
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(reviewRun)}
|
||||
title="Review ambiguous connector result"
|
||||
onClose={() => !busy && setReviewRun(null)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setReviewRun(null)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="danger" onClick={() => void decideReview("rejected")} disabled={busy || reviewReason.trim().length < 5}>Reject</Button>
|
||||
<Button variant="primary" onClick={() => void decideReview("approved")} disabled={busy || reviewReason.trim().length < 5}>Approve</Button>
|
||||
</>}
|
||||
>
|
||||
<p>Review {reviewRun?.summary.ambiguous ?? 0} ambiguous effects against the retained input and configuration hashes before deciding.</p>
|
||||
<FormField label="Decision reason">
|
||||
<textarea rows={4} value={reviewReason} disabled={busy} onChange={(event) => setReviewReason(event.target.value)} />
|
||||
</FormField>
|
||||
<pre className="connector-json-preview">{JSON.stringify(reviewRun?.diagnostics ?? [], null, 2)}</pre>
|
||||
</Dialog>
|
||||
</AdminPageLayout>;
|
||||
}
|
||||
|
||||
function draftFromConfiguration(item: ConnectorConfiguration): ConnectorConfigurationDraft {
|
||||
return {
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
endpoint_url: item.endpoint_url ?? "",
|
||||
credential_ref: item.credential_ref ?? "",
|
||||
local_overrides: JSON.stringify(item.local_overrides, null, 2),
|
||||
ambiguity_policy: item.ambiguity_policy
|
||||
};
|
||||
}
|
||||
|
||||
function draftKey(value: ConnectorConfigurationDraft): string {
|
||||
return JSON.stringify({
|
||||
name: value.name.trim(),
|
||||
status: value.status,
|
||||
endpoint_url: value.endpoint_url.trim(),
|
||||
credential_ref: value.credential_ref.trim(),
|
||||
local_overrides: normalizeJson(value.local_overrides),
|
||||
ambiguity_policy: value.ambiguity_policy
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function parseObject(value: string, label: string): Record<string, unknown> {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
||||
throw new Error(`${label} must be a JSON object.`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseRows(value: string): Array<Record<string, unknown>> {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(parsed) || parsed.some((item) => !item || Array.isArray(item) || typeof item !== "object")) {
|
||||
throw new Error("Sample input must be a JSON array of objects.");
|
||||
}
|
||||
return parsed as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { connectorsModule as default, connectorsModule } from "./module";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type {
|
||||
AdminSectionsUiCapability,
|
||||
PlatformWebModule
|
||||
} from "@govoplan/core-webui";
|
||||
import "./styles/connectors.css";
|
||||
|
||||
const ConnectorGovernancePage = lazy(
|
||||
() => import("./features/ConnectorGovernancePage")
|
||||
);
|
||||
|
||||
const readScopes = [
|
||||
"connectors:source:read",
|
||||
"connectors:source:admin"
|
||||
];
|
||||
|
||||
const adminSections: AdminSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "connector-governance",
|
||||
moduleId: "connectors",
|
||||
kind: "management",
|
||||
surfaceId: "connectors.admin.governed-configurations",
|
||||
label: "Connector governance",
|
||||
group: "SYSTEM",
|
||||
order: 45,
|
||||
anyOf: readScopes,
|
||||
render: ({ settings, auth }) =>
|
||||
createElement(ConnectorGovernancePage, { settings, auth })
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const connectorsModule: PlatformWebModule = {
|
||||
id: "connectors",
|
||||
label: "Connectors",
|
||||
version: "0.1.18",
|
||||
dependencies: [],
|
||||
optionalDependencies: ["access", "audit", "policy", "ops"],
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "connectors.admin.governed-configurations",
|
||||
moduleId: "connectors",
|
||||
kind: "section",
|
||||
label: "Connector governance",
|
||||
order: 45
|
||||
},
|
||||
{
|
||||
id: "connectors.admin.simulation-review",
|
||||
moduleId: "connectors",
|
||||
kind: "section",
|
||||
label: "Connector simulation review",
|
||||
parentId: "connectors.admin.governed-configurations",
|
||||
order: 20
|
||||
}
|
||||
],
|
||||
uiCapabilities: {
|
||||
"admin.sections": adminSections
|
||||
}
|
||||
};
|
||||
|
||||
export default connectorsModule;
|
||||
@@ -0,0 +1,32 @@
|
||||
.connector-governance-page .connector-governance-list,
|
||||
.connector-governance-page .connector-governance-detail {
|
||||
display: grid;
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.connector-governance-page .connector-revision-line,
|
||||
.connector-governance-page .connector-run-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
margin-bottom: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.connector-governance-page textarea {
|
||||
font-family: var(--font-family-mono, ui-monospace, monospace);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.connector-governance-page .connector-definition-editor,
|
||||
.connector-governance-page .connector-json-preview {
|
||||
max-height: 34rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.connector-governance-page .connector-json-preview {
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
padding: var(--space-4, 1rem);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const moduleSource = readFileSync("src/module.ts", "utf8");
|
||||
const page = readFileSync("src/features/ConnectorGovernancePage.tsx", "utf8");
|
||||
const api = readFileSync("src/api/governedConnectors.ts", "utf8");
|
||||
|
||||
assert.match(moduleSource, /"admin.sections": adminSections/);
|
||||
assert.match(moduleSource, /connectors\.admin\.governed-configurations/);
|
||||
assert.match(page, /<AdminPageLayout/);
|
||||
assert.match(page, /<PageActionBar/);
|
||||
assert.match(page, /refreshable/);
|
||||
assert.match(page, /saveAction=/);
|
||||
assert.match(page, /useUnsavedDraftGuard/);
|
||||
assert.match(page, /<WorkspaceLayout/);
|
||||
assert.match(page, /Adopt package revision/);
|
||||
assert.match(page, /Protected paths/);
|
||||
assert.match(page, /manual_review/);
|
||||
assert.match(page, /quarantine/);
|
||||
assert.match(page, /Run simulation/);
|
||||
assert.match(page, /Review ambiguous connector result/);
|
||||
assert.match(api, /idempotency_key/);
|
||||
assert.match(api, /credential_ref/);
|
||||
|
||||
console.log("Connector governance UI structural contract passed.");
|
||||
Reference in New Issue
Block a user