@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.
- package/package.json +7 -2
- package/src/components/charts/AreaChart.tsx +371 -0
- package/src/components/charts/MetricsSummary.tsx +49 -0
- package/src/components/charts/SeriesLegend.tsx +27 -0
- package/src/components/charts/colors.ts +49 -0
- package/src/components/charts/format.ts +41 -0
- package/src/components/charts/index.ts +14 -0
- package/src/components/charts/saturation.test.ts +60 -0
- package/src/components/charts/saturation.ts +32 -0
- package/src/components/charts/types.ts +36 -0
- package/src/components/compare/CompareResourcePicker.tsx +192 -0
- package/src/components/compare/CompareTray.tsx +130 -0
- package/src/components/compare/ResourceCompareView.tsx +272 -0
- package/src/components/compare/index.ts +14 -0
- package/src/components/compare/normalize.test.ts +193 -0
- package/src/components/compare/normalize.ts +69 -0
- package/src/components/compare/picks.test.ts +97 -0
- package/src/components/compare/picks.ts +35 -0
- package/src/components/compare/sort.test.ts +78 -0
- package/src/components/compare/sort.ts +26 -0
- package/src/components/compare/types.ts +43 -0
- package/src/components/compare/url.test.ts +61 -0
- package/src/components/compare/url.ts +26 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
- package/src/components/gitops/GitOpsTableView.tsx +1441 -0
- package/src/components/gitops/RollbackDialog.tsx +117 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
- package/src/components/gitops/detail-helpers.test.ts +97 -0
- package/src/components/gitops/detail-helpers.ts +112 -0
- package/src/components/gitops/index.ts +52 -1
- package/src/components/gitops/short-cluster-name.test.ts +36 -0
- package/src/components/gitops/tree/index.ts +2 -0
- package/src/components/gitops/tree/merge.test.ts +240 -0
- package/src/components/gitops/tree/merge.ts +160 -0
- package/src/components/resources/ResourcesSidebar.tsx +42 -15
- package/src/components/resources/ResourcesView.tsx +403 -69
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
- package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
- package/src/components/resources/renderers/PodRenderer.tsx +207 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
- package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
- package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
- package/src/components/resources/renderers/flux-cells.tsx +12 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
- package/src/components/resources/resource-utils-crossplane.ts +325 -0
- package/src/components/resources/resource-utils-flux.ts +14 -0
- package/src/components/resources/resource-utils.ts +1 -0
- package/src/components/resources/resources-column-filter.test.ts +74 -0
- package/src/components/shared/ResourceActionsBar.tsx +77 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
- package/src/components/ui/YamlEditor.tsx +28 -15
- package/src/components/ui/drawer-components.tsx +8 -20
- package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/index.ts +3 -0
- package/src/types/core.ts +31 -6
- package/src/types/index.ts +1 -0
- package/src/types/rbac.ts +99 -0
- package/src/utils/api-resources.ts +9 -1
- package/src/utils/badge-colors.ts +25 -0
- package/src/utils/gitops-owner.test.ts +70 -111
- package/src/utils/gitops-owner.ts +37 -74
- package/src/utils/helm-status.test.ts +50 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-badges.ts +61 -0
- package/src/utils/rbac-blast-radius.test.ts +137 -0
- 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
|
+
}
|