Repository: kiali/kiali

Issue:
Refactor out global variables from business package
### What do you want to improve?

This is a "tech debt" issue. The business package has a number of global variables that must be set in order to use the package: https://github.com/kiali/kiali/blob/9a11e24d993ef34be0de7fbad3d6df946a530bfa/business/layer.go#L38-L44

Most of the api handlers call `business.Get` which uses these globals and then calls `NewWithBackends` that also uses these globals:
https://github.com/kiali/kiali/blob/9a11e24d993ef34be0de7fbad3d6df946a530bfa/business/layer.go#L83-L115

The problems for this are apparent when writing unit tests for and with the business package. When you write a test that doesn't set all the right globals, your test will panic and fail. It's not always obvious what you need to set and how. This makes it much harder to write tests and discourages folks from writing tests. Another issue is that having global variables lends itself to having global locks alongside those variables to prevent race conditions with multiple different actors trying to use those global variables.

### What is the new behavior?

Instead of having global variables in the business package, those variables can be passed into the business package when creating a new layer. Create a new function to create a `business.Layer` and migrate to the new method over time. Something like
```
NewLayer(cf kubernetes.ClientFactory, cache cache.KialiCache, conf config.Config, prom prometheus.ClientInterface) *Layer {
  	temporaryLayer := &Layer{}

        userClients := cf.GetClients()
	temporaryLayer.App = AppService{prom: prom, userClients: userClients, businessLayer: temporaryLayer}
        ...

	return temporaryLayer
```

Notice that almost all the arguments are interfaces so the "fake" implementations can easily be passed in testing.

The http handlers would need to pass these into the `NewLayer` function when they access the business layer. Although the handlers need to conform to `http.HandlerFunc`, it is a common pattern to wrap handler methods in a closure to pass dependencies into them. So this:
```
func AppList(w http.ResponseWriter, r *http.Request) {
  // Uses globals
  business.Get()
}
```
would become:
```
func AppList(cf kubernetes.ClientFactory, cache cache.KialiCache, conf config.Config, prom prometheus.ClientInterface) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    // Does not use globals
    business.NewLayer(cf, cache, conf, prom)
  }
}
```

Adding the `AppList` handler to the route becomes:
```
		{
			"AppList",
			"GET",
			"/api/namespaces/{namespace}/apps",
			handlers.AppList(cf, cache, conf, prom),
			true,
		},
```

Using this approach we can also migrate each handler individually rather than all at once.

Discussion:

Solution patch:
diff --git a/business/tracing.go b/business/tracing.go
index c0e01ad4b..1b42f0793 100644
--- a/business/tracing.go
+++ b/business/tracing.go
@@ -97,6 +97,7 @@ func operationSpanFilter(ns, service string) SpanFilter {
 	// Filter out app spans based on operation name.
 	// For envoy traces, operation name is like "service-name.namespace.svc.cluster.local:8000/*"
 	return func(span *jaegerModels.Span) bool {
+		log.Tracef("[Tracing] operationSpanFilter '%s' has prefix '%s'", span.OperationName, fqService)
 		return strings.HasPrefix(span.OperationName, fqService)
 	}
 }
@@ -176,9 +177,11 @@ func (in *TracingService) GetServiceTraces(ctx context.Context, ns, service stri
 	}
 	if app.Lookup == service {
 		// No post-filtering
+		log.Tracef("[Tracing] GetServiceTraces '%s' for service '%s'", app.Lookup, service)
 		return in.GetAppTraces(ns, service, app.Lookup, query)
 	}
 
