@skyhook-io/k8s-ui 1.8.9 → 1.8.11

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.
Files changed (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. package/tsconfig.json +2 -2
@@ -0,0 +1,556 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildWorkflowExecutionModel,
4
+ flattenWorkflowExecution,
5
+ } from "./workflow-execution";
6
+
7
+ describe("workflow execution model", () => {
8
+ it("builds execution edges and counts pods/nodes", () => {
9
+ const model = buildWorkflowExecutionModel({
10
+ metadata: {
11
+ namespace: "demo",
12
+ annotations: {
13
+ "workflows.argoproj.io/scheduled-time": "2026-07-05T10:00:00Z",
14
+ },
15
+ },
16
+ spec: {
17
+ workflowTemplateRef: { name: "main-template" },
18
+ },
19
+ status: {
20
+ phase: "Failed",
21
+ startedAt: "2026-07-05T10:00:05Z",
22
+ finishedAt: "2026-07-05T10:01:00Z",
23
+ nodes: {
24
+ root: {
25
+ id: "root",
26
+ displayName: "root",
27
+ type: "DAG",
28
+ phase: "Failed",
29
+ startedAt: "2026-07-05T10:00:05Z",
30
+ finishedAt: "2026-07-05T10:01:00Z",
31
+ children: ["step-a", "step-b"],
32
+ },
33
+ "step-a": {
34
+ id: "step-a",
35
+ displayName: "step-a",
36
+ type: "Pod",
37
+ phase: "Succeeded",
38
+ startedAt: "2026-07-05T10:00:10Z",
39
+ finishedAt: "2026-07-05T10:00:20Z",
40
+ },
41
+ "step-b": {
42
+ id: "step-b",
43
+ displayName: "step-b",
44
+ type: "Pod",
45
+ phase: "Failed",
46
+ message: "exit code 1",
47
+ startedAt: "2026-07-05T10:00:30Z",
48
+ finishedAt: "2026-07-05T10:00:40Z",
49
+ },
50
+ },
51
+ },
52
+ });
53
+
54
+ expect(model.counts.podTotal).toBe(2);
55
+ expect(model.counts.podSucceeded).toBe(1);
56
+ expect(model.counts.podFailed).toBe(1);
57
+ expect(model.counts.nodeTotal).toBe(3);
58
+ expect(model.focusPaths[0].nodes.map((node) => node.id)).toEqual([
59
+ "root",
60
+ "step-b",
61
+ ]);
62
+ expect(model.templateRefs).toMatchObject([
63
+ {
64
+ name: "main-template",
65
+ resourceKind: "workflowtemplates",
66
+ namespace: "demo",
67
+ },
68
+ ]);
69
+ expect(model.activity.map((item) => item.id)).toContain(
70
+ "workflow-scheduled",
71
+ );
72
+ });
73
+
74
+ it("uses boundaryID as a fallback parent when children are missing", () => {
75
+ const model = buildWorkflowExecutionModel({
76
+ status: {
77
+ nodes: {
78
+ group: { displayName: "group", type: "Steps", phase: "Failed" },
79
+ child: {
80
+ displayName: "child",
81
+ type: "Pod",
82
+ phase: "Failed",
83
+ boundaryID: "group",
84
+ },
85
+ },
86
+ },
87
+ });
88
+
89
+ expect(model.edges).toEqual([{ source: "group", target: "child" }]);
90
+ expect(model.focusPaths[0].nodes.map((node) => node.id)).toEqual([
91
+ "group",
92
+ "child",
93
+ ]);
94
+ });
95
+
96
+ it("collects task-level ClusterWorkflowTemplate refs", () => {
97
+ const model = buildWorkflowExecutionModel({
98
+ metadata: { namespace: "demo" },
99
+ spec: {
100
+ templates: [
101
+ {
102
+ name: "main",
103
+ dag: {
104
+ tasks: [
105
+ {
106
+ name: "one",
107
+ templateRef: {
108
+ name: "cluster-lib",
109
+ template: "worker",
110
+ clusterScope: true,
111
+ },
112
+ },
113
+ ],
114
+ },
115
+ },
116
+ ],
117
+ },
118
+ });
119
+
120
+ expect(model.templateRefs).toEqual([
121
+ {
122
+ name: "cluster-lib",
123
+ kind: "ClusterWorkflowTemplate",
124
+ resourceKind: "clusterworkflowtemplates",
125
+ namespace: "",
126
+ clusterScope: true,
127
+ source: "task",
128
+ template: "worker",
129
+ taskName: "one",
130
+ },
131
+ ]);
132
+ });
133
+
134
+ it("uses exact status-node template refs and stored workflow specs", () => {
135
+ const model = buildWorkflowExecutionModel({
136
+ metadata: { namespace: "demo" },
137
+ spec: { workflowTemplateRef: { name: "main-definition" } },
138
+ status: {
139
+ storedWorkflowTemplateSpec: {
140
+ templates: [
141
+ {
142
+ name: "stored",
143
+ dag: {
144
+ tasks: [
145
+ {
146
+ name: "library",
147
+ templateRef: { name: "stored-lib", template: "run" },
148
+ },
149
+ ],
150
+ },
151
+ },
152
+ ],
153
+ },
154
+ nodes: {
155
+ task: {
156
+ displayName: "library(0)",
157
+ type: "Pod",
158
+ phase: "Succeeded",
159
+ templateRef: {
160
+ name: "exact-lib",
161
+ template: "run",
162
+ clusterScope: true,
163
+ },
164
+ },
165
+ },
166
+ },
167
+ });
168
+
169
+ expect(model.nodes[0].templateRef).toMatchObject({
170
+ name: "exact-lib",
171
+ resourceKind: "clusterworkflowtemplates",
172
+ });
173
+ expect(model.templateRefs.map((ref) => ref.name)).toEqual(
174
+ expect.arrayContaining(["main-definition", "stored-lib", "exact-lib"]),
175
+ );
176
+ });
177
+
178
+ it("contracts sequential StepGroups while preserving authored step order", () => {
179
+ const model = buildWorkflowExecutionModel({
180
+ metadata: { name: "workflow-abc" },
181
+ status: {
182
+ phase: "Succeeded",
183
+ startedAt: "2026-07-05T10:00:00Z",
184
+ finishedAt: "2026-07-05T10:01:00Z",
185
+ nodes: {
186
+ root: {
187
+ name: "workflow-abc",
188
+ displayName: "workflow-abc",
189
+ templateName: "main",
190
+ type: "Steps",
191
+ phase: "Succeeded",
192
+ children: ["group-0"],
193
+ },
194
+ "group-0": {
195
+ name: "workflow-abc[0]",
196
+ displayName: "[0]",
197
+ type: "StepGroup",
198
+ phase: "Succeeded",
199
+ children: ["prepare"],
200
+ },
201
+ prepare: {
202
+ name: "workflow-abc[0].prepare",
203
+ displayName: "prepare",
204
+ type: "Pod",
205
+ phase: "Succeeded",
206
+ children: ["group-1"],
207
+ startedAt: "2026-07-05T10:00:01Z",
208
+ finishedAt: "2026-07-05T10:00:20Z",
209
+ },
210
+ "group-1": {
211
+ name: "workflow-abc[1]",
212
+ displayName: "[1]",
213
+ type: "StepGroup",
214
+ phase: "Succeeded",
215
+ children: ["process"],
216
+ },
217
+ process: {
218
+ name: "workflow-abc[1].process",
219
+ displayName: "process",
220
+ type: "Pod",
221
+ phase: "Succeeded",
222
+ startedAt: "2026-07-05T10:00:21Z",
223
+ finishedAt: "2026-07-05T10:00:40Z",
224
+ },
225
+ },
226
+ },
227
+ });
228
+
229
+ expect(model.nodes).toHaveLength(5);
230
+ expect(model.executionNodes).toHaveLength(3);
231
+ expect(
232
+ flattenWorkflowExecution(model).map(({ node, depth }) => [
233
+ node.displayLabel,
234
+ node.displayType,
235
+ depth,
236
+ ]),
237
+ ).toEqual([
238
+ ["main", "Steps template", 0],
239
+ ["prepare", "Step 1 · Pod", 1],
240
+ ["process", "Step 2 · Pod", 2],
241
+ ]);
242
+ expect(model.activity.map((item) => item.label)).toEqual([
243
+ "Workflow started",
244
+ "prepare started",
245
+ "prepare succeeded",
246
+ "process started",
247
+ "process succeeded",
248
+ "Workflow succeeded",
249
+ ]);
250
+ });
251
+
252
+ it("represents parallel steps and loop fan-out without controller indexes", () => {
253
+ const parallel = buildWorkflowExecutionModel({
254
+ metadata: { name: "parallel-wf" },
255
+ status: {
256
+ nodes: {
257
+ root: {
258
+ name: "parallel-wf",
259
+ displayName: "parallel-wf",
260
+ templateName: "main",
261
+ type: "Steps",
262
+ phase: "Succeeded",
263
+ children: ["group"],
264
+ },
265
+ group: {
266
+ name: "parallel-wf[0]",
267
+ displayName: "[0]",
268
+ type: "StepGroup",
269
+ phase: "Succeeded",
270
+ children: ["a", "b"],
271
+ },
272
+ a: { displayName: "download", type: "Pod", phase: "Succeeded" },
273
+ b: { displayName: "validate", type: "Pod", phase: "Succeeded" },
274
+ },
275
+ },
276
+ });
277
+ expect(
278
+ parallel.executionNodes.find((node) => node.id === "group"),
279
+ ).toMatchObject({
280
+ displayLabel: "Step 1",
281
+ displayType: "Parallel · 2 steps",
282
+ });
283
+
284
+ const fanOut = buildWorkflowExecutionModel({
285
+ metadata: { name: "fanout-wf" },
286
+ status: {
287
+ nodes: {
288
+ root: {
289
+ name: "fanout-wf",
290
+ displayName: "fanout-wf",
291
+ templateName: "main",
292
+ type: "Steps",
293
+ phase: "Succeeded",
294
+ children: ["group"],
295
+ },
296
+ group: {
297
+ name: "fanout-wf[0]",
298
+ displayName: "[0]",
299
+ type: "StepGroup",
300
+ phase: "Succeeded",
301
+ children: ["a", "b"],
302
+ },
303
+ a: {
304
+ displayName: "migrate(0:tenant-a)",
305
+ type: "Pod",
306
+ phase: "Succeeded",
307
+ },
308
+ b: {
309
+ displayName: "migrate(1:tenant-b)",
310
+ type: "Pod",
311
+ phase: "Succeeded",
312
+ },
313
+ },
314
+ },
315
+ });
316
+ expect(
317
+ fanOut.executionNodes.find((node) => node.id === "group"),
318
+ ).toMatchObject({
319
+ displayLabel: "migrate",
320
+ displayType: "Step 1 · Fan-out · 2 runs",
321
+ });
322
+ expect(
323
+ fanOut.executionNodes
324
+ .filter((node) => node.type === "Pod")
325
+ .map((node) => [node.displayLabel, node.displayType]),
326
+ ).toEqual([
327
+ ["tenant-a", "Run 1 · Pod"],
328
+ ["tenant-b", "Run 2 · Pod"],
329
+ ]);
330
+ });
331
+
332
+ it("keeps mixed StepGroup children parallel and names retry attempts from suffixes", () => {
333
+ const model = buildWorkflowExecutionModel({
334
+ metadata: { name: "retry-wf" },
335
+ status: {
336
+ nodes: {
337
+ retry: {
338
+ name: "retry-wf",
339
+ displayName: "retry-wf",
340
+ templateName: "main",
341
+ type: "Retry",
342
+ phase: "Failed",
343
+ children: ["attempt-1", "attempt-0"],
344
+ },
345
+ "attempt-1": {
346
+ displayName: "retry-wf(1)",
347
+ type: "Pod",
348
+ phase: "Failed",
349
+ },
350
+ "attempt-0": {
351
+ displayName: "retry-wf(0)",
352
+ type: "Pod",
353
+ phase: "Failed",
354
+ },
355
+ },
356
+ },
357
+ });
358
+ expect(
359
+ model.executionNodes.find((node) => node.id === "retry"),
360
+ ).toMatchObject({ displayLabel: "main", displayType: "Retry strategy" });
361
+ expect(
362
+ Object.fromEntries(
363
+ model.executionNodes
364
+ .filter((node) => node.type === "Pod")
365
+ .map((node) => [node.id, node.displayLabel]),
366
+ ),
367
+ ).toEqual({
368
+ "attempt-0": "Attempt 1",
369
+ "attempt-1": "Attempt 2",
370
+ });
371
+
372
+ const mixed = buildWorkflowExecutionModel({
373
+ status: {
374
+ nodes: {
375
+ group: {
376
+ name: "wf[3]",
377
+ displayName: "[3]",
378
+ type: "StepGroup",
379
+ phase: "Succeeded",
380
+ children: ["a", "b"],
381
+ },
382
+ a: {
383
+ displayName: "migrate(0:tenant-a)",
384
+ type: "Pod",
385
+ phase: "Succeeded",
386
+ },
387
+ b: { displayName: "notify", type: "Pod", phase: "Succeeded" },
388
+ },
389
+ },
390
+ });
391
+ expect(
392
+ mixed.executionNodes.find((node) => node.id === "group"),
393
+ ).toMatchObject({
394
+ displayLabel: "Step 4",
395
+ displayType: "Parallel · 2 steps",
396
+ });
397
+ });
398
+
399
+ it("keeps named DAG fan-out and hides its aggregate lifecycle from activity", () => {
400
+ const model = buildWorkflowExecutionModel({
401
+ metadata: { name: "dag-wf" },
402
+ status: {
403
+ phase: "Failed",
404
+ startedAt: "2026-07-05T10:00:00Z",
405
+ finishedAt: "2026-07-05T10:01:00Z",
406
+ nodes: {
407
+ root: {
408
+ name: "dag-wf",
409
+ displayName: "dag-wf",
410
+ templateName: "main-dag",
411
+ type: "DAG",
412
+ phase: "Failed",
413
+ message: "child failed",
414
+ children: ["group"],
415
+ },
416
+ group: {
417
+ displayName: "tenants",
418
+ type: "TaskGroup",
419
+ phase: "Failed",
420
+ message: "child failed",
421
+ children: ["a", "b"],
422
+ },
423
+ a: {
424
+ displayName: "tenants(0:a)",
425
+ type: "Pod",
426
+ phase: "Succeeded",
427
+ startedAt: "2026-07-05T10:00:01Z",
428
+ finishedAt: "2026-07-05T10:00:10Z",
429
+ },
430
+ b: {
431
+ displayName: "tenants(1:b)",
432
+ type: "Pod",
433
+ phase: "Failed",
434
+ message: "exit code 1",
435
+ startedAt: "2026-07-05T10:00:01Z",
436
+ finishedAt: "2026-07-05T10:00:11Z",
437
+ },
438
+ },
439
+ },
440
+ });
441
+ expect(
442
+ model.executionNodes.find((node) => node.id === "group"),
443
+ ).toMatchObject({
444
+ displayLabel: "tenants",
445
+ displayType: "Fan-out · 2 runs",
446
+ });
447
+ expect(model.activity.map((item) => item.label)).not.toContain(
448
+ "tenants failed",
449
+ );
450
+ expect(model.activity.map((item) => item.label)).toContain("b failed");
451
+ expect(model.counts.podFailed).toBe(1);
452
+ expect(model.counts.nodeFailed).toBe(3);
453
+ });
454
+
455
+ it("retains structural-only failures, exit handlers, and failing container diagnostics", () => {
456
+ const model = buildWorkflowExecutionModel({
457
+ metadata: { name: "hook-wf" },
458
+ status: {
459
+ nodes: {
460
+ root: {
461
+ name: "hook-wf",
462
+ displayName: "hook-wf",
463
+ templateName: "main",
464
+ type: "DAG",
465
+ phase: "Failed",
466
+ },
467
+ group: {
468
+ name: "hook-wf[2]",
469
+ displayName: "[2]",
470
+ type: "StepGroup",
471
+ phase: "Error",
472
+ message: "could not resolve template",
473
+ boundaryID: "root",
474
+ },
475
+ exit: {
476
+ name: "hook-wf.onExit",
477
+ displayName: "hook-wf.onExit",
478
+ templateName: "cleanup",
479
+ type: "Steps",
480
+ phase: "Failed",
481
+ },
482
+ container: {
483
+ displayName: "sidecar",
484
+ type: "Container",
485
+ phase: "Error",
486
+ message: "sidecar crashed",
487
+ boundaryID: "exit",
488
+ startedAt: "2026-07-05T10:00:01Z",
489
+ finishedAt: "2026-07-05T10:00:02Z",
490
+ },
491
+ },
492
+ },
493
+ });
494
+ expect(
495
+ model.executionNodes.find((node) => node.id === "group"),
496
+ ).toMatchObject({ displayLabel: "Step 3", displayType: "Step group" });
497
+ expect(
498
+ model.executionNodes.find((node) => node.id === "exit"),
499
+ ).toMatchObject({ displayLabel: "Exit handler" });
500
+ expect(model.activity.map((item) => item.label)).toContain(
501
+ "sidecar errored",
502
+ );
503
+ });
504
+
505
+ it("contracts hidden chains without losing nodes across multiple parents", () => {
506
+ const model = buildWorkflowExecutionModel({
507
+ status: {
508
+ nodes: {
509
+ left: {
510
+ displayName: "left",
511
+ type: "DAG",
512
+ phase: "Succeeded",
513
+ children: ["group-a"],
514
+ },
515
+ right: {
516
+ displayName: "right",
517
+ type: "DAG",
518
+ phase: "Succeeded",
519
+ children: ["group-a"],
520
+ },
521
+ "group-a": {
522
+ name: "wf[0]",
523
+ displayName: "[0]",
524
+ type: "StepGroup",
525
+ phase: "Succeeded",
526
+ children: ["group-b"],
527
+ },
528
+ "group-b": {
529
+ name: "wf[1]",
530
+ displayName: "[1]",
531
+ type: "StepGroup",
532
+ phase: "Succeeded",
533
+ children: ["leaf"],
534
+ },
535
+ leaf: { displayName: "leaf", type: "Pod", phase: "Succeeded" },
536
+ },
537
+ },
538
+ });
539
+ expect(model.nodes.map((node) => node.id)).toEqual(
540
+ expect.arrayContaining(["left", "right", "group-a", "group-b", "leaf"]),
541
+ );
542
+ expect(model.executionNodes.map((node) => node.id).sort()).toEqual([
543
+ "leaf",
544
+ "left",
545
+ "right",
546
+ ]);
547
+ expect(
548
+ model.executionNodes.find((node) => node.id === "leaf")?.parentIds.sort(),
549
+ ).toEqual(["left", "right"]);
550
+ expect(
551
+ flattenWorkflowExecution(model)
552
+ .map(({ node }) => node.id)
553
+ .sort(),
554
+ ).toEqual(["leaf", "left", "right"]);
555
+ });
556
+ });