@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,325 @@
1
+ // Crossplane CRD utility functions
2
+ //
3
+ // Crossplane v1 vs v2 (v2 GA in late 2025) moved several spec fields under
4
+ // `spec.crossplane.*`. Accessors here check v2 path first, fall back to v1 —
5
+ // never gate on a Crossplane-version check, since both can be present in the
6
+ // same cluster during migrations.
7
+
8
+ import type { StatusBadge } from './resource-utils'
9
+ import { healthColors } from './resource-utils'
10
+
11
+ interface CrossplaneCondition {
12
+ type: string
13
+ status: string
14
+ reason?: string
15
+ message?: string
16
+ lastTransitionTime?: string
17
+ }
18
+
19
+ export interface CrossplaneResourceRef {
20
+ apiVersion: string
21
+ kind: string
22
+ name: string
23
+ namespace?: string
24
+ }
25
+
26
+ // ============================================================================
27
+ // DETECTION HEURISTICS
28
+ // ============================================================================
29
+
30
+ /**
31
+ * A Managed Resource has a providerConfigRef (v1: spec.providerConfigRef,
32
+ * v2: spec.crossplane.providerConfigRef). The crossplane.io/external-name
33
+ * annotation is the secondary signal, but providerConfigRef is more reliable —
34
+ * MRs always have a ProviderConfig (default or explicit), the external-name
35
+ * may be empty until reconciliation succeeds.
36
+ */
37
+ export function isManagedResource(data: any): boolean {
38
+ if (!data?.spec) return false
39
+ if (data.spec.providerConfigRef) return true
40
+ if (data.spec.crossplane?.providerConfigRef) return true
41
+ return false
42
+ }
43
+
44
+ /**
45
+ * A Composite Resource (XR) or v1 Claim. XRs expose spec.resourceRefs
46
+ * (v1) or spec.crossplane.resourceRefs (v2); v1 Claims also expose
47
+ * singular spec.resourceRef + spec.compositionRef pointing at their
48
+ * bound XR. The singular-ref arm matters during the period before
49
+ * reconciliation populates resourceRefs — without it, freshly-applied
50
+ * Claims fall through to GenericRenderer/GenericCell despite being
51
+ * fully-formed Crossplane resources.
52
+ *
53
+ * Mirrors the Go isCrossplaneComposite in internal/audit/runner.go so
54
+ * the audit walker and UI dispatch agree on what counts as a composite.
55
+ * Distinct from an MR: an XR/Claim never has providerConfigRef.
56
+ */
57
+ export function isComposite(data: any): boolean {
58
+ if (!data?.spec) return false
59
+ if (isManagedResource(data)) return false
60
+ if (Array.isArray(data.spec.resourceRefs)) return true
61
+ if (Array.isArray(data.spec.crossplane?.resourceRefs)) return true
62
+ // v1 Claim: singular resourceRef + compositionRef. Both required —
63
+ // singular resourceRef alone shows up in unrelated CRDs.
64
+ if (data.spec.resourceRef && data.spec.compositionRef) return true
65
+ return false
66
+ }
67
+
68
+ /**
69
+ * A Claim is a v1-only namespaced alias for an XR. Detection: has resourceRef
70
+ * (singular — points at the bound XR), plus compositionRef. v2 removed Claims.
71
+ */
72
+ export function isClaim(data: any): boolean {
73
+ if (!data?.spec) return false
74
+ if (isManagedResource(data)) return false
75
+ return Boolean(data.spec.resourceRef && data.spec.compositionRef)
76
+ }
77
+
78
+ // ============================================================================
79
+ // FIELD ACCESSORS (v1+v2 path-aware)
80
+ // ============================================================================
81
+
82
+ export function getProviderConfigRef(data: any): { name: string; kind?: string; apiVersion?: string } | null {
83
+ const ref = data?.spec?.crossplane?.providerConfigRef ?? data?.spec?.providerConfigRef
84
+ if (!ref?.name) return null
85
+ // apiVersion is sometimes present on the ref (Crossplane v1 schema) and
86
+ // sometimes omitted (legacy specs). Pass it through when present so
87
+ // callers can route navigation by GVR; if absent, callers must omit
88
+ // the group rather than guess — ProviderConfig groups can differ from
89
+ // the MR's own group (e.g. an MR in s3.aws.upbound.io references a
90
+ // ProviderConfig in aws.upbound.io).
91
+ return { name: ref.name, kind: ref.kind, apiVersion: ref.apiVersion }
92
+ }
93
+
94
+ export function getCompositionRef(data: any): { name: string } | null {
95
+ const ref = data?.spec?.crossplane?.compositionRef ?? data?.spec?.compositionRef
96
+ if (!ref?.name) return null
97
+ return { name: ref.name }
98
+ }
99
+
100
+ export function getCompositionRevisionRef(data: any): { name: string } | null {
101
+ const ref = data?.spec?.crossplane?.compositionRevisionRef ?? data?.spec?.compositionRevisionRef
102
+ if (!ref?.name) return null
103
+ return { name: ref.name }
104
+ }
105
+
106
+ export function getCompositionUpdatePolicy(data: any): string | null {
107
+ return data?.spec?.crossplane?.compositionUpdatePolicy ?? data?.spec?.compositionUpdatePolicy ?? null
108
+ }
109
+
110
+ export function getCrossplaneResourceRefs(data: any): CrossplaneResourceRef[] {
111
+ const refs = data?.spec?.crossplane?.resourceRefs ?? data?.spec?.resourceRefs
112
+ if (!Array.isArray(refs)) return []
113
+ return refs.filter((r: any) => r?.kind && r?.name)
114
+ }
115
+
116
+ /**
117
+ * Singular resourceRef points at the bound XR for a v1 Claim.
118
+ */
119
+ export function getBoundXRRef(data: any): CrossplaneResourceRef | null {
120
+ const ref = data?.spec?.resourceRef
121
+ if (!ref?.kind || !ref?.name) return null
122
+ return ref
123
+ }
124
+
125
+ export function getExternalName(data: any): string | null {
126
+ return data?.metadata?.annotations?.['crossplane.io/external-name'] ?? null
127
+ }
128
+
129
+ export function getManagementPolicies(data: any): string[] | null {
130
+ const v2 = data?.spec?.crossplane?.managementPolicies
131
+ if (Array.isArray(v2)) return v2
132
+ const v1 = data?.spec?.managementPolicies
133
+ if (Array.isArray(v1)) return v1
134
+ return null
135
+ }
136
+
137
+ export function getDeletionPolicy(data: any): string | null {
138
+ return data?.spec?.crossplane?.deletionPolicy ?? data?.spec?.deletionPolicy ?? null
139
+ }
140
+
141
+ /**
142
+ * Crossplane convention: setting `crossplane.io/paused: "true"` on any MR/XR/Claim
143
+ * stops the provider from reconciling it. Universal across every provider, so
144
+ * surfacing it as a chip on every Crossplane resource is high-leverage.
145
+ */
146
+ export function isCrossplanePaused(data: any): boolean {
147
+ return data?.metadata?.annotations?.['crossplane.io/paused'] === 'true'
148
+ }
149
+
150
+ /**
151
+ * Walk ownerReferences to find the parent Composite for a Managed Resource.
152
+ * Crossplane sets exactly one ownerRef pointing at the composing XR. Returns
153
+ * null for standalone MRs (no composite parent).
154
+ */
155
+ export function getComposingXRRef(data: any): { apiVersion: string; kind: string; name: string } | null {
156
+ const refs = data?.metadata?.ownerReferences
157
+ if (!Array.isArray(refs) || refs.length === 0) return null
158
+ // Crossplane composing-resource ownerRefs are typed with apiextensions.crossplane.io
159
+ // -generated CR groups. The Composite is always the controller; pick that one.
160
+ const owner = refs.find((r: any) => r?.controller === true) ?? refs[0]
161
+ if (!owner?.kind || !owner?.name) return null
162
+ return { apiVersion: owner.apiVersion ?? '', kind: owner.kind, name: owner.name }
163
+ }
164
+
165
+ // ============================================================================
166
+ // STATUS — Managed Resource / Composite / Claim
167
+ // ============================================================================
168
+
169
+ function findCondition(data: any, type: string): CrossplaneCondition | undefined {
170
+ const conds: CrossplaneCondition[] = data?.status?.conditions || []
171
+ return conds.find(c => c.type === type)
172
+ }
173
+
174
+ /**
175
+ * Crossplane resources expose two top-level conditions:
176
+ * - Synced: provider has accepted the spec (no rejected fields, no auth errors)
177
+ * - Ready: external resource exists in the desired state
178
+ *
179
+ * Both must be True for healthy; Synced=False usually means a configuration
180
+ * problem (bad ProviderConfig, malformed spec); Ready=False usually means the
181
+ * external API is still working or the resource hasn't converged yet.
182
+ */
183
+ export function getCrossplaneStatus(data: any): StatusBadge {
184
+ if (data?.metadata?.deletionTimestamp) {
185
+ return { text: 'Terminating', color: healthColors.degraded, level: 'degraded' }
186
+ }
187
+ const ready = findCondition(data, 'Ready')
188
+ const synced = findCondition(data, 'Synced')
189
+
190
+ if (!ready && !synced) {
191
+ return { text: 'Pending', color: healthColors.neutral, level: 'neutral' }
192
+ }
193
+ if (synced?.status === 'False') {
194
+ return { text: 'Out of sync', color: healthColors.alert, level: 'alert' }
195
+ }
196
+ if (ready?.status === 'False') {
197
+ return { text: ready.reason || 'Not ready', color: healthColors.unhealthy, level: 'unhealthy' }
198
+ }
199
+ if (ready?.status === 'True' && synced?.status === 'True') {
200
+ return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
201
+ }
202
+ if (ready?.status === 'Unknown' || synced?.status === 'Unknown') {
203
+ return { text: 'Provisioning', color: healthColors.degraded, level: 'degraded' }
204
+ }
205
+ return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
206
+ }
207
+
208
+ /**
209
+ * First non-empty message from a False/Unknown Ready or Synced condition —
210
+ * used as the tooltip below status badges and as the alert banner body in
211
+ * detail views.
212
+ */
213
+ export function getCrossplaneStatusReason(data: any): string | null {
214
+ const ready = findCondition(data, 'Ready')
215
+ const synced = findCondition(data, 'Synced')
216
+ if (synced && synced.status !== 'True' && synced.message) return synced.message
217
+ if (ready && ready.status !== 'True' && ready.message) return ready.message
218
+ if (synced && synced.status !== 'True' && synced.reason) return synced.reason
219
+ if (ready && ready.status !== 'True' && ready.reason) return ready.reason
220
+ return null
221
+ }
222
+
223
+ // ============================================================================
224
+ // STATUS — Provider
225
+ // ============================================================================
226
+
227
+ /**
228
+ * Providers expose Installed + Healthy conditions. A Provider in
229
+ * Installed=True but Healthy=False is mid-rollout (image pulled but pod
230
+ * crashlooping). Installed=False means the provider package failed
231
+ * verification or the image isn't pullable.
232
+ */
233
+ export function getProviderStatus(data: any): StatusBadge {
234
+ if (data?.metadata?.deletionTimestamp) {
235
+ return { text: 'Terminating', color: healthColors.degraded, level: 'degraded' }
236
+ }
237
+ const installed = findCondition(data, 'Installed')
238
+ const healthy = findCondition(data, 'Healthy')
239
+
240
+ if (installed?.status === 'False') {
241
+ return { text: installed.reason || 'Install failed', color: healthColors.unhealthy, level: 'unhealthy' }
242
+ }
243
+ if (healthy?.status === 'False') {
244
+ return { text: healthy.reason || 'Unhealthy', color: healthColors.alert, level: 'alert' }
245
+ }
246
+ if (installed?.status === 'True' && healthy?.status === 'True') {
247
+ return { text: 'Healthy', color: healthColors.healthy, level: 'healthy' }
248
+ }
249
+ if (installed?.status === 'True') {
250
+ return { text: 'Installed', color: healthColors.degraded, level: 'degraded' }
251
+ }
252
+ return { text: 'Pending', color: healthColors.unknown, level: 'unknown' }
253
+ }
254
+
255
+ // ============================================================================
256
+ // STATUS — ProviderConfig
257
+ // ============================================================================
258
+
259
+ /**
260
+ * ProviderConfigs don't have rich status. Most expose `status.users` (the
261
+ * number of MRs referencing them) and may carry a Ready condition in some
262
+ * providers. We surface "Active" if anything references it, otherwise "Idle".
263
+ */
264
+ export function getProviderConfigStatus(data: any): StatusBadge {
265
+ if (data?.metadata?.deletionTimestamp) {
266
+ return { text: 'Terminating', color: healthColors.degraded, level: 'degraded' }
267
+ }
268
+ const users = typeof data?.status?.users === 'number' ? data.status.users : null
269
+ const ready = findCondition(data, 'Ready')
270
+ if (ready?.status === 'False') {
271
+ return { text: ready.reason || 'Not ready', color: healthColors.unhealthy, level: 'unhealthy' }
272
+ }
273
+ if (users && users > 0) {
274
+ return { text: `${users} in use`, color: healthColors.healthy, level: 'healthy' }
275
+ }
276
+ return { text: 'Idle', color: healthColors.neutral, level: 'neutral' }
277
+ }
278
+
279
+ export function getProviderConfigCredentialsSource(data: any): string {
280
+ return data?.spec?.credentials?.source ?? '-'
281
+ }
282
+
283
+ // ============================================================================
284
+ // PROVIDER FIELD ACCESSORS
285
+ // ============================================================================
286
+
287
+ export function getProviderPackage(data: any): string {
288
+ return data?.spec?.package ?? '-'
289
+ }
290
+
291
+ export function getProviderPackagePullPolicy(data: any): string | null {
292
+ return data?.spec?.packagePullPolicy ?? null
293
+ }
294
+
295
+ export function getProviderCurrentRevision(data: any): string | null {
296
+ return data?.status?.currentRevision ?? null
297
+ }
298
+
299
+ // ============================================================================
300
+ // COMPOSITION / XRD FIELD ACCESSORS (for cell rendering only)
301
+ // ============================================================================
302
+
303
+ export function getXRDClaimNames(data: any): { kind?: string; plural?: string } {
304
+ const cn = data?.spec?.claimNames
305
+ return { kind: cn?.kind, plural: cn?.plural }
306
+ }
307
+
308
+ export function getXRDKind(data: any): string {
309
+ return data?.spec?.names?.kind ?? '-'
310
+ }
311
+
312
+ export function getCompositionMode(data: any): string {
313
+ return data?.spec?.mode ?? 'Resources'
314
+ }
315
+
316
+ export function getCompositionFunctionCount(data: any): number {
317
+ const pipeline = data?.spec?.pipeline
318
+ return Array.isArray(pipeline) ? pipeline.length : 0
319
+ }
320
+
321
+ export function getCompositionCompositeTypeRef(data: any): string {
322
+ const ref = data?.spec?.compositeTypeRef
323
+ if (!ref) return '-'
324
+ return ref.kind ? `${ref.kind}` : '-'
325
+ }
@@ -86,6 +86,20 @@ export function getFluxHelmReleaseStatus(hr: any): StatusBadge {
86
86
  return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
87
87
  }
