@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,939 @@
1
+ import { ShieldAlert, ShieldCheck, ShieldQuestion } from 'lucide-react'
2
+ import { clsx } from 'clsx'
3
+ import { useState } from 'react'
4
+ import { ChevronDown, ChevronRight } from 'lucide-react'
5
+ import { Section } from '../../ui/drawer-components'
6
+ import { SEVERITY_BADGE } from '../../../utils/badge-colors'
7
+ import { isForbiddenError } from '../../../types/fetch-error'
8
+ import { LookupFailureNote } from './LookupFailureNote'
9
+ import {
10
+ getKyvernoPolicyFamily,
11
+ getKyvernoEnforcementPosture,
12
+ getKyvernoResourceRules,
13
+ getKyvernoEffectiveFailurePolicy,
14
+ type KyvernoPolicyFamily,
15
+ } from '../resource-utils-kyverno-modern'
16
+ import { getKyvernoPolicyAction } from '../resource-utils-kyverno'
17
+ import type {
18
+ PolicyCoverageResponse,
19
+ PolicyCoverageRule,
20
+ PolicyCoverageSubject,
21
+ PolicyResourceCounts,
22
+ } from '../../../types/policy'
23
+
24
+ interface PolicyCoverageSectionProps {
25
+ /** The policy resource itself — enforcement consequence is a property of the
26
+ * policy's spec, not of the report data. */
27
+ resource: any
28
+ data: PolicyCoverageResponse | null
29
+ loading?: boolean
30
+ error?: Error | null
31
+ onSelectSubject?: (subject: PolicyCoverageSubject) => void
32
+ /**
33
+ * Re-fetch with a higher subject bound. Without it a capped rule ends at a
34
+ * sentence naming resources the reader cannot open. Hosts that cannot
35
+ * re-fetch omit it and keep the sentence.
36
+ */
37
+ onLoadMore?: () => void
38
+ loadingMore?: boolean
39
+ }
40
+
41
+ const TITLE = 'Resources'
42
+
43
+ // Matches RELATIONSHIP_TRUNCATE_LIMIT in drawer-components: the point at which
44
+ // a list of related resources folds behind "Show all N".
45
+ const SUBJECT_FOLD_LIMIT = 10
46
+
47
+ interface OutcomeWords {
48
+ /** Sentence form, e.g. "13 not mutated". */
49
+ bad: string
50
+ good: string
51
+ /** Badge form, e.g. "Not mutated". */
52
+ badBadge: string
53
+ goodBadge: string
54
+ }
55
+
56
+ /**
57
+ * Kyverno writes `result: fail` for every family and does not mean the same
58
+ * thing by it: a mutating policy's fail carries "mutation is not applied".
59
+ * Validating keeps the upstream words verbatim so the screen matches
60
+ * `kubectl get policyreport`.
61
+ */
62
+ function outcomeWords(family: KyvernoPolicyFamily | undefined): OutcomeWords {
63
+ switch (family) {
64
+ case 'mutating':
65
+ return { bad: 'not mutated', good: 'mutated', badBadge: 'Not mutated', goodBadge: 'Mutated' }
66
+ case 'generating':
67
+ // The subject of a generate result is the TRIGGER, not the object the
68
+ // rule produced — Kyverno records the resource that caused generation and
69
+ // never names the target here. "Generated" on that row states the exact
70
+ // opposite of what happened: it reads as "this ConfigMap was generated"
71
+ // about the ConfigMap that caused a NetworkPolicy to be generated. The
72
+ // wording has to keep the subject in its real role.
73
+ return {
74
+ bad: 'failed to generate',
75
+ good: 'triggered generation',
76
+ badBadge: 'Generate failed',
77
+ goodBadge: 'Triggered',
78
+ }
79
+ default:
80
+ return { bad: 'failing', good: 'passing', badBadge: 'Fail', goodBadge: 'Pass' }
81
+ }
82
+ }
83
+
84
+ function resultTone(result: string, words: OutcomeWords): { badge: string; label: string } {
85
+ switch (result.toLowerCase()) {
86
+ case 'fail':
87
+ return { badge: SEVERITY_BADGE.error, label: words.badBadge }
88
+ case 'pass':
89
+ return { badge: SEVERITY_BADGE.success, label: words.goodBadge }
90
+ // warn / error / skip mean the same thing whatever the family: the rule
91
+ // flagged it, the engine broke, or the rule declined to look.
92
+ case 'warn':
93
+ return { badge: SEVERITY_BADGE.warning, label: 'Warn' }
94
+ case 'error':
95
+ return { badge: SEVERITY_BADGE.alert, label: 'Error' }
96
+ case 'skip':
97
+ return { badge: SEVERITY_BADGE.neutral, label: 'Skipped' }
98
+ default:
99
+ return { badge: SEVERITY_BADGE.neutral, label: result }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * A CREATE-only policy never rejects a change to something that already exists,
105
+ * so resources failing it are grandfathered until recreated. Verified on 1.18.2:
106
+ * annotating a failing ConfigMap is admitted, creating an identical one is not.
107
+ */
108
+ function matchesUpdates(resource: any): boolean {
109
+ if (isLegacyKyvernoPolicy(resource)) return legacyMatchesUpdates(resource)
110
+ const rules = getKyvernoResourceRules(resource)
111
+ if (rules.length === 0) return true
112
+ return rules.some((r) => r.operations.length === 0 || r.operations.includes('UPDATE'))
113
+ }
114
+
115
+ /**
116
+ * Legacy operations live at `spec.rules[].match.any[].resources.operations`, not
117
+ * `spec.matchConstraints`; reading the modern shape finds no rules and defaults
118
+ * to "matches updates", stating the consequence backwards. Unset means both.
119
+ */
120
+ function legacyMatchesUpdates(resource: any): boolean {
121
+ return legacyRuleUpdateMatches(resource).some(Boolean)
122
+ }
123
+
124
+ /** One answer per rule, in `spec.rules` order. */
125
+ function legacyRuleUpdateMatches(resource: any): boolean[] {
126
+ const rules: any[] = resource?.spec?.rules ?? []
127
+ if (rules.length === 0) return [true]
128
+ return rules.map((rule: any) => {
129
+ const blocks = [
130
+ ...(rule?.match?.any ?? []),
131
+ ...(rule?.match?.all ?? []),
132
+ ...(rule?.match?.resources ? [rule.match] : []),
133
+ ]
134
+ if (blocks.length === 0) return true
135
+ return blocks.some((b: any) => {
136
+ const ops = b?.resources?.operations
137
+ return !Array.isArray(ops) || ops.length === 0 || ops.includes('UPDATE')
138
+ })
139
+ })
140
+ }
141
+
142
+ /** The consequence sentence is one claim over the whole failing count, so rules
143
+ * that disagree about updates make either answer false for some of them. */
144
+ function legacyOperationsAgree(resource: any): boolean {
145
+ const answers = legacyRuleUpdateMatches(resource)
146
+ return answers.every((a) => a === answers[0])
147
+ }
148
+
149
+ /** failureAction is per rule, and the sentence cannot say which rule a failure
150
+ * came from — so rules disagreeing on Audit vs Enforce make it a false promise. */
151
+ function legacyActionsAgree(resource: any): boolean {
152
+ const spec = resource?.spec?.validationFailureAction
153
+ const answers = (resource?.spec?.rules ?? [])
154
+ .filter((rule: any) => rule?.validate || rule?.verifyImages)
155
+ .map((rule: any) => {
156
+ const declared = [
157
+ rule?.validate?.failureAction,
158
+ ...(Array.isArray(rule?.verifyImages)
159
+ ? rule.verifyImages.map((v: any) => v?.failureAction)
160
+ : []),
161
+ ].filter(Boolean)
162
+ const effective = declared.length > 0 ? declared : [spec]
163
+ return effective.includes('Enforce')
164
+ })
165
+ return answers.every((a: boolean) => a === answers[0])
166
+ }
167
+
168
+ /**
169
+ * `results[].rule` is not always an authored rule name: the CEL family writes an
170
+ * empty one, and the literal `evaluation` when the engine failed (1.18.2 emits
171
+ * `rule: 'evaluation'` with `result: error`). A name is shown only when the
172
+ * policy declares it; otherwise the bucket is described by what it holds.
173
+ */
174
+ function ruleHeading(resource: any, rule: PolicyCoverageRule): string | undefined {
175
+ const name = rule.rule
176
+ if (!name) return undefined
177
+ const declared: string[] = (resource?.spec?.rules ?? [])
178
+ .map((r: any) => r?.name)
179
+ .filter(Boolean)
180
+ if (declared.includes(name)) return name
181
+ // Not a rule of this policy. If everything in it is an engine error, say so;
182
+ // anything else keeps the raw name rather than inventing a description.
183
+ const c = rule.counts
184
+ if (c.error > 0 && c.fail === 0 && c.pass === 0 && c.warn === 0 && c.skip === 0) {
185
+ return 'Engine errors'
186
+ }
187
+ return name
188
+ }
189
+
190
+ /** A legacy kyverno.io Policy / ClusterPolicy, as opposed to the modern CEL family. */
191
+ function isLegacyKyvernoPolicy(resource: any): boolean {
192
+ return typeof resource?.apiVersion === 'string' && resource.apiVersion.startsWith('kyverno.io/')
193
+ }
194
+
195
+ /** The family a single legacy rule belongs to, read from which block it carries. */
196
+ function legacyRuleFamily(rule: any): KyvernoPolicyFamily | undefined {
197
+ if (rule?.generate) return 'generating'
198
+ if (rule?.mutate) return 'mutating'
199
+ if (rule?.verifyImages) return 'imageValidating'
200
+ if (rule?.validate) return 'validating'
201
+ return undefined
202
+ }
203
+
204
+ /**
205
+ * The modern API puts the family in the kind; a legacy ClusterPolicy carries any
206
+ * mix of rules, so reading the kind alone falls back to Pass/Fail and calls a
207
+ * generate rule "failed". Without a rule name, only a policy whose rules agree
208
+ * has an answer.
209
+ */
210
+ function policyFamilyFor(
211
+ resource: any,
212
+ ruleName?: string,
213
+ ): KyvernoPolicyFamily | undefined {
214
+ if (!isLegacyKyvernoPolicy(resource)) return getKyvernoPolicyFamily(resource?.kind)
215
+ const rules: any[] = resource?.spec?.rules ?? []
216
+ if (rules.length === 0) return undefined
217
+ if (ruleName) {
218
+ // Autogen rules carry the source rule's block, but Kyverno names them
219
+ // `autogen-<rule>` (and `autogen-cronjob-<rule>`), so the report's rule
220
+ // name does not match `spec.rules[].name` verbatim.
221
+ const base = ruleName.replace(/^autogen-(cronjob-)?/, '')
222
+ const rule = rules.find((r) => r?.name === ruleName) ?? rules.find((r) => r?.name === base)
223
+ if (rule) return legacyRuleFamily(rule)
224
+ }
225
+ const families = rules.map(legacyRuleFamily)
226
+ const first = families[0]
227
+ return families.every((f) => f === first) ? first : undefined
228
+ }
229
+
230
+ /**
231
+ * 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.
235
+ */
236
+ function blocksAdmission(resource: any, family: KyvernoPolicyFamily | undefined): boolean {
237
+ if (isLegacyKyvernoPolicy(resource)) {
238
+ return getKyvernoPolicyAction(resource) === 'Enforce'
239
+ }
240
+ return getKyvernoEnforcementPosture(resource, family).blocks
241
+ }
242
+
243
+ /**
244
+ * Whether the consequence sentence can be stated at all.
245
+ *
246
+ * A legacy ClusterPolicy can carry validate, mutate and generate rules at once,
247
+ * so "what enforcing does" has no single answer. Rather than pick one and be
248
+ * confidently wrong, the sentence is withheld and the counts speak for
249
+ * themselves.
250
+ */
251
+ function canStateConsequence(resource: any, family: KyvernoPolicyFamily | undefined): boolean {
252
+ if (!isLegacyKyvernoPolicy(resource)) {
253
+ return family === 'validating' || family === 'imageValidating' || family === undefined
254
+ }
255
+ const rules: any[] = resource?.spec?.rules ?? []
256
+ if (rules.length === 0) return false
257
+ // Every rule has to validate AND agree on whether updates are matched AND on
258
+ // whether failing is blocking; the sentence is one claim about one failing
259
+ // count and cannot straddle any of the three.
260
+ return (
261
+ rules.every((rule) => !!rule?.validate) &&
262
+ legacyOperationsAgree(resource) &&
263
+ legacyActionsAgree(resource)
264
+ )
265
+ }
266
+
267
+ /**
268
+ * Both plausible phrasings are false: admission acts on requests, not on the
269
+ * resources that already exist, and "their next update is rejected" holds only
270
+ * when the policy matches UPDATE. Exported so the wording is pinned directly.
271
+ */
272
+ function enforcementConsequence(
273
+ failing: number,
274
+ blocks: boolean,
275
+ matchesUpdate: boolean,
276
+ ): string {
277
+ // Deliberately kind-neutral. A policy can match ConfigMaps, Secrets or
278
+ // ClusterRoles as readily as Pods, and none of those "run" — the fact being
279
+ // stated is that the resource already exists, not that it is executing.
280
+ const one = failing === 1
281
+ const exists = one ? '1 resource already exists' : `${failing} resources already exist`
282
+ const it = one ? 'it' : 'them'
283
+ const itIs = one ? 'it is' : 'they are'
284
+
285
+ if (blocks) {
286
+ return matchesUpdate
287
+ ? `${exists}, so this policy does not affect ${it} today. The next update to ${one ? 'it' : 'any of them'} will be rejected.`
288
+ : `${exists} and ${one ? 'stays' : 'stay'} as ${itIs} — this policy only checks creation, so ${itIs} rejected only if recreated.`
289
+ }
290
+
291
+ // "Set to enforce" is the legacy family's vocabulary; the modern API spells
292
+ // the same thing `validationActions: [Deny]`, and the posture badge above
293
+ // already says "Deny". Describing the behaviour rather than the field keeps
294
+ // one sentence correct for both.
295
+ const target = one ? 'this resource' : `any of these ${failing}`
296
+ return matchesUpdate
297
+ ? `Nothing is rejected today. Switching this policy to block would reject the next update to ${target}.`
298
+ : `Nothing is rejected today. Switching this policy to block would leave ${
299
+ one ? 'this resource as it is' : `these ${failing} as they are`
300
+ } — only newly created resources would be rejected.`
301
+ }
302
+
303
+ export function PolicyCoverageSection({
304
+ resource,
305
+ data,
306
+ loading,
307
+ error,
308
+ onSelectSubject,
309
+ onLoadMore,
310
+ loadingMore,
311
+ }: PolicyCoverageSectionProps) {
312
+ if (loading) {
313
+ return (
314
+ <Section title={TITLE} icon={ShieldQuestion}>
315
+ <div className="text-sm text-theme-text-tertiary">Loading policy results…</div>
316
+ </Section>
317
+ )
318
+ }
319
+
320
+ if (error) {
321
+ // Same split as every other lookup on these screens, from the component that
322
+ // owns it — a denial is expected and reads calm, a fault stays loud. Repeating
323
+ // the predicate here is how the two drift into disagreeing about the same error.
324
+ return (
325
+ <Section title={TITLE} icon={isForbiddenError(error) ? ShieldQuestion : ShieldAlert}>
326
+ <LookupFailureNote errors={[error]} what="policy results" />
327
+ </Section>
328
+ )
329
+ }
330
+
331
+ if (!data || data.status === 'not_installed') return null
332
+
333
+ if (!data.evaluated) {
334
+ return (
335
+ <Section title={TITLE} icon={ShieldQuestion}>
336
+ <div className="text-sm text-theme-text-tertiary">
337
+ {(data.status === 'warmup'
338
+ ? 'Policy results are still loading from the cluster.'
339
+ : 'Policy results are unavailable, so nothing has been checked against this policy.') +
340
+ (data.reasonCode ? ` (${data.reasonCode})` : '')}
341
+ </div>
342
+ </Section>
343
+ )
344
+ }
345
+
346
+ const family = policyFamilyFor(resource)
347
+ const words = outcomeWords(family)
348
+ const { counts } = data
349
+ const examined = counts.pass + counts.fail + counts.warn + counts.error + counts.skip
350
+
351
+ if (examined === 0) {
352
+ const withheld = anythingWithheld(data)
353
+ return (
354
+ <Section title={TITLE} icon={ShieldQuestion}>
355
+ <div className="text-sm text-theme-text-tertiary">
356
+ {withheld
357
+ ? 'No results to show here.'
358
+ : 'Nothing has been checked against this policy yet.'}
359
+ </div>
360
+ <ScopeNote data={data} />
361
+ </Section>
362
+ )
363
+ }
364
+
365
+ const bad = problemCount(counts)
366
+ return (
367
+ <Section title={TITLE} icon={bad > 0 ? ShieldAlert : ShieldCheck} defaultExpanded={bad > 0}>
368
+ <Headline data={data} words={words} />
369
+ <ForeignEngines data={data} />
370
+ <Consequence resource={resource} data={data} family={family} />
371
+ <div className="mt-3 space-y-3">
372
+ {data.rules.map((rule, i) => (
373
+ <RuleBlock
374
+ key={rule.rule || `unnamed-${i}`}
375
+ rule={rule}
376
+ heading={ruleHeading(resource, rule)}
377
+ partial={anythingWithheld(data)}
378
+ onLoadMore={onLoadMore}
379
+ loadingMore={loadingMore}
380
+ // Per rule, because one legacy policy can validate, mutate and
381
+ // generate at once and each means something different by "fail".
382
+ words={outcomeWords(policyFamilyFor(resource, rule.rule))}
383
+ onSelectSubject={onSelectSubject}
384
+ />
385
+ ))}
386
+ </div>
387
+ <ScopeNote data={data} />
388
+ </Section>
389
+ )
390
+ }
391
+
392
+ /**
393
+ * `results[].policy` is producer-defined — Trivy and Falco write their own
394
+ * identifiers into it — and the lookup keys on that string, so a same-named
395
+ * producer's results land here. Silent when one engine owns the page.
396
+ */
397
+ function ForeignEngines({ data }: { data: PolicyCoverageResponse }) {
398
+ const engines = (data.engines ?? []).filter((e) => e !== 'kyverno' && e !== 'unknown')
399
+ if (engines.length === 0) return null
400
+ return (
401
+ <div className="mt-2 text-sm text-theme-text-secondary">
402
+ {`Some of these results were written by ${engines.join(', ')}, not by Kyverno — the reports key them under the same name.`}
403
+ </div>
404
+ )
405
+ }
406
+
407
+ /**
408
+ * Scope before numbers. A total the caller cannot actually see is a lie of
409
+ * omission, so the sentence names the ground it covers first.
410
+ */
411
+ function Headline({
412
+ data,
413
+ words,
414
+ }: {
415
+ data: PolicyCoverageResponse
416
+ words: OutcomeWords
417
+ }) {
418
+ const { counts } = data
419
+ const parts: string[] = []
420
+ if (counts.fail > 0) parts.push(`${counts.fail} ${words.bad}`)
421
+ if (counts.warn > 0) parts.push(`${counts.warn} warned`)
422
+ if (counts.pass > 0) parts.push(`${counts.pass} ${words.good}`)
423
+ if (counts.error > 0) parts.push(`${counts.error} could not be evaluated`)
424
+ if (counts.skip > 0) parts.push(`${counts.skip} skipped`)
425
+
426
+ const scope =
427
+ data.scopeNamespaces > 0
428
+ ? `Across ${data.scopeNamespaces} ${data.scopeNamespaces === 1 ? 'namespace' : 'namespaces'}${
429
+ data.clusterScoped ? ' and cluster-scoped resources' : ''
430
+ }`
431
+ : data.clusterScoped
432
+ ? 'Across cluster-scoped resources'
433
+ : 'Across the resources you can read'
434
+
435
+ // Leading with the examined total bounds the claim to what the engine looked
436
+ // at, not the cluster.
437
+ const examined = data.examined || 0
438
+ const examinedPhrase = examinedSummary(examined, data.subjects ?? examined)
439
+ return (
440
+ <div className="text-sm text-theme-text-secondary">
441
+ {scope}, {examinedPhrase} — {parts.join(', ')}.
442
+ </div>
443
+ )
444
+ }
445
+
446
+ /**
447
+ * What enforcing this policy would actually do.
448
+ *
449
+ * Two claims are tempting and both are false: that enforcing blocks the
450
+ * resources already failing (it does not — they are left alone), and that their
451
+ * next update is rejected (only if the policy matches UPDATE). The honest answer
452
+ * depends on the policy's declared operations, which is why this reads the spec
453
+ * rather than the report.
454
+ */
455
+ function Consequence({
456
+ resource,
457
+ data,
458
+ family,
459
+ }: {
460
+ resource: any
461
+ data: PolicyCoverageResponse
462
+ family: KyvernoPolicyFamily | undefined
463
+ }) {
464
+ const lines: string[] = []
465
+ // This sentence counts resources — "N already exist", "the next update to any
466
+ // of them" — so it needs the resources, not the outcomes. One resource failing
467
+ // two of the policy's rules is one thing that exists and one thing that gets
468
+ // rejected, however many times it was checked.
469
+ const failing = data.subjectsFailing ?? data.counts.fail
470
+
471
+ if (failing > 0 && canStateConsequence(resource, family)) {
472
+ lines.push(
473
+ enforcementConsequence(failing, blocksAdmission(resource, family), matchesUpdates(resource)),
474
+ )
475
+ }
476
+
477
+ if (data.counts.error > 0) {
478
+ lines.push(engineErrorLine(data.counts.error, getKyvernoEffectiveFailurePolicy(resource) === 'Fail'))
479
+ }
480
+
481
+ if (lines.length === 0) return null
482
+ return (
483
+ <div className="mt-2 text-sm text-theme-text-primary space-y-1">
484
+ {lines.map((l, i) => (
485
+ <div key={i}>{l}</div>
486
+ ))}
487
+ </div>
488
+ )
489
+ }
490
+
491
+ /** An engine error is not a pass — the rule reached no verdict. `skip` stays
492
+ * out: a rule that did not apply is not a problem with anything. */
493
+ function problemCount(counts: PolicyResourceCounts): number {
494
+ return counts.fail + counts.warn + counts.error
495
+ }
496
+
497
+ /**
498
+ * Missing results are dropped before counting, so an examined count of zero
499
+ * means either "nothing was checked" or "something did not arrive". Which one is
500
+ * ScopeNote's to say; this only decides whether absence can be claimed at all.
501
+ */
502
+ function anythingWithheld(data: PolicyCoverageResponse): boolean {
503
+ return (
504
+ (data.withheldByFamily ?? 0) > 0 ||
505
+ data.withheldNamespaces > 0 ||
506
+ !!data.withheldClusterScoped ||
507
+ (data.deniedGroups ?? []).length > 0
508
+ )
509
+ }
510
+
511
+ /**
512
+ * Whether the cap removed rows that would have been listed. `rule.truncated` is
513
+ * too blunt (250 passing + 1 failing is capped, yet everything listable
514
+ * arrived), and rows the view filter withheld are not the cap's doing — counting
515
+ * those offers a "Load the rest" no higher limit can satisfy.
516
+ */
517
+ function capWorthMentioning(
518
+ notableListed: number,
519
+ notableTotal: number,
520
+ notableHidden: number,
521
+ ): boolean {
522
+ return notableListed + notableHidden < notableTotal
523
+ }
524
+
525
+ /** Outcomes and resources are different counts: one resource matched by two
526
+ * rules records two outcomes. Named apart only when they differ. */
527
+ function examinedSummary(examined: number, subjects: number): string {
528
+ const res = (n: number) => `${n} ${n === 1 ? 'resource' : 'resources'}`
529
+ if (subjects === examined) return `${res(examined)} examined`
530
+ return `${examined} ${examined === 1 ? 'check' : 'checks'} on ${res(subjects)}`
531
+ }
532
+
533
+ /** A rule that could not evaluate reached no verdict, so what happens next
534
+ * depends on the policy's failurePolicy rather than on the result. */
535
+ function engineErrorLine(n: number, failClosed: boolean): string {
536
+ if (failClosed) {
537
+ return `${n} could not be evaluated. This policy fails closed, so ${
538
+ n === 1 ? 'that admission is' : 'those admissions are'
539
+ } rejected for an engine error rather than a violation.`
540
+ }
541
+ return `${n} could not be evaluated and ${n === 1 ? 'is' : 'are'} being allowed through unchecked.`
542
+ }
543
+
544
+ /** "Show all N" is a promise the client cannot keep once the server cap bit. */
545
+ function foldLabel(listed: number, total: number, capped: boolean): string {
546
+ return capped ? `Show ${listed} of ${total}` : `Show all ${listed}`
547
+ }
548
+
549
+ /**
550
+ * `counts` describe the cluster, lists describe the view, so the cluster-wide
551
+ * count cannot label a list. Only non-passing subjects are listed, so everything
552
+ * withheld that was not notable was a pass — exact, not inferred.
553
+ */
554
+ function passingInView(passTotal: number, hiddenByFilter: number, hiddenNotable: number): number {
555
+ return Math.max(0, passTotal - Math.max(0, hiddenByFilter - hiddenNotable))
556
+ }
557
+
558
+ /** "All" holds only when nothing is outside the view; next to a footer naming
559
+ * hidden subjects it invites the count to be read as the total. `partial` is
560
+ * the other way the set can be short — a namespace or a report family nobody
561
+ * put in front of this caller — which the counts silently exclude, so the word
562
+ * would be claiming completeness the footer goes on to deny. */
563
+ function passingLabel(
564
+ count: number,
565
+ hiddenByFilter: number,
566
+ words: OutcomeWords,
567
+ partial = false,
568
+ ): string {
569
+ const noun = count === 1 ? 'resource' : 'resources'
570
+ if (hiddenByFilter > 0) return `${count} ${noun} ${words.good} in view`
571
+ return partial ? `${count} ${noun} ${words.good}` : `All ${count} ${noun} ${words.good}`
572
+ }
573
+
574
+ function RuleBlock({
575
+ rule,
576
+ heading,
577
+ words,
578
+ partial,
579
+ onSelectSubject,
580
+ onLoadMore,
581
+ loadingMore,
582
+ }: {
583
+ rule: PolicyCoverageRule
584
+ /** Undefined when this bucket has no name worth showing. */
585
+ heading?: string
586
+ words: OutcomeWords
587
+ /** Findings are missing from the whole response, so no rule may claim "All". */
588
+ partial?: boolean
589
+ onSelectSubject?: (subject: PolicyCoverageSubject) => void
590
+ onLoadMore?: () => void
591
+ loadingMore?: boolean
592
+ }) {
593
+ // Passing subjects are collapsed, not discarded: a policy matching every pod
594
+ // in the cluster would bury its failures under a wall of green, but "which
595
+ // resources satisfy this rule" is half the question the view exists to answer
596
+ // and a bare count cannot be clicked, checked, or navigated from.
597
+ const notable = rule.subjects.filter((s) => s.result.toLowerCase() !== 'pass')
598
+ const passingSubjects = rule.subjects.filter((s) => s.result.toLowerCase() === 'pass')
599
+ const passing = passingInView(rule.counts.pass, rule.hiddenByFilter ?? 0, rule.hiddenNotable ?? 0)
600
+
601
+ // Same shape as RelationshipGroup: fold at a readable length, then expand in
602
+ // place. The house convention never states a count the reader cannot open.
603
+ const [showAll, setShowAll] = useState(false)
604
+ const folded = !showAll && notable.length > SUBJECT_FOLD_LIMIT
605
+ // Everything the rule flagged, whether or not it survived the server cap.
606
+ const notableTotal = rule.counts.fail + rule.counts.warn + rule.counts.error + rule.counts.skip
607
+ const notableCapped = capWorthMentioning(notable.length, notableTotal, rule.hiddenNotable ?? 0)
608
+ const visible = folded ? notable.slice(0, SUBJECT_FOLD_LIMIT) : notable
609
+
610
+ return (
611
+ <div>
612
+ {heading && (
613
+ <div className="text-xs font-medium text-theme-text-tertiary mb-1">{heading}</div>
614
+ )}
615
+ {notable.length === 0 ? (
616
+ /* Two claims to get wrong at once. "All" is only true when the rule
617
+ has nothing outside the view — with subjects held back it invites
618
+ the count to be read as the total. And a count of zero is not a
619
+ statement worth making: "All 0 resources passing" appears exactly
620
+ when the filter withheld everything, which the footer says properly. */
621
+ passing > 0 && (
622
+ <PassingGroup
623
+ label={passingLabel(passing, rule.hiddenByFilter ?? 0, words, partial)}
624
+ subjects={passingSubjects}
625
+ total={passing}
626
+ words={words}
627
+ onSelectSubject={onSelectSubject}
628
+ onLoadMore={onLoadMore}
629
+ loadingMore={loadingMore}
630
+ />
631
+ )
632
+ ) : (
633
+ <div className="space-y-1.5">
634
+ {visible.map((s, i) => (
635
+ <SubjectRow key={`${s.namespace}/${s.kind}/${s.name}/${i}`} subject={s} words={words} onSelect={onSelectSubject} />
636
+ ))}
637
+ {folded && (
638
+ <button
639
+ type="button"
640
+ onClick={() => setShowAll(true)}
641
+ className="px-2 py-0.5 text-xs rounded border border-theme-border text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated transition-colors"
642
+ >
643
+ {/* "Show all N" is the house wording, but it is only true when the
644
+ client holds every subject. Once the server cap has bitten,
645
+ the button can only offer what arrived. */}
646
+ {foldLabel(notable.length, notableTotal, notableCapped)}
647
+ </button>
648
+ )}
649
+ {passing > 0 && (
650
+ <PassingGroup
651
+ label={`${passing} other ${passing === 1 ? 'resource' : 'resources'} ${words.good}`}
652
+ subjects={passingSubjects}
653
+ total={passing}
654
+ words={words}
655
+ onSelectSubject={onSelectSubject}
656
+ onLoadMore={onLoadMore}
657
+ loadingMore={loadingMore}
658
+ />
659
+ )}
660
+ </div>
661
+ )}
662
+ <RuleFooter
663
+ rule={rule}
664
+ notableCapped={notableCapped}
665
+ words={words}
666
+ onLoadMore={onLoadMore}
667
+ loadingMore={loadingMore}
668
+ />
669
+ </div>
670
+ )
671
+ }
672
+
673
+ /**
674
+ * The word for what the hidden subjects ARE. Kyverno files engine errors under
675
+ * a rule of its own, and that bucket holds nothing else — so the family's fail
676
+ * word ("not mutated" for a mutating policy) names the one thing that did not
677
+ * happen to them, and the screen ends up claiming more failures than its own
678
+ * summary counted.
679
+ */
680
+ function hiddenNotableWord(counts: PolicyResourceCounts, words: OutcomeWords): string {
681
+ const notable = counts.fail + counts.warn + counts.error + counts.skip
682
+ return notable > 0 && counts.error === notable ? 'could not be evaluated' : words.bad
683
+ }
684
+
685
+ /**
686
+ * What is not on screen. The count describes the rows actually rendered — only
687
+ * non-passing subjects are listed, so the denominator is that population and a
688
+ * rule where everything passes announces no truncation over its zero rows.
689
+ */
690
+ function RuleFooter({
691
+ rule,
692
+ notableCapped,
693
+ words,
694
+ onLoadMore,
695
+ loadingMore,
696
+ }: {
697
+ rule: PolicyCoverageRule
698
+ /** True when the server cap removed subjects that would have been listed. */
699
+ notableCapped: boolean
700
+ words: OutcomeWords
701
+ onLoadMore?: () => void
702
+ loadingMore?: boolean
703
+ }) {
704
+ const hidden = rule.hiddenByFilter ?? 0
705
+ // Client-side folding is already announced by the "Show all N" button, which
706
+ // is where the house pattern puts it — repeating it here said the same thing
707
+ // twice. What the button CANNOT say is that the server capped the list, in
708
+ // which case "Show all" would be a promise the client cannot keep.
709
+ if (!notableCapped && hidden === 0) return null
710
+ const parts: string[] = []
711
+ if (notableCapped) {
712
+ parts.push(`only the first ${rule.subjects.length} are available here`)
713
+ }
714
+ if (hidden > 0) {
715
+ // Whether any of the withheld subjects were failing is the part a reader
716
+ // acts on — it is the difference between a filter trimming noise and a
717
+ // filter covering a problem.
718
+ const notableHidden = rule.hiddenNotable ?? 0
719
+ parts.push(
720
+ notableHidden > 0
721
+ ? `${hidden} hidden by your namespace filter, ${notableHidden} of them ${hiddenNotableWord(rule.counts, words)}`
722
+ : `${hidden} hidden by your namespace filter`,
723
+ )
724
+ }
725
+ return (
726
+ <div className="flex flex-wrap items-baseline gap-2 pt-1">
727
+ <span className="text-xs text-theme-text-tertiary">{parts.join(', ')}</span>
728
+ {/* The cap is the only part of this the reader can do something about,
729
+ and until there was a way to lift it the sentence was the end of the
730
+ road. */}
731
+ {notableCapped && onLoadMore && (
732
+ <button
733
+ type="button"
734
+ disabled={loadingMore}
735
+ onClick={onLoadMore}
736
+ className="px-2 py-0.5 text-xs rounded border border-theme-border text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated transition-colors disabled:opacity-60"
737
+ >
738
+ {loadingMore ? 'Loading…' : 'Load the rest'}
739
+ </button>
740
+ )}
741
+ </div>
742
+ )
743
+ }
744
+
745
+ /**
746
+ * The resources that satisfy the rule.
747
+ *
748
+ * Collapsed by default — a passing list is usually long and rarely the reason
749
+ * someone opened the page — but never reduced to a bare number. "34 passing"
750
+ * with no way to see the 34 answers half the question this view exists for and
751
+ * leaves the reader no route to any of them; the rows are the same clickable
752
+ * subjects the failures use.
753
+ */
754
+ function PassingGroup({
755
+ label,
756
+ subjects,
757
+ total,
758
+ words,
759
+ onSelectSubject,
760
+ onLoadMore,
761
+ loadingMore,
762
+ }: {
763
+ label: string
764
+ /** Passing subjects that arrived. May be shorter than `total` if the server capped. */
765
+ subjects: PolicyCoverageSubject[]
766
+ /** Exact count of passing subjects in view, unaffected by the cap — so a
767
+ * shortfall against `subjects` is the cap and nothing else. */
768
+ total: number
769
+ words: OutcomeWords
770
+ onSelectSubject?: (subject: PolicyCoverageSubject) => void
771
+ onLoadMore?: () => void
772
+ loadingMore?: boolean
773
+ }) {
774
+ const [open, setOpen] = useState(false)
775
+ const [showAll, setShowAll] = useState(false)
776
+
777
+ // Nothing to open: the count is real but the subjects did not survive the
778
+ // server cap, so the label stands alone rather than offering an empty drawer.
779
+ if (subjects.length === 0) {
780
+ return <div className="text-xs text-theme-text-tertiary pt-0.5">{label}</div>
781
+ }
782
+
783
+ const folded = !showAll && subjects.length > SUBJECT_FOLD_LIMIT
784
+ const visible = folded ? subjects.slice(0, SUBJECT_FOLD_LIMIT) : subjects
785
+ const capped = subjects.length < total
786
+
787
+ return (
788
+ <div className="pt-0.5">
789
+ <button
790
+ type="button"
791
+ onClick={() => setOpen(!open)}
792
+ className="flex items-center gap-1 text-xs text-theme-text-tertiary hover:text-theme-text-secondary"
793
+ >
794
+ {open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
795
+ {label}
796
+ </button>
797
+ {open && (
798
+ <div className="space-y-1.5 mt-1.5">
799
+ {visible.map((s, i) => (
800
+ <SubjectRow
801
+ key={`${s.namespace}/${s.kind}/${s.name}/${i}`}
802
+ subject={s}
803
+ words={words}
804
+ onSelect={onSelectSubject}
805
+ />
806
+ ))}
807
+ {folded && (
808
+ <button
809
+ type="button"
810
+ onClick={() => setShowAll(true)}
811
+ className="px-2 py-0.5 text-xs rounded border border-theme-border text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated transition-colors"
812
+ >
813
+ {capped ? `Show ${subjects.length} of ${total}` : `Show all ${subjects.length}`}
814
+ </button>
815
+ )}
816
+ {capped && !folded && (
817
+ <div className="flex flex-wrap items-baseline gap-2">
818
+ <span className="text-xs text-theme-text-tertiary">
819
+ {`only the first ${subjects.length} are available here`}
820
+ </span>
821
+ {/* The same offer the failing list gets. A rule where everything
822
+ passes is capped just as readily, and without this the count
823
+ names resources the reader has no way to reach — which is the
824
+ dead end the whole section exists to close, on its quiet half. */}
825
+ {onLoadMore && (
826
+ <button
827
+ type="button"
828
+ disabled={loadingMore}
829
+ onClick={onLoadMore}
830
+ className="px-2 py-0.5 text-xs rounded border border-theme-border text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated transition-colors disabled:opacity-60"
831
+ >
832
+ {loadingMore ? 'Loading…' : 'Load the rest'}
833
+ </button>
834
+ )}
835
+ </div>
836
+ )}
837
+ </div>
838
+ )}
839
+ </div>
840
+ )
841
+ }
842
+
843
+ function SubjectRow({
844
+ subject,
845
+ words,
846
+ onSelect,
847
+ }: {
848
+ subject: PolicyCoverageSubject
849
+ words: OutcomeWords
850
+ onSelect?: (subject: PolicyCoverageSubject) => void
851
+ }) {
852
+ const tone = resultTone(subject.result, words)
853
+ const label = subject.namespace ? `${subject.namespace}/${subject.name}` : subject.name
854
+ const body = (
855
+ <>
856
+ <span className={clsx('badge shrink-0', tone.badge)} title={`Report result: ${subject.result}`}>
857
+ {tone.label}
858
+ </span>
859
+ <div className="min-w-0">
860
+ <div className="text-sm text-theme-text-primary truncate">
861
+ <span className="text-theme-text-tertiary">{subject.kind}</span> {label}
862
+ {subject.autogen && (
863
+ <span
864
+ className="text-theme-text-tertiary"
865
+ title="Matched through a rule Kyverno generated from your Pod rule"
866
+ >
867
+ {' · via controller'}
868
+ </span>
869
+ )}
870
+ </div>
871
+ {subject.message && (
872
+ <div className="text-xs text-theme-text-secondary">{subject.message}</div>
873
+ )}
874
+ </div>
875
+ </>
876
+ )
877
+
878
+ if (!onSelect) {
879
+ return <div className="flex items-start gap-2 min-w-0">{body}</div>
880
+ }
881
+ return (
882
+ <button
883
+ type="button"
884
+ onClick={() => onSelect(subject)}
885
+ className="flex items-start gap-2 min-w-0 w-full text-left hover:bg-theme-hover rounded px-1 -mx-1 py-0.5"
886
+ >
887
+ {body}
888
+ </button>
889
+ )
890
+ }
891
+
892
+ /**
893
+ * Three independent ways this answer can be incomplete: report families the
894
+ * caller cannot read, namespaces the caller cannot read, and an index that is no
895
+ * longer updating. Silence on any of them turns a partial view into an implied
896
+ * all-clear.
897
+ */
898
+ function ScopeNote({ data }: { data: PolicyCoverageResponse }) {
899
+ const denied = data.deniedGroups ?? []
900
+ const unreadable = data.unreadableFamilies ?? []
901
+ const withheldByFamily = data.withheldByFamily ?? 0
902
+ if (
903
+ denied.length === 0 &&
904
+ unreadable.length === 0 &&
905
+ data.withheldNamespaces === 0 &&
906
+ !data.withheldClusterScoped &&
907
+ data.liveUpdates
908
+ ) {
909
+ return null
910
+ }
911
+ return (
912
+ <div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-tertiary space-y-0.5">
913
+ {data.withheldNamespaces > 0 && (
914
+ <div>
915
+ {`${data.withheldNamespaces} ${
916
+ data.withheldNamespaces === 1 ? 'namespace is' : 'namespaces are'
917
+ } not shown because you can’t read policy results there.`}
918
+ </div>
919
+ )}
920
+ {data.withheldClusterScoped && (
921
+ <div>Cluster-scoped results are not shown because you can’t read policy reports at cluster scope.</div>
922
+ )}
923
+ {unreadable.length > 0 && (
924
+ <div>
925
+ {`You can’t read ${unreadable.join(', ')}, so ${withheldByFamily === 1 ? '1 result was' : `${withheldByFamily} results were`} left out of this list.`}
926
+ </div>
927
+ )}
928
+ {denied.length > 0 && (
929
+ <div>{`Some policy results could not be read (${denied.join(', ')}), so this list may be incomplete.`}</div>
930
+ )}
931
+ {!data.liveUpdates && <div>These results are not updating live.</div>}
932
+ </div>
933
+ )
934
+ }
935
+
936
+
937
+ /** Internals exposed for tests — family gating is load-bearing and latent
938
+ * (a legacy Enforce policy with failures is rare in fixtures, common in the
939
+ * field), so it is pinned directly rather than through the DOM. */