@skyhook-io/k8s-ui 1.6.1 → 1.7.0

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 (69) 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 +431 -96
  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/topology/K8sResourceNode.tsx +21 -2
  51. package/src/components/topology/TopologyGraph.tsx +46 -10
  52. package/src/components/topology/layout.ts +20 -11
  53. package/src/components/topology/layout.worker.ts +21 -17
  54. package/src/components/ui/YamlEditor.tsx +28 -15
  55. package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
  56. package/src/index.ts +6 -0
  57. package/src/perf/index.ts +1 -0
  58. package/src/perf/store.ts +110 -0
  59. package/src/types/core.ts +10 -0
  60. package/src/types/index.ts +1 -0
  61. package/src/types/rbac.ts +99 -0
  62. package/src/utils/api-resources.ts +9 -1
  63. package/src/utils/index.ts +1 -0
  64. package/src/utils/rbac-badges.ts +61 -0
  65. package/src/utils/rbac-blast-radius.test.ts +137 -0
  66. package/src/utils/rbac-blast-radius.ts +98 -0
  67. package/src/utils/resource-hierarchy.ts +13 -3
  68. package/src/utils/structure-hash.test.ts +94 -0
  69. package/src/utils/structure-hash.ts +35 -0
@@ -22,6 +22,8 @@ import {
22
22
  Copy,
23
23
  Check,
24
24
  Plus,
25
+ GitCompare,
26
+ Regex,
25
27
  } from 'lucide-react'
26
28
  import { clsx } from 'clsx'
27
29
  import { ResourceBar } from '../ui/ResourceBar'
