@skyhook-io/k8s-ui 1.8.10 → 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 (116) 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/ApplicationsView.tsx +62 -10
  8. package/src/components/applications/applications-view.test.tsx +47 -0
  9. package/src/components/applications/index.ts +1 -1
  10. package/src/components/audit/AuditAlerts.tsx +14 -10
  11. package/src/components/checks/ChecksView.tsx +2 -3
  12. package/src/components/checks/severity.ts +14 -10
  13. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  14. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  15. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  16. package/src/components/gitops/GitOpsTableView.tsx +3 -3
  17. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  18. package/src/components/gitops/SyncCountdown.tsx +26 -17
  19. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  20. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  21. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  22. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  23. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  24. package/src/components/issues/IssuesView.tsx +51 -38
  25. package/src/components/issues/ResourceIssuesSection.test.tsx +6 -5
  26. package/src/components/issues/ResourceIssuesSection.tsx +28 -146
  27. package/src/components/issues/index.ts +3 -0
  28. package/src/components/issues/issue-timing.ts +53 -0
  29. package/src/components/issues/issues.test.ts +102 -1
  30. package/src/components/issues/severity.ts +2 -0
  31. package/src/components/logs/LogCore.tsx +14 -4
  32. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  33. package/src/components/logs/useLogStream.ts +8 -1
  34. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  35. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  36. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  37. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  38. package/src/components/resources/ResourcesView.tsx +233 -51
  39. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  41. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  42. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  43. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  44. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  45. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  46. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  47. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  48. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  49. package/src/components/resources/renderers/index.ts +1 -0
  50. package/src/components/resources/resource-utils-capi.ts +1 -1
  51. package/src/components/resources/resource-utils.ts +31 -0
  52. package/src/components/shared/DetailShell.tsx +2 -2
  53. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  54. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  55. package/src/components/timeline/TimelineList.tsx +371 -354
  56. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  57. package/src/components/timeline/TimelineStrip.tsx +836 -0
  58. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  59. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  60. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  61. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  62. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  63. package/src/components/timeline/index.ts +23 -0
  64. package/src/components/timeline/scrubber-math.test.ts +235 -0
  65. package/src/components/timeline/scrubber-math.ts +249 -0
  66. package/src/components/timeline/shared.test.tsx +219 -0
  67. package/src/components/timeline/shared.tsx +232 -447
  68. package/src/components/timeline/timeline-filters.test.ts +196 -0
  69. package/src/components/timeline/timeline-filters.ts +180 -0
  70. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  71. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  72. package/src/components/timeline/timeline-live.test.ts +131 -0
  73. package/src/components/timeline/timeline-live.ts +77 -0
  74. package/src/components/topology/GroupNode.tsx +9 -2
  75. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  76. package/src/components/topology/K8sResourceNode.tsx +387 -243
  77. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  78. package/src/components/topology/TopologyGraph.tsx +89 -16
  79. package/src/components/topology/TopologySearch.tsx +2 -2
  80. package/src/components/topology/layout.ts +10 -2
  81. package/src/components/topology/topology.css +0 -92
  82. package/src/components/ui/Badge.tsx +5 -0
  83. package/src/components/ui/CodeViewer.tsx +2 -2
  84. package/src/components/ui/Collapse.tsx +59 -0
  85. package/src/components/ui/ConfirmDialog.tsx +7 -3
  86. package/src/components/ui/DialogPortal.tsx +31 -9
  87. package/src/components/ui/Input.tsx +24 -0
  88. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  89. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  90. package/src/components/ui/PaneLoader.tsx +10 -2
  91. package/src/components/ui/ResourceBar.tsx +68 -25
  92. package/src/components/ui/SearchBox.tsx +18 -4
  93. package/src/components/ui/SearchPillInput.tsx +2 -2
  94. package/src/components/ui/drawer-components.tsx +39 -26
  95. package/src/components/ui/index.ts +4 -0
  96. package/src/components/ui/severity-tone.ts +11 -4
  97. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  98. package/src/components/workload/WorkloadView.tsx +2682 -472
  99. package/src/components/workload/index.ts +1 -1
  100. package/src/theme/variables.css +1 -1
  101. package/src/types/core.ts +57 -4
  102. package/src/utils/application-topology.test.ts +198 -0
  103. package/src/utils/application-topology.ts +261 -0
  104. package/src/utils/applications.test.ts +167 -1
  105. package/src/utils/applications.ts +1157 -393
  106. package/src/utils/index.ts +2 -0
  107. package/src/utils/navigation.test.ts +32 -1
  108. package/src/utils/navigation.ts +70 -0
  109. package/src/utils/resource-hierarchy.test.ts +990 -1
  110. package/src/utils/resource-hierarchy.ts +905 -267
  111. package/src/utils/resource-icons.ts +2 -0
  112. package/src/utils/topology-neighborhood.test.ts +202 -1
  113. package/src/utils/topology-neighborhood.ts +194 -13
  114. package/src/utils/workflow-execution.test.ts +556 -0
  115. package/src/utils/workflow-execution.ts +814 -0
  116. package/src/utils/workload-colors.ts +2 -3
