@skyhook-io/k8s-ui 1.6.0 → 1.6.2

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 (79) hide show
  1. package/package.json +7 -2
  2. package/src/components/charts/AreaChart.tsx +371 -0
  3. package/src/components/charts/MetricsSummary.tsx +49 -0
  4. package/src/components/charts/SeriesLegend.tsx +27 -0
  5. package/src/components/charts/colors.ts +49 -0
  6. package/src/components/charts/format.ts +41 -0
  7. package/src/components/charts/index.ts +14 -0
  8. package/src/components/charts/saturation.test.ts +60 -0
  9. package/src/components/charts/saturation.ts +32 -0
  10. package/src/components/charts/types.ts +36 -0
  11. package/src/components/compare/CompareResourcePicker.tsx +192 -0
  12. package/src/components/compare/CompareTray.tsx +130 -0
  13. package/src/components/compare/ResourceCompareView.tsx +272 -0
  14. package/src/components/compare/index.ts +14 -0
  15. package/src/components/compare/normalize.test.ts +193 -0
  16. package/src/components/compare/normalize.ts +69 -0
  17. package/src/components/compare/picks.test.ts +97 -0
  18. package/src/components/compare/picks.ts +35 -0
  19. package/src/components/compare/sort.test.ts +78 -0
  20. package/src/components/compare/sort.ts +26 -0
  21. package/src/components/compare/types.ts +43 -0
  22. package/src/components/compare/url.test.ts +61 -0
  23. package/src/components/compare/url.ts +26 -0
  24. package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
  25. package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
  26. package/src/components/gitops/GitOpsTableView.tsx +1441 -0
  27. package/src/components/gitops/RollbackDialog.tsx +117 -0
  28. package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
  29. package/src/components/gitops/detail-helpers.test.ts +97 -0
  30. package/src/components/gitops/detail-helpers.ts +112 -0
  31. package/src/components/gitops/index.ts +52 -1
  32. package/src/components/gitops/short-cluster-name.test.ts +36 -0
  33. package/src/components/gitops/tree/index.ts +2 -0
  34. package/src/components/gitops/tree/merge.test.ts +240 -0
  35. package/src/components/gitops/tree/merge.ts +160 -0
  36. package/src/components/resources/ResourcesSidebar.tsx +42 -15
  37. package/src/components/resources/ResourcesView.tsx +403 -69
  38. package/src/components/resources/index.ts +1 -0
  39. package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
  40. package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
  41. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
  42. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
  43. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  44. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
  45. package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
  46. package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
  47. package/src/components/resources/renderers/PodRenderer.tsx +207 -2
  48. package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
  49. package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
  50. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
  51. package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
  52. package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
  53. package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
  54. package/src/components/resources/renderers/flux-cells.tsx +12 -0
  55. package/src/components/resources/renderers/index.ts +8 -0
  56. package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
  57. package/src/components/resources/resource-utils-crossplane.ts +325 -0
  58. package/src/components/resources/resource-utils-flux.ts +14 -0
  59. package/src/components/resources/resource-utils.ts +1 -0
  60. package/src/components/resources/resources-column-filter.test.ts +74 -0
  61. package/src/components/shared/ResourceActionsBar.tsx +77 -0
  62. package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
  63. package/src/components/ui/YamlEditor.tsx +28 -15
  64. package/src/components/ui/drawer-components.tsx +8 -20
  65. package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
  66. package/src/components/workload/WorkloadView.tsx +2 -2
  67. package/src/index.ts +3 -0
  68. package/src/types/core.ts +31 -6
  69. package/src/types/index.ts +1 -0
  70. package/src/types/rbac.ts +99 -0
  71. package/src/utils/api-resources.ts +9 -1
  72. package/src/utils/badge-colors.ts +25 -0
  73. package/src/utils/gitops-owner.test.ts +70 -111
  74. package/src/utils/gitops-owner.ts +37 -74
  75. package/src/utils/helm-status.test.ts +50 -0
  76. package/src/utils/index.ts +1 -0
  77. package/src/utils/rbac-badges.ts +61 -0
  78. package/src/utils/rbac-blast-radius.test.ts +137 -0
  79. package/src/utils/rbac-blast-radius.ts +98 -0
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { normalizeForCompare, toComparableYaml } from './normalize'
3
+
4
+ describe('normalizeForCompare', () => {
5
+ it('strips server-assigned metadata fields', () => {
6
+ const input = {
7
+ apiVersion: 'apps/v1',
8
+ kind: 'Deployment',
9
+ metadata: {
10
+ name: 'api',
11
+ namespace: 'prod',
12
+ resourceVersion: '12345',
13
+ uid: 'abc-def',
14
+ generation: 3,
15
+ creationTimestamp: '2024-01-01T00:00:00Z',
16
+ selfLink: '/apis/apps/v1/...',
17
+ managedFields: [{ manager: 'kubectl' }],
18
+ },
19
+ spec: { replicas: 3 },
20
+ }
21
+ const out = normalizeForCompare(input) as any
22
+ expect(out.metadata.name).toBe('api')
23
+ expect(out.metadata.namespace).toBe('prod')
24
+ expect(out.metadata.resourceVersion).toBeUndefined()
25
+ expect(out.metadata.uid).toBeUndefined()
26
+ expect(out.metadata.generation).toBeUndefined()
27
+ expect(out.metadata.creationTimestamp).toBeUndefined()
28
+ expect(out.metadata.selfLink).toBeUndefined()
29
+ expect(out.metadata.managedFields).toBeUndefined()
30
+ })
31
+
32
+ it('drops kubectl last-applied annotation', () => {
33
+ const input = {
34
+ metadata: {
35
+ name: 'api',
36
+ annotations: {
37
+ 'kubectl.kubernetes.io/last-applied-configuration': '{"large":"blob"}',
38
+ 'deployment.kubernetes.io/revision': '5',
39
+ 'custom-annotation': 'keep-me',
40
+ },
41
+ },
42
+ }
43
+ const out = normalizeForCompare(input) as any
44
+ expect(out.metadata.annotations['custom-annotation']).toBe('keep-me')
45
+ expect(out.metadata.annotations['kubectl.kubernetes.io/last-applied-configuration']).toBeUndefined()
46
+ expect(out.metadata.annotations['deployment.kubernetes.io/revision']).toBeUndefined()
47
+ })
48
+
49
+ it('removes annotations object entirely when all keys are stripped', () => {
50
+ const input = {
51
+ metadata: {
52
+ name: 'api',
53
+ annotations: {
54
+ 'kubectl.kubernetes.io/last-applied-configuration': '{}',
55
+ },
56
+ },
57
+ }
58
+ const out = normalizeForCompare(input) as any
59
+ expect(out.metadata.annotations).toBeUndefined()
60
+ })
61
+
62
+ it('strips pod-template-hash label noise', () => {
63
+ const input = {
64
+ metadata: {
65
+ name: 'api',
66
+ labels: {
67
+ app: 'api',
68
+ 'pod-template-hash': 'abc123',
69
+ 'controller-revision-hash': 'xyz789',
70
+ },
71
+ },
72
+ }
73
+ const out = normalizeForCompare(input) as any
74
+ expect(out.metadata.labels.app).toBe('api')
75
+ expect(out.metadata.labels['pod-template-hash']).toBeUndefined()
76
+ expect(out.metadata.labels['controller-revision-hash']).toBeUndefined()
77
+ })
78
+
79
+ it('drops status when specOnly is true', () => {
80
+ const input = {
81
+ spec: { replicas: 3 },
82
+ status: { readyReplicas: 3, conditions: [{ type: 'Available' }] },
83
+ }
84
+ const out = normalizeForCompare(input, { specOnly: true }) as any
85
+ expect(out.spec.replicas).toBe(3)
86
+ expect(out.status).toBeUndefined()
87
+ })
88
+
89
+ it('keeps status when specOnly is false', () => {
90
+ const input = {
91
+ spec: { replicas: 3 },
92
+ status: { readyReplicas: 3 },
93
+ }
94
+ const out = normalizeForCompare(input) as any
95
+ expect(out.status.readyReplicas).toBe(3)
96
+ })
97
+
98
+ it('keeps server-assigned metadata when rawMetadata is true', () => {
99
+ const input = {
100
+ metadata: {
101
+ name: 'api',
102
+ resourceVersion: '12345',
103
+ uid: 'abc-def',
104
+ managedFields: [{ manager: 'kubectl' }],
105
+ annotations: { 'kubectl.kubernetes.io/last-applied-configuration': '{}' },
106
+ labels: { 'pod-template-hash': 'abc123', app: 'api' },
107
+ },
108
+ }
109
+ const out = normalizeForCompare(input, { rawMetadata: true }) as any
110
+ expect(out.metadata.resourceVersion).toBe('12345')
111
+ expect(out.metadata.uid).toBe('abc-def')
112
+ expect(out.metadata.managedFields).toEqual([{ manager: 'kubectl' }])
113
+ expect(out.metadata.annotations['kubectl.kubernetes.io/last-applied-configuration']).toBe('{}')
114
+ expect(out.metadata.labels['pod-template-hash']).toBe('abc123')
115
+ })
116
+
117
+ it('still drops status when rawMetadata is true and specOnly is true', () => {
118
+ const input = { metadata: { name: 'api', uid: 'x' }, status: { phase: 'Running' } }
119
+ const out = normalizeForCompare(input, { rawMetadata: true, specOnly: true }) as any
120
+ expect(out.metadata.uid).toBe('x')
121
+ expect(out.status).toBeUndefined()
122
+ })
123
+
124
+ it('does not mutate the input', () => {
125
+ const input = {
126
+ metadata: { name: 'api', resourceVersion: '1', managedFields: [{ manager: 'kubectl' }] },
127
+ }
128
+ normalizeForCompare(input)
129
+ expect((input as any).metadata.resourceVersion).toBe('1')
130
+ expect((input as any).metadata.managedFields).toEqual([{ manager: 'kubectl' }])
131
+ })
132
+
133
+ it('returns input unchanged when not an object', () => {
134
+ expect(normalizeForCompare(null)).toBe(null)
135
+ expect(normalizeForCompare(undefined)).toBe(undefined)
136
+ expect(normalizeForCompare('string')).toBe('string')
137
+ })
138
+
139
+ it('handles missing metadata gracefully', () => {
140
+ const out = normalizeForCompare({ spec: { replicas: 1 } }) as any
141
+ expect(out.spec.replicas).toBe(1)
142
+ expect(out.metadata).toBeUndefined()
143
+ })
144
+
145
+ it('handles non-object metadata gracefully (defensive)', () => {
146
+ expect(() => normalizeForCompare({ metadata: 'oops', spec: {} })).not.toThrow()
147
+ })
148
+
149
+ it('handles null annotations object', () => {
150
+ const out = normalizeForCompare({ metadata: { name: 'x', annotations: null } }) as any
151
+ expect(out.metadata.name).toBe('x')
152
+ expect(out.metadata.annotations).toBeNull()
153
+ })
154
+
155
+ // Recursing into spec.template.metadata risks stripping user-defined selector
156
+ // labels that are themselves the diff signal — leave the template alone.
157
+ it('does NOT strip pod-template-hash inside spec.template.metadata.labels', () => {
158
+ const out = normalizeForCompare({
159
+ spec: {
160
+ template: {
161
+ metadata: {
162
+ labels: { app: 'api', 'pod-template-hash': 'abc' },
163
+ },
164
+ },
165
+ },
166
+ }) as any
167
+ expect(out.spec.template.metadata.labels['pod-template-hash']).toBe('abc')
168
+ expect(out.spec.template.metadata.labels.app).toBe('api')
169
+ })
170
+ })
171
+
172
+ describe('toComparableYaml', () => {
173
+ it('produces deterministic YAML output', () => {
174
+ const input = {
175
+ apiVersion: 'apps/v1',
176
+ kind: 'Deployment',
177
+ metadata: { name: 'api', namespace: 'prod', resourceVersion: '123' },
178
+ spec: { replicas: 3 },
179
+ }
180
+ const yaml = toComparableYaml(input)
181
+ expect(yaml).toContain('apiVersion: apps/v1')
182
+ expect(yaml).toContain('name: api')
183
+ expect(yaml).toContain('replicas: 3')
184
+ // Stripped fields should be absent
185
+ expect(yaml).not.toContain('resourceVersion')
186
+ })
187
+
188
+ it('identical inputs produce identical YAML', () => {
189
+ const a = { metadata: { name: 'x' }, spec: { replicas: 2 } }
190
+ const b = { metadata: { name: 'x' }, spec: { replicas: 2 } }
191
+ expect(toComparableYaml(a)).toBe(toComparableYaml(b))
192
+ })
193
+ })
@@ -0,0 +1,69 @@
1
+ import { stringify } from 'yaml'
2
+
3
+ const NOISY_METADATA_KEYS = new Set([
4
+ 'managedFields',
5
+ 'resourceVersion',
6
+ 'uid',
7
+ 'generation',
8
+ 'creationTimestamp',
9
+ 'selfLink',
10
+ 'deletionGracePeriodSeconds',
11
+ ])
12
+
13
+ const NOISY_ANNOTATIONS = new Set([
14
+ 'kubectl.kubernetes.io/last-applied-configuration',
15
+ 'deployment.kubernetes.io/revision',
16
+ 'control-plane.alpha.kubernetes.io/leader',
17
+ 'autoscaling.alpha.kubernetes.io/conditions',
18
+ 'autoscaling.alpha.kubernetes.io/current-metrics',
19
+ ])
20
+
21
+ export interface NormalizeOptions {
22
+ /** When true, drop status from both sides — focuses the diff on intent. */
23
+ specOnly?: boolean
24
+ /** When true, keep server-assigned metadata noise (resourceVersion, uid, managedFields, etc.).
25
+ * Off by default — the diff is signal not noise. Flip on when debugging API-level oddities. */
26
+ rawMetadata?: boolean
27
+ }
28
+
29
+ /**
30
+ * Strip server-assigned fields that always differ between two resources but
31
+ * don't represent meaningful configuration differences.
32
+ */
33
+ export function normalizeForCompare(input: unknown, opts: NormalizeOptions = {}): unknown {
34
+ if (!input || typeof input !== 'object') return input
35
+
36
+ const obj = JSON.parse(JSON.stringify(input)) as Record<string, any>
37
+
38
+ if (!opts.rawMetadata && obj.metadata && typeof obj.metadata === 'object') {
39
+ for (const key of NOISY_METADATA_KEYS) {
40
+ delete obj.metadata[key]
41
+ }
42
+ if (obj.metadata.annotations && typeof obj.metadata.annotations === 'object') {
43
+ for (const key of NOISY_ANNOTATIONS) {
44
+ delete obj.metadata.annotations[key]
45
+ }
46
+ if (Object.keys(obj.metadata.annotations).length === 0) {
47
+ delete obj.metadata.annotations
48
+ }
49
+ }
50
+ if (obj.metadata.labels && typeof obj.metadata.labels === 'object') {
51
+ delete obj.metadata.labels['pod-template-hash']
52
+ delete obj.metadata.labels['controller-revision-hash']
53
+ if (Object.keys(obj.metadata.labels).length === 0) {
54
+ delete obj.metadata.labels
55
+ }
56
+ }
57
+ }
58
+
59
+ if (opts.specOnly) {
60
+ delete obj.status
61
+ }
62
+
63
+ return obj
64
+ }
65
+
66
+ export function toComparableYaml(input: unknown, opts: NormalizeOptions = {}): string {
67
+ const normalized = normalizeForCompare(input, opts)
68
+ return stringify(normalized, { sortMapEntries: false, lineWidth: 0 })
69
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { togglePick, pickIndex, COMPARE_PICK_CAP } from './picks'
3
+ import type { NamespacedRef } from './types'
4
+
5
+ const ref = (namespace: string, name: string): NamespacedRef => ({ namespace, name })
6
+
7
+ describe('togglePick', () => {
8
+ it('adds first pick', () => {
9
+ expect(togglePick([], ref('prod', 'api'))).toEqual([ref('prod', 'api')])
10
+ })
11
+
12
+ it('adds second pick after first (A,B order preserved)', () => {
13
+ const after = togglePick([ref('prod', 'api')], ref('staging', 'api'))
14
+ expect(after).toEqual([ref('prod', 'api'), ref('staging', 'api')])
15
+ })
16
+
17
+ it('removes existing pick (deselect)', () => {
18
+ const start: NamespacedRef[] = [ref('prod', 'api'), ref('staging', 'api')]
19
+ expect(togglePick(start, ref('prod', 'api'))).toEqual([ref('staging', 'api')])
20
+ })
21
+
22
+ it('replaces oldest when at cap — clicking a third row keeps the click visible', () => {
23
+ const start: NamespacedRef[] = [ref('prod', 'api'), ref('staging', 'api')]
24
+ expect(togglePick(start, ref('dev', 'api'))).toEqual([ref('staging', 'api'), ref('dev', 'api')])
25
+ })
26
+
27
+ it('treats cluster-scoped (ns="") with same name as same pick', () => {
28
+ const start: NamespacedRef[] = [ref('', 'cluster-admin')]
29
+ expect(togglePick(start, ref('', 'cluster-admin'))).toEqual([])
30
+ })
31
+
32
+ it('treats same name in different namespaces as different picks', () => {
33
+ const start: NamespacedRef[] = [ref('prod', 'api')]
34
+ expect(togglePick(start, ref('staging', 'api'))).toEqual([ref('prod', 'api'), ref('staging', 'api')])
35
+ })
36
+
37
+ it('ignores ref without a name (defensive against bad row data)', () => {
38
+ const start: NamespacedRef[] = [ref('prod', 'api')]
39
+ expect(togglePick(start, ref('prod', ''))).toBe(start)
40
+ })
41
+
42
+ it('does not mutate the input array', () => {
43
+ const start: NamespacedRef[] = [ref('prod', 'api')]
44
+ const out = togglePick(start, ref('staging', 'api'))
45
+ expect(start).toEqual([ref('prod', 'api')])
46
+ expect(out).not.toBe(start)
47
+ })
48
+
49
+ it('cap is 2', () => {
50
+ expect(COMPARE_PICK_CAP).toBe(2)
51
+ })
52
+ })
53
+
54
+ describe('pickIndex', () => {
55
+ it('returns -1 when not in list', () => {
56
+ expect(pickIndex([], ref('prod', 'api'))).toBe(-1)
57
+ })
58
+
59
+ it('returns 0 for the first slot (A)', () => {
60
+ expect(pickIndex([ref('prod', 'api'), ref('staging', 'api')], ref('prod', 'api'))).toBe(0)
61
+ })
62
+
63
+ it('returns 1 for the second slot (B)', () => {
64
+ expect(pickIndex([ref('prod', 'api'), ref('staging', 'api')], ref('staging', 'api'))).toBe(1)
65
+ })
66
+
67
+ it('returns -1 for ref with empty name', () => {
68
+ expect(pickIndex([ref('prod', 'api')], ref('prod', ''))).toBe(-1)
69
+ })
70
+ })
71
+
72
+ describe('togglePick — cross-cluster scope', () => {
73
+ // Same ns/name in DIFFERENT clusters must be treated as distinct
74
+ // picks. Otherwise the second click on the same resource name in the
75
+ // second cluster would deselect the first.
76
+ const refC = (cid: string, namespace: string, name: string): NamespacedRef => (
77
+ { namespace, name, clusterId: cid }
78
+ )
79
+
80
+ it('keeps two same-ns-name picks from different clusters', () => {
81
+ const start: NamespacedRef[] = [refC('cl_a', 'kube-system', 'coredns')]
82
+ expect(togglePick(start, refC('cl_b', 'kube-system', 'coredns'))).toEqual([
83
+ refC('cl_a', 'kube-system', 'coredns'),
84
+ refC('cl_b', 'kube-system', 'coredns'),
85
+ ])
86
+ })
87
+
88
+ it('removes a pick keyed by clusterId when same ref re-clicked', () => {
89
+ const start: NamespacedRef[] = [refC('cl_a', 'kube-system', 'coredns')]
90
+ expect(togglePick(start, refC('cl_a', 'kube-system', 'coredns'))).toEqual([])
91
+ })
92
+
93
+ it('does not match scoped ref against unscoped pick of same ns+name', () => {
94
+ const unscoped: NamespacedRef = { namespace: 'kube-system', name: 'coredns' }
95
+ expect(pickIndex([unscoped], refC('cl_a', 'kube-system', 'coredns'))).toBe(-1)
96
+ })
97
+ })
@@ -0,0 +1,35 @@
1
+ import type { NamespacedRef } from './types'
2
+
3
+ /** Max picks the compare flow accepts. Two-way compare only. */
4
+ export const COMPARE_PICK_CAP = 2
5
+
6
+ /**
7
+ * Toggle a resource in the compare picks list.
8
+ * Existing pick → remove. Below cap → append. At cap → drop the oldest so the
9
+ * row click always changes state (no silent no-op).
10
+ */
11
+ function sameRef(a: NamespacedRef, b: NamespacedRef): boolean {
12
+ // clusterId may be undefined on both (OSS single-cluster compare) or
13
+ // set on both (cross-cluster compare). When set, two picks with the
14
+ // same kind+ns+name in DIFFERENT clusters must be treated as distinct
15
+ // — otherwise the second pick would toggle off the first.
16
+ return a.clusterId === b.clusterId && a.namespace === b.namespace && a.name === b.name
17
+ }
18
+
19
+ export function togglePick(picks: NamespacedRef[], ref: NamespacedRef): NamespacedRef[] {
20
+ if (!ref.name) return picks
21
+ const existingIdx = picks.findIndex(p => sameRef(p, ref))
22
+ if (existingIdx >= 0) {
23
+ return picks.filter((_, i) => i !== existingIdx)
24
+ }
25
+ if (picks.length >= COMPARE_PICK_CAP) {
26
+ return [...picks.slice(1), ref]
27
+ }
28
+ return [...picks, ref]
29
+ }
30
+
31
+ /** -1 if not picked; otherwise the slot index (0 = A, 1 = B). */
32
+ export function pickIndex(picks: NamespacedRef[], ref: NamespacedRef): number {
33
+ if (!ref.name) return -1
34
+ return picks.findIndex(p => sameRef(p, ref))
35
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { sortCandidates, filterCandidates } from './sort'
3
+
4
+ const c = (namespace: string, name: string) => ({ namespace, name })
5
+
6
+ describe('sortCandidates', () => {
7
+ it('puts same-namespace candidates first', () => {
8
+ const result = sortCandidates(
9
+ [c('other', 'aa'), c('prod', 'zz'), c('other', 'bb'), c('prod', 'aa')],
10
+ { namespace: 'prod', name: 'src' },
11
+ )
12
+ expect(result.map(r => r.namespace)).toEqual(['prod', 'prod', 'other', 'other'])
13
+ })
14
+
15
+ it('alphabetizes within the same-namespace group', () => {
16
+ const result = sortCandidates(
17
+ [c('prod', 'zz'), c('prod', 'aa'), c('prod', 'mm')],
18
+ { namespace: 'prod', name: 'src' },
19
+ )
20
+ expect(result.map(r => r.name)).toEqual(['aa', 'mm', 'zz'])
21
+ })
22
+
23
+ it('alphabetizes within the other-namespace group too', () => {
24
+ const result = sortCandidates(
25
+ [c('other', 'zz'), c('other', 'aa')],
26
+ { namespace: 'prod', name: 'src' },
27
+ )
28
+ expect(result.map(r => r.name)).toEqual(['aa', 'zz'])
29
+ })
30
+
31
+ it('excludes the source itself', () => {
32
+ const result = sortCandidates(
33
+ [c('prod', 'src'), c('prod', 'api')],
34
+ { namespace: 'prod', name: 'src' },
35
+ )
36
+ expect(result).toEqual([c('prod', 'api')])
37
+ })
38
+
39
+ it('tie-breaks identical names on namespace', () => {
40
+ const result = sortCandidates(
41
+ [c('zeta', 'api'), c('alpha', 'api')],
42
+ { namespace: 'prod', name: 'src' },
43
+ )
44
+ expect(result.map(r => r.namespace)).toEqual(['alpha', 'zeta'])
45
+ })
46
+
47
+ it('does not mutate input', () => {
48
+ const input = [c('prod', 'zz'), c('prod', 'aa')]
49
+ sortCandidates(input, { namespace: 'prod', name: 'src' })
50
+ expect(input).toEqual([c('prod', 'zz'), c('prod', 'aa')])
51
+ })
52
+
53
+ it('handles empty candidate list', () => {
54
+ expect(sortCandidates([], { namespace: 'prod', name: 'src' })).toEqual([])
55
+ })
56
+ })
57
+
58
+ describe('filterCandidates', () => {
59
+ it('returns full list for empty query', () => {
60
+ const list = [c('prod', 'api'), c('staging', 'web')]
61
+ expect(filterCandidates(list, '')).toEqual(list)
62
+ })
63
+
64
+ it('returns full list for whitespace-only query', () => {
65
+ const list = [c('prod', 'api')]
66
+ expect(filterCandidates(list, ' ')).toEqual(list)
67
+ })
68
+
69
+ it('filters by name substring (case insensitive)', () => {
70
+ const result = filterCandidates([c('prod', 'mongo-api'), c('prod', 'web')], 'MONGO')
71
+ expect(result).toEqual([c('prod', 'mongo-api')])
72
+ })
73
+
74
+ it('filters by namespace substring', () => {
75
+ const result = filterCandidates([c('mongodb', 'api'), c('redis', 'api')], 'mongo')
76
+ expect(result).toEqual([c('mongodb', 'api')])
77
+ })
78
+ })
@@ -0,0 +1,26 @@
1
+ import type { NamespacedRef } from './types'
2
+
3
+ /**
4
+ * Order candidates for the compare picker: same-namespace as source first
5
+ * (that's the obvious target), then alphabetical, then namespace tie-break.
6
+ * The source itself is filtered out.
7
+ */
8
+ export function sortCandidates<T extends NamespacedRef>(candidates: T[], source: NamespacedRef): T[] {
9
+ return [...candidates]
10
+ .filter(c => !(c.namespace === source.namespace && c.name === source.name))
11
+ .sort((x, y) => {
12
+ const xSameNs = x.namespace === source.namespace ? 0 : 1
13
+ const ySameNs = y.namespace === source.namespace ? 0 : 1
14
+ if (xSameNs !== ySameNs) return xSameNs - ySameNs
15
+ const nameCmp = x.name.localeCompare(y.name)
16
+ if (nameCmp !== 0) return nameCmp
17
+ return x.namespace.localeCompare(y.namespace)
18
+ })
19
+ }
20
+
21
+ /** Apply a free-text filter to candidates by name OR namespace substring. */
22
+ export function filterCandidates<T extends NamespacedRef>(candidates: T[], query: string): T[] {
23
+ const q = query.trim().toLowerCase()
24
+ if (!q) return candidates
25
+ return candidates.filter(c => c.name.toLowerCase().includes(q) || c.namespace.toLowerCase().includes(q))
26
+ }
@@ -0,0 +1,43 @@
1
+ export interface NamespacedRef {
2
+ namespace: string
3
+ name: string
4
+ /**
5
+ * Optional cluster scope. Populated by cross-cluster compare flows
6
+ * (Radar Hub fleet search) so the same `kube-system/coredns` in two
7
+ * different clusters can both be picked. Undefined in OSS single-
8
+ * cluster compare; equality then falls back to namespace+name only.
9
+ */
10
+ clusterId?: string
11
+ /** Display name for the cluster — surfaced in tray pills + diff side titles. */
12
+ clusterName?: string
13
+ }
14
+
15
+ export type CompareSide = 'a' | 'b'
16
+
17
+ /** Per-side palette. One place for the A/B colors so the picker chip, drawer pill,
18
+ * tray pill, and table row highlight stay in sync if the palette ever changes. */
19
+ export const SIDE_TONES: Record<CompareSide, {
20
+ /** Small label chip: filled background. */
21
+ chipBg: string
22
+ /** Outlined container: border + tint. */
23
+ containerBorder: string
24
+ containerBg: string
25
+ /** Row-level highlight in the resources table. */
26
+ rowBg: string
27
+ rowBgHover: string
28
+ }> = {
29
+ a: {
30
+ chipBg: 'bg-blue-400/90 text-blue-950',
31
+ containerBorder: 'border-blue-400/40',
32
+ containerBg: 'bg-blue-500/10',
33
+ rowBg: 'bg-blue-500/15',
34
+ rowBgHover: 'group-hover/row:bg-blue-500/25',
35
+ },
36
+ b: {
37
+ chipBg: 'bg-emerald-400/90 text-emerald-950',
38
+ containerBorder: 'border-emerald-400/40',
39
+ containerBg: 'bg-emerald-500/10',
40
+ rowBg: 'bg-emerald-500/15',
41
+ rowBgHover: 'group-hover/row:bg-emerald-500/25',
42
+ },
43
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parseRef, refToParam } from './url'
3
+
4
+ describe('parseRef', () => {
5
+ it('parses namespaced ref into namespace and name', () => {
6
+ expect(parseRef('prod/api')).toEqual({ namespace: 'prod', name: 'api' })
7
+ })
8
+
9
+ it('treats slashless value as cluster-scoped', () => {
10
+ expect(parseRef('my-cluster-role')).toEqual({ namespace: '', name: 'my-cluster-role' })
11
+ })
12
+
13
+ it('returns null for null', () => {
14
+ expect(parseRef(null)).toBeNull()
15
+ })
16
+
17
+ it('returns null for undefined', () => {
18
+ expect(parseRef(undefined)).toBeNull()
19
+ })
20
+
21
+ it('returns null for empty string', () => {
22
+ expect(parseRef('')).toBeNull()
23
+ })
24
+
25
+ it('returns null when name is empty after the slash', () => {
26
+ // A URL like `?a=prod/` would otherwise leave the caller stuck in loading
27
+ // because the gated query has nothing to fetch.
28
+ expect(parseRef('prod/')).toBeNull()
29
+ expect(parseRef('/')).toBeNull()
30
+ })
31
+
32
+ it('splits on FIRST slash only — pins behavior against name shape changes', () => {
33
+ expect(parseRef('ns/a/b')).toEqual({ namespace: 'ns', name: 'a/b' })
34
+ })
35
+
36
+ it('handles namespace with leading hyphen edge case', () => {
37
+ expect(parseRef('-ns/api')).toEqual({ namespace: '-ns', name: 'api' })
38
+ })
39
+ })
40
+
41
+ describe('refToParam', () => {
42
+ it('emits ns/name for namespaced', () => {
43
+ expect(refToParam({ namespace: 'prod', name: 'api' })).toBe('prod/api')
44
+ })
45
+
46
+ it('emits bare name for cluster-scoped', () => {
47
+ expect(refToParam({ namespace: '', name: 'my-cluster-role' })).toBe('my-cluster-role')
48
+ })
49
+ })
50
+
51
+ describe('parseRef + refToParam round-trip', () => {
52
+ it('preserves namespaced refs', () => {
53
+ const input = 'prod/api'
54
+ expect(refToParam(parseRef(input)!)).toBe(input)
55
+ })
56
+
57
+ it('preserves cluster-scoped refs', () => {
58
+ const input = 'my-cluster-role'
59
+ expect(refToParam(parseRef(input)!)).toBe(input)
60
+ })
61
+ })
@@ -0,0 +1,26 @@
1
+ import type { NamespacedRef } from './types'
2
+
3
+ export type ParsedRef = NamespacedRef
4
+
5
+ /**
6
+ * Parse a `?a=` / `?b=` query value into `{namespace, name}`.
7
+ * Cluster-scoped resources have no slash: `"my-cluster-role"` → `{namespace: "", name: "my-cluster-role"}`.
8
+ * K8s names are DNS-1123 (no `/`) so splitting on the first slash is unambiguous.
9
+ * Empty name is rejected — a URL like `?a=prod/` would otherwise wedge the
10
+ * caller in an indefinite loading state since `useResource` has no name to fetch.
11
+ */
12
+ export function parseRef(value: string | null | undefined): ParsedRef | null {
13
+ if (!value) return null
14
+ const slash = value.indexOf('/')
15
+ if (slash < 0) {
16
+ return { namespace: '', name: value }
17
+ }
18
+ const name = value.slice(slash + 1)
19
+ if (!name) return null
20
+ return { namespace: value.slice(0, slash), name }
21
+ }
22
+
23
+ /** Inverse of parseRef. Cluster-scoped emits just the name. */
24
+ export function refToParam(r: NamespacedRef): string {
25
+ return r.namespace ? `${r.namespace}/${r.name}` : r.name
26
+ }