@skyhook-io/k8s-ui 1.8.11 → 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.
- 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/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/shared/ResourceActionsBar.tsx +2 -3
- 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/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/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
|
@@ -32,7 +32,13 @@ export type GitOpsCategory =
|
|
|
32
32
|
| 'Suspended'
|
|
33
33
|
| 'Unknown'
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
// How a Drift's field entries were computed. `lastAppliedAnnotation` is the
|
|
36
|
+
// built-in path (kubectl.kubernetes.io/last-applied-configuration); `argocd-api`
|
|
37
|
+
// means the diff came from a connected Argo CD server's managed-resource diff.
|
|
38
|
+
// The `(string & {})` arm keeps the union open: a library consumer may lag the
|
|
39
|
+
// backend and receive a source it doesn't recognize — such values MUST render
|
|
40
|
+
// with the neutral fallback (no source label), never crash a switch.
|
|
41
|
+
export type GitOpsDriftSource = 'lastAppliedAnnotation' | 'argocd-api' | (string & {})
|
|
36
42
|
|
|
37
43
|
export interface GitOpsInsightSummary {
|
|
38
44
|
tool: GitOpsTool
|
|
@@ -66,6 +72,21 @@ export interface GitOpsInsightSummary {
|
|
|
66
72
|
// Finalizers blocking deletion. When stuck, naming the finalizer points
|
|
67
73
|
// the user at the controller they need to investigate.
|
|
68
74
|
finalizers?: string[]
|
|
75
|
+
// Argo comparison-coverage disclosure: the field exclusions declared in
|
|
76
|
+
// spec.ignoreDifferences that suppress drift from comparison (both Argo's
|
|
77
|
+
// and Radar's). Undefined for Flux roots and Applications without any
|
|
78
|
+
// exclusions. unsupportedRuleCount counts entries Radar does NOT evaluate
|
|
79
|
+
// (jqPathExpressions / managedFieldsManagers rules) — the
|
|
80
|
+
// drift panel may surface fields Argo's own UI suppresses.
|
|
81
|
+
ignoredDifferences?: GitOpsIgnoredDifferences
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface GitOpsIgnoredDifferences {
|
|
85
|
+
ruleCount: number
|
|
86
|
+
unsupportedRuleCount: number
|
|
87
|
+
// Sorted unique "Group/Kind" targets ("Kind" for core resources,
|
|
88
|
+
// "group/*" for a group-wide rule that omits kind).
|
|
89
|
+
kinds: string[]
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
export interface GitOpsInsightRef {
|
|
@@ -192,6 +213,53 @@ export interface GitOpsCapabilities {
|
|
|
192
213
|
syncWithSource: boolean
|
|
193
214
|
selectiveSync: boolean
|
|
194
215
|
rollback: boolean
|
|
216
|
+
// True on an Argo CD Application detail when the Argo CD integration is
|
|
217
|
+
// connected, meaning the full Git-rendered desired-vs-live diff endpoint
|
|
218
|
+
// (/api/argo/applications/{ns}/{name}/resource-diff) can be called for this
|
|
219
|
+
// app's managed resources. Absent/false → offer only the last-applied field
|
|
220
|
+
// diff and (for disconnected Argo apps) the "connect Argo CD" hint.
|
|
221
|
+
argoDiffAvailable?: boolean
|
|
222
|
+
// True when the Argo CD integration has settings saved, even if the live
|
|
223
|
+
// connection is down / the token is rejected. Paired with argoDiffAvailable to
|
|
224
|
+
// tell "not set up" (offer Connect) from "set up but disconnected" (offer
|
|
225
|
+
// Reconnect) — instead of advertising a diff that would fail.
|
|
226
|
+
argoConfigured?: boolean
|
|
227
|
+
// True on an Argo CD Application detail when the integration is connected,
|
|
228
|
+
// meaning the revision-metadata endpoint can resolve Git commit details
|
|
229
|
+
// (author, message, signature) for this app's deployed revisions.
|
|
230
|
+
revisionMetadataAvailable?: boolean
|
|
195
231
|
unsupportedReason?: string
|
|
196
232
|
warnings?: string[]
|
|
197
233
|
}
|
|
234
|
+
|
|
235
|
+
// Response of GET /api/argo/applications/{ns}/{name}/revision-metadata — the Git
|
|
236
|
+
// commit metadata for one deployed revision. Every field is best-effort (varies
|
|
237
|
+
// across Argo CD versions); signatureInfo non-empty means a signature was checked.
|
|
238
|
+
export interface ArgoRevisionMetadata {
|
|
239
|
+
author?: string
|
|
240
|
+
date?: string
|
|
241
|
+
tags?: string[]
|
|
242
|
+
message?: string
|
|
243
|
+
signatureInfo?: string
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Response of GET /api/argo/applications/{ns}/{name}/resource-diff. `desired`
|
|
247
|
+
// is the Git-rendered manifest, `live` the normalized cluster state — both YAML
|
|
248
|
+
// for the line-by-line view. `fieldEntries` is the same diff structured per
|
|
249
|
+
// path for a compact summary. `redacted` is set when Secret values were masked
|
|
250
|
+
// server-side; `hook` marks Argo sync-hook resources.
|
|
251
|
+
export interface GitOpsResourceDiff {
|
|
252
|
+
source: string
|
|
253
|
+
desired: string
|
|
254
|
+
live: string
|
|
255
|
+
fieldEntries: GitOpsResourceDiffFieldEntry[]
|
|
256
|
+
redacted: boolean
|
|
257
|
+
hook: boolean
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface GitOpsResourceDiffFieldEntry {
|
|
261
|
+
path: string
|
|
262
|
+
op: 'added' | 'removed' | 'changed'
|
|
263
|
+
desired?: string
|
|
264
|
+
live?: string
|
|
265
|
+
}
|
package/src/types/gitops.ts
CHANGED
|
@@ -254,7 +254,7 @@ export interface ArgoAppStatus {
|
|
|
254
254
|
* ArgoCD status patterns:
|
|
255
255
|
* - sync.status: Synced, OutOfSync, Unknown
|
|
256
256
|
* - health.status: Healthy, Progressing, Degraded, Suspended, Missing, Unknown
|
|
257
|
-
* - operationState.phase: Running, Succeeded, Failed, Error
|
|
257
|
+
* - operationState.phase: Running, Terminating, Succeeded, Failed, Error
|
|
258
258
|
*/
|
|
259
259
|
export function argoStatusToGitOpsStatus(status: ArgoAppStatus): GitOpsStatus {
|
|
260
260
|
const syncStatus = status.sync?.status
|
|
@@ -267,7 +267,7 @@ export function argoStatusToGitOpsStatus(status: ArgoAppStatus): GitOpsStatus {
|
|
|
267
267
|
sync = 'Synced'
|
|
268
268
|
} else if (syncStatus === 'OutOfSync') {
|
|
269
269
|
sync = 'OutOfSync'
|
|
270
|
-
} else if (opPhase === 'Running') {
|
|
270
|
+
} else if (opPhase === 'Running' || opPhase === 'Terminating') {
|
|
271
271
|
sync = 'Reconciling'
|
|
272
272
|
}
|
|
273
273
|
|
|
@@ -288,7 +288,7 @@ export function argoStatusToGitOpsStatus(status: ArgoAppStatus): GitOpsStatus {
|
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
// Override with operation state if active
|
|
291
|
-
if (opPhase === 'Running') {
|
|
291
|
+
if (opPhase === 'Running' || opPhase === 'Terminating') {
|
|
292
292
|
health = 'Progressing'
|
|
293
293
|
} else if (opPhase === 'Failed' || opPhase === 'Error') {
|
|
294
294
|
health = 'Degraded'
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { TimelineEvent } from '../types/core'
|
|
3
|
+
import type { AppHistory, AppRow } from './applications'
|
|
4
|
+
import { buildAppMembershipIndex } from './applications'
|
|
5
|
+
import { buildApplicationHistoryItems } from './application-history'
|
|
6
|
+
|
|
7
|
+
const app: AppRow = {
|
|
8
|
+
key: 'Application/shop',
|
|
9
|
+
name: 'shop',
|
|
10
|
+
namespace: 'prod',
|
|
11
|
+
health: 'healthy',
|
|
12
|
+
sourceRef: {
|
|
13
|
+
type: 'gitops',
|
|
14
|
+
tool: 'argocd',
|
|
15
|
+
kind: 'Application',
|
|
16
|
+
namespace: 'argocd',
|
|
17
|
+
name: 'shop',
|
|
18
|
+
},
|
|
19
|
+
workloads: [{
|
|
20
|
+
kind: 'Deployment',
|
|
21
|
+
namespace: 'prod',
|
|
22
|
+
name: 'shop-api',
|
|
23
|
+
health: 'healthy',
|
|
24
|
+
ready: 2,
|
|
25
|
+
desired: 2,
|
|
26
|
+
restarts: 0,
|
|
27
|
+
}],
|
|
28
|
+
relationships: {
|
|
29
|
+
serviceRefs: [{ kind: 'Service', namespace: 'prod', name: 'shop-api' }],
|
|
30
|
+
configRefs: [{ kind: 'ConfigMap', namespace: 'prod', name: 'shop-config' }],
|
|
31
|
+
storageRefs: [{ kind: 'PersistentVolumeClaim', namespace: 'prod', name: 'shop-data' }],
|
|
32
|
+
},
|
|
33
|
+
matchKeys: ['instance:prod:shop', 'name-stem:shop'],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function event(overrides: Partial<TimelineEvent> = {}): TimelineEvent {
|
|
37
|
+
return {
|
|
38
|
+
id: 'deployment-update',
|
|
39
|
+
timestamp: '2026-07-13T09:30:00.000Z',
|
|
40
|
+
source: 'informer',
|
|
41
|
+
kind: 'Deployment',
|
|
42
|
+
namespace: 'prod',
|
|
43
|
+
name: 'shop-api',
|
|
44
|
+
eventType: 'update',
|
|
45
|
+
diff: {
|
|
46
|
+
summary: 'Image changed to shop:v2',
|
|
47
|
+
fields: [{ path: 'spec.template.spec.containers[0].image', oldValue: 'shop:v1', newValue: 'shop:v2' }],
|
|
48
|
+
},
|
|
49
|
+
...overrides,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('application history membership', () => {
|
|
54
|
+
it('indexes workloads, concrete relationships, and exact evidence for one app', () => {
|
|
55
|
+
const index = buildAppMembershipIndex([app])
|
|
56
|
+
|
|
57
|
+
expect([...index.byResource.keys()].sort()).toEqual([
|
|
58
|
+
'Application/argocd/shop',
|
|
59
|
+
'ConfigMap/prod/shop-config',
|
|
60
|
+
'Deployment/prod/shop-api',
|
|
61
|
+
'PersistentVolumeClaim/prod/shop-data',
|
|
62
|
+
'Service/prod/shop-api',
|
|
63
|
+
])
|
|
64
|
+
expect(index.byEvidence.has('instance:prod:shop')).toBe(true)
|
|
65
|
+
expect(index.byEvidence.has('name-stem:shop')).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('buildApplicationHistoryItems', () => {
|
|
70
|
+
const history: AppHistory = {
|
|
71
|
+
appKey: app.key,
|
|
72
|
+
sourceRef: {
|
|
73
|
+
type: 'gitops',
|
|
74
|
+
tool: 'argocd',
|
|
75
|
+
kind: 'Application',
|
|
76
|
+
namespace: 'argocd',
|
|
77
|
+
name: 'shop',
|
|
78
|
+
},
|
|
79
|
+
anchors: [{
|
|
80
|
+
type: 'gitops',
|
|
81
|
+
title: 'Argo CD sync',
|
|
82
|
+
revision: 'abc123',
|
|
83
|
+
status: 'Succeeded',
|
|
84
|
+
timestamp: '2026-07-13T10:00:00.000Z',
|
|
85
|
+
}],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
it('merges source anchors with curated desired-state changes in reverse chronology', () => {
|
|
89
|
+
const items = buildApplicationHistoryItems(history, [
|
|
90
|
+
event(),
|
|
91
|
+
event({
|
|
92
|
+
id: 'pod-ready',
|
|
93
|
+
kind: 'Pod',
|
|
94
|
+
name: 'shop-api-abc12',
|
|
95
|
+
source: 'k8s_event',
|
|
96
|
+
eventType: 'Normal',
|
|
97
|
+
reason: 'Ready',
|
|
98
|
+
diff: undefined,
|
|
99
|
+
}),
|
|
100
|
+
event({
|
|
101
|
+
id: 'config-update',
|
|
102
|
+
kind: 'ConfigMap',
|
|
103
|
+
name: 'shop-config',
|
|
104
|
+
timestamp: '2026-07-13T09:45:00.000Z',
|
|
105
|
+
diff: {
|
|
106
|
+
summary: 'data.API_URL changed',
|
|
107
|
+
fields: [{ path: 'data.API_URL', oldValue: 'https://old.example', newValue: 'https://new.example' }],
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
])
|
|
111
|
+
|
|
112
|
+
expect(items.map((item) => item.title)).toEqual([
|
|
113
|
+
'Argo CD sync',
|
|
114
|
+
'Config Map updated',
|
|
115
|
+
'Deployment updated',
|
|
116
|
+
])
|
|
117
|
+
expect(items.map((item) => item.category)).toEqual(['deployment', 'change', 'change'])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('coalesces source metadata and operation status for the same deployment change', () => {
|
|
121
|
+
const items = buildApplicationHistoryItems({
|
|
122
|
+
...history,
|
|
123
|
+
anchors: [
|
|
124
|
+
{
|
|
125
|
+
type: 'gitops',
|
|
126
|
+
title: 'Argo CD sync',
|
|
127
|
+
revision: 'abc123',
|
|
128
|
+
source: 'https://github.com/example/shop · deploy/prod',
|
|
129
|
+
initiatedBy: 'automated',
|
|
130
|
+
timestamp: '2026-07-13T10:00:00.000Z',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
type: 'gitops',
|
|
134
|
+
title: 'Argo CD sync',
|
|
135
|
+
revision: 'abc123',
|
|
136
|
+
status: 'Succeeded',
|
|
137
|
+
message: 'successfully synced (all tasks run)',
|
|
138
|
+
initiatedBy: 'automated',
|
|
139
|
+
timestamp: '2026-07-13T10:00:00.000Z',
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
}, [])
|
|
143
|
+
|
|
144
|
+
expect(items).toHaveLength(1)
|
|
145
|
+
expect(items[0]).toMatchObject({
|
|
146
|
+
title: 'Argo CD sync',
|
|
147
|
+
revision: 'abc123',
|
|
148
|
+
status: 'Succeeded',
|
|
149
|
+
detail: 'https://github.com/example/shop · deploy/prod',
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('omits normal batch run lifecycle activity', () => {
|
|
154
|
+
const items = buildApplicationHistoryItems(undefined, [
|
|
155
|
+
event({
|
|
156
|
+
id: 'job-started',
|
|
157
|
+
kind: 'Job',
|
|
158
|
+
name: 'shop-migrate',
|
|
159
|
+
source: 'historical',
|
|
160
|
+
eventType: 'update',
|
|
161
|
+
reason: 'started',
|
|
162
|
+
healthState: 'degraded',
|
|
163
|
+
diff: undefined,
|
|
164
|
+
}),
|
|
165
|
+
])
|
|
166
|
+
|
|
167
|
+
expect(items).toHaveLength(0)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('omits status-only updates while keeping spec and configuration changes', () => {
|
|
171
|
+
const items = buildApplicationHistoryItems(undefined, [
|
|
172
|
+
event({
|
|
173
|
+
id: 'status-only',
|
|
174
|
+
diff: {
|
|
175
|
+
summary: 'readyReplicas changed from 1 to 2',
|
|
176
|
+
fields: [{ path: 'status.readyReplicas', oldValue: 1, newValue: 2 }],
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
event({
|
|
180
|
+
id: 'job-suspended',
|
|
181
|
+
kind: 'Job',
|
|
182
|
+
name: 'shop-migrate',
|
|
183
|
+
diff: {
|
|
184
|
+
summary: 'suspend changed from false to true',
|
|
185
|
+
fields: [{ path: 'spec.suspend', oldValue: false, newValue: true }],
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
event({
|
|
189
|
+
id: 'config-updated',
|
|
190
|
+
kind: 'ConfigMap',
|
|
191
|
+
name: 'shop-config',
|
|
192
|
+
diff: {
|
|
193
|
+
summary: 'Modified keys: FEATURE_FLAG',
|
|
194
|
+
fields: [{ path: 'data (modified keys)', oldValue: 'off', newValue: 'on' }],
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
event({
|
|
198
|
+
id: 'config-key-added',
|
|
199
|
+
kind: 'ConfigMap',
|
|
200
|
+
name: 'shop-config',
|
|
201
|
+
diff: {
|
|
202
|
+
summary: 'Added keys: API_URL',
|
|
203
|
+
fields: [{ path: 'data (added keys)', newValue: 'API_URL' }],
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
event({
|
|
207
|
+
id: 'secret-data',
|
|
208
|
+
kind: 'Secret',
|
|
209
|
+
name: 'shop-secret',
|
|
210
|
+
diff: {
|
|
211
|
+
summary: 'data modified keys: [TOKEN]',
|
|
212
|
+
fields: [{ path: 'data (modified keys)', oldValue: ['TOKEN'], newValue: ['TOKEN'] }],
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
event({
|
|
216
|
+
id: 'sealed-secret-data',
|
|
217
|
+
kind: 'SealedSecret',
|
|
218
|
+
name: 'shop-sealed-secret',
|
|
219
|
+
diff: {
|
|
220
|
+
summary: 'spec.encryptedData modified keys: [TOKEN]',
|
|
221
|
+
fields: [{ path: 'spec.encryptedData (modified keys)', oldValue: ['TOKEN'], newValue: ['TOKEN'] }],
|
|
222
|
+
},
|
|
223
|
+
}),
|
|
224
|
+
event({
|
|
225
|
+
id: 'config-immutable',
|
|
226
|
+
kind: 'ConfigMap',
|
|
227
|
+
name: 'shop-config',
|
|
228
|
+
diff: {
|
|
229
|
+
summary: 'immutable changed',
|
|
230
|
+
fields: [{ path: 'immutable', oldValue: false, newValue: true }],
|
|
231
|
+
},
|
|
232
|
+
}),
|
|
233
|
+
event({
|
|
234
|
+
id: 'config-database-status',
|
|
235
|
+
kind: 'ConfigMap',
|
|
236
|
+
name: 'shop-config',
|
|
237
|
+
diff: {
|
|
238
|
+
summary: 'database status changed',
|
|
239
|
+
fields: [{ path: 'database.status', oldValue: 'old', newValue: 'new' }],
|
|
240
|
+
},
|
|
241
|
+
}),
|
|
242
|
+
])
|
|
243
|
+
|
|
244
|
+
expect(items.map((item) => item.id).sort()).toEqual([
|
|
245
|
+
'event:config-immutable',
|
|
246
|
+
'event:config-key-added',
|
|
247
|
+
'event:config-updated',
|
|
248
|
+
'event:job-suspended',
|
|
249
|
+
'event:sealed-secret-data',
|
|
250
|
+
'event:secret-data',
|
|
251
|
+
])
|
|
252
|
+
expect(items.every((item) => item.category === 'change')).toBe(true)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('omits owned batch run creation but keeps standalone Job creation', () => {
|
|
256
|
+
const items = buildApplicationHistoryItems(undefined, [
|
|
257
|
+
event({
|
|
258
|
+
id: 'cron-run-created',
|
|
259
|
+
kind: 'Job',
|
|
260
|
+
name: 'shop-report-1234',
|
|
261
|
+
eventType: 'add',
|
|
262
|
+
diff: undefined,
|
|
263
|
+
owner: { kind: 'CronJob', name: 'shop-report' },
|
|
264
|
+
}),
|
|
265
|
+
event({
|
|
266
|
+
id: 'standalone-job-created',
|
|
267
|
+
kind: 'Job',
|
|
268
|
+
name: 'shop-migrate',
|
|
269
|
+
eventType: 'add',
|
|
270
|
+
diff: undefined,
|
|
271
|
+
}),
|
|
272
|
+
])
|
|
273
|
+
|
|
274
|
+
expect(items).toHaveLength(1)
|
|
275
|
+
expect(items[0]).toMatchObject({ id: 'event:standalone-job-created', title: 'Job created', category: 'change' })
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('keeps failed batch runs as problems', () => {
|
|
279
|
+
const items = buildApplicationHistoryItems(undefined, [
|
|
280
|
+
event({
|
|
281
|
+
id: 'job-failed',
|
|
282
|
+
kind: 'Job',
|
|
283
|
+
name: 'shop-migrate',
|
|
284
|
+
source: 'k8s_event',
|
|
285
|
+
eventType: 'Warning',
|
|
286
|
+
reason: 'BackoffLimitExceeded',
|
|
287
|
+
message: 'Job has reached the specified backoff limit',
|
|
288
|
+
diff: undefined,
|
|
289
|
+
}),
|
|
290
|
+
])
|
|
291
|
+
|
|
292
|
+
expect(items).toHaveLength(1)
|
|
293
|
+
expect(items[0]).toMatchObject({ title: 'Job failed after repeated retries', category: 'problem' })
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('does not suppress a failed batch signal when its event type is not Warning', () => {
|
|
297
|
+
const items = buildApplicationHistoryItems(undefined, [
|
|
298
|
+
event({
|
|
299
|
+
id: 'job-failed-normal-type',
|
|
300
|
+
kind: 'Job',
|
|
301
|
+
name: 'shop-migrate',
|
|
302
|
+
source: 'historical',
|
|
303
|
+
eventType: 'Normal',
|
|
304
|
+
reason: 'Failed',
|
|
305
|
+
message: 'Job failed',
|
|
306
|
+
healthState: 'unhealthy',
|
|
307
|
+
diff: undefined,
|
|
308
|
+
}),
|
|
309
|
+
])
|
|
310
|
+
|
|
311
|
+
expect(items).toHaveLength(1)
|
|
312
|
+
expect(items[0]).toMatchObject({ title: 'Job failed', category: 'problem' })
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('normalizes lowercase batch failure reasons into developer-facing copy', () => {
|
|
316
|
+
const items = buildApplicationHistoryItems(undefined, [
|
|
317
|
+
event({
|
|
318
|
+
id: 'job-failed-lowercase',
|
|
319
|
+
kind: 'Job',
|
|
320
|
+
name: 'shop-migrate',
|
|
321
|
+
source: 'k8s_event',
|
|
322
|
+
eventType: 'Warning',
|
|
323
|
+
reason: 'failed',
|
|
324
|
+
message: 'Job has reached the specified backoff limit',
|
|
325
|
+
diff: undefined,
|
|
326
|
+
}),
|
|
327
|
+
])
|
|
328
|
+
|
|
329
|
+
expect(items[0]).toMatchObject({ title: 'Job failed', category: 'problem' })
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it.each([
|
|
333
|
+
['CronJob', 'Scheduled run could not be created'],
|
|
334
|
+
['Deployment', 'Pod could not be created'],
|
|
335
|
+
])('translates FailedCreate on %s into developer-facing copy', (kind, title) => {
|
|
336
|
+
const items = buildApplicationHistoryItems(undefined, [
|
|
337
|
+
event({
|
|
338
|
+
id: `failed-create-${kind}`,
|
|
339
|
+
kind,
|
|
340
|
+
source: 'k8s_event',
|
|
341
|
+
eventType: 'Warning',
|
|
342
|
+
reason: 'FailedCreate',
|
|
343
|
+
message: 'admission webhook denied the request',
|
|
344
|
+
diff: undefined,
|
|
345
|
+
}),
|
|
346
|
+
])
|
|
347
|
+
|
|
348
|
+
expect(items[0]).toMatchObject({ title, category: 'problem' })
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('rolls repeated pod warnings up to the owning workload without summing cumulative counts', () => {
|
|
352
|
+
const first = event({
|
|
353
|
+
id: 'warning-1',
|
|
354
|
+
source: 'k8s_event',
|
|
355
|
+
eventType: 'Warning',
|
|
356
|
+
reason: 'FailedScheduling',
|
|
357
|
+
message: 'No nodes available',
|
|
358
|
+
count: 3,
|
|
359
|
+
timestamp: '2026-07-13T09:00:00.000Z',
|
|
360
|
+
diff: undefined,
|
|
361
|
+
owner: { kind: 'DaemonSet', name: 'node-agent' },
|
|
362
|
+
})
|
|
363
|
+
const latest = {
|
|
364
|
+
...first,
|
|
365
|
+
id: 'warning-2',
|
|
366
|
+
name: 'shop-2',
|
|
367
|
+
count: 4,
|
|
368
|
+
timestamp: '2026-07-13T09:10:00.000Z',
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const items = buildApplicationHistoryItems(undefined, [first, latest])
|
|
372
|
+
|
|
373
|
+
expect(items).toHaveLength(1)
|
|
374
|
+
expect(items[0]).toMatchObject({
|
|
375
|
+
category: 'problem',
|
|
376
|
+
title: "Can't be scheduled",
|
|
377
|
+
count: 4,
|
|
378
|
+
timestamp: '2026-07-13T09:10:00.000Z',
|
|
379
|
+
resource: { kind: 'DaemonSet', namespace: 'prod', name: 'node-agent' },
|
|
380
|
+
})
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('suppresses informer churn for an anchored source object but keeps its warnings', () => {
|
|
384
|
+
const sourceUpdate = event({
|
|
385
|
+
id: 'argo-update',
|
|
386
|
+
kind: 'Application',
|
|
387
|
+
namespace: 'argocd',
|
|
388
|
+
name: 'shop',
|
|
389
|
+
})
|
|
390
|
+
const sourceWarning = event({
|
|
391
|
+
id: 'argo-warning',
|
|
392
|
+
kind: 'Application',
|
|
393
|
+
namespace: 'argocd',
|
|
394
|
+
name: 'shop',
|
|
395
|
+
source: 'k8s_event',
|
|
396
|
+
eventType: 'Warning',
|
|
397
|
+
reason: 'SyncError',
|
|
398
|
+
diff: undefined,
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
const items = buildApplicationHistoryItems(history, [sourceUpdate, sourceWarning])
|
|
402
|
+
|
|
403
|
+
expect(items.map((item) => item.title)).toEqual(['Argo CD sync', 'SyncError'])
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('suppresses non-problem source-object changes even when source history is unavailable', () => {
|
|
407
|
+
const items = buildApplicationHistoryItems({ ...history, anchors: [] }, [
|
|
408
|
+
event({
|
|
409
|
+
id: 'argo-update',
|
|
410
|
+
kind: 'Application',
|
|
411
|
+
namespace: 'argocd',
|
|
412
|
+
name: 'shop',
|
|
413
|
+
}),
|
|
414
|
+
])
|
|
415
|
+
|
|
416
|
+
expect(items).toHaveLength(0)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('coalesces a deployment change burst around a source anchor but keeps later drift', () => {
|
|
420
|
+
const items = buildApplicationHistoryItems(history, [
|
|
421
|
+
event({ id: 'sync-fanout', timestamp: '2026-07-13T10:01:00.000Z' }),
|
|
422
|
+
event({ id: 'sync-boundary', timestamp: '2026-07-13T10:02:00.000Z' }),
|
|
423
|
+
event({ id: 'manual-drift', timestamp: '2026-07-13T10:05:00.000Z' }),
|
|
424
|
+
])
|
|
425
|
+
|
|
426
|
+
expect(items.map((item) => item.id)).toEqual([
|
|
427
|
+
'event:manual-drift',
|
|
428
|
+
'source:gitops:2026-07-13T10:00:00.000Z:abc123',
|
|
429
|
+
])
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('keeps configuration data changes near a deployment anchor', () => {
|
|
433
|
+
const items = buildApplicationHistoryItems(history, [
|
|
434
|
+
event({
|
|
435
|
+
id: 'nearby-config-edit',
|
|
436
|
+
kind: 'ConfigMap',
|
|
437
|
+
name: 'shop-config',
|
|
438
|
+
timestamp: '2026-07-13T10:01:00.000Z',
|
|
439
|
+
diff: {
|
|
440
|
+
summary: 'data.FEATURE_FLAG changed',
|
|
441
|
+
fields: [{ path: 'data.FEATURE_FLAG', oldValue: 'off', newValue: 'on' }],
|
|
442
|
+
},
|
|
443
|
+
}),
|
|
444
|
+
])
|
|
445
|
+
|
|
446
|
+
expect(items.map((item) => item.id)).toEqual([
|
|
447
|
+
'event:nearby-config-edit',
|
|
448
|
+
'source:gitops:2026-07-13T10:00:00.000Z:abc123',
|
|
449
|
+
])
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('keeps failed pod signals while omitting ordinary pod and ReplicaSet churn', () => {
|
|
453
|
+
const items = buildApplicationHistoryItems(undefined, [
|
|
454
|
+
event({ id: 'pod-add', kind: 'Pod', name: 'shop-api-abc12', eventType: 'add', diff: undefined }),
|
|
455
|
+
event({ id: 'rs-update', kind: 'ReplicaSet', name: 'shop-api-abc', diff: { summary: 'replicas changed', fields: [] } }),
|
|
456
|
+
event({
|
|
457
|
+
id: 'pod-warning',
|
|
458
|
+
kind: 'Pod',
|
|
459
|
+
name: 'shop-api-abc12',
|
|
460
|
+
source: 'k8s_event',
|
|
461
|
+
eventType: 'Warning',
|
|
462
|
+
reason: 'BackOff',
|
|
463
|
+
message: 'Back-off restarting container',
|
|
464
|
+
diff: undefined,
|
|
465
|
+
}),
|
|
466
|
+
])
|
|
467
|
+
|
|
468
|
+
expect(items).toHaveLength(1)
|
|
469
|
+
expect(items[0]).toMatchObject({ title: 'BackOff', category: 'problem' })
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('keeps explained degraded pod failures but drops transient degraded startup snapshots', () => {
|
|
473
|
+
const items = buildApplicationHistoryItems(undefined, [
|
|
474
|
+
event({
|
|
475
|
+
id: 'pod-started',
|
|
476
|
+
kind: 'Pod',
|
|
477
|
+
name: 'shop-api-abc12',
|
|
478
|
+
reason: 'started',
|
|
479
|
+
healthState: 'degraded',
|
|
480
|
+
diff: undefined,
|
|
481
|
+
}),
|
|
482
|
+
event({
|
|
483
|
+
id: 'pod-unschedulable',
|
|
484
|
+
kind: 'Pod',
|
|
485
|
+
name: 'shop-api-def34',
|
|
486
|
+
reason: 'PodScheduled',
|
|
487
|
+
healthState: 'degraded',
|
|
488
|
+
message: "0/3 nodes are available: 3 node(s) didn't match Pod's node affinity/selector.",
|
|
489
|
+
diff: undefined,
|
|
490
|
+
owner: { kind: 'DaemonSet', name: 'node-agent' },
|
|
491
|
+
}),
|
|
492
|
+
])
|
|
493
|
+
|
|
494
|
+
expect(items).toHaveLength(1)
|
|
495
|
+
expect(items[0]).toMatchObject({
|
|
496
|
+
title: "Can't be scheduled",
|
|
497
|
+
category: 'problem',
|
|
498
|
+
resource: { kind: 'DaemonSet', namespace: 'prod', name: 'node-agent' },
|
|
499
|
+
})
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('keeps unexplained degraded workload snapshots as problems', () => {
|
|
503
|
+
const items = buildApplicationHistoryItems(undefined, [
|
|
504
|
+
event({
|
|
505
|
+
id: 'deployment-degraded',
|
|
506
|
+
healthState: 'degraded',
|
|
507
|
+
diff: undefined,
|
|
508
|
+
}),
|
|
509
|
+
])
|
|
510
|
+
|
|
511
|
+
expect(items).toHaveLength(1)
|
|
512
|
+
expect(items[0]).toMatchObject({
|
|
513
|
+
title: 'Deployment needs attention',
|
|
514
|
+
category: 'problem',
|
|
515
|
+
resource: { kind: 'Deployment', namespace: 'prod', name: 'shop-api' },
|
|
516
|
+
})
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('keeps a degraded source-object snapshot when no source anchor explains it', () => {
|
|
520
|
+
const historyWithoutAnchors: AppHistory = {
|
|
521
|
+
...history,
|
|
522
|
+
anchors: [],
|
|
523
|
+
}
|
|
524
|
+
const items = buildApplicationHistoryItems(historyWithoutAnchors, [
|
|
525
|
+
event({
|
|
526
|
+
id: 'argo-degraded',
|
|
527
|
+
kind: 'Application',
|
|
528
|
+
namespace: 'argocd',
|
|
529
|
+
name: 'shop',
|
|
530
|
+
healthState: 'degraded',
|
|
531
|
+
diff: undefined,
|
|
532
|
+
}),
|
|
533
|
+
])
|
|
534
|
+
|
|
535
|
+
expect(items).toHaveLength(1)
|
|
536
|
+
expect(items[0]).toMatchObject({ title: 'Application needs attention', category: 'problem' })
|
|
537
|
+
})
|
|
538
|
+
})
|