@skyhook-io/k8s-ui 1.7.15 → 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 (39) hide show
  1. package/package.json +2 -1
  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/severity.ts +19 -11
  14. package/src/components/resources/ResourcesSidebar.tsx +3 -1
  15. package/src/components/resources/ResourcesView.tsx +13 -0
  16. package/src/components/resources/index.ts +1 -0
  17. package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
  18. package/src/components/resources/renderers/HPARenderer.tsx +93 -86
  19. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  20. package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
  21. package/src/components/resources/resource-utils-hpa.test.ts +30 -0
  22. package/src/components/resources/resource-utils-hpa.ts +106 -0
  23. package/src/components/resources/resource-utils.ts +2 -10
  24. package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
  25. package/src/components/ui/DistributionBar.tsx +88 -0
  26. package/src/components/ui/Facet.tsx +130 -0
  27. package/src/components/ui/PageHeader.tsx +43 -0
  28. package/src/components/ui/SearchPillInput.tsx +237 -0
  29. package/src/components/ui/SortableTh.tsx +58 -0
  30. package/src/components/ui/SummaryTile.tsx +60 -0
  31. package/src/components/ui/drawer-components.tsx +56 -17
  32. package/src/components/ui/index.ts +12 -0
  33. package/src/components/workload/WorkloadView.tsx +18 -1
  34. package/src/theme/tailwind-theme.css +1 -0
  35. package/src/theme/variables.css +7 -3
  36. package/src/types/core.ts +54 -2
  37. package/src/utils/rbac-blast-radius.test.ts +48 -3
  38. package/src/utils/rbac-blast-radius.ts +14 -4
  39. package/tsconfig.json +1 -0
@@ -1,23 +1,21 @@
1
1
  import type { IssueSeverity } from './types';
2
+ import { BADGE_SEVERITY_COLORS as sev } from '../ui/Badge';
2
3
 
3
- // Visual language for the 2-tier Issues severity, deliberately reusing the
4
- // SAME class strings as the Checks queue (components/checks/severity.ts):
5
- // critical = red (= Checks `critical`), warning = amber (= Checks `medium`).
6
- // Issues and Checks are different severity axes, but the queues must read as
7
- // one product — sharing the exact hues makes the rails/pills pixel-identical.
8
- //
9
- // Class strings are literal so each consuming app's Tailwind @source scan emits
10
- // them.
4
+ // Visual language for the 2-tier Issues severity. critical = red, warning =
5
+ // amber. Issues and Checks are different severity axes but must read as one
6
+ // product — both pull from the canonical Badge severity tones
7
+ // (BADGE_SEVERITY_COLORS), so their pills match each other and every status
8
+ // badge elsewhere.
11
9
 
12
10
  export const ISSUE_SEVERITY_LABEL: Record<IssueSeverity, string> = {
13
11
  critical: 'Critical',
14
12
  warning: 'Warning',
15
13
  };
16
14
 
17
- // Pill badge — the loud, explicit severity signal on a row.
15
+ // Pill badge — the loud, explicit severity signal on a row (rendered with `badge-sm`).
18
16
  export const ISSUE_SEVERITY_BADGE_CLASS: Record<IssueSeverity, string> = {
19
- critical: 'bg-red-50 text-red-700 ring-1 ring-red-200 dark:bg-red-950/50 dark:text-red-300 dark:ring-red-900',
20
- warning: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200 dark:bg-amber-950/50 dark:text-amber-300 dark:ring-amber-900',
17
+ critical: sev.error,
18
+ warning: sev.warning,
21
19
  };
22
20
 
23
21
  // Solid fill — dots + the proportional distribution bar segments.
@@ -73,12 +71,18 @@ const CATEGORY_LABEL: Record<string, string> = {
73
71
  high_restart: 'High restart count',
74
72
  missing_config_ref: 'Missing reference',
75
73
  pdb_blocks_evictions: 'PDB blocks evictions',
74
+ secret_sync_failed: 'Secret sync failed',
76
75
  service_no_endpoints: 'No endpoints',
77
76
  ingress_backend_missing: 'Ingress backend missing',
77
+ load_balancer_pending: 'Load balancer pending',
78
+ gateway_not_ready: 'Gateway not ready',
79
+ gateway_route_invalid: 'Gateway route invalid',
78
80
  dns_failure: 'DNS failure',
79
81
  network_policy_block: 'Network policy block',
80
82
  pvc_pending: 'PVC pending',
81
83
  pvc_lost: 'PVC lost',
84
+ pv_failed: 'PV failed',
85
+ pvc_resize_failed: 'PVC resize failed',
82
86
  volume_mount_failed: 'Volume mount failed',
83
87
  volume_access_mode_conflict: 'Volume access conflict',
84
88
  job_failed: 'Job failed',
@@ -89,6 +93,10 @@ const CATEGORY_LABEL: Record<string, string> = {
89
93
  certificate_not_ready: 'Certificate not ready',
90
94
  pod_security_violation: 'Pod Security violation',
91
95
  node_not_ready: 'Node not ready',
96
+ node_provisioning_failed: 'Node provisioning failed',
97
+ apiservice_unavailable: 'APIService unavailable',
98
+ crossplane_reconcile_failed: 'Crossplane reconcile failed',
99
+ termination_stuck: 'Stuck terminating',
92
100
  operator_condition_failed: 'Controller condition',
93
101
  gitops_sync_failed: 'GitOps sync failed',
94
102
  gitops_render_failed: 'GitOps render failed',
@@ -464,7 +464,9 @@ export function ResourcesSidebar({
464
464
 
465
465
  return (
466
466
  <div className={clsx('w-56 2xl:w-72 bg-theme-surface dark:bg-theme-base border-r border-theme-border overflow-y-auto overflow-x-hidden shrink-0', className)}>
467
- <div className="px-2 py-2 border-b border-theme-border">
467
+ {/* py-3 matches the table toolbar's vertical padding so the sidebar
468
+ header and the toolbar align (same band height + border line). */}
469
+ <div className="px-2 py-3 border-b border-theme-border">
468
470
  <div className="relative">
469
471
  <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
470
472
  <input
@@ -2722,6 +2722,19 @@ export function ResourcesView({
2722
2722
  scope: 'resources',
2723
2723
  handler: () => handleSort('status'),
2724
2724
  },
2725
+ {
2726
+ id: 'resources-bulk-select',
2727
+ keys: 'b',
2728
+ description: 'Toggle bulk select',
2729
+ category: 'Table',
2730
+ scope: 'resources',
2731
+ handler: () => {
2732
+ if (bulkMode) { exitBulkMode(); return }
2733
+ if (!canBulkSelect) return
2734
+ exitCompareMode()
2735
+ setBulkMode(true)
2736
+ },
2737
+ },
2725
2738
  {
2726
2739
  id: 'resources-clear-highlight',
2727
2740
  keys: 'Escape',
@@ -1,4 +1,5 @@
1
1
  export * from './resource-utils'
2
+ export * from './resource-utils-hpa'
2
3
  export * from './resource-utils-argo'
3
4
  export * from './resource-utils-certmanager'
4
5
  export * from './resource-utils-cnpg'
@@ -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
  </>
@@ -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
  // ============================================================================