This commit is contained in:
Ole
2026-05-31 20:25:41 +00:00
commit 0a07ab8593
275 changed files with 52660 additions and 0 deletions
@@ -0,0 +1,855 @@
import type { CogniteClient } from "@cognite/sdk";
import type {
CDFEdge,
CDFNode,
DataModelInfo,
GraphData,
ReverseRelationQuery,
ViewPriorityConfig,
} from "./types";
// =============================================================================
// Node Type Detection
// =============================================================================
const DEFAULT_VIEW_TYPE_PRIORITY = [
"ISA95Asset",
"Equipment",
"Area",
"Site",
"Enterprise",
"WorkCell",
"WorkCenter",
"ProcessCell",
"ProcessArea",
"ProductionLine",
"ProductionRun",
"Product",
"WorkOrder",
"WorkUnit",
"QualityAlert",
"FaultCode",
"CogniteAsset",
"CogniteEquipment",
"CogniteFile",
"CogniteTimeSeries",
"CogniteActivity",
"Cognite3DModel",
"CogniteDescribable",
"CogniteSourceable",
"CogniteVisualizable",
"CogniteSchedulable",
];
const DEFAULT_SKIP_VIEWS_FOR_TYPE = new Set([
"CogniteDescribable",
"CogniteSourceable",
"CogniteVisualizable",
"CogniteSchedulable",
"CogniteSourceSystem",
]);
const DEFAULT_SKIP_VIEWS_FOR_PROPERTIES = new Set([
"CogniteSourceable",
"CogniteVisualizable",
"CogniteSchedulable",
]);
function deriveNodeTypeFromProperties(
properties: Record<string, Record<string, unknown>> | undefined,
viewPriorityConfig?: ViewPriorityConfig
): { space: string; externalId: string } | undefined {
if (!properties) return undefined;
const viewTypePriority = viewPriorityConfig?.viewTypePriority ?? DEFAULT_VIEW_TYPE_PRIORITY;
const skipViewsForType = viewPriorityConfig?.skipViewsForType
? new Set(viewPriorityConfig.skipViewsForType)
: DEFAULT_SKIP_VIEWS_FOR_TYPE;
const viewKeys: Array<{
space: string;
externalId: string;
priority: number;
}> = [];
for (const [spaceKey, viewsObj] of Object.entries(properties)) {
if (typeof viewsObj !== "object" || viewsObj === null) continue;
for (const viewKey of Object.keys(viewsObj)) {
const viewExternalId = viewKey.split("/")[0];
if (skipViewsForType.has(viewExternalId)) continue;
const priorityIndex = viewTypePriority.indexOf(viewExternalId);
const priority = priorityIndex >= 0 ? priorityIndex : 100;
viewKeys.push({
space: spaceKey,
externalId: viewExternalId,
priority,
});
}
}
if (viewKeys.length === 0) return undefined;
viewKeys.sort((a, b) => a.priority - b.priority);
return {
space: viewKeys[0].space,
externalId: viewKeys[0].externalId,
};
}
// =============================================================================
// Query with cursor-based pagination
// =============================================================================
const QUERY_PAGE_LIMIT = 10_000;
/**
* Paginate a CDF instances.query selection until either:
* 1. the API has no more cursors,
* 2. the cumulative number of items reaches `maxTotal` (hard cap), or
* 3. an empty page is returned.
*
* `maxTotal` is a HARD MAXIMUM. The function never returns more than `maxTotal`
* items, and it stops fetching additional pages as soon as the cap is reached.
* Pass `Infinity` to disable the cap (legacy "fetch everything" behaviour).
*/
async function queryWithCursorPagination<T>(
client: CogniteClient,
selectionName: string,
query: {
with: Record<
string,
{
nodes?: { filter?: unknown };
edges?: { filter?: unknown };
limit?: number;
}
>;
select: Record<string, { sources?: unknown[]; sort?: unknown[] }>;
includeTyping?: boolean;
},
limitPerPage: number = QUERY_PAGE_LIMIT,
maxTotal: number = Infinity
): Promise<T[]> {
const results: T[] = [];
let cursors: Record<string, string> | undefined;
while (results.length < maxTotal) {
const remaining = maxTotal - results.length;
const pageLimit = Math.max(1, Math.min(limitPerPage, remaining));
const withWithLimit = { ...query.with };
const firstKey = Object.keys(withWithLimit)[0];
if (firstKey && withWithLimit[firstKey]) {
withWithLimit[firstKey] = {
...withWithLimit[firstKey],
limit: pageLimit,
};
}
const request = {
...query,
with: withWithLimit,
cursors,
};
const res = await client.instances.query(
request as Parameters<CogniteClient["instances"]["query"]>[0]
);
const chunk = (res.items[selectionName] ?? []) as T[];
results.push(...chunk);
cursors =
res.nextCursor && Object.keys(res.nextCursor).length > 0
? (res.nextCursor as Record<string, string>)
: undefined;
if (chunk.length === 0 || !cursors?.[selectionName]) break;
}
return results.length > maxTotal ? results.slice(0, maxTotal) : results;
}
// =============================================================================
// fetchNodeDetails
// =============================================================================
export async function fetchNodeDetails(
client: CogniteClient | null,
space: string,
externalId: string
): Promise<CDFNode | null> {
if (!client) {
throw new Error("CDF client is not available");
}
try {
const inspectResult = await client.instances.inspect({
items: [
{
instanceType: "node" as const,
space,
externalId,
},
],
inspectionOperations: {
involvedViews: {
allVersions: false,
},
},
});
const involvedViews = inspectResult.items?.[0]?.inspectionResults?.involvedViews || [];
const sources = involvedViews.slice(0, 10).map((view) => ({
source: {
type: "view" as const,
space: view.space,
externalId: view.externalId,
version: view.version ?? "latest",
},
}));
const response = await client.instances.retrieve({
items: [
{
instanceType: "node" as const,
space,
externalId,
},
],
includeTyping: true,
sources: sources.length > 0 ? sources : undefined,
});
if (response.items.length === 0) {
return null;
}
const item = response.items[0];
const properties = item.properties as Record<string, Record<string, unknown>>;
const derivedType = deriveNodeTypeFromProperties(properties);
return {
space: item.space,
externalId: item.externalId,
instanceType: "node" as const,
version: item.version,
createdTime: item.createdTime,
lastUpdatedTime: item.lastUpdatedTime,
type: item.type || derivedType,
properties,
};
} catch (error) {
console.error("[GraphViewer] Error fetching node details:", error);
return null;
}
}
// =============================================================================
// fetchConnectedNodes
// =============================================================================
export interface ExpandNodeResult {
newNodes: CDFNode[];
newEdges: CDFEdge[];
connectedNodeIds: string[];
}
export async function fetchConnectedNodes(
client: CogniteClient | null,
nodeSpace: string,
nodeExternalId: string,
existingNodeIds: Set<string>,
_dataModel?: DataModelInfo,
limit = 100,
whitelistedRelationProps?: Set<string>,
coreReverseQueries?: ReverseRelationQuery[],
viewPriorityConfig?: ViewPriorityConfig
): Promise<ExpandNodeResult> {
if (!client) {
throw new Error("CDF client is not available. Please ensure you are authenticated.");
}
const extractDirectRelations = (
properties: Record<string, Record<string, unknown>> | undefined
): Array<{ space: string; externalId: string }> => {
if (!properties) return [];
const refs: Array<{ space: string; externalId: string }> = [];
const seen = new Set<string>();
const processValue = (val: unknown) => {
if (val && typeof val === "object" && "space" in val && "externalId" in val) {
const ref = val as { space: string; externalId: string };
if (typeof ref.space === "string" && typeof ref.externalId === "string") {
const key = `${ref.space}:${ref.externalId}`;
if (!seen.has(key)) {
seen.add(key);
refs.push({ space: ref.space, externalId: ref.externalId });
}
}
} else if (Array.isArray(val)) {
val.forEach(processValue);
}
};
for (const spaceObj of Object.values(properties)) {
if (typeof spaceObj !== "object" || spaceObj === null) continue;
for (const viewObj of Object.values(spaceObj)) {
if (typeof viewObj !== "object" || viewObj === null) continue;
for (const [propName, propVal] of Object.entries(viewObj as Record<string, unknown>)) {
if (!whitelistedRelationProps || whitelistedRelationProps.has(propName)) {
processValue(propVal);
}
}
}
}
return refs;
};
const [sourceNodeRefs, edgeResponse] = await Promise.all([
(async () => {
try {
const sourceInspect = await client.instances.inspect({
items: [
{
instanceType: "node" as const,
space: nodeSpace,
externalId: nodeExternalId,
},
],
inspectionOperations: { involvedViews: { allVersions: false } },
});
if (sourceInspect.items.length > 0) {
const involvedViews =
(
sourceInspect.items[0] as {
inspectionResults?: {
involvedViews?: Array<{
space: string;
externalId: string;
version: string;
}>;
};
}
).inspectionResults?.involvedViews || [];
const sources = involvedViews.slice(0, 10).map((v) => ({
source: {
type: "view" as const,
space: v.space,
externalId: v.externalId,
version: v.version,
},
}));
if (sources.length > 0) {
const sourceNodeResponse = await client.instances.retrieve({
items: [
{
instanceType: "node" as const,
space: nodeSpace,
externalId: nodeExternalId,
},
],
sources,
includeTyping: true,
});
if (sourceNodeResponse.items.length > 0) {
const props = sourceNodeResponse.items[0].properties as Record<
string,
Record<string, unknown>
>;
return extractDirectRelations(props);
}
}
}
return [];
} catch {
return [];
}
})(),
(async () => {
type EdgeItem = {
space: string;
externalId: string;
version: number;
createdTime: number;
lastUpdatedTime: number;
type: { space: string; externalId: string };
startNode: { space: string; externalId: string };
endNode: { space: string; externalId: string };
properties?: Record<string, Record<string, unknown>>;
};
const items = await queryWithCursorPagination<EdgeItem>(
client,
"edges",
{
with: {
edges: {
edges: {
filter: {
or: [
{
equals: {
property: ["edge", "startNode"],
value: { space: nodeSpace, externalId: nodeExternalId },
},
},
{
equals: {
property: ["edge", "endNode"],
value: { space: nodeSpace, externalId: nodeExternalId },
},
},
],
},
},
},
},
select: { edges: {} },
includeTyping: true,
},
Math.min(limit * 2, QUERY_PAGE_LIMIT),
// Hard cap: never return more than `limit` edges per expansion. This
// upper bound protects the consumer from runaway pagination and matches
// the documented contract of `initialConnectionLimit`.
limit
);
return { items };
})(),
]);
const newEdges: CDFEdge[] = edgeResponse.items.map((edgeItem) => {
const item = edgeItem as {
space: string;
externalId: string;
version: number;
createdTime: number;
lastUpdatedTime: number;
type: { space: string; externalId: string };
startNode: { space: string; externalId: string };
endNode: { space: string; externalId: string };
properties?: Record<string, Record<string, unknown>>;
};
return {
space: item.space,
externalId: item.externalId,
instanceType: "edge" as const,
version: item.version,
createdTime: item.createdTime,
lastUpdatedTime: item.lastUpdatedTime,
type: item.type,
startNode: item.startNode,
endNode: item.endNode,
properties: item.properties,
};
});
const connectedNodeRefs = new Map<string, { space: string; externalId: string }>();
for (const edge of newEdges) {
const startKey = `${edge.startNode.space}:${edge.startNode.externalId}`;
const endKey = `${edge.endNode.space}:${edge.endNode.externalId}`;
if (!existingNodeIds.has(startKey)) {
connectedNodeRefs.set(startKey, edge.startNode);
}
if (!existingNodeIds.has(endKey)) {
connectedNodeRefs.set(endKey, edge.endNode);
}
}
const sourceNodeKey = `${nodeSpace}:${nodeExternalId}`;
const directRelationEdges: CDFEdge[] = [];
for (const ref of sourceNodeRefs) {
const refKey = `${ref.space}:${ref.externalId}`;
if (
refKey !== sourceNodeKey &&
!existingNodeIds.has(refKey) &&
!connectedNodeRefs.has(refKey)
) {
connectedNodeRefs.set(refKey, ref);
directRelationEdges.push({
space: nodeSpace,
externalId: `synthetic:${nodeExternalId}->direct->${ref.externalId}`,
instanceType: "edge" as const,
version: 1,
createdTime: Date.now(),
lastUpdatedTime: Date.now(),
type: { space: "synthetic", externalId: "direct-relation" },
startNode: { space: nodeSpace, externalId: nodeExternalId },
endNode: { space: ref.space, externalId: ref.externalId },
properties: {},
});
}
}
newEdges.push(...directRelationEdges);
// Reverse relation queries
const reverseRelationRefs: Array<{ space: string; externalId: string }> = [];
const CORE_REVERSE_QUERIES = coreReverseQueries ?? [];
try {
const nodeRef = { space: nodeSpace, externalId: nodeExternalId };
// Spread the per-expansion budget across all configured reverse queries so
// the total number of nodes contributed by reverse relations stays within
// `limit`. Each query gets at least 1 row.
const perQueryLimit =
CORE_REVERSE_QUERIES.length > 0
? Math.max(1, Math.ceil(limit / CORE_REVERSE_QUERIES.length))
: limit;
const queryPromises = CORE_REVERSE_QUERIES.map(
async ([viewSpace, viewExternalId, viewVersion, propertyName, isList]) => {
try {
const propertyPath = [
viewSpace,
`${viewExternalId}/${viewVersion}`,
propertyName,
];
const filter = isList
? {
containsAny: {
property: propertyPath,
values: [nodeRef],
},
}
: {
equals: {
property: propertyPath,
value: nodeRef,
},
};
const items = await queryWithCursorPagination<{
space: string;
externalId: string;
}>(
client,
"nodes",
{
with: {
nodes: {
nodes: { filter },
},
},
select: { nodes: {} },
includeTyping: false,
},
Math.min(50, perQueryLimit),
perQueryLimit
);
return items.map((item) => ({
space: item.space,
externalId: item.externalId,
}));
} catch {
return [];
}
}
);
const results = await Promise.all(queryPromises);
for (const refs of results) {
reverseRelationRefs.push(...refs);
}
} catch {
// Silently ignore reverse relation query failures
}
const syntheticEdges: CDFEdge[] = [];
for (const ref of reverseRelationRefs) {
const refKey = `${ref.space}:${ref.externalId}`;
if (
refKey !== sourceNodeKey &&
!existingNodeIds.has(refKey) &&
!connectedNodeRefs.has(refKey)
) {
connectedNodeRefs.set(refKey, ref);
syntheticEdges.push({
space: ref.space,
externalId: `synthetic:${ref.externalId}->assets->${nodeExternalId}`,
instanceType: "edge" as const,
version: 1,
createdTime: Date.now(),
lastUpdatedTime: Date.now(),
type: { space: "cdf_cdm", externalId: "references-asset" },
startNode: { space: ref.space, externalId: ref.externalId },
endNode: { space: nodeSpace, externalId: nodeExternalId },
properties: {},
});
}
}
newEdges.push(...syntheticEdges);
const connectedNodeIds = newEdges.flatMap((edge) => [
`${edge.startNode.space}:${edge.startNode.externalId}`,
`${edge.endNode.space}:${edge.endNode.externalId}`,
]);
const nodesToFetch = Array.from(connectedNodeRefs.values()).slice(0, limit);
let newNodes: CDFNode[] = [];
if (nodesToFetch.length > 0) {
const inspectResponse = await client.instances.inspect({
items: nodesToFetch.map((ref) => ({
instanceType: "node" as const,
space: ref.space,
externalId: ref.externalId,
})),
inspectionOperations: { involvedViews: {} },
});
const nodeTypeMap = new Map<string, { space: string; externalId: string }>();
const allViews = new Map<string, { space: string; externalId: string; version: string }>();
for (const inspectItem of inspectResponse.items) {
const item = inspectItem as {
space: string;
externalId: string;
inspectionResults?: {
involvedViews?: Array<{
space: string;
externalId: string;
version: string;
}>;
};
};
const key = `${item.space}:${item.externalId}`;
const views = item.inspectionResults?.involvedViews || [];
const skipViewsForProperties = viewPriorityConfig?.skipViewsForProperties
? new Set(viewPriorityConfig.skipViewsForProperties)
: DEFAULT_SKIP_VIEWS_FOR_PROPERTIES;
const skipViewsForType = viewPriorityConfig?.skipViewsForType
? new Set(viewPriorityConfig.skipViewsForType)
: DEFAULT_SKIP_VIEWS_FOR_TYPE;
for (const view of views) {
if (!skipViewsForProperties.has(view.externalId)) {
const viewKey = `${view.space}:${view.externalId}`;
if (!allViews.has(viewKey)) {
allViews.set(viewKey, view);
}
}
}
const domainView = views.find(
(v) => !v.space.startsWith("cdf_cdm") && !skipViewsForType.has(v.externalId)
);
const cdmView = views.find(
(v) => v.space.startsWith("cdf_cdm") && !skipViewsForType.has(v.externalId)
);
const bestView = domainView || cdmView;
if (bestView) {
nodeTypeMap.set(key, {
space: bestView.space,
externalId: bestView.externalId,
});
}
}
const sources = [
{
source: {
type: "view" as const,
space: "cdf_cdm",
externalId: "CogniteDescribable",
version: "v1",
},
},
...Array.from(allViews.values()).map((v) => ({
source: {
type: "view" as const,
space: v.space,
externalId: v.externalId,
version: v.version,
},
})),
];
const retrieveItems = nodesToFetch.map((ref) => ({
instanceType: "node" as const,
space: ref.space,
externalId: ref.externalId,
}));
let nodeResponse: Awaited<ReturnType<CogniteClient["instances"]["retrieve"]>>;
try {
nodeResponse = await client.instances.retrieve({
items: retrieveItems,
includeTyping: true,
});
} catch {
nodeResponse = { items: [] };
}
const propertiesMap = new Map<string, Record<string, Record<string, unknown>>>();
if (sources.length > 0 && nodeResponse.items.length > 0) {
try {
const propsResponse = await client.instances.retrieve({
items: retrieveItems,
sources,
includeTyping: true,
});
for (const item of propsResponse.items) {
const key = `${item.space}:${item.externalId}`;
if (item.properties) {
propertiesMap.set(key, item.properties as Record<string, Record<string, unknown>>);
}
}
} catch {
// Continue without additional properties
}
}
const nodesWithoutProps = retrieveItems.filter((item) => {
const key = `${item.space}:${item.externalId}`;
return !propertiesMap.has(key);
});
if (nodesWithoutProps.length > 0) {
const individualFetches = nodesWithoutProps.map(async (nodeRef) => {
const key = `${nodeRef.space}:${nodeRef.externalId}`;
try {
const inspectResult = await client.instances.inspect({
items: [nodeRef],
inspectionOperations: { involvedViews: { allVersions: false } },
});
const views =
(
inspectResult.items?.[0] as {
inspectionResults?: {
involvedViews?: Array<{
space: string;
externalId: string;
version: string;
}>;
};
}
)?.inspectionResults?.involvedViews || [];
if (views.length > 0) {
const nodeSources = views.slice(0, 10).map((v) => ({
source: {
type: "view" as const,
space: v.space,
externalId: v.externalId,
version: v.version,
},
}));
const propsResp = await client.instances.retrieve({
items: [nodeRef],
sources: nodeSources,
includeTyping: true,
});
if (propsResp.items.length > 0 && propsResp.items[0].properties) {
propertiesMap.set(
key,
propsResp.items[0].properties as Record<string, Record<string, unknown>>
);
}
}
} catch {
// Silently ignore individual fetch failures
}
});
await Promise.all(individualFetches);
}
newNodes = nodeResponse.items
.filter(
(item): item is typeof item & { instanceType: "node" } =>
(item as { instanceType?: string }).instanceType === "node" || !("instanceType" in item)
)
.map((item) => {
const key = `${item.space}:${item.externalId}`;
const inspectType = nodeTypeMap.get(key);
const mergedProps =
propertiesMap.get(key) || (item.properties as Record<string, Record<string, unknown>>);
const derivedType =
deriveNodeTypeFromProperties(mergedProps, viewPriorityConfig) || inspectType;
return {
space: item.space,
externalId: item.externalId,
instanceType: "node" as const,
version: item.version,
createdTime: item.createdTime,
lastUpdatedTime: item.lastUpdatedTime,
type: item.type || derivedType,
properties: mergedProps,
};
});
}
return { newNodes, newEdges, connectedNodeIds };
}
// =============================================================================
// Graph utility functions (pure, no API calls)
// =============================================================================
export function getGraphStats(graphData: GraphData) {
const nodeTypes = new Map<string, number>();
const connectionTypes = new Map<string, number>();
graphData.nodes.forEach((node) => {
const type = node.data.type?.externalId || "Unknown";
nodeTypes.set(type, (nodeTypes.get(type) || 0) + 1);
});
graphData.connections.forEach((connection) => {
const type = connection.data.type.externalId;
connectionTypes.set(type, (connectionTypes.get(type) || 0) + 1);
});
return {
totalNodes: graphData.nodes.length,
totalConnections: graphData.connections.length,
nodeTypes: Object.fromEntries(nodeTypes),
connectionTypes: Object.fromEntries(connectionTypes),
};
}
export function getConnectedNodes(graphData: GraphData, nodeId: string) {
const connectedNodeIds = new Set<string>();
graphData.connections.forEach((connection) => {
if (connection.source === nodeId) {
connectedNodeIds.add(connection.target);
}
if (connection.target === nodeId) {
connectedNodeIds.add(connection.source);
}
});
return graphData.nodes.filter((node) => connectedNodeIds.has(node.id));
}
export function getNodeEdges(graphData: GraphData, nodeId: string) {
return graphData.connections.filter(
(connection) => connection.source === nodeId || connection.target === nodeId
);
}