562 lines
26 KiB
TypeScript
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);
|
|
}
|