@skyhook-io/k8s-ui 1.14.1 → 1.14.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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/components/issues/IssuesView.tsx +1 -1
  3. package/src/components/issues/types.ts +2 -0
  4. package/src/components/resources/ResourcesView.tsx +129 -67
  5. package/src/components/resources/column-settings-merge.test.ts +104 -0
  6. package/src/components/resources/istio-gateway-filter.test.ts +5 -3
  7. package/src/components/resources/istio-policy-accessors.test.ts +193 -0
  8. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +11 -24
  9. package/src/components/resources/renderers/eso-cells.tsx +0 -5
  10. package/src/components/resources/renderers/istio-cells.tsx +36 -26
  11. package/src/components/resources/renderers/kueue-cells.tsx +0 -9
  12. package/src/components/resources/renderers/kyverno-cells.tsx +1 -1
  13. package/src/components/resources/renderers/prometheus-cells.tsx +3 -3
  14. package/src/components/resources/renderers/ray-cells.tsx +12 -4
  15. package/src/components/resources/renderers/volcano-cells.tsx +0 -9
  16. package/src/components/resources/resource-utils-cnpg.ts +5 -0
  17. package/src/components/resources/resource-utils-eso.test.ts +56 -0
  18. package/src/components/resources/resource-utils-eso.ts +37 -5
  19. package/src/components/resources/resource-utils-istio.ts +152 -44
  20. package/src/components/resources/resource-utils-kyverno.ts +3 -6
  21. package/src/components/resources/resource-utils-prometheus.ts +51 -23
  22. package/src/components/resources/resource-utils-ray.test.ts +36 -0
  23. package/src/components/resources/resource-utils-ray.ts +6 -0
  24. package/src/components/resources/resource-utils.ts +166 -18
  25. package/src/components/resources/status-accessors.test.ts +363 -0
  26. package/src/components/resources/status-overclaim.test.ts +158 -0
  27. package/src/components/timeline/DiffViewer.test.tsx +26 -0
  28. package/src/components/timeline/DiffViewer.tsx +16 -12
  29. package/src/components/topology/TopologyGraph.tsx +7 -2
  30. package/src/components/ui/Collapse.tsx +9 -2
  31. package/src/components/ui/ConfirmDialog.test.tsx +37 -0
  32. package/src/components/ui/ConfirmDialog.tsx +3 -1
  33. package/src/components/ui/drawer-components.tsx +1 -1
  34. package/src/components/workload/WorkloadView.tsx +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.14.1",
3
+ "version": "1.14.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -63,7 +63,7 @@ export interface IssuesViewProps {
63
63
  /** Empty-state CTA shown when there's no data. */
64
64
  emptyAction?: ReactNode;
65
65
  /** Per-row trailing action, rendered after the severity badge — e.g. the
66
- * "Diagnose with AI" button in OSS. Omit to render no per-row action. */
66
+ * "Investigate with AI" button in OSS. Omit to render no per-row action. */
67
67
  renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
68
68
  }
69
69
 
