@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.
@@ -11,7 +11,7 @@ import type { ParsedInfra, InfraTagGroup } from './types';
11
11
  import { resolveColor } from '../colors';
12
12
  import type { ComputedInfraModel } from './types';
13
13
  import type { InfraLayoutResult, InfraLayoutNode, InfraLayoutEdge, InfraLayoutGroup } from './layout';
14
- import { inferRoles, collectDiagramRoles } from './roles';
14
+ import { inferRoles, collectDiagramRoles, collectFanoutSourceIds, FANOUT_ROLE } from './roles';
15
15
  import type { InfraRole } from './roles';
16
16
  import { parseInfra } from './parser';
17
17
  import { computeInfra } from './compute';
@@ -52,6 +52,9 @@ const LEGEND_ENTRY_DOT_GAP = 4;
52
52
  const LEGEND_ENTRY_TRAIL = 8;
53
53
  const LEGEND_GROUP_GAP = 12;
54
54
  const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram
55
+ const SPEED_BADGE_H_PAD = 5; // horizontal padding inside active speed badge
56
+ const SPEED_BADGE_V_PAD = 3; // vertical padding inside active speed badge
57
+ const SPEED_BADGE_GAP = 6; // gap between speed option slots
55
58
 
56
59
  // Health colors (from UX spec)
57
60
  const COLOR_HEALTHY = '#22c55e';
@@ -59,6 +62,33 @@ const COLOR_WARNING = '#eab308';
59
62
  const COLOR_OVERLOADED = '#ef4444';
60
63
  const COLOR_NEUTRAL = '#94a3b8';
61
64
 
65
+ /** SLO thresholds resolved for a single node (chart-level + per-node override). */
66
+ interface NodeSlo {
67
+ availThreshold: number | null; // fraction e.g. 0.999
68
+ latencyP90: number | null; // ms e.g. 200
69
+ warningMargin: number; // fraction e.g. 0.05
70
+ }
71
+
72
+ /** Resolve effective SLO for a node: per-node properties take precedence over chart-level options.
73
+ * Returns null if neither availThreshold nor latencyP90 is declared. */
74
+ function resolveNodeSlo(node: InfraLayoutNode, diagramOptions: Record<string, string>): NodeSlo | null {
75
+ const nodeProp = (key: string) => node.properties.find((p) => p.key === key);
76
+
77
+ const availRaw = nodeProp('slo-availability')?.value ?? diagramOptions['slo-availability'];
78
+ const latencyRaw = nodeProp('slo-p90-latency-ms')?.value ?? diagramOptions['slo-p90-latency-ms'];
79
+ const marginRaw = nodeProp('slo-warning-margin')?.value ?? diagramOptions['slo-warning-margin'];
80
+
81
+ const availParsed = availRaw != null ? parseFloat(String(availRaw).replace('%', '')) / 100 : NaN;
82
+ const availThreshold = !isNaN(availParsed) ? availParsed : null;
83
+ const latencyParsed = latencyRaw != null ? parseFloat(String(latencyRaw)) : NaN;
84
+ const latencyP90 = !isNaN(latencyParsed) ? latencyParsed : null;
85
+ const marginParsed = marginRaw != null ? parseFloat(String(marginRaw).replace('%', '')) / 100 : NaN;
86
+ const warningMargin = !isNaN(marginParsed) ? marginParsed : 0.05;
87
+
88
+ if (availThreshold == null && latencyP90 == null) return null;
89
+ return { availThreshold, latencyP90, warningMargin };
90
+ }
91
+
62
92
  /** A row in the node card body. `inverted` renders as a colored pill with light text. */
