@skyhook-io/k8s-ui 1.12.0 → 1.13.3
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.
- package/package.json +4 -4
- package/src/components/applications/ApplicationDetail.tsx +2 -2
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +32 -7
- package/src/components/issues/severity.ts +1 -0
- package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
- package/src/components/resources/ResourcesView.tsx +419 -9
- package/src/components/resources/index.ts +27 -0
- package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
- package/src/components/resources/renderers/CNPGShared.tsx +27 -0
- package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
- package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
- package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
- package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
- package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
- package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
- package/src/components/resources/renderers/PolicySection.tsx +32 -5
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
- package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
- package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
- package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
- package/src/components/resources/renderers/calico-cells.tsx +177 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
- package/src/components/resources/renderers/index.ts +16 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
- package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
- package/src/components/resources/renderers/velero-cells.tsx +29 -7
- package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
- package/src/components/resources/resource-utils-calico.test.ts +215 -0
- package/src/components/resources/resource-utils-calico.ts +261 -0
- package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
- package/src/components/resources/resource-utils-cnpg.ts +264 -3
- package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
- package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
- package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
- package/src/components/resources/resource-utils-kyverno.ts +43 -7
- package/src/components/resources/resource-utils-velero.test.ts +26 -0
- package/src/components/resources/resource-utils-velero.ts +68 -0
- package/src/components/resources/resource-utils.ts +98 -0
- package/src/components/shared/ResourceActionsBar.tsx +101 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
- package/src/components/shared/index.ts +1 -1
- package/src/components/shared/revision-role-badges.test.ts +120 -0
- package/src/components/topology/K8sResourceNode.test.ts +10 -0
- package/src/components/topology/K8sResourceNode.tsx +22 -0
- package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
- package/src/components/topology/TopologyGraph.tsx +6 -6
- package/src/components/topology/TopologySearch.tsx +7 -0
- package/src/components/topology/layout.ts +3 -0
- package/src/components/trace/ReachabilityView.tsx +1 -0
- package/src/components/trace/TracePanel.tsx +13 -2
- package/src/components/trace/reachFixtures.ts +26 -1
- package/src/components/trace/reachGraphModel.test.ts +20 -0
- package/src/components/trace/reachGraphModel.ts +8 -1
- package/src/components/trace/reachOrigins.test.ts +31 -0
- package/src/components/trace/reachOrigins.ts +11 -1
- package/src/components/ui/Badge.tsx +10 -0
- package/src/components/ui/InClusterConsentDialog.tsx +5 -4
- package/src/components/ui/ScheduleValue.test.tsx +80 -0
- package/src/components/ui/ScheduleValue.tsx +85 -0
- package/src/components/ui/SelectMenu.tsx +202 -28
- package/src/components/ui/drawer-components.test.tsx +17 -1
- package/src/components/ui/drawer-components.tsx +9 -2
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +5 -2
- package/src/types/core.ts +36 -4
- package/src/types/policy.ts +153 -2
- package/src/utils/clipboard.test.ts +86 -0
- package/src/utils/clipboard.ts +50 -0
- package/src/utils/extended-resources.ts +4 -7
- package/src/utils/format.test.ts +83 -1
- package/src/utils/format.ts +80 -1
- package/src/utils/inClusterConsent.test.ts +9 -0
- package/src/utils/inClusterConsent.ts +9 -9
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +5 -0
- package/src/utils/navigation.ts +5 -0
- package/src/utils/resource-icons.test.ts +49 -2
- 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. */
|