@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,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
+ }
@@ -1,5 +1,47 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { compareVersions, appGroupingExplainer, APP_IDENTITY_ANNOTATION, appGroupLagMessage, matchWorkloadAcrossInstances, foldAppGroups, identityEnvInferred, worstHealth, type AppGroupFoldEntry } from './applications'
2
+ import { compareVersions, appGroupingExplainer, APP_IDENTITY_ANNOTATION, appGroupLagMessage, matchWorkloadAcrossInstances, foldAppGroups, identityEnvInferred, worstHealth, buildAppMembershipIndex, batchActivityForApp, batchRuntimeForApp, servingReadiness, type AppGroupFoldEntry, type AppRow, type AppWorkload } from './applications'
3
+
4
+ describe('batch application runtime', () => {
5
+ it('uses the latest retained outcome instead of historical failure count', () => {
6
+ const app: AppRow = {
7
+ key: 'batch', name: 'batch', health: 'unhealthy', workload_class: 'job',
8
+ workloads: [{
9
+ kind: 'CronJob', namespace: 'demo', name: 'nightly', workload_class: 'job', health: 'unhealthy', ready: 0, desired: 0, restarts: 0,
10
+ batch: { retainedRuns: 4, failedRuns: 1, succeededRuns: 3, latestRunName: 'nightly-new', latestRunPhase: 'Succeeded', latestFinishedAt: '2026-07-10T00:00:00Z' },
11
+ }],
12
+ }
13
+ expect(batchRuntimeForApp(app)).toMatchObject({ label: 'Succeeded', health: 'healthy' })
14
+ })
15
+
16
+ it('does not let one workload success mask another workload latest failure', () => {
17
+ const app: AppRow = {
18
+ key: 'batch', name: 'batch', health: 'healthy', workload_class: 'job',
19
+ workloads: [
20
+ {
21
+ kind: 'CronJob', namespace: 'demo', name: 'nightly', workload_class: 'job', health: 'healthy', ready: 0, desired: 0, restarts: 0,
22
+ batch: { retainedRuns: 1, succeededRuns: 1, latestRunName: 'nightly-new', latestRunPhase: 'Succeeded', latestFinishedAt: '2026-07-10T01:00:00Z' },
23
+ },
24
+ {
25
+ kind: 'CronWorkflow', namespace: 'demo', name: 'sync', workload_class: 'job', health: 'unhealthy', ready: 0, desired: 0, restarts: 0,
26
+ batch: { retainedRuns: 1, failedRuns: 1, latestRunName: 'sync-old', latestRunPhase: 'Failed', latestFinishedAt: '2026-07-10T00:00:00Z' },
27
+ },
28
+ ],
29
+ }
30
+
31
+ expect(batchRuntimeForApp(app)).toEqual({
32
+ label: 'Failed',
33
+ health: 'unhealthy',
34
+ detail: 'CronWorkflow/sync latest retained run sync-old failed.',
35
+ })
36
+ })
37
+
38
+ it('excludes batch workloads from serving readiness', () => {
39
+ expect(servingReadiness([
40
+ { kind: 'Deployment', namespace: 'demo', name: 'api', workload_class: 'service', health: 'healthy', ready: 2, desired: 3, restarts: 0 },
41
+ { kind: 'Job', namespace: 'demo', name: 'run', workload_class: 'job', health: 'healthy', ready: 1, desired: 1, restarts: 0, batch: { retainedRuns: 1 } },
42
+ ])).toEqual({ ready: 2, desired: 3 })
43
+ })
44
+ })
3
45
 
