@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/infra/renderer.ts
CHANGED
|
@@ -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': '
|
|
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': '
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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(
|
|
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 (
|
|
360
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 +
|
|
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
|
-
|
|
1384
|
-
|
|
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.
|
|
1389
|
-
.attr('
|
|
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 +=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/infra/roles.ts
CHANGED
|
@@ -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
|
*/
|
package/src/infra/types.ts
CHANGED
|
@@ -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
|
+
}
|