63
93
  interface NodeRow {
64
94
  key: string;
@@ -162,26 +192,34 @@ function isWarning(node: InfraLayoutNode): boolean {
162
192
  // Helpers
163
193
  // ============================================================
164
194
 
165
- /** Display names for behavior property keys. */
195
+ /** Display names for behavior property keys. Aligned with DSL property names. */
166
196
  const PROP_DISPLAY: Record<string, string> = {
167
197
  'cache-hit': 'cache hit',
168
- 'firewall-block': 'fw block',
169
- 'ratelimit-rps': 'rate limit',
198
+ 'firewall-block': 'firewall block',
199
+ 'ratelimit-rps': 'rate limit RPS',
170
200
  'latency-ms': 'latency',
171
201
  'uptime': 'uptime',
172
202
  'instances': 'instances',
173
- 'max-rps': 'capacity',
174
- 'cb-error-threshold': 'CB error',
175
- 'cb-latency-threshold-ms': 'CB latency',
203
+ 'max-rps': 'max RPS',
204
+ 'cb-error-threshold': 'CB error threshold',
205
+ 'cb-latency-threshold-ms': 'CB latency threshold',
176
206
  'concurrency': 'concurrency',
177
207
  'duration-ms': 'duration',
178
208
  'cold-start-ms': 'cold start',
179
209
  'buffer': 'buffer',
180
- 'drain-rate': 'drain',
210
+ 'drain-rate': 'drain rate',
181
211
  'retention-hours': 'retention',
182
212
  'partitions': 'partitions',
183
213
  };
184
214
 
215
+ const DESC_MAX_CHARS = 120;
216
+
217
+ /** Truncate description text to DESC_MAX_CHARS. */
218
+ function truncateDesc(text: string): string {
219
+ if (text.length <= DESC_MAX_CHARS) return text;
220
+ return text.slice(0, DESC_MAX_CHARS - 1) + '…';
221
+ }
222
+
185
223
  /** Keys whose values are RPS counts and should be formatted like RPS. */
186
224
  const RPS_FORMAT_KEYS = new Set(['max-rps', 'ratelimit-rps']);
187
225
 
@@ -191,8 +229,17 @@ const MS_FORMAT_KEYS = new Set(['latency-ms', 'cb-latency-threshold-ms', 'durati
191
229
  /** Keys whose values are percentages and should show the "%" suffix. */
192
230
  const PCT_FORMAT_KEYS = new Set(['cache-hit', 'firewall-block', 'uptime', 'cb-error-threshold']);
193
231
 
232
+ /** Compute SLO color for a p90 latency value against the configured threshold.
233
+ * Callers must guard slo.latencyP90 != null before calling. */
234
+ function sloLatencyColor(p90: number, slo: NodeSlo): string {
235
+ const t = slo.latencyP90 ?? 0;
236
+ if (t === 0) return COLOR_HEALTHY; // no meaningful threshold — treat as healthy
237
+ const m = slo.warningMargin;
238
+ return p90 > t ? COLOR_OVERLOADED : p90 > t * (1 - m) ? COLOR_WARNING : COLOR_HEALTHY;
239
+ }
240
+
194
241
  /** Computed metric rows (latency percentiles, uptime, availability, CB state) shown after declared props. */
195
- function getComputedRows(node: InfraLayoutNode, expanded: boolean): ComputedRow[] {
242
+ function getComputedRows(node: InfraLayoutNode, expanded: boolean, slo?: NodeSlo | null): ComputedRow[] {
196
243
  const rows: ComputedRow[] = [];
197
244
 
198
245
  // Serverless instances: demand vs concurrency limit
@@ -211,9 +258,28 @@ function getComputedRows(node: InfraLayoutNode, expanded: boolean): ComputedRow[
211
258
  if (p.p50 > 0 || p.p90 > 0 || p.p99 > 0) {
212
259
  if (expanded) {
213
260
  rows.push({ key: 'p50', value: formatMsShort(p.p50) });
214
- rows.push({ key: 'p90', value: formatMsShort(p.p90) });
261
+ if (slo?.latencyP90 != null) {
262
+ const color = sloLatencyColor(p.p90, slo);
263
+ const p90Value = color !== COLOR_HEALTHY
264
+ ? `${formatMsShort(p.p90)} / ${formatMsShort(slo.latencyP90!)}`
265
+ : formatMsShort(p.p90);
266
+ rows.push({ key: 'p90', value: p90Value, color, inverted: color !== COLOR_HEALTHY });
267
+ } else {
268
+ rows.push({ key: 'p90', value: formatMsShort(p.p90) });
269
+ }
270
+ rows.push({ key: 'p99', value: formatMsShort(p.p99) });
271
+ } else if (p.p90 > 0) {
272
+ // Collapsed: show p90 (with SLO color if configured) instead of p99
273
+ if (slo?.latencyP90 != null) {
274
+ const color = sloLatencyColor(p.p90, slo);
275
+ const p90Value = color !== COLOR_HEALTHY
276
+ ? `${formatMsShort(p.p90)} / ${formatMsShort(slo.latencyP90!)}`
277
+ : formatMsShort(p.p90);
278
+ rows.push({ key: 'p90', value: p90Value, color, inverted: color !== COLOR_HEALTHY });
279
+ } else {
280
+ rows.push({ key: 'p90', value: formatMsShort(p.p90) });
281
+ }
215
282
  }
216
- rows.push({ key: 'p99', value: formatMsShort(p.p99) });
217
283
  }
218
284
  // Computed (cumulative) uptime — only show when it differs from the declared node uptime.
219
285
  // On the edge node this is the system-wide uptime; on other nodes it's the path product.
@@ -227,10 +293,19 @@ function getComputedRows(node: InfraLayoutNode, expanded: boolean): ComputedRow[
227
293
  }
228
294
  }
229
295
  if (node.computedAvailability < 1) {
230
- const color = node.computedAvailability < 0.95 ? COLOR_OVERLOADED
231
- : node.computedAvailability < 0.99 ? COLOR_WARNING
232
- : undefined;
233
- rows.push({ key: 'availability', value: formatUptimeShort(node.computedAvailability), color, inverted: color != null });
296
+ let color: string | undefined;
297
+ if (slo?.availThreshold != null) {
298
+ const t = slo.availThreshold;
299
+ const m = slo.warningMargin;
300
+ if (node.computedAvailability < t) color = COLOR_OVERLOADED;
301
+ else if (node.computedAvailability < Math.min(1, t + m)) color = COLOR_WARNING;
302
+ else color = COLOR_HEALTHY;
303
+ } else {
304
+ color = node.computedAvailability < 0.95 ? COLOR_OVERLOADED
305
+ : node.computedAvailability < 0.99 ? COLOR_WARNING
306
+ : undefined;
307
+ }
308
+ rows.push({ key: 'availability', value: formatUptimeShort(node.computedAvailability), color, inverted: color != null && color !== COLOR_HEALTHY });
234
309
  }
235
310
  // Circuit breaker state — show when a CB is configured and open
236
311
  if (node.computedCbState === 'open') {
@@ -326,6 +401,7 @@ function getDisplayProps(node: InfraLayoutNode, expanded: boolean, diagramOption
326
401
  return rows;
327
402
  }
328
403
 
404
+
329
405
  function formatRps(rps: number): string {
330
406
  if (rps >= 1000) return `${(rps / 1000).toFixed(1)}k rps`;
331
407
  return `${Math.round(rps)} rps`;
@@ -338,8 +414,11 @@ function formatRpsShort(rps: number): string {
338
414
  }
339
415
 
340
416
  /** Compute the worst severity across all row-producing conditions for a node.
341
- * Returns 'overloaded' (red), 'warning' (yellow), or 'normal'. */
342
- function worstNodeSeverity(node: InfraLayoutNode): 'overloaded' | 'warning' | 'normal' {
417
+ * Returns 'overloaded' (red), 'warning' (yellow), 'healthy' (green), or 'normal'. */
418
+ function worstNodeSeverity(
419
+ node: InfraLayoutNode,
420
+ slo?: NodeSlo | null,
421
+ ): 'overloaded' | 'warning' | 'healthy' | 'normal' {
343
422
  let worst: 'overloaded' | 'warning' | 'normal' = 'normal';
344
423
  const upgrade = (s: 'overloaded' | 'warning') => {
345
424
  if (s === 'overloaded') worst = 'overloaded';
@@ -355,9 +434,27 @@ function worstNodeSeverity(node: InfraLayoutNode): 'overloaded' | 'warning' | 'n
355
434
  if (node.rateLimited) upgrade('warning');
356
435
  if (isWarning(node)) upgrade('warning');
357
436
 
358
- // Availability
359
- if (node.computedAvailability < 0.95) upgrade('overloaded');
360
- else if (node.computedAvailability < 0.99) upgrade('warning');
437
+ // Availability — SLO threshold if declared, otherwise hardcoded fallback
438
+ if (slo?.availThreshold != null) {
439
+ const t = slo.availThreshold;
440
+ const m = slo.warningMargin;
441
+ if (node.computedAvailability < t) upgrade('overloaded');
442
+ else if (node.computedAvailability < Math.min(1, t + m)) upgrade('warning');
443
+ // else: in green zone — handled after loop
444
+ } else {
445
+ if (node.computedAvailability < 0.95) upgrade('overloaded');
446
+ else if (node.computedAvailability < 0.99) upgrade('warning');
447
+ }
448
+
449
+ // p90 Latency SLO
450
+ if (slo?.latencyP90 != null) {
451
+ const t = slo.latencyP90;
452
+ const m = slo.warningMargin;
453
+ const p90 = node.computedLatencyPercentiles.p90;
454
+ if (p90 > t) upgrade('overloaded');
455
+ else if (p90 > t * (1 - m)) upgrade('warning');
456
+ // else: in green zone
457
+ }
361
458
 
362
459
  // Circuit breaker open
363
460
  if (node.computedCbState === 'open') upgrade('overloaded');
@@ -382,15 +479,28 @@ function worstNodeSeverity(node: InfraLayoutNode): 'overloaded' | 'warning' | 'n
382
479
  }
383
480
  }
384
481
 
482
+ // Healthy: SLO declared AND all checks are in the green zone
483
+ if (worst === 'normal' && slo != null) {
484
+ const availGreen = slo.availThreshold == null ||
485
+ node.computedAvailability >= Math.min(1, slo.availThreshold + slo.warningMargin);
486
+ const latencyGreen = slo.latencyP90 == null ||
487
+ node.computedLatencyPercentiles.p90 <= slo.latencyP90 * (1 - slo.warningMargin);
488
+ if (availGreen && latencyGreen) return 'healthy';
489
+ }
490
+
385
491
  return worst;
386
492
  }
387
493
 
388
- function nodeColor(node: InfraLayoutNode, palette: PaletteColors, isDark: boolean): {
494
+ function nodeColor(
495
+ _node: InfraLayoutNode,
496
+ palette: PaletteColors,
497
+ isDark: boolean,
498
+ severity: ReturnType<typeof worstNodeSeverity>,
499
+ ): {
389
500
  fill: string;
390
501
  stroke: string;
391
502
  textFill: string;
392
503
  } {
393
- const severity = worstNodeSeverity(node);
394
504
  if (severity === 'overloaded') {
395
505
  return {
396
506
  fill: mix(palette.bg, COLOR_OVERLOADED, isDark ? 80 : 92),
@@ -405,6 +515,13 @@ function nodeColor(node: InfraLayoutNode, palette: PaletteColors, isDark: boolea
405
515
  textFill: palette.text,
406
516
  };
407
517
  }
518
+ if (severity === 'healthy') {
519
+ return {
520
+ fill: mix(palette.bg, COLOR_HEALTHY, isDark ? 85 : 93),
521
+ stroke: COLOR_HEALTHY,
522
+ textFill: palette.text,
523
+ };
524
+ }
408
525
  return {
409
526
  fill: isDark ? mix(palette.bg, palette.text, 90) : mix(palette.bg, palette.text, 95),
410
527
  stroke: isDark ? mix(palette.text, palette.bg, 60) : mix(palette.text, palette.bg, 40),
@@ -629,16 +746,20 @@ function renderNodes(
629
746
  palette: PaletteColors,
630
747
  isDark: boolean,
631
748
  animate: boolean,
632
- selectedNodeId?: string | null,
749
+ expandedNodeIds?: Set<string> | null,
633
750
  activeGroup?: string | null,
634
751
  diagramOptions?: Record<string, string>,
635
752
  collapsedNodes?: Set<string> | null,
636
753
  tagGroups?: InfraTagGroup[],
754
+ fanoutSourceIds?: Set<string>,
755
+ scaledGroupIds?: Set<string>,
637
756
  ) {
638
757
  const mutedColor = palette.textMuted;
639
758
 
640
759
  for (const node of nodes) {
641
- let { fill, stroke, textFill } = nodeColor(node, palette, isDark);
760
+ const slo = (!node.isEdge && diagramOptions) ? resolveNodeSlo(node, diagramOptions) : null;
761
+ const severity = worstNodeSeverity(node, slo);
762
+ let { fill, stroke, textFill } = nodeColor(node, palette, isDark, severity);
642
763
 
643
764
  // When a tag legend is active, override border color with tag color
644
765
  if (activeGroup && tagGroups && !node.isEdge) {
@@ -652,7 +773,6 @@ function renderNodes(
652
773
  if (animate && node.isEdge) {
653
774
  cls += ' infra-node-edge-throb';
654
775
  } else if (animate && !node.isEdge) {
655
- const severity = worstNodeSeverity(node);
656
776
  if (node.computedCbState === 'open') cls += ' infra-node-cb-open';
657
777
  else if (severity === 'overloaded') cls += ' infra-node-overload';
658
778
  else if (severity === 'warning') cls += ' infra-node-warning';
@@ -680,12 +800,15 @@ function renderNodes(
680
800
  for (const role of roles) {
681
801
  g.attr(`data-role-${role.name.toLowerCase().replace(/\s+/g, '-')}`, 'true');
682
802
  }
803
+ if (fanoutSourceIds?.has(node.id)) {
804
+ g.attr('data-role-fan-out', 'true');
805
+ }
683
806
  }
684
807
 
685
808
  const x = node.x - node.width / 2;
686
809
  const y = node.y - node.height / 2;
687
810
  const isCollapsedGroup = node.id.startsWith('[');
688
- const strokeWidth = worstNodeSeverity(node) !== 'normal' ? OVERLOAD_STROKE_WIDTH : NODE_STROKE_WIDTH;
811
+ const strokeWidth = severity !== 'normal' ? OVERLOAD_STROKE_WIDTH : NODE_STROKE_WIDTH;
689
812
 
690
813
  // Node rect
691
814
  g.append('rect')
@@ -726,15 +849,32 @@ function renderNodes(
726
849
  .text('▼');
727
850
  }
728
851
  if (!isNodeCollapsed) {
729
- const expanded = node.id === selectedNodeId;
852
+ const expanded = expandedNodeIds?.has(node.id) ?? false;
853
+
854
+ // Description subtitle — shown below label only when node is selected
855
+ const descH = (expanded && node.description && !node.isEdge) ? META_LINE_HEIGHT : 0;
856
+ if (descH > 0 && node.description) {
857
+ const descTruncated = truncateDesc(node.description);
858
+ const isTruncated = descTruncated !== node.description;
859
+ const textEl = g.append('text')
860
+ .attr('x', node.x)
861
+ .attr('y', y + NODE_HEADER_HEIGHT + META_LINE_HEIGHT / 2 + META_FONT_SIZE * 0.35)
862
+ .attr('text-anchor', 'middle')
863
+ .attr('font-family', FONT_FAMILY)
864
+ .attr('font-size', META_FONT_SIZE)
865
+ .attr('fill', mutedColor)
866
+ .text(descTruncated);
867
+ if (isTruncated) textEl.append('title').text(node.description);
868
+ }
869
+
730
870
  // Declared properties only shown when node is selected (expanded)
731
871
  const displayProps = (!node.isEdge && expanded) ? getDisplayProps(node, expanded, diagramOptions) : [];
732
- const computedRows = getComputedRows(node, expanded);
872
+ const computedRows = getComputedRows(node, expanded, slo);
733
873
  const hasContent = displayProps.length > 0 || computedRows.length > 0 || node.computedRps > 0;
734
874
 
735
875
  if (hasContent) {
736
876
  // Separator line between header and body
737
- const sepY = y + NODE_HEADER_HEIGHT;
877
+ const sepY = y + NODE_HEADER_HEIGHT + descH;
738
878
  g.append('line')
739
879
  .attr('x1', x)
740
880
  .attr('y1', sepY)
@@ -904,7 +1044,9 @@ function renderNodes(
904
1044
 
905
1045
  // Instance badge — clickable for interactive adjustment (not for edge or serverless nodes)
906
1046
  // Serverless nodes show instances in a computed row instead (demand / concurrency).
907
- if (!node.isEdge && node.computedConcurrentInvocations === 0 && node.computedInstances > 1) {
1047
+ // Nodes inside a scaled group suppress their badge the group header already shows Nx.
1048
+ const inScaledGroup = node.groupId != null && (scaledGroupIds?.has(node.groupId) ?? false);
1049
+ if (!node.isEdge && node.computedConcurrentInvocations === 0 && node.computedInstances > 1 && !inScaledGroup) {
908
1050
  const badgeText = `${node.computedInstances}x`;
909
1051
  g.append('text')
910
1052
  .attr('x', x + node.width - 6)
@@ -1109,11 +1251,15 @@ export function computeInfraLegendGroups(
1109
1251
  nodes: InfraLayoutNode[],
1110
1252
  tagGroups: InfraTagGroup[],
1111
1253
  palette: PaletteColors,
1254
+ edges?: InfraLayoutEdge[],
1112
1255
  ): InfraLegendGroup[] {
1113
1256
  const groups: InfraLegendGroup[] = [];
1114
1257
 
1115
- // Capabilities group (from inferred roles)
1258
+ // Capabilities group (from inferred roles + fanout edges)
1116
1259
  const roles = collectDiagramRoles(nodes.filter((n) => !n.isEdge).map((n) => n.properties));
1260
+ if (edges && collectFanoutSourceIds(edges).size > 0) {
1261
+ roles.push(FANOUT_ROLE);
1262
+ }
1117
1263
  if (roles.length > 0) {
1118
1264
  const entries = roles.map((r) => ({
1119
1265
  value: r.name,
@@ -1174,7 +1320,7 @@ function computePlaybackWidth(playback: InfraPlaybackState | undefined): number
1174
1320
  let entriesW = 8; // gap after pill
1175
1321
  entriesW += LEGEND_PILL_FONT_SIZE * 0.8 + 6; // play/pause
1176
1322
  for (const s of playback.speedOptions) {
1177
- entriesW += `${s}x`.length * LEGEND_ENTRY_FONT_W + 6;
1323
+ entriesW += `${s}x`.length * LEGEND_ENTRY_FONT_W + SPEED_BADGE_H_PAD * 2 + SPEED_BADGE_GAP;
1178
1324
  }
1179
1325
  return LEGEND_CAPSULE_PAD * 2 + pillWidth + entriesW;
1180
1326
  }
@@ -1380,17 +1526,35 @@ function renderLegend(
1380
1526
  for (const s of playback.speedOptions) {
1381
1527
  const label = `${s}x`;
1382
1528
  const isActive = playback.speed === s;
1383
- pbG.append('text')
1384
- .attr('x', entryX).attr('y', entryY)
1529
+ const slotW = label.length * LEGEND_ENTRY_FONT_W + SPEED_BADGE_H_PAD * 2;
1530
+ const badgeH = LEGEND_ENTRY_FONT_SIZE + SPEED_BADGE_V_PAD * 2;
1531
+ const badgeY = (LEGEND_HEIGHT - badgeH) / 2;
1532
+
1533
+ // Wrap in <g> with data attrs so a single element carries the action,
1534
+ // and both rect and text inherit the hit target cleanly.
1535
+ const speedG = pbG.append('g')
1536
+ .attr('data-playback-action', 'set-speed')
1537
+ .attr('data-playback-value', String(s))
1538
+ .style('cursor', 'pointer');
1539
+
1540
+ // Badge rect: filled for active, transparent hit-target for inactive
1541
+ speedG.append('rect')
1542
+ .attr('x', entryX)
1543
+ .attr('y', badgeY)
1544
+ .attr('width', slotW)
1545
+ .attr('height', badgeH)
1546
+ .attr('rx', badgeH / 2)
1547
+ .attr('fill', isActive ? palette.primary : 'transparent');
1548
+
1549
+ speedG.append('text')
1550
+ .attr('x', entryX + slotW / 2).attr('y', entryY)
1385
1551
  .attr('font-family', FONT_FAMILY)
1386
1552
  .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
1387
1553
  .attr('font-weight', isActive ? '600' : '400')
1388
- .attr('fill', isActive ? palette.primary : palette.textMuted)
1389
- .attr('data-playback-action', 'set-speed')
1390
- .attr('data-playback-value', String(s))
1391
- .style('cursor', 'pointer')
1554
+ .attr('fill', isActive ? palette.bg : palette.textMuted)
1555
+ .attr('text-anchor', 'middle')
1392
1556
  .text(label);
1393
- entryX += label.length * LEGEND_ENTRY_FONT_W + 6;
1557
+ entryX += slotW + SPEED_BADGE_GAP;
1394
1558
  }
1395
1559
  }
1396
1560
 
@@ -1421,7 +1585,7 @@ export function renderInfra(
1421
1585
  activeGroup?: string | null,
1422
1586
  animate?: boolean,
1423
1587
  playback?: InfraPlaybackState | null,
1424
- selectedNodeId?: string | null,
1588
+ expandedNodeIds?: Set<string> | null,
1425
1589
  exportMode?: boolean,
1426
1590
  collapsedNodes?: Set<string> | null,
1427
1591
  ) {
@@ -1429,7 +1593,7 @@ export function renderInfra(
1429
1593
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
1430
1594
 
1431
1595
  // Build legend groups
1432
- const legendGroups = computeInfraLegendGroups(layout.nodes, tagGroups ?? [], palette);
1596
+ const legendGroups = computeInfraLegendGroups(layout.nodes, tagGroups ?? [], palette, layout.edges);
1433
1597
  const hasLegend = legendGroups.length > 0 || !!playback;
1434
1598
  // In app mode (not export), legend is rendered as a separate fixed-size SVG
1435
1599
  const fixedLegend = !exportMode && hasLegend;
@@ -1514,7 +1678,17 @@ export function renderInfra(
1514
1678
  // Render layers: groups (back), edge paths, nodes, reject particles, edge labels (front)
1515
1679
  renderGroups(svg, layout.groups, palette, isDark);
1516
1680
  renderEdgePaths(svg, layout.edges, layout.nodes, palette, isDark, shouldAnimate);
1517
- renderNodes(svg, layout.nodes, palette, isDark, shouldAnimate, selectedNodeId, activeGroup, layout.options, collapsedNodes, tagGroups ?? []);
1681
+ const fanoutSourceIds = collectFanoutSourceIds(layout.edges);
1682
+ const scaledGroupIds = new Set<string>(
1683
+ layout.groups
1684
+ .filter((g) => {
1685
+ const gi = typeof g.instances === 'number' ? g.instances
1686
+ : typeof g.instances === 'string' ? parseInt(String(g.instances), 10) || 0 : 0;
1687
+ return gi > 1;
1688
+ })
1689
+ .map((g) => g.id)
1690
+ );
1691
+ renderNodes(svg, layout.nodes, palette, isDark, shouldAnimate, expandedNodeIds, activeGroup, layout.options, collapsedNodes, tagGroups ?? [], fanoutSourceIds, scaledGroupIds);
1518
1692
  if (shouldAnimate) {
1519
1693
  renderRejectParticles(svg, layout.nodes);
1520
1694
  }
@@ -6,6 +6,7 @@
6
6
  // Each role maps to a specific color for badge rendering.
7
7
 
8
8
  import type { InfraProperty } from './types';
9
+ import type { InfraLayoutEdge } from './layout';
9
10
 
10
11
  export interface InfraRole {
11
12
  name: string;
@@ -40,6 +41,20 @@ export function inferRoles(properties: InfraProperty[]): InfraRole[] {
40
41
  return roles;
41
42
  }
42
43
 
44
+ /** The Fan-Out role, assigned to nodes with at least one outgoing fanout edge. */
45
+ export const FANOUT_ROLE: InfraRole = { name: 'Fan-Out', color: '#f97316' };
46
+
47
+ /**
48
+ * Return the set of sourceIds that have at least one outgoing edge with fanout.
49
+ */
50
+ export function collectFanoutSourceIds(edges: InfraLayoutEdge[]): Set<string> {
51
+ const ids = new Set<string>();
52
+ for (const e of edges) {
53
+ if (e.fanout != null) ids.add(e.sourceId);
54
+ }
55
+ return ids;
56
+ }
57
+
43
58
  /**
44
59
  * Collect all unique roles present in the diagram (for legend).
45
60
  */
@@ -41,6 +41,9 @@ export const INFRA_BEHAVIOR_KEYS = new Set<string>([
41
41
  'drain-rate',
42
42
  'retention-hours',
43
43
  'partitions',
44
+ 'slo-availability',
45
+ 'slo-p90-latency-ms',
46
+ 'slo-warning-margin',
44
47
  ]);
45
48
 
46
49
  /** The `rps` key is only valid on the `edge` component. */
@@ -59,6 +62,7 @@ export interface InfraNode {
59
62
  groupId: string | null;
60
63
  tags: Record<string, string>; // tagGroup -> tagValue
61
64
  isEdge: boolean; // true for the `edge` entry-point component
65
+ description?: string;
62
66
  lineNumber: number;
63
67
  }
64
68
 
@@ -67,6 +71,7 @@ export interface InfraEdge {
67
71
  targetId: string;
68
72
  label: string;
69
73
  split: number | null; // percentage 0-100, or null if not declared
74
+ fanout: number | null; // request multiplier: target receives inbound * (split/100) * fanout RPS
70
75
  lineNumber: number;
71
76
  }
72
77
 
@@ -172,6 +177,7 @@ export interface ComputedInfraNode {
172
177
  };
173
178
  properties: InfraProperty[];
174
179
  tags: Record<string, string>;
180
+ description?: string;
175
181
  lineNumber: number;
176
182
  }
177
183
 
@@ -181,6 +187,7 @@ export interface ComputedInfraEdge {
181
187
  label: string;
182
188
  computedRps: number;
183
189
  split: number; // resolved split (always 0-100)
190
+ fanout: number | null;
184
191
  lineNumber: number;
185
192
  }
186
193
 
@@ -0,0 +1,76 @@
1
+ import type { ParsedInitiativeStatus, InitiativeStatus, ISGroup } from './types';
2
+ import { rollUpStatus } from './layout';
3
+
4
+ // ============================================================
5
+ // CollapseResult — returned by collapseInitiativeStatus
6
+ // ============================================================
7
+
8
+ export interface CollapseResult {
9
+ parsed: ParsedInitiativeStatus;
10
+ collapsedGroupStatuses: Map<string, InitiativeStatus>;
11
+ originalGroups: ISGroup[];
12
+ }
13
+
14
+ // ============================================================
15
+ // collapseInitiativeStatus — pure transform
16
+ //
17
+ // Returns a new ParsedInitiativeStatus with:
18
+ // - Children of collapsed groups removed from nodes
19
+ // - Edges redirected: source/target pointing to hidden nodes
20
+ // → point to the group label
21
+ // - Internal edges (both endpoints in same collapsed group) dropped
22
+ // - Duplicate edges (same source, target, label) deduplicated
23
+ // (first occurrence kept)
24
+ // - Collapsed groups removed from groups[] (layout handles them
25
+ // as regular nodes)
26
+ // - collapsedGroupStatuses: worst-case status per collapsed group
27
+ // ============================================================
28
+
29
+ export function collapseInitiativeStatus(
30
+ parsed: ParsedInitiativeStatus,
31
+ collapsedGroups: Set<string>
32
+ ): CollapseResult {
33
+ const originalGroups = parsed.groups;
34
+
35
+ if (collapsedGroups.size === 0) {
36
+ return { parsed, collapsedGroupStatuses: new Map(), originalGroups };
37
+ }
38
+
39
+ // Build node → collapsed group lookup
40
+ const nodeToGroup = new Map<string, string>();
41
+ const collapsedGroupStatuses = new Map<string, InitiativeStatus>();
42
+
43
+ for (const group of parsed.groups) {
44
+ if (!collapsedGroups.has(group.label)) continue;
45
+ const children = group.nodeLabels
46
+ .map((l) => parsed.nodes.find((n) => n.label === l))
47
+ .filter((n): n is (typeof parsed.nodes)[0] => n !== undefined);
48
+ for (const node of children) nodeToGroup.set(node.label, group.label);
49
+ collapsedGroupStatuses.set(group.label, rollUpStatus(children));
50
+ }
51
+
52
+ // Filter nodes: remove children of collapsed groups
53
+ const nodes = parsed.nodes.filter((n) => !nodeToGroup.has(n.label));
54
+
55
+ // Remap and deduplicate edges
56
+ const edgeKeys = new Set<string>();
57
+ const edges: typeof parsed.edges = [];
58
+ for (const edge of parsed.edges) {
59
+ const src = nodeToGroup.get(edge.source) ?? edge.source;
60
+ const tgt = nodeToGroup.get(edge.target) ?? edge.target;
61
+ if (src === tgt) continue; // internal edge → drop
62
+ const key = `${src}|${tgt}|${edge.label ?? ''}`;
63
+ if (edgeKeys.has(key)) continue; // duplicate → drop
64
+ edgeKeys.add(key);
65
+ edges.push({ ...edge, source: src, target: tgt });
66
+ }
67
+
68
+ // Keep only expanded groups in groups[]
69
+ const groups = parsed.groups.filter((g) => !collapsedGroups.has(g.label));
70
+
71
+ return {
72
+ parsed: { ...parsed, nodes, edges, groups },
73
+ collapsedGroupStatuses,
74
+ originalGroups,
75
+ };
76
+ }