@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.
Files changed (87) 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/ResourcesView.tsx +109 -19
  19. package/src/components/resources/column-filter-serialization.test.ts +79 -1
  20. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  21. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  24. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  25. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  26. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  28. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  29. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  30. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  31. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  32. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  33. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  34. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  35. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  36. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  37. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  38. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  39. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  40. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  41. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  42. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  43. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  44. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  54. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  55. package/src/components/resources/resource-utils-argo.ts +8 -0
  56. package/src/components/resources/resource-utils.ts +94 -25
  57. package/src/components/shared/ResourceActionsBar.tsx +54 -22
  58. package/src/components/shared/UnifiedDiff.tsx +51 -0
  59. package/src/components/shared/index.ts +1 -0
  60. package/src/components/timeline/TimelineList.tsx +7 -3
  61. package/src/components/timeline/TimelineToolbarState.test.tsx +24 -0
  62. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  63. package/src/components/topology/TopologyControls.tsx +3 -1
  64. package/src/components/topology/TopologyGraph.tsx +76 -90
  65. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  66. package/src/components/topology/TopologySearch.tsx +23 -15
  67. package/src/components/topology/index.ts +2 -0
  68. package/src/components/ui/Badge.tsx +1 -1
  69. package/src/components/ui/SelectMenu.tsx +81 -0
  70. package/src/components/ui/Tooltip.test.tsx +21 -0
  71. package/src/components/ui/Tooltip.tsx +9 -12
  72. package/src/components/ui/drawer-components.tsx +9 -6
  73. package/src/components/ui/index.ts +2 -0
  74. package/src/components/workload/WorkloadView.tsx +24 -3
  75. package/src/theme/tailwind-theme.css +1 -0
  76. package/src/theme/variables.css +1 -0
  77. package/src/types/gitops-insights.ts +69 -1
  78. package/src/types/gitops.ts +3 -3
  79. package/src/utils/api-resources.test.ts +36 -1
  80. package/src/utils/api-resources.ts +16 -0
  81. package/src/utils/application-history.test.ts +538 -0
  82. package/src/utils/application-history.ts +257 -0
  83. package/src/utils/applications.test.ts +34 -0
  84. package/src/utils/applications.ts +25 -0
  85. package/src/utils/index.ts +1 -0
  86. package/src/utils/resource-hierarchy.test.ts +35 -1
  87. package/src/utils/resource-hierarchy.ts +20 -0
@@ -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
+ })