@skyhook-io/k8s-ui 1.8.9 → 1.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. package/tsconfig.json +2 -2
@@ -0,0 +1,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,87 @@ 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('skips satellites for a multi-namespace app (can not place them unambiguously)', () => {
416
+ const idx = buildAppMembershipIndex([
417
+ row({
418
+ key: 'a', name: 'span', namespaces: ['team-a', 'team-b'],
419
+ workloads: [{ kind: 'Deployment', namespace: 'team-a', name: 'api', health: 'healthy', ready: 1, desired: 1, restarts: 0 }],
420
+ relationships: { services: ['svc'] },
421
+ }),
422
+ ])
423
+ expect(idx.byResource.has('Deployment/team-a/api')).toBe(true)
424
+ expect([...idx.byResource.keys()].some((k) => k.startsWith('Service/'))).toBe(false)
425
+ })
426
+
427
+ it('indexes matchKeys by evidence but excludes name-stem (exact kinds only in v1)', () => {
428
+ // matchKeys are namespace-scoped (kind:namespace:value); name-stem stays unscoped.
429
+ const idx = buildAppMembershipIndex([
430
+ 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'] }),
431
+ ])
432
+ expect(idx.byEvidence.get('instance:team-a:billing-prod')?.appName).toBe('billing')
433
+ expect(idx.byEvidence.get('helm:team-a:billing-v2')?.appName).toBe('billing')
434
+ expect(idx.byEvidence.has('name-stem:billing')).toBe(false)
435
+ })
436
+
437
+ it('carries env + evidence onto the membership', () => {
438
+ const idx = buildAppMembershipIndex([
439
+ row({ key: 'a', name: 'billing', identity: { key: 'billing', env: 'prod', confidence: 'high', evidence: 'name stem billing' }, matchKeys: ['name:team-a:billing'] }),
440
+ ])
441
+ const m = idx.byEvidence.get('name:team-a:billing')
442
+ expect(m?.env).toBe('prod')
443
+ expect(m?.evidence).toBe('name stem billing')
444
+ })
445
+
446
+ it('collision resolves first-wins (server-sorted row order)', () => {
447
+ const idx = buildAppMembershipIndex([
448
+ row({ key: 'first', name: 'first', matchKeys: ['name:team-a:shared'] }),
449
+ row({ key: 'second', name: 'second', matchKeys: ['name:team-a:shared'] }),
450
+ ])
451
+ expect(idx.byEvidence.get('name:team-a:shared')?.appName).toBe('first')
452
+ })
453
+
454
+ it('same label value in two namespaces indexes as distinct evidence keys', () => {
455
+ // The whole point of namespace scoping: "shared" in team-a and team-b must
456
+ // NOT collapse to one key — each namespace maps to its own app.
457
+ const idx = buildAppMembershipIndex([
458
+ row({ key: 'a', name: 'app-a', matchKeys: ['instance:team-a:shared'] }),
459
+ row({ key: 'b', name: 'app-b', matchKeys: ['instance:team-b:shared'] }),
460
+ ])
461
+ expect(idx.byEvidence.get('instance:team-a:shared')?.appName).toBe('app-a')
462
+ expect(idx.byEvidence.get('instance:team-b:shared')?.appName).toBe('app-b')
463
+ })
464
+ })