@skyhook-io/k8s-ui 1.13.0 → 1.13.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.13.0",
3
+ "version": "1.13.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -98,7 +98,7 @@
98
98
  "clsx": "^2.1.1",
99
99
  "diff": "^9.0.0",
100
100
  "elkjs": "^0.11.1",
101
- "lucide-react": "^1.30.0",
101
+ "lucide-react": "^1.33.0",
102
102
  "monaco-editor": "0.55.1",
103
103
  "react": "^19.2.8",
104
104
  "react-dom": "^19.2.8",
@@ -13,7 +13,7 @@ import {
13
13
  Boxes,
14
14
  ChevronDown,
15
15
  Clock3,
16
- DollarSign,
16
+ Coins,
17
17
  ExternalLink,
18
18
  GitCommit,
19
19
  Layers,
@@ -988,7 +988,7 @@ function ApplicationViewTabs({
988
988
  : "border-transparent text-theme-text-secondary hover:border-theme-border-light hover:text-theme-text-primary",
989
989
  )}
990
990
  >
991
- <DollarSign className="h-4 w-4" />
991
+ <Coins className="h-4 w-4" />
992
992
  Cost
993
993
  </button>
994
994
  )}
@@ -18,6 +18,9 @@ export interface ClusterSwitcherItem {
18
18
  /** Raw context / display string. ClusterName collapses GKE/EKS/AKS
19
19
  * shapes; user-named clusters pass through unchanged. */
20
20
  name: string
21
+ /** Short qualifier rendered after the parsed name without affecting
22
+ * provider detection, e.g. a source suffix for colliding contexts. */
23
+ nameQualifier?: string
21
24
  secondary?: string
22
25
  badge?: string
23
26
  /** Origin label, rendered as a folder-icon line under the name.
@@ -44,6 +47,8 @@ export interface ClusterSwitcherProps {
44
47
  /** Raw context / display string. Pass it as-is — the trigger renders
45
48
  * through ClusterName, which handles parse + provider badge + tooltip. */
46
49
  currentName: string
50
+ /** Trigger-side counterpart to {@link ClusterSwitcherItem.nameQualifier}. */
51
+ currentNameQualifier?: string
47
52
  /** Trigger-side counterpart to {@link ClusterSwitcherItem.sourceLabel}.
48
53
  * Only pass when 2+ kubeconfig sources are loaded. */
49
54
  currentSourceLabel?: string
@@ -80,6 +85,7 @@ const TRIGGER_NAME_MAX_WIDTH = 'max-w-[140px] sm:max-w-[220px] xl:max-w-[340px]'
80
85
  export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcherProps>(({
81
86
  currentId,
82
87
  currentName,
88
+ currentNameQualifier,
83
89
  currentSourceLabel,
84
90
  items,
85
91
  onSelect,
@@ -114,6 +120,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
114
120
  if (!q) return true
115
121
  return (
116
122
  item.name.toLowerCase().includes(q) ||
123
+ item.nameQualifier?.toLowerCase().includes(q) ||
117
124
  item.secondary?.toLowerCase().includes(q) ||
118
125
  item.badge?.toLowerCase().includes(q) ||
119
126
  item.sourceLabel?.toLowerCase().includes(q) ||
@@ -209,7 +216,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
209
216
  disabled={disabled || loading}
210
217
  className={
211
218
  variant === 'segment'
212
- ? `flex items-center gap-1.5 px-3 py-1.5 h-full min-w-[150px] max-w-[264px] text-[13px] font-medium
219
+ ? `flex items-center gap-1.5 px-3 py-1.5 h-full min-w-[150px] max-w-[264px] overflow-hidden text-[13px] font-medium
213
220
  text-theme-text-primary hover:bg-theme-hover transition-colors cursor-pointer
214
221
  disabled:opacity-50 disabled:cursor-not-allowed`
215
222
  : `flex items-center gap-1.5 px-2.5 py-1.5 min-w-[140px]
@@ -236,12 +243,22 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
236
243
  // context inline (per-row secondary line), and an extra hover
237
244
  // tooltip would just overlap the search input.
238
245
  <>
239
- <ClusterName
240
- name={currentName}
241
- fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
242
- className={variant === 'segment' ? 'min-w-0 max-w-[214px]' : TRIGGER_NAME_MAX_WIDTH}
243
- noTooltip={isOpen}
244
- />
246
+ <span className={variant === 'segment' ? 'min-w-0 flex-1' : 'min-w-0'}>
247
+ <ClusterName
248
+ name={currentName}
249
+ fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
250
+ className={variant === 'segment' ? 'min-w-0 max-w-full' : TRIGGER_NAME_MAX_WIDTH}
251
+ noTooltip={isOpen}
252
+ />
253
+ </span>
254
+ {currentNameQualifier && (
255
+ <span
256
+ className="shrink-0 max-w-[80px] truncate text-xs font-normal text-theme-text-tertiary"
257
+ title={currentNameQualifier}
258
+ >
259
+ {currentNameQualifier}
260
+ </span>
261
+ )}
245
262
  {currentSourceLabel && (
246
263
  // Icon-only on the trigger: long folder paths (the very case
247
264
  // that motivates the chip) middle-truncate to something
@@ -358,6 +375,14 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
358
375
  : 'text-theme-text-primary'
359
376
  }`}
360
377
  />
378
+ {item.nameQualifier && (
379
+ <span
380
+ className="shrink-0 max-w-[120px] truncate text-xs font-normal text-theme-text-tertiary"
381
+ title={item.nameQualifier}
382
+ >
383
+ {item.nameQualifier}
384
+ </span>
385
+ )}
361
386
  {item.badge && (
362
387
  <span className="shrink-0 text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 rounded">
363
388
  {item.badge}
@@ -35,6 +35,7 @@ import type { SelectedResource, APIResource } from '../../types'
35
35
  import { isForbiddenError } from '../../types/fetch-error'
36
36
  import type { NavigateToResource } from '../../utils/navigation'
37
37
  import { categorizeResources, CORE_RESOURCES, findAPIResourceForRoute } from '../../utils/api-resources'
38
+ import { copyText } from '../../utils/clipboard'
38
39
  import {
39
40
  getPodStatus,
40
41
  getPodRestarts,
@@ -139,6 +140,7 @@ import {
139
140
  import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
140
141
  import { pluralize } from '../../utils/pluralize'
141
142
  import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
143
+ import { parseQuantityToNumber } from '../../utils/format'
142
144
  import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
143
145
  import { FreshnessControl, type FreshnessConnection } from '../ui/FreshnessControl'
144
146
  import { Tooltip } from '../ui/Tooltip'
@@ -5838,10 +5840,11 @@ function CopyNameButton({ name }: { name: string }) {
5838
5840
  <button
5839
5841
  onClick={(e) => {
5840
5842
  e.stopPropagation()
5841
- navigator.clipboard.writeText(name).then(() => {
5843
+ void copyText(name).then((didCopy) => {
5844
+ if (!didCopy) return
5842
5845
  setCopied(true)
5843
5846
  setTimeout(() => setCopied(false), 1500)
5844
- }).catch(() => {})
5847
+ })
5845
5848
  }}
5846
5849
  className="shrink-0 p-0.5 text-theme-text-tertiary hover:text-theme-text-primary opacity-0 group-hover/row:opacity-100 transition-opacity"
5847
5850
  title="Copy name"
@@ -7518,8 +7521,10 @@ function NodeCell({ resource, column, majorityNodeMinorVersion }: { resource: an
7518
7521
  const allocatable = resource.status?.allocatable?.pods
7519
7522
  const podCount = m?.podCount ?? 0
7520
7523
  if (!allocatable) return <span className="text-sm text-theme-text-tertiary font-mono">{podCount || '-'}</span>
7521
- const max = parseInt(allocatable, 10)
7522
- if (isNaN(max) || max <= 0) return <span className="text-sm text-theme-text-tertiary font-mono">{podCount || '-'}</span>
7524
+ // Allocatable pods is a Quantity, so a 1000-pod node reports "1k" —
7525
+ // a raw-integer read would see 1 and peg the bar at 21600%.
7526
+ const max = parseQuantityToNumber(allocatable)
7527
+ if (!Number.isFinite(max) || max <= 0) return <span className="text-sm text-theme-text-tertiary font-mono">{podCount || '-'}</span>
7523
7528
  const pct = (podCount / max) * 100
7524
7529
  return <ResourceBar used={String(podCount)} total={String(max)} percent={pct} colorScheme="count" />
7525
7530
  }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { quotaUsageRatio } from './NamespaceRenderer'
3
+
4
+ describe('quotaUsageRatio', () => {
5
+ // status.used is computed with quantity arithmetic, so a used pod count of
6
+ // 1000 serializes canonically as "1k". A raw-float read sees 1 and reports a
7
+ // saturated quota as barely used.
8
+ it('reads count quantities with SI suffixes on either side of the ratio', () => {
9
+ expect(quotaUsageRatio('pods', '1k', '1k')).toBe(1)
10
+ expect(quotaUsageRatio('pods', '900', '1k')).toBeCloseTo(0.9)
11
+ expect(quotaUsageRatio('count/pods', '1k', '2k')).toBeCloseTo(0.5)
12
+ })
13
+
14
+ it('leaves plain counts unchanged', () => {
15
+ expect(quotaUsageRatio('pods', '5', '10')).toBe(0.5)
16
+ expect(quotaUsageRatio('services', '0', '10')).toBe(0)
17
+ })
18
+
19
+ it('parses cpu and memory with their own unit parsers', () => {
20
+ expect(quotaUsageRatio('cpu', '500m', '1')).toBeCloseTo(0.5)
21
+ expect(quotaUsageRatio('requests.cpu', '2', '4')).toBeCloseTo(0.5)
22
+ expect(quotaUsageRatio('memory', '512Mi', '1Gi')).toBeCloseTo(0.5)
23
+ expect(quotaUsageRatio('requests.storage', '1Gi', '2Gi')).toBeCloseTo(0.5)
24
+ })
25
+
26
+ it('yields null when hard is unset or unparseable so the row falls back to raw strings', () => {
27
+ expect(quotaUsageRatio('pods', '5', '')).toBeNull()
28
+ expect(quotaUsageRatio('pods', '5', 'garbage')).toBeNull()
29
+ })
30
+ })
@@ -5,7 +5,7 @@ import type { RBACNamespaceResponse, RBACBindingWithSubjects, RBACSubject, Resou
5
5
  import { rbacKindBadgeClass } from '../../../utils/rbac-badges'
6
6
  import { RBACErrorSection } from './RBACErrorSection'
7
7
  import { SEVERITY_TEXT, SEVERITY_DOT } from '../../../utils/badge-colors'
8
- import { parseCPUToNanocores, parseMemoryToBytes } from '../../../utils/format'
8
+ import { parseCPUToNanocores, parseMemoryToBytes, parseQuantityToNumber } from '../../../utils/format'
9
9
 
10
10
  interface NamespaceRendererProps {
11
11
  data: any
@@ -95,11 +95,15 @@ export function NamespaceRenderer({ data, rbacData, rbacLoading, rbacError, quot
95
95
  // right unit parser by resource name (cpu → millicores, memory/storage →
96
96
  // bytes, everything else → plain count). Returns null when hard is unset or
97
97
  // unparseable so the row falls back to showing the raw strings.
98
- function quotaUsageRatio(resourceName: string, used: string, hard: string): number | null {
98
+ // Count values are Quantities too: the quota controller computes status.used
99
+ // with quantity arithmetic, which re-serializes canonically — a used pod count
100
+ // of 1000 arrives as "1k". Read with parseFloat that is 1, and a saturated
101
+ // quota renders as barely used.
102
+ export function quotaUsageRatio(resourceName: string, used: string, hard: string): number | null {
99
103
  if (!hard) return null
100
104
  const isCPU = /(^|\.)cpu$/i.test(resourceName)
101
105
  const isBytes = /(memory|storage)$/i.test(resourceName)
102
- const parse = isCPU ? parseCPUToNanocores : isBytes ? parseMemoryToBytes : (v: string) => parseFloat(v) || 0
106
+ const parse = isCPU ? parseCPUToNanocores : isBytes ? parseMemoryToBytes : parseQuantityToNumber
103
107
  const h = parse(hard)
104
108
  if (!h) return null
105
109
  return parse(used || '0') / h
@@ -87,6 +87,7 @@ export interface InClusterCapability {
87
87
  allowed: boolean
88
88
  reason?: string
89
89
  cluster?: string
90
+ clusterKey?: string
90
91
  namespace: string
91
92
  /** Per-call ceiling on probe Pods. Routes past it never run - ordering is
92
93
  * deterministic, so re-running starts from the same first maxProbes. */
@@ -13,6 +13,7 @@ export interface ConsentRequest {
13
13
  interface InClusterConsentDialogProps {
14
14
  open: boolean
15
15
  cluster?: string
16
+ clusterKey?: string
16
17
  namespace: string
17
18
  /** The requests this run will send, one per declared route. */
18
19
  requests?: ConsentRequest[]
@@ -85,7 +86,7 @@ function cluster(name?: string): string {
85
86
  // Confirms the mutating in-cluster reachability test before it spawns a Job/pod,
86
87
  // naming the cluster it lands in. Permission is enforced upstream (the button only
87
88
  // renders when the capability SSAR allows), so this is a safety confirm, not authz.
88
- export function InClusterConsentDialog({ open, cluster, namespace, requests, untestedCount, maxProbes, onClose, onConfirm }: InClusterConsentDialogProps) {
89
+ export function InClusterConsentDialog({ open, cluster, clusterKey, namespace, requests, untestedCount, maxProbes, onClose, onConfirm }: InClusterConsentDialogProps) {
89
90
  const [dontAskAgain, setDontAskAgain] = useState(false)
90
91
 
91
92
  function handleClose() {
@@ -94,7 +95,7 @@ export function InClusterConsentDialog({ open, cluster, namespace, requests, unt
94
95
  }
95
96
 
96
97
  function handleConfirm() {
97
- if (dontAskAgain && cluster) rememberInClusterConsent(cluster)
98
+ if (dontAskAgain && clusterKey) rememberInClusterConsent(clusterKey)
98
99
  setDontAskAgain(false)
99
100
  onConfirm()
100
101
  }
@@ -117,11 +118,11 @@ export function InClusterConsentDialog({ open, cluster, namespace, requests, unt
117
118
  cancelLabel="Cancel"
118
119
  variant="warning"
119
120
  >
120
- {/* "Don't ask again" is stored per cluster. Without a cluster identity
121
+ {/* "Don't ask again" is stored per cluster source. Without a source identity
121
122
  there is nothing safe to key it on (one origin can front many
122
123
  clusters), so consent is never remembered - hide the checkbox rather
123
124
  than promise a persistence that doesn't stick. */}
124
- {cluster ? (
125
+ {clusterKey ? (
125
126
  <label className="flex items-center gap-2 text-sm text-theme-text-secondary">
126
127
  <input
127
128
  type="checkbox"
@@ -1,5 +1,5 @@
1
- import { useEffect, useRef, useState } from 'react'
2
- import { Check, ChevronDown } from 'lucide-react'
1
+ import { useEffect, useId, useMemo, useRef, useState } from 'react'
2
+ import { Check, ChevronDown, Search } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
 
5
5
  export interface SelectMenuOption {
@@ -13,67 +13,241 @@ export function SelectMenu({
13
13
  onChange,
14
14
  ariaLabel,
15
15
  className,
16
+ searchPlaceholder,
16
17
  }: {
17
18
  value: string
18
19
  options: SelectMenuOption[]
19
20
  onChange: (value: string) => void
20
21
  ariaLabel: string
21
22
  className?: string
23
+ searchPlaceholder?: string
22
24
  }) {
23
25
  const [open, setOpen] = useState(false)
26
+ const [query, setQuery] = useState('')
27
+ const [highlightedIndex, setHighlightedIndex] = useState(0)
28
+ const listboxId = useId()
24
29
  const rootRef = useRef<HTMLDivElement>(null)
30
+ const triggerRef = useRef<HTMLButtonElement>(null)
31
+ const searchInputRef = useRef<HTMLInputElement>(null)
32
+ const listRef = useRef<HTMLDivElement>(null)
33
+ const pointerDownInsideRef = useRef(false)
25
34
  const selected = options.find((option) => option.value === value) ?? options[0]
35
+ const filteredOptions = useMemo(() => {
36
+ const normalized = query.trim().toLowerCase()
37
+ if (!normalized) return options
38
+ return options.filter((option) => option.label.toLowerCase().includes(normalized))
39
+ }, [options, query])
40
+ const selectedIsVisible = filteredOptions.some((option) => option.value === value)
41
+
42
+ const selectOption = (nextValue: string) => {
43
+ onChange(nextValue)
44
+ setOpen(false)
45
+ triggerRef.current?.focus()
46
+ }
26
47
 
27
48
  useEffect(() => {
28
49
  if (!open) return
29
50
  const onPointerDown = (event: MouseEvent) => {
30
51
  if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
31
52
  }
32
- const onKeyDown = (event: KeyboardEvent) => {
33
- if (event.key === 'Escape') setOpen(false)
34
- }
35
53
  document.addEventListener('mousedown', onPointerDown)
36
- document.addEventListener('keydown', onKeyDown)
37
54
  return () => {
38
55
  document.removeEventListener('mousedown', onPointerDown)
39
- document.removeEventListener('keydown', onKeyDown)
40
56
  }
41
57
  }, [open])
42
58
 
59
+ useEffect(() => {
60
+ if (!open) setQuery('')
61
+ }, [open])
62
+
63
+ useEffect(() => {
64
+ if (!open || !query) return
65
+ if (listRef.current) listRef.current.scrollTop = 0
66
+ }, [open, query])
67
+
68
+ useEffect(() => {
69
+ if (!open) return
70
+ if (!searchPlaceholder) {
71
+ triggerRef.current?.focus()
72
+ return
73
+ }
74
+ listRef.current
75
+ ?.querySelector<HTMLElement>('[role="option"][aria-selected="true"]')
76
+ ?.scrollIntoView({ block: 'nearest' })
77
+ }, [open, searchPlaceholder])
78
+
43
79
  return (
44
- <div ref={rootRef} className={clsx('relative', className)}>
80
+ <div
81
+ ref={rootRef}
82
+ className={clsx('relative', className)}
83
+ onMouseDownCapture={() => {
84
+ pointerDownInsideRef.current = true
85
+ requestAnimationFrame(() => {
86
+ pointerDownInsideRef.current = false
87
+ })
88
+ }}
89
+ onKeyDown={(event) => {
90
+ if (event.key !== 'Escape' || !open) return
91
+ event.preventDefault()
92
+ event.stopPropagation()
93
+ setOpen(false)
94
+ triggerRef.current?.focus()
95
+ }}
96
+ onBlur={(event) => {
97
+ if (!open) return
98
+ if (event.relatedTarget && rootRef.current?.contains(event.relatedTarget)) return
99
+ if (!pointerDownInsideRef.current) {
100
+ setOpen(false)
101
+ return
102
+ }
103
+ requestAnimationFrame(() => {
104
+ if (!document.hasFocus() || !listRef.current) return
105
+ if (searchPlaceholder) searchInputRef.current?.focus()
106
+ else triggerRef.current?.focus()
107
+ })
108
+ }}
109
+ >
45
110
  <button
111
+ ref={triggerRef}
46
112
  type="button"
47
113
  aria-label={ariaLabel}
48
114
  aria-haspopup="listbox"
49
115
  aria-expanded={open}
50
- onClick={() => setOpen((current) => !current)}
116
+ aria-controls={open ? listboxId : undefined}
117
+ onClick={() => {
118
+ if (!open) {
119
+ setHighlightedIndex(
120
+ Math.max(
121
+ options.findIndex((option) => option.value === value),
122
+ 0,
123
+ ),
124
+ )
125
+ }
126
+ setOpen(!open)
127
+ }}
51
128
  className="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-border bg-theme-elevated px-2.5 text-xs text-theme-text-primary transition-colors hover:bg-theme-hover"
52
129
  >
53
130
  <span className="truncate">{selected?.label}</span>
54
131
  <ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', open && 'rotate-180')} />
55
132
  </button>
56
133
  {open && (
57
- <div role="listbox" className="absolute right-0 top-full z-50 mt-1 min-w-full overflow-hidden rounded-md border border-theme-border bg-theme-surface py-1 shadow-theme-lg">
58
- {options.map((option) => {
59
- const active = option.value === value
60
- return (
61
- <button
62
- key={option.value}
63
- type="button"
64
- role="option"
65
- aria-selected={active}
66
- onClick={() => {
67
- onChange(option.value)
68
- setOpen(false)
134
+ <div
135
+ className={clsx(
136
+ 'absolute top-full z-50 mt-1 min-w-full overflow-hidden rounded-md border border-theme-border bg-theme-surface shadow-theme-lg',
137
+ searchPlaceholder ? 'left-0 right-0' : 'right-0'
138
+ )}
139
+ >
140
+ {searchPlaceholder && (
141
+ <div
142
+ className="flex items-center gap-2 border-b border-theme-border px-2.5 py-2"
143
+ onMouseDown={(event) => {
144
+ if (event.target !== searchInputRef.current) event.preventDefault()
145
+ }}
146
+ >
147
+ <Search className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
148
+ <input
149
+ ref={searchInputRef}
150
+ autoFocus
151
+ value={query}
152
+ onChange={(event) => {
153
+ setQuery(event.target.value)
154
+ setHighlightedIndex(0)
69
155
  }}
70
- className="flex w-full items-center gap-2 whitespace-nowrap px-2.5 py-1.5 text-left text-xs text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
71
- >
72
- <Check className={clsx('h-3.5 w-3.5 shrink-0 text-accent', !active && 'opacity-0')} />
73
- {option.label}
74
- </button>
75
- )
76
- })}
156
+ onKeyDown={(event) => {
157
+ if (event.key === 'Enter' && filteredOptions.length > 0) {
158
+ event.preventDefault()
159
+ const nextIndex = Math.min(highlightedIndex, filteredOptions.length - 1)
160
+ selectOption(filteredOptions[nextIndex].value)
161
+ } else if (event.key === 'ArrowDown') {
162
+ event.preventDefault()
163
+ const optionElements = listRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]')
164
+ optionElements?.[Math.min(highlightedIndex, optionElements.length - 1)]?.focus()
165
+ }
166
+ }}
167
+ aria-label={searchPlaceholder}
168
+ role="combobox"
169
+ aria-autocomplete="list"
170
+ aria-controls={listboxId}
171
+ aria-expanded="true"
172
+ aria-activedescendant={
173
+ filteredOptions.length > 0
174
+ ? `${listboxId}-option-${Math.min(highlightedIndex, filteredOptions.length - 1)}`
175
+ : undefined
176
+ }
177
+ placeholder={searchPlaceholder}
178
+ className="min-w-0 flex-1 bg-transparent text-xs text-theme-text-primary outline-none placeholder:text-theme-text-tertiary"
179
+ />
180
+ </div>
181
+ )}
182
+ {searchPlaceholder && (
183
+ <span className="sr-only" aria-live="polite">
184
+ {filteredOptions.length === 0
185
+ ? 'No matches.'
186
+ : `${filteredOptions.length} ${filteredOptions.length === 1 ? 'option' : 'options'} available.`}
187
+ </span>
188
+ )}
189
+ <div
190
+ id={listboxId}
191
+ ref={listRef}
192
+ role="listbox"
193
+ aria-label={ariaLabel}
194
+ onKeyDown={(event) => {
195
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
196
+ const optionElements = Array.from(
197
+ listRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]') ?? [],
198
+ )
199
+ const currentIndex = optionElements.indexOf(event.target as HTMLButtonElement)
200
+ if (currentIndex < 0) return
201
+ event.preventDefault()
202
+ if (event.key === 'ArrowUp' && currentIndex === 0 && searchPlaceholder) {
203
+ searchInputRef.current?.focus()
204
+ return
205
+ }
206
+ const nextIndex = event.key === 'Home'
207
+ ? 0
208
+ : event.key === 'End'
209
+ ? optionElements.length - 1
210
+ : event.key === 'ArrowDown'
211
+ ? Math.min(currentIndex + 1, optionElements.length - 1)
212
+ : Math.max(currentIndex - 1, 0)
213
+ optionElements[nextIndex]?.focus()
214
+ }}
215
+ className={clsx(filteredOptions.length > 0 && 'py-1', searchPlaceholder && 'max-h-64 overflow-y-auto')}
216
+ >
217
+ {filteredOptions.map((option, index) => {
218
+ const active = option.value === value
219
+ return (
220
+ <button
221
+ key={option.value}
222
+ id={`${listboxId}-option-${index}`}
223
+ type="button"
224
+ role="option"
225
+ aria-selected={active}
226
+ tabIndex={
227
+ searchPlaceholder ? (index === highlightedIndex ? 0 : -1) : active || (!selectedIsVisible && index === 0) ? 0 : -1
228
+ }
229
+ onClick={() => selectOption(option.value)}
230
+ onFocus={() => setHighlightedIndex(index)}
231
+ className={clsx(
232
+ 'flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary',
233
+ searchPlaceholder && index === highlightedIndex && 'bg-theme-hover text-theme-text-primary',
234
+ !searchPlaceholder && 'whitespace-nowrap'
235
+ )}
236
+ >
237
+ <Check className={clsx('h-3.5 w-3.5 shrink-0 text-accent', !active && 'opacity-0')} />
238
+ <span className={clsx(searchPlaceholder && 'truncate')}>{option.label}</span>
239
+ </button>
240
+ )
241
+ })}
242
+ </div>
243
+ {filteredOptions.length === 0 && (
244
+ <p
245
+ onMouseDown={(event) => event.preventDefault()}
246
+ className="px-3 py-2 text-xs text-theme-text-tertiary"
247
+ >
248
+ No matches.
249
+ </p>
250
+ )}
77
251
  </div>
78
252
  )}
79
253
  </div>
@@ -30,7 +30,7 @@ import {
30
30
  BarChart3,
31
31
  Network,
32
32
  Stethoscope,
33
- DollarSign,
33
+ Coins,
34
34
  } from 'lucide-react'
35
35
  import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis, WorkloadPodInfo } from '../../types'
36
36
  import type { GitOpsStatus } from '../../types/gitops'
@@ -722,7 +722,7 @@ export function WorkloadView({
722
722
  icon: <Stethoscope className="w-4 h-4" />,
723
723
  hidden: !(renderDiagnoseTab && (isDiagnoseKind(apiKind, group) || (reachableVia?.length ?? 0) > 0)),
724
724
  },
725
- { id: 'cost', label: 'Cost', icon: <DollarSign className="w-4 h-4" />, hidden: !costTabVisible },
725
+ { id: 'cost', label: 'Cost', icon: <Coins className="w-4 h-4" />, hidden: !costTabVisible },
726
726
  { id: 'yaml', label: 'YAML', icon: <FileText className="w-4 h-4" /> },
727
727
  ]
728
728
  const requestedTabAvailable = tabs.some((tab) => tab.id === requestedTab && !tab.hidden)
package/src/types/core.ts CHANGED
@@ -455,12 +455,15 @@ export interface ClusterInfo {
455
455
  // Context info for context switching
456
456
  export interface ContextInfo {
457
457
  name: string
458
+ /** Original context name inside its source file. Set for isolated sources
459
+ * so display code need not infer backend-added collision qualifiers. */
460
+ originalName?: string
458
461
  cluster: string
459
462
  user: string
460
463
  namespace: string
461
464
  isCurrent: boolean
462
- /** Source kubeconfig label (e.g. "kube-cluster-paris"). Set by backend
463
- * only when 2+ kubeconfig files are loaded; empty otherwise. */
465
+ /** Source kubeconfig label (e.g. "kube-cluster-paris"). Set by the backend
466
+ * for contexts loaded through the isolated source registry. */
464
467
  source?: string
465
468
  }
466
469
 
@@ -1273,8 +1276,8 @@ export interface AggregatedFlow {
1273
1276
 
1274
1277
  // Cluster info for traffic detection
1275
1278
  export interface TrafficClusterInfo {
1276
- platform: string // gke, eks, aks, generic
1277
- cni: string // cilium, calico, flannel, vpc-cni, azure-cni
1279
+ platform: string // rke2, gke, eks, aks, minikube, kind, docker-desktop, openshift, rancher, generic
1280
+ cni: string // cilium, canal, calico, flannel, vpc-cni, azure-cni, gke-native, unknown
1278
1281
  dataplaneV2: boolean
1279
1282
  clusterName?: string
1280
1283
  k8sVersion?: string
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { copyText } from './clipboard'
4
+
5
+ function fallbackDocument(copied: boolean) {
6
+ const document = {
7
+ createElement: vi.fn(() => textarea),
8
+ body: { appendChild: vi.fn() },
9
+ execCommand: vi.fn(() => copied),
10
+ activeElement: null as unknown,
11
+ }
12
+ const textarea = {
13
+ value: '',
14
+ style: {} as CSSStyleDeclaration,
15
+ setAttribute: vi.fn(),
16
+ select: vi.fn(() => { document.activeElement = textarea }),
17
+ setSelectionRange: vi.fn(),
18
+ remove: vi.fn(),
19
+ }
20
+ return { document, textarea }
21
+ }
22
+
23
+ describe('copyText', () => {
24
+ it('uses the synchronous fallback when the Clipboard API is unavailable on HTTP', async () => {
25
+ const { document } = fallbackDocument(true)
26
+
27
+ await expect(copyText('busybox', undefined, document as unknown as Document)).resolves.toBe(true)
28
+
29
+ expect(document.execCommand).toHaveBeenCalledWith('copy')
30
+ })
31
+
32
+ it('falls back to a synchronous copy command when the Clipboard API rejects', async () => {
33
+ const clipboard = { writeText: vi.fn().mockRejectedValue(new DOMException('Blocked', 'NotAllowedError')) }
34
+ const { document, textarea } = fallbackDocument(true)
35
+
36
+ await expect(copyText('busybox', clipboard, document as unknown as Document)).resolves.toBe(true)
37
+
38
+ expect(clipboard.writeText).toHaveBeenCalledWith('busybox')
39
+ expect(document.body.appendChild).toHaveBeenCalledWith(textarea)
40
+ expect(textarea.value).toBe('busybox')
41
+ expect(textarea.select).toHaveBeenCalledOnce()
42
+ expect(document.execCommand).toHaveBeenCalledWith('copy')
43
+ expect(textarea.remove).toHaveBeenCalledOnce()
44
+ })
45
+
46
+ it('restores focus to the previously active element without scrolling', async () => {
47
+ const { document } = fallbackDocument(true)
48
+ const previouslyFocused = { focus: vi.fn(), isConnected: true }
49
+ document.activeElement = previouslyFocused
50
+
51
+ await expect(copyText('busybox', undefined, document as unknown as Document)).resolves.toBe(true)
52
+
53
+ expect(previouslyFocused.focus).toHaveBeenCalledWith({ preventScroll: true })
54
+ })
55
+
56
+ it('leaves focus alone when a copy handler focused something else', async () => {
57
+ const { document, textarea } = fallbackDocument(true)
58
+ const previouslyFocused = { focus: vi.fn(), isConnected: true }
59
+ document.activeElement = previouslyFocused
60
+ const somethingElse = {}
61
+ textarea.select.mockImplementation(() => { document.activeElement = somethingElse })
62
+
63
+ await expect(copyText('busybox', undefined, document as unknown as Document)).resolves.toBe(true)
64
+
65
+ expect(previouslyFocused.focus).not.toHaveBeenCalled()
66
+ })
67
+
68
+ it('does not refocus an element that left the DOM during the copy', async () => {
69
+ const { document } = fallbackDocument(true)
70
+ const previouslyFocused = { focus: vi.fn(), isConnected: false }
71
+ document.activeElement = previouslyFocused
72
+
73
+ await expect(copyText('busybox', undefined, document as unknown as Document)).resolves.toBe(true)
74
+
75
+ expect(previouslyFocused.focus).not.toHaveBeenCalled()
76
+ })
77
+
78
+ it('does not create a fallback element when the Clipboard API succeeds', async () => {
79
+ const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }
80
+ const { document } = fallbackDocument(true)
81
+
82
+ await expect(copyText('busybox', clipboard, document as unknown as Document)).resolves.toBe(true)
83
+
84
+ expect(document.createElement).not.toHaveBeenCalled()
85
+ })
86
+ })
@@ -0,0 +1,50 @@
1
+ type ClipboardWriter = Pick<Clipboard, 'writeText'>
2
+
3
+ /**
4
+ * Copy text from a user gesture, including pages served from an insecure origin.
5
+ * The async Clipboard API is unavailable or rejects on plain HTTP, while the
6
+ * legacy command remains permitted when it runs synchronously from the click.
7
+ */
8
+ export async function copyText(
9
+ text: string,
10
+ clipboard: ClipboardWriter | undefined = typeof navigator === 'undefined' ? undefined : navigator.clipboard,
11
+ doc: Document | undefined = typeof document === 'undefined' ? undefined : document,
12
+ ): Promise<boolean> {
13
+ if (clipboard?.writeText) {
14
+ try {
15
+ await clipboard.writeText(text)
16
+ return true
17
+ } catch {
18
+ // Fall through for insecure origins and denied Clipboard API access.
19
+ }
20
+ }
21
+
22
+ if (!doc?.body) return false
23
+
24
+ const previouslyFocused = doc.activeElement as { focus?: (options?: FocusOptions) => void; isConnected?: boolean } | null
25
+ const textarea = doc.createElement('textarea')
26
+ textarea.value = text
27
+ textarea.readOnly = true
28
+ textarea.setAttribute('aria-hidden', 'true')
29
+ textarea.style.position = 'fixed'
30
+ textarea.style.opacity = '0'
31
+ textarea.style.pointerEvents = 'none'
32
+
33
+ try {
34
+ doc.body.appendChild(textarea)
35
+ textarea.select()
36
+ textarea.setSelectionRange(0, text.length)
37
+ return doc.execCommand('copy')
38
+ } catch {
39
+ return false
40
+ } finally {
41
+ // Restore focus only when the textarea still owns it — a copy handler may
42
+ // have legitimately focused something else — and without scrolling a
43
+ // now-offscreen element back into view.
44
+ const textareaOwnsFocus = doc.activeElement === (textarea as unknown as Element)
45
+ textarea.remove()
46
+ if (textareaOwnsFocus && previouslyFocused?.isConnected !== false) {
47
+ previouslyFocused?.focus?.({ preventScroll: true })
48
+ }
49
+ }
50
+ }
@@ -2,7 +2,7 @@
2
2
  // the GPU views. Keep all effective-request math here — K8s semantics for
3
3
  // extended resources are subtle and must not be reimplemented per call site.
4
4
 
5
- import { parseMemoryToBytes } from './format'
5
+ import { parseQuantityToNumber } from './format'
6
6
 
7
7
  const STANDARD_NODE_RESOURCES = new Set(['cpu', 'memory', 'pods', 'ephemeral-storage'])
8
8
 
@@ -48,14 +48,11 @@ export function getEffectiveResources(resources: any): Record<string, string> {
48
48
  }
49
49
 
50
50
  // K8s quantity → number. Counts are usually plain integers, but quantities may
51
- // legally carry suffixes: "3000m" is 3, "3k" is 3000.
51
+ // legally carry suffixes: "3000m" is 3, "3k" is 3000. The shared quantity
52
+ // parser is the one place that knows the suffix table.
52
53
  function asCount(value: unknown): number {
53
54
  if (typeof value === 'number') return Number.isFinite(value) ? value : 0
54
- const s = String(value ?? '').trim()
55
- if (!s) return 0
56
- if (/^\d+(\.\d+)?m$/.test(s)) return Number(s.slice(0, -1)) / 1000
57
- const n = parseMemoryToBytes(s)
58
- return Number.isFinite(n) ? n : 0
55
+ return parseQuantityToNumber(String(value ?? ''))
59
56
  }
60
57
 
61
58
  function containerGpuCount(container: any): number {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { formatCPUString, formatMemoryString, parseMemoryToBytes } from './format'
2
+ import { formatCPUString, formatMemoryString, parseMemoryToBytes, parseQuantityToNumber } from './format'
3
3
 
4
4
  describe('formatCPUString', () => {
5
5
  it('renders exact zero as zero, not a fabricated near-zero', () => {
@@ -32,4 +32,86 @@ describe('parseMemoryToBytes', () => {
32
32
  expect(parseMemoryToBytes('-1Ki')).toBe(-1024)
33
33
  expect(parseMemoryToBytes('1Ki')).toBe(1024)
34
34
  })
35
+
36
+ // Aggregate cluster memory reaches suffixes a single node never carries —
37
+ // "1Pi" must not fall through to the bare-number fallback and read as 1 byte.
38
+ it('parses the large suffixes an aggregate capacity can carry', () => {
39
+ expect(parseMemoryToBytes('1Pi')).toBe(1024 ** 5)
40
+ expect(parseMemoryToBytes('1Ei')).toBe(1024 ** 6)
41
+ expect(parseMemoryToBytes('1P')).toBe(1000 ** 5)
42
+ expect(parseMemoryToBytes('1E')).toBe(1000 ** 6)
43
+ })
44
+ })
45
+
46
+ describe('parseQuantityToNumber', () => {
47
+ // The reported bug. A Quantity's canonical form collapses trailing zeros into
48
+ // an SI suffix, so a node started with --max-pods=1000 reports "1k". Read as
49
+ // a raw integer that is 1, and a node running 216 pods rendered as 216 / 1
50
+ // with the usage bar pegged at 21600%.
51
+ it('reads a pod capacity of 1000 as 1000, not 1', () => {
52
+ expect(parseQuantityToNumber('1k')).toBe(1000)
53
+ expect((216 / parseQuantityToNumber('1k')) * 100).toBeCloseTo(21.6)
54
+ })
55
+
56
+ it('only collapses zeros in groups of three, so nearby counts are unchanged', () => {
57
+ expect(parseQuantityToNumber('2k')).toBe(2000)
58
+ expect(parseQuantityToNumber('1020')).toBe(1020)
59
+ expect(parseQuantityToNumber('990')).toBe(990)
60
+ expect(parseQuantityToNumber('216')).toBe(216)
61
+ })
62
+
63
+ it('handles the sub-unit decimal suffixes a count can legally carry', () => {
64
+ expect(parseQuantityToNumber('3000m')).toBe(3)
65
+ expect(parseQuantityToNumber('1500m')).toBe(1.5)
66
+ expect(parseQuantityToNumber('1000000u')).toBe(1)
67
+ expect(parseQuantityToNumber('1000000000n')).toBe(1)
68
+ })
69
+
70
+ it('handles the whole decimal SI range', () => {
71
+ expect(parseQuantityToNumber('5M')).toBe(5e6)
72
+ expect(parseQuantityToNumber('5G')).toBe(5e9)
73
+ expect(parseQuantityToNumber('5T')).toBe(5e12)
74
+ expect(parseQuantityToNumber('5P')).toBe(5e15)
75
+ expect(parseQuantityToNumber('5E')).toBe(5e18)
76
+ })
77
+
78
+ it('handles binary suffixes in powers of 1024', () => {
79
+ expect(parseQuantityToNumber('1Ki')).toBe(1024)
80
+ expect(parseQuantityToNumber('2Gi')).toBe(2 * 1024 ** 3)
81
+ expect(parseQuantityToNumber('1Ei')).toBe(1024 ** 6)
82
+ })
83
+
84
+ // "1E3" is the exponent form (1000), while "1E" is exa. Deciding the exponent
85
+ // first is what keeps the two apart.
86
+ it('reads the decimal-exponent form without confusing it for exa', () => {
87
+ expect(parseQuantityToNumber('1e3')).toBe(1000)
88
+ expect(parseQuantityToNumber('1E3')).toBe(1000)
89
+ expect(parseQuantityToNumber('1.5e3')).toBe(1500)
90
+ expect(parseQuantityToNumber('1e-3')).toBe(0.001)
91
+ expect(parseQuantityToNumber('1E')).toBe(1e18)
92
+ })
93
+
94
+ it('preserves the sign', () => {
95
+ expect(parseQuantityToNumber('-1k')).toBe(-1000)
96
+ expect(parseQuantityToNumber('+1k')).toBe(1000)
97
+ })
98
+
99
+ // A number this cannot read is reported as unknown so the caller can fall back,
100
+ // rather than becoming a plausible-looking wrong number on screen.
101
+ it('yields zero for input it cannot parse rather than guessing', () => {
102
+ expect(parseQuantityToNumber('1z')).toBe(0)
103
+ expect(parseQuantityToNumber('1KB')).toBe(0)
104
+ expect(parseQuantityToNumber('abc')).toBe(0)
105
+ expect(parseQuantityToNumber('')).toBe(0)
106
+ expect(parseQuantityToNumber(' ')).toBe(0)
107
+ expect(parseQuantityToNumber(null)).toBe(0)
108
+ expect(parseQuantityToNumber(undefined)).toBe(0)
109
+ })
110
+
111
+ it('passes through numbers the API already decoded, and rejects non-finite ones', () => {
112
+ expect(parseQuantityToNumber(216)).toBe(216)
113
+ expect(parseQuantityToNumber(0)).toBe(0)
114
+ expect(parseQuantityToNumber(Number.NaN)).toBe(0)
115
+ expect(parseQuantityToNumber(Number.POSITIVE_INFINITY)).toBe(0)
116
+ })
35
117
  })
@@ -126,12 +126,15 @@ export function parseMemoryToBytes(memString: string): number {
126
126
  const num = parseFloat(match[1])
127
127
  const suffix = match[2]
128
128
 
129
- // Binary suffixes (powers of 1024)
129
+ // Binary suffixes (powers of 1024). Pi/Ei are real inputs at cluster scale —
130
+ // an aggregate of a thousand 1TiB nodes serializes as "1Pi".
130
131
  const binarySuffixes: Record<string, number> = {
131
132
  'Ki': 1024,
132
133
  'Mi': 1024 ** 2,
133
134
  'Gi': 1024 ** 3,
134
135
  'Ti': 1024 ** 4,
136
+ 'Pi': 1024 ** 5,
137
+ 'Ei': 1024 ** 6,
135
138
  }
136
139
 
137
140
  // Decimal suffixes (powers of 1000)
@@ -141,6 +144,8 @@ export function parseMemoryToBytes(memString: string): number {
141
144
  'M': 1000 ** 2,
142
145
  'G': 1000 ** 3,
143
146
  'T': 1000 ** 4,
147
+ 'P': 1000 ** 5,
148
+ 'E': 1000 ** 6,
144
149
  }
145
150
 
146
151
  if (suffix in binarySuffixes) {
@@ -164,6 +169,80 @@ export function formatMemoryString(memString: string): string {
164
169
  return formatMemoryBytes(parseMemoryToBytes(memString))
165
170
  }
166
171
 
172
+ // =============================================================================
173
+ // Quantity Parsing (counts)
174
+ // =============================================================================
175
+
176
+ /**
177
+ * Every suffix a `resource.Quantity` can carry, in the API's own groupings:
178
+ * decimal SI in powers of 1000 (including the sub-unit n/u/m), and binary SI in
179
+ * powers of 1024. `K` is not canonical Kubernetes — only lowercase `k` is — but
180
+ * it is accepted here for the same leniency `parseMemoryToBytes` already has.
181
+ */
182
+ const QUANTITY_SUFFIXES: Record<string, number> = {
183
+ n: 1e-9,
184
+ u: 1e-6,
185
+ m: 1e-3,
186
+ k: 1e3,
187
+ K: 1e3,
188
+ M: 1e6,
189
+ G: 1e9,
190
+ T: 1e12,
191
+ P: 1e15,
192
+ E: 1e18,
193
+ Ki: 1024,
194
+ Mi: 1024 ** 2,
195
+ Gi: 1024 ** 3,
196
+ Ti: 1024 ** 4,
197
+ Pi: 1024 ** 5,
198
+ Ei: 1024 ** 6,
199
+ }
200
+
201
+ /**
202
+ * Parse a Kubernetes `resource.Quantity` into a plain number.
203
+ *
204
+ * Counts are the surface that needs this. A Quantity's canonical serialization
205
+ * collapses trailing zeros into an SI suffix, so a node started with
206
+ * `--max-pods=1000` reports `"1k"` and not `"1000"` — read as a raw integer
207
+ * that is 1, so a usage bar divides by 1 instead of 1000. Any count that is
208
+ * a multiple of 1000 hits it.
209
+ *
210
+ * Unlike `parseMemoryToBytes`, an unrecognised suffix yields 0 rather than the
211
+ * bare number: a Quantity this cannot read is better reported as unknown by the
212
+ * caller than silently turned into a plausible-looking wrong number.
213
+ *
214
+ * @param quantity - K8s quantity like "1k", "216", "3000m", "2Gi", "1e3"
215
+ * @returns The value as a number, or 0 when it cannot be parsed
216
+ */
217
+ export function parseQuantityToNumber(quantity: string | number | null | undefined): number {
218
+ if (typeof quantity === 'number') return Number.isFinite(quantity) ? quantity : 0
219
+ if (!quantity) return 0
220
+
221
+ const str = String(quantity).trim()
222
+ if (!str) return 0
223
+
224
+ // Decimal-exponent form. A Quantity's suffix is either an SI suffix or an
225
+ // exponent, never both, so this is decided before the suffix table — which
226
+ // also keeps "1E3" (1000) from being read as exa (1e18).
227
+ const exponent = str.match(/^([+-]?\d+(?:\.\d+)?)[eE]([+-]?\d+)$/)
228
+ if (exponent) {
229
+ const value = Number(`${exponent[1]}e${exponent[2]}`)
230
+ return Number.isFinite(value) ? value : 0
231
+ }
232
+
233
+ const match = str.match(/^([+-]?\d+(?:\.\d+)?)\s*([A-Za-z]*)$/)
234
+ if (!match) return 0
235
+
236
+ const num = parseFloat(match[1])
237
+ if (!Number.isFinite(num)) return 0
238
+
239
+ const suffix = match[2]
240
+ if (!suffix) return num
241
+
242
+ const multiplier = QUANTITY_SUFFIXES[suffix]
243
+ return multiplier === undefined ? 0 : num * multiplier
244
+ }
245
+
167
246
  // =============================================================================
168
247
  // Dashboard Metrics Formatting (different units from metrics-server)
169
248
  // =============================================================================
@@ -26,6 +26,15 @@ describe('inClusterConsent', () => {
26
26
  expect(inClusterConsentGiven('prod')).toBe(false)
27
27
  })
28
28
 
29
+ it('does not trust consent stored under the visible-name key format', () => {
30
+ const store: Record<string, string> = { 'radar.inClusterConsent.prod': '1' }
31
+ vi.stubGlobal('localStorage', {
32
+ getItem: (k: string) => (k in store ? store[k] : null),
33
+ setItem: (k: string, v: string) => { store[k] = v },
34
+ })
35
+ expect(inClusterConsentGiven('prod')).toBe(false)
36
+ })
37
+
29
38
  it('does not throw when localStorage is unavailable, and reports not-given', () => {
30
39
  vi.stubGlobal('localStorage', {
31
40
  getItem: () => { throw new Error('denied') },
@@ -1,9 +1,9 @@
1
1
  // Consent memory for the mutating in-cluster reachability test (it creates a
2
- // short-lived Job/pod). Keyed by CLUSTER so a "don't ask again" on one cluster
2
+ // short-lived Job/pod). Keyed by an opaque source identity so a "don't ask again" on one cluster
3
3
  // never silently suppresses the confirm on another - the cluster the pod lands
4
4
  // in is the whole point of asking. Switching kube-context re-prompts.
5
5
  //
6
- // No cluster identity → no memory of ANY kind: the operator is asked on every
6
+ // No source identity → no memory of ANY kind: the operator is asked on every
7
7
  // run. localStorage is per-ORIGIN, and one origin (Radar Hub) can front many
8
8
  // clusters, so a shared fallback key would let "don't ask again" on one cluster
9
9
  // suppress the pod-creating confirm on all of them - and even an in-memory
@@ -11,22 +11,22 @@
11
11
  // The dialog hides its "don't ask again" checkbox in that case, so nothing is
12
12
  // promised that doesn't stick.
13
13
 
14
- const key = (cluster: string) => `radar.inClusterConsent.${cluster}`
14
+ const key = (clusterKey: string) => `radar.inClusterConsent.v2.${clusterKey}`
15
15
 
16
- export function inClusterConsentGiven(cluster?: string): boolean {
17
- if (!cluster) return false
16
+ export function inClusterConsentGiven(clusterKey?: string): boolean {
17
+ if (!clusterKey) return false
18
18
  try {
19
- return localStorage.getItem(key(cluster)) === '1'
19
+ return localStorage.getItem(key(clusterKey)) === '1'
20
20
  } catch {
21
21
  // localStorage unavailable (private mode / non-browser) - fail toward asking.
22
22
  return false
23
23
  }
24
24
  }
25
25
 
26
- export function rememberInClusterConsent(cluster?: string): void {
27
- if (!cluster) return
26
+ export function rememberInClusterConsent(clusterKey?: string): void {
27
+ if (!clusterKey) return
28
28
  try {
29
- localStorage.setItem(key(cluster), '1')
29
+ localStorage.setItem(key(clusterKey), '1')
30
30
  } catch {
31
31
  // Non-fatal: if we can't persist, the user is simply asked again next time.
32
32
  }
@@ -9,6 +9,7 @@ export * from './animation'
9
9
  export * from './resource-hierarchy'
10
10
  export * from './log-format'
11
11
  export * from './download'
12
+ export * from './clipboard'
12
13
  export * from './env-from'
13
14
  export * from './extended-resources'
14
15
  export * from './api-resources'