@skyhook-io/k8s-ui 1.10.4 → 1.11.0

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 (99) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.test.tsx +46 -0
  3. package/src/components/applications/ApplicationDetail.tsx +14 -15
  4. package/src/components/issues/IssuesView.tsx +9 -0
  5. package/src/components/issues/diagnostic.ts +3 -0
  6. package/src/components/issues/issues.test.ts +11 -0
  7. package/src/components/issues/severity.ts +3 -0
  8. package/src/components/issues/types.ts +3 -0
  9. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  10. package/src/components/resources/ResourcesView.tsx +259 -30
  11. package/src/components/resources/index.ts +2 -0
  12. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  13. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  14. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  15. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
  16. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
  17. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  18. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  19. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  20. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  21. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  22. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  23. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  24. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  25. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  26. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  27. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  28. package/src/components/resources/renderers/index.ts +4 -0
  29. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  30. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  31. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  32. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  33. package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
  34. package/src/components/resources/resource-utils-cnpg.ts +373 -56
  35. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  36. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  37. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  38. package/src/components/resources/resource-utils-velero.ts +214 -22
  39. package/src/components/resources/resource-utils.ts +70 -3
  40. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  41. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  42. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  43. package/src/components/topology/K8sResourceNode.tsx +11 -0
  44. package/src/components/topology/TopologyGraph.tsx +85 -5
  45. package/src/components/trace/ReachabilityGraph.tsx +517 -0
  46. package/src/components/trace/ReachabilityView.tsx +1173 -0
  47. package/src/components/trace/TracePanel.test.ts +172 -0
  48. package/src/components/trace/TracePanel.tsx +558 -0
  49. package/src/components/trace/TraceSummary.test.ts +213 -0
  50. package/src/components/trace/TraceSummary.tsx +168 -0
  51. package/src/components/trace/inClusterSummary.test.ts +98 -0
  52. package/src/components/trace/inClusterSummary.ts +81 -0
  53. package/src/components/trace/index.ts +19 -0
  54. package/src/components/trace/podReach.test.ts +71 -0
  55. package/src/components/trace/podReach.ts +37 -0
  56. package/src/components/trace/probe-display.test.ts +107 -0
  57. package/src/components/trace/probe-display.ts +37 -0
  58. package/src/components/trace/problemRows.test.ts +107 -0
  59. package/src/components/trace/reachFixtures.ts +261 -0
  60. package/src/components/trace/reachGraphModel.test.ts +1423 -0
  61. package/src/components/trace/reachGraphModel.ts +1521 -0
  62. package/src/components/trace/reachInspector.test.ts +892 -0
  63. package/src/components/trace/reachInspector.ts +797 -0
  64. package/src/components/trace/reachMarks.test.ts +698 -0
  65. package/src/components/trace/reachMarks.ts +623 -0
  66. package/src/components/trace/reachOrigins.test.ts +392 -0
  67. package/src/components/trace/reachOrigins.ts +449 -0
  68. package/src/components/trace/reachVerdict.test.ts +537 -0
  69. package/src/components/trace/reachVerdict.ts +601 -0
  70. package/src/components/trace/traceFingerprint.test.ts +214 -0
  71. package/src/components/trace/traceFingerprint.ts +122 -0
  72. package/src/components/trace/traceToSubgraph.test.ts +623 -0
  73. package/src/components/trace/traceToSubgraph.ts +463 -0
  74. package/src/components/trace/types.ts +414 -0
  75. package/src/components/ui/Badge.tsx +36 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  77. package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
  78. package/src/components/ui/InClusterConsentDialog.tsx +141 -0
  79. package/src/components/ui/index.ts +1 -0
  80. package/src/components/workload/WorkloadView.tsx +58 -5
  81. package/src/components/workload/index.ts +1 -1
  82. package/src/components/workload/reachabilityTab.test.ts +47 -0
  83. package/src/index.ts +5 -0
  84. package/src/theme/components.css +64 -0
  85. package/src/theme/variables.css +5 -0
  86. package/src/types/core.ts +8 -0
  87. package/src/utils/api-resources.ts +2 -0
  88. package/src/utils/application-history.test.ts +1 -1
  89. package/src/utils/applications.test.ts +63 -1
  90. package/src/utils/applications.ts +27 -0
  91. package/src/utils/inClusterConsent.test.ts +109 -0
  92. package/src/utils/inClusterConsent.ts +72 -0
  93. package/src/utils/index.ts +1 -0
  94. package/src/utils/navigation.test.ts +61 -0
  95. package/src/utils/navigation.ts +11 -9
  96. package/src/utils/pluralize.test.ts +80 -1
  97. package/src/utils/pluralize.ts +34 -0
  98. package/src/utils/resource-icons.test.ts +33 -0
  99. package/src/utils/resource-icons.ts +65 -2
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { inClusterConsentGiven, rememberInClusterConsent, consentRequestRows } from './inClusterConsent'
3
+
4
+ describe('inClusterConsent', () => {
5
+ beforeEach(() => {
6
+ const store: Record<string, string> = {}
7
+ vi.stubGlobal('localStorage', {
8
+ getItem: (k: string) => (k in store ? store[k] : null),
9
+ setItem: (k: string, v: string) => { store[k] = v },
10
+ removeItem: (k: string) => { delete store[k] },
11
+ })
12
+ })
13
+
14
+ it('defaults to not-given', () => {
15
+ expect(inClusterConsentGiven('prod')).toBe(false)
16
+ })
17
+
18
+ it('remembers consent for a cluster', () => {
19
+ rememberInClusterConsent('prod')
20
+ expect(inClusterConsentGiven('prod')).toBe(true)
21
+ })
22
+
23
+ it('is keyed per cluster - consent on one does not grant another', () => {
24
+ rememberInClusterConsent('dev')
25
+ expect(inClusterConsentGiven('dev')).toBe(true)
26
+ expect(inClusterConsentGiven('prod')).toBe(false)
27
+ })
28
+
29
+ it('does not throw when localStorage is unavailable, and reports not-given', () => {
30
+ vi.stubGlobal('localStorage', {
31
+ getItem: () => { throw new Error('denied') },
32
+ setItem: () => { throw new Error('denied') },
33
+ })
34
+ expect(() => rememberInClusterConsent('prod')).not.toThrow()
35
+ expect(inClusterConsentGiven('prod')).toBe(false)
36
+ })
37
+ })
38
+
39
+ // No stable cluster identity → consent is never remembered, in ANY store. A
40
+ // persisted fallback key is shared by every cluster a single origin (Radar
41
+ // Hub) serves, and even an in-memory session flag is shared across every
42
+ // identity-less cluster in the session - either would let one cluster's
43
+ // "don't ask again" suppress the pod-creating confirm on all of them. So an
44
+ // identity-less run always asks: given() is always false, remember() no-ops.
45
+ describe('inClusterConsent without a cluster identity', () => {
46
+ it('never writes to localStorage', () => {
47
+ const setItem = vi.fn()
48
+ vi.stubGlobal('localStorage', { getItem: () => null, setItem })
49
+ rememberInClusterConsent(undefined)
50
+ rememberInClusterConsent('')
51
+ expect(setItem).not.toHaveBeenCalled()
52
+ })
53
+
54
+ it('never remembers consent - remember() is a no-op and given() stays false', () => {
55
+ vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => {} })
56
+ expect(inClusterConsentGiven(undefined)).toBe(false)
57
+ rememberInClusterConsent(undefined)
58
+ expect(inClusterConsentGiven(undefined)).toBe(false)
59
+ expect(inClusterConsentGiven('')).toBe(false)
60
+ })
61
+
62
+ it('does not read a persisted key when no cluster is known', () => {
63
+ // Even if some earlier version persisted a shared sentinel, an identity-less
64
+ // check must not honor it.
65
+ const store: Record<string, string> = { 'radar.inClusterConsent.current': '1' }
66
+ vi.stubGlobal('localStorage', {
67
+ getItem: (k: string) => (k in store ? store[k] : null),
68
+ setItem: (k: string, v: string) => { store[k] = v },
69
+ })
70
+ expect(inClusterConsentGiven(undefined)).toBe(false)
71
+ })
72
+
73
+ it('identity-less remember does not leak into a named cluster', () => {
74
+ vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => {} })
75
+ rememberInClusterConsent(undefined)
76
+ expect(inClusterConsentGiven('prod')).toBe(false)
77
+ })
78
+ })
79
+
80
+ describe('consent rows name the traffic the Job actually sends', () => {
81
+ it('a TCP-only candidate is a TCP connection, never a fabricated GET', () => {
82
+ const rows = consentRequestRows(
83
+ [{ route: 'database', target: 'database:6379', inClusterRequest: { protocol: 'tcp' } }],
84
+ '',
85
+ )
86
+ expect(rows).toEqual([{ route: 'database', request: 'TCP connection to database:6379' }])
87
+ })
88
+ it('HTTP(S) candidates keep the full request with dialled address and Host header', () => {
89
+ const rows = consentRequestRows(
90
+ [{ route: '/web', target: 'shop:80', inClusterRequest: { protocol: 'http', scheme: 'http', host: 'shop.example.com', path: '/web' } }],
91
+ '',
92
+ )
93
+ expect(rows[0].request).toBe('GET http://shop:80/web (Host: shop.example.com)')
94
+ })
95
+ it('a path override applies to HTTP only - a TCP dial has no path to override', () => {
96
+ const rows = consentRequestRows(
97
+ [
98
+ { route: 'database', target: 'database:6379', inClusterRequest: { protocol: 'tcp' } },
99
+ { route: '/web', target: 'shop:80', inClusterRequest: { protocol: 'http', scheme: 'http', path: '/' } },
100
+ ],
101
+ '/healthz',
102
+ )
103
+ expect(rows[0].request).toBe('TCP connection to database:6379')
104
+ expect(rows[1].request).toContain('/healthz')
105
+ })
106
+ it('benign dormant routes are never presented for approval', () => {
107
+ expect(consentRequestRows([{ route: 'x', target: 'x:80', benign: true, inClusterRequest: { protocol: 'http' } }], '')).toEqual([])
108
+ })
109
+ })
@@ -0,0 +1,72 @@
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
3
+ // never silently suppresses the confirm on another - the cluster the pod lands
4
+ // in is the whole point of asking. Switching kube-context re-prompts.
5
+ //
6
+ // No cluster identity → no memory of ANY kind: the operator is asked on every
7
+ // run. localStorage is per-ORIGIN, and one origin (Radar Hub) can front many
8
+ // clusters, so a shared fallback key would let "don't ask again" on one cluster
9
+ // suppress the pod-creating confirm on all of them - and even an in-memory
10
+ // session flag is shared across every identity-less cluster in the session.
11
+ // The dialog hides its "don't ask again" checkbox in that case, so nothing is
12
+ // promised that doesn't stick.
13
+
14
+ const key = (cluster: string) => `radar.inClusterConsent.${cluster}`
15
+
16
+ export function inClusterConsentGiven(cluster?: string): boolean {
17
+ if (!cluster) return false
18
+ try {
19
+ return localStorage.getItem(key(cluster)) === '1'
20
+ } catch {
21
+ // localStorage unavailable (private mode / non-browser) - fail toward asking.
22
+ return false
23
+ }
24
+ }
25
+
26
+ export function rememberInClusterConsent(cluster?: string): void {
27
+ if (!cluster) return
28
+ try {
29
+ localStorage.setItem(key(cluster), '1')
30
+ } catch {
31
+ // Non-fatal: if we can't persist, the user is simply asked again next time.
32
+ }
33
+ }
34
+
35
+ /** One consent row per runnable route - the words the operator approves.
36
+ * This is the MUTATION CONSENT, so it must name the traffic the Job actually
37
+ * sends: a TCP-only candidate (redis, postgres) opens a socket and sends no
38
+ * request - presenting it as "GET http://database:6379/" asks approval for
39
+ * traffic that will never happen. Path overrides apply to HTTP(S) only. */
40
+ export function consentRequestRows(
41
+ routes: Array<{
42
+ route: string
43
+ target?: string
44
+ benign?: boolean
45
+ inClusterRequest?: { protocol?: string; scheme?: string; host?: string; path?: string }
46
+ }>,
47
+ override: string,
48
+ ): { route: string; request: string }[] {
49
+ const rows: { route: string; request: string }[] = []
50
+ for (const r of routes) {
51
+ const req = r.inClusterRequest
52
+ if (!req) continue
53
+ // The runner skips benign (deliberately scaled-to-0) routes outright, so
54
+ // listing them as requests overstates what the operator is agreeing to.
55
+ if (r.benign) continue
56
+ const dialled = r.target || ''
57
+ if (req.protocol === 'tcp') {
58
+ rows.push({ route: r.route, request: `TCP connection to ${dialled || req.host || ''}`.trim() })
59
+ continue
60
+ }
61
+ const path = override || req.path || '/'
62
+ // The probe dials the SERVICE and passes the hostname as a Host/SNI header.
63
+ // Showing only the hostname put a public-looking address on a consent
64
+ // screen for traffic that never leaves the cluster - so name the address
65
+ // actually dialled, and the Host header as the header it is.
66
+ const scheme = req.scheme || 'http'
67
+ const addr = dialled ? `${scheme}://${dialled}${path}` : `${scheme}://${req.host ?? ''}${path}`
68
+ const asHost = req.host && dialled ? ` (Host: ${req.host})` : ''
69
+ rows.push({ route: r.route, request: `GET ${addr}${asHost}`.trim() })
70
+ }
71
+ return rows
72
+ }
@@ -26,4 +26,5 @@ export * from './topology-neighborhood'
26
26
  export * from './application-topology'
