Files
zemion 65609665a2
Module Package Release / publish-packages (push) Successful in 12s
feat(connectors): document consequential governance actions
2026-08-24 11:36:36 +02:00

562 lines
26 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import {
AdminPageLayout,
Button,
Card,
Dialog,
FilterBar,
FormField,
FormGrid,
MetricCard,
MetricGrid,
PageActionBar,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
WorkspaceLayout,
formatDateTime,
hasScope,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
createKnowledgeProfile,
discoverKnowledgeProfile,
listKnowledgeObjects,
listKnowledgeProfiles,
listKnowledgeRuns,
previewKnowledgeMigration,
publishKnowledgePage,
synchronizeKnowledgeProfile,
updateKnowledgeProfile,
type KnowledgeMigrationPreview,
type KnowledgeObject,
type KnowledgeProfile,
type KnowledgeRun
} from "../api/externalKnowledge";
type Props = {
settings: ApiSettings;
auth: AuthInfo;
};
type ProfileDraft = {
status: "active" | "paused";
desired_maturity: KnowledgeProfile["desired_maturity"];
source_authority_mode: KnowledgeProfile["source_authority_mode"];
default_visibility: KnowledgeProfile["default_visibility"];
default_acl_tokens: string;
namespace_mappings: string;
};
const EMPTY_DRAFT: ProfileDraft = {
status: "active",
desired_maturity: "migrate",
source_authority_mode: "external_mirror",
default_visibility: "restricted",
default_acl_tokens: "scope:connectors:knowledge:read",
namespace_mappings: JSON.stringify([{
source_namespace_id: 0,
source_name: "",
target_space_ref: "external-knowledge",
target_path_prefix: "",
include: true,
acl_tokens: []
}], null, 2)
};
export default function ExternalKnowledgePage({ settings, auth }: Props) {
const [profiles, setProfiles] = useState<KnowledgeProfile[]>([]);
const [objects, setObjects] = useState<KnowledgeObject[]>([]);
const [runs, setRuns] = useState<KnowledgeRun[]>([]);
const [selectedId, setSelectedId] = useState("");
const [draft, setDraft] = useState<ProfileDraft>(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 [createOpen, setCreateOpen] = useState(false);
const [configurationId, setConfigurationId] = useState("");
const [newDraft, setNewDraft] = useState<ProfileDraft>(EMPTY_DRAFT);
const [migrationOpen, setMigrationOpen] = useState(false);
const [targetSpace, setTargetSpace] = useState("external-knowledge");
const [supportedMacros, setSupportedMacros] = useState("");
const [existingTargets, setExistingTargets] = useState("[]");
const [migration, setMigration] = useState<KnowledgeMigrationPreview | null>(null);
const [publishOpen, setPublishOpen] = useState(false);
const [externalPageId, setExternalPageId] = useState("");
const [publishTitle, setPublishTitle] = useState("");
const [publishBody, setPublishBody] = useState("");
const [publishSummary, setPublishSummary] = useState("");
const [publishRevision, setPublishRevision] = useState("");
const { requestDiscard } = useUnsavedChanges();
const selected = profiles.find((item) => item.id === selectedId) ?? null;
const canAdmin = hasScope(auth, "connectors:knowledge:admin");
const canSync = hasScope(auth, "connectors:knowledge:sync");
const canMigrate = hasScope(auth, "connectors:knowledge:migrate");
const canPublish = hasScope(auth, "connectors:knowledge:publish");
const dirty = Boolean(selected && draftKey(draft) !== savedKey);
const applyProfile = useCallback((profile: KnowledgeProfile | null) => {
const next = profile ? draftFromProfile(profile) : EMPTY_DRAFT;
setDraft(next);
setSavedKey(profile ? draftKey(next) : "");
}, []);
const reload = useCallback(async (preferredId?: string) => {
setLoading(true);
setError("");
try {
const nextProfiles = await listKnowledgeProfiles(settings);
const nextId = preferredId && nextProfiles.some((item) => item.id === preferredId)
? preferredId
: nextProfiles.some((item) => item.id === selectedId)
? selectedId
: nextProfiles[0]?.id ?? "";
const [nextObjects, nextRuns] = nextId
? await Promise.all([
listKnowledgeObjects(settings, nextId),
listKnowledgeRuns(settings, nextId)
])
: [[], []];
setProfiles(nextProfiles);
setSelectedId(nextId);
setObjects(nextObjects);
setRuns(nextRuns);
applyProfile(nextProfiles.find((item) => item.id === nextId) ?? null);
} catch (caught) {
setError(errorMessage(caught));
} finally {
setLoading(false);
}
}, [applyProfile, selectedId, settings]);
useEffect(() => {
void reload();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
const save = async (): Promise<boolean> => {
if (!selected || !canAdmin) return false;
setBusy(true);
setError("");
try {
const updated = await updateKnowledgeProfile(settings, selected.id, {
expected_resource_revision: selected.resource_revision,
status: draft.status,
desired_maturity: draft.desired_maturity,
source_authority_mode: draft.source_authority_mode,
default_visibility: draft.default_visibility,
default_acl_tokens: lines(draft.default_acl_tokens),
namespace_mappings: parseArray(draft.namespace_mappings, "Namespace mappings")
});
setSuccess("External knowledge profile saved; fallback ACLs and Search projections were refreshed.");
await reload(updated.id);
return true;
} catch (caught) {
setError(errorMessage(caught));
return false;
} finally {
setBusy(false);
}
};
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: () => applyProfile(selected),
title: "Unsaved knowledge profile changes",
message: "Save or discard the profile changes before continuing."
});
const selectProfile = (profile: KnowledgeProfile) => {
if (profile.id === selectedId) return;
requestDiscard(() => {
setSelectedId(profile.id);
applyProfile(profile);
setObjects([]);
setRuns([]);
setMigration(null);
void Promise.all([
listKnowledgeObjects(settings, profile.id),
listKnowledgeRuns(settings, profile.id)
]).then(([nextObjects, nextRuns]) => {
setObjects(nextObjects);
setRuns(nextRuns);
}).catch((caught) => setError(errorMessage(caught)));
});
};
const createProfile = async () => {
if (!configurationId.trim()) return;
setBusy(true);
setError("");
try {
const created = await createKnowledgeProfile(settings, {
configuration_id: configurationId.trim(),
desired_maturity: newDraft.desired_maturity,
source_authority_mode: newDraft.source_authority_mode,
default_visibility: newDraft.default_visibility,
default_acl_tokens: lines(newDraft.default_acl_tokens),
namespace_mappings: parseArray(newDraft.namespace_mappings, "Namespace mappings")
});
setCreateOpen(false);
setConfigurationId("");
setNewDraft(EMPTY_DRAFT);
setSuccess("External knowledge profile created. Run discovery before synchronization.");
await reload(created.id);
} catch (caught) {
setError(errorMessage(caught));
} finally {
setBusy(false);
}
};
const discover = async () => {
if (!selected || dirty) return;
setBusy(true);
setError("");
try {
const result = await discoverKnowledgeProfile(settings, selected.id);
setSuccess(`Discovered ${result.product} ${result.product_version ?? ""} at ${result.maturity} maturity with ${result.diagnostics.length} diagnostics.`);
await reload(selected.id);
} catch (caught) {
setError(errorMessage(caught));
} finally {
setBusy(false);
}
};
const sync = async (forceFull: boolean) => {
if (!selected || dirty) return;
setBusy(true);
setError("");
try {
const run = await synchronizeKnowledgeProfile(settings, selected.id, {
idempotency_key: `knowledge-${forceFull ? "backfill" : "delta"}-${crypto.randomUUID()}`,
force_full: forceFull,
limit: 100
});
setSuccess(`${forceFull ? "Backfill" : "Delta"} completed with ${effectTotal(run)} effects.`);
await reload(selected.id);
} catch (caught) {
setError(errorMessage(caught));
} finally {
setBusy(false);
}
};
const previewMigration = async () => {
if (!selected || !targetSpace.trim()) return;
setBusy(true);
setError("");
try {
const result = await previewKnowledgeMigration(settings, selected.id, {
idempotency_key: `knowledge-migration-${crypto.randomUUID()}`,
target_space_ref: targetSpace.trim(),
max_items: 100,
supported_macros: lines(supportedMacros),
existing_targets: parseArray(existingTargets, "Existing targets")
});
setMigration(result);
setMigrationOpen(false);
setSuccess(result.can_apply
? "Migration preview is complete and contains no blocking conflict. It did not write Wiki pages."
: "Migration preview found conflicts, errors, or truncation. It did not write Wiki pages.");
await reload(selected.id);
} catch (caught) {
setError(errorMessage(caught));
} finally {
setBusy(false);
}
};
const publish = async () => {
if (!selected || !externalPageId.trim() || !publishTitle.trim()) return;
setBusy(true);
setError("");
try {
const result = await publishKnowledgePage(
settings,
selected.id,
externalPageId.trim(),
{
idempotency_key: `knowledge-publish-${crypto.randomUUID()}`,
title: publishTitle.trim(),
body: publishBody,
summary: publishSummary.trim(),
expected_external_revision: publishRevision.trim() || null,
minor: false
}
);
setPublishOpen(false);
setSuccess(result.outcome_unknown
? "Publication outcome is unknown. Reconcile the provider revision before retrying."
: "Provider accepted the page revision and durable recovery evidence was recorded.");
await reload(selected.id);
} catch (caught) {
setError(errorMessage(caught));
} finally {
setBusy(false);
}
};
const visibleProfiles = useMemo(() => {
const needle = search.trim().toLocaleLowerCase();
return profiles.filter((item) => !needle ||
`${item.product} ${item.product_version ?? ""} ${item.health_status} ${item.configuration_id}`
.toLocaleLowerCase()
.includes(needle));
}, [profiles, search]);
const actionBar = <PageActionBar
variant="editor"
state={busy ? "saving" : dirty ? "dirty" : "clean"}
refreshable
reloadAction={{ onReload: () => void reload(selectedId), loading }}
primaryActions={<>
<Button onClick={() => setCreateOpen(true)} disabled={!canAdmin || busy}>New profile</Button>
<Button variant="secondary" onClick={() => void discover()} disabled={!selected || !canAdmin || busy || dirty}>Discover</Button>
<Button variant="secondary" onClick={() => void sync(false)} disabled={!selected || !canSync || busy || dirty}>Run delta</Button>
<Button variant="secondary" onClick={() => void sync(true)} disabled={!selected || !canSync || busy || dirty}>Run full backfill</Button>
<Button variant="secondary" onClick={() => setMigrationOpen(true)} disabled={!selected || !canMigrate || busy || dirty}>Preview migration</Button>
<Button variant="primary" helpContextId="connectors.admin.external-knowledge" helpModuleId="connectors" onClick={() => setPublishOpen(true)} disabled={!selected || !canPublish || busy || dirty}>Publish page</Button>
</>}
discardAction={{
label: "Discard changes",
disabled: !selected,
onClick: () => applyProfile(selected)
}}
saveAction={{
label: "Save",
disabled: !selected || !canAdmin || busy,
disabledReason: !canAdmin ? "External knowledge administration permission is required." : undefined,
onClick: () => void save()
}}
/>;
return <AdminPageLayout
archetype="workspace"
title="External knowledge"
description="Discover and synchronize MediaWiki or BlueSpice, preserve stable identity and permissions, and preview migration into native Wiki."
loading={loading && !profiles.length}
error={error}
success={success}
actions={actionBar}
className="connector-knowledge-page"
helpContextId="connectors.admin.external-knowledge"
>
<MetricGrid columns={4} density="compact" minimum="compact">
<MetricCard label="Profiles" value={profiles.length} />
<MetricCard label="Active pages" value={objects.filter((item) => item.status !== "deleted").length} />
<MetricCard label="Unhealthy profiles" value={profiles.filter((item) => !["healthy", "unknown"].includes(item.health_status)).length} tone="warning" />
<MetricCard label="Unresolved runs" value={runs.filter((item) => ["failed", "outcome_unknown"].includes(item.status)).length} tone="warning" />
</MetricGrid>
<WorkspaceLayout
variant="split"
primarySize="compact"
surface="contained"
primaryScrollable={false}
contentScrollable={false}
primaryLabel="Knowledge profiles"
contentLabel="Profile details"
primary={<div className="connector-knowledge-list">
<FilterBar surface="panel">
<input type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search profiles" aria-label="Search external knowledge profiles" />
</FilterBar>
<SelectionList variant="navigation" label="External knowledge profiles">
{visibleProfiles.map((profile) => <SelectionListItem key={profile.id} selected={profile.id === selectedId} onClick={() => selectProfile(profile)}>
<SelectionListItemContent
title={`${profile.product}${profile.product_version ? ` ${profile.product_version}` : ""}`}
description={`${profile.discovered_maturity} · ${profile.configuration_id}`}
/>
<StatusBadge status={profile.status === "paused" ? "inactive" : profile.health_status} />
</SelectionListItem>)}
{!visibleProfiles.length ? <StatePanel size="compact" description="No matching knowledge profiles." /> : null}
</SelectionList>
</div>}
>
{!selected ? <StatePanel size="fill" title="External knowledge profiles" description="Create or select a profile to discover provider capabilities and inspect synchronization evidence." /> : <div className="connector-knowledge-detail">
<Card title={`${selected.product}${selected.product_version ? ` ${selected.product_version}` : ""}`}>
<div className="connector-revision-line">
<StatusBadge status={selected.status} />
<StatusBadge status={selected.health_status} />
<span>Discovered maturity: {selected.discovered_maturity}</span>
<code title={selected.discovery_revision ?? undefined}>r{selected.resource_revision}</code>
</div>
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Status" hint="Pausing immediately makes Search authorization fail closed.">
<select value={draft.status} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, status: event.target.value as ProfileDraft["status"] })}>
<option value="active">Active</option>
<option value="paused">Paused</option>
</select>
</FormField>
<FormField label="Desired maturity" hint="This is the operator ceiling even when the provider offers more.">
<select value={draft.desired_maturity} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, desired_maturity: event.target.value as ProfileDraft["desired_maturity"] })}>
{['discover', 'link', 'search', 'read', 'publish', 'synchronize', 'migrate'].map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</FormField>
<FormField label="Source authority">
<select value={draft.source_authority_mode} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, source_authority_mode: event.target.value as ProfileDraft["source_authority_mode"] })}>
<option value="external_authoritative">External authoritative</option>
<option value="external_mirror">External mirror</option>
<option value="governed_sync">Governed sync</option>
<option value="linked_reference">Linked reference</option>
</select>
</FormField>
<FormField label="Fallback visibility">
<select value={draft.default_visibility} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, default_visibility: event.target.value as ProfileDraft["default_visibility"] })}>
<option value="restricted">Restricted</option>
<option value="tenant">Tenant</option>
</select>
</FormField>
<FormField label="Fallback ACL tokens" hint="One account, membership, identity, group, role, function, or scope token per line.">
<textarea rows={6} value={draft.default_acl_tokens} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, default_acl_tokens: event.target.value })} />
</FormField>
<FormField label="Namespace mappings" hint="JSON array; maps source namespace ids to target Wiki space references and path prefixes.">
<textarea rows={12} value={draft.namespace_mappings} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, namespace_mappings: event.target.value })} />
</FormField>
</FormGrid>
<p className="muted">Capabilities: {selected.capabilities.length ? selected.capabilities.join(", ") : "run discovery"}</p>
<p className="muted">Last high-watermark: {selected.last_high_watermark ?? "none"} · credential reference: {selected.credential_reference_present ? "configured" : "not configured"}</p>
</Card>
<Card title="Synchronized pages">
<SelectionList variant="static" label="Synchronized external knowledge pages">
{objects.slice(0, 100).map((item) => <SelectionListItem key={item.id}>
<SelectionListItemContent title={item.title} description={`${item.status} · revision ${item.source_revision} · ${item.visibility}`} />
{item.canonical_url ? <a href={item.canonical_url} target="_blank" rel="noreferrer">Open source</a> : null}
</SelectionListItem>)}
{!objects.length ? <StatePanel size="compact" description="No synchronized pages. Run a full backfill after discovery." /> : null}
</SelectionList>
</Card>
<Card title="Synchronization and migration evidence">
<SelectionList variant="static" label="Knowledge connector runs">
{runs.map((run) => <SelectionListItem key={run.id}>
<SelectionListItemContent title={`${run.mode.replaceAll("_", " ")} · ${run.status}`} description={`${formatDateTime(run.started_at)} · ${effectTotal(run)} effects · ${run.diagnostics.length} diagnostics`} />
<StatusBadge status={run.status} />
</SelectionListItem>)}
{!runs.length ? <StatePanel size="compact" description="No knowledge connector runs have been recorded." /> : null}
</SelectionList>
</Card>
{migration ? <Card title="Latest migration preview">
<div className="connector-revision-line">
<StatusBadge status={migration.can_apply ? "success" : "warning"} label={migration.can_apply ? "No blocking conflict" : "Review required"} />
<span>{migration.effects.length} effects</span>
<span>{migration.diagnostics.length} diagnostics</span>
<code title={migration.source_fingerprint}>{migration.source_fingerprint.slice(0, 12)}</code>
</div>
<p className="muted">Preview only: no native Wiki page was written.</p>
<pre className="connector-json-preview">{JSON.stringify({ summary: migration.summary, diagnostics: migration.diagnostics, truncated: migration.truncated }, null, 2)}</pre>
</Card> : null}
</div>}
</WorkspaceLayout>
<Dialog open={createOpen} title="Create external knowledge profile" onClose={() => !busy && setCreateOpen(false)} closeDisabled={busy} footer={<>
<Button onClick={() => setCreateOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={() => void createProfile()} disabled={busy || !configurationId.trim()}>Create profile</Button>
</>}>
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Governed configuration id" hint="Select an active MediaWiki Action API configuration from Connector governance.">
<input value={configurationId} disabled={busy} onChange={(event) => setConfigurationId(event.target.value)} />
</FormField>
<FormField label="Desired maturity">
<select value={newDraft.desired_maturity} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, desired_maturity: event.target.value as ProfileDraft["desired_maturity"] })}>
{['discover', 'link', 'search', 'read', 'publish', 'synchronize', 'migrate'].map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</FormField>
<FormField label="Fallback visibility">
<select value={newDraft.default_visibility} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, default_visibility: event.target.value as ProfileDraft["default_visibility"] })}>
<option value="restricted">Restricted</option>
<option value="tenant">Tenant</option>
</select>
</FormField>
<FormField label="Fallback ACL tokens">
<textarea rows={5} value={newDraft.default_acl_tokens} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, default_acl_tokens: event.target.value })} />
</FormField>
<FormField label="Namespace mappings">
<textarea rows={12} value={newDraft.namespace_mappings} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, namespace_mappings: event.target.value })} />
</FormField>
</FormGrid>
</Dialog>
<Dialog open={migrationOpen} title="Preview migration into native Wiki" onClose={() => !busy && setMigrationOpen(false)} closeDisabled={busy} footer={<>
<Button onClick={() => setMigrationOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={() => void previewMigration()} disabled={busy || !targetSpace.trim()}>Run migration preview</Button>
</>}>
<p className="muted">This dry-run detects path, attachment, macro, and truncation problems. It never writes Wiki pages.</p>
<FormField label="Target Wiki space reference"><input value={targetSpace} disabled={busy} onChange={(event) => setTargetSpace(event.target.value)} /></FormField>
<FormField label="Supported macros" hint="One macro name per line."><textarea rows={5} value={supportedMacros} disabled={busy} onChange={(event) => setSupportedMacros(event.target.value)} /></FormField>
<FormField label="Existing targets" hint="JSON array with path, optional source_external_id, and attachment_names."><textarea rows={10} value={existingTargets} disabled={busy} onChange={(event) => setExistingTargets(event.target.value)} /></FormField>
</Dialog>
<Dialog open={publishOpen} title="Publish provider page revision" onClose={() => !busy && setPublishOpen(false)} closeDisabled={busy} footer={<>
<Button onClick={() => setPublishOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="primary" helpContextId="connectors.admin.external-knowledge" helpModuleId="connectors" onClick={() => void publish()} disabled={busy || !externalPageId.trim() || !publishTitle.trim()}>Publish revision</Button>
</>}>
<p className="muted">Publication is an external effect. Supply the current provider revision where possible; an unknown outcome blocks blind retry.</p>
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Stable external page id"><input value={externalPageId} disabled={busy} onChange={(event) => setExternalPageId(event.target.value)} /></FormField>
<FormField label="Expected provider revision"><input value={publishRevision} disabled={busy} onChange={(event) => setPublishRevision(event.target.value)} /></FormField>
<FormField label="Title"><input value={publishTitle} disabled={busy} onChange={(event) => setPublishTitle(event.target.value)} /></FormField>
<FormField label="Edit summary"><input value={publishSummary} disabled={busy} onChange={(event) => setPublishSummary(event.target.value)} /></FormField>
<FormField label="Wikitext body"><textarea rows={16} value={publishBody} disabled={busy} onChange={(event) => setPublishBody(event.target.value)} /></FormField>
</FormGrid>
</Dialog>
</AdminPageLayout>;
}
function draftFromProfile(profile: KnowledgeProfile): ProfileDraft {
return {
status: profile.status,
desired_maturity: profile.desired_maturity,
source_authority_mode: profile.source_authority_mode,
default_visibility: profile.default_visibility,
default_acl_tokens: profile.default_acl_tokens.join("\n"),
namespace_mappings: JSON.stringify(profile.namespace_mappings, null, 2)
};
}
function draftKey(draft: ProfileDraft): string {
return JSON.stringify({
...draft,
default_acl_tokens: lines(draft.default_acl_tokens),
namespace_mappings: normalizeJson(draft.namespace_mappings)
});
}
function lines(value: string): string[] {
return [...new Set(value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean))];
}
function normalizeJson(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value.trim();
}
}
function parseArray(value: string, label: string): unknown[] {
const parsed: unknown = JSON.parse(value);
if (!Array.isArray(parsed)) throw new Error(`${label} must be a JSON array.`);
return parsed;
}
function effectTotal(run: KnowledgeRun): number {
return Object.values(run.counts).reduce((total, value) => total + Number(value || 0), 0);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}