@skyhook-io/k8s-ui 1.12.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.
Files changed (117) hide show
  1. package/package.json +4 -4
  2. package/src/components/applications/ApplicationDetail.tsx +2 -2
  3. package/src/components/cluster-switcher/ClusterSwitcher.tsx +32 -7
  4. package/src/components/issues/severity.ts +1 -0
  5. package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
  6. package/src/components/resources/ResourcesView.tsx +419 -9
  7. package/src/components/resources/index.ts +27 -0
  8. package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
  9. package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
  10. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
  11. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
  12. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
  13. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
  14. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
  15. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
  16. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
  17. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
  18. package/src/components/resources/renderers/CNPGShared.tsx +27 -0
  19. package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
  20. package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
  21. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
  22. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
  23. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
  24. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
  25. package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
  26. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
  27. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
  28. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
  29. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
  30. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
  31. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
  32. package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
  33. package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
  34. package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
  35. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
  36. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
  37. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
  38. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
  39. package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
  40. package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
  41. package/src/components/resources/renderers/PolicySection.tsx +32 -5
  42. package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
  43. package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
  44. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
  45. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
  46. package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
  47. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
  48. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
  49. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
  50. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
  51. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
  52. package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
  53. package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
  54. package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
  55. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
  56. package/src/components/resources/renderers/calico-cells.tsx +177 -0
  57. package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
  58. package/src/components/resources/renderers/index.ts +16 -1
  59. package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
  60. package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
  61. package/src/components/resources/renderers/velero-cells.tsx +29 -7
  62. package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
  63. package/src/components/resources/resource-utils-calico.test.ts +215 -0
  64. package/src/components/resources/resource-utils-calico.ts +261 -0
  65. package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
  66. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
  67. package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
  68. package/src/components/resources/resource-utils-cnpg.ts +264 -3
  69. package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
  70. package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
  71. package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
  72. package/src/components/resources/resource-utils-kyverno.ts +43 -7
  73. package/src/components/resources/resource-utils-velero.test.ts +26 -0
  74. package/src/components/resources/resource-utils-velero.ts +68 -0
  75. package/src/components/resources/resource-utils.ts +98 -0
  76. package/src/components/shared/ResourceActionsBar.tsx +101 -16
  77. package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
  78. package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
  79. package/src/components/shared/index.ts +1 -1
  80. package/src/components/shared/revision-role-badges.test.ts +120 -0
  81. package/src/components/topology/K8sResourceNode.test.ts +10 -0
  82. package/src/components/topology/K8sResourceNode.tsx +22 -0
  83. package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
  84. package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
  85. package/src/components/topology/TopologyGraph.tsx +6 -6
  86. package/src/components/topology/TopologySearch.tsx +7 -0
  87. package/src/components/topology/layout.ts +3 -0
  88. package/src/components/trace/ReachabilityView.tsx +1 -0
  89. package/src/components/trace/TracePanel.tsx +13 -2
  90. package/src/components/trace/reachFixtures.ts +26 -1
  91. package/src/components/trace/reachGraphModel.test.ts +20 -0
  92. package/src/components/trace/reachGraphModel.ts +8 -1
  93. package/src/components/trace/reachOrigins.test.ts +31 -0
  94. package/src/components/trace/reachOrigins.ts +11 -1
  95. package/src/components/ui/Badge.tsx +10 -0
  96. package/src/components/ui/InClusterConsentDialog.tsx +5 -4
  97. package/src/components/ui/ScheduleValue.test.tsx +80 -0
  98. package/src/components/ui/ScheduleValue.tsx +85 -0
  99. package/src/components/ui/SelectMenu.tsx +202 -28
  100. package/src/components/ui/drawer-components.test.tsx +17 -1
  101. package/src/components/ui/drawer-components.tsx +9 -2
  102. package/src/components/ui/index.ts +2 -0
  103. package/src/components/workload/WorkloadView.tsx +5 -2
  104. package/src/types/core.ts +36 -4
  105. package/src/types/policy.ts +153 -2
  106. package/src/utils/clipboard.test.ts +86 -0
  107. package/src/utils/clipboard.ts +50 -0
  108. package/src/utils/extended-resources.ts +4 -7
  109. package/src/utils/format.test.ts +83 -1
  110. package/src/utils/format.ts +80 -1
  111. package/src/utils/inClusterConsent.test.ts +9 -0
  112. package/src/utils/inClusterConsent.ts +9 -9
  113. package/src/utils/index.ts +1 -0
  114. package/src/utils/navigation.test.ts +5 -0
  115. package/src/utils/navigation.ts +5 -0
  116. package/src/utils/resource-icons.test.ts +49 -2
  117. package/src/utils/resource-icons.ts +29 -2