27
27
  export * from './application-history'
28
28
  export * from './bulk-workload-actions'
29
+ export * from './inClusterConsent'
29
30
  export * from './workflow-execution'
@@ -1,4 +1,5 @@
1
1
  import { describe, test, expect, afterEach } from 'vitest'
2
+ import { englishPlural } from './pluralize'
2
3
  import { kindToPlural, pluralToKind, refToSelectedResource, initNavigationMap, resetNavigationMap, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
3
4
 
4
5
  afterEach(() => {
@@ -65,6 +66,66 @@ describe('kindToPlural', () => {
65
66
  expect(kindToPlural('pvc')).toBe('persistentvolumeclaims')
66
67
  expect(kindToPlural('PodGroup')).toBe('pods')
67
68
  })
69
+
70
+ // A cold direct-URL load runs kindToPlural against the URL's plural slug one
71
+ // round-trip BEFORE initNavigationMap lands, so the discovered-plural guard
72
+ // can't fire for a CRD absent from BUILTIN_PLURAL_TO_KIND.
73
+ describe('CRD plurals before the discovery map arrives', () => {
74
+ test('idempotent on unknown lowercase plurals', () => {
75
+ expect(kindToPlural('schedules')).toBe('schedules')
76
+ expect(kindToPlural('validatingpolicies')).toBe('validatingpolicies')
77
+ expect(kindToPlural('virtualservices')).toBe('virtualservices')
78
+ expect(kindToPlural('clusters')).toBe('clusters')
79
+ expect(kindToPlural('backups')).toBe('backups')
80
+ })
81
+
82
+ // Plurals of `*se` singulars — Flux's HelmRelease and coordination.k8s.io's
83
+ // Lease are both kinds Radar handles, and both reach this cold path.
84
+ test('idempotent on plurals of *se singulars', () => {
85
+ expect(kindToPlural('helmreleases')).toBe('helmreleases')
86
+ expect(kindToPlural('leases')).toBe('leases')
87
+ expect(kindToPlural('databases')).toBe('databases')
88
+ })
89
+
90
+ test('still pluralizes singular PascalCase CRD kinds', () => {
91
+ expect(kindToPlural('Schedule')).toBe('schedules')
92
+ expect(kindToPlural('ValidatingPolicy')).toBe('validatingpolicies')
93
+ expect(kindToPlural('VirtualService')).toBe('virtualservices')
94
+ expect(kindToPlural('Cluster')).toBe('clusters')
95
+ })
96
+
97
+ // Lowercase does NOT imply plural. WorkloadViewRoute passes the URL segment
98
+ // through verbatim, so /workload/deployment/ns/name yields "deployment";
99
+ // pkg/topology's normalizeKind likewise returns its input unchanged when a
100
+ // kind resolves through neither its static map nor discovery, so a
101
+ // ResourceRef can carry "certificaterequest". Both must still pluralize —
102
+ // including the singulars that already end in 's'.
103
+ test('still pluralizes lowercase singular kinds', () => {
104
+ expect(kindToPlural('deployment')).toBe('deployments')
105
+ expect(kindToPlural('pod')).toBe('pods')
106
+ expect(kindToPlural('cronjob')).toBe('cronjobs')
107
+ expect(kindToPlural('certificaterequest')).toBe('certificaterequests')
108
+ expect(kindToPlural('validatingpolicy')).toBe('validatingpolicies')
109
+ expect(kindToPlural('ingress')).toBe('ingresses')
110
+ expect(kindToPlural('nodeclass')).toBe('nodeclasses')
111
+ expect(kindToPlural('ec2nodeclass')).toBe('ec2nodeclasses')
112
+ expect(kindToPlural('storageclass')).toBe('storageclasses')
113
+ })
114
+
115
+ test('leaves the empty-string result unchanged', () => {
116
+ expect(kindToPlural('')).toBe(englishPlural(''))
117
+ })
118
+
119
+ test('agrees with the post-discovery answer', () => {
120
+ const inputs = ['schedules', 'validatingpolicies', 'Schedule', 'ValidatingPolicy']
121
+ const cold = inputs.map(kindToPlural)
122
+ initNavigationMap([
123
+ { group: 'velero.io', version: 'v1', kind: 'Schedule', name: 'schedules', namespaced: true, isCrd: true, verbs: [] },
124
+ { group: 'kyverno.io', version: 'v1alpha1', kind: 'ValidatingPolicy', name: 'validatingpolicies', namespaced: false, isCrd: true, verbs: [] },
125
+ ])
126
+ expect(inputs.map(kindToPlural)).toEqual(cold)
127
+ })
128
+ })
68
129
  })
69
130
 
70
131
  // Demonstrate that the naive .toLowerCase() + 's' pattern used by renderers is broken.
@@ -1,5 +1,5 @@
1
1
  import type { SelectedResource, ResourceRef, APIResource } from '../types/core'
2
- import { englishPlural } from './pluralize'
2
+ import { englishPlural, englishSingular, isEnglishPlural } from './pluralize'
3
3
 
4
4
  /**
5
5
  * Canonical callback type for navigating to a resource.
@@ -103,6 +103,15 @@ export function kindToPlural(kind: string): string {
103
103
  }
104
104
  if (aliases[kindLower]) return aliases[kindLower]
105
105
 
106
+ // Keep the idempotence contract alive for CRD plurals that neither map knows
107
+ // yet: the discovery map arrives one round-trip after a cold direct-URL load,
108
+ // and English-pluralizing an already-plural slug yields `scheduleses`. An
109
+ // all-lowercase input is an API resource name rather than a Kind (Kubernetes
110
+ // Kinds are PascalCase), and isEnglishPlural separates a real plural from a
111
+ // singular that merely ends in 's' — `ingress` and `nodeclass` must still be
112
+ // pluralized, `schedules` and `validatingpolicies` must not.
113
+ if (kind === kindLower && isEnglishPlural(kindLower)) return kindLower
114
+
106
115
  // Fallback: English pluralization rules (shared with pluralize() in
107
116
  // utils/pluralize.ts so a rule change updates both call paths).
108
117
  return englishPlural(kindLower)
@@ -125,14 +134,7 @@ export function pluralToKind(plural: string): string {
125
134
  }
126
135
 
127
136
  // Fallback: basic de-pluralization + capitalize first letter
128
- let singular = lower
129
- if (singular.endsWith('ies')) {
130
- singular = singular.slice(0, -3) + 'y'
131
- } else if (singular.endsWith('ses') || singular.endsWith('xes') || singular.endsWith('ches') || singular.endsWith('shes')) {
132
- singular = singular.slice(0, -2)
133
- } else if (singular.endsWith('s')) {
134
- singular = singular.slice(0, -1)
135
- }
137
+ const singular = englishSingular(lower)
136
138
  return singular.charAt(0).toUpperCase() + singular.slice(1)
137
139
  }
138
140
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { englishPlural, pluralNoun, pluralize } from './pluralize'
2
+ import { englishPlural, englishSingular, isEnglishPlural, pluralNoun, pluralize } from './pluralize'
3
3
 
4
4
  describe('englishPlural', () => {
5
5
  describe('default +s', () => {
@@ -96,3 +96,82 @@ describe('pluralize', () => {
96
96
  expect(pluralize(2, 'box')).toBe('2 boxes')
97
97
  })
98
98
  })
99
+
100
+ describe('englishSingular', () => {
101
+ it('inverts englishPlural for regular plurals', () => {
102
+ expect(englishSingular('clusters')).toBe('cluster')
103
+ expect(englishSingular('policies')).toBe('policy')
104
+ expect(englishSingular('classes')).toBe('class')
105
+ expect(englishSingular('boxes')).toBe('box')
106
+ expect(englishSingular('batches')).toBe('batch')
107
+ })
108
+
109
+ it('leaves nouns with no plural ending alone', () => {
110
+ expect(englishSingular('cluster')).toBe('cluster')
111
+ expect(englishSingular('policy')).toBe('policy')
112
+ })
113
+
114
+ // The `-es` strip over-shortens a `*se` singular ('leases' → 'leas', not
115
+ // 'lease'), but englishPlural's `s → +es` rule inverts it exactly, so the
116
+ // round trip these two functions are used for still holds. Only the round
117
+ // trip is contractual — the intermediate stem is not.
118
+ it('over-strips *se stems but stays round-trip exact', () => {
119
+ expect(englishSingular('leases')).toBe('leas')
120
+ expect(englishPlural('leas')).toBe('leases')
121
+ expect(englishSingular('helmreleases')).toBe('helmreleas')
122
+ expect(englishPlural('helmreleas')).toBe('helmreleases')
123
+ })
124
+ })
125
+
126
+ describe('isEnglishPlural', () => {
127
+ it('recognizes regular plurals', () => {
128
+ expect(isEnglishPlural('schedules')).toBe(true)
129
+ expect(isEnglishPlural('validatingpolicies')).toBe(true)
130
+ expect(isEnglishPlural('virtualservices')).toBe(true)
131
+ expect(isEnglishPlural('gatewayclasses')).toBe(true)
132
+ expect(isEnglishPlural('endpoints')).toBe(true)
133
+ })
134
+
135
+ // Plurals whose singular ends in `se`. The stem englishSingular produces is
136
+ // wrong, but the round trip is not — see the englishSingular case above.
137
+ it('recognizes plurals of *se singulars', () => {
138
+ expect(isEnglishPlural('leases')).toBe(true)
139
+ expect(isEnglishPlural('helmreleases')).toBe(true)
140
+ expect(isEnglishPlural('releases')).toBe(true)
141
+ expect(isEnglishPlural('licenses')).toBe(true)
142
+ expect(isEnglishPlural('databases')).toBe(true)
143
+ })
144
+
145
+ // KNOWN LIMIT. A singular ending in a lone `s` whose stem doesn't end in
146
+ // s/x/ch/sh is indistinguishable from a plural by English rules alone
147
+ // ('status' → 'statu' → 'status' round-trips exactly like 'clusters' →
148
+ // 'cluster' → 'clusters'). No Kubernetes Kind Radar handles has this shape,
149
+ // and kindToPlural's discovered-map lookup takes over the moment discovery
150
+ // lands, so the mistake is confined to the cold window. Pinned so a future
151
+ // change to these rules surfaces the boundary rather than moving it silently.
152
+ it('cannot distinguish singulars ending in a lone s', () => {
153
+ expect(isEnglishPlural('status')).toBe(true)
154
+ expect(isEnglishPlural('alias')).toBe(true)
155
+ expect(isEnglishPlural('canvas')).toBe(true)
156
+ })
157
+
158
+ // The reason this predicate exists rather than a bare endsWith('s'): these
159
+ // are singular Kubernetes kinds that happen to end in 's'.
160
+ it('rejects singulars that merely end in s', () => {
161
+ expect(isEnglishPlural('ingress')).toBe(false)
162
+ expect(isEnglishPlural('nodeclass')).toBe(false)
163
+ expect(isEnglishPlural('ec2nodeclass')).toBe(false)
164
+ expect(isEnglishPlural('storageclass')).toBe(false)
165
+ })
166
+
167
+ it('rejects singulars with no plural ending', () => {
168
+ expect(isEnglishPlural('cluster')).toBe(false)
169
+ expect(isEnglishPlural('validatingpolicy')).toBe(false)
170
+ })
171
+
172
+ it('agrees with englishPlural on every round trip', () => {
173
+ for (const singular of ['cluster', 'policy', 'class', 'box', 'batch', 'schedule', 'service']) {
174
+ expect(isEnglishPlural(englishPlural(singular))).toBe(true)
175
+ }
176
+ })
177
+ })
@@ -30,6 +30,40 @@ export function englishPlural(noun: string): string {
30
30
  return noun + 's';
31
31
  }
32
32
 
33
+ /**
34
+ * Undo englishPlural's regular rules.
35
+ *
36
+ * englishSingular('classes') → 'class'
37
+ * englishSingular('policies') → 'policy'
38
+ * englishSingular('clusters') → 'cluster'
39
+ *
40
+ * Lossy by construction — a noun that is already singular comes back mangled
41
+ * ('ingress' → 'ingres'). Use isEnglishPlural to find out which case you have.
42
+ */
43
+ export function englishSingular(noun: string): string {
44
+ const lower = noun.toLowerCase();
45
+ if (lower.endsWith('ies')) return noun.slice(0, -3) + 'y';
46
+ if (lower.endsWith('ses') || lower.endsWith('xes') || lower.endsWith('ches') || lower.endsWith('shes')) {
47
+ return noun.slice(0, -2);
48
+ }
49
+ if (lower.endsWith('s')) return noun.slice(0, -1);
50
+ return noun;
51
+ }
52
+
53
+ /**
54
+ * Whether a noun is already the regular English plural of its own singular —
55
+ * i.e. whether englishPlural would reproduce it. Distinguishes a plural from a
56
+ * singular that merely ends in 's':
57
+ *
58
+ * isEnglishPlural('schedules') → true ('schedule' → 'schedules')
59
+ * isEnglishPlural('policies') → true ('policy' → 'policies')
60
+ * isEnglishPlural('ingress') → false ('ingres' → 'ingreses')
61
+ * isEnglishPlural('nodeclass') → false ('nodeclas' → 'nodeclases')
62
+ */
63
+ export function isEnglishPlural(noun: string): boolean {
64
+ return englishPlural(englishSingular(noun)) === noun;
65
+ }
66
+
33
67
  /**
34
68
  * Count-aware plural noun. Returns the singular when n===1, else the
35
69
  * plural form (passed-in or derived via englishPlural).
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { Database, Server, Puzzle } from 'lucide-react'
3
+ import { getResourceIcon, DEFAULT_RESOURCE_ICON } from './resource-icons'
4
+
5
+ describe('getResourceIcon', () => {
6
+ it('disambiguates colliding kinds by API group', () => {
7
+ // `Cluster` is shipped by CNPG, Cluster API, KubeBlocks and others. Without
8
+ // the group there is nothing to pick on, so the browser passes it.
9
+ expect(getResourceIcon('Cluster', 'postgresql.cnpg.io')).toBe(Database)
10
+ expect(getResourceIcon('Cluster', 'cluster.x-k8s.io')).toBe(Server)
11
+ })
12
+
13
+ it('falls back to the default for an unmapped group', () => {
14
+ // A third operator's `clusters` CRD must not inherit a Postgres icon.
15
+ expect(getResourceIcon('Cluster', 'apps.kubeblocks.io')).toBe(DEFAULT_RESOURCE_ICON)
16
+ expect(getResourceIcon('Backup', 'kubevirt.io')).toBe(DEFAULT_RESOURCE_ICON)
17
+ })
18
+
19
+ it('ignores the group for kinds that do not collide', () => {
20
+ expect(getResourceIcon('Pod')).not.toBe(Puzzle)
21
+ expect(getResourceIcon('Pod', 'postgresql.cnpg.io')).toBe(getResourceIcon('Pod'))
22
+ })
23
+
24
+ it('resolves unambiguous CNPG kinds without a group', () => {
25
+ // Pooler is CNPG-only, so it keys directly and works everywhere.
26
+ expect(getResourceIcon('Pooler')).not.toBe(DEFAULT_RESOURCE_ICON)
27
+ expect(getResourceIcon('Pooler')).toBe(getResourceIcon('Pooler', 'postgresql.cnpg.io'))
28
+ })
29
+
30
+ it('returns the default for an entirely unknown kind', () => {
31
+ expect(getResourceIcon('Widget')).toBe(DEFAULT_RESOURCE_ICON)
32
+ })
33
+ })
@@ -29,7 +29,14 @@ import {
29
29
  HardDrive,
30
30
  Cylinder,
31
31
  Database,
32
+ DatabaseBackup,
33
+ Waypoints,
32
34
  FileSearch,
35
+ Fingerprint,
36
+ Wand2,
37
+ Sparkles,
38
+ Trash2,
39
+ ShieldOff,
33
40
 
34
41
  // Cluster
35
42
  Cpu,
@@ -65,6 +72,11 @@ import {
65
72
  HeartPulse,
66
73
  BookOpen,
67
74
 
75
+ // Velero
76
+ Cloud,
77
+ Camera,
78
+ Package,
79
+
68
80
  // Fallback
69
81
  Puzzle,
70
82
  } from 'lucide-react'
@@ -196,6 +208,12 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
196
208
  // Contour
197
209
  httpproxy: Globe,
198
210
 
211
+ // CloudNativePG. Pooler is unambiguous so it keys directly; CNPG's colliding
212
+ // kinds resolve through GROUP_QUALIFIED_KIND_ICONS below. Topology
213
+ // pseudo-kinds (cnpgcluster/…) belong here only once pkg/topology's
214
+ // KindForGVK emits them — it has no CNPG case today.
215
+ pooler: Waypoints,
216
+
199
217
  // Cluster API
200
218
  capicluster: Server,
201
219
  machinedeployment: Layers,
@@ -227,16 +245,61 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
227
245
  azuremachinetemplate: Cpu,
228
246
  azuremanagedcluster: Server,
229
247
 
248
+ // Kyverno — legacy kyverno.io family, modern policies.kyverno.io CEL
249
+ // family, and the wgpolicyk8s.io reports every engine writes into.
250
+ policy: Shield,
251
+ clusterpolicy: Shield,
252
+ policyreport: FileSearch,
253
+ clusterpolicyreport: FileSearch,
254
+ validatingpolicy: ShieldCheck,
255
+ namespacedvalidatingpolicy: ShieldCheck,
256
+ imagevalidatingpolicy: Fingerprint,
257
+ namespacedimagevalidatingpolicy: Fingerprint,
258
+ mutatingpolicy: Wand2,
259
+ namespacedmutatingpolicy: Wand2,
260
+ generatingpolicy: Sparkles,
261
+ namespacedgeneratingpolicy: Sparkles,
262
+ deletingpolicy: Trash2,
263
+ namespaceddeletingpolicy: Trash2,
264
+ policyexception: ShieldOff,
265
+ cleanuppolicy: Trash2,
266
+ clustercleanuppolicy: Trash2,
267
+
230
268
  // Trivy Operator
231
269
  vulnerabilityreport: Shield,
232
270
  configauditreport: ShieldCheck,
233
271
  exposedsecretreport: ShieldAlert,
234
272
  sbomreport: FileSearch,
273
+
274
+ // Velero. Only kinds whose names no other operator claims: this map is keyed
275
+ // on kind alone with no group awareness, so a shared name would give a foreign
276
+ // resource a Velero icon. That rules out `backup` (CNPG), `restore`
277
+ // (rancher/backup-restore-operator) and `schedule` (several operators).
278
+ backupstoragelocation: Cloud,
279
+ volumesnapshotlocation: Camera,
280
+ backuprepository: Package,
235
281
  }
236
282
 
237
283
  /** Get the icon for a Kubernetes resource kind (case-insensitive). */
238
- export function getResourceIcon(kind: string): LucideIcon {
239
- return KIND_ICON_MAP[kind.toLowerCase()] ?? Puzzle
284
+ /**
285
+ * Kinds shared by more than one operator, where the icon can only be chosen
286
+ * once the API group is known. The resource browser passes the group; callers
287
+ * that don't have one fall through to the ungrouped map.
288
+ */
289
+ const GROUP_QUALIFIED_KIND_ICONS: Record<string, Record<string, LucideIcon>> = {
290
+ cluster: { 'postgresql.cnpg.io': Database, 'cluster.x-k8s.io': Server },
291
+ backup: { 'postgresql.cnpg.io': DatabaseBackup },
292
+ scheduledbackup: { 'postgresql.cnpg.io': DatabaseBackup },
293
+ pooler: { 'postgresql.cnpg.io': Waypoints },
294
+ }
295
+
296
+ export function getResourceIcon(kind: string, group?: string): LucideIcon {
297
+ const k = kind.toLowerCase()
298
+ if (group) {
299
+ const byGroup = GROUP_QUALIFIED_KIND_ICONS[k]?.[group]
300
+ if (byGroup) return byGroup
301
+ }
302
+ return KIND_ICON_MAP[k] ?? Puzzle
240
303
  }
241
304
 
242
305
  /** Get the icon for a topology node kind, including virtual kinds (Internet, PodGroup). */