@skyhook-io/k8s-ui 1.6.1 → 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 (59) hide show
  1. package/package.json +6 -1
  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/resources/ResourcesView.tsx +338 -57
  25. package/src/components/resources/index.ts +1 -0
  26. package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
  27. package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
  28. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
  29. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
  30. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  31. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
  32. package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
  33. package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
  34. package/src/components/resources/renderers/PodRenderer.tsx +207 -2
  35. package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
  36. package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
  37. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
  38. package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
  39. package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
  40. package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
  41. package/src/components/resources/renderers/flux-cells.tsx +12 -0
  42. package/src/components/resources/renderers/index.ts +8 -0
  43. package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
  44. package/src/components/resources/resource-utils-crossplane.ts +325 -0
  45. package/src/components/resources/resource-utils-flux.ts +14 -0
  46. package/src/components/resources/resource-utils.ts +1 -0
  47. package/src/components/resources/resources-column-filter.test.ts +74 -0
  48. package/src/components/shared/ResourceActionsBar.tsx +77 -0
  49. package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
  50. package/src/components/ui/YamlEditor.tsx +28 -15
  51. package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
  52. package/src/index.ts +3 -0
  53. package/src/types/index.ts +1 -0
  54. package/src/types/rbac.ts +99 -0
  55. package/src/utils/api-resources.ts +9 -1
  56. package/src/utils/index.ts +1 -0
  57. package/src/utils/rbac-badges.ts +61 -0
  58. package/src/utils/rbac-blast-radius.test.ts +137 -0
  59. package/src/utils/rbac-blast-radius.ts +98 -0
@@ -22,6 +22,7 @@ import {
22
22
  Copy,
23
23
  Check,
24
24
  Plus,
25
+ GitCompare,
25
26
  } from 'lucide-react'
26
27
  import { clsx } from 'clsx'
27
28
  import { ResourceBar } from '../ui/ResourceBar'
