@skyhook-io/k8s-ui 1.7.2 → 1.7.3

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.2",
3
+ "version": "1.7.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -63,7 +63,7 @@
63
63
  "dependencies": {
64
64
  "@monaco-editor/react": "^4.7.0",
65
65
  "html-to-image": "^1.11.0",
66
- "react-virtuoso": "^4.18.6",
66
+ "react-virtuoso": "^4.18.7",
67
67
  "shiki": "^4.0.0"
68
68
  },
69
69
  "peerDependencies": {
@@ -90,7 +90,7 @@
90
90
  "clsx": "^2.1.1",
91
91
  "diff": "^9.0.0",
92
92
  "elkjs": "^0.11.1",
93
- "lucide-react": "^1.12.0",
93
+ "lucide-react": "^1.16.0",
94
94
  "react": "^19.2.6",
95
95
  "react-dom": "^19.2.6",
96
96
  "typescript": "^6.0.2",
@@ -3,6 +3,7 @@ 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 { RBACErrorSection } from './RBACErrorSection'
6
7
  import { SEVERITY_TEXT, SEVERITY_DOT } from '../../../utils/badge-colors'
7
8
  import { parseCPUToNanocores, parseMemoryToBytes } from '../../../utils/format'
8
9
 
@@ -197,11 +198,7 @@ function NamespaceRBACSection({
197
198
  )
198
199
  }
199
200
  if (error) {
200
- return (
201
- <Section title="RBAC" icon={Shield}>
202
- <div className="text-sm text-red-400">Could not load RBAC summary: {error.message}</div>
203
- </Section>
204
- )
201
+ return <RBACErrorSection title="RBAC" error={error} errorPrefix="Could not load RBAC summary" />
205
202
  }
206
203
  if (!rbacData) return null
207
204
 
@@ -10,6 +10,7 @@ import {
10
10
  rbacApiGroupBadgeClass,
11
11
  } from '../../../utils/rbac-badges'
12
12
  import { detectBlastRadius, rulePermissivenessScore } from '../../../utils/rbac-blast-radius'
13
+ import { RBACErrorSection, isRBACUnavailable } from './RBACErrorSection'
13
14
  import type { ResolvedEnvFrom, RBACSubjectResponse, RBACPolicyRule } from '../../../types'
14
15
  import { Tooltip } from '../../ui/Tooltip'
15
16
  import { MetricsChart } from '../../ui/MetricsChart'
@@ -877,13 +878,11 @@ function PodPermissionsSection({
877
878
  )
878
879
  }
879
880
  if (error) {
880
- return (
881
- <Section title={title} icon={Shield}>
882
- <div className="text-sm text-red-400">
883
- Could not load permissions: {error.message}
884
- </div>
885
- </Section>
886
- )
881
+ // Permissions is a bonus section here; when RBAC is simply not available
882
+ // (cluster-static) or forbidden, hide it rather than repeat a note on every
883
+ // Pod. Genuine faults still surface.
884
+ if (isRBACUnavailable(error)) return null
885
+ return <RBACErrorSection title={title} error={error} />
887
886
  }
888
887
  if (!rbacData) return null
889
888
 
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { RBACErrorSection, isRBACUnavailable } from './RBACErrorSection'
4
+
5
+ function err(message: string, status?: number): Error {
6
+ return Object.assign(new Error(message), status === undefined ? {} : { status })
7
+ }
8
+
9
+ describe('isRBACUnavailable', () => {
10
+ it('is true for the RBAC-cache 503 and any 403 (expected, non-actionable)', () => {
11
+ expect(isRBACUnavailable(err('RBAC cache not available', 503))).toBe(true)
12
+ expect(isRBACUnavailable(err('forbidden', 403))).toBe(true)
13
+ })
14
+
15
+ it('is false for genuine faults so they still surface', () => {
16
+ expect(isRBACUnavailable(err('Not connected to cluster', 503))).toBe(false)
17
+ expect(isRBACUnavailable(err('boom', 500))).toBe(false)
18
+ expect(isRBACUnavailable(err('Failed to fetch'))).toBe(false)
19
+ })
20
+ })
21
+
22
+ describe('RBACErrorSection', () => {
23
+ it('renders 503 (SA cannot read RBAC) as a calm note, not a red error', () => {
24
+ const html = renderToString(
25
+ <RBACErrorSection title="Permissions" error={err('RBAC cache not available', 503)} />,
26
+ )
27
+ expect(html).toContain('RBAC visibility')
28
+ expect(html).not.toContain('text-red-400')
29
+ })
30
+
31
+ it('renders a non-RBAC 503 (e.g. cluster disconnect) in red, not the calm RBAC note', () => {
32
+ const html = renderToString(
33
+ <RBACErrorSection title="Permissions" error={err('Not connected to cluster', 503)} />,
34
+ )
35
+ expect(html).toContain('text-red-400')
36
+ expect(html).not.toContain('RBAC visibility')
37
+ })
38
+
39
+ it('renders 403 (viewer lacks permission) as a calm note, not a red error', () => {
40
+ const html = renderToString(
41
+ <RBACErrorSection title="Permissions" error={err('forbidden', 403)} />,
42
+ )
43
+ expect(html).toContain('permission to view RBAC bindings')
44
+ expect(html).not.toContain('text-red-400')
45
+ })
46
+
47
+ it('renders genuine failures (500 / no status) in red with the prefix', () => {
48
+ const html = renderToString(
49
+ <RBACErrorSection title="Permissions" error={err('boom', 500)} />,
50
+ )
51
+ expect(html).toContain('text-red-400')
52
+ expect(html).toContain('Could not load permissions')
53
+ expect(html).toContain('boom')
54
+ })
55
+
56
+ it('honors a custom errorPrefix for genuine failures', () => {
57
+ const html = renderToString(
58
+ <RBACErrorSection title="Bindings" error={err('boom')} errorPrefix="Could not load RBAC data" />,
59
+ )
60
+ expect(html).toContain('Could not load RBAC data')
61
+ })
62
+ })
@@ -0,0 +1,72 @@
1
+ import { Shield } from 'lucide-react'
2
+ import type { ComponentType } from 'react'
3
+ import { Section } from '../../ui/drawer-components'
4
+
5
+ interface RBACErrorSectionProps {
6
+ title: string
7
+ error: Error
8
+ // Matches the icon of the section's success/loading state (Shield for most,
9
+ // Users for the Role bindings section) so the error state isn't jarring.
10
+ icon?: ComponentType<{ className?: string }>
11
+ // Prefix for the genuine-error line; copy differs slightly across renderers
12
+ // ("permissions" on Pod/Workload, "RBAC data" on ServiceAccount).
13
+ errorPrefix?: string
14
+ }
15
+
16
+ const errorStatus = (error: Error): number | undefined => (error as { status?: number }).status
17
+
18
+ // 503 because Radar's SA can't read RBAC, so the informers never synced — a
19
+ // cluster-static config state (same on every resource), not a failure. The message
20
+ // check distinguishes it from a generic connectivity 503 ("Not connected to
21
+ // cluster"), which is a real fault and must stay loud (red).
22
+ const isRBACCacheUnavailable = (error: Error): boolean =>
23
+ errorStatus(error) === 503 && error.message.includes('RBAC cache')
24
+
25
+ // 403 because the requesting user lacks list permission on bindings.
26
+ const isRBACForbidden = (error: Error): boolean => errorStatus(error) === 403
27
+
28
+ // True for the two expected, non-actionable RBAC states above. Surfaces that treat
29
+ // the RBAC section as a bonus (Pod/Workload Permissions) hide it entirely for these.
30
+ // Genuine faults — connectivity 503, 500, network errors — are deliberately NOT
31
+ // included, so they still surface rather than being silently dropped.
32
+ export function isRBACUnavailable(error: Error): boolean {
33
+ return isRBACForbidden(error) || isRBACCacheUnavailable(error)
34
+ }
35
+
36
+ // RBACErrorSection renders each expected state as a calm note (distinct copy per
37
+ // state) and reserves the red treatment for genuine failures. It shares the two
38
+ // sub-predicates with isRBACUnavailable so the "what counts as unavailable" rule
39
+ // has a single source of truth and can't drift.
40
+ export function RBACErrorSection({
41
+ title,
42
+ error,
43
+ icon = Shield,
44
+ errorPrefix = 'Could not load permissions',
45
+ }: RBACErrorSectionProps) {
46
+ if (isRBACCacheUnavailable(error)) {
47
+ return (
48
+ <Section title={title} icon={icon}>
49
+ <div className="text-sm text-theme-text-tertiary">
50
+ RBAC visibility isn’t available — the identity Radar connects with can’t read
51
+ RBAC resources in this cluster.
52
+ </div>
53
+ </Section>
54
+ )
55
+ }
56
+ if (isRBACForbidden(error)) {
57
+ return (
58
+ <Section title={title} icon={icon}>
59
+ <div className="text-sm text-theme-text-tertiary">
60
+ You don’t have permission to view RBAC bindings here.
61
+ </div>
62
+ </Section>
63
+ )
64
+ }
65
+ return (
66
+ <Section title={title} icon={icon}>
67
+ <div className="text-sm text-red-400">
68
+ {errorPrefix}: {error.message}
69
+ </div>
70
+ </Section>
71
+ )
72
+ }
@@ -3,6 +3,7 @@ import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
4
4
  import type { RBACRoleResponse, RBACSubject, ResourceRef } from '../../../types'
5
5
  import { rbacKindBadgeClass, rbacVerbBadgeClass } from '../../../utils/rbac-badges'
6
+ import { RBACErrorSection } from './RBACErrorSection'
6
7
 
7
8
  interface RoleRendererProps {
8
9
  data: any
@@ -216,13 +217,7 @@ function RoleBindingsSection({ rbacRoleData, loading, error, onNavigate }: RoleB
216
217
  )
217
218
  }
218
219
  if (error) {
219
- return (
220
- <Section title="Bindings" icon={Users}>
221
- <div className="text-sm text-red-400">
222
- Could not load bindings: {error.message}
223
- </div>
224
- </Section>
225
- )
220
+ return <RBACErrorSection title="Bindings" error={error} icon={Users} errorPrefix="Could not load bindings" />
226
221
  }
227
222
  if (!rbacRoleData) return null
228
223
 
@@ -16,6 +16,7 @@ import {
16
16
  rbacKindBadgeClass,
17
17
  } from '../../../utils/rbac-badges'
18
18
  import { RBAC_BLAST_ESCALATION_VERBS } from '../../../utils/rbac-blast-radius'
19
+ import { RBACErrorSection } from './RBACErrorSection'
19
20
  import { BADGE_SEVERITY_COLORS } from '../../ui/Badge'
20
21
 
21
22
  interface ServiceAccountRendererProps {
@@ -194,13 +195,7 @@ function RBACSections({ rbacData, loading, error, onNavigate }: RBACSectionsProp
194
195
  )
195
196
  }
196
197
  if (error) {
197
- return (
198
- <Section title="Bindings" icon={Shield}>
199
- <div className="text-sm text-red-400">
200
- Could not load RBAC data: {error.message}
201
- </div>
202
- </Section>
203
- )
198
+ return <RBACErrorSection title="Bindings" error={error} errorPrefix="Could not load RBAC data" />
204
199
  }
205
200
  if (!rbacData) return null
206
201
 
@@ -5,6 +5,7 @@ import { Section, PropertyList, Property, ConditionsSection, PodTemplateSection,
5
5
  import { DialogPortal } from '../../ui/DialogPortal'
6
6
  import type { RBACSubjectResponse, RBACPolicyRule } from '../../../types'
7
7
  import { detectBlastRadius, rulePermissivenessScore } from '../../../utils/rbac-blast-radius'
8
+ import { RBACErrorSection, isRBACUnavailable } from './RBACErrorSection'
8
9
  import {
9
10
  rbacVerbBadgeClass,
10
11
  rbacResourceBadgeClass,
@@ -379,11 +380,11 @@ function WorkloadPermissionsSection({
379
380
  )
380
381
  }
381
382
  if (error) {
382
- return (
383
- <Section title={title} icon={Shield}>
384
- <div className="text-sm text-red-400">Could not load permissions: {error.message}</div>
385
- </Section>
386
- )
383
+ // Permissions is a bonus section here; when RBAC is simply not available
384
+ // (cluster-static) or forbidden, hide it rather than repeat a note on every
385
+ // workload. Genuine faults still surface.
386
+ if (isRBACUnavailable(error)) return null
387
+ return <RBACErrorSection title={title} error={error} />
387
388
  }
388
389
  if (!rbacData) return null
389
390
 
@@ -91,10 +91,18 @@ export function ClusterName({ name, variant = 'inline', noBadge, fallbackBadge,
91
91
  const showFallback = !noBadge && !hasProvider && fallbackBadge != null
92
92
  const showRegion = parsed.region !== null && variant === 'stacked'
93
93
  const collapsed = parsed.raw !== parsed.clusterName
94
- // Tooltip when there's something to discloseeither we collapsed the
95
- // raw, or the displayed name is being middle-truncated to fit. Callers
96
- // can opt out via `noTooltip` when the raw is already visible elsewhere.
97
- const needsTooltip = !noTooltip && (collapsed || truncated)
94
+ // The styled <Tooltip> wrapper is gated ONLY on `collapsed` a stable,
95
+ // string-derived fact. Truncation must NOT gate the wrapper: wrapping
96
+ // changes the layout box MiddleEllipsis measures, so a truncated↔untruncated
97
+ // flip oscillates through its ResizeObserver (the exact feedback its
98
+ // onTruncatedChange note warns against). For the truncation case we disclose
99
+ // the full name via MiddleEllipsis's native `title` instead — an attribute,
100
+ // not a layout change — so the measurement can't feed back.
101
+ const showRawTooltip = !noTooltip && collapsed
102
+ // Don't also set a native title when the raw is already shown via the
103
+ // wrapper (collapsed) — that would double up. Only the non-collapsed
104
+ // truncation path uses it; there raw === clusterName.
105
+ const truncationTitle = !noTooltip && !collapsed && truncated ? parsed.clusterName : undefined
98
106
 
99
107
  const body = (
100
108
  <span className={['inline-flex items-center gap-1.5 min-w-0', className ?? ''].join(' ')}>
@@ -102,7 +110,7 @@ export function ClusterName({ name, variant = 'inline', noBadge, fallbackBadge,
102
110
  {showFallback && fallbackBadge}
103
111
  {variant === 'stacked' ? (
104
112
  <span className="flex flex-col min-w-0 flex-1">
105
- <MiddleEllipsis text={parsed.clusterName} onTruncatedChange={onTruncatedChange} />
113
+ <MiddleEllipsis text={parsed.clusterName} title={truncationTitle} onTruncatedChange={onTruncatedChange} />
106
114
  {showRegion && (
107
115
  <span className="text-[10px] text-theme-text-tertiary truncate">
108
116
  {parsed.provider} · {parsed.region}
@@ -110,12 +118,12 @@ export function ClusterName({ name, variant = 'inline', noBadge, fallbackBadge,
110
118
  )}
111
119
  </span>
112
120
  ) : (
113
- <MiddleEllipsis text={parsed.clusterName} onTruncatedChange={onTruncatedChange} />
121
+ <MiddleEllipsis text={parsed.clusterName} title={truncationTitle} onTruncatedChange={onTruncatedChange} />
114
122
  )}
115
123
  </span>
116
124
  )
117
125
 
118
- if (!needsTooltip) return body
126
+ if (!showRawTooltip) return body
119
127
 
120
128
  return (
121
129
  <Tooltip content={parsed.raw} delay={250}>
@@ -145,7 +145,7 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
145
145
  nodeclaim: Server,
146
146
  ec2nodeclass: Server,
147
147
  aksnodeclass: Server,
148
- gcpnodeclass: Server,
148
+ gcenodeclass: Server,
149
149
 
150
150
  // KEDA
151
151
  scaledobject: Scaling,