@eventcatalog/core 5.0.0-beta.10 → 5.0.0-beta.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-CRKJBDN7.js → chunk-BWC4YKW3.js} +1 -1
- package/dist/{chunk-LJMXOEEC.js → chunk-CX2VB7AI.js} +1 -1
- package/dist/{chunk-IONYKKOY.js → chunk-FY6MSP6J.js} +1 -1
- package/dist/{chunk-2LBDRHE4.js → chunk-QB7X34OD.js} +1 -1
- package/dist/{chunk-EM36OL6A.js → chunk-TSY5CC27.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.js +5 -5
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
- package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +34 -3
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
- package/eventcatalog/src/components/NavigationProgress.astro +7 -0
- package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +67 -67
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
- package/eventcatalog/src/features/studio/agent-choreography.ts +8 -6
- package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
- package/eventcatalog/src/features/studio/api/client.ts +6 -1
- package/eventcatalog/src/features/studio/canvas-actions.ts +140 -22
- package/eventcatalog/src/features/studio/canvas-doc.ts +165 -10
- package/eventcatalog/src/features/studio/catalog.ts +15 -10
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +54 -89
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +43 -26
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +8 -11
- package/eventcatalog/src/features/studio/components/Comments.tsx +15 -8
- package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +27 -43
- package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +32 -4
- package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +47 -38
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +323 -99
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +33 -17
- package/eventcatalog/src/features/studio/flow-state.ts +29 -0
- package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
- package/eventcatalog/src/features/studio/grouping.ts +4 -0
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +1 -1
- package/eventcatalog/src/features/studio/hooks/use-counter-scale.ts +20 -0
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +67 -13
- package/eventcatalog/src/features/studio/layout.ts +29 -15
- package/eventcatalog/src/features/studio/level-layout.ts +145 -0
- package/eventcatalog/src/features/studio/levels.ts +82 -27
- package/eventcatalog/src/features/studio/limits.ts +2 -0
- package/eventcatalog/src/features/studio/node-types.ts +44 -4
- package/eventcatalog/src/features/studio/pages/index.astro +2 -1
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +1 -1
- package/eventcatalog/src/features/studio/server/runtime.ts +12 -4
- package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
- package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
- package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { Edge, Node } from '@xyflow/react';
|
|
2
|
+
import { getNodeSize as getLayoutSize } from '@eventcatalog/visualiser/layout';
|
|
3
|
+
import type { LevelPlace } from './canvas-doc';
|
|
4
|
+
import { fitGroup, getAbsolutePosition } from './grouping';
|
|
5
|
+
import { getNodeSize, isGroupType } from './node-types';
|
|
6
|
+
import { placeNodes } from './placement';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* L1 and L2 as people arrange them. A level's content comes from the canvas (see levels.ts), but where things are on
|
|
10
|
+
* it is its own: each level keeps a layout (a LevelPlace per node, in the canvas), so things stay where they're put.
|
|
11
|
+
* Nothing on a level moves when something's added: new things are placed next to what they connect to (like agents
|
|
12
|
+
* place what they add) and containers grow to fit.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type Graph = { nodes: Node[]; edges: Edge[] };
|
|
16
|
+
|
|
17
|
+
/** A container's size: as it's being worked on (width and height), or as laid out (levels size containers with style) */
|
|
18
|
+
const containerSize = (node: Node) => {
|
|
19
|
+
if (node.width && node.height) return { width: node.width, height: node.height };
|
|
20
|
+
const { width, height } = node.style ?? {};
|
|
21
|
+
return typeof width === 'number' && typeof height === 'number' ? { width, height } : undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** The size the visualiser's layout gives a container with nothing in it (levels leave containers' size to it) */
|
|
25
|
+
const emptyContainerSize = ({ type }: Pick<Node, 'type'>) => getLayoutSize({ id: '', type, position: { x: 0, y: 0 }, data: {} });
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The size something new of a type is shown at on L1 or L2: L1 shows domains and systems with nothing in them as
|
|
29
|
+
* cards, L2 as empty containers
|
|
30
|
+
*/
|
|
31
|
+
export const getLevelNodeSize = (type: string | undefined, level: 1 | 2) => {
|
|
32
|
+
if (!isGroupType(type)) return getNodeSize(type);
|
|
33
|
+
return level === 1 ? getNodeSize() : emptyContainerSize({ type });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Where a node is on a level, and a container's size */
|
|
37
|
+
const placeOf = (node: Node): LevelPlace => ({
|
|
38
|
+
x: node.position.x,
|
|
39
|
+
y: node.position.y,
|
|
40
|
+
...(isGroupType(node.type) && containerSize(node)),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** A level's nodes where they are, by id (not its notes, which are kept on the canvas): what its layout keeps */
|
|
44
|
+
export const levelPlaces = (nodes: Node[]) =>
|
|
45
|
+
new Map(nodes.filter((node) => node.type !== 'note').map((node) => [node.id, placeOf(node)]));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A level arranged: each node where the level's layout has it, else where the diagram the canvas was opened from had
|
|
49
|
+
* it (`fallback`), and anything with neither placed next to what it connects to. Containers grow to fit what's in
|
|
50
|
+
* them. `placed` is where things the layout didn't have were put, and containers that grew (to keep, so they stay
|
|
51
|
+
* there). Undefined when nothing on the level has a place yet: it's laid out from scratch then.
|
|
52
|
+
*/
|
|
53
|
+
export const arrangeLevel = (
|
|
54
|
+
graph: Graph,
|
|
55
|
+
layout: ReadonlyMap<string, LevelPlace>,
|
|
56
|
+
fallback: ReadonlyMap<string, LevelPlace> = new Map()
|
|
57
|
+
): { graph: Graph; placed: Map<string, LevelPlace> } | undefined => {
|
|
58
|
+
const known = (id: string) => layout.get(id) ?? fallback.get(id);
|
|
59
|
+
if (!graph.nodes.some((node) => known(node.id))) return undefined;
|
|
60
|
+
|
|
61
|
+
const placed = new Map<string, LevelPlace>();
|
|
62
|
+
// Containers carry their size as width and height while they're worked on (what placement and fitting read)
|
|
63
|
+
let nodes: Node[] = graph.nodes.map((node) => {
|
|
64
|
+
const place = known(node.id);
|
|
65
|
+
const size = isGroupType(node.type)
|
|
66
|
+
? place?.width && place.height
|
|
67
|
+
? { width: place.width, height: place.height }
|
|
68
|
+
: (containerSize(node) ?? emptyContainerSize(node))
|
|
69
|
+
: undefined;
|
|
70
|
+
return { ...node, position: place ? { x: place.x, y: place.y } : { x: 0, y: 0 }, ...size };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Anything new, next to what it connects to (on the level), moving nothing that's there
|
|
74
|
+
const toPlace = nodes.filter((node) => !known(node.id)).map((node) => node.id);
|
|
75
|
+
if (toPlace.length) {
|
|
76
|
+
const positions = placeNodes(nodes, toPlace, graph.edges);
|
|
77
|
+
const lookup = new Map(nodes.map((node) => [node.id, node]));
|
|
78
|
+
nodes = nodes.map((node) => {
|
|
79
|
+
const at = positions.get(node.id);
|
|
80
|
+
if (!at) return node;
|
|
81
|
+
const parent = node.parentId ? lookup.get(node.parentId) : undefined;
|
|
82
|
+
const origin = parent ? getAbsolutePosition(parent, lookup) : { x: 0, y: 0 };
|
|
83
|
+
const moved = { ...node, position: { x: at.x - origin.x, y: at.y - origin.y } };
|
|
84
|
+
placed.set(node.id, placeOf(moved));
|
|
85
|
+
return moved;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Containers grow to fit what's in them (the innermost first), moving nothing on the level
|
|
90
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
91
|
+
const depth = (node: Node): number => {
|
|
92
|
+
const parent = node.parentId ? byId.get(node.parentId) : undefined;
|
|
93
|
+
return parent ? 1 + depth(parent) : 0;
|
|
94
|
+
};
|
|
95
|
+
const containers = nodes.filter((node) => isGroupType(node.type)).sort((a, b) => depth(b) - depth(a));
|
|
96
|
+
for (const { id } of containers) {
|
|
97
|
+
const fit = fitGroup(id, nodes);
|
|
98
|
+
const current = nodes.find((node) => node.id === id)!;
|
|
99
|
+
const { x: dx, y: dy } = fit?.shift ?? { x: 0, y: 0 };
|
|
100
|
+
if (!fit || (!dx && !dy && fit.width === current.width && fit.height === current.height)) continue;
|
|
101
|
+
nodes = nodes.map((node) => {
|
|
102
|
+
if (node.parentId === id && (dx || dy)) {
|
|
103
|
+
const shifted = { ...node, position: { x: node.position.x + dx, y: node.position.y + dy } };
|
|
104
|
+
placed.set(node.id, placeOf(shifted));
|
|
105
|
+
return shifted;
|
|
106
|
+
}
|
|
107
|
+
if (node.id !== id) return node;
|
|
108
|
+
const grown = {
|
|
109
|
+
...node,
|
|
110
|
+
position: { x: node.position.x - dx, y: node.position.y - dy },
|
|
111
|
+
width: fit.width,
|
|
112
|
+
height: fit.height,
|
|
113
|
+
};
|
|
114
|
+
placed.set(id, placeOf(grown));
|
|
115
|
+
return grown;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
graph: {
|
|
121
|
+
edges: graph.edges,
|
|
122
|
+
// Containers drawn at their size with style, like levels laid out from scratch
|
|
123
|
+
nodes: nodes.map(({ width, height, ...node }) =>
|
|
124
|
+
isGroupType(node.type) && width && height ? { ...node, style: { ...node.style, width, height } } : node
|
|
125
|
+
),
|
|
126
|
+
},
|
|
127
|
+
placed,
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A level's edges drawn along the routes the diagram the canvas was opened from had for the same connections (between
|
|
133
|
+
* the same two nodes), once the level's worked out from the canvas: routes follow their nodes when they move, so
|
|
134
|
+
* connections the diagram had stay drawn around what's in their way
|
|
135
|
+
*/
|
|
136
|
+
export const withDiagramRoutes = (edges: Edge[], diagramEdges: Edge[] = []) => {
|
|
137
|
+
const routes = new Map(
|
|
138
|
+
diagramEdges.flatMap((edge) => (edge.data?.route ? [[`${edge.source}>${edge.target}`, edge.data.route] as const] : []))
|
|
139
|
+
);
|
|
140
|
+
if (routes.size === 0) return edges;
|
|
141
|
+
return edges.map((edge) => {
|
|
142
|
+
const route = routes.get(`${edge.source}>${edge.target}`);
|
|
143
|
+
return route && !edge.data?.route ? { ...edge, data: { ...edge.data, route } } : edge;
|
|
144
|
+
});
|
|
145
|
+
};
|
|
@@ -31,7 +31,8 @@ const isContainer = (node: Node) => CONTAINER_TYPES.has(node.type ?? '');
|
|
|
31
31
|
const messageName = (node: Node) => String((node.data as { message?: { name?: string } }).message?.name ?? node.id);
|
|
32
32
|
|
|
33
33
|
// Edges as the visualiser's levels draw them: what's folded together is joined by a plain line, and what was joined
|
|
34
|
-
// through hidden messages or channels by a dashed, muted one ("bridged"), each with the visualiser's arrow
|
|
34
|
+
// through hidden messages or channels by a dashed, muted one ("bridged"), each with the visualiser's arrow. On L1
|
|
35
|
+
// the visualiser folds messages into the domain or system they're in, so edges to and from them are plain.
|
|
35
36
|
const EDGE_STROKE = 'var(--ec-edge-stroke, #6b7280)';
|
|
36
37
|
const MUTED = 'rgb(var(--ec-page-text-muted))';
|
|
37
38
|
const FOLDED_EDGE = {
|
|
@@ -53,12 +54,25 @@ type Representative = { kind: 'node'; id: string } | { kind: 'carrier' } | { kin
|
|
|
53
54
|
* (messages and channels: the nodes either side get connected directly, "bridged") or dropped. Edges between two
|
|
54
55
|
* nodes that are kept as they are stay as they were (`keepDirect`, for L2); the rest are merged per pair and kind
|
|
55
56
|
* (folded or bridged), labelled with the messages they carry.
|
|
57
|
+
*
|
|
58
|
+
* With `relationships` (L1), edges drawn straight between two nodes that are kept as they are (e.g. one system to
|
|
59
|
+
* another) are relationships, like the visualiser's context relationships: labelled as they were, and shown
|
|
60
|
+
* instead of the messages between the same two nodes (either way).
|
|
56
61
|
*/
|
|
57
62
|
const collapseGraph = (
|
|
58
63
|
nodes: Node[],
|
|
59
64
|
edges: Edge[],
|
|
60
65
|
representativeOf: (node: Node) => Representative,
|
|
61
|
-
{
|
|
66
|
+
{
|
|
67
|
+
keepDirect = false,
|
|
68
|
+
relationships = false,
|
|
69
|
+
foldsMessagesInto,
|
|
70
|
+
}: {
|
|
71
|
+
keepDirect?: boolean;
|
|
72
|
+
relationships?: boolean;
|
|
73
|
+
/** Nodes the messages they send or receive are folded into (L1: domains and systems), so edges through them are plain */
|
|
74
|
+
foldsMessagesInto?: (node: Node) => boolean;
|
|
75
|
+
} = {}
|
|
62
76
|
) => {
|
|
63
77
|
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
64
78
|
// Whether one node is inside another (e.g. a system in its domain): they're not connected, one holds the other
|
|
@@ -70,7 +84,14 @@ const collapseGraph = (
|
|
|
70
84
|
const outgoing = new Map<string, Edge[]>();
|
|
71
85
|
edges.forEach((edge) => outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge]));
|
|
72
86
|
|
|
73
|
-
type Merged = {
|
|
87
|
+
type Merged = {
|
|
88
|
+
source: string;
|
|
89
|
+
target: string;
|
|
90
|
+
bridged: boolean;
|
|
91
|
+
relationship: boolean;
|
|
92
|
+
messages: Map<string, string>;
|
|
93
|
+
labels: string[];
|
|
94
|
+
};
|
|
74
95
|
const merged = new Map<string, Merged>();
|
|
75
96
|
const kept: Edge[] = [];
|
|
76
97
|
const addMessage = (messages: Map<string, string>, node: Node) => {
|
|
@@ -81,7 +102,8 @@ const collapseGraph = (
|
|
|
81
102
|
const from = representatives.get(edge.source);
|
|
82
103
|
if (from?.kind !== 'node') return;
|
|
83
104
|
const to = representatives.get(edge.target);
|
|
84
|
-
|
|
105
|
+
const direct = from.id === edge.source && to?.kind === 'node' && to.id === edge.target;
|
|
106
|
+
if (keepDirect && direct) return kept.push(edge);
|
|
85
107
|
// Follow the edge through carried nodes to the node(s) it reaches, collecting the messages on the way
|
|
86
108
|
const stack = [{ id: edge.target, messages: new Map<string, string>(), labels: [String(edge.label ?? '')], bridged: false }];
|
|
87
109
|
const visited = new Set<string>();
|
|
@@ -104,11 +126,13 @@ const collapseGraph = (
|
|
|
104
126
|
// Edges inside what was collapsed disappear, and so do edges to what a node is in (e.g. a service loose in a
|
|
105
127
|
// domain, folded into it, and a system in the same domain)
|
|
106
128
|
if (from.id === reached.id || isInside(from.id, reached.id) || isInside(reached.id, from.id)) continue;
|
|
107
|
-
const
|
|
129
|
+
const relationship = relationships && direct && current.id === edge.target;
|
|
130
|
+
const key = `${relationship ? 'relationship' : current.bridged ? 'bridged' : 'level'}-${from.id}-${reached.id}`;
|
|
108
131
|
const entry: Merged = merged.get(key) ?? {
|
|
109
132
|
source: from.id,
|
|
110
133
|
target: reached.id,
|
|
111
134
|
bridged: current.bridged,
|
|
135
|
+
relationship,
|
|
112
136
|
messages: new Map(),
|
|
113
137
|
labels: [],
|
|
114
138
|
};
|
|
@@ -119,18 +143,39 @@ const collapseGraph = (
|
|
|
119
143
|
});
|
|
120
144
|
|
|
121
145
|
const keptIds = new Set([...representatives.values()].flatMap((rep) => (rep.kind === 'node' ? [rep.id] : [])));
|
|
122
|
-
//
|
|
146
|
+
// A relationship between two nodes is shown instead of the messages between them (either way)
|
|
147
|
+
const related = new Set(
|
|
148
|
+
[...merged.values()]
|
|
149
|
+
.filter((entry) => entry.relationship)
|
|
150
|
+
.flatMap(({ source, target }) => [`${source}>${target}`, `${target}>${source}`])
|
|
151
|
+
);
|
|
152
|
+
// Actors' connections are relationships too, labelled as they were (even when what they use is folded)
|
|
123
153
|
const isActor = (id: string) => byId.get(id)?.type === 'actor';
|
|
154
|
+
const labelOf = ({ source, target, bridged, relationship, messages, labels }: Merged) => {
|
|
155
|
+
const written = labels.length ? [...new Set(labels)].join(', ') : undefined;
|
|
156
|
+
if (relationship) return written;
|
|
157
|
+
return getMessagesLabel(messages) ?? (!bridged && (isActor(source) || isActor(target)) ? written : undefined);
|
|
158
|
+
};
|
|
124
159
|
return {
|
|
125
160
|
nodes: nodes.filter((node) => keptIds.has(node.id)),
|
|
126
161
|
edges: [
|
|
127
162
|
...kept,
|
|
128
|
-
...[...merged.entries()]
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
163
|
+
...[...merged.entries()]
|
|
164
|
+
.filter(([, entry]) => entry.relationship || !related.has(`${entry.source}>${entry.target}`))
|
|
165
|
+
.map(([id, entry]): Edge => {
|
|
166
|
+
const label = labelOf(entry);
|
|
167
|
+
const folded = [entry.source, entry.target].some((nodeId) => {
|
|
168
|
+
const node = byId.get(nodeId);
|
|
169
|
+
return !!node && !!foldsMessagesInto?.(node);
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
id,
|
|
173
|
+
source: entry.source,
|
|
174
|
+
target: entry.target,
|
|
175
|
+
...(entry.bridged && !folded ? BRIDGED_EDGE : FOLDED_EDGE),
|
|
176
|
+
...(label && { label }),
|
|
177
|
+
};
|
|
178
|
+
}),
|
|
134
179
|
],
|
|
135
180
|
};
|
|
136
181
|
};
|
|
@@ -188,8 +233,8 @@ const isWithin = (node: Node, containerId: string, byId: Map<string, Node>) => {
|
|
|
188
233
|
};
|
|
189
234
|
|
|
190
235
|
/**
|
|
191
|
-
* L1 as EventCatalog's diagrams draw it: systems as cards with what's in them counted, and domains with
|
|
192
|
-
* them (
|
|
236
|
+
* L1 as EventCatalog's diagrams draw it: systems as cards with what's in them counted, and domains with nothing
|
|
237
|
+
* shown in them (no system, in them or their subdomains, or subdomain) as domain cards
|
|
193
238
|
*/
|
|
194
239
|
const asSystemsLevel = (
|
|
195
240
|
{ nodes, edges }: { nodes: Node[]; edges: Edge[] },
|
|
@@ -226,7 +271,12 @@ const asSystemsLevel = (
|
|
|
226
271
|
messagesCount: messagesOf(id),
|
|
227
272
|
},
|
|
228
273
|
};
|
|
229
|
-
|
|
274
|
+
// A domain container stays one while something is shown in it (a system, or a subdomain shown as a card)
|
|
275
|
+
if (
|
|
276
|
+
node.type === GROUP_TYPES.domain &&
|
|
277
|
+
!all.some((other) => isSystem(other) && isWithin(other, id, byId)) &&
|
|
278
|
+
!nodes.some((other) => isWithin(other, id, byId))
|
|
279
|
+
)
|
|
230
280
|
return {
|
|
231
281
|
id,
|
|
232
282
|
type: 'context-domain',
|
|
@@ -248,18 +298,23 @@ const asSystemsLevel = (
|
|
|
248
298
|
/** L1: services folded into their system (or domain); actors, external systems and loose services stay */
|
|
249
299
|
const level1 = (nodes: Node[], edges: Edge[]) => {
|
|
250
300
|
const containers = getContainers(nodes, edges);
|
|
251
|
-
const collapsed = collapseGraph(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
301
|
+
const collapsed = collapseGraph(
|
|
302
|
+
nodes,
|
|
303
|
+
edges,
|
|
304
|
+
(node) => {
|
|
305
|
+
const container = containers.get(node.id);
|
|
306
|
+
// Systems stay (inside their domain's container, if they're in one); everything else folds into its system or domain
|
|
307
|
+
if (isContainer(node) && (isSystem(node) || !container?.system)) return { kind: 'node', id: node.id };
|
|
308
|
+
if (container?.system) return { kind: 'node', id: container.system };
|
|
309
|
+
// Messages and channels connect the systems either side, even when they sit in a domain rather than a system
|
|
310
|
+
// (the visualiser has them outside its domains; catalog domains on a canvas hold their messages)
|
|
311
|
+
if (isCarrier(node)) return { kind: 'carrier' };
|
|
312
|
+
if (container?.domain) return { kind: 'node', id: container.domain };
|
|
313
|
+
if (node.type === 'data' || isWrittenOnCanvas(node.type)) return { kind: 'drop' };
|
|
314
|
+
return { kind: 'node', id: node.id };
|
|
315
|
+
},
|
|
316
|
+
{ relationships: true, foldsMessagesInto: isContainer }
|
|
317
|
+
);
|
|
263
318
|
return asSystemsLevel(collapsed, { all: nodes, allEdges: edges, containers });
|
|
264
319
|
};
|
|
265
320
|
|
|
@@ -12,6 +12,22 @@ export const NODE_CATEGORIES: { id: NodeCategory; label: string }[] = [
|
|
|
12
12
|
{ id: 'other', label: 'Other' },
|
|
13
13
|
];
|
|
14
14
|
|
|
15
|
+
/** The catalog collections whose resources can go on a canvas */
|
|
16
|
+
export const CATALOG_COLLECTIONS = [
|
|
17
|
+
'domains',
|
|
18
|
+
'systems',
|
|
19
|
+
'services',
|
|
20
|
+
'events',
|
|
21
|
+
'commands',
|
|
22
|
+
'queries',
|
|
23
|
+
'channels',
|
|
24
|
+
'containers',
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type CatalogCollection = (typeof CATALOG_COLLECTIONS)[number];
|
|
28
|
+
export const isCatalogCollection = (collection: string): collection is CatalogCollection =>
|
|
29
|
+
(CATALOG_COLLECTIONS as readonly string[]).includes(collection);
|
|
30
|
+
|
|
15
31
|
/** Domains and systems as containers: boxes other nodes sit in */
|
|
16
32
|
export const GROUP_TYPES = { domain: 'domain-group', system: 'system-group' } as const;
|
|
17
33
|
export const isGroupType = (type?: string) => type === GROUP_TYPES.domain || type === GROUP_TYPES.system;
|
|
@@ -29,6 +45,11 @@ export type NodeDefinition = {
|
|
|
29
45
|
defaultSize?: { width: number; height: number };
|
|
30
46
|
/** Only its width is set: its height follows what's in it (text) */
|
|
31
47
|
autoHeight?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The first level of detail that shows it (it's on the more detailed ones too, see levels.ts): L1 domains, systems
|
|
50
|
+
* and actors, L2 services, data stores, text and the like, L3 messages and channels. Notes are on every level.
|
|
51
|
+
*/
|
|
52
|
+
firstLevel: 1 | 2 | 3;
|
|
32
53
|
createData: () => Record<string, unknown>;
|
|
33
54
|
};
|
|
34
55
|
|
|
@@ -37,6 +58,7 @@ const resource = (name: string, summary: string) => ({ name, version: '0.0.1', s
|
|
|
37
58
|
export const nodeDefinitions: NodeDefinition[] = [
|
|
38
59
|
{
|
|
39
60
|
type: GROUP_TYPES.domain,
|
|
61
|
+
firstLevel: 1,
|
|
40
62
|
label: 'Domain',
|
|
41
63
|
category: 'boundaries',
|
|
42
64
|
resourceKey: 'domain',
|
|
@@ -45,6 +67,7 @@ export const nodeDefinitions: NodeDefinition[] = [
|
|
|
45
67
|
},
|
|
46
68
|
{
|
|
47
69
|
type: GROUP_TYPES.system,
|
|
70
|
+
firstLevel: 1,
|
|
48
71
|
label: 'System',
|
|
49
72
|
category: 'boundaries',
|
|
50
73
|
resourceKey: 'system',
|
|
@@ -53,6 +76,7 @@ export const nodeDefinitions: NodeDefinition[] = [
|
|
|
53
76
|
},
|
|
54
77
|
{
|
|
55
78
|
type: 'service',
|
|
79
|
+
firstLevel: 2,
|
|
56
80
|
category: 'architecture',
|
|
57
81
|
label: 'Service',
|
|
58
82
|
resourceKey: 'service',
|
|
@@ -63,6 +87,7 @@ export const nodeDefinitions: NodeDefinition[] = [
|
|
|
63
87
|
},
|
|
64
88
|
{
|
|
65
89
|
type: 'event',
|
|
90
|
+
firstLevel: 3,
|
|
66
91
|
category: 'messages',
|
|
67
92
|
label: 'Event',
|
|
68
93
|
resourceKey: 'message',
|
|
@@ -70,6 +95,7 @@ export const nodeDefinitions: NodeDefinition[] = [
|
|
|
70
95
|
},
|
|
71
96
|
{
|
|
72
97
|
type: 'command',
|
|
98
|
+
firstLevel: 3,
|
|
73
99
|
category: 'messages',
|
|
74
100
|
label: 'Command',
|
|
75
101
|
resourceKey: 'message',
|
|
@@ -77,6 +103,7 @@ export const nodeDefinitions: NodeDefinition[] = [
|
|
|
77
103
|
},
|
|
78
104
|
{
|
|
79
105
|
type: 'query',
|
|
106
|
+
firstLevel: 3,
|
|
80
107
|
category: 'messages',
|
|
81
108
|
label: 'Query',
|
|
82
109
|
resourceKey: 'message',
|
|
@@ -84,6 +111,7 @@ export const nodeDefinitions: NodeDefinition[] = [
|
|
|
84
111
|
},
|
|
85
112
|
{
|
|
86
113
|
type: 'channel',
|
|
114
|
+
firstLevel: 3,
|
|
87
115
|
category: 'architecture',
|
|
88
116
|
label: 'Channel',
|
|
89
117
|
resourceKey: 'channel',
|
|
@@ -91,6 +119,7 @@ export const nodeDefinitions: NodeDefinition[] = [
|
|
|
91
119
|
},
|
|
92
120
|
{
|
|
93
121
|
type: 'data',
|
|
122
|
+
firstLevel: 2,
|
|
94
123
|
category: 'architecture',
|
|
95
124
|
label: 'Data Store',
|
|
96
125
|
resourceKey: 'data',
|
|
@@ -98,6 +127,7 @@ export const nodeDefinitions: NodeDefinition[] = [
|
|
|
98
127
|
},
|
|
99
128
|
{
|
|
100
129
|
type: 'view',
|
|
130
|
+
firstLevel: 2,
|
|
101
131
|
category: 'architecture',
|
|
102
132
|
label: 'View',
|
|
103
133
|
resourceKey: 'view',
|
|
@@ -105,6 +135,7 @@ export const nodeDefinitions: NodeDefinition[] = [
|
|
|
105
135
|
},
|
|
106
136
|
{
|
|
107
137
|
type: 'externalSystem',
|
|
138
|
+
firstLevel: 1,
|
|
108
139
|
category: 'architecture',
|
|
109
140
|
unlisted: true,
|
|
110
141
|
label: 'External System',
|
|
@@ -113,6 +144,7 @@ export const nodeDefinitions: NodeDefinition[] = [
|
|
|
113
144
|
},
|
|
114
145
|
{
|
|
115
146
|
type: 'agent',
|
|
147
|
+
firstLevel: 2,
|
|
116
148
|
category: 'architecture',
|
|
117
149
|
label: 'Agent',
|
|
118
150
|
resourceKey: 'agent',
|
|
@@ -120,12 +152,15 @@ export const nodeDefinitions: NodeDefinition[] = [
|
|
|
120
152
|
},
|
|
121
153
|
{
|
|
122
154
|
type: 'actor',
|
|
155
|
+
firstLevel: 1,
|
|
123
156
|
category: 'other',
|
|
124
157
|
label: 'Actor',
|
|
125
|
-
|
|
158
|
+
// Drawn like the visualiser's actors (by name only); the summary is for agents and the properties panel
|
|
159
|
+
createData: () => ({ mode: 'full', name: 'Customer', summary: '' }),
|
|
126
160
|
},
|
|
127
161
|
{
|
|
128
162
|
type: 'text',
|
|
163
|
+
firstLevel: 2,
|
|
129
164
|
category: 'other',
|
|
130
165
|
label: 'Text',
|
|
131
166
|
defaultSize: { width: 240, height: 40 },
|
|
@@ -134,6 +169,7 @@ export const nodeDefinitions: NodeDefinition[] = [
|
|
|
134
169
|
},
|
|
135
170
|
{
|
|
136
171
|
type: 'note',
|
|
172
|
+
firstLevel: 1,
|
|
137
173
|
category: 'other',
|
|
138
174
|
label: 'Sticky Note',
|
|
139
175
|
// About square, like a post-it (taller if more is written on it)
|
|
@@ -151,9 +187,9 @@ export const COMPONENT_TYPES = nodeDefinitions
|
|
|
151
187
|
const definitionsByType = new Map(nodeDefinitions.map((definition) => [definition.type, definition]));
|
|
152
188
|
|
|
153
189
|
// Node types that only come from the catalog (not in the components list)
|
|
154
|
-
const catalogNodeTypes: Record<string, { label: string; resourceKey: string }> = {
|
|
155
|
-
system: { label: 'System', resourceKey: 'system' },
|
|
156
|
-
'context-domain': { label: 'Domain', resourceKey: 'domain' },
|
|
190
|
+
const catalogNodeTypes: Record<string, { label: string; resourceKey: string; firstLevel: 1 | 2 | 3 }> = {
|
|
191
|
+
system: { label: 'System', resourceKey: 'system', firstLevel: 1 },
|
|
192
|
+
'context-domain': { label: 'Domain', resourceKey: 'domain', firstLevel: 1 },
|
|
157
193
|
};
|
|
158
194
|
|
|
159
195
|
export const getNodeDefinition = (type?: string) => (type ? definitionsByType.get(type) : undefined);
|
|
@@ -188,6 +224,10 @@ export const getNoteLevel = (node: { type?: string; data: Record<string, unknown
|
|
|
188
224
|
export const isOnLevel = (node: { type?: string; data: Record<string, unknown> }, level: 1 | 2 | 3) =>
|
|
189
225
|
node.type !== 'note' || getNoteLevel(node) === level;
|
|
190
226
|
|
|
227
|
+
/** Whether a level of detail shows a kind of node (so it can be added there, and stay there) */
|
|
228
|
+
export const isShownOnLevel = (type: string | undefined, level: 1 | 2 | 3) =>
|
|
229
|
+
level >= (getNodeDefinition(type)?.firstLevel ?? catalogNodeTypes[type ?? '']?.firstLevel ?? 3);
|
|
230
|
+
|
|
191
231
|
/** Notes and text: written on the canvas itself (their text is their data, not a resource with a name) */
|
|
192
232
|
export const isWrittenOnCanvas = (type?: string) => type === 'note' || type === 'text';
|
|
193
233
|
|
|
@@ -4,7 +4,8 @@ import { ChevronDown, ChevronLeft, ChevronRight, Copy, EllipsisVertical, Pencil,
|
|
|
4
4
|
import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
|
|
5
5
|
import StudioNotice from '../components/StudioNotice.astro';
|
|
6
6
|
import { buildUrl } from '@utils/url-builder';
|
|
7
|
-
import { CANVAS_STATUSES,
|
|
7
|
+
import { CANVAS_STATUSES, type CanvasStatus } from '../canvas-doc';
|
|
8
|
+
import { MAX_TITLE_LENGTH } from '../limits';
|
|
8
9
|
import { MAX_NAME_LENGTH } from '../identity';
|
|
9
10
|
import { CANVAS_STATUS_LOOK, STATUS } from '../components/status';
|
|
10
11
|
import { CANVASES_API_PATH } from '../api/canvases-api';
|
|
@@ -427,7 +427,7 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
|
|
|
427
427
|
const { nodes, edges } = await runtime.withCanvas(id, readCanvas);
|
|
428
428
|
const positions = await getLayoutPositions(nodes, edges);
|
|
429
429
|
await playLayout(asAgent(id, name).stage, positions);
|
|
430
|
-
return text({ laidOut: positions.size });
|
|
430
|
+
return text({ laidOut: positions.nodes.size });
|
|
431
431
|
}
|
|
432
432
|
);
|
|
433
433
|
|
|
@@ -6,16 +6,18 @@ import type { XYPosition } from '@xyflow/react';
|
|
|
6
6
|
import type { AgentPresence } from '../agent-choreography';
|
|
7
7
|
import {
|
|
8
8
|
CANVAS_DELETED_REASON,
|
|
9
|
-
|
|
9
|
+
addCanvasContent,
|
|
10
10
|
canvasDocumentName,
|
|
11
11
|
canvasIdFromDocumentName,
|
|
12
12
|
getCanvasMaps,
|
|
13
13
|
readMeta,
|
|
14
14
|
setMeta,
|
|
15
15
|
getCanvasStatus,
|
|
16
|
+
type CanvasContent,
|
|
16
17
|
type CanvasStatus,
|
|
17
18
|
} from '../canvas-doc';
|
|
18
19
|
import { getCanvasPreview, type CanvasPreview } from '../canvas-preview';
|
|
20
|
+
import { MAX_TITLE_LENGTH } from '../limits';
|
|
19
21
|
import { fromBase64, toBase64, type SyncRequest, type SyncResponse } from '../tool-sync';
|
|
20
22
|
import config from '../../../utils/eventcatalog-config/source';
|
|
21
23
|
import { isAuthEnabled } from '../../../utils/feature';
|
|
@@ -299,10 +301,16 @@ export class StudioRuntime {
|
|
|
299
301
|
return true;
|
|
300
302
|
}
|
|
301
303
|
|
|
302
|
-
/**
|
|
303
|
-
|
|
304
|
+
/**
|
|
305
|
+
* Starts a canvas with a title, recording when and by whom (a person, or an agent), and what's on it to begin
|
|
306
|
+
* with, if anything. Returns its id.
|
|
307
|
+
*/
|
|
308
|
+
async createCanvas(started: { title?: string; createdBy?: string }, content?: CanvasContent) {
|
|
304
309
|
const canvasId = crypto.randomUUID();
|
|
305
|
-
await this.withCanvas(canvasId, (doc) =>
|
|
310
|
+
await this.withCanvas(canvasId, (doc) => {
|
|
311
|
+
setStarted(doc, started);
|
|
312
|
+
if (content) addCanvasContent(doc, content);
|
|
313
|
+
});
|
|
306
314
|
return canvasId;
|
|
307
315
|
}
|
|
308
316
|
|
|
@@ -267,7 +267,7 @@ export const buildDomainGraph = ({
|
|
|
267
267
|
draggable: false,
|
|
268
268
|
selectable: false,
|
|
269
269
|
data: {
|
|
270
|
-
domain: { name: d.data.name, version: d.data.version },
|
|
270
|
+
domain: { id: d.data.id, name: d.data.name, version: d.data.version },
|
|
271
271
|
// The domain viewed may itself be a subdomain of another
|
|
272
272
|
subdomain: !!parentId || parentDomainOf.has(d.data.id),
|
|
273
273
|
isFocused: !parentId,
|
|
@@ -309,7 +309,7 @@ export const buildDomainGraph = ({
|
|
|
309
309
|
position: { x: 0, y: 0 },
|
|
310
310
|
...(parentId ? { parentId } : {}),
|
|
311
311
|
data: {
|
|
312
|
-
domain: { name: other.data.name, version: other.data.version },
|
|
312
|
+
domain: { id: other.data.id, name: other.data.name, version: other.data.version },
|
|
313
313
|
subdomain: !!parentId,
|
|
314
314
|
isFocused: false,
|
|
315
315
|
otherDomain: true,
|
|
@@ -375,7 +375,7 @@ export const buildDomainGraph = ({
|
|
|
375
375
|
type: 'system-group',
|
|
376
376
|
parentId: domainGroup,
|
|
377
377
|
position: { x: 0, y: 0 },
|
|
378
|
-
data: { system: { name: system.data.name, version: system.data.version } },
|
|
378
|
+
data: { system: { id: system.data.id, name: system.data.name, version: system.data.version } },
|
|
379
379
|
});
|
|
380
380
|
}
|
|
381
381
|
addNodes(graph.nodes);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catalog resource a diagram node shows, for the node types the node graphs build. Has no imports so it can
|
|
3
|
+
* be shared by the server and the browser (the visualiser's "Open in Studio", the MCP App views).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// The collection each node type shows a resource of
|
|
7
|
+
const NODE_COLLECTIONS: Record<string, string> = {
|
|
8
|
+
service: 'services',
|
|
9
|
+
services: 'services',
|
|
10
|
+
agent: 'agents',
|
|
11
|
+
agents: 'agents',
|
|
12
|
+
event: 'events',
|
|
13
|
+
events: 'events',
|
|
14
|
+
command: 'commands',
|
|
15
|
+
commands: 'commands',
|
|
16
|
+
query: 'queries',
|
|
17
|
+
queries: 'queries',
|
|
18
|
+
channel: 'channels',
|
|
19
|
+
channels: 'channels',
|
|
20
|
+
flow: 'flows',
|
|
21
|
+
flows: 'flows',
|
|
22
|
+
data: 'containers',
|
|
23
|
+
containers: 'containers',
|
|
24
|
+
'data-products': 'data-products',
|
|
25
|
+
domain: 'domains',
|
|
26
|
+
domains: 'domains',
|
|
27
|
+
'context-domain': 'domains',
|
|
28
|
+
'domain-group': 'domains',
|
|
29
|
+
system: 'systems',
|
|
30
|
+
systems: 'systems',
|
|
31
|
+
'system-group': 'systems',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Where each node type keeps the resource it shows
|
|
35
|
+
const RESOURCE_DATA_KEYS = [
|
|
36
|
+
'service',
|
|
37
|
+
'agent',
|
|
38
|
+
'message',
|
|
39
|
+
'channel',
|
|
40
|
+
'data',
|
|
41
|
+
'dataProduct',
|
|
42
|
+
'flow',
|
|
43
|
+
'entity',
|
|
44
|
+
'domain',
|
|
45
|
+
'system',
|
|
46
|
+
'custom',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
type DiagramNode = { type?: string; data?: Record<string, unknown> };
|
|
50
|
+
type ResourceDetails = { id?: unknown; version?: unknown; [key: string]: unknown };
|
|
51
|
+
|
|
52
|
+
/** The resource a node shows, as in its data: the fields shown, or the catalog entry's data (if it holds the entry) */
|
|
53
|
+
export const getNodeResourceDetails = (node: DiagramNode): ResourceDetails | undefined => {
|
|
54
|
+
const data = node.data ?? {};
|
|
55
|
+
const resource = RESOURCE_DATA_KEYS.map((key) => data[key]).find(Boolean) as
|
|
56
|
+
| (ResourceDetails & { data?: ResourceDetails })
|
|
57
|
+
| undefined;
|
|
58
|
+
return resource?.data ?? resource;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The catalog resource a node shows (its collection, id and version), if it shows one */
|
|
62
|
+
export const getNodeCatalogResource = (node: DiagramNode): { collection: string; id: string; version?: string } | undefined => {
|
|
63
|
+
const collection = NODE_COLLECTIONS[node.type ?? ''];
|
|
64
|
+
const details = getNodeResourceDetails(node);
|
|
65
|
+
if (!collection || typeof details?.id !== 'string') return undefined;
|
|
66
|
+
return { collection, id: details.id, ...(typeof details.version === 'string' && { version: details.version }) };
|
|
67
|
+
};
|