@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.
Files changed (48) hide show
  1. package/package.json +7 -7
  2. package/src/components/applications/ApplicationsList.tsx +131 -115
  3. package/src/components/applications/index.ts +3 -1
  4. package/src/components/audit/AuditCard.tsx +1 -1
  5. package/src/components/charts/AreaChart.tsx +52 -18
  6. package/src/components/charts/MetricsSummary.tsx +12 -3
  7. package/src/components/charts/saturation.test.ts +34 -0
  8. package/src/components/charts/saturation.ts +1 -0
  9. package/src/components/charts/types.ts +5 -4
  10. package/src/components/checks/ChecksView.tsx +12 -23
  11. package/src/components/checks/severity.ts +11 -11
  12. package/src/components/gitops/GitOpsTableView.tsx +298 -274
  13. package/src/components/issues/IssuesView.tsx +4 -6
  14. package/src/components/issues/severity.ts +19 -11
  15. package/src/components/issues/types.ts +22 -2
  16. package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
  17. package/src/components/resources/ResourcesSidebar.tsx +3 -1
  18. package/src/components/resources/ResourcesView.tsx +180 -21
  19. package/src/components/resources/get-pod-problems.test.ts +127 -0
  20. package/src/components/resources/index.ts +1 -0
  21. package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
  22. package/src/components/resources/renderers/HPARenderer.tsx +93 -86
  23. package/src/components/resources/renderers/PodRenderer.test.tsx +56 -0
  24. package/src/components/resources/renderers/PodRenderer.tsx +1 -1
  25. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  26. package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
  27. package/src/components/resources/resource-utils-hpa.test.ts +30 -0
  28. package/src/components/resources/resource-utils-hpa.ts +106 -0
  29. package/src/components/resources/resource-utils.ts +41 -28
  30. package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
  31. package/src/components/ui/DistributionBar.tsx +88 -0
  32. package/src/components/ui/Facet.tsx +130 -0
  33. package/src/components/ui/PageHeader.tsx +43 -0
  34. package/src/components/ui/SearchPillInput.tsx +237 -0
  35. package/src/components/ui/SortableTh.tsx +58 -0
  36. package/src/components/ui/SummaryTile.tsx +60 -0
  37. package/src/components/ui/drawer-components.tsx +56 -17
  38. package/src/components/ui/index.ts +12 -0
  39. package/src/components/workload/WorkloadView.tsx +18 -1
  40. package/src/theme/tailwind-theme.css +1 -0
  41. package/src/theme/variables.css +7 -3
  42. package/src/types/core.ts +64 -2
  43. package/src/utils/bulk-workload-actions.test.ts +78 -0
  44. package/src/utils/bulk-workload-actions.ts +50 -0
  45. package/src/utils/index.ts +1 -0
  46. package/src/utils/rbac-blast-radius.test.ts +48 -3
  47. package/src/utils/rbac-blast-radius.ts +14 -4
  48. 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 { Cpu, AlertTriangle } from 'lucide-react'
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
- // Extract problems from HPA conditions
16
- function getHPAProblems(data: any): string[] {
17
- const problems: string[] = []
18
- const conditions = data.status?.conditions || []
19
- const status = data.status || {}
20
- const spec = data.spec || {}
21
-
22
- // Check conditions for issues
23
- for (const cond of conditions) {
24
- // AbleToScale = False means target not found or can't scale
25
- if (cond.type === 'AbleToScale' && cond.status === 'False') {
26
- problems.push(`Cannot scale: ${cond.reason}${cond.message ? ' - ' + cond.message : ''}`)
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
- // ScalingActive = False means metrics unavailable
30
- if (cond.type === 'ScalingActive' && cond.status === 'False') {
31
- if (cond.reason === 'FailedGetResourceMetric') {
32
- problems.push('Metrics unavailable — is metrics-server running?')
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
- // ScalingLimited = True means at min/max bound
39
- if (cond.type === 'ScalingLimited' && cond.status === 'True') {
40
- if (cond.reason === 'TooFewReplicas') {
41
- problems.push(`At minimum replicas (${spec.minReplicas || 1}) — cannot scale down further`)
42
- } else if (cond.reason === 'TooManyReplicas') {
43
- problems.push(`At maximum replicas (${spec.maxReplicas}) — cannot scale up further`)
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
- // Check for desired != current (scaling in progress or stuck)
49
- if (status.currentReplicas !== undefined && status.desiredReplicas !== undefined) {
50
- if (status.currentReplicas !== status.desiredReplicas) {
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
- return problems
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
- {/* Problems/warnings alert */}
76
- {hasProblems && (
77
- <div className={clsx(
78
- 'mb-4 p-3 rounded-lg border',
79
- hasErrors
80
- ? 'bg-red-500/10 border-red-500/30'
81
- : 'bg-yellow-500/10 border-yellow-500/30'
82
- )}>
83
- <div className="flex items-start gap-2">
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
- <ul className={clsx(
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
- </div>
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 type { RBACSubjectResponse, RBACPolicyRule, ResourceRef } from '../../../types'
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
- export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale, isScalePending, scaleBlockedBy, onRequestRefresh, rbacData, rbacLoading, rbacError }: WorkloadRendererProps) {
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
+ }