@@ -1,2 +1,2 @@
1
- export { WorkloadView, type WorkloadTabType } from './WorkloadView'
1
+ export { WorkloadView, type ResourceOwnershipContext, type ServingResourceDetail, type WorkloadTabType } from './WorkloadView'
2
2
  export { ResourceDetailDrawer } from './ResourceDetailDrawer'
@@ -156,6 +156,6 @@
156
156
  --shadow-sm: 0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.06);
157
157
  --shadow-md: 0 4px 12px -2px rgba(0,0,0,0.35), 0 2px 4px rgba(0,0,0,0.15), 0 0 0 1px rgba(255,255,255,0.06);
158
158
  --shadow-lg: 0 12px 32px -6px rgba(0,0,0,0.3), 0 4px 8px rgba(0,0,0,0.1);
159
- --shadow-drawer: -16px 0 60px rgba(0,0,0,0.6), -6px 0 20px rgba(0,0,0,0.4);
159
+ --shadow-drawer: -12px 0 36px rgba(0,0,0,0.45), -4px 0 14px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.06);
160
160
  --topology-node-shadow: 0 2px 6px rgba(0,0,0,0.2), 0 6px 16px rgba(0,0,0,0.15);
161
161
  }
package/src/types/core.ts CHANGED
@@ -11,6 +11,7 @@ export interface ResourcePermissions {
11
11
  statefulSets: boolean
12
12
  replicaSets: boolean
13
13
  ingresses: boolean
14
+ ingressClasses: boolean
14
15
  configMaps: boolean
15
16
  secrets: boolean
16
17
  events: boolean
@@ -94,7 +95,6 @@ export interface Deployment {
94
95
  // When adding a new kind here, also update:
95
96
  // - ALL_NODE_KINDS in App.tsx
96
97
  // - TopologyFilterSidebar.tsx RESOURCE_KINDS array
97
- // - index.css .topology-icon-* class
98
98
  // - K8sResourceNode.tsx NODE_DIMENSIONS
99
99
  // - resource-icons.ts KIND_ICON_MAP
100
100
  // - kindToPlural in navigation.ts (if irregular plural)
@@ -123,9 +123,17 @@ export type CoreNodeKind =
123
123
  | 'PodGroup'
124
124
  | 'ConfigMap'
125
125
  | 'Secret'
126
+ | 'ServiceAccount'
127
+ | 'SealedSecret'
128
+ | 'ServiceMonitor'
129
+ | 'PodMonitor'
126
130
  | 'HorizontalPodAutoscaler'
127
131
  | 'Job'
128
132
  | 'CronJob'
133
+ | 'Workflow'
134
+ | 'CronWorkflow'
135
+ | 'WorkflowTemplate'
136
+ | 'ClusterWorkflowTemplate'
129
137
  | 'PersistentVolumeClaim'
130
138
  | 'Node'
131
139
  | 'Namespace'
@@ -168,6 +176,10 @@ export function displayKind(kind: string): string {
168
176
  const shortNames: Record<string, string> = {
169
177
  HorizontalPodAutoscaler: 'HPA',
170
178
  PersistentVolumeClaim: 'PVC',
179
+ ServiceAccount: 'Service Account',
180
+ SealedSecret: 'Sealed Secret',
181
+ ServiceMonitor: 'Service Monitor',
182
+ PodMonitor: 'Pod Monitor',
171
183
  EC2NodeClass: 'NodeClass',
172
184
  KnativeService: 'Knative Svc',
173
185
  KnativeConfiguration: 'Knative Config',
@@ -275,6 +287,10 @@ export interface TimelineEvent {
275
287
  id: string
276
288
  timestamp: string // ISO date string
277
289
  source: EventSource // Where event originated: 'informer', 'k8s_event', 'historical'
290
+ // Store-assigned arrival number (monotonic per store instance). The delta
291
+ // cursor keys on it — arrival order, not event time, so late-arriving
292
+ // events can't slip behind a client's cursor.
293
+ seq?: number
278
294
 
279
295
  // Resource identity
280
296
  kind: string
@@ -339,6 +355,20 @@ export function isWorkloadKind(kind: string): boolean {
339
355
  ].includes(kind)
340
356
  }
341
357
 
358
+ export function isDeploymentLikeWorkloadKind(kind: string, group = ''): boolean {
359
+ switch (kind) {
360
+ case 'Deployment':
361
+ case 'ReplicaSet':
362
+ case 'StatefulSet':
363
+ case 'DaemonSet':
364
+ return group === '' || group === 'apps'
365
+ case 'Rollout':
366
+ return group === '' || group === 'argoproj.io'
367
+ default:
368
+ return false
369
+ }
370
+ }
371
+
342
372
  // Check if a resource kind is typically managed by another
343
373
  export function isManagedKind(kind: string): boolean {
344
374
  return ['ReplicaSet', 'Pod', 'Event'].includes(kind)
@@ -353,7 +383,7 @@ export interface TimelineFilters {
353
383
  timeRange: TimeRange
354
384
  }
355
385
 
356
- export type TimeRange = '5m' | '30m' | '1h' | '6h' | '24h' | 'all'
386
+ export type TimeRange = '5m' | '30m' | '1h' | '6h' | '24h' | '7d' | '30d' | 'all'
357
387
 
358
388
  // Cluster info
359
389
  export interface ClusterInfo {
@@ -445,6 +475,7 @@ export interface Relationships {
445
475
  configRefs?: ResourceRef[]
446
476
  consumers?: ResourceRef[]
447
477
  scalers?: ResourceRef[]
478
+ storageRefs?: ResourceRef[]
448
479
  scaleTarget?: ResourceRef
449
480
  pdbs?: ResourceRef[] // PodDisruptionBudgets protecting this workload
450
481
  networkPolicies?: ResourceRef[] // NetworkPolicy / CiliumNetworkPolicy / ClusterNetworkPolicy variants selecting this workload
@@ -822,9 +853,11 @@ export interface UpgradeInfo {
822
853
  // oci:// chart reference an OCI-sourced upgrade lives at (display only).
823
854
  chartRef?: string
824
855
  error?: string
856
+ // Machine-readable source-resolution failure, used to make the Helm drawer
857
+ // explain whether tracking an OCI source can help.
858
+ sourceIssue?: 'untracked' | 'repo_index_error' | 'ambiguous_repository'
825
859
  // True only when the error is a genuinely untracked source (registering a
826
- // chart source could fix it) — NOT for repo-side errors like a stale index or
827
- // classic ambiguity. Gates the "track source" affordance.
860
+ // chart source could fix it). Kept for compatibility; prefer sourceIssue.
828
861
  untracked?: boolean
829
862
  }
830
863
 
@@ -836,6 +869,8 @@ export interface BatchUpgradeInfo {
836
869
  // Request body for applying new values to a release
837
870
  export interface ApplyValuesRequest {
838
871
  values: Record<string, unknown>
872
+ version?: string
873
+ repository?: string
839
874
  }
840
875
 
841
876
  // Response for previewing values changes
@@ -1234,10 +1269,28 @@ export interface ImageMetadata {
1234
1269
  // ============================================================================
1235
1270
 
1236
1271
  // Pod info returned from workload pods endpoint
1272
+ export interface WorkloadPodContainerInfo {
1273
+ name: string
1274
+ init?: boolean
1275
+ ready: boolean
1276
+ restartCount: number
1277
+ }
1278
+
1237
1279
  export interface WorkloadPodInfo {
1238
1280
  name: string
1239
1281
  containers: string[]
1240
1282
  ready: boolean
1283
+ phase?: string
1284
+ healthLevel?: HealthStatus
1285
+ reason?: string
1286
+ message?: string
1287
+ restartCount?: number
1288
+ lastTerminationReason?: string
1289
+ createdAt?: string
1290
+ containerStatuses?: WorkloadPodContainerInfo[]
1291
+ stepID?: string
1292
+ stepName?: string
1293
+ stepPhase?: string
1241
1294
  }
1242
1295
 
1243
1296
  // SSE event types for workload log streaming
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { GitOpsResourceTree, Topology } from '../types'
3
+ import { collapseStableReplicaSets, deploymentInventoryFromGitOps, deploymentInventoryFromHelm, layerDeploymentInventory, topologyGroup } from './application-topology'
4
+
5
+ const topology: Topology = {
6
+ nodes: [
7
+ { id: 'deployment/default/api', kind: 'Deployment', name: 'api', status: 'healthy', data: { namespace: 'default', apiVersion: 'apps/v1' } },
8
+ { id: 'service/default/external', kind: 'Service', name: 'external', status: 'healthy', data: { namespace: 'default', apiVersion: 'v1' } },
9
+ ],
10
+ edges: [],
11
+ }
12
+
13
+ it('resolves the stored API group for source-only CRD nodes', () => {
14
+ expect(topologyGroup({
15
+ id: 'source/prometheusrule',
16
+ kind: 'PrometheusRule',
17
+ name: 'alerts',
18
+ status: 'healthy',
19
+ data: { namespace: 'monitoring', sourceGroup: 'monitoring.coreos.com' },
20
+ })).toBe('monitoring.coreos.com')
21
+ })
22
+
23
+ function inventory(warnings?: string[]): GitOpsResourceTree {
24
+ const root = { id: 'root', ref: { group: 'argoproj.io', kind: 'Application', namespace: 'argocd', name: 'api' }, role: 'root' as const, tool: 'argocd' as const }
25
+ return {
26
+ root,
27
+ warnings,
28
+ edges: [],
29
+ nodes: [
30
+ root,
31
+ { id: 'deployment', ref: { group: 'apps', kind: 'Deployment', namespace: 'default', name: 'api' }, role: 'declared', tool: 'argocd' },
32
+ { id: 'config', ref: { kind: 'ConfigMap', namespace: 'default', name: 'api-config' }, role: 'declared', tool: 'argocd' },
33
+ ],
34
+ }
35
+ }
36
+
37
+ describe('layerDeploymentInventory', () => {
38
+ it('separates overlapping, runtime-only, and managed-only resources', () => {
39
+ const result = layerDeploymentInventory(topology, deploymentInventoryFromGitOps(inventory())!, 'Argo CD')
40
+
41
+ expect(result.managedRuntimeCount).toBe(1)
42
+ expect(result.runtimeOnlyCount).toBe(1)
43
+ expect(result.managedOnly.map((node) => node.name)).toEqual(['api-config'])
44
+ expect(result.topology.nodes[0].data?.deploymentMembership).toBeUndefined()
45
+ expect(result.topology.nodes[1].data?.deploymentMembership).toBe('runtime-only')
46
+ expect(result.topology.nodes[2]).toMatchObject({ kind: 'ConfigMap', name: 'api-config', data: { deploymentMembership: 'source-only' } })
47
+ })
48
+
49
+ it('does not claim runtime-only membership for a partial inventory', () => {
50
+ const result = layerDeploymentInventory(topology, deploymentInventoryFromGitOps(inventory(['Some resources are hidden']))!, 'Argo CD')
51
+
52
+ expect(result.inventoryComplete).toBe(false)
53
+ expect(result.runtimeOnlyCount).toBe(0)
54
+ expect(result.topology.nodes[1].data?.deploymentMembership).toBeUndefined()
55
+ })
56
+
57
+ it('does not claim runtime-only membership for Helm without a completeness signal', () => {
58
+ const helmInventory = deploymentInventoryFromHelm([{
59
+ apiVersion: 'apps/v1',
60
+ kind: 'Deployment',
61
+ namespace: 'default',
62
+ name: 'api',
63
+ status: 'ready',
64
+ }])!
65
+ const result = layerDeploymentInventory(topology, helmInventory, 'Helm')
66
+
67
+ expect(result.inventoryComplete).toBe(false)
68
+ expect(result.runtimeOnlyCount).toBe(0)
69
+ expect(result.topology.nodes[1].data?.deploymentMembership).toBeUndefined()
70
+ })
71
+
72
+ it('adds no membership markers when source and runtime agree', () => {
73
+ const matchingTopology: Topology = {
74
+ nodes: [topology.nodes[0]],
75
+ edges: [],
76
+ }
77
+ const matchingInventory = inventory()
78
+ matchingInventory.nodes = matchingInventory.nodes.filter((node) => node.id !== 'config')
79
+
80
+ const result = layerDeploymentInventory(matchingTopology, deploymentInventoryFromGitOps(matchingInventory)!, 'Argo CD')
81
+
82
+ expect(result.runtimeOnlyCount).toBe(0)
83
+ expect(result.managedOnly).toEqual([])
84
+ expect(result.topology.nodes[0].data?.deploymentMembership).toBeUndefined()
85
+ })
86
+
87
+ it('does not merge same-named resources from different CRD groups', () => {
88
+ const collisionTopology: Topology = {
89
+ nodes: [{ id: 'service/default/api', kind: 'Service', name: 'api', status: 'healthy', data: { namespace: 'default', apiVersion: 'serving.knative.dev/v1' } }],
90
+ edges: [],
91
+ }
92
+ const result = layerDeploymentInventory(collisionTopology, deploymentInventoryFromGitOps({
93
+ root: inventory().root,
94
+ edges: [],
95
+ nodes: [
96
+ inventory().root,
97
+ { id: 'core-service', ref: { kind: 'Service', namespace: 'default', name: 'api' }, role: 'declared', tool: 'argocd' },
98
+ ],
99
+ })!, 'Argo CD')
100
+
101
+ expect(result.managedRuntimeCount).toBe(0)
102
+ expect(result.runtimeOnlyCount).toBe(1)
103
+ expect(result.managedOnly.map((node) => node.id)).toEqual(['core-service'])
104
+ })
105
+
106
+ it('does not classify synthetic graph nodes as runtime-only resources', () => {
107
+ const syntheticTopology: Topology = {
108
+ nodes: [
109
+ { id: 'podgroup/default/api', kind: 'PodGroup', name: 'api', status: 'healthy', data: { namespace: 'default' } },
110
+ { id: 'internet', kind: 'Internet', name: 'Internet', status: 'healthy', data: {} },
111
+ ],
112
+ edges: [],
113
+ }
114
+ const result = layerDeploymentInventory(syntheticTopology, deploymentInventoryFromGitOps(inventory())!, 'Argo CD')
115
+
116
+ expect(result.runtimeOnlyCount).toBe(0)
117
+ expect(result.topology.nodes.slice(0, 2).every((node) => node.data?.deploymentMembership === undefined)).toBe(true)
118
+ })
119
+
120
+ it('groups repeated source-only resources so large bundles remain readable', () => {
121
+ const largeInventory = inventory()
122
+ largeInventory.nodes = [
123
+ largeInventory.root,
124
+ ...Array.from({ length: 5 }, (_, index) => ({
125
+ id: `rule-${index}`,
126
+ ref: { group: 'monitoring.coreos.com', kind: 'PrometheusRule', namespace: 'monitoring', name: `rule-${index}` },
127
+ role: 'declared' as const,
128
+ tool: 'argocd' as const,
129
+ })),
130
+ ]
131
+
132
+ const result = layerDeploymentInventory({ nodes: [], edges: [] }, deploymentInventoryFromGitOps(largeInventory)!, 'Argo CD')
133
+
134
+ expect(result.managedOnly).toHaveLength(5)
135
+ expect(result.topology.nodes).toHaveLength(1)
136
+ expect(result.topology.nodes[0]).toMatchObject({ kind: 'PrometheusRule', data: { sourceInventoryGroup: true, sourceInventoryCount: 5 } })
137
+ })
138
+
139
+ it('summarizes a large source-only inventory outside the graph', () => {
140
+ const largeInventory = inventory()
141
+ largeInventory.nodes = [
142
+ largeInventory.root,
143
+ ...Array.from({ length: 10 }, (_, index) => ({
144
+ id: `rule-${index}`,
145
+ ref: { group: 'monitoring.coreos.com', kind: 'PrometheusRule', namespace: 'monitoring', name: `rule-${index}` },
146
+ role: 'declared' as const,
147
+ tool: 'argocd' as const,
148
+ })),
149
+ ...Array.from({ length: 5 }, (_, index) => ({
150
+ id: `config-${index}`,
151
+ ref: { kind: 'ConfigMap', namespace: 'monitoring', name: `config-${index}` },
152
+ role: 'declared' as const,
153
+ tool: 'argocd' as const,
154
+ })),
155
+ ]
156
+
157
+ const result = layerDeploymentInventory({ nodes: [], edges: [] }, deploymentInventoryFromGitOps(largeInventory)!, 'Argo CD')
158
+
159
+ expect(result.managedOnly).toHaveLength(15)
160
+ expect(result.topology.nodes).toHaveLength(0)
161
+ expect(result.managedOnlySummary).toBe('10 PrometheusRules · 5 ConfigMaps')
162
+ })
163
+ })
164
+
165
+ describe('collapseStableReplicaSets', () => {
166
+ const replicaSetTopology: Topology = {
167
+ nodes: [
168
+ { id: 'deployment/default/api', kind: 'Deployment', name: 'api', status: 'healthy', data: { namespace: 'default' } },
169
+ { id: 'replicaset/default/api-abc', kind: 'ReplicaSet', name: 'api-abc', status: 'healthy', data: { namespace: 'default', readyReplicas: 2, totalReplicas: 2 } },
170
+ { id: 'pod/default/api-1', kind: 'Pod', name: 'api-1', status: 'healthy', data: { namespace: 'default' } },
171
+ ],
172
+ edges: [
173
+ { id: 'deployment-rs', source: 'deployment/default/api', target: 'replicaset/default/api-abc', type: 'manages' },
174
+ { id: 'rs-pod', source: 'replicaset/default/api-abc', target: 'pod/default/api-1', type: 'manages' },
175
+ ],
176
+ }
177
+
178
+ it('collapses a stable single ReplicaSet and rewires its pods', () => {
179
+ const result = collapseStableReplicaSets(replicaSetTopology, new Set())
180
+ expect(result.nodes.map((node) => node.kind)).toEqual(['Deployment', 'Pod'])
181
+ expect(result.edges).toContainEqual(expect.objectContaining({ source: 'deployment/default/api', target: 'pod/default/api-1' }))
182
+ expect(result.nodes[0].data).toMatchObject({ replicaSetCount: 1, replicaSetsCollapsed: true })
183
+ })
184
+
185
+ it('shows the ReplicaSet when the developer expands it', () => {
186
+ const result = collapseStableReplicaSets(replicaSetTopology, new Set(['deployment/default/api']))
187
+ expect(result.nodes.map((node) => node.kind)).toEqual(['Deployment', 'ReplicaSet', 'Pod'])
188
+ expect(result.nodes[0].data).toMatchObject({ replicaSetCount: 1, replicaSetsCollapsed: false })
189
+ })
190
+
191
+ it('keeps an unhealthy ReplicaSet visible automatically', () => {
192
+ const unhealthy = {
193
+ ...replicaSetTopology,
194
+ nodes: replicaSetTopology.nodes.map((node) => node.kind === 'ReplicaSet' ? { ...node, status: 'degraded' as const } : node),
195
+ }
196
+ expect(collapseStableReplicaSets(unhealthy, new Set()).nodes.map((node) => node.kind)).toContain('ReplicaSet')
197
+ })
198
+ })
@@ -0,0 +1,261 @@
1
+ import type { GitOpsResourceTree, HealthStatus, HelmOwnedResource, Topology, TopologyNode } from '../types'
2
+ import { apiVersionToGroup, groupQualifiesLaneId } from './navigation'
3
+ import { pluralize } from './pluralize'
4
+
5
+ export type DeploymentMembership = 'runtime-only' | 'source-only'
6
+
7
+ export interface DeploymentInventoryNode {
8
+ id: string
9
+ kind: string
10
+ group?: string
11
+ namespace: string
12
+ name: string
13
+ role: 'declared' | 'generated'
14
+ status?: HealthStatus
15
+ }
16
+
17
+ export interface DeploymentInventory {
18
+ nodes: DeploymentInventoryNode[]
19
+ warnings?: string[]
20
+ complete?: boolean
21
+ }
22
+
23
+ export interface DeploymentTopologyLayer {
24
+ topology: Topology
25
+ managedRuntimeCount: number
26
+ runtimeOnlyCount: number
27
+ managedOnly: DeploymentInventoryNode[]
28
+ managedOnlySummary?: string
29
+ inventoryComplete: boolean
30
+ }
31
+
32
+ export function collapseStableReplicaSets(topology: Topology, expandedOwners: ReadonlySet<string>): Topology {
33
+ const nodesByID = new Map(topology.nodes.map((node) => [node.id, node]))
34
+ const replicaSetsByOwner = new Map<string, string[]>()
35
+ for (const edge of topology.edges) {
36
+ if (edge.type !== 'manages' || nodesByID.get(edge.target)?.kind !== 'ReplicaSet') continue
37
+ replicaSetsByOwner.set(edge.source, [...(replicaSetsByOwner.get(edge.source) ?? []), edge.target])
38
+ }
39
+
40
+ const collapsedReplicaSets = new Set<string>()
41
+ const rewiredEdges = [] as Topology['edges']
42
+ const parentData = new Map<string, Record<string, unknown>>()
43
+
44
+ for (const [ownerID, replicaSetIDs] of replicaSetsByOwner) {
45
+ const owner = nodesByID.get(ownerID)
46
+ const replicaSet = replicaSetIDs.length === 1 ? nodesByID.get(replicaSetIDs[0]) : undefined
47
+ const ready = Number(replicaSet?.data?.readyReplicas ?? 0)
48
+ const total = Number(replicaSet?.data?.totalReplicas ?? 0)
49
+ const incoming = topology.edges.filter((edge) => edge.target === replicaSet?.id)
50
+ const stable = owner?.status === 'healthy'
51
+ && replicaSet?.status === 'healthy'
52
+ && total > 0
53
+ && ready >= total
54
+ && incoming.every((edge) => edge.source === ownerID && edge.type === 'manages')
55
+ const shouldCollapse = replicaSetIDs.length === 1 && stable && !expandedOwners.has(ownerID)
56
+ parentData.set(ownerID, {
57
+ replicaSetCount: replicaSetIDs.length,
58
+ replicaSetsCollapsed: shouldCollapse,
59
+ replicaSetsExpandable: stable && replicaSetIDs.length === 1,
60
+ })
61
+ if (shouldCollapse && replicaSet) collapsedReplicaSets.add(replicaSet.id)
62
+ }
63
+
64
+ for (const edge of topology.edges) {
65
+ if (collapsedReplicaSets.has(edge.target)) continue
66
+ if (!collapsedReplicaSets.has(edge.source)) {
67
+ rewiredEdges.push(edge)
68
+ continue
69
+ }
70
+ const ownerEdge = topology.edges.find((candidate) => candidate.target === edge.source && candidate.type === 'manages')
71
+ if (!ownerEdge) continue
72
+ rewiredEdges.push({
73
+ ...edge,
74
+ id: `${ownerEdge.source}-to-${edge.target}`,
75
+ source: ownerEdge.source,
76
+ type: 'manages',
77
+ })
78
+ }
79
+
80
+ return {
81
+ ...topology,
82
+ nodes: topology.nodes
83
+ .filter((node) => !collapsedReplicaSets.has(node.id))
84
+ .map((node) => parentData.has(node.id) ? { ...node, data: { ...node.data, ...parentData.get(node.id) } } : node),
85
+ edges: rewiredEdges,
86
+ }
87
+ }
88
+
89
+ const GENERATED_RUNTIME_KINDS = new Set(['pod', 'podgroup', 'replicaset'])
90
+ const MAX_INDIVIDUAL_SOURCE_ONLY_RESOURCES = 12
91
+
92
+ function identityKey(kind: string, group: string | undefined, namespace: string, name: string): string {
93
+ const identityGroup = groupQualifiesLaneId(group) ? group!.toLowerCase() : ''
94
+ return `${identityGroup}/${kind.toLowerCase()}/${namespace}/${name}`
95
+ }
96
+
97
+ export function topologyGroup(node: TopologyNode): string | undefined {
98
+ return apiVersionToGroup(node.data?.apiVersion as string | undefined) || (node.data?.sourceGroup as string | undefined)
99
+ }
100
+
101
+ export function deploymentInventoryFromGitOps(tree: GitOpsResourceTree | undefined): DeploymentInventory | undefined {
102
+ if (!tree) return undefined
103
+ return {
104
+ nodes: tree.nodes
105
+ .filter((node) => node.role === 'declared' || node.role === 'generated')
106
+ .map((node) => ({
107
+ id: node.id,
108
+ kind: node.ref.kind,
109
+ group: node.ref.group,
110
+ namespace: node.ref.namespace,
111
+ name: node.ref.name,
112
+ role: node.role as 'declared' | 'generated',
113
+ status: node.topologyStatus,
114
+ })),
115
+ warnings: tree.warnings,
116
+ }
117
+ }
118
+
119
+ export function deploymentInventoryFromHelm(resources: HelmOwnedResource[] | undefined): DeploymentInventory | undefined {
120
+ if (!resources) return undefined
121
+ return {
122
+ complete: false,
123
+ nodes: resources.map((resource) => ({
124
+ id: `helm/${resource.kind.toLowerCase()}/${resource.namespace}/${resource.name}`,
125
+ kind: resource.kind,
126
+ group: apiVersionToGroup(resource.apiVersion),
127
+ namespace: resource.namespace,
128
+ name: resource.name,
129
+ role: 'declared',
130
+ status: helmResourceStatus(resource.status),
131
+ })),
132
+ }
133
+ }
134
+
135
+ function helmResourceStatus(status: string | undefined): HealthStatus {
136
+ const normalized = status?.toLowerCase() ?? ''
137
+ if (['running', 'active', 'ready', 'succeeded', 'completed', 'bound'].includes(normalized)) return 'healthy'
138
+ if (['failed', 'error', 'unhealthy'].includes(normalized)) return 'unhealthy'
139
+ if (['pending', 'degraded'].includes(normalized)) return 'degraded'
140
+ return 'unknown'
141
+ }
142
+
143
+ export function layerDeploymentInventory(
144
+ topology: Topology,
145
+ inventory: DeploymentInventory,
146
+ sourceLabel: string,
147
+ ): DeploymentTopologyLayer {
148
+ const inventoryByExact = new Map(inventory.nodes.map((node) => [
149
+ identityKey(node.kind, node.group, node.namespace, node.name),
150
+ node,
151
+ ]))
152
+ const matchedInventoryIds = new Set<string>()
153
+ let managedRuntimeCount = 0
154
+ let runtimeOnlyCount = 0
155
+ const inventoryComplete = inventory.complete !== false && (inventory.warnings?.length ?? 0) === 0
156
+
157
+ const runtimeNodes = topology.nodes.map((node) => {
158
+ if (node.kind === 'PodGroup' || node.kind === 'Internet') return node
159
+ const namespace = (node.data?.namespace as string) || ''
160
+ const match = inventoryByExact.get(identityKey(node.kind, topologyGroup(node), namespace, node.name))
161
+ if (match) {
162
+ matchedInventoryIds.add(match.id)
163
+ managedRuntimeCount += 1
164
+ return node
165
+ }
166
+ if (!inventoryComplete || GENERATED_RUNTIME_KINDS.has(node.kind.toLowerCase())) return node
167
+ runtimeOnlyCount += 1
168
+ return {
169
+ ...node,
170
+ data: {
171
+ ...node.data,
172
+ deploymentMembership: 'runtime-only' satisfies DeploymentMembership,
173
+ deploymentSourceLabel: sourceLabel,
174
+ },
175
+ }
176
+ })
177
+
178
+ const managedOnly = inventory.nodes.filter((node) => node.role === 'declared' && !matchedInventoryIds.has(node.id))
179
+ const managedOnlySummary = managedOnly.length > MAX_INDIVIDUAL_SOURCE_ONLY_RESOURCES
180
+ ? summarizeInventory(managedOnly)
181
+ : undefined
182
+ const sourceOnlyNodes = managedOnlySummary
183
+ ? []
184
+ : groupedSourceOnlyNodes(managedOnly, sourceLabel)
185
+
186
+ return {
187
+ topology: { ...topology, nodes: [...runtimeNodes, ...sourceOnlyNodes] },
188
+ managedRuntimeCount,
189
+ runtimeOnlyCount,
190
+ managedOnly,
191
+ managedOnlySummary,
192
+ inventoryComplete,
193
+ }
194
+ }
195
+
196
+ function groupedSourceOnlyNodes(nodes: DeploymentInventoryNode[], sourceLabel: string): TopologyNode[] {
197
+ const groups = new Map<string, DeploymentInventoryNode[]>()
198
+ for (const node of nodes) {
199
+ const key = `${node.group ?? ''}/${node.kind}/${node.namespace}`
200
+ groups.set(key, [...(groups.get(key) ?? []), node])
201
+ }
202
+
203
+ const result: TopologyNode[] = []
204
+ for (const [groupKey, group] of groups) {
205
+ if (group.length <= 3) {
206
+ result.push(...group.map((node) => sourceOnlyNode(node, sourceLabel)))
207
+ continue
208
+ }
209
+ const representative = group[0]
210
+ result.push({
211
+ id: `source-group/${groupKey}`,
212
+ kind: representative.kind,
213
+ name: pluralize(group.length, representative.kind),
214
+ status: worstTopologyStatus(group.map((node) => node.status)),
215
+ data: {
216
+ namespace: representative.namespace,
217
+ sourceGroup: representative.group,
218
+ sourceInventoryGroup: true,
219
+ sourceInventoryCount: group.length,
220
+ deploymentMembership: 'source-only' satisfies DeploymentMembership,
221
+ deploymentSourceLabel: sourceLabel,
222
+ },
223
+ })
224
+ }
225
+ return result
226
+ }
227
+
228
+ function summarizeInventory(nodes: DeploymentInventoryNode[]): string {
229
+ const counts = new Map<string, number>()
230
+ for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) ?? 0) + 1)
231
+ const leadingKinds = [...counts.entries()]
232
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
233
+ .slice(0, 2)
234
+ const summarizedCount = leadingKinds.reduce((total, [, count]) => total + count, 0)
235
+ const summary = [
236
+ ...leadingKinds.map(([kind, count]) => pluralize(count, kind)),
237
+ ...(nodes.length > summarizedCount ? [`${nodes.length - summarizedCount} more`] : []),
238
+ ].join(' · ')
239
+
240
+ return summary
241
+ }
242
+
243
+ function sourceOnlyNode(node: DeploymentInventoryNode, sourceLabel: string): TopologyNode {
244
+ return {
245
+ id: `source/${node.id}`,
246
+ kind: node.kind,
247
+ name: node.name,
248
+ status: node.status ?? 'unknown',
249
+ data: {
250
+ namespace: node.namespace,
251
+ sourceGroup: node.group,
252
+ deploymentMembership: 'source-only' satisfies DeploymentMembership,
253
+ deploymentSourceLabel: sourceLabel,
254
+ },
255
+ }
256
+ }
257
+
258
+ function worstTopologyStatus(statuses: Array<HealthStatus | undefined>): HealthStatus {
259
+ const rank: Record<HealthStatus, number> = { unknown: 0, neutral: 1, healthy: 2, degraded: 3, unhealthy: 4 }
260
+ return statuses.reduce<HealthStatus>((worst, status) => status && rank[status] > rank[worst] ? status : worst, 'unknown')
261
+ }