@skyhook-io/k8s-ui 1.8.10 → 1.8.12

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 (168) hide show
  1. package/package.json +7 -6
  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 +697 -0
  6. package/src/components/applications/ApplicationDetail.tsx +3269 -474
  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/GitOpsDetailLayout.tsx +13 -3
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +13 -6
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  21. package/src/components/gitops/SyncOptionsDialog.tsx +190 -46
  22. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  23. package/src/components/gitops/insights/GitOpsInsightViews.tsx +474 -121
  24. package/src/components/gitops/insights/index.ts +1 -0
  25. package/src/components/gitops/insights/insights-helpers.test.ts +102 -1
  26. package/src/components/gitops/insights/insights-helpers.ts +74 -1
  27. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +10 -2
  28. package/src/components/issues/IssuesView.tsx +91 -52
  29. package/src/components/issues/ResourceIssuesSection.test.tsx +39 -6
  30. package/src/components/issues/ResourceIssuesSection.tsx +46 -144
  31. package/src/components/issues/index.ts +3 -0
  32. package/src/components/issues/issue-timing.ts +53 -0
  33. package/src/components/issues/issues.test.ts +102 -1
  34. package/src/components/issues/severity.ts +3 -0
  35. package/src/components/logs/LogCore.tsx +14 -4
  36. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  37. package/src/components/logs/useLogStream.ts +8 -1
  38. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  39. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  40. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  41. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  42. package/src/components/resources/ResourcesView.tsx +233 -51
  43. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  44. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  54. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  55. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  56. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  57. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  58. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  59. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  60. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  61. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  62. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  63. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  64. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  65. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  66. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  67. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  68. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  69. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  70. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  71. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  72. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  73. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  75. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  76. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  77. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  78. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  79. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  80. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  81. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  82. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  83. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  84. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  85. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  86. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  87. package/src/components/resources/renderers/index.ts +1 -0
  88. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  89. package/src/components/resources/resource-utils-argo.ts +8 -0
  90. package/src/components/resources/resource-utils-capi.ts +1 -1
  91. package/src/components/resources/resource-utils.ts +31 -0
  92. package/src/components/shared/DetailShell.tsx +2 -2
  93. package/src/components/shared/ResourceActionsBar.tsx +142 -111
  94. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  95. package/src/components/shared/UnifiedDiff.tsx +51 -0
  96. package/src/components/shared/index.ts +1 -0
  97. package/src/components/timeline/TimelineList.tsx +375 -354
  98. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  99. package/src/components/timeline/TimelineStrip.tsx +836 -0
  100. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  101. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  102. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  103. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  104. package/src/components/timeline/TimelineToolbarState.test.tsx +88 -0
  105. package/src/components/timeline/index.ts +23 -0
  106. package/src/components/timeline/scrubber-math.test.ts +235 -0
  107. package/src/components/timeline/scrubber-math.ts +249 -0
  108. package/src/components/timeline/shared.test.tsx +219 -0
  109. package/src/components/timeline/shared.tsx +232 -447
  110. package/src/components/timeline/timeline-filters.test.ts +196 -0
  111. package/src/components/timeline/timeline-filters.ts +180 -0
  112. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  113. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  114. package/src/components/timeline/timeline-live.test.ts +131 -0
  115. package/src/components/timeline/timeline-live.ts +77 -0
  116. package/src/components/topology/GroupNode.tsx +9 -2
  117. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  118. package/src/components/topology/K8sResourceNode.tsx +387 -243
  119. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  120. package/src/components/topology/TopologyControls.tsx +3 -1
  121. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  122. package/src/components/topology/TopologyGraph.tsx +149 -90
  123. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  124. package/src/components/topology/TopologySearch.tsx +25 -17
  125. package/src/components/topology/index.ts +2 -0
  126. package/src/components/topology/layout.ts +10 -2
  127. package/src/components/topology/topology.css +0 -92
  128. package/src/components/ui/Badge.tsx +6 -1
  129. package/src/components/ui/CodeViewer.tsx +2 -2
  130. package/src/components/ui/Collapse.tsx +59 -0
  131. package/src/components/ui/ConfirmDialog.tsx +7 -3
  132. package/src/components/ui/DialogPortal.tsx +31 -9
  133. package/src/components/ui/Input.tsx +24 -0
  134. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  135. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  136. package/src/components/ui/PaneLoader.tsx +10 -2
  137. package/src/components/ui/ResourceBar.tsx +68 -25
  138. package/src/components/ui/SearchBox.tsx +18 -4
  139. package/src/components/ui/SearchPillInput.tsx +2 -2
  140. package/src/components/ui/SelectMenu.tsx +81 -0
  141. package/src/components/ui/drawer-components.tsx +48 -32
  142. package/src/components/ui/index.ts +6 -0
  143. package/src/components/ui/severity-tone.ts +11 -4
  144. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  145. package/src/components/workload/WorkloadView.tsx +2706 -475
  146. package/src/components/workload/index.ts +1 -1
  147. package/src/theme/tailwind-theme.css +1 -0
  148. package/src/theme/variables.css +2 -1
  149. package/src/types/core.ts +57 -4
  150. package/src/types/gitops-insights.ts +69 -1
  151. package/src/types/gitops.ts +3 -3
  152. package/src/utils/application-history.test.ts +538 -0
  153. package/src/utils/application-history.ts +257 -0
  154. package/src/utils/application-topology.test.ts +198 -0
  155. package/src/utils/application-topology.ts +261 -0
  156. package/src/utils/applications.test.ts +201 -1
  157. package/src/utils/applications.ts +1182 -393
  158. package/src/utils/index.ts +3 -0
  159. package/src/utils/navigation.test.ts +32 -1
  160. package/src/utils/navigation.ts +70 -0
  161. package/src/utils/resource-hierarchy.test.ts +1024 -1
  162. package/src/utils/resource-hierarchy.ts +925 -267
  163. package/src/utils/resource-icons.ts +2 -0
  164. package/src/utils/topology-neighborhood.test.ts +202 -1
  165. package/src/utils/topology-neighborhood.ts +194 -13
  166. package/src/utils/workflow-execution.test.ts +556 -0
  167. package/src/utils/workflow-execution.ts +814 -0
  168. package/src/utils/workload-colors.ts +2 -3