88
88
 
89
+ /**
90
+ * Last diagnostic message for a HelmRelease — surfaces dependency-wait vs install/upgrade failure
91
+ * vs test failure, which is otherwise hidden inside conditions. Returns '' for healthy releases
92
+ * (Ready=True) so the table column stays quiet on a green cluster.
93
+ */
94
+ export function getFluxHelmReleaseMessage(hr: any): string {
95
+ const conditions: FluxCondition[] = hr.status?.conditions || []
96
+ const readyCondition = conditions.find((c) => c.type === 'Ready')
97
+ if (readyCondition?.status === 'True') return ''
98
+ if (readyCondition?.message) return readyCondition.message
99
+ const releasedCondition = conditions.find((c) => c.type === 'Released')
100
+ return releasedCondition?.message || ''
101
+ }
102
+
89
103
  // ============================================================================
90
104
  // FLUXCD TABLE CELL UTILITIES
91
105
  // ============================================================================
@@ -1263,6 +1263,7 @@ export {
1263
1263
  getFluxHelmReleaseChart,
1264
1264
  getFluxHelmReleaseVersion,
1265
1265
  getFluxHelmReleaseRevision,
1266
+ getFluxHelmReleaseMessage,
1266
1267
  getFluxAlertProvider,
1267
1268
  getFluxAlertEventCount,
1268
1269
  } from './resource-utils-flux'
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { isColumnFilterableByDistinctCount, SKIP_FILTER_COLUMNS } from './ResourcesView'
3
+
4
+ describe('isColumnFilterableByDistinctCount', () => {
5
+ // Caps were tuned after a customer-visible regression where the namespace
6
+ // filter silently vanished on clusters with >20 namespaces. Pin them so a
7
+ // future "tidy up" of the ternary doesn't re-introduce the bug.
8
+
9
+ describe('namespace (uncapped — scales with cluster, not kind enum)', () => {
10
+ it('is filterable at 2 distinct values', () => {
11
+ expect(isColumnFilterableByDistinctCount('namespace', 2)).toBe(true)
12
+ })
13
+
14
+ it('is filterable above the old 20-value cap', () => {
15
+ expect(isColumnFilterableByDistinctCount('namespace', 25)).toBe(true)
16
+ })
17
+
18
+ it('is filterable at very large clusters (5000 namespaces)', () => {
19
+ expect(isColumnFilterableByDistinctCount('namespace', 5000)).toBe(true)
20
+ })
21
+
22
+ it('is not filterable with a single distinct value (nothing to filter)', () => {
23
+ expect(isColumnFilterableByDistinctCount('namespace', 1)).toBe(false)
24
+ })
25
+ })
26
+
27
+ describe('node (cap = 200 — large but bounded)', () => {
28
+ it('is filterable below the cap', () => {
29
+ expect(isColumnFilterableByDistinctCount('node', 150)).toBe(true)
30
+ })
31
+
32
+ it('is filterable exactly at the cap', () => {
33
+ expect(isColumnFilterableByDistinctCount('node', 200)).toBe(true)
34
+ })
35
+
36
+ it('is not filterable just above the cap', () => {
37
+ expect(isColumnFilterableByDistinctCount('node', 201)).toBe(false)
38
+ })
39
+
40
+ it('is not filterable far above the cap', () => {
41
+ expect(isColumnFilterableByDistinctCount('node', 500)).toBe(false)
42
+ })
43
+ })
44
+
45
+ describe('generic column (cap = 30 — enum-style)', () => {
46
+ it('is filterable at 2 distinct values', () => {
47
+ expect(isColumnFilterableByDistinctCount('status', 2)).toBe(true)
48
+ })
49
+
50
+ it('is filterable exactly at the cap', () => {
51
+ expect(isColumnFilterableByDistinctCount('status', 30)).toBe(true)
52
+ })
53
+
54
+ it('is not filterable just above the cap', () => {
55
+ expect(isColumnFilterableByDistinctCount('status', 31)).toBe(false)
56
+ })
57
+ })
58
+
59
+ describe('SKIP_FILTER_COLUMNS denylist', () => {
60
+ it('skips columns in the denylist regardless of distinct count', () => {
61
+ expect(isColumnFilterableByDistinctCount('name', 5)).toBe(false)
62
+ expect(isColumnFilterableByDistinctCount('age', 5)).toBe(false)
63
+ expect(isColumnFilterableByDistinctCount('message', 5)).toBe(false)
64
+ })
65
+
66
+ // Removed from the denylist deliberately — events benefit from filtering
67
+ // by Reason (FailedScheduling, BackOff, Killing, …). Re-adding would
68
+ // silently kill the Reason dropdown on the Events table.
69
+ it('does not skip `reason` (the Events Reason filter)', () => {
70
+ expect(SKIP_FILTER_COLUMNS.has('reason')).toBe(false)
71
+ expect(isColumnFilterableByDistinctCount('reason', 8)).toBe(true)
72
+ })
73
+ })
74
+ })
@@ -37,6 +37,18 @@ interface ResourceActionsBarProps {
37
37
  showYaml?: boolean
38
38
  onToggleYaml?: () => void
39
39
 
40
+ /** When provided, renders a "Compare" button that opens the compare picker. */
41
+ onCompareTo?: () => void
42
+
43
+ /**
44
+ * Host-supplied callback for cross-cluster compare. When set alongside
45
+ * onCompareTo, the Compare button becomes a small dropdown offering both
46
+ * scopes. When set alone, the button opens cross-cluster compare directly.
47
+ * Only embedded hosts (Radar Hub) wire this; standalone Radar leaves it
48
+ * undefined and the button stays single-cluster.
49
+ */
50
+ onCompareAcrossClusters?: () => void
51
+
40
52
  // Capabilities (injected by platform)
41
53
  canExec?: boolean
42
54
  canViewLogs?: boolean
@@ -111,6 +123,8 @@ interface ResourceActionsBarProps {
111
123
 
112
124
  export function ResourceActionsBar({
113
125
  resource, data, onClose, hideLogs, showYaml, onToggleYaml,
126
+ onCompareTo,
127
+ onCompareAcrossClusters,
114
128
  canExec, canViewLogs, canPortForward,
115
129
  onOpenTerminal, onOpenLogs: openLogs, onOpenWorkloadLogs: openWorkloadLogs, onCopyCommand,
116
130
  renderPortForward,
@@ -189,6 +203,16 @@ export function ResourceActionsBar({
189
203
  const [showLogsMenu, setShowLogsMenu] = useState(false)
190
204
  const logsMenuTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
191
205
 
206
+ const [showCompareMenu, setShowCompareMenu] = useState(false)
207
+ const compareMenuTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
208
+ const handleCompareMouseEnter = () => {
209
+ if (compareMenuTimeout.current) clearTimeout(compareMenuTimeout.current)
210
+ if (onCompareTo && onCompareAcrossClusters) setShowCompareMenu(true)
211
+ }
212
+ const handleCompareMouseLeave = () => {
213
+ compareMenuTimeout.current = setTimeout(() => setShowCompareMenu(false), 150)
214
+ }
215
+
192
216
  const handleLogsMouseEnter = () => {
193
217
  if (logsMenuTimeout.current) clearTimeout(logsMenuTimeout.current)
194
218
  if (containers.length > 1) setShowLogsMenu(true)
@@ -483,6 +507,59 @@ export function ResourceActionsBar({
483
507
  </button>
484
508
  )}
485
509
 
510
+ {(onCompareTo || onCompareAcrossClusters) && (
511
+ <div
512
+ className="relative"
513
+ onMouseEnter={handleCompareMouseEnter}
514
+ onMouseLeave={handleCompareMouseLeave}
515
+ >
516
+ <Tooltip
517
+ content={
518
+ onCompareTo && onCompareAcrossClusters
519
+ ? `Compare ${formatKindName(resource.kind).toLowerCase()}`
520
+ : onCompareAcrossClusters
521
+ ? `Compare across clusters`
522
+ : `Compare to another ${formatKindName(resource.kind).toLowerCase()}`
523
+ }
524
+ >
525
+ <button
526
+ onClick={onCompareTo ?? onCompareAcrossClusters}
527
+ aria-label={
528
+ onCompareTo && onCompareAcrossClusters
529
+ ? `Compare ${formatKindName(resource.kind).toLowerCase()}`
530
+ : onCompareAcrossClusters
531
+ ? `Compare across clusters`
532
+ : `Compare to another ${formatKindName(resource.kind).toLowerCase()}`
533
+ }
534
+ className="p-1.5 text-theme-text-secondary border border-theme-border-light rounded-lg hover:text-theme-text-primary hover:bg-theme-elevated transition-colors flex items-center"
535
+ >
536
+ <GitCompare className="w-3.5 h-3.5" />
537
+ {onCompareTo && onCompareAcrossClusters && (
538
+ <ChevronDown className="w-3 h-3 ml-0.5" />
539
+ )}
540
+ </button>
541
+ </Tooltip>
542
+ {showCompareMenu && onCompareTo && onCompareAcrossClusters && (
543
+ <div className="absolute top-full right-0 mt-1 min-w-[220px] py-1 bg-theme-surface border border-theme-border rounded-lg shadow-theme-lg z-50">
544
+ <button
545
+ onClick={() => { onCompareTo(); setShowCompareMenu(false) }}
546
+ className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-theme-text-primary hover:bg-theme-hover transition-colors text-left"
547
+ >
548
+ <GitCompare className="w-3 h-3 text-theme-text-tertiary shrink-0" />
549
+ <span>Compare in this cluster</span>
550
+ </button>
551
+ <button
552
+ onClick={() => { onCompareAcrossClusters(); setShowCompareMenu(false) }}
553
+ className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-theme-text-primary hover:bg-theme-hover transition-colors text-left"
554
+ >
555
+ <GitCompare className="w-3 h-3 text-theme-text-tertiary shrink-0" />
556
+ <span>Compare across clusters</span>
557
+ </button>
558
+ </div>
559
+ )}
560
+ </div>
561
+ )}
562
+
486
563
  {onDelete && (
487
564
  <Tooltip content="Delete resource">
488
565
  <button