@skyhook-io/k8s-ui 1.8.11 → 1.8.13
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 +4 -4
- package/src/components/applications/ApplicationDetail.test.tsx +136 -8
- package/src/components/applications/ApplicationDetail.tsx +1215 -487
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
- package/src/components/gitops/GitOpsTableView.tsx +10 -3
- package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +189 -45
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +201 -21
- package/src/components/gitops/insights/index.ts +1 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +16 -0
- package/src/components/gitops/insights/insights-helpers.ts +5 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -0
- package/src/components/issues/IssuesView.tsx +55 -29
- package/src/components/issues/ResourceIssuesSection.test.tsx +33 -1
- package/src/components/issues/ResourceIssuesSection.tsx +21 -1
- package/src/components/issues/severity.ts +1 -0
- package/src/components/resources/ResourcesView.tsx +109 -19
- package/src/components/resources/column-filter-serialization.test.ts +79 -1
- package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
- package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
- package/src/components/resources/resource-utils-argo.test.ts +18 -1
- package/src/components/resources/resource-utils-argo.ts +8 -0
- package/src/components/resources/resource-utils.ts +94 -25
- package/src/components/shared/ResourceActionsBar.tsx +54 -22
- package/src/components/shared/UnifiedDiff.tsx +51 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +7 -3
- package/src/components/timeline/TimelineToolbarState.test.tsx +24 -0
- package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
- package/src/components/topology/TopologyControls.tsx +3 -1
- package/src/components/topology/TopologyGraph.tsx +76 -90
- package/src/components/topology/TopologyOverlayBar.tsx +30 -0
- package/src/components/topology/TopologySearch.tsx +23 -15
- package/src/components/topology/index.ts +2 -0
- package/src/components/ui/Badge.tsx +1 -1
- package/src/components/ui/SelectMenu.tsx +81 -0
- package/src/components/ui/Tooltip.test.tsx +21 -0
- package/src/components/ui/Tooltip.tsx +9 -12
- package/src/components/ui/drawer-components.tsx +9 -6
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +24 -3
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +1 -0
- package/src/types/gitops-insights.ts +69 -1
- package/src/types/gitops.ts +3 -3
- package/src/utils/api-resources.test.ts +36 -1
- package/src/utils/api-resources.ts +16 -0
- package/src/utils/application-history.test.ts +538 -0
- package/src/utils/application-history.ts +257 -0
- package/src/utils/applications.test.ts +34 -0
- package/src/utils/applications.ts +25 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/resource-hierarchy.test.ts +35 -1
- package/src/utils/resource-hierarchy.ts +20 -0
|
@@ -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
|
+
}
|
|
@@ -412,6 +412,40 @@ describe('buildAppMembershipIndex', () => {
|
|
|
412
412
|
expect(idx.byResource.has('Route/team-a/HTTPRoute/web-http')).toBe(false)
|
|
413
413
|
})
|
|
414
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
|
+
|
|
415
449
|
it('skips satellites for a multi-namespace app (can not place them unambiguously)', () => {
|
|
416
450
|
const idx = buildAppMembershipIndex([
|
|
417
451
|
row({
|
|
@@ -641,6 +641,31 @@ export function buildAppMembershipIndex(rows: AppRow[]): AppMembershipIndex {
|
|
|
641
641
|
setFirst(byResource, `${routeKind}/${ns}/${routeName}`, membership);
|
|
642
642
|
}
|
|
643
643
|
}
|
|
644
|
+
if (row.relationships) {
|
|
645
|
+
const explicitRefs = [
|
|
646
|
+
row.relationships.serviceRefs,
|
|
647
|
+
row.relationships.ingressRefs,
|
|
648
|
+
row.relationships.routeRefs,
|
|
649
|
+
row.relationships.configRefs,
|
|
650
|
+
row.relationships.scalerRefs,
|
|
651
|
+
row.relationships.storageRefs,
|
|
652
|
+
row.relationships.pdbRefs,
|
|
653
|
+
row.relationships.networkPolicyRefs,
|
|
654
|
+
];
|
|
655
|
+
for (const refs of explicitRefs) {
|
|
656
|
+
for (const ref of refs ?? []) {
|
|
657
|
+
if (ref.kind && ref.name)
|
|
658
|
+
setFirst(byResource, `${ref.kind}/${ref.namespace}/${ref.name}`, membership);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (row.sourceRef?.kind && row.sourceRef.name) {
|
|
663
|
+
setFirst(
|
|
664
|
+
byResource,
|
|
665
|
+
`${row.sourceRef.kind}/${row.sourceRef.namespace}/${row.sourceRef.name}`,
|
|
666
|
+
membership,
|
|
667
|
+
);
|
|
668
|
+
}
|
|
644
669
|
for (const mk of row.matchKeys || []) {
|
|
645
670
|
if (mk.startsWith("name-stem:")) continue;
|
|
646
671
|
setFirst(byEvidence, mk, membership);
|
package/src/utils/index.ts
CHANGED
|
@@ -23,5 +23,6 @@ export * from './git-provider-urls'
|
|
|
23
23
|
export * from './applications'
|
|
24
24
|
export * from './topology-neighborhood'
|
|
25
25
|
export * from './application-topology'
|
|
26
|
+
export * from './application-history'
|
|
26
27
|
export * from './bulk-workload-actions'
|
|
27
28
|
export * from './workflow-execution'
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|
|
2
2
|
|
|
3
3
|
import type { TimelineEvent, Topology } from '../types/core'
|
|
4
4
|
|
|
5
|
-
import { buildResourceHierarchy, extractPinnedLanes, removePinnedLanes, isPinnedLaneRef, laneTrackEvents, laneHasEventInWindow, isChildVisibleInWindow, subtreeEvents, getAllEventsFromHierarchy, collidingLaneKeys, laneCollisionKey, type ResourceLane } from './resource-hierarchy'
|
|
5
|
+
import { buildResourceHierarchy, eventsForApplication, extractPinnedLanes, removePinnedLanes, isPinnedLaneRef, laneTrackEvents, laneHasEventInWindow, isChildVisibleInWindow, subtreeEvents, getAllEventsFromHierarchy, collidingLaneKeys, laneCollisionKey, type ResourceLane } from './resource-hierarchy'
|
|
6
6
|
import type { AppMembershipIndex, AppMembership } from './applications'
|
|
7
7
|
|
|
8
8
|
function svcEvent(namespace: string, name: string): TimelineEvent {
|
|
@@ -274,6 +274,40 @@ describe('buildResourceHierarchy app-membership cascade (grouping=app)', () => {
|
|
|
274
274
|
})
|
|
275
275
|
})
|
|
276
276
|
|
|
277
|
+
describe('eventsForApplication', () => {
|
|
278
|
+
const billing: AppMembership = { appKey: 'team-a/app/billing', appName: 'billing' }
|
|
279
|
+
|
|
280
|
+
it('returns a selected singleton app subtree without the global two-member threshold', () => {
|
|
281
|
+
const deployment = changeEvent('Deployment', 'team-a', 'billing-api', { id: 'deployment' })
|
|
282
|
+
const pod = changeEvent('Pod', 'team-a', 'billing-api-abc12', {
|
|
283
|
+
id: 'pod',
|
|
284
|
+
owner: { kind: 'Deployment', name: 'billing-api' },
|
|
285
|
+
})
|
|
286
|
+
const unrelated = changeEvent('Deployment', 'team-a', 'other', { id: 'other' })
|
|
287
|
+
const appIndex = index({ 'Deployment/team-a/billing-api': billing })
|
|
288
|
+
|
|
289
|
+
const events = eventsForApplication([deployment, pod, unrelated], undefined, appIndex)
|
|
290
|
+
|
|
291
|
+
expect(events.map((event) => event.id).sort()).toEqual(['deployment', 'pod'])
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('includes a relationship root and its owned descendants', () => {
|
|
295
|
+
const service = changeEvent('Service', 'team-a', 'billing-api', { id: 'service' })
|
|
296
|
+
const deployment = changeEvent('Deployment', 'team-a', 'billing-api', { id: 'deployment' })
|
|
297
|
+
const topology = {
|
|
298
|
+
nodes: [
|
|
299
|
+
{ id: 'service/team-a/billing-api', data: {} },
|
|
300
|
+
{ id: 'deployment/team-a/billing-api', data: {} },
|
|
301
|
+
],
|
|
302
|
+
edges: [{ id: 'edge', source: 'service/team-a/billing-api', target: 'deployment/team-a/billing-api', type: 'exposes' }],
|
|
303
|
+
} as unknown as Topology
|
|
304
|
+
const appIndex = index({ 'Service/team-a/billing-api': billing })
|
|
305
|
+
|
|
306
|
+
expect(eventsForApplication([service, deployment], topology, appIndex).map((event) => event.id).sort())
|
|
307
|
+
.toEqual(['deployment', 'service'])
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
277
311
|
// --- kind-contract nesting (parent-driven naming) ---------------------------
|
|
278
312
|
//
|
|
279
313
|
// A resource whose only in-window events are ownerless (deletion-time cache miss)
|
|
@@ -1049,6 +1049,26 @@ export function subtreeEvents(lane: ResourceLane): TimelineEvent[] {
|
|
|
1049
1049
|
return sortEventsForRendering(unique)
|
|
1050
1050
|
}
|
|
1051
1051
|
|
|
1052
|
+
/** Return every event attributable to one selected application. The caller
|
|
1053
|
+
* supplies an index containing only that app, so singleton apps are retained
|
|
1054
|
+
* even though the global Timeline intentionally avoids one-member app groups. */
|
|
1055
|
+
export function eventsForApplication(
|
|
1056
|
+
events: TimelineEvent[],
|
|
1057
|
+
topology: Topology | undefined,
|
|
1058
|
+
appIndex: AppMembershipIndex,
|
|
1059
|
+
): TimelineEvent[] {
|
|
1060
|
+
const roots = buildResourceHierarchy({ events, topology, grouping: 'owner' })
|
|
1061
|
+
const matched = roots.filter((lane) => cascadeRootMembership(lane, appIndex) !== null)
|
|
1062
|
+
const unique = new Map<string, TimelineEvent>()
|
|
1063
|
+
for (const lane of matched) {
|
|
1064
|
+
for (const event of subtreeEvents(lane)) unique.set(event.id, event)
|
|
1065
|
+
}
|
|
1066
|
+
return [...unique.values()].sort((a, b) => {
|
|
1067
|
+
const byTime = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
1068
|
+
return byTime || (b.seq ?? 0) - (a.seq ?? 0) || a.id.localeCompare(b.id)
|
|
1069
|
+
})
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1052
1072
|
/** The events a lane paints on its OWN track row, given whether it's expanded.
|
|
1053
1073
|
* - collapsed parent → whole-subtree aggregate (the roll-up)
|
|
1054
1074
|
* - expanded parent, or a leaf → only the lane's own events
|