@skyhook-io/k8s-ui 1.8.8 → 1.8.10

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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/ApplicationsList.tsx +4 -1
  3. package/src/components/applications/ApplicationsView.tsx +73 -38
  4. package/src/components/checks/ChecksView.tsx +105 -63
  5. package/src/components/checks/severity.ts +29 -39
  6. package/src/components/gitops/GitOpsTableView.tsx +72 -43
  7. package/src/components/issues/IssuesView.tsx +186 -123
  8. package/src/components/issues/ResourceIssuesSection.test.tsx +39 -0
  9. package/src/components/issues/ResourceIssuesSection.tsx +21 -3
  10. package/src/components/issues/index.ts +0 -2
  11. package/src/components/issues/issues.test.ts +45 -4
  12. package/src/components/issues/severity.ts +27 -32
  13. package/src/components/resources/ResourcesSidebar.test.tsx +36 -0
  14. package/src/components/resources/ResourcesSidebar.tsx +24 -6
  15. package/src/components/resources/ResourcesView.tsx +10 -2
  16. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  17. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  18. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  19. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  20. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  23. package/src/components/ui/BoardSkeleton.tsx +47 -0
  24. package/src/components/ui/CardSection.tsx +117 -0
  25. package/src/components/ui/SummaryTile.tsx +9 -1
  26. package/src/components/ui/Toast.tsx +1 -1
  27. package/src/components/ui/index.ts +2 -0
  28. package/src/components/ui/severity-tone.ts +56 -0
  29. package/src/filter-state/filter-state-core.test.ts +98 -0
  30. package/src/filter-state/filter-state-core.ts +138 -0
  31. package/src/filter-state/filter-state.tsx +127 -0
  32. package/src/filter-state/index.ts +16 -0
  33. package/src/index.ts +4 -0
  34. package/src/theme/variables.css +14 -0
  35. package/src/utils/api-resources.test.ts +54 -0
  36. package/src/utils/api-resources.ts +68 -1
  37. package/tsconfig.json +2 -2
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ decodeFilters,
4
+ defineFilterSchema,
5
+ withField,
6
+ withToggle,
7
+ withCleared,
8
+ isFilterActive,
9
+ } from './filter-state-core'
10
+
11
+ const schema = defineFilterSchema({
12
+ ns: { param: 'namespace', type: 'set' },
13
+ sev: { param: 'severity', type: 'set' },
14
+ q: { param: 'q', type: 'text' },
15
+ group: { param: 'group', type: 'single', default: 'none' },
16
+ managed: { param: 'managed', type: 'boolean' },
17
+ })
18
+
19
+ const p = (s = '') => new URLSearchParams(s)
20
+
21
+ describe('filter-state core', () => {
22
+ it('decodes params by type', () => {
23
+ const v = decodeFilters(schema, p('namespace=argocd,default&severity=high&q=foo&managed=1'))
24
+ expect([...v.ns]).toEqual(['argocd', 'default'])
25
+ expect([...v.sev]).toEqual(['high'])
26
+ expect(v.q).toBe('foo')
27
+ expect(v.managed).toBe(true)
28
+ })
29
+
30
+ it('applies string defaults when the param is absent', () => {
31
+ const v = decodeFilters(schema, p(''))
32
+ expect(v.group).toBe('none')
33
+ expect(v.q).toBe('')
34
+ expect(v.managed).toBe(false)
35
+ expect(v.ns.size).toBe(0)
36
+ })
37
+
38
+ it('toggles a set member and omits the param when empty (empty = all)', () => {
39
+ const added = withToggle(schema, p(''), 'ns', 'argocd')
40
+ expect(added.toString()).toBe('namespace=argocd')
41
+ const removed = withToggle(schema, added, 'ns', 'argocd')
42
+ expect(removed.toString()).toBe('')
43
+ })
44
+
45
+ it('composes successive toggles (each sees the previous result)', () => {
46
+ let params = p('')
47
+ params = withToggle(schema, params, 'ns', 'a')
48
+ params = withToggle(schema, params, 'ns', 'b')
49
+ expect([...decodeFilters(schema, params).ns].sort()).toEqual(['a', 'b'])
50
+ })
51
+
52
+ it('withField replaces a set wholesale and clears when empty', () => {
53
+ const set = withField(schema, p('namespace=x'), 'ns', new Set(['a', 'b']))
54
+ expect([...decodeFilters(schema, set).ns]).toEqual(['a', 'b'])
55
+ expect(withField(schema, set, 'ns', new Set<string>()).toString()).toBe('')
56
+ })
57
+
58
+ it('omits a string param when equal to its default', () => {
59
+ expect(withField(schema, p(''), 'group', 'namespace').toString()).toBe('group=namespace')
60
+ expect(withField(schema, p('group=namespace'), 'group', 'none').toString()).toBe('')
61
+ })
62
+
63
+ it('encodes a boolean as =1 and omits false', () => {
64
+ expect(withField(schema, p(''), 'managed', true).toString()).toBe('managed=1')
65
+ expect(withField(schema, p('managed=1'), 'managed', false).toString()).toBe('')
66
+ })
67
+
68
+ it('clears one field or all schema fields, leaving unrelated params', () => {
69
+ const one = withCleared(schema, p('namespace=a&severity=high&keep=yes'), ['ns'])
70
+ const oneV = decodeFilters(schema, one)
71
+ expect(oneV.ns.size).toBe(0)
72
+ expect(oneV.sev.size).toBe(1)
73
+ expect(one.get('keep')).toBe('yes')
74
+ expect(withCleared(schema, p('namespace=a&severity=high&keep=yes')).toString()).toBe('keep=yes')
75
+ })
76
+
77
+ it('isFilterActive is true only when a field diverges from its default', () => {
78
+ expect(isFilterActive(schema, decodeFilters(schema, p('q=foo')))).toBe(true)
79
+ expect(isFilterActive(schema, decodeFilters(schema, p('group=none')))).toBe(false)
80
+ expect(isFilterActive(schema, decodeFilters(schema, p('')))).toBe(false)
81
+ })
82
+
83
+ it('writes set values sorted, so the URL is canonical regardless of order', () => {
84
+ const a = withToggle(schema, withToggle(schema, p(''), 'ns', 'zebra'), 'ns', 'alpha')
85
+ expect(a.get('namespace')).toBe('alpha,zebra')
86
+ })
87
+
88
+ it('decodes an empty string param (?group=) as the default', () => {
89
+ expect(decodeFilters(schema, p('group=')).group).toBe('none')
90
+ expect(decodeFilters(schema, p('q=')).q).toBe('')
91
+ })
92
+
93
+ it('defineFilterSchema rejects duplicate URL params', () => {
94
+ expect(() =>
95
+ defineFilterSchema({ a: { param: 'x', type: 'set' }, b: { param: 'x', type: 'set' } }),
96
+ ).toThrow(/duplicate/)
97
+ })
98
+ })
@@ -0,0 +1,138 @@
1
+ // Pure URL <-> filter-state transforms. No React, no router, no window — just
2
+ // URLSearchParams in, URLSearchParams / typed values out. This is the behavioral
3
+ // contract every list view shares; keeping it pure makes it exhaustively
4
+ // testable and keeps the React hook a thin wrapper.
5
+
6
+ export interface FilterFieldDef {
7
+ /** URL query-param name. Stable — this is a durable, shareable public API. */
8
+ param: string
9
+ /**
10
+ * - 'set' multi-select; comma-list. Empty ⇒ param omitted ⇒ "all" (no
11
+ * narrowing). Values must not contain commas (k8s identifiers and
12
+ * controlled enums never do). Written sorted, so the URL is
13
+ * canonical regardless of selection order. Value: Set<string>.
14
+ * - 'text' free-text search; written with history `replace` (no entry per
15
+ * keystroke). Omitted when empty / equal to `default`. Value: string.
16
+ * - 'single' single choice (enum / dropdown, incl. tri-state all|yes|no);
17
+ * pushes a history entry. Omitted when equal to `default`. Value:
18
+ * string.
19
+ * - 'boolean' two-state flag; `param=1` when true, omitted when false. For
20
+ * three states use 'single' with an enum default. Value: boolean.
21
+ */
22
+ type: 'set' | 'text' | 'single' | 'boolean'
23
+ /** Default for 'text'/'single' (omitted from the URL when the value equals it). */
24
+ default?: string
25
+ }
26
+
27
+ export type FilterSchema = Record<string, FilterFieldDef>
28
+
29
+ type FieldValue<F extends FilterFieldDef> = F['type'] extends 'set'
30
+ ? Set<string>
31
+ : F['type'] extends 'boolean'
32
+ ? boolean
33
+ : string
34
+
35
+ export type FilterValues<S extends FilterSchema> = { [K in keyof S]: FieldValue<S[K]> }
36
+
37
+ /** Keys of a schema whose field is one of the given kinds — for type-safe setters. */
38
+ export type FieldKeysOfType<S extends FilterSchema, T extends FilterFieldDef['type']> = {
39
+ [K in keyof S]: S[K]['type'] extends T ? K : never
40
+ }[keyof S]
41
+
42
+ /**
43
+ * Identity helper that preserves literal types (so `values.x` is precisely typed)
44
+ * and validates the schema — duplicate URL params would silently clobber each
45
+ * other, so we fail loudly at module load.
46
+ */
47
+ export function defineFilterSchema<const S extends FilterSchema>(schema: S): S {
48
+ const seen = new Set<string>()
49
+ for (const key in schema) {
50
+ const { param } = schema[key]
51
+ if (seen.has(param)) throw new Error(`defineFilterSchema: duplicate URL param "${param}"`)
52
+ seen.add(param)
53
+ }
54
+ return schema
55
+ }
56
+
57
+ export function decodeFilters<S extends FilterSchema>(schema: S, params: URLSearchParams): FilterValues<S> {
58
+ const out = {} as FilterValues<S>
59
+ for (const key in schema) {
60
+ const def = schema[key]
61
+ const raw = params.get(def.param)
62
+ if (def.type === 'set') {
63
+ ;(out[key] as Set<string>) = new Set((raw ?? '').split(',').filter(Boolean))
64
+ } else if (def.type === 'boolean') {
65
+ ;(out[key] as boolean) = raw === '1'
66
+ } else {
67
+ // text / single: an empty param (?f=) reads as the default, same as omission.
68
+ ;(out[key] as string) = raw ? raw : (def.default ?? '')
69
+ }
70
+ }
71
+ return out
72
+ }
73
+
74
+ /** Write one field into `params` in place. Read (decode) and write agree exactly. */
75
+ function writeField(params: URLSearchParams, def: FilterFieldDef, value: Set<string> | string | boolean): void {
76
+ if (def.type === 'set') {
77
+ const set = value as Set<string>
78
+ if (set.size === 0) params.delete(def.param)
79
+ else params.set(def.param, [...set].sort().join(','))
80
+ } else if (def.type === 'boolean') {
81
+ if (value) params.set(def.param, '1')
82
+ else params.delete(def.param)
83
+ } else {
84
+ const str = value as string
85
+ if (!str || str === (def.default ?? '')) params.delete(def.param)
86
+ else params.set(def.param, str)
87
+ }
88
+ }
89
+
90
+ /** Return new params with one field set to `value`. */
91
+ export function withField<S extends FilterSchema>(
92
+ schema: S,
93
+ params: URLSearchParams,
94
+ key: keyof S,
95
+ value: Set<string> | string | boolean,
96
+ ): URLSearchParams {
97
+ const next = new URLSearchParams(params)
98
+ writeField(next, schema[key], value)
99
+ return next
100
+ }
101
+
102
+ /** Return new params with one member added to / removed from a 'set' field. */
103
+ export function withToggle<S extends FilterSchema>(
104
+ schema: S,
105
+ params: URLSearchParams,
106
+ key: keyof S,
107
+ value: string,
108
+ ): URLSearchParams {
109
+ const next = new URLSearchParams(params)
110
+ const cur = new Set((next.get(schema[key].param) ?? '').split(',').filter(Boolean))
111
+ if (cur.has(value)) cur.delete(value)
112
+ else cur.add(value)
113
+ writeField(next, schema[key], cur)
114
+ return next
115
+ }
116
+
117
+ /** Default (empty) value for a field, used to reset it. */
118
+ export function emptyValue(def: FilterFieldDef): Set<string> | string | boolean {
119
+ return def.type === 'set' ? new Set<string>() : def.type === 'boolean' ? false : (def.default ?? '')
120
+ }
121
+
122
+ /** Return new params with the given schema fields cleared (all of them if omitted). */
123
+ export function withCleared<S extends FilterSchema>(schema: S, params: URLSearchParams, keys?: (keyof S)[]): URLSearchParams {
124
+ const next = new URLSearchParams(params)
125
+ for (const key of keys ?? (Object.keys(schema) as (keyof S)[])) next.delete(schema[key].param)
126
+ return next
127
+ }
128
+
129
+ export function isFilterActive<S extends FilterSchema>(schema: S, values: FilterValues<S>): boolean {
130
+ for (const key in schema) {
131
+ const def = schema[key]
132
+ const v = values[key]
133
+ if (def.type === 'set' && (v as Set<string>).size > 0) return true
134
+ if (def.type === 'boolean' && (v as boolean)) return true
135
+ if ((def.type === 'text' || def.type === 'single') && (v as string) !== (def.default ?? '')) return true
136
+ }
137
+ return false
138
+ }
@@ -0,0 +1,127 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import {
3
+ decodeFilters,
4
+ isFilterActive,
5
+ withCleared,
6
+ withField,
7
+ withToggle,
8
+ emptyValue,
9
+ type FieldKeysOfType,
10
+ type FilterSchema,
11
+ type FilterValues,
12
+ } from './filter-state-core'
13
+
14
+ // Shared filter-state contract for Radar's list views (OSS + Hub).
15
+ //
16
+ // The consolidation center is BEHAVIOR, not components: one place owns how a
17
+ // view's filters serialize to the URL, what "cleared" means, and how defaults
18
+ // encode — so every list view is shareable/bookmarkable and behaves the same,
19
+ // whether it renders as a facet sidebar or a compact bar.
20
+ //
21
+ // URL is the single source of truth (no localStorage of filter values — that
22
+ // goes stale across clusters/orgs). Router-agnostic + app-agnostic: this never
23
+ // imports react-router or reads window; the host injects a FilterLocation
24
+ // adapter over its own router. All transforms live in ./filter-state-core (pure,
25
+ // exhaustively tested); this file is only the React glue.
26
+
27
+ /** A reactive view of the URL query string, injected by the host app. */
28
+ export interface FilterLocation {
29
+ /** Current query params — reactive (sourced from the app's router hook). */
30
+ searchParams: URLSearchParams
31
+ /**
32
+ * Apply an update. The updater receives the LATEST params (not a captured
33
+ * snapshot), so rapid successive toggles compose instead of clobbering.
34
+ * `replace` avoids a history entry per keystroke (used for text fields).
35
+ */
36
+ update: (updater: (prev: URLSearchParams) => URLSearchParams, opts?: { replace?: boolean }) => void
37
+ }
38
+
39
+ const FilterLocationContext = createContext<FilterLocation | null>(null)
40
+
41
+ /**
42
+ * Wrap the app subtree once with the host's router adapter, e.g.
43
+ * const [searchParams, setSearchParams] = useSearchParams()
44
+ * <FilterLocationProvider value={{ searchParams, update: setSearchParams }}>
45
+ */
46
+ export function FilterLocationProvider({ value, children }: { value: FilterLocation; children: ReactNode }) {
47
+ return <FilterLocationContext.Provider value={value}>{children}</FilterLocationContext.Provider>
48
+ }
49
+
50
+ export function useFilterLocation(): FilterLocation {
51
+ const ctx = useContext(FilterLocationContext)
52
+ if (!ctx) throw new Error('useFilterState requires a <FilterLocationProvider> ancestor')
53
+ return ctx
54
+ }
55
+
56
+ export interface FilterState<S extends FilterSchema> {
57
+ values: FilterValues<S>
58
+ /** Any field diverges from its default (⇒ show a "Clear all" affordance). */
59
+ isActive: boolean
60
+ /** Add/remove one value in a 'set' field. */
61
+ toggle: (key: FieldKeysOfType<S, 'set'>, value: string) => void
62
+ /** Replace a 'set' field wholesale. */
63
+ setSet: (key: FieldKeysOfType<S, 'set'>, values: string[]) => void
64
+ /** Set a 'text' or 'single' field. 'text' replaces history; 'single' pushes it. */
65
+ setString: (key: FieldKeysOfType<S, 'text' | 'single'>, value: string) => void
66
+ setBoolean: (key: FieldKeysOfType<S, 'boolean'>, value: boolean) => void
67
+ /** Reset one field to its default. */
68
+ clear: (key: keyof S) => void
69
+ /** Reset every field in the schema (leaves unrelated params untouched). */
70
+ clearAll: () => void
71
+ }
72
+
73
+ export function useFilterState<S extends FilterSchema>(schema: S): FilterState<S> {
74
+ const ctx = useContext(FilterLocationContext)
75
+
76
+ // Graceful fallback: without a provider, keep filter state locally (still
77
+ // works, just not URL-synced). This lets a shared view adopt the contract
78
+ // before every host has wired a FilterLocationBridge — a host that hasn't
79
+ // (e.g. Radar Hub, until it picks up a published build) keeps its prior local
80
+ // behavior instead of crashing. A one-time warning flags the missing wiring.
81
+ const [localParams, setLocalParams] = useState(() => new URLSearchParams())
82
+ const warned = useRef(false)
83
+ useEffect(() => {
84
+ if (!ctx && !warned.current) {
85
+ warned.current = true
86
+ console.warn('[k8s-ui] useFilterState: no <FilterLocationProvider> ancestor — filters are local, not URL-synced.')
87
+ }
88
+ }, [ctx])
89
+ const fallback = useMemo<FilterLocation>(
90
+ () => ({ searchParams: localParams, update: (u) => setLocalParams((prev) => u(new URLSearchParams(prev))) }),
91
+ [localParams],
92
+ )
93
+ const { searchParams, update } = ctx ?? fallback
94
+
95
+ // Depend on the serialized string, not the URLSearchParams object identity, so
96
+ // a router adapter that returns a mutated/unstable instance can't stale the memo.
97
+ const paramsKey = searchParams.toString()
98
+ const values = useMemo(() => decodeFilters(schema, new URLSearchParams(paramsKey)), [schema, paramsKey])
99
+ const isActive = useMemo(() => isFilterActive(schema, values), [schema, values])
100
+
101
+ const toggle = useCallback(
102
+ (key: FieldKeysOfType<S, 'set'>, value: string) => update((prev) => withToggle(schema, prev, key, value)),
103
+ [schema, update],
104
+ )
105
+ const setSet = useCallback(
106
+ (key: FieldKeysOfType<S, 'set'>, vals: string[]) => update((prev) => withField(schema, prev, key, new Set(vals))),
107
+ [schema, update],
108
+ )
109
+ const setString = useCallback(
110
+ (key: FieldKeysOfType<S, 'text' | 'single'>, v: string) =>
111
+ // Typing into a search box shouldn't spam history; picking an enum should be
112
+ // a real back-step. 'text' replaces, 'single' pushes.
113
+ update((prev) => withField(schema, prev, key, v), { replace: schema[key].type === 'text' }),
114
+ [schema, update],
115
+ )
116
+ const setBoolean = useCallback(
117
+ (key: FieldKeysOfType<S, 'boolean'>, v: boolean) => update((prev) => withField(schema, prev, key, v)),
118
+ [schema, update],
119
+ )
120
+ const clear = useCallback(
121
+ (key: keyof S) => update((prev) => withField(schema, prev, key, emptyValue(schema[key]))),
122
+ [schema, update],
123
+ )
124
+ const clearAll = useCallback(() => update((prev) => withCleared(schema, prev)), [schema, update])
125
+
126
+ return { values, isActive, toggle, setSet, setString, setBoolean, clear, clearAll }
127
+ }
@@ -0,0 +1,16 @@
1
+ export {
2
+ FilterLocationProvider,
3
+ useFilterLocation,
4
+ useFilterState,
5
+ } from './filter-state'
6
+ export type { FilterLocation, FilterState } from './filter-state'
7
+ export {
8
+ defineFilterSchema,
9
+ decodeFilters,
10
+ withField,
11
+ withToggle,
12
+ withCleared,
13
+ emptyValue,
14
+ isFilterActive,
15
+ } from './filter-state-core'
16
+ export type { FilterFieldDef, FilterSchema, FilterValues, FieldKeysOfType } from './filter-state-core'
package/src/index.ts CHANGED
@@ -61,6 +61,10 @@ export * from './components/namespace-switcher'
61
61
  // segments into one unit (OSS header + Radar Hub cluster top bar)
