@diagrammo/dgmo 0.5.3 → 0.5.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/README.md +16 -16
- package/dist/cli.cjs +158 -158
- package/dist/index.cjs +766 -319
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -23
- package/dist/index.d.ts +37 -23
- package/dist/index.js +765 -319
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +20 -2
- package/package.json +1 -1
- package/src/d3.ts +241 -204
- package/src/index.ts +3 -0
- package/src/infra/compute.ts +88 -10
- package/src/infra/layout.ts +97 -12
- package/src/infra/parser.ts +47 -4
- package/src/infra/renderer.ts +216 -42
- package/src/infra/roles.ts +15 -0
- package/src/infra/types.ts +7 -0
- package/src/initiative-status/collapse.ts +76 -0
- package/src/initiative-status/layout.ts +193 -26
- package/src/initiative-status/renderer.ts +94 -46
- package/src/org/layout.ts +5 -2
- package/src/org/renderer.ts +65 -11
- package/src/org/resolver.ts +1 -1
- package/src/sharing.ts +12 -0
package/src/index.ts
CHANGED
|
@@ -218,6 +218,9 @@ export type {
|
|
|
218
218
|
|
|
219
219
|
export { renderInitiativeStatus, renderInitiativeStatusForExport } from './initiative-status/renderer';
|
|
220
220
|
|
|
221
|
+
export { collapseInitiativeStatus } from './initiative-status/collapse';
|
|
222
|
+
export type { CollapseResult } from './initiative-status/collapse';
|
|
223
|
+
|
|
221
224
|
export { parseSitemap, looksLikeSitemap } from './sitemap/parser';
|
|
222
225
|
|
|
223
226
|
export type {
|
package/src/infra/compute.ts
CHANGED
|
@@ -295,29 +295,35 @@ function collapseGroups(parsed: ParsedInfra, collapsedIds: Set<string>, defaultL
|
|
|
295
295
|
const children = groupChildren.get(group.id) ?? [];
|
|
296
296
|
if (children.length === 0) continue;
|
|
297
297
|
|
|
298
|
-
// Aggregate properties:
|
|
299
|
-
|
|
298
|
+
// Aggregate properties: bottleneck capacity, compose behaviors
|
|
299
|
+
// Latency is computed separately below via critical-path analysis.
|
|
300
300
|
let minEffectiveCapacity = Infinity;
|
|
301
301
|
let hasMaxRps = false;
|
|
302
302
|
let composedUptime = 1;
|
|
303
303
|
const behaviorProps: InfraProperty[] = [];
|
|
304
304
|
const perChildCapacities: number[] = [];
|
|
305
305
|
|
|
306
|
+
// Collect per-child latency values for critical-path computation
|
|
307
|
+
const childIdSet = new Set(children.map((c) => c.id));
|
|
308
|
+
const childLatencies = new Map<string, number>();
|
|
309
|
+
|
|
306
310
|
for (const child of children) {
|
|
307
311
|
// Latency: use explicit value, or diagram default (matching BFS behavior)
|
|
308
312
|
const latencyProp = child.properties.find((p) => p.key === 'latency-ms');
|
|
309
313
|
const childIsServerless = child.properties.some((p) => p.key === 'concurrency');
|
|
314
|
+
let childLat: number;
|
|
310
315
|
if (childIsServerless) {
|
|
311
316
|
// Serverless nodes use duration-ms as latency contribution
|
|
312
317
|
const durationProp = child.properties.find((p) => p.key === 'duration-ms');
|
|
313
|
-
|
|
318
|
+
childLat = durationProp
|
|
314
319
|
? (typeof durationProp.value === 'number' ? durationProp.value : parseFloat(String(durationProp.value)) || 100)
|
|
315
320
|
: 100;
|
|
316
321
|
} else if (latencyProp) {
|
|
317
|
-
|
|
322
|
+
childLat = typeof latencyProp.value === 'number' ? latencyProp.value : parseFloat(String(latencyProp.value)) || 0;
|
|
318
323
|
} else {
|
|
319
|
-
|
|
324
|
+
childLat = defaultLatencyMs;
|
|
320
325
|
}
|
|
326
|
+
childLatencies.set(child.id, childLat);
|
|
321
327
|
|
|
322
328
|
const maxRps = child.properties.find((p) => p.key === 'max-rps');
|
|
323
329
|
if (maxRps) {
|
|
@@ -349,6 +355,73 @@ function collapseGroups(parsed: ParsedInfra, collapsedIds: Set<string>, defaultL
|
|
|
349
355
|
}
|
|
350
356
|
}
|
|
351
357
|
|
|
358
|
+
// ── Critical-path latency through the group ──────────────────────────────
|
|
359
|
+
// totalLatency = max path length from external-inbound entry nodes to
|
|
360
|
+
// external-outbound exit nodes, traversing only internal edges.
|
|
361
|
+
// This prevents side-dependency latencies (nodes only reached via internal
|
|
362
|
+
// edges with no external exit) from inflating the virtual node's latency.
|
|
363
|
+
const entryIds = new Set<string>();
|
|
364
|
+
const exitIds = new Set<string>();
|
|
365
|
+
for (const edge of inboundEdges) {
|
|
366
|
+
if (childIdSet.has(edge.targetId)) entryIds.add(edge.targetId);
|
|
367
|
+
}
|
|
368
|
+
for (const edge of outboundEdges) {
|
|
369
|
+
if (childIdSet.has(edge.sourceId)) exitIds.add(edge.sourceId);
|
|
370
|
+
}
|
|
371
|
+
for (const edge of crossGroupEdges) {
|
|
372
|
+
if (childIdSet.has(edge.sourceId)) exitIds.add(edge.sourceId);
|
|
373
|
+
if (childIdSet.has(edge.targetId)) entryIds.add(edge.targetId);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Build forward adjacency for internal edges scoped to this group
|
|
377
|
+
const fwdAdj = new Map<string, string[]>();
|
|
378
|
+
for (const edge of internalEdges) {
|
|
379
|
+
if (!childIdSet.has(edge.sourceId) || !childIdSet.has(edge.targetId)) continue;
|
|
380
|
+
const list = fwdAdj.get(edge.sourceId) ?? [];
|
|
381
|
+
list.push(edge.targetId);
|
|
382
|
+
fwdAdj.set(edge.sourceId, list);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// DAG longest-path from entry nodes (topological DFS + relaxation)
|
|
386
|
+
const topoOrder: string[] = [];
|
|
387
|
+
const tsVisited = new Set<string>();
|
|
388
|
+
const dfsTopoSort = (id: string) => {
|
|
389
|
+
if (tsVisited.has(id)) return;
|
|
390
|
+
tsVisited.add(id);
|
|
391
|
+
for (const next of fwdAdj.get(id) ?? []) dfsTopoSort(next);
|
|
392
|
+
topoOrder.unshift(id);
|
|
393
|
+
};
|
|
394
|
+
for (const child of children) dfsTopoSort(child.id);
|
|
395
|
+
|
|
396
|
+
const dist = new Map<string, number>();
|
|
397
|
+
for (const child of children) {
|
|
398
|
+
dist.set(child.id, entryIds.has(child.id) ? (childLatencies.get(child.id) ?? 0) : -Infinity);
|
|
399
|
+
}
|
|
400
|
+
for (const nodeId of topoOrder) {
|
|
401
|
+
const curDist = dist.get(nodeId) ?? -Infinity;
|
|
402
|
+
if (curDist === -Infinity) continue;
|
|
403
|
+
for (const nextId of fwdAdj.get(nodeId) ?? []) {
|
|
404
|
+
const newDist = curDist + (childLatencies.get(nextId) ?? 0);
|
|
405
|
+
if (newDist > (dist.get(nextId) ?? -Infinity)) dist.set(nextId, newDist);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
let totalLatency = 0;
|
|
410
|
+
if (exitIds.size > 0) {
|
|
411
|
+
for (const id of exitIds) {
|
|
412
|
+
const d = dist.get(id);
|
|
413
|
+
if (d !== undefined && d > -Infinity && d > totalLatency) totalLatency = d;
|
|
414
|
+
}
|
|
415
|
+
} else if (entryIds.size > 0) {
|
|
416
|
+
// No explicit exits — use max reachable distance from entry nodes
|
|
417
|
+
for (const [, d] of dist) {
|
|
418
|
+
if (d > 0 && d > totalLatency) totalLatency = d;
|
|
419
|
+
}
|
|
420
|
+
} else {
|
|
421
|
+
// No external connections — fall back to summing all children
|
|
422
|
+
for (const [, lat] of childLatencies) totalLatency += lat;
|
|
423
|
+
}
|
|
424
|
+
|
|
352
425
|
// Build virtual node properties
|
|
353
426
|
const props: InfraProperty[] = [];
|
|
354
427
|
if (totalLatency > 0) props.push({ key: 'latency-ms', value: totalLatency, lineNumber: group.lineNumber });
|
|
@@ -692,8 +765,10 @@ export function computeInfra(
|
|
|
692
765
|
|
|
693
766
|
for (const { edge, split } of resolved) {
|
|
694
767
|
const edgeRps = outboundRps * (split / 100);
|
|
768
|
+
const fanout = (edge.fanout != null && edge.fanout >= 1) ? edge.fanout : 1;
|
|
769
|
+
const fanoutedRps = edgeRps * fanout;
|
|
695
770
|
const edgeKey = `${edge.sourceId}->${edge.targetId}`;
|
|
696
|
-
computedEdgeRps.set(edgeKey,
|
|
771
|
+
computedEdgeRps.set(edgeKey, fanoutedRps);
|
|
697
772
|
|
|
698
773
|
// Resolve target — could be a group or a node
|
|
699
774
|
let targetIds: string[];
|
|
@@ -705,7 +780,7 @@ export function computeInfra(
|
|
|
705
780
|
}
|
|
706
781
|
|
|
707
782
|
for (const targetId of targetIds) {
|
|
708
|
-
const perTarget =
|
|
783
|
+
const perTarget = fanoutedRps / targetIds.length;
|
|
709
784
|
const existing = computedRps.get(targetId) ?? 0;
|
|
710
785
|
computedRps.set(targetId, existing + perTarget);
|
|
711
786
|
|
|
@@ -837,6 +912,7 @@ export function computeInfra(
|
|
|
837
912
|
const paths: LeafPath[] = [];
|
|
838
913
|
|
|
839
914
|
for (const { edge, split } of resolved) {
|
|
915
|
+
const fanout = (edge.fanout != null && edge.fanout >= 1) ? edge.fanout : 1;
|
|
840
916
|
const groupChildren = groupChildMap.get(edge.targetId);
|
|
841
917
|
const targetIds = (groupChildren && groupChildren.length > 0)
|
|
842
918
|
? groupChildren : [edge.targetId];
|
|
@@ -850,21 +926,21 @@ export function computeInfra(
|
|
|
850
926
|
latency: nodeLatency + cp.latency,
|
|
851
927
|
uptime: nodeUptimeFrac * cp.uptime,
|
|
852
928
|
availability: nodeAvail * cp.availability,
|
|
853
|
-
weight: cp.weight * (split / 100) / targetIds.length * 0.95,
|
|
929
|
+
weight: cp.weight * (split / 100) / targetIds.length * fanout * 0.95,
|
|
854
930
|
});
|
|
855
931
|
// Cold path (5% of requests)
|
|
856
932
|
paths.push({
|
|
857
933
|
latency: coldLatency + cp.latency,
|
|
858
934
|
uptime: nodeUptimeFrac * cp.uptime,
|
|
859
935
|
availability: nodeAvail * cp.availability,
|
|
860
|
-
weight: cp.weight * (split / 100) / targetIds.length * 0.05,
|
|
936
|
+
weight: cp.weight * (split / 100) / targetIds.length * fanout * 0.05,
|
|
861
937
|
});
|
|
862
938
|
} else {
|
|
863
939
|
paths.push({
|
|
864
940
|
latency: nodeLatency + cp.latency,
|
|
865
941
|
uptime: nodeUptimeFrac * cp.uptime,
|
|
866
942
|
availability: nodeAvail * cp.availability,
|
|
867
|
-
weight: cp.weight * (split / 100) / targetIds.length,
|
|
943
|
+
weight: cp.weight * (split / 100) / targetIds.length * fanout,
|
|
868
944
|
});
|
|
869
945
|
}
|
|
870
946
|
}
|
|
@@ -1063,6 +1139,7 @@ export function computeInfra(
|
|
|
1063
1139
|
queueMetrics,
|
|
1064
1140
|
properties: node.properties,
|
|
1065
1141
|
tags: node.tags,
|
|
1142
|
+
description: node.description,
|
|
1066
1143
|
lineNumber: node.lineNumber,
|
|
1067
1144
|
};
|
|
1068
1145
|
});
|
|
@@ -1093,6 +1170,7 @@ export function computeInfra(
|
|
|
1093
1170
|
label: edge.label,
|
|
1094
1171
|
computedRps: rps,
|
|
1095
1172
|
split: resolvedSplit,
|
|
1173
|
+
fanout: edge.fanout,
|
|
1096
1174
|
lineNumber: edge.lineNumber,
|
|
1097
1175
|
};
|
|
1098
1176
|
});
|
package/src/infra/layout.ts
CHANGED
|
@@ -41,6 +41,7 @@ export interface InfraLayoutNode {
|
|
|
41
41
|
properties: ComputedInfraNode['properties'];
|
|
42
42
|
queueMetrics?: ComputedInfraNode['queueMetrics'];
|
|
43
43
|
tags: Record<string, string>;
|
|
44
|
+
description?: string;
|
|
44
45
|
lineNumber: number;
|
|
45
46
|
}
|
|
46
47
|
|
|
@@ -50,6 +51,7 @@ export interface InfraLayoutEdge {
|
|
|
50
51
|
label: string;
|
|
51
52
|
computedRps: number;
|
|
52
53
|
split: number;
|
|
54
|
+
fanout: number | null;
|
|
53
55
|
points: { x: number; y: number }[];
|
|
54
56
|
lineNumber: number;
|
|
55
57
|
}
|
|
@@ -109,8 +111,8 @@ const DISPLAY_KEYS = new Set([
|
|
|
109
111
|
/** Display names for width estimation. */
|
|
110
112
|
const DISPLAY_NAMES: Record<string, string> = {
|
|
111
113
|
'cache-hit': 'cache hit', 'firewall-block': 'fw block',
|
|
112
|
-
'ratelimit-rps': 'rate limit', 'latency-ms': 'latency', 'uptime': 'uptime',
|
|
113
|
-
'instances': 'instances', 'max-rps': '
|
|
114
|
+
'ratelimit-rps': 'rate limit RPS', 'latency-ms': 'latency', 'uptime': 'uptime',
|
|
115
|
+
'instances': 'instances', 'max-rps': 'max RPS',
|
|
114
116
|
'cb-error-threshold': 'CB error', 'cb-latency-threshold-ms': 'CB latency',
|
|
115
117
|
'concurrency': 'concurrency', 'duration-ms': 'duration', 'cold-start-ms': 'cold start',
|
|
116
118
|
'buffer': 'buffer', 'drain-rate': 'drain', 'retention-hours': 'retention', 'partitions': 'partitions',
|
|
@@ -133,13 +135,13 @@ function countDisplayProps(node: ComputedInfraNode, expanded: boolean, options?:
|
|
|
133
135
|
return count;
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
/** Count computed rows shown below declared props. When expanded, shows p50/p90/p99; otherwise just
|
|
138
|
+
/** Count computed rows shown below declared props. When expanded, shows p50/p90/p99; otherwise just p90. */
|
|
137
139
|
function countComputedRows(node: ComputedInfraNode, expanded: boolean): number {
|
|
138
140
|
let count = 0;
|
|
139
141
|
// Serverless instances row
|
|
140
142
|
if (node.computedConcurrentInvocations > 0) count += 1;
|
|
141
143
|
const p = node.computedLatencyPercentiles;
|
|
142
|
-
if (p.p50 > 0 || p.p90 > 0 || p.p99 > 0) count += expanded ? 3 : 1; // all percentiles or just
|
|
144
|
+
if (p.p50 > 0 || p.p90 > 0 || p.p99 > 0) count += expanded ? 3 : 1; // all percentiles or just p90
|
|
143
145
|
if (node.computedUptime < 1) {
|
|
144
146
|
const declaredUptime = node.properties.find((p) => p.key === 'uptime');
|
|
145
147
|
const declaredVal = declaredUptime ? Number(declaredUptime.value) / 100 : 1;
|
|
@@ -196,7 +198,7 @@ function computeNodeWidth(node: ComputedInfraNode, expanded: boolean, options?:
|
|
|
196
198
|
if (expanded) {
|
|
197
199
|
allKeys.push('p50', 'p90', 'p99');
|
|
198
200
|
} else {
|
|
199
|
-
allKeys.push('
|
|
201
|
+
allKeys.push('p90');
|
|
200
202
|
}
|
|
201
203
|
}
|
|
202
204
|
if (node.computedUptime < 1) {
|
|
@@ -253,16 +255,30 @@ function computeNodeWidth(node: ComputedInfraNode, expanded: boolean, options?:
|
|
|
253
255
|
maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH);
|
|
254
256
|
}
|
|
255
257
|
}
|
|
256
|
-
// Computed row widths (e.g., "
|
|
258
|
+
// Computed row widths (e.g., "p90: 520ms" or "p90: 520ms / 500ms" when SLO configured)
|
|
257
259
|
if (computedRows > 0) {
|
|
258
260
|
const perc = node.computedLatencyPercentiles;
|
|
259
|
-
const msValues = expanded ? [perc.p50, perc.p90, perc.p99] : [perc.
|
|
261
|
+
const msValues = expanded ? [perc.p50, perc.p90, perc.p99] : [perc.p90];
|
|
260
262
|
for (const ms of msValues) {
|
|
261
263
|
if (ms > 0) {
|
|
262
264
|
const valLen = formatMs(ms).length;
|
|
263
265
|
maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH);
|
|
264
266
|
}
|
|
265
267
|
}
|
|
268
|
+
// p90 may show "<current> / <threshold>" when non-green. Always reserve combined width
|
|
269
|
+
// so node width doesn't reflow when SLO state transitions from green to warning/overloaded.
|
|
270
|
+
if (perc.p90 > 0) {
|
|
271
|
+
const rawThreshold =
|
|
272
|
+
node.properties.find((p) => p.key === 'slo-p90-latency-ms')?.value ??
|
|
273
|
+
options?.['slo-p90-latency-ms'];
|
|
274
|
+
const threshold = rawThreshold != null ? parseFloat(String(rawThreshold)) : NaN;
|
|
275
|
+
if (!isNaN(threshold) && threshold > 0) {
|
|
276
|
+
// formatMs here must produce the same string as formatMsShort in renderer.ts — both are identical.
|
|
277
|
+
// If either changes, the reserved width and the rendered text will diverge.
|
|
278
|
+
const combinedVal = `${formatMs(perc.p90)} / ${formatMs(threshold)}`;
|
|
279
|
+
maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + combinedVal.length) * META_CHAR_WIDTH);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
266
282
|
if (node.computedUptime < 1) {
|
|
267
283
|
const valLen = formatUptime(node.computedUptime).length;
|
|
268
284
|
maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH);
|
|
@@ -277,16 +293,21 @@ function computeNodeWidth(node: ComputedInfraNode, expanded: boolean, options?:
|
|
|
277
293
|
}
|
|
278
294
|
}
|
|
279
295
|
|
|
280
|
-
|
|
296
|
+
const DESC_MAX_CHARS = 120;
|
|
297
|
+
const descText = (expanded && node.description && !node.isEdge) ? node.description : '';
|
|
298
|
+
const descTruncated = descText.length > DESC_MAX_CHARS ? descText.slice(0, DESC_MAX_CHARS - 1) + '…' : descText;
|
|
299
|
+
const descWidth = descTruncated.length > 0 ? descTruncated.length * META_CHAR_WIDTH + PADDING_X : 0;
|
|
300
|
+
return Math.max(MIN_NODE_WIDTH, labelWidth, maxRowWidth + 20, descWidth);
|
|
281
301
|
}
|
|
282
302
|
|
|
283
303
|
function computeNodeHeight(node: ComputedInfraNode, expanded: boolean, options?: Record<string, string>): number {
|
|
284
304
|
const propCount = countDisplayProps(node, expanded, options);
|
|
285
305
|
const computedCount = countComputedRows(node, expanded);
|
|
286
306
|
const hasRps = node.computedRps > 0;
|
|
287
|
-
|
|
307
|
+
const descH = expanded && node.description && !node.isEdge ? META_LINE_HEIGHT : 0;
|
|
308
|
+
if (propCount === 0 && computedCount === 0 && !hasRps) return NODE_HEADER_HEIGHT + descH + NODE_PAD_BOTTOM;
|
|
288
309
|
|
|
289
|
-
let h = NODE_HEADER_HEIGHT + NODE_SEPARATOR_GAP;
|
|
310
|
+
let h = NODE_HEADER_HEIGHT + descH + NODE_SEPARATOR_GAP;
|
|
290
311
|
// Computed section: RPS + computed rows
|
|
291
312
|
const computedSectionCount = (hasRps ? 1 : 0) + computedCount;
|
|
292
313
|
h += computedSectionCount * META_LINE_HEIGHT;
|
|
@@ -331,11 +352,69 @@ function formatUptime(fraction: number): string {
|
|
|
331
352
|
return `${pct.toFixed(1)}%`;
|
|
332
353
|
}
|
|
333
354
|
|
|
355
|
+
// ============================================================
|
|
356
|
+
// Group separation pass
|
|
357
|
+
// ============================================================
|
|
358
|
+
|
|
359
|
+
const GROUP_GAP = 24; // min clear gap between group boxes — matches GROUP_HEADER_HEIGHT
|
|
360
|
+
|
|
361
|
+
export function separateGroups(
|
|
362
|
+
groups: InfraLayoutGroup[],
|
|
363
|
+
nodes: InfraLayoutNode[],
|
|
364
|
+
isLR: boolean,
|
|
365
|
+
maxIterations = 20,
|
|
366
|
+
): void {
|
|
367
|
+
// Symmetric 2D rectangle intersection — no sorting needed, handles all
|
|
368
|
+
// relative positions correctly, stable after mid-pass shifts.
|
|
369
|
+
// Endpoint edge routing is not affected: renderer.ts recomputes border
|
|
370
|
+
// connection points from node x/y at render time via nodeBorderPoint().
|
|
371
|
+
for (let iter = 0; iter < maxIterations; iter++) {
|
|
372
|
+
let anyOverlap = false;
|
|
373
|
+
for (let i = 0; i < groups.length; i++) {
|
|
374
|
+
for (let j = i + 1; j < groups.length; j++) {
|
|
375
|
+
const ga = groups[i];
|
|
376
|
+
const gb = groups[j];
|
|
377
|
+
|
|
378
|
+
// Symmetric primary-axis overlap (Y for LR, X for TB)
|
|
379
|
+
const primaryOverlap = isLR
|
|
380
|
+
? Math.min(ga.y + ga.height, gb.y + gb.height) - Math.max(ga.y, gb.y)
|
|
381
|
+
: Math.min(ga.x + ga.width, gb.x + gb.width) - Math.max(ga.x, gb.x);
|
|
382
|
+
if (primaryOverlap <= 0) continue;
|
|
383
|
+
|
|
384
|
+
// Symmetric cross-axis overlap — boxes must intersect in 2D
|
|
385
|
+
const crossOverlap = isLR
|
|
386
|
+
? Math.min(ga.x + ga.width, gb.x + gb.width) - Math.max(ga.x, gb.x)
|
|
387
|
+
: Math.min(ga.y + ga.height, gb.y + gb.height) - Math.max(ga.y, gb.y);
|
|
388
|
+
if (crossOverlap <= 0) continue;
|
|
389
|
+
|
|
390
|
+
anyOverlap = true;
|
|
391
|
+
const shift = primaryOverlap + GROUP_GAP;
|
|
392
|
+
|
|
393
|
+
// Shift the group with the larger primary-axis center (deterministic)
|
|
394
|
+
const aCenter = isLR ? ga.y + ga.height / 2 : ga.x + ga.width / 2;
|
|
395
|
+
const bCenter = isLR ? gb.y + gb.height / 2 : gb.x + gb.width / 2;
|
|
396
|
+
const groupToShift = aCenter <= bCenter ? gb : ga;
|
|
397
|
+
|
|
398
|
+
if (isLR) groupToShift.y += shift;
|
|
399
|
+
else groupToShift.x += shift;
|
|
400
|
+
|
|
401
|
+
for (const node of nodes) {
|
|
402
|
+
if (node.groupId === groupToShift.id) {
|
|
403
|
+
if (isLR) node.y += shift;
|
|
404
|
+
else node.x += shift;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (!anyOverlap) break;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
334
413
|
// ============================================================
|
|
335
414
|
// Layout engine
|
|
336
415
|
// ============================================================
|
|
337
416
|
|
|
338
|
-
export function layoutInfra(computed: ComputedInfraModel,
|
|
417
|
+
export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<string> | null, collapsedNodes?: Set<string> | null): InfraLayoutResult {
|
|
339
418
|
if (computed.nodes.length === 0) {
|
|
340
419
|
return { nodes: [], edges: [], groups: [], options: {}, width: 0, height: 0 };
|
|
341
420
|
}
|
|
@@ -364,7 +443,7 @@ export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: strin
|
|
|
364
443
|
const heightMap = new Map<string, number>();
|
|
365
444
|
for (const node of computed.nodes) {
|
|
366
445
|
const isNodeCollapsed = collapsedNodes?.has(node.id) ?? false;
|
|
367
|
-
const expanded = !isNodeCollapsed && node.id
|
|
446
|
+
const expanded = !isNodeCollapsed && (expandedNodeIds?.has(node.id) ?? false);
|
|
368
447
|
const width = computeNodeWidth(node, expanded, computed.options);
|
|
369
448
|
const height = isNodeCollapsed
|
|
370
449
|
? NODE_HEADER_HEIGHT + NODE_PAD_BOTTOM
|
|
@@ -437,6 +516,7 @@ export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: strin
|
|
|
437
516
|
queueMetrics: node.queueMetrics,
|
|
438
517
|
properties: node.properties,
|
|
439
518
|
tags: node.tags,
|
|
519
|
+
description: node.description,
|
|
440
520
|
lineNumber: node.lineNumber,
|
|
441
521
|
};
|
|
442
522
|
});
|
|
@@ -454,6 +534,7 @@ export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: strin
|
|
|
454
534
|
label: edge.label,
|
|
455
535
|
computedRps: edge.computedRps,
|
|
456
536
|
split: edge.split,
|
|
537
|
+
fanout: edge.fanout,
|
|
457
538
|
points: edgeData?.points ?? [],
|
|
458
539
|
lineNumber: edge.lineNumber,
|
|
459
540
|
});
|
|
@@ -465,6 +546,7 @@ export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: strin
|
|
|
465
546
|
label: edge.label,
|
|
466
547
|
computedRps: edge.computedRps,
|
|
467
548
|
split: edge.split,
|
|
549
|
+
fanout: edge.fanout,
|
|
468
550
|
points: edgeData?.points ?? [],
|
|
469
551
|
lineNumber: edge.lineNumber,
|
|
470
552
|
});
|
|
@@ -509,6 +591,9 @@ export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: strin
|
|
|
509
591
|
};
|
|
510
592
|
});
|
|
511
593
|
|
|
594
|
+
// Separate overlapping groups (post-layout pass)
|
|
595
|
+
separateGroups(layoutGroups, layoutNodes, isLR);
|
|
596
|
+
|
|
512
597
|
// Compute total dimensions
|
|
513
598
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
514
599
|
for (const node of layoutNodes) {
|
package/src/infra/parser.ts
CHANGED
|
@@ -23,13 +23,13 @@ import { INFRA_BEHAVIOR_KEYS, EDGE_ONLY_KEYS } from './types';
|
|
|
23
23
|
// Regex patterns
|
|
24
24
|
// ============================================================
|
|
25
25
|
|
|
26
|
-
// Connection: -label-> Target or -> Target (with optional | split: N%)
|
|
26
|
+
// Connection: -label-> Target or -> Target (with optional | split: N% and optional x5 fanout)
|
|
27
27
|
const CONNECTION_RE =
|
|
28
|
-
/^-(?:([^-].*?))?->\s+(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
|
|
28
|
+
/^-(?:([^-].*?))?->\s+(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*(?:x(\d+))?\s*$/;
|
|
29
29
|
|
|
30
30
|
// Simple connection shorthand: -> Target (no label, no dash prefix needed for edge)
|
|
31
31
|
const SIMPLE_CONNECTION_RE =
|
|
32
|
-
/^->\s+(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
|
|
32
|
+
/^->\s+(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*(?:x(\d+))?\s*$/;
|
|
33
33
|
|
|
34
34
|
// Group declaration: [Group Name]
|
|
35
35
|
const GROUP_RE = /^\[([^\]]+)\]$/;
|
|
@@ -41,7 +41,8 @@ const TAG_GROUP_RE = /^tag\s*:\s*(\w[\w\s]*?)(?:\s+alias\s+(\w+))?\s*$/;
|
|
|
41
41
|
const TAG_VALUE_RE = /^(\w[\w\s]*?)(?:\(([^)]+)\))?(\s+default)?\s*$/;
|
|
42
42
|
|
|
43
43
|
// Component line: ComponentName or ComponentName | t: Backend | env: Prod
|
|
44
|
-
|
|
44
|
+
// Allows hyphens in names (e.g. api-gateway, my-service-v2) — but not at the start.
|
|
45
|
+
const COMPONENT_RE = /^([a-zA-Z_][\w-]*)(.*)$/;
|
|
45
46
|
|
|
46
47
|
// Pipe metadata: | key: value or | k1: v1, k2: v2 (comma-separated)
|
|
47
48
|
const PIPE_META_RE = /[|,]\s*(\w+)\s*:\s*([^|,]+)/g;
|
|
@@ -231,6 +232,24 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
231
232
|
continue;
|
|
232
233
|
}
|
|
233
234
|
|
|
235
|
+
// slo-availability: <percentage e.g. 99.9%>
|
|
236
|
+
if (/^slo-availability\s*:/i.test(trimmed)) {
|
|
237
|
+
result.options['slo-availability'] = trimmed.replace(/^slo-availability\s*:\s*/i, '').trim();
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// slo-p90-latency-ms: <number>
|
|
242
|
+
if (/^slo-p90-latency-ms\s*:/i.test(trimmed)) {
|
|
243
|
+
result.options['slo-p90-latency-ms'] = trimmed.replace(/^slo-p90-latency-ms\s*:\s*/i, '').trim();
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// slo-warning-margin: <percentage e.g. 5%>
|
|
248
|
+
if (/^slo-warning-margin\s*:/i.test(trimmed)) {
|
|
249
|
+
result.options['slo-warning-margin'] = trimmed.replace(/^slo-warning-margin\s*:\s*/i, '').trim();
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
234
253
|
// scenario: Name
|
|
235
254
|
if (/^scenario\s*:/i.test(trimmed)) {
|
|
236
255
|
finishCurrentNode();
|
|
@@ -397,12 +416,19 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
397
416
|
if (simpleConn) {
|
|
398
417
|
const targetName = simpleConn[1].trim();
|
|
399
418
|
const splitStr = simpleConn[2];
|
|
419
|
+
const fanoutStr = simpleConn[3];
|
|
400
420
|
const split = splitStr ? parseFloat(splitStr) : null;
|
|
421
|
+
const fanoutRaw = fanoutStr ? parseInt(fanoutStr, 10) : null;
|
|
422
|
+
if (fanoutRaw !== null && fanoutRaw < 1) {
|
|
423
|
+
warn(lineNumber, `Fan-out multiplier must be at least 1 (got x${fanoutRaw}). Ignoring.`);
|
|
424
|
+
}
|
|
425
|
+
const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
|
|
401
426
|
result.edges.push({
|
|
402
427
|
sourceId: currentNode.id,
|
|
403
428
|
targetId: nodeId(targetName),
|
|
404
429
|
label: '',
|
|
405
430
|
split,
|
|
431
|
+
fanout,
|
|
406
432
|
lineNumber,
|
|
407
433
|
});
|
|
408
434
|
continue;
|
|
@@ -414,7 +440,13 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
414
440
|
const label = connMatch[1]?.trim() || '';
|
|
415
441
|
const targetName = connMatch[2].trim();
|
|
416
442
|
const splitStr = connMatch[3];
|
|
443
|
+
const fanoutStr = connMatch[4];
|
|
417
444
|
const split = splitStr ? parseFloat(splitStr) : null;
|
|
445
|
+
const fanoutRaw = fanoutStr ? parseInt(fanoutStr, 10) : null;
|
|
446
|
+
if (fanoutRaw !== null && fanoutRaw < 1) {
|
|
447
|
+
warn(lineNumber, `Fan-out multiplier must be at least 1 (got x${fanoutRaw}). Ignoring.`);
|
|
448
|
+
}
|
|
449
|
+
const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
|
|
418
450
|
|
|
419
451
|
// Target might be a group ref like [API Pods]
|
|
420
452
|
let targetId: string;
|
|
@@ -430,17 +462,28 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
430
462
|
targetId,
|
|
431
463
|
label,
|
|
432
464
|
split,
|
|
465
|
+
fanout,
|
|
433
466
|
lineNumber,
|
|
434
467
|
});
|
|
435
468
|
continue;
|
|
436
469
|
}
|
|
437
470
|
|
|
471
|
+
// Empty description: (no value) — silently skip rather than emitting "Unexpected line"
|
|
472
|
+
if (/^description\s*:\s*$/i.test(trimmed)) continue;
|
|
473
|
+
|
|
438
474
|
// Property: key: value
|
|
439
475
|
const propMatch = trimmed.match(PROPERTY_RE);
|
|
440
476
|
if (propMatch) {
|
|
441
477
|
const key = propMatch[1].toLowerCase();
|
|
442
478
|
const rawVal = propMatch[2].trim();
|
|
443
479
|
|
|
480
|
+
// description is display metadata, not a behavior key; silently ignored on edge nodes.
|
|
481
|
+
// Single-line only — no length enforcement, but keep it short for legibility.
|
|
482
|
+
if (key === 'description' && currentNode) {
|
|
483
|
+
if (!currentNode.isEdge) currentNode.description = rawVal;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
|
|
444
487
|
// Validate property key
|
|
445
488
|
if (!INFRA_BEHAVIOR_KEYS.has(key) && !EDGE_ONLY_KEYS.has(key)) {
|
|
446
489
|
const allKeys = [...INFRA_BEHAVIOR_KEYS, ...EDGE_ONLY_KEYS];
|