662 lines
26 KiB
TypeScript
662 lines
26 KiB
TypeScript
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." helpContextId="connectors.admin.governed-configurations" helpModuleId="connectors">
|
|
<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" helpContextId="connectors.admin.governed-configurations" helpModuleId="connectors">
|
|
<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" helpContextId="connectors.action.reject-ambiguous-result" helpModuleId="connectors" onClick={() => void decideReview("rejected")} disabled={busy || reviewReason.trim().length < 5}>Reject</Button>
|
|
<Button variant="primary" helpContextId="connectors.action.approve-ambiguous-result" helpModuleId="connectors" 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);
|
|
}
|