62
62
  export * from './components/scope-pill'
63
63
 
64
+ // Filter-state contract — shared URL-synced filter state for list views (OSS +
65
+ // Hub), router-agnostic via an injected FilterLocation adapter
66
+ export * from './filter-state'
67
+
64
68
  // Applications (shared host-agnostic list + detail shell for the deployable-
65
69
  // software surface; OSS renders single-cluster, Cloud adds the fleet layer)
66
70
  export * from './components/applications'
@@ -54,6 +54,10 @@
54
54
  --border-default: light-dark(#D8E0EE, #222840);
55
55
  --border-light: light-dark(#CDD6E8, #2C324A);
56
56
  --border-subtle: light-dark(rgba(74, 100, 180, 0.07), rgba(255, 255, 255, 0.04));
57
+ /* Emphasis edge for the focused/expanded card in a queue — must read clearly
58
+ against sibling cards' --border-default, unlike --border-light's one-notch
59
+ step. */
60
+ --border-emphasis: light-dark(#96A8CA, rgba(255, 255, 255, 0.22));
57
61
  --border-focus: var(--color-brand);
58
62
 
59
63
  /* ── Accent ── */
@@ -108,6 +112,16 @@
108
112
  --group-icon-app: light-dark(#059669, #34d399);
109
113
  --group-icon-label: light-dark(#d97706, #fbbf24);
110
114
 
115
+ /* ── Terminal / console surface (intentionally dark in BOTH themes — it's a
116
+ console, not chrome). The outer border darkens on a light page and lightens
117
+ on a dark one so the block stays defined; the inner divider and text stay
118
+ light because the surface is always dark. ── */
119
+ --terminal-bg: #0F172A;
120
+ --terminal-border: light-dark(#1E293B, rgba(255, 255, 255, 0.10));
121
+ --terminal-divider: rgba(255, 255, 255, 0.10);
122
+ --terminal-label: #94A3B8;
123
+ --terminal-text: #CBD5E1;
124
+
111
125
  /* ── ANSI terminal colors (theme-aware for log viewer) ── */
112
126
  --ansi-black: light-dark(#3b3b3b, #4c4c4c);
113
127
  --ansi-red: light-dark(#cd3131, #cd3131);
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { categorizeResources, formatGroupName, shortenGroupName } from './api-resources'
3
+
4
+ describe('formatGroupName', () => {
5
+ it('uses friendly names for common CRD groups seen in clusters', () => {
6
+ expect(formatGroupName('policies.kyverno.io')).toBe('Kyverno')
7
+ expect(formatGroupName('networking.gke.io')).toBe('GKE Networking')
8
+ expect(formatGroupName('apiregistration.k8s.io')).toBe('API Registration')
9
+ expect(formatGroupName('monitoring.googleapis.com')).toBe('Google Cloud Monitoring')
10
+ expect(formatGroupName('sql.cnrm.cloud.google.com')).toBe('Config Connector')
11
+ expect(formatGroupName('crd.k8s.amazonaws.com')).toBe('AWS VPC CNI')
12
+ expect(formatGroupName('acid.zalan.do')).toBe('Zalando Postgres')
13
+ })
14
+
15
+ it('formats unmapped API groups without exposing raw domain strings', () => {
16
+ expect(formatGroupName('widgets.example.io')).toBe('Example Widgets')
17
+ expect(formatGroupName('api.my-company.dev')).toBe('My Company API')
18
+ expect(formatGroupName('dns.gke.example.io')).toBe('Example DNS GKE')
19
+ expect(formatGroupName('widgets.example.io')).not.toMatch(/\./)
20
+ })
21
+
22
+ it('does not promote Kubernetes domain-family suffixes into visible labels', () => {
23
+ expect(formatGroupName('flowcontrol.apiserver.k8s.io')).toBe('Apiserver Flowcontrol')
24
+ expect(formatGroupName('addons.cluster.x-k8s.io')).toBe('Cluster API')
25
+ expect(formatGroupName('ipam.cluster.x-k8s.io')).toBe('Cluster API')
26
+ expect(formatGroupName('nfd.k8s-sigs.io')).toBe('NFD')
27
+ })
28
+
29
+ it('keeps unmapped CRD groups separate from core categories', () => {
30
+ expect(formatGroupName('networking.io')).toBe('Networking APIs')
31
+ expect(formatGroupName('storage.io')).toBe('Storage APIs')
32
+ })
33
+
34
+ it('keeps shortenGroupName as the legacy suffix-stripping helper', () => {
35
+ expect(shortenGroupName('networking.gke.io')).toBe('networking.gke')
36
+ expect(shortenGroupName('rbac.authorization.k8s.io')).toBe('rbac.authorization')
37
+ expect(shortenGroupName('widgets.example.dev')).toBe('widgets.example')
38
+ })
39
+ })
40
+
41
+ describe('categorizeResources', () => {
42
+ it('does not merge unmapped CRDs into matching core categories', () => {
43
+ const categories = categorizeResources([
44
+ { group: 'networking.io', version: 'v1', kind: 'WidgetRoute', name: 'widgetroutes', namespaced: true, isCrd: true, verbs: ['list'] },
45
+ ])
46
+
47
+ const networking = categories.find(c => c.name === 'Networking')
48
+ const networkingAPIs = categories.find(c => c.name === 'Networking APIs')
49
+
50
+ expect(networking?.resources.some(r => r.kind === 'Service')).toBe(true)
51
+ expect(networking?.resources.some(r => r.kind === 'WidgetRoute')).toBe(false)
52
+ expect(networkingAPIs?.resources.map(r => r.kind)).toEqual(['WidgetRoute'])
53
+ })
54
+ })
@@ -13,6 +13,7 @@ const CONFIG_KINDS = ['ConfigMap', 'Secret', 'HorizontalPodAutoscaler', 'PodDisr
13
13
  const STORAGE_KINDS = ['PersistentVolumeClaim', 'PersistentVolume', 'StorageClass', 'VolumeAttachment']
14
14
  const ACCESS_CONTROL_KINDS = ['ServiceAccount', 'Role', 'ClusterRole', 'RoleBinding', 'ClusterRoleBinding']
15
15
  const CLUSTER_KINDS = ['Node', 'Namespace', 'Event']
16
+ const CORE_CATEGORY_NAMES = new Set(['Workloads', 'Networking', 'Configuration', 'Storage', 'Access Control', 'Cluster'])
16
17
 
17
18
  // Core resources that must always be present (fallback if API discovery misses them)
18
19
  export const CORE_RESOURCES: APIResource[] = [
@@ -112,6 +113,7 @@ export function categorizeResources(resources: APIResource[]): ResourceCategory[
112
113
  export function formatGroupName(group: string): string {
113
114
  const knownGroups: Record<string, string> = {
114
115
  'argoproj.io': 'Argo',
116
+ 'apiregistration.k8s.io': 'API Registration',
115
117
  'cert-manager.io': 'Cert Manager',
116
118
  'acme.cert-manager.io': 'Cert Manager',
117
119
  'istio.io': 'Istio',
@@ -119,10 +121,12 @@ export function formatGroupName(group: string): string {
119
121
  'security.istio.io': 'Istio',
120
122
  'telemetry.istio.io': 'Istio',
121
123
  'monitoring.coreos.com': 'Prometheus',
124
+ 'monitoring.googleapis.com': 'Google Cloud Monitoring',
122
125
  'velero.io': 'Velero',
123
126
  'external-secrets.io': 'External Secrets',
124
127
  'keda.sh': 'KEDA',
125
128
  'gateway.networking.k8s.io': 'Gateway API',
129
+ 'gateway.envoyproxy.io': 'Envoy Gateway',
126
130
  'traefik.io': 'Traefik',
127
131
  'traefik.containo.us': 'Traefik',
128
132
  'crossplane.io': 'Crossplane',
@@ -166,7 +170,11 @@ export function formatGroupName(group: string): string {
166
170
  'infrastructure.cluster.x-k8s.io': 'Cluster API',
167
171
  'ceph.rook.io': 'Rook',
168
172
  'kyverno.io': 'Kyverno',
173
+ 'policies.kyverno.io': 'Kyverno',
169
174
  'k8s.nginx.org': 'NGINX',
175
+ 'networking.gke.io': 'GKE Networking',
176
+ 'warden.gke.io': 'GKE Warden',
177
+ 'cloud.google.com': 'Google Cloud',
170
178
  'sparkoperator.k8s.io': 'Spark',
171
179
  'kubeflow.org': 'Kubeflow',
172
180
  'snapshot.storage.k8s.io': 'Snapshots',
@@ -177,6 +185,12 @@ export function formatGroupName(group: string): string {
177
185
  'resource.k8s.io': 'Dynamic Resource Allocation',
178
186
  'kueue.x-k8s.io': 'Kueue',
179
187
  'autoscaling.x-k8s.io': 'Cluster Autoscaler',
188
+ 'crd.k8s.amazonaws.com': 'AWS VPC CNI',
189
+ 'vpcresources.k8s.aws': 'AWS VPC CNI',
190
+ 'elbv2.k8s.aws': 'AWS Load Balancer',
191
+ 'eks.amazonaws.com': 'EKS',
192
+ 'networking.k8s.aws': 'AWS Networking',
193
+ 'acid.zalan.do': 'Zalando Postgres',
180
194
  'serving.kserve.io': 'KServe',
181
195
  'ray.io': 'KubeRay',
182
196
  'leaderworkerset.x-k8s.io': 'LeaderWorkerSet',
@@ -197,7 +211,10 @@ export function formatGroupName(group: string): string {
197
211
  // their own per-service groups like s3.aws.upbound.io, compute.gcp.upbound.io).
198
212
  if (group.endsWith('.upbound.io')) return 'Crossplane'
199
213
  if (group.endsWith('.crossplane.io')) return 'Crossplane'
200
- return group
214
+ if (group === 'cluster.x-k8s.io' || group.endsWith('.cluster.x-k8s.io')) return 'Cluster API'
215
+ if (group.endsWith('.cnrm.cloud.google.com')) return 'Config Connector'
216
+ if (group.endsWith('.openshift.io')) return 'OpenShift'
217
+ return formatUnmappedGroupName(group)
201
218
  }
202
219
 
203
220
  export function shortenGroupName(group: string): string {
@@ -206,6 +223,56 @@ export function shortenGroupName(group: string): string {
206
223
  .replace(/\.k8s$/, '')
207
224
  }
208
225
 
226
+ const GROUP_SUFFIX_LABELS = new Set([
227
+ 'io', 'com', 'org', 'dev', 'sh', 'net', 'do', 'ai', 'cloud', 'co', 'app',
228
+ 'k8s', 'x-k8s', 'k8s-sigs', 'sigs',
229
+ ])
230
+ const GROUP_ACRONYMS: Record<string, string> = {
231
+ api: 'API',
232
+ aws: 'AWS',
233
+ amazonaws: 'AWS',
234
+ cnrm: 'CNRM',
235
+ crd: 'CRD',
236
+ csi: 'CSI',
237
+ dns: 'DNS',
238
+ gcp: 'GCP',
239
+ gke: 'GKE',
240
+ gpu: 'GPU',
241
+ hpa: 'HPA',
242
+ ipam: 'IPAM',
243
+ k8s: 'K8s',
244
+ nfd: 'NFD',
245
+ tcp: 'TCP',
246
+ tls: 'TLS',
247
+ udp: 'UDP',
248
+ }
249
+
250
+ function formatUnmappedGroupName(group: string): string {
251
+ const labels = group
252
+ .split('.')
253
+ .map(label => label.trim().toLowerCase())
254
+ .filter(label => label && !GROUP_SUFFIX_LABELS.has(label))
255
+
256
+ if (labels.length === 0) return group
257
+ if (labels.length === 1) return avoidCoreCategoryCollision(formatGroupToken(labels[0]))
258
+
259
+ const owner = labels[labels.length - 1]
260
+ const descriptors = labels.slice(0, -1)
261
+ return avoidCoreCategoryCollision([owner, ...descriptors].map(formatGroupToken).join(' '))
262
+ }
263
+
264
+ function avoidCoreCategoryCollision(label: string): string {
265
+ return CORE_CATEGORY_NAMES.has(label) ? `${label} APIs` : label
266
+ }
267
+
268
+ function formatGroupToken(token: string): string {
269
+ return token
270
+ .split(/[-_]/)
271
+ .filter(Boolean)
272
+ .map(part => GROUP_ACRONYMS[part] ?? part.charAt(0).toUpperCase() + part.slice(1))
273
+ .join(' ')
274
+ }
275
+
209
276
  function sortResources(resources: APIResource[]): APIResource[] {
210
277
  return [...resources].sort((a, b) => a.kind.localeCompare(b.kind))
211
278
  }
package/tsconfig.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "compilerOptions": {
3
- "target": "ES2020",
4
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
5
  "module": "ESNext",
6
6
  "moduleResolution": "bundler",
7
7
  "allowImportingTsExtensions": true,