Repository: kiali/kiali
Pull request #7847 (2024-10-21 07:47:21)

Uncomment waypoint tests
### Describe the change
Wait for the traffic to be created.

### Steps to test the PR

`yarn cypress open -e TAGS=@waypoint`

### Automation testing

cypress tests update

### Issue reference
Ref. https://github.com/kiali/kiali/pull/7799



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/cypress/integration/common/graph_display-pf.ts b/frontend/cypress/integration/common/graph_display-pf.ts
index 81575da91..a43bddc38 100644
--- a/frontend/cypress/integration/common/graph_display-pf.ts
+++ b/frontend/cypress/integration/common/graph_display-pf.ts
@@ -497,6 +497,8 @@ When('user {string} {string} traffic option', (action: string, option: string) =
 
 Then('{int} edges appear in the graph', (graphEdges: number) => {
   cy.waitForReact();
+  ensureKialiFinishedLoading();
+
   cy.getReact('GraphPagePFComponent', { state: { isReady: true } })
     .should('have.length', '1')
     .then($graph => {
@@ -526,7 +528,7 @@ Then('the {string} node {string} exists', (nodeName: string, action: string) =>
       const foundNode = nodes.filter(node => node.getData().workload === nodeName);
 
       if (action === 'does') {
-        assert.isAtLeast(foundNode.length, 1);
+        assert.equal(foundNode.length, 1);
       } else {
         assert.equal(foundNode.length, 0);
       }
diff --git a/frontend/cypress/integration/common/waypoint.ts b/frontend/cypress/integration/common/waypoint.ts
index e3a260afd..b64ae0518 100644
--- a/frontend/cypress/integration/common/waypoint.ts
+++ b/frontend/cypress/integration/common/waypoint.ts
@@ -30,7 +30,30 @@ const waitForWorkloadEnrolled = (maxRetries = 30, retryCount = 0): void => {
       return;
     } else {
       return cy.wait(10000).then(() => {
-        return waitForWorkloadEnrolled(maxRetries, retryCount + 1); // Ensure to return the recursive call
+        return waitForWorkloadEnrolled(maxRetries, retryCount + 1);
+      });
+    }
+  });
+};
+
+const waitForBookinfoWaypointTrafficGeneratedInGraph = (maxRetries = 30, retryCount = 0): void => {
+  if (retryCount >= maxRetries) {
+    throw new Error(`Condition not met after ${maxRetries} retries`);
+  }
+
+  cy.request({
+    method: 'GET',
+    url:
+      '/api/namespaces/graph?duration=60s&graphType=versionedApp&includeIdleEdges=false&injectServiceNodes=true&boxBy=cluster,namespace,app&waypoints=false&ambientTraffic=waypoint&appenders=deadNode,istio,serviceEntry,meshCheck,workloadEntry,health,ambient&rateGrpc=requests&rateHttp=requests&rateTcp=sent&namespaces=bookinfo'
+  }).then(response => {
+    expect(response.status).to.equal(200);
+    const elements = response.body.elements;
+
+    if (elements?.edges?.length > 10) {
+      return;
+    } else {
+      return cy.wait(10000).then(() => {
+        return waitForBookinfoWaypointTrafficGeneratedInGraph(maxRetries, retryCount + 1);
       });
     }
   });
@@ -41,6 +64,10 @@ Then('{string} namespace is labeled with the waypoint label', (namespace: string
   waitForWorkloadEnrolled();
 });
 
+Then('the graph page has enough data', () => {
+  waitForBookinfoWaypointTrafficGeneratedInGraph();
+});
+
 Then('the user hovers in the {string} label and sees {string} in the tooltip', (label: string, text: string) => {
   cy.get(`[data-test=workload-description-card]`).contains('span', label).trigger('mouseenter');
   cy.get('[role="tooltip"]').should('be.visible').and('contain', text);
diff --git a/frontend/cypress/integration/featureFiles/waypoint.feature b/frontend/cypress/integration/featureFiles/waypoint.feature
index 0e2d5f78c..22b2d9551 100644
--- a/frontend/cypress/integration/featureFiles/waypoint.feature
+++ b/frontend/cypress/integration/featureFiles/waypoint.feature
@@ -13,6 +13,7 @@ Feature: Kiali Waypoint related features
   @waypoint
   Scenario: namespace is labeled with waypoint label
     Then "bookinfo" namespace is labeled with the waypoint label
+    And the graph page has enough data
 
   @waypoint
   Scenario: See the waypoint workload with the correct info
@@ -43,16 +44,6 @@ Feature: Kiali Waypoint related features
     And user "enables" "ambientZtunnel" traffic option
     Then 7 edges appear in the graph
 
-  # TODO Fix waypoint issue
-  #@waypoint
-  #Scenario: User sees waypoint traffic
-  #  Given user is at the "graphpf" page
-  #  When user graphs "bookinfo" namespaces
-  #  Then user sees the "bookinfo" namespace
-  #  Then user opens traffic menu
-  #  And user "enables" "ambientWaypoint" traffic option
-  #  Then 11 edges appear in the graph
-
   @waypoint
   Scenario: User sees no Ambient traffic
     Given user is at the "graphpf" page
@@ -73,17 +64,27 @@ Feature: Kiali Waypoint related features
     Then 16 edges appear in the graph
 
   @waypoint
-  Scenario: User sees doesn't see waypoint proxy
+  Scenario: User doesn't see waypoint proxy
     And the "waypoint" node "doesn't" exists
 
-  # TODO Fix waypoint issue
-  #@waypoint
-  #Scenario: User sees waypoint proxy
-  #  When user opens display menu
-  #  Then the display menu opens
-  #  Then user "enables" "filterWaypoints" edge labels
-  #  Then 16 edges appear in the graph
-  #  And the "waypoint" node "does" exists
+  @waypoint
+  Scenario: User sees waypoint proxy
+    When user opens display menu
+    Then the display menu opens
+    Then user "enables" "filterWaypoints" edge labels
+    Then user opens traffic menu
+    And user "enables" "ambientTotal" traffic option
+    Then 16 edges appear in the graph
+    And the "waypoint" node "does" exists
+
+  @waypoint
+  Scenario: User sees waypoint traffic
+    Given user is at the "graphpf" page
+    When user graphs "bookinfo" namespaces
+    Then user sees the "bookinfo" namespace
+    Then user opens traffic menu
+    And user "enables" "ambientWaypoint" traffic option
+    Then 11 edges appear in the graph
 
   @waypoint
   Scenario: Waypoint should not have validation errors
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/frontend/yarn.lock b/frontend/yarn.lock
index 7a361e60a..1353fc0ea 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -7557,9 +7557,9 @@ http-proxy-agent@^4.0.1:
     debug "4"
 
 http-proxy-middleware@^2.0.3:
-  version "2.0.6"
-  resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz#e1a4dd6979572c7ab5a4e4b55095d1f32a74963f"
-  integrity sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==
+  version "2.0.7"
+  resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz#915f236d92ae98ef48278a95dedf17e991936ec6"
+  integrity sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==
   dependencies:
     "@types/http-proxy" "^1.17.8"
     http-proxy "^1.18.1"
@@ -12007,7 +12007,14 @@ stringify-object@^3.3.0:
     is-obj "^1.0.1"
     is-regexp "^1.0.0"
 
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
+strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -13519,7 +13526,7 @@ workerpool@^6.5.1:
   resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544"
   integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==
 
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -13537,6 +13544,15 @@ wrap-ansi@^6.2.0:
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
+wrap-ansi@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+  dependencies:
+    ansi-styles "^4.0.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
+
 wrap-ansi@^8.1.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
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)
+		})
+	}
+}