@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -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-center gap-1.5">
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">{p.message}</span>
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: 'Check for resource constraints or node availability.',
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')
@@ -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',