@@ -0,0 +1,257 @@
1
+ import type { ResourceRef, TimelineEvent } from '../types/core'
2
+ import { getKindLabel } from './api-resources'
3
+ import type { AppHistory, AppHistoryAnchor, AppSourceRef } from './applications'
4
+ import { isProblematicEvent } from './resource-hierarchy'
5
+
6
+ export type ApplicationHistoryCategory = 'deployment' | 'change' | 'problem'
7
+ export type ApplicationHistoryRange = '24h' | '7d' | '30d' | 'all'
8
+
9
+ export interface ApplicationHistoryItem {
10
+ id: string
11
+ category: ApplicationHistoryCategory
12
+ title: string
13
+ timestamp: string
14
+ detail?: string
15
+ status?: string
16
+ revision?: string
17
+ initiatedBy?: string
18
+ count?: number
19
+ resource?: ResourceRef
20
+ sourceRef?: AppSourceRef
21
+ }
22
+
23
+ const ROOT_RUNTIME_KINDS = new Set([
24
+ 'Deployment',
25
+ 'DaemonSet',
26
+ 'StatefulSet',
27
+ 'Rollout',
28
+ 'Job',
29
+ 'CronJob',
30
+ 'Workflow',
31
+ 'CronWorkflow',
32
+ ])
33
+
34
+ const RELATED_RUNTIME_KINDS = new Set([
35
+ 'Service',
36
+ 'Ingress',
37
+ 'Gateway',
38
+ 'HTTPRoute',
39
+ 'GRPCRoute',
40
+ 'TCPRoute',
41
+ 'TLSRoute',
42
+ 'ConfigMap',
43
+ 'Secret',
44
+ 'SealedSecret',
45
+ 'HorizontalPodAutoscaler',
46
+ 'ScaledObject',
47
+ 'ScaledJob',
48
+ 'PersistentVolumeClaim',
49
+ 'PodDisruptionBudget',
50
+ 'NetworkPolicy',
51
+ 'ServiceAccount',
52
+ 'ServiceMonitor',
53
+ 'PodMonitor',
54
+ ])
55
+
56
+ const NORMAL_BATCH_LIFECYCLE_REASONS = new Set([
57
+ 'started',
58
+ 'created',
59
+ 'completed',
60
+ 'complete',
61
+ 'successcriteriamet',
62
+ ])
63
+
64
+ const CONFIG_DATA_KINDS = new Set(['ConfigMap', 'Secret', 'SealedSecret'])
65
+ const CONFIG_DATA_PATH = /^(?:spec\.)?(?:data|stringData|binaryData|encryptedData)(\W|$)/
66
+ const EPHEMERAL_RUN_KINDS = new Set(['Job', 'Workflow'])
67
+ const SOURCE_BURST_WINDOW_MS = 2 * 60 * 1000
68
+
69
+ const PROBLEM_TITLES: Record<string, string> = {
70
+ failedscheduling: "Can't be scheduled",
71
+ failedmount: "Volume can't be mounted",
72
+ failedattachvolume: "Volume can't be attached",
73
+ failedpull: 'Image pull failed',
74
+ errimagepull: 'Image pull failed',
75
+ imagepullbackoff: 'Image pull back-off',
76
+ unhealthy: 'Health check failed',
77
+ }
78
+
79
+ function validTimestamp(value: string | undefined): value is string {
80
+ return Boolean(value && !value.startsWith('0001-01-01T00:00:00') && Number.isFinite(new Date(value).getTime()))
81
+ }
82
+
83
+ function sourceMatchesEvent(source: AppSourceRef | undefined, event: TimelineEvent): boolean {
84
+ if (!source) return false
85
+ return source.kind.toLowerCase() === event.kind.toLowerCase()
86
+ && source.namespace === event.namespace
87
+ && source.name === event.name
88
+ }
89
+
90
+ function anchorItem(anchor: AppHistoryAnchor, sourceRef: AppSourceRef | undefined, index: number): ApplicationHistoryItem | null {
91
+ if (!validTimestamp(anchor.timestamp)) return null
92
+ return {
93
+ id: `source:${anchor.type}:${anchor.timestamp}:${anchor.revision ?? index}`,
94
+ category: 'deployment',
95
+ title: anchor.title,
96
+ timestamp: anchor.timestamp,
97
+ detail: anchor.source || anchor.message,
98
+ status: anchor.status,
99
+ revision: anchor.revision,
100
+ initiatedBy: anchor.initiatedBy,
101
+ sourceRef,
102
+ }
103
+ }
104
+
105
+ function eventTitle(event: TimelineEvent, problem: boolean): string {
106
+ const kind = getKindLabel(event.kind)
107
+ if (problem) {
108
+ const reason = event.reason?.toLowerCase()
109
+ if (reason === 'podscheduled' && event.message?.includes('nodes are available')) return "Can't be scheduled"
110
+ if (reason === 'failedcreate') {
111
+ if (event.kind === 'CronJob' || event.kind === 'CronWorkflow') return 'Scheduled run could not be created'
112
+ if (ROOT_RUNTIME_KINDS.has(event.kind)) return 'Pod could not be created'
113
+ return `${kind} could not create a resource`
114
+ }
115
+ if (EPHEMERAL_RUN_KINDS.has(event.kind)) {
116
+ if (reason === 'backofflimitexceeded') return `${kind} failed after repeated retries`
117
+ if (reason === 'deadlineexceeded') return `${kind} exceeded its deadline`
118
+ if (reason === 'failed') return `${kind} failed`
119
+ }
120
+ return PROBLEM_TITLES[reason ?? ''] ?? event.reason ?? `${kind} needs attention`
121
+ }
122
+ if (event.eventType === 'add') return `${kind} created`
123
+ if (event.eventType === 'delete') return `${kind} deleted`
124
+ if (event.reason && event.reason !== 'Update' && event.reason !== 'Updated') return event.reason
125
+ return `${kind} updated`
126
+ }
127
+
128
+ function eventDetail(event: TimelineEvent): string | undefined {
129
+ const message = event.message?.trim()
130
+ const summary = event.diff?.summary?.trim()
131
+ if (message && summary && message !== summary) return `${message} · ${summary}`
132
+ return message || summary || undefined
133
+ }
134
+
135
+ function hasDesiredStateChange(event: TimelineEvent): boolean {
136
+ return Boolean(event.diff?.fields.some(({ path }) => {
137
+ if (path === 'spec' || path.startsWith('spec.')) return true
138
+ if (!CONFIG_DATA_KINDS.has(event.kind)) return false
139
+ return path === 'immutable' || (event.kind === 'Secret' && path === 'type') || CONFIG_DATA_PATH.test(path)
140
+ }))
141
+ }
142
+
143
+ function hasConfigurationDataChange(event: TimelineEvent): boolean {
144
+ return CONFIG_DATA_KINDS.has(event.kind) && Boolean(event.diff?.fields.some(({ path }) =>
145
+ path === 'immutable' || CONFIG_DATA_PATH.test(path),
146
+ ))
147
+ }
148
+
149
+ function isEphemeralRun(event: TimelineEvent): boolean {
150
+ return EPHEMERAL_RUN_KINDS.has(event.kind) && Boolean(event.owner)
151
+ }
152
+
153
+ function historyEventItem(event: TimelineEvent): ApplicationHistoryItem | null {
154
+ if (!validTimestamp(event.timestamp)) return null
155
+ const unhealthy = event.healthState === 'degraded' || event.healthState === 'unhealthy'
156
+ const leafRuntimeResource = event.kind === 'Pod' || event.kind === 'ReplicaSet'
157
+ const reason = event.reason?.toLowerCase()
158
+ const normalBatchLifecycle = EPHEMERAL_RUN_KINDS.has(event.kind)
159
+ && event.eventType !== 'Warning'
160
+ && Boolean(reason && NORMAL_BATCH_LIFECYCLE_REASONS.has(reason))
161
+ const problem = !normalBatchLifecycle
162
+ && (isProblematicEvent(event) || (unhealthy && (!leafRuntimeResource || Boolean(event.message?.trim()))))
163
+ if (!problem && event.source === 'k8s_event') return null
164
+ if (event.kind === 'Pod' || event.kind === 'ReplicaSet') {
165
+ if (!problem) return null
166
+ } else if (!problem && !ROOT_RUNTIME_KINDS.has(event.kind) && !RELATED_RUNTIME_KINDS.has(event.kind)) {
167
+ return null
168
+ }
169
+ if (!problem && event.eventType === 'update' && !hasDesiredStateChange(event)) return null
170
+ if (!problem && (event.eventType === 'add' || event.eventType === 'delete') && isEphemeralRun(event)) return null
171
+ const resource = problem && event.owner
172
+ ? {
173
+ kind: event.owner.kind,
174
+ namespace: event.namespace,
175
+ name: event.owner.name,
176
+ }
177
+ : {
178
+ kind: event.kind,
179
+ namespace: event.namespace,
180
+ name: event.name,
181
+ group: event.apiVersion?.includes('/') ? event.apiVersion.split('/')[0] : undefined,
182
+ }
183
+ return {
184
+ id: `event:${event.id}`,
185
+ category: problem ? 'problem' : 'change',
186
+ title: eventTitle(event, problem),
187
+ timestamp: event.timestamp,
188
+ detail: eventDetail(event),
189
+ count: event.count,
190
+ resource,
191
+ }
192
+ }
193
+
194
+ function warningKey(item: ApplicationHistoryItem): string {
195
+ const resource = item.resource
196
+ return `${item.title}\u0000${resource?.kind ?? ''}\u0000${resource?.namespace ?? ''}\u0000${resource?.name ?? ''}\u0000${item.detail ?? ''}`
197
+ }
198
+
199
+ export function buildApplicationHistoryItems(
200
+ history: AppHistory | undefined,
201
+ events: TimelineEvent[],
202
+ ): ApplicationHistoryItem[] {
203
+ const anchorsByChange = new Map<string, AppHistoryAnchor>()
204
+ for (const anchor of history?.anchors ?? []) {
205
+ if (!validTimestamp(anchor.timestamp)) continue
206
+ const key = `${anchor.type}\u0000${anchor.title}\u0000${anchor.timestamp}\u0000${anchor.revision ?? ''}`
207
+ const existing = anchorsByChange.get(key)
208
+ if (!existing) {
209
+ anchorsByChange.set(key, anchor)
210
+ continue
211
+ }
212
+ anchorsByChange.set(key, {
213
+ ...existing,
214
+ status: anchor.status ?? existing.status,
215
+ source: existing.source ?? anchor.source,
216
+ message: anchor.message ?? existing.message,
217
+ initiatedBy: anchor.initiatedBy ?? existing.initiatedBy,
218
+ })
219
+ }
220
+ const anchors = [...anchorsByChange.values()]
221
+ .map((anchor, index) => anchorItem(anchor, history?.sourceRef, index))
222
+ .filter((item): item is ApplicationHistoryItem => item !== null)
223
+ const hasAnchors = anchors.length > 0
224
+ const anchorTimes = anchors.map(({ timestamp }) => new Date(timestamp).getTime())
225
+ const warningItems = new Map<string, ApplicationHistoryItem>()
226
+ const runtimeItems: ApplicationHistoryItem[] = []
227
+
228
+ for (const event of events) {
229
+ const item = historyEventItem(event)
230
+ if (!item) continue
231
+ if (item.category !== 'problem' && sourceMatchesEvent(history?.sourceRef, event)) continue
232
+ if (item.category !== 'problem') {
233
+ const eventTime = new Date(item.timestamp).getTime()
234
+ if (!hasConfigurationDataChange(event)
235
+ && hasAnchors
236
+ && anchorTimes.some((anchorTime) => Math.abs(eventTime - anchorTime) <= SOURCE_BURST_WINDOW_MS)) continue
237
+ runtimeItems.push(item)
238
+ continue
239
+ }
240
+ const key = warningKey(item)
241
+ const existing = warningItems.get(key)
242
+ if (!existing) {
243
+ warningItems.set(key, item)
244
+ continue
245
+ }
246
+ existing.count = Math.max(existing.count ?? 1, item.count ?? 1)
247
+ if (new Date(item.timestamp).getTime() > new Date(existing.timestamp).getTime()) {
248
+ existing.timestamp = item.timestamp
249
+ existing.id = item.id
250
+ }
251
+ }
252
+
253
+ return [...anchors, ...runtimeItems, ...warningItems.values()].sort((a, b) => {
254
+ const byTime = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
255
+ return byTime || a.id.localeCompare(b.id)
256
+ })
257
+ }
@@ -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
+ })