@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
@@ -129,7 +129,9 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
129
129
 
130
130
  // Argo
131
131
  workflow: Activity,
132
+ cronworkflow: Activity,
132
133
  workflowtemplate: Activity,
134
+ clusterworkflowtemplate: Activity,
133
135
  application: GitBranch, // ArgoCD Application
134
136
  applicationset: GitBranch, // ArgoCD ApplicationSet
135
137
 
@@ -1,10 +1,13 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { neighborhoodFor, tagWorkloadOwnership } from './topology-neighborhood'
2
+ import { batchRunParentNodes, neighborhoodFor, tagWorkloadOwnership } from './topology-neighborhood'
3
3
  import type { Topology, NodeKind, EdgeType } from '../types/core'
4
4
 
5
5
  function node(id: string, kind: string, ns: string, name: string): Topology['nodes'][number] {
6
6
  return { id, kind: kind as NodeKind, name, status: 'healthy' as Topology['nodes'][number]['status'], data: { namespace: ns } }
7
7
  }
8
+ function crdNode(id: string, kind: string, ns: string, name: string, apiVersion: string): Topology['nodes'][number] {
9
+ return { id, kind: kind as NodeKind, name, status: 'healthy' as Topology['nodes'][number]['status'], data: { namespace: ns, apiVersion } }
10
+ }
8
11
  function edge(source: string, target: string, type: EdgeType): Topology['edges'][number] {
9
12
  return { id: `${source}->${target}`, source, target, type }
10
13
  }
@@ -32,6 +35,27 @@ describe('neighborhoodFor', () => {
32
35
  expect(new Set(out.nodes.map((n) => n.id))).toEqual(new Set(['dep', 'rs', 'pod', 'svc', 'cm']))
33
36
  })
34
37
 
38
+ it('includes the complete upstream serving path without crossing through a shared Service', () => {
39
+ const topo: Topology = {
40
+ nodes: [
41
+ node('route', 'HTTPRoute', 'app', 'web'),
42
+ node('ingress', 'Ingress', 'app', 'web'),
43
+ node('svc', 'Service', 'app', 'web'),
44
+ node('depA', 'Deployment', 'app', 'a'),
45
+ node('depB', 'Deployment', 'app', 'b'),
46
+ ],
47
+ edges: [
48
+ edge('route', 'svc', 'routes-to'),
49
+ edge('ingress', 'svc', 'routes-to'),
50
+ edge('svc', 'depA', 'exposes'),
51
+ edge('svc', 'depB', 'exposes'),
52
+ ],
53
+ }
54
+
55
+ const out = neighborhoodFor(topo, [{ kind: 'Deployment', namespace: 'app', name: 'a' }])
56
+ expect(new Set(out.nodes.map((n) => n.id))).toEqual(new Set(['route', 'ingress', 'svc', 'depA']))
57
+ })
58
+
35
59
  // The leaf rule: a ConfigMap shared by two unrelated Deployments must NOT
36
60
  // bridge the second Deployment into the first's neighborhood.
