@skyhook-io/k8s-ui 1.12.0 → 1.13.0

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 (103) hide show
  1. package/package.json +4 -4
  2. package/src/components/issues/severity.ts +1 -0
  3. package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
  4. package/src/components/resources/ResourcesView.tsx +410 -5
  5. package/src/components/resources/index.ts +27 -0
  6. package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
  7. package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
  8. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
  9. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
  10. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
  11. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
  12. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
  13. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
  14. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
  15. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
  16. package/src/components/resources/renderers/CNPGShared.tsx +27 -0
  17. package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
  18. package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
  19. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
  20. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
  21. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
  22. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
  23. package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
  24. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
  25. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
  26. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
  27. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
  28. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
  29. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
  30. package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
  31. package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
  32. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
  33. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
  34. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
  35. package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
  36. package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
  37. package/src/components/resources/renderers/PolicySection.tsx +32 -5
  38. package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
  39. package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
  40. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
  41. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
  42. package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
  43. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
  44. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
  45. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
  46. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
  47. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
  48. package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
  49. package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
  50. package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
  51. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
  52. package/src/components/resources/renderers/calico-cells.tsx +177 -0
  53. package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
  54. package/src/components/resources/renderers/index.ts +16 -1
  55. package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
  56. package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
  57. package/src/components/resources/renderers/velero-cells.tsx +29 -7
  58. package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
  59. package/src/components/resources/resource-utils-calico.test.ts +215 -0
  60. package/src/components/resources/resource-utils-calico.ts +261 -0
  61. package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
  62. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
  63. package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
  64. package/src/components/resources/resource-utils-cnpg.ts +264 -3
  65. package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
  66. package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
  67. package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
  68. package/src/components/resources/resource-utils-kyverno.ts +43 -7
  69. package/src/components/resources/resource-utils-velero.test.ts +26 -0
  70. package/src/components/resources/resource-utils-velero.ts +68 -0
  71. package/src/components/resources/resource-utils.ts +98 -0
  72. package/src/components/shared/ResourceActionsBar.tsx +101 -16
  73. package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
  74. package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
  75. package/src/components/shared/index.ts +1 -1
  76. package/src/components/shared/revision-role-badges.test.ts +120 -0
  77. package/src/components/topology/K8sResourceNode.test.ts +10 -0
  78. package/src/components/topology/K8sResourceNode.tsx +22 -0
  79. package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
  80. package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
  81. package/src/components/topology/TopologyGraph.tsx +6 -6
  82. package/src/components/topology/TopologySearch.tsx +7 -0
  83. package/src/components/topology/layout.ts +3 -0
  84. package/src/components/trace/ReachabilityView.tsx +1 -0
  85. package/src/components/trace/TracePanel.tsx +12 -2
  86. package/src/components/trace/reachFixtures.ts +26 -1
  87. package/src/components/trace/reachGraphModel.test.ts +20 -0
  88. package/src/components/trace/reachGraphModel.ts +8 -1
  89. package/src/components/trace/reachOrigins.test.ts +31 -0
  90. package/src/components/trace/reachOrigins.ts +11 -1
  91. package/src/components/ui/Badge.tsx +10 -0
  92. package/src/components/ui/ScheduleValue.test.tsx +80 -0
  93. package/src/components/ui/ScheduleValue.tsx +85 -0
  94. package/src/components/ui/drawer-components.test.tsx +17 -1
  95. package/src/components/ui/drawer-components.tsx +9 -2
  96. package/src/components/ui/index.ts +2 -0
  97. package/src/components/workload/WorkloadView.tsx +3 -0
  98. package/src/types/core.ts +29 -0
  99. package/src/types/policy.ts +153 -2
  100. package/src/utils/navigation.test.ts +5 -0
  101. package/src/utils/navigation.ts +5 -0
  102. package/src/utils/resource-icons.test.ts +49 -2
  103. package/src/utils/resource-icons.ts +29 -2
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ getKyvernoRequestType,
4
+ getKyvernoRequestTriggers,
5
+ getKyvernoRequestGenerated,
6
+ getKyvernoRequestState,
7
+ getKyvernoReportSubject,
8
+ getKyvernoReportSummary,
9
+ getKyvernoReportResults,
10
+ getKyvernoReportStatus,
11
+ kyvernoReportTimestamp,
12
+ } from './resource-utils-kyverno-queue'
13
+
14
+ /**
15
+ * The fixtures below are trimmed copies of objects captured with a watch on a
16
+ * live Kyverno 1.18.2 while a generateExisting rule and a mutate-existing rule
17
+ * ran: 251 UpdateRequests and 3322 EphemeralReports. Every assertion here
18
+ * exists because the obvious reading of the CRD produces a blank screen.
19
+ */
20
+
21
+ const GENERATE_REQUEST = {
22
+ metadata: { name: 'ur-j8qjv', namespace: 'kyverno', labels: { 'generate.kyverno.io/policy-name': 'probe-generate-existing' } },
23
+ spec: {
24
+ policy: 'probe-generate-existing',
25
+ requestType: 'generate',
26
+ deleteDownstream: false,
27
+ // Both empty on every generate request observed.
28
+ resource: {},
29
+ rule: '',
30
+ ruleContext: [
31
+ { rule: 'companion-for-existing', deleteDownstream: false, trigger: { apiVersion: 'v1', kind: 'ConfigMap', name: 'bulk-001', namespace: 'policy-demo', uid: 'a' } },
32
+ { rule: 'companion-for-existing', deleteDownstream: false, trigger: { apiVersion: 'v1', kind: 'ConfigMap', name: 'bulk-002', namespace: 'policy-demo', uid: 'b' } },
33
+ ],
34
+ },
35
+ status: {
36
+ state: 'Completed',
37
+ generatedResources: [{ apiVersion: 'networking.k8s.io/v1', kind: 'NetworkPolicy', name: 'bulk-250-probe', namespace: 'policy-demo' }],
38
+ },
39
+ }
40
+
41
+ const MUTATE_REQUEST = {
42
+ metadata: { name: 'ur-2mhmq', namespace: 'kyverno' },
43
+ spec: {
44
+ policy: 'probe-mutate-existing',
45
+ requestType: 'mutate',
46
+ rule: 'label-existing-configmaps',
47
+ deleteDownstream: false,
48
+ resource: { apiVersion: 'v1', kind: 'ConfigMap', name: 'bulk-001', namespace: 'policy-demo', uid: 'a' },
49
+ },
50
+ status: { state: 'Completed' },
51
+ }
52
+
53
+ describe('UpdateRequest', () => {
54
+ it('reads triggers off ruleContext for a generate request', () => {
55
+ // spec.resource is {} here — reading it alone renders an empty page.
56
+ const t = getKyvernoRequestTriggers(GENERATE_REQUEST)
57
+ expect(t).toHaveLength(2)
58
+ expect(t[0].name).toBe('bulk-001')
59
+ expect(t[0].kind).toBe('ConfigMap')
60
+ expect(t[0].rule).toBe('companion-for-existing')
61
+ })
62
+
63
+ it('reads the single trigger off spec.resource for a mutate request', () => {
64
+ const t = getKyvernoRequestTriggers(MUTATE_REQUEST)
65
+ expect(t).toHaveLength(1)
66
+ expect(t[0].name).toBe('bulk-001')
67
+ })
68
+
69
+ it('reports no triggers rather than a blank row when neither is present', () => {
70
+ expect(getKyvernoRequestTriggers({ spec: { requestType: 'generate', resource: {}, ruleContext: [] } })).toEqual([])
71
+ })
72
+
73
+ it('distinguishes the two request types', () => {
74
+ expect(getKyvernoRequestType(GENERATE_REQUEST)).toBe('generate')
75
+ expect(getKyvernoRequestType(MUTATE_REQUEST)).toBe('mutate')
76
+ expect(getKyvernoRequestType({ spec: {} })).toBe('unknown')
77
+ })
78
+
79
+ it('lists what was created, and only on the request that creates', () => {
80
+ expect(getKyvernoRequestGenerated(GENERATE_REQUEST)).toHaveLength(1)
81
+ expect(getKyvernoRequestGenerated(MUTATE_REQUEST)).toEqual([])
82
+ })
83
+
84
+ // Pending is the state worth surfacing: work queued and never done.
85
+ it('separates queued from done from failed', () => {
86
+ expect(getKyvernoRequestState({ status: { state: 'Pending' } }).level).toBe('degraded')
87
+ expect(getKyvernoRequestState({ status: { state: 'Completed' } }).level).toBe('healthy')
88
+ expect(getKyvernoRequestState({ status: { state: 'Failed' } }).level).toBe('unhealthy')
89
+ expect(getKyvernoRequestState({}).level).toBe('unknown')
90
+ })
91
+ })
92
+
93
+ const REPORT = {
94
+ metadata: {
95
+ name: 'eae46cfc-2733-466c-b20a-f1a354b6f73b-7b47h',
96
+ namespace: 'kube-system',
97
+ labels: {
98
+ 'audit.kyverno.io/resource.kind': 'Pod',
99
+ 'audit.kyverno.io/resource.group': '',
100
+ 'audit.kyverno.io/resource.version': 'v1',
101
+ 'audit.kyverno.io/resource.uid': 'eae46cfc-2733-466c-b20a-f1a354b6f73b',
102
+ 'audit.kyverno.io/source': 'background-scan',
103
+ },
104
+ ownerReferences: [{ apiVersion: 'v1', kind: 'Pod', name: 'coredns-7d764666f9-dl4p6', uid: 'eae46cfc' }],
105
+ },
106
+ spec: {
107
+ // Present and blank on every report observed.
108
+ owner: { apiVersion: '', kind: '', name: '', uid: '' },
109
+ summary: { error: 0, fail: 1, pass: 2, skip: 0, warn: 0 },
110
+ results: [
111
+ { message: 'mutation is not applied', policy: 'add-default-labels', result: 'fail', source: 'KyvernoMutatingPolicy', timestamp: { seconds: 1786622539, nanos: 0 } },
112
+ ],
113
+ },
114
+ }
115
+
116
+ describe('EphemeralReport', () => {
117
+ // spec.owner is the documented field and is useless; the owner reference is
118
+ // where the subject actually is.
119
+ it('takes the subject from the owner reference when spec.owner is blank', () => {
120
+ const s = getKyvernoReportSubject(REPORT)
121
+ expect(s?.kind).toBe('Pod')
122
+ expect(s?.name).toBe('coredns-7d764666f9-dl4p6')
123
+ expect(s?.namespace).toBe('kube-system')
124
+ })
125
+
126
+ it('falls back to the labels when the subject is already gone', () => {
127
+ const { ownerReferences, ...meta } = REPORT.metadata as any
128
+ const s = getKyvernoReportSubject({ ...REPORT, metadata: meta })
129
+ expect(s?.kind).toBe('Pod')
130
+ expect(s?.name).toBeUndefined()
131
+ expect(s?.apiVersion).toBe('v1')
132
+ })
133
+
134
+ it('prefers spec.owner when a future version starts filling it in', () => {
135
+ const s = getKyvernoReportSubject({
136
+ ...REPORT,
137
+ spec: { ...REPORT.spec, owner: { apiVersion: 'apps/v1', kind: 'Deployment', name: 'web', uid: 'x' } },
138
+ })
139
+ expect(s?.kind).toBe('Deployment')
140
+ })
141
+
142
+ // The findings live in spec, not status, which is the reverse of every other
143
+ // report-shaped object here.
144
+ it('reads results and summary out of spec', () => {
145
+ expect(getKyvernoReportResults(REPORT)).toHaveLength(1)
146
+ expect(getKyvernoReportResults({ status: { results: [{}] } })).toEqual([])
147
+ const s = getKyvernoReportSummary(REPORT)
148
+ expect(s).toMatchObject({ fail: 1, pass: 2, total: 3 })
149
+ })
150
+
151
+ it('reports the worst outcome for the list badge', () => {
152
+ expect(getKyvernoReportStatus(REPORT).level).toBe('unhealthy')
153
+ expect(getKyvernoReportStatus({ spec: { summary: { pass: 4 } } }).text).toBe('4 pass')
154
+ expect(getKyvernoReportStatus({ spec: {} }).text).toBe('No results')
155
+ })
156
+
157
+ // `new Date({seconds, nanos})` is Invalid Date, not an error.
158
+ it('converts the seconds/nanos timestamp rather than passing it to Date', () => {
159
+ expect(kyvernoReportTimestamp({ seconds: 1786622539, nanos: 0 })).toBe('2026-08-13T12:02:19.000Z')
160
+ expect(kyvernoReportTimestamp({ seconds: 0 })).toBeUndefined()
161
+ expect(kyvernoReportTimestamp(undefined)).toBeUndefined()
162
+ expect(kyvernoReportTimestamp('2026-08-13T09:22:19Z')).toBe('2026-08-13T09:22:19Z')
163
+ })
164
+ })
@@ -0,0 +1,198 @@
1
+ import { healthColors } from './resource-utils'
2
+ import type { StatusBadge } from './resource-utils'
3
+
4
+ /**
5
+ * UpdateRequest and EphemeralReport — Kyverno's two working records.
6
+ *
7
+ * Both are shaped differently from every other Kyverno object, and in ways the
8
+ * CRD schema does not warn about. Everything here was read off live objects
9
+ * captured with a watch on Kyverno 1.18.2 while the rules that produce them
10
+ * ran, rather than derived from the schema:
11
+ *
12
+ * - A generate UpdateRequest leaves `spec.resource` EMPTY and `spec.rule` an
13
+ * empty string. Its triggers live in `spec.ruleContext[].trigger`, and one
14
+ * request can carry hundreds of them. A mutate UpdateRequest is the
15
+ * opposite: `spec.resource` and `spec.rule` are populated and there is no
16
+ * ruleContext at all.
17
+ * - An EphemeralReport carries its findings in `spec`, not `status`, and its
18
+ * `spec.owner` is present but blank — the subject is only in
19
+ * `metadata.ownerReferences` and in the `audit.kyverno.io/resource.*`
20
+ * labels.
21
+ * - Report timestamps are `{seconds, nanos}` objects, not RFC 3339 strings.
22
+ */
23
+
24
+ export type KyvernoRequestType = 'generate' | 'mutate' | 'unknown'
25
+
26
+ export interface KyvernoTrigger {
27
+ apiVersion?: string
28
+ kind?: string
29
+ name?: string
30
+ namespace?: string
31
+ uid?: string
32
+ /** Whether deleting this trigger also deletes what the rule made for it. */
33
+ deleteDownstream: boolean
34
+ rule?: string
35
+ }
36
+
37
+ export function getKyvernoRequestType(resource: any): KyvernoRequestType {
38
+ const t = resource?.spec?.requestType
39
+ return t === 'generate' || t === 'mutate' ? t : 'unknown'
40
+ }
41
+
42
+ /**
43
+ * Every resource this request acts on.
44
+ *
45
+ * Reading `spec.resource` alone — the obvious field, and the only one the CRD
46
+ * documents as "the resource" — returns nothing at all for a generate request.
47
+ */
48
+ export function getKyvernoRequestTriggers(resource: any): KyvernoTrigger[] {
49
+ const ctx = resource?.spec?.ruleContext
50
+ if (Array.isArray(ctx) && ctx.length > 0) {
51
+ return ctx
52
+ .map((c: any) => ({
53
+ ...(c?.trigger ?? {}),
54
+ deleteDownstream: c?.deleteDownstream === true,
55
+ rule: c?.rule,
56
+ }))
57
+ .filter((t: KyvernoTrigger) => !!t.name)
58
+ }
59
+ const r = resource?.spec?.resource
60
+ if (r?.name) {
61
+ return [{ ...r, deleteDownstream: resource?.spec?.deleteDownstream === true, rule: resource?.spec?.rule }]
62
+ }
63
+ return []
64
+ }
65
+
66
+ /** Resources the request has created so far. Absent on mutate requests. */
67
+ export function getKyvernoRequestGenerated(resource: any): Array<{
68
+ apiVersion?: string
69
+ kind?: string
70
+ name?: string
71
+ namespace?: string
72
+ }> {
73
+ const g = resource?.status?.generatedResources
74
+ return Array.isArray(g) ? g.filter((r: any) => !!r?.name) : []
75
+ }
76
+
77
+ /**
78
+ * State of the request.
79
+ *
80
+ * `Pending` is the one that matters: a request stuck there is generation that
81
+ * has silently stopped, which is the reason to look at this object at all.
82
+ */
83
+ export function getKyvernoRequestState(resource: any): StatusBadge {
84
+ const state = resource?.status?.state
85
+ switch (state) {
86
+ case 'Completed':
87
+ return { text: 'Completed', color: healthColors.healthy, level: 'healthy' }
88
+ case 'Failed':
89
+ return { text: 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
90
+ case 'Pending':
91
+ return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
92
+ case 'Skip':
93
+ return { text: 'Skipped', color: healthColors.neutral, level: 'neutral' }
94
+ default:
95
+ return { text: state || 'Unknown', color: healthColors.unknown, level: 'unknown' }
96
+ }
97
+ }
98
+
99
+ export function getKyvernoRequestPolicy(resource: any): string | undefined {
100
+ return resource?.spec?.policy || undefined
101
+ }
102
+
103
+ export function getKyvernoRequestMessage(resource: any): string | undefined {
104
+ const m = resource?.status?.message
105
+ return typeof m === 'string' && m !== '' ? m : undefined
106
+ }
107
+
108
+ // ============================================================================
109
+ // EphemeralReport
110
+ // ============================================================================
111
+
112
+ export interface KyvernoReportSubject {
113
+ apiVersion?: string
114
+ kind?: string
115
+ name?: string
116
+ namespace?: string
117
+ uid?: string
118
+ }
119
+
120
+ /**
121
+ * The resource an EphemeralReport is about.
122
+ *
123
+ * `spec.owner` exists on every one of these and is blank on every one observed,
124
+ * so the owner reference is the real source and the labels are the fallback for
125
+ * a report whose owner has already been deleted.
126
+ */
127
+ export function getKyvernoReportSubject(resource: any): KyvernoReportSubject | undefined {
128
+ const owner = resource?.spec?.owner
129
+ if (owner?.name) return owner
130
+
131
+ const ref = (resource?.metadata?.ownerReferences ?? [])[0]
132
+ if (ref?.name) {
133
+ return {
134
+ apiVersion: ref.apiVersion,
135
+ kind: ref.kind,
136
+ name: ref.name,
137
+ namespace: resource?.metadata?.namespace,
138
+ uid: ref.uid,
139
+ }
140
+ }
141
+
142
+ const labels = resource?.metadata?.labels ?? {}
143
+ const kind = labels['audit.kyverno.io/resource.kind']
144
+ if (!kind) return undefined
145
+ const group = labels['audit.kyverno.io/resource.group']
146
+ const version = labels['audit.kyverno.io/resource.version']
147
+ return {
148
+ apiVersion: group ? `${group}/${version}` : version,
149
+ kind,
150
+ namespace: resource?.metadata?.namespace,
151
+ uid: labels['audit.kyverno.io/resource.uid'],
152
+ }
153
+ }
154
+
155
+ /** How the report was produced — background scan or admission. */
156
+ export function getKyvernoReportSource(resource: any): string | undefined {
157
+ return resource?.metadata?.labels?.['audit.kyverno.io/source'] || undefined
158
+ }
159
+
160
+ export function getKyvernoReportSummary(resource: any): {
161
+ pass: number
162
+ fail: number
163
+ warn: number
164
+ error: number
165
+ skip: number
166
+ total: number
167
+ } {
168
+ const s = resource?.spec?.summary ?? {}
169
+ const n = (v: unknown) => (typeof v === 'number' ? v : 0)
170
+ const out = { pass: n(s.pass), fail: n(s.fail), warn: n(s.warn), error: n(s.error), skip: n(s.skip) }
171
+ return { ...out, total: out.pass + out.fail + out.warn + out.error + out.skip }
172
+ }
173
+
174
+ export function getKyvernoReportResults(resource: any): any[] {
175
+ const r = resource?.spec?.results
176
+ return Array.isArray(r) ? r : []
177
+ }
178
+
179
+ /**
180
+ * Report timestamps are `{seconds, nanos}`, and `new Date(...)` on that object
181
+ * yields Invalid Date rather than failing loudly.
182
+ */
183
+ export function kyvernoReportTimestamp(ts: any): string | undefined {
184
+ if (typeof ts === 'string') return ts || undefined
185
+ const seconds = ts?.seconds
186
+ if (typeof seconds !== 'number' || seconds <= 0) return undefined
187
+ return new Date(seconds * 1000).toISOString()
188
+ }
189
+
190
+ /** List badge: worst outcome in the report, matching the PolicyReport columns. */
191
+ export function getKyvernoReportStatus(resource: any): StatusBadge {
192
+ const s = getKyvernoReportSummary(resource)
193
+ if (s.total === 0) return { text: 'No results', color: healthColors.neutral, level: 'neutral' }
194
+ if (s.error > 0) return { text: `${s.error} error`, color: healthColors.alert, level: 'alert' }
195
+ if (s.fail > 0) return { text: `${s.fail} fail`, color: healthColors.unhealthy, level: 'unhealthy' }
196
+ if (s.warn > 0) return { text: `${s.warn} warn`, color: healthColors.degraded, level: 'degraded' }
197
+ return { text: `${s.pass} pass`, color: healthColors.healthy, level: 'healthy' }
198
+ }
@@ -115,14 +115,47 @@ export function getKyvernoPolicyStatus(resource: any): StatusBadge {
115
115
  return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
116
116
  }
117
117
 
118
+ /** The rule blocks whose failure an admission request can be rejected for. */
119
+ function rejectingActions(rule: any): string[] {
120
+ if (!rule) return []
121
+ const actions: string[] = []
122
+ if (rule.validate) actions.push(rule.validate.failureAction)
123
+ if (Array.isArray(rule.verifyImages)) {
124
+ for (const v of rule.verifyImages) actions.push(v?.failureAction)
125
+ }
126
+ return actions.filter(Boolean)
127
+ }
128
+
129
+ /**
130
+ * Whether this policy can REJECT an admission request, as Audit or Enforce.
131
+ *
132
+ * Three things make the obvious reading wrong. A rule-level failureAction
133
+ * OVERRIDES `spec.validationFailureAction` rather than deferring to it, so
134
+ * consulting the spec field first answers Audit for a policy whose rule
135
+ * enforces. Image verification carries its own failureAction on
136
+ * `verifyImages[]` rather than under `validate`, so a signing policy blocks
137
+ * without either of the fields the name suggests. And the spec field is
138
+ * defaulted to Audit when absent, so its presence proves nothing.
139
+ *
140
+ * Only validate and verifyImages rules can reject at all, so only they inherit
141
+ * the spec-level action — a mutate or generate rule sitting in the same policy
142
+ * would otherwise carry Enforce into a policy where nothing enforces.
143
+ *
144
+ * Rules can disagree, and one value has to stand for the whole policy. The
145
+ * question worth answering is whether anything here can reject, so any Enforce
146
+ * wins; the Rules section is where a mixed policy is read rule by rule.
147
+ */
118
148
  export function getKyvernoPolicyAction(resource: any): string {
119
- // Check spec-level validationFailureAction (deprecated but still widely used)
120
- const action = resource.spec?.validationFailureAction
121
- if (action) return action
122
- // Fallback: check first rule's validate.failureAction
149
+ const specAction = resource.spec?.validationFailureAction
123
150
  const rules = resource.spec?.rules || []
124
- for (const rule of rules) {
125
- if (rule.validate?.failureAction) return rule.validate.failureAction
151
+ if (rules.length === 0) return specAction || 'Audit'
152
+ const rejecting = rules.filter((rule: any) => rule?.validate || rule?.verifyImages)
153
+ // Rules, but none that can reject: the spec-level action governs nothing here.
154
+ if (rejecting.length === 0) return 'Audit'
155
+ for (const rule of rejecting) {
156
+ const overrides = rejectingActions(rule)
157
+ const effective = overrides.length > 0 ? overrides : [specAction]
158
+ if (effective.includes('Enforce')) return 'Enforce'
126
159
  }
127
160
  return 'Audit'
128
161
  }
@@ -184,7 +217,10 @@ export function getKyvernoPolicyRuleCountByType(resource: any): {
184
217
  validate: statusCount.validate ?? 0,
185
218
  mutate: statusCount.mutate ?? 0,
186
219
  generate: statusCount.generate ?? 0,
187
- verifyImages: statusCount.verifyImages ?? 0,
220
+ // Kyverno writes this key all-lowercase (`verifyimages`), verified on
221
+ // 1.18.2. Reading the camelCase spelling alone returns 0 for every
222
+ // image-verification policy in existence.
223
+ verifyImages: statusCount.verifyImages ?? statusCount.verifyimages ?? 0,
188
224
  }
189
225
  }
190
226
 
@@ -9,6 +9,7 @@ import {
9
9
  isVeleroResource,
10
10
  getScheduleCronInfo,
11
11
  getBackupRepositoryStatus,
12
+ resolveBackupStorageLocation,
12
13
  } from './resource-utils-velero'
13
14
 
14
15
  const backup = (phase: string, extra: Record<string, unknown> = {}) => ({
@@ -235,3 +236,28 @@ describe('getScheduleCronInfo', () => {
235
236
  expect(getScheduleCronInfo({ spec: {} })).toEqual({ cron: '-', readable: '', malformed: false })
236
237
  })
237
238
  })
239
+
240
+ describe('which location holds a backup', () => {
241
+ const locations = [
242
+ { metadata: { name: 'primary' }, spec: { default: true } },
243
+ { metadata: { name: 'dr-replica' }, spec: { default: false } },
244
+ ]
245
+
246
+ it('uses the location the backup names', () => {
247
+ expect(resolveBackupStorageLocation({ spec: { storageLocation: 'dr-replica' } }, locations)).toBe('dr-replica')
248
+ })
249
+
250
+ // The rule Velero actually applies: the flag, not the name.
251
+ it('resolves an unset location through spec.default', () => {
252
+ expect(resolveBackupStorageLocation({ spec: {} }, locations)).toBe('primary')
253
+ })
254
+
255
+ // Velero's own fallback when nothing claims the flag.
256
+ it('falls back to the conventional name when no location is marked default', () => {
257
+ expect(resolveBackupStorageLocation({ spec: {} }, [{ metadata: { name: 'only' }, spec: {} }])).toBe('default')
258
+ })
259
+
260
+ it('falls back when the list has not been read', () => {
261
+ expect(resolveBackupStorageLocation({ spec: {} }, undefined)).toBe('default')
262
+ })
263
+ })
@@ -131,10 +131,34 @@ export function getBackupQueuePosition(resource: any): number | null {
131
131
  return typeof pos === 'number' ? pos : null
132
132
  }
133
133
 
134
+ // The name a Backup carries, applying Velero's fallback by name only. Callers
135
+ // that hold the location list should prefer resolveBackupStorageLocation, which
136
+ // applies Velero's actual rule.
134
137
  export function getBackupStorageLocation(resource: any): string {
135
138
  return resource.spec?.storageLocation || 'default'
136
139
  }
137
140
 
141
+ // Velero resolves an unset spec.storageLocation to whichever location carries
142
+ // spec.default — a flag, not a name. The literal name "default" is only the
143
+ // fallback when nothing claims the flag, and an install that renamed its
144
+ // default location has no such object at all: the health lookup finds nothing
145
+ // and the link points at a name that does not exist.
146
+ //
147
+ // Mirrors the server-side resolution behind the stored-backups endpoint so the
148
+ // two cannot disagree about which location holds a backup.
149
+ export function getVeleroDefaultLocationName(locations: any[] | undefined): string {
150
+ for (const l of locations ?? []) {
151
+ if (l?.spec?.default === true && l?.metadata?.name) return l.metadata.name
152
+ }
153
+ return ''
154
+ }
155
+
156
+ export function resolveBackupStorageLocation(resource: any, locations: any[] | undefined): string {
157
+ const declared = resource?.spec?.storageLocation
158
+ if (declared) return declared
159
+ return getVeleroDefaultLocationName(locations) || 'default'
160
+ }
161
+
138
162
  export function getBackupIncludedNamespaces(resource: any): string[] {
139
163
  return resource.spec?.includedNamespaces || []
140
164
  }
@@ -196,6 +220,22 @@ export function getBackupTTL(resource: any): string {
196
220
  return resource.spec?.ttl || '-'
197
221
  }
198
222
 
223
+ /**
224
+ * How long this backup allows a single operation before it has outlived its own
225
+ * budget. Shown because the stalled-run issue measures against this field and
226
+ * quotes it — without it on the page, the message cites a number from a spec
227
+ * option the page lists every sibling of.
228
+ *
229
+ * Unset renders as "default", the same word getBackupSnapshotVolumes uses for
230
+ * the same situation — not as a number. Velero's built-in is four hours, but the
231
+ * controller takes `--default-item-operation-timeout` and distributions ship
232
+ * their own, so printing 4h here would state an install setting nothing on this
233
+ * page can read.
234
+ */
235
+ export function getBackupItemOperationTimeout(resource: any): string {
236
+ return resource.spec?.itemOperationTimeout || 'default'
237
+ }
238
+
199
239
  export function getBackupSnapshotVolumes(resource: any): string {
200
240
  const val = resource.spec?.snapshotVolumes
201
241
  if (val === undefined || val === null) return 'default'
@@ -483,3 +523,31 @@ export function getBackupRepositoryStatus(resource: any): StatusBadge {
483
523
  export function getBackupRepositoryType(resource: any): string {
484
524
  return resource.spec?.repositoryType || '-'
485
525
  }
526
+
527
+ // The namespace whose volumes this repository holds. One repository per
528
+ // (namespace, location, type) is how Velero partitions them, so this is what
529
+ // the repository is *for* rather than where the object lives.
530
+ export function getBackupRepositoryVolumeNamespace(resource: any): string {
531
+ return resource.spec?.volumeNamespace || '-'
532
+ }
533
+
534
+ export function getBackupRepositoryLocation(resource: any): string {
535
+ return resource.spec?.backupStorageLocation || '-'
536
+ }
537
+
538
+ export function getBackupRepositoryMaintenanceFrequency(resource: any): string {
539
+ return resource.spec?.maintenanceFrequency || '-'
540
+ }
541
+
542
+ export function getBackupRepositoryLastMaintenance(resource: any): string {
543
+ const at = resource.status?.lastMaintenanceTime
544
+ if (!at) return '-'
545
+ return formatAge(at)
546
+ }
547
+
548
+ // Why the repository is not ready, in the controller's own words. Velero writes
549
+ // it on the object and nowhere else, so without it the phase is the whole story
550
+ // a reader gets.
551
+ export function getBackupRepositoryMessage(resource: any): string {
552
+ return resource.status?.message || ''
553
+ }
@@ -16,6 +16,7 @@ import { getBackupStatus as _getBackupStatus, getRestoreStatus as _getRestoreSta
16
16
  import { getExternalSecretStatus as _getExternalSecretStatus, getClusterExternalSecretStatus as _getClusterExternalSecretStatus, getSecretStoreStatus as _getSecretStoreStatus, getClusterSecretStoreStatus as _getClusterSecretStoreStatus, getSecretStoreProviderType as _getSecretStoreProviderType } from './resource-utils-eso'
17
17
  import { getHPATableState, hpaStatusFromState } from './resource-utils-hpa'
18
18
  import { getCNPGClusterStatus as _getCNPGClusterStatus, getCNPGBackupStatus as _getCNPGBackupStatus, getCNPGScheduledBackupStatus as _getCNPGScheduledBackupStatus, getCNPGPoolerStatus as _getCNPGPoolerStatus, isApiGroup as _isApiGroup, CNPG_GROUP as _CNPG_GROUP } from './resource-utils-cnpg'
19
+ import { getCalicoIPPoolAllowedUses, getCalicoIPPoolBlockSize, getCalicoIPPoolEncapsulation, getCalicoPolicyNamespaceSelector, getCalicoPolicyServiceAccountSelector, getCalicoPolicyTypes, isCalicoApiVersion, isCalicoPolicyResource } from './resource-utils-calico'
19
20
 
20
21
  // ============================================================================
21
22
  // STATUS & HEALTH UTILITIES
@@ -1492,6 +1493,42 @@ export function getRolloutStatus(rollout: any): StatusBadge {
1492
1493
  }
1493
1494
  }
1494
1495
 
1496
+ export function getAnalysisRunStatus(run: any): StatusBadge {
1497
+ const phase = run.status?.phase || 'Unknown'
1498
+ switch (phase) {
1499
+ case 'Successful':
1500
+ return { text: 'Successful', color: healthColors.healthy, level: 'healthy' }
1501
+ case 'Running':
1502
+ case 'Pending':
1503
+ return { text: phase, color: healthColors.degraded, level: 'degraded' }
1504
+ case 'Inconclusive':
1505
+ return { text: 'Inconclusive', color: healthColors.alert, level: 'alert' }
1506
+ case 'Failed':
1507
+ case 'Error':
1508
+ return { text: phase, color: healthColors.unhealthy, level: 'unhealthy' }
1509
+ default:
1510
+ return { text: phase, color: healthColors.unknown, level: 'unknown' }
1511
+ }
1512
+ }
1513
+
1514
+ // Counts only verdict-bearing metrics: Argo excludes dryRun results from the run's
1515
+ // phase, and Running/Pending have not passed yet.
1516
+ export function summarizeAnalysisMetrics(run: any): {
1517
+ total: number
1518
+ passing: number
1519
+ notPassing: number
1520
+ dryRun: number
1521
+ } {
1522
+ const results: any[] = run?.status?.metricResults || []
1523
+ const scored = results.filter((m) => !m.dryRun)
1524
+ return {
1525
+ total: scored.length,
1526
+ passing: scored.filter((m) => m.phase === 'Successful').length,
1527
+ notPassing: scored.filter((m) => ['Failed', 'Error', 'Inconclusive'].includes(m.phase)).length,
1528
+ dryRun: results.length - scored.length,
1529
+ }
1530
+ }
1531
+
1495
1532
  export function getRolloutStrategy(rollout: any): string {
1496
1533
  if (rollout.spec?.strategy?.canary) return 'Canary'
1497
1534
  if (rollout.spec?.strategy?.blueGreen) return 'BlueGreen'
@@ -2114,6 +2151,7 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
2114
2151
  if (kindLower === 'persistentvolumeclaims') return getPVCStatus(resource).text
2115
2152
  if (kindLower === 'persistentvolumes') return getPVStatus(resource).text
2116
2153
  if (kindLower === 'rollouts') return getRolloutStatus(resource).text
2154
+ if (kindLower === 'analysisruns') return getAnalysisRunStatus(resource).text
2117
2155
  if (kindLower === 'workflows') return getWorkflowStatus(resource).text
2118
2156
  if (kindLower === 'cronworkflows') return getCronWorkflowStatus(resource).text
2119
2157
  if (kindLower === 'hpas' || kindLower === 'horizontalpodautoscalers') return getHPAStatus(resource).text
@@ -2200,7 +2238,67 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
2200
2238
  return getServiceAccountAutomount(resource)
2201
2239
  case 'policyTypes':
2202
2240
  return getNetworkPolicyTypes(resource)
2241
+ case 'types':
2242
+ if (isCalicoPolicyResource(resource)) return getCalicoPolicyTypes(resource).join(', ')
2243
+ break
2244
+ case 'namespaceSelector':
2245
+ if (isCalicoPolicyResource(resource)) return getCalicoPolicyNamespaceSelector(resource)
2246
+ break
2247
+ case 'serviceAccountSelector':
2248
+ if (isCalicoPolicyResource(resource)) return getCalicoPolicyServiceAccountSelector(resource)
2249
+ break
2250
+ case 'tier':
2251
+ if (isCalicoPolicyResource(resource)) return String(resource.spec?.tier ?? 'default')
2252
+ break
2253
+ case 'cidr':
2254
+ if (isCalicoApiVersion(resource?.apiVersion)) return String(resource.spec?.cidr ?? '')
2255
+ break
2256
+ case 'blockSize':
2257
+ if (isCalicoApiVersion(resource?.apiVersion)) {
2258
+ const size = getCalicoIPPoolBlockSize(resource)
2259
+ return size === undefined ? '' : String(size)
2260
+ }
2261
+ break
2262
+ case 'natOutgoing':
2263
+ if (isCalicoApiVersion(resource?.apiVersion)) return resource.spec?.natOutgoing ? 'Yes' : 'No'
2264
+ break
2265
+ case 'nodeSelector':
2266
+ if (isCalicoApiVersion(resource?.apiVersion)) return String(resource.spec?.nodeSelector ?? 'all()')
2267
+ break
2268
+ case 'allowedUses':
2269
+ if (isCalicoApiVersion(resource?.apiVersion)) return getCalicoIPPoolAllowedUses(resource)
2270
+ break
2271
+ case 'expectedIPs':
2272
+ if (isCalicoApiVersion(resource?.apiVersion)) {
2273
+ return Array.isArray(resource.spec?.expectedIPs) ? resource.spec.expectedIPs.map(String).join(', ') : ''
2274
+ }
2275
+ break
2276
+ case 'profiles':
2277
+ if (isCalicoApiVersion(resource?.apiVersion)) {
2278
+ return Array.isArray(resource.spec?.profiles) ? resource.spec.profiles.map(String).join(', ') : ''
2279
+ }
2280
+ break
2281
+ case 'encapsulation':
2282
+ if (isCalicoApiVersion(resource?.apiVersion)) return getCalicoIPPoolEncapsulation(resource)
2283
+ break
2284
+ case 'disabled':
2285
+ if (isCalicoApiVersion(resource?.apiVersion)) return resource.spec?.disabled === true ? 'Disabled' : 'No'
2286
+ break
2287
+ case 'interfaceName':
2288
+ if (isCalicoApiVersion(resource?.apiVersion)) return String(resource.spec?.interfaceName ?? '')
2289
+ break
2290
+ case 'defaultAction':
2291
+ if (isCalicoApiVersion(resource?.apiVersion)) return String(resource.spec?.defaultAction ?? 'Deny')
2292
+ break
2293
+ case 'order':
2294
+ if (isCalicoPolicyResource(resource) && resource.spec?.order !== undefined) return String(resource.spec.order)
2295
+ break
2296
+ case 'stagedAction':
2297
+ if (isCalicoPolicyResource(resource)) return String(resource.spec?.stagedAction ?? '')
2298
+ break
2203
2299
  case 'node':
2300
+ // A Calico HostEndpoint names its node in spec.node; a Pod uses spec.nodeName.
2301
+ if (isCalicoApiVersion(resource?.apiVersion)) return String(resource.spec?.node ?? '')
2204
2302
  return resource.spec?.nodeName || ''
2205
2303
  case 'version':
2206
2304
  if (kindLower === 'nodes') return getNodeVersion(resource)