@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.
- package/package.json +2 -1
- 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/severity.ts +19 -11
- package/src/components/resources/ResourcesSidebar.tsx +3 -1
- package/src/components/resources/ResourcesView.tsx +13 -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/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 +2 -10
- 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 +54 -2
- package/src/utils/rbac-blast-radius.test.ts +48 -3
- package/src/utils/rbac-blast-radius.ts +14 -4
- 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,
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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:
|
|
20
|
-
warning:
|
|
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
|
-
|
|
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',
|
|
@@ -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
|
</>
|
|
@@ -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
|
// ============================================================================
|