Repository: kiali/kiali
Pull request #7852 (2024-10-21 11:32:59)

Fine-tune Mesh topology style
### Describe the change

This PR changes some mesh topology styles for a better appearance.
- Keep other nodes highlighted when a mesh node is selected or hovered. This feature is useful in the traffic graph, where many nodes are present, but it's less helpful in the mesh graph when there are only a few nodes.

- Hovering over an edge does not change its style. Since edges on the mesh page are not clickable, it's better to maintain a consistent CSS style to avoid user confusion.

- Don't hide the icons at low detail levels. The icons remain recognizable even at the lowest zoom levels.

![image](https://github.com/user-attachments/assets/88d940ef-79f7-421f-a0f0-8e186a32ff6d)

- Scale the nodes at low detail levels:

![image](https://github.com/user-attachments/assets/9d1dd4fb-6250-47ed-bd86-b5c44bf84c27)

- The selection stroke is wider and blue like in the traffic graph:

![image](https://github.com/user-attachments/assets/4fbac044-d574-402c-85ce-8e5141eece6e)

- Edges connected to failure nodes are thicker, making the failures more visible:

![image](https://github.com/user-attachments/assets/f7f72305-db22-4767-b776-fdf17bf562b9)


**Bonus:**
- Adjusted the margin for the Waypoint proxy icon in the traffic graph.

Before:

![image](https://github.com/user-attachments/assets/bc410390-7916-429d-a502-31002c4a5e3e)

After:

![image](https://github.com/user-attachments/assets/1c743e04-0d9f-405c-a654-30d7e71a593e)
 

### Steps to test the PR

1. Go to the mesh page
2. Check that style changes look good

### Automation testing

N/A



Patch:
diff --git a/Makefile b/Makefile
index 1ad3c2096..4ca630dde 100644
--- a/Makefile
+++ b/Makefile
@@ -11,7 +11,7 @@ TARGET_ARCHS ?= amd64 arm64 s390x ppc64le
 
 # Identifies the current build.
 # These will be embedded in the app and displayed when it starts.
-VERSION ?= v2.0.0-SNAPSHOT
+VERSION ?= v2.1.0-SNAPSHOT
 COMMIT_HASH ?= $(shell git rev-parse HEAD)
 
 # The path where the UI project has been git cloned. The UI should
diff --git a/business/istio_status.go b/business/istio_status.go
index b9d371476..4542e6bb2 100644
--- a/business/istio_status.go
+++ b/business/istio_status.go
@@ -8,6 +8,7 @@ import (
 	"k8s.io/apimachinery/pkg/labels"
 
 	"github.com/kiali/kiali/config"
+	"github.com/kiali/kiali/istio"
 	"github.com/kiali/kiali/kubernetes"
 	"github.com/kiali/kiali/log"
 	"github.com/kiali/kiali/models"
@@ -21,7 +22,7 @@ func NewIstioStatusService(
 	userClients map[string]kubernetes.ClientInterface,
 	tracing *TracingService,
 	workloads *WorkloadService,
-	discovery meshDiscovery,
+	discovery istio.MeshDiscovery,
 ) IstioStatusService {
 	return IstioStatusService{
 		conf:                conf,
@@ -36,7 +37,7 @@ func NewIstioStatusService(
 // SvcService deals with fetching istio/kubernetes services related content and convert to kiali model
 type IstioStatusService struct {
 	conf                *config.Config
-	discovery           meshDiscovery
+	discovery           istio.MeshDiscovery
 	homeClusterSAClient kubernetes.ClientInterface
 	tracing             *TracingService
 	userClients         map[string]kubernetes.ClientInterface
diff --git a/business/istio_validations.go b/business/istio_validations.go
index 953bf0982..07861f0a8 100644
--- a/business/istio_validations.go
+++ b/business/istio_validations.go
@@ -15,6 +15,7 @@ import (
 	"github.com/kiali/kiali/business/checkers"
 	"github.com/kiali/kiali/business/references"
 	"github.com/kiali/kiali/config"
+	"github.com/kiali/kiali/istio"
 	"github.com/kiali/kiali/kubernetes"
 	"github.com/kiali/kiali/log"
 	"github.com/kiali/kiali/models"
@@ -24,7 +25,7 @@ import (
 
 type IstioValidationsService struct {
 	businessLayer *Layer
-	discovery     meshDiscovery
+	discovery     istio.MeshDiscovery
 	userClients   map[string]kubernetes.ClientInterface
 }
 
diff --git a/business/layer.go b/business/layer.go
index cffc517d6..7ef180974 100644
--- a/business/layer.go
+++ b/business/layer.go
@@ -35,7 +35,7 @@ type Layer struct {
 // Global clientfactory and prometheus clients.
 var (
 	clientFactory       kubernetes.ClientFactory
-	discovery           meshDiscovery
+	discovery           istio.MeshDiscovery
 	grafanaService      *grafana.Service
 	kialiCache          cache.KialiCache
 	poller              ControlPlaneMonitor
@@ -49,7 +49,7 @@ func Start(
 	cf kubernetes.ClientFactory,
 	controlPlaneMonitor ControlPlaneMonitor,
 	cache cache.KialiCache,
-	disc meshDiscovery,
+	disc istio.MeshDiscovery,
 	prom prometheus.ClientInterface,
 	traceClientLoader func() tracing.ClientInterface,
 	grafana *grafana.Service,
@@ -104,7 +104,7 @@ func newLayer(
 	cache cache.KialiCache,
 	conf *config.Config,
 	grafana *grafana.Service,
-	discovery meshDiscovery,
+	discovery istio.MeshDiscovery,
 	cpm ControlPlaneMonitor,
 ) *Layer {
 	temporaryLayer := &Layer{}
@@ -114,9 +114,9 @@ func newLayer(
 	// TODO: Modify the k8s argument to other services to pass the whole k8s map if needed
 	temporaryLayer.App = NewAppService(temporaryLayer, conf, prom, grafana, userClients)
 	temporaryLayer.Health = HealthService{prom: prom, businessLayer: temporaryLayer, userClients: userClients}
-	temporaryLayer.IstioConfig = IstioConfigService{config: *conf, userClients: userClients, kialiCache: cache, businessLayer: temporaryLayer, controlPlaneMonitor: poller}
+	temporaryLayer.IstioConfig = IstioConfigService{config: *conf, userClients: userClients, kialiCache: cache, businessLayer: temporaryLayer, controlPlaneMonitor: cpm}
 	temporaryLayer.Namespace = NewNamespaceService(userClients, kialiSAClients, cache, conf, discovery)
-	temporaryLayer.Mesh = NewMeshService(kialiSAClients, cache, temporaryLayer.Namespace, conf, discovery)
+	temporaryLayer.Mesh = NewMeshService(kialiSAClients, discovery)
 	temporaryLayer.ProxyStatus = ProxyStatusService{kialiSAClients: kialiSAClients, kialiCache: cache, businessLayer: temporaryLayer}
 	// Out of order because it relies on ProxyStatus
 	temporaryLayer.ProxyLogging = ProxyLoggingService{userClients: userClients, proxyStatus: &temporaryLayer.ProxyStatus}
diff --git a/business/mesh.go b/business/mesh.go
index 59ac319a8..ade8f0fa7 100644
--- a/business/mesh.go
+++ b/business/mesh.go
@@ -2,14 +2,9 @@ package business
 
 import (
 	"context"
-	"fmt"
 
-	"golang.org/x/exp/maps"
-
-	"github.com/kiali/kiali/config"
 	"github.com/kiali/kiali/istio"
 	"github.com/kiali/kiali/kubernetes"
-	"github.com/kiali/kiali/kubernetes/cache"
 	"github.com/kiali/kiali/log"
 	"github.com/kiali/kiali/models"
 )
@@ -26,89 +21,23 @@ const (
 	AllowAny = "ALLOW_ANY"
 )
 
-type meshDiscovery interface {
-	Mesh(ctx context.Context) (*models.Mesh, error)
-}
-
-// TODO: move meshDiscovery here.
-
 // MeshService is a support service for retrieving data about the mesh environment
 // when Istio is installed with multi-cluster enabled. Prefer initializing this
 // type via the NewMeshService function.
 type MeshService struct {
-	conf                *config.Config
-	discovery           meshDiscovery
-	homeClusterSAClient kubernetes.ClientInterface
-	kialiCache          cache.KialiCache
-	kialiSAClients      map[string]kubernetes.ClientInterface
-	namespaceService    NamespaceService
+	discovery      istio.MeshDiscovery
+	kialiSAClients map[string]kubernetes.ClientInterface
 }
 
 // NewMeshService initializes a new MeshService structure with the given k8s clients.
 func NewMeshService(
 	kialiSAClients map[string]kubernetes.ClientInterface,
-	cache cache.KialiCache,
-	namespaceService NamespaceService,
-	conf *config.Config,
-	discovery meshDiscovery,
+	discovery istio.MeshDiscovery,
 ) MeshService {
 	return MeshService{
-		conf:                conf,
-		discovery:           discovery,
-		homeClusterSAClient: kialiSAClients[conf.KubernetesConfig.ClusterName],
-		kialiCache:          cache,
-		kialiSAClients:      kialiSAClients,
-		namespaceService:    namespaceService,
-	}
-}
-
-func (in *MeshService) CanaryUpgradeStatus() (*models.CanaryUpgradeStatus, error) {
-	kubeCache, err := in.kialiCache.GetKubeCache(in.conf.KubernetesConfig.ClusterName)
-	if err != nil {
-		return nil, err
-	}
-
-	revisions, err := istio.GetHealthyIstiodRevisions(kubeCache, in.conf.IstioNamespace)
-	if err != nil {
-		return nil, err
+		discovery:      discovery,
+		kialiSAClients: kialiSAClients,
 	}
-	namespacesPerRevision := make(map[string][]string)
-
-	if len(revisions) == 1 {
-		return &models.CanaryUpgradeStatus{
-			NamespacesPerRevision: namespacesPerRevision,
-		}, nil
-	}
-
-	for _, revision := range revisions {
-		nsList := make(map[string]bool)
-		// Get namespaces for revision
-		// TODO: Support multi-primary
-		nss, err := in.homeClusterSAClient.GetNamespaces(fmt.Sprintf("%s=%s", in.conf.IstioLabels.InjectionLabelRev, revision))
-		if err != nil {
-			return nil, err
-		}
-		for _, ns := range nss {
-			nsList[ns.Name] = true
-		}
-
-		// include not revision labeled namespaces into default ones
-		if revision == models.DefaultRevisionLabel {
-			pendingNss, err := in.homeClusterSAClient.GetNamespaces(fmt.Sprintf("%s=enabled", in.conf.IstioLabels.InjectionLabelName))
-			if err != nil {
-				return nil, err
-			}
-			for _, ns := range pendingNss {
-				nsList[ns.Name] = true
-			}
-		}
-		namespacesPerRevision[revision] = maps.Keys(nsList)
-	}
-	status := &models.CanaryUpgradeStatus{
-		NamespacesPerRevision: namespacesPerRevision,
-	}
-
-	return status, nil
 }
 
 // Checks if a cluster exist
diff --git a/business/namespaces.go b/business/namespaces.go
index f24a4840f..5f7a38275 100644
--- a/business/namespaces.go
+++ b/business/namespaces.go
@@ -20,7 +20,7 @@ import (
 // NamespaceService deals with fetching k8sClients namespaces / OpenShift projects and convert to kiali model
 type NamespaceService struct {
 	conf                  *config.Config
-	discovery             meshDiscovery
+	discovery             istio.MeshDiscovery
 	hasProjects           map[string]bool
 	homeClusterUserClient kubernetes.ClientInterface
 	kialiCache            cache.KialiCache
@@ -46,7 +46,7 @@ func NewNamespaceService(
 	kialiSAClients map[string]kubernetes.ClientInterface,
 	cache cache.KialiCache,
 	conf *config.Config,
-	discovery meshDiscovery,
+	discovery istio.MeshDiscovery,
 ) NamespaceService {
 	homeClusterName := conf.KubernetesConfig.ClusterName
 	hasProjects := make(map[string]bool)
diff --git a/business/tls.go b/business/tls.go
index f02b880fd..d1bc6d8d1 100644
--- a/business/tls.go
+++ b/business/tls.go
@@ -8,6 +8,7 @@ import (
 	security_v1 "istio.io/client-go/pkg/apis/security/v1"
 
 	"github.com/kiali/kiali/config"
+	"github.com/kiali/kiali/istio"
 	"github.com/kiali/kiali/kubernetes"
 	"github.com/kiali/kiali/kubernetes/cache"
 	"github.com/kiali/kiali/models"
@@ -18,7 +19,7 @@ import (
 
 type TLSService struct {
 	businessLayer *Layer
-	discovery     meshDiscovery
+	discovery     istio.MeshDiscovery
 	userClients   map[string]kubernetes.ClientInterface
 	kialiCache    cache.KialiCache
 }
diff --git a/frontend/package.json b/frontend/package.json
index 50809c175..d1fa64bb9 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@kiali/kiali-ui",
-  "version": "1.90.0",
+  "version": "2.1.0",
   "description": "React UI for [Kiali](https://github.com/kiali/kiali).",
   "keywords": [
     "istio service mesh",
diff --git a/frontend/public/locales/zh/translation.json b/frontend/public/locales/zh/translation.json
index 8dd2a587a..afe13dbce 100644
--- a/frontend/public/locales/zh/translation.json
+++ b/frontend/public/locales/zh/translation.json
@@ -299,6 +299,7 @@
   "Switch language": "更换语言",
   "Switch to {{theme}} Mode": "Switch to {{theme}} Mode",
   "System Error": "System Error",
+  "Tag: {{tag}}": "Tag: {{tag}}",
   "TCP Connection": "TCP Connection",
   "TCP Traffic": "TCP Traffic",
   "TCP Traffic Shifting": "TCP 流量调整",
diff --git a/frontend/src/actions/ActionKeys.ts b/frontend/src/actions/ActionKeys.ts
index cfcc03ccf..568b8ea23 100644
--- a/frontend/src/actions/ActionKeys.ts
+++ b/frontend/src/actions/ActionKeys.ts
@@ -41,6 +41,7 @@ export enum ActionKeys {
 
   GRAPH_UPDATE_SUMMARY = 'GRAPH_UPDATE_SUMMARY',
 
+  MESH_SET_CONTROLPLANES = 'MESH_SET_CONTROLPLANES',
   MESH_SET_DEFINITION = 'MESH_SET_DEFINITION',
   MESH_SET_LAYOUT = 'MESH_SET_LAYOUT',
   MESH_SET_TARGET = 'MESH_SET_TARGET',
diff --git a/frontend/src/config/Config.ts b/frontend/src/config/Config.ts
index f50d4a9bb..f3a9b4e2a 100644
--- a/frontend/src/config/Config.ts
+++ b/frontend/src/config/Config.ts
@@ -120,7 +120,6 @@ const conf = {
       appTraces: (namespace: string, app: string) => `api/namespaces/${namespace}/apps/${app}/traces`,
       authenticate: 'api/authenticate',
       authInfo: 'api/auth/info',
-      canaryUpgradeStatus: () => 'api/mesh/canaries/status',
       clustersApps: () => `api/clusters/apps`,
       clustersHealth: () => `api/clusters/health`,
       clustersMetrics: () => `api/clusters/metrics`,
@@ -130,6 +129,7 @@ const conf = {
       configValidations: () => `api/istio/validations`,
       controlPlaneMetrics: (namespace: string, controlPlane: string) =>
         `api/namespaces/${namespace}/controlplanes/${controlPlane}/metrics`,
+      controlPlanes: 'api/mesh/controlplanes',
       crippledFeatures: 'api/crippled',
       customDashboard: (namespace: string, template: string) =>
         `api/namespaces/${namespace}/customdashboard/${template}`,
diff --git a/frontend/src/pages/GraphPF/GraphPFElems.tsx b/frontend/src/pages/GraphPF/GraphPFElems.tsx
index 7d4c8ebf2..7176a7ff9 100644
--- a/frontend/src/pages/GraphPF/GraphPFElems.tsx
+++ b/frontend/src/pages/GraphPF/GraphPFElems.tsx
@@ -208,7 +208,7 @@ export const setNodeAttachments = (node: Node<NodeModel>, settings: GraphPFSetti
   }
 };
 
-const labelIconStyle = kialiStyle({
+const rootIconStyle = kialiStyle({
   color: PFColors.White,
   display: 'flex',
   marginLeft: '0.125rem',
@@ -221,6 +221,13 @@ const gatewayIconStyle = kialiStyle({
   marginTop: '-0.125rem'
 });
 
+const waypointIconStyle = kialiStyle({
+  color: PFColors.White,
+  display: 'flex',
+  marginLeft: '0.125rem',
+  marginTop: '-0.125rem'
+});
+
 export const setNodeLabel = (
   node: NodeModel,
   nodeMap: NodeMap,
@@ -269,7 +276,7 @@ export const setNodeLabel = (
     ) {
       data.labelIcon = <span className={`${badgeMap.get('GW')?.className} ${gatewayIconStyle}`}></span>;
     } else {
-      data.labelIcon = <span className={`${badgeMap.get('RO')?.className} ${labelIconStyle}`}></span>;
+      data.labelIcon = <span className={`${badgeMap.get('RO')?.className} ${rootIconStyle}`}></span>;
     }
   } else {
     if (data.isGateway?.egressInfo?.hostnames?.length !== undefined) {
@@ -277,7 +284,7 @@ export const setNodeLabel = (
     }
     // A Waypoint should be mutually exclusive with being a traffic source
     if (data.isWaypoint) {
-      data.labelIcon = <span className={`${badgeMap.get('WA')?.className} ${labelIconStyle}`}></span>;
+      data.labelIcon = <span className={`${badgeMap.get('WA')?.className} ${waypointIconStyle}`}></span>;
     }
   }
 
diff --git a/frontend/src/pages/GraphPF/MiniGraphCardPF.tsx b/frontend/src/pages/GraphPF/MiniGraphCardPF.tsx
index fa040b9c2..e782d4805 100644
--- a/frontend/src/pages/GraphPF/MiniGraphCardPF.tsx
+++ b/frontend/src/pages/GraphPF/MiniGraphCardPF.tsx
@@ -179,7 +179,7 @@ class MiniGraphCardPFComponent extends React.Component<MiniGraphCardPropsPF, Min
           </CardHeader>
 
           <CardBody>
-            <div style={{ height: '100%' }}>
+            <div id="pft-graph" style={{ height: '100%' }}>
               <EmptyGraphLayout
                 elements={this.state.graphData}
                 isLoading={this.props.dataSource.isLoading}
diff --git a/frontend/src/pages/GraphPF/styles/styleNode.tsx b/frontend/src/pages/GraphPF/styles/styleNode.tsx
index a758bebe3..aeb83cefa 100644
--- a/frontend/src/pages/GraphPF/styles/styleNode.tsx
+++ b/frontend/src/pages/GraphPF/styles/styleNode.tsx
@@ -74,7 +74,7 @@ const getNodeShape = (node: Node): React.FunctionComponent<ShapeProps> => {
   }
 };
 
-const nodeClass = kialiStyle({
+const nodeStyle = kialiStyle({
   $nest: {
     '&.pf-m-hover': {
       cursor: 'pointer'
@@ -87,7 +87,7 @@ const nodeClass = kialiStyle({
 });
 
 // Hide the kebab menu of Patternfly topology nodes
-const labelClass = kialiStyle({
+const labelStyle = kialiStyle({
   $nest: {
     '& text:not(.pf-m-secondary)': {
       transform: 'translateX(10px)'
@@ -167,14 +167,14 @@ const StyleNodeComponent: React.FC<StyleNodeProps> = ({ element, ...rest }) => {
       {data.isFind && <ShapeComponent className={findOverlayStyle} width={width} height={height} element={element} />}
       {data.isFocus && <ShapeComponent className={focusOverlayStyle} width={width} height={height} element={element} />}
       <DefaultNode
-        className={nodeClass}
-        labelClassName={labelClass}
+        className={nodeStyle}
+        labelClassName={labelStyle}
         element={element}
         {...rest}
         {...passedData}
         attachments={hover || detailsLevel === ScaleDetailsLevel.high ? data.attachments : undefined}
         getCustomShape={getNodeShape}
-        scaleLabel={hover && detailsLevel !== ScaleDetailsLevel.low}
+        scaleLabel={hover && detailsLevel === ScaleDetailsLevel.high}
         scaleNode={hover && detailsLevel !== ScaleDetailsLevel.high}
         showLabel={hover || detailsLevel === ScaleDetailsLevel.high}
         showStatusBackground={detailsLevel !== ScaleDetailsLevel.high}
diff --git a/frontend/src/pages/Mesh/MeshElems.tsx b/frontend/src/pages/Mesh/MeshElems.tsx
index af178e810..3af94cdf7 100644
--- a/frontend/src/pages/Mesh/MeshElems.tsx
+++ b/frontend/src/pages/Mesh/MeshElems.tsx
@@ -147,6 +147,9 @@ export const setNodeLabel = (node: NodeModel, _nodeMap: NodeMap): void => {
         break;
       case MeshInfraType.ISTIOD:
         pfBadge = PFBadges.Istio;
+        if (data.infraData?.tag) {
+          content.push(`Tag: ${data.infraData.tag.name}`);
+        }
         break;
       case MeshInfraType.KIALI:
         pfBadge = PFBadges.Kiali;
@@ -200,7 +203,7 @@ const getPathStyle = (data: EdgeData): React.CSSProperties => {
     case FAILURE.name:
     case DEGRADED.name:
       return {
-        strokeWidth: 3,
+        strokeWidth: 6,
         strokeDasharray: '10 10'
       } as React.CSSProperties;
     default:
diff --git a/frontend/src/pages/Mesh/MeshHighlighter.ts b/frontend/src/pages/Mesh/MeshHighlighter.ts
index be7a0c176..c149e1bc4 100644
--- a/frontend/src/pages/Mesh/MeshHighlighter.ts
+++ b/frontend/src/pages/Mesh/MeshHighlighter.ts
@@ -108,9 +108,9 @@ export class MeshHighlighter {
           if ((element as Node).isGroup()) {
             return this.getBoxHighlight(element as Node);
           }
-          return { toHighlight: this.getNodeHighlight(element as Node), unhighlightOthers: true };
+          return { toHighlight: this.getNodeHighlight(element as Node), unhighlightOthers: false };
         case 'edge':
-          return { toHighlight: this.getEdgeHighlight(element as Edge), unhighlightOthers: true };
+          return { toHighlight: this.getEdgeHighlight(element as Edge), unhighlightOthers: false };
 
         default:
         // fall through
diff --git a/frontend/src/pages/Mesh/components/meshComponentFactory.tsx b/frontend/src/pages/Mesh/components/meshComponentFactory.tsx
index 497132500..60176eab8 100644
--- a/frontend/src/pages/Mesh/components/meshComponentFactory.tsx
+++ b/frontend/src/pages/Mesh/components/meshComponentFactory.tsx
@@ -1,10 +1,8 @@
 import {
   ComponentFactory,
   GraphComponent,
-  GraphElement,
   ModelKind,
   nodeDragSourceSpec,
-  withContextMenu,
   withDragNode,
   withPanZoom,
   withSelection
@@ -14,30 +12,6 @@ import { MeshEdge } from '../styles/MeshEdge';
 import { MeshNode } from '../styles/MeshNode';
 import { MeshGroup } from '../styles/MeshGroup';
 
-// There are currently no actions to take on a mesh node, so these placeholders are just commented out
-
-/*
-type ContextMenuOptionPF = ContextMenuOption & {
-  altClickHandler?: (node: GraphElement) => void;
-  node?: GraphElement;
-};
-
-// There is no node-graph for mesh nodes, so currently this does nothing
-const doubleTapHandler = (node: GraphElement) => {
-  handleDoubleTap(node);
-};
-
-// There is no node-graph for mesh nodes, so currently this does nothing
-const handleDoubleTap = (_doubleTapNode: GraphElement) => {
-  return;
-};
-*/
-
-// There are currently no actiones to take on a mesh node, so this returns an empty array
-const nodeContextMenu = (_node: GraphElement): React.ReactElement[] => {
-  return [];
-};
-
 export const meshComponentFactory: ComponentFactory = (
   kind: ModelKind,
   type: string
@@ -45,23 +19,13 @@ export const meshComponentFactory: ComponentFactory = (
   switch (kind) {
     case ModelKind.edge:
       // Currently, no side panel for edges, nothing really to show but the connectivity
-      // return withSelection({ multiSelect: false, controlled: false })(MeshEdge as any);
       return MeshEdge as any;
     case ModelKind.graph:
       return withSelection({ multiSelect: false, controlled: false })(withPanZoom()(GraphComponent));
     case ModelKind.node: {
-      if (type === 'group') {
-        return withDragNode(nodeDragSourceSpec('node', true, true))(
-          // context menu is empty, this is just for global css compatibility
-          withContextMenu(e => nodeContextMenu(e))(
-            withSelection({ multiSelect: false, controlled: false })(MeshGroup as any)
-          )
-        );
-      } else {
-        return withDragNode(nodeDragSourceSpec('node', true, true))(
-          withSelection({ multiSelect: false, controlled: false })(MeshNode as any)
-        );
-      }
+      return withDragNode(nodeDragSourceSpec('node', true, true))(
+        withSelection({ multiSelect: false, controlled: false })((type === 'group' ? MeshGroup : MeshNode) as any)
+      );
     }
     default:
       return undefined;
diff --git a/frontend/src/pages/Mesh/styles/MeshEdge.tsx b/frontend/src/pages/Mesh/styles/MeshEdge.tsx
index c5ed2b23b..fdb812f6d 100644
--- a/frontend/src/pages/Mesh/styles/MeshEdge.tsx
+++ b/frontend/src/pages/Mesh/styles/MeshEdge.tsx
@@ -42,9 +42,16 @@ const MeshEdgeComponent: React.FC<MeshEdgeProps> = ({ element, ...rest }) => {
   const edgeClass = kialiStyle({
     $nest: {
       '& .pf-topology__edge__link': data.pathStyle,
-      '& .pf-topology-connector-arrow': {
-        stroke: data.pathStyle.stroke,
-        fill: data.pathStyle.stroke
+
+      // remove the hover CSS change (edges cannot be selected on the mesh page)
+      '&.pf-m-hover': {
+        $nest: {
+          '& .pf-topology__edge__background': data.pathStyle,
+          '& .pf-topology__edge__link': {
+            ...data.pathStyle,
+            stroke: 'var(--edge--stroke)'
+          }
+        }
       }
     }
   });
diff --git a/frontend/src/pages/Mesh/styles/MeshNode.tsx b/frontend/src/pages/Mesh/styles/MeshNode.tsx
index f7b033eb0..badc7cc3a 100644
--- a/frontend/src/pages/Mesh/styles/MeshNode.tsx
+++ b/frontend/src/pages/Mesh/styles/MeshNode.tsx
@@ -75,6 +75,21 @@ const renderIcon = (element: Node): React.ReactNode => {
   );
 };
 
+const nodeStyle = kialiStyle({
+  $nest: {
+    '.pf-topology__node__background': {
+      strokeWidth: 3
+    },
+    '&.pf-m-hover': {
+      cursor: 'pointer'
+    },
+    '&.pf-m-selected .pf-topology__node__background': {
+      stroke: PFColors.Active,
+      strokeWidth: 6
+    }
+  }
+});
+
 const MeshNodeComponent: React.FC<MeshNodeProps> = ({ element, ...rest }) => {
   const data = element.getData();
   const detailsLevel = useDetailsLevel();
@@ -124,16 +139,16 @@ const MeshNodeComponent: React.FC<MeshNodeProps> = ({ element, ...rest }) => {
     <g style={{ opacity: opacity }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} ref={hoverRef as any}>
       {data.isFind && <ShapeComponent className={findOverlayStyle} width={width} height={height} element={element} />}
       <DefaultNode
+        className={nodeStyle}
         element={element}
         {...rest}
         {...passedData}
         attachments={hover || detailsLevel === ScaleDetailsLevel.high ? data.attachments : undefined}
-        scaleLabel={hover && detailsLevel !== ScaleDetailsLevel.high}
-        // scaleNode={hover && detailsLevel === ScaleDetailsLevel.low}
+        scaleLabel={hover && detailsLevel === ScaleDetailsLevel.high}
+        scaleNode={hover && detailsLevel !== ScaleDetailsLevel.high}
         showLabel={hover || detailsLevel === ScaleDetailsLevel.high}
-        showStatusBackground={detailsLevel === ScaleDetailsLevel.low}
       >
-        {(hover || detailsLevel !== ScaleDetailsLevel.low) && renderIcon(element)}
+        {renderIcon(element)}
       </DefaultNode>
     </g>
   );
diff --git a/frontend/src/pages/Mesh/target/TargetPanelCommon.tsx b/frontend/src/pages/Mesh/target/TargetPanelCommon.tsx
index c21f80c35..2276dd9cb 100644
--- a/frontend/src/pages/Mesh/target/TargetPanelCommon.tsx
+++ b/frontend/src/pages/Mesh/target/TargetPanelCommon.tsx
@@ -166,7 +166,9 @@ export const renderNodeHeader = (
           {data.infraName}
 
           {renderHealthStatus(data)}
-          {serverConfig.ambientEnabled && data.infraType === MeshInfraType.ISTIOD && <AmbientLabel tooltip={tooltipMsgType.mesh} />}
+          {serverConfig.ambientEnabled && data.infraType === MeshInfraType.ISTIOD && (
+            <AmbientLabel tooltip={tooltipMsgType.mesh} />
+          )}
         </span>
       </Title>
       {!options.nameOnly && (
diff --git a/frontend/src/pages/Mesh/target/TargetPanelControlPlane.tsx b/frontend/src/pages/Mesh/target/TargetPanelControlPlane.tsx
index 7649834e0..c95936e4a 100644
--- a/frontend/src/pages/Mesh/target/TargetPanelControlPlane.tsx
+++ b/frontend/src/pages/Mesh/target/TargetPanelControlPlane.tsx
@@ -10,7 +10,6 @@ import {
 } from './TargetPanelCommon';
 import { Title, TitleSizes } from '@patternfly/react-core';
 import { serverConfig } from 'config';
-import { CanaryUpgradeStatus } from 'types/IstioObjects';
 import { NamespaceInfo, NamespaceStatus } from 'types/NamespaceInfo';
 import { DirectionType } from 'pages/Overview/OverviewToolbar';
 import { PromisesRegistry } from 'utils/CancelablePromises';
@@ -20,8 +19,6 @@ import { IstioMetricsOptions } from 'types/MetricsOptions';
 import { computePrometheusRateParams } from 'services/Prometheus';
 import { ApiError } from 'types/Api';
 import { DEGRADED, FAILURE, HEALTHY, Health, NOT_READY } from 'types/Health';
-import * as AlertUtils from '../../../utils/AlertUtils';
-import { MessageType } from 'types/MessageCenter';
 import { TLSStatus, nsWideMTLSStatus } from 'types/TLSStatus';
 import * as FilterHelper from '../../../components/FilterList/FilterHelper';
 import { NodeData } from '../MeshElems';
@@ -38,6 +35,7 @@ import { CertsInfo } from 'types/CertsInfo';
 import { IstioCertsInfo } from 'components/IstioCertsInfo/IstioCertsInfo';
 import { TargetPanelControlPlaneMetrics } from './TargetPanelControlPlaneMetrics';
 import { TargetPanelControlPlaneStatus } from './TargetPanelControlPlaneStatus';
+import { ControlPlane } from 'types/Mesh';
 
 type TargetPanelControlPlaneProps = TargetPanelCommonProps & {
   meshStatus: string;
@@ -45,7 +43,6 @@ type TargetPanelControlPlaneProps = TargetPanelCommonProps & {
 };
 
 type TargetPanelControlPlaneState = {
-  canaryUpgradeStatus?: CanaryUpgradeStatus;
   certificates?: CertsInfo[];
   controlPlaneMetrics?: ControlPlaneMetricsMap;
   controlPlaneNode?: Node<NodeModel, any>;
@@ -56,7 +53,6 @@ type TargetPanelControlPlaneState = {
 };
 
 const defaultState: TargetPanelControlPlaneState = {
-  canaryUpgradeStatus: undefined,
   certificates: undefined,
   controlPlaneMetrics: undefined,
   controlPlaneNode: undefined,
@@ -143,9 +139,8 @@ export class TargetPanelControlPlane extends React.Component<
     const nsInfo = this.state.nsInfo;
     const data = this.state.controlPlaneNode?.getData() as NodeData;
 
-    // Controlplane infradata is structured: {config: configuration, revision: string}
-    const { config, revision } = data.infraData;
-    const parsedCm = config.ConfigMap ? this.getParsedYaml(config.ConfigMap) : '';
+    const controlPlane: ControlPlane = data.infraData;
+    const parsedCm = controlPlane.config.configMap ? this.getParsedYaml(controlPlane.config.configMap) : '';
 
     return (
       <div
@@ -156,13 +151,15 @@ export class TargetPanelControlPlane extends React.Component<
         <div className={panelHeadingStyle}>{renderNodeHeader(data, {})}</div>
 
         <div className={panelBodyStyle}>
+          {controlPlane.tag && <div>{t('Tag: {{tag}}', { tag: controlPlane.tag.name })}</div>}
+
           <div>{t('Version: {{version}}', { version: data.version || t(UNKNOWN) })}</div>
 
-          <MeshMTLSStatus cluster={data.cluster} revision={revision} />
+          <MeshMTLSStatus cluster={data.cluster} revision={controlPlane.revision} />
 
           <TargetPanelControlPlaneStatus
             controlPlaneMetrics={this.state.controlPlaneMetrics}
-            outboundTrafficPolicy={config.OutboundTrafficPolicy}
+            outboundTrafficPolicy={controlPlane.config.outboundTrafficPolicy}
           />
 
           <TLSInfo version={this.props.minTLS} />
@@ -206,38 +203,17 @@ export class TargetPanelControlPlane extends React.Component<
   private load = (): void => {
     this.promises.cancelAll();
 
-    API.getNamespaces()
-      .then(result => {
-        const data = this.state.controlPlaneNode!.getData() as NodeData;
-        const cluster = data.cluster;
-        const namespace = data.namespace;
-        const nsInfo = result.data.find(ns => ns.cluster === cluster && ns.name === namespace);
-
-        if (!nsInfo) {
-          AlertUtils.add(`Failed to find |${cluster}:${namespace}| in GetNamespaces() result`);
-          this.setState({ ...defaultState, loading: false });
-          return;
-        }
+    const data = this.state.controlPlaneNode!.getData() as NodeData;
 
-        this.promises
-          .registerAll(`promises-${data.cluster}:${data.namespace}`, [
-            this.fetchCanariesStatus(),
-            this.fetchHealthStatus(),
-            this.fetchMetrics(),
-            this.fetchTLS()
-          ])
-          .then(_ => {
-            this.setState({ loading: false, nsInfo: nsInfo });
-          })
-          .catch(err => {
-            if (err.isCanceled) {
-              console.debug('TargetPanelNamespace: Ignore fetch error (canceled).');
-              return;
-            }
-
-            this.setState({ ...defaultState, loading: false });
-            this.handleApiError('Could not loading target namespace panel', err);
-          });
+    this.promises
+      .registerAll(`promises-${data.cluster}:${data.namespace}`, [
+        this.fetchHealthStatus(),
+        this.fetchMetrics(),
+        this.fetchTLS(),
+        this.fetchNamespaceInfo()
+      ])
+      .then(_ => {
+        this.setState({ loading: false });
       })
       .catch(err => {
         if (err.isCanceled) {
@@ -246,28 +222,22 @@ export class TargetPanelControlPlane extends React.Component<
         }
 
         this.setState({ ...defaultState, loading: false });
-        this.handleApiError('Could not fetch namespaces when loading target panel', err);
+        this.handleApiError('Could not loading target namespace panel', err);
       });
 
     this.setState({ loading: true });
   };
 
-  private fetchCanariesStatus = async (): Promise<void> => {
-    if (!this.isControlPlane()) {
-      return Promise.resolve();
-    }
+  private fetchNamespaceInfo = async (): Promise<void> => {
+    const data = this.state.controlPlaneNode!.getData() as NodeData;
 
-    return API.getCanaryUpgradeStatus()
+    return API.getNamespaceInfo(data.namespace, data.cluster)
       .then(response => {
         this.setState({
-          canaryUpgradeStatus: {
-            namespacesPerRevision: response.data.namespacesPerRevision
-          }
+          nsInfo: response.data
         });
       })
-      .catch(error => {
-        AlertUtils.addError('Error fetching namespace canary upgrade status.', error, 'default', MessageType.ERROR);
-      });
+      .catch(err => this.handleApiError('Could not fetch namespace info', err));
   };
 
   private fetchHealthStatus = async (): Promise<void> => {
diff --git a/frontend/src/pages/Mesh/target/TargetPanelNamespace.tsx b/frontend/src/pages/Mesh/target/TargetPanelNamespace.tsx
index 7c9ad95c1..04b09800a 100644
--- a/frontend/src/pages/Mesh/target/TargetPanelNamespace.tsx
+++ b/frontend/src/pages/Mesh/target/TargetPanelNamespace.tsx
@@ -3,19 +3,29 @@ import { ElementModel, GraphElement, Node, NodeModel } from '@patternfly/react-t
 import { kialiStyle } from 'styles/StyleUtils';
 import { TargetPanelCommonProps, shouldRefreshData, targetPanelHR, targetPanelStyle } from './TargetPanelCommon';
 import { PFBadge, PFBadges } from 'components/Pf/PfBadges';
-import { Card, CardBody, CardHeader, Label, Title, TitleSizes, Tooltip, TooltipPosition } from '@patternfly/react-core';
+import {
+  Card,
+  CardBody,
+  CardHeader,
+  Label,
+  List,
+  ListItem,
+  Title,
+  TitleSizes,
+  Tooltip,
+  TooltipPosition
+} from '@patternfly/react-core';
 import { Paths, serverConfig } from 'config';
-import { CanaryUpgradeStatus, ValidationStatus } from 'types/IstioObjects';
+import { ValidationStatus } from 'types/IstioObjects';
 import { OverviewNamespaceAction, OverviewNamespaceActions } from 'pages/Overview/OverviewNamespaceActions';
 import { NamespaceInfo, NamespaceStatus } from 'types/NamespaceInfo';
 import { NamespaceMTLSStatus } from 'components/MTls/NamespaceMTLSStatus';
 import { NamespaceStatuses } from 'pages/Overview/NamespaceStatuses';
 import { DirectionType, OverviewType } from 'pages/Overview/OverviewToolbar';
 import { PromisesRegistry } from 'utils/CancelablePromises';
-import { CanaryUpgradeProgress } from 'pages/Overview/CanaryUpgradeProgress';
+import { ControlPlaneDonut } from 'pages/Overview/ControlPlaneDonut';
 import { isParentKiosk, kioskOverviewAction } from 'components/Kiosk/KioskActions';
 import { Show } from 'pages/Overview/OverviewPage';
-import { ControlPlaneBadge } from 'pages/Overview/ControlPlaneBadge';
 import { ControlPlaneVersionBadge } from 'pages/Overview/ControlPlaneVersionBadge';
 import { AmbientBadge } from 'components/Ambient/AmbientBadge';
 import { PFColors } from 'components/Pf/PfColors';
@@ -38,29 +48,29 @@ import { Metric } from 'types/Metrics';
 import { classes } from 'typestyle';
 import { panelBodyStyle, panelHeadingStyle, panelStyle } from 'pages/Graph/SummaryPanelStyle';
 import { isRemoteCluster } from './TargetPanelControlPlane';
+import { ControlPlane } from 'types/Mesh';
+import { MeshInfraType } from 'types/Mesh';
 
 type TargetPanelNamespaceProps = TargetPanelCommonProps;
 
 type TargetPanelNamespaceState = {
-  canaryUpgradeStatus?: CanaryUpgradeStatus;
+  controlPlanes?: ControlPlane[];
   errorMetrics?: Metric[];
   loading: boolean;
   metrics?: Metric[];
   nsInfo?: NamespaceInfo;
   status?: NamespaceStatus;
-  targetCluster?: string;
-  targetNamespace?: string;
-  targetNode?: Node<NodeModel, any>;
+  targetCluster: string;
+  targetNamespace: string;
+  targetNode: Node<NodeModel, any>;
   tlsStatus?: TLSStatus;
 };
 
-const defaultState: TargetPanelNamespaceState = {
-  canaryUpgradeStatus: undefined,
+const defaultState = {
   errorMetrics: undefined,
   loading: false,
   nsInfo: undefined,
   status: undefined,
-  targetNode: undefined,
   tlsStatus: undefined
 };
 
@@ -91,9 +101,21 @@ export class TargetPanelNamespace extends React.Component<TargetPanelNamespacePr
     super(props);
 
     const targetNode = this.props.target.elem as Node<NodeModel, any>;
+
     const data = (props.target.elem as GraphElement<ElementModel, any>).getData();
+
+    // Find the controlplanes nodes and pull out the controlplane
+    // object from infraData. The controlplane object has all the
+    // managed namespaces that is needed by elements on this page.
+    const controlPlanes: ControlPlane[] | undefined = props.target.elem
+      ?.getGraph()
+      .getData()
+      .meshData.elements.nodes?.filter(node => node.data.infraType === MeshInfraType.ISTIOD)
+      .map(node => node.data.infraData);
+
     this.state = {
       ...defaultState,
+      controlPlanes: controlPlanes,
       targetCluster: data.cluster,
       targetNamespace: data.namespace,
       targetNode: targetNode
@@ -101,19 +123,24 @@ export class TargetPanelNamespace extends React.Component<TargetPanelNamespacePr
   }
 
   static getDerivedStateFromProps(
-    props: TargetPanelCommonProps,
+    props: TargetPanelNamespaceProps,
     state: TargetPanelNamespaceState
   ): TargetPanelNamespaceState | null {
     // if the target (e.g. namespaceBox) has changed, then init the state and set to loading. The loading
     // will actually be kicked off after the render (in componentDidMount/Update).
-    return props.target.elem !== state.targetNode
-      ? ({
-          targetNode: props.target.elem,
-          targetCluster: (props.target.elem as GraphElement<ElementModel, any>).getData().cluster,
-          targetNamespace: (props.target.elem as GraphElement<ElementModel, any>).getData().namespace,
-          loading: true
-        } as TargetPanelNamespaceState)
-      : null;
+    if (props.target.elem !== state.targetNode) {
+      const { cluster, namespace } = (props.target.elem as GraphElement<ElementModel, any>).getData();
+      return {
+        controlPlanes: state.controlPlanes,
+        targetNode: props.target.elem as any,
+        targetCluster: cluster,
+        targetNamespace: namespace,
+        nsInfo: state.nsInfo,
+        loading: true
+      };
+    }
+
+    return null;
   }
 
   componentDidMount(): void {
@@ -135,10 +162,11 @@ export class TargetPanelNamespace extends React.Component<TargetPanelNamespacePr
       return this.getLoading();
     }
 
+    const listItemStyle = { marginTop: 0 };
     const isControlPlane = this.isControlPlane();
     const nsInfo = this.state.nsInfo;
     const ns = nsInfo.name;
-    const actions = this.getNamespaceActions(nsInfo);
+    const actions = this.getNamespaceActions();
     const namespaceActions = (
       <OverviewNamespaceActions key={`namespaceAction_${ns}`} namespace={ns} actions={actions} />
     );
@@ -183,18 +211,34 @@ export class TargetPanelNamespace extends React.Component<TargetPanelNamespacePr
 
                 {isControlPlane && (
                   <>
-                    {this.state.canaryUpgradeStatus && this.hasCanaryUpgradeConfigured() && (
-                      <>
+                    {this.state.controlPlanes && (
+                      <div>
                         {targetPanelHR}
-                        <CanaryUpgradeProgress canaryUpgradeStatus={this.state.canaryUpgradeStatus} />
-                      </>
+                        <ControlPlaneDonut controlPlanes={this.state.controlPlanes} />
+                      </div>
                     )}
 
-                    {this.props.istioAPIEnabled && (
-                      <>
+                    {this.state.controlPlanes && (
+                      <div style={{ textAlign: 'left', alignContent: 'start', alignItems: 'start' }}>
                         {targetPanelHR}
-                        {this.renderCharts()}
-                      </>
+                        <Title headingLevel="h3">Control Planes</Title>
+                        <List isPlain isBordered>
+                          {this.state.controlPlanes
+                            .sort((a, b) => a.istiodName.localeCompare(b.istiodName))
+                            .map(cp => (
+                              <ListItem key={cp.istiodName}>
+                                <Title headingLevel="h4">{cp.istiodName}</Title>
+                                <List style={listItemStyle} isPlain>
+                                  <ListItem style={listItemStyle}>
+                                    Version: {cp.version ? cp.version.version : 'Unknown'}
+                                  </ListItem>
+                                  <ListItem style={listItemStyle}>Revision: {cp.revision}</ListItem>
+                                  {cp.tag && <ListItem style={listItemStyle}>Tag: {cp.tag.name}</ListItem>}
+                                </List>
+                              </ListItem>
+                            ))}
+                        </List>
+                      </div>
                     )}
                   </>
                 )}
@@ -266,17 +310,7 @@ export class TargetPanelNamespace extends React.Component<TargetPanelNamespacePr
     );
   };
 
-  private hasCanaryUpgradeConfigured = (): boolean => {
-    if (this.state.canaryUpgradeStatus) {
-      if (Object.keys(this.state.canaryUpgradeStatus.namespacesPerRevision).length > 1) {
-        return true;
-      }
-    }
-
-    return false;
-  };
-
-  private getNamespaceActions = (nsInfo: NamespaceInfo): OverviewNamespaceAction[] => {
+  private getNamespaceActions = (): OverviewNamespaceAction[] => {
     // Today actions are fixed, but soon actions may depend of the state of a namespace
     // So we keep this wrapped in a showActions function.
     const namespaceActions: OverviewNamespaceAction[] = isParentKiosk(this.props.kiosk)
@@ -344,190 +378,6 @@ export class TargetPanelNamespace extends React.Component<TargetPanelNamespacePr
             ]
           }
         ];
-    // We are going to assume that if the user can create/update Istio AuthorizationPolicies in a namespace
-    // then it can use the Istio Injection Actions.
-    // RBAC allow more fine granularity but Kiali won't check that in detail.
-
-    if (serverConfig.istioNamespace !== nsInfo.name) {
-      if (serverConfig.kialiFeatureFlags.istioInjectionAction && !serverConfig.kialiFeatureFlags.istioUpgradeAction) {
-        namespaceActions.push({
-          isGroup: false,
-          isSeparator: true
-        });
-
-        const enableAction = {
-          'data-test': `enable-${nsInfo.name}-namespace-sidecar-injection`,
-          isGroup: false,
-          isSeparator: false,
-          title: 'Enable Auto Injection',
-          action: (ns: string) => console.log(`TODO: Enable Auto Injection [${ns}]`)
-          /*
-            this.setState({
-              showTrafficPoliciesModal: true,
-              nsTarget: ns,
-              opTarget: 'enable',
-              kind: 'injection',
-              clusterTarget: nsInfo.cluster
-            })
-            */
-        };
-
-        const disableAction = {
-          'data-test': `disable-${nsInfo.name}-namespace-sidecar-injection`,
-          isGroup: false,
-          isSeparator: false,
-          title: 'Disable Auto Injection',
-          action: (ns: string) => console.log(`TODO: Disable Auto Injection [${ns}]`)
-          /*
-            this.setState({
-              showTrafficPoliciesModal: true,
-              nsTarget: ns,
-              opTarget: 'disable',
-              kind: 'injection',
-              clusterTarget: nsInfo.cluster
-            })
-            */
-        };
-
-        const removeAction = {
-          'data-test': `remove-${nsInfo.name}-namespace-sidecar-injection`,
-          isGroup: false,
-          isSeparator: false,
-          title: 'Remove Auto Injection',
-          action: (ns: string) => console.log(`TODO: Remove Auto Injection [${ns}]`)
-          /*
-            this.setState({
-              showTrafficPoliciesModal: true,
-              nsTarget: ns,
-              opTarget: 'remove',
-              kind: 'injection',
-              clusterTarget: nsInfo.cluster
-            })
-            */
-        };
-
-        if (
-          nsInfo.labels &&
-          ((nsInfo.labels[serverConfig.istioLabels.injectionLabelName] &&
-            nsInfo.labels[serverConfig.istioLabels.injectionLabelName] === 'enabled') ||
-            nsInfo.labels[serverConfig.istioLabels.injectionLabelRev])
-        ) {
-          namespaceActions.push(disableAction);
-          namespaceActions.push(removeAction);
-        } else if (
-          nsInfo.labels &&
-          nsInfo.labels[serverConfig.istioLabels.injectionLabelName] &&
-          nsInfo.labels[serverConfig.istioLabels.injectionLabelName] === 'disabled'
-        ) {
-          namespaceActions.push(enableAction);
-          namespaceActions.push(removeAction);
-        } else {
-          namespaceActions.push(enableAction);
-        }
-      }
-
-      if (serverConfig.kialiFeatureFlags.istioUpgradeAction && this.hasCanaryUpgradeConfigured()) {
-        const revisionActions = Object.keys(this.state.canaryUpgradeStatus?.namespacesPerRevision || {})
-          .filter(revision => !this.state.canaryUpgradeStatus!.namespacesPerRevision[revision].includes(nsInfo.name))
-          .map(revision => ({
-            isGroup: false,
-            isSeparator: false,
-            title: `Switch to ${revision} revision`,
-            action: (ns: string) => console.log(`TODO: Upgrade revision [${ns}]`)
-            /*
-            this.setState({
-              opTarget: 'upgrade',
-              kind: 'canary',
-              nsTarget: ns,
-              showTrafficPoliciesModal: true,
-              clusterTarget: nsInfo.cluster
-            })
-            */
-          }));
-
-        namespaceActions.push({
-          isGroup: false,
-          isSeparator: true
-        });
-
-        revisionActions.forEach(action => {
-          namespaceActions.push(action);
-        });
-      }
-
-      const aps = nsInfo.istioConfig?.authorizationPolicies ?? [];
-
-      const addAuthorizationAction = {
-        isGroup: false,
-        isSeparator: false,
-        title: `${aps.length === 0 ? 'Create ' : 'Update'} Traffic Policies`,
-        action: (ns: string) => console.log(`TODO: create traffic policies [${ns}]`)
-        /*
-          this.setState({
-            opTarget: aps.length === 0 ? 'create' : 'update',
-            nsTarget: ns,
-            clusterTarget: nsInfo.cluster,
-            showTrafficPoliciesModal: true,
-            kind: 'policy'
-          });
-        */
-      };
-
-      const removeAuthorizationAction = {
-        isGroup: false,
-        isSeparator: false,
-        title: 'Delete Traffic Policies',
-        action: (ns: string) => console.log(`TODO: delete traffic policies [${ns}]`)
-        /*
-          this.setState({
-            opTarget: 'delete',
-            nsTarget: ns,
-            showTrafficPoliciesModal: true,
-            kind: 'policy',
-            clusterTarget: nsInfo.cluster
-          })
-          */
-      };
-
-      if (this.props.istioAPIEnabled) {
-        namespaceActions.push({
-          isGroup: false,
-          isSeparator: true
-        });
-
-        namespaceActions.push(addAuthorizationAction);
-
-        if (aps.length > 0) {
-          namespaceActions.push(removeAuthorizationAction);
-        }
-      }
-    } else {
-      console.log(`TODO: grafana links`);
-      /*
-    if (this.state.grafanaLinks.length > 0) {
-      // Istio namespace will render external Grafana dashboards
-      namespaceActions.push({
-        isGroup: false,
-        isSeparator: true
-      });
-
-      this.state.grafanaLinks.forEach(link => {
-        const grafanaDashboard = {
-          isGroup: false,
-          isSeparator: false,
-          isExternal: true,
-          title: link.name,
-          action: (_ns: string) => {
-            window.open(link.url, '_blank');
-            this.load();
-          }
-        };
-
-        namespaceActions.push(grafanaDashboard);
-      });
-      */
-    }
-
     return namespaceActions;
   };
 
@@ -535,18 +385,9 @@ export class TargetPanelNamespace extends React.Component<TargetPanelNamespacePr
     const isControlPlane = this.isControlPlane();
     return (
       <>
-        {isControlPlane && <ControlPlaneBadge cluster={ns.cluster} annotations={ns.annotations}></ControlPlaneBadge>}
-
-        {isControlPlane &&
-          this.hasCanaryUpgradeConfigured() &&
-          this.state.canaryUpgradeStatus &&
-          this.state.canaryUpgradeStatus.namespacesPerRevision &&
-          Object.keys(this.state.canaryUpgradeStatus!.namespacesPerRevision).map(
-            revision =>
-              !this.state.canaryUpgradeStatus!.namespacesPerRevision[revision].includes(ns.name) && (
-                <ControlPlaneVersionBadge version={revision} />
-              )
-          )}
+        {isControlPlane && ns.name !== serverConfig.istioNamespace && (
+          <ControlPlaneVersionBadge version={ns.labels ? ns.labels['istio.io/rev'] : ''} />
+        )}
 
         {isControlPlane && !this.props.istioAPIEnabled && (
           <Label style={{ marginLeft: '0.5rem' }} color="orange" isCompact>
@@ -621,35 +462,10 @@ export class TargetPanelNamespace extends React.Component<TargetPanelNamespacePr
   private load = (): void => {
     this.promises.cancelAll();
 
-    API.getNamespaces()
-      .then(result => {
-        const cluster = this.state.targetCluster;
-        const namespace = this.state.targetNamespace;
-        const nsInfo = result.data.find(ns => ns.cluster === cluster && ns.name === namespace);
-        if (!nsInfo) {
-          AlertUtils.add(`Failed to find |${cluster}:${namespace}| in GetNamespaces() result`);
-          this.setState({ ...defaultState, loading: false });
-          return;
-        }
-
-        this.promises
-          .registerAll(`promises-${cluster}:${namespace}`, [
-            this.fetchCanariesStatus(),
-            this.fetchHealthStatus(),
-            this.fetchMetrics()
-          ])
-          .then(_ => {
-            this.setState({ loading: false, nsInfo: nsInfo });
-          })
-          .catch(err => {
-            if (err.isCanceled) {
-              console.debug('TargetPanelNamespace: Ignore fetch error (canceled).');
-              return;
-            }
-
-            this.setState({ ...defaultState, loading: false });
-            this.handleApiError('Could not loading target namespace panel', err);
-          });
+    this.promises
+      .registerAll(`promises`, [this.fetchNamespaceInfo(), this.fetchHealthStatus(), this.fetchMetrics()])
+      .then(() => {
+        this.setState({ loading: false });
       })
       .catch(err => {
         if (err.isCanceled) {
@@ -658,27 +474,21 @@ export class TargetPanelNamespace extends React.Component<TargetPanelNamespacePr
         }
 
         this.setState({ ...defaultState, loading: false });
-        this.handleApiError('Could not fetch namespaces when loading target panel', err);
+        this.handleApiError('Could not loading target namespace panel', err);
       });
 
     this.setState({ loading: true });
   };
 
-  private fetchCanariesStatus = async (): Promise<void> => {
-    if (!this.isControlPlane()) {
-      return Promise.resolve();
-    }
-
-    return API.getCanaryUpgradeStatus()
+  private fetchNamespaceInfo = async (): Promise<void> => {
+    return API.getNamespaceInfo(this.state.targetNamespace)
       .then(response => {
         this.setState({
-          canaryUpgradeStatus: {
-            namespacesPerRevision: response.data.namespacesPerRevision
-          }
+          nsInfo: response.data
         });
       })
       .catch(error => {
-        AlertUtils.addError('Error fetching namespace canary upgrade status.', error, 'default', MessageType.ERROR);
+        AlertUtils.addError('Error fetching namespace info.', error, 'default', MessageType.ERROR);
       });
   };
 
diff --git a/frontend/src/pages/Overview/CanaryUpgradeProgress.tsx b/frontend/src/pages/Overview/CanaryUpgradeProgress.tsx
deleted file mode 100644
index da66991b6..000000000
--- a/frontend/src/pages/Overview/CanaryUpgradeProgress.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { ChartDonut } from '@patternfly/react-charts';
-import { Tooltip, TooltipPosition } from '@patternfly/react-core';
-import { KialiIcon } from 'config/KialiIcon';
-import * as React from 'react';
-import { CanaryUpgradeStatus } from 'types/IstioObjects';
-import { kialiStyle } from 'styles/StyleUtils';
-
-type Props = {
-  canaryUpgradeStatus: CanaryUpgradeStatus;
-};
-
-export const infoStyle = kialiStyle({
-  margin: '0 0 -0.125rem 0.25rem'
-});
-
-function totalNamespaces(canaryUpgradeStatus: CanaryUpgradeStatus): number {
-  return Object.values(canaryUpgradeStatus.namespacesPerRevision).reduce(
-    (acc, namespaces) => acc + namespaces.length,
-    0
-  );
-}
-
-function convertToChartData(canaryUpgradeStatus: CanaryUpgradeStatus): { x: string; y: number }[] {
-  return Object.entries(canaryUpgradeStatus.namespacesPerRevision).map(([key, value]) => ({
-    x: key,
-    y: value.length
-  }));
-}
-
-export const CanaryUpgradeProgress: React.FC<Props> = (props: Props) => {
-  const total = totalNamespaces(props.canaryUpgradeStatus);
-
-  return (
-    <div style={{ textAlign: 'center' }} data-test="canary-upgrade">
-      <span>Canary upgrade status</span>
-
-      <Tooltip
-        position={TooltipPosition.right}
-        content={`There is an in progress canary upgrade of namespaces per control plane revision`}
-      >
-        <KialiIcon.Info className={infoStyle} />
-      </Tooltip>
-
-      <div style={{ height: '180px' }}>
-        <ChartDonut
-          data={convertToChartData(props.canaryUpgradeStatus)}
-          labels={({ datum }) => (datum.x ? `${datum.x}: ${datum.y}, ${((datum.y * 100) / total).toFixed(2)}%` : null)}
-          height={170}
-        />
-      </div>
-    </div>
-  );
-};
diff --git a/frontend/src/pages/Overview/ControlPlaneDonut.tsx b/frontend/src/pages/Overview/ControlPlaneDonut.tsx
new file mode 100644
index 000000000..2d3a8a199
--- /dev/null
+++ b/frontend/src/pages/Overview/ControlPlaneDonut.tsx
@@ -0,0 +1,103 @@
+import { ChartDonut, ChartLabel, ChartLegend } from '@patternfly/react-charts';
+import * as React from 'react';
+import { ControlPlane } from 'types/Mesh';
+import { kialiStyle } from 'styles/StyleUtils';
+
+type Props = {
+  controlPlanes: ControlPlane[];
+};
+
+export const infoStyle = kialiStyle({
+  margin: '0 0 -0.125rem 0.25rem'
+});
+
+export const ControlPlaneDonut: React.FC<Props> = ({ controlPlanes }) => {
+  const total = controlPlanes.reduce((acc, cp) => acc + (cp.managedNamespaces?.length || 0), 0);
+  const [hoveredSlice, setHoveredSlice] = React.useState<number | undefined>(undefined);
+  // Sorting so that the positions of the donut slices don't change on re-render.
+  // We can't sort the legendData because that includes the ':' char in the name
+  // which messes up the sort order.
+  controlPlanes.sort((a, b) => a.istiodName.localeCompare(b.istiodName));
+  const legendData = controlPlanes.map(cp => ({
+    name: `${cp.istiodName}:`,
+    subtext: ` ${cp.managedNamespaces ? cp.managedNamespaces.length : 0}`
+  }));
+
+  // Using a custom label here because there's no way to bold part of the text otherwise.
+  // This let's us break the text into two parts and apply separate styles to each so
+  // we can bold the first part and leave the second normal.
+  // e.g. <strong>1-21-1:</strong> 4
+  const label = (
+    <ChartLabel
+      inline={true}
+      style={[{ fontWeight: 'bold' }, { fontWeight: 'inherit' }]}
+      text={({ datum }) => [datum.name, datum.subtext] as any}
+    />
+  );
+
+  // Using a custom legendComponent so that we can pass custom data through to
+  // the custom labelComponent. Otherwise the label component will only get
+  // the name as the datum.
+  const legend = <ChartLegend labelComponent={label} data={legendData} />;
+
+  // This shows the number of namespaces inside the donut.
+  // It will update based on what slice you are hovering over.
+  const title =
+    hoveredSlice !== undefined && controlPlanes.at(hoveredSlice)
+      ? controlPlanes[hoveredSlice].managedNamespaces?.length || 0
+      : total;
+  const height = 180;
+  const width = 300;
+  return (
+    <div data-test="controlplane-donut">
+      <div>Namespaces managed by Control Planes</div>
+      <div style={{ height: `${height}px`, width: `${width}px` }}>
+        <ChartDonut
+          constrainToVisibleArea
+          style={{
+            data: {
+              // This will highlight the hovered slice on the donut
+              // by making the rest of the slices
+              // that aren't hovered more opaque.
+              opacity: ({ index }) => (hoveredSlice !== undefined && hoveredSlice !== index ? 0.3 : 1.0)
+            }
+          }}
+          height={height}
+          width={width}
+          // Padding is used to "center" the chart svg underneath the title
+          padding={{
+            top: 20,
+            left: 100,
+            bottom: 20,
+            right: 0
+          }}
+          data={controlPlanes}
+          events={[
+            {
+              target: 'data',
+              eventHandlers: {
+                onMouseEnter: (_, props) => {
+                  setHoveredSlice(props.index);
+                },
+                onMouseLeave: () => {
+                  setHoveredSlice(undefined);
+                }
+              }
+            }
+          ]}
+          labels={({ datum }) =>
+            `${datum.istiodName}: ${(((datum.managedNamespaces?.length || 0) / total) * 100).toFixed(2)}%`
+          }
+          legendData={legendData}
+          legendComponent={legend}
+          legendOrientation="vertical"
+          title={`${title}`}
+          subTitle="namespaces"
+          sortOrder="ascending"
+          x="istiodName"
+          y={datum => datum.managedNamespaces?.length || 0}
+        />
+      </div>
+    </div>
+  );
+};
diff --git a/frontend/src/pages/Overview/OverviewPage.tsx b/frontend/src/pages/Overview/OverviewPage.tsx
index 021339a68..2938978a9 100644
--- a/frontend/src/pages/Overview/OverviewPage.tsx
+++ b/frontend/src/pages/Overview/OverviewPage.tsx
@@ -29,6 +29,7 @@ import {
   Health,
   NamespaceAppHealth
 } from '../../types/Health';
+import { ControlPlane } from '../../types/Mesh';
 import { SortField } from '../../types/SortFilters';
 import { PromisesRegistry } from '../../utils/CancelablePromises';
 import { OverviewToolbar, OverviewDisplayMode, OverviewType, DirectionType } from './OverviewToolbar';
@@ -61,7 +62,7 @@ import { OverviewNamespaceAction, OverviewNamespaceActions } from './OverviewNam
 import { router, HistoryManager, URLParam } from '../../app/History';
 import * as AlertUtils from '../../utils/AlertUtils';
 import { MessageType } from '../../types/MessageCenter';
-import { CanaryUpgradeStatus, ValidationStatus } from '../../types/IstioObjects';
+import { ValidationStatus } from '../../types/IstioObjects';
 import { GrafanaInfo, ISTIO_DASHBOARDS } from '../../types/GrafanaInfo';
 import { ExternalLink } from '../../types/Dashboards';
 import { isParentKiosk, kioskOverviewAction } from '../../components/Kiosk/KioskActions';
@@ -130,8 +131,8 @@ export enum Show {
 }
 
 type State = {
-  canaryUpgradeStatus?: CanaryUpgradeStatus;
   clusterTarget?: string;
+  controlPlanes?: ControlPlane[];
   direction: DirectionType;
   displayMode: OverviewDisplayMode;
   grafanaLinks: ExternalLink[];
@@ -178,7 +179,7 @@ export class OverviewPageComponent extends React.Component<OverviewProps, State>
       clusterTarget: '',
       opTarget: '',
       grafanaLinks: [],
-      canaryUpgradeStatus: undefined
+      controlPlanes: undefined
     };
   }
 
@@ -242,8 +243,8 @@ export class OverviewPageComponent extends React.Component<OverviewProps, State>
               errorMetrics: previous ? previous.errorMetrics : undefined,
               validations: previous ? previous.validations : undefined,
               labels: ns.labels,
-              annotations: ns.annotations
-              //controlPlaneMetrics: previous ? previous.controlPlaneMetrics : undefined
+              annotations: ns.annotations,
+              revision: ns.revision
             };
           });
 
@@ -271,7 +272,7 @@ export class OverviewPageComponent extends React.Component<OverviewProps, State>
             this.fetchHealth(isAscending, sortField, type);
             this.fetchTLS(isAscending, sortField);
             this.fetchValidations(isAscending, sortField);
-            this.fetchCanariesStatus();
+            this.fetchControlPlanes();
 
             if (displayMode !== OverviewDisplayMode.COMPACT) {
               this.fetchMetrics(direction);
@@ -638,17 +639,15 @@ export class OverviewPageComponent extends React.Component<OverviewProps, State>
       });
   };
 
-  private fetchCanariesStatus = async (): Promise<void> => {
-    return API.getCanaryUpgradeStatus()
+  private fetchControlPlanes = async (): Promise<void> => {
+    return API.getControlPlanes()
       .then(response => {
         this.setState({
-          canaryUpgradeStatus: {
-            namespacesPerRevision: response.data.namespacesPerRevision
-          }
+          controlPlanes: response.data
         });
       })
       .catch(error => {
-        AlertUtils.addError('Error fetching canary upgrade status.', error, 'default', MessageType.ERROR);
+        AlertUtils.addError('Error fetching controlplanes.', error, 'default', MessageType.ERROR);
       });
   };
 
@@ -862,15 +861,20 @@ export class OverviewPageComponent extends React.Component<OverviewProps, State>
       }
 
       if (serverConfig.kialiFeatureFlags.istioUpgradeAction && this.hasCanaryUpgradeConfigured()) {
-        const revisionActions = Object.keys(this.state.canaryUpgradeStatus?.namespacesPerRevision || {})
-          .filter(revision => !this.state.canaryUpgradeStatus!.namespacesPerRevision[revision].includes(nsInfo.name))
-          .map(revision => ({
+        const revisionActions = this.state.controlPlanes
+          ?.filter(
+            controlplane =>
+              nsInfo.revision &&
+              controlplane.managedClusters?.some(managedCluster => managedCluster.name === nsInfo.cluster) &&
+              controlplane.revision !== nsInfo.revision
+          )
+          .map(controlPlane => ({
             isGroup: false,
             isSeparator: false,
-            title: `Switch to ${revision} revision`,
+            title: `Switch to ${controlPlane.revision} revision`,
             action: (ns: string) =>
               this.setState({
-                opTarget: revision,
+                opTarget: controlPlane.revision,
                 kind: 'canary',
                 nsTarget: ns,
                 showTrafficPoliciesModal: true,
@@ -878,12 +882,14 @@ export class OverviewPageComponent extends React.Component<OverviewProps, State>
               })
           }));
 
-        namespaceActions.push({
-          isGroup: false,
-          isSeparator: true
-        });
+        if (revisionActions && revisionActions.length > 0) {
+          namespaceActions.push({
+            isGroup: false,
+            isSeparator: true
+          });
+        }
 
-        revisionActions.forEach(action => {
+        revisionActions?.forEach(action => {
           namespaceActions.push(action);
         });
       }
@@ -968,13 +974,7 @@ export class OverviewPageComponent extends React.Component<OverviewProps, State>
   };
 
   hasCanaryUpgradeConfigured = (): boolean => {
-    if (this.state.canaryUpgradeStatus) {
-      if (Object.keys(this.state.canaryUpgradeStatus.namespacesPerRevision).length > 1) {
-        return true;
-      }
-    }
-
-    return false;
+    return this.state.controlPlanes !== undefined;
   };
 
   render(): React.ReactNode {
@@ -1105,7 +1105,9 @@ export class OverviewPageComponent extends React.Component<OverviewProps, State>
         <OverviewTrafficPolicies
           opTarget={this.state.opTarget}
           isOpen={this.state.showTrafficPoliciesModal}
-          canaryUpgradeStatus={this.state.canaryUpgradeStatus}
+          controlPlanes={this.state.controlPlanes?.filter(cp =>
+            cp.managedNamespaces?.some(mn => mn.name === this.state.nsTarget)
+          )}
           kind={this.state.kind}
           hideConfirmModal={this.hideTrafficManagement}
           nsTarget={this.state.nsTarget}
@@ -1329,16 +1331,7 @@ export class OverviewPageComponent extends React.Component<OverviewProps, State>
           <ControlPlaneBadge cluster={ns.cluster} annotations={ns.annotations}></ControlPlaneBadge>
         )}
 
-        {ns.name !== serverConfig.istioNamespace &&
-          this.hasCanaryUpgradeConfigured() &&
-          this.state.canaryUpgradeStatus &&
-          this.state.canaryUpgradeStatus.namespacesPerRevision &&
-          Object.keys(this.state.canaryUpgradeStatus!.namespacesPerRevision).map(
-            revision =>
-              this.state.canaryUpgradeStatus!.namespacesPerRevision[revision].includes(ns.name) && (
-                <ControlPlaneVersionBadge version={revision} />
-              )
-          )}
+        {ns.name !== serverConfig.istioNamespace && ns.revision && <ControlPlaneVersionBadge version={ns.revision} />}
 
         {ns.name === serverConfig.istioNamespace && !this.props.istioAPIEnabled && (
           <Label style={{ marginLeft: '0.5rem' }} color="orange" isCompact>
diff --git a/frontend/src/pages/Overview/OverviewTrafficPolicies.tsx b/frontend/src/pages/Overview/OverviewTrafficPolicies.tsx
index c7d0711c8..9bec284ac 100644
--- a/frontend/src/pages/Overview/OverviewTrafficPolicies.tsx
+++ b/frontend/src/pages/Overview/OverviewTrafficPolicies.tsx
@@ -1,7 +1,8 @@
 import * as React from 'react';
 import { Button, ButtonVariant, Modal, ModalVariant } from '@patternfly/react-core';
 import { NamespaceInfo } from '../../types/NamespaceInfo';
-import { AuthorizationPolicy, CanaryUpgradeStatus, Sidecar } from 'types/IstioObjects';
+import { ControlPlane } from '../../types/Mesh';
+import { AuthorizationPolicy, Sidecar } from 'types/IstioObjects';
 import { MessageType } from 'types/MessageCenter';
 import { PromisesRegistry } from 'utils/CancelablePromises';
 import { DurationInSeconds } from 'types/Common';
@@ -18,7 +19,7 @@ import { dicIstioTypeToGVK } from '../../types/IstioConfigList';
 import { gvkToString } from '../../utils/IstioConfigUtils';
 
 type OverviewTrafficPoliciesProps = {
-  canaryUpgradeStatus?: CanaryUpgradeStatus;
+  controlPlanes?: ControlPlane[];
   duration: DurationInSeconds;
   hideConfirmModal: () => void;
   isOpen: boolean;
@@ -49,7 +50,7 @@ export class OverviewTrafficPolicies extends React.Component<OverviewTrafficPoli
       sidecars: [],
       loaded: this.props.opTarget === 'update',
       disableOp: true,
-      selectedRevision: this.props.kind === 'canary' ? this.getCanaryUpgradeVersion(this.props.opTarget) : ''
+      selectedRevision: this.props.kind === 'canary' ? this.props.opTarget : ''
     };
   }
 
@@ -57,15 +58,6 @@ export class OverviewTrafficPolicies extends React.Component<OverviewTrafficPoli
     return this.props.kind === 'canary' || this.props.kind === 'injection';
   };
 
-  getCanaryUpgradeVersion = (opTarget: string): string => {
-    if (this.props.canaryUpgradeStatus) {
-      if (opTarget in this.props.canaryUpgradeStatus.namespacesPerRevision) {
-        return opTarget;
-      }
-    }
-    return '';
-  };
-
   componentDidUpdate(prevProps: OverviewTrafficPoliciesProps): void {
     if (prevProps.nsTarget !== this.props.nsTarget || prevProps.opTarget !== this.props.opTarget) {
       switch (this.props.kind) {
@@ -73,9 +65,7 @@ export class OverviewTrafficPolicies extends React.Component<OverviewTrafficPoli
           this.fetchPermission(true);
           break;
         case 'canary':
-          this.setState({ selectedRevision: this.getCanaryUpgradeVersion(this.props.opTarget) }, () =>
-            this.fetchPermission(true)
-          );
+          this.setState({ selectedRevision: this.props.opTarget }, () => this.fetchPermission(true));
           break;
         default:
           if (this.props.opTarget === 'create') {
@@ -305,7 +295,7 @@ export class OverviewTrafficPolicies extends React.Component<OverviewTrafficPoli
   };
 
   render(): React.ReactNode {
-    const canaryVersion = this.props.kind === 'canary' ? this.getCanaryUpgradeVersion(this.props.opTarget) : '';
+    const canaryVersion = this.props.kind === 'canary' ? this.props.opTarget : '';
 
     const modalAction =
       this.props.kind === 'canary'
diff --git a/frontend/src/services/Api.ts b/frontend/src/services/Api.ts
index cbd648145..0bba276eb 100644
--- a/frontend/src/services/Api.ts
+++ b/frontend/src/services/Api.ts
@@ -39,7 +39,6 @@ import {
   K8sHTTPRoute,
   K8sGRPCRoute,
   OutboundTrafficPolicy,
-  CanaryUpgradeStatus,
   PodLogsQuery,
   LogLevelQuery,
   LogType,
@@ -47,7 +46,7 @@ import {
 } from '../types/IstioObjects';
 import { ComponentStatus, IstiodResourceThresholds } from '../types/IstioStatus';
 import { TracingInfo, TracingResponse, TracingSingleResponse } from '../types/TracingInfo';
-import { MeshDefinition, MeshQuery } from '../types/Mesh';
+import { ControlPlane, MeshDefinition, MeshQuery } from '../types/Mesh';
 import { DashboardQuery, IstioMetricsOptions, MetricsStatsQuery } from '../types/MetricsOptions';
 import { IstioMetricsMap, MetricsPerNamespace, MetricsStatsResult } from '../types/Metrics';
 import { Namespace } from '../types/Namespace';
@@ -1338,8 +1337,8 @@ export const getCrippledFeatures = (): Promise<ApiResponse<KialiCrippledFeatures
   return newRequest<KialiCrippledFeatures>(HTTP_VERBS.GET, urls.crippledFeatures, {}, {});
 };
 
-export const getCanaryUpgradeStatus = (): Promise<ApiResponse<CanaryUpgradeStatus>> => {
-  return newRequest<CanaryUpgradeStatus>(HTTP_VERBS.GET, urls.canaryUpgradeStatus(), {}, {});
+export const getControlPlanes = (): Promise<ApiResponse<ControlPlane[]>> => {
+  return newRequest<ControlPlane[]>(HTTP_VERBS.GET, urls.controlPlanes, {}, {});
 };
 
 export const getMeshGraph = (params: MeshQuery): Promise<ApiResponse<MeshDefinition>> => {
diff --git a/frontend/src/store/Store.ts b/frontend/src/store/Store.ts
index bf88558f3..a7e52e984 100644
--- a/frontend/src/store/Store.ts
+++ b/frontend/src/store/Store.ts
@@ -45,12 +45,17 @@ export interface ClusterState {
 }
 
 export interface NamespaceState {
-  readonly activeNamespaces: Namespace[];
-  readonly filter: string;
+  readonly activeNamespaces: Namespace[]; // the namespaces currently selected in NamespaceDropdown
+  readonly filter: string; // the filter currently defined in NamespaceDropdown
   readonly isFetching: boolean;
+  // TODO: Can this be non-optional since we default to empty list?
+  // Set of unique namespace names, for convenience.
+  // namespaces on different clusters that share the same name
+  // are filtered out of this list. Do not use this expecting
+  // it to be a complete list of namespaces across all clusters.
   readonly items?: Namespace[];
   readonly lastUpdated?: Date;
-  readonly namespacesPerCluster: Map<string, string[]>;
+  readonly namespacesPerCluster: Map<string, string[]>; // map clusterName -> namespaces on cluster
 }
 
 // Various pages are described here with their various sections
diff --git a/frontend/src/styles/GlobalStyle.ts b/frontend/src/styles/GlobalStyle.ts
index 947cbe5d0..8158a190c 100644
--- a/frontend/src/styles/GlobalStyle.ts
+++ b/frontend/src/styles/GlobalStyle.ts
@@ -91,7 +91,7 @@ export const globalStyle = kialiStyle({
     /**
      * Hide the kebab menu of Patternfly topology groups
      */
-    '& .pf-topology__group__label': {
+    '& #pft-graph .pf-topology__group__label': {
       $nest: {
         '& .pf-topology__node__label__badge ~ text:not(.pf-m-secondary)': {
           transform: 'translateX(10px)'
diff --git a/frontend/src/types/IstioObjects.ts b/frontend/src/types/IstioObjects.ts
index 7b4d56d15..de023d6d4 100644
--- a/frontend/src/types/IstioObjects.ts
+++ b/frontend/src/types/IstioObjects.ts
@@ -1427,9 +1427,5 @@ export interface APIKey {
   query?: string;
 }
 
-export interface CanaryUpgradeStatus {
-  namespacesPerRevision: { [key: string]: string[] };
-}
-
 export const MAX_PORT = 65535;
 export const MIN_PORT = 0;
diff --git a/frontend/src/types/Mesh.ts b/frontend/src/types/Mesh.ts
index cfdedb619..d80e52f73 100644
--- a/frontend/src/types/Mesh.ts
+++ b/frontend/src/types/Mesh.ts
@@ -46,7 +46,7 @@ export interface MeshNodeData {
   cluster: string;
   healthData?: MeshNodeHealthData;
   id: string;
-  infraData?: MeshCluster | NamespaceInfo[] | any; // add other type options as the case arises
+  infraData?: MeshCluster | NamespaceInfo[] | ControlPlane | any; // add other type options as the case arises
   infraName: string;
   infraType: MeshInfraType;
   isAmbient?: boolean;
@@ -92,6 +92,29 @@ export interface MeshDefinition {
   timestamp: number;
 }
 
+export interface Tag {
+  name: string;
+}
+
+export interface ControlPlaneConfig {
+  configMap?: any;
+  outboundTrafficPolicy?: any;
+}
+
+export interface ControlPlane {
+  cluster: MeshCluster;
+  config: ControlPlaneConfig;
+  istiodName: string;
+  managedClusters?: { name: string }[];
+  managedNamespaces?: NamespaceInfo[];
+  revision: string;
+  tag?: Tag;
+  thresholds: any;
+  version?: {
+    version: string;
+  };
+}
+
 // Node data after decorating at fetch-time (what is mainly used by ui code)
 export interface DecoratedMeshNodeData extends MeshNodeData {
   healthStatus: string; // status name
diff --git a/frontend/src/types/Namespace.ts b/frontend/src/types/Namespace.ts
index cce790758..83086bd0d 100644
--- a/frontend/src/types/Namespace.ts
+++ b/frontend/src/types/Namespace.ts
@@ -1,15 +1,17 @@
 export interface Namespace {
-  name: string;
+  annotations?: { [key: string]: string };
   cluster?: string;
   isAmbient?: boolean;
   labels?: { [key: string]: string };
-  annotations?: { [key: string]: string };
+  name: string;
+  revision?: string;
 }
 
-export const namespaceFromString = (namespace: string) => ({ name: namespace });
+export const namespaceFromString = (namespace: string): { name: string } => ({ name: namespace });
 
-export const namespacesFromString = (namespaces: string) => {
+export const namespacesFromString = (namespaces: string): { name: string }[] => {
   return namespaces.split(',').map(name => namespaceFromString(name));
 };
 
-export const namespacesToString = (namespaces: Namespace[]) => namespaces.map(namespace => namespace.name).join(',');
+export const namespacesToString = (namespaces: Namespace[]): string =>
+  namespaces.map(namespace => namespace.name).join(',');
diff --git a/frontend/src/types/NamespaceInfo.ts b/frontend/src/types/NamespaceInfo.ts
index f916011ff..3b17ea05c 100644
--- a/frontend/src/types/NamespaceInfo.ts
+++ b/frontend/src/types/NamespaceInfo.ts
@@ -13,6 +13,7 @@ export type NamespaceInfo = {
   metrics?: Metric[];
   name: string;
   outboundPolicyMode?: string;
+  revision?: string;
   status?: NamespaceStatus;
   tlsStatus?: TLSStatus;
   validations?: ValidationStatus;
diff --git a/hack/setup-kind-in-ci.sh b/hack/setup-kind-in-ci.sh
index 57587486d..885614eb5 100755
--- a/hack/setup-kind-in-ci.sh
+++ b/hack/setup-kind-in-ci.sh
@@ -110,6 +110,7 @@ if [ -z "${HELM_CHARTS_DIR}" ]; then
   HELM_CHARTS_DIR="$(mktemp -d)"
   infomsg "Cloning kiali helm-charts..."
   git clone --single-branch --branch "${TARGET_BRANCH}" https://github.com/kiali/helm-charts.git "${HELM_CHARTS_DIR}"
+  make -C "${HELM_CHARTS_DIR}" build-helm-charts
 fi
 
 # print out our settings for debug purposes
@@ -201,8 +202,6 @@ setup_kind_singlecluster() {
   infomsg "Pushing the images into the cluster..."
   make -e DORP="${DORP}" -e CLUSTER_TYPE="kind" -e KIND_NAME="ci" cluster-push-kiali
 
-  make -C "${HELM_CHARTS_DIR}" build-helm-charts
-
   HELM="${HELM_CHARTS_DIR}/_output/helm-install/helm"
 
   infomsg "Using helm: $(ls -l ${HELM})"
@@ -282,8 +281,6 @@ setup_kind_tempo() {
   infomsg "Pushing the images into the cluster..."
   make -e DORP="${DORP}" -e CLUSTER_TYPE="kind" -e KIND_NAME="ci" cluster-push-kiali
 
-  make -C "${HELM_CHARTS_DIR}" build-helm-charts
-
   HELM="${HELM_CHARTS_DIR}/_output/helm-install/helm"
 
   infomsg "Using helm: $(ls -l ${HELM})"
@@ -337,8 +334,6 @@ setup_kind_multicluster() {
   infomsg "Downloading istio"
   "${SCRIPT_DIR}"/istio/download-istio.sh ${DOWNLOAD_ISTIO_VERSION_ARG}
 
-  make -C "${HELM_CHARTS_DIR}" build-helm-charts
-
   local script_dir
   script_dir="$(cd $(dirname "${BASH_SOURCE[0]}") && pwd)"
   local output_dir
diff --git a/handlers/mesh.go b/handlers/mesh.go
index ebee56566..8a77c2c74 100644
--- a/handlers/mesh.go
+++ b/handlers/mesh.go
@@ -19,15 +19,25 @@ import (
 	"github.com/kiali/kiali/tracing"
 )
 
-func IstiodCanariesStatus(w http.ResponseWriter, r *http.Request) {
-	business, err := getBusiness(r)
-	if err != nil {
-		RespondWithError(w, http.StatusInternalServerError, err.Error())
-		return
-	}
+func ControlPlanes(cache cache.KialiCache, clientFactory kubernetes.ClientFactory, conf *config.Config, discovery istio.MeshDiscovery) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		userClients, err := getUserClients(r, clientFactory)
+		if err != nil {
+			RespondWithError(w, http.StatusInternalServerError, err.Error())
+			return
+		}
+		namespaceService := business.NewNamespaceService(userClients, clientFactory.GetSAClients(), cache, conf, discovery)
+
+		mesh, err := discovery.Mesh(r.Context())
+		if err != nil {
+			RespondWithError(w, http.StatusInternalServerError, err.Error())
+			return
+		}
+
+		filterAccessibleControlPlanes(r.Context(), namespaceService, mesh)
 
-	irt, _ := business.Mesh.CanaryUpgradeStatus()
-	RespondWithJSON(w, http.StatusOK, irt)
+		RespondWithJSON(w, http.StatusOK, mesh.ControlPlanes)
+	}
 }
 
 // MeshGraph is a REST http.HandlerFunc handling graph generation for the mesh
@@ -70,7 +80,6 @@ func MeshGraph(
 }
 
 func filterAccessibleControlPlanes(ctx context.Context, namespaceService business.NamespaceService, mesh *models.Mesh) {
-
 	authorizedControlPlanes := []models.ControlPlane{}
 	for _, cp := range mesh.ControlPlanes {
 		// Check if the user is able to access to the control plane
diff --git a/handlers/utils.go b/handlers/utils.go
index d550b6bea..444da302a 100644
--- a/handlers/utils.go
+++ b/handlers/utils.go
@@ -149,6 +149,15 @@ func createMetricsServiceForNamespaces(w http.ResponseWriter, r *http.Request, p
 	return metrics, nsInfos
 }
 
+func getUserClients(r *http.Request, cf kubernetes.ClientFactory) (map[string]kubernetes.ClientInterface, error) {
+	authInfos, err := getAuthInfo(r)
+	if err != nil {
+		return nil, err
+	}
+
+	return cf.GetClients(authInfos)
+}
+
 // getAuthInfo retrieves the token from the request's context
 func getAuthInfo(r *http.Request) (map[string]*api.AuthInfo, error) {
 	authInfoContext := authentication.GetAuthInfoContext(r.Context())
diff --git a/istio/discovery.go b/istio/discovery.go
index f62ad665c..7ec1130fc 100644
--- a/istio/discovery.go
+++ b/istio/discovery.go
@@ -126,6 +126,10 @@ func sidecarInjectorConfigMapName(revision string) string {
 	return revisionedConfigMapName(baseIstioSidecarInjectorConfigMapName, revision)
 }
 
+type MeshDiscovery interface {
+	Mesh(ctx context.Context) (*models.Mesh, error)
+}
+
 // Discovery detects istio infrastructure and configuration across clusters.
 type Discovery struct {
 	conf           *config.Config
@@ -314,14 +318,25 @@ func (in *Discovery) Mesh(ctx context.Context) (*models.Mesh, error) {
 				}
 			}
 
-			// If the cluster id set on the controlplane matches the cluster's id then it manages the cluster it is deployed on.
+			// If the cluster id that is set on the controlplane matches this cluster's id then it manages the cluster it is deployed on.
+			// This is for single cluster deployments and primary-remote where the cluster here is the primary cluster.
 			if controlPlane.ID == cluster.Name {
 				controlPlane.ManagedClusters = append(controlPlane.ManagedClusters, &cluster)
 			} else {
-				// It's an "external controlplane".
+				// It's an "external controlplane" don't add this cluster as a "managed cluster".
 				controlPlane.ExternalControlPlane = true
 			}
 
+			// If the controlplane doesn't manage an external cluster and the cluster id doesn't
+			// match this cluster's name then it's probably a misconfiguration. For primary-remote
+			// where the primary could be misconfigured, it's unclear how to detect this.
+			if !controlPlane.ManagesExternal && controlPlane.ID != cluster.Name {
+				log.Warningf("The controlplane [%s/%s] cluster name ['%s'] does not match the cluster ['%s'] where it is deployed. "+
+					"This is likely a misconfiguration. Check your 'values.global.multiCluster.clusterName' setting on your controlplane "+
+					"or check your Kiali configuration setting 'kubernetes_config.cluster_name'.",
+					controlPlane.IstiodNamespace, controlPlane.IstiodName, controlPlane.ID, cluster.Name)
+			}
+
 			// Even if we fail to get the version we should still return the controlplane object.
 			func() {
 				saClient := in.kialiSAClients[cluster.Name]
@@ -432,13 +447,10 @@ func (in *Discovery) Mesh(ctx context.Context) (*models.Mesh, error) {
 	}
 
 	// Get the tags for the mesh.
-	tags, err := in.getTags(ctx, mesh.ControlPlanes)
-	if err != nil {
+	if err := in.setTags(ctx, mesh.ControlPlanes); err != nil {
 		return nil, err
 	}
 
-	mesh.Tags = tags
-
 	namespacesByClusterAndRev := map[clusterRevisionKey][]models.Namespace{}
 	// Multi-cluster is not supported without cluster wide access.
 	if !in.conf.AllNamespacesAccessible() {
@@ -507,13 +519,9 @@ func (in *Discovery) Mesh(ctx context.Context) (*models.Mesh, error) {
 		for _, cluster := range cp.ManagedClusters {
 			// Default to controlplane revision but if there's a tag then overwrite with that.
 			rev := cp.Revision
-			if cp.Tags != nil {
-				// Find the tag for that cluster.
-				for _, tag := range cp.Tags {
-					if tag.Cluster == cluster.Name && tag.Revision == cp.Revision {
-						rev = tag.Name
-						break
-					}
+			if cp.Tag != nil {
+				if cp.Tag.Cluster == cluster.Name && cp.Tag.Revision == cp.Revision {
+					rev = cp.Tag.Name
 				}
 			}
 			key := clusterRevisionKey{Cluster: cluster.Name, Revision: rev}
@@ -535,8 +543,8 @@ func (in *Discovery) Mesh(ctx context.Context) (*models.Mesh, error) {
 	return mesh, nil
 }
 
-func (in *Discovery) getTags(ctx context.Context, controlPlanes []models.ControlPlane) ([]models.Tag, error) {
-	var tags []models.Tag
+func (in *Discovery) setTags(ctx context.Context, controlPlanes []models.ControlPlane) error {
+	tagsByClusterRev := map[string]*models.Tag{}
 	for cluster, client := range in.kialiSAClients {
 		if !in.kialiCache.CanListWebhooks(cluster) {
 			log.Debugf("Unable to list webhooks for cluster [%s]. Give Kiali permission to read 'mutatingwebhookconfigurations'. Skipping getting tags.", cluster)
@@ -547,7 +555,7 @@ func (in *Discovery) getTags(ctx context.Context, controlPlanes []models.Control
 			ctx, metav1.ListOptions{LabelSelector: models.IstioTagLabel},
 		)
 		if err != nil {
-			return nil, err
+			return err
 		}
 
 		for _, webhook := range webhooks.Items {
@@ -557,30 +565,26 @@ func (in *Discovery) getTags(ctx context.Context, controlPlanes []models.Control
 				Name:     webhook.Labels[models.IstioTagLabel],
 				Revision: webhook.Labels[models.IstioRevisionLabel],
 			}
-			tags = append(tags, tag)
+			key := tag.Cluster + tag.Revision
+			if _, found := tagsByClusterRev[key]; found {
+				log.Debugf("Found more than one webhook for tag [%s] pointing to revision: [%s] on cluster: [%s]. This is likely a misconfiguration.", tag.Name, tag.Revision, tag.Cluster)
+				continue
+			}
+			tagsByClusterRev[key] = &tag
 		}
 	}
 
-	tagsByClusterRev := map[string][]*models.Tag{}
-	for i := range tags {
-		tagsByClusterRev[tags[i].Cluster+tags[i].Revision] = append(tagsByClusterRev[tags[i].Cluster+tags[i].Revision], &tags[i])
-	}
-
 	for i := range controlPlanes {
 		controlPlane := &controlPlanes[i]
 		for _, cluster := range controlPlane.ManagedClusters {
 			key := cluster.Name + controlPlane.Revision
-			if cpTags, ok := tagsByClusterRev[key]; ok {
-				for _, tag := range cpTags {
-					tag := tag
-					tag.ControlPlane = controlPlane
-					controlPlane.Tags = append(controlPlane.Tags, *tag)
-				}
+			if tag, ok := tagsByClusterRev[key]; ok {
+				controlPlane.Tag = tag
 			}
 		}
 	}
 
-	return tags, nil
+	return nil
 }
 
 func (in *Discovery) getKialiInstances(cluster models.KubeCluster) ([]models.KialiInstance, error) {
diff --git a/mesh/generator/generator.go b/mesh/generator/generator.go
index 5b7cf72b2..d67dd6e83 100644
--- a/mesh/generator/generator.go
+++ b/mesh/generator/generator.go
@@ -93,13 +93,8 @@ func BuildMeshMap(ctx context.Context, o mesh.Options, gi *mesh.GlobalInfo) (mes
 			version = cp.Version.Version
 		}
 
-		infraData := map[string]any{
-			"config":     cp.Config,
-			"revision":   cp.Revision,
-			"thresholds": cp.Thresholds,
-		}
 		healthDataKey := componentHealthKey{Name: cp.IstiodName, Namespace: cp.IstiodNamespace, Cluster: cp.Cluster.Name}.String()
-		istiod, _, err := addInfra(meshMap, mesh.InfraTypeIstiod, cp.Cluster.Name, cp.IstiodNamespace, name, infraData, version, false, healthData[healthDataKey])
+		istiod, _, err := addInfra(meshMap, mesh.InfraTypeIstiod, cp.Cluster.Name, cp.IstiodNamespace, name, cp, version, false, healthData[healthDataKey])
 		mesh.CheckError(err)
 
 		for _, mc := range cp.ManagedClusters {
@@ -126,14 +121,9 @@ func BuildMeshMap(ctx context.Context, o mesh.Options, gi *mesh.GlobalInfo) (mes
 				})
 				// Show the tag instead of the revision name since that is what is actually set on the namespaces.
 				rev := cp.Revision
-				if cp.Tags != nil {
-					for _, tag := range cp.Tags {
-						if tag.Cluster == cp.Cluster.Name {
-							// There should just be one tag pointing to this controlplane revision per cluster.
-							rev = tag.Name
-							break
-						}
-					}
+				if cp.Tag != nil {
+					// There should just be one tag pointing to this controlplane revision per cluster.
+					rev = cp.Tag.Name
 				}
 				dp, _, err := addInfra(meshMap, mesh.InfraTypeDataPlane, mc.Name, "", "Data Plane", namespaces, rev, false, "")
 				graph.CheckError(err)
diff --git a/models/mesh.go b/models/mesh.go
index ed82540a8..dd8d1d1f7 100644
--- a/models/mesh.go
+++ b/models/mesh.go
@@ -19,6 +19,8 @@ const (
 	DefaultRevisionLabel = "default"
 	// IstioInjectionLabel is the key for the istio injection label on a namespace.
 	IstioInjectionLabel = "istio-injection"
+	// IstioInjectionDisabledLabelValue is the value for the istio injection label when it is disabled.
+	IstioInjectionDisabledLabelValue = "disabled"
 	// IstioInjectionEnabledLabelValue is the value for the istio injection label when it is enabled.
 	IstioInjectionEnabledLabelValue = "enabled"
 )
@@ -29,8 +31,6 @@ const (
 type Mesh struct {
 	// ControlPlanes that share the same mesh ID.
 	ControlPlanes []ControlPlane
-	// Tags are the tags associated with the mesh.
-	Tags []Tag
 }
 
 // Tag maps a controlplane revision to a namespace label.
@@ -40,13 +40,11 @@ type Mesh struct {
 // your controlplane.
 type Tag struct {
 	// Cluster is the cluster that the tag is associated with.
-	Cluster string
-	// ControlPlane associated with the tag.
-	ControlPlane *ControlPlane
+	Cluster string `json:"cluster"`
 	// Name is the name of the tag.
-	Name string
+	Name string `json:"name"`
 	// Revision is the revision of the controlplane associated with this tag.
-	Revision string
+	Revision string `json:"revision"`
 }
 
 // ControlPlane manages the dataPlane for one or more kube clusters.
@@ -54,70 +52,70 @@ type Tag struct {
 // It has configuration for all the clusters/namespaces associated with it.
 type ControlPlane struct {
 	// Cluster the kube cluster that the controlplane is running on.
-	Cluster *KubeCluster
+	Cluster *KubeCluster `json:"cluster"`
 
 	// Config
-	Config ControlPlaneConfiguration
+	Config ControlPlaneConfiguration `json:"config"`
 
 	// ExternalControlPlane indicates if the controlplane is managing an external cluster.
-	ExternalControlPlane bool
+	ExternalControlPlane bool `json:"externalControlPlane"`
 
 	// ID is the control plane ID as known by istiod.
-	ID string
+	ID string `json:"id"`
 
 	// IstiodName is the control plane name
-	IstiodName string
+	IstiodName string `json:"istiodName"`
 
 	// IstiodNamespace is the namespace name of the deployed control plane
-	IstiodNamespace string
+	IstiodNamespace string `json:"istiodNamespace"`
 
 	// ManagedClusters are the clusters that this controlplane manages.
 	// This could include the cluster that the controlplane is running on.
-	ManagedClusters []*KubeCluster
+	ManagedClusters []*KubeCluster `json:"managedClusters"`
 
 	// ManagesExternal indicates if the controlplane manages an external cluster.
 	// It could also manage the cluster that it is running on.
-	ManagesExternal bool
+	ManagesExternal bool `json:"managesExternal"`
 
 	// ManagedNamespaces are the namespaces that the controlplane is managing.
 	// More specifically, it is a namespace with either injection enabled
 	// or ambient enabled and it matches this controlplane's revision either
 	// directly or through a tag.
-	ManagedNamespaces []Namespace
+	ManagedNamespaces []Namespace `json:"managedNamespaces"`
 
 	// Resources are the resources that the controlplane is using.
-	Resources corev1.ResourceRequirements
+	Resources corev1.ResourceRequirements `json:"resources"`
 
 	// Revision is the revision of the controlplane.
 	// Can be empty when it's the default revision.
-	Revision string
+	Revision string `json:"revision"`
 
 	// Status is the status of the controlplane as reported by kiali.
 	// It includes the deployment status and whether kiali can connect
 	// to the controlplane or not.
-	Status string
+	Status string `json:"status"`
 
 	// Tags are the tags associated with the controlplane.
-	Tags []Tag
+	Tag *Tag `json:"tag,omitempty"`
 
 	// Thresholds is the thresholds for the controlplane.
-	Thresholds *IstiodThresholds
+	Thresholds *IstiodThresholds `json:"thresholds,omitempty"`
 
 	// Version is the version of the controlplane.
-	Version *ExternalServiceInfo
+	Version *ExternalServiceInfo `json:"version,omitempty"`
 }
 
 // ControlPlaneConfiguration is the configuration for the controlPlane and any associated dataPlane.
 type ControlPlaneConfiguration struct {
 	// Config Map
-	ConfigMap map[string]string `yaml:"configMap,omitempty"`
+	ConfigMap map[string]string `json:"configMap,omitempty" yaml:"configMap,omitempty"`
 
 	// IsGatewayToNamespace specifies the PILOT_SCOPE_GATEWAY_TO_NAMESPACE environment variable in Control Plane
 	// This is not currently used by the frontend so excluding it from the API response.
 	IsGatewayToNamespace bool `json:"-"`
 
 	// Network is the name of the network that the controlplane is using.
-	Network string
+	Network string `json:"network,omitempty"`
 
 	// IstioMeshConfig comes from the istio configmap.
 	IstioMeshConfig
@@ -154,6 +152,9 @@ func (ci *Certificate) Parse(certificate []byte) {
 	ci.Accessible = true
 }
 
+// TODO: Lowercase these as they are used on the frontend.
+// Better yet, change YAML parsing to first convert the
+// YAML to JSON so that we don't need to use yaml tags at all.
 type IstioMeshConfig struct {
 	Certificates  []Certificate `yaml:"certificates,omitempty" json:"certificates,omitempty"`
 	DefaultConfig struct {
@@ -169,7 +170,7 @@ type IstioMeshConfig struct {
 	MeshMTLS                       struct {
 		MinProtocolVersion string `yaml:"minProtocolVersion"`
 	} `yaml:"meshMtls"`
-	OutboundTrafficPolicy OutboundPolicy `yaml:"outboundTrafficPolicy,omitempty"`
+	OutboundTrafficPolicy OutboundPolicy `yaml:"outboundTrafficPolicy,omitempty" json:"outboundTrafficPolicy,omitempty"`
 	TrustDomain           string         `yaml:"trustDomain,omitempty"`
 }
 
diff --git a/models/namespace.go b/models/namespace.go
index 760714a30..f6fdaf721 100644
--- a/models/namespace.go
+++ b/models/namespace.go
@@ -43,6 +43,13 @@ type Namespace struct {
 
 	// Specific annotations used in Kiali
 	Annotations map[string]string `json:"annotations"`
+
+	// Revision managing this namespace.
+	// If the namespace has 'istio-injection: enabled' label,
+	// it will be set to the 'default' revision. Otherwise
+	// it matches on istio.io/rev. Note that his can also
+	// be a Tag and not the actual revision.
+	Revision string `json:"revision,omitempty"`
 }
 
 type (
@@ -69,6 +76,18 @@ func CastNamespace(ns core_v1.Namespace, cluster string) Namespace {
 	namespace.Labels = ns.Labels
 	namespace.Annotations = ns.Annotations
 
+	if ns.Labels != nil {
+		// Injection label takes precedence.
+		// See rules at: https://istio.io/latest/docs/setup/additional-setup/sidecar-injection/#controlling-the-injection-policy
+		if injectionLabel := ns.Labels[IstioInjectionLabel]; injectionLabel == IstioInjectionDisabledLabelValue {
+			namespace.Revision = ""
+		} else if injectionLabel == IstioInjectionEnabledLabelValue {
+			namespace.Revision = DefaultRevisionLabel
+		} else if label, hasLabel := ns.Labels[IstioRevisionLabel]; hasLabel {
+			namespace.Revision = label
+		}
+	}
+
 	if label, hasLabel := ns.Labels[istioLabels.AmbientNamespaceLabel]; hasLabel && label == istioLabels.AmbientNamespaceLabelValue {
 		namespace.IsAmbient = true
 	}
diff --git a/routing/routes.go b/routing/routes.go
index d9cb7cb96..4d7f5b573 100644
--- a/routing/routes.go
+++ b/routing/routes.go
@@ -1294,6 +1294,27 @@ func NewRoutes(
 			handlers.MeshGraph(conf, clientFactory, kialiCache, grafana, prom, traceClientLoader, discovery, cpm),
 			true,
 		},
+		// swagger:route GET /mesh/controlplanes controlplanes
+		// ---
+		// The backing JSON for mesh controlplanes
+		//
+		//     Produces:
+		//     - application/json
+		//
+		//     Schemes: http, https
+		//
+		// responses:
+		//      400: badRequestError
+		//      500: internalError
+		//      200: graphResponse
+		//
+		{
+			"MeshControlPlanes",
+			"GET",
+			"/api/mesh/controlplanes",
+			handlers.ControlPlanes(kialiCache, clientFactory, conf, discovery),
+			true,
+		},
 		// swagger:route GET /grafana integrations grafanaInfo
 		// ---
 		// Get the grafana URL and other descriptors
@@ -1485,25 +1506,6 @@ func NewRoutes(
 			HandlerFunc:   handlers.MetricsStats,
 			Authenticated: true,
 		},
-		// swagger:route GET /api/mesh/canaries/status
-		// ---
-		// Endpoint to get the IstiodCanariesStatus.
-		//              Produces:
-		//              - application/json
-		//
-		//              Schemes: http, https
-		//
-		// responses:
-		//              500: internalError
-		//              200: istiodCanariesStatus
-		{
-			"IstiodCanariesStatus",
-			"GET",
-			"/api/mesh/canaries/status",
-			handlers.IstiodCanariesStatus,
-			true,
-		},
 	}
-
 	return
 }


Test patch:
diff --git a/business/istio_status_test.go b/business/istio_status_test.go
index c0b5229c0..1b5d069ee 100644
--- a/business/istio_status_test.go
+++ b/business/istio_status_test.go
@@ -17,6 +17,7 @@ import (
 	"k8s.io/apimachinery/pkg/runtime"
 
 	"github.com/kiali/kiali/config"
+	"github.com/kiali/kiali/istio/istiotest"
 	"github.com/kiali/kiali/kubernetes"
 	"github.com/kiali/kiali/kubernetes/kubetest"
 	"github.com/kiali/kiali/log"
@@ -25,14 +26,6 @@ import (
 	"github.com/kiali/kiali/tracing/tracingtest"
 )
 
-type fakeMeshDiscovery struct {
-	mesh models.Mesh
-}
-
-func (fmd *fakeMeshDiscovery) Mesh(ctx context.Context) (*models.Mesh, error) {
-	return &fmd.mesh, nil
-}
-
 type addOnsSetup struct {
 	Url        string
 	StatusCode int
@@ -445,8 +438,8 @@ func TestDefaults(t *testing.T) {
 	conf := config.NewConfig()
 	// Set global cache var
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{Cluster: &models.KubeCluster{Name: conf.KubernetesConfig.ClusterName}, IstiodName: "istiod", Status: kubernetes.ComponentHealthy}},
 		},
 	}
@@ -504,8 +497,8 @@ func TestNonDefaults(t *testing.T) {
 
 	// Set global cache var
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{Cluster: &models.KubeCluster{Name: conf.KubernetesConfig.ClusterName}, IstiodName: "istiod", Status: kubernetes.ComponentHealthy}},
 		},
 	}
@@ -557,8 +550,8 @@ func TestIstiodNotReady(t *testing.T) {
 
 	// Set global cache var
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{Cluster: &models.KubeCluster{Name: conf.KubernetesConfig.ClusterName}, IstiodName: "istiod", Status: kubernetes.ComponentNotReady}},
 		},
 	}
@@ -616,8 +609,8 @@ func TestCustomizedAppLabel(t *testing.T) {
 
 	// Set global cache var
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{Cluster: &models.KubeCluster{Name: conf.KubernetesConfig.ClusterName}, IstiodName: "istiod", Status: kubernetes.ComponentHealthy}},
 		},
 	}
@@ -671,8 +664,8 @@ func TestDaemonSetComponentHealthy(t *testing.T) {
 
 	// Set global cache var
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{Cluster: &models.KubeCluster{Name: conf.KubernetesConfig.ClusterName}, IstiodName: "istiod", Status: kubernetes.ComponentHealthy}},
 		},
 	}
@@ -722,8 +715,8 @@ func TestDaemonSetComponentUnhealthy(t *testing.T) {
 
 	// Set global cache var
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{Cluster: &models.KubeCluster{Name: conf.KubernetesConfig.ClusterName}, IstiodName: "istiod", Status: kubernetes.ComponentHealthy}},
 		},
 	}
diff --git a/business/mesh_test.go b/business/mesh_test.go
index 6bae1730a..bc59ed01d 100644
--- a/business/mesh_test.go
+++ b/business/mesh_test.go
@@ -1,140 +1,18 @@
 package business_test
 
 import (
-	"context"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
-	core_v1 "k8s.io/api/core/v1"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
 	"github.com/kiali/kiali/business"
 	"github.com/kiali/kiali/config"
-	"github.com/kiali/kiali/istio"
+	"github.com/kiali/kiali/istio/istiotest"
 	"github.com/kiali/kiali/kubernetes"
-	"github.com/kiali/kiali/kubernetes/cache"
 	"github.com/kiali/kiali/kubernetes/kubetest"
 	"github.com/kiali/kiali/models"
 )
 
-type fakeMeshDiscovery struct {
-	mesh models.Mesh
-}
-
-func (fmd *fakeMeshDiscovery) Mesh(ctx context.Context) (*models.Mesh, error) {
-	return &fmd.mesh, nil
-}
-
-// TestCanaryUpgradeNotConfigured verifies that when there is no canary upgrade configured, both the migrated and pending namespace lists are empty
-func TestCanaryUpgradeNotConfigured(t *testing.T) {
-	check := assert.New(t)
-
-	conf := config.NewConfig()
-	conf.KubernetesConfig.ClusterName = "Kubernetes"
-	kubernetes.SetConfig(t, *conf)
-	config.Set(conf)
-
-	migratedNamespace := *kubetest.FakeNamespaceWithLabels("travel-agency", map[string]string{"istio.io/rev": "canary"})
-	pendingNamespace := *kubetest.FakeNamespaceWithLabels("travel-portal", map[string]string{"istio-injection": "enabled"})
-
-	// Create a MeshService and invoke IsMeshConfigured
-	k8sclients := map[string]kubernetes.ClientInterface{
-		conf.KubernetesConfig.ClusterName: kubetest.NewFakeK8sClient(
-			&core_v1.Namespace{ObjectMeta: v1.ObjectMeta{Name: conf.IstioNamespace}},
-			runningIstiodPod(),
-			&migratedNamespace,
-			&pendingNamespace,
-		),
-	}
-
-	cf := kubetest.NewFakeClientFactory(conf, k8sclients)
-	cache := cache.NewTestingCacheWithFactory(t, cf, *conf)
-	discovery := istio.NewDiscovery(k8sclients, cache, conf)
-	nsService := business.NewNamespaceService(k8sclients, k8sclients, cache, conf, discovery)
-	business.SetupBusinessLayer(t, k8sclients[conf.KubernetesConfig.ClusterName], *conf)
-	business.WithKialiCache(cache)
-	business.WithDiscovery(discovery)
-	meshSvc := business.NewMeshService(k8sclients, cache, nsService, conf, discovery)
-
-	canaryUpgradeStatus, err := meshSvc.CanaryUpgradeStatus()
-
-	check.Nil(err, "IstiodCanariesStatus failed: %s", err)
-	check.NotNil(canaryUpgradeStatus)
-	check.Equal(0, len(canaryUpgradeStatus.NamespacesPerRevision))
-}
-
-// TestCanaryUpgradeConfigured verifies that when there is a canary upgrade in place, the migrated and pending namespaces should have namespaces
-func TestCanaryUpgradeConfigured(t *testing.T) {
-	check := assert.New(t)
-
-	conf := config.NewConfig()
-	conf.KubernetesConfig.ClusterName = "Kubernetes"
-	kubernetes.SetConfig(t, *conf)
-	config.Set(conf)
-
-	migratedNamespace := *kubetest.FakeNamespaceWithLabels("travel-agency", map[string]string{"istio.io/rev": "canary"})
-	pendingNamespace := *kubetest.FakeNamespaceWithLabels("travel-portal", map[string]string{"istio-injection": "enabled"})
-
-	k8sclients := map[string]kubernetes.ClientInterface{
-		conf.KubernetesConfig.ClusterName: kubetest.NewFakeK8sClient(
-			kubetest.FakeNamespace(conf.IstioNamespace),
-			runningIstiodPod(),
-			runningIstiodCanaryPod(),
-			&migratedNamespace,
-			&pendingNamespace,
-		),
-	}
-
-	cf := kubetest.NewFakeClientFactory(conf, k8sclients)
-	cache := cache.NewTestingCacheWithFactory(t, cf, *conf)
-	discovery := istio.NewDiscovery(k8sclients, cache, conf)
-	nsService := business.NewNamespaceService(k8sclients, k8sclients, cache, conf, discovery)
-	business.SetupBusinessLayer(t, k8sclients[conf.KubernetesConfig.ClusterName], *conf)
-	business.WithKialiCache(cache)
-	business.WithDiscovery(discovery)
-	meshSvc := business.NewMeshService(k8sclients, cache, nsService, conf, discovery)
-
-	canaryUpgradeStatus, err := meshSvc.CanaryUpgradeStatus()
-
-	check.Nil(err, "IstiodCanariesStatus failed: %s", err)
-	check.Contains(canaryUpgradeStatus.NamespacesPerRevision["canary"], "travel-agency")
-	check.Equal(1, len(canaryUpgradeStatus.NamespacesPerRevision["canary"]))
-	check.Contains(canaryUpgradeStatus.NamespacesPerRevision["default"], "travel-portal")
-	check.Equal(1, len(canaryUpgradeStatus.NamespacesPerRevision["default"]))
-}
-
-func runningIstiodPod() *core_v1.Pod {
-	return &core_v1.Pod{
-		ObjectMeta: v1.ObjectMeta{
-			Name:      "istiod-123",
-			Namespace: "istio-system",
-			Labels: map[string]string{
-				"app":          "istiod",
-				"istio.io/rev": "default",
-			},
-		},
-		Status: core_v1.PodStatus{
-			Phase: core_v1.PodRunning,
-		},
-	}
-}
-
-func runningIstiodCanaryPod() *core_v1.Pod {
-	return &core_v1.Pod{
-		ObjectMeta: v1.ObjectMeta{
-			Name:      "istiod-456",
-			Namespace: "istio-system",
-			Labels: map[string]string{
-				"app":          "istiod",
-				"istio.io/rev": "canary",
-			},
-		},
-		Status: core_v1.PodStatus{
-			Phase: core_v1.PodRunning,
-		},
-	}
-}
-
 func TestGetMeshConfig(t *testing.T) {
 	check := assert.New(t)
 
@@ -148,8 +26,8 @@ func TestGetMeshConfig(t *testing.T) {
 	// Create a MeshService and invoke IsMeshConfigured
 	k8sclients := make(map[string]kubernetes.ClientInterface)
 	k8sclients[conf.KubernetesConfig.ClusterName] = k8s
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{
 				Cluster: &models.KubeCluster{Name: conf.KubernetesConfig.ClusterName, IsKialiHome: true},
 				Config: models.ControlPlaneConfiguration{
diff --git a/business/testing.go b/business/testing.go
index 5329b8cbb..57c766a21 100644
--- a/business/testing.go
+++ b/business/testing.go
@@ -18,7 +18,7 @@ import (
 
 // SetWithBackends allows for specifying the ClientFactory and Prometheus clients to be used.
 // Mock friendly. Used only with tests.
-func setWithBackends(cf kubernetes.ClientFactory, prom prometheus.ClientInterface, cache cache.KialiCache, cpm ControlPlaneMonitor, d meshDiscovery) {
+func setWithBackends(cf kubernetes.ClientFactory, prom prometheus.ClientInterface, cache cache.KialiCache, cpm ControlPlaneMonitor, d istio.MeshDiscovery) {
 	clientFactory = cf
 	discovery = d
 	kialiCache = cache
@@ -67,6 +67,6 @@ func WithControlPlaneMonitor(cpm ControlPlaneMonitor) {
 }
 
 // WithDiscovery is a testing func that lets you replace the global discovery var.
-func WithDiscovery(disc meshDiscovery) {
+func WithDiscovery(disc istio.MeshDiscovery) {
 	discovery = disc
 }
diff --git a/business/tls_perf_test.go b/business/tls_perf_test.go
index f0dbc8b69..a72405558 100644
--- a/business/tls_perf_test.go
+++ b/business/tls_perf_test.go
@@ -14,6 +14,7 @@ import (
 	"k8s.io/apimachinery/pkg/runtime"
 
 	"github.com/kiali/kiali/config"
+	"github.com/kiali/kiali/istio/istiotest"
 	"github.com/kiali/kiali/kubernetes"
 	"github.com/kiali/kiali/kubernetes/kubetest"
 	"github.com/kiali/kiali/models"
@@ -82,8 +83,8 @@ func testPerfScenario(exStatus string, namespaces []core_v1.Namespace, drs []*ne
 
 	k8s := kubetest.NewFakeK8sClient(objs...)
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{
 				IstiodNamespace: conf.IstioNamespace,
 				Revision:        "default",
diff --git a/business/tls_test.go b/business/tls_test.go
index 900567b0a..a1f3b7ce5 100644
--- a/business/tls_test.go
+++ b/business/tls_test.go
@@ -13,6 +13,7 @@ import (
 	"k8s.io/apimachinery/pkg/runtime"
 
 	"github.com/kiali/kiali/config"
+	"github.com/kiali/kiali/istio/istiotest"
 	"github.com/kiali/kiali/kubernetes"
 	"github.com/kiali/kiali/kubernetes/kubetest"
 	"github.com/kiali/kiali/models"
@@ -45,8 +46,8 @@ func TestMeshStatusEnabled(t *testing.T) {
 
 	k8s := kubetest.NewFakeK8sClient(objs...)
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{
 				IstiodNamespace: conf.IstioNamespace,
 				Revision:        "default",
@@ -91,8 +92,8 @@ func TestMeshStatusEnabledAutoMtls(t *testing.T) {
 
 	k8s := kubetest.NewFakeK8sClient(objs...)
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{
 				IstiodNamespace: conf.IstioNamespace,
 				Revision:        "default",
@@ -140,8 +141,8 @@ func TestMeshStatusPartiallyEnabled(t *testing.T) {
 
 	k8s := kubetest.NewFakeK8sClient(objs...)
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{
 				IstiodNamespace: conf.IstioNamespace,
 				Revision:        "default",
@@ -185,8 +186,8 @@ func TestMeshStatusNotEnabled(t *testing.T) {
 
 	k8s := kubetest.NewFakeK8sClient(objs...)
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{
 				IstiodNamespace: conf.IstioNamespace,
 				Revision:        "default",
@@ -233,8 +234,8 @@ func TestMeshStatusDisabled(t *testing.T) {
 
 	k8s := kubetest.NewFakeK8sClient(objs...)
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{
 				IstiodNamespace: conf.IstioNamespace,
 				Revision:        "default",
@@ -268,8 +269,8 @@ func TestMeshStatusNotEnabledAutoMtls(t *testing.T) {
 	ns := kubetest.FakeNamespaceWithLabels("test", injectionEnabledLabel)
 	k8s := kubetest.NewFakeK8sClient(ns)
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{
 				IstiodNamespace: conf.IstioNamespace,
 				Revision:        "default",
@@ -412,8 +413,8 @@ func TestNamespaceHasDestinationRuleEnabledDifferentNs(t *testing.T) {
 	conf.Deployment.ClusterWideAccess = true
 	kubernetes.SetConfig(t, *conf)
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{
 				IstiodNamespace: conf.IstioNamespace,
 				Revision:        "default",
@@ -463,8 +464,8 @@ func testNamespaceScenario(exStatus string, drs []*networking_v1.DestinationRule
 	k8sclients := make(map[string]kubernetes.ClientInterface)
 	k8sclients[conf.KubernetesConfig.ClusterName] = k8s
 	SetupBusinessLayer(t, k8s, *conf)
-	discovery := &fakeMeshDiscovery{
-		mesh: models.Mesh{
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: models.Mesh{
 			ControlPlanes: []models.ControlPlane{{
 				IstiodNamespace: conf.IstioNamespace,
 				Revision:        "default",
diff --git a/frontend/src/reducers/__tests__/NamespacesReducer.test.ts b/frontend/src/reducers/__tests__/NamespacesReducer.test.ts
index 3092c0870..474daafbb 100644
--- a/frontend/src/reducers/__tests__/NamespacesReducer.test.ts
+++ b/frontend/src/reducers/__tests__/NamespacesReducer.test.ts
@@ -1,6 +1,7 @@
 import { NamespaceStateReducer } from '../NamespaceState';
 import { GlobalActions } from '../../actions/GlobalActions';
 import { NamespaceActions } from '../../actions/NamespaceAction';
+import { NamespaceState } from 'store/Store';
 
 describe('Namespaces reducer', () => {
   it('should return the initial state', () => {
@@ -11,7 +12,7 @@ describe('Namespaces reducer', () => {
       lastUpdated: undefined,
       filter: '',
       namespacesPerCluster: new Map<string, string[]>()
-    });
+    } as NamespaceState);
   });
 
   it('should handle ACTIVE_NAMESPACES', () => {
@@ -152,7 +153,7 @@ describe('Namespaces reducer', () => {
       [{ name: 'a' }, { name: 'b' }, { name: 'c' }],
       currentDate
     );
-    const expectedState = {
+    const expectedState: NamespaceState = {
       activeNamespaces: [],
       filter: '',
       isFetching: false,
diff --git a/handlers/mesh_test.go b/handlers/mesh_test.go
index 72f963ffc..6d4647682 100644
--- a/handlers/mesh_test.go
+++ b/handlers/mesh_test.go
@@ -1,6 +1,7 @@
 package handlers_test
 
 import (
+	"encoding/json"
 	"io"
 	"net/http"
 	"net/http/httptest"
@@ -10,7 +11,6 @@ import (
 	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-
 	"k8s.io/client-go/tools/clientcmd/api"
 
 	"github.com/kiali/kiali/business"
@@ -18,9 +18,11 @@ import (
 	"github.com/kiali/kiali/grafana"
 	"github.com/kiali/kiali/handlers"
 	"github.com/kiali/kiali/istio"
+	"github.com/kiali/kiali/istio/istiotest"
 	"github.com/kiali/kiali/kubernetes"
 	"github.com/kiali/kiali/kubernetes/cache"
 	"github.com/kiali/kiali/kubernetes/kubetest"
+	"github.com/kiali/kiali/models"
 	"github.com/kiali/kiali/prometheus"
 	"github.com/kiali/kiali/prometheus/prometheustest"
 	"github.com/kiali/kiali/tracing"
@@ -131,3 +133,69 @@ func TestGetMeshGraph(t *testing.T) {
 	require.NoError(err)
 	checkStatus(t, http.StatusOK, resp)
 }
+
+func TestControlPlanes(t *testing.T) {
+	require := require.New(t)
+
+	conf := config.NewConfig()
+	clients := map[string]kubernetes.ClientInterface{conf.KubernetesConfig.ClusterName: kubetest.NewFakeK8sClient()}
+	cf := kubetest.NewFakeClientFactory(conf, clients)
+	cache := cache.NewTestingCacheWithFactory(t, cf, *conf)
+	mesh := models.Mesh{
+		ControlPlanes: []models.ControlPlane{
+			{
+				Cluster:         &models.KubeCluster{Name: "east"},
+				ManagedClusters: []*models.KubeCluster{{Name: "east"}},
+			},
+		},
+	}
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: mesh,
+	}
+
+	authInfo := map[string]*api.AuthInfo{conf.KubernetesConfig.ClusterName: {Token: "test"}}
+	handler := handlers.WithAuthInfo(authInfo, handlers.ControlPlanes(cache, cf, conf, discovery))
+	r := httptest.NewRequest("GET", "/api/mesh/controlplanes", nil)
+	w := httptest.NewRecorder()
+
+	handler(w, r)
+
+	resp := w.Result()
+	body, err := io.ReadAll(resp.Body)
+	require.NoError(err)
+	require.Equal(200, resp.StatusCode)
+
+	var controlPlaneResponse []models.ControlPlane
+	require.NoError(json.Unmarshal(body, &controlPlaneResponse))
+
+	require.Len(controlPlaneResponse, 1)
+}
+
+func TestControlPlanesUnauthorized(t *testing.T) {
+	require := require.New(t)
+
+	conf := config.NewConfig()
+	clients := map[string]kubernetes.ClientInterface{conf.KubernetesConfig.ClusterName: kubetest.NewFakeK8sClient()}
+	cf := kubetest.NewFakeClientFactory(conf, clients)
+	cache := cache.NewTestingCacheWithFactory(t, cf, *conf)
+	mesh := models.Mesh{
+		ControlPlanes: []models.ControlPlane{
+			{
+				Cluster:         &models.KubeCluster{Name: "east"},
+				ManagedClusters: []*models.KubeCluster{{Name: "east"}},
+			},
+		},
+	}
+	discovery := &istiotest.FakeDiscovery{
+		MeshReturn: mesh,
+	}
+
+	handler := handlers.ControlPlanes(cache, cf, conf, discovery)
+	r := httptest.NewRequest("GET", "/api/mesh/controlplanes", nil)
+	w := httptest.NewRecorder()
+
+	handler(w, r)
+
+	resp := w.Result()
+	require.Equal(500, resp.StatusCode)
+}
diff --git a/istio/discovery_test.go b/istio/discovery_test.go
index 0deb51b65..72a20a478 100644
--- a/istio/discovery_test.go
+++ b/istio/discovery_test.go
@@ -1853,14 +1853,31 @@ func TestDiscoverWithTags(t *testing.T) {
 						},
 					},
 				}
+				// Add in another tag to add noise and ensure the proper tag gets picked up.
+				tagDev := &admissionregistrationv1.MutatingWebhookConfiguration{
+					ObjectMeta: v1.ObjectMeta{
+						Name: "istio-revision-tag-dev",
+						Labels: map[string]string{
+							models.IstioRevisionLabel: "1.22.0",
+							models.IstioTagLabel:      "dev",
+						},
+					},
+				}
+				istiod_1_22 := fakeIstiodDeployment(conf.KubernetesConfig.ClusterName, false)
+				istiod_1_22.Name = "istiod-1-22-0"
+				istiod_1_22.Labels[models.IstioRevisionLabel] = "1.22.0"
 				istiod_1_23 := fakeIstiodDeployment(conf.KubernetesConfig.ClusterName, false)
+				istiod_1_23.Name = "istiod-1-23-0"
 				istiod_1_23.Labels[models.IstioRevisionLabel] = "1.23.0"
 				return map[string][]runtime.Object{
 					conf.KubernetesConfig.ClusterName: {
 						defaultWebhook,
 						&core_v1.Namespace{ObjectMeta: v1.ObjectMeta{Name: "bookinfo", Labels: map[string]string{models.IstioRevisionLabel: "prod"}}},
 						tagProd,
+						tagDev,
 						istiod_1_23,
+						istiod_1_22,
+						fakeIstioConfigMap("1.22.0"),
 						&core_v1.Namespace{ObjectMeta: v1.ObjectMeta{Name: "istio-system"}},
 						fakeIstioConfigMap("1.23.0"),
 						FakeCertificateConfigMap("istio-system"),
@@ -1869,6 +1886,7 @@ func TestDiscoverWithTags(t *testing.T) {
 			},
 			expectedNamespacesByRev: map[clusterRevisionKey][]string{
 				{Cluster: conf.KubernetesConfig.ClusterName, ControlPlaneRevision: "1.23.0"}: {"bookinfo"},
+				{Cluster: conf.KubernetesConfig.ClusterName, ControlPlaneRevision: "1.22.0"}: nil,
 			},
 		},
 		"bookinfo namespace not selected by discovery selectors": {
@@ -2016,7 +2034,7 @@ func TestDiscoverWithTags(t *testing.T) {
 				{Cluster: conf.KubernetesConfig.ClusterName, ControlPlaneRevision: "1-24-0"}: {"travels"},
 			},
 		},
-		"primary-remote with default tag should manage bookinfo on each cluster": {
+		"primary-remote with default tag should manage bookinfo on primary cluster": {
 			conf: func() *config.Config {
 				conf := *conf
 				conf.KubernetesConfig.ClusterName = "primary"
@@ -2045,7 +2063,7 @@ func TestDiscoverWithTags(t *testing.T) {
 				{Cluster: "primary", ControlPlaneRevision: "default"}: {"bookinfo", "bookinfo"},
 			},
 		},
-		"primary-remote with tag should manage bookinfo on both clusters": {
+		"primary-remote with tag should manage bookinfo on primary cluster": {
 			conf: func() *config.Config {
 				conf := *conf
 				conf.KubernetesConfig.ClusterName = "primary"
@@ -2083,7 +2101,7 @@ func TestDiscoverWithTags(t *testing.T) {
 				}
 			},
 			expectedNamespacesByRev: map[clusterRevisionKey][]string{
-				{Cluster: "primary", ControlPlaneRevision: "1-23-0"}: {"bookinfo", "bookinfo"},
+				{Cluster: "primary", ControlPlaneRevision: "1-23-0"}: {"bookinfo"},
 			},
 		},
 		"multi-primary with tag should only manage bookinfo on their own cluster": {
@@ -2168,11 +2186,8 @@ func TestDiscoverWithTags(t *testing.T) {
 			require.NoError(err)
 			require.Len(mesh.ControlPlanes, len(maps.Keys(tc.expectedNamespacesByRev)))
 			for _, cp := range mesh.ControlPlanes {
-				require.NotNil(cp.Tags)
-				for _, tag := range cp.Tags {
-					require.NotNil(tag.ControlPlane)
-					require.Equal(&cp, tag.ControlPlane)
-				}
+				require.NotNil(cp.Tag)
+				require.Equal(cp.Revision, cp.Tag.Revision)
 			}
 			for clusterRev, expectedNamespaces := range tc.expectedNamespacesByRev {
 				controlPlane := slicetest.FindOrFail(t, mesh.ControlPlanes, func(cp models.ControlPlane) bool {
@@ -2239,7 +2254,7 @@ func TestDiscoverTagsWithoutWebhookPermissions(t *testing.T) {
 	mesh, err := discovery.Mesh(context.TODO())
 	require.NoError(err)
 	require.Len(mesh.ControlPlanes, 1)
-	require.Nil(mesh.ControlPlanes[0].Tags)
+	require.Nil(mesh.ControlPlanes[0].Tag)
 }
 
 func approvingClient(client kubernetes.ClientInterface) *accessReviewClient {
@@ -2323,13 +2338,15 @@ func TestDiscoverTagsWithExternalCluster(t *testing.T) {
 	externalControlPlane := slicetest.FindOrFail(t, mesh.ControlPlanes, func(cp models.ControlPlane) bool {
 		return cp.ID == "remote"
 	})
-	require.Len(externalIstioSystem.Tags, 1)
-	require.Len(externalControlPlane.Tags, 1)
+	require.NotNil(externalIstioSystem.Tag)
+	require.NotNil(externalControlPlane.Tag)
 
-	require.Equal(externalIstioSystem.Tags[0].ControlPlane.ID, externalIstioSystem.ID)
-	require.Equal(externalIstioSystem.Tags[0].ControlPlane.IstiodName, externalIstioSystem.IstiodName)
-	require.Equal(externalControlPlane.Tags[0].ControlPlane.ID, externalControlPlane.ID)
-	require.Equal(externalControlPlane.Tags[0].ControlPlane.IstiodName, externalControlPlane.IstiodName)
+	require.Equal(externalIstioSystem.Tag.Revision, externalIstioSystem.Revision)
+	require.Equal(externalIstioSystem.Tag.Cluster, externalIstioSystem.Cluster.Name)
+	require.Equal(externalControlPlane.Tag.Revision, externalControlPlane.Revision)
+	require.NotEqual(externalControlPlane.Tag.Cluster, externalControlPlane.Cluster.Name)
+	require.Equal(externalControlPlane.Tag.Cluster, externalControlPlane.ID)
 
-	require.Equal([]models.Namespace{{Name: "bookinfo", Cluster: "remote", Labels: map[string]string{models.IstioRevisionLabel: "prod"}}}, externalControlPlane.ManagedNamespaces)
+	expectedNamespaces := []models.Namespace{{Name: "bookinfo", Cluster: "remote", Labels: map[string]string{models.IstioRevisionLabel: "prod"}, Revision: "prod"}}
+	require.Equal(expectedNamespaces, externalControlPlane.ManagedNamespaces)
 }
diff --git a/istio/istiotest/fake.go b/istio/istiotest/fake.go
new file mode 100644
index 000000000..9b3842e84
--- /dev/null
+++ b/istio/istiotest/fake.go
@@ -0,0 +1,17 @@
+package istiotest
+
+import (
+	"context"
+
+	"github.com/kiali/kiali/models"
+)
+
+// FakeDiscovery implements the MeshDiscovery interface. Useful for testing.
+type FakeDiscovery struct {
+	// MeshReturn is the return value of Mesh().
+	MeshReturn models.Mesh
+}
+
+func (fmd *FakeDiscovery) Mesh(ctx context.Context) (*models.Mesh, error) {
+	return &fmd.MeshReturn, nil
+}
diff --git a/mesh/api/testdata/test_mesh_graph.expected b/mesh/api/testdata/test_mesh_graph.expected
index 3a6337384..dc182cd4d 100644
--- a/mesh/api/testdata/test_mesh_graph.expected
+++ b/mesh/api/testdata/test_mesh_graph.expected
@@ -207,7 +207,8 @@
                 "istio-injection": "enabled",
                 "kubernetes.io/metadata.name": "data-plane-1"
               },
-              "annotations": null
+              "annotations": null,
+              "revision": "default"
             },
             {
               "name": "data-plane-2",
@@ -217,7 +218,8 @@
                 "istio.io/rev": "default",
                 "kubernetes.io/metadata.name": "data-plane-2"
               },
-              "annotations": null
+              "annotations": null,
+              "revision": "default"
             }
           ],
           "version": "default"
@@ -239,9 +241,9 @@
               "Components": []
             },
             "ConfigMapName": "",
+            "EgressGatewayNamespace": "",
             "EnvoyAdminLocalPort": 15000,
             "GatewayAPIClasses": [],
-            "EgressGatewayNamespace": "",
             "IngressGatewayNamespace": "",
             "IstioAPIEnabled": true,
             "IstioIdentityDomain": "svc.cluster.local",
@@ -268,25 +270,35 @@
           "nodeType": "infra",
           "healthData": "Healthy",
           "infraData": {
+            "cluster": {
+              "apiEndpoint": "http://127.0.0.2:9443",
+              "isKialiHome": true,
+              "kialiInstances": [
+                {
+                  "namespace": "istio-system",
+                  "operatorResource": "",
+                  "serviceName": "kiali",
+                  "url": "",
+                  "version": ""
+                }
+              ],
+              "name": "cluster-primary",
+              "secretName": "",
+              "accessible": true
+            },
             "config": {
-              "ConfigMap": {
+              "configMap": {
                 "mesh": "accessLogFile: /dev/stdout\nenableAutoMtls: true\nrootNamespace: istio-system\ntrustDomain: cluster.local\n"
               },
-              "Network": "kialiNetwork",
-              "DisableMixerHttpReports": false,
-              "DiscoverySelectors": null,
-              "EnableAutoMtls": true,
-              "MeshMTLS": {
-                "MinProtocolVersion": ""
-              },
+              "network": "kialiNetwork",
               "certificates": [
                 {
-                  "configMapName": "istio-ca-root-cert",
                   "dnsNames": null,
-                  "error": "",
+                  "configMapName": "istio-ca-root-cert",
                   "issuer": "O=cluster.local",
                   "notBefore": "2021-07-27T14:37:00Z",
                   "notAfter": "2031-07-25T14:37:00Z",
+                  "error": "",
                   "accessible": true,
                   "cluster": ""
                 }
@@ -294,15 +306,100 @@
               "defaultConfig": {
                 "MeshId": ""
               },
-              "OutboundTrafficPolicy": {
-                "mode": "ALLOW_ANY"
-              },
-              "TrustDomain": "cluster.local",
               "DefaultDestinationRuleExportTo": null,
               "DefaultServiceExportTo": null,
-              "DefaultVirtualServiceExportTo": null
+              "DefaultVirtualServiceExportTo": null,
+              "DisableMixerHttpReports": false,
+              "DiscoverySelectors": null,
+              "EnableAutoMtls": true,
+              "MeshMTLS": {
+                "MinProtocolVersion": ""
+              },
+              "outboundTrafficPolicy": {
+                "mode": "ALLOW_ANY"
+              },
+              "TrustDomain": "cluster.local"
             },
+            "externalControlPlane": false,
+            "id": "cluster-primary",
+            "istiodName": "istiod",
+            "istiodNamespace": "istio-system",
+            "managedClusters": [
+              {
+                "apiEndpoint": "http://127.0.0.2:9443",
+                "isKialiHome": true,
+                "kialiInstances": [
+                  {
+                    "namespace": "istio-system",
+                    "operatorResource": "",
+                    "serviceName": "kiali",
+                    "url": "",
+                    "version": ""
+                  }
+                ],
+                "name": "cluster-primary",
+                "secretName": "",
+                "accessible": true
+              },
+              {
+                "apiEndpoint": "",
+                "isKialiHome": false,
+                "kialiInstances": null,
+                "name": "cluster-remote",
+                "secretName": "",
+                "accessible": true
+              }
+            ],
+            "managesExternal": true,
+            "managedNamespaces": [
+              {
+                "name": "data-plane-1",
+                "cluster": "cluster-primary",
+                "isAmbient": false,
+                "labels": {
+                  "istio-injection": "enabled",
+                  "kubernetes.io/metadata.name": "data-plane-1"
+                },
+                "annotations": null,
+                "revision": "default"
+              },
+              {
+                "name": "data-plane-2",
+                "cluster": "cluster-primary",
+                "isAmbient": false,
+                "labels": {
+                  "istio.io/rev": "default",
+                  "kubernetes.io/metadata.name": "data-plane-2"
+                },
+                "annotations": null,
+                "revision": "default"
+              },
+              {
+                "name": "data-plane-3",
+                "cluster": "cluster-remote",
+                "isAmbient": false,
+                "labels": {
+                  "istio-injection": "enabled",
+                  "kubernetes.io/metadata.name": "data-plane-3"
+                },
+                "annotations": null,
+                "revision": "default"
+              },
+              {
+                "name": "data-plane-4",
+                "cluster": "cluster-remote",
+                "isAmbient": false,
+                "labels": {
+                  "istio.io/rev": "default",
+                  "kubernetes.io/metadata.name": "data-plane-4"
+                },
+                "annotations": null,
+                "revision": "default"
+              }
+            ],
+            "resources": {},
             "revision": "default",
+            "status": "",
             "thresholds": {
               "memory": 0,
               "cpu": 0
@@ -330,7 +427,8 @@
                 "istio-injection": "enabled",
                 "kubernetes.io/metadata.name": "data-plane-3"
               },
-              "annotations": null
+              "annotations": null,
+              "revision": "default"
             },
             {
               "name": "data-plane-4",
@@ -340,7 +438,8 @@
                 "istio.io/rev": "default",
                 "kubernetes.io/metadata.name": "data-plane-4"
               },
-              "annotations": null
+              "annotations": null,
+              "revision": "default"
             }
           ],
           "version": "default"
diff --git a/models/namespace_test.go b/models/namespace_test.go
new file mode 100644
index 000000000..89d7751bf
--- /dev/null
+++ b/models/namespace_test.go
@@ -0,0 +1,66 @@
+package models_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/kiali/kiali/config"
+	"github.com/kiali/kiali/models"
+)
+
+func TestNamespaceRevision(t *testing.T) {
+	config.Set(config.NewConfig())
+	cases := map[string]struct {
+		expectedRevision string
+		labels           map[string]string
+	}{
+		"namespace has istio-injection enabled": {
+			expectedRevision: "default",
+			labels:           map[string]string{"istio-injection": "enabled"},
+		},
+		"namespace has istio-injection disabled": {
+			expectedRevision: "",
+			labels:           map[string]string{"istio-injection": "disabled"},
+		},
+		"namespace has both istio-injection enabled and istio.io/rev labels": {
+			expectedRevision: "default",
+			labels: map[string]string{
+				"istio-injection": "enabled",
+				"istio.io/rev":    "1-23-0",
+			},
+		},
+		"namespace has both istio-injection disabled and istio.io/rev labels": {
+			expectedRevision: "default",
+			labels: map[string]string{
+				"istio-injection": "enabled",
+				"istio.io/rev":    "1-23-0",
+			},
+		},
+		"namespace has istio.io/rev label": {
+			expectedRevision: "1-23-0",
+			labels: map[string]string{
+				"istio.io/rev": "1-23-0",
+			},
+		},
+		"namespace has no labels": {
+			expectedRevision: "",
+		},
+	}
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			require := require.New(t)
+
+			kubeNamespace := corev1.Namespace{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels: tc.labels,
+				},
+			}
+			namespace := models.CastNamespace(kubeNamespace, "")
+
+			require.Equal(tc.expectedRevision, namespace.Revision)
+		})
+	}
+}