@skyhook-io/k8s-ui 1.13.4 → 1.14.1

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 (43) hide show
  1. package/package.json +4 -4
  2. package/src/components/gitops/GitOpsTableView.tsx +168 -11
  3. package/src/components/gitops/applicationset-row.test.ts +214 -0
  4. package/src/components/gitops/detail-helpers.test.ts +54 -0
  5. package/src/components/gitops/detail-helpers.ts +13 -3
  6. package/src/components/gitops/index.ts +1 -0
  7. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +7 -0
  8. package/src/components/resources/ResourcesView.tsx +259 -80
  9. package/src/components/resources/column-header-width.test.ts +75 -0
  10. package/src/components/resources/kyverno-legacy-action.test.ts +100 -1
  11. package/src/components/resources/kyverno-modern-posture.test.ts +36 -0
  12. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +29 -0
  13. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +3 -2
  14. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +69 -3
  15. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +6 -2
  16. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +12 -7
  17. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +92 -1
  18. package/src/components/resources/renderers/PolicyCoverageSection.tsx +64 -8
  19. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +47 -0
  20. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +16 -1
  21. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +88 -0
  22. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +44 -2
  23. package/src/components/resources/renderers/cnpg-cells.tsx +12 -1
  24. package/src/components/resources/renderers/kyverno-cells.tsx +8 -4
  25. package/src/components/resources/resource-utils-cnpg.golden.test.ts +13 -5
  26. package/src/components/resources/resource-utils-cnpg.test.ts +142 -2
  27. package/src/components/resources/resource-utils-cnpg.ts +132 -7
  28. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +14 -0
  29. package/src/components/resources/resource-utils-kueue.ts +4 -2
  30. package/src/components/resources/resource-utils-kyverno-modern.ts +3 -1
  31. package/src/components/resources/resource-utils-kyverno.ts +51 -2
  32. package/src/components/resources/resource-utils-velero.ts +32 -0
  33. package/src/components/resources/rules-column-width.test.ts +42 -0
  34. package/src/components/shared/ResourceRendererDispatch.test.tsx +75 -0
  35. package/src/components/shared/ResourceRendererDispatch.tsx +24 -6
  36. package/src/components/topology/GroupNode.tsx +6 -2
  37. package/src/components/topology/topology.css +4 -1
  38. package/src/components/ui/Collapse.tsx +9 -1
  39. package/src/components/ui/RowActionMenu.tsx +4 -0
  40. package/src/components/ui/SelectMenu.tsx +10 -1
  41. package/src/components/ui/drawer-components.tsx +4 -2
  42. package/src/components/workload/WorkloadView.tsx +9 -2
  43. package/src/types/gitops.ts +51 -0
@@ -11,9 +11,17 @@ import {
11
11
  getKyvernoEnforcementPosture,
12
12
  getKyvernoResourceRules,
13
13
  getKyvernoEffectiveFailurePolicy,
14
+ getKyvernoValidationActions,
15
+ getKyvernoEvaluationMode,
16
+ isKyvernoAdmissionEnabled,
14
17
  type KyvernoPolicyFamily,
15
18
  } from '../resource-utils-kyverno-modern'
16
- import { getKyvernoPolicyAction } from '../resource-utils-kyverno'
19
+ import {
20
+ getKyvernoEnforcement,
21
+ getKyvernoPolicyAction,
22
+ getKyvernoPolicyAdmission,
23
+ isKyvernoEnforceAction,
24
+ } from '../resource-utils-kyverno'
17
25
  import type {
18
26
  PolicyCoverageResponse,
19
27
  PolicyCoverageRule,
@@ -160,7 +168,7 @@ function legacyActionsAgree(resource: any): boolean {
160
168
  : []),
161
169
  ].filter(Boolean)
162
170
  const effective = declared.length > 0 ? declared : [spec]
163
- return effective.includes('Enforce')
171
+ return effective.some(isKyvernoEnforceAction)
164
172
  })
165
173
  return answers.every((a: boolean) => a === answers[0])
166
174
  }
