Release Connectors v0.1.22 with service-desk federation
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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user