Repository: kiali/kiali
Pull request #8290 (2025-04-01 18:37:42)

Add separate timeout for getting istio version during discovery
### Describe the change

Adds a separate timeout for getting the istio version during mesh discovery so the request doesn't cause the whole discovery process to hang.

### Steps to test the PR

N/A

### Automation testing

Added unit test.

### Issue reference

Relate to: https://github.com/kiali/kiali/discussions/8283



Patch:
diff --git a/Makefile b/Makefile
index 20aabb607..f692e4023 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.8.0-SNAPSHOT
+VERSION ?= v2.9.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/checkers/gateways/multi_match_checker.go b/business/checkers/gateways/multi_match_checker.go
index 9b2071845..6cb366532 100644
--- a/business/checkers/gateways/multi_match_checker.go
+++ b/business/checkers/gateways/multi_match_checker.go
@@ -5,7 +5,6 @@ import (
 	"strconv"
 	"strings"
 
-	api_networking_v1 "istio.io/api/networking/v1"
 	networking_v1 "istio.io/client-go/pkg/apis/networking/v1"
 
 	"k8s.io/apimachinery/pkg/labels"
@@ -13,15 +12,13 @@ import (
 	"github.com/kiali/kiali/config"
 	"github.com/kiali/kiali/kubernetes"
 	"github.com/kiali/kiali/models"
-	"github.com/kiali/kiali/util/intutil"
 )
 
 type MultiMatchChecker struct {
-	Cluster         string
-	Conf            *config.Config
-	Gateways        []*networking_v1.Gateway
-	existingList    map[string][]Host
-	hostRegexpCache map[string]regexp.Regexp
+	Cluster   string
+	Conf      *config.Config
+	Gateways  []*networking_v1.Gateway
+	regexpMap map[string]regexp.Regexp
 }
 
 const (
@@ -31,71 +28,101 @@ const (
 )
 
 type Host struct {
-	Port            int
-	Hostname        string
-	Namespace       string
-	ServerIndex     int
+	Gateway         *networking_v1.Gateway
 	HostIndex       int
-	GatewayRuleName string
+	Name            string
+	ServerIndex     int
 	TargetNamespace string
 }
 
+// hostMatchKey groups hosts that match already match on Port and Selector, so
+// that we can perform matching on smaller sets of hosts
+type hostMatchKey = struct {
+	Port     uint32
+	Selector string
+}
+
+// hostMatchMap groups hosts that minimally match on the hostMatchKey fields
+type hostMatchMap map[hostMatchKey][]Host
+
 // Check validates that no two gateways share the same host+port combination
 func (m MultiMatchChecker) Check() models.IstioValidations {
+	hostMap := hostMatchMap{}
+	hostKey := hostMatchKey{}
 	validations := models.IstioValidations{}
-	m.existingList = map[string][]Host{}
-	m.hostRegexpCache = map[string]regexp.Regexp{}
-
-	for _, g := range m.Gateways {
-		gatewayRuleName := g.Name
-		gatewayNamespace := g.Namespace
-
-		selectorString := ""
-		if len(g.Spec.Selector) > 0 {
-			selectorString = labels.Set(g.Spec.Selector).String()
+	m.regexpMap = map[string]regexp.Regexp{}
+
+	// first, collect all of the gateway hosts and group together those that need to be checked
+	// against each other.
+	for _, gw := range m.Gateways {
+		hostKey.Selector = ""
+		if len(gw.Spec.Selector) > 0 {
+			hostKey.Selector = labels.Set(gw.Spec.Selector).String()
 		}
-		for i, server := range g.Spec.Servers {
+
+		for serverIndex, server := range gw.Spec.Servers {
 			if server == nil {
 				continue
 			}
-			hosts := parsePortAndHostnames(server)
-			for hi, host := range hosts {
-				host.ServerIndex = i
-				host.HostIndex = hi
-				host.GatewayRuleName = gatewayRuleName
-				host.Namespace = gatewayNamespace
-				// Hostname can be given in <target-namespace>/Hostname syntax
-				host.TargetNamespace = targetNamespaceAll
-				namespaceAndHost := strings.Split(host.Hostname, "/")
+
+			hostKey.Port = 0
+			if server.Port != nil {
+				hostKey.Port = server.Port.Number
+			}
+
+			for hostIndex, serverHost := range server.Hosts {
+				host := Host{
+					Gateway:         gw,
+					HostIndex:       hostIndex,
+					Name:            serverHost,
+					ServerIndex:     serverIndex,
+					TargetNamespace: targetNamespaceAll,
+				}
+				// host can be given in <target-namespace>/host syntax
+				namespaceAndHost := strings.Split(serverHost, "/")
 				if len(namespaceAndHost) > 1 {
-					host.Hostname = namespaceAndHost[1]
+					host.Name = namespaceAndHost[1]
 					host.TargetNamespace = namespaceAndHost[0]
 					// replace targetNamespaceCurrent with GW namespace to simplify duplicate checking
 					if host.TargetNamespace == targetNamespaceCurrent {
-						host.TargetNamespace = gatewayNamespace
+						host.TargetNamespace = gw.Namespace
 					}
 				}
-				duplicate, dhosts := m.findMatch(host, selectorString)
-				if duplicate {
-					// The above is referenced by each one below..
-					currentHostValidation := createError(host.GatewayRuleName, host.Namespace, m.Cluster, host.ServerIndex, host.HostIndex)
-					existingHosts := make(map[string]bool)
-					for i := 0; i < len(dhosts); i++ {
-						dh := dhosts[i]
-						// we skip CurrentHostValidation
-						// skip duplicate references when one gateway has several duplicate hosts
-						if (dh.Namespace == gatewayNamespace && dh.GatewayRuleName == gatewayRuleName) || existingHosts[dh.Namespace+"/"+dh.GatewayRuleName] {
-							continue
-						}
-						existingHosts[dh.Namespace+"/"+dh.GatewayRuleName] = true
-						refValidation := createError(dh.GatewayRuleName, dh.Namespace, m.Cluster, dh.ServerIndex, dh.HostIndex)
-						refValidation = refValidation.MergeReferences(currentHostValidation)
-						currentHostValidation = currentHostValidation.MergeReferences(refValidation)
-						validations = validations.MergeValidations(refValidation)
+				if hosts, found := hostMap[hostKey]; found {
+					hostMap[hostKey] = append(hosts, host)
+				} else {
+					hostMap[hostKey] = []Host{host}
+				}
+			}
+		}
+	}
+
+	// second, look for matches for each grouped set of hosts
+	for _, hosts := range hostMap {
+		for i, host := range hosts {
+			if len(hosts) == i+1 {
+				break
+			}
+			hasDuplicates, duplicates := m.findMatch(host, hosts[i+1:])
+			if hasDuplicates {
+				hostValidationError := newValidationError(host.Gateway.Name, host.Gateway.Namespace, m.Cluster, host.ServerIndex, host.HostIndex)
+				existingHosts := make(map[string]bool)
+
+				for _, duplicate := range duplicates {
+					existingHostKey := duplicate.Gateway.Namespace + "/" + duplicate.Gateway.Name
+
+					// skip duplicate references when one gateway has several duplicate hosts
+					if (duplicate.Gateway.Namespace == host.Gateway.Namespace && duplicate.Gateway.Name == host.Gateway.Name) || existingHosts[existingHostKey] {
+						continue
 					}
-					validations = validations.MergeValidations(currentHostValidation)
+
+					existingHosts[existingHostKey] = true
+					refValidation := newValidationError(duplicate.Gateway.Name, duplicate.Gateway.Namespace, m.Cluster, duplicate.ServerIndex, duplicate.HostIndex)
+					refValidation = refValidation.MergeReferences(hostValidationError)
+					hostValidationError = hostValidationError.MergeReferences(refValidation)
+					validations = validations.MergeValidations(refValidation)
 				}
-				m.existingList[selectorString] = append(m.existingList[selectorString], host)
+				validations = validations.MergeValidations(hostValidationError)
 			}
 		}
 	}
@@ -103,13 +130,13 @@ func (m MultiMatchChecker) Check() models.IstioValidations {
 	return validations
 }
 
-func createError(gatewayRuleName, namespace, cluster string, serverIndex, hostIndex int) models.IstioValidations {
-	key := models.IstioValidationKey{Name: gatewayRuleName, Namespace: namespace, ObjectGVK: kubernetes.Gateways, Cluster: cluster}
+func newValidationError(gatewayName, namespace, cluster string, serverIndex, hostIndex int) models.IstioValidations {
+	key := models.IstioValidationKey{Name: gatewayName, Namespace: namespace, ObjectGVK: kubernetes.Gateways, Cluster: cluster}
 	checks := models.Build("gateways.multimatch",
 		"spec/servers["+strconv.Itoa(serverIndex)+"]/hosts["+strconv.Itoa(hostIndex)+"]")
 	rrValidation := &models.IstioValidation{
 		Cluster:   cluster,
-		Name:      gatewayRuleName,
+		Name:      gatewayName,
 		ObjectGVK: key.ObjectGVK,
 		Valid:     true,
 		Checks: []*models.IstioCheck{
@@ -120,72 +147,52 @@ func createError(gatewayRuleName, namespace, cluster string, serverIndex, hostIn
 	return models.IstioValidations{key: rrValidation}
 }
 
-func parsePortAndHostnames(serverDef *api_networking_v1.Server) []Host {
-	var port int
-	if serverDef.Port != nil {
-		if n, e := intutil.Convert(serverDef.Port.Number); e == nil {
-			port = n
-		}
-	}
-	if len(serverDef.Hosts) > 0 {
-		hosts := make([]Host, 0, len(serverDef.Hosts))
-		for _, hostname := range serverDef.Hosts {
-			hosts = append(hosts, Host{
-				Port:     port,
-				Hostname: hostname,
-			})
-		}
-		return hosts
-	}
-	return nil
-}
-
 // findMatch uses a linear search with regexp to check for matching gateway host + port combinations. If this becomes a bottleneck for performance, replace with a graph or trie algorithm.
-func (m MultiMatchChecker) findMatch(host Host, selector string) (bool, []Host) {
+func (m MultiMatchChecker) findMatch(host Host, otherHosts []Host) (bool, []Host) {
 	duplicates := make([]Host, 0)
+	skipWild := m.Conf.KialiFeatureFlags.Validations.SkipWildcardGatewayHosts
 
-	for groupSelector, hostGroup := range m.existingList {
-		if groupSelector != selector {
+	for _, other := range otherHosts {
+		// only compare hosts that share the target namespace or hosts where at least one is exported to all namespaces
+		if host.TargetNamespace != other.TargetNamespace && host.TargetNamespace != targetNamespaceAll && other.TargetNamespace != targetNamespaceAll {
+			continue
+		}
+		// wildcardMatches will always match unless SkipWildcardGatewayHosts is set 'true'
+		if host.Name == wildCardMatch || other.Name == wildCardMatch {
+			if !skipWild {
+				duplicates = append(duplicates, host)
+				duplicates = append(duplicates, other)
+			}
 			continue
 		}
 
-		for _, h := range hostGroup {
-			// only compare hosts that share the target namespace or hosts where at least one of the pair is exported to all namespaces
-			if h.TargetNamespace == targetNamespaceAll || host.TargetNamespace == targetNamespaceAll || h.TargetNamespace == host.TargetNamespace {
-				if h.Port == host.Port {
-					// wildcardMatches will always match unless SkipWildcardGatewayHosts is set 'true'
-					if host.Hostname == wildCardMatch || h.Hostname == wildCardMatch {
-						if !m.Conf.KialiFeatureFlags.Validations.SkipWildcardGatewayHosts {
-							duplicates = append(duplicates, host)
-							duplicates = append(duplicates, h)
-						}
-						continue
-					}
+		// DNS is case-insensitive
+		hostName := strings.ToLower(host.Name)
+		otherName := strings.ToLower(other.Name)
 
-					// DNS is case-insensitive
-					current := strings.ToLower(host.Hostname)
-					previous := strings.ToLower(h.Hostname)
+		// avoid regex if it's simple equality
+		if hostName == otherName {
+			duplicates = append(duplicates, host)
+			duplicates = append(duplicates, other)
+			continue
+		}
 
-					// lazily compile hostname Regex
-					currentRegexp, ok := m.hostRegexpCache[current]
-					if !ok {
-						currentRegexp = *regexpFromHostname(current)
-						m.hostRegexpCache[current] = currentRegexp
-					}
-					previousRegexp, ok := m.hostRegexpCache[previous]
-					if !ok {
-						previousRegexp = *regexpFromHostname(previous)
-						m.hostRegexpCache[previous] = previousRegexp
-					}
+		// lazily compile hostname Regex
+		hostRegexp, ok := m.regexpMap[hostName]
+		if !ok {
+			hostRegexp = *regexpFromHostname(hostName)
+			m.regexpMap[hostName] = hostRegexp
+		}
+		otherRegexp, ok := m.regexpMap[otherName]
+		if !ok {
+			otherRegexp = *regexpFromHostname(otherName)
+			m.regexpMap[otherName] = otherRegexp
+		}
 
-					if currentRegexp.MatchString(previous) ||
-						previousRegexp.MatchString(current) {
-						duplicates = append(duplicates, host)
-						duplicates = append(duplicates, h)
-						continue
-					}
-				}
-			}
+		if hostRegexp.MatchString(otherName) || otherRegexp.MatchString(hostName) {
+			duplicates = append(duplicates, host)
+			duplicates = append(duplicates, other)
+			continue
 		}
 	}
 	return len(duplicates) > 0, duplicates
diff --git a/business/istio_status.go b/business/istio_status.go
index 5db386b92..d00612c61 100644
--- a/business/istio_status.go
+++ b/business/istio_status.go
@@ -14,6 +14,7 @@ import (
 	"github.com/kiali/kiali/models"
 	"github.com/kiali/kiali/observability"
 	"github.com/kiali/kiali/util/httputil"
+	"github.com/kiali/kiali/util/sliceutil"
 )
 
 func NewIstioStatusService(
@@ -123,19 +124,22 @@ func (iss *IstioStatusService) getIstioComponentStatus(ctx context.Context, clus
 	}
 
 	// Autodiscover gateways.
-	gateways, err := iss.workloads.GetAllGateways(ctx, cluster, "")
+	allGateways, err := iss.workloads.GetGateways(ctx)
 	if err != nil {
 		// Don't error on gateways since they are non-essential.
 		log.Debugf("Unable to get gateway workloads when building istio component status. Cluster: %s. Err: %s", cluster, err)
 		return istiodStatus, nil
 	}
+	gateways := sliceutil.Filter(allGateways, func(gw *models.Workload) bool {
+		return gw.Cluster == cluster
+	})
 
-	for _, workload := range gateways {
+	for _, gateway := range gateways {
 		istiodStatus = append(istiodStatus, kubernetes.ComponentStatus{
-			Cluster:   workload.Cluster,
-			Name:      workload.Name,
-			Namespace: workload.Namespace,
-			Status:    GetWorkloadStatus(*workload),
+			Cluster:   gateway.Cluster,
+			Name:      gateway.Name,
+			Namespace: gateway.Namespace,
+			Status:    GetWorkloadStatus(*gateway),
 			IsCore:    false,
 		})
 	}
diff --git a/business/workloads.go b/business/workloads.go
index b79ba5a81..461ce8f87 100644
--- a/business/workloads.go
+++ b/business/workloads.go
@@ -185,16 +185,29 @@ func (in *WorkloadService) GetAllWorkloads(ctx context.Context, cluster string,
 	return workloads, nil
 }
 
-// GetAllGateways fetches all gateway workloads across every namespace in the cluster.
-func (in *WorkloadService) GetAllGateways(ctx context.Context, cluster string, labelSelector string) (models.Workloads, error) {
-	workloads, err := in.GetAllWorkloads(ctx, cluster, labelSelector)
-	if err != nil {
-		return nil, err
+// GetGateways fetches all gateway workloads across all clusters and namespaces.
+func (in *WorkloadService) GetGateways(ctx context.Context) (models.Workloads, error) {
+	if gateways, ok := in.cache.GetGateways(); ok {
+		log.Tracef("GetGateways: Returning list from cache")
+		return gateways, nil
 	}
 
-	return sliceutil.Filter(workloads, func(w *models.Workload) bool {
-		return w.IsGateway()
-	}), nil
+	gateways := models.Workloads{}
+	for cluster := range in.userClients {
+		workloads, err := in.GetAllWorkloads(ctx, cluster, "")
+		if err != nil {
+			log.Debugf("GetGateways: Error fetching workloads for cluster=[%s]: %s", cluster, err.Error())
+			continue
+		}
+
+		clusterGateways := sliceutil.Filter(workloads, func(w *models.Workload) bool {
+			return w.IsGateway()
+		})
+		gateways = append(gateways, clusterGateways...)
+	}
+
+	in.cache.SetGateways(gateways)
+	return gateways, nil
 }
 
 // GetWorkloadList is the API handler to fetch the list of workloads in a given namespace.
@@ -2150,7 +2163,7 @@ func (in *WorkloadService) fetchWorkload(ctx context.Context, criteria WorkloadC
 					includeServices = true
 				}
 				// Get waypoint workloads
-				in.cache.GetWaypointList()
+				in.cache.GetWaypoints()
 				waypointWorkloads, waypointServices := in.listWaypointWorkloads(ctx, w.Name, w.Namespace, criteria.Cluster, includeServices)
 				w.WaypointWorkloads = waypointWorkloads
 				if includeServices {
@@ -2172,13 +2185,12 @@ func (in *WorkloadService) GetZtunnelConfig(cluster, namespace, pod string) *kub
 
 // GetWaypoints: Return the list of waypoint workloads.  This looks for all k8s gateways and then tests their labels
 func (in *WorkloadService) GetWaypoints(ctx context.Context) models.Workloads {
-	if !in.cache.IsWaypointListExpired() {
+	if waypoints, ok := in.cache.GetWaypoints(); ok {
 		log.Tracef("GetWaypoints: Returning list from cache")
-		return in.cache.GetWaypointList()
+		return waypoints
 	}
 
 	waypoints := models.Workloads{}
-
 	for cluster := range in.userClients {
 		gateways, err := in.GetAllWorkloads(ctx, cluster, config.GatewayLabel)
 		if err != nil {
@@ -2189,7 +2201,7 @@ func (in *WorkloadService) GetWaypoints(ctx context.Context) models.Workloads {
 		clusterWaypoints := sliceutil.Filter(gateways, func(gw *models.Workload) bool { return gw.IsWaypoint() })
 		waypoints = append(waypoints, clusterWaypoints...)
 	}
-	in.cache.SetWaypointList(waypoints)
+	in.cache.SetWaypoints(waypoints)
 	return waypoints
 }
 
@@ -2295,31 +2307,36 @@ func (in *WorkloadService) getCapturingWaypoints(ctx context.Context, workload m
 // reflects the active waypoint, the others have been overriden.
 func (in *WorkloadService) GetWaypointsForWorkload(ctx context.Context, workload models.Workload, all bool) []models.WorkloadReferenceInfo {
 	workloadsList := []models.WorkloadReferenceInfo{}
-	workloadsMap := map[string]bool{} // Ensure unique
 
 	if workload.Labels[config.WaypointUseLabel] == config.WaypointNone {
 		return workloadsList
 	}
 
 	// get waypoint references for the workload
-	waypoints, found := in.getCapturingWaypoints(ctx, workload, all)
+	capturingWaypoints, found := in.getCapturingWaypoints(ctx, workload, all)
 	if !found {
 		return workloadsList
 	}
 
 	// then, get the waypoint workloads to filter out "forNone" waypoints
-	for _, waypoint := range waypoints {
-		waypointWorkload, err := in.fetchWorkload(ctx, WorkloadCriteria{Cluster: workload.Cluster, Namespace: waypoint.Namespace, WorkloadName: waypoint.Name, WorkloadGVK: schema.GroupVersionKind{}, IncludeWaypoints: false})
-		if err != nil {
-			log.Debugf("GetWaypointsForWorkload: Error fetching waypoint workload %s", err.Error())
-			return nil
+	waypointWorkloads := in.GetWaypoints(ctx)
+	workloadsMap := map[string]bool{} // Ensure unique
+	for _, capturingWaypoint := range capturingWaypoints {
+		var waypointWorkload *models.Workload
+		for _, ww := range waypointWorkloads {
+			if ww.Name == capturingWaypoint.Name && ww.Namespace == capturingWaypoint.Namespace && ww.Cluster == capturingWaypoint.Cluster {
+				waypointWorkload = ww
+				break
+			}
 		}
-		waypointFor, waypointForFound := waypointWorkload.Labels[config.WaypointFor]
-		if !waypointForFound || waypointFor != config.WaypointForNone {
-			key := fmt.Sprintf("%s_%s_%s", workload.Cluster, waypoint.Namespace, waypoint.Name)
-			if waypointWorkload != nil && !workloadsMap[key] {
-				workloadsList = append(workloadsList, models.WorkloadReferenceInfo{Name: waypoint.Name, Namespace: waypoint.Namespace, Cluster: waypoint.Cluster, Type: waypointWorkload.WaypointFor()})
-				workloadsMap[key] = true
+		if waypointWorkload != nil {
+			waypointFor, waypointForFound := waypointWorkload.Labels[config.WaypointFor]
+			if !waypointForFound || waypointFor != config.WaypointForNone {
+				key := fmt.Sprintf("%s_%s_%s", workload.Cluster, capturingWaypoint.Namespace, capturingWaypoint.Name)
+				if !workloadsMap[key] {
+					workloadsList = append(workloadsList, models.WorkloadReferenceInfo{Name: capturingWaypoint.Name, Namespace: capturingWaypoint.Namespace, Cluster: capturingWaypoint.Cluster, Type: waypointWorkload.WaypointFor()})
+					workloadsMap[key] = true
+				}
 			}
 		}
 	}
@@ -2782,7 +2799,7 @@ func (in *WorkloadService) StreamPodLogs(ctx context.Context, cluster, namespace
 				waypoint := wk.WaypointWorkloads[0]
 				waypointWk, errWaypoint := in.GetWorkload(ctx, WorkloadCriteria{Cluster: waypoint.Cluster, Namespace: waypoint.Namespace, WorkloadName: waypoint.Name, IncludeServices: false})
 				if errWaypoint != nil {
-					log.Errorf("Error when getting workload info: %s", err.Error())
+					log.Errorf("Error when getting waypoint workload info: %s", errWaypoint)
 				} else {
 					for _, pod := range waypointWk.Pods {
 						names = append(names, pod.Name)
diff --git a/frontend/cypress/integration/common/mesh.ts b/frontend/cypress/integration/common/mesh.ts
index c77b3d719..753970797 100644
--- a/frontend/cypress/integration/common/mesh.ts
+++ b/frontend/cypress/integration/common/mesh.ts
@@ -250,3 +250,13 @@ Then('user sees tracing node side panel', () => {
       cy.contains(new RegExp('jaeger|Jaeger|tempo|Tempo', 'g'));
     });
 });
+
+Then('user sees {string} icon side panel', (iconType: string) => {
+  cy.waitForReact();
+  cy.get('#target-panel-node').get(`[data-test="icon-${iconType}-validation"]`).should('be.visible');
+});
+
+Then('user does not see {string} icon side panel', (iconType: string) => {
+  cy.waitForReact();
+  cy.get('#target-panel-node').get(`[data-test="icon-${iconType}-validation"]`).should('not.exist');
+});
diff --git a/frontend/cypress/integration/featureFiles/mesh.feature b/frontend/cypress/integration/featureFiles/mesh.feature
index 114b6b968..4d967a07d 100644
--- a/frontend/cypress/integration/featureFiles/mesh.feature
+++ b/frontend/cypress/integration/featureFiles/mesh.feature
@@ -97,3 +97,19 @@ Feature: Kiali Mesh page
     And user sees 1 "dataplane" nodes on the "west" cluster
     And user sees 1 "istiod" nodes on the "east" cluster
     And user sees 1 "istiod" nodes on the "west" cluster
+
+  @component-health-upscale
+  Scenario: Grafana Infra unreachable
+    When user scales to "0" the "grafana" in namespace "istio-system"
+    Then the user refreshes the page
+    When user selects mesh node with label "Grafana"
+    Then user sees "Grafana" node side panel
+    Then user sees "Version: unknown" node side panel
+    Then user sees "error" icon side panel
+    When user scales to "1" the "grafana" in namespace "istio-system"
+    Then the user refreshes the page
+    When user selects mesh node with label "Grafana"
+    Then user sees "Grafana" node side panel
+    Then user sees "correct" icon side panel
+    Then user does not see "error" icon side panel
+    Then user does not see "warning" icon side panel
diff --git a/frontend/cypress/integration/featureFiles/services.feature b/frontend/cypress/integration/featureFiles/services.feature
index 888040065..41f0f7b03 100644
--- a/frontend/cypress/integration/featureFiles/services.feature
+++ b/frontend/cypress/integration/featureFiles/services.feature
@@ -163,3 +163,12 @@ Feature: Kiali Services page
     Then the list is sorted by column "Cluster" in "ascending" order
     When user sorts the list by column "Cluster" in "descending" order
     Then the list is sorted by column "Cluster" in "descending" order
+
+  @ambient
+  Scenario: Filter services table by health
+    And user is at the "services" page
+    When user selects the "bookinfo" namespace
+    And user selects filter "Health"
+    And user filters for health "Healthy"
+    Then user sees "something" in the table
+    And user should only see healthy services in the table
diff --git a/frontend/cypress/integration/featureFiles/waypoint.feature b/frontend/cypress/integration/featureFiles/waypoint.feature
index 0aef0e19c..8d246e52d 100644
--- a/frontend/cypress/integration/featureFiles/waypoint.feature
+++ b/frontend/cypress/integration/featureFiles/waypoint.feature
@@ -276,6 +276,26 @@ Feature: Kiali Waypoint related features
     And user "disables" "http" traffic option
     Then 2 edges appear in the graph
 
+  Scenario: [Waypoint details] The waypoint details for a waypoint for none are valid
+    Given user is at the details page for the "workload" "waypoint-fornone/curl-client" located in the "" cluster
+    And the user doesn't see a L7 link
+    And user is at the details page for the "workload" "waypoint-fornone/waypoint" located in the "" cluster
+    When the user goes to the "Waypoint" tab
+    Then the "Services" subtab doesn't exist
+    Then the "Workloads" subtab doesn't exist
+    Then user goes to the waypoint "Info" subtab
+    And validates waypoint Info data for "none"
+
+  Scenario: [Waypoint details] The waypoint details for a waypoint for service are valid
+    Given user is at the details page for the "workload" "waypoint-forservice/curl-client" located in the "" cluster
+    And the user sees the L7 "waypoint" link
+    And the link for the waypoint "waypoint" should redirect to a valid workload details
+    When the user goes to the "Waypoint" tab
+    Then user goes to the waypoint "Services" subtab
+    And validates Services data with "1" rows and "echo-service" workload, "waypoint-forservice" namespace, "namespace" label for, "pfbadge-S" badge
+    Then user goes to the waypoint "Info" subtab
+    And validates waypoint Info data for "service"
+
   Scenario: [Waypoint details] The waypoint details for a waypoint in different ns are valid
     Given user is at the details page for the "workload" "waypoint-differentns/curl-client" located in the "" cluster
     And the user sees the L7 "egress-gateway" link
@@ -298,26 +318,6 @@ Feature: Kiali Waypoint related features
     Then user goes to the waypoint "Info" subtab
     And validates waypoint Info data for "all"
 
-  Scenario: [Waypoint details] The waypoint details for a waypoint for none are valid
-    Given user is at the details page for the "workload" "waypoint-fornone/curl-client" located in the "" cluster
-    And the user doesn't see a L7 link
-    And user is at the details page for the "workload" "waypoint-fornone/waypoint" located in the "" cluster
-    When the user goes to the "Waypoint" tab
-    Then the "Services" subtab doesn't exist
-    Then the "Workloads" subtab doesn't exist
-    Then user goes to the waypoint "Info" subtab
-    And validates waypoint Info data for "none"
-
-  Scenario: [Waypoint details] The waypoint details for a waypoint for service are valid
-    Given user is at the details page for the "workload" "waypoint-forservice/curl-client" located in the "" cluster
-    And the user sees the L7 "waypoint" link
-    And the link for the waypoint "waypoint" should redirect to a valid workload details
-    When the user goes to the "Waypoint" tab
-    Then user goes to the waypoint "Services" subtab
-    And validates Services data with "1" rows and "echo-service" workload, "waypoint-forservice" namespace, "namespace" label for, "pfbadge-S" badge
-    Then user goes to the waypoint "Info" subtab
-    And validates waypoint Info data for "service"
-
   Scenario: [Waypoint details] The waypoint details for a waypoint for workload are valid
     Given user is at the details page for the "workload" "waypoint-forworkload/echo-server" located in the "" cluster
     And the user sees the L7 "bwaypoint" link
diff --git a/frontend/i18next-parser.config.ts b/frontend/i18next-parser.config.ts
index e387f8bf3..ecf7b2a60 100644
--- a/frontend/i18next-parser.config.ts
+++ b/frontend/i18next-parser.config.ts
@@ -34,7 +34,7 @@ const i18nextParserConfig = {
     default: ['JavascriptLexer']
   },
 
-  locales: ['en', 'zh'],
+  locales: ['en', 'es', 'zh'],
   // An array of the locales in your applications
 
   namespaceSeparator: '~',
diff --git a/frontend/package.json b/frontend/package.json
index c6657eb72..e8262064c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@kiali/kiali-ui",
-  "version": "2.8.0",
+  "version": "2.9.0",
   "description": "React UI for [Kiali](https://github.com/kiali/kiali).",
   "keywords": [
     "istio service mesh",
diff --git a/frontend/public/locales/es/translation.json b/frontend/public/locales/es/translation.json
new file mode 100644
index 000000000..8cfc0d658
--- /dev/null
+++ b/frontend/public/locales/es/translation.json
@@ -0,0 +1,279 @@
+{
+  "{{count}} application_one": "{{count}} aplicación",
+  "{{count}} application_many": "{{count}} aplicaciones",
+  "{{count}} application_other": "{{count}} aplicaciones",
+  "{{count}} label_one": "{{count}} etiqueta",
+  "{{count}} label_many": "{{count}} etiquetas",
+  "{{count}} label_other": "{{count}} etiquetas",
+  "{{count}} namespace_one": "{{count}} espacio de nombres",
+  "{{count}} namespace_many": "{{count}} espacios de nombres",
+  "{{count}} namespace_other": "{{count}} espacios de nombres",
+  "{{count}} Open Issue_one": "{{count}} problema",
+  "{{count}} Open Issue_many": "{{count}} problemas",
+  "{{count}} Open Issue_other": "{{count}} problemas",
+  "{{count}} service_one": "{{count}} servicio",
+  "{{count}} service_many": "{{count}} servicios",
+  "{{count}} service_other": "{{count}} servicios",
+  "{{count}} workload_one": "{{count}} carga de trabajo",
+  "{{count}} workload_many": "{{count}} cargas de trabajo",
+  "{{count}} workload_other": "{{count}} cargas de trabajo",
+  "{{direction}} traffic, {{duration}}": "{{direction}} traffic, {{duration}}",
+  "{{oldDate}} to {{newDate}}": "{{oldDate}} to {{newDate}}",
+  "Actions": "Actions",
+  "Additional State": "Additional State",
+  "and": "and",
+  "API Endpoint: {{apiEndpoint}}": "API Endpoint: {{apiEndpoint}}",
+  "App": "Applicación",
+  "Applications": "Aplicaciones",
+  "Apps": "Aplicaciones",
+  "Attribute": "Attribute",
+  "Back": "Back",
+  "Canary: {{isCanary}}": "Canary: {{isCanary}}",
+  "Cancel": "Cancel",
+  "Choose the traffic rates used to generate the graph. Each supported protocol offers one or more options. Unused protocols can be omitted.": "Choose the traffic rates used to generate the graph. Each supported protocol offers one or more options. Unused protocols can be omitted.",
+  "Circuit Breaker": "Circuit Breaker",
+  "Click + Drag": "Click + Drag",
+  "Click kebab menu on a node label for links to details, traffic, inbound/outbound metrics and node graph.": "Click kebab menu on a node label for links to details, traffic, inbound/outbound metrics and node graph.",
+  "Click kebab menu on a node label to see the contextual menu options for the node.": "Click kebab menu on a node label to see the contextual menu options for the node.",
+  "Click on a node or edge to see its summary and emphasize its end-to-end paths.": "Click on a node or edge to see its summary and emphasize its end-to-end paths.",
+  "Close": "Close",
+  "Close Legend": "Close Legend",
+  "Close Replay": "Close Replay",
+  "Cluster": "Clúster",
+  "Compact view": "Vista compacta",
+  "Compare with similar traces": "Compare with similar traces",
+  "Concentric": "Concentric",
+  "Configuration": "Configuration",
+  "Configuration:": "Configuration:",
+  "Confirm": "Confirm",
+  "Contextual Menu": "Contextual Menu",
+  "Contextual menu on nodes": "Contextual menu on nodes",
+  "Control plane": "Control plane",
+  "Control plane metrics": "Control plane metrics",
+  "Copied": "Copied",
+  "Copy": "Copy",
+  "Copy all resources": "Copy all resources",
+  "cores": "cores",
+  "CPU": "CPU",
+  "Create": "Create",
+  "Current duration": "Current duration",
+  "Current refresh interval: {{refreshInterval}}": "Current refresh interval: {{refreshInterval}}",
+  "Current time range": "Current time range",
+  "custom": "custom",
+  "Custom": "Custom",
+  "Dagre": "Dagre",
+  "Data Plane": "Data Plane",
+  "Dataplane table": "Dataplane table",
+  "Debug information": "Debug information",
+  "Debug Information": "Debug Information",
+  "Debug information has been copied to your clipboard.": "Debug information has been copied to your clipboard.",
+  "Debug information was copied to your clipboard, but is outdated now. It could be caused by new data received by auto refresh timers.": "Debug information was copied to your clipboard, but is outdated now. It could be caused by new data received by auto refresh timers.",
+  "Degraded": "Degraded",
+  "Details in side panel on nodes": "Details in side panel on nodes",
+  "Direction": "Direction",
+  "Direction type is not valid.": "Direction type is not valid.",
+  "Disabled": "Disabled",
+  "Display": "Display",
+  "Display the legend to learn about what the different shapes, colors and backgrounds mean.": "Display the legend to learn about what the different shapes, colors and backgrounds mean.",
+  "Distributed Tracing": "Trazas distribuidas",
+  "Done": "Done",
+  "Download": "Download",
+  "Download all resources in a file": "Download all resources in a file",
+  "Download configuration in a file": "Download configuration in a file",
+  "Duration": "Duration",
+  "Edges": "Edges",
+  "Enabled": "Enabled",
+  "Error fetching Istio deployment status.": "Error fetching Istio deployment status.",
+  "Every 10s": "Cada 10s",
+  "Every 15m": "Cada 15m",
+  "Every 15s": "Cada 15s",
+  "Every 1m": "Cada 1m",
+  "Every 30s": "Cada 30s",
+  "Every 5m": "Cada 5m",
+  "Expand view": "Vista expandida",
+  "Failed Request": "Failed Request",
+  "Failure": "Failure",
+  "Fault Injection": "Fault Injection",
+  "Filter by Application Health": "Filtrar por la salud de la aplicación",
+  "Filter by mTLS": "Filtrar por mTLS",
+  "Filter by Namespace": "Filtrar por el espacio de nombres",
+  "Filter by Namespace Label": "Filtrar por la etiqueta del espacio de nombres",
+  "Find and Hide": "Find and Hide",
+  "FQDN": "FQDN",
+  "Gateway": "Gateway",
+  "Graph": "Graph",
+  "Graph Traffic": "Graph Traffic",
+  "Graph Type": "Graph Type",
+  "GraphTour": "GraphTour",
+  "Grid": "Grid",
+  "Health": "Salud",
+  "Health for": "Salud de",
+  "Health status": "Health status",
+  "Healthy": "Healthy",
+  "Healthy Request": "Healthy Request",
+  "Help": "Help",
+  "Hide": "Hide",
+  "Highlight or Hide graph elements via typed expressions. Click the dropdown for preset Find or Hide expressions. Click the Find/Hide help icon for details on the expression language.": "Highlight or Hide graph elements via typed expressions. Click the dropdown for preset Find or Hide expressions. Click the Find/Hide help icon for details on the expression language.",
+  "Highlight or Hide mesh elements via typed expressions. Click the dropdown for preset Find or Hide expressions. Click the Find/Hide help icon for details on the expression language.": "Highlight or Hide mesh elements via typed expressions. Click the dropdown for preset Find or Hide expressions. Click the Find/Hide help icon for details on the expression language.",
+  "Idle": "Idle",
+  "inbound": "entrante",
+  "Inbound": "Entrante",
+  "Infra node": "Infra node",
+  "Issuer:": "Issuer:",
+  "Istio Ambient ztunnel detected in the Control plane": "Istio Ambient ztunnel detected in the Control plane",
+  "Istio Component List": "Istio Component List",
+  "Istio Components Status": "Istio Components Status",
+  "Istio config": "Configuración de Istio",
+  "Istio Config": "Configuración de Istio",
+  "Istio control plane": "Istio control plane",
+  "Istio deployment status disabled.": "Istio deployment status disabled.",
+  "Istio revision {{version}}": "Istio revision {{version}}",
+  "Kiali Config": "Kiali Config",
+  "Kiali home cluster": "Clúster de residencia de Kiali",
+  "Kiali home cluster: {{name}}": "Clúster de residencia de Kiali: {{name}}",
+  "Kiali on GitHub": "Kiali on GitHub",
+  "Last": "Últimos",
+  "Last {{duration}}": "Últimos {{duration}}",
+  "Layout selection": "Layout selection",
+  "Legend": "Legend",
+  "List view": "Vista de lista",
+  "Loading actions...": "Loading actions...",
+  "Loading...": "Loading...",
+  "Login to {{cluster}}": "Login to {{cluster}}",
+  "Logout": "Logout",
+  "mb": "mb",
+  "Mb (Threshold)": "Mb (Threshold)",
+  "Memory": "Memoria",
+  "Mesh": "Malla de servicios",
+  "Mesh Name: {{meshName}}": "Nombre de la malla de servicios: {{meshName}}",
+  "Mesh page": "Mesh page",
+  "mesh settings": "mesh settings",
+  "Min TLS version": "Min TLS version",
+  "Mirroring": "Mirroring",
+  "Missing Sidecar": "Missing Sidecar",
+  "More info at": "More info at",
+  "Mouse wheel": "Mouse wheel",
+  "mTLS": "mTLS",
+  "mTLS (badge)": "mTLS (badge)",
+  "n/a": "n/a",
+  "Name": "Nombre",
+  "Namespace": "Espacio de nombres",
+  "Namespace Label": "Etiqueta del espacio de nombres",
+  "Namespace: {{namespace}}": "Espacio de nombres: {{namespace}}",
+  "Namespaces": "Espacios de nombres",
+  "Next": "Next",
+  "No {{direction}} traffic": "No hay tráfico {{direction}}",
+  "No cert info": "No cert info",
+  "No health information": "No health information",
+  "No labels": "No labels",
+  "No results found": "No results found",
+  "Node Background": "Node Background",
+  "Node Badges": "Node Badges",
+  "Node Colors": "Node Colors",
+  "Node Shapes": "Node Shapes",
+  "none": "none",
+  "Normal": "Normal",
+  "Normal Request": "Normal Request",
+  "Not found": "Not found",
+  "Not healthy": "Not healthy",
+  "Not ready": "Not ready",
+  "Not Ready": "Not Ready",
+  "Notification badge": "Notification badge",
+  "now": "now",
+  "Operation": "Operation",
+  "or": "or",
+  "outbound": "saliente",
+  "Outbound": "Saliente",
+  "Outbound policy": "Outbound policy",
+  "Overview": "Descripción general",
+  "Overview type is not valid.": "Overview type is not valid.",
+  "Panning": "Panning",
+  "Partially Enabled": "Partially Enabled",
+  "Pause": "Pausa",
+  "Please include this information when opening a bug:": "Please include this information when opening a bug:",
+  "Pod Status": "Pod Status",
+  "Port": "Port",
+  "Proxy push time": "Proxy push time",
+  "Refresh interval": "Refresh interval",
+  "Replay": "Replay",
+  "Replay...": "Replay...",
+  "Request Timeout": "Request Timeout",
+  "Reset": "Reset",
+  "Restricted / External": "Restricted / External",
+  "Return to last page": "Return to last page",
+  "Revision: {{revision}}": "Revision: {{revision}}",
+  "Right click": "Right click",
+  "Right-click a node or an edge to see the contextual menu with links to details, traffic and inbound/outbound metrics for the node or edge.": "Right-click a node or an edge to see the contextual menu with links to details, traffic and inbound/outbound metrics for the node or edge.",
+  "Secret Name: {{secretName}}": "Secret Name: {{secretName}}",
+  "Select a workload, service or application graph view. An application view can optionally be versioned and relies on app and version labeling. Workload and service graphs provide physical and logical views, respectively.": "Select a workload, service or application graph view. An application view can optionally be versioned and relies on app and version labeling. Workload and service graphs provide physical and logical views, respectively.",
+  "Select how often to refresh the graph and how much historical metric data is used to build the graph, per refresh. For example \"Last 5m\" means use the most recent 5 minutes of request metric data.  To replay a historical time window click the replay icon.  This replaces the current time range controls with the replay toolbar.": "Select how often to refresh the graph and how much historical metric data is used to build the graph, per refresh. For example \"Last 5m\" means use the most recent 5 minutes of request metric data.  To replay a historical time window click the replay icon.  This replaces the current time range controls with the replay toolbar.",
+  "Select how often to refresh the mesh and how much historical metric data is used for metric charts. For example \"Last 5m\" means use the most recent 5 minutes of request metric data.": "Select how often to refresh the mesh and how much historical metric data is used for metric charts. For example \"Last 5m\" means use the most recent 5 minutes of request metric data.",
+  "Select the graph layout for the mesh. Different layouts work best with different meshes. Find the layout that works best. Other buttons here provide zoom and fit-to-screen options.": "Select the graph layout for the mesh. Different layouts work best with different meshes. Find the layout that works best. Other buttons here provide zoom and fit-to-screen options.",
+  "Select the layout for the mesh. Different layouts work best with different meshes. Find the layout that works best. Other buttons here provide zoom and fit-to-screen options.": "Select the layout for the mesh. Different layouts work best with different meshes. Find the layout that works best. Other buttons here provide zoom and fit-to-screen options.",
+  "Select the namespaces you want to see in the graph.": "Select the namespaces you want to see in the graph.",
+  "Select the time range of shown data, and the refresh interval.": "Select the time range of shown data, and the refresh interval.",
+  "Select zoom area": "Select zoom area",
+  "Service": "Servicio",
+  "Service Entry": "Service Entry",
+  "Services": "Servicios",
+  "Set display options for the mesh.": "Set display options for the mesh.",
+  "Set edge labeling, node badging, and various display options. Response-time edge labeling, security badging, and traffic animation may affect performance. Response-times reflect the 95th percentile.": "Set edge labeling, node badging, and various display options. Response-time edge labeling, security badging, and traffic animation may affect performance. Response-times reflect the 95th percentile.",
+  "Shift + Drag": "Shift + Drag",
+  "Shortcuts": "Shortcuts",
+  "Shortcuts and tips...": "Shortcuts and tips...",
+  "Show": "Mostrar",
+  "Side Panel": "Side Panel",
+  "Single click": "Single click",
+  "Something went wrong": "Something went wrong",
+  "Sorry, there was a problem. Try a refresh or navigate to a different page.": "Sorry, there was a problem. Try a refresh or navigate to a different page.",
+  "Subset": "Subset",
+  "Switch language": "Cambiar idioma",
+  "Switch to {{theme}} Mode": "Cambiar a modo {{theme}}",
+  "System Error": "System Error",
+  "TCP Connection": "TCP Connection",
+  "TCP Traffic": "TCP Traffic",
+  "The meshConfig.meshMTLS.minProtocolVersion field specifies the minimum TLS version for the TLS                   connections among Istio workloads. N/A if it was not set.": "The meshConfig.meshMTLS.minProtocolVersion field specifies the minimum TLS version for the TLS                   connections among Istio workloads. N/A if it was not set.",
+  "The Side Panel shows details about the currently selected node or edge, otherwise the whole graph.": "The Side Panel shows details about the currently selected node or edge, otherwise the whole graph.",
+  "The Side Panel shows details about the currently selected node or edge, otherwise the whole mesh.": "The Side Panel shows details about the currently selected node or edge, otherwise the whole mesh.",
+  "This chart shows cpu consumption for the istiod {{cpuMetricSource}}": "This chart shows cpu consumption for the istiod {{cpuMetricSource}}",
+  "This chart shows memory consumption for the istiod {{memoryMetricSource}}": "This chart shows memory consumption for the istiod {{memoryMetricSource}}",
+  "This value represents the delay in seconds between config change and a proxy receiving all required                 configuration.": "This value represents the delay in seconds between config change and a proxy receiving all required                 configuration.",
+  "This value represents the meshConfig.outboundTrafficPolicy.mode, that configures the sidecar handling of                 external services, that is, those services that are not defined in Istio’s internal service registry. If                 this option is set to ALLOW_ANY, the Istio proxy lets calls to unknown services pass through. If the                 option is set to REGISTRY_ONLY, then the Istio proxy blocks any host without an HTTP service or service                 entry defined within the mesh": "This value represents the meshConfig.outboundTrafficPolicy.mode, that configures the sidecar handling of                 external services, that is, those services that are not defined in Istio’s internal service registry. If                 this option is set to ALLOW_ANY, the Istio proxy lets calls to unknown services pass through. If the                 option is set to REGISTRY_ONLY, then the Istio proxy blocks any host without an HTTP service or service                 entry defined within the mesh",
+  "Time duration": "Time duration",
+  "Time range": "Time range",
+  "Time Range": "Time Range",
+  "Time Range & Replay": "Time Range & Replay",
+  "Traffic": "Tráfico",
+  "Traffic Animation": "Traffic Animation",
+  "Traffic Graph": "Tráfico",
+  "Traffic Graph [Cy]": "Tráfico [Cy]",
+  "Traffic Graph [PF]": "Tráfico [PF]",
+  "Traffic metrics per frame": "Traffic metrics per frame",
+  "Traffic metrics per refresh": "Traffic metrics per refresh",
+  "Traffic routing already exists for this service": "Traffic routing already exists for this service",
+  "Traffic routing doesn't exist for this service": "Traffic routing doesn't exist for this service",
+  "Traffic Shifting / TCP Traffic Shifting": "Traffic Shifting / TCP Traffic Shifting",
+  "Traffic Source": "Traffic Source",
+  "Traffic Status": "Traffic Status",
+  "Traffic Status (Last {{duration}})": "Traffic Status (Last {{duration}})",
+  "Unhealthy": "Unhealthy",
+  "unknown": "unknown",
+  "Unreachable": "Unreachable",
+  "Unselected Namespace": "Unselected Namespace",
+  "Update": "Actualizar",
+  "User does not have enough permission for this action.": "User does not have enough permission for this action.",
+  "User does not have permission": "User does not have permission",
+  "Valid From:": "Valido desde:",
+  "Valid To:": "Valid hasta:",
+  "Value": "Valor",
+  "Version: {{version}}": "Versión: {{version}}",
+  "View in Tracing": "View in Tracing",
+  "View on Graph": "View on Graph",
+  "Virtual Service / Request Routing": "Virtual Service / Request Routing",
+  "Visit the Mesh page": "Ver la malla de servicios",
+  "Warn": "Warn",
+  "Workload": "Carga de trabajo",
+  "Workload Entry": "Workload Entry",
+  "Workloads": "Cargas de trabajo",
+  "Zoom": "Zoom"
+}
diff --git a/frontend/src/actions/GraphActions.ts b/frontend/src/actions/GraphActions.ts
index fb018ab58..c639b87e9 100644
--- a/frontend/src/actions/GraphActions.ts
+++ b/frontend/src/actions/GraphActions.ts
@@ -1,8 +1,7 @@
 import { ActionType, createAction, createStandardAction } from 'typesafe-actions';
-import { GraphEvent, EdgeMode, GraphDefinition, NodeParamsType, RankResult } from '../types/Graph';
+import { GraphEvent, EdgeMode, GraphDefinition, NodeParamsType, RankResult, GraphLayout } from '../types/Graph';
 import { ActionKeys } from './ActionKeys';
 import { TimeInMilliseconds } from 'types/Common';
-import { GraphLayout } from 'pages/Graph/Graph';
 
 export const GraphActions = {
   onNamespaceChange: createAction(ActionKeys.GRAPH_ON_NAMESPACE_CHANGE),
diff --git a/frontend/src/components/DetailDescription/DetailDescription.tsx b/frontend/src/components/DetailDescription/DetailDescription.tsx
index df43c6026..c38a7df21 100644
--- a/frontend/src/components/DetailDescription/DetailDescription.tsx
+++ b/frontend/src/components/DetailDescription/DetailDescription.tsx
@@ -396,8 +396,8 @@ const DetailDescriptionComponent: React.FC<Props> = (props: Props) => {
       {props.apps !== undefined && appList()}
       {props.workloads !== undefined && workloadSummary()}
       {props.services !== undefined && serviceList()}
-      {props.health && renderTrafficStatus(props.health)}
       {props.waypointWorkloads && renderWaypoints()}
+      {props.health && renderTrafficStatus(props.health)}
     </div>
   );
 };
diff --git a/frontend/src/components/Nav/Masthead/LanguageSwitch.tsx b/frontend/src/components/Nav/Masthead/LanguageSwitch.tsx
index b0dddf1cb..03b606c2f 100644
--- a/frontend/src/components/Nav/Masthead/LanguageSwitch.tsx
+++ b/frontend/src/components/Nav/Masthead/LanguageSwitch.tsx
@@ -34,12 +34,17 @@ export const LanguageSwitchComponent: React.FC<LanguageSwitchProps> = ({ languag
   const { t } = useKialiTranslation();
 
   const languageItems: React.ReactNode[] = [
-    <DropdownItem key="English" onClick={() => switchLanguage(Language.ENGLISH)}>
+    <DropdownItem key="english" onClick={() => switchLanguage(Language.ENGLISH)}>
       <span>English</span>
       {language === Language.ENGLISH && <KialiIcon.Check className={checkStyle} />}
     </DropdownItem>,
 
-    <DropdownItem key="Chinese" onClick={() => switchLanguage(Language.CHINESE)}>
+    <DropdownItem key="spanish" onClick={() => switchLanguage(Language.SPANISH)}>
+      <span>Español</span>
+      {language === Language.SPANISH && <KialiIcon.Check className={checkStyle} />}
+    </DropdownItem>,
+
+    <DropdownItem key="chinese" onClick={() => switchLanguage(Language.CHINESE)}>
       <span>中文</span>
       {language === Language.CHINESE && <KialiIcon.Check className={checkStyle} />}
     </DropdownItem>
diff --git a/frontend/src/components/Nav/NavUtils.tsx b/frontend/src/components/Nav/NavUtils.tsx
index d266df705..428179479 100644
--- a/frontend/src/components/Nav/NavUtils.tsx
+++ b/frontend/src/components/Nav/NavUtils.tsx
@@ -1,10 +1,17 @@
-import { EdgeLabelMode, NodeType, NodeParamsType, GraphType, TrafficRate, EdgeMode } from '../../types/Graph';
+import {
+  EdgeLabelMode,
+  NodeType,
+  NodeParamsType,
+  GraphType,
+  TrafficRate,
+  EdgeMode,
+  GraphLayout
+} from '../../types/Graph';
 import { DurationInSeconds, IntervalInMilliseconds } from '../../types/Common';
 import { Namespace } from '../../types/Namespace';
 import { URLParam } from '../../app/History';
 import { getKioskMode, isKioskMode } from '../../utils/SearchParamUtils';
 import { isMultiCluster } from 'config';
-import { GraphLayout } from 'pages/Graph/Graph';
 
 export type GraphUrlParams = {
   activeNamespaces: Namespace[];
diff --git a/frontend/src/components/Nav/Navigation.tsx b/frontend/src/components/Nav/Navigation.tsx
index 3d6423600..10ab87a78 100644
--- a/frontend/src/components/Nav/Navigation.tsx
+++ b/frontend/src/components/Nav/Navigation.tsx
@@ -63,7 +63,7 @@ export const NavigationComponent: React.FC<NavigationProps> = (props: Navigation
   }, []);
 
   const isGraph = (): boolean => {
-    return pathname.startsWith('/graph') || pathname.startsWith('/graph') || pathname.startsWith('/mesh');
+    return pathname.startsWith('/graph') || pathname.startsWith('/mesh');
   };
 
   const onNavToggleDesktop = (): void => {
diff --git a/frontend/src/pages/Graph/Graph.tsx b/frontend/src/pages/Graph/Graph.tsx
index caec24d50..e52d65d6b 100644
--- a/frontend/src/pages/Graph/Graph.tsx
+++ b/frontend/src/pages/Graph/Graph.tsx
@@ -32,7 +32,10 @@ import {
   BoxByType,
   EdgeLabelMode,
   EdgeMode,
+  FocusNode,
   GraphEvent,
+  GraphLayout,
+  LayoutType,
   NodeAttr,
   NodeType,
   RankMode,
@@ -60,7 +63,7 @@ import { TimeInMilliseconds } from 'types/Common';
 import { HistoryManager, URLParam } from 'app/History';
 import { TourStop } from 'components/Tour/TourStop';
 import { GraphTourStops } from 'pages/Graph/GraphHelpTour';
-import { supportsGroups } from 'utils/GraphUtils';
+import { getValidGraphLayout, supportsGroups } from 'utils/GraphUtils';
 import { GraphData, GraphRefs } from './GraphPage';
 import { WizardAction, WizardMode } from 'components/IstioWizards/WizardActions';
 import { ServiceDetailsInfo } from 'types/ServiceInfo';
@@ -74,39 +77,11 @@ import { GraphHighlighter } from './GraphHighlighter';
 const DEFAULT_NODE_SIZE = 40;
 const ZOOM_IN = 4 / 3;
 const ZOOM_OUT = 3 / 4;
-
-export const FIT_PADDING = 90;
-
-export enum LayoutType {
-  Layout = 'layout',
-  LayoutNoFit = 'layoutNoFit',
-  Resize = 'resize'
-}
+const FIT_PADDING = 90;
 
 let initialLayout = false;
 let layoutInProgress: LayoutType | undefined;
 
-// the layouts offered by the traffic graph page
-export enum GraphLayout {
-  BreadthFirst = 'breadthfirst',
-  Concentric = 'concentric',
-  Dagre = 'dagre',
-  Grid = 'grid'
-}
-
-export function getValidGraphLayout(layout: string): GraphLayout {
-  switch (layout) {
-    case GraphLayout.BreadthFirst:
-      return GraphLayout.BreadthFirst;
-    case GraphLayout.Concentric:
-      return GraphLayout.Concentric;
-    case GraphLayout.Grid:
-      return GraphLayout.Grid;
-    default:
-      return GraphLayout.Dagre;
-  }
-}
-
 export function graphLayout(controller: Controller, layoutType: LayoutType, reset = true): void {
   if (!controller?.hasGraph()) {
     console.debug('TG: Skip graphLayout, no graph');
@@ -128,11 +103,6 @@ export function graphLayout(controller: Controller, layoutType: LayoutType, rese
   controller.getGraph().layout();
 }
 
-export interface FocusNode {
-  id: string;
-  isSelected?: boolean;
-}
-
 // The is the main graph rendering component
 const TopologyContent: React.FC<{
   controller: Controller;
diff --git a/frontend/src/pages/Graph/GraphElems.tsx b/frontend/src/pages/Graph/GraphElems.tsx
index 29841f5b2..a9966a5eb 100644
--- a/frontend/src/pages/Graph/GraphElems.tsx
+++ b/frontend/src/pages/Graph/GraphElems.tsx
@@ -20,6 +20,7 @@ import {
   DecoratedGraphEdgeWrapper,
   DecoratedGraphNodeData,
   EdgeLabelMode,
+  GraphLayout,
   GraphType,
   NodeType,
   numLabels,
@@ -35,7 +36,6 @@ import { getEdgeHealth } from 'types/ErrorRate/GraphEdgeStatus';
 import { Span } from 'types/TracingInfo';
 import { IconType } from 'config/Icons';
 import { NodeDecorator } from './NodeDecorator';
-import { GraphLayout } from './Graph';
 import { supportsGroups } from 'utils/GraphUtils';
 import { kialiStyle } from 'styles/StyleUtils';
 import { TrafficPointGenerator } from '../Graph/TrafficAnimation/TrafficRenderer';
diff --git a/frontend/src/pages/Graph/GraphPage.tsx b/frontend/src/pages/Graph/GraphPage.tsx
index dec2872c5..896e15148 100644
--- a/frontend/src/pages/Graph/GraphPage.tsx
+++ b/frontend/src/pages/Graph/GraphPage.tsx
@@ -18,7 +18,9 @@ import {
   TrafficRate,
   RankMode,
   RankResult,
-  EdgeMode
+  EdgeMode,
+  GraphLayout,
+  FocusNode
 } from '../../types/Graph';
 import { computePrometheusRateParams } from '../../services/Prometheus';
 import * as AlertUtils from '../../utils/AlertUtils';
@@ -67,11 +69,12 @@ import { deleteServiceTrafficRouting } from 'services/Api';
 import { canCreate, canUpdate } from '../../types/Permissions';
 import { connectRefresh } from '../../components/Refresh/connectRefresh';
 import { triggerRefresh } from '../../hooks/refresh';
-import { Graph, FocusNode, getValidGraphLayout, GraphLayout } from './Graph';
+import { Graph } from './Graph';
 import { Controller } from '@patternfly/react-topology';
 import { GraphLegend } from './GraphLegend';
 import { HistoryManager, URLParam } from 'app/History';
 import { elementsChanged } from 'helpers/GraphHelpers';
+import { getValidGraphLayout } from 'utils/GraphUtils';
 
 // GraphURLPathProps holds path variable values.  Currently all path variables are relevant only to a node graph
 export type GraphURLPathProps = {
diff --git a/frontend/src/pages/Graph/GraphToolbar/GraphFind.tsx b/frontend/src/pages/Graph/GraphToolbar/GraphFind.tsx
index 94ff789ef..d2150e4dd 100644
--- a/frontend/src/pages/Graph/GraphToolbar/GraphFind.tsx
+++ b/frontend/src/pages/Graph/GraphToolbar/GraphFind.tsx
@@ -19,7 +19,7 @@ import { KialiAppState } from '../../../store/Store';
 import { findValueSelector, hideValueSelector, edgeLabelsSelector, edgeModeSelector } from '../../../store/Selectors';
 import { GraphToolbarActions } from '../../../actions/GraphToolbarActions';
 import { GraphHelpFind } from '../../../pages/Graph/GraphHelpFind';
-import { EdgeLabelMode, NodeType, EdgeMode, NodeAttr, EdgeAttr } from '../../../types/Graph';
+import { EdgeLabelMode, NodeType, EdgeMode, NodeAttr, EdgeAttr, LayoutType } from '../../../types/Graph';
 import * as AlertUtils from '../../../utils/AlertUtils';
 import { KialiIcon } from 'config/KialiIcon';
 import { kialiStyle } from 'styles/StyleUtils';
@@ -35,7 +35,7 @@ import { EdgeData, NodeData } from 'pages/Graph/GraphElems';
 import { elems, SelectAnd, SelectExp, selectOr, SelectOr, setObserved, toSafeFieldName } from 'helpers/GraphHelpers';
 import { descendents } from 'helpers/GraphHelpers';
 import { isArray } from 'lodash';
-import { graphLayout, LayoutType } from 'pages/Graph/Graph';
+import { graphLayout } from 'pages/Graph/Graph';
 import { infoStyle } from 'styles/IconStyle';
 
 type ReduxStateProps = {
diff --git a/frontend/src/pages/Graph/GraphToolbar/GraphToolbar.tsx b/frontend/src/pages/Graph/GraphToolbar/GraphToolbar.tsx
index f8666af60..2cd8051a2 100644
--- a/frontend/src/pages/Graph/GraphToolbar/GraphToolbar.tsx
+++ b/frontend/src/pages/Graph/GraphToolbar/GraphToolbar.tsx
@@ -67,7 +67,6 @@ type ReduxProps = ReduxStateProps & ReduxDispatchProps;
 
 type GraphToolbarProps = ReduxProps & {
   controller?: any;
-  cy?: any;
   disabled: boolean;
   elementsChanged: boolean;
   onToggleHelp: () => void;
diff --git a/frontend/src/pages/Graph/MiniGraphCard.tsx b/frontend/src/pages/Graph/MiniGraphCard.tsx
index 8b9cdcebb..49d3cfd34 100644
--- a/frontend/src/pages/Graph/MiniGraphCard.tsx
+++ b/frontend/src/pages/Graph/MiniGraphCard.tsx
@@ -15,13 +15,13 @@ import {
 import { Edge, EdgeModel, Node, NodeModel } from '@patternfly/react-topology';
 import { URLParam, location, router } from '../../app/History';
 import { GraphDataSource } from '../../services/GraphDataSource';
-import { DecoratedGraphElements, EdgeMode, GraphEvent, GraphType, NodeType } from '../../types/Graph';
+import { DecoratedGraphElements, EdgeMode, GraphEvent, GraphLayout, GraphType, NodeType } from '../../types/Graph';
 import { GraphUrlParams, makeNodeGraphUrlFromParams } from 'components/Nav/NavUtils';
 import { store } from 'store/ConfigStore';
 import { TimeInMilliseconds } from '../../types/Common';
 import { ServiceDetailsInfo } from '../../types/ServiceInfo';
 import { KialiAppState } from '../../store/Store';
-import { GraphLayout, Graph } from './Graph';
+import { Graph } from './Graph';
 import { WizardAction, WizardMode } from 'components/IstioWizards/WizardActions';
 import { isKiosk, isParentKiosk, kioskContextMenuAction } from 'components/Kiosk/KioskActions';
 import { ServiceWizardActionsDropdownGroup } from 'components/IstioWizards/ServiceWizardActionsDropdownGroup';
diff --git a/frontend/src/pages/Graph/SummaryPanel.tsx b/frontend/src/pages/Graph/SummaryPanel.tsx
index 3667c639a..c1dd5020b 100644
--- a/frontend/src/pages/Graph/SummaryPanel.tsx
+++ b/frontend/src/pages/Graph/SummaryPanel.tsx
@@ -4,7 +4,7 @@ import { kialiStyle } from 'styles/StyleUtils';
 import { SummaryPanelEdge } from './SummaryPanelEdge';
 import { SummaryPanelGraph } from './SummaryPanelGraph';
 import { SummaryPanelAppBox } from './SummaryPanelAppBox';
-import { SummaryPanelPropType, BoxByType, SummaryData, NodeAttr } from '../../types/Graph';
+import { SummaryPanelPropType, BoxByType, SummaryData, NodeAttr, FocusNode } from '../../types/Graph';
 import { KialiIcon } from 'config/KialiIcon';
 import { SummaryPanelNode } from './SummaryPanelNode';
 import { TracingState } from 'reducers/TracingState';
@@ -18,7 +18,6 @@ import { summaryPanelWidth } from './SummaryPanelCommon';
 import { WizardAction, WizardMode } from 'components/IstioWizards/WizardActions';
 import { ServiceDetailsInfo } from '../../types/ServiceInfo';
 import { PeerAuthentication } from '../../types/IstioObjects';
-import { FocusNode } from 'pages/Graph/Graph';
 import { classes } from 'typestyle';
 import { panelBodyStyle, panelStyle } from './SummaryPanelStyle';
 import { PFColors } from 'components/Pf/PfColors';
diff --git a/frontend/src/pages/Graph/SummaryPanelTraceDetails.tsx b/frontend/src/pages/Graph/SummaryPanelTraceDetails.tsx
index 522570932..2acb919c2 100644
--- a/frontend/src/pages/Graph/SummaryPanelTraceDetails.tsx
+++ b/frontend/src/pages/Graph/SummaryPanelTraceDetails.tsx
@@ -14,14 +14,13 @@ import { findChildren, findParent, formatDuration } from 'utils/tracing/TracingH
 import { FormattedTraceInfo, shortIDStyle } from 'components/TracingIntegration/TracingResults/FormattedTraceInfo';
 import { SimpleSelect } from 'components/Select/SimpleSelect';
 import { summaryFont, summaryTitle } from './SummaryPanelCommon';
-import { NodeParamsType, GraphType, SummaryData, NodeAttr } from 'types/Graph';
+import { NodeParamsType, GraphType, SummaryData, NodeAttr, FocusNode } from 'types/Graph';
 import { KialiDispatch } from 'types/Redux';
 import { bindActionCreators } from 'redux';
 import { responseFlags } from 'utils/ResponseFlags';
 import { isParentKiosk, kioskContextMenuAction } from '../../components/Kiosk/KioskActions';
 import { Visualization, Node } from '@patternfly/react-topology';
 import { elems, selectAnd } from 'helpers/GraphHelpers';
-import { FocusNode } from 'pages/Graph/Graph';
 import { ExternalServiceInfo } from '../../types/StatusState';
 import { isMultiCluster } from '../../config';
 import { KialiIcon } from 'config/KialiIcon';
diff --git a/frontend/src/pages/Graph/layouts/layoutFactory.ts b/frontend/src/pages/Graph/layouts/layoutFactory.ts
index f76a128b9..9d505eb8a 100644
--- a/frontend/src/pages/Graph/layouts/layoutFactory.ts
+++ b/frontend/src/pages/Graph/layouts/layoutFactory.ts
@@ -6,8 +6,8 @@ import {
   LayoutFactory,
   BreadthFirstLayout
 } from '@patternfly/react-topology';
-import { GraphLayout } from '../Graph';
 import { ExtendedDagreLayout } from './ExtendedDagreLayout';
+import { GraphLayout } from 'types/Graph';
 
 /*
 This is just for reference, a copy of PFT defaults, so we can compare any tweaks we've made below...
diff --git a/frontend/src/pages/Mesh/Mesh.tsx b/frontend/src/pages/Mesh/Mesh.tsx
index 3b9a041ba..c4cb208f3 100644
--- a/frontend/src/pages/Mesh/Mesh.tsx
+++ b/frontend/src/pages/Mesh/Mesh.tsx
@@ -56,13 +56,12 @@ const DEFAULT_NODE_SIZE = 100;
 const NAMESPACE_NODE_SIZE = 70;
 const ZOOM_IN = 4 / 3;
 const ZOOM_OUT = 3 / 4;
-
-export const FIT_PADDING = 90;
+const FIT_PADDING = 90;
 
 let initialLayout = false;
 let layoutInProgress: MeshLayoutType | undefined;
 
-export function layoutMesh(controller: Controller, layoutType: MeshLayoutType, reset: boolean = true): void {
+export function layoutMesh(controller: Controller, layoutType: MeshLayoutType, reset = true): void {
   if (!controller?.hasGraph()) {
     console.debug('Skip meshLayout, no graph');
     return;
diff --git a/frontend/src/pages/Mesh/MeshPage.tsx b/frontend/src/pages/Mesh/MeshPage.tsx
index de080bee8..bca3d11e5 100644
--- a/frontend/src/pages/Mesh/MeshPage.tsx
+++ b/frontend/src/pages/Mesh/MeshPage.tsx
@@ -57,7 +57,6 @@ type ReduxStateProps = {
   refreshInterval: IntervalInMilliseconds;
   showGateways: boolean;
   showLegend: boolean;
-  showOutOfMesh: boolean;
   showWaypoints: boolean;
   target: MeshTarget | null;
 };
@@ -418,7 +417,6 @@ const mapStateToProps = (state: KialiAppState): ReduxStateProps => ({
   refreshInterval: refreshIntervalSelector(state),
   showGateways: state.mesh.toolbarState.showGateways,
   showLegend: state.mesh.toolbarState.showLegend,
-  showOutOfMesh: state.graph.toolbarState.showOutOfMesh,
   showWaypoints: state.mesh.toolbarState.showWaypoints,
   target: state.mesh.target
 });
diff --git a/frontend/src/reducers/GraphDataState.ts b/frontend/src/reducers/GraphDataState.ts
index cac1b72fa..9a830a84e 100644
--- a/frontend/src/reducers/GraphDataState.ts
+++ b/frontend/src/reducers/GraphDataState.ts
@@ -2,10 +2,9 @@ import { getType } from 'typesafe-actions';
 import { GraphActions } from '../actions/GraphActions';
 import { KialiAppAction } from '../actions/KialiAppAction';
 import { GraphState } from '../store/Store';
-import { EdgeMode, GraphType, TrafficRate } from '../types/Graph';
+import { EdgeMode, GraphLayout, GraphType, TrafficRate } from '../types/Graph';
 import { GraphToolbarActions } from '../actions/GraphToolbarActions';
 import { updateState } from '../utils/Reducer';
-import { GraphLayout } from 'pages/Graph/Graph';
 
 export const INITIAL_GRAPH_STATE: GraphState = {
   edgeMode: EdgeMode.ALL,
diff --git a/frontend/src/services/Api.ts b/frontend/src/services/Api.ts
index 7d46f35b5..2e523e00d 100644
--- a/frontend/src/services/Api.ts
+++ b/frontend/src/services/Api.ts
@@ -739,9 +739,7 @@ export const getClustersAppHealth = async (
           Object.keys(namespaceAppHealth[ns]).forEach(k => {
             if (namespaceAppHealth[ns][k]) {
               const ah = AppHealth.fromJson(namespaces, k, namespaceAppHealth[ns][k], {
-                rateInterval: duration,
-                hasSidecar: true,
-                hasAmbient: false
+                rateInterval: duration
               });
 
               ret[ns][k] = ah;
@@ -790,9 +788,7 @@ export const getClustersServiceHealth = async (
           Object.keys(namespaceServiceHealth[ns]).forEach(k => {
             if (namespaceServiceHealth[ns][k]) {
               const sh = ServiceHealth.fromJson(namespaces, k, namespaceServiceHealth[ns][k], {
-                rateInterval: duration,
-                hasSidecar: true,
-                hasAmbient: false
+                rateInterval: duration
               });
 
               ret[ns][k] = sh;
@@ -839,9 +835,7 @@ export const getClustersWorkloadHealth = async (
           Object.keys(namespaceWorkloadHealth[ns]).forEach(k => {
             if (namespaceWorkloadHealth[ns][k]) {
               const wh = WorkloadHealth.fromJson(namespaces, k, namespaceWorkloadHealth[ns][k], {
-                rateInterval: duration,
-                hasSidecar: true,
-                hasAmbient: false
+                rateInterval: duration
               });
 
               ret[ns][k] = wh;
diff --git a/frontend/src/store/Store.ts b/frontend/src/store/Store.ts
index 56b90dec2..920234042 100644
--- a/frontend/src/store/Store.ts
+++ b/frontend/src/store/Store.ts
@@ -12,6 +12,7 @@ import {
   EdgeLabelMode,
   EdgeMode,
   GraphDefinition,
+  GraphLayout,
   GraphType,
   NodeParamsType,
   RankMode,
@@ -27,7 +28,6 @@ import { TracingState } from 'reducers/TracingState';
 import { MetricsStatsState } from 'reducers/MetricsStatsState';
 import { CertsInfo } from 'types/CertsInfo';
 import { MeshCluster, MeshDefinition, MeshTarget } from '../types/Mesh';
-import { GraphLayout } from 'pages/Graph/Graph';
 import { MeshLayout } from 'pages/Mesh/layouts/layoutFactory';
 
 // Store is the Redux Data store
diff --git a/frontend/src/types/Common.ts b/frontend/src/types/Common.ts
index 3dcf6d558..db9ec6cec 100644
--- a/frontend/src/types/Common.ts
+++ b/frontend/src/types/Common.ts
@@ -26,7 +26,8 @@ export const enum Theme {
 
 export const enum Language {
   CHINESE = 'zh',
-  ENGLISH = 'en'
+  ENGLISH = 'en',
+  SPANISH = 'es'
 }
 
 export type TargetKind = 'app' | 'service' | 'workload';
diff --git a/frontend/src/types/Graph.ts b/frontend/src/types/Graph.ts
index f1c8d34ce..49e2e5473 100644
--- a/frontend/src/types/Graph.ts
+++ b/frontend/src/types/Graph.ts
@@ -196,6 +196,20 @@ export const toTcpRate = (rate: string): TrafficRate | undefined => {
   }
 };
 
+export enum LayoutType {
+  Layout = 'layout',
+  LayoutNoFit = 'layoutNoFit',
+  Resize = 'resize'
+}
+
+// the layouts offered by the traffic graph page
+export enum GraphLayout {
+  BreadthFirst = 'breadthfirst',
+  Concentric = 'concentric',
+  Dagre = 'dagre',
+  Grid = 'grid'
+}
+
 export enum GraphType {
   APP = 'app',
   SERVICE = 'service',
@@ -234,6 +248,11 @@ export interface NodeParamsType {
   workload: string;
 }
 
+export interface FocusNode {
+  id: string;
+  isSelected?: boolean;
+}
+
 export interface GraphEvent {
   summaryTarget: GraphElement | Controller; // the target of the event
   summaryType: SummaryType; // what the summary panel should show
diff --git a/frontend/src/types/Health.ts b/frontend/src/types/Health.ts
index 8f88f76b4..e1da651f7 100644
--- a/frontend/src/types/Health.ts
+++ b/frontend/src/types/Health.ts
@@ -310,6 +310,15 @@ export const getRequestErrorsSubItem = (thresholdStatus: ThresholdStatus, prefix
   };
 };
 
+export const isRequestHealthNotEmpty = (requests: RequestHealth): boolean => {
+  return (
+    requests &&
+    (Object.keys(requests.inbound).length > 0 ||
+      Object.keys(requests.outbound).length > 0 ||
+      Object.keys(requests.healthAnnotations).length > 0)
+  );
+};
+
 export abstract class Health {
   constructor(public health: HealthConfig) {}
 
@@ -359,8 +368,8 @@ export abstract class Health {
 }
 
 interface HealthContext {
-  hasAmbient: boolean;
-  hasSidecar: boolean;
+  hasAmbient?: boolean;
+  hasSidecar?: boolean;
   rateInterval: number;
 }
 
@@ -372,7 +381,8 @@ export class ServiceHealth extends Health {
     const items: HealthItem[] = [];
     let statusConfig: HealthItemConfig | undefined = undefined;
 
-    if (ctx.hasSidecar) {
+    // hasAmbient and hasSidecars can be false, but still health requests have info
+    if (ctx.hasSidecar || ctx.hasAmbient || isRequestHealthNotEmpty(requests)) {
       // Request errors
       const reqError = calculateErrorRate(ns, srv, 'service', requests);
       const reqErrorsText =
@@ -406,7 +416,7 @@ export class ServiceHealth extends Health {
         type: HealthItemType.TRAFFIC_STATUS,
         title: t('Traffic Status'),
         status: NA,
-        text: 'No Istio sidecar'
+        text: NA.name
       });
     }
     return { items, statusConfig };
@@ -459,7 +469,8 @@ export class AppHealth extends Health {
     }
 
     // Request errors
-    if (ctx.hasSidecar) {
+    // hasAmbient and hasSidecars can be false, but still health requests have info
+    if (ctx.hasSidecar || ctx.hasAmbient || isRequestHealthNotEmpty(requests)) {
       const reqError = calculateErrorRate(ns, app, 'app', requests);
       const reqIn = reqError.errorRatio.inbound.status;
       const reqOut = reqError.errorRatio.outbound.status;
@@ -567,7 +578,8 @@ export class WorkloadHealth extends Health {
     }
 
     // Request errors
-    if (ctx.hasSidecar) {
+    // hasAmbient and hasSidecars can be false, but still health requests have info
+    if (ctx.hasSidecar || ctx.hasAmbient || isRequestHealthNotEmpty(requests)) {
       const reqError = calculateErrorRate(ns, workload, 'workload', requests);
       const reqIn = reqError.errorRatio.inbound.status;
       const reqOut = reqError.errorRatio.outbound.status;
diff --git a/frontend/src/utils/GraphUtils.ts b/frontend/src/utils/GraphUtils.ts
index 47b843cfc..cdab717a0 100644
--- a/frontend/src/utils/GraphUtils.ts
+++ b/frontend/src/utils/GraphUtils.ts
@@ -1,6 +1,19 @@
-import { GraphLayout } from 'pages/Graph/Graph';
+import { GraphLayout } from 'types/Graph';
 
 // check if the graph layout supports grouping
 export const supportsGroups = (layoutName: GraphLayout): boolean => {
   return layoutName === GraphLayout.Dagre;
 };
+
+export function getValidGraphLayout(layout: string): GraphLayout {
+  switch (layout) {
+    case GraphLayout.BreadthFirst:
+      return GraphLayout.BreadthFirst;
+    case GraphLayout.Concentric:
+      return GraphLayout.Concentric;
+    case GraphLayout.Grid:
+      return GraphLayout.Grid;
+    default:
+      return GraphLayout.Dagre;
+  }
+}
diff --git a/graph/api/api.go b/graph/api/api.go
index beea01ace..f536468f4 100644
--- a/graph/api/api.go
+++ b/graph/api/api.go
@@ -48,7 +48,7 @@ func graphNamespacesIstio(ctx context.Context, business *business.Layer, prom *p
 	// Create a 'global' object to store the business. Global only to the request.
 	globalInfo := graph.NewGlobalInfo(ctx, business, prom, config.Get())
 
-	trafficMap := istio.BuildNamespacesTrafficMap(ctx, o.TelemetryOptions, globalInfo)
+	trafficMap := istio.BuildNamespacesTrafficMap(o.TelemetryOptions, globalInfo)
 	code, graphConfig = generateGraph(trafficMap, o)
 
 	return code, graphConfig
diff --git a/graph/telemetry/istio/istio.go b/graph/telemetry/istio/istio.go
index 5c4b01f04..c478727fd 100644
--- a/graph/telemetry/istio/istio.go
+++ b/graph/telemetry/istio/istio.go
@@ -40,7 +40,6 @@ import (
 	"github.com/kiali/kiali/graph/telemetry/istio/appender"
 	"github.com/kiali/kiali/graph/telemetry/istio/util"
 	"github.com/kiali/kiali/log"
-	"github.com/kiali/kiali/models"
 	"github.com/kiali/kiali/observability"
 	"github.com/kiali/kiali/prometheus/internalmetrics"
 	"github.com/kiali/kiali/util/sliceutil"
@@ -51,12 +50,19 @@ const (
 	tsHashMap graph.MetadataKey = "tsHashMap"
 )
 
+type waypointKey = struct {
+	Cluster   string
+	Namespace string
+	Name      string
+}
+
+type waypointMap = map[waypointKey]bool
+
 var grpcMetric = regexp.MustCompile(`istio_.*_messages`)
 
 // BuildNamespacesTrafficMap is required by the graph/TelemetryVendor interface
-func BuildNamespacesTrafficMap(ctx context.Context, o graph.TelemetryOptions, globalInfo *graph.GlobalInfo) graph.TrafficMap {
-	var end observability.EndFunc
-	ctx, end = observability.StartSpan(ctx, "BuildNamespacesTrafficMap",
+func BuildNamespacesTrafficMap(o graph.TelemetryOptions, globalInfo *graph.GlobalInfo) graph.TrafficMap {
+	ctx, end := observability.StartSpan(globalInfo.Context, "BuildNamespacesTrafficMap",
 		observability.Attribute("package", "istio"),
 	)
 	defer end()
@@ -66,15 +72,11 @@ func BuildNamespacesTrafficMap(ctx context.Context, o graph.TelemetryOptions, gl
 	appenders, finalizers := appender.ParseAppenders(o)
 	trafficMap := graph.NewTrafficMap()
 
-	// To handle ambient telemetry we need to know about the configured waypoints. We typically avoid mixing
-	// config with telemetry because config is current and telemetry can be dated.  But for the moment
-	// it's unavoidable, as we need to do our best to identify source or dest waypoint workloads.  If in
-	// the future we can make that determination via the telem, we should change to that approach.
-	if sliceutil.Some(finalizers, func(f graph.Appender) bool {
+	handleAmbient := sliceutil.Some(finalizers, func(f graph.Appender) bool {
 		return f.Name() == appender.AmbientAppenderName
-	}) {
-		waypoints := globalInfo.Business.Workload.GetWaypoints(ctx)
-		globalInfo.Vendor[appender.AmbientWaypoints] = waypoints
+	})
+	if handleAmbient {
+		globalInfo.Vendor[appender.AmbientWaypoints] = GetWaypointMap(ctx, globalInfo)
 	}
 
 	for _, namespaceInfo := range o.Namespaces {
@@ -325,6 +327,8 @@ func populateTrafficMap(trafficMap graph.TrafficMap, vector *model.Vector, metri
 	}
 	skipRequestsGrpc := isRequests && o.Rates.Grpc != graph.RateRequests
 	skipRequestsHttp := isRequests && o.Rates.Http != graph.RateRequests
+	wpKeySource := &waypointKey{}
+	wpKeyDest := &waypointKey{}
 
 	for _, s := range *vector {
 		val := float64(s.Value)
@@ -413,8 +417,10 @@ func populateTrafficMap(trafficMap graph.TrafficMap, vector *model.Vector, metri
 		// - destSvcName is PassthroughCluster (see https://github.com/kiali/kiali/issues/4488)
 		// - dest node is already a service node
 		// - source or dest workload is an ambient waypoint
-		var inject bool
-		sourceIsWaypoint, destIsWaypoint := hasWaypoint(ztunnel, sourceCluster, sourceWlNs, sourceWl, destCluster, destWlNs, destWl, globalInfo)
+		var inject, sourceIsWaypoint, destIsWaypoint bool
+		if ztunnel {
+			sourceIsWaypoint, destIsWaypoint = hasWaypoint(setWaypointKey(wpKeySource, sourceCluster, sourceWlNs, sourceWl), setWaypointKey(wpKeyDest, destCluster, destWlNs, destWl), globalInfo)
+		}
 		if o.InjectServiceNodes && graph.IsOK(destSvcName) && destSvcName != graph.PassthroughCluster {
 			_, destNodeType, err := graph.Id(destCluster, destSvcNs, destSvcName, destWlNs, destWl, destApp, destVer, o.GraphType)
 			if err != nil {
@@ -557,15 +563,11 @@ func BuildNodeTrafficMap(o graph.TelemetryOptions, globalInfo *graph.GlobalInfo)
 
 	appenders, finalizers := appender.ParseAppenders(o)
 
-	// To handle ambient telemetry we need to know about the configured waypoints. We typically avoid mixing
-	// config with telemetry because config is current and telemetry can be dated.  But for the moment
-	// it's unavoidable, as we need to do our best to identify source or dest waypoint workloads.  If in
-	// the future we can make that determination via the telem, we should change to that approach.
-	if sliceutil.Some(finalizers, func(f graph.Appender) bool {
+	handleAmbient := sliceutil.Some(finalizers, func(f graph.Appender) bool {
 		return f.Name() == appender.AmbientAppenderName
-	}) {
-		waypoints := globalInfo.Business.Workload.GetWaypoints(context.Background())
-		globalInfo.Vendor[appender.AmbientWaypoints] = waypoints
+	})
+	if handleAmbient {
+		globalInfo.Vendor[appender.AmbientWaypoints] = GetWaypointMap(globalInfo.Context, globalInfo)
 	}
 
 	trafficMap := buildNodeTrafficMap(o.Cluster, o.NodeOptions.Namespace, n, o, globalInfo)
@@ -858,11 +860,8 @@ func buildNodeTrafficMap(cluster string, namespaceInfo graph.NamespaceInfo, n *g
 
 		inboundReporter := `reporter="destination"`
 		outboutReporter := `reporter="source"`
-		if waypoints, ok := globalInfo.Vendor[appender.AmbientWaypoints]; ok {
-			isAWaypoint := sliceutil.Some(waypoints.(models.Workloads), func(wp *models.Workload) bool {
-				return isWaypoint(wp, n.Cluster, n.Namespace, n.Workload)
-			})
-			if isAWaypoint {
+		if wpMap, ok := globalInfo.Vendor[appender.AmbientWaypoints]; ok {
+			if isWaypoint(wpMap.(waypointMap), setWaypointKey(nil, n.Cluster, n.Namespace, n.Workload)) {
 				inboundReporter = util.GetReporter("source", o.Rates)
 				outboutReporter = util.GetReporter("destination", o.Rates)
 			}
@@ -1096,30 +1095,49 @@ func promQuery(query string, queryTime time.Time, api prom_v1.API, conf *config.
 	return nil
 }
 
-// hasWaypoint returns true if the source or dest workload is determined to be a waypoint workload.  Note that this logic can
-// go away if and when https://github.com/istio/ztunnel/issues/1128 is implemented, and then we can make this determination
-// directly from the telemetry
-func hasWaypoint(ztunnel bool, sourceCluster, sourceWlNs, srcWl, destCluster, destWlNs, destWl string, globalInfo *graph.GlobalInfo) (sourceIsWaypoint bool, destIsWaypoint bool) {
-	if !ztunnel {
-		return false, false
+// GetWaypointMap returns a waypointMap based on the current workloads. To handle ambient telemetry we
+// need to know about the configured waypoints. We typically avoid mixing config with telemetry because
+// config is current and telemetry can be dated.  But for the moment it's unavoidable, as we need to do
+// our best to identify source or dest waypoint workloads.  If in the future we can make that determination
+// via the telem, we should change to that approach (see https://github.com/istio/ztunnel/issues/1128).
+// Here we build a waypointMap to help quickly identify waypoints in telemetry.
+func GetWaypointMap(ctx context.Context, gi *graph.GlobalInfo) waypointMap {
+	waypoints := gi.Business.Workload.GetWaypoints(ctx)
+	wpMap := make(waypointMap, len(waypoints))
+	var wpKey = waypointKey{} // a re-usable key struct. This works because map keys are always copies
+
+	for _, wp := range waypoints {
+		wpKey.Cluster = wp.Cluster
+		wpKey.Namespace = wp.Namespace
+		wpKey.Name = wp.Name
+		wpMap[wpKey] = true
+		if wp.Cluster != graph.Unknown {
+			wpKey.Cluster = graph.Unknown
+		}
+		wpMap[wpKey] = true
 	}
+	return wpMap
+}
 
-	if waypoints, ok := globalInfo.Vendor[appender.AmbientWaypoints]; ok {
-		sourceIsWaypoint = sliceutil.Some(waypoints.(models.Workloads), func(wp *models.Workload) bool {
-			return isWaypoint(wp, sourceCluster, sourceWlNs, srcWl)
-		})
-		if !sourceIsWaypoint {
-			destIsWaypoint = sliceutil.Some(waypoints.(models.Workloads), func(wp *models.Workload) bool {
-				return isWaypoint(wp, destCluster, destWlNs, destWl)
-			})
-		}
+func setWaypointKey(wpKey *waypointKey, cluster, namespace, name string) *waypointKey {
+	if wpKey == nil {
+		wpKey = &waypointKey{}
 	}
+	wpKey.Cluster = cluster
+	wpKey.Namespace = namespace
+	wpKey.Name = name
+	return wpKey
+}
+
+// hasWaypoint returns true if the source or dest workload is determined to be a waypoint workload.
+func hasWaypoint(wpKeySource, wpKeyDest *waypointKey, globalInfo *graph.GlobalInfo) (sourceIsWaypoint bool, destIsWaypoint bool) {
+	wpMap := globalInfo.Vendor[appender.AmbientWaypoints].(waypointMap)
+	sourceIsWaypoint = wpMap[*wpKeySource]
+	destIsWaypoint = wpMap[*wpKeyDest]
 	return sourceIsWaypoint, destIsWaypoint
 }
 
-// isWaypoint returns true if the ns, name and cluster of a workload matches with one of the waypoints in the list
-// We need the waypoint list
-// NOTE: Skip cluster comparaison if cluster is unknown
-func isWaypoint(w *models.Workload, cluster, namespace, name string) bool {
-	return w.WorkloadListItem.Name == name && w.WorkloadListItem.Namespace == namespace && (cluster == "unknown" || w.Cluster == cluster)
+// isWaypoint returns true if the ns, name and cluster of a workload matches with one of the known waypoints
+func isWaypoint(wpMap waypointMap, wpKey *waypointKey) bool {
+	return wpMap[*wpKey]
 }
diff --git a/graph/telemetry/istio/util/util.go b/graph/telemetry/istio/util/util.go
index 274903112..d3e1c3abf 100644
--- a/graph/telemetry/istio/util/util.go
+++ b/graph/telemetry/istio/util/util.go
@@ -145,7 +145,7 @@ func AddQueryScope(query string, conf *config.Config) string {
 // GetReporter returns the "reporter=" prom query fragment based on whether the reporter must include waypoint traffic
 func GetReporter(reporter string, rates graph.RequestedRates) string {
 	if rates.Ambient == graph.AmbientTrafficWaypoint || rates.Ambient == graph.AmbientTrafficTotal {
-		return fmt.Sprintf(`reporter=~"%s|waypoint"`, reporter)
+		return fmt.Sprintf(`reporter=~"waypoint|%s"`, reporter)
 	}
 	return fmt.Sprintf(`reporter="%s"`, reporter)
 }
diff --git a/hack/README.adoc b/hack/README.adoc
index 703760666..a3728d7ca 100644
--- a/hack/README.adoc
+++ b/hack/README.adoc
@@ -37,7 +37,7 @@ Runs OpenShift on the local machine using link:https://github.com/code-ready/crc
 
 * `link:docker-io-auth.sh[]`
 
-Creates the necessary resources allowing your cluster to authenticate itself with docker.io when pulling Docker Hub images. This helps avoid the annoying docker.io rate limiting errors.
+Creates the necessary resources allowing your cluster to authenticate itself with Docker Hub when pulling Docker Hub images. This helps avoid the annoying Docker Hub rate limiting errors.
 
 * `link:fix_imports.sh[]`
 
diff --git a/hack/crc-openshift.sh b/hack/crc-openshift.sh
index 203611ef7..b8169854f 100755
--- a/hack/crc-openshift.sh
+++ b/hack/crc-openshift.sh
@@ -243,9 +243,9 @@ print_all_service_endpoints() {
 
 exec_ssh() {
   local sshcmd=${1}
-  # when network-mode is "user", the port is 2222.
+  # when network-mode is explicitly set to "user" or it is the default, the port is 2222.
   local port=22
-  if ${CRC_COMMAND} config get network-mode | grep -q ' user$'; then
+  if ${CRC_COMMAND} config get network-mode | grep -Eq " user$|'user'"; then
     port=2222
   fi
   ssh -y -p ${port} -i ${CRC_ROOT_DIR}/machines/crc/id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null core@$(${CRC_COMMAND} ip) ${sshcmd}
@@ -509,10 +509,10 @@ SCRIPT_ROOT="$( cd "$(dirname "$0")" ; pwd -P )"
 cd ${SCRIPT_ROOT}
 
 # The default version of the crc tool to be downloaded
-DEFAULT_CRC_DOWNLOAD_VERSION="2.45.0"
+DEFAULT_CRC_DOWNLOAD_VERSION="2.49.0"
 
 # The default version of the crc bundle - this is typically the version included with the CRC download
-DEFAULT_CRC_LIBVIRT_DOWNLOAD_VERSION="4.17.7"
+DEFAULT_CRC_LIBVIRT_DOWNLOAD_VERSION="4.18.2"
 
 # The default virtual CPUs assigned to the CRC VM
 DEFAULT_CRC_CPUS="6"
diff --git a/hack/istio/bookinfo-traffic/bookinfo-service-entry.yaml b/hack/istio/bookinfo-traffic/bookinfo-service-entry.yaml
index 6536b8dcb..813f2ca5b 100644
--- a/hack/istio/bookinfo-traffic/bookinfo-service-entry.yaml
+++ b/hack/istio/bookinfo-traffic/bookinfo-service-entry.yaml
@@ -10,7 +10,7 @@
 # To create this simulated external ratings:v1 service, you must run a ratings service
 # by executing this command:
 #
-#   $ docker run -t -i --network=host docker.io/istio/examples-bookinfo-ratings-v1
+#   $ docker run -t -i --network=host quay.io/sail-dev/examples-bookinfo-ratings-v1
 #
 # At this point, you have a ratings service running outside of your local cluster bound
 # to your host's IP on port 9080. To test, make sure you get a response (albeit a 404)
diff --git a/hack/istio/install-bookinfo-demo.sh b/hack/istio/install-bookinfo-demo.sh
index 15a26e450..1467f0b0a 100755
--- a/hack/istio/install-bookinfo-demo.sh
+++ b/hack/istio/install-bookinfo-demo.sh
@@ -315,6 +315,7 @@ if [ "${MONGO_ENABLED}" == "true" ]; then
   MONGO_DB_YAML="${ISTIO_DIR}/samples/bookinfo/platform/kube/bookinfo-db.yaml"
   MONGO_SERVICE_YAML="${ISTIO_DIR}/samples/bookinfo/platform/kube/bookinfo-ratings-v2.yaml"
 
+  # Leaving these alone since they involve IBM arch testing. This is moving off docker.io anyway (https://github.com/kiali/kiali/issues/8278)
   if [ "${ARCH}" == "ppc64le" ]; then
     sed  "s;docker.io/istio.*;quay.io/maistra/examples-bookinfo-mongodb:2.0.0-ibm-p;g" ${MONGO_DB_YAML} > ${ISTIO_DIR}/samples/bookinfo/platform/kube/bookinfo-db-ppc64le.yaml
     MONGO_DB_YAML="${ISTIO_DIR}/samples/bookinfo/platform/kube/bookinfo-db-ppc64le.yaml"
@@ -352,6 +353,7 @@ if [ "${MYSQL_ENABLED}" == "true" ]; then
   MYSQL_DB_YAML="${ISTIO_DIR}/samples/bookinfo/platform/kube/bookinfo-mysql.yaml"
   MYSQL_SERVICE_YAML="${ISTIO_DIR}/samples/bookinfo/platform/kube/bookinfo-ratings-v2-mysql.yaml"
 
+  # Leaving these alone since they involve IBM arch testing. This is moving off docker.io anyway (https://github.com/kiali/kiali/issues/8278)
   if [ "${ARCH}" == "ppc64le" ]; then
     sed  "s;docker.io/istio.*;quay.io/maistra/examples-bookinfo-mysqldb:2.0.0-ibm-p;g" ${MYSQL_DB_YAML} > ${ISTIO_DIR}/samples/bookinfo/platform/kube/bookinfo-mysql-ppc64le.yaml
     MYSQL_DB_YAML="${ISTIO_DIR}/samples/bookinfo/platform/kube/bookinfo-mysql-ppc64le.yaml"
diff --git a/hack/istio/install-bookinfo-workload-entry.sh b/hack/istio/install-bookinfo-workload-entry.sh
index 2b4c744a7..309233f12 100755
--- a/hack/istio/install-bookinfo-workload-entry.sh
+++ b/hack/istio/install-bookinfo-workload-entry.sh
@@ -90,7 +90,7 @@ function create_workload_pod {
     inject_sidecar "${POD_NAME}" "${WORKLOAD_NAME}"
     sudo podman run --name ${WORKLOAD_NAME} \
         -d --rm --pod "${POD_NAME}" \
-        docker.io/istio/examples-bookinfo-${WORKLOAD_NAME}:latest
+        quay.io/sail-dev/examples-bookinfo-${WORKLOAD_NAME}:latest
     # Wait for pod to be running to ensure it has been assigned an IP address.
     sudo podman wait "${WORKLOAD_NAME}" --condition "running"
     cat ${TEMPLATE_DIR}/bookinfo-service-entry.yaml | \
diff --git a/hack/istio/install-istio-via-istioctl.sh b/hack/istio/install-istio-via-istioctl.sh
index a43eb3f30..bf7833753 100755
--- a/hack/istio/install-istio-via-istioctl.sh
+++ b/hack/istio/install-istio-via-istioctl.sh
@@ -247,7 +247,7 @@ Valid command line arguments:
   -ih|--image-hub <hub id>
        The hub where the Istio images will be pulled from.
        You can set this to "default" in order to use the default hub that the Istio charts use but
-       this may be using docker.io and docker hub rate limiting may cause the installation to fail.
+       this may be using Docker Hub, and Docker Hub rate limiting may cause the installation to fail.
        Default: gcr.io/istio-release
   -it|--image-tag <tag>
        The tag of the Istio images. Leave this as "default" (which means the default images are pulled)
diff --git a/hack/istio/multicluster/deploy-kiali.sh b/hack/istio/multicluster/deploy-kiali.sh
index ba2770e0e..55deaaf5c 100755
--- a/hack/istio/multicluster/deploy-kiali.sh
+++ b/hack/istio/multicluster/deploy-kiali.sh
@@ -183,7 +183,6 @@ deploy_kiali() {
     --set external_services.grafana.external_url="http://grafana.istio-system:3000"
     --set external_services.grafana.dashboards[0].name="Istio Mesh Dashboard"
     --set external_services.istio.validation_reconcile_interval="5s"
-    --set kiali_feature_flags.ui_defaults.graph.impl="pf"
     --set health_config.rate[0].kind="service"
     --set health_config.rate[0].name="y-server"
     --set health_config.rate[0].namespace="alpha"
@@ -210,7 +209,7 @@ deploy_kiali() {
   # If using openid auth strategy, create the keycloak realm and the kiali user.
 
   if [ "${KIALI_AUTH_STRATEGY}" == "openid" ]; then
-    # Get a token from keycloak to use the admin api
+    echo "## Kiali auth strategy is openid; get a token from keycloak at [${KEYCLOAK_ADDRESS}] to use the admin api"
     TOKEN_KEY=$(curl -k -X POST https://"${KEYCLOAK_ADDRESS}"/realms/master/protocol/openid-connect/token \
                 -d grant_type=password \
                 -d client_id=admin-cli \
diff --git a/hack/istio/multicluster/env.sh b/hack/istio/multicluster/env.sh
index 897fb3e91..754685641 100755
--- a/hack/istio/multicluster/env.sh
+++ b/hack/istio/multicluster/env.sh
@@ -57,7 +57,7 @@ ISTIO_NAMESPACE="${ISTIO_NAMESPACE:-istio-system}"
 # If you want to pull Istio images from a different image repository than what the hack script
 # will tell Istio to pull from, then set that hub name here. If you set this to "default",
 # the images will come from the repo that the Istio distro pulls from.
-# Suffice it to say, this setting is needed to avoid docker.io (and its throttling behavior) for
+# Suffice it to say, this setting is needed to avoid Docker Hub (and its throttling behavior) for
 # Istio production releases but allows you to run with Istio dev builds (set this to "default" for dev builds).
 ISTIO_HUB=""
 
diff --git a/hack/k8s-minikube.sh b/hack/k8s-minikube.sh
index 6a3721af8..ba8708335 100755
--- a/hack/k8s-minikube.sh
+++ b/hack/k8s-minikube.sh
@@ -168,7 +168,7 @@ EOF
 26c26
 <       - image: dexidp/dex:v2.27.0 #or quay.io/dexidp/dex:v2.26.0
 ---
->       - image: docker.io/dexidp/dex:${DEX_VERSION}
+>       - image: ghcr.io/dexidp/dex:${DEX_VERSION}
 41c41
 <         - name: GITHUB_CLIENT_ID
 ---
diff --git a/hack/setup-kind-in-ci.sh b/hack/setup-kind-in-ci.sh
index 11b2a87c2..f3a2454ab 100755
--- a/hack/setup-kind-in-ci.sh
+++ b/hack/setup-kind-in-ci.sh
@@ -244,7 +244,6 @@ setup_kind_singlecluster() {
     --set health_config.rate[0].tolerance[0].code="5xx" \
     --set health_config.rate[0].tolerance[0].degraded=2 \
     --set health_config.rate[0].tolerance[0].failure=100 \
-    --set kiali_feature_flags.ui_defaults.graph.impl="pf" \
     kiali-server \
     "${HELM_CHARTS_DIR}"/_output/charts/kiali-server-*.tgz
 
@@ -336,7 +335,6 @@ setup_kind_tempo() {
     --set health_config.rate[0].tolerance[0].code="5xx" \
     --set health_config.rate[0].tolerance[0].degraded=2 \
     --set health_config.rate[0].tolerance[0].failure=100 \
-    --set kiali_feature_flags.ui_defaults.graph.impl="pf" \
     kiali-server \
     "${HELM_CHARTS_DIR}"/_output/charts/kiali-server-*.tgz
   
diff --git a/handlers/istio_config.go b/handlers/istio_config.go
index 7c466c35b..41a41c902 100644
--- a/handlers/istio_config.go
+++ b/handlers/istio_config.go
@@ -421,3 +421,52 @@ func IstioConfigPermissions(
 		RespondWithJSON(w, http.StatusOK, istioConfigPermissions)
 	}
 }
+
+// IstioConfigValidationSummary is the API handler to fetch validations summary to be displayed.
+// It is related to all the Istio Objects within given namespaces
+func IstioConfigValidationSummary(
+	conf *config.Config,
+	kialiCache cache.KialiCache,
+	clientFactory kubernetes.ClientFactory,
+	prom prometheus.ClientInterface,
+	cpm business.ControlPlaneMonitor,
+	traceClientLoader func() tracing.ClientInterface,
+	grafana *grafana.Service,
+	discovery *istio.Discovery,
+) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		params := r.URL.Query()
+		namespaces := params.Get("namespaces") // csl of namespaces
+		nss := []string{}
+		if len(namespaces) > 0 {
+			nss = strings.Split(namespaces, ",")
+		}
+		cluster := clusterNameFromQuery(conf, params)
+
+		business, err := getLayer(r, conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery)
+		if err != nil {
+			RespondWithError(w, http.StatusInternalServerError, "Services initialization error: "+err.Error())
+			return
+		}
+
+		if len(nss) == 0 {
+			loadedNamespaces, _ := business.Namespace.GetClusterNamespaces(r.Context(), cluster)
+			for _, ns := range loadedNamespaces {
+				nss = append(nss, ns.Name)
+			}
+		}
+
+		validationSummaries := []models.IstioValidationSummary{}
+		for _, ns := range nss {
+			istioConfigValidationResults, errValidations := business.Validations.GetValidationsForNamespace(r.Context(), cluster, ns)
+			if errValidations != nil {
+				log.Error(errValidations)
+				RespondWithError(w, http.StatusInternalServerError, errValidations.Error())
+				return
+			}
+			validationSummaries = append(validationSummaries, *istioConfigValidationResults.SummarizeValidation(ns, cluster))
+		}
+
+		RespondWithJSON(w, http.StatusOK, validationSummaries)
+	}
+}
diff --git a/handlers/namespaces.go b/handlers/namespaces.go
index e94013033..5ccfbe473 100644
--- a/handlers/namespaces.go
+++ b/handlers/namespaces.go
@@ -3,71 +3,89 @@ package handlers
 import (
 	"io"
 	"net/http"
-	"strings"
 
 	"github.com/gorilla/mux"
 
+	"github.com/kiali/kiali/business"
 	"github.com/kiali/kiali/config"
+	"github.com/kiali/kiali/grafana"
 	"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"
+	"github.com/kiali/kiali/prometheus"
+	"github.com/kiali/kiali/tracing"
 )
 
-func NamespaceList(w http.ResponseWriter, r *http.Request) {
-	business, err := getBusiness(r)
-	if err != nil {
-		log.Error(err)
-		RespondWithError(w, http.StatusInternalServerError, err.Error())
-		return
-	}
+func NamespaceList(conf *config.Config, kialiCache cache.KialiCache, clientFactory kubernetes.ClientFactory, 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, "Services initialization error: "+err.Error())
+			return
+		}
 
-	namespaces, err := business.Namespace.GetNamespaces(r.Context())
-	if err != nil {
-		log.Error(err)
-		RespondWithError(w, http.StatusInternalServerError, err.Error())
-		return
-	}
+		namespace := business.NewNamespaceService(kialiCache, conf, discovery, clientFactory.GetSAClients(), userClients)
 
-	RespondWithJSON(w, http.StatusOK, namespaces)
-}
+		namespaces, err := namespace.GetNamespaces(r.Context())
+		if err != nil {
+			log.Error(err)
+			RespondWithError(w, http.StatusInternalServerError, err.Error())
+			return
+		}
 
-func NamespaceInfo(w http.ResponseWriter, r *http.Request) {
-	query := r.URL.Query()
-	vars := mux.Vars(r)
-	namespace := vars["namespace"]
-	cluster := clusterNameFromQuery(config.Get(), query)
-
-	business, err := getBusiness(r)
-	if err != nil {
-		log.Error(err)
-		RespondWithError(w, http.StatusInternalServerError, err.Error())
-		return
+		RespondWithJSON(w, http.StatusOK, namespaces)
 	}
+}
 
-	namespaceInfo, err := business.Namespace.GetClusterNamespace(r.Context(), namespace, cluster)
-	if err != nil {
-		log.Error(err)
-		RespondWithError(w, http.StatusInternalServerError, err.Error())
-		return
-	}
+func NamespaceInfo(conf *config.Config, cache cache.KialiCache, clientFactory kubernetes.ClientFactory, discovery istio.MeshDiscovery) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		query := r.URL.Query()
+		vars := mux.Vars(r)
+		namespace := vars["namespace"]
+		cluster := clusterNameFromQuery(conf, query)
+
+		userClients, err := getUserClients(r, clientFactory)
+		if err != nil {
+			RespondWithError(w, http.StatusInternalServerError, err.Error())
+			return
+		}
+
+		namespaceService := business.NewNamespaceService(cache, conf, discovery, clientFactory.GetSAClients(), userClients)
+		namespaceInfo, err := namespaceService.GetClusterNamespace(r.Context(), namespace, cluster)
+		if err != nil {
+			log.Error(err)
+			RespondWithError(w, http.StatusInternalServerError, err.Error())
+			return
+		}
 
-	RespondWithJSON(w, http.StatusOK, namespaceInfo)
+		RespondWithJSON(w, http.StatusOK, namespaceInfo)
+	}
 }
 
 // NamespaceValidationSummary is the API handler to fetch validations summary to be displayed.
 // It is related to all the Istio Objects within the namespace
-func NamespaceValidationSummary(discovery *istio.Discovery) http.HandlerFunc {
+func NamespaceValidationSummary(
+	conf *config.Config,
+	kialiCache cache.KialiCache,
+	clientFactory kubernetes.ClientFactory,
+	prom prometheus.ClientInterface,
+	cpm business.ControlPlaneMonitor,
+	traceClientLoader func() tracing.ClientInterface,
+	grafana *grafana.Service,
+	discovery *istio.Discovery,
+) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		query := r.URL.Query()
 		vars := mux.Vars(r)
 		namespace := vars["namespace"]
 
-		cluster := clusterNameFromQuery(config.Get(), query)
+		cluster := clusterNameFromQuery(conf, query)
 
-		business, err := getBusiness(r)
+		business, err := getLayer(r, conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery)
 		if err != nil {
-			log.Error(err)
-			RespondWithError(w, http.StatusInternalServerError, err.Error())
+			RespondWithError(w, http.StatusInternalServerError, "Services initialization error: "+err.Error())
 			return
 		}
 
@@ -100,68 +118,36 @@ func NamespaceValidationSummary(discovery *istio.Discovery) http.HandlerFunc {
 	}
 }
 
-// ConfigValidationSummary is the API handler to fetch validations summary to be displayed.
-// It is related to all the Istio Objects within given namespaces
-func ConfigValidationSummary(w http.ResponseWriter, r *http.Request) {
-	params := r.URL.Query()
-	namespaces := params.Get("namespaces") // csl of namespaces
-	nss := []string{}
-	if len(namespaces) > 0 {
-		nss = strings.Split(namespaces, ",")
-	}
-	cluster := clusterNameFromQuery(config.Get(), params)
-
-	business, err := getBusiness(r)
-	if err != nil {
-		log.Error(err)
-		RespondWithError(w, http.StatusInternalServerError, err.Error())
-		return
-	}
-
-	if len(nss) == 0 {
-		loadedNamespaces, _ := business.Namespace.GetClusterNamespaces(r.Context(), cluster)
-		for _, ns := range loadedNamespaces {
-			nss = append(nss, ns.Name)
-		}
-	}
-
-	validationSummaries := []models.IstioValidationSummary{}
-	for _, ns := range nss {
-		istioConfigValidationResults, errValidations := business.Validations.GetValidationsForNamespace(r.Context(), cluster, ns)
-		if errValidations != nil {
-			log.Error(errValidations)
-			RespondWithError(w, http.StatusInternalServerError, errValidations.Error())
+// NamespaceUpdate is the API to perform a patch on a Namespace configuration
+func NamespaceUpdate(conf *config.Config, kialiCache cache.KialiCache, clientFactory kubernetes.ClientFactory, discovery istio.MeshDiscovery) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		params := mux.Vars(r)
+		namespace := params["namespace"]
+		body, err := io.ReadAll(r.Body)
+		if err != nil {
+			RespondWithError(w, http.StatusBadRequest, "Update request with bad update patch: "+err.Error())
 			return
 		}
-		validationSummaries = append(validationSummaries, *istioConfigValidationResults.SummarizeValidation(ns, cluster))
-	}
+		defer r.Body.Close()
 
-	RespondWithJSON(w, http.StatusOK, validationSummaries)
-}
+		jsonPatch := string(body)
 
-// NamespaceUpdate is the API to perform a patch on a Namespace configuration
-func NamespaceUpdate(w http.ResponseWriter, r *http.Request) {
-	params := mux.Vars(r)
-	business, err := getBusiness(r)
-	if err != nil {
-		RespondWithError(w, http.StatusInternalServerError, "Namespace initialization error: "+err.Error())
-		return
-	}
-	namespace := params["namespace"]
-	body, err := io.ReadAll(r.Body)
-	if err != nil {
-		RespondWithError(w, http.StatusBadRequest, "Update request with bad update patch: "+err.Error())
-	}
-	jsonPatch := string(body)
+		query := r.URL.Query()
+		cluster := clusterNameFromQuery(conf, query)
 
-	query := r.URL.Query()
-	cluster := clusterNameFromQuery(config.Get(), query)
+		userClients, err := getUserClients(r, clientFactory)
+		if err != nil {
+			RespondWithError(w, http.StatusInternalServerError, "Services initialization error: "+err.Error())
+			return
+		}
 
-	ns, err := business.Namespace.UpdateNamespace(r.Context(), namespace, jsonPatch, cluster)
-	if err != nil {
-		handleErrorResponse(w, err)
-		return
+		namespaceService := business.NewNamespaceService(kialiCache, conf, discovery, clientFactory.GetSAClients(), userClients)
+		ns, err := namespaceService.UpdateNamespace(r.Context(), namespace, jsonPatch, cluster)
+		if err != nil {
+			handleErrorResponse(w, err)
+			return
+		}
+		audit(r, "UPDATE on Namespace: "+namespace+" Patch: "+jsonPatch)
+		RespondWithJSON(w, http.StatusOK, ns)
 	}
-	audit(r, "UPDATE on Namespace: "+namespace+" Patch: "+jsonPatch)
-	RespondWithJSON(w, http.StatusOK, ns)
 }
diff --git a/handlers/proxy_logging.go b/handlers/proxy_logging.go
index 67b7d4358..501da2a69 100644
--- a/handlers/proxy_logging.go
+++ b/handlers/proxy_logging.go
@@ -9,43 +9,59 @@ import (
 
 	"github.com/kiali/kiali/business"
 	"github.com/kiali/kiali/config"
+	"github.com/kiali/kiali/grafana"
+	"github.com/kiali/kiali/istio"
+	"github.com/kiali/kiali/kubernetes"
+	"github.com/kiali/kiali/kubernetes/cache"
+	"github.com/kiali/kiali/prometheus"
+	"github.com/kiali/kiali/tracing"
 )
 
-func LoggingUpdate(w http.ResponseWriter, r *http.Request) {
-	params := mux.Vars(r)
+func LoggingUpdate(
+	conf *config.Config,
+	kialiCache cache.KialiCache,
+	clientFactory kubernetes.ClientFactory,
+	cpm business.ControlPlaneMonitor,
+	prom prometheus.ClientInterface,
+	traceClientLoader func() tracing.ClientInterface,
+	grafana *grafana.Service,
+	discovery istio.MeshDiscovery,
+) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		params := mux.Vars(r)
 
-	if config.Get().Deployment.ViewOnlyMode {
-		RespondWithError(w, http.StatusForbidden, "Log level cannot be changed in view-only mode")
-		return
-	}
+		if conf.Deployment.ViewOnlyMode {
+			RespondWithError(w, http.StatusForbidden, "Log level cannot be changed in view-only mode")
+			return
+		}
 
-	// Get business layer
-	businessLayer, err := getBusiness(r)
-	if err != nil {
-		RespondWithError(w, http.StatusInternalServerError, "Services initialization error: "+err.Error())
-		return
-	}
+		businessLayer, err := getLayer(r, conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery)
+		if err != nil {
+			RespondWithError(w, http.StatusInternalServerError, "Apps initialization error: "+err.Error())
+			return
+		}
 
-	namespace := params["namespace"]
-	pod := params["pod"]
-	query := r.URL.Query()
-	level := query.Get("level")
-	switch {
-	case level == "":
-		RespondWithError(w, 400, "level query param is not set")
-		return
-	case !business.IsValidProxyLogLevel(level):
-		msg := fmt.Sprintf("%s is an invalid log level. Valid log levels are: %s", level, strings.Join(business.ValidProxyLogLevels, ", "))
-		RespondWithError(w, 400, msg)
-		return
-	}
+		namespace := params["namespace"]
+		pod := params["pod"]
+		query := r.URL.Query()
+		level := query.Get("level")
+		switch {
+		case level == "":
+			RespondWithError(w, 400, "level query param is not set")
+			return
+		case !business.IsValidProxyLogLevel(level):
+			msg := fmt.Sprintf("%s is an invalid log level. Valid log levels are: %s", level, strings.Join(business.ValidProxyLogLevels, ", "))
+			RespondWithError(w, 400, msg)
+			return
+		}
 
-	cluster := clusterNameFromQuery(config.Get(), query)
+		cluster := clusterNameFromQuery(conf, query)
 
-	if err := businessLayer.ProxyLogging.SetLogLevel(cluster, namespace, pod, level); err != nil {
-		handleErrorResponse(w, err)
-		return
+		if err := businessLayer.ProxyLogging.SetLogLevel(cluster, namespace, pod, level); err != nil {
+			handleErrorResponse(w, err)
+			return
+		}
+		audit(r, "UPDATE Envoy log. Cluster: "+cluster+" Namespace: "+namespace+" Pod: "+pod+" Log level:"+level)
+		RespondWithCode(w, 200)
 	}
-	audit(r, "UPDATE Envoy log. Cluster: "+cluster+" Namespace: "+namespace+" Pod: "+pod+" Log level:"+level)
-	RespondWithCode(w, 200)
 }
diff --git a/istio/discovery.go b/istio/discovery.go
index 3247da55c..3de86b505 100644
--- a/istio/discovery.go
+++ b/istio/discovery.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"slices"
 	"strings"
+	"time"
 
 	"golang.org/x/exp/maps"
 
@@ -39,6 +40,9 @@ const (
 	certificateName                       = "root-cert.pem"
 )
 
+// 3 seconds is somewhat arbitrary but mesh discovery expires after 20s so it needs to be less than that.
+var getVersionTimeout = time.Second * 3
+
 func parseIstioConfigMap(istioConfig *corev1.ConfigMap) (*models.IstioMeshConfig, error) {
 	meshConfig := &models.IstioMeshConfig{}
 
@@ -367,20 +371,25 @@ func (in *Discovery) Mesh(ctx context.Context) (*models.Mesh, error) {
 			}
 
 			// Even if we fail to get the version we should still return the controlplane object.
-			func() {
+			func(ctx context.Context) {
 				saClient := in.kialiSAClients[cluster.Name]
 				if saClient == nil {
 					log.Warningf("Unable to get service account client for cluster [%s].", cluster.Name)
 					return
 				}
 
+				// If this call hangs it can cause the rest of the mesh discovery to timeout.
+				// Getting the version shouldn't block discovery.
+				var cancel context.CancelFunc
+				ctx, cancel = context.WithTimeout(ctx, getVersionTimeout)
+				defer cancel()
 				versionInfo, err := GetVersion(ctx, in.conf, saClient, kubeCache, controlPlane.Revision, controlPlane.IstiodNamespace)
 				if err != nil {
 					log.Warningf("Unable to get version info for controlplane [%s/%s] on cluster [%s]. Err: %s", controlPlane.IstiodName, controlPlane.IstiodNamespace, cluster.Name, err)
 					return
 				}
 				controlPlane.Version = versionInfo
-			}()
+			}(ctx)
 
 			// Get the status for the control plane.
 			status, err := in.canConnectToIstiodForRevision(controlPlane)
diff --git a/kubernetes/cache/cache.go b/kubernetes/cache/cache.go
index 93bd15c40..e488653ff 100644
--- a/kubernetes/cache/cache.go
+++ b/kubernetes/cache/cache.go
@@ -12,6 +12,7 @@ import (
 	v1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/tools/cache"
 
 	"github.com/kiali/kiali/config"
 	"github.com/kiali/kiali/kubernetes"
@@ -23,12 +24,15 @@ import (
 
 const (
 	ambientCheckExpirationTime = 10 * time.Minute
+	gatewayExpirationTime      = 3 * time.Minute
 	meshExpirationTime         = 20 * time.Second
-	waypointExpirationTime     = 1 * time.Minute
+	waypointExpirationTime     = 3 * time.Minute
 )
 
 const (
-	kialiCacheMeshKey = "mesh"
+	kialiCacheGatewaysKey  = "gateways"
+	kialiCacheMeshKey      = "mesh"
+	kialiCacheWaypointsKey = "waypoints"
 )
 
 // KialiCache stores both kube objects and non-kube related data such as pods' proxy status.
@@ -48,6 +52,10 @@ type KialiCache interface {
 	// GetClusters returns the list of clusters that the cache knows about.
 	// This gets set by the mesh service.
 	GetClusters() []models.KubeCluster
+
+	GetGateways() (models.Workloads, bool)
+	SetGateways(models.Workloads)
+
 	GetKubeCaches() map[string]KubeCache
 	GetKubeCache(cluster string) (KubeCache, error)
 
@@ -60,14 +68,12 @@ type KialiCache interface {
 	// GetNamespaces returns all namespaces for the cluster/token from the in memory cache.
 	GetNamespaces(cluster string, token string) ([]models.Namespace, bool)
 
+	GetWaypoints() (models.Workloads, bool)
+	SetWaypoints(models.Workloads)
+
 	// GetZtunnelPods returns a list of ztunnel pods from the ztunnel daemonset
 	GetZtunnelPods(cluster string) []v1.Pod
 
-	// GetWaypointList returns a list of waypoint proxies workloads by cluster and namespace
-	GetWaypointList() models.Workloads
-	SetWaypointList(models.Workloads)
-	IsWaypointListExpired() bool
-
 	// IsAmbientEnabled checks if the istio Ambient profile was enabled
 	// by checking if the ztunnel daemonset exists on the cluster.
 	IsAmbientEnabled(cluster string) bool
@@ -107,6 +113,13 @@ type kialiCacheImpl struct {
 	cleanup                 func()
 	conf                    config.Config
 
+	// Info about the kube clusters that the cache knows about.
+	clusters    []models.KubeCluster
+	clusterLock sync.RWMutex
+
+	// Cache gateways to speed up access for these specific workloads. The only key is kialiCacheGatewaysKey
+	gatewayStore store.Store[string, models.Workloads]
+
 	// Maps a cluster name to a KubeCache
 	kubeCache map[string]KubeCache
 
@@ -118,31 +131,35 @@ type kialiCacheImpl struct {
 	// so you can easily deref the namespace in GetNamespace and SetNamespace. The downside to this is that
 	// we need an additional lock for the namespace map that gets returned from the store to ensure it is threadsafe.
 	namespaceStore store.Store[namespacesKey, map[string]models.Namespace]
-
 	// Only necessary because we want to cache the namespaces per cluster and token as a map
 	// and maps are not thread safe. We need an additional lock on top of the Store to ensure
 	// that the map returned from the store is threadsafe.
 	namespacesLock sync.RWMutex
 
-	refreshDuration time.Duration
 	// ProxyStatusStore stores the proxy status and should be key'd off cluster + namespace + pod.
 	proxyStatusStore store.Store[string, *kubernetes.ProxyStatus]
+
+	refreshDuration time.Duration
+
 	// RegistryStatusStore stores the registry status and should be key'd off of the cluster name.
 	registryStatusStore store.Store[string, *kubernetes.RegistryStatus]
-	// ProxyStatusStore stores ztunnel config dump per cluster + namespace + pod.
-	ztunnelConfigStore store.Store[string, *kubernetes.ZtunnelConfigDump]
 
-	waypointList models.WaypointStore
 	// validations key'd by the validation key
 	validations      store.Store[models.IstioValidationKey, *models.IstioValidation]
 	validationConfig store.Store[string, string]
 
-	// Info about the kube clusters that the cache knows about.
-	clusters    []models.KubeCluster
-	clusterLock sync.RWMutex
+	// Cache gateways to speed up access for these specific workloads. The only key is kialiCacheWaypointsKey
+	waypointStore store.Store[string, models.Workloads]
+
+	// ProxyStatusStore stores ztunnel config dump per cluster + namespace + pod.
+	ztunnelConfigStore store.Store[string, *kubernetes.ZtunnelConfigDump]
 }
 
 func NewKialiCache(kialiSAClients map[string]kubernetes.ClientInterface, conf config.Config) (KialiCache, error) {
+	return newKialiCache(kialiSAClients, conf, cache.WaitForCacheSync)
+}
+
+func newKialiCache(kialiSAClients map[string]kubernetes.ClientInterface, conf config.Config, waitForSync waitForCacheSyncFunc) (KialiCache, error) {
 	ctx, cancel := context.WithCancel(context.Background())
 	namespaceKeyTTL := time.Duration(conf.KubernetesConfig.CacheTokenNamespaceDuration) * time.Second
 	kialiCacheImpl := kialiCacheImpl{
@@ -150,14 +167,16 @@ func NewKialiCache(kialiSAClients map[string]kubernetes.ClientInterface, conf co
 		canReadWebhookByCluster: make(map[string]bool),
 		cleanup:                 cancel,
 		conf:                    conf,
+		gatewayStore:            store.NewExpirationStore(ctx, store.New[string, models.Workloads](), util.AsPtr(gatewayExpirationTime), nil),
 		kubeCache:               make(map[string]KubeCache),
-		validations:             store.New[models.IstioValidationKey, *models.IstioValidation](),
-		validationConfig:        store.New[string, string](),
 		meshStore:               store.NewExpirationStore(ctx, store.New[string, *models.Mesh](), util.AsPtr(meshExpirationTime), nil),
 		namespaceStore:          store.NewExpirationStore(ctx, store.New[namespacesKey, map[string]models.Namespace](), &namespaceKeyTTL, nil),
-		refreshDuration:         time.Duration(conf.KubernetesConfig.CacheDuration) * time.Second,
 		proxyStatusStore:        store.New[string, *kubernetes.ProxyStatus](),
+		refreshDuration:         time.Duration(conf.KubernetesConfig.CacheDuration) * time.Second,
 		registryStatusStore:     store.New[string, *kubernetes.RegistryStatus](),
+		waypointStore:           store.NewExpirationStore(ctx, store.New[string, models.Workloads](), util.AsPtr(waypointExpirationTime), nil),
+		validations:             store.New[models.IstioValidationKey, *models.IstioValidation](),
+		validationConfig:        store.New[string, string](),
 		ztunnelConfigStore:      store.New[string, *kubernetes.ZtunnelConfigDump](),
 	}
 
@@ -167,7 +186,7 @@ func NewKialiCache(kialiSAClients map[string]kubernetes.ClientInterface, conf co
 		if !conf.Deployment.ClusterWideAccess {
 			errHandler = kialiCacheImpl.deleteNamespace
 		}
-		cache, err := NewKubeCache(client, conf, errHandler)
+		cache, err := NewKubeCache(client, conf, errHandler, waitForSync)
 		if err != nil {
 			log.Errorf("[Kiali Cache] Error creating kube cache for cluster: [%s]. Err: %v", cluster, err)
 			return nil, err
@@ -339,21 +358,24 @@ func (in *kialiCacheImpl) GetZtunnelPods(cluster string) []v1.Pod {
 	return ztunnelPods
 }
 
-// GetWaypointList Returns a list of waypoint proxies by cluster and namespace
-func (c *kialiCacheImpl) GetWaypointList() models.Workloads {
-	return c.waypointList.Waypoints
+// GetGateways Returns a list of all gateway workloads by cluster and namespace
+func (c *kialiCacheImpl) GetGateways() (models.Workloads, bool) {
+	return c.gatewayStore.Get(kialiCacheGatewaysKey)
+}
+
+// SetGateways Sets a list of all gateway workloads by cluster and namespace
+func (c *kialiCacheImpl) SetGateways(gateways models.Workloads) {
+	c.gatewayStore.Set(kialiCacheGatewaysKey, gateways)
 }
 
-// SetWaypointList Modifies the list of waypoint proxies by cluster and namespace
-func (c *kialiCacheImpl) SetWaypointList(wpList models.Workloads) {
-	c.waypointList.Waypoints = wpList
-	c.waypointList.LastUpdate = time.Now()
+// GetWaypoints Returns a list of waypoint proxies by cluster and namespace
+func (c *kialiCacheImpl) GetWaypoints() (models.Workloads, bool) {
+	return c.waypointStore.Get(kialiCacheWaypointsKey)
 }
 
-func (c *kialiCacheImpl) IsWaypointListExpired() bool {
-	currentTime := time.Now()
-	expirationTime := c.waypointList.LastUpdate.Add(waypointExpirationTime)
-	return currentTime.After(expirationTime)
+// SetWaypoints Sets a list of all waypoint workloads by cluster and namespace
+func (c *kialiCacheImpl) SetWaypoints(waypoints models.Workloads) {
+	c.waypointStore.Set(kialiCacheWaypointsKey, waypoints)
 }
 
 type namespacesKey struct {
diff --git a/kubernetes/cache/kube_cache.go b/kubernetes/cache/kube_cache.go
index cd7426d4b..592fc52ad 100644
--- a/kubernetes/cache/kube_cache.go
+++ b/kubernetes/cache/kube_cache.go
@@ -166,6 +166,8 @@ type cacheLister struct {
 // err: the error that occurred
 type ErrorHandler func(kc *kubeCache, namespace string, err error)
 
+type waitForCacheSyncFunc func(stopCh <-chan struct{}, cacheSyncs ...cache.InformerSynced) bool
+
 // kubeCache is a local cache of kube objects. Manages informers and listers.
 type kubeCache struct {
 	cacheLock sync.RWMutex
@@ -187,10 +189,13 @@ type kubeCache struct {
 	stopClusterScopedChan chan struct{}
 	// Stops the namespace scoped informers when a refresh is necessary.
 	stopNSChans map[string]chan struct{}
+	// This is only for testing purposes to speed up test runs since the default kube
+	// implementation polls for 100ms.
+	waitForCacheSync waitForCacheSyncFunc
 }
 
 // Starts all informers. These run until context is cancelled.
-func NewKubeCache(kialiClient kubernetes.ClientInterface, conf config.Config, errorHandler ErrorHandler) (*kubeCache, error) {
+func NewKubeCache(kialiClient kubernetes.ClientInterface, conf config.Config, errorHandler ErrorHandler, waitForCacheSync waitForCacheSyncFunc) (*kubeCache, error) {
 	refreshDuration := time.Duration(conf.KubernetesConfig.CacheDuration) * time.Second
 
 	c := &kubeCache{
@@ -200,9 +205,10 @@ func NewKubeCache(kialiClient kubernetes.ClientInterface, conf config.Config, er
 		// Only when all namespaces are accessible should the cache be cluster scoped.
 		// Otherwise, kiali may not have access to all namespaces since
 		// the operator only grants clusterroles when all namespaces are accessible.
-		clusterScoped:   conf.AllNamespacesAccessible(),
-		refreshDuration: refreshDuration,
-		refreshOnce:     &OnceWrapper{once: &sync.Once{}},
+		clusterScoped:    conf.AllNamespacesAccessible(),
+		refreshDuration:  refreshDuration,
+		refreshOnce:      &OnceWrapper{once: &sync.Once{}},
+		waitForCacheSync: waitForCacheSync,
 	}
 
 	if c.clusterScoped {
@@ -383,7 +389,7 @@ func (c *kubeCache) startInformers(namespace string) error {
 	}
 
 	log.Infof("[Kiali Cache] Waiting for %s cache to sync", scope)
-	if !cache.WaitForCacheSync(stop, c.getCacheLister(namespace).cachesSynced...) {
+	if !c.waitForCacheSync(stop, c.getCacheLister(namespace).cachesSynced...) {
 		log.Errorf("[Kiali Cache] Failed to sync %s cache", scope)
 		return errors.New("failed to sync cache")
 	}
diff --git a/make/Makefile.molecule.mk b/make/Makefile.molecule.mk
index 8398c7502..bf3a51304 100644
--- a/make/Makefile.molecule.mk
+++ b/make/Makefile.molecule.mk
@@ -171,10 +171,12 @@ endif
 
 	podman volume exists molecule-tests-volume && echo "Podman volume already exists; deleting it" && podman volume rm molecule-tests-volume || true
 	podman volume create molecule-tests-volume
-	podman create -v molecule-tests-volume:/data --name molecule-volume-helper docker.io/busybox true
-	podman cp "${HELM_CHARTS_REPO}" molecule-volume-helper:/data/helm-charts-repo
-	podman cp "${ROOTDIR}/operator/" molecule-volume-helper:/data/operator
-	podman cp "${MOLECULE_KUBECONFIG}" molecule-volume-helper:/data/kubeconfig
+	podman create -v molecule-tests-volume:/data --name molecule-volume-helper quay.io/fedora/fedora-minimal:latest sleep infinity
+	podman start molecule-volume-helper
+	podman run --rm -v molecule-tests-volume:/data -v "${HELM_CHARTS_REPO}":/source:ro,Z quay.io/fedora/fedora-minimal cp -r /source /data/helm-charts-repo
+	podman run --rm -v molecule-tests-volume:/data -v "$$(realpath ${ROOTDIR}/operator)":/source:ro,Z quay.io/fedora/fedora-minimal cp -r /source /data/operator
+	podman run --rm -v molecule-tests-volume:/data -v "${MOLECULE_KUBECONFIG}":/source:ro,Z quay.io/fedora/fedora-minimal cp -r /source /data/kubeconfig
+	podman stop molecule-volume-helper
 	podman rm molecule-volume-helper
 
 ## molecule-test: Runs Molecule tests using the Molecule docker image
diff --git a/mesh/generator/generator.go b/mesh/generator/generator.go
index 270faa5ff..3d4ae5f0b 100644
--- a/mesh/generator/generator.go
+++ b/mesh/generator/generator.go
@@ -153,7 +153,7 @@ func BuildMeshMap(ctx context.Context, o mesh.Options, gi *mesh.GlobalInfo) (mes
 			cluster, namespace, isExternal := discoverInfraService(es.Prometheus.URL, ctx, gi)
 			var node *mesh.Node
 			name := "Prometheus"
-			node, _, err = addInfra(meshMap, mesh.InfraTypeMetricStore, cluster, namespace, name, es.Prometheus, esVersions[name], isExternal, healthData["prometheus"])
+			node, _, err = addInfra(meshMap, mesh.InfraTypeMetricStore, cluster, namespace, name, es.Prometheus, esVersions[name], isExternal, healthData[fmt.Sprintf("%s%s", "prometheus", gi.Conf.KubernetesConfig.ClusterName)])
 			mesh.CheckError(err)
 
 			kiali.AddEdge(node)
@@ -162,7 +162,7 @@ func BuildMeshMap(ctx context.Context, o mesh.Options, gi *mesh.GlobalInfo) (mes
 			if conf.ExternalServices.Tracing.Enabled {
 				cluster, namespace, isExternal = discoverInfraService(es.Tracing.InternalURL, ctx, gi)
 				name = string(es.Tracing.Provider)
-				node, _, err = addInfra(meshMap, mesh.InfraTypeTraceStore, cluster, namespace, name, es.Tracing, esVersions[name], isExternal, healthData["tracing"])
+				node, _, err = addInfra(meshMap, mesh.InfraTypeTraceStore, cluster, namespace, name, es.Tracing, esVersions[name], isExternal, healthData[fmt.Sprintf("%s%s", "tracing", gi.Conf.KubernetesConfig.ClusterName)])
 				mesh.CheckError(err)
 
 				kiali.AddEdge(node)
@@ -172,7 +172,7 @@ func BuildMeshMap(ctx context.Context, o mesh.Options, gi *mesh.GlobalInfo) (mes
 			if conf.ExternalServices.Grafana.Enabled {
 				cluster, namespace, isExternal = discoverInfraService(es.Grafana.InternalURL, ctx, gi)
 				name = "Grafana"
-				node, _, err = addInfra(meshMap, mesh.InfraTypeGrafana, cluster, namespace, name, es.Grafana, esVersions[name], isExternal, healthData["grafana"])
+				node, _, err = addInfra(meshMap, mesh.InfraTypeGrafana, cluster, namespace, name, es.Grafana, esVersions[name], isExternal, healthData[fmt.Sprintf("%s%s", "grafana", gi.Conf.KubernetesConfig.ClusterName)])
 				mesh.CheckError(err)
 
 				kiali.AddEdge(node)
diff --git a/models/workload.go b/models/workload.go
index 971ff5ead..b6b01caf9 100644
--- a/models/workload.go
+++ b/models/workload.go
@@ -2,7 +2,6 @@ package models
 
 import (
 	"strconv"
-	"time"
 
 	osapps_v1 "github.com/openshift/api/apps/v1"
 	networking_v1 "istio.io/client-go/pkg/apis/networking/v1"
@@ -54,11 +53,6 @@ const (
 	LogTypeZtunnel  LogType = "ztunnel"
 )
 
-type WaypointStore struct {
-	LastUpdate time.Time
-	Waypoints  Workloads
-}
-
 // WorkloadListItem has the necessary information to display the console workload list
 type WorkloadListItem struct {
 	// Name of the workload
diff --git a/routing/routes.go b/routing/routes.go
index d500c1907..508115d8f 100644
--- a/routing/routes.go
+++ b/routing/routes.go
@@ -721,7 +721,7 @@ func NewRoutes(
 			"NamespaceList",
 			"GET",
 			"/api/namespaces",
-			handlers.NamespaceList,
+			handlers.NamespaceList(conf, kialiCache, clientFactory, discovery),
 			true,
 		},
 		// swagger:route PATCH /namespaces/{namespace} namespaces namespaceUpdate
@@ -746,7 +746,7 @@ func NewRoutes(
 			"NamespaceUpdate",
 			"PATCH",
 			"/api/namespaces/{namespace}",
-			handlers.NamespaceUpdate,
+			handlers.NamespaceUpdate(conf, kialiCache, clientFactory, discovery),
 			true,
 		},
 		// swagger:route GET /namespaces/{namespace}/info namespaces namespaceInfo
@@ -766,7 +766,7 @@ func NewRoutes(
 			"NamespaceInfo",
 			"GET",
 			"/api/namespaces/{namespace}/info",
-			handlers.NamespaceInfo,
+			handlers.NamespaceInfo(conf, kialiCache, clientFactory, discovery),
 			true,
 		},
 		// swagger:route GET /namespaces/{namespace}/services/{service}/metrics services serviceMetrics
@@ -1060,7 +1060,7 @@ func NewRoutes(
 			"NamespaceValidationSummary",
 			"GET",
 			"/api/namespaces/{namespace}/validations",
-			handlers.NamespaceValidationSummary(discovery),
+			handlers.NamespaceValidationSummary(conf, kialiCache, clientFactory, prom, cpm, traceClientLoader, grafana, discovery),
 			true,
 		},
 		// swagger:route GET /istio/validations namespaces namespacesValidations
@@ -1081,7 +1081,7 @@ func NewRoutes(
 			"ConfigValidationSummary",
 			"GET",
 			"/api/istio/validations",
-			handlers.ConfigValidationSummary,
+			handlers.IstioConfigValidationSummary(conf, kialiCache, clientFactory, prom, cpm, traceClientLoader, grafana, discovery),
 			true,
 		},
 		// swagger:route GET /mesh/tls tls meshTls
@@ -1524,7 +1524,7 @@ func NewRoutes(
 			"PodProxyLogging",
 			"POST",
 			"/api/namespaces/{namespace}/pods/{pod}/logging",
-			handlers.LoggingUpdate,
+			handlers.LoggingUpdate(conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery),
 			true,
 		},
 		// swagger:route GET /clusters/metrics clusterName namespaces clustersMetrics


Test patch:
diff --git a/business/workloads_test.go b/business/workloads_test.go
index 96fcad7bd..402e4c1cf 100644
--- a/business/workloads_test.go
+++ b/business/workloads_test.go
@@ -27,6 +27,7 @@ import (
 	"github.com/kiali/kiali/models"
 	"github.com/kiali/kiali/prometheus/prometheustest"
 	"github.com/kiali/kiali/tests/data"
+	"github.com/kiali/kiali/util/sliceutil"
 )
 
 func setupWorkloadService(k8s kubernetes.ClientInterface, conf *config.Config) WorkloadService {
@@ -986,6 +987,29 @@ func TestGetWaypointPodLogsProxy(t *testing.T) {
 		kubeObjs = append(kubeObjs, &o)
 	}
 
+	kubeObjs = append(kubeObjs,
+		&apps_v1.Deployment{
+			TypeMeta: meta_v1.TypeMeta{
+				APIVersion: kubernetes.Deployments.GroupVersion().String(),
+				Kind:       kubernetes.Deployments.Kind,
+			},
+			ObjectMeta: meta_v1.ObjectMeta{
+				Name:      "waypoint",
+				Namespace: "Namespace",
+			},
+			Spec: apps_v1.DeploymentSpec{
+				Template: core_v1.PodTemplateSpec{
+					ObjectMeta: meta_v1.ObjectMeta{
+						Labels: map[string]string{
+							"gateway.istio.io/managed":               "istio.io-mesh-controller",
+							"gateway.networking.k8s.io/gateway-name": "waypoint",
+						},
+					},
+				},
+			},
+		},
+	)
+
 	client := kubetest.NewFakeK8sClient(kubeObjs...)
 	client.OpenShift = true
 
@@ -1394,6 +1418,29 @@ func TestValidateWaypoint(t *testing.T) {
 		kubeObjs = append(kubeObjs, &o)
 	}
 
+	kubeObjs = append(kubeObjs,
+		&apps_v1.Deployment{
+			TypeMeta: meta_v1.TypeMeta{
+				APIVersion: kubernetes.Deployments.GroupVersion().String(),
+				Kind:       kubernetes.Deployments.Kind,
+			},
+			ObjectMeta: meta_v1.ObjectMeta{
+				Name:      "waypoint",
+				Namespace: "Namespace",
+			},
+			Spec: apps_v1.DeploymentSpec{
+				Template: core_v1.PodTemplateSpec{
+					ObjectMeta: meta_v1.ObjectMeta{
+						Labels: map[string]string{
+							"gateway.istio.io/managed":               "istio.io-mesh-controller",
+							"gateway.networking.k8s.io/gateway-name": "waypoint",
+						},
+					},
+				},
+			},
+		},
+	)
+
 	k8s := kubetest.NewFakeK8sClient(kubeObjs...)
 	k8s.OpenShift = true
 	SetupBusinessLayer(t, k8s, *conf)
@@ -1455,6 +1502,29 @@ func TestValidateWaypointNS(t *testing.T) {
 		kubeObjs = append(kubeObjs, &o)
 	}
 
+	kubeObjs = append(kubeObjs,
+		&apps_v1.Deployment{
+			TypeMeta: meta_v1.TypeMeta{
+				APIVersion: kubernetes.Deployments.GroupVersion().String(),
+				Kind:       kubernetes.Deployments.Kind,
+			},
+			ObjectMeta: meta_v1.ObjectMeta{
+				Name:      "waypoint",
+				Namespace: "Namespace",
+			},
+			Spec: apps_v1.DeploymentSpec{
+				Template: core_v1.PodTemplateSpec{
+					ObjectMeta: meta_v1.ObjectMeta{
+						Labels: map[string]string{
+							"gateway.istio.io/managed":               "istio.io-mesh-controller",
+							"gateway.networking.k8s.io/gateway-name": "waypoint",
+						},
+					},
+				},
+			},
+		},
+	)
+
 	// cache
 	clientFactory := kubetest.NewK8SClientFactoryMock(nil)
 	clients := map[string]kubernetes.ClientInterface{
@@ -1533,6 +1603,29 @@ func TestValidateWaypointService(t *testing.T) {
 		kubeObjs = append(kubeObjs, &o)
 	}
 
+	kubeObjs = append(kubeObjs,
+		&apps_v1.Deployment{
+			TypeMeta: meta_v1.TypeMeta{
+				APIVersion: kubernetes.Deployments.GroupVersion().String(),
+				Kind:       kubernetes.Deployments.Kind,
+			},
+			ObjectMeta: meta_v1.ObjectMeta{
+				Name:      "waypoint",
+				Namespace: "Namespace",
+			},
+			Spec: apps_v1.DeploymentSpec{
+				Template: core_v1.PodTemplateSpec{
+					ObjectMeta: meta_v1.ObjectMeta{
+						Labels: map[string]string{
+							"gateway.istio.io/managed":               "istio.io-mesh-controller",
+							"gateway.networking.k8s.io/gateway-name": "waypoint",
+						},
+					},
+				},
+			},
+		},
+	)
+
 	k8s := kubetest.NewFakeK8sClient(kubeObjs...)
 	k8s.OpenShift = true
 	SetupBusinessLayer(t, k8s, *conf)
@@ -1602,6 +1695,29 @@ func TestGetWaypointWorkloads(t *testing.T) {
 		kubeObjs = append(kubeObjs, &o)
 	}
 
+	kubeObjs = append(kubeObjs,
+		&apps_v1.Deployment{
+			TypeMeta: meta_v1.TypeMeta{
+				APIVersion: kubernetes.Deployments.GroupVersion().String(),
+				Kind:       kubernetes.Deployments.Kind,
+			},
+			ObjectMeta: meta_v1.ObjectMeta{
+				Name:      "waypoint",
+				Namespace: "Namespace",
+			},
+			Spec: apps_v1.DeploymentSpec{
+				Template: core_v1.PodTemplateSpec{
+					ObjectMeta: meta_v1.ObjectMeta{
+						Labels: map[string]string{
+							"gateway.istio.io/managed":               "istio.io-mesh-controller",
+							"gateway.networking.k8s.io/gateway-name": "waypoint",
+						},
+					},
+				},
+			},
+		},
+	)
+
 	k8s := kubetest.NewFakeK8sClient(kubeObjs...)
 	k8s.OpenShift = true
 	SetupBusinessLayer(t, k8s, *conf)
@@ -1774,11 +1890,14 @@ func TestGetAllGateways(t *testing.T) {
 	SetupBusinessLayer(t, k8s, *conf)
 	svc := setupWorkloadService(k8s, conf)
 
-	workloads, err := svc.GetAllGateways(context.Background(), conf.KubernetesConfig.ClusterName, "")
+	gateways, err := svc.GetGateways(context.Background())
 	require.NoError(err)
+	gateways = sliceutil.Filter(gateways, func(gw *models.Workload) bool {
+		return gw.Cluster == conf.KubernetesConfig.ClusterName
+	})
 
-	require.Len(workloads, 1)
-	require.True(workloads[0].IsGateway(), "Expected IsGateway to be True but it was false")
+	require.Len(gateways, 1)
+	require.True(gateways[0].IsGateway(), "Expected IsGateway to be True but it was false")
 }
 
 func TestGetWorkloadListWithCustomKindThatMatchesCoreKind(t *testing.T) {
diff --git a/frontend/src/components/Nav/__tests__/LanguageSwitch.test.tsx b/frontend/src/components/Nav/__tests__/LanguageSwitch.test.tsx
index e347ebcb7..957204fcd 100644
--- a/frontend/src/components/Nav/__tests__/LanguageSwitch.test.tsx
+++ b/frontend/src/components/Nav/__tests__/LanguageSwitch.test.tsx
@@ -30,7 +30,7 @@ describe('Language switch', () => {
 
     // select English option
     wrapper
-      .findWhere(node => node.key() === 'English')
+      .findWhere(node => node.key() === 'english')
       .findWhere(node => node.type() === 'button')
       .simulate('click');
 
@@ -40,6 +40,24 @@ describe('Language switch', () => {
     expect(store.getState().globalState.language).toBe(Language.ENGLISH);
   });
 
+  it('changes to spanish language', async () => {
+    const wrapper = mount(<LanguageSwitchComponent language={Language.ENGLISH} />);
+
+    // click menu toggle
+    wrapper.find(MenuToggle).simulate('click');
+
+    // select Chinese option
+    wrapper
+      .findWhere(node => node.key() === 'spanish')
+      .findWhere(node => node.type() === 'button')
+      .simulate('click');
+
+    // wait a few ms for the language to be modified
+    await delay(100);
+
+    expect(store.getState().globalState.language).toBe(Language.SPANISH);
+  });
+
   it('changes to chinese language', async () => {
     const wrapper = mount(<LanguageSwitchComponent language={Language.ENGLISH} />);
 
@@ -48,7 +66,7 @@ describe('Language switch', () => {
 
     // select Chinese option
     wrapper
-      .findWhere(node => node.key() === 'Chinese')
+      .findWhere(node => node.key() === 'chinese')
       .findWhere(node => node.type() === 'button')
       .simulate('click');
 
diff --git a/frontend/src/components/Nav/__tests__/__snapshots__/LanguageSwitch.test.tsx.snap b/frontend/src/components/Nav/__tests__/__snapshots__/LanguageSwitch.test.tsx.snap
index 48a7119c9..1c091badb 100644
--- a/frontend/src/components/Nav/__tests__/__snapshots__/LanguageSwitch.test.tsx.snap
+++ b/frontend/src/components/Nav/__tests__/__snapshots__/LanguageSwitch.test.tsx.snap
@@ -24,7 +24,7 @@ exports[`Language switch renders correctly 1`] = `
   >
     <DropdownList>
       <DropdownItem
-        key="English"
+        key="english"
         onClick={[Function]}
       >
         <span>
@@ -35,7 +35,15 @@ exports[`Language switch renders correctly 1`] = `
         />
       </DropdownItem>
       <DropdownItem
-        key="Chinese"
+        key="spanish"
+        onClick={[Function]}
+      >
+        <span>
+          Español
+        </span>
+      </DropdownItem>
+      <DropdownItem
+        key="chinese"
         onClick={[Function]}
       >
         <span>
diff --git a/frontend/src/reducers/__tests__/GraphDataState.test.ts b/frontend/src/reducers/__tests__/GraphDataState.test.ts
index 8321780f6..31c4522dd 100644
--- a/frontend/src/reducers/__tests__/GraphDataState.test.ts
+++ b/frontend/src/reducers/__tests__/GraphDataState.test.ts
@@ -1,10 +1,9 @@
 import { GraphDataStateReducer } from '../GraphDataState';
 import { GraphActions } from '../../actions/GraphActions';
 import { GlobalActions } from '../../actions/GlobalActions';
-import { DefaultTrafficRates, EdgeMode, GraphType } from '../../types/Graph';
+import { DefaultTrafficRates, EdgeMode, GraphLayout, GraphType } from '../../types/Graph';
 import { GraphState } from 'store/Store';
 import { GraphElement } from '@patternfly/react-topology';
-import { GraphLayout } from 'pages/Graph/Graph';
 
 describe('GraphDataState', () => {
   it('should return the initial state', () => {
diff --git a/graph/api/api_test.go b/graph/api/api_test.go
index 70b2c0b0a..7a052292e 100644
--- a/graph/api/api_test.go
+++ b/graph/api/api_test.go
@@ -1836,7 +1836,7 @@ func TestWorkloadNodeGraph(t *testing.T) {
 }
 
 func TestAppNodeGraph(t *testing.T) {
-	q0 := `round(sum(rate(istio_requests_total{reporter=~"destination|waypoint",destination_service_namespace="bookinfo",destination_canonical_service="productpage"} [600s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags) > 0,0.001)`
+	q0 := `round(sum(rate(istio_requests_total{reporter=~"waypoint|destination",destination_service_namespace="bookinfo",destination_canonical_service="productpage"} [600s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags) > 0,0.001)`
 	q0m0 := model.Metric{
 		"source_workload_namespace":      "unknown",
 		"source_workload":                "unknown",
@@ -1886,7 +1886,7 @@ func TestAppNodeGraph(t *testing.T) {
 		},
 	}
 
-	q1 := `round(sum(rate(istio_requests_total{reporter=~"source|waypoint",source_workload_namespace="bookinfo",source_canonical_service="productpage"} [600s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags) > 0,0.001)`
+	q1 := `round(sum(rate(istio_requests_total{reporter=~"waypoint|source",source_workload_namespace="bookinfo",source_canonical_service="productpage"} [600s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags) > 0,0.001)`
 	q1m0 := model.Metric{
 		"source_workload_namespace":      "bookinfo",
 		"source_workload":                "productpage-v1",
@@ -3927,13 +3927,13 @@ func ambientWorkloads(t *testing.T) *business.Layer {
 
 func ambientMockGraph(api *prometheustest.PromAPIMock) {
 
-	q0 := `round(sum(rate(istio_requests_total{reporter=~"source|waypoint",source_workload_namespace!="bookinfo",destination_workload_namespace="unknown",destination_workload="unknown",destination_service=~"^.+\\.bookinfo\\..+$"} [600s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags) > 0,0.001)`
+	q0 := `round(sum(rate(istio_requests_total{reporter=~"waypoint|source",source_workload_namespace!="bookinfo",destination_workload_namespace="unknown",destination_workload="unknown",destination_service=~"^.+\\.bookinfo\\..+$"} [600s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags) > 0,0.001)`
 	v0 := model.Vector{}
 
 	q1 := `round(sum(rate(istio_requests_total{reporter="source",source_workload_namespace!="bookinfo",destination_workload_namespace="bookinfo"} [600s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags) > 0,0.001)`
 	v1 := model.Vector{}
 
-	q2 := `round(sum(rate(istio_requests_total{reporter=~"destination|waypoint",destination_workload_namespace="bookinfo"} [600s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags) > 0,0.001)`
+	q2 := `round(sum(rate(istio_requests_total{reporter=~"waypoint|destination",destination_workload_namespace="bookinfo"} [600s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags) > 0,0.001)`
 	q2m0 := model.Metric{
 		"source_workload_namespace":      "bookinfo",
 		"source_workload":                "productpage-v1",
@@ -3983,7 +3983,7 @@ func ambientMockGraph(api *prometheustest.PromAPIMock) {
 		},
 	}
 
-	q3 := `round(sum(rate(istio_requests_total{reporter=~"source|waypoint",source_workload_namespace="bookinfo"} [600s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags) > 0,0.001)`
+	q3 := `round(sum(rate(istio_requests_total{reporter=~"waypoint|source",source_workload_namespace="bookinfo"} [600s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags) > 0,0.001)`
 	q3m0 := model.Metric{
 		"source_workload_namespace":      "bookinfo",
 		"source_workload":                "productpage-v1",
@@ -4033,10 +4033,10 @@ func ambientMockGraph(api *prometheustest.PromAPIMock) {
 		},
 	}
 
-	q4 := `round(sum(rate(istio_tcp_received_bytes_total{reporter=~"source|waypoint",source_workload_namespace!="bookinfo",destination_workload_namespace="unknown",destination_workload="unknown",destination_service=~"^.+\\.bookinfo\\..+$"} [600s])) by (app,source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,response_flags) > 0,0.001)`
+	q4 := `round(sum(rate(istio_tcp_received_bytes_total{reporter=~"waypoint|source",source_workload_namespace!="bookinfo",destination_workload_namespace="unknown",destination_workload="unknown",destination_service=~"^.+\\.bookinfo\\..+$"} [600s])) by (app,source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,response_flags) > 0,0.001)`
 	v4 := model.Vector{}
 
-	q5 := `round(sum(rate(istio_tcp_received_bytes_total{reporter=~"destination|waypoint",destination_workload_namespace="bookinfo"} [600s])) by (app,source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,response_flags) > 0,0.001)`
+	q5 := `round(sum(rate(istio_tcp_received_bytes_total{reporter=~"waypoint|destination",destination_workload_namespace="bookinfo"} [600s])) by (app,source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,response_flags) > 0,0.001)`
 	q5m0 := model.Metric{
 		"source_workload_namespace":      "bookinfo",
 		"source_workload":                "waypoint",
@@ -4103,7 +4103,7 @@ func ambientMockGraph(api *prometheustest.PromAPIMock) {
 		},
 	}
 
-	q6 := `round(sum(rate(istio_tcp_received_bytes_total{reporter=~"source|waypoint",source_workload_namespace="bookinfo"} [600s])) by (app,source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,response_flags) > 0,0.001)`
+	q6 := `round(sum(rate(istio_tcp_received_bytes_total{reporter=~"waypoint|source",source_workload_namespace="bookinfo"} [600s])) by (app,source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,response_flags) > 0,0.001)`
 	q6m0 := model.Metric{
 		"source_workload_namespace":      "bookinfo",
 		"source_workload":                "productpage-v1",
diff --git a/graph/telemetry/istio/appender/aggregate_node_test.go b/graph/telemetry/istio/appender/aggregate_node_test.go
index 9b686af1e..1d0a4545b 100644
--- a/graph/telemetry/istio/appender/aggregate_node_test.go
+++ b/graph/telemetry/istio/appender/aggregate_node_test.go
@@ -14,10 +14,10 @@ import (
 func TestNamespacesGraphWithServiceInjection(t *testing.T) {
 	assert := assert.New(t)
 
-	q0 := `round(sum(rate(istio_requests_total{reporter=~"destination|waypoint",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo",request_operation!="unknown"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
+	q0 := `round(sum(rate(istio_requests_total{reporter=~"waypoint|destination",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo",request_operation!="unknown"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
 	v0 := model.Vector{}
 
-	q1 := `round(sum(rate(istio_requests_total{reporter=~"destination|waypoint",source_workload_namespace="bookinfo",request_operation!="unknown"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
+	q1 := `round(sum(rate(istio_requests_total{reporter=~"waypoint|destination",source_workload_namespace="bookinfo",request_operation!="unknown"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
 	q1m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "bookinfo",
@@ -142,10 +142,10 @@ func TestNamespacesGraphWithServiceInjection(t *testing.T) {
 func TestNamespacesGraphNoServiceInjection(t *testing.T) {
 	assert := assert.New(t)
 
-	q0 := `round(sum(rate(istio_requests_total{reporter=~"destination|waypoint",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo",request_operation!="unknown"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
+	q0 := `round(sum(rate(istio_requests_total{reporter=~"waypoint|destination",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo",request_operation!="unknown"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
 	v0 := model.Vector{}
 
-	q1 := `round(sum(rate(istio_requests_total{reporter=~"destination|waypoint",source_workload_namespace="bookinfo",request_operation!="unknown"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
+	q1 := `round(sum(rate(istio_requests_total{reporter=~"waypoint|destination",source_workload_namespace="bookinfo",request_operation!="unknown"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
 	q1m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "bookinfo",
@@ -263,7 +263,7 @@ func TestNamespacesGraphNoServiceInjection(t *testing.T) {
 func TestNodeGraphWithServiceInjection(t *testing.T) {
 	assert := assert.New(t)
 
-	q0 := `round(sum(rate(istio_requests_total{reporter=~"destination|waypoint",destination_service_namespace="bookinfo",request_operation="Top",destination_service_name="reviews"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
+	q0 := `round(sum(rate(istio_requests_total{reporter=~"waypoint|destination",destination_service_namespace="bookinfo",request_operation="Top",destination_service_name="reviews"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
 	q0m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "bookinfo",
@@ -351,10 +351,10 @@ func TestNodeGraphWithServiceInjection(t *testing.T) {
 func TestNamespacesGraphWithServiceInjectionSkipRates(t *testing.T) {
 	assert := assert.New(t)
 
-	q0 := `round(sum(rate(istio_requests_total{reporter=~"destination|waypoint",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo",request_operation!="unknown"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
+	q0 := `round(sum(rate(istio_requests_total{reporter=~"waypoint|destination",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo",request_operation!="unknown"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
 	v0 := model.Vector{}
 
-	q1 := `round(sum(rate(istio_requests_total{reporter=~"destination|waypoint",source_workload_namespace="bookinfo",request_operation!="unknown"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
+	q1 := `round(sum(rate(istio_requests_total{reporter=~"waypoint|destination",source_workload_namespace="bookinfo",request_operation!="unknown"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
 	q1m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "bookinfo",
@@ -454,7 +454,7 @@ func TestNamespacesGraphWithServiceInjectionSkipRates(t *testing.T) {
 func TestNodeGraphNoServiceInjection(t *testing.T) {
 	assert := assert.New(t)
 
-	q0 := `round(sum(rate(istio_requests_total{reporter=~"destination|waypoint",destination_service_namespace="bookinfo",request_operation="Top"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
+	q0 := `round(sum(rate(istio_requests_total{reporter=~"waypoint|destination",destination_service_namespace="bookinfo",request_operation="Top"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
 	q0m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "bookinfo",
@@ -536,7 +536,7 @@ func TestNodeGraphNoServiceInjection(t *testing.T) {
 func TestNodeGraphWithServiceInjectionSkipRates(t *testing.T) {
 	assert := assert.New(t)
 
-	q0 := `round(sum(rate(istio_requests_total{reporter=~"destination|waypoint",destination_service_namespace="bookinfo",request_operation="Top",destination_service_name="reviews"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
+	q0 := `round(sum(rate(istio_requests_total{reporter=~"waypoint|destination",destination_service_namespace="bookinfo",request_operation="Top",destination_service_name="reviews"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol,response_code,grpc_response_status,response_flags,request_operation) > 0,0.001)`
 	q0m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "bookinfo",
diff --git a/graph/telemetry/istio/appender/response_time_test.go b/graph/telemetry/istio/appender/response_time_test.go
index 4c2612669..724ded519 100644
--- a/graph/telemetry/istio/appender/response_time_test.go
+++ b/graph/telemetry/istio/appender/response_time_test.go
@@ -17,7 +17,7 @@ func TestResponseTimeP95(t *testing.T) {
 	q0 := `round(histogram_quantile(0.95, sum(rate(istio_request_duration_milliseconds_bucket{reporter="source",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo"}[60s])) by (le,source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol)) > 0,0.001)`
 	v0 := model.Vector{}
 
-	q1 := `round(histogram_quantile(0.95, sum(rate(istio_request_duration_milliseconds_bucket{reporter=~"destination|waypoint",destination_service_namespace="bookinfo"}[60s])) by (le,source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol)) > 0,0.001)`
+	q1 := `round(histogram_quantile(0.95, sum(rate(istio_request_duration_milliseconds_bucket{reporter=~"waypoint|destination",destination_service_namespace="bookinfo"}[60s])) by (le,source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol)) > 0,0.001)`
 	q1m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "istio-system",
@@ -111,7 +111,7 @@ func TestResponseTimeP95(t *testing.T) {
 			Value:  0.030}, // same edge reported by outgoing (q1), this > value should be preferred
 	}
 
-	q2 := `round(histogram_quantile(0.95, sum(rate(istio_request_duration_milliseconds_bucket{reporter=~"source|waypoint",source_workload_namespace="bookinfo"}[60s])) by (le,source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol)) > 0,0.001)`
+	q2 := `round(histogram_quantile(0.95, sum(rate(istio_request_duration_milliseconds_bucket{reporter=~"waypoint|source",source_workload_namespace="bookinfo"}[60s])) by (le,source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol)) > 0,0.001)`
 	q2m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "bookinfo",
@@ -294,7 +294,7 @@ func TestResponseTimeP95(t *testing.T) {
 func TestResponseTimeAvgSkipRates(t *testing.T) {
 	assert := assert.New(t)
 
-	q0 := `round(sum(rate(istio_request_duration_milliseconds_sum{reporter=~"destination|waypoint",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) / sum(rate(istio_request_duration_milliseconds_count{reporter=~"destination|waypoint",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) > 0,0.001)`
+	q0 := `round(sum(rate(istio_request_duration_milliseconds_sum{reporter=~"waypoint|destination",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) / sum(rate(istio_request_duration_milliseconds_count{reporter=~"waypoint|destination",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) > 0,0.001)`
 	q0m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "istio-system",
@@ -388,7 +388,7 @@ func TestResponseTimeAvgSkipRates(t *testing.T) {
 			Value:  0.030}, // same edge reported by outgoing (q1), this > value should be preferred
 	}
 
-	q1 := `round(sum(rate(istio_request_duration_milliseconds_sum{reporter=~"source|waypoint",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) / sum(rate(istio_request_duration_milliseconds_count{reporter=~"source|waypoint",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) > 0,0.001)`
+	q1 := `round(sum(rate(istio_request_duration_milliseconds_sum{reporter=~"waypoint|source",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) / sum(rate(istio_request_duration_milliseconds_count{reporter=~"waypoint|source",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) > 0,0.001)`
 	q1m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "bookinfo",
@@ -569,7 +569,7 @@ func TestResponseTimeAvgSkipRates(t *testing.T) {
 func TestResponseTimeAvg(t *testing.T) {
 	assert := assert.New(t)
 
-	q0 := `round(sum(rate(istio_request_duration_milliseconds_sum{reporter=~"destination|waypoint",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) / sum(rate(istio_request_duration_milliseconds_count{reporter=~"destination|waypoint",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) > 0,0.001)`
+	q0 := `round(sum(rate(istio_request_duration_milliseconds_sum{reporter=~"waypoint|destination",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) / sum(rate(istio_request_duration_milliseconds_count{reporter=~"waypoint|destination",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) > 0,0.001)`
 	q0m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "istio-system",
@@ -663,7 +663,7 @@ func TestResponseTimeAvg(t *testing.T) {
 			Value:  0.030}, // same edge reported by outgoing (q1), this > value should be preferred
 	}
 
-	q1 := `round(sum(rate(istio_request_duration_milliseconds_sum{reporter=~"source|waypoint",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) / sum(rate(istio_request_duration_milliseconds_count{reporter=~"source|waypoint",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) > 0,0.001)`
+	q1 := `round(sum(rate(istio_request_duration_milliseconds_sum{reporter=~"waypoint|source",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) / sum(rate(istio_request_duration_milliseconds_count{reporter=~"waypoint|source",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,request_protocol) > 0,0.001)`
 	q1m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "bookinfo",
diff --git a/graph/telemetry/istio/appender/security_policy_test.go b/graph/telemetry/istio/appender/security_policy_test.go
index b1e8a4cf5..1177d31d6 100644
--- a/graph/telemetry/istio/appender/security_policy_test.go
+++ b/graph/telemetry/istio/appender/security_policy_test.go
@@ -140,13 +140,13 @@ func TestSecurityPolicyAmbientDefaultRates(t *testing.T) {
 	assert := assert.New(t)
 
 	q0 := fmt.Sprintf("round((%s) OR (%s),0.001)",
-		`sum(rate(istio_requests_total{mesh_id="mesh1",reporter=~"destination|waypoint",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,source_principal,destination_cluster,destination_service_namespace,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,destination_principal,connection_security_policy) > 0`,
-		`sum(rate(istio_tcp_sent_bytes_total{mesh_id="mesh1",reporter=~"destination|waypoint",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,source_principal,destination_cluster,destination_service_namespace,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,destination_principal,connection_security_policy) > 0`)
+		`sum(rate(istio_requests_total{mesh_id="mesh1",reporter=~"waypoint|destination",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,source_principal,destination_cluster,destination_service_namespace,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,destination_principal,connection_security_policy) > 0`,
+		`sum(rate(istio_tcp_sent_bytes_total{mesh_id="mesh1",reporter=~"waypoint|destination",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,source_principal,destination_cluster,destination_service_namespace,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,destination_principal,connection_security_policy) > 0`)
 	v0 := model.Vector{}
 
 	q1 := fmt.Sprintf("round((%s) OR (%s) OR (%s),0.001)",
-		`sum(rate(istio_requests_total{mesh_id="mesh1",reporter=~"destination|waypoint",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,source_principal,destination_cluster,destination_service_namespace,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,destination_principal,connection_security_policy) > 0`,
-		`sum(rate(istio_tcp_sent_bytes_total{mesh_id="mesh1",reporter=~"destination|waypoint",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,source_principal,destination_cluster,destination_service_namespace,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,destination_principal,connection_security_policy) > 0`,
+		`sum(rate(istio_requests_total{mesh_id="mesh1",reporter=~"waypoint|destination",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,source_principal,destination_cluster,destination_service_namespace,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,destination_principal,connection_security_policy) > 0`,
+		`sum(rate(istio_tcp_sent_bytes_total{mesh_id="mesh1",reporter=~"waypoint|destination",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,source_principal,destination_cluster,destination_service_namespace,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,destination_principal,connection_security_policy) > 0`,
 		`sum(rate(istio_tcp_sent_bytes_total{mesh_id="mesh1",app="ztunnel",reporter="source",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,source_principal,destination_cluster,destination_service_namespace,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision,destination_principal,connection_security_policy) > 0`)
 	// edge 1, represents ztunnel dest reported
 	q1m0 := model.Metric{
diff --git a/graph/telemetry/istio/appender/throughput_test.go b/graph/telemetry/istio/appender/throughput_test.go
index c0334d53d..b5367733f 100644
--- a/graph/telemetry/istio/appender/throughput_test.go
+++ b/graph/telemetry/istio/appender/throughput_test.go
@@ -14,7 +14,7 @@ import (
 func TestResponseThroughput(t *testing.T) {
 	assert := assert.New(t)
 
-	q0 := `round(sum(rate(istio_response_bytes_sum{reporter=~"destination|waypoint",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision) > 0,0.001)`
+	q0 := `round(sum(rate(istio_response_bytes_sum{reporter=~"waypoint|destination",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision) > 0,0.001)`
 	q0m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "istio-system",
@@ -35,7 +35,7 @@ func TestResponseThroughput(t *testing.T) {
 			Value:  1000.0},
 	}
 
-	q1 := `round(sum(rate(istio_response_bytes_sum{reporter=~"destination|waypoint",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision) > 0,0.001)`
+	q1 := `round(sum(rate(istio_response_bytes_sum{reporter=~"waypoint|destination",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision) > 0,0.001)`
 	q1m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "bookinfo",
@@ -242,7 +242,7 @@ func responseThroughputTestTraffic() graph.TrafficMap {
 func TestRequestThroughput(t *testing.T) {
 	assert := assert.New(t)
 
-	q0 := `round(sum(rate(istio_request_bytes_sum{reporter=~"source|waypoint",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision) > 0,0.001)`
+	q0 := `round(sum(rate(istio_request_bytes_sum{reporter=~"waypoint|source",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision) > 0,0.001)`
 	q0m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "istio-system",
@@ -263,7 +263,7 @@ func TestRequestThroughput(t *testing.T) {
 			Value:  1000.0},
 	}
 
-	q1 := `round(sum(rate(istio_request_bytes_sum{reporter=~"source|waypoint",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision) > 0,0.001)`
+	q1 := `round(sum(rate(istio_request_bytes_sum{reporter=~"waypoint|source",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision) > 0,0.001)`
 	q1m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "bookinfo",
@@ -353,7 +353,7 @@ func TestRequestThroughput(t *testing.T) {
 func TestRequestThroughputSkipRates(t *testing.T) {
 	assert := assert.New(t)
 
-	q0 := `round(sum(rate(istio_request_bytes_sum{reporter=~"source|waypoint",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision) > 0,0.001)`
+	q0 := `round(sum(rate(istio_request_bytes_sum{reporter=~"waypoint|source",source_workload_namespace!="bookinfo",destination_service_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision) > 0,0.001)`
 	q0m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "istio-system",
@@ -374,7 +374,7 @@ func TestRequestThroughputSkipRates(t *testing.T) {
 			Value:  1000.0},
 	}
 
-	q1 := `round(sum(rate(istio_request_bytes_sum{reporter=~"source|waypoint",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision) > 0,0.001)`
+	q1 := `round(sum(rate(istio_request_bytes_sum{reporter=~"waypoint|source",source_workload_namespace="bookinfo"}[60s])) by (source_cluster,source_workload_namespace,source_workload,source_canonical_service,source_canonical_revision,destination_cluster,destination_service_namespace,destination_service,destination_service_name,destination_workload_namespace,destination_workload,destination_canonical_service,destination_canonical_revision) > 0,0.001)`
 	q1m0 := model.Metric{
 		"source_cluster":                 config.DefaultClusterID,
 		"source_workload_namespace":      "bookinfo",
diff --git a/handlers/metrics_test.go b/handlers/metrics_test.go
index 6fcaee718..5b8e6b0e6 100644
--- a/handlers/metrics_test.go
+++ b/handlers/metrics_test.go
@@ -729,3 +729,158 @@ func setupWorkloadMetricsEndpoint(t *testing.T) (*httptest.Server, *prometheuste
 
 	return ts, xapi
 }
+
+// TestNamespaceMetricsDefault is unit test (testing request handling, not the prometheus client behaviour)
+func TestNamespaceMetricsDefault(t *testing.T) {
+	ts, api := setupNamespaceMetricsEndpoint(t)
+
+	url := ts.URL + "/api/namespaces/ns/metrics"
+	now := time.Now()
+	delta := 15 * time.Second
+	var gaugeSentinel uint32
+
+	api.SpyArgumentsAndReturnEmpty(func(args mock.Arguments) {
+		query := args[1].(string)
+		assert.IsType(t, prom_v1.Range{}, args[2])
+		r := args[2].(prom_v1.Range)
+		assert.Contains(t, query, "_namespace=\"ns\"")
+		assert.Contains(t, query, "[1m]")
+		assert.NotContains(t, query, "histogram_quantile")
+		atomic.AddUint32(&gaugeSentinel, 1)
+		assert.Equal(t, 15*time.Second, r.Step)
+		assert.WithinDuration(t, now, r.End, delta)
+		assert.WithinDuration(t, now.Add(-30*time.Minute), r.Start, delta)
+	})
+
+	resp, err := http.Get(url)
+	if err != nil {
+		t.Fatal(err)
+	}
+	actual, _ := io.ReadAll(resp.Body)
+
+	assert.NotEmpty(t, actual)
+	assert.Equal(t, 200, resp.StatusCode, string(actual))
+	// Assert branch coverage
+	assert.NotZero(t, gaugeSentinel)
+}
+
+func TestNamespaceMetricsWithParams(t *testing.T) {
+	ts, api := setupNamespaceMetricsEndpoint(t)
+
+	req, err := http.NewRequest("GET", ts.URL+"/api/namespaces/ns/metrics", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	q := req.URL.Query()
+	q.Add("rateInterval", "5h")
+	q.Add("rateFunc", "rate")
+	q.Add("step", "2")
+	q.Add("queryTime", "1523364075")
+	q.Add("duration", "1000")
+	q.Add("byLabels[]", "response_code")
+	q.Add("quantiles[]", "0.5")
+	q.Add("quantiles[]", "0.95")
+	q.Add("filters[]", "request_count")
+	q.Add("filters[]", "request_size")
+	req.URL.RawQuery = q.Encode()
+
+	queryTime := time.Unix(1523364075, 0)
+	delta := 2 * time.Second
+	var histogramSentinel, gaugeSentinel uint32
+
+	api.SpyArgumentsAndReturnEmpty(func(args mock.Arguments) {
+		query := args[1].(string)
+		assert.IsType(t, prom_v1.Range{}, args[2])
+		r := args[2].(prom_v1.Range)
+		assert.Contains(t, query, "rate(")
+		assert.Contains(t, query, "[5h]")
+		if strings.Contains(query, "histogram_quantile") {
+			// Histogram specific queries
+			assert.Contains(t, query, " by (le,response_code)")
+			assert.Contains(t, query, "istio_request_bytes")
+			atomic.AddUint32(&histogramSentinel, 1)
+		} else {
+			assert.Contains(t, query, " by (response_code)")
+			atomic.AddUint32(&gaugeSentinel, 1)
+		}
+		assert.Equal(t, 2*time.Second, r.Step)
+		assert.WithinDuration(t, queryTime, r.End, delta)
+		assert.WithinDuration(t, queryTime.Add(-1000*time.Second), r.Start, delta)
+	})
+
+	httpclient := &http.Client{}
+	resp, err := httpclient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+	actual, _ := io.ReadAll(resp.Body)
+
+	assert.NotEmpty(t, actual)
+	assert.Equal(t, 200, resp.StatusCode, string(actual))
+	// Assert branch coverage
+	assert.NotZero(t, histogramSentinel)
+	assert.NotZero(t, gaugeSentinel)
+}
+
+func TestNamespaceMetricsInaccessibleNamespace(t *testing.T) {
+	ts, _ := setupNamespaceMetricsEndpoint(t)
+	k8s := kubetest.NewFakeK8sClient(kubetest.FakeNamespace("my_namespace"))
+	business.SetupBusinessLayer(t, &noPrivClient{k8s}, *config.NewConfig())
+
+	url := ts.URL + "/api/namespaces/my_namespace/metrics"
+
+	resp, err := http.Get(url)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	assert.Equal(t, http.StatusForbidden, resp.StatusCode)
+}
+
+func setupNamespaceMetricsEndpoint(t *testing.T) (*httptest.Server, *prometheustest.PromAPIMock) {
+	client, xapi := setupMocked(t)
+
+	authInfo := map[string]*api.AuthInfo{config.Get().KubernetesConfig.ClusterName: {Token: "test"}}
+	mr := mux.NewRouter()
+	mr.HandleFunc("/api/namespaces/{namespace}/metrics", http.HandlerFunc(
+		WithAuthInfo(
+			authInfo,
+			NamespaceMetrics(func() (*prometheus.Client, error) { return client, nil }),
+		),
+	))
+
+	ts := httptest.NewServer(mr)
+	t.Cleanup(ts.Close)
+	return ts, xapi
+}
+
+// Setup mock
+
+func setupMocked(t *testing.T) (*prometheus.Client, *prometheustest.PromAPIMock) {
+	t.Helper()
+
+	conf := config.NewConfig()
+	config.Set(conf)
+
+	k := kubetest.NewFakeK8sClient(
+		kubetest.FakeNamespace("bookinfo"),
+		kubetest.FakeNamespace("tutorial"),
+		kubetest.FakeNamespace("ns"),
+		&osproject_v1.Project{ObjectMeta: meta_v1.ObjectMeta{Name: "bookinfo"}},
+		&osproject_v1.Project{ObjectMeta: meta_v1.ObjectMeta{Name: "tutorial"}},
+		&osproject_v1.Project{ObjectMeta: meta_v1.ObjectMeta{Name: "ns"}},
+	)
+	k.OpenShift = true
+
+	api := new(prometheustest.PromAPIMock)
+	client, err := prometheus.NewClient()
+	if err != nil {
+		t.Fatal(err)
+		return nil, nil
+	}
+	client.Inject(api)
+
+	business.SetupBusinessLayer(t, k, *conf)
+
+	return client, api
+}
diff --git a/handlers/namespaces_test.go b/handlers/namespaces_test.go
index 77cce3f31..7bbb9f470 100644
--- a/handlers/namespaces_test.go
+++ b/handlers/namespaces_test.go
@@ -1,142 +1,37 @@
-package handlers
+package handlers_test
 
 import (
 	"io"
 	"net/http"
 	"net/http/httptest"
-	"strings"
-	"sync/atomic"
 	"testing"
-	"time"
 
 	"github.com/gorilla/mux"
 	osproject_v1 "github.com/openshift/api/project/v1"
-	prom_v1 "github.com/prometheus/client_golang/api/prometheus/v1"
 	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
 	meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/client-go/tools/clientcmd/api"
 
-	"github.com/kiali/kiali/business"
 	"github.com/kiali/kiali/config"
+	"github.com/kiali/kiali/handlers"
+	"github.com/kiali/kiali/istio"
+	"github.com/kiali/kiali/kubernetes/cache"
 	"github.com/kiali/kiali/kubernetes/kubetest"
 	"github.com/kiali/kiali/prometheus"
 	"github.com/kiali/kiali/prometheus/prometheustest"
 )
 
-// TestNamespaceMetricsDefault is unit test (testing request handling, not the prometheus client behaviour)
-func TestNamespaceMetricsDefault(t *testing.T) {
-	ts, api := setupNamespaceMetricsEndpoint(t)
-
-	url := ts.URL + "/api/namespaces/ns/metrics"
-	now := time.Now()
-	delta := 15 * time.Second
-	var gaugeSentinel uint32
-
-	api.SpyArgumentsAndReturnEmpty(func(args mock.Arguments) {
-		query := args[1].(string)
-		assert.IsType(t, prom_v1.Range{}, args[2])
-		r := args[2].(prom_v1.Range)
-		assert.Contains(t, query, "_namespace=\"ns\"")
-		assert.Contains(t, query, "[1m]")
-		assert.NotContains(t, query, "histogram_quantile")
-		atomic.AddUint32(&gaugeSentinel, 1)
-		assert.Equal(t, 15*time.Second, r.Step)
-		assert.WithinDuration(t, now, r.End, delta)
-		assert.WithinDuration(t, now.Add(-30*time.Minute), r.Start, delta)
-	})
-
-	resp, err := http.Get(url)
-	if err != nil {
-		t.Fatal(err)
-	}
-	actual, _ := io.ReadAll(resp.Body)
-
-	assert.NotEmpty(t, actual)
-	assert.Equal(t, 200, resp.StatusCode, string(actual))
-	// Assert branch coverage
-	assert.NotZero(t, gaugeSentinel)
-}
-
-func TestNamespaceMetricsWithParams(t *testing.T) {
-	ts, api := setupNamespaceMetricsEndpoint(t)
-
-	req, err := http.NewRequest("GET", ts.URL+"/api/namespaces/ns/metrics", nil)
-	if err != nil {
-		t.Fatal(err)
-	}
-	q := req.URL.Query()
-	q.Add("rateInterval", "5h")
-	q.Add("rateFunc", "rate")
-	q.Add("step", "2")
-	q.Add("queryTime", "1523364075")
-	q.Add("duration", "1000")
-	q.Add("byLabels[]", "response_code")
-	q.Add("quantiles[]", "0.5")
-	q.Add("quantiles[]", "0.95")
-	q.Add("filters[]", "request_count")
-	q.Add("filters[]", "request_size")
-	req.URL.RawQuery = q.Encode()
-
-	queryTime := time.Unix(1523364075, 0)
-	delta := 2 * time.Second
-	var histogramSentinel, gaugeSentinel uint32
-
-	api.SpyArgumentsAndReturnEmpty(func(args mock.Arguments) {
-		query := args[1].(string)
-		assert.IsType(t, prom_v1.Range{}, args[2])
-		r := args[2].(prom_v1.Range)
-		assert.Contains(t, query, "rate(")
-		assert.Contains(t, query, "[5h]")
-		if strings.Contains(query, "histogram_quantile") {
-			// Histogram specific queries
-			assert.Contains(t, query, " by (le,response_code)")
-			assert.Contains(t, query, "istio_request_bytes")
-			atomic.AddUint32(&histogramSentinel, 1)
-		} else {
-			assert.Contains(t, query, " by (response_code)")
-			atomic.AddUint32(&gaugeSentinel, 1)
-		}
-		assert.Equal(t, 2*time.Second, r.Step)
-		assert.WithinDuration(t, queryTime, r.End, delta)
-		assert.WithinDuration(t, queryTime.Add(-1000*time.Second), r.Start, delta)
-	})
-
-	httpclient := &http.Client{}
-	resp, err := httpclient.Do(req)
-	if err != nil {
-		t.Fatal(err)
-	}
-	actual, _ := io.ReadAll(resp.Body)
-
-	assert.NotEmpty(t, actual)
-	assert.Equal(t, 200, resp.StatusCode, string(actual))
-	// Assert branch coverage
-	assert.NotZero(t, histogramSentinel)
-	assert.NotZero(t, gaugeSentinel)
-}
-
-func TestNamespaceMetricsInaccessibleNamespace(t *testing.T) {
-	ts, _ := setupNamespaceMetricsEndpoint(t)
-	k8s := kubetest.NewFakeK8sClient(kubetest.FakeNamespace("my_namespace"))
-	business.SetupBusinessLayer(t, &noPrivClient{k8s}, *config.NewConfig())
-
-	url := ts.URL + "/api/namespaces/my_namespace/metrics"
+func TestNamespaceInfo(t *testing.T) {
+	conf := config.NewConfig()
+	_, _, k8s := setupMocked(t)
 
-	resp, err := http.Get(url)
-	if err != nil {
-		t.Fatal(err)
-	}
+	cf := kubetest.NewFakeClientFactoryWithClient(conf, k8s)
+	cache := cache.NewTestingCacheWithFactory(t, cf, *conf)
+	discovery := istio.NewDiscovery(cf.Clients, cache, conf)
 
-	assert.Equal(t, http.StatusForbidden, resp.StatusCode)
-}
+	handler := handlers.WithFakeAuthInfo(conf, handlers.NamespaceInfo(conf, cache, cf, discovery))
 
-func TestNamespaceInfo(t *testing.T) {
-	setupMocked(t)
-
-	authInfo := map[string]*api.AuthInfo{config.Get().KubernetesConfig.ClusterName: {Token: "test"}}
 	mr := mux.NewRouter()
-	mr.HandleFunc("/api/namespaces/{namespace}/info", WithAuthInfo(authInfo, NamespaceInfo))
+	mr.HandleFunc("/api/namespaces/{namespace}/info", handler)
 
 	ts := httptest.NewServer(mr)
 	t.Cleanup(ts.Close)
@@ -157,31 +52,9 @@ func TestNamespaceInfo(t *testing.T) {
 	assert.Equal(t, 200, resp.StatusCode, string(actual))
 }
 
-func setupNamespaceMetricsEndpoint(t *testing.T) (*httptest.Server, *prometheustest.PromAPIMock) {
-	client, xapi := setupMocked(t)
-
-	authInfo := map[string]*api.AuthInfo{config.Get().KubernetesConfig.ClusterName: {Token: "test"}}
-	mr := mux.NewRouter()
-	mr.HandleFunc("/api/namespaces/{namespace}/metrics", http.HandlerFunc(
-		WithAuthInfo(
-			authInfo,
-			NamespaceMetrics(func() (*prometheus.Client, error) { return client, nil }),
-		),
-	))
-
-	ts := httptest.NewServer(mr)
-	t.Cleanup(ts.Close)
-	return ts, xapi
-}
-
-// Setup mock
-
-func setupMocked(t *testing.T) (*prometheus.Client, *prometheustest.PromAPIMock) {
+func setupMocked(t *testing.T) (*prometheus.Client, *prometheustest.PromAPIMock, *kubetest.FakeK8sClient) {
 	t.Helper()
 
-	conf := config.NewConfig()
-	config.Set(conf)
-
 	k := kubetest.NewFakeK8sClient(
 		kubetest.FakeNamespace("bookinfo"),
 		kubetest.FakeNamespace("tutorial"),
@@ -196,11 +69,9 @@ func setupMocked(t *testing.T) (*prometheus.Client, *prometheustest.PromAPIMock)
 	client, err := prometheus.NewClient()
 	if err != nil {
 		t.Fatal(err)
-		return nil, nil
+		return nil, nil, nil
 	}
 	client.Inject(api)
 
-	business.SetupBusinessLayer(t, k, *conf)
-
-	return client, api
+	return client, api, k
 }
diff --git a/handlers/proxy_logging_test.go b/handlers/proxy_logging_test.go
index 90ed333e2..407508b40 100644
--- a/handlers/proxy_logging_test.go
+++ b/handlers/proxy_logging_test.go
@@ -1,43 +1,48 @@
-package handlers
+package handlers_test
 
 import (
 	"fmt"
 	"io"
-	"net/http"
 	"net/http/httptest"
 	"testing"
 
 	"github.com/gorilla/mux"
 	"github.com/stretchr/testify/assert"
 	corev1 "k8s.io/api/core/v1"
-	"k8s.io/client-go/tools/clientcmd/api"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
 	"github.com/kiali/kiali/business"
 	"github.com/kiali/kiali/config"
+	"github.com/kiali/kiali/grafana"
+	"github.com/kiali/kiali/handlers"
+	"github.com/kiali/kiali/istio"
+	"github.com/kiali/kiali/kubernetes/cache"
 	"github.com/kiali/kiali/kubernetes/kubetest"
+	"github.com/kiali/kiali/prometheus/prometheustest"
+	"github.com/kiali/kiali/tracing"
 )
 
 func setupTestLoggingServer(t *testing.T, namespace, pod string) *httptest.Server {
+	conf := config.NewConfig()
+	k8s := kubetest.NewFakeK8sClient(
+		&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}},
+		&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: pod, Namespace: namespace}, Status: corev1.PodStatus{Phase: corev1.PodRunning}},
+	)
+	prom := new(prometheustest.PromClientMock)
+	cf := kubetest.NewFakeClientFactoryWithClient(conf, k8s)
+	cache := cache.NewTestingCacheWithFactory(t, cf, *conf)
+	discovery := istio.NewDiscovery(cf.Clients, cache, conf)
+	cpm := &business.FakeControlPlaneMonitor{}
+	traceLoader := func() tracing.ClientInterface { return nil }
+	grafana := grafana.NewService(conf, cf.GetSAHomeClusterClient())
+
+	handler := handlers.WithFakeAuthInfo(conf, handlers.LoggingUpdate(conf, cache, cf, cpm, prom, traceLoader, grafana, discovery))
 	mr := mux.NewRouter()
-	path := "/api/namespaces/{namespace}/pods/{pod}/logging"
-	authInfo := map[string]*api.AuthInfo{config.Get().KubernetesConfig.ClusterName: {Token: "test"}}
-	mr.HandleFunc(path, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		WithAuthInfo(authInfo, LoggingUpdate)(w, r)
-	}))
+	mr.HandleFunc("/api/namespaces/{namespace}/pods/{pod}/logging", handler)
 
 	ts := httptest.NewServer(mr)
 	t.Cleanup(ts.Close)
 
-	k8s := new(kubetest.K8SClientMock)
-	k8s.On("IsOpenShift").Return(false)
-	k8s.On("IsGatewayAPI").Return(false)
-	k8s.On("SetProxyLogLevel").Return(nil)
-	var fakePod *corev1.Pod
-	k8s.On("GetPod", namespace, pod).Return(fakePod, nil)
-
-	mockClientFactory := kubetest.NewK8SClientFactoryMock(k8s)
-	business.SetWithBackends(mockClientFactory, nil)
-
 	return ts
 }
 
diff --git a/istio/discovery_internal_test.go b/istio/discovery_internal_test.go
index cf5f42316..c2b485688 100644
--- a/istio/discovery_internal_test.go
+++ b/istio/discovery_internal_test.go
@@ -1,14 +1,15 @@
 package istio
 
 import (
+	"context"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
-	core_v1 "k8s.io/api/core/v1"
+	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
 	"github.com/kiali/kiali/config"
 	"github.com/kiali/kiali/kubernetes"
@@ -18,8 +19,8 @@ import (
 )
 
 func FakeCertificateConfigMap(namespace string) *corev1.ConfigMap {
-	return &core_v1.ConfigMap{
-		ObjectMeta: v1.ObjectMeta{
+	return &corev1.ConfigMap{
+		ObjectMeta: metav1.ObjectMeta{
 			Name:      "istio-ca-root-cert",
 			Namespace: namespace,
 		},
@@ -191,3 +192,62 @@ func TestIstioConfigMapName(t *testing.T) {
 		})
 	}
 }
+
+func TestHangingOnGetVersionStillReturnsControlPlane(t *testing.T) {
+	istiodDeployment := &appsv1.Deployment{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "istiod",
+			Namespace: "istio-system",
+			Labels: map[string]string{
+				"app":                     "istiod",
+				config.IstioRevisionLabel: "default",
+			},
+		},
+	}
+	const configMapData = `accessLogFile: /dev/stdout
+enableAutoMtls: true
+rootNamespace: istio-system
+trustDomain: cluster.local
+`
+	istioConfigMap := &corev1.ConfigMap{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "istio",
+			Namespace: "istio-system",
+			Labels:    map[string]string{config.IstioRevisionLabel: "default"},
+		},
+		Data: map[string]string{"mesh": configMapData},
+	}
+	sideCarConfigMap := &corev1.ConfigMap{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "istio-sidecar-injector",
+			Namespace: "istio-system",
+		},
+		Data: map[string]string{
+			"values": "{ \"global\": { \"network\": \"kialiNetwork\" } }",
+		},
+	}
+
+	require := require.New(t)
+	conf := config.NewConfig()
+	k8s := kubetest.NewFakeK8sClient(
+		kubetest.FakeNamespace("istio-system"),
+		istiodDeployment,
+		istioConfigMap,
+		sideCarConfigMap,
+		FakeCertificateConfigMap("istio-system"),
+	)
+	cache := cache.NewTestingCache(t, k8s, *conf)
+
+	old := getVersionTimeout
+	t.Cleanup(func() {
+		getVersionTimeout = old
+	})
+	getVersionTimeout = time.Nanosecond
+
+	clients := map[string]kubernetes.ClientInterface{conf.KubernetesConfig.ClusterName: k8s}
+	discovery := NewDiscovery(clients, cache, conf)
+	mesh, err := discovery.Mesh(context.Background())
+	require.NoError(err)
+	require.Len(mesh.ControlPlanes, 1)
+	require.Empty(mesh.ControlPlanes[0].Version)
+}
diff --git a/kubernetes/cache/kube_cache_test.go b/kubernetes/cache/kube_cache_test.go
index 6f38a8e90..abcb2f68a 100644
--- a/kubernetes/cache/kube_cache_test.go
+++ b/kubernetes/cache/kube_cache_test.go
@@ -25,7 +25,7 @@ const IstioAPIEnabled = true
 func newTestingKubeCache(t *testing.T, cfg *config.Config, objects ...runtime.Object) *kubeCache {
 	t.Helper()
 
-	kubeCache, err := NewKubeCache(kubetest.NewFakeK8sClient(objects...), *cfg, nil)
+	kubeCache, err := NewKubeCache(kubetest.NewFakeK8sClient(objects...), *cfg, nil, ConstantWaitForSync)
 	if err != nil {
 		t.Fatalf("Unable to create kube cache for testing. Err: %s", err)
 	}
@@ -326,7 +326,7 @@ func TestIstioAPIDisabled(t *testing.T) {
 	cfg := config.NewConfig()
 	fakeClient := kubetest.NewFakeK8sClient(ns)
 	fakeClient.IstioAPIEnabled = false
-	kubeCache, err := NewKubeCache(fakeClient, *cfg, nil)
+	kubeCache, err := NewKubeCache(fakeClient, *cfg, nil, ConstantWaitForSync)
 	if err != nil {
 		t.Fatalf("Unable to create kube cache for testing. Err: %s", err)
 	}
diff --git a/kubernetes/cache/testing.go b/kubernetes/cache/testing.go
index 3d0e82705..6a6cfdf45 100644
--- a/kubernetes/cache/testing.go
+++ b/kubernetes/cache/testing.go
@@ -7,6 +7,8 @@ package cache
 import (
 	"testing"
 
+	"k8s.io/client-go/tools/cache"
+
 	"github.com/kiali/kiali/config"
 	"github.com/kiali/kiali/kubernetes"
 	"github.com/kiali/kiali/kubernetes/kubetest"
@@ -18,7 +20,7 @@ func newTestingCache(t testing.TB, cf kubernetes.ClientFactory, conf config.Conf
 	// when the cache is created.
 	conf.ExternalServices.Istio.IstioAPIEnabled = false
 
-	cache, err := NewKialiCache(cf.GetSAClients(), conf)
+	cache, err := newKialiCache(cf.GetSAClients(), conf, ConstantWaitForSync)
 	if err != nil {
 		t.Fatalf("Error creating KialiCache: %v", err)
 	}
@@ -48,3 +50,26 @@ func NewTestingCacheWithClients(t *testing.T, clients map[string]kubernetes.Clie
 	cf.SetClients(clients)
 	return newTestingCache(t, cf, conf)
 }
+
+// ConstantWaitForSync waits continuously with zero interval. Only use this for testing.
+func ConstantWaitForSync(stopCh <-chan struct{}, cacheSyncs ...cache.InformerSynced) bool {
+	for {
+		select {
+		case <-stopCh:
+			return false
+		default:
+			if allCachesSynced(cacheSyncs...) {
+				return true
+			}
+		}
+	}
+}
+
+func allCachesSynced(cacheSyncs ...cache.InformerSynced) bool {
+	for _, syncFunc := range cacheSyncs {
+		if !syncFunc() {
+			return false
+		}
+	}
+	return true
+}
diff --git a/kubernetes/kubetest/fake.go b/kubernetes/kubetest/fake.go
index 725060784..54c437ca6 100644
--- a/kubernetes/kubetest/fake.go
+++ b/kubernetes/kubetest/fake.go
@@ -167,5 +167,8 @@ func (c *FakeK8sClient) IsGatewayAPI() bool                 { return c.GatewayAP
 func (c *FakeK8sClient) IsIstioAPI() bool                   { return c.IstioAPIEnabled }
 func (c *FakeK8sClient) GetToken() string                   { return c.Token }
 func (c *FakeK8sClient) ClusterInfo() kialikube.ClusterInfo { return c.KubeClusterInfo }
+func (c *FakeK8sClient) SetProxyLogLevel(namespace string, podName string, level string) error {
+	return nil
+}
 
 var _ kialikube.ClientInterface = &FakeK8sClient{}