@@ -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'
@@ -24,6 +24,11 @@ describe('kindToPlural', () => {
24
24
 
25
25
  test('handles kinds ending in consonant+y (changes to -ies)', () => {
26
26
  expect(kindToPlural('NetworkPolicy')).toBe('networkpolicies')
27
+ expect(kindToPlural('CalicoNetworkPolicy')).toBe('networkpolicies')
28
+ expect(kindToPlural('CalicoGlobalNetworkPolicy')).toBe('globalnetworkpolicies')
29
+ expect(kindToPlural('CalicoStagedNetworkPolicy')).toBe('stagednetworkpolicies')
30
+ expect(kindToPlural('CalicoStagedGlobalNetworkPolicy')).toBe('stagedglobalnetworkpolicies')
31
+ expect(kindToPlural('CalicoStagedKubernetesNetworkPolicy')).toBe('stagedkubernetesnetworkpolicies')
27
32
  })
28
33
 
29
34
  test('handles already-plural kind names (Endpoints)', () => {
@@ -100,6 +100,11 @@ export function kindToPlural(kind: string): string {
100
100
  horizontalpodautoscaler: 'horizontalpodautoscalers',
101
101
  pvc: 'persistentvolumeclaims',
102
102
  podgroup: 'pods',
103
+ caliconetworkpolicy: 'networkpolicies',
104
+ calicoglobalnetworkpolicy: 'globalnetworkpolicies',
105
+ calicostagednetworkpolicy: 'stagednetworkpolicies',
106
+ calicostagedglobalnetworkpolicy: 'stagedglobalnetworkpolicies',
107
+ calicostagedkubernetesnetworkpolicy: 'stagedkubernetesnetworkpolicies',
103
108
  }
104
109
  if (aliases[kindLower]) return aliases[kindLower]
105
110
 
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { Database, Server, Puzzle } from 'lucide-react'
3
- import { getResourceIcon, DEFAULT_RESOURCE_ICON } from './resource-icons'
2
+ import { Database, Network, Server, ShieldCheck, Puzzle } from 'lucide-react'
3
+ import { getResourceIcon, getTopologyIcon, DEFAULT_RESOURCE_ICON } from './resource-icons'
4
4
 
5
5
  describe('getResourceIcon', () => {
6
6
  it('disambiguates colliding kinds by API group', () => {
@@ -14,6 +14,53 @@ describe('getResourceIcon', () => {
14
14
  // A third operator's `clusters` CRD must not inherit a Postgres icon.
15
15
  expect(getResourceIcon('Cluster', 'apps.kubeblocks.io')).toBe(DEFAULT_RESOURCE_ICON)
16
16
  expect(getResourceIcon('Backup', 'kubevirt.io')).toBe(DEFAULT_RESOURCE_ICON)
17
+ expect(getResourceIcon('HostEndpoint', 'networking.example.io')).toBe(DEFAULT_RESOURCE_ICON)
18
+ expect(getResourceIcon('IPPool', 'networking.example.io')).toBe(DEFAULT_RESOURCE_ICON)
19
+ expect(getResourceIcon('IPPool', 'extension.projectcalico.org')).toBe(DEFAULT_RESOURCE_ICON)
20
+ expect(getResourceIcon('Tier', 'extension.projectcalico.org')).toBe(DEFAULT_RESOURCE_ICON)
21
+ })
22
+
23
+ it('resolves Calico IPPool only for Calico API groups', () => {
24
+ expect(getResourceIcon('IPPool', 'crd.projectcalico.org')).toBe(Network)
25
+ expect(getResourceIcon('IPPool', 'projectcalico.org')).toBe(Network)
26
+ expect(getResourceIcon('IPPool')).toBe(DEFAULT_RESOURCE_ICON)
27
+ })
28
+
29
+ it('resolves Calico HostEndpoint only for Calico API groups', () => {
30
+ expect(getResourceIcon('HostEndpoint', 'crd.projectcalico.org')).toBe(Network)
31
+ expect(getResourceIcon('HostEndpoint', 'projectcalico.org')).toBe(Network)
32
+ expect(getResourceIcon('HostEndpoint')).toBe(DEFAULT_RESOURCE_ICON)
33
+ })
34
+
35
+ it('resolves Calico Tier only for Calico API groups', () => {
36
+ expect(getResourceIcon('Tier', 'crd.projectcalico.org')).toBe(Network)
37
+ expect(getResourceIcon('Tier', 'projectcalico.org')).toBe(Network)
38
+ expect(getResourceIcon('Tier')).toBe(DEFAULT_RESOURCE_ICON)
39
+ })
40
+
41
+ it('disambiguates NetworkPolicy and Calico policy kinds by API group', () => {
42
+ expect(getResourceIcon('NetworkPolicy', 'networking.k8s.io')).toBe(ShieldCheck)
43
+ expect(getResourceIcon('NetworkPolicy', 'projectcalico.org')).toBe(ShieldCheck)
44
+ expect(getResourceIcon('NetworkPolicy', 'crd.projectcalico.org')).toBe(ShieldCheck)
45
+ expect(getResourceIcon('NetworkPolicy', 'networking.example.io')).toBe(DEFAULT_RESOURCE_ICON)
46
+ expect(getResourceIcon('GlobalNetworkPolicy', 'projectcalico.org')).toBe(ShieldCheck)
47
+ expect(getResourceIcon('StagedNetworkPolicy', 'crd.projectcalico.org')).toBe(ShieldCheck)
48
+ expect(getResourceIcon('StagedGlobalNetworkPolicy', 'projectcalico.org')).toBe(ShieldCheck)
49
+ expect(getResourceIcon('StagedKubernetesNetworkPolicy', 'crd.projectcalico.org')).toBe(ShieldCheck)
50
+ expect(getResourceIcon('GlobalNetworkPolicy', 'networking.example.io')).toBe(DEFAULT_RESOURCE_ICON)
51
+ })
52
+
53
+ it('uses ShieldCheck for all Calico policy topology nodes', () => {
54
+ for (const kind of [
55
+ 'CalicoNetworkPolicy',
56
+ 'CalicoGlobalNetworkPolicy',
57
+ 'CalicoStagedNetworkPolicy',
58
+ 'CalicoStagedGlobalNetworkPolicy',
59
+ 'CalicoStagedKubernetesNetworkPolicy',
60
+ ]) {
61
+ expect(getTopologyIcon(kind)).toBe(ShieldCheck)
62
+ expect(getResourceIcon(kind)).toBe(ShieldCheck)
63
+ }
17
64
  })
18
65
 
19
66
  it('ignores the group for kinds that do not collide', () => {
@@ -20,6 +20,7 @@ import {
20
20
  ShieldAlert,
21
21
  Radio,
22
22
  Globe,
23
+ Network,
23
24
 
24
25
  // Config
25
26
  FileSliders,
@@ -96,6 +97,11 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
96
97
  service: Plug,
97
98
  ingress: DoorOpen,
98
99
  networkpolicy: ShieldCheck,
100
+ caliconetworkpolicy: ShieldCheck,
101
+ calicoglobalnetworkpolicy: ShieldCheck,
102
+ calicostagednetworkpolicy: ShieldCheck,
103
+ calicostagedglobalnetworkpolicy: ShieldCheck,
104
+ calicostagedkubernetesnetworkpolicy: ShieldCheck,
99
105
  ciliumnetworkpolicy: ShieldCheck,
100
106
  ciliumclusterwidenetworkpolicy: ShieldCheck,
101
107
  clusternetworkpolicy: ShieldCheck,
@@ -291,13 +297,33 @@ const GROUP_QUALIFIED_KIND_ICONS: Record<string, Record<string, LucideIcon>> = {
291
297
  backup: { 'postgresql.cnpg.io': DatabaseBackup },
292
298
  scheduledbackup: { 'postgresql.cnpg.io': DatabaseBackup },
293
299
  pooler: { 'postgresql.cnpg.io': Waypoints },
300
+ networkpolicy: {
301
+ 'networking.k8s.io': ShieldCheck,
302
+ 'crd.projectcalico.org': ShieldCheck,
303
+ 'projectcalico.org': ShieldCheck,
304
+ },
305
+ hostendpoint: { 'crd.projectcalico.org': Network, 'projectcalico.org': Network },
306
+ ippool: { 'crd.projectcalico.org': Network, 'projectcalico.org': Network },
307
+ tier: { 'crd.projectcalico.org': Network, 'projectcalico.org': Network },
308
+ globalnetworkpolicy: { 'crd.projectcalico.org': ShieldCheck, 'projectcalico.org': ShieldCheck },
309
+ stagednetworkpolicy: { 'crd.projectcalico.org': ShieldCheck, 'projectcalico.org': ShieldCheck },
310
+ stagedglobalnetworkpolicy: { 'crd.projectcalico.org': ShieldCheck, 'projectcalico.org': ShieldCheck },
311
+ stagedkubernetesnetworkpolicy: { 'crd.projectcalico.org': ShieldCheck, 'projectcalico.org': ShieldCheck },
312
+ }
313
+
314
+ const TOPOLOGY_KIND_ICONS: Record<string, LucideIcon> = {
315
+ CalicoNetworkPolicy: ShieldCheck,
316
+ CalicoGlobalNetworkPolicy: ShieldCheck,
317
+ CalicoStagedNetworkPolicy: ShieldCheck,
318
+ CalicoStagedGlobalNetworkPolicy: ShieldCheck,
319
+ CalicoStagedKubernetesNetworkPolicy: ShieldCheck,
294
320
  }
295
321
 
296
322
  export function getResourceIcon(kind: string, group?: string): LucideIcon {
297
323
  const k = kind.toLowerCase()
298
324
  if (group) {
299
- const byGroup = GROUP_QUALIFIED_KIND_ICONS[k]?.[group]
300
- if (byGroup) return byGroup
325
+ const groupMap = GROUP_QUALIFIED_KIND_ICONS[k]
326
+ if (groupMap) return groupMap[group] ?? DEFAULT_RESOURCE_ICON
301
327
  }
302
328
  return KIND_ICON_MAP[k] ?? Puzzle
303
329
  }
@@ -306,6 +332,7 @@ export function getResourceIcon(kind: string, group?: string): LucideIcon {
306
332
  export function getTopologyIcon(kind: string): LucideIcon {
307
333
  if (kind === 'Internet') return Globe
308
334
  if (kind === 'PodGroup') return Boxes
335
+ if (TOPOLOGY_KIND_ICONS[kind]) return TOPOLOGY_KIND_ICONS[kind]
309
336
  return getResourceIcon(kind)
310
337
  }
311
338