@skyhook-io/k8s-ui 1.7.0 → 1.7.2
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 +1 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +105 -2
- package/src/components/resources/renderers/PodRenderer.tsx +6 -3
- package/src/components/resources/resource-utils.ts +22 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
- package/src/components/topology/K8sResourceNode.tsx +51 -1
- package/src/utils/navigation.test.ts +7 -0
- package/src/utils/navigation.ts +1 -0
package/package.json
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { Shield, Box, Users } from 'lucide-react'
|
|
1
|
+
import { Shield, Box, Users, Gauge } from 'lucide-react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, PropertyList, Property, ResourceLink } from '../../ui/drawer-components'
|
|
4
4
|
import type { RBACNamespaceResponse, RBACBindingWithSubjects, RBACSubject, ResourceRef } from '../../../types'
|
|
5
5
|
import { rbacKindBadgeClass } from '../../../utils/rbac-badges'
|
|
6
|
+
import { SEVERITY_TEXT, SEVERITY_DOT } from '../../../utils/badge-colors'
|
|
7
|
+
import { parseCPUToNanocores, parseMemoryToBytes } from '../../../utils/format'
|
|
6
8
|
|
|
7
9
|
interface NamespaceRendererProps {
|
|
8
10
|
data: any
|
|
@@ -14,10 +16,23 @@ interface NamespaceRendererProps {
|
|
|
14
16
|
rbacData?: RBACNamespaceResponse | null
|
|
15
17
|
rbacLoading?: boolean
|
|
16
18
|
rbacError?: Error | null
|
|
19
|
+
/**
|
|
20
|
+
* ResourceQuota objects for this namespace (from /api/resources/
|
|
21
|
+
* resourcequotas?namespace=). Undefined when the host hasn't wired the
|
|
22
|
+
* fetch (quota section omitted). A saturated quota is exactly why a
|
|
23
|
+
* namespace stops admitting pods, yet it's shown nowhere else.
|
|
24
|
+
*/
|
|
25
|
+
quotaData?: any[] | null
|
|
26
|
+
/**
|
|
27
|
+
* Non-403 quota fetch error. When set, the quota section renders a note
|
|
28
|
+
* instead of silently disappearing — so a quota-constrained namespace whose
|
|
29
|
+
* fetch 500/503s isn't mistaken for quota-free. (403 stays hidden upstream.)
|
|
30
|
+
*/
|
|
31
|
+
quotaError?: Error | null
|
|
17
32
|
onNavigate?: (ref: ResourceRef) => void
|
|
18
33
|
}
|
|
19
34
|
|
|
20
|
-
export function NamespaceRenderer({ data, rbacData, rbacLoading, rbacError, onNavigate }: NamespaceRendererProps) {
|
|
35
|
+
export function NamespaceRenderer({ data, rbacData, rbacLoading, rbacError, quotaData, quotaError, onNavigate }: NamespaceRendererProps) {
|
|
21
36
|
const metadata = data.metadata || {}
|
|
22
37
|
const status = data.status || {}
|
|
23
38
|
const phase = status.phase
|
|
@@ -48,6 +63,11 @@ export function NamespaceRenderer({ data, rbacData, rbacLoading, rbacError, onNa
|
|
|
48
63
|
</PropertyList>
|
|
49
64
|
</Section>
|
|
50
65
|
|
|
66
|
+
{/* ResourceQuota usage — only when host wired the fetch. */}
|
|
67
|
+
{(quotaError || (quotaData != null && quotaData.length > 0)) && (
|
|
68
|
+
<NamespaceQuotaSection quotas={quotaData ?? []} error={quotaError ?? null} />
|
|
69
|
+
)}
|
|
70
|
+
|
|
51
71
|
{/* RBAC summary — only when host wired the fetch. */}
|
|
52
72
|
{rbacData !== undefined && (
|
|
53
73
|
<NamespaceRBACSection
|
|
@@ -61,6 +81,89 @@ export function NamespaceRenderer({ data, rbacData, rbacLoading, rbacError, onNa
|
|
|
61
81
|
)
|
|
62
82
|
}
|
|
63
83
|
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// NAMESPACE QUOTA SECTION
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// Shows ResourceQuota saturation — the signal that answers "why did this
|
|
88
|
+
// namespace stop admitting pods?" A quota at its hard limit blocks every new
|
|
89
|
+
// pod the namespace tries to create, with no failing Pod to inspect (the
|
|
90
|
+
// controller's FailedCreate event is the only trace). Surfacing usage here
|
|
91
|
+
// turns that invisible failure into a glanceable bar.
|
|
92
|
+
|
|
93
|
+
// quotaUsageRatio parses a used/hard pair for a quota resource, picking the
|
|
94
|
+
// right unit parser by resource name (cpu → millicores, memory/storage →
|
|
95
|
+
// bytes, everything else → plain count). Returns null when hard is unset or
|
|
96
|
+
// unparseable so the row falls back to showing the raw strings.
|
|
97
|
+
function quotaUsageRatio(resourceName: string, used: string, hard: string): number | null {
|
|
98
|
+
if (!hard) return null
|
|
99
|
+
const isCPU = /(^|\.)cpu$/i.test(resourceName)
|
|
100
|
+
const isBytes = /(memory|storage)$/i.test(resourceName)
|
|
101
|
+
const parse = isCPU ? parseCPUToNanocores : isBytes ? parseMemoryToBytes : (v: string) => parseFloat(v) || 0
|
|
102
|
+
const h = parse(hard)
|
|
103
|
+
if (!h) return null
|
|
104
|
+
return parse(used || '0') / h
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function NamespaceQuotaSection({ quotas, error }: { quotas: any[]; error?: Error | null }) {
|
|
108
|
+
return (
|
|
109
|
+
<Section title="Resource Quotas" icon={Gauge} defaultExpanded>
|
|
110
|
+
{error && (
|
|
111
|
+
<div className="text-xs text-theme-text-secondary">
|
|
112
|
+
Couldn’t load resource quotas — retry shortly. A quota at its limit blocks new pods in this namespace.
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
<div className="space-y-3">
|
|
116
|
+
{quotas.map((q: any, qi: number) => {
|
|
117
|
+
const name = q?.metadata?.name ?? `quota-${qi}`
|
|
118
|
+
const hard: Record<string, string> = q?.status?.hard ?? q?.spec?.hard ?? {}
|
|
119
|
+
const used: Record<string, string> = q?.status?.used ?? {}
|
|
120
|
+
const resourceNames = Object.keys(hard).sort()
|
|
121
|
+
return (
|
|
122
|
+
<div key={name} className="card-inner">
|
|
123
|
+
<div className="text-xs font-medium text-theme-text-primary mb-1.5">{name}</div>
|
|
124
|
+
{resourceNames.length === 0 ? (
|
|
125
|
+
<div className="text-xs text-theme-text-secondary">No hard limits set.</div>
|
|
126
|
+
) : (
|
|
127
|
+
<div className="space-y-1">
|
|
128
|
+
{resourceNames.map((res) => {
|
|
129
|
+
const ratio = quotaUsageRatio(res, used[res] ?? '0', hard[res])
|
|
130
|
+
const pct = ratio === null ? null : Math.min(100, Math.round(ratio * 100))
|
|
131
|
+
const tone =
|
|
132
|
+
ratio === null ? SEVERITY_TEXT.neutral
|
|
133
|
+
: ratio >= 1 ? SEVERITY_TEXT.error
|
|
134
|
+
: ratio >= 0.9 ? SEVERITY_TEXT.alert
|
|
135
|
+
: SEVERITY_TEXT.neutral
|
|
136
|
+
const barTone =
|
|
137
|
+
ratio === null ? 'bg-theme-border'
|
|
138
|
+
: ratio >= 1 ? SEVERITY_DOT.error
|
|
139
|
+
: ratio >= 0.9 ? SEVERITY_DOT.alert
|
|
140
|
+
: 'bg-theme-text-tertiary'
|
|
141
|
+
return (
|
|
142
|
+
<div key={res} className="text-xs">
|
|
143
|
+
<div className="flex items-center justify-between gap-2">
|
|
144
|
+
<span className="text-theme-text-secondary truncate">{res}</span>
|
|
145
|
+
<span className={clsx('shrink-0 tabular-nums', tone)}>
|
|
146
|
+
{used[res] ?? '0'} / {hard[res]}{pct !== null && ` (${pct}%)`}
|
|
147
|
+
</span>
|
|
148
|
+
</div>
|
|
149
|
+
{pct !== null && (
|
|
150
|
+
<div className="mt-0.5 h-1 rounded-full bg-theme-base overflow-hidden">
|
|
151
|
+
<div className={clsx('h-full rounded-full', barTone)} style={{ width: `${pct}%` }} />
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
})}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
})}
|
|
162
|
+
</div>
|
|
163
|
+
</Section>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
64
167
|
// ============================================================================
|
|
65
168
|
// NAMESPACE RBAC SECTION
|
|
66
169
|
// ============================================================================
|
|
@@ -338,9 +338,12 @@ export function PodRenderer({
|
|
|
338
338
|
<AlertBanner variant="error" title="Issues Detected">
|
|
339
339
|
<ul className="text-xs space-y-1">
|
|
340
340
|
{podProblems.map((p, i) => (
|
|
341
|
-
<li key={i} className="flex items-
|
|
342
|
-
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_COLOR[p.severity])} />
|
|
343
|
-
<span className="text-red-600 dark:text-red-400">
|
|
341
|
+
<li key={i} className="flex items-start gap-1.5">
|
|
342
|
+
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0 mt-1', SEVERITY_DOT_COLOR[p.severity])} />
|
|
343
|
+
<span className="text-red-600 dark:text-red-400">
|
|
344
|
+
{p.message}
|
|
345
|
+
{p.detail && <span className="text-theme-text-secondary">: {p.detail}</span>}
|
|
346
|
+
</span>
|
|
344
347
|
</li>
|
|
345
348
|
))}
|
|
346
349
|
</ul>
|
|
@@ -48,6 +48,27 @@ export const healthColors: Record<HealthLevel, string> = {
|
|
|
48
48
|
export interface PodProblem {
|
|
49
49
|
severity: 'critical' | 'high' | 'medium'
|
|
50
50
|
message: string
|
|
51
|
+
// detail carries extra human context shown after the short message (e.g.
|
|
52
|
+
// the scheduler's verdict for an Unschedulable pod). message stays the
|
|
53
|
+
// stable short label so filter-chip matching (podMatchesProblemCategory)
|
|
54
|
+
// and known-pattern checks keep working on exact strings.
|
|
55
|
+
detail?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Condense a kube-scheduler verdict (the PodScheduled=False / FailedScheduling
|
|
60
|
+
* message) for display: drop the "0/N nodes are available:" prefix and the
|
|
61
|
+
* "preemption: …" tail, keeping the per-predicate clause list — which already
|
|
62
|
+
* names untolerated taints, insufficient resources, and affinity/selector
|
|
63
|
+
* misses. Presentation-only; the backend `scheduling` issue source does the
|
|
64
|
+
* structured decomposition + node-label resolution (e.g. naming arm64).
|
|
65
|
+
*/
|
|
66
|
+
export function summarizeSchedulerMessage(message?: string): string {
|
|
67
|
+
if (!message) return ''
|
|
68
|
+
let m = message.split('. preemption:')[0].split(' preemption:')[0].trim()
|
|
69
|
+
const colon = m.indexOf(':')
|
|
70
|
+
if (colon >= 0) m = m.slice(colon + 1).trim()
|
|
71
|
+
return m.replace(/\.\s*$/, '').trim()
|
|
51
72
|
}
|
|
52
73
|
|
|
53
74
|
/** Tailwind classes for severity dot indicators (used in tooltips and alert banners) */
|
|
@@ -302,7 +323,7 @@ export function getPodProblems(pod: any): PodProblem[] {
|
|
|
302
323
|
for (const cond of conditions) {
|
|
303
324
|
if (cond.type === 'PodScheduled' && cond.status === 'False') {
|
|
304
325
|
if (cond.reason === 'Unschedulable') {
|
|
305
|
-
problems.push({ severity: 'high', message: 'Unschedulable' })
|
|
326
|
+
problems.push({ severity: 'high', message: 'Unschedulable', detail: summarizeSchedulerMessage(cond.message) || undefined })
|
|
306
327
|
}
|
|
307
328
|
}
|
|
308
329
|
// Readiness/Liveness probe failures
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { summarizeSchedulerMessage } from './resource-utils'
|
|
3
|
+
|
|
4
|
+
describe('summarizeSchedulerMessage', () => {
|
|
5
|
+
it('strips the "0/N nodes are available:" prefix and the preemption tail', () => {
|
|
6
|
+
const msg =
|
|
7
|
+
'0/5 nodes are available: 2 Insufficient cpu, 3 node(s) had untolerated taint {dedicated: gpu}. ' +
|
|
8
|
+
'preemption: 0/5 nodes are available: 5 No preemption victims found for incoming pod.'
|
|
9
|
+
expect(summarizeSchedulerMessage(msg)).toBe(
|
|
10
|
+
'2 Insufficient cpu, 3 node(s) had untolerated taint {dedicated: gpu}',
|
|
11
|
+
)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns the clause list without a node prefix unchanged (minus trailing period)', () => {
|
|
15
|
+
expect(summarizeSchedulerMessage('0/2 nodes are available: 2 Insufficient memory.')).toBe(
|
|
16
|
+
'2 Insufficient memory',
|
|
17
|
+
)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('handles the bare " preemption:" tail variant', () => {
|
|
21
|
+
expect(
|
|
22
|
+
summarizeSchedulerMessage('0/3 nodes are available: 3 Insufficient cpu preemption: not helpful'),
|
|
23
|
+
).toBe('3 Insufficient cpu')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns empty string for empty/undefined input (so detail is omitted, message stays the stable label)', () => {
|
|
27
|
+
expect(summarizeSchedulerMessage('')).toBe('')
|
|
28
|
+
expect(summarizeSchedulerMessage(undefined)).toBe('')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -43,13 +43,63 @@ function getIssueTooltip(issue: string | undefined): React.ReactNode {
|
|
|
43
43
|
Pending: {
|
|
44
44
|
title: 'Pending',
|
|
45
45
|
description: 'Pod is waiting to be scheduled to a node.',
|
|
46
|
-
action: '
|
|
46
|
+
action: 'Open the pod to see the scheduler verdict (taints, resources, affinity).',
|
|
47
47
|
},
|
|
48
48
|
FailedScheduling: {
|
|
49
49
|
title: 'Scheduling Failed',
|
|
50
50
|
description: 'No suitable node found for this pod.',
|
|
51
51
|
action: 'Check node resources, taints, tolerations, and affinity rules.',
|
|
52
52
|
},
|
|
53
|
+
Unschedulable: {
|
|
54
|
+
title: 'Unschedulable',
|
|
55
|
+
description: 'The scheduler tried every node and none fit.',
|
|
56
|
+
action: 'Open the pod for the decomposed reason — arch/OS mismatch, untolerated taint, insufficient resources, or affinity.',
|
|
57
|
+
},
|
|
58
|
+
QuotaExceeded: {
|
|
59
|
+
title: 'ResourceQuota Exceeded',
|
|
60
|
+
description: 'A namespace ResourceQuota is at its hard limit, so new pods are rejected at admission.',
|
|
61
|
+
action: 'Open the namespace to see quota usage; raise the quota or free usage.',
|
|
62
|
+
},
|
|
63
|
+
QuotaNearLimit: {
|
|
64
|
+
title: 'ResourceQuota Near Limit',
|
|
65
|
+
description: 'A namespace ResourceQuota is close to its hard limit and will soon block new pods.',
|
|
66
|
+
action: 'Open the namespace to see quota usage.',
|
|
67
|
+
},
|
|
68
|
+
IPExhaustion: {
|
|
69
|
+
title: 'IP Exhaustion (CNI)',
|
|
70
|
+
description: 'The pod was scheduled but the CNI could not assign an IP — the node/subnet pool is exhausted.',
|
|
71
|
+
action: 'Free IPs, scale the subnet/ENI pool, or move the pod to a node with capacity.',
|
|
72
|
+
},
|
|
73
|
+
SandboxCreationFailed: {
|
|
74
|
+
title: 'Sandbox Creation Failed',
|
|
75
|
+
description: 'The kubelet could not create the pod sandbox.',
|
|
76
|
+
action: 'Check kubelet/CNI events on the node.',
|
|
77
|
+
},
|
|
78
|
+
VolumeMount: {
|
|
79
|
+
title: 'Volume Mount Failed',
|
|
80
|
+
description: 'The pod was scheduled but a volume could not be mounted.',
|
|
81
|
+
action: 'Check the PVC/PV binding and the CSI driver on the node.',
|
|
82
|
+
},
|
|
83
|
+
VolumeAttach: {
|
|
84
|
+
title: 'Volume Attach Failed',
|
|
85
|
+
description: 'A volume could not be attached to the node.',
|
|
86
|
+
action: 'Check the CSI driver and cloud-provider attach limits.',
|
|
87
|
+
},
|
|
88
|
+
VolumeMultiAttach: {
|
|
89
|
+
title: 'Volume Multi-Attach',
|
|
90
|
+
description: 'The volume is still attached to another node — a RWO volume cannot attach in two places.',
|
|
91
|
+
action: 'Wait for the old pod to terminate, or cordon/drain the stale node.',
|
|
92
|
+
},
|
|
93
|
+
PodSecurityViolation: {
|
|
94
|
+
title: 'Pod Security Violation',
|
|
95
|
+
description: 'Pod Security Admission rejected the pod template at admission.',
|
|
96
|
+
action: 'Align the pod securityContext with the namespace PSA level.',
|
|
97
|
+
},
|
|
98
|
+
WebhookDenied: {
|
|
99
|
+
title: 'Admission Webhook Denied',
|
|
100
|
+
description: 'A validating/mutating admission webhook rejected pod creation.',
|
|
101
|
+
action: 'Check the webhook policy that denied the request.',
|
|
102
|
+
},
|
|
53
103
|
Evicted: {
|
|
54
104
|
title: 'Pod Evicted',
|
|
55
105
|
description: 'Pod was evicted from the node (usually due to resource pressure).',
|
|
@@ -25,6 +25,13 @@ describe('kindToPlural', () => {
|
|
|
25
25
|
expect(kindToPlural('NetworkPolicy')).toBe('networkpolicies')
|
|
26
26
|
})
|
|
27
27
|
|
|
28
|
+
test('handles already-plural kind names (Endpoints)', () => {
|
|
29
|
+
// The Kind "Endpoints" IS its resource name; englishPlural would wrongly
|
|
30
|
+
// yield "endpointses" (ends in s → +es) without the builtin map entry.
|
|
31
|
+
expect(kindToPlural('Endpoints')).toBe('endpoints')
|
|
32
|
+
expect(pluralToKind('endpoints')).toBe('Endpoints')
|
|
33
|
+
})
|
|
34
|
+
|
|
28
35
|
test('handles kinds ending in ss (Class-suffix)', () => {
|
|
29
36
|
expect(kindToPlural('StorageClass')).toBe('storageclasses')
|
|
30
37
|
expect(kindToPlural('IngressClass')).toBe('ingressclasses')
|
package/src/utils/navigation.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type NavigateToResource = (resource: SelectedResource) => void
|
|
|
11
11
|
const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
|
|
12
12
|
pods: 'Pod',
|
|
13
13
|
services: 'Service',
|
|
14
|
+
endpoints: 'Endpoints', // already-plural resource name; englishPlural would yield "endpointses"
|
|
14
15
|
deployments: 'Deployment',
|
|
15
16
|
daemonsets: 'DaemonSet',
|
|
16
17
|
statefulsets: 'StatefulSet',
|