Release Connectors v0.1.22 with service-desk federation
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-22 16:11:07 +02:00
parent 79e2315e89
commit b277e8d7ac
22 changed files with 7348 additions and 13 deletions
+208
View File
@@ -0,0 +1,208 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type ServiceDeskDiagnostic = {
severity: "info" | "warning" | "error";
code: string;
message: string;
object_ref?: string | null;
field?: string | null;
retryable: boolean;
details: Record<string, unknown>;
};
export type ServiceDeskRouteMapping = {
search_path: string;
ticket_path: string;
update_path?: string | null;
search_method: "GET" | "POST";
ticket_method: "GET" | "POST";
update_method: "PATCH" | "POST" | "PUT";
ticket_web_url_template?: string | null;
search_filters: Record<string, unknown>;
};
export type ServiceDeskQueueMapping = {
source_queue: string;
target_queue_ref?: string | null;
include: boolean;
visibility: "tenant" | "restricted";
acl_tokens: string[];
};
export type ServiceDeskDynamicFieldMapping = {
source_name: string;
target_name?: string | null;
include: boolean;
value_type: "string" | "number" | "boolean" | "date" | "json";
};
export type ServiceDeskProfile = {
id: string;
tenant_id: string;
configuration_id: string;
status: "active" | "paused";
integration_mode: "link" | "import" | "synchronize";
product: string;
product_version?: string | null;
desired_maturity: "discover" | "link" | "search" | "read" | "publish" | "synchronize";
discovered_maturity: string;
source_authority_mode: "external_authoritative" | "external_mirror" | "governed_sync" | "linked_reference";
default_visibility: "tenant" | "restricted";
default_acl_tokens: string[];
routes: ServiceDeskRouteMapping;
queue_mappings: ServiceDeskQueueMapping[];
dynamic_field_mappings: ServiceDeskDynamicFieldMapping[];
capabilities: string[];
discovery_revision?: string | null;
discovered_configuration_revision?: number | null;
discovered_configuration_hash?: 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 ServiceDeskObject = {
id: string;
profile_id: string;
object_type: string;
external_id: string;
external_ticket_number?: string | null;
title: string;
canonical_url?: string | null;
status: string;
source_revision: string;
visibility: string;
acl_tokens: string[];
mapped_data: Record<string, unknown>;
provenance: Record<string, unknown>;
source_updated_at?: string | null;
observed_at: string;
resource_revision: number;
};
export type ServiceDeskRun = {
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: ServiceDeskDiagnostic[];
provenance: Record<string, unknown>;
started_at: string;
finished_at?: string | null;
created_at: string;
};
export type ServiceDeskDiscovery = {
profile: ServiceDeskProfile;
product: string;
product_version?: string | null;
api_family: string;
capabilities: string[];
maturity: string;
health_status: string;
diagnostics: ServiceDeskDiagnostic[];
revision: string;
};
export type ServiceDeskTicketUpdateResult = {
run: ServiceDeskRun;
object: ServiceDeskObject;
accepted: boolean;
outcome_unknown: boolean;
};
const ROOT = "/api/v1/connectors/service-desk";
export async function listServiceDeskProfiles(settings: ApiSettings): Promise<ServiceDeskProfile[]> {
const response = await apiFetch<{ items: ServiceDeskProfile[] }>(settings, `${ROOT}/profiles`);
return response.items;
}
export function createServiceDeskProfile(
settings: ApiSettings,
payload: Record<string, unknown>
): Promise<ServiceDeskProfile> {
return apiFetch(settings, `${ROOT}/profiles`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateServiceDeskProfile(
settings: ApiSettings,
profileId: string,
payload: Record<string, unknown>
): Promise<ServiceDeskProfile> {
return apiFetch(settings, `${ROOT}/profiles/${encodeURIComponent(profileId)}`, {
method: "PUT",
body: JSON.stringify(payload)
});
}
export function discoverServiceDeskProfile(
settings: ApiSettings,
profileId: string
): Promise<ServiceDeskDiscovery> {
return apiFetch(settings, `${ROOT}/profiles/${encodeURIComponent(profileId)}/discover`, {
method: "POST"
});
}
export function synchronizeServiceDeskProfile(
settings: ApiSettings,
profileId: string,
payload: Record<string, unknown>
): Promise<ServiceDeskRun> {
return apiFetch(settings, `${ROOT}/profiles/${encodeURIComponent(profileId)}/sync`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listServiceDeskObjects(
settings: ApiSettings,
profileId: string
): Promise<ServiceDeskObject[]> {
const response = await apiFetch<{ items: ServiceDeskObject[] }>(
settings,
apiPath(`${ROOT}/profiles/${encodeURIComponent(profileId)}/objects`, { limit: 100 })
);
return response.items;
}
export async function listServiceDeskRuns(
settings: ApiSettings,
profileId?: string
): Promise<ServiceDeskRun[]> {
const response = await apiFetch<{ items: ServiceDeskRun[] }>(
settings,
apiPath(`${ROOT}/runs`, { profile_id: profileId || undefined, limit: 100 })
);
return response.items;
}
export function updateServiceDeskTicket(
settings: ApiSettings,
profileId: string,
externalTicketId: string,
payload: Record<string, unknown>
): Promise<ServiceDeskTicketUpdateResult> {
return apiFetch(
settings,
`${ROOT}/profiles/${encodeURIComponent(profileId)}/tickets/${encodeURIComponent(externalTicketId)}/update`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
@@ -0,0 +1,573 @@
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 {
createServiceDeskProfile,
discoverServiceDeskProfile,
listServiceDeskObjects,
listServiceDeskProfiles,
listServiceDeskRuns,
synchronizeServiceDeskProfile,
updateServiceDeskProfile,
updateServiceDeskTicket,
type ServiceDeskObject,
type ServiceDeskProfile,
type ServiceDeskRun
} from "../api/externalServiceDesk";
type Props = { settings: ApiSettings; auth: AuthInfo };
type ProfileDraft = {
status: "active" | "paused";
integration_mode: ServiceDeskProfile["integration_mode"];
desired_maturity: ServiceDeskProfile["desired_maturity"];
source_authority_mode: ServiceDeskProfile["source_authority_mode"];
default_visibility: ServiceDeskProfile["default_visibility"];
default_acl_tokens: string;
routes: string;
queue_mappings: string;
dynamic_field_mappings: string;
};
const DEFAULT_ROUTES = {
search_path: "/Ticket/Search",
ticket_path: "/Ticket/{ticket_id}",
update_path: null,
search_method: "POST",
ticket_method: "GET",
update_method: "PATCH",
ticket_web_url_template: null,
search_filters: {}
};
const EMPTY_DRAFT: ProfileDraft = {
status: "active",
integration_mode: "synchronize",
desired_maturity: "synchronize",
source_authority_mode: "external_authoritative",
default_visibility: "restricted",
default_acl_tokens: "scope:connectors:service_desk:read",
routes: JSON.stringify(DEFAULT_ROUTES, null, 2),
queue_mappings: JSON.stringify([], null, 2),
dynamic_field_mappings: JSON.stringify([], null, 2)
};
export default function ExternalServiceDeskPage({ settings, auth }: Props) {
const [profiles, setProfiles] = useState<ServiceDeskProfile[]>([]);
const [objects, setObjects] = useState<ServiceDeskObject[]>([]);
const [runs, setRuns] = useState<ServiceDeskRun[]>([]);
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 [updateOpen, setUpdateOpen] = useState(false);
const [updateObjectId, setUpdateObjectId] = useState("");
const [updateTitle, setUpdateTitle] = useState("");
const [updateQueue, setUpdateQueue] = useState("");
const [updateState, setUpdateState] = useState("");
const [updatePriority, setUpdatePriority] = useState("");
const [updateOwner, setUpdateOwner] = useState("");
const [updateResponsible, setUpdateResponsible] = useState("");
const [updateDynamicFields, setUpdateDynamicFields] = useState("{}");
const { requestDiscard } = useUnsavedChanges();
const selected = profiles.find((item) => item.id === selectedId) ?? null;
const selectedObject = objects.find((item) => item.id === updateObjectId) ?? null;
const canAdmin = hasScope(auth, "connectors:service_desk:admin");
const canSync = hasScope(auth, "connectors:service_desk:sync");
const canUpdate = hasScope(auth, "connectors:service_desk:update");
const dirty = Boolean(selected && draftKey(draft) !== savedKey);
const applyProfile = useCallback((profile: ServiceDeskProfile | 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 listServiceDeskProfiles(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([
listServiceDeskObjects(settings, nextId),
listServiceDeskRuns(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 updateServiceDeskProfile(settings, selected.id, {
expected_resource_revision: selected.resource_revision,
status: draft.status,
integration_mode: draft.integration_mode,
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),
routes: parseObject(draft.routes, "Routes"),
queue_mappings: parseArray(draft.queue_mappings, "Queue mappings"),
dynamic_field_mappings: parseArray(draft.dynamic_field_mappings, "Dynamic-field mappings")
});
setSuccess("Service-desk profile saved; queue 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 service-desk profile changes",
message: "Save or discard the profile changes before continuing."
});
const selectProfile = (profile: ServiceDeskProfile) => {
if (profile.id === selectedId) return;
requestDiscard(() => {
setSelectedId(profile.id);
applyProfile(profile);
setObjects([]);
setRuns([]);
void Promise.all([
listServiceDeskObjects(settings, profile.id),
listServiceDeskRuns(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 createServiceDeskProfile(settings, {
configuration_id: configurationId.trim(),
integration_mode: newDraft.integration_mode,
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),
routes: parseObject(newDraft.routes, "Routes"),
queue_mappings: parseArray(newDraft.queue_mappings, "Queue mappings"),
dynamic_field_mappings: parseArray(newDraft.dynamic_field_mappings, "Dynamic-field mappings")
});
setCreateOpen(false);
setConfigurationId("");
setNewDraft(EMPTY_DRAFT);
setSuccess("Service-desk 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 discoverServiceDeskProfile(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 (mode: "auto" | "full") => {
if (!selected || dirty) return;
setBusy(true);
setError("");
try {
const run = await synchronizeServiceDeskProfile(settings, selected.id, {
idempotency_key: `service-desk-${mode}-${crypto.randomUUID()}`,
mode,
limit: 100
});
setSuccess(`${mode === "full" ? "Full synchronization" : "Next synchronization page"} completed with ${effectTotal(run)} effects.`);
await reload(selected.id);
} catch (caught) {
setError(errorMessage(caught));
} finally {
setBusy(false);
}
};
const openTicketUpdate = (item: ServiceDeskObject) => {
setUpdateObjectId(item.id);
setUpdateTitle("");
setUpdateQueue("");
setUpdateState("");
setUpdatePriority("");
setUpdateOwner("");
setUpdateResponsible("");
setUpdateDynamicFields("{}");
setUpdateOpen(true);
};
const submitTicketUpdate = async () => {
if (!selected || !selectedObject) return;
const dynamicFields = parseObject(updateDynamicFields, "Dynamic fields");
const changes = compact({
title: updateTitle,
queue: updateQueue,
state: updateState,
priority: updatePriority,
owner: updateOwner,
responsible: updateResponsible
});
if (!Object.keys(changes).length && !Object.keys(dynamicFields).length) {
setError("Enter at least one governed ticket change.");
return;
}
setBusy(true);
setError("");
try {
const result = await updateServiceDeskTicket(
settings,
selected.id,
selectedObject.external_id,
{
idempotency_key: `service-desk-update-${crypto.randomUUID()}`,
expected_external_revision: selectedObject.source_revision,
...changes,
dynamic_fields: dynamicFields
}
);
setUpdateOpen(false);
setSuccess(result.outcome_unknown
? "Update outcome is unknown. Inspect the provider revision before retrying."
: "Provider accepted the revision-checked ticket update and durable 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.integration_mode} ${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("auto")} disabled={!selected || !canSync || busy || dirty}>Run next page</Button>
<Button variant="secondary" onClick={() => void sync("full")} disabled={!selected || !canSync || busy || dirty}>Restart full sync</Button>
</>}
discardAction={{
label: "Discard changes",
disabled: !selected,
onClick: () => applyProfile(selected)
}}
saveAction={{
label: "Save",
disabled: !selected || !canAdmin || busy,
disabledReason: !canAdmin ? "Service-desk administration permission is required." : undefined,
onClick: () => void save()
}}
/>;
return <AdminPageLayout
archetype="workspace"
title="External service desk"
description="Connect Znuny or OTRS-compatible tickets while preserving provider identity, source authority, current ACLs, and domain-module boundaries."
loading={loading && !profiles.length}
error={error}
success={success}
actions={actionBar}
className="connector-service-desk-page"
helpContextId="connectors.admin.external-service-desk"
>
<MetricGrid columns={4} density="compact" minimum="compact">
<MetricCard label="Profiles" value={profiles.length} />
<MetricCard label="Active tickets" 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="Service-desk 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 service-desk profiles" />
</FilterBar>
<SelectionList variant="navigation" label="External service-desk 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.integration_mode} · ${profile.discovered_maturity} · ${profile.configuration_id}`}
/>
<StatusBadge status={profile.status === "paused" ? "inactive" : profile.health_status} />
</SelectionListItem>)}
{!visibleProfiles.length ? <StatePanel size="compact" description="No matching service-desk profiles." /> : null}
</SelectionList>
</div>}
>
{!selected ? <StatePanel size="fill" title="External service-desk profiles" description="Create or select a profile to discover deployment routes 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="Integration mode" hint="Link and import are intentionally not continuous bidirectional synchronization.">
<select value={draft.integration_mode} disabled={!canAdmin || busy} onChange={(event) => setDraft(withMode(draft, event.target.value as ProfileDraft["integration_mode"]))}>
<option value="link">Link</option><option value="import">Import snapshot</option><option value="synchronize">Synchronize</option>
</select>
</FormField>
<FormField label="Desired maturity" hint="Link permits link/search, import requires read, synchronize requires synchronize.">
<select value={draft.desired_maturity} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, desired_maturity: event.target.value as ProfileDraft["desired_maturity"] })}>
{maturityOptions(draft.integration_mode).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"] })}>
{authorityOptions(draft.integration_mode).map(([value, label]) => <option key={value} value={value}>{label}</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="Used only when provider and queue mappings supply no portable ACL.">
<textarea rows={6} value={draft.default_acl_tokens} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, default_acl_tokens: event.target.value })} />
</FormField>
<FormField label="GenericInterface routes" hint="JSON object; deployment-defined relative paths and methods plus optional absolute browser-link template.">
<textarea rows={14} value={draft.routes} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, routes: event.target.value })} />
</FormField>
<FormField label="Queue mappings" hint="JSON array; inclusion, target queue ref, visibility, and ACL tokens.">
<textarea rows={14} value={draft.queue_mappings} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, queue_mappings: event.target.value })} />
</FormField>
<FormField label="Dynamic-field mappings" hint="JSON array; source name, governed target name, inclusion, and value type.">
<textarea rows={14} value={draft.dynamic_field_mappings} disabled={!canAdmin || busy} onChange={(event) => setDraft({ ...draft, dynamic_field_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 external tickets">
<SelectionList variant="static" label="Synchronized external service-desk tickets">
{objects.slice(0, 100).map((item) => <SelectionListItem key={item.id}>
<SelectionListItemContent title={`${item.external_ticket_number ? `${item.external_ticket_number}: ` : ""}${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}
<Button variant="secondary" onClick={() => openTicketUpdate(item)} disabled={!canUpdate || busy || dirty || selected.source_authority_mode !== "governed_sync" || !selected.capabilities.includes("publish") || item.status === "deleted"}>Update</Button>
</SelectionListItem>)}
{!objects.length ? <StatePanel size="compact" description="No synchronized tickets. Run discovery and a full synchronization." /> : null}
</SelectionList>
</Card>
<Card title="Synchronization and mutation evidence">
<SelectionList variant="static" label="Service-desk 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 service-desk connector runs have been recorded." /> : null}
</SelectionList>
</Card>
</div>}
</WorkspaceLayout>
<Dialog open={createOpen} title="Create external service-desk 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 Znuny/OTRS GenericInterface REST configuration from Connector governance.">
<input value={configurationId} disabled={busy} onChange={(event) => setConfigurationId(event.target.value)} />
</FormField>
<FormField label="Integration mode"><select value={newDraft.integration_mode} disabled={busy} onChange={(event) => setNewDraft(withMode(newDraft, event.target.value as ProfileDraft["integration_mode"]))}><option value="link">Link</option><option value="import">Import snapshot</option><option value="synchronize">Synchronize</option></select></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"] })}>{maturityOptions(newDraft.integration_mode).map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
<FormField label="Source authority"><select value={newDraft.source_authority_mode} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, source_authority_mode: event.target.value as ProfileDraft["source_authority_mode"] })}>{authorityOptions(newDraft.integration_mode).map(([value, label]) => <option key={value} value={value}>{label}</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="GenericInterface routes"><textarea rows={12} value={newDraft.routes} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, routes: event.target.value })} /></FormField>
<FormField label="Queue mappings"><textarea rows={12} value={newDraft.queue_mappings} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, queue_mappings: event.target.value })} /></FormField>
<FormField label="Dynamic-field mappings"><textarea rows={12} value={newDraft.dynamic_field_mappings} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, dynamic_field_mappings: event.target.value })} /></FormField>
</FormGrid>
</Dialog>
<Dialog open={updateOpen} title="Update external ticket" onClose={() => !busy && setUpdateOpen(false)} closeDisabled={busy} footer={<>
<Button onClick={() => setUpdateOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={() => void submitTicketUpdate()} disabled={busy || !selectedObject}>Submit revision-checked update</Button>
</>}>
<p className="muted">This is an external effect. Empty fields remain unchanged; an unknown result blocks blind retry and requires provider reconciliation.</p>
<p><strong>{selectedObject?.external_ticket_number}</strong> {selectedObject?.title}<br /><span className="muted">Expected provider revision: {selectedObject?.source_revision}</span></p>
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Title"><input value={updateTitle} disabled={busy} onChange={(event) => setUpdateTitle(event.target.value)} /></FormField>
<FormField label="Queue"><input value={updateQueue} disabled={busy} onChange={(event) => setUpdateQueue(event.target.value)} /></FormField>
<FormField label="State"><input value={updateState} disabled={busy} onChange={(event) => setUpdateState(event.target.value)} /></FormField>
<FormField label="Priority"><input value={updatePriority} disabled={busy} onChange={(event) => setUpdatePriority(event.target.value)} /></FormField>
<FormField label="Owner"><input value={updateOwner} disabled={busy} onChange={(event) => setUpdateOwner(event.target.value)} /></FormField>
<FormField label="Responsible"><input value={updateResponsible} disabled={busy} onChange={(event) => setUpdateResponsible(event.target.value)} /></FormField>
<FormField label="Governed dynamic fields" hint="JSON object keyed by configured target name."><textarea rows={8} value={updateDynamicFields} disabled={busy} onChange={(event) => setUpdateDynamicFields(event.target.value)} /></FormField>
</FormGrid>
</Dialog>
</AdminPageLayout>;
}
function draftFromProfile(profile: ServiceDeskProfile): ProfileDraft {
return {
status: profile.status,
integration_mode: profile.integration_mode,
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"),
routes: JSON.stringify(profile.routes, null, 2),
queue_mappings: JSON.stringify(profile.queue_mappings, null, 2),
dynamic_field_mappings: JSON.stringify(profile.dynamic_field_mappings, null, 2)
};
}
function withMode(draft: ProfileDraft, mode: ProfileDraft["integration_mode"]): ProfileDraft {
if (mode === "link") return { ...draft, integration_mode: mode, desired_maturity: "link", source_authority_mode: "linked_reference" };
if (mode === "import") return { ...draft, integration_mode: mode, desired_maturity: "read", source_authority_mode: "external_mirror" };
return { ...draft, integration_mode: mode, desired_maturity: "synchronize", source_authority_mode: "external_authoritative" };
}
function maturityOptions(mode: ProfileDraft["integration_mode"]): ProfileDraft["desired_maturity"][] {
if (mode === "link") return ["link", "search"];
if (mode === "import") return ["read"];
return ["synchronize"];
}
function authorityOptions(mode: ProfileDraft["integration_mode"]): Array<[ProfileDraft["source_authority_mode"], string]> {
if (mode === "link") return [["linked_reference", "Linked reference"]];
if (mode === "import") return [["external_authoritative", "External authoritative"], ["external_mirror", "External mirror"]];
return [["external_authoritative", "External authoritative"], ["governed_sync", "Governed sync"]];
}
function draftKey(draft: ProfileDraft): string {
return JSON.stringify({
...draft,
default_acl_tokens: lines(draft.default_acl_tokens),
routes: normalizeJson(draft.routes),
queue_mappings: normalizeJson(draft.queue_mappings),
dynamic_field_mappings: normalizeJson(draft.dynamic_field_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 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 compact(values: Record<string, string>): Record<string, string> {
return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value));
}
function effectTotal(run: ServiceDeskRun): 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
View File
@@ -11,6 +11,9 @@ const ConnectorGovernancePage = lazy(
const ExternalKnowledgePage = lazy(
() => import("./features/ExternalKnowledgePage")
);
const ExternalServiceDeskPage = lazy(
() => import("./features/ExternalServiceDeskPage")
);
const readScopes = [
"connectors:source:read",
@@ -20,6 +23,10 @@ const knowledgeReadScopes = [
"connectors:knowledge:read",
"connectors:knowledge:admin"
];
const serviceDeskReadScopes = [
"connectors:service_desk:read",
"connectors:service_desk:admin"
];
const adminSections: AdminSectionsUiCapability = {
sections: [
@@ -46,6 +53,18 @@ const adminSections: AdminSectionsUiCapability = {
anyOf: knowledgeReadScopes,
render: ({ settings, auth }) =>
createElement(ExternalKnowledgePage, { settings, auth })
},
{
id: "connector-external-service-desk",
moduleId: "connectors",
kind: "management",
surfaceId: "connectors.admin.external-service-desk",
label: "External service desk",
group: "SYSTEM",
order: 47,
anyOf: serviceDeskReadScopes,
render: ({ settings, auth }) =>
createElement(ExternalServiceDeskPage, { settings, auth })
}
]
};
@@ -53,9 +72,9 @@ const adminSections: AdminSectionsUiCapability = {
export const connectorsModule: PlatformWebModule = {
id: "connectors",
label: "Connectors",
version: "0.1.21",
version: "0.1.22",
dependencies: [],
optionalDependencies: ["access", "audit", "policy", "ops", "search", "wiki"],
optionalDependencies: ["access", "audit", "policy", "ops", "search", "wiki", "tickets", "helpdesk", "cases"],
viewSurfaces: [
{
id: "connectors.admin.governed-configurations",
@@ -79,6 +98,14 @@ export const connectorsModule: PlatformWebModule = {
label: "External knowledge",
parentId: "connectors.admin.governed-configurations",
order: 30
},
{
id: "connectors.admin.external-service-desk",
moduleId: "connectors",
kind: "section",
label: "External service desk",
parentId: "connectors.admin.governed-configurations",
order: 40
}
],
uiCapabilities: {