4
46
  describe('compareVersions', () => {
5
47
  it('orders semver', () => {
@@ -87,6 +129,46 @@ describe('identityEnvInferred', () => {
87
129
  })
88
130
  })
89
131
 
132
+ describe('batchActivityForApp', () => {
133
+ const workload = (kind: string, name: string, batch?: AppWorkload['batch']): AppWorkload => ({
134
+ kind,
135
+ namespace: 'prod',
136
+ name,
137
+ health: 'neutral',
138
+ ready: 0,
139
+ desired: 0,
140
+ restarts: 0,
141
+ workload_class: kind === 'Deployment' ? 'service' : 'job',
142
+ batch,
143
+ })
144
+ const app = (workloads: AppWorkload[]): AppRow => ({
145
+ key: 'billing',
146
+ name: 'billing',
147
+ health: 'neutral',
148
+ workloads,
149
+ })
150
+
151
+ it('ranks failed, active, suspended, and quiet batch workloads without inventing retained history', () => {
152
+ const activity = batchActivityForApp(app([
153
+ workload('Deployment', 'api'),
154
+ workload('CronJob', 'nightly-success', { retainedRuns: 2, latestRunPhase: 'Succeeded', latestRunName: 'nightly-success-1' }),
155
+ workload('CronWorkflow', 'hourly-suspended', { suspended: true, retainedRuns: 0, schedule: '0 * * * *' }),
156
+ workload('Job', 'active-reindex', { activeRuns: 1, retainedRuns: 1, latestRunPhase: 'Running' }),
157
+ workload('Workflow', 'failed-migration', { failedRuns: 1, retainedRuns: 1, latestRunPhase: 'Failed', latestRunName: 'failed-migration', message: 'pod crashed' }),
158
+ ]))
159
+
160
+ expect(activity.map((item) => item.workload.name)).toEqual([
161
+ 'failed-migration',
162
+ 'active-reindex',
163
+ 'hourly-suspended',
164
+ 'nightly-success',
165
+ ])
166
+ expect(activity[0]).toMatchObject({ tone: 'rose', label: 'Latest run failed', failedRuns: 1, detail: 'pod crashed' })
167
+ expect(activity[1]).toMatchObject({ tone: 'sky', label: '1 active run', activeRuns: 1 })
168
+ expect(activity[3]).toMatchObject({ tone: 'muted', label: 'Latest run succeeded', retainedRuns: 2 })
169
+ })
170
+ })
171
+
90
172
  // Position-preserving env switch: exact match, stem fallback (suffix, prefix,
91
173
  // discovered tokens), and the explicit no-counterpart null.
92
174
  describe('matchWorkloadAcrossInstances', () => {
@@ -296,3 +378,121 @@ describe('foldAppGroups pathKey disambiguation', () => {
296
378
  expect(rows.filter((r) => r.kind === 'instance').length).toBe(2)
297
379
  })
298
380
  })
381
+
382
+ describe('buildAppMembershipIndex', () => {
383
+ // Minimal AppRow builder — only the fields the index reads.
384
+ const row = (over: Partial<AppRow>): AppRow => ({
385
+ key: 'k', name: 'app', health: 'healthy', workloads: [], ...over,
386
+ })
387
+
388
+ it('indexes workloads by Kind/ns/name and satellites by the app namespace', () => {
389
+ const idx = buildAppMembershipIndex([
390
+ row({
391
+ key: 'team-a/app/billing', name: 'billing', namespace: 'team-a',
392
+ workloads: [{ kind: 'Deployment', namespace: 'team-a', name: 'billing-api', health: 'healthy', ready: 1, desired: 1, restarts: 0 }],
393
+ relationships: { services: ['billing-svc'], ingresses: ['billing-ing'] },
394
+ }),
395
+ ])
396
+ expect(idx.byResource.get('Deployment/team-a/billing-api')?.appName).toBe('billing')
397
+ expect(idx.byResource.get('Service/team-a/billing-svc')?.appName).toBe('billing')
398
+ expect(idx.byResource.get('Ingress/team-a/billing-ing')?.appName).toBe('billing')
399
+ })
400
+
401
+ it('indexes routes under their concrete kind, not a generic "Route"', () => {
402
+ // The server ships "Kind/name" for polymorphic routes; the index must key on
403
+ // the concrete kind so it matches the route lane id (HTTPRoute/…/name).
404
+ const idx = buildAppMembershipIndex([
405
+ row({
406
+ key: 'team-a/app/web', name: 'web', namespace: 'team-a',
407
+ relationships: { routes: ['HTTPRoute/web-http', 'GRPCRoute/web-grpc'] },
408
+ }),
409
+ ])
410
+ expect(idx.byResource.get('HTTPRoute/team-a/web-http')?.appName).toBe('web')
411
+ expect(idx.byResource.get('GRPCRoute/team-a/web-grpc')?.appName).toBe('web')
412
+ expect(idx.byResource.has('Route/team-a/HTTPRoute/web-http')).toBe(false)
413
+ })
414
+
415
+ it('indexes explicit relationship refs across namespaces', () => {
416
+ const idx = buildAppMembershipIndex([
417
+ row({
418
+ key: 'team-a/app/billing', name: 'billing', namespaces: ['team-a', 'shared'],
419
+ relationships: {
420
+ serviceRefs: [{ kind: 'Service', namespace: 'team-a', name: 'billing-api' }],
421
+ configRefs: [{ kind: 'ConfigMap', namespace: 'shared', name: 'billing-config' }],
422
+ storageRefs: [{ kind: 'PersistentVolumeClaim', namespace: 'team-a', name: 'billing-data' }],
423
+ },
424
+ }),
425
+ ])
426
+
427
+ expect(idx.byResource.get('Service/team-a/billing-api')?.appName).toBe('billing')
428
+ expect(idx.byResource.get('ConfigMap/shared/billing-config')?.appName).toBe('billing')
429
+ expect(idx.byResource.get('PersistentVolumeClaim/team-a/billing-data')?.appName).toBe('billing')
430
+ })
431
+
432
+ it('indexes the exact deployment source object', () => {
433
+ const idx = buildAppMembershipIndex([
434
+ row({
435
+ key: 'team-a/app/billing', name: 'billing',
436
+ sourceRef: {
437
+ type: 'gitops',
438
+ tool: 'argocd',
439
+ kind: 'Application',
440
+ namespace: 'argocd',
441
+ name: 'billing',
442
+ },
443
+ }),
444
+ ])
445
+
446
+ expect(idx.byResource.get('Application/argocd/billing')?.appName).toBe('billing')
447
+ })
448
+
449
+ it('skips satellites for a multi-namespace app (can not place them unambiguously)', () => {
450
+ const idx = buildAppMembershipIndex([
451
+ row({
452
+ key: 'a', name: 'span', namespaces: ['team-a', 'team-b'],
453
+ workloads: [{ kind: 'Deployment', namespace: 'team-a', name: 'api', health: 'healthy', ready: 1, desired: 1, restarts: 0 }],
454
+ relationships: { services: ['svc'] },
455
+ }),
456
+ ])
457
+ expect(idx.byResource.has('Deployment/team-a/api')).toBe(true)
458
+ expect([...idx.byResource.keys()].some((k) => k.startsWith('Service/'))).toBe(false)
459
+ })
460
+
461
+ it('indexes matchKeys by evidence but excludes name-stem (exact kinds only in v1)', () => {
462
+ // matchKeys are namespace-scoped (kind:namespace:value); name-stem stays unscoped.
463
+ const idx = buildAppMembershipIndex([
464
+ row({ key: 'a', name: 'billing', identity: { key: 'billing', env: 'prod', confidence: 'high', evidence: 'x' }, matchKeys: ['instance:team-a:billing-prod', 'helm:team-a:billing-v2', 'name-stem:billing'] }),
465
+ ])
466
+ expect(idx.byEvidence.get('instance:team-a:billing-prod')?.appName).toBe('billing')
467
+ expect(idx.byEvidence.get('helm:team-a:billing-v2')?.appName).toBe('billing')
468
+ expect(idx.byEvidence.has('name-stem:billing')).toBe(false)
469
+ })
470
+
471
+ it('carries env + evidence onto the membership', () => {
472
+ const idx = buildAppMembershipIndex([
473
+ row({ key: 'a', name: 'billing', identity: { key: 'billing', env: 'prod', confidence: 'high', evidence: 'name stem billing' }, matchKeys: ['name:team-a:billing'] }),
474
+ ])
475
+ const m = idx.byEvidence.get('name:team-a:billing')
476
+ expect(m?.env).toBe('prod')
477
+ expect(m?.evidence).toBe('name stem billing')
478
+ })
479
+
480
+ it('collision resolves first-wins (server-sorted row order)', () => {
481
+ const idx = buildAppMembershipIndex([
482
+ row({ key: 'first', name: 'first', matchKeys: ['name:team-a:shared'] }),
483
+ row({ key: 'second', name: 'second', matchKeys: ['name:team-a:shared'] }),
484
+ ])
485
+ expect(idx.byEvidence.get('name:team-a:shared')?.appName).toBe('first')
486
+ })
487
+
488
+ it('same label value in two namespaces indexes as distinct evidence keys', () => {
489
+ // The whole point of namespace scoping: "shared" in team-a and team-b must
490
+ // NOT collapse to one key — each namespace maps to its own app.
491
+ const idx = buildAppMembershipIndex([
492
+ row({ key: 'a', name: 'app-a', matchKeys: ['instance:team-a:shared'] }),
493
+ row({ key: 'b', name: 'app-b', matchKeys: ['instance:team-b:shared'] }),
494
+ ])
495
+ expect(idx.byEvidence.get('instance:team-a:shared')?.appName).toBe('app-a')
496
+ expect(idx.byEvidence.get('instance:team-b:shared')?.appName).toBe('app-b')
497
+ })
498
+ })