@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 +2 -2
- package/src/components/applications/ApplicationDetail.tsx +2 -2
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +32 -7
- package/src/components/resources/ResourcesView.tsx +9 -4
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
- package/src/components/trace/TracePanel.tsx +1 -0
- package/src/components/ui/InClusterConsentDialog.tsx +5 -4
- package/src/components/ui/SelectMenu.tsx +202 -28
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/types/core.ts +7 -4
- package/src/utils/clipboard.test.ts +86 -0
- package/src/utils/clipboard.ts +50 -0
- package/src/utils/extended-resources.ts +4 -7
- package/src/utils/format.test.ts +83 -1
- package/src/utils/format.ts +80 -1
- package/src/utils/inClusterConsent.test.ts +9 -0
- package/src/utils/inClusterConsent.ts +9 -9
- package/src/utils/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.13.
|
|
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.
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
5843
|
+
void copyText(name).then((didCopy) => {
|
|
5844
|
+
if (!didCopy) return
|
|
5842
5845
|
setCopied(true)
|
|
5843
5846
|
setTimeout(() => setCopied(false), 1500)
|
|
5844
|
-
})
|
|
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
|
-
|
|
7522
|
-
|
|
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
|
-
|
|
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 :
|
|
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 &&
|
|
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
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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
|
|
58
|
-
{
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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: <
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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 {
|
package/src/utils/format.test.ts
CHANGED
|
@@ -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
|
})
|
package/src/utils/format.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 = (
|
|
14
|
+
const key = (clusterKey: string) => `radar.inClusterConsent.v2.${clusterKey}`
|
|
15
15
|
|
|
16
|
-
export function inClusterConsentGiven(
|
|
17
|
-
if (!
|
|
16
|
+
export function inClusterConsentGiven(clusterKey?: string): boolean {
|
|
17
|
+
if (!clusterKey) return false
|
|
18
18
|
try {
|
|
19
|
-
return localStorage.getItem(key(
|
|
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(
|
|
27
|
-
if (!
|
|
26
|
+
export function rememberInClusterConsent(clusterKey?: string): void {
|
|
27
|
+
if (!clusterKey) return
|
|
28
28
|
try {
|
|
29
|
-
localStorage.setItem(key(
|
|
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
|
}
|
package/src/utils/index.ts
CHANGED