@diagrammo/dgmo 0.4.2 → 0.4.4
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/.claude/skills/dgmo-chart/SKILL.md +28 -0
- package/.claude/skills/dgmo-generate/SKILL.md +1 -0
- package/.claude/skills/dgmo-sequence/SKILL.md +24 -1
- package/.cursorrules +27 -2
- package/.github/copilot-instructions.md +36 -3
- package/.windsurfrules +27 -2
- package/README.md +12 -3
- package/dist/cli.cjs +197 -154
- package/dist/index.cjs +8647 -3447
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +503 -58
- package/dist/index.d.ts +503 -58
- package/dist/index.js +8379 -3200
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +1 -1
- package/docs/language-reference.md +336 -17
- package/docs/migration-sequence-color-to-tags.md +98 -0
- package/package.json +1 -1
- package/src/c4/renderer.ts +1 -20
- package/src/class/renderer.ts +1 -11
- package/src/cli.ts +40 -0
- package/src/d3.ts +92 -2
- package/src/dgmo-router.ts +11 -0
- package/src/echarts.ts +74 -8
- package/src/er/parser.ts +29 -3
- package/src/er/renderer.ts +1 -15
- package/src/graph/flowchart-parser.ts +7 -30
- package/src/graph/flowchart-renderer.ts +62 -69
- package/src/graph/layout.ts +5 -0
- package/src/graph/state-parser.ts +388 -0
- package/src/graph/state-renderer.ts +496 -0
- package/src/graph/types.ts +4 -2
- package/src/index.ts +42 -1
- package/src/infra/compute.ts +1113 -0
- package/src/infra/layout.ts +578 -0
- package/src/infra/parser.ts +559 -0
- package/src/infra/renderer.ts +1553 -0
- package/src/infra/roles.ts +60 -0
- package/src/infra/serialize.ts +67 -0
- package/src/infra/types.ts +221 -0
- package/src/infra/validation.ts +192 -0
- package/src/initiative-status/layout.ts +56 -61
- package/src/initiative-status/renderer.ts +13 -13
- package/src/kanban/renderer.ts +1 -24
- package/src/org/layout.ts +28 -37
- package/src/org/parser.ts +16 -1
- package/src/org/renderer.ts +159 -121
- package/src/org/resolver.ts +90 -23
- package/src/palettes/color-utils.ts +30 -0
- package/src/render.ts +2 -0
- package/src/sequence/parser.ts +202 -42
- package/src/sequence/renderer.ts +576 -113
- package/src/sequence/tag-resolution.ts +163 -0
- package/src/sharing.ts +8 -0
- package/src/sitemap/collapse.ts +187 -0
- package/src/sitemap/layout.ts +738 -0
- package/src/sitemap/parser.ts +489 -0
- package/src/sitemap/renderer.ts +774 -0
- package/src/sitemap/types.ts +42 -0
- package/src/utils/tag-groups.ts +119 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Infra Chart Layout Engine
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// Uses dagre for LR/TB DAG layout. Groups are implemented as
|
|
6
|
+
// post-layout bounding box wrappers around their children.
|
|
7
|
+
|
|
8
|
+
import dagre from '@dagrejs/dagre';
|
|
9
|
+
import type {
|
|
10
|
+
ComputedInfraModel,
|
|
11
|
+
ComputedInfraNode,
|
|
12
|
+
ComputedInfraEdge,
|
|
13
|
+
InfraGroup,
|
|
14
|
+
} from './types';
|
|
15
|
+
|
|
16
|
+
// ============================================================
|
|
17
|
+
// Layout types
|
|
18
|
+
// ============================================================
|
|
19
|
+
|
|
20
|
+
export interface InfraLayoutNode {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
computedRps: number;
|
|
28
|
+
overloaded: boolean;
|
|
29
|
+
rateLimited: boolean;
|
|
30
|
+
isEdge: boolean;
|
|
31
|
+
groupId: string | null;
|
|
32
|
+
computedLatencyMs: number;
|
|
33
|
+
computedLatencyPercentiles: ComputedInfraNode['computedLatencyPercentiles'];
|
|
34
|
+
computedUptime: number;
|
|
35
|
+
computedAvailability: number;
|
|
36
|
+
computedAvailabilityPercentiles: ComputedInfraNode['computedAvailabilityPercentiles'];
|
|
37
|
+
computedInstances: number;
|
|
38
|
+
computedConcurrentInvocations: number;
|
|
39
|
+
computedCbState: ComputedInfraNode['computedCbState'];
|
|
40
|
+
childHealthState?: ComputedInfraNode['childHealthState'];
|
|
41
|
+
properties: ComputedInfraNode['properties'];
|
|
42
|
+
queueMetrics?: ComputedInfraNode['queueMetrics'];
|
|
43
|
+
tags: Record<string, string>;
|
|
44
|
+
lineNumber: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface InfraLayoutEdge {
|
|
48
|
+
sourceId: string;
|
|
49
|
+
targetId: string;
|
|
50
|
+
label: string;
|
|
51
|
+
computedRps: number;
|
|
52
|
+
split: number;
|
|
53
|
+
points: { x: number; y: number }[];
|
|
54
|
+
lineNumber: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface InfraLayoutGroup {
|
|
58
|
+
id: string;
|
|
59
|
+
label: string;
|
|
60
|
+
x: number;
|
|
61
|
+
y: number;
|
|
62
|
+
width: number;
|
|
63
|
+
height: number;
|
|
64
|
+
instances?: number | string;
|
|
65
|
+
lineNumber: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface InfraLayoutResult {
|
|
69
|
+
nodes: InfraLayoutNode[];
|
|
70
|
+
edges: InfraLayoutEdge[];
|
|
71
|
+
groups: InfraLayoutGroup[];
|
|
72
|
+
/** Diagram-level options (e.g., default-latency-ms, default-uptime). */
|
|
73
|
+
options: Record<string, string>;
|
|
74
|
+
width: number;
|
|
75
|
+
height: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ============================================================
|
|
79
|
+
// Sizing constants
|
|
80
|
+
// ============================================================
|
|
81
|
+
|
|
82
|
+
const MIN_NODE_WIDTH = 140;
|
|
83
|
+
const NODE_HEADER_HEIGHT = 28;
|
|
84
|
+
const META_LINE_HEIGHT = 14;
|
|
85
|
+
const NODE_SEPARATOR_GAP = 4;
|
|
86
|
+
const NODE_PAD_BOTTOM = 10;
|
|
87
|
+
const ROLE_DOT_ROW = 12;
|
|
88
|
+
const COLLAPSE_BAR_HEIGHT = 6;
|
|
89
|
+
const CHAR_WIDTH = 7;
|
|
90
|
+
const META_CHAR_WIDTH = 6;
|
|
91
|
+
const PADDING_X = 24;
|
|
92
|
+
const GROUP_PADDING = 20;
|
|
93
|
+
const GROUP_HEADER_HEIGHT = 24;
|
|
94
|
+
const EDGE_MARGIN = 60;
|
|
95
|
+
|
|
96
|
+
// ============================================================
|
|
97
|
+
// Node sizing
|
|
98
|
+
// ============================================================
|
|
99
|
+
|
|
100
|
+
/** Display property keys shown as key: value rows. */
|
|
101
|
+
const DISPLAY_KEYS = new Set([
|
|
102
|
+
'cache-hit', 'firewall-block', 'ratelimit-rps',
|
|
103
|
+
'latency-ms', 'uptime', 'instances', 'max-rps',
|
|
104
|
+
'cb-error-threshold', 'cb-latency-threshold-ms',
|
|
105
|
+
'concurrency', 'duration-ms', 'cold-start-ms',
|
|
106
|
+
'buffer', 'drain-rate', 'retention-hours', 'partitions',
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
/** Display names for width estimation. */
|
|
110
|
+
const DISPLAY_NAMES: Record<string, string> = {
|
|
111
|
+
'cache-hit': 'cache hit', 'firewall-block': 'fw block',
|
|
112
|
+
'ratelimit-rps': 'rate limit', 'latency-ms': 'latency', 'uptime': 'uptime',
|
|
113
|
+
'instances': 'instances', 'max-rps': 'capacity',
|
|
114
|
+
'cb-error-threshold': 'CB error', 'cb-latency-threshold-ms': 'CB latency',
|
|
115
|
+
'concurrency': 'concurrency', 'duration-ms': 'duration', 'cold-start-ms': 'cold start',
|
|
116
|
+
'buffer': 'buffer', 'drain-rate': 'drain', 'retention-hours': 'retention', 'partitions': 'partitions',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function countDisplayProps(node: ComputedInfraNode, expanded: boolean, options?: Record<string, string>): number {
|
|
120
|
+
// Declared properties are only shown when the node is selected (expanded)
|
|
121
|
+
if (!expanded) return 0;
|
|
122
|
+
let count = node.properties.filter((p) => DISPLAY_KEYS.has(p.key)).length;
|
|
123
|
+
// Count diagram-level default rows for properties the node doesn't explicitly declare
|
|
124
|
+
if (options) {
|
|
125
|
+
const hasLatency = node.properties.some((p) => p.key === 'latency-ms');
|
|
126
|
+
const hasUptime = node.properties.some((p) => p.key === 'uptime');
|
|
127
|
+
const isServerless = node.properties.some((p) => p.key === 'concurrency');
|
|
128
|
+
const defaultLatency = parseFloat(options['default-latency-ms'] ?? '') || 0;
|
|
129
|
+
const defaultUptime = parseFloat(options['default-uptime'] ?? '') || 0;
|
|
130
|
+
if (!hasLatency && !isServerless && defaultLatency > 0) count++;
|
|
131
|
+
if (!hasUptime && defaultUptime > 0 && defaultUptime < 100) count++;
|
|
132
|
+
}
|
|
133
|
+
return count;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Count computed rows shown below declared props. When expanded, shows p50/p90/p99; otherwise just p99. */
|
|
137
|
+
function countComputedRows(node: ComputedInfraNode, expanded: boolean): number {
|
|
138
|
+
let count = 0;
|
|
139
|
+
// Serverless instances row
|
|
140
|
+
if (node.computedConcurrentInvocations > 0) count += 1;
|
|
141
|
+
const p = node.computedLatencyPercentiles;
|
|
142
|
+
if (p.p50 > 0 || p.p90 > 0 || p.p99 > 0) count += expanded ? 3 : 1; // all percentiles or just p99
|
|
143
|
+
if (node.computedUptime < 1) {
|
|
144
|
+
const declaredUptime = node.properties.find((p) => p.key === 'uptime');
|
|
145
|
+
const declaredVal = declaredUptime ? Number(declaredUptime.value) / 100 : 1;
|
|
146
|
+
const differs = Math.abs(node.computedUptime - declaredVal) > 0.000001;
|
|
147
|
+
if (differs || node.isEdge) count += 1;
|
|
148
|
+
}
|
|
149
|
+
if (node.computedAvailability < 1) count += 1;
|
|
150
|
+
// CB state row when circuit breaker is open
|
|
151
|
+
if (node.computedCbState === 'open') count += 1;
|
|
152
|
+
// Queue computed rows: lag + overflow
|
|
153
|
+
if (node.queueMetrics) {
|
|
154
|
+
if (node.queueMetrics.fillRate > 0) count += 1; // lag row
|
|
155
|
+
if (node.queueMetrics.fillRate > 0 && node.queueMetrics.timeToOverflow < Infinity) count += 1; // overflow row
|
|
156
|
+
}
|
|
157
|
+
return count;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function hasRoles(node: ComputedInfraNode): boolean {
|
|
161
|
+
if (node.isEdge) return false;
|
|
162
|
+
return node.properties.some((p) => DISPLAY_KEYS.has(p.key));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function computeNodeWidth(node: ComputedInfraNode, expanded: boolean, options?: Record<string, string>): number {
|
|
166
|
+
// Account for badge text (e.g., "3x") in header width — serverless nodes no longer show a badge
|
|
167
|
+
const badgeVal = node.computedConcurrentInvocations === 0 && node.computedInstances > 1
|
|
168
|
+
? node.computedInstances : 0;
|
|
169
|
+
const badgeLen = badgeVal > 0 ? `${badgeVal}x`.length + 2 : 0;
|
|
170
|
+
const labelWidth = (node.label.length + badgeLen) * CHAR_WIDTH + PADDING_X;
|
|
171
|
+
|
|
172
|
+
// Collect all key names (including "RPS" and computed rows) to compute aligned value column
|
|
173
|
+
const allKeys: string[] = [];
|
|
174
|
+
if (node.computedRps > 0) allKeys.push('RPS');
|
|
175
|
+
// Declared property keys only included when expanded
|
|
176
|
+
if (expanded) {
|
|
177
|
+
for (const p of node.properties) {
|
|
178
|
+
const dk = DISPLAY_NAMES[p.key];
|
|
179
|
+
if (dk) allKeys.push(dk);
|
|
180
|
+
}
|
|
181
|
+
// Default property keys
|
|
182
|
+
if (options) {
|
|
183
|
+
const hasLatency = node.properties.some((p) => p.key === 'latency-ms');
|
|
184
|
+
const hasUptime = node.properties.some((p) => p.key === 'uptime');
|
|
185
|
+
const isServerless = node.properties.some((p) => p.key === 'concurrency');
|
|
186
|
+
if (!hasLatency && !isServerless && (parseFloat(options['default-latency-ms'] ?? '') || 0) > 0) allKeys.push('latency');
|
|
187
|
+
if (!hasUptime && (parseFloat(options['default-uptime'] ?? '') || 0) > 0 && parseFloat(options['default-uptime'] ?? '') < 100) allKeys.push('uptime');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Computed rows
|
|
191
|
+
const computedRows = countComputedRows(node, expanded);
|
|
192
|
+
if (computedRows > 0) {
|
|
193
|
+
if (node.computedConcurrentInvocations > 0) allKeys.push('instances');
|
|
194
|
+
const perc = node.computedLatencyPercentiles;
|
|
195
|
+
if (perc.p50 > 0 || perc.p90 > 0 || perc.p99 > 0) {
|
|
196
|
+
if (expanded) {
|
|
197
|
+
allKeys.push('p50', 'p90', 'p99');
|
|
198
|
+
} else {
|
|
199
|
+
allKeys.push('p99');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (node.computedUptime < 1) {
|
|
203
|
+
const declaredUptime = node.properties.find((p) => p.key === 'uptime');
|
|
204
|
+
const declaredVal = declaredUptime ? Number(declaredUptime.value) / 100 : 1;
|
|
205
|
+
if (Math.abs(node.computedUptime - declaredVal) > 0.000001 || node.isEdge) {
|
|
206
|
+
allKeys.push('eff. uptime');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (node.computedAvailability < 1) allKeys.push('availability');
|
|
210
|
+
if (node.computedCbState === 'open') allKeys.push('CB');
|
|
211
|
+
if (node.queueMetrics) {
|
|
212
|
+
if (node.queueMetrics.fillRate > 0) allKeys.push('lag');
|
|
213
|
+
if (node.queueMetrics.fillRate > 0 && node.queueMetrics.timeToOverflow < Infinity) allKeys.push('overflow');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (allKeys.length === 0) return Math.max(MIN_NODE_WIDTH, labelWidth);
|
|
217
|
+
|
|
218
|
+
const maxKeyLen = Math.max(...allKeys.map((k) => k.length));
|
|
219
|
+
// key + ": " + value
|
|
220
|
+
let maxRowWidth = 0;
|
|
221
|
+
if (node.computedRps > 0) {
|
|
222
|
+
// RPS row may show "29.3k / 50k" when an effective cap exists
|
|
223
|
+
const nodeMaxRps = getNumProp(node, 'max-rps', 0);
|
|
224
|
+
const nodeRateLimit = getNumProp(node, 'ratelimit-rps', 0);
|
|
225
|
+
const nodeConcurrency = getNumProp(node, 'concurrency', 0);
|
|
226
|
+
const nodeDurationMs = getNumProp(node, 'duration-ms', 100);
|
|
227
|
+
const serverlessCap = nodeConcurrency > 0 ? nodeConcurrency / (nodeDurationMs / 1000) : 0;
|
|
228
|
+
const effectiveCap = serverlessCap > 0 ? serverlessCap
|
|
229
|
+
: nodeMaxRps > 0 && nodeRateLimit > 0
|
|
230
|
+
? Math.min(nodeMaxRps * node.computedInstances, nodeRateLimit)
|
|
231
|
+
: nodeMaxRps > 0 ? nodeMaxRps * node.computedInstances
|
|
232
|
+
: nodeRateLimit > 0 ? nodeRateLimit
|
|
233
|
+
: 0;
|
|
234
|
+
const rpsVal = effectiveCap > 0 && !node.isEdge
|
|
235
|
+
? `${formatRpsShort(node.computedRps)} / ${formatRpsShort(effectiveCap)}`
|
|
236
|
+
: formatRps(node.computedRps);
|
|
237
|
+
maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + rpsVal.length) * META_CHAR_WIDTH);
|
|
238
|
+
}
|
|
239
|
+
// Declared property value widths only when expanded
|
|
240
|
+
if (expanded) {
|
|
241
|
+
for (const p of node.properties) {
|
|
242
|
+
const dk = DISPLAY_NAMES[p.key];
|
|
243
|
+
if (!dk) continue;
|
|
244
|
+
const numVal = typeof p.value === 'number' ? p.value : parseFloat(String(p.value)) || 0;
|
|
245
|
+
const PCT_KEYS = ['cache-hit', 'firewall-block', 'uptime', 'cb-error-threshold'];
|
|
246
|
+
const valLen = (p.key === 'max-rps' || p.key === 'ratelimit-rps')
|
|
247
|
+
? formatRpsShort(numVal).length
|
|
248
|
+
: (p.key === 'latency-ms' || p.key === 'cb-latency-threshold-ms' || p.key === 'duration-ms' || p.key === 'cold-start-ms')
|
|
249
|
+
? formatMs(numVal).length
|
|
250
|
+
: PCT_KEYS.includes(p.key)
|
|
251
|
+
? `${numVal}%`.length
|
|
252
|
+
: String(p.value).length;
|
|
253
|
+
maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Computed row widths (e.g., "p99: 120ms")
|
|
257
|
+
if (computedRows > 0) {
|
|
258
|
+
const perc = node.computedLatencyPercentiles;
|
|
259
|
+
const msValues = expanded ? [perc.p50, perc.p90, perc.p99] : [perc.p99];
|
|
260
|
+
for (const ms of msValues) {
|
|
261
|
+
if (ms > 0) {
|
|
262
|
+
const valLen = formatMs(ms).length;
|
|
263
|
+
maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (node.computedUptime < 1) {
|
|
267
|
+
const valLen = formatUptime(node.computedUptime).length;
|
|
268
|
+
maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH);
|
|
269
|
+
}
|
|
270
|
+
if (node.computedAvailability < 1) {
|
|
271
|
+
const valLen = formatUptime(node.computedAvailability).length;
|
|
272
|
+
maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH);
|
|
273
|
+
}
|
|
274
|
+
// CB state row ("CB: OPEN") — inverted pill, use full text width
|
|
275
|
+
if (node.computedCbState === 'open') {
|
|
276
|
+
maxRowWidth = Math.max(maxRowWidth, 'CB: OPEN'.length * META_CHAR_WIDTH + 8);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return Math.max(MIN_NODE_WIDTH, labelWidth, maxRowWidth + 20);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function computeNodeHeight(node: ComputedInfraNode, expanded: boolean, options?: Record<string, string>): number {
|
|
284
|
+
const propCount = countDisplayProps(node, expanded, options);
|
|
285
|
+
const computedCount = countComputedRows(node, expanded);
|
|
286
|
+
const hasRps = node.computedRps > 0;
|
|
287
|
+
if (propCount === 0 && computedCount === 0 && !hasRps) return NODE_HEADER_HEIGHT + NODE_PAD_BOTTOM;
|
|
288
|
+
|
|
289
|
+
let h = NODE_HEADER_HEIGHT + NODE_SEPARATOR_GAP;
|
|
290
|
+
// Computed section: RPS + computed rows
|
|
291
|
+
const computedSectionCount = (hasRps ? 1 : 0) + computedCount;
|
|
292
|
+
h += computedSectionCount * META_LINE_HEIGHT;
|
|
293
|
+
// Separator between computed and declared sections
|
|
294
|
+
if (computedSectionCount > 0 && propCount > 0) h += NODE_SEPARATOR_GAP;
|
|
295
|
+
// Declared property rows
|
|
296
|
+
h += propCount * META_LINE_HEIGHT;
|
|
297
|
+
// Role dots row
|
|
298
|
+
if (hasRoles(node)) h += ROLE_DOT_ROW;
|
|
299
|
+
h += NODE_PAD_BOTTOM;
|
|
300
|
+
// Collapsed group nodes have a collapse bar at the bottom — add space so dots aren't obscured
|
|
301
|
+
if (node.id.startsWith('[')) h += COLLAPSE_BAR_HEIGHT;
|
|
302
|
+
return h;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function formatRps(rps: number): string {
|
|
306
|
+
if (rps >= 1000) return `${(rps / 1000).toFixed(1)}k rps`;
|
|
307
|
+
return `${Math.round(rps)} rps`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function formatRpsShort(rps: number): string {
|
|
311
|
+
if (rps >= 1000) return `${(rps / 1000).toFixed(1)}k`;
|
|
312
|
+
return `${Math.round(rps)}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function getNumProp(node: ComputedInfraNode, key: string, fallback: number): number {
|
|
316
|
+
const p = node.properties.find((pr) => pr.key === key);
|
|
317
|
+
if (!p) return fallback;
|
|
318
|
+
return typeof p.value === 'number' ? p.value : parseFloat(String(p.value)) || fallback;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function formatMs(ms: number): string {
|
|
322
|
+
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
|
|
323
|
+
return `${Math.round(ms)}ms`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function formatUptime(fraction: number): string {
|
|
327
|
+
const pct = fraction * 100;
|
|
328
|
+
if (pct >= 99.99) return '99.99%';
|
|
329
|
+
if (pct >= 99.9) return `${pct.toFixed(2)}%`;
|
|
330
|
+
if (pct >= 99) return `${pct.toFixed(1)}%`;
|
|
331
|
+
return `${pct.toFixed(1)}%`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ============================================================
|
|
335
|
+
// Layout engine
|
|
336
|
+
// ============================================================
|
|
337
|
+
|
|
338
|
+
export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: string | null, collapsedNodes?: Set<string> | null): InfraLayoutResult {
|
|
339
|
+
if (computed.nodes.length === 0) {
|
|
340
|
+
return { nodes: [], edges: [], groups: [], options: {}, width: 0, height: 0 };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const g = new dagre.graphlib.Graph();
|
|
344
|
+
g.setGraph({
|
|
345
|
+
rankdir: computed.direction === 'TB' ? 'TB' : 'LR',
|
|
346
|
+
nodesep: 50,
|
|
347
|
+
ranksep: 100,
|
|
348
|
+
edgesep: 20,
|
|
349
|
+
});
|
|
350
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
351
|
+
|
|
352
|
+
// Build set of grouped node IDs for inflating dimensions
|
|
353
|
+
const groupedNodeIds = new Set<string>();
|
|
354
|
+
for (const node of computed.nodes) {
|
|
355
|
+
if (node.groupId) groupedNodeIds.add(node.id);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Extra space dagre must reserve for the group bounding box
|
|
359
|
+
const GROUP_INFLATE = GROUP_PADDING * 2 + GROUP_HEADER_HEIGHT;
|
|
360
|
+
const isLR = computed.direction !== 'TB';
|
|
361
|
+
|
|
362
|
+
// Add nodes — inflate grouped nodes so dagre accounts for group boxes
|
|
363
|
+
const widthMap = new Map<string, number>();
|
|
364
|
+
const heightMap = new Map<string, number>();
|
|
365
|
+
for (const node of computed.nodes) {
|
|
366
|
+
const isNodeCollapsed = collapsedNodes?.has(node.id) ?? false;
|
|
367
|
+
const expanded = !isNodeCollapsed && node.id === selectedNodeId;
|
|
368
|
+
const width = computeNodeWidth(node, expanded, computed.options);
|
|
369
|
+
const height = isNodeCollapsed
|
|
370
|
+
? NODE_HEADER_HEIGHT + NODE_PAD_BOTTOM
|
|
371
|
+
: computeNodeHeight(node, expanded, computed.options);
|
|
372
|
+
widthMap.set(node.id, width);
|
|
373
|
+
heightMap.set(node.id, height);
|
|
374
|
+
const inGroup = groupedNodeIds.has(node.id);
|
|
375
|
+
g.setNode(node.id, {
|
|
376
|
+
label: node.label,
|
|
377
|
+
width: inGroup && !isLR ? width + GROUP_INFLATE : width,
|
|
378
|
+
height: inGroup && isLR ? height + GROUP_INFLATE : height,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Add edges — skip edges targeting groups (resolve to children instead)
|
|
383
|
+
const groupChildIds = new Set<string>();
|
|
384
|
+
for (const node of computed.nodes) {
|
|
385
|
+
if (node.groupId) groupChildIds.add(node.id);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Build group child lookup
|
|
389
|
+
const groupChildren = new Map<string, string[]>();
|
|
390
|
+
for (const node of computed.nodes) {
|
|
391
|
+
if (node.groupId) {
|
|
392
|
+
const list = groupChildren.get(node.groupId) ?? [];
|
|
393
|
+
list.push(node.id);
|
|
394
|
+
groupChildren.set(node.groupId, list);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
for (const edge of computed.edges) {
|
|
399
|
+
// If target is a group, add edges to all children of that group
|
|
400
|
+
const children = groupChildren.get(edge.targetId);
|
|
401
|
+
if (children && children.length > 0) {
|
|
402
|
+
for (const childId of children) {
|
|
403
|
+
g.setEdge(edge.sourceId, childId, { label: edge.label });
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
g.setEdge(edge.sourceId, edge.targetId, { label: edge.label });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Run layout
|
|
411
|
+
dagre.layout(g);
|
|
412
|
+
|
|
413
|
+
// Extract positioned nodes
|
|
414
|
+
const layoutNodes: InfraLayoutNode[] = computed.nodes.map((node) => {
|
|
415
|
+
const pos = g.node(node.id);
|
|
416
|
+
return {
|
|
417
|
+
id: node.id,
|
|
418
|
+
label: node.label,
|
|
419
|
+
x: pos.x,
|
|
420
|
+
y: pos.y,
|
|
421
|
+
width: widthMap.get(node.id) ?? MIN_NODE_WIDTH,
|
|
422
|
+
height: heightMap.get(node.id) ?? NODE_HEADER_HEIGHT + NODE_PAD_BOTTOM,
|
|
423
|
+
computedRps: node.computedRps,
|
|
424
|
+
overloaded: node.overloaded,
|
|
425
|
+
rateLimited: node.rateLimited,
|
|
426
|
+
isEdge: node.isEdge,
|
|
427
|
+
groupId: node.groupId,
|
|
428
|
+
computedLatencyMs: node.computedLatencyMs,
|
|
429
|
+
computedLatencyPercentiles: node.computedLatencyPercentiles,
|
|
430
|
+
computedUptime: node.computedUptime,
|
|
431
|
+
computedAvailability: node.computedAvailability,
|
|
432
|
+
computedAvailabilityPercentiles: node.computedAvailabilityPercentiles,
|
|
433
|
+
computedInstances: node.computedInstances,
|
|
434
|
+
computedConcurrentInvocations: node.computedConcurrentInvocations,
|
|
435
|
+
computedCbState: node.computedCbState,
|
|
436
|
+
childHealthState: node.childHealthState,
|
|
437
|
+
queueMetrics: node.queueMetrics,
|
|
438
|
+
properties: node.properties,
|
|
439
|
+
tags: node.tags,
|
|
440
|
+
lineNumber: node.lineNumber,
|
|
441
|
+
};
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Extract edge waypoints
|
|
445
|
+
const layoutEdges: InfraLayoutEdge[] = [];
|
|
446
|
+
for (const edge of computed.edges) {
|
|
447
|
+
const children = groupChildren.get(edge.targetId);
|
|
448
|
+
if (children && children.length > 0) {
|
|
449
|
+
// Use the first child's edge points as representative
|
|
450
|
+
const edgeData = g.edge(edge.sourceId, children[0]);
|
|
451
|
+
layoutEdges.push({
|
|
452
|
+
sourceId: edge.sourceId,
|
|
453
|
+
targetId: edge.targetId,
|
|
454
|
+
label: edge.label,
|
|
455
|
+
computedRps: edge.computedRps,
|
|
456
|
+
split: edge.split,
|
|
457
|
+
points: edgeData?.points ?? [],
|
|
458
|
+
lineNumber: edge.lineNumber,
|
|
459
|
+
});
|
|
460
|
+
} else {
|
|
461
|
+
const edgeData = g.edge(edge.sourceId, edge.targetId);
|
|
462
|
+
layoutEdges.push({
|
|
463
|
+
sourceId: edge.sourceId,
|
|
464
|
+
targetId: edge.targetId,
|
|
465
|
+
label: edge.label,
|
|
466
|
+
computedRps: edge.computedRps,
|
|
467
|
+
split: edge.split,
|
|
468
|
+
points: edgeData?.points ?? [],
|
|
469
|
+
lineNumber: edge.lineNumber,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Compute group bounding boxes from children
|
|
475
|
+
const layoutGroups: InfraLayoutGroup[] = computed.groups.map((group) => {
|
|
476
|
+
const childNodes = layoutNodes.filter((n) => n.groupId === group.id);
|
|
477
|
+
if (childNodes.length === 0) {
|
|
478
|
+
return {
|
|
479
|
+
id: group.id,
|
|
480
|
+
label: group.label,
|
|
481
|
+
x: 0,
|
|
482
|
+
y: 0,
|
|
483
|
+
width: MIN_NODE_WIDTH,
|
|
484
|
+
height: NODE_HEADER_HEIGHT + NODE_PAD_BOTTOM,
|
|
485
|
+
instances: group.instances,
|
|
486
|
+
lineNumber: group.lineNumber,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
490
|
+
for (const child of childNodes) {
|
|
491
|
+
const left = child.x - child.width / 2;
|
|
492
|
+
const right = child.x + child.width / 2;
|
|
493
|
+
const top = child.y - child.height / 2;
|
|
494
|
+
const bottom = child.y + child.height / 2;
|
|
495
|
+
if (left < minX) minX = left;
|
|
496
|
+
if (right > maxX) maxX = right;
|
|
497
|
+
if (top < minY) minY = top;
|
|
498
|
+
if (bottom > maxY) maxY = bottom;
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
id: group.id,
|
|
502
|
+
label: group.label,
|
|
503
|
+
x: minX - GROUP_PADDING,
|
|
504
|
+
y: minY - GROUP_PADDING - GROUP_HEADER_HEIGHT,
|
|
505
|
+
width: maxX - minX + GROUP_PADDING * 2,
|
|
506
|
+
height: maxY - minY + GROUP_PADDING * 2 + GROUP_HEADER_HEIGHT,
|
|
507
|
+
instances: group.instances,
|
|
508
|
+
lineNumber: group.lineNumber,
|
|
509
|
+
};
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// Compute total dimensions
|
|
513
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
514
|
+
for (const node of layoutNodes) {
|
|
515
|
+
const left = node.x - node.width / 2;
|
|
516
|
+
const right = node.x + node.width / 2;
|
|
517
|
+
const top = node.y - node.height / 2;
|
|
518
|
+
const bottom = node.y + node.height / 2;
|
|
519
|
+
if (left < minX) minX = left;
|
|
520
|
+
if (right > maxX) maxX = right;
|
|
521
|
+
if (top < minY) minY = top;
|
|
522
|
+
if (bottom > maxY) maxY = bottom;
|
|
523
|
+
}
|
|
524
|
+
for (const group of layoutGroups) {
|
|
525
|
+
if (group.x < minX) minX = group.x;
|
|
526
|
+
if (group.x + group.width > maxX) maxX = group.x + group.width;
|
|
527
|
+
if (group.y < minY) minY = group.y;
|
|
528
|
+
if (group.y + group.height > maxY) maxY = group.y + group.height;
|
|
529
|
+
}
|
|
530
|
+
for (const edge of layoutEdges) {
|
|
531
|
+
for (const pt of edge.points) {
|
|
532
|
+
if (pt.x < minX) minX = pt.x;
|
|
533
|
+
if (pt.x > maxX) maxX = pt.x;
|
|
534
|
+
if (pt.y < minY) minY = pt.y;
|
|
535
|
+
if (pt.y > maxY) maxY = pt.y;
|
|
536
|
+
}
|
|
537
|
+
// Account for edge label width at midpoint
|
|
538
|
+
if (edge.label) {
|
|
539
|
+
const midIdx = Math.floor(edge.points.length / 2);
|
|
540
|
+
const midPt = edge.points[midIdx];
|
|
541
|
+
if (midPt) {
|
|
542
|
+
const halfWidth = (edge.label.length * 6.5 + 8) / 2;
|
|
543
|
+
if (midPt.x - halfWidth < minX) minX = midPt.x - halfWidth;
|
|
544
|
+
if (midPt.x + halfWidth > maxX) maxX = midPt.x + halfWidth;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Shift everything to start at EDGE_MARGIN
|
|
550
|
+
const shiftX = -minX + EDGE_MARGIN;
|
|
551
|
+
const shiftY = -minY + EDGE_MARGIN;
|
|
552
|
+
for (const node of layoutNodes) {
|
|
553
|
+
node.x += shiftX;
|
|
554
|
+
node.y += shiftY;
|
|
555
|
+
}
|
|
556
|
+
for (const edge of layoutEdges) {
|
|
557
|
+
for (const pt of edge.points) {
|
|
558
|
+
pt.x += shiftX;
|
|
559
|
+
pt.y += shiftY;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
for (const group of layoutGroups) {
|
|
563
|
+
group.x += shiftX;
|
|
564
|
+
group.y += shiftY;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const totalWidth = (maxX + shiftX) + EDGE_MARGIN;
|
|
568
|
+
const totalHeight = (maxY + shiftY) + EDGE_MARGIN;
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
nodes: layoutNodes,
|
|
572
|
+
edges: layoutEdges,
|
|
573
|
+
groups: layoutGroups,
|
|
574
|
+
options: computed.options,
|
|
575
|
+
width: totalWidth,
|
|
576
|
+
height: totalHeight,
|
|
577
|
+
};
|
|
578
|
+
}
|