@@ -137,6 +138,8 @@ import { PolicyReportCell, ClusterPolicyReportCell, KyvernoPolicyCell, ClusterPo
137
138
  import { ExternalSecretCell, ClusterExternalSecretCell, SecretStoreCell, ClusterSecretStoreCell } from './renderers/eso-cells'
138
139
  import { BackupCell, RestoreCell, ScheduleCell, BackupStorageLocationCell } from './renderers/velero-cells'
139
140
  import { CNPGClusterCell, CNPGBackupCell, CNPGScheduledBackupCell, CNPGPoolerCell } from './renderers/cnpg-cells'
141
+ import { ManagedResourceCell, CompositeResourceCell, CrossplaneProviderCell, CrossplaneProviderConfigCell, CompositionCell, XRDCell } from './renderers/crossplane-cells'
142
+ import { isManagedResource, isComposite } from './resource-utils-crossplane'
140
143
  import { VirtualServiceCell, DestinationRuleCell, IstioGatewayCell, ServiceEntryCell, PeerAuthenticationCell, AuthorizationPolicyCell } from './renderers/istio-cells'
141
144
  import { KnativeServiceCell, ConfigurationCell as KnativeConfigurationCell, RevisionCell as KnativeRevisionCell, RouteCell as KnativeRouteCell, BrokerCell, TriggerCell, EventTypeCell, PingSourceCell, ApiServerSourceCell, ContainerSourceCell, SinkBindingCell, ChannelCell, InMemoryChannelCell, SubscriptionCell, SequenceCell, ParallelCell, DomainMappingCell, ServerlessServiceCell, KnativeIngressCell, KnativeCertificateCell } from './renderers/knative-cells'
142
145
  import { IngressRouteCell, MiddlewareCell, TraefikServiceCell, ServersTransportCell, TLSOptionCell } from './renderers/traefik-cells'
@@ -148,6 +151,7 @@ import { AzureManagedControlPlaneCell, AzureManagedMachinePoolCell, AzureMachine
148
151
  import { useRegisterShortcut, useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
149
152
  import { ResourcesSidebar } from './ResourcesSidebar'
150
153
  import type { SelectedKindInfo } from './ResourcesSidebar'
154
+ import { CompareTray, togglePick, pickIndex, refToParam, SIDE_TONES, type CompareTrayPick, type NamespacedRef } from '../compare'
151
155
 
152
156
  // Pod problem filter options (special multi-select, not a single column value)
153
157
  const POD_PROBLEMS = ['CrashLoopBackOff', 'ImagePullBackOff', 'OOMKilled', 'Unschedulable', 'Not Ready', 'High Restarts', 'Init Failed', 'Exit Code Error', 'Failed', 'Other'] as const
@@ -155,7 +159,7 @@ const WORKLOAD_PROBLEMS = ['Unavailable', 'Rollout Stuck', 'Rollout In Progress'
155
159
  const WORKLOAD_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets'])
156
160
 
157
161
  // Columns to skip for auto-detected filters (high cardinality, text-like, or non-filterable)
158
- const SKIP_FILTER_COLUMNS = new Set([
162
+ export const SKIP_FILTER_COLUMNS = new Set([
159
163
  'name', 'age', 'keys', 'size', 'images', 'domains', 'hosts', 'rules',
160
164
  'ports', 'message', 'url', 'ref', 'revision', 'path', 'selector', 'ready', 'restarts',
161
165
  'completions', 'duration', 'schedule', 'lastRun', 'target', 'replicas', 'metrics',
@@ -163,12 +167,22 @@ const SKIP_FILTER_COLUMNS = new Set([
163
167
  'issuer', 'domain', 'presented', 'listeners', 'routes', 'addresses', 'hostnames',
164
168
  'parents', 'backends', 'controller', 'description', 'externalIP', 'address',
165
169
  'conditions', 'taints', 'desired', 'upToDate', 'available', 'owner',
166
- 'tls', 'endpoints', 'object', 'count', 'lastSeen', 'reason', 'source', 'inventory',
170
+ 'tls', 'endpoints', 'object', 'count', 'lastSeen', 'source', 'inventory',
167
171
  'lastUpdated', 'chart', 'events', 'repo',
168
172
  'generators', 'applications', 'destinations', 'sources', 'budget', 'healthy', 'allowed',
169
173
  'secrets', 'subjects', 'role', 'entrypoint', 'templates',
170
174
  ])
171
175
 
176
+ // Namespace/node cardinality scales with cluster size, not kind enum size;
177
+ // node keeps a generous cap as a sanity bound, namespace is uncapped.
178
+ export function isColumnFilterableByDistinctCount(colKey: string, distinctCount: number): boolean {
179
+ if (SKIP_FILTER_COLUMNS.has(colKey)) return false
180
+ const maxDistinct = colKey === 'namespace'
181
+ ? Number.POSITIVE_INFINITY
182
+ : colKey === 'node' ? 200 : 30
183
+ return distinctCount >= 2 && distinctCount <= maxDistinct
184
+ }
185
+
172
186
  // Column definitions per resource kind
173
187
  interface Column {
174
188
  key: string
@@ -233,11 +247,11 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
233
247
  pods: [
234
248
  { key: 'name', label: 'Name' },
235
249
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
236
- { key: 'containers', label: 'Containers', width: 'w-28' },
250
+ { key: 'containers', label: 'Containers', width: 'w-32' },
237
251
  { key: 'status', label: 'Status', width: 'w-40' },
238
252
  { key: 'cpu', label: 'CPU', width: 'w-40', tooltip: 'CPU usage / limit (marker = request)' },
239
253
  { key: 'memory', label: 'Memory', width: 'w-40', tooltip: 'Memory usage / limit (marker = request)' },
240
- { key: 'restarts', label: 'Restarts', width: 'w-24' },
254
+ { key: 'restarts', label: 'Restarts', width: 'w-28' },
241
255
  { key: 'podIP', label: 'Pod IP', width: 'w-32', defaultVisible: false },
242
256
  { key: 'node', label: 'Node', width: 'w-44', hideOnMobile: true },
243
257
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -246,8 +260,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
246
260
  { key: 'name', label: 'Name' },
247
261
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
248
262
  { key: 'ready', label: 'Ready', width: 'w-24', tooltip: 'Ready pods / Desired replicas' },
249
- { key: 'upToDate', label: 'Up-to-date', width: 'w-24', hideOnMobile: true, tooltip: 'Number of pods running the current pod template' },
250
- { key: 'available', label: 'Available', width: 'w-24', hideOnMobile: true, tooltip: 'Number of pods available (ready for minReadySeconds)' },
263
+ { key: 'upToDate', label: 'Up-to-date', width: 'w-32', hideOnMobile: true, tooltip: 'Number of pods running the current pod template' },
264
+ { key: 'available', label: 'Available', width: 'w-28', hideOnMobile: true, tooltip: 'Number of pods available (ready for minReadySeconds)' },
251
265
  { key: 'images', label: 'Images', width: 'w-48', hideOnMobile: true },
252
266
  { key: 'age', label: 'Age', width: 'w-24' },
253
267
  ],
@@ -256,8 +270,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
256
270
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
257
271
  { key: 'desired', label: 'Desired', width: 'w-20', tooltip: 'Number of nodes that should run the daemon pod (based on node selector)' },
258
272
  { key: 'ready', label: 'Ready', width: 'w-20', tooltip: 'Number of pods that are ready (passing readiness probes)' },
259
- { key: 'upToDate', label: 'Up-to-date', width: 'w-24', hideOnMobile: true, tooltip: 'Number of pods running the current pod template spec' },
260
- { key: 'available', label: 'Available', width: 'w-24', hideOnMobile: true, tooltip: 'Number of pods available (ready for minReadySeconds duration)' },
273
+ { key: 'upToDate', label: 'Up-to-date', width: 'w-32', hideOnMobile: true, tooltip: 'Number of pods running the current pod template spec' },
274
+ { key: 'available', label: 'Available', width: 'w-28', hideOnMobile: true, tooltip: 'Number of pods available (ready for minReadySeconds duration)' },
261
275
  { key: 'images', label: 'Images', width: 'w-48', hideOnMobile: true },
262
276
  { key: 'age', label: 'Age', width: 'w-24' },
263
277
  ],
@@ -265,7 +279,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
265
279
  { key: 'name', label: 'Name' },
266
280
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
267
281
  { key: 'ready', label: 'Ready', width: 'w-24', tooltip: 'Ready pods / Desired replicas' },
268
- { key: 'upToDate', label: 'Up-to-date', width: 'w-24', hideOnMobile: true, tooltip: 'Number of pods running the current pod template' },
282
+ { key: 'upToDate', label: 'Up-to-date', width: 'w-32', hideOnMobile: true, tooltip: 'Number of pods running the current pod template' },
269
283
  { key: 'images', label: 'Images', width: 'w-48', hideOnMobile: true },
270
284
  { key: 'age', label: 'Age', width: 'w-24' },
271
285
  ],
@@ -328,7 +342,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
328
342
  { key: 'name', label: 'Name' },
329
343
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
330
344
  { key: 'status', label: 'Status', width: 'w-28' },
331
- { key: 'completions', label: 'Completions', width: 'w-28' },
345
+ { key: 'completions', label: 'Completions', width: 'w-32' },
332
346
  { key: 'duration', label: 'Duration', width: 'w-24', hideOnMobile: true },
333
347
  { key: 'age', label: 'Age', width: 'w-24' },
334
348
  ],
@@ -363,7 +377,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
363
377
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
364
378
  { key: 'status', label: 'Status', width: 'w-24' },
365
379
  { key: 'capacity', label: 'Capacity', width: 'w-24' },
366
- { key: 'storageClass', label: 'Storage Class', width: 'w-36', hideOnMobile: true },
380
+ { key: 'storageClass', label: 'Storage Class', width: 'w-40', hideOnMobile: true },
367
381
  { key: 'accessModes', label: 'Access', width: 'w-20', tooltip: 'Access modes: RWO=ReadWriteOnce, RWX=ReadWriteMany, ROX=ReadOnlyMany' },
368
382
  { key: 'volume', label: 'Volume', width: 'w-48', hideOnMobile: true },
369
383
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -402,7 +416,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
402
416
  { key: 'capacity', label: 'Capacity', width: 'w-24' },
403
417
  { key: 'accessModes', label: 'Access', width: 'w-20', tooltip: 'RWO=ReadWriteOnce, ROX=ReadOnlyMany, RWX=ReadWriteMany' },
404
418
  { key: 'reclaimPolicy', label: 'Reclaim', width: 'w-20' },
405
- { key: 'storageClass', label: 'Storage Class', width: 'w-36', hideOnMobile: true },
419
+ { key: 'storageClass', label: 'Storage Class', width: 'w-40', hideOnMobile: true },
406
420
  { key: 'claim', label: 'Claim', width: 'w-48', hideOnMobile: true },
407
421
  { key: 'age', label: 'Age', width: 'w-24' },
408
422
  ],
@@ -667,8 +681,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
667
681
  serviceaccounts: [
668
682
  { key: 'name', label: 'Name' },
669
683
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
670
- { key: 'automount', label: 'Automount', width: 'w-24', tooltip: 'Whether token is automatically mounted in pods' },
671
- { key: 'secrets', label: 'Secrets', width: 'w-20' },
684
+ { key: 'automount', label: 'Automount', width: 'w-32', tooltip: 'Whether token is automatically mounted in pods' },
685
+ { key: 'secrets', label: 'Secrets', width: 'w-24' },
672
686
  { key: 'age', label: 'Age', width: 'w-24' },
673
687
  ],
674
688
  roles: [
@@ -705,13 +719,13 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
705
719
  { key: 'name', label: 'Name' },
706
720
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
707
721
  { key: 'holder', label: 'Holder', width: 'w-48' },
708
- { key: 'renewTime', label: 'Last Renewed', width: 'w-28' },
722
+ { key: 'renewTime', label: 'Last Renewed', width: 'w-32' },
709
723
  { key: 'age', label: 'Age', width: 'w-24' },
710
724
  ],
711
725
  priorityclasses: [
712
726
  { key: 'name', label: 'Name' },
713
727
  { key: 'value', label: 'Value', width: 'w-24' },
714
- { key: 'globalDefault', label: 'Global Default', width: 'w-28' },
728
+ { key: 'globalDefault', label: 'Global Default', width: 'w-36' },
715
729
  { key: 'preemptionPolicy', label: 'Preemption', width: 'w-32' },
716
730
  { key: 'description', label: 'Description', width: 'w-64', hideOnMobile: true },
717
731
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -723,27 +737,27 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
723
737
  ],
724
738
  mutatingwebhookconfigurations: [
725
739
  { key: 'name', label: 'Name' },
726
- { key: 'webhooks', label: 'Webhooks', width: 'w-20' },
727
- { key: 'failurePolicy', label: 'Failure Policy', width: 'w-28' },
740
+ { key: 'webhooks', label: 'Webhooks', width: 'w-28' },
741
+ { key: 'failurePolicy', label: 'Failure Policy', width: 'w-36' },
728
742
  { key: 'target', label: 'Target', width: 'w-48', hideOnMobile: true },
729
743
  { key: 'age', label: 'Age', width: 'w-24' },
730
744
  ],
731
745
  validatingwebhookconfigurations: [
732
746
  { key: 'name', label: 'Name' },
733
- { key: 'webhooks', label: 'Webhooks', width: 'w-20' },
734
- { key: 'failurePolicy', label: 'Failure Policy', width: 'w-28' },
747
+ { key: 'webhooks', label: 'Webhooks', width: 'w-28' },
748
+ { key: 'failurePolicy', label: 'Failure Policy', width: 'w-36' },
735
749
  { key: 'target', label: 'Target', width: 'w-48', hideOnMobile: true },
736
750
  { key: 'age', label: 'Age', width: 'w-24' },
737
751
  ],
738
752
  events: [
739
753
  { key: 'name', label: 'Name' },
740
754
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
741
- { key: 'type', label: 'Type', width: 'w-20' },
742
- { key: 'reason', label: 'Reason', width: 'w-28' },
755
+ { key: 'type', label: 'Type', width: 'w-24' },
756
+ { key: 'reason', label: 'Reason', width: 'w-32' },
743
757
  { key: 'message', label: 'Message', width: 'w-64' },
744
758
  { key: 'object', label: 'Object', width: 'w-48', hideOnMobile: true },
745
- { key: 'count', label: 'Count', width: 'w-16' },
746
- { key: 'lastSeen', label: 'Last Seen', width: 'w-24' },
759
+ { key: 'count', label: 'Count', width: 'w-20' },
760
+ { key: 'lastSeen', label: 'Last Seen', width: 'w-28' },
747
761
  ],
748
762
  // ============================================================================
749
763
  // FLUXCD GITOPS RESOURCES
@@ -791,6 +805,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
791
805
  { key: 'chart', label: 'Chart', width: 'w-40' },
792
806
  { key: 'version', label: 'Version', width: 'w-24' },
793
807
  { key: 'status', label: 'Status', width: 'w-24' },
808
+ { key: 'message', label: 'Message', width: 'w-64', hideOnMobile: true, tooltip: 'Last diagnostic message — distinguishes dependency-wait, install/upgrade failure, and test failure' },
794
809
  { key: 'revision', label: 'Rev', width: 'w-16', hideOnMobile: true, tooltip: 'Helm release revision number' },
795
810
  { key: 'lastUpdated', label: 'Last Updated', width: 'w-28', tooltip: 'Time since last successful reconciliation' },
796
811
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -932,7 +947,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
932
947
  { key: 'store', label: 'Store', width: 'w-36' },
933
948
  { key: 'provider', label: 'Provider', width: 'w-28' },
934
949
  { key: 'refreshInterval', label: 'Refresh', width: 'w-24' },
935
- { key: 'lastSync', label: 'Last Sync', width: 'w-24' },
950
+ { key: 'lastSync', label: 'Last Sync', width: 'w-28' },
936
951
  { key: 'age', label: 'Age', width: 'w-24' },
937
952
  ],
938
953
  clusterexternalsecrets: [
@@ -966,7 +981,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
966
981
  { key: 'namespaces', label: 'Scope', width: 'w-24', tooltip: 'Included namespaces (* = all)' },
967
982
  { key: 'duration', label: 'Duration', width: 'w-24' },
968
983
  { key: 'expiry', label: 'Expires', width: 'w-24' },
969
- { key: 'errors', label: 'Errors', width: 'w-16' },
984
+ { key: 'errors', label: 'Errors', width: 'w-20' },
970
985
  { key: 'age', label: 'Age', width: 'w-24' },
971
986
  ],
972
987
  restores: [
@@ -975,7 +990,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
975
990
  { key: 'status', label: 'Status', width: 'w-28' },
976
991
  { key: 'backupName', label: 'Backup', width: 'w-40' },
977
992
  { key: 'duration', label: 'Duration', width: 'w-24' },
978
- { key: 'errors', label: 'Errors', width: 'w-16' },
993
+ { key: 'errors', label: 'Errors', width: 'w-20' },
979
994
  { key: 'age', label: 'Age', width: 'w-24' },
980
995
  ],
981
996
  schedules: [
@@ -1004,10 +1019,10 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1004
1019
  { key: 'name', label: 'Name' },
1005
1020
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1006
1021
  { key: 'status', label: 'Status', width: 'w-28' },
1007
- { key: 'instances', label: 'Instances', width: 'w-24', tooltip: 'Ready/Total' },
1022
+ { key: 'instances', label: 'Instances', width: 'w-28', tooltip: 'Ready/Total' },
1008
1023
  { key: 'primary', label: 'Primary', width: 'w-36' },
1009
1024
  { key: 'image', label: 'Image', width: 'w-28' },
1010
- { key: 'storage', label: 'Storage', width: 'w-20' },
1025
+ { key: 'storage', label: 'Storage', width: 'w-28' },
1011
1026
  { key: 'age', label: 'Age', width: 'w-24' },
1012
1027
  ],
1013
1028
  scheduledbackups: [
@@ -1016,8 +1031,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1016
1031
  { key: 'status', label: 'Status', width: 'w-24' },
1017
1032
  { key: 'cluster', label: 'Cluster', width: 'w-36' },
1018
1033
  { key: 'schedule', label: 'Schedule', width: 'w-36' },
1019
- { key: 'lastSchedule', label: 'Last Run', width: 'w-24' },
1020
- { key: 'suspended', label: 'Suspended', width: 'w-20' },
1034
+ { key: 'lastSchedule', label: 'Last Run', width: 'w-28' },
1035
+ { key: 'suspended', label: 'Suspended', width: 'w-28' },
1021
1036
  { key: 'age', label: 'Age', width: 'w-24' },
1022
1037
  ],
1023
1038
  poolers: [
@@ -1026,8 +1041,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1026
1041
  { key: 'status', label: 'Status', width: 'w-24' },
1027
1042
  { key: 'cluster', label: 'Cluster', width: 'w-36' },
1028
1043
  { key: 'type', label: 'Type', width: 'w-16' },
1029
- { key: 'poolMode', label: 'Pool Mode', width: 'w-28' },
1030
- { key: 'instances', label: 'Instances', width: 'w-24', tooltip: 'Ready/Total' },
1044
+ { key: 'poolMode', label: 'Pool Mode', width: 'w-32' },
1045
+ { key: 'instances', label: 'Instances', width: 'w-28', tooltip: 'Ready/Total' },
1031
1046
  { key: 'age', label: 'Age', width: 'w-24' },
1032
1047
  ],
1033
1048
  // ============================================================================
@@ -1101,7 +1116,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1101
1116
  { key: 'status', label: 'Status', width: 'w-28' },
1102
1117
  { key: 'routing', label: 'Traffic', width: 'w-20', tooltip: 'Whether this revision is receiving traffic' },
1103
1118
  { key: 'image', label: 'Image', width: 'w-48' },
1104
- { key: 'concurrency', label: 'Concurrency', width: 'w-24' },
1119
+ { key: 'concurrency', label: 'Concurrency', width: 'w-32' },
1105
1120
  { key: 'age', label: 'Age', width: 'w-24' },
1106
1121
  ],
1107
1122
  knativeroutes: [
@@ -1363,7 +1378,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1363
1378
  { key: 'namespace', label: 'Namespace', width: 'w-36 shrink-0' },
1364
1379
  { key: 'cluster', label: 'Cluster', width: 'w-32 shrink-0' },
1365
1380
  { key: 'ready', label: 'Ready', width: 'w-20 shrink-0' },
1366
- { key: 'initialized', label: 'Initialized', width: 'w-24 shrink-0' },
1381
+ { key: 'initialized', label: 'Initialized', width: 'w-28 shrink-0' },
1367
1382
  { key: 'version', label: 'Version', width: 'w-24 shrink-0' },
1368
1383
  { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
1369
1384
  ],
@@ -1513,6 +1528,43 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1513
1528
  { key: 'status', label: 'Status', width: 'w-28 shrink-0' },
1514
1529
  { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
1515
1530
  ],
1531
+ // Crossplane — Managed Resources are unbounded (one kind per provider CRD);
1532
+ // routed via isLikelyCrossplaneMRGroup, not by exact kind plural.
1533
+ crossplanemanagedresources: [
1534
+ { key: 'name', label: 'Name', width: 'min-w-40' },
1535
+ { key: 'namespace', label: 'Namespace', width: 'w-32 shrink-0' },
1536
+ { key: 'kind', label: 'Kind', width: 'w-32 shrink-0' },
1537
+ { key: 'external', label: 'External Name', width: 'min-w-48', hideOnMobile: true },
1538
+ { key: 'provider', label: 'Provider Config', width: 'w-40 shrink-0', hideOnMobile: true },
1539
+ { key: 'status', label: 'Status', width: 'w-32 shrink-0' },
1540
+ { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
1541
+ ],
1542
+ providers: [
1543
+ { key: 'name', label: 'Name', width: 'min-w-40' },
1544
+ { key: 'package', label: 'Package', width: 'min-w-48' },
1545
+ { key: 'revision', label: 'Revision', width: 'w-36 shrink-0', hideOnMobile: true },
1546
+ { key: 'status', label: 'Status', width: 'w-32 shrink-0' },
1547
+ { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
1548
+ ],
1549
+ providerconfigs: [
1550
+ { key: 'name', label: 'Name', width: 'min-w-40' },
1551
+ { key: 'credentials', label: 'Credentials', width: 'w-36 shrink-0' },
1552
+ { key: 'status', label: 'Status', width: 'w-32 shrink-0' },
1553
+ { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
1554
+ ],
1555
+ compositions: [
1556
+ { key: 'name', label: 'Name', width: 'min-w-40' },
1557
+ { key: 'composite', label: 'Composite Kind', width: 'w-44 shrink-0' },
1558
+ { key: 'mode', label: 'Mode', width: 'w-24 shrink-0' },
1559
+ { key: 'functions', label: 'Functions', width: 'w-28 shrink-0', hideOnMobile: true },
1560
+ { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
1561
+ ],
1562
+ compositeresourcedefinitions: [
1563
+ { key: 'name', label: 'Name', width: 'min-w-40' },
1564
+ { key: 'kind', label: 'Kind', width: 'w-40 shrink-0' },
1565
+ { key: 'claim', label: 'Claim Kind', width: 'w-40 shrink-0', hideOnMobile: true },
1566
+ { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
1567
+ ],
1516
1568
  }
1517
1569
 
1518
1570
  // Map (plural, group) → KNOWN_COLUMNS key for kinds that collide with core K8s
@@ -1545,8 +1597,32 @@ function normalizeKindToPlural(kind: string, group?: string): string {
1545
1597
  return lower
1546
1598
  }
1547
1599
 
1600
+ // Crossplane Managed Resources are unbounded (each provider ships its own
1601
+ // CRDs under per-service groups like s3.aws.upbound.io, compute.gcp.upbound.io).
1602
+ // Route any non-core-Crossplane resource in these groups to the generic MR
1603
+ // column set. ProviderConfig is excluded — those have their own renderer.
1604
+ function isLikelyCrossplaneMRGroup(kind: string, group: string): boolean {
1605
+ if (!group) return false
1606
+ const k = kind.toLowerCase()
1607
+ if (k === 'providerconfig' || k === 'providerconfigs') return false
1608
+ if (group.endsWith('.upbound.io')) return true
1609
+ if (group === 'kubernetes.crossplane.io' || group === 'helm.crossplane.io') return true
1610
+ // Reserved Crossplane core groups — never MRs.
1611
+ if (group === 'crossplane.io' || group === 'pkg.crossplane.io' || group === 'apiextensions.crossplane.io') {
1612
+ return false
1613
+ }
1614
+ // Any other *.crossplane.io subgroup is presumed to be a provider's MR group.
1615
+ if (group.endsWith('.crossplane.io')) return true
1616
+ return false
1617
+ }
1618
+
1548
1619
  function getColumnsForKind(kind: string, group?: string): Column[] {
1549
- return KNOWN_COLUMNS[normalizeKindToPlural(kind, group)] || DEFAULT_COLUMNS
1620
+ const key = normalizeKindToPlural(kind, group)
1621
+ if (KNOWN_COLUMNS[key]) return KNOWN_COLUMNS[key]
1622
+ if (group && isLikelyCrossplaneMRGroup(kind, group)) {
1623
+ return KNOWN_COLUMNS.crossplanemanagedresources
1624
+ }
1625
+ return DEFAULT_COLUMNS
1550
1626
  }
1551
1627
 
1552
1628
  // Get the default visible columns for a kind
@@ -1679,6 +1755,26 @@ interface ResourcesViewProps {
1679
1755
  * should also wire onResourceClick to handle the deep-link-on-load
1680
1756
  * case. */
1681
1757
  onRowSelect?: (resource: any) => void
1758
+ /**
1759
+ * Overrides the default compare-mode submit (which navigates to
1760
+ * `/compare?kind=...&a=...&b=...`). Hosts use this to route to a
1761
+ * different URL — e.g. Radar Hub's `/fleet/compare` with cluster IDs.
1762
+ * Picks arrive in click order, each carrying `clusterId`/`clusterName`
1763
+ * when `resolveRowCluster` is set (which is what keeps cross-cluster
1764
+ * picks distinct in the equality check).
1765
+ */
1766
+ onCompareSubmit?: (
1767
+ picks: NamespacedRef[],
1768
+ kind: { name: string; group?: string },
1769
+ ) => void
1770
+ /**
1771
+ * Resolves the cluster scope for a row when compare-mode is enabled.
1772
+ * Stamps `clusterId`/`clusterName` onto each pick so the same `ns/name`
1773
+ * in two different clusters is treated as two distinct picks. Hub-web
1774
+ * returns `row._cluster` here; OSS leaves it unset and picks key on
1775
+ * namespace+name only.
1776
+ */
1777
+ resolveRowCluster?: (resource: any) => { id: string; name: string } | undefined
1682
1778
  }
1683
1779
 
1684
1780
  // Default selected kind
@@ -1823,6 +1919,8 @@ export function ResourcesView({
1823
1919
  defaultKind = DEFAULT_KIND_INFO,
1824
1920
  extraLeadingColumns,
1825
1921
  onRowSelect,
1922
+ onCompareSubmit,
1923
+ resolveRowCluster,
1826
1924
  }: ResourcesViewProps) {
1827
1925
  const initialFilters = getInitialFiltersFromURL()
1828
1926
  const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath, defaultKind, locationPathname, locationSearch))
@@ -1966,6 +2064,13 @@ export function ResourcesView({
1966
2064
 
1967
2065
  useEffect(() => {
1968
2066
  const saved = loadColumnSettings(selectedKind.name, selectedKind.group)
2067
+ // Host-injected extra columns (e.g. fleet Cluster) default to visible
2068
+ // even when the saved column-visibility blob predates them — a naive
2069
+ // Set(saved.visible) would silently hide host columns the user has
2070
+ // never been shown. Trade-off: a user can't permanently hide a host
2071
+ // extra column via the column picker — next mount re-adds it. Track
2072
+ // a sibling "hidden-extras" set if this becomes a real complaint.
2073
+ const extraKeys = extraLeadingColumns?.map(c => c.key) ?? []
1969
2074
  if (saved) {
1970
2075
  // If saved columns are just the defaults but this kind has specialized columns,
1971
2076
  // discard the stale save and use the specialized columns instead
@@ -1978,14 +2083,16 @@ export function ResourcesView({
1978
2083
  setVisibleColumns(getDefaultVisibleColumns(allColumns))
1979
2084
  setColumnWidths({})
1980
2085
  } else {
1981
- setVisibleColumns(new Set(saved.visible))
2086
+ const merged = new Set(saved.visible)
2087
+ for (const k of extraKeys) merged.add(k)
2088
+ setVisibleColumns(merged)
1982
2089
  setColumnWidths(saved.widths || {})
1983
2090
  }
1984
2091
  } else {
1985
2092
  setVisibleColumns(getDefaultVisibleColumns(allColumns))
1986
2093
  setColumnWidths({})
1987
2094
  }
1988
- }, [selectedKind.name, selectedKind.group, allColumns])
2095
+ }, [selectedKind.name, selectedKind.group, allColumns, extraLeadingColumns])
1989
2096
 
1990
2097
  // Save column settings when they change (skip initial load)
1991
2098
  const isColumnSettingsLoaded = useRef(false)
@@ -2121,6 +2228,56 @@ export function ResourcesView({
2121
2228
  const [highlightedIndex, setHighlightedIndex] = useState(-1)
2122
2229
  const virtuosoRef = useRef<TableVirtuosoHandle>(null)
2123
2230
 
2231
+ // Compare mode is only meaningful when the host can route picks. Either
2232
+ // path qualifies: onNavigate for the default same-cluster `/compare?...`
2233
+ // URL, or onCompareSubmit for hosts that build their own URL (e.g.
2234
+ // Radar Hub's `/fleet/compare` with cluster IDs).
2235
+ const compareEnabled = !!onNavigate || !!onCompareSubmit
2236
+ const [compareMode, setCompareMode] = useState(false)
2237
+ const [comparePicks, setComparePicks] = useState<CompareTrayPick[]>([])
2238
+
2239
+ // Kind change invalidates picks AND mode — leaving the tray on with empty
2240
+ // pills after the user moves to a different kind is more confusing than helpful.
2241
+ useEffect(() => {
2242
+ setComparePicks([])
2243
+ setCompareMode(false)
2244
+ }, [selectedKind.name, selectedKind.group])
2245
+
2246
+ const exitCompareMode = useCallback(() => {
2247
+ setCompareMode(false)
2248
+ setComparePicks([])
2249
+ }, [])
2250
+
2251
+ const toggleComparePick = useCallback((resource: any) => {
2252
+ if (!resource?.metadata?.name) return
2253
+ const ns = resource.metadata.namespace || ''
2254
+ // Stamp clusterId on the pick when the host provides a resolver
2255
+ // (cross-cluster compare). Without it, two rows with the same ns+name
2256
+ // from different clusters would collapse into one pick.
2257
+ const cluster = resolveRowCluster?.(resource)
2258
+ setComparePicks(prev => togglePick(prev, {
2259
+ namespace: ns,
2260
+ name: resource.metadata.name,
2261
+ clusterId: cluster?.id,
2262
+ clusterName: cluster?.name,
2263
+ }))
2264
+ }, [resolveRowCluster])
2265
+
2266
+ const handleCompareNavigate = useCallback(() => {
2267
+ if (comparePicks.length !== 2) return
2268
+ if (onCompareSubmit) {
2269
+ onCompareSubmit(comparePicks, { name: selectedKind.name, group: selectedKind.group })
2270
+ return
2271
+ }
2272
+ if (!onNavigate) return
2273
+ const params = new URLSearchParams()
2274
+ params.set('kind', selectedKind.name)
2275
+ if (selectedKind.group) params.set('apiGroup', selectedKind.group)
2276
+ params.set('a', refToParam(comparePicks[0]))
2277
+ params.set('b', refToParam(comparePicks[1]))
2278
+ onNavigate(`/compare?${params.toString()}`)
2279
+ }, [comparePicks, selectedKind.name, selectedKind.group, onNavigate, onCompareSubmit])
2280
+
2124
2281
  // Reset highlight when kind, search, sort, or namespace changes
2125
2282
  const namespacesKey = namespaces.join(',')
2126
2283
  useEffect(() => { setHighlightedIndex(-1) }, [selectedKind.name, searchTerm, sortColumn, sortDirection, namespacesKey])
@@ -2226,7 +2383,11 @@ export function ResourcesView({
2226
2383
  description: 'Open resource detail',
2227
2384
  category: 'Resource Actions',
2228
2385
  scope: 'resources',
2229
- handler: () => selectResource(getHighlightedResource()),
2386
+ handler: () => {
2387
+ const res = getHighlightedResource()
2388
+ if (compareMode) toggleComparePick(res)
2389
+ else selectResource(res)
2390
+ },
2230
2391
  enabled: highlightedIndex >= 0,
2231
2392
  },
2232
2393
  {
@@ -2235,7 +2396,11 @@ export function ResourcesView({
2235
2396
  description: 'Open resource detail',
2236
2397
  category: 'Resource Actions',
2237
2398
  scope: 'resources',
2238
- handler: () => selectResource(getHighlightedResource()),
2399
+ handler: () => {
2400
+ const res = getHighlightedResource()
2401
+ if (compareMode) toggleComparePick(res)
2402
+ else selectResource(res)
2403
+ },
2239
2404
  enabled: highlightedIndex >= 0,
2240
2405
  },
2241
2406
  {
@@ -2250,7 +2415,7 @@ export function ResourcesView({
2250
2415
  const cb = onResourceClickYaml || onResourceClick
2251
2416
  cb?.({ kind: selectedKind.name, namespace: res.metadata.namespace || '', name: res.metadata.name, group: selectedKind.group })
2252
2417
  },
2253
- enabled: highlightedIndex >= 0,
2418
+ enabled: highlightedIndex >= 0 && !compareMode,
2254
2419
  },
2255
2420
  {
2256
2421
  id: 'resources-logs',
@@ -2274,7 +2439,7 @@ export function ResourcesView({
2274
2439
  openWorkloadLogs?.({ namespace: ns, workloadKind: selectedKind.kind, workloadName: name })
2275
2440
  }
2276
2441
  },
2277
- enabled: highlightedIndex >= 0 && ['pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs'].includes(selectedKind.name.toLowerCase()),
2442
+ enabled: highlightedIndex >= 0 && !compareMode && ['pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs'].includes(selectedKind.name.toLowerCase()),
2278
2443
  },
2279
2444
  {
2280
2445
  id: 'resources-sort-name',
@@ -2307,11 +2472,11 @@ export function ResourcesView({
2307
2472
  category: 'Table',
2308
2473
  scope: 'resources',
2309
2474
  handler: () => {
2310
- // Close dropdowns first, then clear highlight, then blur search
2311
2475
  if (showColumnPicker) { setShowColumnPicker(false); return }
2312
2476
  if (openColumnFilter) { setOpenColumnFilter(null); return }
2313
2477
  if (showProblemsDropdown) { setShowProblemsDropdown(false); return }
2314
2478
  if (showLabelsDropdown) { setShowLabelsDropdown(false); return }
2479
+ if (compareMode) { exitCompareMode(); return }
2315
2480
  if (highlightedIndex >= 0) setHighlightedIndex(-1)
2316
2481
  else searchInputRef.current?.blur()
2317
2482
  },
@@ -3156,6 +3321,14 @@ export function ResourcesView({
3156
3321
  style={{ ...props.style, tableLayout: 'fixed' }}
3157
3322
  >
3158
3323
  <colgroup>
3324
+ {/*
3325
+ Compare-mode adds a leading <th>/<td> per row but isn't part
3326
+ of `columns`. Under table-layout:fixed the <colgroup> must
3327
+ match the actual column count, otherwise the browser pads
3328
+ the missing entry by stealing width from a sized neighbour
3329
+ — typically blowing this narrow column out to ~200px.
3330
+ */}
3331
+ {compareMode && <col style={{ width: 36 }} />}
3159
3332
  {columns.map(col => (
3160
3333
  <col
3161
3334
  key={col.key}
@@ -3173,7 +3346,7 @@ export function ResourcesView({
3173
3346
  )
3174
3347
  }),
3175
3348
  TableRow: VirtuosoTableRow,
3176
- }), [columns, columnWidths, hasResizedColumns])
3349
+ }), [columns, columnWidths, hasResizedColumns, compareMode])
3177
3350
 
3178
3351
  // Calculate filter options with counts based on current resources (before filtering)
3179
3352
  const filterOptions = useMemo(() => {
@@ -3213,10 +3386,7 @@ export function ResourcesView({
3213
3386
  }
3214
3387
 
3215
3388
  const distinctCount = Object.keys(valueCounts).length
3216
- // Only include if 2-20 distinct values (too few = useless, too many = not a filter)
3217
- // Node column gets a higher cap (50) since clusters commonly have 20-50 nodes
3218
- const maxDistinct = col.key === 'node' ? 50 : 20
3219
- if (distinctCount >= 2 && distinctCount <= maxDistinct) {
3389
+ if (isColumnFilterableByDistinctCount(col.key, distinctCount)) {
3220
3390
  filterableColumns.push({
3221
3391
  key: col.key,
3222
3392
  label: col.label,
@@ -3648,6 +3818,22 @@ export function ResourcesView({
3648
3818
  </button>
3649
3819
  </Tooltip>
3650
3820
  )}
3821
+ {compareEnabled && (
3822
+ <Tooltip content={compareMode ? 'Exit compare mode' : 'Compare two resources side-by-side'}>
3823
+ <button
3824
+ onClick={() => (compareMode ? exitCompareMode() : setCompareMode(true))}
3825
+ aria-pressed={compareMode}
3826
+ className={clsx(
3827
+ 'p-2 rounded-lg transition-colors',
3828
+ compareMode
3829
+ ? 'bg-skyhook-500/15 text-skyhook-300 border border-skyhook-400/50'
3830
+ : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated',
3831
+ )}
3832
+ >
3833
+ <GitCompare className="w-4 h-4" />
3834
+ </button>
3835
+ </Tooltip>
3836
+ )}
3651
3837
  </div>
3652
3838
 
3653
3839
  {/* Table */}
@@ -3750,8 +3936,20 @@ export function ResourcesView({
3750
3936
  components={virtuosoComponents}
3751
3937
  fixedHeaderContent={() => (
3752
3938
  <tr>
3939
+ {compareMode && (
3940
+ <th
3941
+ // Inline px width — under `table-layout:fixed`,
3942
+ // `w-9` is a hint the browser absorbs into leftover
3943
+ // row width on an icon-only column.
3944
+ style={{ width: 36, minWidth: 36, maxWidth: 36 }}
3945
+ className="px-2 py-3 text-xs font-medium uppercase tracking-wide bg-theme-base border-b border-r-subtle border-theme-border text-center text-skyhook-400"
3946
+ title="Compare mode"
3947
+ >
3948
+ <GitCompare className="w-3.5 h-3.5 inline-block opacity-70" />
3949
+ </th>
3950
+ )}
3753
3951
  {columns.map((col, colIdx) => {
3754
- const isSortable = ['name', 'namespace', 'age', 'status', 'ready', 'restarts', 'type', 'version', 'desired', 'available', 'upToDate', 'lastSeen', 'count', 'reason', 'object', 'cpu', 'memory'].includes(col.key)
3952
+ const isSortable = ['name', 'namespace', 'age', 'status', 'ready', 'restarts', 'type', 'version', 'desired', 'available', 'upToDate', 'lastSeen', 'count', 'reason', 'object', 'cpu', 'memory', 'containers'].includes(col.key)
3755
3953
  const isSorted = sortColumn === col.key
3756
3954
  const isLastCol = colIdx === columns.length - 1
3757
3955
  const filterCol = filterableColumnMap.get(col.key)
@@ -3809,7 +4007,7 @@ export function ResourcesView({
3809
4007
  ? 'px-1.5 py-0.5 -my-0.5 selection-strong selection-text hover:bg-skyhook-500/30'
3810
4008
  : isFilterOpen
3811
4009
  ? 'p-0.5 text-theme-text-primary'
3812
- : 'p-0.5 text-theme-text-disabled opacity-0 group-hover/th:opacity-100 hover:text-theme-text-primary'
4010
+ : 'p-0.5 text-theme-text-disabled opacity-40 group-hover/th:opacity-100 hover:text-theme-text-primary'
3813
4011
  )}
3814
4012
  >
3815
4013
  <ListFilter className="w-3 h-3" />
@@ -3921,6 +4119,13 @@ export function ResourcesView({
3921
4119
  selectedResource?.namespace === resource.metadata?.namespace &&
3922
4120
  selectedResource?.name === resource.metadata?.name
3923
4121
  const isHighlighted = index === highlightedIndex
4122
+ const pickIdx = compareMode
4123
+ ? pickIndex(comparePicks, {
4124
+ namespace: resource.metadata?.namespace || '',
4125
+ name: resource.metadata?.name || '',
4126
+ clusterId: resolveRowCluster?.(resource)?.id,
4127
+ })
4128
+ : -1
3924
4129
  return (
3925
4130
  <ResourceRowCells
3926
4131
  resource={resource}
@@ -3932,8 +4137,10 @@ export function ResourcesView({
3932
4137
  isSelected={isSelected}
3933
4138
  isHighlighted={isHighlighted}
3934
4139
  majorityNodeMinorVersion={majorityNodeMinorVersion}
3935
- onClick={() => selectResource(resource, isSelected)}
4140
+ onClick={() => compareMode ? toggleComparePick(resource) : selectResource(resource, isSelected)}
3936
4141
  onMouseEnter={() => setHighlightedIndex(-1)}
4142
+ compareMode={compareMode}
4143
+ comparePickIndex={pickIdx}
3937
4144
  />
3938
4145
  )
3939
4146
  }}
@@ -3949,6 +4156,15 @@ export function ResourcesView({
3949
4156
  </MetricsContext.Provider>
3950
4157
  )}
3951
4158
  </div>
4159
+ {compareMode && compareEnabled && (
4160
+ <CompareTray
4161
+ kind={selectedKind.name}
4162
+ picks={comparePicks}
4163
+ onRemove={(idx) => setComparePicks(prev => prev.filter((_, i) => i !== idx))}
4164
+ onCompare={handleCompareNavigate}
4165
+ onExit={exitCompareMode}
4166
+ />
4167
+ )}
3952
4168
  </div>
3953
4169
  </div>
3954
4170
  </ResourcesViewDataContext.Provider>
@@ -3967,11 +4183,58 @@ interface ResourceRowCellsProps {
3967
4183
  majorityNodeMinorVersion?: string
3968
4184
  onClick?: () => void
3969
4185
  onMouseEnter?: () => void
4186
+ compareMode?: boolean
4187
+ /** -1 when not picked; 0 = pick A; 1 = pick B. */
4188
+ comparePickIndex?: number
4189
+ }
4190
+
4191
+ function rowHighlightClass(
4192
+ compareMode: boolean | undefined,
4193
+ comparePickIndex: number,
4194
+ isSelected: boolean | undefined,
4195
+ isHighlighted: boolean | undefined,
4196
+ ): string {
4197
+ if (compareMode) {
4198
+ if (comparePickIndex === 0) return `${SIDE_TONES.a.rowBg} ${SIDE_TONES.a.rowBgHover}`
4199
+ if (comparePickIndex === 1) return `${SIDE_TONES.b.rowBg} ${SIDE_TONES.b.rowBgHover}`
4200
+ if (isHighlighted) return 'selection selection-ring'
4201
+ return 'group-hover/row:bg-theme-surface/50'
4202
+ }
4203
+ if (isSelected) return 'selection-strong group-hover/row:bg-skyhook-500/30'
4204
+ if (isHighlighted) return 'selection selection-ring'
4205
+ return 'group-hover/row:bg-theme-surface/50'
3970
4206
  }
3971
4207
 
3972
- function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter }: ResourceRowCellsProps) {
4208
+ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter, compareMode, comparePickIndex = -1 }: ResourceRowCellsProps) {
4209
+ const rowHighlight = rowHighlightClass(compareMode, comparePickIndex, isSelected, isHighlighted)
4210
+ const pickedSide = comparePickIndex === 0 ? 'a' : comparePickIndex === 1 ? 'b' : null
3973
4211
  return (
3974
4212
  <>
4213
+ {compareMode && (
4214
+ <td
4215
+ onClick={onClick}
4216
+ onMouseEnter={onMouseEnter}
4217
+ style={{ width: 36, minWidth: 36, maxWidth: 36 }}
4218
+ className={clsx('px-2 py-3 border-b-subtle cursor-pointer text-center align-middle transition-colors', rowHighlight)}
4219
+ >
4220
+ {pickedSide ? (
4221
+ <span
4222
+ className={clsx(
4223
+ 'inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold leading-none',
4224
+ SIDE_TONES[pickedSide].chipBg,
4225
+ )}
4226
+ aria-label={pickedSide === 'a' ? 'Pick A' : 'Pick B'}
4227
+ >
4228
+ {pickedSide === 'a' ? 'A' : 'B'}
4229
+ </span>
4230
+ ) : (
4231
+ <span
4232
+ className="inline-block w-4 h-4 rounded border border-theme-border-light group-hover/row:border-skyhook-400/60 transition-colors"
4233
+ aria-hidden
4234
+ />
4235
+ )}
4236
+ </td>
4237
+ )}
3975
4238
  {columns.map((col) => (
3976
4239
  <td
3977
4240
  key={col.key}
@@ -3980,11 +4243,7 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
3980
4243
  className={clsx(
3981
4244
  'px-4 py-3 border-b-subtle cursor-pointer transition-colors',
3982
4245
  col.key !== 'status' && 'overflow-hidden truncate',
3983
- isSelected
3984
- ? 'selection-strong group-hover/row:bg-skyhook-500/30'
3985
- : isHighlighted
3986
- ? 'selection selection-ring'
3987
- : 'group-hover/row:bg-theme-surface/50'
4246
+ rowHighlight,
3988
4247
  )}
3989
4248
  >
3990
4249
  <CellContent resource={resource} kind={kind} group={group} column={col.key} majorityNodeMinorVersion={majorityNodeMinorVersion} extraColumn={extraColumnsByKey?.get(col.key)} />
@@ -4405,7 +4664,29 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
4405
4664
  return <AzureMachineTemplateCell resource={resource} column={column} />
4406
4665
  case 'azuremanagedclusters':
4407
4666
  return <AzureManagedClusterCell resource={resource} column={column} />
4667
+ // Crossplane
4668
+ case 'providers':
4669
+ // Disambiguate from any future kind named "providers" by checking group
4670
+ if (resource.apiVersion?.startsWith('pkg.crossplane.io/')) {
4671
+ return <CrossplaneProviderCell resource={resource} column={column} />
4672
+ }
4673
+ return <GenericCell resource={resource} column={column} />
4674
+ case 'providerconfigs':
4675
+ return <CrossplaneProviderConfigCell resource={resource} column={column} />
4676
+ case 'compositions':
4677
+ return <CompositionCell resource={resource} column={column} />
4678
+ case 'compositeresourcedefinitions':
4679
+ return <XRDCell resource={resource} column={column} />
4408
4680
  default:
4681
+ // Crossplane Managed Resources: unbounded plurals (one CRD per provider
4682
+ // service), detected via group prefix + spec.providerConfigRef heuristic.
4683
+ if (isLikelyCrossplaneMRGroup(kind, group ?? '') && isManagedResource(resource)) {
4684
+ return <ManagedResourceCell resource={resource} column={column} />
4685
+ }
4686
+ // Composite Resources (XRs) — user-defined kinds with resourceRefs.
4687
+ if (isComposite(resource)) {
4688
+ return <CompositeResourceCell resource={resource} column={column} />
4689
+ }
4409
4690
  // Generic cell for CRDs and unknown resources
4410
4691
  return <GenericCell resource={resource} column={column} />
4411
4692
  }