@@ -229,17 +237,40 @@ function policyFamilyFor(
229
237
 
230
238
  /**
231
239
  * The families answer from different fields: legacy carries
232
- * `spec.validationFailureAction` (or a per-rule `validate.failureAction`), while
233
- * `getKyvernoEnforcementPosture` reads `spec.validationActions`, absent on
234
- * legacy — which makes an Enforce policy read as audit-only.
240
+ * `spec.validationFailureAction` (or a per-rule `validate.failureAction`) gated
241
+ * by `spec.admission`, while `getKyvernoEnforcementPosture` reads
242
+ * `spec.validationActions` and `spec.evaluation.admission.enabled`, both absent
243
+ * on legacy — which makes an Enforce policy read as audit-only.
235
244
  */
236
245
  function blocksAdmission(resource: any, family: KyvernoPolicyFamily | undefined): boolean {
237
246
  if (isLegacyKyvernoPolicy(resource)) {
238
- return getKyvernoPolicyAction(resource) === 'Enforce'
247
+ return getKyvernoEnforcement(resource).blocks
239
248
  }
240
249
  return getKyvernoEnforcementPosture(resource, family).blocks
241
250
  }
242
251
 
252
+ /**
253
+ * A policy declares a rejecting action yet does not block, because admission
254
+ * evaluation is switched off. The distinction changes the remediation the
255
+ * consequence sentence offers — re-enable admission, not switch the action —
256
+ * so it must not be conflated with an audit policy that simply never blocks.
257
+ *
258
+ * Only the validating families carry `validationActions`, and
259
+ * `getKyvernoValidationActions` applies Kyverno's absent-means-Deny default,
260
+ * so asking a mutating or generating policy would read Deny off a field that
261
+ * kind never has. The same guard `getKyvernoEnforcementPosture` uses.
262
+ */
263
+ function declaresBlockingButAdmissionOff(
264
+ resource: any,
265
+ family: KyvernoPolicyFamily | undefined,
266
+ ): boolean {
267
+ if (isLegacyKyvernoPolicy(resource)) {
268
+ return getKyvernoPolicyAction(resource) === 'Enforce' && !getKyvernoPolicyAdmission(resource)
269
+ }
270
+ if (family !== 'validating' && family !== 'imageValidating') return false
271
+ return getKyvernoValidationActions(resource).includes('Deny') && !isKyvernoAdmissionEnabled(resource)
272
+ }
273
+
243
274
  /**
244
275
  * Whether the consequence sentence can be stated at all.
245
276
  *
@@ -250,6 +281,11 @@ function blocksAdmission(resource: any, family: KyvernoPolicyFamily | undefined)
250
281
  */
251
282
  function canStateConsequence(resource: any, family: KyvernoPolicyFamily | undefined): boolean {
252
283
  if (!isLegacyKyvernoPolicy(resource)) {
284
+ // A JSON-mode policy is evaluated against payloads outside the cluster and
285
+ // never joins admission, so every phrasing of the sentence is false: it
286
+ // rejects nothing today, and neither switching the action nor enabling
287
+ // admission would change that. The posture badge already says "JSON mode".
288
+ if (getKyvernoEvaluationMode(resource) === 'JSON') return false
253
289
  return family === 'validating' || family === 'imageValidating' || family === undefined
254
290
  }
255
291
  const rules: any[] = resource?.spec?.rules ?? []
@@ -273,6 +309,7 @@ function enforcementConsequence(
273
309
  failing: number,
274
310
  blocks: boolean,
275
311
  matchesUpdate: boolean,
312
+ admissionOff = false,
276
313
  ): string {
277
314
  // Deliberately kind-neutral. A policy can match ConfigMaps, Secrets or
278
315
  // ClusterRoles as readily as Pods, and none of those "run" — the fact being
@@ -288,11 +325,24 @@ function enforcementConsequence(
288
325
  : `${exists} and ${one ? 'stays' : 'stay'} as ${itIs} — this policy only checks creation, so ${itIs} rejected only if recreated.`
289
326
  }
290
327
 
328
+ const target = one ? 'this resource' : `any of these ${failing}`
329
+
330
+ // The policy already declares a rejecting action; it does not block only
331
+ // because admission evaluation is disabled. Telling the operator to change
332
+ // the action would point at the wrong knob — the fix is to re-enable
333
+ // admission.
334
+ if (admissionOff) {
335
+ return matchesUpdate
336
+ ? `Nothing is rejected today because admission is disabled for this policy. Enabling admission would reject the next update to ${target}.`
337
+ : `Nothing is rejected today because admission is disabled for this policy. Enabling admission would leave ${
338
+ one ? 'this resource as it is' : `these ${failing} as they are`
339
+ } — only newly created resources would be rejected.`
340
+ }
341
+
291
342
  // "Set to enforce" is the legacy family's vocabulary; the modern API spells
292
343
  // the same thing `validationActions: [Deny]`, and the posture badge above
293
344
  // already says "Deny". Describing the behaviour rather than the field keeps
294
345
  // one sentence correct for both.
295
- const target = one ? 'this resource' : `any of these ${failing}`
296
346
  return matchesUpdate
297
347
  ? `Nothing is rejected today. Switching this policy to block would reject the next update to ${target}.`
298
348
  : `Nothing is rejected today. Switching this policy to block would leave ${
@@ -469,8 +519,14 @@ function Consequence({
469
519
  const failing = data.subjectsFailing ?? data.counts.fail
470
520
 
471
521
  if (failing > 0 && canStateConsequence(resource, family)) {
522
+ const blocks = blocksAdmission(resource, family)
472
523
  lines.push(
473
- enforcementConsequence(failing, blocksAdmission(resource, family), matchesUpdates(resource)),
524
+ enforcementConsequence(
525
+ failing,
526
+ blocks,
527
+ matchesUpdates(resource),
528
+ !blocks && declaresBlockingButAdmissionOff(resource, family),
529
+ ),
474
530
  )
475
531
  }
476
532
 
@@ -176,6 +176,53 @@ describe('what a storage location holds', () => {
176
176
  })
177
177
  })
178
178
 
179
+ /**
180
+ * Velero writes why a location is unavailable into `status.message`, and the
181
+ * BSL object carries no conditions, so that field is the only place the reason
182
+ * exists. A page opened from an unavailable-location problem has to show it -
183
+ * the same way the sibling BackupRepository renderer leads with its own reason.
184
+ */
185
+ describe('an unavailable location Velero gave a reason for', () => {
186
+ const bslMsg = (phase: string, message?: string) => ({
187
+ metadata: { name: 'dr-replica', namespace: 'velero' },
188
+ spec: { provider: 'aws', objectStorage: { bucket: 'dr' } },
189
+ status: { phase, ...(message !== undefined ? { message } : {}) },
190
+ })
191
+
192
+ it('leads with the reason Velero gave', () => {
193
+ const html = renderToString(
194
+ <VeleroBSLRenderer
195
+ data={bslMsg('Unavailable', 'BackupStorageLocation is invalid: rpc error: code = Unknown desc = NoSuchBucket')}
196
+ />,
197
+ )
198
+ expect(html).toContain('NoSuchBucket')
199
+ })
200
+
201
+ // Velero can set Unavailable with no message. Saying nothing would leave a red
202
+ // badge and no consequence, so the page states what unavailable costs.
203
+ it('names the consequence when Velero gave no reason', () => {
204
+ const html = renderToString(<VeleroBSLRenderer data={bslMsg('Unavailable')} />)
205
+ expect(html).toContain('cannot be written')
206
+ })
207
+
208
+ it('says nothing alarming when the location is available', () => {
209
+ const html = renderToString(
210
+ <VeleroBSLRenderer data={bslMsg('Available', 'stale message left on the object')} />,
211
+ )
212
+ expect(html).not.toContain('Storage Location Unavailable')
213
+ expect(html).not.toContain('stale message left on the object')
214
+ expect(html).not.toContain('cannot be written')
215
+ })
216
+
217
+ // A location Velero has not validated yet has no phase. It is not unavailable,
218
+ // so it gets no alarm even if a message lingers from a prior state.
219
+ it('raises no alarm on a location Velero has not reported on', () => {
220
+ const html = renderToString(<VeleroBSLRenderer data={bslMsg('', 'old message')} />)
221
+ expect(html).not.toContain('Storage Location Unavailable')
222
+ expect(html).not.toContain('old message')
223
+ })
224
+ })
225
+
179
226
  /**
180
227
  * The list counts what is stored; the restorable point is derived from what
181
228
  * completed. On a healthy location those are the only two numbers on the page,
@@ -1,5 +1,5 @@
1
1
  import { HardDrive, Clock, Archive } from 'lucide-react'
2
- import { Section, PropertyList, Property, ConditionsSection, RelationshipGroup } from '../../ui/drawer-components'
2
+ import { Section, PropertyList, Property, ConditionsSection, RelationshipGroup, AlertBanner } from '../../ui/drawer-components'
3
3
  import { TimeValue } from '../../ui/ScheduleValue'
4
4
  import {
5
5
  getBSLStatus,
@@ -11,6 +11,7 @@ import {
11
11
  getBSLAccessMode,
12
12
  getBSLLastValidation,
13
13
  getBSLLastSynced,
14
+ getBSLMessage,
14
15
  } from '../resource-utils-velero'
15
16
  import { VeleroPhaseValue } from './velero-cells'
16
17
 
@@ -66,6 +67,7 @@ export function VeleroBSLRenderer({ data, storedBackups, storedTotal, restorable
66
67
  const status = data.status || {}
67
68
  const conditions = status.conditions || []
68
69
  const bslStatus = getBSLStatus(data)
70
+ const message = getBSLMessage(data)
69
71
  const config = data.spec?.config || {}
70
72
  // Two things falsify "restorable", and the phase sees neither. A backup that
71
73
  // did complete is still not something to go back to once its TTL has passed —
@@ -90,6 +92,19 @@ export function VeleroBSLRenderer({ data, storedBackups, storedTotal, restorable
90
92
 
91
93
  return (
92
94
  <>
95
+ {/* The reason, not just the state. An unavailable location means Velero's
96
+ last validation of the bucket failed, and status.message is the only
97
+ place that reason exists — the object carries no conditions. */}
98
+ {bslStatus.level === 'unhealthy' && (
99
+ <AlertBanner
100
+ variant="error"
101
+ title="Storage Location Unavailable"
102
+ message={message
103
+ ? message
104
+ : 'Velero reported this location as unavailable and gave no reason. New backups cannot be written here, and backups already stored here cannot be restored from, while it stays that way.'}
105
+ />
106
+ )}
107
+
93
108
  {/* Status section */}
94
109
  <Section title="Status" icon={Clock} defaultExpanded>
95
110
  <PropertyList>
@@ -8,6 +8,12 @@ const restore = (status: Record<string, unknown>) => ({
8
8
  status,
9
9
  })
10
10
 
11
+ const scopedRestore = (spec: Record<string, unknown>) => ({
12
+ metadata: { name: 'live-restore', namespace: 'velero' },
13
+ spec: { backupName: 'live-completed', ...spec },
14
+ status: { phase: 'Completed' },
15
+ })
16
+
11
17
  /**
12
18
  * The restore side carries the same claims as the backup side and its own
13
19
  * wording for them, so pinning one does not pin the other. Both banners below
@@ -53,3 +59,85 @@ describe('what a restore claims about itself', () => {
53
59
  expect(html).toContain('Backup live-completed not found')
54
60
  })
55
61
  })
62
+
63
+ /**
64
+ * Scope is where an operator reads what a restore will touch and where it lands.
65
+ * namespaceMapping remaps source namespaces to different targets on restore, so
66
+ * omitting it hides where the data actually goes; a labelSelector narrows the
67
+ * restore to a subset of objects.
68
+ */
69
+ // react-dom/server inserts <!-- --> markers between adjacent text nodes, which
70
+ // splits a badge like `app=nginx` in the raw markup; strip them so assertions
71
+ // read the visible text.
72
+ const renderScope = (data: unknown) =>
73
+ renderToString(<VeleroRestoreRenderer data={data} />).replace(/<!-- -->/g, '')
74
+
75
+ describe('what a restore says it will touch', () => {
76
+ it('shows namespaceMapping as source to target', () => {
77
+ const html = renderScope(
78
+ scopedRestore({ namespaceMapping: { prod: 'prod-clone', staging: 'staging-clone' } }),
79
+ )
80
+ expect(html).toContain('Scope')
81
+ expect(html).toContain('Namespace Mapping')
82
+ expect(html).toContain('prod')
83
+ expect(html).toContain('prod-clone')
84
+ expect(html).toContain('staging-clone')
85
+ })
86
+
87
+ it('shows the label selector that narrows the restore', () => {
88
+ const html = renderScope(
89
+ scopedRestore({
90
+ labelSelector: {
91
+ matchLabels: { app: 'nginx' },
92
+ matchExpressions: [{ key: 'tier', operator: 'In', values: ['frontend'] }],
93
+ },
94
+ }),
95
+ )
96
+ expect(html).toContain('Scope')
97
+ expect(html).toContain('app=nginx')
98
+ expect(html).toContain('tier In frontend')
99
+ })
100
+
101
+ it('renders a Scope section for a restore filtered only by label selector', () => {
102
+ const html = renderScope(scopedRestore({ labelSelector: { matchLabels: { app: 'nginx' } } }))
103
+ expect(html).toContain('Scope')
104
+ expect(html).toContain('app=nginx')
105
+ })
106
+
107
+ it('shows orLabelSelectors joined by OR', () => {
108
+ const html = renderScope(
109
+ scopedRestore({
110
+ orLabelSelectors: [{ matchLabels: { app: 'nginx' } }, { matchLabels: { app: 'redis' } }],
111
+ }),
112
+ )
113
+ expect(html).toContain('Scope')
114
+ expect(html).toContain('app=nginx')
115
+ expect(html).toContain('app=redis')
116
+ })
117
+
118
+ it('shows included and excluded namespaces alongside mapping', () => {
119
+ const html = renderScope(
120
+ scopedRestore({
121
+ includedNamespaces: ['prod'],
122
+ excludedNamespaces: ['kube-system'],
123
+ namespaceMapping: { prod: 'prod-clone' },
124
+ }),
125
+ )
126
+ expect(html).toContain('Included Namespaces')
127
+ expect(html).toContain('Excluded Namespaces')
128
+ expect(html).toContain('kube-system')
129
+ expect(html).toContain('prod-clone')
130
+ })
131
+
132
+ it('renders no empty Scope section for an unscoped restore', () => {
133
+ const html = renderScope(scopedRestore({}))
134
+ expect(html).not.toContain('Scope')
135
+ })
136
+
137
+ // An empty selector matches everything, so it is not scope. Presenting it as
138
+ // scope would tell an operator a restore is filtered when it is not.
139
+ it('treats an empty label selector as unscoped', () => {
140
+ const html = renderScope(scopedRestore({ labelSelector: {}, orLabelSelectors: [{}] }))
141
+ expect(html).not.toContain('Scope')
142
+ })
143
+ })
@@ -1,5 +1,5 @@
1
1
  import { ArchiveRestore, Filter } from 'lucide-react'
2
- import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
2
+ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, LabelSelectorDisplay } from '../../ui/drawer-components'
3
3
  import {
4
4
  getRestoreStatus,
5
5
  getRestoreBackupName,
@@ -7,6 +7,10 @@ import {
7
7
  getRestoreExcludedNamespaces,
8
8
  getRestoreIncludedResources,
9
9
  getRestoreExcludedResources,
10
+ getRestoreNamespaceMapping,
11
+ getRestoreLabelSelector,
12
+ getRestoreOrLabelSelectors,
13
+ hasLabelSelectorTerms,
10
14
  getRestoreDuration,
11
15
  getRestoreErrors,
12
16
  getRestoreWarnings,
@@ -41,6 +45,19 @@ export function VeleroRestoreRenderer({ data, messages, onNavigate }: VeleroRest
41
45
  const excludedNamespaces = getRestoreExcludedNamespaces(data)
42
46
  const includedResources = getRestoreIncludedResources(data)
43
47
  const excludedResources = getRestoreExcludedResources(data)
48
+ const namespaceMapping = getRestoreNamespaceMapping(data)
49
+ const rawLabelSelector = getRestoreLabelSelector(data)
50
+ const labelSelector = hasLabelSelectorTerms(rawLabelSelector) ? rawLabelSelector : null
51
+ const orLabelSelectors = getRestoreOrLabelSelectors(data).filter(hasLabelSelectorTerms)
52
+ const namespaceMappingEntries = Object.entries(namespaceMapping)
53
+ const hasScope =
54
+ includedNamespaces.length > 0 ||
55
+ excludedNamespaces.length > 0 ||
56
+ includedResources.length > 0 ||
57
+ excludedResources.length > 0 ||
58
+ namespaceMappingEntries.length > 0 ||
59
+ Boolean(labelSelector) ||
60
+ orLabelSelectors.length > 0
44
61
 
45
62
  const phase = status.phase || ''
46
63
  const validationErrors = getRestoreValidationErrors(data)
@@ -148,7 +165,7 @@ export function VeleroRestoreRenderer({ data, messages, onNavigate }: VeleroRest
148
165
  )}
149
166
 
150
167
  {/* Scope section */}
151
- {(includedNamespaces.length > 0 || excludedNamespaces.length > 0 || includedResources.length > 0 || excludedResources.length > 0) && (
168
+ {hasScope && (
152
169
  <Section title="Scope" icon={Filter} defaultExpanded>
153
170
  <PropertyList>
154
171
  {includedNamespaces.length > 0 && (
@@ -172,6 +189,19 @@ export function VeleroRestoreRenderer({ data, messages, onNavigate }: VeleroRest
172
189
  </div>
173
190
  } />
174
191
  )}
192
+ {namespaceMappingEntries.length > 0 && (
193
+ <Property label="Namespace Mapping" value={
194
+ <div className="flex flex-col gap-1">
195
+ {namespaceMappingEntries.map(([source, target]) => (
196
+ <div key={source} className="flex items-center gap-1.5">
197
+ <span className="badge-sm bg-theme-hover text-theme-text-secondary">{source}</span>
198
+ <span className="text-theme-text-tertiary" aria-hidden="true">&rarr;</span>
199
+ <span className="badge-sm bg-theme-hover text-theme-text-secondary">{String(target)}</span>
200
+ </div>
201
+ ))}
202
+ </div>
203
+ } />
204
+ )}
175
205
  {includedResources.length > 0 && (
176
206
  <Property label="Included Resources" value={
177
207
  <div className="flex flex-wrap gap-1">
@@ -190,6 +220,18 @@ export function VeleroRestoreRenderer({ data, messages, onNavigate }: VeleroRest
190
220
  </div>
191
221
  } />
192
222
  )}
223
+ {labelSelector && (
224
+ <Property label="Label Selector" value={<LabelSelectorDisplay selector={labelSelector} />} />
225
+ )}
226
+ {orLabelSelectors.length > 0 && (
227
+ <Property label="Label Selectors (match any)" value={
228
+ <div className="flex flex-col gap-1">
229
+ {orLabelSelectors.map((sel: any, i: number) => (
230
+ <LabelSelectorDisplay key={i} selector={sel} />
231
+ ))}
232
+ </div>
233
+ } />
234
+ )}
193
235
  </PropertyList>
194
236
  </Section>
195
237
  )}
@@ -4,6 +4,7 @@ import { clsx } from 'clsx'
4
4
  import { Tooltip } from '../../ui/Tooltip'
5
5
  import {
6
6
  getCNPGClusterStatus,
7
+ getCNPGClusterAvailability,
7
8
  classifyCNPGClusterPhase,
8
9
  getCNPGClusterInstances,
9
10
  getCNPGClusterPrimary,
@@ -66,8 +67,18 @@ export function CNPGClusterCell({ resource, column }: { resource: any; column: s
66
67
  </Tooltip>
67
68
  )
68
69
  }
70
+ // Colour tracks the badge: a was-up total outage (readyInstances omitted,
71
+ // so readyKnown is false) reads red like its "Not Ready" badge rather than
72
+ // muted grey, and a partial shortfall stays yellow. Without the
73
+ // availability check the most severe count was the least emphasised.
74
+ const down = getCNPGClusterAvailability(resource) === 'down'
69
75
  return (
70
- <span className={clsx('text-sm', readyKnown && ready < desired ? 'text-yellow-400' : 'text-theme-text-secondary')}>
76
+ <span
77
+ className={clsx(
78
+ 'text-sm',
79
+ down ? 'text-red-400' : readyKnown && ready < desired ? 'text-yellow-400' : 'text-theme-text-secondary',
80
+ )}
81
+ >
71
82
  {instances}
72
83
  </span>
73
84
  )
@@ -6,7 +6,7 @@ import {
6
6
  getPolicyReportStatus,
7
7
  getPolicyReportSummary,
8
8
  getKyvernoPolicyStatus,
9
- getKyvernoPolicyAction,
9
+ getKyvernoEnforcement,
10
10
  getKyvernoPolicyRuleCount,
11
11
  } from '../resource-utils-kyverno'
12
12
  import {
@@ -79,13 +79,17 @@ export function KyvernoPolicyCell({ resource, column }: { resource: any; column:
79
79
  )
80
80
  }
81
81
  case 'action': {
82
- const action = getKyvernoPolicyAction(resource)
82
+ const { label, blocks, discrepancy } = getKyvernoEnforcement(resource)
83
83
  return (
84
84
  <span className={clsx(
85
85
  'badge',
86
- action === 'Enforce' ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400',
86
+ blocks
87
+ ? 'bg-red-500/20 text-red-400'
88
+ : discrepancy
89
+ ? 'bg-orange-500/20 text-orange-400'
90
+ : 'bg-yellow-500/20 text-yellow-400',
87
91
  )}>
88
- {action}
92
+ {label}
89
93
  </span>
90
94
  )
91
95
  }
@@ -27,6 +27,7 @@ interface GoldenCase {
27
27
  name: string
28
28
  spec: Record<string, unknown>
29
29
  status: Record<string, unknown>
30
+ metadata?: Record<string, unknown>
30
31
  badge: { text: string; level: string }
31
32
  issues: string[]
32
33
  worst: string
@@ -40,7 +41,7 @@ describe('CNPG golden matrix — badge side', () => {
40
41
  })
41
42
 
42
43
  it.each(cases.map((c) => [c.name, c] as const))('%s', (_name, tc) => {
43
- const badge = getCNPGClusterStatus({ spec: tc.spec, status: tc.status })
44
+ const badge = getCNPGClusterStatus({ metadata: tc.metadata, spec: tc.spec, status: tc.status })
44
45
  expect({ text: badge.text, level: badge.level }).toEqual(tc.badge)
45
46
  })
46
47
 
@@ -50,7 +51,7 @@ describe('CNPG golden matrix — badge side', () => {
50
51
  it.each(cases.map((c) => [c.name, c] as const))(
51
52
  'badge and issues do not contradict — %s',
52
53
  (_name, tc) => {
53
- const badge = getCNPGClusterStatus({ spec: tc.spec, status: tc.status })
54
+ const badge = getCNPGClusterStatus({ metadata: tc.metadata, spec: tc.spec, status: tc.status })
54
55
  if (badge.level === 'healthy') {
55
56
  expect(tc.issues, 'a healthy badge must not sit next to an issue').toEqual([])
56
57
  }
@@ -69,16 +70,23 @@ describe('CNPG golden matrix — badge side', () => {
69
70
  // This is the gap that let the bug through: the matrix already pinned
70
71
  // "readyInstances absent — unknown, not zero; must not fabricate an outage"
71
72
  // for the badge, and the cell rendered 0/N on that very case.
73
+ //
74
+ // The cell reads '-' exactly when the ready count is genuinely unknown: no
75
+ // explicit readyInstances AND no currentPrimary. Once a primary has been
76
+ // elected, an omitted readyInstances is a real 0 and the cell says so — which
77
+ // is what keeps "0/3" agreeing with the "Not Ready" badge on a was-up outage,
78
+ // while a still-bootstrapping cluster (no primary) keeps its dash.
72
79
  it.each(cases.map((c) => [c.name, c] as const))(
73
80
  'instances cell does not claim an outage the badge denies — %s',
74
81
  (_name, tc) => {
75
82
  const cell = getCNPGClusterInstances({ spec: tc.spec, status: tc.status })
76
83
  const ready = cell.split('/')[0]
77
- const readyReported = typeof tc.status.readyInstances === 'number'
84
+ const readyResolvable =
85
+ typeof tc.status.readyInstances === 'number' || !!tc.status.currentPrimary
78
86
  expect(
79
87
  ready === '-',
80
- `readyInstances ${readyReported ? 'is reported' : 'is absent'} but the cell reads ${cell}`,
81
- ).toBe(!readyReported)
88
+ `ready is ${readyResolvable ? 'resolvable' : 'unknown'} but the cell reads ${cell}`,
89
+ ).toBe(!readyResolvable)
82
90
  },
83
91
  )
84
92
  })