@skyhook-io/k8s-ui 1.13.3 → 1.14.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.
- package/package.json +4 -4
- package/src/components/applications/AppTooltips.tsx +13 -1
- package/src/components/applications/ApplicationDetail.test.tsx +25 -0
- package/src/components/applications/ApplicationDetail.tsx +39 -7
- package/src/components/applications/ApplicationsView.tsx +18 -3
- package/src/components/applications/ReadyBar.tsx +3 -2
- package/src/components/applications/applications-view.test.tsx +91 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +40 -31
- package/src/components/issues/ResourceIssuesSection.tsx +3 -2
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +123 -4
- package/src/components/issues/issues.test.ts +221 -10
- package/src/components/issues/types.ts +31 -5
- package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
- package/src/components/resources/ResourcesView.tsx +966 -98
- package/src/components/resources/column-settings-key.test.ts +98 -0
- package/src/components/resources/column-visibility-merge.test.ts +64 -0
- package/src/components/resources/curated-column-ownership.test.ts +236 -0
- package/src/components/resources/gateway-policy-status.test.ts +291 -0
- package/src/components/resources/gateway-policy-status.ts +237 -0
- package/src/components/resources/generic-status.test.ts +199 -0
- package/src/components/resources/generic-status.ts +171 -0
- package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
- package/src/components/resources/index.ts +9 -1
- package/src/components/resources/istio-gateway-filter.test.ts +97 -0
- package/src/components/resources/kyverno-legacy-action.test.ts +100 -1
- package/src/components/resources/kyverno-modern-posture.test.ts +36 -0
- package/src/components/resources/printer-columns.test.ts +298 -0
- package/src/components/resources/printer-columns.ts +249 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +29 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +3 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +69 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +6 -2
- package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
- package/src/components/resources/renderers/EventRenderer.tsx +3 -5
- package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
- package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +12 -7
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
- package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +92 -1
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +64 -8
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +47 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +16 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +44 -2
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
- package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +12 -1
- package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
- package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
- package/src/components/resources/renderers/kai-cells.tsx +67 -0
- package/src/components/resources/renderers/kaito-cells.tsx +62 -0
- package/src/components/resources/renderers/kserve-cells.tsx +145 -0
- package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
- package/src/components/resources/renderers/kueue-cells.tsx +176 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +8 -4
- package/src/components/resources/renderers/nim-cells.tsx +81 -0
- package/src/components/resources/renderers/ray-cells.tsx +124 -0
- package/src/components/resources/renderers/volcano-cells.tsx +140 -0
- package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +13 -5
- package/src/components/resources/resource-utils-cnpg.test.ts +142 -2
- package/src/components/resources/resource-utils-cnpg.ts +132 -7
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +236 -0
- package/src/components/resources/resource-utils-hpa.test.ts +4 -0
- package/src/components/resources/resource-utils-hpa.ts +6 -2
- package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
- package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
- package/src/components/resources/resource-utils-kai.ts +105 -0
- package/src/components/resources/resource-utils-kaito.ts +114 -0
- package/src/components/resources/resource-utils-kserve.ts +186 -0
- package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
- package/src/components/resources/resource-utils-kueue.ts +200 -0
- package/src/components/resources/resource-utils-kyverno-modern.ts +3 -1
- package/src/components/resources/resource-utils-kyverno.ts +51 -2
- package/src/components/resources/resource-utils-nim.ts +109 -0
- package/src/components/resources/resource-utils-ray.ts +214 -0
- package/src/components/resources/resource-utils-rollout.test.ts +183 -0
- package/src/components/resources/resource-utils-velero.test.ts +30 -0
- package/src/components/resources/resource-utils-velero.ts +32 -0
- package/src/components/resources/resource-utils-volcano.ts +176 -0
- package/src/components/resources/resource-utils.ts +156 -47
- package/src/components/resources/resources-column-filter.test.ts +27 -1
- package/src/components/resources/workload-rollout-golden.test.ts +55 -0
- package/src/components/shared/CreateResourceDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.tsx +4 -2
- package/src/components/shared/ResourceActionsBar.tsx +170 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
- package/src/components/shared/SetImageDialog.test.ts +129 -0
- package/src/components/shared/SetImageDialog.tsx +577 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/shared/revision-role-badges.test.ts +56 -4
- package/src/components/timeline/TimelineList.tsx +19 -3
- package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
- package/src/components/trace/ReachabilityView.tsx +1 -1
- package/src/components/ui/SelectMenu.tsx +10 -1
- package/src/components/ui/drawer-components.tsx +12 -0
- package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
- package/src/components/workload/WorkloadView.tsx +144 -81
- package/src/hooks/useProgressiveRefresh.ts +37 -0
- package/src/source-hygiene.test.ts +28 -0
- package/src/theme/tailwind-theme.css +4 -0
- package/src/types/core.ts +55 -0
- package/src/utils/api-resources.test.ts +40 -1
- package/src/utils/api-resources.ts +11 -1
- package/src/utils/applications.ts +50 -0
- package/src/utils/bulk-workload-actions.test.ts +20 -0
- package/src/utils/bulk-workload-actions.ts +15 -0
- package/src/utils/navigation.test.ts +31 -1
- package/src/utils/navigation.ts +21 -2
- package/src/utils/workload-rollout.ts +292 -0
|
@@ -39,9 +39,33 @@ describe('CNPGClusterRenderer — the drawer must not contradict the badge', ()
|
|
|
39
39
|
expect(html(settled)).toContain('Degraded Cluster')
|
|
40
40
|
})
|
|
41
41
|
|
|
42
|
-
it('still raises Cluster Down at zero ready, whatever the phase', () => {
|
|
43
|
-
// No phase excuses a database
|
|
44
|
-
|
|
42
|
+
it('still raises Cluster Down for a was-up cluster at zero ready, whatever the phase', () => {
|
|
43
|
+
// No phase excuses a database that was serving and now has nothing ready.
|
|
44
|
+
// readyInstances is OMITTED — the shape CNPG actually emits for zero ready —
|
|
45
|
+
// and currentPrimary is the "was up" signal that tells this apart from a
|
|
46
|
+
// first bootstrap.
|
|
47
|
+
const wasUpDown = {
|
|
48
|
+
apiVersion: 'postgresql.cnpg.io/v1',
|
|
49
|
+
kind: 'Cluster',
|
|
50
|
+
metadata: { name: 'pg', namespace: 'db' },
|
|
51
|
+
spec: { instances: 3 },
|
|
52
|
+
status: { phase: 'Upgrading cluster', currentPrimary: 'pg-1' },
|
|
53
|
+
}
|
|
54
|
+
expect(html(wasUpDown)).toContain('Cluster Down')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('does not raise Cluster Down for a bootstrapping cluster with no primary yet', () => {
|
|
58
|
+
// Reported (phase) but no primary elected and readyInstances omitted — the
|
|
59
|
+
// shape a fresh cluster emits. A red banner here is the false alarm the
|
|
60
|
+
// availability verdict exists to prevent.
|
|
61
|
+
const bootstrapping = {
|
|
62
|
+
apiVersion: 'postgresql.cnpg.io/v1',
|
|
63
|
+
kind: 'Cluster',
|
|
64
|
+
metadata: { name: 'pg', namespace: 'db' },
|
|
65
|
+
spec: { instances: 3 },
|
|
66
|
+
status: { phase: 'Setting up primary' },
|
|
67
|
+
}
|
|
68
|
+
expect(html(bootstrapping)).not.toContain('Cluster Down')
|
|
45
69
|
})
|
|
46
70
|
|
|
47
71
|
it('renders a failed last backup at the same tier as the badge and detector', () => {
|
|
@@ -116,3 +140,45 @@ describe('recovery points must be readable as ages, not raw machine timestamps',
|
|
|
116
140
|
expect(html).toContain('2026-01-30 09:40:37 UTC')
|
|
117
141
|
})
|
|
118
142
|
})
|
|
143
|
+
|
|
144
|
+
describe('CNPGClusterRenderer — the replica-cluster role tracks live state, not stanza presence', () => {
|
|
145
|
+
const withReplica = (replica: any, name = 'pg-eu') => ({
|
|
146
|
+
apiVersion: 'postgresql.cnpg.io/v1',
|
|
147
|
+
kind: 'Cluster',
|
|
148
|
+
metadata: { name, namespace: 'db' },
|
|
149
|
+
spec: { instances: 3, replica },
|
|
150
|
+
status: { phase: 'Cluster in healthy state', readyInstances: 3 },
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('shows the Replica Cluster section for a genuine replica (replica mode enabled)', () => {
|
|
154
|
+
const out = renderToString(<CNPGClusterRenderer data={withReplica({ source: 'pg-us', enabled: true })} />)
|
|
155
|
+
expect(out).toContain('Replica Cluster')
|
|
156
|
+
expect(out).toContain('Role')
|
|
157
|
+
expect(out).toContain('pg-us')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('shows Replica for a distributed-topology cluster whose primary is elsewhere', () => {
|
|
161
|
+
const out = renderToString(
|
|
162
|
+
<CNPGClusterRenderer data={withReplica({ source: 'pg-us', primary: 'pg-us', self: 'pg-eu' })} />,
|
|
163
|
+
)
|
|
164
|
+
expect(out).toContain('Replica Cluster')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('drops the Replica label once a distributed replica is promoted, though the stanza remains', () => {
|
|
168
|
+
// Promotion sets replica.primary to this cluster; the stanza is not removed.
|
|
169
|
+
const out = renderToString(
|
|
170
|
+
<CNPGClusterRenderer data={withReplica({ source: 'pg-us', primary: 'pg-eu', self: 'pg-eu' })} />,
|
|
171
|
+
)
|
|
172
|
+
expect(out).not.toContain('Replica Cluster')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('drops the Replica label once a standalone replica is promoted (replica.enabled off)', () => {
|
|
176
|
+
const out = renderToString(<CNPGClusterRenderer data={withReplica({ source: 'pg-us', enabled: false })} />)
|
|
177
|
+
expect(out).not.toContain('Replica Cluster')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('a normal standalone cluster shows no Replica Cluster section', () => {
|
|
181
|
+
const out = renderToString(<CNPGClusterRenderer data={cluster('Cluster in healthy state', 3)} />)
|
|
182
|
+
expect(out).not.toContain('Replica Cluster')
|
|
183
|
+
})
|
|
184
|
+
})
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
getCNPGWALArchivingFailure,
|
|
24
24
|
getCNPGLastBackupFailure,
|
|
25
25
|
classifyCNPGClusterPhase,
|
|
26
|
+
getCNPGClusterAvailability,
|
|
26
27
|
CNPG_BARMAN_OBJECTSTORE_GROUP,
|
|
27
28
|
} from '../resource-utils-cnpg'
|
|
28
29
|
import { formatAge } from '../resource-utils'
|
|
@@ -79,8 +80,11 @@ export function CNPGClusterRenderer({ data, onNavigate, declared}: CNPGClusterRe
|
|
|
79
80
|
const lastBackupFailure = getCNPGLastBackupFailure(data)
|
|
80
81
|
const phaseBucket = classifyCNPGClusterPhase(phase)
|
|
81
82
|
|
|
82
|
-
// Problem detection
|
|
83
|
-
|
|
83
|
+
// Problem detection. isDown reuses the badge's availability verdict so the
|
|
84
|
+
// three surfaces agree: a fully-down cluster CNPG reports by omitting
|
|
85
|
+
// readyInstances (countsKnown false) is unreachable through the raw counts,
|
|
86
|
+
// and hibernation / all-fenced are deliberate not-serving states, not outages.
|
|
87
|
+
const isDown = getCNPGClusterAvailability(data) === 'down'
|
|
84
88
|
// A shortfall the PHASE already explains is not a separate problem. Mirrors
|
|
85
89
|
// source_cnpg.go's phaseExplained gate and the badge's ordering, both of
|
|
86
90
|
// which check the phase before the instance counts: mid-switchover or waiting
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { renderToString } from 'react-dom/server'
|
|
2
|
-
import {
|
|
2
|
+
import type { ReactElement, ReactNode } from 'react'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
4
|
|
|
4
5
|
import { EventRenderer } from './EventRenderer'
|
|
6
|
+
import { initNavigationMap, resetNavigationMap } from '../../../utils/navigation'
|
|
7
|
+
|
|
8
|
+
afterEach(resetNavigationMap)
|
|
9
|
+
|
|
10
|
+
function findButton(node: ReactNode): ReactElement<{ onClick: () => void }> | undefined {
|
|
11
|
+
if (!node || typeof node !== 'object' || !('props' in node)) return undefined
|
|
12
|
+
const element = node as ReactElement<{ children?: ReactNode; onClick?: () => void }>
|
|
13
|
+
if (element.type === 'button') return element as ReactElement<{ onClick: () => void }>
|
|
14
|
+
const children = element.props.children
|
|
15
|
+
for (const child of Array.isArray(children) ? children : [children]) {
|
|
16
|
+
const button = findButton(child)
|
|
17
|
+
if (button) return button
|
|
18
|
+
}
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
5
21
|
|
|
6
22
|
describe('EventRenderer', () => {
|
|
7
23
|
it.each([
|
|
@@ -20,4 +36,37 @@ describe('EventRenderer', () => {
|
|
|
20
36
|
expect(html).not.toMatch(/text-(?:amber|blue)-(?:200|300|400)/)
|
|
21
37
|
expect(html).not.toContain('opacity-')
|
|
22
38
|
})
|
|
39
|
+
|
|
40
|
+
it('navigates to the group-qualified native PodGroup resource', () => {
|
|
41
|
+
initNavigationMap([{
|
|
42
|
+
group: 'scheduling.k8s.io',
|
|
43
|
+
version: 'v1beta1',
|
|
44
|
+
kind: 'PodGroup',
|
|
45
|
+
name: 'podgroups',
|
|
46
|
+
namespaced: true,
|
|
47
|
+
isCrd: false,
|
|
48
|
+
verbs: ['get', 'list'],
|
|
49
|
+
}])
|
|
50
|
+
const onNavigate = vi.fn()
|
|
51
|
+
const tree = EventRenderer({
|
|
52
|
+
data: {
|
|
53
|
+
involvedObject: {
|
|
54
|
+
apiVersion: 'scheduling.k8s.io/v1beta1',
|
|
55
|
+
kind: 'PodGroup',
|
|
56
|
+
namespace: 'default',
|
|
57
|
+
name: 'batch',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
onNavigate,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
findButton(tree)?.props.onClick()
|
|
64
|
+
|
|
65
|
+
expect(onNavigate).toHaveBeenCalledWith({
|
|
66
|
+
kind: 'podgroups',
|
|
67
|
+
namespace: 'default',
|
|
68
|
+
name: 'batch',
|
|
69
|
+
group: 'scheduling.k8s.io',
|
|
70
|
+
})
|
|
71
|
+
})
|
|
23
72
|
})
|
|
@@ -4,7 +4,7 @@ import { Badge } from '../../ui/Badge'
|
|
|
4
4
|
import { Section, PropertyList, Property } from '../../ui/drawer-components'
|
|
5
5
|
import { formatAge } from '../resource-utils'
|
|
6
6
|
import type { NavigateToResource } from '../../../utils/navigation'
|
|
7
|
-
import {
|
|
7
|
+
import { apiVersionToGroup, kindToPluralWithGroup } from '../../../utils/navigation'
|
|
8
8
|
|
|
9
9
|
interface EventRendererProps {
|
|
10
10
|
data: any
|
|
@@ -75,11 +75,9 @@ export function EventRenderer({ data, onNavigate }: EventRendererProps) {
|
|
|
75
75
|
{onNavigate ? (
|
|
76
76
|
<button
|
|
77
77
|
onClick={() => {
|
|
78
|
-
|
|
79
|
-
const apiVersion = involvedObject.apiVersion || ''
|
|
80
|
-
const group = apiVersion.includes('/') ? apiVersion.split('/')[0] : undefined
|
|
78
|
+
const group = apiVersionToGroup(involvedObject.apiVersion) || undefined
|
|
81
79
|
onNavigate({
|
|
82
|
-
kind:
|
|
80
|
+
kind: kindToPluralWithGroup(involvedObject.kind, group ?? ''),
|
|
83
81
|
namespace: involvedObject.namespace || '',
|
|
84
82
|
name: involvedObject.name,
|
|
85
83
|
group,
|
|
@@ -2,6 +2,8 @@ import { useState } from 'react'
|
|
|
2
2
|
import { Info, CheckCircle, XCircle, AlertCircle, ChevronRight, ChevronDown, FileCode, AlertTriangle, Layers } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { Section, PropertyList, Property } from '../../ui/drawer-components'
|
|
5
|
+
import { getGenericResourceStatus } from '../generic-status'
|
|
6
|
+
import type { HealthLevel } from '../resource-utils'
|
|
5
7
|
|
|
6
8
|
interface GenericRendererProps {
|
|
7
9
|
data: any
|
|
@@ -13,7 +15,7 @@ export function GenericRenderer({ data }: GenericRendererProps) {
|
|
|
13
15
|
const conditions = status.conditions
|
|
14
16
|
|
|
15
17
|
// Determine health status from conditions or status fields
|
|
16
|
-
const healthStatus = getHealthStatus(
|
|
18
|
+
const healthStatus = getHealthStatus(data)
|
|
17
19
|
|
|
18
20
|
// Extract important fields from spec and status
|
|
19
21
|
const statusFields = getImportantFields(status, 'status')
|
|
@@ -138,89 +140,22 @@ interface HealthStatusResult {
|
|
|
138
140
|
message?: string
|
|
139
141
|
}
|
|
140
142
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return { type: 'healthy', label: phase }
|
|
153
|
-
}
|
|
154
|
-
if (degradedPhases.includes(phase)) {
|
|
155
|
-
return { type: 'degraded', label: phase, message: status.message || status.reason }
|
|
156
|
-
}
|
|
157
|
-
if (unhealthyPhases.includes(phase)) {
|
|
158
|
-
return { type: 'unhealthy', label: phase, message: status.message || status.reason }
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Check conditions
|
|
163
|
-
if (conditions && Array.isArray(conditions) && conditions.length > 0) {
|
|
164
|
-
// Look for Ready or Available condition
|
|
165
|
-
const readyCondition = conditions.find((c: any) =>
|
|
166
|
-
c.type === 'Ready' || c.type === 'Available' || c.type === 'Healthy'
|
|
167
|
-
)
|
|
168
|
-
if (readyCondition) {
|
|
169
|
-
if (readyCondition.status === 'True') {
|
|
170
|
-
return { type: 'healthy', label: 'Ready' }
|
|
171
|
-
}
|
|
172
|
-
if (readyCondition.status === 'False') {
|
|
173
|
-
return {
|
|
174
|
-
type: 'unhealthy',
|
|
175
|
-
label: 'Not Ready',
|
|
176
|
-
message: readyCondition.message || readyCondition.reason
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Check for any False conditions that indicate problems
|
|
182
|
-
const problemConditions = conditions.filter((c: any) =>
|
|
183
|
-
c.status === 'False' && ['Ready', 'Available', 'Healthy', 'Initialized'].includes(c.type)
|
|
184
|
-
)
|
|
185
|
-
if (problemConditions.length > 0) {
|
|
186
|
-
const problem = problemConditions[0]
|
|
187
|
-
return {
|
|
188
|
-
type: 'unhealthy',
|
|
189
|
-
label: `${problem.type}: False`,
|
|
190
|
-
message: problem.message || problem.reason
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Check for warning conditions
|
|
195
|
-
const warningConditions = conditions.filter((c: any) =>
|
|
196
|
-
c.status === 'True' && ['Degraded', 'Warning', 'ScalingLimited'].includes(c.type)
|
|
197
|
-
)
|
|
198
|
-
if (warningConditions.length > 0) {
|
|
199
|
-
const warning = warningConditions[0]
|
|
200
|
-
return {
|
|
201
|
-
type: 'degraded',
|
|
202
|
-
label: warning.type,
|
|
203
|
-
message: warning.message || warning.reason
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Check replica-based status
|
|
209
|
-
if (status.replicas !== undefined) {
|
|
210
|
-
const desired = status.replicas
|
|
211
|
-
const ready = status.readyReplicas || status.availableReplicas || 0
|
|
212
|
-
if (desired > 0 && ready >= desired) {
|
|
213
|
-
return { type: 'healthy', label: `${ready}/${desired} Ready` }
|
|
214
|
-
}
|
|
215
|
-
if (desired > 0 && ready > 0) {
|
|
216
|
-
return { type: 'degraded', label: `${ready}/${desired} Ready` }
|
|
217
|
-
}
|
|
218
|
-
if (desired > 0 && ready === 0) {
|
|
219
|
-
return { type: 'unhealthy', label: `0/${desired} Ready` }
|
|
220
|
-
}
|
|
221
|
-
}
|
|
143
|
+
// This renderer's banner has four tones, while the shared derivation speaks the
|
|
144
|
+
// full HealthLevel vocabulary. `alert` folds into unhealthy and `neutral` into
|
|
145
|
+
// unknown — the banner has no distinct treatment for either.
|
|
146
|
+
const BANNER_TYPE: Record<HealthLevel, HealthType> = {
|
|
147
|
+
healthy: 'healthy',
|
|
148
|
+
degraded: 'degraded',
|
|
149
|
+
alert: 'unhealthy',
|
|
150
|
+
unhealthy: 'unhealthy',
|
|
151
|
+
neutral: 'unknown',
|
|
152
|
+
unknown: 'unknown',
|
|
153
|
+
}
|
|
222
154
|
|
|
223
|
-
|
|
155
|
+
function getHealthStatus(resource: any): HealthStatusResult | null {
|
|
156
|
+
const derived = getGenericResourceStatus(resource)
|
|
157
|
+
if (!derived) return null
|
|
158
|
+
return { type: BANNER_TYPE[derived.tone], label: derived.text, message: derived.reason }
|
|
224
159
|
}
|
|
225
160
|
|
|
226
161
|
// ============================================================================
|
|
@@ -88,4 +88,32 @@ describe('HPARenderer', () => {
|
|
|
88
88
|
expect(html).toContain('TooManyReplicas')
|
|
89
89
|
expect(html).not.toContain('the desired replica count is more than the maximum replica count')
|
|
90
90
|
})
|
|
91
|
+
|
|
92
|
+
it('preserves minReplicas zero and renders ScaledToZero as neutral evidence', () => {
|
|
93
|
+
const html = renderToString(
|
|
94
|
+
<HPARenderer
|
|
95
|
+
data={{
|
|
96
|
+
...baseHPA,
|
|
97
|
+
spec: { ...baseHPA.spec, minReplicas: 0 },
|
|
98
|
+
status: {
|
|
99
|
+
currentReplicas: 0,
|
|
100
|
+
desiredReplicas: 0,
|
|
101
|
+
conditions: [
|
|
102
|
+
{
|
|
103
|
+
type: 'ScaledToZero',
|
|
104
|
+
status: 'True',
|
|
105
|
+
reason: 'SucceededRescale',
|
|
106
|
+
message: 'the HPA controller scaled the target to zero',
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
}}
|
|
111
|
+
/>,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
expect(html).toMatch(/>Min<\/span><span[^>]*>0<\/span>/)
|
|
115
|
+
expect(html).toContain('ScaledToZero')
|
|
116
|
+
expect(html).toContain('bg-gray-400/20')
|
|
117
|
+
expect(html).not.toContain('failing')
|
|
118
|
+
})
|
|
91
119
|
})
|
|
@@ -34,6 +34,11 @@ function hpaBadgeSeverity(state: HPADiagnosisState): BadgeSeverity {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function hpaConditionTone(condition: any): ConditionTone | undefined {
|
|
37
|
+
if (condition?.type === 'ScaledToZero') {
|
|
38
|
+
if (condition.status === 'False') return 'ok'
|
|
39
|
+
if (condition.status === 'True') return 'unknown'
|
|
40
|
+
return 'unknown'
|
|
41
|
+
}
|
|
37
42
|
if (condition?.type !== 'ScalingLimited') return undefined
|
|
38
43
|
if (condition.status === 'False') return 'ok'
|
|
39
44
|
if (condition.status !== 'True') return 'unknown'
|
|
@@ -109,7 +114,7 @@ export function HPARenderer({ data, onNavigate, hpaDiagnosis, extraSections }: H
|
|
|
109
114
|
} />
|
|
110
115
|
<Property label="Current" value={status.currentReplicas} />
|
|
111
116
|
<Property label="Desired" value={status.desiredReplicas} />
|
|
112
|
-
<Property label="Min" value={spec.minReplicas
|
|
117
|
+
<Property label="Min" value={spec.minReplicas ?? 1} />
|
|
113
118
|
<Property label="Max" value={spec.maxReplicas} />
|
|
114
119
|
{status.lastScaleTime && <Property label="Last Scale" value={formatAge(status.lastScaleTime)} />}
|
|
115
120
|
</PropertyList>
|
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
getPolicyReportResults,
|
|
9
9
|
getPolicyReportScope,
|
|
10
10
|
getPolicyReportSource,
|
|
11
|
-
|
|
11
|
+
getKyvernoEnforcement,
|
|
12
|
+
getKyvernoPolicyAdmission,
|
|
12
13
|
getKyvernoPolicyRuleCount,
|
|
13
14
|
getKyvernoPolicyBackground,
|
|
14
15
|
getKyvernoPolicyRules,
|
|
@@ -246,15 +247,14 @@ export function KyvernoPolicyRenderer({ data, coverage, queued }: KyvernoPolicyR
|
|
|
246
247
|
const spec = data.spec || {}
|
|
247
248
|
const status = data.status || {}
|
|
248
249
|
const conditions = status.conditions || []
|
|
249
|
-
const
|
|
250
|
+
const enforcement = getKyvernoEnforcement(data)
|
|
251
|
+
const admission = getKyvernoPolicyAdmission(data)
|
|
250
252
|
const ruleCount = getKyvernoPolicyRuleCount(data)
|
|
251
253
|
const background = getKyvernoPolicyBackground(data)
|
|
252
254
|
const rules = getKyvernoPolicyRules(data)
|
|
253
255
|
const ruleCountByType = getKyvernoPolicyRuleCountByType(data)
|
|
254
256
|
const autogenRules = getKyvernoPolicyAutogenRules(data)
|
|
255
257
|
|
|
256
|
-
const isEnforce = action === 'Enforce'
|
|
257
|
-
|
|
258
258
|
return (
|
|
259
259
|
<>
|
|
260
260
|
{coverage}
|
|
@@ -269,15 +269,20 @@ export function KyvernoPolicyRenderer({ data, coverage, queued }: KyvernoPolicyR
|
|
|
269
269
|
writing to the cluster. status.rulecount is Kyverno's own count,
|
|
270
270
|
so this is checkable rather than guessed. */}
|
|
271
271
|
{(ruleCountByType.validate > 0 || ruleCountByType.verifyImages > 0) && (
|
|
272
|
-
<Property label="
|
|
272
|
+
<Property label="Enforcement" value={
|
|
273
273
|
<span className={clsx(
|
|
274
274
|
'badge',
|
|
275
|
-
|
|
275
|
+
enforcement.blocks
|
|
276
|
+
? 'bg-red-500/20 text-red-400'
|
|
277
|
+
: enforcement.discrepancy
|
|
278
|
+
? 'bg-orange-500/20 text-orange-400'
|
|
279
|
+
: 'bg-yellow-500/20 text-yellow-400',
|
|
276
280
|
)}>
|
|
277
|
-
{
|
|
281
|
+
{enforcement.label}
|
|
278
282
|
</span>
|
|
279
283
|
} />
|
|
280
284
|
)}
|
|
285
|
+
<Property label="Admission" value={admission ? 'Enabled' : 'Disabled'} />
|
|
281
286
|
<Property label="Background" value={background ? 'Enabled' : 'Disabled'} />
|
|
282
287
|
{spec.webhookTimeoutSeconds && (
|
|
283
288
|
<Property label="Webhook Timeout" value={`${spec.webhookTimeoutSeconds}s`} />
|
|
@@ -21,7 +21,8 @@ export function MetricsUnavailableNotice({ rawError, diagnosis }: MetricsUnavail
|
|
|
21
21
|
This panel uses Kubernetes metrics.k8s.io for live CPU and memory; Prometheus data does not fill it.
|
|
22
22
|
</span>
|
|
23
23
|
<span className="block">
|
|
24
|
-
{diagnosis ||
|
|
24
|
+
{diagnosis ||
|
|
25
|
+
"Check that metrics-server is installed and healthy, and that its metrics.k8s.io APIService is Available."}
|
|
25
26
|
</span>
|
|
26
27
|
<span className="block">
|
|
27
28
|
Raw error: <span className="font-mono break-words">{rawError}</span>
|
|
@@ -20,4 +20,48 @@ describe('PVCRenderer', () => {
|
|
|
20
20
|
expect(html).not.toContain('This is normal')
|
|
21
21
|
expect(html).not.toContain('expected indefinitely')
|
|
22
22
|
})
|
|
23
|
+
|
|
24
|
+
it('surfaces an unused claim as informational cleanup evidence', () => {
|
|
25
|
+
const html = renderToString(
|
|
26
|
+
<PVCRenderer
|
|
27
|
+
data={{
|
|
28
|
+
metadata: { name: 'archive', namespace: 'demo' },
|
|
29
|
+
status: {
|
|
30
|
+
phase: 'Bound',
|
|
31
|
+
conditions: [
|
|
32
|
+
{
|
|
33
|
+
type: 'Unused',
|
|
34
|
+
status: 'True',
|
|
35
|
+
lastTransitionTime: '2026-08-28T12:00:00Z',
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
}}
|
|
40
|
+
/>,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
expect(html).toContain('Unused for about')
|
|
44
|
+
expect(html).toContain('No non-terminal Pod currently references this claim')
|
|
45
|
+
expect(html).toContain('Verify its retention policy and data ownership')
|
|
46
|
+
expect(html).toContain('bg-gray-400/20')
|
|
47
|
+
expect(html).not.toContain('failing')
|
|
48
|
+
expect(html).not.toContain('bg-emerald-500/20')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does not show cleanup guidance while Unused is false or absent', () => {
|
|
52
|
+
for (const conditions of [
|
|
53
|
+
[{ type: 'Unused', status: 'False' }],
|
|
54
|
+
undefined,
|
|
55
|
+
]) {
|
|
56
|
+
const html = renderToString(
|
|
57
|
+
<PVCRenderer
|
|
58
|
+
data={{
|
|
59
|
+
metadata: { name: 'active', namespace: 'demo' },
|
|
60
|
+
status: { phase: 'Bound', conditions },
|
|
61
|
+
}}
|
|
62
|
+
/>,
|
|
63
|
+
)
|
|
64
|
+
expect(html).not.toContain('No non-terminal Pod currently references this claim')
|
|
65
|
+
}
|
|
66
|
+
})
|
|
23
67
|
})
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import { HardDrive } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
-
import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, useOperationalIssuesShown} from '../../ui/drawer-components'
|
|
4
|
+
import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, useOperationalIssuesShown, type ConditionTone } from '../../ui/drawer-components'
|
|
5
|
+
import { formatAge } from '../resource-utils'
|
|
5
6
|
|
|
6
7
|
interface PVCRendererProps {
|
|
7
8
|
data: any
|
|
@@ -22,6 +23,11 @@ function formatAccessModes(modes: string[] | undefined): string | undefined {
|
|
|
22
23
|
return modes.map(m => accessModeShorthand[m] || m).join(', ')
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
function pvcConditionTone(condition: any): ConditionTone | undefined {
|
|
27
|
+
if (condition?.type !== 'Unused') return undefined
|
|
28
|
+
return condition.status === 'True' ? 'unknown' : condition.status === 'False' ? 'ok' : 'unknown'
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProps) {
|
|
26
32
|
const status = data.status || {}
|
|
27
33
|
const spec = data.spec || {}
|
|
@@ -39,6 +45,9 @@ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProp
|
|
|
39
45
|
const bindCompleted = annotations['pv.kubernetes.io/bind-completed']
|
|
40
46
|
const hasProvisionerInfo = provisioner || selectedNode || bindCompleted
|
|
41
47
|
const operationalIssuesShown = useOperationalIssuesShown()
|
|
48
|
+
const unused = Array.isArray(status.conditions)
|
|
49
|
+
? status.conditions.find((condition: any) => condition?.type === 'Unused' && condition.status === 'True')
|
|
50
|
+
: undefined
|
|
42
51
|
|
|
43
52
|
return (
|
|
44
53
|
<>
|
|
@@ -59,6 +68,14 @@ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProp
|
|
|
59
68
|
/>
|
|
60
69
|
)}
|
|
61
70
|
|
|
71
|
+
{unused && (
|
|
72
|
+
<AlertBanner
|
|
73
|
+
variant="info"
|
|
74
|
+
title={unused.lastTransitionTime ? `Unused for about ${formatAge(unused.lastTransitionTime)}` : 'Unused'}
|
|
75
|
+
message="No non-terminal Pod currently references this claim. Verify its retention policy and data ownership before deleting it."
|
|
76
|
+
/>
|
|
77
|
+
)}
|
|
78
|
+
|
|
62
79
|
<Section title="Status" icon={HardDrive}>
|
|
63
80
|
<PropertyList>
|
|
64
81
|
<Property
|
|
@@ -98,7 +115,7 @@ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProp
|
|
|
98
115
|
|
|
99
116
|
{extraSections}
|
|
100
117
|
|
|
101
|
-
<ConditionsSection conditions={status.conditions} />
|
|
118
|
+
<ConditionsSection conditions={status.conditions} getConditionTone={pvcConditionTone} />
|
|
102
119
|
</>
|
|
103
120
|
)
|
|
104
121
|
}
|
|
@@ -4,7 +4,7 @@ import { PolicyCoverageSection } from './PolicyCoverageSection'
|
|
|
4
4
|
import type { PolicyCoverageResponse, PolicyCoverageRule, PolicyCoverageSubject } from '../../../types/policy'
|
|
5
5
|
|
|
6
6
|
const vpol = (spec: any = {}) => ({ apiVersion: 'policies.kyverno.io/v1', kind: 'ValidatingPolicy', spec })
|
|
7
|
-
const mpol = () => ({ apiVersion: 'policies.kyverno.io/v1', kind: 'MutatingPolicy', spec
|
|
7
|
+
const mpol = (spec: any = {}) => ({ apiVersion: 'policies.kyverno.io/v1', kind: 'MutatingPolicy', spec })
|
|
8
8
|
const legacy = (spec: any) => ({ apiVersion: 'kyverno.io/v1', kind: 'ClusterPolicy', spec })
|
|
9
9
|
|
|
10
10
|
const counts = (c: Partial<PolicyCoverageResponse['counts']> = {}) =>
|
|
@@ -82,6 +82,97 @@ describe('what the section says a policy did', () => {
|
|
|
82
82
|
expect(html).not.toContain('Generated')
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
// A legacy Enforce policy with `spec.admission: false` is left out of the
|
|
86
|
+
// admission webhook, so it rejects nothing — the consequence sentence must not
|
|
87
|
+
// promise a rejection it cannot make.
|
|
88
|
+
it('does not promise rejection when admission evaluation is disabled', () => {
|
|
89
|
+
const html = render(
|
|
90
|
+
coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
|
|
91
|
+
rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] }),
|
|
92
|
+
legacy({ validationFailureAction: 'Enforce', admission: false,
|
|
93
|
+
rules: [{ name: 'r', validate: { pattern: {} } }] }),
|
|
94
|
+
)
|
|
95
|
+
expect(html).toContain('Nothing is rejected today')
|
|
96
|
+
expect(html).not.toContain('will be rejected')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// The policy already declares Enforce, so the remediation is to re-enable
|
|
100
|
+
// admission — not to "switch this policy to block", which would point at the
|
|
101
|
+
// wrong knob.
|
|
102
|
+
it('names admission, not the action, as the reason nothing is rejected', () => {
|
|
103
|
+
const html = render(
|
|
104
|
+
coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
|
|
105
|
+
rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] }),
|
|
106
|
+
legacy({ validationFailureAction: 'Enforce', admission: false,
|
|
107
|
+
rules: [{ name: 'r', validate: { pattern: {} } }] }),
|
|
108
|
+
)
|
|
109
|
+
expect(html).toContain('admission is disabled for this policy')
|
|
110
|
+
expect(html).toContain('Enabling admission would reject the next update')
|
|
111
|
+
expect(html).not.toContain('Switching this policy to block')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// The modern CEL family reaches the same non-blocking state via
|
|
115
|
+
// `spec.evaluation.admission.enabled: false`, and the shared sentence must be
|
|
116
|
+
// honest there too.
|
|
117
|
+
it('names admission for a modern Deny policy with admission disabled', () => {
|
|
118
|
+
const html = render(
|
|
119
|
+
coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
|
|
120
|
+
rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] }),
|
|
121
|
+
vpol({ validationActions: ['Deny'], evaluation: { admission: { enabled: false } } }),
|
|
122
|
+
)
|
|
123
|
+
expect(html).toContain('admission is disabled for this policy')
|
|
124
|
+
expect(html).not.toContain('Switching this policy to block')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// A JSON-mode policy is evaluated against payloads outside the cluster and
|
|
128
|
+
// never joins admission. Both phrasings would be false: it rejects nothing
|
|
129
|
+
// today, and neither switching the action nor enabling admission changes
|
|
130
|
+
// that. The sentence is withheld rather than guessed at.
|
|
131
|
+
it('states no admission consequence for a JSON-mode policy', () => {
|
|
132
|
+
const failing = coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
|
|
133
|
+
rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] })
|
|
134
|
+
|
|
135
|
+
const admissionOff = render(failing, vpol({
|
|
136
|
+
validationActions: ['Deny'],
|
|
137
|
+
evaluation: { mode: 'JSON', admission: { enabled: false } },
|
|
138
|
+
}))
|
|
139
|
+
expect(admissionOff).not.toContain('admission is disabled for this policy')
|
|
140
|
+
expect(admissionOff).not.toContain('Switching this policy to block')
|
|
141
|
+
|
|
142
|
+
// Same for JSON mode with admission left at its default.
|
|
143
|
+
const admissionDefault = render(failing, vpol({
|
|
144
|
+
validationActions: ['Deny'],
|
|
145
|
+
evaluation: { mode: 'JSON' },
|
|
146
|
+
}))
|
|
147
|
+
expect(admissionDefault).not.toContain('Switching this policy to block')
|
|
148
|
+
expect(admissionDefault).not.toContain('will be rejected')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// `getKyvernoValidationActions` applies Kyverno's absent-means-Deny default,
|
|
152
|
+
// which is right for the validating families and wrong for every other kind.
|
|
153
|
+
// A mutating policy with admission off must not be described as a policy that
|
|
154
|
+
// would reject once admission is re-enabled.
|
|
155
|
+
it('does not read a Deny default off a mutating policy with admission disabled', () => {
|
|
156
|
+
const html = render(
|
|
157
|
+
coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
|
|
158
|
+
rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] }),
|
|
159
|
+
mpol({ evaluation: { admission: { enabled: false } } }),
|
|
160
|
+
)
|
|
161
|
+
expect(html).not.toContain('admission is disabled for this policy')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// An Audit policy genuinely never blocks, so its remediation IS to switch the
|
|
165
|
+
// action — the admission-off wording must not leak onto it.
|
|
166
|
+
it('still offers to switch the action for an audit policy', () => {
|
|
167
|
+
const html = render(
|
|
168
|
+
coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
|
|
169
|
+
rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] }),
|
|
170
|
+
vpol({ validationActions: ['Audit'] }),
|
|
171
|
+
)
|
|
172
|
+
expect(html).toContain('Switching this policy to block')
|
|
173
|
+
expect(html).not.toContain('admission is disabled')
|
|
174
|
+
})
|
|
175
|
+
|
|
85
176
|
it('withholds the consequence when the policy’s rules disagree on blocking', () => {
|
|
86
177
|
const html = render(
|
|
87
178
|
coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
|