37
61
  it('does not bleed through a shared ConfigMap', () => {
@@ -53,6 +77,29 @@ describe('neighborhoodFor', () => {
53
77
  expect(ids.has('depB')).toBe(false) // …but it doesn't drag in the other app
54
78
  })
55
79
 
80
+ it('includes resources that produce a workload Secret without crossing through it', () => {
81
+ const topo: Topology = {
82
+ nodes: [
83
+ node('depA', 'Deployment', 'app', 'a'),
84
+ node('depB', 'Deployment', 'app', 'b'),
85
+ node('secret', 'Secret', 'app', 'generated'),
86
+ node('sealed', 'SealedSecret', 'app', 'encrypted'),
87
+ node('cert', 'Certificate', 'app', 'tls'),
88
+ ],
89
+ edges: [
90
+ edge('sealed', 'secret', 'manages'),
91
+ edge('cert', 'secret', 'manages'),
92
+ edge('secret', 'depA', 'configures'),
93
+ edge('secret', 'depB', 'configures'),
94
+ ],
95
+ }
96
+
97
+ const out = neighborhoodFor(topo, [{ kind: 'Deployment', namespace: 'app', name: 'a' }])
98
+ expect(new Set(out.nodes.map((n) => n.id))).toEqual(new Set(['depA', 'secret', 'sealed', 'cert']))
99
+ expect(out.edges).toEqual(expect.arrayContaining([expect.objectContaining({ source: 'sealed', target: 'secret' })]))
100
+ expect(out.edges).toEqual(expect.arrayContaining([expect.objectContaining({ source: 'cert', target: 'secret' })]))
101
+ })
102
+
56
103
  // A GitOps manager reached upward is a leaf: "managed by" is shown, but its
57
104
  // sibling workloads are not pulled in.
58
105
  it('does not expand through a GitOps manager to its siblings', () => {
@@ -119,6 +166,139 @@ describe('neighborhoodFor', () => {
119
166
  expect(out.nodes).toHaveLength(0)
120
167
  expect(out.warnings?.some((w) => w.includes('No topology nodes matched'))).toBe(true)
121
168
  })
169
+
170
+ it('uses seed group to disambiguate same-kind nodes', () => {
171
+ const topo: Topology = {
172
+ nodes: [
173
+ crdNode('argo', 'Workflow', 'app', 'run', 'argoproj.io/v1alpha1'),
174
+ crdNode('other', 'Workflow', 'app', 'run', 'example.com/v1'),
175
+ ],
176
+ edges: [],
177
+ }
178
+ const out = neighborhoodFor(topo, [{ kind: 'Workflow', group: 'argoproj.io', namespace: 'app', name: 'run' }])
179
+ expect(out.nodes.map((n) => n.id)).toEqual(['argo'])
180
+ })
181
+
182
+ it('expands from an Argo WorkflowTemplate to its runs and run pods', () => {
183
+ const topo: Topology = {
184
+ nodes: [
185
+ crdNode('wt', 'WorkflowTemplate', 'app', 'migration', 'argoproj.io/v1alpha1'),
186
+ crdNode('wf', 'Workflow', 'app', 'migration-abc', 'argoproj.io/v1alpha1'),
187
+ node('pod', 'Pod', 'app', 'migration-abc-main'),
188
+ ],
189
+ edges: [
190
+ edge('wt', 'wf', 'configures'),
191
+ edge('wf', 'pod', 'manages'),
192
+ ],
193
+ }
194
+ const out = neighborhoodFor(topo, [{ kind: 'WorkflowTemplate', group: 'argoproj.io', namespace: 'app', name: 'migration' }])
195
+ expect(new Set(out.nodes.map((n) => n.id))).toEqual(new Set(['wt', 'wf', 'pod']))
196
+ })
197
+
198
+ it('keeps active plus representative failed and successful retained runs', () => {
199
+ const runs = Array.from({ length: 18 }, (_, i) => ({
200
+ ...crdNode(`wf${i}`, 'Workflow', 'app', `migration-${i}`, 'argoproj.io/v1alpha1'),
201
+ status: i === 4 ? 'unhealthy' as const : i === 17 ? 'neutral' as const : 'healthy' as const,
202
+ data: { namespace: 'app', apiVersion: 'argoproj.io/v1alpha1', phase: i === 4 ? 'Failed' : i === 17 ? 'Running' : 'Succeeded', startedAt: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00Z` },
203
+ }))
204
+ const topo: Topology = {
205
+ nodes: [crdNode('wt', 'WorkflowTemplate', 'app', 'migration', 'argoproj.io/v1alpha1'), ...runs],
206
+ edges: runs.map((run) => edge('wt', run.id, 'configures')),
207
+ }
208
+ const out = neighborhoodFor(topo, [{ kind: 'WorkflowTemplate', group: 'argoproj.io', namespace: 'app', name: 'migration' }])
209
+ const ids = new Set(out.nodes.map((n) => n.id))
210
+ expect(ids.has('wt')).toBe(true)
211
+ expect(ids.has('wf0')).toBe(false)
212
+ expect(ids.has('wf4')).toBe(true)
213
+ expect(ids.has('wf16')).toBe(true)
214
+ expect(ids.has('wf17')).toBe(true)
215
+ expect(out.warnings?.some((w) => w.includes('active and representative completed runs from 18 retained Workflow runs'))).toBe(true)
216
+ })
217
+
218
+ it('keeps the newest active runs plus the oldest potentially stuck run', () => {
219
+ const active = Array.from({ length: 12 }, (_, i) => ({
220
+ ...crdNode(`active${i}`, 'Workflow', 'app', `migration-active-${i}`, 'argoproj.io/v1alpha1'),
221
+ data: { namespace: 'app', apiVersion: 'argoproj.io/v1alpha1', phase: 'Running', startedAt: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00Z` },
222
+ }))
223
+ const failed = { ...crdNode('failed', 'Workflow', 'app', 'migration-failed', 'argoproj.io/v1alpha1'), status: 'unhealthy' as const, data: { namespace: 'app', phase: 'Failed', startedAt: '2026-02-01T00:00:00Z' } }
224
+ const succeeded = { ...crdNode('succeeded', 'Workflow', 'app', 'migration-succeeded', 'argoproj.io/v1alpha1'), status: 'healthy' as const, data: { namespace: 'app', phase: 'Succeeded', startedAt: '2026-02-02T00:00:00Z' } }
225
+ const runs = [...active, failed, succeeded]
226
+ const topo: Topology = {
227
+ nodes: [crdNode('wt', 'WorkflowTemplate', 'app', 'migration', 'argoproj.io/v1alpha1'), ...runs],
228
+ edges: runs.map((run) => edge('wt', run.id, 'configures')),
229
+ }
230
+ const out = neighborhoodFor(topo, [{ kind: 'WorkflowTemplate', group: 'argoproj.io', namespace: 'app', name: 'migration' }])
231
+ const ids = new Set(out.nodes.map((node) => node.id))
232
+ expect(ids.has('active0')).toBe(true)
233
+ for (let i = 1; i < 5; i++) expect(ids.has(`active${i}`)).toBe(false)
234
+ for (let i = 5; i < 12; i++) expect(ids.has(`active${i}`)).toBe(true)
235
+ expect(ids.has('failed')).toBe(true)
236
+ expect(ids.has('succeeded')).toBe(true)
237
+ expect(out.warnings?.some((warning) => warning.includes('7 newest and oldest of 12 active runs'))).toBe(true)
238
+ })
239
+
240
+ it('uses the name tiebreak for runs without timestamps', () => {
241
+ const runs = Array.from({ length: 10 }, (_, i) => ({
242
+ ...crdNode(`wf${i}`, 'Workflow', 'app', `migration-${i}`, 'argoproj.io/v1alpha1'),
243
+ status: 'healthy' as const,
244
+ data: { namespace: 'app', phase: 'Succeeded' },
245
+ }))
246
+ const topo: Topology = {
247
+ nodes: [crdNode('wt', 'WorkflowTemplate', 'app', 'migration', 'argoproj.io/v1alpha1'), ...runs],
248
+ edges: runs.map((run) => edge('wt', run.id, 'configures')),
249
+ }
250
+ const out = neighborhoodFor(topo, [{ kind: 'WorkflowTemplate', group: 'argoproj.io', namespace: 'app', name: 'migration' }])
251
+ expect(out.nodes.some((node) => node.id === 'wf9')).toBe(true)
252
+ })
253
+ })
254
+
255
+ describe('batchRunParentNodes', () => {
256
+ it('finds scheduled and template parents for generated runs only', () => {
257
+ const cronJob = node('cron', 'CronJob', 'app', 'backup')
258
+ const job = node('job', 'Job', 'app', 'backup-123')
259
+ const template = crdNode('template', 'WorkflowTemplate', 'app', 'migration', 'argoproj.io/v1alpha1')
260
+ const workflow = crdNode('workflow', 'Workflow', 'app', 'migration-abc', 'argoproj.io/v1alpha1')
261
+ const deployment = node('deployment', 'Deployment', 'app', 'api')
262
+ const pod = node('pod', 'Pod', 'app', 'api-123')
263
+ const topology: Topology = {
264
+ nodes: [cronJob, job, template, workflow, deployment, pod],
265
+ edges: [
266
+ edge('cron', 'job', 'manages'),
267
+ edge('template', 'workflow', 'configures'),
268
+ edge('deployment', 'pod', 'manages'),
269
+ ],
270
+ }
271
+
272
+ expect(batchRunParentNodes(topology, job)).toEqual([cronJob])
273
+ expect(batchRunParentNodes(topology, workflow)).toEqual([template])
274
+ expect(batchRunParentNodes(topology, pod)).toEqual([])
275
+ })
276
+
277
+ it('prefers a scheduler over template provenance when a Workflow has both', () => {
278
+ const cronWorkflow = crdNode('cron', 'CronWorkflow', 'app', 'scheduled-migration', 'argoproj.io/v1alpha1')
279
+ const template = crdNode('template', 'WorkflowTemplate', 'app', 'migration', 'argoproj.io/v1alpha1')
280
+ const workflow = crdNode('workflow', 'Workflow', 'app', 'scheduled-migration-123', 'argoproj.io/v1alpha1')
281
+ const topology: Topology = {
282
+ nodes: [cronWorkflow, template, workflow],
283
+ edges: [
284
+ edge('template', 'workflow', 'configures'),
285
+ edge('cron', 'workflow', 'manages'),
286
+ ],
287
+ }
288
+
289
+ expect(batchRunParentNodes(topology, workflow)).toEqual([cronWorkflow, template])
290
+ })
291
+
292
+ it('finds template provenance for a CronWorkflow', () => {
293
+ const template = crdNode('template', 'WorkflowTemplate', 'app', 'scheduled-migration', 'argoproj.io/v1alpha1')
294
+ const cronWorkflow = crdNode('cron', 'CronWorkflow', 'app', 'scheduled-migration', 'argoproj.io/v1alpha1')
295
+ const topology: Topology = {
296
+ nodes: [template, cronWorkflow],
297
+ edges: [edge('template', 'cron', 'configures')],
298
+ }
299
+
300
+ expect(batchRunParentNodes(topology, cronWorkflow)).toEqual([template])
301
+ })
122
302
  })
123
303
 
124
304
  describe('tagWorkloadOwnership', () => {
@@ -132,6 +312,7 @@ describe('tagWorkloadOwnership', () => {
132
312
  node('depA', 'Deployment', 'app', 'a'),
133
313
  node('podA', 'Pod', 'app', 'a-1'),
134
314
  node('svcA', 'Service', 'app', 'a'),
315
+ node('routeA', 'HTTPRoute', 'app', 'a'),
135
316
  node('depB', 'Deployment', 'app', 'b'),
136
317
  node('podB', 'Pod', 'app', 'b-1'),
137
318
  node('shared', 'ConfigMap', 'app', 'shared'),
@@ -139,6 +320,7 @@ describe('tagWorkloadOwnership', () => {
139
320
  edges: [
140
321
  edge('depA', 'podA', 'manages'),
141
322
  edge('svcA', 'depA', 'exposes'),
323
+ edge('routeA', 'svcA', 'routes-to'),
142
324
  edge('depB', 'podB', 'manages'),
143
325
  edge('shared', 'depA', 'configures'),
144
326
  edge('shared', 'depB', 'configures'),
@@ -155,6 +337,7 @@ describe('tagWorkloadOwnership', () => {
155
337
  expect(dataOf(topology, 'depA').ownerWorkloadId).toBe('Deployment/app/a')
156
338
  expect(dataOf(topology, 'podA').ownerColorIndex).toBe(a)
157
339
  expect(dataOf(topology, 'svcA').ownerColorIndex).toBe(a)
340
+ expect(dataOf(topology, 'routeA').ownerColorIndex).toBe(a)
158
341
  expect(dataOf(topology, 'podB').ownerColorIndex).toBe(b)
159
342
  // the ConfigMap touches both workloads → neutral color…
160
343
  expect(dataOf(topology, 'shared').ownerWorkloadId).toBeNull()
@@ -165,6 +348,7 @@ describe('tagWorkloadOwnership', () => {
165
348
  )
166
349
  // an exclusive satellite's focus set is just its own workload.
167
350
  expect(dataOf(topology, 'svcA').focusWorkloadIds).toEqual(['Deployment/app/a'])
351
+ expect(dataOf(topology, 'routeA').focusWorkloadIds).toEqual(['Deployment/app/a'])
168
352
  })
169
353
 
170
354
  // A GitOps manager is context, not membership — it never claims a color even
@@ -182,4 +366,21 @@ describe('tagWorkloadOwnership', () => {
182
366
  expect(dataOf(topology, 'ks').ownerWorkloadId).toBeNull()
183
367
  expect(dataOf(topology, 'pod').ownerWorkloadId).toBe('Deployment/app/web')
184
368
  })
369
+
370
+ it('attributes Secret producers through the Secret they produce', () => {
371
+ const topo: Topology = {
372
+ nodes: [
373
+ node('dep', 'Deployment', 'app', 'web'),
374
+ node('secret', 'Secret', 'app', 'generated'),
375
+ node('sealed', 'SealedSecret', 'app', 'encrypted'),
376
+ node('cert', 'Certificate', 'app', 'tls'),
377
+ ],
378
+ edges: [edge('sealed', 'secret', 'manages'), edge('cert', 'secret', 'manages'), edge('secret', 'dep', 'configures')],
379
+ }
380
+
381
+ const { topology } = tagWorkloadOwnership(topo, [{ kind: 'Deployment', namespace: 'app', name: 'web' }])
382
+ expect(dataOf(topology, 'secret').ownerWorkloadId).toBe('Deployment/app/web')
383
+ expect(dataOf(topology, 'sealed').ownerWorkloadId).toBe('Deployment/app/web')
384
+ expect(dataOf(topology, 'cert').ownerWorkloadId).toBe('Deployment/app/web')
385
+ })
185
386
  })
@@ -12,10 +12,10 @@ import type { Topology, TopologyNode, TopologyEdge, EdgeType, NodeKind } from '.
12
12
  // identity (`manages`) — the ownerRef / controller chain
13
13
  // (Deployment→ReplicaSet→Pod). Walk it: a
14
14
  // workload's pods ARE the workload.
15
- // routing (`exposes`,`routes-to`)— a Service/Ingress/Route in front of the
16
- // workload. INCLUDE it, but as a LEAF: a
17
- // shared Ingress fronts many unrelated apps,
18
- // so we don't expand THROUGH it.
15
+ // routing (`exposes`,`routes-to`)— walk UPSTREAM from workload to Service to
16
+ // Ingress/Route, then stop. Never walk back
17
+ // DOWN through a shared Service into another
18
+ // workload.
19
19
  // context (`configures`,`uses`, — a ConfigMap/Secret/HPA/PDB attached to the
20
20
  // `protects`) workload. INCLUDE as a LEAF: a shared
21
21
  // ConfigMap mounted by two apps must not glue
@@ -30,12 +30,14 @@ import type { Topology, TopologyNode, TopologyEdge, EdgeType, NodeKind } from '.
30
30
 
31
31
  export interface NeighborhoodSeed {
32
32
  kind: string
33
+ group?: string
33
34
  namespace: string
34
35
  name: string
35
36
  }
36
37
 
37
38
  const IDENTITY_EDGES = new Set<EdgeType>(['manages'])
38
39
  const ROUTING_EDGES = new Set<EdgeType>(['exposes', 'routes-to'])
40
+ const BATCH_RUN_FANOUT_LIMIT = 8
39
41
  // context: 'configures' | 'uses' | 'protects' — everything else is leaf-attached.
40
42
 
41
43
  // GitOps managers: included as context ("managed by"), never expanded through.
@@ -45,12 +47,101 @@ const GITOPS_MANAGER_KINDS = new Set<NodeKind>([
45
47
  'HelmRelease',
46
48
  'GitRepository',
47
49
  ] as NodeKind[])
50
+ const SECRET_PRODUCER_KINDS = new Set<NodeKind>(['SealedSecret', 'Certificate'])
48
51
 
49
52
  function nodeNamespace(node: TopologyNode): string {
50
53
  const ns = node.data?.namespace
51
54
  return typeof ns === 'string' ? ns : ''
52
55
  }
53
56
 
57
+ function nodeGroup(node: TopologyNode): string {
58
+ const apiVersion = node.data?.apiVersion
59
+ return typeof apiVersion === 'string' && apiVersion.includes('/') ? apiVersion.split('/')[0] : ''
60
+ }
61
+
62
+ function isWorkflowTemplateKind(kind: NodeKind | string): boolean {
63
+ return kind === 'WorkflowTemplate' || kind === 'ClusterWorkflowTemplate'
64
+ }
65
+
66
+ function isTemplateConfiguredRun(kind: NodeKind | string): boolean {
67
+ return kind === 'Workflow' || kind === 'CronWorkflow'
68
+ }
69
+
70
+ function isTemplateToRunEdge(edge: TopologyEdge, nodeById: Map<string, TopologyNode>): boolean {
71
+ return edge.type === 'configures'
72
+ && isWorkflowTemplateKind(nodeById.get(edge.source)?.kind ?? '')
73
+ && isTemplateConfiguredRun(nodeById.get(edge.target)?.kind ?? '')
74
+ }
75
+
76
+ function isBatchRunFanoutEdge(edge: TopologyEdge, nodeById: Map<string, TopologyNode>): boolean {
77
+ const source = nodeById.get(edge.source)
78
+ const target = nodeById.get(edge.target)
79
+ if (!source || !target) return false
80
+ if (edge.type === 'manages') {
81
+ return (source.kind === 'CronJob' || source.kind === 'ScaledJob') && target.kind === 'Job'
82
+ || source.kind === 'CronWorkflow' && target.kind === 'Workflow'
83
+ }
84
+ return isTemplateToRunEdge(edge, nodeById)
85
+ }
86
+
87
+ export function batchRunParentNodes(topology: Topology, run: TopologyNode): TopologyNode[] {
88
+ const nodeById = new Map(topology.nodes.map((node) => [node.id, node]))
89
+ const parentEdges = topology.edges.filter((edge) => edge.target === run.id && isBatchRunFanoutEdge(edge, nodeById))
90
+ parentEdges.sort((left, right) => Number(right.type === 'manages') - Number(left.type === 'manages'))
91
+ return parentEdges.flatMap((edge) => {
92
+ const parent = nodeById.get(edge.source)
93
+ return parent ? [parent] : []
94
+ })
95
+ }
96
+
97
+ function nodeRunTime(node: TopologyNode): number {
98
+ const data = node.data ?? {}
99
+ for (const key of ['startedAt', 'finishedAt', 'startTime', 'completionTime', 'creationTimestamp']) {
100
+ const value = data[key]
101
+ if (typeof value !== 'string' || value === '') continue
102
+ const parsed = Date.parse(value)
103
+ if (Number.isFinite(parsed)) return parsed
104
+ }
105
+ return Number.NEGATIVE_INFINITY
106
+ }
107
+
108
+ function compareRunsNewestFirst(a: TopologyNode, b: TopologyNode): number {
109
+ const aTime = nodeRunTime(a)
110
+ const bTime = nodeRunTime(b)
111
+ if (bTime !== aTime) return bTime - aTime
112
+ return b.name.localeCompare(a.name)
113
+ }
114
+
115
+ function runDisposition(node: TopologyNode): 'active' | 'failed' | 'succeeded' | 'other' {
116
+ const data = node.data ?? {}
117
+ const phase = typeof data.phase === 'string' ? data.phase : ''
118
+ if (phase === 'Running' || phase === 'Pending' || Number(data.active ?? 0) > 0) return 'active'
119
+ if (phase === 'Failed' || phase === 'Error' || node.status === 'unhealthy') return 'failed'
120
+ if (phase === 'Succeeded' || node.status === 'healthy' || Number(data.succeeded ?? 0) > 0) return 'succeeded'
121
+ if (node.kind === 'Job' && !data.completionTime) return 'active'
122
+ return 'other'
123
+ }
124
+
125
+ function representativeRuns(targets: TopologyNode[]): TopologyNode[] {
126
+ const sorted = [...targets].sort(compareRunsNewestFirst)
127
+ const active = sorted.filter((node) => runDisposition(node) === 'active')
128
+ const selected: TopologyNode[] = active.length > BATCH_RUN_FANOUT_LIMIT
129
+ ? [...active.slice(0, BATCH_RUN_FANOUT_LIMIT - 1), active[active.length - 1]]
130
+ : active
131
+ const addLatest = (disposition: 'failed' | 'succeeded') => {
132
+ const node = sorted.find((candidate) => runDisposition(candidate) === disposition)
133
+ if (node && !selected.some((candidate) => candidate.id === node.id)) selected.push(node)
134
+ }
135
+ addLatest('failed')
136
+ addLatest('succeeded')
137
+ if (selected.length === 0 && sorted[0]) selected.push(sorted[0])
138
+ return selected
139
+ }
140
+
141
+ function batchFanoutLimit(edge: TopologyEdge, nodeById: Map<string, TopologyNode>): number | null {
142
+ return isBatchRunFanoutEdge(edge, nodeById) ? BATCH_RUN_FANOUT_LIMIT : null
143
+ }
144
+
54
145
  /** The identity string for a workload/seed — `kind/namespace/name`. This format
55
146
  * is a cross-module contract: rail rows, the `?workload=` URL param, hover
56
147
  * focus, and the ownership stamp all compare these strings. Always construct
@@ -61,7 +152,10 @@ export function workloadKey(ref: NeighborhoodSeed): string {
61
152
  }
62
153
 
63
154
  function matchSeedNode(node: TopologyNode, seeds: NeighborhoodSeed[]): boolean {
64
- return seeds.some((s) => s.kind === node.kind && s.name === node.name && s.namespace === nodeNamespace(node))
155
+ return seeds.some((s) => {
156
+ if (s.kind !== node.kind || s.name !== node.name || s.namespace !== nodeNamespace(node)) return false
157
+ return !s.group || s.group === nodeGroup(node)
158
+ })
65
159
  }
66
160
 
67
161
  /** Filter a topology to the neighborhood of `seeds`. Returns the subgraph; an
@@ -92,18 +186,43 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
92
186
  }
93
187
  }
94
188
 
189
+ const limitedFanouts = new Map<string, { allowed: Set<string>; total: number; kind: string; activeTotal: number; activeShown: number }>()
190
+ for (const [sourceId, edges] of adjacency) {
191
+ const runEdges = edges.filter((edge) => edge.source === sourceId && batchFanoutLimit(edge, nodeById) !== null)
192
+ if (runEdges.length <= BATCH_RUN_FANOUT_LIMIT) continue
193
+ const targets = runEdges
194
+ .map((edge) => nodeById.get(edge.target))
195
+ .filter((node): node is TopologyNode => !!node)
196
+ .sort(compareRunsNewestFirst)
197
+ const representatives = representativeRuns(targets)
198
+ limitedFanouts.set(sourceId, {
199
+ allowed: new Set(representatives.map((node) => node.id)),
200
+ total: targets.length,
201
+ kind: targets[0]?.kind === 'Job' ? 'Job' : 'Workflow',
202
+ activeTotal: targets.filter((node) => runDisposition(node) === 'active').length,
203
+ activeShown: representatives.filter((node) => runDisposition(node) === 'active').length,
204
+ })
205
+ }
206
+
95
207
  const keep = new Set(seedIds)
96
208
  // Nodes included for context but never expanded THROUGH.
97
209
  const leaf = new Set<string>()
98
210
  const queue: string[] = Array.from(seedIds)
211
+ const cappedSources = new Set<string>()
99
212
 
100
213
  while (queue.length) {
101
214
  const id = queue.shift()!
102
215
  if (leaf.has(id)) continue // a leaf is a dead end — don't traverse out of it
216
+ const currentNode = nodeById.get(id)
103
217
  for (const e of adjacency.get(id) ?? []) {
104
218
  const nextId = e.source === id ? e.target : e.source
105
219
  const nextNode = nodeById.get(nextId)
106
220
  if (!nextNode) continue
221
+ const fanout = e.source === id ? limitedFanouts.get(id) : undefined
222
+ if (fanout && !fanout.allowed.has(nextId)) {
223
+ cappedSources.add(id)
224
+ continue
225
+ }
107
226
 
108
227
  let asLeaf: boolean
109
228
  if (IDENTITY_EDGES.has(e.type)) {
@@ -114,7 +233,17 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
114
233
  // every sibling Job its CronJob owns).
115
234
  asLeaf = nextId === e.source
116
235
  } else if (ROUTING_EDGES.has(e.type)) {
117
- asLeaf = true // a Service/Ingress in front of the workload — leaf
236
+ if (e.type === 'exposes' && id === e.source) {
237
+ // We reached a Service from one of its workloads. Only continue
238
+ // upstream to its routes; following the Service's other targets would
239
+ // pull sibling or unrelated workloads into this neighborhood.
240
+ continue
241
+ }
242
+ // A Service reached from a workload may expand once more to the
243
+ // Ingress/Route in front of it. The entrypoint itself remains a leaf.
244
+ asLeaf = e.type === 'routes-to' || nextNode.kind !== 'Service'
245
+ } else if (currentNode && isTemplateToRunEdge(e, nodeById) && e.source === currentNode.id) {
246
+ asLeaf = false
118
247
  } else {
119
248
  asLeaf = true // configures / uses / protects — leaf
120
249
  }
@@ -123,6 +252,16 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
123
252
  if (asLeaf) leaf.add(nextId)
124
253
  queue.push(nextId)
125
254
  }
255
+
256
+ if (asLeaf && nextNode.kind === 'Secret') {
257
+ for (const producerEdge of adjacency.get(nextId) ?? []) {
258
+ if (producerEdge.type !== 'manages' || producerEdge.target !== nextId) continue
259
+ const producer = nodeById.get(producerEdge.source)
260
+ if (!producer || !SECRET_PRODUCER_KINDS.has(producer.kind)) continue
261
+ keep.add(producer.id)
262
+ leaf.add(producer.id)
263
+ }
264
+ }
126
265
  }
127
266
  }
128
267
 
@@ -130,6 +269,15 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
130
269
  ...topology,
131
270
  nodes: topology.nodes.filter((n) => keep.has(n.id)),
132
271
  edges: topology.edges.filter((e) => keep.has(e.source) && keep.has(e.target)),
272
+ warnings: [
273
+ ...(topology.warnings ?? []),
274
+ ...Array.from(cappedSources).map((sourceId) => {
275
+ const source = nodeById.get(sourceId)
276
+ const fanout = limitedFanouts.get(sourceId)
277
+ const activeNote = fanout && fanout.activeTotal > fanout.activeShown ? ` showing the ${fanout.activeShown - 1} newest and oldest of ${fanout.activeTotal} active runs plus representative completed runs` : ' showing active and representative completed runs'
278
+ return `Topology view:${activeNote} from ${fanout?.total ?? BATCH_RUN_FANOUT_LIMIT} retained ${fanout?.kind ?? 'batch'} runs for ${source?.kind ?? 'workload'}/${source?.name ?? sourceId}. Use Run history for the full retained set.`
279
+ }),
280
+ ],
133
281
  }
134
282
  }
135
283
 
@@ -190,19 +338,26 @@ export function tagWorkloadOwnership(topology: Topology, seeds: NeighborhoodSeed
190
338
 
191
339
  // The seed nodes present in the subgraph, by their workload key.
192
340
  const seedKeyById = new Map<string, string>()
341
+ const subNodeById = new Map(sub.nodes.map((node) => [node.id, node]))
193
342
  for (const n of sub.nodes) {
194
343
  if (matchSeedNode(n, seeds)) {
195
344
  seedKeyById.set(n.id, workloadKey({ kind: n.kind, namespace: nodeNamespace(n), name: n.name }))
196
345
  }
197
346
  }
198
347
 
199
- // manages-DOWN children (source manages target) + undirected neighbors.
200
- const downChildren = new Map<string, string[]>()
348
+ // manages-DOWN children, template-to-run provenance, and undirected neighbors.
349
+ const nodeById = new Map<string, TopologyNode>()
350
+ for (const n of sub.nodes) nodeById.set(n.id, n)
351
+ const managedChildren = new Map<string, string[]>()
352
+ const templateRunChildren = new Map<string, string[]>()
201
353
  const neighbors = new Map<string, Set<string>>()
202
354
  for (const e of sub.edges) {
203
355
  if (e.type === 'manages') {
204
- if (!downChildren.has(e.source)) downChildren.set(e.source, [])
205
- downChildren.get(e.source)!.push(e.target)
356
+ if (!managedChildren.has(e.source)) managedChildren.set(e.source, [])
357
+ managedChildren.get(e.source)!.push(e.target)
358
+ } else if (isTemplateToRunEdge(e, nodeById)) {
359
+ if (!templateRunChildren.has(e.source)) templateRunChildren.set(e.source, [])
360
+ templateRunChildren.get(e.source)!.push(e.target)
206
361
  }
207
362
  for (const [a, b] of [[e.source, e.target], [e.target, e.source]] as const) {
208
363
  if (!neighbors.has(a)) neighbors.set(a, new Set())
@@ -210,8 +365,10 @@ export function tagWorkloadOwnership(topology: Topology, seeds: NeighborhoodSeed
210
365
  }
211
366
  }
212
367
 
213
- // Core = each seed plus everything reachable DOWN the manages chain from it
214
- // (its ReplicaSets, Pods). Exclusive by construction — a pod has one controller.
368
+ // Scheduler/controller ownership is authoritative. Claim every seed and its
369
+ // manages descendants first, then let template seeds claim only unowned runs
370
+ // and their descendants. A shared template remains provenance when a
371
+ // CronWorkflow in the same app already owns the Workflow.
215
372
  const coreOwner = new Map<string, string>()
216
373
  for (const [seedId, key] of seedKeyById) {
217
374
  const queue = [seedId]
@@ -219,7 +376,18 @@ export function tagWorkloadOwnership(topology: Topology, seeds: NeighborhoodSeed
219
376
  const id = queue.shift()!
220
377
  if (coreOwner.has(id)) continue
221
378
  coreOwner.set(id, key)
222
- for (const c of downChildren.get(id) ?? []) if (!coreOwner.has(c)) queue.push(c)
379
+ for (const c of managedChildren.get(id) ?? []) if (!coreOwner.has(c)) queue.push(c)
380
+ }
381
+ }
382
+ for (const [seedId, key] of seedKeyById) {
383
+ const seed = nodeById.get(seedId)
384
+ if (!seed || !isWorkflowTemplateKind(seed.kind)) continue
385
+ const queue = [...(templateRunChildren.get(seedId) ?? [])]
386
+ while (queue.length) {
387
+ const id = queue.shift()!
388
+ if (coreOwner.has(id)) continue
389
+ coreOwner.set(id, key)
390
+ for (const c of managedChildren.get(id) ?? []) if (!coreOwner.has(c)) queue.push(c)
223
391
  }
224
392
  }
225
393
 
@@ -240,6 +408,19 @@ export function tagWorkloadOwnership(topology: Topology, seeds: NeighborhoodSeed
240
408
  for (const nb of neighbors.get(n.id) ?? []) {
241
409
  const o = coreOwner.get(nb)
242
410
  if (o) related.add(o)
411
+ const neighborNode = subNodeById.get(nb)
412
+ if (neighborNode?.kind === 'Service') {
413
+ for (const serviceNeighbor of neighbors.get(nb) ?? []) {
414
+ const serviceOwner = coreOwner.get(serviceNeighbor)
415
+ if (serviceOwner) related.add(serviceOwner)
416
+ }
417
+ }
418
+ if (SECRET_PRODUCER_KINDS.has(n.kind) && neighborNode?.kind === 'Secret') {
419
+ for (const secretNeighbor of neighbors.get(nb) ?? []) {
420
+ const secretOwner = coreOwner.get(secretNeighbor)
421
+ if (secretOwner) related.add(secretOwner)
422
+ }
423
+ }
243
424
  }
244
425
  focusWorkloadIds = [...related]
245
426
  owner = related.size === 1 && !GITOPS_MANAGER_KINDS.has(n.kind) ? [...related][0] : null