@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.
- package/package.json +5 -5
- package/src/components/applications/ApplicationDetail.test.tsx +46 -0
- package/src/components/applications/ApplicationDetail.tsx +14 -15
- package/src/components/issues/IssuesView.tsx +9 -0
- package/src/components/issues/diagnostic.ts +3 -0
- package/src/components/issues/issues.test.ts +11 -0
- package/src/components/issues/severity.ts +3 -0
- package/src/components/issues/types.ts +3 -0
- package/src/components/resources/ResourcesSidebar.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +259 -30
- package/src/components/resources/index.ts +2 -0
- package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
- package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
- package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/EventRenderer.tsx +10 -18
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
- package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
- package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
- package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
- package/src/components/resources/renderers/index.ts +4 -0
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
- package/src/components/resources/renderers/velero-cells.tsx +128 -9
- package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
- package/src/components/resources/resource-utils-cnpg.ts +373 -56
- package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
- package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
- package/src/components/resources/resource-utils-velero.test.ts +237 -0
- package/src/components/resources/resource-utils-velero.ts +214 -22
- package/src/components/resources/resource-utils.ts +70 -3
- package/src/components/shared/ResourceActionsBar.tsx +3 -1
- package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
- package/src/components/topology/K8sResourceNode.tsx +11 -0
- package/src/components/topology/TopologyGraph.tsx +85 -5
- package/src/components/trace/ReachabilityGraph.tsx +517 -0
- package/src/components/trace/ReachabilityView.tsx +1173 -0
- package/src/components/trace/TracePanel.test.ts +172 -0
- package/src/components/trace/TracePanel.tsx +558 -0
- package/src/components/trace/TraceSummary.test.ts +213 -0
- package/src/components/trace/TraceSummary.tsx +168 -0
- package/src/components/trace/inClusterSummary.test.ts +98 -0
- package/src/components/trace/inClusterSummary.ts +81 -0
- package/src/components/trace/index.ts +19 -0
- package/src/components/trace/podReach.test.ts +71 -0
- package/src/components/trace/podReach.ts +37 -0
- package/src/components/trace/probe-display.test.ts +107 -0
- package/src/components/trace/probe-display.ts +37 -0
- package/src/components/trace/problemRows.test.ts +107 -0
- package/src/components/trace/reachFixtures.ts +261 -0
- package/src/components/trace/reachGraphModel.test.ts +1423 -0
- package/src/components/trace/reachGraphModel.ts +1521 -0
- package/src/components/trace/reachInspector.test.ts +892 -0
- package/src/components/trace/reachInspector.ts +797 -0
- package/src/components/trace/reachMarks.test.ts +698 -0
- package/src/components/trace/reachMarks.ts +623 -0
- package/src/components/trace/reachOrigins.test.ts +392 -0
- package/src/components/trace/reachOrigins.ts +449 -0
- package/src/components/trace/reachVerdict.test.ts +537 -0
- package/src/components/trace/reachVerdict.ts +601 -0
- package/src/components/trace/traceFingerprint.test.ts +214 -0
- package/src/components/trace/traceFingerprint.ts +122 -0
- package/src/components/trace/traceToSubgraph.test.ts +623 -0
- package/src/components/trace/traceToSubgraph.ts +463 -0
- package/src/components/trace/types.ts +414 -0
- package/src/components/ui/Badge.tsx +36 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
- package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
- package/src/components/ui/InClusterConsentDialog.tsx +141 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +58 -5
- package/src/components/workload/index.ts +1 -1
- package/src/components/workload/reachabilityTab.test.ts +47 -0
- package/src/index.ts +5 -0
- package/src/theme/components.css +64 -0
- package/src/theme/variables.css +5 -0
- package/src/types/core.ts +8 -0
- package/src/utils/api-resources.ts +2 -0
- package/src/utils/application-history.test.ts +1 -1
- package/src/utils/applications.test.ts +63 -1
- package/src/utils/applications.ts +27 -0
- package/src/utils/inClusterConsent.test.ts +109 -0
- package/src/utils/inClusterConsent.ts +72 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +61 -0
- package/src/utils/navigation.ts +11 -9
- package/src/utils/pluralize.test.ts +80 -1
- package/src/utils/pluralize.ts +34 -0
- package/src/utils/resource-icons.test.ts +33 -0
- 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
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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.
|
package/src/utils/navigation.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|
package/src/utils/pluralize.ts
CHANGED
|
@@ -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
|
-
|
|
239
|
-
|
|
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). */
|