@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.
- package/package.json +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +101 -18
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +81 -51
- package/src/components/checks/severity.ts +39 -45
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +14 -59
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +219 -143
- package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
- package/src/components/issues/ResourceIssuesSection.tsx +28 -128
- package/src/components/issues/index.ts +3 -2
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +146 -4
- package/src/components/issues/severity.ts +29 -32
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
- package/src/components/resources/ResourcesSidebar.tsx +70 -52
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +243 -53
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +63 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +15 -1
- package/src/types/core.ts +57 -4
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
- 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
|
+
})
|