@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.
Files changed (80) hide show
  1. package/package.json +4 -4
  2. package/src/components/applications/ApplicationDetail.test.tsx +136 -8
  3. package/src/components/applications/ApplicationDetail.tsx +1215 -487
  4. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
  5. package/src/components/gitops/GitOpsTableView.tsx +10 -3
  6. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  7. package/src/components/gitops/SyncOptionsDialog.tsx +189 -45
  8. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  9. package/src/components/gitops/insights/GitOpsInsightViews.tsx +201 -21
  10. package/src/components/gitops/insights/index.ts +1 -0
  11. package/src/components/gitops/insights/insights-helpers.test.ts +16 -0
  12. package/src/components/gitops/insights/insights-helpers.ts +5 -0
  13. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -0
  14. package/src/components/issues/IssuesView.tsx +55 -29
  15. package/src/components/issues/ResourceIssuesSection.test.tsx +33 -1
  16. package/src/components/issues/ResourceIssuesSection.tsx +21 -1
  17. package/src/components/issues/severity.ts +1 -0
  18. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  19. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  20. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  21. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  24. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  25. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  26. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  27. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  28. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  29. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  30. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  31. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  32. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  33. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  34. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  35. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  36. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  37. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  38. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  39. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  40. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  41. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  42. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  43. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  44. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  52. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  53. package/src/components/resources/resource-utils-argo.ts +8 -0
  54. package/src/components/shared/ResourceActionsBar.tsx +2 -3
  55. package/src/components/shared/UnifiedDiff.tsx +51 -0
  56. package/src/components/shared/index.ts +1 -0
  57. package/src/components/timeline/TimelineList.tsx +7 -3
  58. package/src/components/timeline/TimelineToolbarState.test.tsx +24 -0
  59. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  60. package/src/components/topology/TopologyControls.tsx +3 -1
  61. package/src/components/topology/TopologyGraph.tsx +76 -90
  62. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  63. package/src/components/topology/TopologySearch.tsx +23 -15
  64. package/src/components/topology/index.ts +2 -0
  65. package/src/components/ui/Badge.tsx +1 -1
  66. package/src/components/ui/SelectMenu.tsx +81 -0
  67. package/src/components/ui/drawer-components.tsx +9 -6
  68. package/src/components/ui/index.ts +2 -0
  69. package/src/components/workload/WorkloadView.tsx +24 -3
  70. package/src/theme/tailwind-theme.css +1 -0
  71. package/src/theme/variables.css +1 -0
  72. package/src/types/gitops-insights.ts +69 -1
  73. package/src/types/gitops.ts +3 -3
  74. package/src/utils/application-history.test.ts +538 -0
  75. package/src/utils/application-history.ts +257 -0
  76. package/src/utils/applications.test.ts +34 -0
  77. package/src/utils/applications.ts +25 -0
  78. package/src/utils/index.ts +1 -0
  79. package/src/utils/resource-hierarchy.test.ts +35 -1
  80. package/src/utils/resource-hierarchy.ts +20 -0
@@ -32,7 +32,13 @@ export type GitOpsCategory =
32
32
  | 'Suspended'
33
33
  | 'Unknown'
34
34
 
35
- export type GitOpsDriftSource = 'lastAppliedAnnotation'
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
+ }
@@ -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
+ })