@skyhook-io/k8s-ui 1.7.14 → 1.8.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 +7 -7
- package/src/components/applications/ApplicationsList.tsx +131 -115
- package/src/components/applications/index.ts +3 -1
- package/src/components/audit/AuditCard.tsx +1 -1
- package/src/components/charts/AreaChart.tsx +52 -18
- package/src/components/charts/MetricsSummary.tsx +12 -3
- package/src/components/charts/saturation.test.ts +34 -0
- package/src/components/charts/saturation.ts +1 -0
- package/src/components/charts/types.ts +5 -4
- package/src/components/checks/ChecksView.tsx +12 -23
- package/src/components/checks/severity.ts +11 -11
- package/src/components/gitops/GitOpsTableView.tsx +298 -274
- package/src/components/issues/IssuesView.tsx +4 -6
- package/src/components/issues/severity.ts +19 -11
- package/src/components/issues/types.ts +22 -2
- package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
- package/src/components/resources/ResourcesSidebar.tsx +3 -1
- package/src/components/resources/ResourcesView.tsx +180 -21
- package/src/components/resources/get-pod-problems.test.ts +127 -0
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
- package/src/components/resources/renderers/HPARenderer.tsx +93 -86
- package/src/components/resources/renderers/PodRenderer.test.tsx +56 -0
- package/src/components/resources/renderers/PodRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
- package/src/components/resources/resource-utils-hpa.test.ts +30 -0
- package/src/components/resources/resource-utils-hpa.ts +106 -0
- package/src/components/resources/resource-utils.ts +41 -28
- package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
- package/src/components/ui/DistributionBar.tsx +88 -0
- package/src/components/ui/Facet.tsx +130 -0
- package/src/components/ui/PageHeader.tsx +43 -0
- package/src/components/ui/SearchPillInput.tsx +237 -0
- package/src/components/ui/SortableTh.tsx +58 -0
- package/src/components/ui/SummaryTile.tsx +60 -0
- package/src/components/ui/drawer-components.tsx +56 -17
- package/src/components/ui/index.ts +12 -0
- package/src/components/workload/WorkloadView.tsx +18 -1
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +7 -3
- package/src/types/core.ts +64 -2
- package/src/utils/bulk-workload-actions.test.ts +78 -0
- package/src/utils/bulk-workload-actions.ts +50 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-blast-radius.test.ts +48 -3
- package/src/utils/rbac-blast-radius.ts +14 -4
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { HPARenderer } from './HPARenderer'
|
|
4
|
+
|
|
5
|
+
const baseHPA = {
|
|
6
|
+
metadata: { name: 'api-hpa', namespace: 'prod' },
|
|
7
|
+
spec: {
|
|
8
|
+
maxReplicas: 10,
|
|
9
|
+
scaleTargetRef: { apiVersion: 'apps/v1', kind: 'Deployment', name: 'api' },
|
|
10
|
+
},
|
|
11
|
+
status: {
|
|
12
|
+
currentReplicas: 3,
|
|
13
|
+
desiredReplicas: 3,
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('HPARenderer', () => {
|
|
18
|
+
it('does not count ScalingLimited=False as a failing condition', () => {
|
|
19
|
+
const html = renderToString(
|
|
20
|
+
<HPARenderer
|
|
21
|
+
data={{
|
|
22
|
+
...baseHPA,
|
|
23
|
+
status: {
|
|
24
|
+
...baseHPA.status,
|
|
25
|
+
conditions: [
|
|
26
|
+
{ type: 'ScalingActive', status: 'False', reason: 'FailedGetResourceMetric' },
|
|
27
|
+
{ type: 'AbleToScale', status: 'True', reason: 'SucceededGetScale' },
|
|
28
|
+
{ type: 'ScalingLimited', status: 'False', reason: 'DesiredWithinRange' },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
}}
|
|
32
|
+
/>,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
expect(html).toContain('Conditions (3) · 1 failing')
|
|
36
|
+
expect(html).not.toContain('2 failing')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('renders ScalingLimited=True at max as a warning instead of a healthy condition', () => {
|
|
40
|
+
const html = renderToString(
|
|
41
|
+
<HPARenderer
|
|
42
|
+
data={{
|
|
43
|
+
...baseHPA,
|
|
44
|
+
status: {
|
|
45
|
+
...baseHPA.status,
|
|
46
|
+
conditions: [
|
|
47
|
+
{
|
|
48
|
+
type: 'ScalingLimited',
|
|
49
|
+
status: 'True',
|
|
50
|
+
reason: 'TooManyReplicas',
|
|
51
|
+
message: 'the desired replica count is more than the maximum replica count',
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
}}
|
|
56
|
+
/>,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(html).toContain('border-amber-400/60')
|
|
60
|
+
expect(html).toContain('text-amber-600')
|
|
61
|
+
expect(html).not.toContain('failing')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('does not repeat max-limited controller text as a second diagnosis headline', () => {
|
|
65
|
+
const html = renderToString(
|
|
66
|
+
<HPARenderer
|
|
67
|
+
data={baseHPA}
|
|
68
|
+
hpaDiagnosis={{
|
|
69
|
+
state: 'limited_max',
|
|
70
|
+
summary: 'HPA wants more replicas but is capped at maxReplicas=10',
|
|
71
|
+
target: { kind: 'Deployment', name: 'api' },
|
|
72
|
+
bounds: { min: 1, max: 10, current: 10, desired: 10 },
|
|
73
|
+
reasons: [
|
|
74
|
+
{
|
|
75
|
+
id: 'limited_max',
|
|
76
|
+
message: 'the desired replica count is more than the maximum replica count',
|
|
77
|
+
conditionType: 'ScalingLimited',
|
|
78
|
+
conditionReason: 'TooManyReplicas',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
}}
|
|
82
|
+
/>,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
expect(html).toContain('HPA wants more replicas but is capped at maxReplicas=10')
|
|
86
|
+
expect(html).toContain('Evidence')
|
|
87
|
+
expect(html).toContain('ScalingLimited')
|
|
88
|
+
expect(html).toContain('TooManyReplicas')
|
|
89
|
+
expect(html).not.toContain('the desired replica count is more than the maximum replica count')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -1,114 +1,97 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import { Activity, Cpu } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
-
import { Section, PropertyList, Property, ConditionsSection, ResourceLink } from '../../ui/drawer-components'
|
|
4
|
+
import { Section, PropertyList, Property, ConditionsSection, ResourceLink, type ConditionTone } from '../../ui/drawer-components'
|
|
5
|
+
import { Badge, type BadgeSeverity } from '../../ui/Badge'
|
|
5
6
|
import { kindToPlural } from '../../../utils/navigation'
|
|
6
7
|
import { formatAge } from '../resource-utils'
|
|
8
|
+
import { hpaStateLabel, hpaStateLevel } from '../resource-utils-hpa'
|
|
9
|
+
import type { HPADiagnosis, HPADiagnosisState } from '../../../types'
|
|
7
10
|
|
|
8
11
|
interface HPARendererProps {
|
|
9
12
|
data: any
|
|
10
13
|
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
|
|
14
|
+
hpaDiagnosis?: HPADiagnosis
|
|
11
15
|
/** Optional host-provided section rendered after Conditions — used to inject Prometheus-backed charts. */
|
|
12
16
|
extraSections?: ReactNode
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
function hpaBadgeSeverity(state: HPADiagnosisState): BadgeSeverity {
|
|
20
|
+
switch (hpaStateLevel(state)) {
|
|
21
|
+
case 'healthy':
|
|
22
|
+
return 'success'
|
|
23
|
+
case 'unhealthy':
|
|
24
|
+
return 'error'
|
|
25
|
+
case 'degraded':
|
|
26
|
+
return 'warning'
|
|
27
|
+
case 'alert':
|
|
28
|
+
return 'alert'
|
|
29
|
+
case 'neutral':
|
|
30
|
+
return 'info'
|
|
31
|
+
default:
|
|
32
|
+
return 'neutral'
|
|
33
|
+
}
|
|
34
|
+
}
|
|
28
35
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
} else {
|
|
34
|
-
problems.push(`Scaling inactive: ${cond.reason}${cond.message ? ' - ' + cond.message : ''}`)
|
|
35
|
-
}
|
|
36
|
-
}
|
|
36
|
+
function hpaConditionTone(condition: any): ConditionTone | undefined {
|
|
37
|
+
if (condition?.type !== 'ScalingLimited') return undefined
|
|
38
|
+
if (condition.status === 'False') return 'ok'
|
|
39
|
+
if (condition.status !== 'True') return 'unknown'
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
41
|
+
const reason = String(condition.reason ?? '').toLowerCase()
|
|
42
|
+
const message = String(condition.message ?? '').toLowerCase()
|
|
43
|
+
if (reason.includes('toomany') || message.includes('maximum')) return 'warning'
|
|
44
|
+
if (reason.includes('toofew') || message.includes('minimum')) return 'ok'
|
|
45
|
+
return 'warning'
|
|
46
|
+
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const direction = status.desiredReplicas > status.currentReplicas ? 'up' : 'down'
|
|
52
|
-
problems.push(`Scaling ${direction}: ${status.currentReplicas} → ${status.desiredReplicas} replicas`)
|
|
53
|
-
}
|
|
54
|
-
}
|
|
48
|
+
function formatReasonID(id: string): string {
|
|
49
|
+
return id.replace(/_/g, ' ')
|
|
50
|
+
}
|
|
55
51
|
|
|
56
|
-
|
|
52
|
+
function isReasonMessageRedundant(state: HPADiagnosisState, reason: NonNullable<HPADiagnosis['reasons']>[number]): boolean {
|
|
53
|
+
return state === 'limited_max' && reason.id === 'limited_max'
|
|
57
54
|
}
|
|
58
55
|
|
|
59
|
-
export function HPARenderer({ data, onNavigate, extraSections }: HPARendererProps) {
|
|
56
|
+
export function HPARenderer({ data, onNavigate, hpaDiagnosis, extraSections }: HPARendererProps) {
|
|
60
57
|
const status = data.status || {}
|
|
61
58
|
const spec = data.spec || {}
|
|
62
59
|
const metrics = status.currentMetrics || []
|
|
63
60
|
|
|
64
|
-
// Check for problems
|
|
65
|
-
const problems = getHPAProblems(data)
|
|
66
|
-
const hasProblems = problems.length > 0
|
|
67
|
-
|
|
68
|
-
// Determine if these are errors (red) or warnings (yellow)
|
|
69
|
-
const hasErrors = problems.some(p =>
|
|
70
|
-
p.includes('Cannot scale') || p.includes('unavailable') || p.includes('inactive')
|
|
71
|
-
)
|
|
72
|
-
|
|
73
61
|
return (
|
|
74
62
|
<>
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
<AlertTriangle className={clsx(
|
|
85
|
-
'w-4 h-4 mt-0.5 shrink-0',
|
|
86
|
-
hasErrors ? 'text-red-400' : 'text-yellow-400'
|
|
87
|
-
)} />
|
|
88
|
-
<div className="flex-1 min-w-0">
|
|
89
|
-
<div className={clsx(
|
|
90
|
-
'text-sm font-medium mb-1',
|
|
91
|
-
hasErrors ? 'text-red-400' : 'text-yellow-400'
|
|
92
|
-
)}>
|
|
93
|
-
{hasErrors ? 'Scaling Issues' : 'Scaling Status'}
|
|
63
|
+
{hpaDiagnosis && (
|
|
64
|
+
<Section title="Diagnosis" icon={Activity}>
|
|
65
|
+
<div className="card-inner space-y-3">
|
|
66
|
+
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
67
|
+
<div className="min-w-0">
|
|
68
|
+
<div className="text-sm font-medium text-theme-text-primary">{hpaDiagnosis.summary}</div>
|
|
69
|
+
<div className="mt-1 text-xs text-theme-text-secondary">
|
|
70
|
+
{hpaDiagnosis.bounds.current}/{hpaDiagnosis.bounds.desired} replicas, bounds {hpaDiagnosis.bounds.min}-{hpaDiagnosis.bounds.max}
|
|
71
|
+
</div>
|
|
94
72
|
</div>
|
|
95
|
-
<
|
|
96
|
-
'text-xs space-y-1',
|
|
97
|
-
hasErrors ? 'text-red-300' : 'text-yellow-300'
|
|
98
|
-
)}>
|
|
99
|
-
{problems.map((problem, i) => (
|
|
100
|
-
<li key={i} className="flex items-start gap-1.5">
|
|
101
|
-
<span className={clsx(
|
|
102
|
-
'mt-0.5',
|
|
103
|
-
hasErrors ? 'text-red-400/60' : 'text-yellow-400/60'
|
|
104
|
-
)}>•</span>
|
|
105
|
-
<span>{problem}</span>
|
|
106
|
-
</li>
|
|
107
|
-
))}
|
|
108
|
-
</ul>
|
|
73
|
+
<Badge severity={hpaBadgeSeverity(hpaDiagnosis.state)}>{hpaStateLabel(hpaDiagnosis.state)}</Badge>
|
|
109
74
|
</div>
|
|
75
|
+
{hpaDiagnosis.reasons && hpaDiagnosis.reasons.length > 0 && (
|
|
76
|
+
<div className="space-y-2">
|
|
77
|
+
{hpaDiagnosis.reasons.map((reason) => (
|
|
78
|
+
<div key={`${reason.id}-${reason.message}`} className="rounded border border-theme-border bg-theme-surface p-2">
|
|
79
|
+
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
80
|
+
<span className="font-medium text-theme-text-primary">Evidence</span>
|
|
81
|
+
<span className="text-theme-text-tertiary">{formatReasonID(reason.id)}</span>
|
|
82
|
+
{reason.conditionType && <span className="text-theme-text-tertiary">{reason.conditionType}</span>}
|
|
83
|
+
{reason.conditionReason && <span className="text-theme-text-tertiary">{reason.conditionReason}</span>}
|
|
84
|
+
{reason.detail && <span className="text-theme-text-tertiary">{reason.detail}</span>}
|
|
85
|
+
</div>
|
|
86
|
+
{!isReasonMessageRedundant(hpaDiagnosis.state, reason) && (
|
|
87
|
+
<div className="mt-1 text-xs text-theme-text-secondary">{reason.message}</div>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
110
93
|
</div>
|
|
111
|
-
</
|
|
94
|
+
</Section>
|
|
112
95
|
)}
|
|
113
96
|
|
|
114
97
|
<Section title="Scaling" icon={Cpu}>
|
|
@@ -132,7 +115,31 @@ export function HPARenderer({ data, onNavigate, extraSections }: HPARendererProp
|
|
|
132
115
|
</PropertyList>
|
|
133
116
|
</Section>
|
|
134
117
|
|
|
135
|
-
{metrics.length > 0 && (
|
|
118
|
+
{hpaDiagnosis?.metrics && hpaDiagnosis.metrics.length > 0 && (
|
|
119
|
+
<Section title="Metrics" defaultExpanded>
|
|
120
|
+
<div className="space-y-2">
|
|
121
|
+
{hpaDiagnosis.metrics.map((metric) => (
|
|
122
|
+
<div key={`${metric.type}-${metric.name}`} className="card-inner">
|
|
123
|
+
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
|
124
|
+
<div>
|
|
125
|
+
<div className="font-medium text-theme-text-primary">{metric.name}</div>
|
|
126
|
+
<div className="text-xs text-theme-text-tertiary">{metric.type}</div>
|
|
127
|
+
</div>
|
|
128
|
+
<div className="flex flex-wrap items-center gap-2 text-xs text-theme-text-secondary">
|
|
129
|
+
{metric.current && <span>Current {metric.current}</span>}
|
|
130
|
+
{metric.target && <span>Target {metric.target}</span>}
|
|
131
|
+
<Badge severity={metric.status === 'ok' ? 'success' : metric.status === 'missing' ? 'warning' : 'neutral'} size="sm">
|
|
132
|
+
{metric.status.replace(/_/g, ' ')}
|
|
133
|
+
</Badge>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
))}
|
|
138
|
+
</div>
|
|
139
|
+
</Section>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{!hpaDiagnosis?.metrics?.length && metrics.length > 0 && (
|
|
136
143
|
<Section title="Metrics" defaultExpanded>
|
|
137
144
|
<div className="space-y-3">
|
|
138
145
|
{metrics.map((metric: any, i: number) => {
|
|
@@ -162,7 +169,7 @@ export function HPARenderer({ data, onNavigate, extraSections }: HPARendererProp
|
|
|
162
169
|
</Section>
|
|
163
170
|
)}
|
|
164
171
|
|
|
165
|
-
<ConditionsSection conditions={status.conditions} />
|
|
172
|
+
<ConditionsSection conditions={status.conditions} getConditionTone={hpaConditionTone} />
|
|
166
173
|
|
|
167
174
|
{extraSections}
|
|
168
175
|
</>
|
|
@@ -51,3 +51,59 @@ describe('PodRenderer envFrom expansion', () => {
|
|
|
51
51
|
expect(html).not.toContain('PUBLIC_URL<!-- -->=')
|
|
52
52
|
})
|
|
53
53
|
})
|
|
54
|
+
|
|
55
|
+
describe('PodRenderer issues banner', () => {
|
|
56
|
+
it('renders pod status messages for evicted pods', () => {
|
|
57
|
+
const html = renderToString(
|
|
58
|
+
<PodRenderer
|
|
59
|
+
data={{
|
|
60
|
+
metadata: { name: 'nginx', namespace: 'default' },
|
|
61
|
+
spec: { containers: [{ name: 'nginx', image: 'nginx:latest' }] },
|
|
62
|
+
status: {
|
|
63
|
+
phase: 'Failed',
|
|
64
|
+
reason: 'Evicted',
|
|
65
|
+
message: 'Usage of EmptyDir volume "logs-nginx" exceeds the limit "2Gi".',
|
|
66
|
+
},
|
|
67
|
+
}}
|
|
68
|
+
onCopy={() => undefined}
|
|
69
|
+
copied={null}
|
|
70
|
+
/>,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
expect(html).toContain('Issues Detected')
|
|
74
|
+
expect(html).toContain('Evicted')
|
|
75
|
+
expect(html).toContain('Usage of EmptyDir volume')
|
|
76
|
+
expect(html).toContain('exceeds the limit')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('wraps long issue detail text inside the banner', () => {
|
|
80
|
+
const html = renderToString(
|
|
81
|
+
<PodRenderer
|
|
82
|
+
data={{
|
|
83
|
+
metadata: { name: 'api', namespace: 'default' },
|
|
84
|
+
spec: { containers: [{ name: 'api', image: 'registry.example.com/api:missing' }] },
|
|
85
|
+
status: {
|
|
86
|
+
phase: 'Pending',
|
|
87
|
+
containerStatuses: [
|
|
88
|
+
{
|
|
89
|
+
name: 'api',
|
|
90
|
+
restartCount: 0,
|
|
91
|
+
state: {
|
|
92
|
+
waiting: {
|
|
93
|
+
reason: 'ImagePullBackOff',
|
|
94
|
+
message: `Back-off pulling image "${'a'.repeat(240)}"`,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
}}
|
|
101
|
+
onCopy={() => undefined}
|
|
102
|
+
copied={null}
|
|
103
|
+
/>,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
expect(html).toContain('ImagePullBackOff')
|
|
107
|
+
expect(html).toContain('min-w-0 break-words')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -347,7 +347,7 @@ export function PodRenderer({
|
|
|
347
347
|
{podProblems.map((p, i) => (
|
|
348
348
|
<li key={i} className="flex items-start gap-1.5">
|
|
349
349
|
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0 mt-1', SEVERITY_DOT_COLOR[p.severity])} />
|
|
350
|
-
<span className="text-red-600 dark:text-red-400">
|
|
350
|
+
<span className="min-w-0 break-words text-red-600 dark:text-red-400">
|
|
351
351
|
{p.message}
|
|
352
352
|
{p.detail && <span className="text-theme-text-secondary">: {p.detail}</span>}
|
|
353
353
|
</span>
|
|
@@ -34,8 +34,91 @@ describe('WorkloadRenderer', () => {
|
|
|
34
34
|
|
|
35
35
|
expect(html).toContain('disabled=""')
|
|
36
36
|
expect(html).toContain('Manual scaling is disabled')
|
|
37
|
+
expect(html).not.toContain('title="Manual scaling is disabled')
|
|
37
38
|
expect(html).toContain('Controlled by')
|
|
38
39
|
expect(html).toContain('HorizontalPodAutoscaler prod/api')
|
|
39
40
|
expect(html).toContain('ScaledObject prod/api-queue')
|
|
41
|
+
expect(html).toContain('flex-wrap')
|
|
42
|
+
expect(html).toContain('hpa<!-- -->/')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('renders compact HPA diagnosis inline with the state badge first', () => {
|
|
46
|
+
const html = renderToString(
|
|
47
|
+
<WorkloadRenderer
|
|
48
|
+
kind="deployments"
|
|
49
|
+
data={deployment}
|
|
50
|
+
onScale={async () => {}}
|
|
51
|
+
scaleBlockedBy={[{ kind: 'HorizontalPodAutoscaler', namespace: 'prod', name: 'api' }]}
|
|
52
|
+
scalerDiagnostics={[
|
|
53
|
+
{
|
|
54
|
+
ref: { kind: 'HorizontalPodAutoscaler', namespace: 'prod', name: 'api' },
|
|
55
|
+
diagnosis: {
|
|
56
|
+
state: 'limited_max',
|
|
57
|
+
summary: 'HPA wants more replicas but is capped at maxReplicas=5',
|
|
58
|
+
target: { kind: 'Deployment', name: 'api' },
|
|
59
|
+
bounds: { min: 2, max: 5, current: 5, desired: 5 },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
]}
|
|
63
|
+
/>,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
expect(html.indexOf('Maxed')).toBeLessThan(html.indexOf('Wants more; capped at maxReplicas=5'))
|
|
67
|
+
expect(html).toContain('Wants more; capped at maxReplicas=5')
|
|
68
|
+
expect(html).not.toContain('HPA wants more replicas but is capped at maxReplicas=5')
|
|
69
|
+
expect(html).toContain('px-2 py-1.5')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('uses compact missing-metrics copy in workload autoscaler context', () => {
|
|
73
|
+
const html = renderToString(
|
|
74
|
+
<WorkloadRenderer
|
|
75
|
+
kind="deployments"
|
|
76
|
+
data={deployment}
|
|
77
|
+
onScale={async () => {}}
|
|
78
|
+
scaleBlockedBy={[{ kind: 'HorizontalPodAutoscaler', namespace: 'prod', name: 'api' }]}
|
|
79
|
+
scalerDiagnostics={[
|
|
80
|
+
{
|
|
81
|
+
ref: { kind: 'HorizontalPodAutoscaler', namespace: 'prod', name: 'api' },
|
|
82
|
+
diagnosis: {
|
|
83
|
+
state: 'metrics_unavailable',
|
|
84
|
+
summary: 'Add memory requests to the target pods so HPA can compute replicas',
|
|
85
|
+
target: { kind: 'Deployment', name: 'api' },
|
|
86
|
+
bounds: { min: 2, max: 10, current: 3, desired: 3 },
|
|
87
|
+
metrics: [{ type: 'Resource', name: 'memory', status: 'missing' }],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
]}
|
|
91
|
+
/>,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
expect(html).toContain('Metrics unavailable')
|
|
95
|
+
expect(html).toContain('Add memory requests; HPA cannot compute replicas')
|
|
96
|
+
expect(html).not.toContain('Add memory requests to the target pods')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('uses compact pinned copy in workload autoscaler context', () => {
|
|
100
|
+
const html = renderToString(
|
|
101
|
+
<WorkloadRenderer
|
|
102
|
+
kind="deployments"
|
|
103
|
+
data={deployment}
|
|
104
|
+
onScale={async () => {}}
|
|
105
|
+
scaleBlockedBy={[{ kind: 'HorizontalPodAutoscaler', namespace: 'prod', name: 'api' }]}
|
|
106
|
+
scalerDiagnostics={[
|
|
107
|
+
{
|
|
108
|
+
ref: { kind: 'HorizontalPodAutoscaler', namespace: 'prod', name: 'api' },
|
|
109
|
+
diagnosis: {
|
|
110
|
+
state: 'pinned',
|
|
111
|
+
summary: 'HPA is configured for a fixed replica count of 3',
|
|
112
|
+
target: { kind: 'Deployment', name: 'api' },
|
|
113
|
+
bounds: { min: 3, max: 3, current: 3, desired: 3 },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
]}
|
|
117
|
+
/>,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
expect(html).toContain('Pinned')
|
|
121
|
+
expect(html).toContain('Fixed at 3 replicas')
|
|
122
|
+
expect(html).not.toContain('fixed replica count')
|
|
40
123
|
})
|
|
41
124
|
})
|
|
@@ -4,15 +4,24 @@ import { clsx } from 'clsx'
|
|
|
4
4
|
import { Section, PropertyList, Property, ConditionsSection, PodTemplateSection, AlertBanner, ResourceLink, ResourceRefBadge } from '../../ui/drawer-components'
|
|
5
5
|
import { DialogPortal } from '../../ui/DialogPortal'
|
|
6
6
|
import { Tooltip } from '../../ui/Tooltip'
|
|
7
|
-
import
|
|
7
|
+
import { Badge, type BadgeSeverity } from '../../ui/Badge'
|
|
8
|
+
import type { RBACSubjectResponse, RBACPolicyRule, ResourceRef, HPADiagnosis } from '../../../types'
|
|
8
9
|
import { detectBlastRadius, rulePermissivenessScore } from '../../../utils/rbac-blast-radius'
|
|
9
10
|
import { RBACErrorSection, isRBACUnavailable } from './RBACErrorSection'
|
|
11
|
+
import { hpaStateLabel, hpaStateLevel } from '../resource-utils-hpa'
|
|
10
12
|
import {
|
|
11
13
|
rbacVerbBadgeClass,
|
|
12
14
|
rbacResourceBadgeClass,
|
|
13
15
|
rbacApiGroupBadgeClass,
|
|
14
16
|
} from '../../../utils/rbac-badges'
|
|
15
17
|
|
|
18
|
+
export interface ScalerDiagnosis {
|
|
19
|
+
ref: ResourceRef
|
|
20
|
+
diagnosis?: HPADiagnosis
|
|
21
|
+
loading?: boolean
|
|
22
|
+
error?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
interface WorkloadRendererProps {
|
|
17
26
|
kind: string
|
|
18
27
|
data: any
|
|
@@ -21,6 +30,7 @@ interface WorkloadRendererProps {
|
|
|
21
30
|
onScale?: (replicas: number) => Promise<void>
|
|
22
31
|
isScalePending?: boolean
|
|
23
32
|
scaleBlockedBy?: ResourceRef[]
|
|
33
|
+
scalerDiagnostics?: ScalerDiagnosis[]
|
|
24
34
|
onRequestRefresh?: () => void
|
|
25
35
|
/**
|
|
26
36
|
* RBAC reverse-lookup for the workload's pod-template ServiceAccount.
|
|
@@ -110,7 +120,27 @@ function formatScalerLabel(ref: ResourceRef): string {
|
|
|
110
120
|
return `${ref.kind} ${prefix}${ref.name}`
|
|
111
121
|
}
|
|
112
122
|
|
|
113
|
-
|
|
123
|
+
function compactHPASummary(diagnosis: HPADiagnosis): string {
|
|
124
|
+
if (diagnosis.state === 'limited_max') {
|
|
125
|
+
return `Wants more; capped at maxReplicas=${diagnosis.bounds.max}`
|
|
126
|
+
}
|
|
127
|
+
if (diagnosis.state === 'pinned') {
|
|
128
|
+
return `Fixed at ${diagnosis.bounds.current} replicas`
|
|
129
|
+
}
|
|
130
|
+
if (diagnosis.state === 'metrics_unavailable' || diagnosis.state === 'metrics_incomplete') {
|
|
131
|
+
const missingMetric = diagnosis.metrics?.find((metric) => metric.status !== 'ok')
|
|
132
|
+
if (missingMetric?.type === 'Resource' && missingMetric.name) {
|
|
133
|
+
return `Add ${missingMetric.name} requests; HPA cannot compute replicas`
|
|
134
|
+
}
|
|
135
|
+
if (missingMetric?.name) {
|
|
136
|
+
return `${missingMetric.name} metric unavailable; HPA cannot compute replicas`
|
|
137
|
+
}
|
|
138
|
+
return 'Metrics unavailable; HPA cannot compute replicas'
|
|
139
|
+
}
|
|
140
|
+
return diagnosis.summary
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale, isScalePending, scaleBlockedBy, scalerDiagnostics, onRequestRefresh, rbacData, rbacLoading, rbacError }: WorkloadRendererProps) {
|
|
114
144
|
const status = data.status || {}
|
|
115
145
|
const spec = data.spec || {}
|
|
116
146
|
const metadata = data.metadata || {}
|
|
@@ -226,8 +256,15 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
|
|
|
226
256
|
value={
|
|
227
257
|
<div className="flex flex-wrap gap-1">
|
|
228
258
|
{scaleBlockedBy.map((ref) => (
|
|
229
|
-
<ResourceRefBadge key={`${ref.kind}/${ref.namespace}/${ref.name}`} resourceRef={ref} onClick={onNavigate} />
|
|
259
|
+
<ResourceRefBadge key={`${ref.kind}/${ref.namespace}/${ref.name}`} resourceRef={ref} onClick={onNavigate} wrapAtSeparator />
|
|
230
260
|
))}
|
|
261
|
+
{scalerDiagnostics && scalerDiagnostics.length > 0 && (
|
|
262
|
+
<div className="mt-1 w-full space-y-1">
|
|
263
|
+
{scalerDiagnostics.map((entry) => (
|
|
264
|
+
<ScalerDiagnosisRow key={`${entry.ref.kind}/${entry.ref.namespace}/${entry.ref.name}`} entry={entry} />
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
231
268
|
</div>
|
|
232
269
|
}
|
|
233
270
|
/>
|
|
@@ -261,7 +298,6 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
|
|
|
261
298
|
<button
|
|
262
299
|
type="button"
|
|
263
300
|
disabled
|
|
264
|
-
title={scaleBlockedReason}
|
|
265
301
|
aria-label={scaleBlockedReason}
|
|
266
302
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-theme-text-tertiary bg-theme-elevated border border-theme-border rounded cursor-not-allowed"
|
|
267
303
|
>
|
|
@@ -384,6 +420,39 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
|
|
|
384
420
|
)
|
|
385
421
|
}
|
|
386
422
|
|
|
423
|
+
function ScalerDiagnosisRow({ entry }: { entry: ScalerDiagnosis }) {
|
|
424
|
+
if (entry.loading) {
|
|
425
|
+
return <div className="text-xs text-theme-text-tertiary">Loading autoscaler diagnosis...</div>
|
|
426
|
+
}
|
|
427
|
+
if (entry.error) {
|
|
428
|
+
return <div className="text-xs text-theme-text-tertiary">Autoscaler diagnosis unavailable</div>
|
|
429
|
+
}
|
|
430
|
+
if (!entry.diagnosis) return null
|
|
431
|
+
return (
|
|
432
|
+
<div className="rounded border border-theme-border bg-theme-surface px-2 py-1.5 text-xs leading-6 text-theme-text-secondary whitespace-normal break-normal">
|
|
433
|
+
<Badge severity={badgeSeverityForHPA(entry.diagnosis)} size="sm" className="mr-1.5 align-middle">{hpaStateLabel(entry.diagnosis.state)}</Badge>
|
|
434
|
+
<span className="align-middle whitespace-normal break-normal">{compactHPASummary(entry.diagnosis)}</span>
|
|
435
|
+
</div>
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function badgeSeverityForHPA(diagnosis: HPADiagnosis): BadgeSeverity {
|
|
440
|
+
switch (hpaStateLevel(diagnosis.state)) {
|
|
441
|
+
case 'healthy':
|
|
442
|
+
return 'success'
|
|
443
|
+
case 'unhealthy':
|
|
444
|
+
return 'error'
|
|
445
|
+
case 'degraded':
|
|
446
|
+
return 'warning'
|
|
447
|
+
case 'alert':
|
|
448
|
+
return 'alert'
|
|
449
|
+
case 'neutral':
|
|
450
|
+
return 'info'
|
|
451
|
+
default:
|
|
452
|
+
return 'neutral'
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
387
456
|
// ============================================================================
|
|
388
457
|
// WORKLOAD PERMISSIONS SECTION
|
|
389
458
|
// ============================================================================
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { getHPATableState, hpaStateLabel } from './resource-utils-hpa'
|
|
5
|
+
import type { HPADiagnosisState } from '../../types'
|
|
6
|
+
|
|
7
|
+
interface FixtureCase {
|
|
8
|
+
name: string
|
|
9
|
+
hpa: unknown
|
|
10
|
+
expectedTableState: HPADiagnosisState
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('getHPATableState', () => {
|
|
14
|
+
for (const tc of loadCases()) {
|
|
15
|
+
it(`classifies ${tc.name}`, () => {
|
|
16
|
+
expect(getHPATableState(tc.hpa)).toBe(tc.expectedTableState)
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('hpaStateLabel', () => {
|
|
22
|
+
it('keeps max-bound wording terse for table cells', () => {
|
|
23
|
+
expect(hpaStateLabel('limited_max')).toBe('Maxed')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
function loadCases(): FixtureCase[] {
|
|
28
|
+
const path = resolve(process.cwd(), '../../testdata/hpa-diagnosis/cases.json')
|
|
29
|
+
return JSON.parse(readFileSync(path, 'utf8')) as FixtureCase[]
|
|
30
|
+
}
|