@@ -121,6 +121,8 @@ export interface IssueRecentChangeField {
121
121
  export interface IssueRecentChange {
122
122
  source?: string;
123
123
  kind: string;
124
+ /** Kubernetes API version for an exact, collision-safe resource identity. */
125
+ apiVersion?: string;
124
126
  namespace?: string;
125
127
  name: string;
126
128
  changeType: string;
@@ -110,6 +110,7 @@ import {
110
110
  getGatewayClassController,
111
111
  getGatewayClassDescription,
112
112
  getRouteStatus,
113
+ getRouteStatusReason,
113
114
  getRouteParents,
114
115
  getRouteHostnames,
115
116
  getRouteBackends,
@@ -566,7 +567,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
566
567
  { key: 'name', label: 'Name' },
567
568
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
568
569
  { key: 'status', label: 'Status', width: 'w-40' },
569
- { key: 'desired', label: 'Desired', width: 'w-20', tooltip: 'Number of nodes that should run the daemon pod (based on node selector)' },
570
+ { key: 'desired', label: 'Desired', width: 'w-20', tooltip: 'Nodes eligible for this daemon after node selectors, required node affinity and taints/tolerations are applied' },
570
571
  { key: 'ready', label: 'Ready', width: 'w-20', tooltip: 'Number of pods that are ready (passing readiness probes)' },
571
572
  { key: 'upToDate', label: 'Up-to-date', width: 'w-32', tooltip: 'Number of pods running the current pod template spec' },
572
573
  { key: 'available', label: 'Available', width: 'w-28', tooltip: 'Number of pods available (ready for minReadySeconds duration)' },
@@ -578,7 +579,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
578
579
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
579
580
  { key: 'status', label: 'Status', width: 'w-40' },
580
581
  { key: 'ready', label: 'Ready', width: 'w-24', tooltip: 'Ready pods / Desired replicas' },
581
- { key: 'upToDate', label: 'Up-to-date', width: 'w-32', tooltip: 'Number of pods running the current pod template' },
582
+ { key: 'upToDate', label: 'Up-to-date', width: 'w-32', tooltip: 'Pods running the current pod template; a rolling-update partition or OnDelete strategy can intentionally leave pods on an older template' },
582
583
  { key: 'images', label: 'Images', width: 'w-48' },
583
584
  { key: 'age', label: 'Age', width: 'w-24' },
584
585
  ],
@@ -587,7 +588,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
587
588
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
588
589
  { key: 'ready', label: 'Ready', width: 'w-24' },
589
590
  { key: 'owner', label: 'Owner', width: 'w-48' },
590
- { key: 'status', label: 'Status', width: 'w-24' },
591
+ { key: 'status', label: 'Status', width: 'w-24', tooltip: 'Active means desired replicas are greater than zero; Old means desired replicas are zero, regardless of Deployment revision' },
591
592
  { key: 'age', label: 'Age', width: 'w-24' },
592
593
  ],
593
594
  services: [
@@ -623,7 +624,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
623
624
  nodes: [
624
625
  { key: 'name', label: 'Name' },
625
626
  { key: 'status', label: 'Status', width: 'w-44' },
626
- { key: 'roles', label: 'Roles', width: 'w-28' },
627
+ { key: 'roles', label: 'Roles', width: 'w-28', tooltip: 'Node roles from node-role.kubernetes.io/* labels. A node with no role label shows as worker.' },
627
628
  { key: 'cpu', label: 'CPU', width: 'w-40', tooltip: 'Current CPU usage / allocatable' },
628
629
  { key: 'memory', label: 'Memory', width: 'w-40', tooltip: 'Current memory usage / allocatable' },
629
630
  { key: 'pods', label: 'Pods', width: 'w-28', tooltip: 'Pods running / allocatable' },
@@ -637,7 +638,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
637
638
  { key: 'name', label: 'Name' },
638
639
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
639
640
  { key: 'keys', label: 'Keys', width: 'w-48' },
640
- { key: 'size', label: 'Size', width: 'w-24' },
641
+ { key: 'size', label: 'Size', width: 'w-24', tooltip: 'Estimated size of data values, including decoded binary data. Kubernetes rejects ConfigMaps whose combined data exceeds 1 MiB.' },
641
642
  { key: 'age', label: 'Age', width: 'w-24' },
642
643
  ],
643
644
  secrets: [
@@ -645,7 +646,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
645
646
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
646
647
  { key: 'type', label: 'Type', width: 'w-28' },
647
648
  { key: 'keys', label: 'Keys', width: 'w-20' },
648
- { key: 'expires', label: 'Expires', width: 'w-24', tooltip: 'Certificate expiry for TLS secrets' },
649
+ { key: 'expires', label: 'Expires', width: 'w-24', tooltip: "Days until the certificate in a TLS Secret expires. Blank for Secrets carrying no readable TLS certificate; ! means the expiry lookup itself failed." },
649
650
  { key: 'age', label: 'Age', width: 'w-24' },
650
651
  ],
651
652
  jobs: [
@@ -659,7 +660,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
659
660
  cronjobs: [
660
661
  { key: 'name', label: 'Name' },
661
662
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
662
- { key: 'schedule', label: 'Schedule', width: 'w-40' },
663
+ { key: 'schedule', label: 'Schedule', width: 'w-40', tooltip: "Cron schedule; fires in spec.timeZone when set, otherwise the controller's zone" },
663
664
  { key: 'status', label: 'Status', width: 'w-28' },
664
665
  { key: 'lastRun', label: 'Last Run', width: 'w-28' },
665
666
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -685,10 +686,10 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
685
686
  persistentvolumeclaims: [
686
687
  { key: 'name', label: 'Name' },
687
688
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
688
- { key: 'status', label: 'Status', width: 'w-24' },
689
+ { key: 'status', label: 'Status', width: 'w-24', tooltip: 'With WaitForFirstConsumer, Pending can mean no pod has triggered binding yet. If a pod is waiting, check PVC events.' },
689
690
  { key: 'capacity', label: 'Capacity', width: 'w-24' },
690
691
  { key: 'storageClass', label: 'Storage Class', width: 'w-40' },
691
- { key: 'accessModes', label: 'Access', width: 'w-20', tooltip: 'Access modes: RWO=ReadWriteOnce, RWX=ReadWriteMany, ROX=ReadOnlyMany' },
692
+ { key: 'accessModes', label: 'Access', width: 'w-20', tooltip: 'RWO=ReadWriteOnce (one node, possibly several pods), ROX=ReadOnlyMany (read-only on many nodes), RWX=ReadWriteMany (read-write on many nodes), RWOP=ReadWriteOncePod (one pod only)' },
692
693
  { key: 'volume', label: 'Volume', width: 'w-48' },
693
694
  { key: 'age', label: 'Age', width: 'w-24' },
694
695
  ],
@@ -733,16 +734,16 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
733
734
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
734
735
  { key: 'status', label: 'Ready', width: 'w-24' },
735
736
  { key: 'domains', label: 'Domains', width: 'w-56' },
736
- { key: 'issuer', label: 'Issuer', width: 'w-36' },
737
+ { key: 'issuer', label: 'Issuer', width: 'w-36', tooltip: "Issuer name alone may be ambiguous. Check the certificate's issuer reference for its kind and API group." },
737
738
  { key: 'expires', label: 'Expires', width: 'w-24', tooltip: 'Days until certificate expires' },
738
739
  { key: 'age', label: 'Age', width: 'w-24' },
739
740
  ],
740
741
  persistentvolumes: [
741
742
  { key: 'name', label: 'Name' },
742
- { key: 'status', label: 'Status', width: 'w-24' },
743
+ { key: 'status', label: 'Status', width: 'w-24', tooltip: "Released means the claim is gone but the volume still holds the previous claimant's data, so it will not be re-bound — check Reclaim before reusing it." },
743
744
  { key: 'capacity', label: 'Capacity', width: 'w-24' },
744
- { key: 'accessModes', label: 'Access', width: 'w-20', tooltip: 'RWO=ReadWriteOnce, ROX=ReadOnlyMany, RWX=ReadWriteMany' },
745
- { key: 'reclaimPolicy', label: 'Reclaim', width: 'w-20' },
745
+ { key: 'accessModes', label: 'Access', width: 'w-20', tooltip: 'RWO=ReadWriteOnce (one node, possibly several pods), ROX=ReadOnlyMany (read-only on many nodes), RWX=ReadWriteMany (read-write on many nodes), RWOP=ReadWriteOncePod (one pod only)' },
746
+ { key: 'reclaimPolicy', label: 'Reclaim', width: 'w-20', tooltip: 'When the claim is deleted, Delete removes the backing storage and its data; Retain leaves it for manual reclamation.' },
746
747
  { key: 'storageClass', label: 'Storage Class', width: 'w-40' },
747
748
  { key: 'claim', label: 'Claim', width: 'w-48' },
748
749
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -750,8 +751,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
750
751
  storageclasses: [
751
752
  { key: 'name', label: 'Name' },
752
753
  { key: 'provisioner', label: 'Provisioner', width: 'w-48' },
753
- { key: 'reclaimPolicy', label: 'Reclaim', width: 'w-20' },
754
- { key: 'bindingMode', label: 'Binding Mode', width: 'w-36' },
754
+ { key: 'reclaimPolicy', label: 'Reclaim', width: 'w-20', tooltip: 'Default policy for volumes this class provisions — Delete removes the backing storage when the claim goes away; Retain leaves it for manual reclamation.' },
755
+ { key: 'bindingMode', label: 'Binding Mode', width: 'w-36', tooltip: "When the volume is provisioned — WaitForFirstConsumer waits for a pod so the disk lands in the right zone; Immediate provisions before the pod's placement is known, which can leave the pod unschedulable." },
755
756
  { key: 'expansion', label: 'Expansion', width: 'w-24', tooltip: 'Whether volumes can be expanded after creation' },
756
757
  { key: 'age', label: 'Age', width: 'w-24' },
757
758
  ],
@@ -779,7 +780,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
779
780
  orders: [
780
781
  { key: 'name', label: 'Name' },
781
782
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
782
- { key: 'state', label: 'State', width: 'w-24' },
783
+ { key: 'state', label: 'State', width: 'w-24', tooltip: 'Valid means ACME issuance finished; it does not confirm the TLS Secret is ready. If Pending persists, inspect the challenges and the order reason.' },
783
784
  { key: 'domains', label: 'Domains', width: 'w-48' },
784
785
  { key: 'issuer', label: 'Issuer', width: 'w-36' },
785
786
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -788,25 +789,27 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
788
789
  { key: 'name', label: 'Name' },
789
790
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
790
791
  { key: 'challengeType', label: 'Type', width: 'w-20' },
791
- { key: 'state', label: 'State', width: 'w-24' },
792
+ { key: 'state', label: 'State', width: 'w-24', tooltip: 'Valid means domain authorization succeeded. If Pending persists, inspect the reason for scheduling, solver, or propagation problems.' },
792
793
  { key: 'domain', label: 'Domain', width: 'w-48' },
793
- { key: 'presented', label: 'Presented', width: 'w-24' },
794
+ { key: 'presented', label: 'Presented', width: 'w-24', tooltip: 'While Pending, Yes means the solver presented the token, not that it is reachable. Check the reason for propagation failures; No can also mean queued.' },
794
795
  { key: 'age', label: 'Age', width: 'w-24' },
795
796
  ],
796
797
  gateways: [
797
798
  { key: 'name', label: 'Name' },
798
799
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
799
- { key: 'status', label: 'Status', width: 'w-28' },
800
+ { key: 'status', label: 'Status', width: 'w-28', tooltip: "Programmed = the controller reports configuration should take effect soon; traffic has not been tested. Accepted = the controller accepted this Gateway but has not reported Programmed. If it stays Accepted, check the Gateway's conditions and addresses." },
800
801
  { key: 'class', label: 'Class', width: 'w-36' },
801
802
  { key: 'listeners', label: 'Listeners', width: 'w-40', tooltip: 'Protocol:Port for each listener' },
802
- { key: 'routes', label: 'Routes', width: 'w-20', tooltip: 'Total attached routes across all listeners' },
803
+ // attachedRoutes is reported per listener, so a route attached to several
804
+ // listeners is counted once for each — the total is not a route count.
805
+ { key: 'routes', label: 'Routes', width: 'w-20', tooltip: "Sum of route counts reported by listeners; one route can count more than once. If 0 is unexpected, check the routes' Gateway names, listener permissions, and controller status." },
803
806
  { key: 'addresses', label: 'Addresses', width: 'w-48' },
804
807
  { key: 'age', label: 'Age', width: 'w-24' },
805
808
  ],
806
809
  httproutes: [
807
810
  { key: 'name', label: 'Name' },
808
811
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
809
- { key: 'status', label: 'Status', width: 'w-28' },
812
+ { key: 'status', label: 'Status', width: 'w-28', tooltip: 'Accepted = controllers accepted this route and confirmed it can use the backends and other objects it names. Traffic has not been tested. Degraded or Not Accepted: open the route to see which Gateway or backend failed. Pending or Unknown: check the named Gateways or Services and their controllers.' },
810
813
  { key: 'hostnames', label: 'Hostnames', width: 'w-48' },
811
814
  { key: 'parents', label: 'Gateways', width: 'w-36' },
812
815
  { key: 'backends', label: 'Backends', width: 'w-48', tooltip: 'Backend services receiving traffic' },
@@ -818,7 +821,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
818
821
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
819
822
  { key: 'status', label: 'Status', width: 'w-28' },
820
823
  { key: 'servers', label: 'Servers', width: 'w-24' },
821
- { key: 'selector', label: 'Selector', width: 'w-56' },
824
+ { key: 'selector', label: 'Selector', width: 'w-56', tooltip: "Selects gateway workloads that receive this configuration. An omitted selector applies to all workloads in Istio's selection scope; it does not mean none." },
822
825
  { key: 'age', label: 'Age', width: 'w-24' },
823
826
  ],
824
827
  gatewayclasses: [
@@ -838,7 +841,6 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
838
841
  ],
839
842
  nodeclaims: [
840
843
  { key: 'name', label: 'Name' },
841
- { key: 'namespace', label: 'Namespace', width: 'w-36' },
842
844
  { key: 'status', label: 'Status', width: 'w-24' },
843
845
  { key: 'instanceType', label: 'Instance Type', width: 'w-32' },
844
846
  { key: 'capacityType', label: 'Capacity', width: 'w-24', tooltip: 'Spot or On-Demand' },
@@ -868,7 +870,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
868
870
  { key: 'name', label: 'Name' },
869
871
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
870
872
  { key: 'status', label: 'Status', width: 'w-28', tooltip: 'Allocated + reserved, allocated-but-unreserved, or pending' },
871
- { key: 'deviceClass', label: 'Device Class', width: 'w-44' },
873
+ { key: 'deviceClass', label: 'Device Class', width: 'w-44', tooltip: 'DeviceClasses whose selectors constrain eligible devices. A request may list alternative classes; this column does not show which alternative was allocated.' },
872
874
  { key: 'allocated', label: 'Allocated Driver', width: 'w-44' },
873
875
  { key: 'reservedFor', label: 'Reserved For', width: 'w-44' },
874
876
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -881,15 +883,17 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
881
883
  ],
882
884
  deviceclasses: [
883
885
  { key: 'name', label: 'Name' },
884
- { key: 'selectors', label: 'Selectors', width: 'w-28', tooltip: 'CEL device selector count' },
886
+ // Selectors are ANDed, so an empty list restricts nothing rather than
887
+ // matching nothing — the reading most people reach for first.
888
+ { key: 'selectors', label: 'Selectors', width: 'w-28', tooltip: 'Devices must satisfy every selector. Zero selectors means this class imposes no selector restriction; claim selectors and other allocation requirements still apply.' },
885
889
  { key: 'age', label: 'Age', width: 'w-24' },
886
890
  ],
887
891
  resourceslices: [
888
892
  { key: 'name', label: 'Name' },
889
893
  { key: 'driver', label: 'Driver', width: 'w-44' },
890
- { key: 'pool', label: 'Pool', width: 'w-36' },
891
- { key: 'node', label: 'Node', width: 'w-44' },
892
- { key: 'devices', label: 'Devices', width: 'w-24' },
894
+ { key: 'pool', label: 'Pool', width: 'w-36', tooltip: "Pools are identified by driver and pool name. The scheduler needs a complete set of slices at the pool's highest generation before allocating from it." },
895
+ { key: 'node', label: 'Node', width: 'w-44', tooltip: "Node providing this slice's devices. A dash means node access is described by a node selector, all-nodes access, or per-device node selection." },
896
+ { key: 'devices', label: 'Devices', width: 'w-24', tooltip: 'Devices published in this slice, including devices already allocated. This is not a count of free devices or the whole pool.' },
893
897
  { key: 'age', label: 'Age', width: 'w-24' },
894
898
  ],
895
899
  nvidiaclusterpolicies: [
@@ -935,7 +939,6 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
935
939
  ],
936
940
  resourceflavors: [
937
941
  { key: 'name', label: 'Name' },
938
- { key: 'status', label: 'Status', width: 'w-24' },
939
942
  { key: 'nodeLabels', label: 'Node Labels', width: 'w-28', tooltip: 'Node label selector count' },
940
943
  { key: 'taints', label: 'Taints', width: 'w-24' },
941
944
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -985,7 +988,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
985
988
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
986
989
  { key: 'status', label: 'Status', width: 'w-24' },
987
990
  { key: 'schedule', label: 'Schedule', width: 'w-32' },
988
- { key: 'suspend', label: 'Suspend', width: 'w-20' },
991
+ { key: 'timeZone', label: 'Time Zone', width: 'w-36', tooltip: 'IANA zone the schedule is read in; unset follows the local zone of the KubeRay operator pod' },
989
992
  { key: 'lastSchedule', label: 'Last Schedule', width: 'w-28' },
990
993
  { key: 'age', label: 'Age', width: 'w-24' },
991
994
  ],
@@ -1112,7 +1115,6 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1112
1115
  jobtemplates: [
1113
1116
  { key: 'name', label: 'Name' },
1114
1117
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1115
- { key: 'status', label: 'Status', width: 'w-24' },
1116
1118
  { key: 'tasks', label: 'Tasks', width: 'w-20' },
1117
1119
  { key: 'age', label: 'Age', width: 'w-24' },
1118
1120
  ],
@@ -1219,7 +1221,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1219
1221
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1220
1222
  { key: 'status', label: 'Status', width: 'w-24' },
1221
1223
  { key: 'target', label: 'Job Target', width: 'w-48' },
1222
- { key: 'strategy', label: 'Strategy', width: 'w-28' },
1224
+ { key: 'strategy', label: 'Strategy', width: 'w-28', tooltip: 'Controls how queue length and running or pending Jobs determine how many new Jobs KEDA creates' },
1223
1225
  { key: 'triggerTypes', label: 'Trigger Types', width: 'w-40' },
1224
1226
  { key: 'age', label: 'Age', width: 'w-24' },
1225
1227
  ],
@@ -1241,7 +1243,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1241
1243
  servicemonitors: [
1242
1244
  { key: 'name', label: 'Name' },
1243
1245
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1244
- { key: 'status', label: 'Status', width: 'w-24' },
1246
+ { key: 'status', label: 'Status', width: 'w-36' },
1245
1247
  { key: 'endpoints', label: 'Endpoints', width: 'w-20', tooltip: 'Number of scrape endpoints' },
1246
1248
  { key: 'jobLabel', label: 'Job Label', width: 'w-32' },
1247
1249
  { key: 'selector', label: 'Selector', width: 'w-48' },
@@ -1250,7 +1252,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1250
1252
  prometheusrules: [
1251
1253
  { key: 'name', label: 'Name' },
1252
1254
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1253
- { key: 'status', label: 'Status', width: 'w-24' },
1255
+ { key: 'status', label: 'Status', width: 'w-36' },
1254
1256
  { key: 'groups', label: 'Groups', width: 'w-20' },
1255
1257
  { key: 'rules', label: 'Rules', width: 'w-20', tooltip: 'Total alert + recording rules' },
1256
1258
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -1258,7 +1260,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1258
1260
  podmonitors: [
1259
1261
  { key: 'name', label: 'Name' },
1260
1262
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1261
- { key: 'status', label: 'Status', width: 'w-24' },
1263
+ { key: 'status', label: 'Status', width: 'w-36' },
1262
1264
  { key: 'endpoints', label: 'Endpoints', width: 'w-20', tooltip: 'Number of pod metrics endpoints' },
1263
1265
  { key: 'selector', label: 'Selector', width: 'w-48' },
1264
1266
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -1420,8 +1422,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1420
1422
  grpcroutes: [
1421
1423
  { key: 'name', label: 'Name' },
1422
1424
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
1423
- { key: 'status', label: 'Status', width: 'w-28' },
1424
- { key: 'hostnames', label: 'Hostnames', width: 'w-48' },
1425
+ { key: 'status', label: 'Status', width: 'w-28', tooltip: 'Accepted = controllers accepted this route and confirmed it can use the backends and other objects it names. Traffic has not been tested. Degraded or Not Accepted: open the route to see which Gateway or backend failed. Pending or Unknown: check the named Gateways or Services and their controllers.' },
1426
+ { key: 'hostnames', label: 'Hostnames', width: 'w-48', tooltip: "gRPC hostnames this route matches. Any means the route adds no hostname restriction; the Gateway listener and the route's other matching rules still apply." },
1425
1427
  { key: 'parents', label: 'Gateways', width: 'w-36' },
1426
1428
  { key: 'backends', label: 'Backends', width: 'w-48', tooltip: 'Backend services receiving traffic' },
1427
1429
  { key: 'rules', label: 'Rules', width: 'w-20' },
@@ -1430,7 +1432,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1430
1432
  tcproutes: [
1431
1433
  { key: 'name', label: 'Name' },
1432
1434
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
1433
- { key: 'status', label: 'Status', width: 'w-28' },
1435
+ { key: 'status', label: 'Status', width: 'w-28', tooltip: 'Accepted = controllers accepted this route and confirmed it can use the backends and other objects it names. Traffic has not been tested. Degraded or Not Accepted: open the route to see which Gateway or backend failed. Pending or Unknown: check the named Gateways or Services and their controllers.' },
1434
1436
  { key: 'parents', label: 'Gateways', width: 'w-36' },
1435
1437
  { key: 'backends', label: 'Backends', width: 'w-48', tooltip: 'Backend services receiving traffic' },
1436
1438
  { key: 'rules', label: 'Rules', width: 'w-20' },
@@ -1439,8 +1441,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1439
1441
  tlsroutes: [
1440
1442
  { key: 'name', label: 'Name' },
1441
1443
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
1442
- { key: 'status', label: 'Status', width: 'w-28' },
1443
- { key: 'hostnames', label: 'Hostnames', width: 'w-48', tooltip: 'SNI hostnames for TLS routing' },
1444
+ { key: 'status', label: 'Status', width: 'w-28', tooltip: 'Accepted = controllers accepted this route and confirmed it can use the backends and other objects it names. Traffic has not been tested. Degraded or Not Accepted: open the route to see which Gateway or backend failed. Pending or Unknown: check the named Gateways or Services and their controllers.' },
1445
+ { key: 'hostnames', label: 'Hostnames', width: 'w-48', tooltip: 'TLS SNI names requested by this route. Any means no route-level hostname restriction; the Gateway listener still applies.' },
1444
1446
  { key: 'parents', label: 'Gateways', width: 'w-36' },
1445
1447
  { key: 'backends', label: 'Backends', width: 'w-48', tooltip: 'Backend services receiving traffic' },
1446
1448
  { key: 'rules', label: 'Rules', width: 'w-20' },
@@ -1472,14 +1474,13 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1472
1474
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
1473
1475
  { key: 'policyTypes', label: 'Types', width: 'w-28' },
1474
1476
  { key: 'selector', label: 'Pod Selector', width: 'w-48' },
1475
- { key: 'rules', label: 'Rules', width: 'w-24', tooltip: 'Ingress / Egress rule count' },
1477
+ { key: 'rules', label: 'Rules', width: 'w-24', tooltip: 'Ingress / Egress allow-rule counts. For a direction listed in Types, 0 means this policy allows none; other policies can still allow traffic.' },
1476
1478
  { key: 'age', label: 'Age', width: 'w-24' },
1477
1479
  ],
1478
1480
  caliconetworkpolicies: [
1479
1481
  { key: 'name', label: 'Name' },
1480
1482
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1481
1483
  { key: 'selector', label: 'Selector', width: 'w-48' },
1482
- { key: 'namespaceSelector', label: 'Namespace Selector', width: 'w-48', defaultVisible: false },
1483
1484
  { key: 'serviceAccountSelector', label: 'Service Account Selector', width: 'w-48', defaultVisible: false },
1484
1485
  { key: 'tier', label: 'Tier', width: 'w-28' },
1485
1486
  { key: 'order', label: 'Order', width: 'w-24' },
@@ -1502,7 +1503,6 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1502
1503
  { key: 'name', label: 'Name' },
1503
1504
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1504
1505
  { key: 'selector', label: 'Selector', width: 'w-48' },
1505
- { key: 'namespaceSelector', label: 'Namespace Selector', width: 'w-48', defaultVisible: false },
1506
1506
  { key: 'serviceAccountSelector', label: 'Service Account Selector', width: 'w-48', defaultVisible: false },
1507
1507
  { key: 'tier', label: 'Tier', width: 'w-28' },
1508
1508
  { key: 'order', label: 'Order', width: 'w-24' },
@@ -1561,7 +1561,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1561
1561
  { key: 'name', label: 'Name' },
1562
1562
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
1563
1563
  { key: 'status', label: 'Status', width: 'w-24' },
1564
- { key: 'budget', label: 'Budget', width: 'w-36' },
1564
+ { key: 'budget', label: 'Budget', width: 'w-36', tooltip: 'min keeps at least this many pods available; max unavail permits at most this many unavailable during voluntary evictions; percentages use the expected pod count' },
1565
1565
  { key: 'healthy', label: 'Healthy', width: 'w-24' },
1566
1566
  { key: 'allowed', label: 'Allowed', width: 'w-24', tooltip: 'Number of disruptions currently allowed' },
1567
1567
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -1569,7 +1569,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1569
1569
  serviceaccounts: [
1570
1570
  { key: 'name', label: 'Name' },
1571
1571
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
1572
- { key: 'automount', label: 'Automount', width: 'w-32', tooltip: 'Whether token is automatically mounted in pods' },
1572
+ { key: 'automount', label: 'Automount', width: 'w-32', tooltip: 'Default automatic API-token mounting for pods using this ServiceAccount. A pod can override this setting. Disable it for workloads that do not need Kubernetes API credentials.' },
1573
1573
  { key: 'secrets', label: 'Secrets', width: 'w-24' },
1574
1574
  { key: 'age', label: 'Age', width: 'w-24' },
1575
1575
  ],
@@ -1607,12 +1607,12 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1607
1607
  { key: 'name', label: 'Name' },
1608
1608
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
1609
1609
  { key: 'holder', label: 'Holder', width: 'w-48' },
1610
- { key: 'renewTime', label: 'Last Renewed', width: 'w-32' },
1610
+ { key: 'renewTime', label: 'Last Renewed', width: 'w-32', tooltip: 'Time since the recorded renewal; red means it exceeds leaseDurationSeconds. Check holder health and API connectivity' },
1611
1611
  { key: 'age', label: 'Age', width: 'w-24' },
1612
1612
  ],
1613
1613
  priorityclasses: [
1614
1614
  { key: 'name', label: 'Name' },
1615
- { key: 'value', label: 'Value', width: 'w-24' },
1615
+ { key: 'value', label: 'Value', width: 'w-24', tooltip: 'Higher values receive scheduling preference; Pods can still be preempted by Pods with higher priority' },
1616
1616
  { key: 'globalDefault', label: 'Global Default', width: 'w-36' },
1617
1617
  { key: 'preemptionPolicy', label: 'Preemption', width: 'w-32' },
1618
1618
  { key: 'description', label: 'Description', width: 'w-64' },
@@ -1626,7 +1626,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1626
1626
  mutatingwebhookconfigurations: [
1627
1627
  { key: 'name', label: 'Name' },
1628
1628
  { key: 'webhooks', label: 'Webhooks', width: 'w-28' },
1629
- { key: 'failurePolicy', label: 'Failure Policy', width: 'w-36' },
1629
+ { key: 'failurePolicy', label: 'Failure Policy', width: 'w-36', tooltip: 'If a matching webhook call errors or times out, Fail rejects the API request; Ignore continues without that webhook. This describes failure handling, not current webhook health.' },
1630
1630
  { key: 'target', label: 'Target', width: 'w-48' },
1631
1631
  { key: 'age', label: 'Age', width: 'w-24' },
1632
1632
  ],
@@ -1833,8 +1833,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1833
1833
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1834
1834
  { key: 'status', label: 'Status', width: 'w-24' },
1835
1835
  { key: 'store', label: 'Store', width: 'w-36' },
1836
- { key: 'provider', label: 'Provider', width: 'w-28' },
1837
- { key: 'refreshInterval', label: 'Refresh', width: 'w-24' },
1836
+ { key: 'refreshInterval', label: 'Refresh', width: 'w-24', tooltip: 'How often the value is re-pulled from the provider. A zero interval means it is fetched once and not updated afterward.' },
1838
1837
  { key: 'lastSync', label: 'Last Sync', width: 'w-28' },
1839
1838
  { key: 'age', label: 'Age', width: 'w-24' },
1840
1839
  ],
@@ -2090,16 +2089,16 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
2090
2089
  destinationrules: [
2091
2090
  { key: 'name', label: 'Name' },
2092
2091
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
2093
- { key: 'status', label: 'Status', width: 'w-24' },
2094
- { key: 'host', label: 'Host', width: 'w-48' },
2095
- { key: 'subsets', label: 'Subsets', width: 'w-20' },
2096
- { key: 'loadBalancer', label: 'LB Policy', width: 'w-28' },
2092
+ { key: 'status', label: 'Status', width: 'w-24', defaultVisible: false },
2093
+ { key: 'host', label: 'Host', width: 'w-48', tooltip: "The service this rule's traffic policy applies to" },
2094
+ { key: 'subsets', label: 'Subsets', width: 'w-20', tooltip: 'Named backend groups that routes can select, often for canary releases. Defining subsets does not send traffic to them.' },
2095
+ { key: 'tlsMode', label: 'Client TLS', width: 'w-28', tooltip: 'Declared client-side TLS mode for this host at the rule level. Subset and port policies can override it; this does not establish the effective mTLS posture.' },
2096
+ { key: 'loadBalancer', label: 'LB Policy', width: 'w-28', tooltip: "Load balancing algorithm declared by this rule. '-' means none is set, so Istio applies its mesh default (LEAST_REQUEST since 1.14, overridable per mesh). Subset and port policies can differ." },
2097
2097
  { key: 'age', label: 'Age', width: 'w-24' },
2098
2098
  ],
2099
2099
  serviceentries: [
2100
2100
  { key: 'name', label: 'Name' },
2101
2101
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
2102
- { key: 'status', label: 'Status', width: 'w-24' },
2103
2102
  { key: 'hosts', label: 'Hosts', width: 'w-48' },
2104
2103
  { key: 'location', label: 'Location', width: 'w-28' },
2105
2104
  { key: 'ports', label: 'Ports', width: 'w-32' },
@@ -2108,7 +2107,6 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
2108
2107
  peerauthentications: [
2109
2108
  { key: 'name', label: 'Name' },
2110
2109
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
2111
- { key: 'status', label: 'Status', width: 'w-24' },
2112
2110
  { key: 'mode', label: 'mTLS Mode', width: 'w-28' },
2113
2111
  { key: 'selector', label: 'Selector', width: 'w-48' },
2114
2112
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -2116,10 +2114,10 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
2116
2114
  authorizationpolicies: [
2117
2115
  { key: 'name', label: 'Name' },
2118
2116
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
2119
- { key: 'status', label: 'Status', width: 'w-24' },
2120
- { key: 'action', label: 'Action', width: 'w-24' },
2121
- { key: 'rules', label: 'Rules', width: 'w-20' },
2122
- { key: 'selector', label: 'Selector', width: 'w-48' },
2117
+ { key: 'status', label: 'Status', width: 'w-24', defaultVisible: false },
2118
+ { key: 'action', label: 'Action', width: 'w-24', tooltip: 'ALLOW contributes permitted matches; DENY blocks matches; AUDIT marks matches for a configured audit plugin; CUSTOM adds an external check. Other policies also affect the decision.' },
2119
+ { key: 'rules', label: 'Rules', width: 'w-20', tooltip: 'Rules are alternatives — zero rules match nothing, an empty rule matches everything. An ALLOW policy with zero rules permits nothing on its own.' },
2120
+ { key: 'selector', label: 'Applies to', width: 'w-48', tooltip: 'Declared workload labels or resource attachments. With neither, scope is inherited — the namespace, or the whole mesh when the policy sits in the mesh root namespace.' },
2123
2121
  { key: 'age', label: 'Age', width: 'w-24' },
2124
2122
  ],
2125
2123
  // Knative Serving
@@ -3138,6 +3136,8 @@ export function mergeSavedVisibleColumns(
3138
3136
  savedVisible: string[],
3139
3137
  extraKeys: string[],
3140
3138
  printerColumns: ExtraColumn[],
3139
+ effective?: Column[],
3140
+ known?: string[],
3141
3141
  ): Set<string> {
3142
3142
  const merged = new Set(savedVisible)
3143
3143
  for (const k of extraKeys) merged.add(k)
@@ -3146,6 +3146,24 @@ export function mergeSavedVisibleColumns(
3146
3146
  if (c.defaultVisible !== false) merged.add(c.key)
3147
3147
  }
3148
3148
  }
3149
+ // Curated columns the blob predates. Same principle as the two above, but it
3150
+ // needs a record of what the user was actually offered: the blob stores only
3151
+ // what is visible, so a curated key's absence is ambiguous between "hidden on
3152
+ // purpose" and "not offered yet". `known` is that record. Without one the two
3153
+ // cases are indistinguishable, so nothing is inferred and visibility is left
3154
+ // to the saved set alone.
3155
+ //
3156
+ // Printer columns are excluded even when `known` lists none of them. They are
3157
+ // fetched, so `known` can be written during the window before the printer
3158
+ // table arrives; treating their absence as "new" would re-show ones the user
3159
+ // hid. Their own rule above already covers them.
3160
+ if (effective && Array.isArray(known)) {
3161
+ const seen = new Set(known)
3162
+ for (const c of effective) {
3163
+ if (c.key.startsWith(PRINTER_COLUMN_PREFIX)) continue
3164
+ if (!seen.has(c.key) && c.defaultVisible !== false) merged.add(c.key)
3165
+ }
3166
+ }
3149
3167
  return merged
3150
3168
  }
3151
3169
 
@@ -3201,6 +3219,10 @@ interface ColumnSettings {
3201
3219
  visible: string[]
3202
3220
  widths: Record<string, number>
3203
3221
  custom?: CustomColumnDef[]
3222
+ /** Every column key the user was offered when this was written. Lets a later
3223
+ * load tell a column they hid from one that did not exist yet. Absent on
3224
+ * blobs written before this field. */
3225
+ known?: string[]
3204
3226
  }
3205
3227
 
3206
3228
  function loadColumnSettings(kind: string, group?: string): ColumnSettings | null {
@@ -3778,6 +3800,12 @@ export function ResourcesView({
3778
3800
  return [...filteredExtras, ...kindColumns, ...builtCustomColumns]
3779
3801
  }, [selectedKind.name, selectedKind.group, extraLeadingColumns, builtCustomColumns, builtPrinterColumns])
3780
3802
 
3803
+ // The offered key set, and a stable signature for it. allColumns is a new
3804
+ // identity on every printer-column refetch; depending on the array itself
3805
+ // would rewrite the settings blob on each poll.
3806
+ const allColumnKeys = useMemo(() => allColumns.map(c => c.key), [allColumns])
3807
+ const allColumnKeysSig = allColumnKeys.join('\u0000')
3808
+
3781
3809
  // Map of extra column keys for fast O(1) lookup on each render path
3782
3810
  // (cell render, sort, column-filter unique-values).
3783
3811
  // Recompute header-derived widths once the web font swaps in (see watchHeaderFont).
@@ -3794,6 +3822,13 @@ export function ResourcesView({
3794
3822
  // Guards the save effect from persisting on the initial load of each kind
3795
3823
  // (set false by the load effect, flipped true on its first skipped save).
3796
3824
  const isColumnSettingsLoaded = useRef(false)
3825
+ // Every column key this kind has ever offered, accumulated. A single save
3826
+ // cannot stand in for it: the offered set shrinks as well as grows — an
3827
+ // uncurated kind's generic Status disappears once its printer columns arrive
3828
+ // — and a shrunk snapshot would forget that a column absent from `visible`
3829
+ // was one the user hid rather than one never shown.
3830
+ const knownColumnKeys = useRef<Set<string>>(new Set())
3831
+ const knownColumnKeysKind = useRef('')
3797
3832
  // Whether the user has a persisted column blob for this kind — data-driven
3798
3833
  // column defaults (GPU auto-show) must never override explicit user choices.
3799
3834
  const hadSavedColumnSettings = useRef(false)
@@ -3819,6 +3854,15 @@ export function ResourcesView({
3819
3854
  // (non-array, blank path, bad source) must not crash the later .map or add dead columns.
3820
3855
  const savedCustom = sanitizeCustomColumnDefs(saved?.custom)
3821
3856
  setCustomColumns(savedCustom)
3857
+ // Reset on a kind change only. This effect also re-runs when the printer
3858
+ // columns arrive, and that is the moment the offered set shrinks — clearing
3859
+ // here would drop the evidence for the column they replaced just before the
3860
+ // next save persists the record without it.
3861
+ const knownKeysIdentity = selectedKindIdentityOf(selectedKind)
3862
+ if (knownColumnKeysKind.current !== knownKeysIdentity) {
3863
+ knownColumnKeys.current = new Set()
3864
+ knownColumnKeysKind.current = knownKeysIdentity
3865
+ }
3822
3866
  const kindColumns = columnsForKindWithPrinter(selectedKind.name, selectedKind.group, builtPrinterColumns)
3823
3867
  const builtinKeys = new Set(kindColumns.map(c => c.key))
3824
3868
  const extras = filterHostExtras(extraLeadingColumns, builtinKeys, selectedKind.name)
@@ -3835,18 +3879,32 @@ export function ResourcesView({
3835
3879
  // discard the stale save and use the specialized columns instead. User-added
3836
3880
  // custom columns mean the blob isn't a stale pure-defaults save — keep it, or
3837
3881
  // the migration would clear them from storage (and un-hide hidden ones).
3882
+ // Persisted JSON: only an array of keys is usable, anything else reads as
3883
+ // no record at all.
3884
+ const savedKnown = Array.isArray(saved.known)
3885
+ ? saved.known.filter((k): k is string => typeof k === 'string')
3886
+ : undefined
3887
+ for (const k of savedKnown ?? []) knownColumnKeys.current.add(k)
3888
+
3838
3889
  const defaultKeys = DEFAULT_COLUMNS.map(c => c.key)
3890
+ // A blob carrying an offered-key record is not a pre-curation leftover:
3891
+ // its visible set matching the generic defaults means the user hid their
3892
+ // way down to them, and clearing it would undo exactly that.
3839
3893
  const isStaleDefaults = kindColumns !== DEFAULT_COLUMNS &&
3894
+ !savedKnown &&
3840
3895
  !savedCustom.length &&
3841
3896
  saved.visible.length === defaultKeys.length &&
3842
3897
  saved.visible.every(v => defaultKeys.includes(v))
3843
3898
  if (isStaleDefaults) {
3844
3899
  clearColumnSettings(selectedKind.name, selectedKind.group)
3900
+ knownColumnKeys.current = new Set()
3845
3901
  hadSavedColumnSettings.current = false
3846
3902
  setVisibleColumns(getDefaultVisibleColumns(effective))
3847
3903
  setColumnWidths({})
3848
3904
  } else {
3849
- setVisibleColumns(mergeSavedVisibleColumns(saved.visible, extraKeys, builtPrinterColumns))
3905
+ setVisibleColumns(mergeSavedVisibleColumns(
3906
+ saved.visible, extraKeys, builtPrinterColumns, effective, savedKnown,
3907
+ ))
3850
3908
  setColumnWidths(saved.widths || {})
3851
3909
  }
3852
3910
  } else {
@@ -3862,6 +3920,7 @@ export function ResourcesView({
3862
3920
  // Save column settings when they change (skip the initial load of each kind)
3863
3921
  useEffect(() => {
3864
3922
  if (visibleColumns.size === 0) return // not loaded yet
3923
+ for (const k of allColumnKeys) knownColumnKeys.current.add(k)
3865
3924
  if (!isColumnSettingsLoaded.current) {
3866
3925
  isColumnSettingsLoaded.current = true
3867
3926
  return
@@ -3870,11 +3929,13 @@ export function ResourcesView({
3870
3929
  visible: Array.from(visibleColumns),
3871
3930
  widths: columnWidths,
3872
3931
  custom: customColumns,
3932
+ known: Array.from(knownColumnKeys.current),
3873
3933
  })
3874
3934
  // A persisted blob blocks GPU auto-show from here on — same rule in-session
3875
3935
  // as across sessions, so a later data refresh can't override user choices.
3876
3936
  hadSavedColumnSettings.current = true
3877
- }, [visibleColumns, columnWidths, customColumns, selectedKind.name, selectedKind.group])
3937
+ // allColumnKeysSig, not allColumnKeys: see the memo above.
3938
+ }, [visibleColumns, columnWidths, customColumns, selectedKind.name, selectedKind.group, allColumnKeysSig])
3878
3939
 
3879
3940
  // Close column picker on outside click or Escape
3880
3941
  useEffect(() => {
@@ -3977,6 +4038,7 @@ export function ResourcesView({
3977
4038
  // Reset column settings to defaults (also drops user-added custom columns)
3978
4039
  const resetColumnSettings = useCallback(() => {
3979
4040
  clearColumnSettings(selectedKind.name, selectedKind.group)
4041
+ knownColumnKeys.current = new Set()
3980
4042
  setCustomColumns([])
3981
4043
  const customKeys = new Set(builtCustomColumns.map(c => c.key))
3982
4044
  setVisibleColumns(getDefaultVisibleColumns(allColumns.filter(c => !customKeys.has(c.key))))
@@ -8899,11 +8961,11 @@ function RouteCell({ resource, column }: { resource: any; column: string }) {
8899
8961
  switch (column) {
8900
8962
  case 'status': {
8901
8963
  const status = getRouteStatus(resource)
8902
- return (
8903
- <span className={clsx('badge', status.color)}>
8904
- {status.text}
8905
- </span>
8906
- )
8964
+ const reason = getRouteStatusReason(resource)
8965
+ const badge = <span className={clsx('badge', status.color)}>{status.text}</span>
8966
+ // "Degraded" on its own says something is wrong and not what. The
8967
+ // controller's own reason is usually "the backend you named is missing".
8968
+ return reason ? <Tooltip content={reason}>{badge}</Tooltip> : badge
8907
8969
  }
8908
8970
  case 'hostnames': {
8909
8971
  const hostnames = getRouteHostnames(resource)