feat: govern connector configurations and simulations

This commit is contained in:
2026-08-20 12:58:53 +02:00
parent 3964a2e4e7
commit 0f61fe4607
18 changed files with 3309 additions and 14 deletions
+29
View File
@@ -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"
}
}
+204
View File
@@ -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);
}
+1
View File
@@ -0,0 +1 @@
export { connectorsModule as default, connectorsModule } from "./module";
+62
View File
@@ -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;
+32
View File
@@ -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.");