+	log.Tracef("[Tracing] GetServiceTraces '%s' for service '%s'", app.Lookup, service)
 	r, err := in.GetAppTraces(ns, app.Lookup, service, query)
 	if r != nil && err == nil {
 		// Filter out app traces based on operation name.
@@ -274,7 +277,7 @@ func spanMatchesWorkload(span *jaegerModels.Span, namespace string, tracingName
 	// When the workload has a waypoint, the operation name is filtered by the service
 	if tracingName.WaypointName != "" {
 		op := fmt.Sprintf("%s.%s", tracingName.App, namespace)
-		log.Tracef("[Tracing] Filtering span trace by service %s", op)
+		log.Tracef("[Tracing] spanMatchesWorkload '%s' has prefix '%s'", span.OperationName, op)
 		return strings.HasPrefix(span.OperationName, op)
 	}
 	// For envoy traces, with a workload named "ai-locals", node_id is like:
@@ -283,6 +286,9 @@ func spanMatchesWorkload(span *jaegerModels.Span, namespace string, tracingName
 		if tag.Key == "node_id" {
 			if v, ok := tag.Value.(string); ok {
 				parts := strings.Split(v, "~")
+				if len(parts) >= 3 {
+					log.Tracef("[Tracing] spanMatchesWorkload '%s' HasPrefix '%s' and suffix %s", parts[2], tracingName.Workload, namespace)
+				}
 				if len(parts) >= 3 && strings.HasPrefix(parts[2], tracingName.Workload) && strings.HasSuffix(parts[2], namespace) {
 					return true
 				}
@@ -291,6 +297,7 @@ func spanMatchesWorkload(span *jaegerModels.Span, namespace string, tracingName
 		// For Tempo Traces
 		if tag.Key == "hostname" {
 			if v, ok := tag.Value.(string); ok {
+				log.Tracef("[Tracing] spanMatchesWorkload '%s' HasPrefix '%s'", v, tracingName.Workload)
 				if strings.HasPrefix(v, tracingName.Workload) {
 					return true
 				}
@@ -302,6 +309,7 @@ func spanMatchesWorkload(span *jaegerModels.Span, namespace string, tracingName
 		for _, tag := range span.Process.Tags {
 			if tag.Key == "hostname" {
 				if v, ok := tag.Value.(string); ok {
+					log.Tracef("[Tracing] spanMatchesWorkload '%s' HasPrefix '%s'", v, tracingName.Workload)
 					if strings.HasPrefix(v, tracingName.Workload) {
 						return true
 					}
@@ -363,6 +371,6 @@ func tracesToSpans(app models.TracingName, r *model.TracingResponse, filter Span
 			}
 		}
 	}
-	log.Tracef("Found %d spans in the %d traces for app %s", len(spans), len(r.Data), app)
+	log.Tracef("[Tracing] Found %d spans in the %d traces for app %s", len(spans), len(r.Data), app)
 	return spans
 }
diff --git a/handlers/istio_status.go b/handlers/istio_status.go
index c0e742eea..c5dd00247 100644
--- a/handlers/istio_status.go
+++ b/handlers/istio_status.go
@@ -2,22 +2,41 @@ package handlers
 
 import (
 	"net/http"
+
+	"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"
 )
 
 // IstioStatus returns a list of istio components and its status
-func IstioStatus(w http.ResponseWriter, r *http.Request) {
-	// Get business layer
-	business, err := getBusiness(r)
-	if err != nil {
-		RespondWithError(w, http.StatusInternalServerError, "Services initialization error: "+err.Error())
-		return
-	}
+func IstioStatus(
+	conf *config.Config,
+	kialiCache cache.KialiCache,
+	clientFactory kubernetes.ClientFactory,
+	prom prometheus.ClientInterface,
+	traceClientLoader func() tracing.ClientInterface,
+	discovery istio.MeshDiscovery,
+	cpm business.ControlPlaneMonitor,
+	grafana *grafana.Service,
+) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		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
+		}
 
-	istioStatus, err := business.IstioStatus.GetStatus(r.Context())
-	if err != nil {
-		handleErrorResponse(w, err)
-		return
-	}
+		istioStatus, err := business.IstioStatus.GetStatus(r.Context())
+		if err != nil {
+			handleErrorResponse(w, err)
+			return
+		}
 
-	RespondWithJSON(w, http.StatusOK, istioStatus)
+		RespondWithJSON(w, http.StatusOK, istioStatus)
+	}
 }
diff --git a/handlers/workloads.go b/handlers/workloads.go
index 69d18ba01..e774f59c7 100644
--- a/handlers/workloads.go
+++ b/handlers/workloads.go
@@ -15,8 +15,14 @@ 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/log"
 	"github.com/kiali/kiali/models"
+	"github.com/kiali/kiali/prometheus"
+	"github.com/kiali/kiali/tracing"
 	"github.com/kiali/kiali/util"
 )
 
@@ -24,6 +30,8 @@ import (
 //
 // swagger:parameters workloadParams
 type workloadParams struct {
+	// TODO: No need to override Namespace and ClusterName here.
+	// Combine with baseHealthParams.
 	baseHealthParams
 	// The target workload
 	//
@@ -63,278 +71,340 @@ func (p *workloadParams) extract(r *http.Request) error {
 	return nil
 }
 
-// ClustersWorkloads is the API handler to fetch all the workloads to be displayed, related to a single namespace
-func ClustersWorkloads(w http.ResponseWriter, r *http.Request) {
-	query := r.URL.Query()
-	namespacesQueryParam := query.Get("namespaces") // csl of namespaces
-	p := workloadParams{}
-	errParse := p.extract(r)
-	if errParse != nil {
-		RespondWithError(w, http.StatusInternalServerError, "Request parsing error: "+errParse.Error())
-		return
-	}
+// ClusterWorkloads is the API handler to fetch all the workloads to be displayed, related to a single namespace
+func ClusterWorkloads(
+	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) {
+		query := r.URL.Query()
+		namespacesQueryParam := query.Get("namespaces") // csl of namespaces
+		p := workloadParams{}
+		errParse := p.extract(r)
+		if errParse != nil {
+			RespondWithError(w, http.StatusInternalServerError, "Request parsing error: "+errParse.Error())
+			return
+		}
 
-	// Get business layer
-	businessLayer, err := getBusiness(r)
-	if err != nil {
-		RespondWithError(w, http.StatusInternalServerError, "Workloads 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
+		}
 
-	nss := []string{}
-	namespacesFromQueryParams := strings.Split(namespacesQueryParam, ",")
-	loadedNamespaces, _ := businessLayer.Namespace.GetClusterNamespaces(r.Context(), p.ClusterName)
-	for _, ns := range loadedNamespaces {
-		// If namespaces have been provided in the query, further filter the results to only include those namespaces.
-		if len(namespacesQueryParam) > 0 {
-			if slices.Contains(namespacesFromQueryParams, ns.Name) {
+		nss := []string{}
+		namespacesFromQueryParams := strings.Split(namespacesQueryParam, ",")
+		loadedNamespaces, _ := businessLayer.Namespace.GetClusterNamespaces(r.Context(), p.ClusterName)
+		for _, ns := range loadedNamespaces {
+			// If namespaces have been provided in the query, further filter the results to only include those namespaces.
+			if len(namespacesQueryParam) > 0 {
+				if slices.Contains(namespacesFromQueryParams, ns.Name) {
+					nss = append(nss, ns.Name)
+				}
+			} else {
+				// Otherwise no namespaces have been provided in the query params, so include all namespaces the user has access to.
 				nss = append(nss, ns.Name)
 			}
-		} else {
-			// Otherwise no namespaces have been provided in the query params, so include all namespaces the user has access to.
-			nss = append(nss, ns.Name)
 		}
-	}
-
-	clusterWorkloadsList := &models.ClusterWorkloads{
-		Cluster:     p.ClusterName,
-		Workloads:   []models.WorkloadListItem{},
-		Validations: models.IstioValidations{},
-	}
 
-	for _, ns := range nss {
-		criteria := business.WorkloadCriteria{
-			Cluster: p.ClusterName, Namespace: ns, IncludeHealth: p.IncludeHealth,
-			IncludeIstioResources: p.IncludeIstioResources, RateInterval: p.RateInterval, QueryTime: p.QueryTime,
+		clusterWorkloadsList := &models.ClusterWorkloads{
+			Cluster:     p.ClusterName,
+			Workloads:   []models.WorkloadListItem{},
+			Validations: models.IstioValidations{},
 		}
 
-		if p.IncludeHealth {
-			rateInterval, err := adjustRateInterval(r.Context(), businessLayer, ns, p.RateInterval, p.QueryTime, p.ClusterName)
+		for _, ns := range nss {
+			criteria := business.WorkloadCriteria{
+				Cluster: p.ClusterName, Namespace: ns, IncludeHealth: p.IncludeHealth,
+				IncludeIstioResources: p.IncludeIstioResources, RateInterval: p.RateInterval, QueryTime: p.QueryTime,
+			}
+
+			if p.IncludeHealth {
+				rateInterval, err := adjustRateInterval(r.Context(), businessLayer, ns, p.RateInterval, p.QueryTime, p.ClusterName)
+				if err != nil {
+					handleErrorResponse(w, err, "Adjust rate interval error: "+err.Error())
+					return
+				}
+				criteria.RateInterval = rateInterval
+			}
+
+			// Fetch and build workloads
+			workloadList, err := businessLayer.Workload.GetWorkloadList(r.Context(), criteria)
 			if err != nil {
-				handleErrorResponse(w, err, "Adjust rate interval error: "+err.Error())
+				handleErrorResponse(w, err)
 				return
 			}
-			criteria.RateInterval = rateInterval
+			clusterWorkloadsList.Workloads = append(clusterWorkloadsList.Workloads, workloadList.Workloads...)
+			clusterWorkloadsList.Validations = clusterWorkloadsList.Validations.MergeValidations(workloadList.Validations)
 		}
 
-		// Fetch and build workloads
-		workloadList, err := businessLayer.Workload.GetWorkloadList(r.Context(), criteria)
-		if err != nil {
-			handleErrorResponse(w, err)
-			return
-		}
-		clusterWorkloadsList.Workloads = append(clusterWorkloadsList.Workloads, workloadList.Workloads...)
-		clusterWorkloadsList.Validations = clusterWorkloadsList.Validations.MergeValidations(workloadList.Validations)
+		RespondWithJSON(w, http.StatusOK, clusterWorkloadsList)
 	}
-
-	RespondWithJSON(w, http.StatusOK, clusterWorkloadsList)
 }
 
 // WorkloadDetails is the API handler to fetch all details to be displayed, related to a single workload
-func WorkloadDetails(w http.ResponseWriter, r *http.Request) {
-	p := workloadParams{}
-	errParse := p.extract(r)
-	if errParse != nil {
-		RespondWithError(w, http.StatusInternalServerError, "Request parsing error: "+errParse.Error())
-		return
-	}
+func WorkloadDetails(
+	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) {
+		p := workloadParams{}
+		errParse := p.extract(r)
+		if errParse != nil {
+			RespondWithError(w, http.StatusInternalServerError, "Request parsing error: "+errParse.Error())
+			return
+		}
 
-	criteria := business.WorkloadCriteria{
-		Namespace: p.Namespace, WorkloadName: p.WorkloadName,
-		WorkloadGVK: p.WorkloadGVK, IncludeIstioResources: true, IncludeServices: true, IncludeHealth: p.IncludeHealth, RateInterval: p.RateInterval,
-		QueryTime: p.QueryTime, Cluster: p.ClusterName,
-	}
+		criteria := business.WorkloadCriteria{
+			Namespace: p.Namespace, WorkloadName: p.WorkloadName,
+			WorkloadGVK: p.WorkloadGVK, IncludeIstioResources: true, IncludeServices: true, IncludeHealth: p.IncludeHealth, RateInterval: p.RateInterval,
+			QueryTime: p.QueryTime, Cluster: p.ClusterName,
+		}
 
-	// Get business layer
-	business, err := getBusiness(r)
-	if err != nil {
-		RespondWithError(w, http.StatusInternalServerError, "Workloads initialization error: "+err.Error())
-		return
-	}
+		business, err := getLayer(r, conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery)
+		if err != nil {
+			RespondWithError(w, http.StatusInternalServerError, "Apps initialization error: "+err.Error())
+			return
+		}
 
-	includeValidations := false
-	if p.IncludeIstioResources {
-		includeValidations = true
-	}
+		includeValidations := false
+		if p.IncludeIstioResources {
+			includeValidations = true
+		}
 
-	istioConfigValidations := models.IstioValidations{}
-	var errValidations error
+		istioConfigValidations := models.IstioValidations{}
+		var errValidations error
 
-	wg := sync.WaitGroup{}
-	if includeValidations {
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			istioConfigValidations, errValidations = business.Validations.GetValidationsForWorkload(r.Context(), criteria.Cluster, criteria.Namespace, criteria.WorkloadName)
-		}()
-	}
+		wg := sync.WaitGroup{}
+		if includeValidations {
+			wg.Add(1)
+			go func() {
+				defer wg.Done()
+				istioConfigValidations, errValidations = business.Validations.GetValidationsForWorkload(r.Context(), criteria.Cluster, criteria.Namespace, criteria.WorkloadName)
+			}()
+		}
 
-	// Fetch and build workload
-	workloadDetails, err := business.Workload.GetWorkload(r.Context(), criteria)
-	if includeValidations && err == nil {
-		wg.Wait()
-		workloadDetails.Validations = istioConfigValidations
-		err = errValidations
-	}
+		// Fetch and build workload
+		workloadDetails, err := business.Workload.GetWorkload(r.Context(), criteria)
+		if includeValidations && err == nil {
+			wg.Wait()
+			workloadDetails.Validations = istioConfigValidations
+			err = errValidations
+		}
+
+		if criteria.IncludeHealth && err == nil {
+			workloadDetails.Health, err = business.Health.GetWorkloadHealth(r.Context(), criteria.Namespace, criteria.Cluster, criteria.WorkloadName, criteria.RateInterval, criteria.QueryTime, workloadDetails)
+			if err != nil {
+				handleErrorResponse(w, err)
+			}
+		}
 
-	if criteria.IncludeHealth && err == nil {
-		workloadDetails.Health, err = business.Health.GetWorkloadHealth(r.Context(), criteria.Namespace, criteria.Cluster, criteria.WorkloadName, criteria.RateInterval, criteria.QueryTime, workloadDetails)
 		if err != nil {
 			handleErrorResponse(w, err)
+			return
 		}
-	}
 
-	if err != nil {
-		handleErrorResponse(w, err)
-		return
+		RespondWithJSON(w, http.StatusOK, workloadDetails)
 	}
-
-	RespondWithJSON(w, http.StatusOK, workloadDetails)
 }
 
 // WorkloadUpdate is the API to perform a patch on a Workload configuration
-func WorkloadUpdate(w http.ResponseWriter, r *http.Request) {
-	params := mux.Vars(r)
-	query := r.URL.Query()
-
-	// Get business layer
-	business, err := getBusiness(r)
-	if err != nil {
-		RespondWithError(w, http.StatusInternalServerError, "Workloads initialization error: "+err.Error())
-		return
-	}
+func WorkloadUpdate(
+	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)
+		query := r.URL.Query()
+
+		business, err := getLayer(r, conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery)
+		if err != nil {
+			RespondWithError(w, http.StatusInternalServerError, "Apps initialization error: "+err.Error())
+			return
+		}
 
-	patchType := query.Get("patchType")
-	if patchType == "" {
-		patchType = defaultPatchType
-	}
+		patchType := query.Get("patchType")
+		if patchType == "" {
+			patchType = defaultPatchType
+		}
 
-	namespace := params["namespace"]
-	workload := params["workload"]
-	workloadGVK, errGVK := util.StringToGVK(query.Get("workloadGVK"))
-	if errGVK != nil {
-		RespondWithError(w, http.StatusBadRequest, "Update request with bad workloadGVK param: "+errGVK.Error())
-	}
+		namespace := params["namespace"]
+		workload := params["workload"]
+		workloadGVK, errGVK := util.StringToGVK(query.Get("workloadGVK"))
+		if errGVK != nil {
+			RespondWithError(w, http.StatusBadRequest, "Update request with bad workloadGVK param: "+errGVK.Error())
+		}
 
-	cluster := clusterNameFromQuery(config.Get(), query)
-	log.Debugf("Cluster: %s", cluster)
+		cluster := clusterNameFromQuery(conf, query)
+		log.Debugf("Cluster: %s", cluster)
 
-	includeValidations := false
-	if _, found := query["validate"]; found {
-		includeValidations = true
-	}
+		includeValidations := false
+		if _, found := query["validate"]; found {
+			includeValidations = true
+		}
 
-	body, err := io.ReadAll(r.Body)
-	if err != nil {
-		RespondWithError(w, http.StatusBadRequest, "Update request with bad update patch: "+err.Error())
-	}
-	jsonPatch := string(body)
-
-	istioConfigValidations := models.IstioValidations{}
-	var errValidations error
-
-	wg := sync.WaitGroup{}
-	if includeValidations {
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			istioConfigValidations, errValidations = business.Validations.GetValidationsForWorkload(r.Context(), cluster, namespace, workload)
-		}()
-	}
+		body, err := io.ReadAll(r.Body)
+		if err != nil {
+			RespondWithError(w, http.StatusBadRequest, "Update request with bad update patch: "+err.Error())
+		}
+		jsonPatch := string(body)
+
+		istioConfigValidations := models.IstioValidations{}
+		var errValidations error
+
+		wg := sync.WaitGroup{}
+		if includeValidations {
+			wg.Add(1)
+			go func() {
+				defer wg.Done()
+				istioConfigValidations, errValidations = business.Validations.GetValidationsForWorkload(r.Context(), cluster, namespace, workload)
+			}()
+		}
 
-	workloadDetails, err := business.Workload.UpdateWorkload(r.Context(), cluster, namespace, workload, workloadGVK, true, jsonPatch, patchType)
-	if includeValidations && err == nil {
-		wg.Wait()
-		workloadDetails.Validations = istioConfigValidations
-		err = errValidations
-	}
-	if err != nil {
-		handleErrorResponse(w, err)
-		return
+		workloadDetails, err := business.Workload.UpdateWorkload(r.Context(), cluster, namespace, workload, workloadGVK, true, jsonPatch, patchType)
+		if includeValidations && err == nil {
+			wg.Wait()
+			workloadDetails.Validations = istioConfigValidations
+			err = errValidations
+		}
+		if err != nil {
+			handleErrorResponse(w, err)
+			return
+		}
+		auditMsg := fmt.Sprintf("UPDATE on Cluster: [%s] Namespace: [%s] Workload name: [%s] Type: [%s] Patch: [%s]", cluster, namespace, workload, workloadGVK, jsonPatch)
+		audit(r, auditMsg)
+		RespondWithJSON(w, http.StatusOK, workloadDetails)
 	}
-	auditMsg := fmt.Sprintf("UPDATE on Cluster: [%s] Namespace: [%s] Workload name: [%s] Type: [%s] Patch: [%s]", cluster, namespace, workload, workloadGVK, jsonPatch)
-	audit(r, auditMsg)
-	RespondWithJSON(w, http.StatusOK, workloadDetails)
 }
 
 // PodDetails is the API handler to fetch all details to be displayed, related to a single pod
-func PodDetails(w http.ResponseWriter, r *http.Request) {
-	vars := mux.Vars(r)
-	query := r.URL.Query()
+func PodDetails(
+	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) {
+		vars := mux.Vars(r)
+		query := r.URL.Query()
+
+		business, err := getLayer(r, conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery)
+		if err != nil {
+			RespondWithError(w, http.StatusInternalServerError, "Apps initialization error: "+err.Error())
+			return
+		}
 
-	// Get business layer
-	business, err := getBusiness(r)
-	if err != nil {
-		RespondWithError(w, http.StatusInternalServerError, "Pods initialization error: "+err.Error())
-		return
-	}
-	cluster := clusterNameFromQuery(config.Get(), query)
-	namespace := vars["namespace"]
-	pod := vars["pod"]
+		cluster := clusterNameFromQuery(conf, query)
+		namespace := vars["namespace"]
+		pod := vars["pod"]
 
-	// Fetch and build pod
-	podDetails, err := business.Workload.GetPod(cluster, namespace, pod)
-	if err != nil {
-		handleErrorResponse(w, err)
-		return
-	}
+		// Fetch and build pod
+		podDetails, err := business.Workload.GetPod(cluster, namespace, pod)
+		if err != nil {
+			handleErrorResponse(w, err)
+			return
+		}
 
-	RespondWithJSON(w, http.StatusOK, podDetails)
+		RespondWithJSON(w, http.StatusOK, podDetails)
+	}
 }
 
 // PodLogs is the API handler to fetch logs for a single pod container
-func PodLogs(w http.ResponseWriter, r *http.Request) {
-	if config.IsFeatureDisabled(config.FeatureLogView) {
-		RespondWithError(w, http.StatusForbidden, "Pod Logs access is disabled")
-		return
-	}
-	vars := mux.Vars(r)
-	queryParams := r.URL.Query()
-
-	// Get business layer
-	business, err := getBusiness(r)
-	if err != nil {
-		RespondWithError(w, http.StatusInternalServerError, "Pod Logs initialization error: "+err.Error())
-		return
-	}
-	cluster := clusterNameFromQuery(config.Get(), queryParams)
-	namespace := vars["namespace"]
-	pod := vars["pod"]
-
-	// Get log options
-	opts, err := business.Workload.BuildLogOptionsCriteria(
-		queryParams.Get("container"),
-		queryParams.Get("duration"),
-		models.LogType(queryParams.Get("logType")),
-		queryParams.Get("sinceTime"),
-		queryParams.Get("maxLines"))
-	if err != nil {
-		handleErrorResponse(w, err)
-		return
-	}
+func PodLogs(
+	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) {
+		if config.IsFeatureDisabled(config.FeatureLogView) {
+			RespondWithError(w, http.StatusForbidden, "Pod Logs access is disabled")
+			return
+		}
+		vars := mux.Vars(r)
+		queryParams := r.URL.Query()
 
-	// Fetch pod logs
-	err = business.Workload.StreamPodLogs(r.Context(), cluster, namespace, queryParams.Get("workload"), queryParams.Get("service"), pod, opts, w)
-	if err != nil {
-		handleErrorResponse(w, err)
-		return
-	}
-}
+		business, err := getLayer(r, conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery)
+		if err != nil {
+			RespondWithError(w, http.StatusInternalServerError, "Apps initialization error: "+err.Error())
+			return
+		}
 
-func ConfigDumpZtunnel(w http.ResponseWriter, r *http.Request) {
-	params := mux.Vars(r)
+		cluster := clusterNameFromQuery(conf, queryParams)
+		namespace := vars["namespace"]
+		pod := vars["pod"]
+
+		// Get log options
+		opts, err := business.Workload.BuildLogOptionsCriteria(
+			queryParams.Get("container"),
+			queryParams.Get("duration"),
+			models.LogType(queryParams.Get("logType")),
+			queryParams.Get("sinceTime"),
+			queryParams.Get("maxLines"))
+		if err != nil {
+			handleErrorResponse(w, err)
+			return
+		}
 
-	business, err := getBusiness(r)
-	if err != nil {
-		RespondWithError(w, http.StatusInternalServerError, "Services initialization error: "+err.Error())
-		return
+		// Fetch pod logs
+		err = business.Workload.StreamPodLogs(r.Context(), cluster, namespace, queryParams.Get("workload"), queryParams.Get("service"), pod, opts, w)
+		if err != nil {
+			handleErrorResponse(w, err)
+			return
+		}
 	}
+}
 
-	cluster := clusterNameFromQuery(config.Get(), r.URL.Query())
-	namespace := params["namespace"]
-	pod := params["pod"]
+func ConfigDumpZtunnel(
+	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)
+
+		business, err := getLayer(r, conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery)
+		if err != nil {
+			RespondWithError(w, http.StatusInternalServerError, "Apps initialization error: "+err.Error())
+			return
+		}
 
-	dump := business.Workload.GetZtunnelConfig(cluster, namespace, pod)
+		cluster := clusterNameFromQuery(conf, r.URL.Query())
+		namespace := params["namespace"]
+		pod := params["pod"]
 
-	RespondWithJSON(w, http.StatusOK, dump)
+		dump := business.Workload.GetZtunnelConfig(cluster, namespace, pod)
+		RespondWithJSON(w, http.StatusOK, dump)
+	}
 }
diff --git a/prometheus/metrics.go b/prometheus/metrics.go
index 70283ac21..1527be54d 100644
--- a/prometheus/metrics.go
+++ b/prometheus/metrics.go
@@ -165,7 +165,9 @@ func getAllRequestRates(ctx context.Context, api prom_v1.API, namespace, cluster
 // should be used mainly for calculating ratios (e.g total rates / error rates)
 func getNamespaceServicesRequestRates(ctx context.Context, api prom_v1.API, namespace, cluster string, queryTime time.Time, ratesInterval string) (model.Vector, error) {
 	// traffic for the namespace services
-	lblNs := fmt.Sprintf(`destination_service_namespace="%s",destination_cluster="%s"`, namespace, cluster)
+	// in certain scenarios, like 503, Istio fails to provide the destination_cluster. To handle this we use destination_cluster=~"%s|unknown".
+	// This gives better results in most cases, although may not always be correct.
+	lblNs := fmt.Sprintf(`destination_service_namespace="%s",destination_cluster=~"%s|unknown"`, namespace, cluster)
 	ns, err := getRequestRatesForLabel(ctx, api, queryTime, lblNs, ratesInterval)
 	if err != nil {
 		return model.Vector{}, err
@@ -177,7 +179,9 @@ func getNamespaceServicesRequestRates(ctx context.Context, api prom_v1.API, name
 // Note that it does not discriminate on "reporter", so rates can be inflated due to duplication, and therefore
 // should be used mainly for calculating ratios (e.g total rates / error rates)
 func getServiceRequestRates(ctx context.Context, api prom_v1.API, namespace, cluster, service string, queryTime time.Time, ratesInterval string) (model.Vector, error) {
-	lbl := fmt.Sprintf(`destination_service_name="%s",destination_service_namespace="%s",destination_cluster="%s"`, service, namespace, cluster)
+	// in certain scenarios, like 503, Istio fails to provide the destination_cluster. To handle this we use destination_cluster=~"%s|unknown".
+	// This gives better results in most cases, although may not always be correct.
+	lbl := fmt.Sprintf(`destination_service_name="%s",destination_service_namespace="%s",destination_cluster=~"%s|unknown"`, service, namespace, cluster)
 	in, err := getRequestRatesForLabel(ctx, api, queryTime, lbl, ratesInterval)
 	if err != nil {
 		return model.Vector{}, err
diff --git a/routing/routes.go b/routing/routes.go
index f86769ed7..d500c1907 100644
--- a/routing/routes.go
+++ b/routing/routes.go
@@ -611,10 +611,10 @@ func NewRoutes(
 		//      200: workloadListResponse
 		//
 		{
-			"ClustersWorkloads",
+			"ClusterWorkloads",
 			"GET",
 			"/api/clusters/workloads",
-			handlers.ClustersWorkloads,
+			handlers.ClusterWorkloads(conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery),
 			true,
 		},
 		// swagger:route GET /namespaces/{namespace}/workloads/{workload} workloads workloadDetails
@@ -635,7 +635,7 @@ func NewRoutes(
 			"WorkloadDetails",
 			"GET",
 			"/api/namespaces/{namespace}/workloads/{workload}",
-			handlers.WorkloadDetails,
+			handlers.WorkloadDetails(conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery),
 			true,
 		},
 		// swagger:route PATCH /namespaces/{namespace}/workloads/{workload} workloads workloadUpdate
@@ -660,7 +660,7 @@ func NewRoutes(
 			"WorkloadUpdate",
 			"PATCH",
 			"/api/namespaces/{namespace}/workloads/{workload}",
-			handlers.WorkloadUpdate,
+			handlers.WorkloadUpdate(conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery),
 			true,
 		},
 		// swagger:route GET /clusters/apps apps appList
@@ -1165,7 +1165,7 @@ func NewRoutes(
 			"IstioStatus",
 			"GET",
 			"/api/istio/status",
-			handlers.IstioStatus,
+			handlers.IstioStatus(conf, kialiCache, clientFactory, prom, traceClientLoader, discovery, cpm, grafana),
 			true,
 		},
 		// swagger:route GET /namespaces/graph graphs graphNamespaces
@@ -1418,7 +1418,7 @@ func NewRoutes(
 			"PodDetails",
 			"GET",
 			"/api/namespaces/{namespace}/pods/{pod}",
-			handlers.PodDetails,
+			handlers.PodDetails(conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery),
 			true,
 		},
 		// swagger:route GET /namespaces/{namespace}/pods/{pod}/logs pods podLogs
@@ -1439,7 +1439,7 @@ func NewRoutes(
 			"PodLogs",
 			"GET",
 			"/api/namespaces/{namespace}/pods/{pod}/logs",
-			handlers.PodLogs,
+			handlers.PodLogs(conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery),
 			true,
 		},
 		// swagger:route GET /namespaces/{namespace}/pods/{pod}/config_dump pods podProxyDump
@@ -1502,7 +1502,7 @@ func NewRoutes(
 			"ZtunnelConfigDump",
 			"GET",
 			"/api/namespaces/{namespace}/pods/{pod}/config_dump_ztunnel",
-			handlers.ConfigDumpZtunnel,
+			handlers.ConfigDumpZtunnel(conf, kialiCache, clientFactory, cpm, prom, traceClientLoader, grafana, discovery),
 			true,
 		},
 		// swagger:route POST /namespaces/{namespace}/pods/{pod}/logging pods podProxyLogging
diff --git a/tracing/jaeger/grpc_client.go b/tracing/jaeger/grpc_client.go
index 30c6b7c1d..3b2232744 100644
--- a/tracing/jaeger/grpc_client.go
+++ b/tracing/jaeger/grpc_client.go
@@ -51,6 +51,7 @@ func (jc JaegerGRPCClient) FindTraces(ctx context.Context, serviceName string, q
 		},
 	}
 
+	log.Debugf("Jaeger gRPC FindTraces request: %v", findTracesRQ)
 	tracesMap, err := jc.queryTraces(ctx, findTracesRQ)
 
 	if err != nil {



Test patch:
diff --git a/handlers/metrics_test.go b/handlers/metrics_test.go
index a9703155f..6fcaee718 100644
--- a/handlers/metrics_test.go
+++ b/handlers/metrics_test.go
@@ -474,3 +474,258 @@ func TestValidateBadRequest(t *testing.T) {
 	assert.Contains(errs.Error(), "bad request")
 	assert.Len(errs.Strings(), 2)
 }
+
+func TestWorkloadMetricsDefault(t *testing.T) {
+	ts, api := setupWorkloadMetricsEndpoint(t)
+
+	url := ts.URL + "/api/namespaces/ns/workloads/my_workload/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, "_workload=\"my_workload\"")
+		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 TestWorkloadMetricsWithParams(t *testing.T) {
+	ts, api := setupWorkloadMetricsEndpoint(t)
+
+	req, err := http.NewRequest("GET", ts.URL+"/api/namespaces/ns/workloads/my-workload/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 TestWorkloadMetricsBadQueryTime(t *testing.T) {
+	ts, api := setupWorkloadMetricsEndpoint(t)
+
+	req, err := http.NewRequest("GET", ts.URL+"/api/namespaces/ns/workloads/my-workload/metrics", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	q := req.URL.Query()
+	q.Add("rateInterval", "5h")
+	q.Add("step", "99")
+	q.Add("queryTime", "abc")
+	q.Add("duration", "1000")
+	req.URL.RawQuery = q.Encode()
+
+	api.SpyArgumentsAndReturnEmpty(func(args mock.Arguments) {
+		// Make sure there's no client call and we fail fast
+		t.Error("Unexpected call to client while having bad request")
+	})
+
+	httpclient := &http.Client{}
+	resp, err := httpclient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+	actual, _ := io.ReadAll(resp.Body)
+
+	assert.Equal(t, 400, resp.StatusCode)
+	assert.Contains(t, string(actual), "cannot parse query parameter 'queryTime'")
+}
+
+func TestWorkloadMetricsBadDuration(t *testing.T) {
+	ts, api := setupWorkloadMetricsEndpoint(t)
+
+	req, err := http.NewRequest("GET", ts.URL+"/api/namespaces/ns/workloads/my-workload/metrics", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	q := req.URL.Query()
+	q.Add("rateInterval", "5h")
+	q.Add("step", "99")
+	q.Add("duration", "abc")
+	req.URL.RawQuery = q.Encode()
+
+	api.SpyArgumentsAndReturnEmpty(func(args mock.Arguments) {
+		// Make sure there's no client call and we fail fast
+		t.Error("Unexpected call to client while having bad request")
+	})
+
+	httpclient := &http.Client{}
+	resp, err := httpclient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+	actual, _ := io.ReadAll(resp.Body)
+
+	assert.Equal(t, 400, resp.StatusCode)
+	assert.Contains(t, string(actual), "cannot parse query parameter 'duration'")
+}
+
+func TestWorkloadMetricsBadStep(t *testing.T) {
+	ts, api := setupWorkloadMetricsEndpoint(t)
+
+	req, err := http.NewRequest("GET", ts.URL+"/api/namespaces/ns/workloads/my-workload/metrics", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	q := req.URL.Query()
+	q.Add("rateInterval", "5h")
+	q.Add("step", "abc")
+	q.Add("duration", "1000")
+	req.URL.RawQuery = q.Encode()
+
+	api.SpyArgumentsAndReturnEmpty(func(args mock.Arguments) {
+		// Make sure there's no client call and we fail fast
+		t.Error("Unexpected call to client while having bad request")
+	})
+
+	httpclient := &http.Client{}
+	resp, err := httpclient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+	actual, _ := io.ReadAll(resp.Body)
+
+	assert.Equal(t, 400, resp.StatusCode)
+	assert.Contains(t, string(actual), "cannot parse query parameter 'step'")
+}
+
+func TestWorkloadMetricsBadRateFunc(t *testing.T) {
+	ts, api := setupWorkloadMetricsEndpoint(t)
+
+	req, err := http.NewRequest("GET", ts.URL+"/api/namespaces/ns/workloads/my-workload/metrics", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	q := req.URL.Query()
+	q.Add("rateInterval", "5h")
+	q.Add("rateFunc", "invalid rate func")
+	req.URL.RawQuery = q.Encode()
+
+	api.SpyArgumentsAndReturnEmpty(func(args mock.Arguments) {
+		// Make sure there's no client call and we fail fast
+		t.Error("Unexpected call to client while having bad request")
+	})
+
+	httpclient := &http.Client{}
+	resp, err := httpclient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+	actual, _ := io.ReadAll(resp.Body)
+
+	assert.Equal(t, 400, resp.StatusCode)
+	assert.Contains(t, string(actual), "query parameter 'rateFunc' must be either 'rate' or 'irate'")
+}
+
+func TestWorkloadMetricsInaccessibleNamespace(t *testing.T) {
+	ts, _ := setupWorkloadMetricsEndpoint(t)
+	k8s := kubetest.NewFakeK8sClient(kubetest.FakeNamespace("ns"),
+		kubetest.FakeNamespace("my_namespace"))
+	business.SetupBusinessLayer(t, &nsForbidden{k8s, "my_namespace"}, *config.Get())
+
+	url := ts.URL + "/api/namespaces/my_namespace/workloads/my_workload/metrics"
+	resp, err := http.Get(url)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	assert.Equal(t, http.StatusForbidden, resp.StatusCode)
+}
+
+func setupWorkloadMetricsEndpoint(t *testing.T) (*httptest.Server, *prometheustest.PromAPIMock) {
+	conf := config.NewConfig()
+	conf.ExternalServices.Istio.IstioAPIEnabled = false
+	config.Set(conf)
+	xapi := new(prometheustest.PromAPIMock)
+	prom, err := prometheus.NewClient()
+	if err != nil {
+		t.Fatal(err)
+	}
+	prom.Inject(xapi)
+
+	mr := mux.NewRouter()
+	authInfo := map[string]*api.AuthInfo{config.Get().KubernetesConfig.ClusterName: {Token: "test"}}
+	mr.HandleFunc("/api/namespaces/{namespace}/workloads/{workload}/metrics", http.HandlerFunc(
+		WithAuthInfo(authInfo, func(w http.ResponseWriter, r *http.Request) {
+			getWorkloadMetrics(w, r, func() (*prometheus.Client, error) {
+				return prom, nil
+			})
+		})),
+	)
+
+	ts := httptest.NewServer(mr)
+	t.Cleanup(ts.Close)
+
+	k8s := kubetest.NewFakeK8sClient(kubetest.FakeNamespace("ns"))
+
+	business.SetupBusinessLayer(t, k8s, *conf)
+
+	return ts, xapi
+}
diff --git a/handlers/workloads_test.go b/handlers/workloads_test.go
index 0156fbf41..53c302f9e 100644
--- a/handlers/workloads_test.go
+++ b/handlers/workloads_test.go
@@ -4,38 +4,34 @@ import (
 	"io"
 	"net/http"
 	"net/http/httptest"
-	"strings"
-	"sync/atomic"
 	"testing"
-	"time"
 
 	"github.com/gorilla/mux"
-	prom_v1 "github.com/prometheus/client_golang/api/prometheus/v1"
 	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
 	"k8s.io/apimachinery/pkg/runtime"
-	"k8s.io/client-go/tools/clientcmd/api"
 
 	"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/cache"
 	"github.com/kiali/kiali/kubernetes/kubetest"
-	"github.com/kiali/kiali/prometheus"
 	"github.com/kiali/kiali/prometheus/prometheustest"
+	"github.com/kiali/kiali/tracing"
 )
 
-func setupWorkloadList(t *testing.T, k8s *kubetest.FakeK8sClient) (*httptest.Server, *prometheustest.PromClientMock) {
+func setupWorkloadList(t *testing.T, k8s *kubetest.FakeK8sClient, conf *config.Config) (*httptest.Server, *prometheustest.PromClientMock) {
 	prom := new(prometheustest.PromClientMock)
-
-	business.SetupBusinessLayer(t, k8s, *config.Get())
-
+	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 := WithFakeAuthInfo(conf, ClusterWorkloads(conf, cache, cf, cpm, prom, traceLoader, grafana, discovery))
 	mr := mux.NewRouter()
-
-	authInfo := map[string]*api.AuthInfo{config.Get().KubernetesConfig.ClusterName: {Token: "test"}}
-	mr.HandleFunc("/api/clusters/workloads", http.HandlerFunc(
-		WithAuthInfo(authInfo, func(w http.ResponseWriter, r *http.Request) {
-			ClustersWorkloads(w, r)
-		})),
-	)
+	mr.HandleFunc("/api/clusters/workloads", handler)
 
 	ts := httptest.NewServer(mr)
 	t.Cleanup(ts.Close)
@@ -43,27 +39,25 @@ func setupWorkloadList(t *testing.T, k8s *kubetest.FakeK8sClient) (*httptest.Ser
 }
 
 func TestWorkloadsEndpoint(t *testing.T) {
-	cfg := config.NewConfig()
-	cfg.ExternalServices.Istio.IstioAPIEnabled = false
-	config.Set(cfg)
+	conf := config.NewConfig()
 
 	mockClock()
 
 	kubeObjects := []runtime.Object{kubetest.FakeNamespace("ns")}
-	for _, obj := range business.FakeDepSyncedWithRS(cfg) {
+	for _, obj := range business.FakeDepSyncedWithRS(conf) {
 		o := obj
 		kubeObjects = append(kubeObjects, &o)
 	}
-	for _, obj := range business.FakeRSSyncedWithPods(cfg) {
+	for _, obj := range business.FakeRSSyncedWithPods(conf) {
 		o := obj
 		kubeObjects = append(kubeObjects, &o)
 	}
-	for _, obj := range business.FakePodsSyncedWithDeployments(cfg) {
+	for _, obj := range business.FakePodsSyncedWithDeployments(conf) {
 		o := obj
 		kubeObjects = append(kubeObjects, &o)
 	}
 	k8s := kubetest.NewFakeK8sClient(kubeObjects...)
-	ts, _ := setupWorkloadList(t, k8s)
+	ts, _ := setupWorkloadList(t, k8s, conf)
 
 	url := ts.URL + "/api/clusters/workloads?namespaces=ns"
 
@@ -76,258 +70,3 @@ func TestWorkloadsEndpoint(t *testing.T) {
 	assert.NotEmpty(t, actual)
 	assert.Equal(t, 200, resp.StatusCode, string(actual))
 }
-
-func TestWorkloadMetricsDefault(t *testing.T) {
-	ts, api := setupWorkloadMetricsEndpoint(t)
-
-	url := ts.URL + "/api/namespaces/ns/workloads/my_workload/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, "_workload=\"my_workload\"")
-		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 TestWorkloadMetricsWithParams(t *testing.T) {
-	ts, api := setupWorkloadMetricsEndpoint(t)
-
-	req, err := http.NewRequest("GET", ts.URL+"/api/namespaces/ns/workloads/my-workload/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 TestWorkloadMetricsBadQueryTime(t *testing.T) {
-	ts, api := setupWorkloadMetricsEndpoint(t)
-
-	req, err := http.NewRequest("GET", ts.URL+"/api/namespaces/ns/workloads/my-workload/metrics", nil)
-	if err != nil {
-		t.Fatal(err)
-	}
-	q := req.URL.Query()
-	q.Add("rateInterval", "5h")
-	q.Add("step", "99")
-	q.Add("queryTime", "abc")
-	q.Add("duration", "1000")
-	req.URL.RawQuery = q.Encode()
-
-	api.SpyArgumentsAndReturnEmpty(func(args mock.Arguments) {
-		// Make sure there's no client call and we fail fast
-		t.Error("Unexpected call to client while having bad request")
-	})
-
-	httpclient := &http.Client{}
-	resp, err := httpclient.Do(req)
-	if err != nil {
-		t.Fatal(err)
-	}
-	actual, _ := io.ReadAll(resp.Body)
-
-	assert.Equal(t, 400, resp.StatusCode)
-	assert.Contains(t, string(actual), "cannot parse query parameter 'queryTime'")
-}
-
-func TestWorkloadMetricsBadDuration(t *testing.T) {
-	ts, api := setupWorkloadMetricsEndpoint(t)
-
-	req, err := http.NewRequest("GET", ts.URL+"/api/namespaces/ns/workloads/my-workload/metrics", nil)
-	if err != nil {
-		t.Fatal(err)
-	}
-	q := req.URL.Query()
-	q.Add("rateInterval", "5h")
-	q.Add("step", "99")
-	q.Add("duration", "abc")
-	req.URL.RawQuery = q.Encode()
-
-	api.SpyArgumentsAndReturnEmpty(func(args mock.Arguments) {
-		// Make sure there's no client call and we fail fast
-		t.Error("Unexpected call to client while having bad request")
-	})
-
-	httpclient := &http.Client{}
-	resp, err := httpclient.Do(req)
-	if err != nil {
-		t.Fatal(err)
-	}
-	actual, _ := io.ReadAll(resp.Body)
-
-	assert.Equal(t, 400, resp.StatusCode)
-	assert.Contains(t, string(actual), "cannot parse query parameter 'duration'")
-}
-
-func TestWorkloadMetricsBadStep(t *testing.T) {
-	ts, api := setupWorkloadMetricsEndpoint(t)
-
-	req, err := http.NewRequest("GET", ts.URL+"/api/namespaces/ns/workloads/my-workload/metrics", nil)
-	if err != nil {
-		t.Fatal(err)
-	}
-	q := req.URL.Query()
-	q.Add("rateInterval", "5h")
-	q.Add("step", "abc")
-	q.Add("duration", "1000")
-	req.URL.RawQuery = q.Encode()
-
-	api.SpyArgumentsAndReturnEmpty(func(args mock.Arguments) {
-		// Make sure there's no client call and we fail fast
-		t.Error("Unexpected call to client while having bad request")
-	})
-
-	httpclient := &http.Client{}
-	resp, err := httpclient.Do(req)
-	if err != nil {
-		t.Fatal(err)
-	}
-	actual, _ := io.ReadAll(resp.Body)
-
-	assert.Equal(t, 400, resp.StatusCode)
-	assert.Contains(t, string(actual), "cannot parse query parameter 'step'")
-}
-
-func TestWorkloadMetricsBadRateFunc(t *testing.T) {
-	ts, api := setupWorkloadMetricsEndpoint(t)
-
-	req, err := http.NewRequest("GET", ts.URL+"/api/namespaces/ns/workloads/my-workload/metrics", nil)
-	if err != nil {
-		t.Fatal(err)
-	}
-	q := req.URL.Query()
-	q.Add("rateInterval", "5h")
-	q.Add("rateFunc", "invalid rate func")
-	req.URL.RawQuery = q.Encode()
-
-	api.SpyArgumentsAndReturnEmpty(func(args mock.Arguments) {
-		// Make sure there's no client call and we fail fast
-		t.Error("Unexpected call to client while having bad request")
-	})
-
-	httpclient := &http.Client{}
-	resp, err := httpclient.Do(req)
-	if err != nil {
-		t.Fatal(err)
-	}
-	actual, _ := io.ReadAll(resp.Body)
-
-	assert.Equal(t, 400, resp.StatusCode)
-	assert.Contains(t, string(actual), "query parameter 'rateFunc' must be either 'rate' or 'irate'")
-}
-
-func TestWorkloadMetricsInaccessibleNamespace(t *testing.T) {
-	ts, _ := setupWorkloadMetricsEndpoint(t)
-	k8s := kubetest.NewFakeK8sClient(kubetest.FakeNamespace("ns"),
-		kubetest.FakeNamespace("my_namespace"))
-	business.SetupBusinessLayer(t, &nsForbidden{k8s, "my_namespace"}, *config.Get())
-
-	url := ts.URL + "/api/namespaces/my_namespace/workloads/my_workload/metrics"
-	resp, err := http.Get(url)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	assert.Equal(t, http.StatusForbidden, resp.StatusCode)
-}
-
-func setupWorkloadMetricsEndpoint(t *testing.T) (*httptest.Server, *prometheustest.PromAPIMock) {
-	conf := config.NewConfig()
-	conf.ExternalServices.Istio.IstioAPIEnabled = false
-	config.Set(conf)
-	xapi := new(prometheustest.PromAPIMock)
-	prom, err := prometheus.NewClient()
-	if err != nil {
-		t.Fatal(err)
-	}
-	prom.Inject(xapi)
-
-	mr := mux.NewRouter()
-	authInfo := map[string]*api.AuthInfo{config.Get().KubernetesConfig.ClusterName: {Token: "test"}}
-	mr.HandleFunc("/api/namespaces/{namespace}/workloads/{workload}/metrics", http.HandlerFunc(
-		WithAuthInfo(authInfo, func(w http.ResponseWriter, r *http.Request) {
-			getWorkloadMetrics(w, r, func() (*prometheus.Client, error) {
-				return prom, nil
-			})
-		})),
-	)
-
-	ts := httptest.NewServer(mr)
-	t.Cleanup(ts.Close)
-
-	k8s := kubetest.NewFakeK8sClient(kubetest.FakeNamespace("ns"))
-
-	business.SetupBusinessLayer(t, k8s, *conf)
-
-	return ts, xapi
-}
diff --git a/prometheus/prometheustest/client_test.go b/prometheus/prometheustest/client_test.go
index 463fee5bb..af03adf27 100644
--- a/prometheus/prometheustest/client_test.go
+++ b/prometheus/prometheustest/client_test.go
@@ -104,7 +104,7 @@ func TestGetNamespaceServicesRequestRates(t *testing.T) {
 			Metric:    model.Metric{"foo": "bar"},
 		},
 	}
-	api.OnQueryTime(`rate(istio_requests_total{destination_service_namespace="ns",destination_cluster="east"}[5m]) > 0`, &queryTime, vectorQ1)
+	api.OnQueryTime(`rate(istio_requests_total{destination_service_namespace="ns",destination_cluster=~"east|unknown"}[5m]) > 0`, &queryTime, vectorQ1)
 
 	rates, _ := client.GetNamespaceServicesRequestRates("ns", "east", "5m", queryTime)
 	assert.Equal(t, 1, rates.Len())