Release Connectors v0.1.21 with external knowledge integration
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type KnowledgeDiagnostic = {
|
||||
severity: "info" | "warning" | "error";
|
||||
code: string;
|
||||
message: string;
|
||||
object_ref?: string | null;
|
||||
field?: string | null;
|
||||
retryable: boolean;
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type KnowledgeNamespaceMapping = {
|
||||
source_namespace_id: number;
|
||||
source_name: string;
|
||||
target_space_ref: string;
|
||||
target_path_prefix: string;
|
||||
include: boolean;
|
||||
visibility?: "tenant" | "restricted" | null;
|
||||
acl_tokens: string[];
|
||||
};
|
||||
|
||||
export type KnowledgeProfile = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
configuration_id: string;
|
||||
status: "active" | "paused";
|
||||
product: string;
|
||||
product_version?: string | null;
|
||||
desired_maturity: "discover" | "link" | "search" | "read" | "publish" | "synchronize" | "migrate";
|
||||
discovered_maturity: string;
|
||||
source_authority_mode: "external_authoritative" | "external_mirror" | "governed_sync" | "linked_reference";
|
||||
default_visibility: "tenant" | "restricted";
|
||||
default_acl_tokens: string[];
|
||||
namespace_mappings: KnowledgeNamespaceMapping[];
|
||||
capabilities: string[];
|
||||
discovery_revision?: string | null;
|
||||
health_status: string;
|
||||
health_details: Record<string, unknown>;
|
||||
discovered_at?: string | null;
|
||||
last_sync_cursor?: string | null;
|
||||
last_high_watermark?: string | null;
|
||||
resource_revision: number;
|
||||
credential_reference_present: boolean;
|
||||
endpoint_configured: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type KnowledgeObject = {
|
||||
id: string;
|
||||
profile_id: string;
|
||||
object_type: string;
|
||||
external_id: string;
|
||||
external_page_id?: string | null;
|
||||
external_revision_id?: string | null;
|
||||
namespace_id?: number | null;
|
||||
title: string;
|
||||
canonical_url?: string | null;
|
||||
status: string;
|
||||
source_revision: string;
|
||||
visibility: string;
|
||||
acl_tokens: string[];
|
||||
mapped_data: Record<string, unknown>;
|
||||
observed_at: string;
|
||||
resource_revision: number;
|
||||
};
|
||||
|
||||
export type KnowledgeRun = {
|
||||
id: string;
|
||||
profile_id: string;
|
||||
mode: string;
|
||||
idempotency_key: string;
|
||||
status: string;
|
||||
cursor_before?: string | null;
|
||||
cursor_after?: string | null;
|
||||
high_watermark?: string | null;
|
||||
counts: Record<string, number>;
|
||||
effects: Array<Record<string, unknown>>;
|
||||
diagnostics: KnowledgeDiagnostic[];
|
||||
provenance: Record<string, unknown>;
|
||||
started_at: string;
|
||||
finished_at?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type KnowledgeDiscovery = {
|
||||
profile: KnowledgeProfile;
|
||||
product: string;
|
||||
product_version?: string | null;
|
||||
capabilities: string[];
|
||||
namespaces: Array<Record<string, unknown>>;
|
||||
extensions: Array<Record<string, unknown>>;
|
||||
maturity: string;
|
||||
health_status: string;
|
||||
diagnostics: KnowledgeDiagnostic[];
|
||||
revision: string;
|
||||
};
|
||||
|
||||
export type KnowledgeMigrationPreview = {
|
||||
run: KnowledgeRun;
|
||||
target_space_ref: string;
|
||||
source_revision: string;
|
||||
source_fingerprint: string;
|
||||
summary: Record<string, number>;
|
||||
effects: Array<Record<string, unknown>>;
|
||||
diagnostics: KnowledgeDiagnostic[];
|
||||
truncated: boolean;
|
||||
can_apply: boolean;
|
||||
};
|
||||
|
||||
export type KnowledgePublishResult = {
|
||||
run: KnowledgeRun;
|
||||
external_reference: Record<string, unknown>;
|
||||
accepted: boolean;
|
||||
outcome_unknown: boolean;
|
||||
};
|
||||
|
||||
const ROOT = "/api/v1/connectors/knowledge";
|
||||
|
||||
export async function listKnowledgeProfiles(settings: ApiSettings): Promise<KnowledgeProfile[]> {
|
||||
const response = await apiFetch<{ items: KnowledgeProfile[] }>(settings, `${ROOT}/profiles`);
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export function createKnowledgeProfile(
|
||||
settings: ApiSettings,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<KnowledgeProfile> {
|
||||
return apiFetch(settings, `${ROOT}/profiles`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateKnowledgeProfile(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<KnowledgeProfile> {
|
||||
return apiFetch(settings, `${ROOT}/profiles/${encodeURIComponent(profileId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function discoverKnowledgeProfile(
|
||||
settings: ApiSettings,
|
||||
profileId: string
|
||||
): Promise<KnowledgeDiscovery> {
|
||||
return apiFetch(settings, `${ROOT}/profiles/${encodeURIComponent(profileId)}/discover`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
|
||||
export function synchronizeKnowledgeProfile(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<KnowledgeRun> {
|
||||
return apiFetch(settings, `${ROOT}/profiles/${encodeURIComponent(profileId)}/sync`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function listKnowledgeObjects(
|
||||
settings: ApiSettings,
|
||||
profileId: string
|
||||
): Promise<KnowledgeObject[]> {
|
||||
const response = await apiFetch<{ items: KnowledgeObject[] }>(
|
||||
settings,
|
||||
apiPath(`${ROOT}/profiles/${encodeURIComponent(profileId)}/objects`, { limit: 100 })
|
||||
);
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function listKnowledgeRuns(
|
||||
settings: ApiSettings,
|
||||
profileId?: string
|
||||
): Promise<KnowledgeRun[]> {
|
||||
const response = await apiFetch<{ items: KnowledgeRun[] }>(
|
||||
settings,
|
||||
apiPath(`${ROOT}/runs`, { profile_id: profileId || undefined, limit: 100 })
|
||||
);
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export function previewKnowledgeMigration(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<KnowledgeMigrationPreview> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`${ROOT}/profiles/${encodeURIComponent(profileId)}/migration-dry-runs`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export function publishKnowledgePage(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
externalPageId: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<KnowledgePublishResult> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`${ROOT}/profiles/${encodeURIComponent(profileId)}/pages/${encodeURIComponent(externalPageId)}/publish`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
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" 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" 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);
|
||||
}
|
||||
+29
-2
@@ -8,11 +8,18 @@ import "./styles/connectors.css";
|
||||
const ConnectorGovernancePage = lazy(
|
||||
() => import("./features/ConnectorGovernancePage")
|
||||
);
|
||||
const ExternalKnowledgePage = lazy(
|
||||
() => import("./features/ExternalKnowledgePage")
|
||||
);
|
||||
|
||||
const readScopes = [
|
||||
"connectors:source:read",
|
||||
"connectors:source:admin"
|
||||
];
|
||||
const knowledgeReadScopes = [
|
||||
"connectors:knowledge:read",
|
||||
"connectors:knowledge:admin"
|
||||
];
|
||||
|
||||
const adminSections: AdminSectionsUiCapability = {
|
||||
sections: [
|
||||
@@ -27,6 +34,18 @@ const adminSections: AdminSectionsUiCapability = {
|
||||
anyOf: readScopes,
|
||||
render: ({ settings, auth }) =>
|
||||
createElement(ConnectorGovernancePage, { settings, auth })
|
||||
},
|
||||
{
|
||||
id: "connector-external-knowledge",
|
||||
moduleId: "connectors",
|
||||
kind: "management",
|
||||
surfaceId: "connectors.admin.external-knowledge",
|
||||
label: "External knowledge",
|
||||
group: "SYSTEM",
|
||||
order: 46,
|
||||
anyOf: knowledgeReadScopes,
|
||||
render: ({ settings, auth }) =>
|
||||
createElement(ExternalKnowledgePage, { settings, auth })
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -34,9 +53,9 @@ const adminSections: AdminSectionsUiCapability = {
|
||||
export const connectorsModule: PlatformWebModule = {
|
||||
id: "connectors",
|
||||
label: "Connectors",
|
||||
version: "0.1.18",
|
||||
version: "0.1.21",
|
||||
dependencies: [],
|
||||
optionalDependencies: ["access", "audit", "policy", "ops"],
|
||||
optionalDependencies: ["access", "audit", "policy", "ops", "search", "wiki"],
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "connectors.admin.governed-configurations",
|
||||
@@ -52,6 +71,14 @@ export const connectorsModule: PlatformWebModule = {
|
||||
label: "Connector simulation review",
|
||||
parentId: "connectors.admin.governed-configurations",
|
||||
order: 20
|
||||
},
|
||||
{
|
||||
id: "connectors.admin.external-knowledge",
|
||||
moduleId: "connectors",
|
||||
kind: "section",
|
||||
label: "External knowledge",
|
||||
parentId: "connectors.admin.governed-configurations",
|
||||
order: 30
|
||||
}
|
||||
],
|
||||
uiCapabilities: {
|
||||
|
||||
@@ -4,8 +4,15 @@
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.connector-knowledge-page .connector-knowledge-list,
|
||||
.connector-knowledge-page .connector-knowledge-detail {
|
||||
display: grid;
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.connector-governance-page .connector-revision-line,
|
||||
.connector-governance-page .connector-run-actions {
|
||||
.connector-governance-page .connector-run-actions,
|
||||
.connector-knowledge-page .connector-revision-line {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -13,7 +20,8 @@
|
||||
margin-bottom: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.connector-governance-page textarea {
|
||||
.connector-governance-page textarea,
|
||||
.connector-knowledge-page textarea {
|
||||
font-family: var(--font-family-mono, ui-monospace, monospace);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user