@@ -137,6 +139,8 @@ import { PolicyReportCell, ClusterPolicyReportCell, KyvernoPolicyCell, ClusterPo
137
139
  import { ExternalSecretCell, ClusterExternalSecretCell, SecretStoreCell, ClusterSecretStoreCell } from './renderers/eso-cells'
138
140
  import { BackupCell, RestoreCell, ScheduleCell, BackupStorageLocationCell } from './renderers/velero-cells'
139
141
  import { CNPGClusterCell, CNPGBackupCell, CNPGScheduledBackupCell, CNPGPoolerCell } from './renderers/cnpg-cells'
142
+ import { ManagedResourceCell, CompositeResourceCell, CrossplaneProviderCell, CrossplaneProviderConfigCell, CompositionCell, XRDCell } from './renderers/crossplane-cells'
143
+ import { isManagedResource, isComposite } from './resource-utils-crossplane'
140
144
  import { VirtualServiceCell, DestinationRuleCell, IstioGatewayCell, ServiceEntryCell, PeerAuthenticationCell, AuthorizationPolicyCell } from './renderers/istio-cells'
141
145
  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
146
  import { IngressRouteCell, MiddlewareCell, TraefikServiceCell, ServersTransportCell, TLSOptionCell } from './renderers/traefik-cells'
@@ -148,6 +152,7 @@ import { AzureManagedControlPlaneCell, AzureManagedMachinePoolCell, AzureMachine
148
152
  import { useRegisterShortcut, useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
149
153
  import { ResourcesSidebar } from './ResourcesSidebar'
150
154
  import type { SelectedKindInfo } from './ResourcesSidebar'
155
+ import { CompareTray, togglePick, pickIndex, refToParam, SIDE_TONES, type CompareTrayPick, type NamespacedRef } from '../compare'
151
156
 
152
157
  // Pod problem filter options (special multi-select, not a single column value)
153
158
  const POD_PROBLEMS = ['CrashLoopBackOff', 'ImagePullBackOff', 'OOMKilled', 'Unschedulable', 'Not Ready', 'High Restarts', 'Init Failed', 'Exit Code Error', 'Failed', 'Other'] as const
@@ -155,7 +160,7 @@ const WORKLOAD_PROBLEMS = ['Unavailable', 'Rollout Stuck', 'Rollout In Progress'
155
160
  const WORKLOAD_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets'])
156
161
 
157
162
  // Columns to skip for auto-detected filters (high cardinality, text-like, or non-filterable)
158
- const SKIP_FILTER_COLUMNS = new Set([
163
+ export const SKIP_FILTER_COLUMNS = new Set([
159
164
  'name', 'age', 'keys', 'size', 'images', 'domains', 'hosts', 'rules',
160
165
  'ports', 'message', 'url', 'ref', 'revision', 'path', 'selector', 'ready', 'restarts',
161
166
  'completions', 'duration', 'schedule', 'lastRun', 'target', 'replicas', 'metrics',
@@ -163,12 +168,22 @@ const SKIP_FILTER_COLUMNS = new Set([
163
168
  'issuer', 'domain', 'presented', 'listeners', 'routes', 'addresses', 'hostnames',
164
169
  'parents', 'backends', 'controller', 'description', 'externalIP', 'address',
165
170
  'conditions', 'taints', 'desired', 'upToDate', 'available', 'owner',
166
- 'tls', 'endpoints', 'object', 'count', 'lastSeen', 'reason', 'source', 'inventory',
171
+ 'tls', 'endpoints', 'object', 'count', 'lastSeen', 'source', 'inventory',
167
172
  'lastUpdated', 'chart', 'events', 'repo',
168
173
  'generators', 'applications', 'destinations', 'sources', 'budget', 'healthy', 'allowed',
169
174
  'secrets', 'subjects', 'role', 'entrypoint', 'templates',
170
175
  ])
171
176
 
177
+ // Namespace/node cardinality scales with cluster size, not kind enum size;
178
+ // node keeps a generous cap as a sanity bound, namespace is uncapped.
179
+ export function isColumnFilterableByDistinctCount(colKey: string, distinctCount: number): boolean {
180
+ if (SKIP_FILTER_COLUMNS.has(colKey)) return false
181
+ const maxDistinct = colKey === 'namespace'
182
+ ? Number.POSITIVE_INFINITY
183
+ : colKey === 'node' ? 200 : 30
184
+ return distinctCount >= 2 && distinctCount <= maxDistinct
185
+ }
186
+
172
187
  // Column definitions per resource kind
173
188
  interface Column {
174
189
  key: string
@@ -233,11 +248,11 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
233
248
  pods: [
234
249
  { key: 'name', label: 'Name' },
235
250
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
236
- { key: 'containers', label: 'Containers', width: 'w-28' },
251
+ { key: 'containers', label: 'Containers', width: 'w-32' },
237
252
  { key: 'status', label: 'Status', width: 'w-40' },
238
253
  { key: 'cpu', label: 'CPU', width: 'w-40', tooltip: 'CPU usage / limit (marker = request)' },
239
254
  { key: 'memory', label: 'Memory', width: 'w-40', tooltip: 'Memory usage / limit (marker = request)' },
240
- { key: 'restarts', label: 'Restarts', width: 'w-24' },
255
+ { key: 'restarts', label: 'Restarts', width: 'w-28' },
241
256
  { key: 'podIP', label: 'Pod IP', width: 'w-32', defaultVisible: false },
242
257
  { key: 'node', label: 'Node', width: 'w-44', hideOnMobile: true },
243
258
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -246,8 +261,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
246
261
  { key: 'name', label: 'Name' },
247
262
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
248
263
  { 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)' },
264
+ { key: 'upToDate', label: 'Up-to-date', width: 'w-32', hideOnMobile: true, tooltip: 'Number of pods running the current pod template' },
265
+ { key: 'available', label: 'Available', width: 'w-28', hideOnMobile: true, tooltip: 'Number of pods available (ready for minReadySeconds)' },
251
266
  { key: 'images', label: 'Images', width: 'w-48', hideOnMobile: true },
252
267
  { key: 'age', label: 'Age', width: 'w-24' },
253
268
  ],
@@ -256,8 +271,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
256
271
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
257
272
  { key: 'desired', label: 'Desired', width: 'w-20', tooltip: 'Number of nodes that should run the daemon pod (based on node selector)' },
258
273
  { 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)' },
274
+ { key: 'upToDate', label: 'Up-to-date', width: 'w-32', hideOnMobile: true, tooltip: 'Number of pods running the current pod template spec' },
275
+ { key: 'available', label: 'Available', width: 'w-28', hideOnMobile: true, tooltip: 'Number of pods available (ready for minReadySeconds duration)' },
261
276
  { key: 'images', label: 'Images', width: 'w-48', hideOnMobile: true },
262
277
  { key: 'age', label: 'Age', width: 'w-24' },
263
278
  ],
@@ -265,7 +280,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
265
280
  { key: 'name', label: 'Name' },
266
281
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
267
282
  { 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' },
283
+ { key: 'upToDate', label: 'Up-to-date', width: 'w-32', hideOnMobile: true, tooltip: 'Number of pods running the current pod template' },
269
284
  { key: 'images', label: 'Images', width: 'w-48', hideOnMobile: true },
270
285
  { key: 'age', label: 'Age', width: 'w-24' },
271
286
  ],
@@ -328,7 +343,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
328
343
  { key: 'name', label: 'Name' },
329
344
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
330
345
  { key: 'status', label: 'Status', width: 'w-28' },
331
- { key: 'completions', label: 'Completions', width: 'w-28' },
346
+ { key: 'completions', label: 'Completions', width: 'w-32' },
332
347
  { key: 'duration', label: 'Duration', width: 'w-24', hideOnMobile: true },
333
348
  { key: 'age', label: 'Age', width: 'w-24' },
334
349
  ],
@@ -363,7 +378,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
363
378
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
364
379
  { key: 'status', label: 'Status', width: 'w-24' },
365
380
  { key: 'capacity', label: 'Capacity', width: 'w-24' },
366
- { key: 'storageClass', label: 'Storage Class', width: 'w-36', hideOnMobile: true },
381
+ { key: 'storageClass', label: 'Storage Class', width: 'w-40', hideOnMobile: true },
367
382
  { key: 'accessModes', label: 'Access', width: 'w-20', tooltip: 'Access modes: RWO=ReadWriteOnce, RWX=ReadWriteMany, ROX=ReadOnlyMany' },
368
383
  { key: 'volume', label: 'Volume', width: 'w-48', hideOnMobile: true },
369
384
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -402,7 +417,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
402
417
  { key: 'capacity', label: 'Capacity', width: 'w-24' },
403
418
  { key: 'accessModes', label: 'Access', width: 'w-20', tooltip: 'RWO=ReadWriteOnce, ROX=ReadOnlyMany, RWX=ReadWriteMany' },
404
419
  { key: 'reclaimPolicy', label: 'Reclaim', width: 'w-20' },
405
- { key: 'storageClass', label: 'Storage Class', width: 'w-36', hideOnMobile: true },
420
+ { key: 'storageClass', label: 'Storage Class', width: 'w-40', hideOnMobile: true },
406
421
  { key: 'claim', label: 'Claim', width: 'w-48', hideOnMobile: true },
407
422
  { key: 'age', label: 'Age', width: 'w-24' },
408
423
  ],
@@ -667,8 +682,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
667
682
  serviceaccounts: [
668
683
  { key: 'name', label: 'Name' },
669
684
  { 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' },
685
+ { key: 'automount', label: 'Automount', width: 'w-32', tooltip: 'Whether token is automatically mounted in pods' },
686
+ { key: 'secrets', label: 'Secrets', width: 'w-24' },
672
687
  { key: 'age', label: 'Age', width: 'w-24' },
673
688
  ],
674
689
  roles: [
@@ -705,13 +720,13 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
705
720
  { key: 'name', label: 'Name' },
706
721
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
707
722
  { key: 'holder', label: 'Holder', width: 'w-48' },
708
- { key: 'renewTime', label: 'Last Renewed', width: 'w-28' },
723
+ { key: 'renewTime', label: 'Last Renewed', width: 'w-32' },
709
724
  { key: 'age', label: 'Age', width: 'w-24' },
710
725
  ],
711
726
  priorityclasses: [
712
727
  { key: 'name', label: 'Name' },
713
728
  { key: 'value', label: 'Value', width: 'w-24' },
714
- { key: 'globalDefault', label: 'Global Default', width: 'w-28' },
729
+ { key: 'globalDefault', label: 'Global Default', width: 'w-36' },
715
730
  { key: 'preemptionPolicy', label: 'Preemption', width: 'w-32' },
716
731
  { key: 'description', label: 'Description', width: 'w-64', hideOnMobile: true },
717
732
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -723,27 +738,27 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
723
738
  ],
724
739
  mutatingwebhookconfigurations: [
725
740
  { key: 'name', label: 'Name' },
726
- { key: 'webhooks', label: 'Webhooks', width: 'w-20' },
727
- { key: 'failurePolicy', label: 'Failure Policy', width: 'w-28' },
741
+ { key: 'webhooks', label: 'Webhooks', width: 'w-28' },
742
+ { key: 'failurePolicy', label: 'Failure Policy', width: 'w-36' },
728
743
  { key: 'target', label: 'Target', width: 'w-48', hideOnMobile: true },
729
744
  { key: 'age', label: 'Age', width: 'w-24' },
730
745
  ],
731
746
  validatingwebhookconfigurations: [
732
747
  { key: 'name', label: 'Name' },
733
- { key: 'webhooks', label: 'Webhooks', width: 'w-20' },
734
- { key: 'failurePolicy', label: 'Failure Policy', width: 'w-28' },
748
+ { key: 'webhooks', label: 'Webhooks', width: 'w-28' },
749
+ { key: 'failurePolicy', label: 'Failure Policy', width: 'w-36' },
735
750
  { key: 'target', label: 'Target', width: 'w-48', hideOnMobile: true },
736
751
  { key: 'age', label: 'Age', width: 'w-24' },
737
752
  ],
738
753
  events: [
739
754
  { key: 'name', label: 'Name' },
740
755
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
741
- { key: 'type', label: 'Type', width: 'w-20' },
742
- { key: 'reason', label: 'Reason', width: 'w-28' },
756
+ { key: 'type', label: 'Type', width: 'w-24' },
757
+ { key: 'reason', label: 'Reason', width: 'w-32' },
743
758
  { key: 'message', label: 'Message', width: 'w-64' },
744
759
  { 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' },
760
+ { key: 'count', label: 'Count', width: 'w-20' },
761
+ { key: 'lastSeen', label: 'Last Seen', width: 'w-28' },
747
762
  ],
748
763
  // ============================================================================
749
764
  // FLUXCD GITOPS RESOURCES
@@ -791,6 +806,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
791
806
  { key: 'chart', label: 'Chart', width: 'w-40' },
792
807
  { key: 'version', label: 'Version', width: 'w-24' },
793
808
  { key: 'status', label: 'Status', width: 'w-24' },
809
+ { key: 'message', label: 'Message', width: 'w-64', hideOnMobile: true, tooltip: 'Last diagnostic message — distinguishes dependency-wait, install/upgrade failure, and test failure' },
794
810
  { key: 'revision', label: 'Rev', width: 'w-16', hideOnMobile: true, tooltip: 'Helm release revision number' },
795
811
  { key: 'lastUpdated', label: 'Last Updated', width: 'w-28', tooltip: 'Time since last successful reconciliation' },
796
812
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -932,7 +948,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
932
948
  { key: 'store', label: 'Store', width: 'w-36' },
933
949
  { key: 'provider', label: 'Provider', width: 'w-28' },
934
950
  { key: 'refreshInterval', label: 'Refresh', width: 'w-24' },
935
- { key: 'lastSync', label: 'Last Sync', width: 'w-24' },
951
+ { key: 'lastSync', label: 'Last Sync', width: 'w-28' },
936
952
  { key: 'age', label: 'Age', width: 'w-24' },
937
953
  ],
938
954
  clusterexternalsecrets: [
@@ -966,7 +982,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
966
982
  { key: 'namespaces', label: 'Scope', width: 'w-24', tooltip: 'Included namespaces (* = all)' },
967
983
  { key: 'duration', label: 'Duration', width: 'w-24' },
968
984
  { key: 'expiry', label: 'Expires', width: 'w-24' },
969
- { key: 'errors', label: 'Errors', width: 'w-16' },
985
+ { key: 'errors', label: 'Errors', width: 'w-20' },
970
986
  { key: 'age', label: 'Age', width: 'w-24' },
971
987
  ],
972
988
  restores: [
@@ -975,7 +991,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
975
991
  { key: 'status', label: 'Status', width: 'w-28' },
976
992
  { key: 'backupName', label: 'Backup', width: 'w-40' },
977
993
  { key: 'duration', label: 'Duration', width: 'w-24' },
978
- { key: 'errors', label: 'Errors', width: 'w-16' },
994
+ { key: 'errors', label: 'Errors', width: 'w-20' },
979
995
  { key: 'age', label: 'Age', width: 'w-24' },
980
996
  ],
981
997
  schedules: [
@@ -1004,10 +1020,10 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1004
1020
  { key: 'name', label: 'Name' },
1005
1021
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1006
1022
  { key: 'status', label: 'Status', width: 'w-28' },
1007
- { key: 'instances', label: 'Instances', width: 'w-24', tooltip: 'Ready/Total' },
1023
+ { key: 'instances', label: 'Instances', width: 'w-28', tooltip: 'Ready/Total' },
1008
1024
  { key: 'primary', label: 'Primary', width: 'w-36' },
1009
1025
  { key: 'image', label: 'Image', width: 'w-28' },
1010
- { key: 'storage', label: 'Storage', width: 'w-20' },
1026
+ { key: 'storage', label: 'Storage', width: 'w-28' },
1011
1027
  { key: 'age', label: 'Age', width: 'w-24' },
1012
1028
  ],
1013
1029
  scheduledbackups: [
@@ -1016,8 +1032,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1016
1032
  { key: 'status', label: 'Status', width: 'w-24' },
1017
1033
  { key: 'cluster', label: 'Cluster', width: 'w-36' },
1018
1034
  { key: 'schedule', label: 'Schedule', width: 'w-36' },
1019
- { key: 'lastSchedule', label: 'Last Run', width: 'w-24' },
1020
- { key: 'suspended', label: 'Suspended', width: 'w-20' },
1035
+ { key: 'lastSchedule', label: 'Last Run', width: 'w-28' },
1036
+ { key: 'suspended', label: 'Suspended', width: 'w-28' },
1021
1037
  { key: 'age', label: 'Age', width: 'w-24' },
1022
1038
  ],
1023
1039
  poolers: [
@@ -1026,8 +1042,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1026
1042
  { key: 'status', label: 'Status', width: 'w-24' },
1027
1043
  { key: 'cluster', label: 'Cluster', width: 'w-36' },
1028
1044
  { 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' },
1045
+ { key: 'poolMode', label: 'Pool Mode', width: 'w-32' },
1046
+ { key: 'instances', label: 'Instances', width: 'w-28', tooltip: 'Ready/Total' },
1031
1047
  { key: 'age', label: 'Age', width: 'w-24' },
1032
1048
  ],
1033
1049
  // ============================================================================
@@ -1101,7 +1117,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1101
1117
  { key: 'status', label: 'Status', width: 'w-28' },
1102
1118
  { key: 'routing', label: 'Traffic', width: 'w-20', tooltip: 'Whether this revision is receiving traffic' },
1103
1119
  { key: 'image', label: 'Image', width: 'w-48' },
1104
- { key: 'concurrency', label: 'Concurrency', width: 'w-24' },
1120
+ { key: 'concurrency', label: 'Concurrency', width: 'w-32' },
1105
1121
  { key: 'age', label: 'Age', width: 'w-24' },
1106
1122
  ],
1107
1123
  knativeroutes: [
@@ -1363,7 +1379,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1363
1379
  { key: 'namespace', label: 'Namespace', width: 'w-36 shrink-0' },
1364
1380
  { key: 'cluster', label: 'Cluster', width: 'w-32 shrink-0' },
1365
1381
  { key: 'ready', label: 'Ready', width: 'w-20 shrink-0' },
1366
- { key: 'initialized', label: 'Initialized', width: 'w-24 shrink-0' },
1382
+ { key: 'initialized', label: 'Initialized', width: 'w-28 shrink-0' },
1367
1383
  { key: 'version', label: 'Version', width: 'w-24 shrink-0' },
1368
1384
  { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
1369
1385
  ],
@@ -1513,6 +1529,43 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1513
1529
  { key: 'status', label: 'Status', width: 'w-28 shrink-0' },
1514
1530
  { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
1515
1531
  ],
1532
+ // Crossplane — Managed Resources are unbounded (one kind per provider CRD);
1533
+ // routed via isLikelyCrossplaneMRGroup, not by exact kind plural.
1534
+ crossplanemanagedresources: [
1535
+ { key: 'name', label: 'Name', width: 'min-w-40' },
1536
+ { key: 'namespace', label: 'Namespace', width: 'w-32 shrink-0' },
1537
+ { key: 'kind', label: 'Kind', width: 'w-32 shrink-0' },
1538
+ { key: 'external', label: 'External Name', width: 'min-w-48', hideOnMobile: true },
1539
+ { key: 'provider', label: 'Provider Config', width: 'w-40 shrink-0', hideOnMobile: true },
1540
+ { key: 'status', label: 'Status', width: 'w-32 shrink-0' },
1541
+ { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
1542
+ ],
1543
+ providers: [
1544
+ { key: 'name', label: 'Name', width: 'min-w-40' },
1545
+ { key: 'package', label: 'Package', width: 'min-w-48' },
1546
+ { key: 'revision', label: 'Revision', width: 'w-36 shrink-0', hideOnMobile: true },
1547
+ { key: 'status', label: 'Status', width: 'w-32 shrink-0' },
1548
+ { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
1549
+ ],
1550
+ providerconfigs: [
1551
+ { key: 'name', label: 'Name', width: 'min-w-40' },
1552
+ { key: 'credentials', label: 'Credentials', width: 'w-36 shrink-0' },
1553
+ { key: 'status', label: 'Status', width: 'w-32 shrink-0' },
1554
+ { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
1555
+ ],
1556
+ compositions: [
1557
+ { key: 'name', label: 'Name', width: 'min-w-40' },
1558
+ { key: 'composite', label: 'Composite Kind', width: 'w-44 shrink-0' },
1559
+ { key: 'mode', label: 'Mode', width: 'w-24 shrink-0' },
1560
+ { key: 'functions', label: 'Functions', width: 'w-28 shrink-0', hideOnMobile: true },
1561
+ { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
1562
+ ],
1563
+ compositeresourcedefinitions: [
1564
+ { key: 'name', label: 'Name', width: 'min-w-40' },
1565
+ { key: 'kind', label: 'Kind', width: 'w-40 shrink-0' },
1566
+ { key: 'claim', label: 'Claim Kind', width: 'w-40 shrink-0', hideOnMobile: true },
1567
+ { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
1568
+ ],
1516
1569
  }
1517
1570
 
1518
1571
  // Map (plural, group) → KNOWN_COLUMNS key for kinds that collide with core K8s
@@ -1545,8 +1598,32 @@ function normalizeKindToPlural(kind: string, group?: string): string {
1545
1598
  return lower
1546
1599
  }
1547
1600
 
1601
+ // Crossplane Managed Resources are unbounded (each provider ships its own
1602
+ // CRDs under per-service groups like s3.aws.upbound.io, compute.gcp.upbound.io).
1603
+ // Route any non-core-Crossplane resource in these groups to the generic MR
1604
+ // column set. ProviderConfig is excluded — those have their own renderer.
1605
+ function isLikelyCrossplaneMRGroup(kind: string, group: string): boolean {
1606
+ if (!group) return false
1607
+ const k = kind.toLowerCase()
1608
+ if (k === 'providerconfig' || k === 'providerconfigs') return false
1609
+ if (group.endsWith('.upbound.io')) return true
1610
+ if (group === 'kubernetes.crossplane.io' || group === 'helm.crossplane.io') return true
1611
+ // Reserved Crossplane core groups — never MRs.
1612
+ if (group === 'crossplane.io' || group === 'pkg.crossplane.io' || group === 'apiextensions.crossplane.io') {
1613
+ return false
1614
+ }
1615
+ // Any other *.crossplane.io subgroup is presumed to be a provider's MR group.
1616
+ if (group.endsWith('.crossplane.io')) return true
1617
+ return false
1618
+ }
1619
+
1548
1620
  function getColumnsForKind(kind: string, group?: string): Column[] {
1549
- return KNOWN_COLUMNS[normalizeKindToPlural(kind, group)] || DEFAULT_COLUMNS
1621
+ const key = normalizeKindToPlural(kind, group)
1622
+ if (KNOWN_COLUMNS[key]) return KNOWN_COLUMNS[key]
1623
+ if (group && isLikelyCrossplaneMRGroup(kind, group)) {
1624
+ return KNOWN_COLUMNS.crossplanemanagedresources
1625
+ }
1626
+ return DEFAULT_COLUMNS
1550
1627
  }
1551
1628
 
1552
1629
  // Get the default visible columns for a kind
@@ -1679,6 +1756,26 @@ interface ResourcesViewProps {
1679
1756
  * should also wire onResourceClick to handle the deep-link-on-load
1680
1757
  * case. */
1681
1758
  onRowSelect?: (resource: any) => void
1759
+ /**
1760
+ * Overrides the default compare-mode submit (which navigates to
1761
+ * `/compare?kind=...&a=...&b=...`). Hosts use this to route to a
1762
+ * different URL — e.g. Radar Hub's `/fleet/compare` with cluster IDs.
1763
+ * Picks arrive in click order, each carrying `clusterId`/`clusterName`
1764
+ * when `resolveRowCluster` is set (which is what keeps cross-cluster
1765
+ * picks distinct in the equality check).
1766
+ */
1767
+ onCompareSubmit?: (
1768
+ picks: NamespacedRef[],
1769
+ kind: { name: string; group?: string },
1770
+ ) => void
1771
+ /**
1772
+ * Resolves the cluster scope for a row when compare-mode is enabled.
1773
+ * Stamps `clusterId`/`clusterName` onto each pick so the same `ns/name`
1774
+ * in two different clusters is treated as two distinct picks. Hub-web
1775
+ * returns `row._cluster` here; OSS leaves it unset and picks key on
1776
+ * namespace+name only.
1777
+ */
1778
+ resolveRowCluster?: (resource: any) => { id: string; name: string } | undefined
1682
1779
  }
1683
1780
 
1684
1781
  // Default selected kind
@@ -1823,6 +1920,8 @@ export function ResourcesView({
1823
1920
  defaultKind = DEFAULT_KIND_INFO,
1824
1921
  extraLeadingColumns,
1825
1922
  onRowSelect,
1923
+ onCompareSubmit,
1924
+ resolveRowCluster,
1826
1925
  }: ResourcesViewProps) {
1827
1926
  const initialFilters = getInitialFiltersFromURL()
1828
1927
  const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath, defaultKind, locationPathname, locationSearch))
@@ -1842,6 +1941,7 @@ export function ResourcesView({
1842
1941
  onSelectedKindChange?.(selectedKind)
1843
1942
  }, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
1844
1943
  const [searchTerm, setSearchTerm] = useState(initialFilters.search)
1944
+ const [regexMode, setRegexMode] = useState(false)
1845
1945
  const [sortColumn, setSortColumn] = useState<string | null>(null)
1846
1946
  const [sortDirection, setSortDirection] = useState<SortDirection>(null)
1847
1947
  const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
@@ -1966,6 +2066,13 @@ export function ResourcesView({
1966
2066
 
1967
2067
  useEffect(() => {
1968
2068
  const saved = loadColumnSettings(selectedKind.name, selectedKind.group)
2069
+ // Host-injected extra columns (e.g. fleet Cluster) default to visible
2070
+ // even when the saved column-visibility blob predates them — a naive
2071
+ // Set(saved.visible) would silently hide host columns the user has
2072
+ // never been shown. Trade-off: a user can't permanently hide a host
2073
+ // extra column via the column picker — next mount re-adds it. Track
2074
+ // a sibling "hidden-extras" set if this becomes a real complaint.
2075
+ const extraKeys = extraLeadingColumns?.map(c => c.key) ?? []
1969
2076
  if (saved) {
1970
2077
  // If saved columns are just the defaults but this kind has specialized columns,
1971
2078
  // discard the stale save and use the specialized columns instead
@@ -1978,14 +2085,16 @@ export function ResourcesView({
1978
2085
  setVisibleColumns(getDefaultVisibleColumns(allColumns))
1979
2086
  setColumnWidths({})
1980
2087
  } else {
1981
- setVisibleColumns(new Set(saved.visible))
2088
+ const merged = new Set(saved.visible)
2089
+ for (const k of extraKeys) merged.add(k)
2090
+ setVisibleColumns(merged)
1982
2091
  setColumnWidths(saved.widths || {})
1983
2092
  }
1984
2093
  } else {
1985
2094
  setVisibleColumns(getDefaultVisibleColumns(allColumns))
1986
2095
  setColumnWidths({})
1987
2096
  }
1988
- }, [selectedKind.name, selectedKind.group, allColumns])
2097
+ }, [selectedKind.name, selectedKind.group, allColumns, extraLeadingColumns])
1989
2098
 
1990
2099
  // Save column settings when they change (skip initial load)
1991
2100
  const isColumnSettingsLoaded = useRef(false)
@@ -2121,9 +2230,59 @@ export function ResourcesView({
2121
2230
  const [highlightedIndex, setHighlightedIndex] = useState(-1)
2122
2231
  const virtuosoRef = useRef<TableVirtuosoHandle>(null)
2123
2232
 
2233
+ // Compare mode is only meaningful when the host can route picks. Either
2234
+ // path qualifies: onNavigate for the default same-cluster `/compare?...`
2235
+ // URL, or onCompareSubmit for hosts that build their own URL (e.g.
2236
+ // Radar Hub's `/fleet/compare` with cluster IDs).
2237
+ const compareEnabled = !!onNavigate || !!onCompareSubmit
2238
+ const [compareMode, setCompareMode] = useState(false)
2239
+ const [comparePicks, setComparePicks] = useState<CompareTrayPick[]>([])
2240
+
2241
+ // Kind change invalidates picks AND mode — leaving the tray on with empty
2242
+ // pills after the user moves to a different kind is more confusing than helpful.
2243
+ useEffect(() => {
2244
+ setComparePicks([])
2245
+ setCompareMode(false)
2246
+ }, [selectedKind.name, selectedKind.group])
2247
+
2248
+ const exitCompareMode = useCallback(() => {
2249
+ setCompareMode(false)
2250
+ setComparePicks([])
2251
+ }, [])
2252
+
2253
+ const toggleComparePick = useCallback((resource: any) => {
2254
+ if (!resource?.metadata?.name) return
2255
+ const ns = resource.metadata.namespace || ''
2256
+ // Stamp clusterId on the pick when the host provides a resolver
2257
+ // (cross-cluster compare). Without it, two rows with the same ns+name
2258
+ // from different clusters would collapse into one pick.
2259
+ const cluster = resolveRowCluster?.(resource)
2260
+ setComparePicks(prev => togglePick(prev, {
2261
+ namespace: ns,
2262
+ name: resource.metadata.name,
2263
+ clusterId: cluster?.id,
2264
+ clusterName: cluster?.name,
2265
+ }))
2266
+ }, [resolveRowCluster])
2267
+
2268
+ const handleCompareNavigate = useCallback(() => {
2269
+ if (comparePicks.length !== 2) return
2270
+ if (onCompareSubmit) {
2271
+ onCompareSubmit(comparePicks, { name: selectedKind.name, group: selectedKind.group })
2272
+ return
2273
+ }
2274
+ if (!onNavigate) return
2275
+ const params = new URLSearchParams()
2276
+ params.set('kind', selectedKind.name)
2277
+ if (selectedKind.group) params.set('apiGroup', selectedKind.group)
2278
+ params.set('a', refToParam(comparePicks[0]))
2279
+ params.set('b', refToParam(comparePicks[1]))
2280
+ onNavigate(`/compare?${params.toString()}`)
2281
+ }, [comparePicks, selectedKind.name, selectedKind.group, onNavigate, onCompareSubmit])
2282
+
2124
2283
  // Reset highlight when kind, search, sort, or namespace changes
2125
2284
  const namespacesKey = namespaces.join(',')
2126
- useEffect(() => { setHighlightedIndex(-1) }, [selectedKind.name, searchTerm, sortColumn, sortDirection, namespacesKey])
2285
+ useEffect(() => { setHighlightedIndex(-1) }, [selectedKind.name, searchTerm, regexMode, sortColumn, sortDirection, namespacesKey])
2127
2286
 
2128
2287
  // Scroll highlighted row into view
2129
2288
  useEffect(() => {
@@ -2226,7 +2385,11 @@ export function ResourcesView({
2226
2385
  description: 'Open resource detail',
2227
2386
  category: 'Resource Actions',
2228
2387
  scope: 'resources',
2229
- handler: () => selectResource(getHighlightedResource()),
2388
+ handler: () => {
2389
+ const res = getHighlightedResource()
2390
+ if (compareMode) toggleComparePick(res)
2391
+ else selectResource(res)
2392
+ },
2230
2393
  enabled: highlightedIndex >= 0,
2231
2394
  },
2232
2395
  {
@@ -2235,7 +2398,11 @@ export function ResourcesView({
2235
2398
  description: 'Open resource detail',
2236
2399
  category: 'Resource Actions',
2237
2400
  scope: 'resources',
2238
- handler: () => selectResource(getHighlightedResource()),
2401
+ handler: () => {
2402
+ const res = getHighlightedResource()
2403
+ if (compareMode) toggleComparePick(res)
2404
+ else selectResource(res)
2405
+ },
2239
2406
  enabled: highlightedIndex >= 0,
2240
2407
  },
2241
2408
  {
@@ -2250,7 +2417,7 @@ export function ResourcesView({
2250
2417
  const cb = onResourceClickYaml || onResourceClick
2251
2418
  cb?.({ kind: selectedKind.name, namespace: res.metadata.namespace || '', name: res.metadata.name, group: selectedKind.group })
2252
2419
  },
2253
- enabled: highlightedIndex >= 0,
2420
+ enabled: highlightedIndex >= 0 && !compareMode,
2254
2421
  },
2255
2422
  {
2256
2423
  id: 'resources-logs',
@@ -2274,7 +2441,7 @@ export function ResourcesView({
2274
2441
  openWorkloadLogs?.({ namespace: ns, workloadKind: selectedKind.kind, workloadName: name })
2275
2442
  }
2276
2443
  },
2277
- enabled: highlightedIndex >= 0 && ['pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs'].includes(selectedKind.name.toLowerCase()),
2444
+ enabled: highlightedIndex >= 0 && !compareMode && ['pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs'].includes(selectedKind.name.toLowerCase()),
2278
2445
  },
2279
2446
  {
2280
2447
  id: 'resources-sort-name',
@@ -2307,11 +2474,11 @@ export function ResourcesView({
2307
2474
  category: 'Table',
2308
2475
  scope: 'resources',
2309
2476
  handler: () => {
2310
- // Close dropdowns first, then clear highlight, then blur search
2311
2477
  if (showColumnPicker) { setShowColumnPicker(false); return }
2312
2478
  if (openColumnFilter) { setOpenColumnFilter(null); return }
2313
2479
  if (showProblemsDropdown) { setShowProblemsDropdown(false); return }
2314
2480
  if (showLabelsDropdown) { setShowLabelsDropdown(false); return }
2481
+ if (compareMode) { exitCompareMode(); return }
2315
2482
  if (highlightedIndex >= 0) setHighlightedIndex(-1)
2316
2483
  else searchInputRef.current?.blur()
2317
2484
  },
@@ -2908,6 +3075,18 @@ export function ResourcesView({
2908
3075
  }, [])
2909
3076
 
2910
3077
 
3078
+ // On an invalid pattern, fall back to a null matcher (search un-applied, all
3079
+ // rows shown) rather than zero results, so the table doesn't flash empty
3080
+ // while the user is mid-typing a pattern.
3081
+ const searchRegex = useMemo<{ re: RegExp | null; error: string | null }>(() => {
3082
+ if (!regexMode || !searchTerm) return { re: null, error: null }
3083
+ try {
3084
+ return { re: new RegExp(searchTerm, 'i'), error: null }
3085
+ } catch (e) {
3086
+ return { re: null, error: e instanceof Error ? e.message : 'Invalid regex' }
3087
+ }
3088
+ }, [regexMode, searchTerm])
3089
+
2911
3090
  // Filter resources by search term, status, problems, and sort
2912
3091
  const filteredResources = useMemo(() => {
2913
3092
  if (!resources) return []
@@ -2916,11 +3095,21 @@ export function ResourcesView({
2916
3095
 
2917
3096
  // Apply search filter
2918
3097
  if (searchTerm) {
2919
- const term = searchTerm.toLowerCase()
2920
- result = result.filter((r: any) =>
2921
- r.metadata?.name?.toLowerCase().includes(term) ||
2922
- r.metadata?.namespace?.toLowerCase().includes(term)
2923
- )
3098
+ if (regexMode) {
3099
+ const re = searchRegex.re
3100
+ if (re) {
3101
+ result = result.filter((r: any) =>
3102
+ re.test(r.metadata?.name ?? '') ||
3103
+ re.test(r.metadata?.namespace ?? '')
3104
+ )
3105
+ }
3106
+ } else {
3107
+ const term = searchTerm.toLowerCase()
3108
+ result = result.filter((r: any) =>
3109
+ r.metadata?.name?.toLowerCase().includes(term) ||
3110
+ r.metadata?.namespace?.toLowerCase().includes(term)
3111
+ )
3112
+ }
2924
3113
  }
2925
3114
 
2926
3115
  // Apply column filters (generic, multi-select per column — OR within column, AND across columns)
@@ -3076,7 +3265,7 @@ export function ResourcesView({
3076
3265
  }
3077
3266
 
3078
3267
  return result
3079
- }, [resources, searchTerm, columnFilters, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, sortColumn, sortDirection, getSortValue, podMatchesProblemFilter])
3268
+ }, [resources, searchTerm, regexMode, searchRegex, columnFilters, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, sortColumn, sortDirection, getSortValue, podMatchesProblemFilter])
3080
3269
 
3081
3270
  // For nodes table: compute the majority minor version so outliers can be highlighted
3082
3271
  const majorityNodeMinorVersion = useMemo(() => {
@@ -3156,6 +3345,14 @@ export function ResourcesView({
3156
3345
  style={{ ...props.style, tableLayout: 'fixed' }}
3157
3346
  >
3158
3347
  <colgroup>
3348
+ {/*
3349
+ Compare-mode adds a leading <th>/<td> per row but isn't part
3350
+ of `columns`. Under table-layout:fixed the <colgroup> must
3351
+ match the actual column count, otherwise the browser pads
3352
+ the missing entry by stealing width from a sized neighbour
3353
+ — typically blowing this narrow column out to ~200px.
3354
+ */}
3355
+ {compareMode && <col style={{ width: 36 }} />}
3159
3356
  {columns.map(col => (
3160
3357
  <col
3161
3358
  key={col.key}
@@ -3173,7 +3370,7 @@ export function ResourcesView({
3173
3370
  )
3174
3371
  }),
3175
3372
  TableRow: VirtuosoTableRow,
3176
- }), [columns, columnWidths, hasResizedColumns])
3373
+ }), [columns, columnWidths, hasResizedColumns, compareMode])
3177
3374
 
3178
3375
  // Calculate filter options with counts based on current resources (before filtering)
3179
3376
  const filterOptions = useMemo(() => {
@@ -3213,10 +3410,7 @@ export function ResourcesView({
3213
3410
  }
3214
3411
 
3215
3412
  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) {
3413
+ if (isColumnFilterableByDistinctCount(col.key, distinctCount)) {
3220
3414
  filterableColumns.push({
3221
3415
  key: col.key,
3222
3416
  label: col.label,
@@ -3387,38 +3581,68 @@ export function ResourcesView({
3387
3581
  <div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-theme-surface">
3388
3582
  {/* Toolbar */}
3389
3583
  <div className="flex items-center gap-3 px-4 py-3 border-b border-theme-border bg-theme-base shrink-0">
3390
- <div className="flex-1 relative">
3391
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
3392
- <input
3393
- ref={searchInputRef}
3394
- type="text"
3395
- placeholder="Search... (press /)"
3396
- value={searchTerm}
3397
- onChange={(e) => setSearchTerm(e.target.value)}
3398
- onKeyDown={(e) => {
3399
- if (e.key === 'ArrowDown') {
3400
- // Hand off to the table's keyboard navigation — blur the input
3401
- // so the registered ArrowDown/j/k shortcuts take over, and
3402
- // highlight the first row.
3403
- e.preventDefault()
3404
- searchInputRef.current?.blur()
3405
- setHighlightedIndex(0)
3406
- } else if (e.key === 'Enter' && filteredResourceCountRef.current > 0) {
3407
- // Select the first (or currently highlighted) resource
3408
- e.preventDefault()
3409
- searchInputRef.current?.blur()
3410
- if (highlightedIndex < 0) setHighlightedIndex(0)
3411
- // Defer to next frame so the highlight renders before we open
3412
- requestAnimationFrame(() => {
3413
- const res = highlightedResourceRef.current ?? filteredResources[0]
3414
- selectResource(res)
3415
- })
3416
- } else if (e.key === 'Escape') {
3417
- searchInputRef.current?.blur()
3418
- }
3419
- }}
3420
- className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-skyhook-500"
3421
- />
3584
+ <div className="flex-1 min-w-0">
3585
+ <div className="relative max-w-md">
3586
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
3587
+ <input
3588
+ ref={searchInputRef}
3589
+ type="text"
3590
+ placeholder={regexMode ? 'Search by regex... (press /)' : 'Search... (press /)'}
3591
+ value={searchTerm}
3592
+ onChange={(e) => setSearchTerm(e.target.value)}
3593
+ onKeyDown={(e) => {
3594
+ if (e.key === 'ArrowDown') {
3595
+ // Hand off to the table's keyboard navigation blur the input
3596
+ // so the registered ArrowDown/j/k shortcuts take over, and
3597
+ // highlight the first row.
3598
+ e.preventDefault()
3599
+ searchInputRef.current?.blur()
3600
+ setHighlightedIndex(0)
3601
+ } else if (e.key === 'Enter' && filteredResourceCountRef.current > 0) {
3602
+ // Select the first (or currently highlighted) resource
3603
+ e.preventDefault()
3604
+ searchInputRef.current?.blur()
3605
+ if (highlightedIndex < 0) setHighlightedIndex(0)
3606
+ // Defer to next frame so the highlight renders before we open
3607
+ requestAnimationFrame(() => {
3608
+ const res = highlightedResourceRef.current ?? filteredResources[0]
3609
+ selectResource(res)
3610
+ })
3611
+ } else if (e.key === 'Escape') {
3612
+ searchInputRef.current?.blur()
3613
+ }
3614
+ }}
3615
+ className={clsx(
3616
+ 'w-full pl-10 pr-10 py-2 bg-theme-elevated border rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2',
3617
+ searchRegex.error
3618
+ ? 'border-red-500/60 focus:ring-red-500'
3619
+ : 'border-theme-border-light focus:ring-skyhook-500'
3620
+ )}
3621
+ />
3622
+ <button
3623
+ type="button"
3624
+ onClick={() => setRegexMode((v) => !v)}
3625
+ aria-pressed={regexMode}
3626
+ aria-label={regexMode ? 'Disable regex search' : 'Enable regex search'}
3627
+ title={regexMode ? 'Regex search enabled — click to disable' : 'Enable regex search'}
3628
+ className={clsx(
3629
+ 'absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-6 h-6 rounded transition-colors',
3630
+ regexMode
3631
+ ? 'bg-skyhook-500/20 text-skyhook-400'
3632
+ : 'text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover'
3633
+ )}
3634
+ >
3635
+ <Regex className="w-3.5 h-3.5" />
3636
+ </button>
3637
+ {searchRegex.error && (
3638
+ <div
3639
+ title={searchRegex.error}
3640
+ className="absolute left-0 top-full mt-1 z-10 px-2 py-1 rounded bg-theme-elevated border border-red-500/40 text-[11px] text-red-400 shadow-theme-sm"
3641
+ >
3642
+ Invalid regex pattern
3643
+ </div>
3644
+ )}
3645
+ </div>
3422
3646
  </div>
3423
3647
 
3424
3648
  {/* Problems dropdown (pods only) */}
@@ -3648,6 +3872,22 @@ export function ResourcesView({
3648
3872
  </button>
3649
3873
  </Tooltip>
3650
3874
  )}
3875
+ {compareEnabled && (
3876
+ <Tooltip content={compareMode ? 'Exit compare mode' : 'Compare two resources side-by-side'}>
3877
+ <button
3878
+ onClick={() => (compareMode ? exitCompareMode() : setCompareMode(true))}
3879
+ aria-pressed={compareMode}
3880
+ className={clsx(
3881
+ 'p-2 rounded-lg transition-colors',
3882
+ compareMode
3883
+ ? 'bg-skyhook-500/15 text-skyhook-300 border border-skyhook-400/50'
3884
+ : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated',
3885
+ )}
3886
+ >
3887
+ <GitCompare className="w-4 h-4" />
3888
+ </button>
3889
+ </Tooltip>
3890
+ )}
3651
3891
  </div>
3652
3892
 
3653
3893
  {/* Table */}
@@ -3750,8 +3990,20 @@ export function ResourcesView({
3750
3990
  components={virtuosoComponents}
3751
3991
  fixedHeaderContent={() => (
3752
3992
  <tr>
3993
+ {compareMode && (
3994
+ <th
3995
+ // Inline px width — under `table-layout:fixed`,
3996
+ // `w-9` is a hint the browser absorbs into leftover
3997
+ // row width on an icon-only column.
3998
+ style={{ width: 36, minWidth: 36, maxWidth: 36 }}
3999
+ 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"
4000
+ title="Compare mode"
4001
+ >
4002
+ <GitCompare className="w-3.5 h-3.5 inline-block opacity-70" />
4003
+ </th>
4004
+ )}
3753
4005
  {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)
4006
+ const isSortable = ['name', 'namespace', 'age', 'status', 'ready', 'restarts', 'type', 'version', 'desired', 'available', 'upToDate', 'lastSeen', 'count', 'reason', 'object', 'cpu', 'memory', 'containers'].includes(col.key)
3755
4007
  const isSorted = sortColumn === col.key
3756
4008
  const isLastCol = colIdx === columns.length - 1
3757
4009
  const filterCol = filterableColumnMap.get(col.key)
@@ -3809,7 +4061,7 @@ export function ResourcesView({
3809
4061
  ? 'px-1.5 py-0.5 -my-0.5 selection-strong selection-text hover:bg-skyhook-500/30'
3810
4062
  : isFilterOpen
3811
4063
  ? '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'
4064
+ : 'p-0.5 text-theme-text-disabled opacity-40 group-hover/th:opacity-100 hover:text-theme-text-primary'
3813
4065
  )}
3814
4066
  >
3815
4067
  <ListFilter className="w-3 h-3" />
@@ -3921,6 +4173,13 @@ export function ResourcesView({
3921
4173
  selectedResource?.namespace === resource.metadata?.namespace &&
3922
4174
  selectedResource?.name === resource.metadata?.name
3923
4175
  const isHighlighted = index === highlightedIndex
4176
+ const pickIdx = compareMode
4177
+ ? pickIndex(comparePicks, {
4178
+ namespace: resource.metadata?.namespace || '',
4179
+ name: resource.metadata?.name || '',
4180
+ clusterId: resolveRowCluster?.(resource)?.id,
4181
+ })
4182
+ : -1
3924
4183
  return (
3925
4184
  <ResourceRowCells
3926
4185
  resource={resource}
@@ -3932,8 +4191,10 @@ export function ResourcesView({
3932
4191
  isSelected={isSelected}
3933
4192
  isHighlighted={isHighlighted}
3934
4193
  majorityNodeMinorVersion={majorityNodeMinorVersion}
3935
- onClick={() => selectResource(resource, isSelected)}
4194
+ onClick={() => compareMode ? toggleComparePick(resource) : selectResource(resource, isSelected)}
3936
4195
  onMouseEnter={() => setHighlightedIndex(-1)}
4196
+ compareMode={compareMode}
4197
+ comparePickIndex={pickIdx}
3937
4198
  />
3938
4199
  )
3939
4200
  }}
@@ -3949,6 +4210,15 @@ export function ResourcesView({
3949
4210
  </MetricsContext.Provider>
3950
4211
  )}
3951
4212
  </div>
4213
+ {compareMode && compareEnabled && (
4214
+ <CompareTray
4215
+ kind={selectedKind.name}
4216
+ picks={comparePicks}
4217
+ onRemove={(idx) => setComparePicks(prev => prev.filter((_, i) => i !== idx))}
4218
+ onCompare={handleCompareNavigate}
4219
+ onExit={exitCompareMode}
4220
+ />
4221
+ )}
3952
4222
  </div>
3953
4223
  </div>
3954
4224
  </ResourcesViewDataContext.Provider>
@@ -3967,11 +4237,58 @@ interface ResourceRowCellsProps {
3967
4237
  majorityNodeMinorVersion?: string
3968
4238
  onClick?: () => void
3969
4239
  onMouseEnter?: () => void
4240
+ compareMode?: boolean
4241
+ /** -1 when not picked; 0 = pick A; 1 = pick B. */
4242
+ comparePickIndex?: number
4243
+ }
4244
+
4245
+ function rowHighlightClass(
4246
+ compareMode: boolean | undefined,
4247
+ comparePickIndex: number,
4248
+ isSelected: boolean | undefined,
4249
+ isHighlighted: boolean | undefined,
4250
+ ): string {
4251
+ if (compareMode) {
4252
+ if (comparePickIndex === 0) return `${SIDE_TONES.a.rowBg} ${SIDE_TONES.a.rowBgHover}`
4253
+ if (comparePickIndex === 1) return `${SIDE_TONES.b.rowBg} ${SIDE_TONES.b.rowBgHover}`
4254
+ if (isHighlighted) return 'selection selection-ring'
4255
+ return 'group-hover/row:bg-theme-surface/50'
4256
+ }
4257
+ if (isSelected) return 'selection-strong group-hover/row:bg-skyhook-500/30'
4258
+ if (isHighlighted) return 'selection selection-ring'
4259
+ return 'group-hover/row:bg-theme-surface/50'
3970
4260
  }
3971
4261
 
3972
- function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter }: ResourceRowCellsProps) {
4262
+ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter, compareMode, comparePickIndex = -1 }: ResourceRowCellsProps) {
4263
+ const rowHighlight = rowHighlightClass(compareMode, comparePickIndex, isSelected, isHighlighted)
4264
+ const pickedSide = comparePickIndex === 0 ? 'a' : comparePickIndex === 1 ? 'b' : null
3973
4265
  return (
3974
4266
  <>
4267
+ {compareMode && (
4268
+ <td
4269
+ onClick={onClick}
4270
+ onMouseEnter={onMouseEnter}
4271
+ style={{ width: 36, minWidth: 36, maxWidth: 36 }}
4272
+ className={clsx('px-2 py-3 border-b-subtle cursor-pointer text-center align-middle transition-colors', rowHighlight)}
4273
+ >
4274
+ {pickedSide ? (
4275
+ <span
4276
+ className={clsx(
4277
+ 'inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold leading-none',
4278
+ SIDE_TONES[pickedSide].chipBg,
4279
+ )}
4280
+ aria-label={pickedSide === 'a' ? 'Pick A' : 'Pick B'}
4281
+ >
4282
+ {pickedSide === 'a' ? 'A' : 'B'}
4283
+ </span>
4284
+ ) : (
4285
+ <span
4286
+ className="inline-block w-4 h-4 rounded border border-theme-border-light group-hover/row:border-skyhook-400/60 transition-colors"
4287
+ aria-hidden
4288
+ />
4289
+ )}
4290
+ </td>
4291
+ )}
3975
4292
  {columns.map((col) => (
3976
4293
  <td
3977
4294
  key={col.key}
@@ -3980,11 +4297,7 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
3980
4297
  className={clsx(
3981
4298
  'px-4 py-3 border-b-subtle cursor-pointer transition-colors',
3982
4299
  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'
4300
+ rowHighlight,
3988
4301
  )}
3989
4302
  >
3990
4303
  <CellContent resource={resource} kind={kind} group={group} column={col.key} majorityNodeMinorVersion={majorityNodeMinorVersion} extraColumn={extraColumnsByKey?.get(col.key)} />
@@ -4405,7 +4718,29 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
4405
4718
  return <AzureMachineTemplateCell resource={resource} column={column} />
4406
4719
  case 'azuremanagedclusters':
4407
4720
  return <AzureManagedClusterCell resource={resource} column={column} />
4721
+ // Crossplane
4722
+ case 'providers':
4723
+ // Disambiguate from any future kind named "providers" by checking group
4724
+ if (resource.apiVersion?.startsWith('pkg.crossplane.io/')) {
4725
+ return <CrossplaneProviderCell resource={resource} column={column} />
4726
+ }
4727
+ return <GenericCell resource={resource} column={column} />
4728
+ case 'providerconfigs':
4729
+ return <CrossplaneProviderConfigCell resource={resource} column={column} />
4730
+ case 'compositions':
4731
+ return <CompositionCell resource={resource} column={column} />
4732
+ case 'compositeresourcedefinitions':
4733
+ return <XRDCell resource={resource} column={column} />
4408
4734
  default:
4735
+ // Crossplane Managed Resources: unbounded plurals (one CRD per provider
4736
+ // service), detected via group prefix + spec.providerConfigRef heuristic.
4737
+ if (isLikelyCrossplaneMRGroup(kind, group ?? '') && isManagedResource(resource)) {
4738
+ return <ManagedResourceCell resource={resource} column={column} />
4739
+ }
4740
+ // Composite Resources (XRs) — user-defined kinds with resourceRefs.
4741
+ if (isComposite(resource)) {
4742
+ return <CompositeResourceCell resource={resource} column={column} />
4743
+ }
4409
4744
  // Generic cell for CRDs and unknown resources
4410
4745
  return <GenericCell resource={resource} column={column} />
4411
4746
  }